Software Engineering · Dependencies

Advisory Ranges, Reachability, and Deciding a Vulnerability Does Not Matter

How advisory databases express which versions are affected, why a version range in an advisory is not the same claim as an exploitable dependency, and what evidence supports suppressing a finding.

A scanner reports that a project depends on a package version listed as affected by a published advisory. The report is correct and it is not yet a statement about risk. Between “this version is in the affected range” and “this project can be attacked through this dependency” there are several independent questions, and a triage process is a procedure for answering them in an order that discards findings cheaply.

The order matters because the cheap questions eliminate most findings. A dependency that is not loaded at run time cannot be exploited. A vulnerable function that is never called cannot be reached. A code path that is reachable but only with input the attacker cannot control is not exploitable. Each step is more expensive than the last, so a triage process should be arranged as a filter cascade, not as a uniform review.

What an advisory actually asserts

Advisory formats differ in structure but converge on the same content: an identifier, the affected package, a set of version ranges or explicit version lists, and a description. The Open Source Vulnerability format uses an explicit affected array with range events — an introduced version and a fixed version — rather than a single range string, which matters because a single range cannot express a backport. A fix applied to an older maintenance line produces a set of affected intervals that is not contiguous, and an advisory that expresses it as one range either over-reports the newer lines or under-reports the backport.

The same shape appears in ecosystem-specific advisory formats and in vendor advisories, and the practical consequence for a consumer is that the affected set must be computed by testing the installed version against several intervals rather than by comparing a single version number against a threshold.

Three properties of advisory ranges are routinely misread.

The range describes the package as published, not as used. An advisory against a library states that a version of the library contains a defect; whether the defect is reachable depends on how the library is called.

The fixed version is a version where the maintainers consider the issue addressed, and the fix may be in a major that the project cannot adopt without breaking changes. A project pinned to an older major may have no fixed version available at all, which is a different problem from being exposed.

The severity score is a property of the vulnerability in the abstract, computed with assumptions about deployment that may not hold. A score that assumes network reachability overstates a defect in a library used only for offline parsing, and understates one in a library that parses untrusted input at a trust boundary.

The distance between a version and a code path

The first filter is whether the dependency is present in the deployed artifact at all. Development-only dependencies, test fixtures, and build tools are in the graph and not in the running program. A finding against them is real at the level of the graph and irrelevant at the level of the artifact, provided the separation between the two is genuine — which it is in a build that produces a self-contained artifact and is not in one that ships the dependency tree.

The second filter is whether the package is loaded. A dependency that is installed but never imported by any code path in the artifact contributes bytes and no behaviour. In a language with static imports, this is answerable from the module graph. In one with dynamic loading, it is answerable only by observing the running program, and a static answer is a guess.

The third filter is reachability of the vulnerable function. The package is loaded and the specific function containing the defect is never called. This is the filter that requires the most machinery, and the one whose result is least certain: a call graph from the program’s entry points through the dependency’s exports, computed over the actual source of the installed version.

# A filter cascade over plain data: each stage discards findings cheaply
# before the expensive call-graph query runs.
def triage(finding, loaded, reachable_symbols, tainted_inputs):
    if finding["package"] not in loaded:
        return "not loaded"
    if finding["symbol"] not in reachable_symbols:
        return "unreachable symbol"
    if not tainted_inputs.get(finding["symbol"]):
        return "no untrusted input"
    return "reachable"

The sets in that sketch come from a module graph and a call graph; the point is the ordering, not the representation. A finding that fails the first test costs a set lookup.

Building that call graph is the hard part. Dynamic dispatch, reflection, callbacks registered by name, and dependency injection all defeat a purely static analysis, and any tool that reports “unreachable” is reporting the absence of a path it could find rather than the absence of a path. The distinction is the same one that separates a sound analysis from a useful one, and the honest framing of a reachability result is “no path found in the analysed call graph”.

The fourth filter is input control. A reachable vulnerable function that is called only with constants, or only with data from a trusted source, is not exploitable through that path. This is where the analysis stops being mechanical: deciding whether an attacker controls an input requires understanding the application’s trust boundaries, which no scanner knows.

Suppression is an assertion with an expiry

A triage decision produces a suppression: a record that a specific finding against a specific package version is accepted, with a reason. The engineering question is what makes a suppression defensible and how it stops being one.

A defensible suppression names the evidence. “Not loaded in the production artifact” is checkable and stable while the artifact’s dependency set is stable. “Function unreachable from any entry point” is checkable and invalidated by any change to the call graph, which is to say by almost any code change. “Input not attacker-controlled” is a claim about the application’s threat model, and it is invalidated by a change in deployment rather than by a change in code.

That asymmetry means suppressions should be scoped to the evidence they rest on. A suppression keyed by package version alone silently outlives the reasoning: the version stays, the code around it changes, and the reason no longer holds. Keying by version plus the specific call path, or requiring re-review on a schedule, converts the suppression into something that expires when its premise does.

The failure mode of a suppression-heavy process is that the suppressions accumulate and the next reviewer inherits a list of decisions with reasons that were true at the time. The mitigation is not to suppress less but to make each suppression carry a machine-checkable condition where possible: a build assertion that the package is absent from the production artifact can be verified automatically on every build, which is stronger than a note.

Why the fixed version may be worse than the finding

An advisory creates pressure to upgrade, and the upgrade has costs that the advisory does not model. A patch upgrade within the same minor is usually safe by the ecosystem’s compatibility convention, but the convention is a promise and not a guarantee; the range that admitted the old version admits the new one for the same reason, which is that nothing checks the promise.

A fix that lands only in a newer major forces a choice between adopting breaking changes and staying exposed. For a defect that is unreachable in the project’s usage, staying is the correct decision, and it is only defensible if the unreachability has been established. For a defect that is reachable and has a public exploit, the major upgrade is usually correct even at high cost.

There is a third case that is easy to miss: the fix exists in a maintenance branch that the project’s range excludes. A range written as ^1.4.0 does not admit 1.3.9, so a backported fix to the 1.3 line is invisible to the resolver even though it is exactly the version the project should adopt. This is a limitation of range syntax rather than of the advisory, and it requires editing the constraint rather than waiting for the resolver to find the fix.

The graph is the unit of exposure

Findings are reported per package and exposure is a property of the graph. A vulnerable package present at two versions, one reachable and one not, is a single advisory and two different risks. A package that is only reachable through a dependency the project is about to remove is a finding that resolves itself.

This is why the useful inventory is the resolved graph from the lockfile, not the manifest. A manifest lists direct requirements; the graph lists what is actually present, at which versions, and through which paths. Two projects with the same manifest can have different exposure because their locks resolved differently, and a scanner that reads only the manifest misses both the transitive findings and the duplicate instances.

The graph also supplies the answer to “who introduced this”. A vulnerable transitive package is present because some direct dependency requires it, and the practical remedy is often to change that direct dependency rather than to add an override. Overrides exist and they work, and they carry a cost: an override that forces a version outside the requiring package’s declared range produces a graph the publisher never tested, which is the same duplication hazard in a different form.

What the process should produce

A triage process that works produces three artifacts, none of which is a severity-ranked list.

An inventory of the graph with versions and paths, refreshed whenever the lock changes, so that the question “what does the project depend on” has a current answer.

A set of decisions with evidence and expiry, where each accepted finding names the condition under which it stops being acceptable, and as many of those conditions as possible are checked automatically.

A short list of actual work: reachable findings that need a version change, and packages whose fix requires a major migration, with the migration planned rather than deferred indefinitely.

The severity score belongs in the ordering of that last list, and it belongs nowhere else. Ordering by score alone produces a queue that spends the most effort on findings that are easy to score and hard to fix, while reachable findings in low-scoring packages wait. The score is a property of the defect; the queue is a property of the project.

None of this reduces the exposure created by the dependencies themselves. A project that installs code from many publishers has accepted that those publishers’ defects become its own, and the mechanisms that bound that exposure — narrow constraints, reviewed pins, verified metadata, and a default-deny posture toward install-time execution — operate before a finding exists. Triage handles what got through.

References

  1. Common Security Advisory Framework (CSAF) 2.0 — OASIS
  2. Common Vulnerability Scoring System v4.0 Specification — FIRST
  3. OSV Schema — Open Source Security Foundation