The SBOM lists a vulnerable component. Are we actually affected? What an SBOM records, what VEX adds, and what a not_affected statement has to prove

DedSec Application Security ·

Picture a customer's security team running the SBOM shipped with release 3.2 of an on-premises product through their scanner. They send back a spreadsheet: a critical deserialisation CVE in a JSON library the SBOM lists. The engineers who own the product say it never deserialises polymorphic types, so it cannot be affected. Someone proposes answering with a VEX document that says not_affected, and the customer's scanner will then stop reporting it.

That proposal may be right. It is also a suppression instruction that the customer's tooling will apply automatically, so it deserves the same scrutiny as any other decision to stop looking at a finding. This post is for whoever has to write that statement, or receive one. It covers what the SBOM actually recorded, what VEX adds, what each not_affected justification has to prove, how each one goes wrong, and how scanners apply VEX they are given.

It builds on earlier posts rather than repeating them: why vulnerability databases disagree for how advisories are matched to packages, reachability for how taint and CVE reachability work, container image CVEs for what an image scan can see, CVSS, EPSS and KEV for urgency, malicious packages for the problem VEX does not address, and finding identity for what a suppression is keyed to.

Sources: the NTIA report "The Minimum Elements for a Software Bill of Materials (SBOM)" (July 2021) at ntia.gov; three CISA documents, "Minimum Requirements for Vulnerability Exploitability eXchange (VEX)" (April 2023), "Vulnerability Exploitability eXchange (VEX) - Status Justifications" (June 2022) and "Vulnerability Exploitability eXchange (VEX) - Use Cases" (April 2022), retrieved from the Internet Archive's copies of their cisa.gov URLs because cisa.gov refused our automated request; the OpenVEX specification v0.2.0 on GitHub; the CycloneDX 1.7 JSON schema on GitHub and the CycloneDX VEX capability page; the OASIS CSAF 2.0 specification, section 4.5 "Profile 5: VEX"; the SPDX overview at spdx.dev and the SPDX 3.0.1 model pages for SbomType, VexJustificationType and VexNotAffectedVulnAssessmentRelationship; the Package-URL specification and its pypi type definition on GitHub; Trivy's VEX documentation in the aquasecurity/trivy repository; and Grype's "Filter scan results" guide at oss.anchore.com. All were retrieved on 27 October 2026. The scenario is hypothetical, and no number in this post is a measurement of any team or tool, ours included.

What an SBOM records

The NTIA report sets out seven minimum data fields for each component: supplier, component name, version, other unique identifiers, dependency relationship, author of the SBOM data, and a timestamp. It names three formats for conveying them, SPDX, CycloneDX and SWID tags, and says they "have been deemed interoperable for the core data fields".

Beyond that core, the two common formats differ in emphasis.

The more important point is what neither format is. The NTIA report says "SBOM data is primarily static", reflecting built software at a point in time, while "Vulnerability data, meanwhile, is dynamic and evolves over time." It recommends that "vulnerability data be tracked in separate data structures from the SBOM". An SBOM says what was in the box. Whether anything in the box is vulnerable is a lookup made later, against advisory data that did not exist when the box was packed. Whether the product is affected is a third question that neither answers.

Identity is where the match breaks

A scanner reading an SBOM does not look at code. It takes each component's identifier, finds advisories that name the same identifier and a version range, and reports the intersection. Every step depends on the SBOM generator, the advisory and the scanner agreeing on what the component is called.

The Package-URL specification gives the syntax scheme:type/namespace/name@version?qualifiers#subpath, with examples such as pkg:deb/debian/[email protected]?arch=i386&distro=jessie. Type and name are required; namespace, version, qualifiers and subpath are optional. Each type also has its own rules. The pypi type definition says a PyPI package name "shall be lowercased and underscore _ replaced with a dash -", because PyPI treats them as the same. A generator that writes the name as it appears in a requirements file and a matcher that normalises it will disagree on a single character.

CPE is the other identifier in common use. It names a product in NIST's dictionary rather than a package in an ecosystem, so mapping a CPE vendor and product to the jar, wheel or deb you actually installed is a heuristic. The advisory matching post walks through how that mapping goes wrong, using Log4Shell's records.

Three identity failures matter most for this post:

When the SBOM was generated decides what it can miss

Both formats have a way to say when a BOM was produced. CycloneDX defines lifecycle phases including design, pre-build, build, post-build and operations. A pre-build BOM "may contain source files and development artifacts and manifests. The inventory may need to be resolved and retrieved prior to use." At build time, "The precise versions of resolved components are usually available". SPDX 3.0.1 has a parallel SbomType vocabulary: design, source, build, analyzed, deployed and runtime. An analysed SBOM is generated after the build and "generally requires a variety of heuristics".

In practice each point in the pipeline sees something different.

Dev dependencies show how much the generation point matters. CycloneDX gives each component a scope of required, optional or excluded. Excluded components exist "to document component usage for test and other non-runtime purposes" and "are not reachable within a call graph at runtime". The schema also says "If scope is not specified, 'required' scope SHOULD be assumed by the consumer of the BOM." So a generator that does not set scope makes every test framework a runtime component as far as the consumer can tell. And the optional scope is narrower than it sounds: components "that are installed but due to configuration or other restrictions are prohibited from being called must be scoped as 'required'."

Depth and known unknowns

The NTIA report says an SBOM should list all top-level components with their transitive dependencies and that "At a minimum, all top-level dependencies must be listed with enough detail to seek out the transitive dependencies recursively." Where the graph is not fully enumerated, the author must identify "known unknowns", and "the default interpretation of the data should be that the data is incomplete".

CycloneDX encodes this in compositions, whose aggregate values include complete, incomplete and unknown, where unknown "usually signifies a 'best-effort'" whose completeness "is inconclusive".

This matters for VEX because one justification is a negative claim. The NTIA report notes that "some use cases require complete or mostly complete graphs, such as the ability to 'prove the negative' that a given component is not on an organization's network." An SBOM that does not list a component is not evidence that the component is absent unless the SBOM also says its inventory is complete.

What VEX adds

The CISA minimum requirements describe VEX as indicating "the status of a software product or component with respect to a vulnerability." A VEX statement "MUST convey a single [status] that applies to a single [vul_id] for one or more [product_id]s". The status must be one of four values:

Two rules in the same document change how a consumer should read VEX. First, "VEX does not specify, assume, or imply any default status that is not explicitly provided in VEX statements." Silence about a vulnerability means nothing. Second, a statement can separate component and product: "A common VEX use case is to convey that [subcomponent_id] is 'affected' by [vul_id] while [product_id] is 'not_affected' by [vul_id]." That is the shape of the opening scenario: the JSON library is vulnerable, and the claim is that the product is not.

The five not_affected justifications are component_not_present, vulnerable_code_not_present, vulnerable_code_not_in_execute_path, vulnerable_code_cannot_be_controlled_by_adversary and inline_mitigations_already_exist. The Status Justifications document is explicit about their weight: "Like all assertions, the consumer of the VEX document may choose to accept it or not", and statuses "are not intended to be a discussion-ending declaration but a way to empower consumers to make informed decisions."

Three formats, two vocabularies

The CISA document says CSAF, CycloneDX and OpenVEX "can contain or be used to generate VEX documents".

The CycloneDX vocabulary is not the CISA vocabulary under different names. CycloneDX separates false_positive (the vulnerability "was falsely identified or associated") from not_affected, while the CISA Status Justifications document gives a mistaken detection inside a JAR as an example of component_not_present. CycloneDX has protected_at_perimeter, "Attacks are blocked at physical, logical, or network perimeter", whereas CISA's inline_mitigations_already_exist requires protections that "cannot be configured or disabled by the user". Those are different strengths of claim. Translating a statement from one format to another is a judgement, and the translation should record what it decided.

What each justification has to prove, and how it goes wrong

Each justification is a claim with a different burden of evidence. The definitions below are from the CISA minimum requirements, with examples from the Status Justifications document.

component_not_present

The claim: "The vulnerable [subcomponent_id] is not included in [product_id]." The CISA example is a product written in Python and Elixir, with no Java, so it cannot be affected by Log4j.

Evidence: an inventory of the artifact actually shipped, not the manifest, together with a search that does not depend on package metadata. That means looking inside nested archives, vendored directories and binaries, by class name, file hash or symbol.

How it goes wrong: it is a negative claim made from an inventory that does not say it is complete. The CISA document notes that "Because Java JAR files often contain other JAR files, automation may incorrectly flag a component as existing in a JAR file without verifying it as actually present." The same nesting cuts the other way: a shaded copy under a rewritten package name is present but does not show up in any inventory. The CISA container example, a package installed in a base layer and removed in a later one, is only true for the final image that was checked.

vulnerable_code_not_present

The claim: the component is included "but the vulnerable code is not present. Typically, this case occurs when source code is configured or built in a way that excludes the vulnerable code." The CISA examples are a compiler stripping the relevant function, and a vulnerability introduced in a later version than the one shipped.

Evidence: the build configuration that excludes the code, or inspection of the shipped binary showing the function is absent; or the commit that introduced the vulnerability, showing the shipped version predates it.

How it goes wrong: build-dependent absence depends on the build. A change to compile flags, link-time optimisation, a bundler's tree-shaking or an import pattern can bring the function back without anyone touching the dependency. Version-based absence depends on the advisory's affected range, and advisories are revised. If the introduced version moves earlier, the claim becomes false without any change to your product.

vulnerable_code_not_in_execute_path

The claim: the vulnerable code "cannot be executed due to the way it is used by [product_id]. Typically, this case occurs when [product_id] includes the vulnerable code but does not call or otherwise use it." The Status Justifications document adds the caution that matters most: "A single path of execution should not be assumed since the attacker may be able to divert the path of execution." The SPDX definition says the code is not reachable "including non-anticipated states of the product".

Evidence: first, which function is vulnerable. Advisories often name a package and a version range, not a function; the fix commit usually shows it. Then a call graph from every entry point (request handlers, message consumers, scheduled jobs, CLI commands, startup code) showing no path to that function, including through other dependencies. The reachability post explains how that analysis is done.

How it goes wrong:

vulnerable_code_cannot_be_controlled_by_adversary

The claim: "The vulnerable code is present and used by [product_id] but cannot be controlled by an attacker to exploit the vulnerability." The Status Justifications document adds "without exploiting at least one other vulnerability", and the OpenVEX specification notes that "This justification could be difficult to prove conclusively."

Evidence: data-flow analysis showing that no attacker-influenced source reaches the vulnerable parameter, along with a written statement of who the attacker is. The CISA examples include a hard-coded variable that denies user-generated input to the vulnerable function, and a logging facility only called on hardware malfunction or during installation.

How it goes wrong: "trusted" input often is not. A configuration file read from a shared volume, a header a proxy is assumed to set, a database row another tenant can write, and a message from an internal queue fed by an external system are all attacker-influenced in some deployment. The claim also depends on the deployment it was made for. The CISA EternalBlue example holds because SMB ports are disabled "and the user does not have access to the O/S to enable the ports". If the user can re-enable them, it is a configuration recommendation, not a not_affected statement.

inline_mitigations_already_exist

The claim: the product "includes built-in protections or features that prevent exploitation", and "These built-in protections cannot be subverted by the attacker and cannot be configured or disabled by the user. These mitigations completely prevent exploitation based on known attack vectors."

The Status Justifications document separates this from the previous justification: with "cannot be controlled", there is nothing the attacker can do to reach the vulnerable code; with "inline mitigations", the attacker can influence execution "but some other control blocks malicious execution."

Evidence: the mitigation, where it sits in the path, the attack inputs it was tested against, and the reason it cannot be turned off.

How it goes wrong: it is the hardest of the five to sustain. OpenVEX says it plainly: "History is littered with examples of mitigation bypasses, typically involving minor modifications of existing exploit code." The CISA document warns that "A mitigation which negates a vulnerability in one software/service may not necessarily be the case in another software/service." A sanitiser credited against the wrong class of sink is a mitigation that does not mitigate; the SAST false positives post covers that mechanism. A web application firewall rule or a feature flag can be changed by an operator, so it fails the "cannot be configured or disabled" test. And "known attack vectors" is a moving set.

Statements are per product and version, and they go stale

The CISA Use Cases document states the rule directly: "For a given version of a given product, a particular vulnerability can only have a single status." A not_affected statement about 3.2 is a claim about 3.2. The CISA minimum requirements allow product identifiers to cover sets, including version ranges, but a range is a claim about versions that may not exist yet when the statement is written.

Matching can widen a statement further than its author intended. Trivy's documentation says that for VEX purl matching, "Omitting the version in the PURL applies the statement to all versions of the package." A statement written about one version and published without a version in its identifier silently covers every later one. Grype's guide makes the same point about container images: "Use container digests for the most reliable matching, as tags can move to different images over time." A statement keyed to a tag is about whatever that tag points to today.

Scope works the same way inside an artifact. Trivy's documentation shows a statement naming only a container image as the product and notes that "this approach would suppress all instances of CVE-2024-32002 within the container image", which is why it recommends naming the specific package as a subcomponent.

As general practice, these events should reopen a not_affected statement:

  1. A new product version, including one that only changes dependencies.
  2. A change to the affected dependency's version, or to how it is built or bundled.
  3. A revised advisory: a new affected range, newly named vulnerable functions, or a new attack vector.
  4. A published mitigation bypass, for statements resting on inline_mitigations_already_exist.
  5. A change in how the product is configured or deployed, for statements resting on controllability.
  6. New exploitation evidence, such as a CISA KEV listing, which does not change the facts but should raise the bar for evidence.

The formats give you the fields to track this. The CISA minimum requirements make document and statement versions and timestamps mandatory, and require the document version to be incremented "when any content within the VEX document changes". What none of them can do is notice that the code changed. That is the issuer's job.

Who issues VEX, and why trust is the whole question

The CISA document says "VEX documents can be authored by the supplier of the software or by a third party", and allows an author role drawn from CSAF's publisher categories: coordinator, discoverer, other, translator, user and vendor.

Supplier and consumer each know things the other does not. The supplier knows the code: which functions are called, how the dependency was built, what was stripped. The consumer knows the deployment: which features are enabled, what is exposed, which network controls exist. A supplier statement resting on controllability or mitigations makes assumptions about the consumer's deployment, and the consumer should read it with that in mind. A consumer writing its own VEX about a third-party product is recording a triage decision in a standard format, and it needs the same governance as any suppression: a named owner, evidence, review and an expiry. The suppression post covers how suppressions rot.

Trust is the hard part, because a not_affected statement is an instruction to stop reporting. Anyone who can put a VEX document in front of your scanner can hide a finding. The CISA requirements say author identity "SHOULD be cryptographically associated with the signature of the VEX document or other exchange mechanism" and that "VEX documents and statements SHOULD be signed." In practice a consumer should decide which authors it accepts VEX from, for which products, and with which justifications, rather than accepting any well-formed document that arrives with an SBOM.

How scanners apply VEX

Two open-source scanners document VEX input. This is what their documentation says, not a test of their behaviour.

Trivy. The VEX documentation is marked experimental: "This feature might change without preserving backwards compatibility." It is enabled with a --vex option taking a VEX repository, a local file, VEX attestations discovered in an OCI registry, or documents referenced from a CycloneDX SBOM's external references. For local files it lists CycloneDX, OpenVEX and CSAF. CycloneDX VEX is supported only when scanning an SBOM, and "Trivy only supports the Independent BOM and VEX BOM format"; OpenVEX "can be used in all Trivy targets, unlike CycloneDX VEX", and CSAF is listed for all targets. Statements are matched by purl. The version rule is quoted above, and the documentation also says a purl without qualifiers matches every qualifier variant of that package version. Trivy also applies statements to its dependency tree and, as noted earlier, treats the root project as affected if any path to the vulnerable package is not covered by a statement.

Grype. The "Filter scan results" guide says Grype accepts OpenVEX and CSAF VEX through --vex or a vex-documents configuration entry. "By default, Grype moves vulnerabilities with not_affected or fixed status to the ignored list." Statements with affected or under_investigation only add results when augmentation is enabled with vex-add. Filtered results are hidden from the table by default and shown with --show-suppressed, and in JSON they move to ignoredMatches with the applied rules listed.

One sentence in Grype's guide shows why VEX belongs in a gate review: "The --fail-on check runs after vulnerability matching and filtering", and "The severity threshold check then evaluates only the remaining vulnerabilities." A VEX document can turn a failing pipeline green. That may be correct. It is also exactly the situation the scan-passed post warns about: a clean exit that reflects what was filtered as much as what was found. Log which VEX documents were applied, by which author, to which findings, on every run.

Grype treating fixed as a filtering status has its own trap. A fixed statement is about the version that contains the fix. If the identifier matches more broadly than that, a statement about the patched release can hide a finding in the unpatched one. (This is our inference, not something Grype's guide describes.)

A method for answering "are we affected?"

  1. Confirm the component is in the artifact you ship. Check the final artifact, not the manifest: image digest, binary, or bundle. Look inside nested archives and vendored directories. If it is absent, that is component_not_present, and record how you searched.
  2. Confirm the identity and version. Check the purl or CPE match against the advisory, the distribution qualifier, and any backports. If the match itself is wrong, say so; in CycloneDX that is false_positive rather than not_affected.
  3. Find the vulnerable code. Read the advisory and the fix commit and name the functions, classes or configuration involved. Check whether they exist in the version and build you ship.
  4. Establish reachability. Trace from every entry point. List the dynamic-dispatch mechanisms in play and say how each was handled. If one was not analysed, the claim is not established.
  5. Establish controllability. Write down who the attacker is. Trace attacker-influenced inputs to the vulnerable parameters, including inputs you consider internal.
  6. Credit mitigations only if they hold. Built in, not configurable by the user, tested against known exploit inputs.
  7. Write the statement narrowly. Product with a version or digest; subcomponent with a versioned purl; one justification; an impact statement that names the evidence, the commit analysed and the analysis date.
  8. Record what would reopen it, from the list in the staleness section, and attach those triggers to your release process.
  9. If you cannot finish, say under_investigation. It is an honest status, and the CISA requirements expect it to change.

Where AppSecD fits

AppSecD's composition analysis runs Trivy and OSV across npm, PyPI, Go, Maven, RubyGems, Cargo, Packagist and NuGet with CISA KEV correlation, and correlates every advisory against the versions, manifests and repositories actually present in your software estate. Its CVE-reachability answers whether the vulnerable code path can be reached at all before anyone is paged.

That is the whole claim. This post does not claim that AppSecD generates SBOMs, ingests SBOMs, produces VEX documents or consumes them, and the Trivy VEX behaviour quoted above is Trivy's documentation, not a description of how AppSecD invokes Trivy. Reachability evidence of the kind described here supports a vulnerable_code_not_in_execute_path claim; it is not the claim, and the limits in this post apply to any reachability analysis, ours included. Put the checklist below to us as you would to anyone else.

A checklist for SBOM and VEX

For an SBOM you produce:

  1. Generate it from the artifact you ship, or from the build that produced it, and record the lifecycle phase or SBOM type.
  2. Set component scope, so test and build dependencies are not presented as runtime.
  3. State completeness explicitly. If transitive dependencies are not fully enumerated, say so in the data.
  4. Record identity carefully: purls with versions and distribution qualifiers, and note vendored, shaded and statically linked components that no package metadata records.
  5. Produce a new SBOM for every release, including dependency-only releases.

For a not_affected statement you issue:

  1. Scope it to a product version or digest and a versioned subcomponent purl. Never publish a versionless identifier unless you mean every version.
  2. Use one of the five justifications, and attach an impact statement that names the evidence, the commit and the date.
  3. For vulnerable_code_not_in_execute_path, name the vulnerable functions and list the dynamic-dispatch mechanisms you checked.
  4. For cannot_be_controlled_by_adversary, write down the attacker model and every input traced.
  5. For inline_mitigations_already_exist, confirm the mitigation cannot be configured or disabled by the user; if it can, it is not this justification.
  6. Sign the document, version every change, and reopen the statement on the triggers listed above.

For VEX you receive:

  1. Accept VEX only from authors you have decided to trust, for products they supply, and verify signatures where they exist.
  2. Check that each statement's product and version match what you run, and treat a versionless or tag-based identifier as broader than it looks.
  3. Log every finding a VEX document suppressed, with the author and justification, and review that log as you would any other suppression.
  4. Re-review not_affected statements when a CVE is added to CISA KEV or its advisory is revised.

Back to the spreadsheet. The JSON library is in release 3.2; the SBOM was right to list it, and the customer's scanner was right to match it. Whether 3.2 is affected depends on whether polymorphic deserialisation can be reached with attacker-controlled input, which is a question about code and configuration that neither document can answer. A not_affected statement is a good answer if it names that evidence, is scoped to 3.2, and gets reopened when 3.3 ships. Without that, it is a suppression with a schema.

More about AppSecD · All posts