Safeguard
Architecture•

Inside Safeguard's Reachability Engine

How Safeguard's static reachability builds a call graph from your code to the vulnerable function and labels each finding by whether it can be reached.

Shadab Khan
Security Engineer
8 min read

Reachability is the difference between a vulnerability report that a team will action and one they will quietly ignore. If a finding says "your dependency has a known CVE," a senior engineer looks at it, asks whether any code path in their application actually calls the vulnerable function, and, without a good answer, bins the alert. Safeguard's reachability analysis exists to answer that question from your code, for every finding it surfaces. This post walks through how the static analysis is put together, what the labels on a finding mean, and where the approach has honest limits.

What does reachability actually mean in our model?

Safeguard's documentation defines it plainly: a CVE is "reachable" when your code actually calls the vulnerable function or triggers the vulnerable code path. The analysis that decides this is static. Safeguard builds a call graph from your source, lockfile and bytecode, then checks whether any path in that graph leads from your code to the vulnerable sink.

The result is not a simple yes or no. Each finding carries one of four labels:

  • Reachable: a call path exists from your code to the vulnerable function.
  • Conditionally reachable: a path exists, but it depends on a feature flag, route or configuration that is not currently enabled.
  • Unreachable: no call path exists in your compiled code.
  • Unknown: the analyzer could not decide, for example because of reflection, virtual dispatch it cannot resolve, or native code. Unknown findings are treated conservatively as potentially reachable and are not suppressed.

We keep four labels because triage policy varies. A regulated team may want to patch everything that is reachable or conditionally reachable, while another team may only work the reachable set this sprint and schedule the rest. Collapsing this to a boolean hides the nuance, and it hides the most important case of all: the one where the analyzer is not sure. Every Safeguard policy rule can condition on the label, and the vulnerabilities table shows a Reachability column next to Priority so the label is never buried.

What is the architecture of the analysis?

Conceptually the pipeline has four stages: collect the inputs, build the call graph, locate the vulnerable functions, and search for a path between them.

 Source, lockfile, bytecode
            |
            v
   Call graph construction
   (functions, call sites, entry points)
            |
            v
   Vulnerable function lookup
   (advisory mapped to the affected symbols)
            |
            v
   Path search: entry point to vulnerable sink
            |
            v
   Label written on the finding
   (reachable | conditional | unreachable | unknown)

The inputs matter. Source code tells the analyzer what your application calls. The lockfile pins which version of each dependency is actually resolved, which decides whether the vulnerable code is present at all. Bytecode and compiled artifacts fill in what the source alone does not show, such as code generated at build time. Reading all three is what lets the call graph describe the application you ship rather than the one you think you ship.

How is the call graph actually constructed?

A call graph is a directed graph where each node is a function and each edge says "this function can call that one." Building it is straightforward for direct calls and hard for everything else.

Direct calls are easy: parse(input) resolves to one function. Interface and virtual calls are harder: handler.process(request) can land in any class that implements process. A static analyzer has to decide which implementations are plausible targets. Resolve too narrowly and you miss real paths, which produces false "unreachable" labels. Resolve too broadly and every method looks reachable from everywhere, which brings back the noise reachability was meant to remove. Good analyzers narrow the candidate set using the types your code actually instantiates, rather than every type that could in theory satisfy the interface.

Entry points are the other half of the problem. A web application rarely has a main function that calls everything in sequence. Frameworks wire request handlers through annotations, decorators, route tables and dependency injection. A handler that only ever runs because a framework registered it would look like dead code to a naive analyzer. Treating those framework conventions as entry points is what keeps the graph honest for real services.

Here is a minimal example of why this matters:

@RestController
public class ReportController {
    @GetMapping("/report")
    public String render(@RequestParam String template) {
        return TemplateEngine.render(template); // calls into the vulnerable library
    }
}

Nothing in the codebase calls render directly. The framework does. If the analyzer does not recognize @GetMapping as an entry point, the vulnerable call inside TemplateEngine.render looks unreachable when it is in fact one HTTP request away.

How do you go from a CVE to a path?

A CVE identifier names a package and a version range. Reachability needs more: it needs the specific functions inside that package that carry the flaw. Once the affected symbols are known, the question becomes a graph search. Starting from the vulnerable function and walking backwards toward your entry points is the natural direction, because there are far fewer vulnerable sinks than there are functions in your application.

When a path exists, the finding is labelled reachable, and the path itself is the evidence. When a path exists only through code that is switched off by a flag, route or configuration, the label is conditionally reachable. When the search exhausts the graph without reaching your code, the label is unreachable. When the search hits a node it cannot resolve, the label is unknown.

How do the labels feed prioritization?

Reachability is one of the signals Safeguard combines into a single ranked backlog. The others are exploitability, from the daily EPSS feed, the CISA Known Exploited Vulnerabilities list and public proof-of-concept availability, and business context that you declare in policy, such as which assets are regulated or hold customer data.

In the vulnerabilities table, filters let a team cut straight to the work that matters:

  • reachable:true shows only reachable findings.
  • kev:true shows only findings on the KEV list.
  • reachable:true kev:true combines them into the short list most teams should start with.

Labels also flow into guardrails. A policy can block a release when a reachable or conditionally reachable finding is also on KEV:

- id: no-reachable-kev-in-prod
  condition: |
    any(vulnerabilities,
      reachability_class in ["reachable", "conditional"]
      AND kev == true
    )
  effect: BLOCK

Notice that the rule includes the conditional label. A path that is only switched off today can be switched on by a configuration change tomorrow, so a strict policy treats it as live.

You can also ask in plain language. Griffin, Safeguard's security model, understands the prioritization surface, so a question like "which reachable findings in the payments service are on KEV?" returns the same filtered set. Griffin can also explain why a finding was de-prioritized, which is what an exception review needs.

How do you prevent the analysis from lying at the edges?

Static reachability is a strong signal, not a proof, and the honest thing is to say where it is weakest. The documented weak spots are:

  • Reflection, eval and unresolved dispatch. If the analyzer cannot see which function will be called, it cannot rule a path in or out. These findings are marked unknown and are not suppressed.
  • Data-driven flaws. Some vulnerabilities are triggered by what the input contains, not by which function is called. A parser can be exploitable even when static analysis says the specific vulnerable helper is unreachable.
  • Configurations the analyzer cannot enumerate. If a path only exists under a combination of settings that are not visible in the code and its configuration files, the analyzer may not see it.

For these cases the right move is to lean on the other signals, KEV and EPSS in particular, and keep conservative defaults. The label "unknown" exists precisely so that uncertainty is surfaced rather than quietly counted as safe.

Fix it once you know it matters

A reachable label tells you where to spend effort; it does not fix anything on its own. Once a finding is prioritized, Griffin can propose a minimum-risk upgrade that resolves the vulnerability, suggest a safe downgrade when a patched version does not exist yet, and open pull requests for the highest priorities on a schedule. The reachability path stays attached to the finding, so the reviewer of that pull request can see exactly which call made the upgrade urgent.

How Safeguard Helps

Safeguard's reachability analysis turns a noisy vulnerability feed into a ranked list that an engineering team can actually work through. It builds a call graph from your source, lockfile and bytecode, checks whether any path reaches the vulnerable function, and labels each finding reachable, conditionally reachable, unreachable or unknown, with unknown treated as potentially reachable rather than hidden. Those labels drive the Reachability column, the filters, the policy guardrails and the questions you can ask Griffin, Safeguard's security model. Language support is listed on the reachability analysis documentation. If you want to see it against your own codebase, reachability is available to every Safeguard tenant.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.