Safeguard
Supply Chain Security•

VEX in SPDX 3.0 Security Profile vs CSAF, OpenVEX and CycloneDX

SPDX 3.0 writes VEX as four typed relationships from a vulnerability to products. How that compares with CSAF 2.0, OpenVEX and CycloneDX, field by field.

Safeguard Security Team
40 min read

SPDX 3.0 expresses VEX in its Security profile as relationships: a vulnerability element points to the product elements it affects, does not affect, is fixed in, or is under investigation for, using four relationship classes. CSAF 2.0, OpenVEX and CycloneDX carry the same four statuses with different containers, vocabularies and identifiers, so the same claim looks very different in each.

This post goes through the SPDX 3.0.1 model class by class, lines its status values and justifications up against the other three formats, maps every element in CISA's minimum requirements for VEX to the field that carries it, and writes one "not affected" statement four ways. All four examples below validated against their published JSON schemas when we wrote this, and the SPDX one also passed the SPDX 3.0.1 SHACL shapes.

Key takeaways

  • SPDX 3.0.1 has four concrete VEX classes, one per status: VexAffectedVulnAssessmentRelationship, VexNotAffectedVulnAssessmentRelationship, VexFixedVulnAssessmentRelationship and VexUnderInvestigationVulnAssessmentRelationship. Each is locked to one relationship type: affects, doesNotAffect, fixedIn or underInvestigationFor.
  • The status values line up cleanly across all four formats. CycloneDX names them differently (exploitable, resolved, in_triage) and adds false_positive; CSAF adds extra product status buckets such as first_fixed and recommended.
  • SPDX 3, CSAF and OpenVEX share the five CISA justification labels (SPDX writes them in camelCase). CycloneDX has its own nine justifications, and only some map one to one.
  • Identification is where VEX documents break in practice. OpenVEX leans on purl, CSAF uses its own product IDs inside a product tree with identification helpers, CycloneDX references bom-ref values or BOM-Links, and SPDX 3 uses IRIs for spdxId with purl as a package property.
  • SPDX 3 and CycloneDX can embed VEX next to the inventory or ship it as a separate document; OpenVEX and CSAF are always separate documents that point at products.
  • Pick by publisher: CSAF suits vendors who already publish advisories, OpenVEX suits open source and image maintainers who want small files and signed attestations, CycloneDX suits teams already shipping CycloneDX BOMs, and SPDX 3 suits teams that have standardised on SPDX and want one graph.

If you want the background first, our earlier posts cover CISA's minimum requirements for VEX, the anatomy of an OpenVEX document, the CSAF VEX profile checklist and OpenVEX versus CycloneDX VEX. This post adds the SPDX 3 view and puts all four side by side.

What VEX is for, in one paragraph

A Vulnerability Exploitability eXchange (VEX) statement tells a consumer whether a specific product is affected by a specific vulnerability. CISA's April 2023 minimum requirements define a VEX statement as a declaration that conveys a single status for a single vulnerability across one or more products, held inside a VEX document. The typical case is a scanner flagging a component inside your product, and you, the supplier, stating that the product is not affected and why. Without that statement, every downstream customer repeats the same triage. VEX does not replace an SBOM or an advisory; CISA's document says VEX is designed to integrate with SBOMs, vulnerability databases and advisories, but does not require any of them.

Timeline: the documents and spec versions in this comparison

Dates below come from the publications themselves. Where a source carried only a version and no date, the table says so rather than guessing.

DatePublication or versionWhat it settled for VEX
April 2022CISA, VEX Use CasesCommon scenarios, including multiple products and vulnerabilities in one document
June 2022CISA, VEX Status JustificationsThe five "not affected" justification labels used by SPDX 3, CSAF and OpenVEX
18 November 2022OASIS CSAF 2.0 approved as an OASIS StandardProfile 5 "VEX", with csaf_vex as the document category and five machine readable flag labels
CycloneDX 1.4 (version; date not checked)vulnerabilities[].analysis with state, justification and responseCycloneDX VEX; the same six states and nine justifications are still in the 1.6 and 1.7 schemas
April 2023CISA, Minimum Requirements for VEX (data element requirements version 1.0.0)Required document and statement fields, the four statuses, and rules on timestamps, versions and signatures
OpenVEX v0.2.0 (current spec)OpenVEX specificationLightweight JSON-LD format written to meet the April 2023 minimum requirements
SPDX 3.0.1 (current model)SPDX Security profileFour VEX relationship classes plus CVSS v2, v3, v4, EPSS, SSVC and exploit catalog assessments
21 October 2026CSAF 2.1, fourth committee specification draft (csd04) on the OASIS docs serverStill a draft; CSAF 2.0 remains the approved standard as of this writing

Two small inconsistencies are worth knowing. The OpenVEX spec says the justification catalog was published "on July 2022", while the CISA PDF itself is dated June 2022. And the SPDX 3.0.1 class pages show example JSON that uses property names such as publishedTime without the security_ prefix that the 3.0.1 JSON schema expects. When the prose and the schema disagree, validate against the schema.

How the SPDX 3 Security profile expresses VEX

The SPDX 3.0.1 Security profile page describes the profile in a single sentence: it holds security related information. The SPDX project's own profile page says it exists to exchange information about vulnerabilities, their severity, and how a vulnerability affects a specific element, including whether a fix is available. VEX is one part of that.

Everything is a relationship

SPDX 3 is a graph. Packages, files, people, organisations and vulnerabilities are all Elements with an spdxId, and facts about how they connect are Relationship elements with a from, a to list and a relationshipType. VEX statements are not a separate document section; they are relationships whose from end is a Vulnerability and whose to end lists the products the statement is about.

The class hierarchy on the spec pages is:

/Core/Element
  /Core/Relationship
    /Security/VulnAssessmentRelationship            (abstract)
      /Security/VexVulnAssessmentRelationship       (abstract)
        /Security/VexAffectedVulnAssessmentRelationship
        /Security/VexNotAffectedVulnAssessmentRelationship
        /Security/VexFixedVulnAssessmentRelationship
        /Security/VexUnderInvestigationVulnAssessmentRelationship

VulnAssessmentRelationship is also the parent of the non VEX assessments: CVSS v2, v3 and v4 scores, EPSS probabilities, SSVC decisions and exploit catalog entries all sit beside VEX in the same profile. That matters, because an SPDX 3 document can carry a severity score, an exploit catalog listing and a VEX status for the same vulnerability and product in one graph, which none of the other three formats does in quite the same way.

Four classes, four relationship types

Each concrete VEX class is tied to exactly one relationship type, and the spec pages state the constraint explicitly.

SPDX 3.0.1 classRequired relationshipTypeVEX statusExtra property
VexAffectedVulnAssessmentRelationshipaffectsaffectedactionStatement (required, 1..1), actionStatementTime
VexNotAffectedVulnAssessmentRelationshipdoesNotAffectnot_affectedjustificationType, impactStatement, impactStatementTime (each 0..1, but one of justification or impact statement must be present)
VexFixedVulnAssessmentRelationshipfixedInfixednone beyond the shared ones
VexUnderInvestigationVulnAssessmentRelationshipunderInvestigationForunder_investigationnone beyond the shared ones

The not affected rule deserves a second look. The SPDX model makes both justificationType and impactStatement optional in cardinality, then says on the class page that to produce a valid VEX not_affected statement, one of them MUST be defined, citing the minimum elements for VEX. A JSON schema check will not catch a not affected relationship that has neither. You need a semantic check or your own lint rule.

The affects class is the reverse case: actionStatement has a minimum count of one, so a schema check does catch an affected statement with no action. That mirrors CISA's rule that an affected statement MUST include an action statement.

The properties that carry the VEX content

These are the properties a VEX relationship can carry in SPDX 3.0.1, collected from the class pages. In JSON-LD the Security profile properties take a security_ prefix; suppliedBy is a Core property and has no prefix.

Property (JSON-LD name)Defined onCardinalityWhat it holds
fromRelationship1..1The Vulnerability element
toRelationship1..*The products the status applies to
security_assessedElementVulnAssessmentRelationship0..1The component inside the product where the vulnerability was detected
suppliedByVulnAssessmentRelationship0..1The agent who supplied the assessment
security_publishedTimeVulnAssessmentRelationship0..1When the assessment was published
security_modifiedTimeVulnAssessmentRelationship0..1When it last changed
security_withdrawnTimeVulnAssessmentRelationship0..1When it was withdrawn
security_vexVersionVexVulnAssessmentRelationship0..1Version of the VEX statement
security_statusNotesVexVulnAssessmentRelationship0..1How the status was determined
security_justificationTypeVexNotAffected0..1One of five justification values
security_impactStatementVexNotAffected0..1Free text explaining why not affected
security_impactStatementTimeVexNotAffected0..1When the impact statement was issued
security_actionStatementVexAffected1..1What the consumer should do
security_actionStatementTimeVexAffected0..1When the action statement was issued

assessedElement is SPDX's answer to CISA's [subcomponent_id]. The relationship's to end names the product, for example your application, and assessedElement names the library inside it, for example the npm package that triggered the scanner. That is the common VEX pattern CISA describes: the subcomponent is affected in isolation, while the product is not.

Inheritance from the document

The abstract VexVulnAssessmentRelationship page spells out how statements inherit metadata. If a VEX relationship has no author or timestamp of its own, tools must read the author from the document's CreationInfo and treat the document's creation date as authoritative. This matches the CISA rule that statements may inherit metadata from their containing document, and the OpenVEX inheritance flow.

In practice, the CreationInfo node holds specVersion, created, createdBy (agents) and optionally createdUsing (tools). One CreationInfo is usually shared by every element in the document through a blank node reference.

How SPDX 3 identifies the product and the vulnerability

Every SPDX 3 element has an spdxId, which is an IRI. Packages carry ecosystem identifiers as properties such as software_packageUrl or as externalIdentifier entries of types including cpe22, cpe23, packageUrl, swid, swhid and gitoid. The Vulnerability element carries its public ID as an externalIdentifier of type cve or securityOther, and its advisories as externalRef entries.

So an SPDX 3 VEX statement does not say "pkg:npm/elliptic@6.5.2 is not affected". It says "the element with this IRI is not affected", and that element in turn carries the purl. A consumer has to resolve the IRI, either within the same document or through an external map, before it can match the statement to anything a scanner reports.

Status values across the four formats

The four statuses come from CISA, and all four formats can express them. The names and the extras differ.

CISA statusSPDX 3.0.1CSAF 2.0 (product_status)OpenVEX v0.2.0CycloneDX 1.6 (analysis.state)
not_affectedVexNotAffectedVulnAssessmentRelationship, doesNotAffectknown_not_affectednot_affectednot_affected
affectedVexAffectedVulnAssessmentRelationship, affectsknown_affected (also first_affected, last_affected)affectedexploitable
fixedVexFixedVulnAssessmentRelationship, fixedInfixed (also first_fixed)fixedresolved or resolved_with_pedigree
under_investigationVexUnderInvestigationVulnAssessmentRelationship, underInvestigationForunder_investigationunder_investigationin_triage
no CISA equivalentnonerecommendednonefalse_positive

Three notes on the rows that need care.

CycloneDX exploitable is defined in the schema as "may be directly or indirectly exploitable". That is close to, but stronger in tone than, CISA's affected, which only says actions are recommended. Converting affected to exploitable is the usual mapping; converting back is lossless.

CycloneDX false_positive means the vulnerability was wrongly attributed to the component or service in the first place. It has no direct equivalent elsewhere. The closest translation into the other three is not_affected with component_not_present or vulnerable_code_not_present plus an impact statement explaining the misattribution.

CSAF's recommended bucket is not a VEX status at all; it tells the consumer which version to run. A csaf_vex document needs at least one of fixed, known_affected, known_not_affected or under_investigation.

Justifications across the four formats

SPDX 3, CSAF and OpenVEX share CISA's five labels. CSAF puts them in vulnerabilities[].flags[].label, OpenVEX in statements[].justification, and SPDX 3 in security_justificationType with camelCase spellings. CycloneDX has nine values of its own. The CycloneDX column is our interpretation, not a crosswalk published by either project, and it agrees with the rough mapping in our CycloneDX states versus OpenVEX status post.

CISA labelSPDX 3.0.1 VexJustificationTypeCSAF 2.0 flag labelOpenVEXNearest CycloneDX 1.6
Component_not_presentcomponentNotPresentcomponent_not_presentcomponent_not_presentNo direct value; usually the component is simply absent from the BOM, or use detail
Vulnerable_code_not_presentvulnerableCodeNotPresentvulnerable_code_not_presentvulnerable_code_not_presentcode_not_present
Vulnerable_code_not_in_execute_pathvulnerableCodeNotInExecutePathvulnerable_code_not_in_execute_pathvulnerable_code_not_in_execute_pathcode_not_reachable
Vulnerable_code_cannot_be_controlled_by_adversaryvulnerableCodeCannotBeControlledByAdversaryvulnerable_code_cannot_be_controlled_by_adversaryvulnerable_code_cannot_be_controlled_by_adversaryNo direct value; requires_configuration or requires_environment sometimes fit
Inline_mitigations_already_existinlineMitigationsAlreadyExistinline_mitigations_already_existinline_mitigations_already_existprotected_by_mitigating_control, protected_at_runtime or protected_by_compiler, depending on the control

Two CycloneDX justifications need special handling when you convert. requires_dependency means exploitation requires a dependency that is not present, which can sit under either component or code not present depending on context. protected_at_perimeter describes a protection outside the product, such as a network control. CISA's inline mitigation label requires built in protections that the user cannot configure or disable, so a perimeter control does not qualify. Carry that reasoning into an impact statement rather than forcing a label that overstates the claim.

The definitions are not quite identical even where the labels match. CISA's 2023 text defines each label in terms of its own data elements, for example that the vulnerable subcomponent is not included in the product. The SPDX 3.0.1 vocabulary page and the CSAF 2.0 flag section use a different set of descriptions, and those two match each other word for word for all five labels, which makes SPDX 3 and CSAF the easiest pair to convert between.

Which fields CISA's minimum requirements demand, and where each format puts them

CISA's April 2023 document defines data elements in square brackets and marks each one MUST, SHOULD or MAY. This table maps every required element, plus the common optional ones, to the field that carries it. "Inherited" means the format lets a statement take the value from its containing document, which the CISA text explicitly allows.

CISA elementRequirementSPDX 3.0.1CSAF 2.0OpenVEX v0.2.0CycloneDX 1.6
[doc_id]MUSTSpdxDocument spdxIddocument.tracking.id@idserialNumber
[doc_version]MUSTNo dedicated document version property in the model pages we read; encode it in the document IRI or use security_vexVersion per statementdocument.tracking.version plus revision_historyversionversion
[author]MUSTCreationInfo.createdBy (agents)document.publisher.name and namespaceauthormetadata.authors
[author_role]MAYNo direct field; agent type and relationships such as coordinatedBy cover some casesdocument.publisher.categoryroleNo direct field
[tooling]MAYCreationInfo.createdUsingdocument.tracking.generatortoolingmetadata.tools
[doc_time_first_issued]MUSTCreationInfo.createddocument.tracking.initial_release_datetimestampmetadata.timestamp
[doc_time_last_updated]MUSTNew created value on each regenerated documentdocument.tracking.current_release_datelast_updatedmetadata.timestamp on the new BOM version
[statement_id]SHOULDThe relationship's spdxIdNo per statement ID; each vulnerabilities[] entry plus product IDs acts as the statementstatement @idvulnerabilities[].bom-ref
[statement_version]MUSTsecurity_vexVersionInherited from tracking.versionstatement versionInherited from BOM version
[statement_time_first_issued]MUSTsecurity_publishedTime (else inherited)Inherited, or vulnerabilities[].release_datestatement timestamp (else inherited)analysis.firstIssued
[statement_time_last_updated]MUSTsecurity_modifiedTimeInherited from current_release_datestatement last_updatedanalysis.lastUpdated
[product_id]MUSTto list of element IRIsProduct IDs in product_status, defined in product_treeproducts[].@id and identifiersaffects[].ref
[subcomponent_id]MAYsecurity_assessedElementproduct_tree.relationships such as default_component_ofproducts[].subcomponentsThe component holding the vulnerability, by bom-ref
[supplier]SHOULDsuppliedBy on the package elementVendor branch in product_tree.branchesstatement suppliercomponent.supplier
[vul_id]MUSTVulnerability with externalIdentifiercve or idsvulnerability.name (plus aliases)vulnerabilities[].id and source
[vul_description]MUST (include or reference)Vulnerability.description or externalRefnotes with category descriptionvulnerability.description or @id linkdescription, references, advisories
[status]MUSTClass plus relationshipTypeproduct_status bucketstatusanalysis.state
[justification]SHOULD for not_affectedsecurity_justificationTypeflags[].labeljustificationanalysis.justification
[impact_statement]MUST if no justificationsecurity_impactStatementthreats[] with category impactimpact_statementanalysis.detail
[action_statement]MUST for affectedsecurity_actionStatementremediations[]action_statementanalysis.response plus recommendation
[status_notes]MAYsecurity_statusNotesnotesstatus_notesanalysis.detail
SignaturesSHOULD sign; implementations MUST supportNo signature field in the VEX classes; sign the serialized file externally (verifiedUsing holds element hashes, not document signatures)OpenPGP signature files are among the CSAF distribution requirements (Requirement 19)External, for example a signed in-toto attestationEmbedded JSON signature (JSF) or external

A few judgement calls in that table are worth flagging. CSAF has no explicit statement ID, so a strict reading of CISA's "a VEX statement MUST be able to be specifically referenced" is satisfied by the combination of document ID, vulnerability and product ID. SPDX 3's lack of a document version property is the gap that surprised us most; the CISA rule that the document version MUST be incremented on any change has to be met by convention, for example by minting a new document IRI per version. Check the current model before relying on that gap, as SPDX revisions may add one.

How product and component identification differ

Matching is the whole point of VEX. A consumer's scanner says "component X in image Y has CVE Z", and a VEX statement only helps if the consumer can prove the statement is about the same X and Y. The four formats take different routes.

FormatPrimary product identifierOther identifiers supportedProduct sets and ranges
SPDX 3.0.1spdxId IRI of an elementpurl via software_packageUrl; externalIdentifier types including cpe22, cpe23, packageUrl, swid, swhid, gitoid; hashes via verifiedUsingOne relationship can list many products in to; no version range syntax in the VEX classes themselves
CSAF 2.0Product ID string (for example CSAFPID-0001) defined in product_treeproduct_identification_helper with cpe, hashes, model numbers, purl, SBOM URLs, serial numbers, SKUs and generic URIsProduct groups, branches by vendor, product name and version, and version ranges in the branch tree
OpenVEX v0.2.0products[].@id, an IRI; purl recommendedidentifiers map (purl, cpe22, cpe23 and others), hashes map keyed by IANA hash namesMany products per statement; sets expressed by listing
CycloneDX 1.6bom-ref of a component in the same BOM, or a BOM-Link to another BOMComponent purl, cpe, swid, hashes; affects[].versions with version or range and an affected, unaffected or unknown statusVersion ranges per affected entry

The practical consequences:

  1. OpenVEX is the most direct to match. If the @id is a purl, a scanner that already reports purls can match without a lookup step. The cost is that purl alone cannot express a hardware model or a firmware SKU.
  2. CSAF is the most expressive for physical products. Model numbers, serial numbers and SKUs are first class, which is why device makers lean on it. The cost is indirection: every status references an internal product ID that you must resolve through the product tree.
  3. CycloneDX is strongest when the VEX travels with the BOM. A bom-ref is only meaningful inside one BOM, so a standalone CycloneDX VEX should reference the original BOM with a BOM-Link (urn:cdx:serial/version#bom-ref), which CycloneDX describes as a URN registered with IANA and compliant with RFC 8141.
  4. SPDX 3 is strongest when you control the whole graph. IRIs are globally unique, so a VEX document can point at elements defined in an SBOM published elsewhere. The consumer still needs that SBOM, or a copy of the referenced elements, to get from the IRI to a purl.

CISA asks that product and subcomponent identifiers SHOULD reference existing SBOM identifiers and follow a supplier, product, version construct. All four formats can do that; only SPDX 3 and CycloneDX do it natively by pointing into the SBOM itself.

Where VEX sits relative to the BOM: embedded, linked or separate

There are three placements, and the formats support them unevenly.

PlacementSPDX 3.0.1CSAF 2.0OpenVEXCycloneDX
Embedded in the SBOMYes; VEX relationships live in the same graph as the packagesNoNo (but OpenVEX defines encapsulating documents that can carry its statements)Yes; vulnerabilities[] inside the same BOM
Separate document linked by identifierYes; separate SpdxDocument referencing element IRIsYes; sbom_urls helper can point to an SBOMYes; purl or IRI referencesYes; a VEX only BOM using BOM-Link references
Attached as a signed attestationPossible with any formatPossibleCommon; vexctl attest writes signed attestationsPossible

Embedding sounds tidy but has a cost. An SBOM describes a build and should not change after release, while VEX statements change every time a new CVE lands or an investigation closes. If the VEX lives inside the SBOM, every status change produces a new SBOM version, and consumers have to work out that only the vulnerability section moved. Keeping VEX in a separate document, with its own version counter and identifier links back to the SBOM, avoids that. CycloneDX's BOM-Link page describes exactly this modular approach, and CISA's minimum requirements say a VEX document must be able to exist without additional infrastructure.

Embedding still makes sense for internal use: an SPDX 3 graph that holds packages, vulnerabilities, CVSS scores, EPSS values and VEX status in one place is a good working model for a product security team, even if what you publish externally is split into separate documents.

Write the same "not affected" statement in all four formats

Here is a single fact, expressed four ways:

  • Supplier: Example Corp (a fictional company)
  • Product: example-web-app version 1.3.0, purl pkg:npm/example-web-app@1.3.0
  • Subcomponent: elliptic 6.5.2, purl pkg:npm/elliptic@6.5.2
  • Vulnerability: CVE-2020-28498, the same identifier SPDX uses in its own class examples
  • Status: not affected
  • Justification: vulnerable code not in execute path
  • Impact statement: the app never calls the affected signature verification path

The vulnerability and package are only illustrations. The claim about Example Corp is invented for this post.

Step 1: SPDX 3.0.1 (JSON-LD)

This validates against https://spdx.org/schema/3.0.1/spdx-json-schema.json and conforms to the SHACL shapes in https://spdx.org/rdf/3.0.1/spdx-model.ttl.

{
  "@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
  "@graph": [
    {
      "type": "CreationInfo",
      "@id": "_:creationinfo",
      "specVersion": "3.0.1",
      "created": "2026-10-20T09:00:00Z",
      "createdBy": ["https://example.com/spdx/agent/example-corp"]
    },
    {
      "type": "Organization",
      "spdxId": "https://example.com/spdx/agent/example-corp",
      "creationInfo": "_:creationinfo",
      "name": "Example Corp"
    },
    {
      "type": "SpdxDocument",
      "spdxId": "https://example.com/spdx/vex/web-app-1.3.0-v1",
      "creationInfo": "_:creationinfo",
      "profileConformance": ["core", "software", "security"],
      "rootElement": ["https://example.com/spdx/vex/stmt-1"],
      "element": [
        "https://example.com/spdx/agent/example-corp",
        "https://example.com/spdx/pkg/web-app-1.3.0",
        "https://example.com/spdx/pkg/elliptic-6.5.2",
        "https://example.com/spdx/vuln/CVE-2020-28498",
        "https://example.com/spdx/vex/stmt-1"
      ]
    },
    {
      "type": "software_Package",
      "spdxId": "https://example.com/spdx/pkg/web-app-1.3.0",
      "creationInfo": "_:creationinfo",
      "name": "example-web-app",
      "software_packageVersion": "1.3.0",
      "software_packageUrl": "pkg:npm/example-web-app@1.3.0"
    },
    {
      "type": "software_Package",
      "spdxId": "https://example.com/spdx/pkg/elliptic-6.5.2",
      "creationInfo": "_:creationinfo",
      "name": "elliptic",
      "software_packageVersion": "6.5.2",
      "software_packageUrl": "pkg:npm/elliptic@6.5.2"
    },
    {
      "type": "security_Vulnerability",
      "spdxId": "https://example.com/spdx/vuln/CVE-2020-28498",
      "creationInfo": "_:creationinfo",
      "externalIdentifier": [
        {
          "type": "ExternalIdentifier",
          "externalIdentifierType": "cve",
          "identifier": "CVE-2020-28498",
          "identifierLocator": ["https://www.cve.org/CVERecord?id=CVE-2020-28498"]
        }
      ]
    },
    {
      "type": "security_VexNotAffectedVulnAssessmentRelationship",
      "spdxId": "https://example.com/spdx/vex/stmt-1",
      "creationInfo": "_:creationinfo",
      "relationshipType": "doesNotAffect",
      "from": "https://example.com/spdx/vuln/CVE-2020-28498",
      "to": ["https://example.com/spdx/pkg/web-app-1.3.0"],
      "security_assessedElement": "https://example.com/spdx/pkg/elliptic-6.5.2",
      "security_justificationType": "vulnerableCodeNotInExecutePath",
      "security_impactStatement": "The app never calls the affected signature verification path in elliptic.",
      "security_publishedTime": "2026-10-20T09:00:00Z",
      "suppliedBy": "https://example.com/spdx/agent/example-corp",
      "security_vexVersion": "1"
    }
  ]
}

Things to notice. The product and the subcomponent are full software_Package elements, not strings. suppliedBy is a single value even though the spec page example shows an array; the class table gives it a maximum count of one. We left out dataLicense on the document because pointing it at the CC0 license IRI without also defining that license element failed the SHACL check, while the JSON schema accepted it. If you include dataLicense, include the license element it points to.

Step 2: OpenVEX v0.2.0

This validates against the openvex_json_schema.json file in the openvex/spec repository.

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://example.com/vex/web-app-1.3.0-v1",
  "author": "Example Corp Product Security <psirt@example.com>",
  "role": "vendor",
  "timestamp": "2026-10-20T09:00:00Z",
  "version": 1,
  "tooling": "vexctl",
  "statements": [
    {
      "vulnerability": {
        "@id": "https://www.cve.org/CVERecord?id=CVE-2020-28498",
        "name": "CVE-2020-28498"
      },
      "products": [
        {
          "@id": "pkg:npm/example-web-app@1.3.0",
          "subcomponents": [
            { "@id": "pkg:npm/elliptic@6.5.2" }
          ]
        }
      ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "The app never calls the affected signature verification path in elliptic."
    }
  ]
}

OpenVEX is the shortest of the four because the purl does the identification work and timestamps cascade from the document. The spec recommends always using the machine readable justification and treating impact_statement as optional enrichment, because free text breaks automation.

You can produce the core of this with the official OpenVEX tool. The vexctl README shows this form:

go install github.com/openvex/vexctl@latest

vexctl create \
  --product="pkg:npm/example-web-app@1.3.0" \
  --vuln="CVE-2020-28498" \
  --status="not_affected" \
  --justification="vulnerable_code_not_in_execute_path" > web-app.vex.json

Then add the author, subcomponent and impact statement, and check the result. The README describes vexctl validate as checking required fields, allowed combinations of status, justification and statements, purl parsing, CPE format and hash lengths, with a strict mode that fails on warnings:

vexctl validate --strict web-app.vex.json

Step 3: CycloneDX 1.6 (VEX inside a BOM)

This validates against bom-1.6.schema.json from the CycloneDX specification repository.

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
  "version": 1,
  "metadata": {
    "timestamp": "2026-10-20T09:00:00Z",
    "authors": [ { "name": "Example Corp Product Security", "email": "psirt@example.com" } ],
    "component": {
      "type": "application",
      "bom-ref": "pkg:npm/example-web-app@1.3.0",
      "name": "example-web-app",
      "version": "1.3.0",
      "purl": "pkg:npm/example-web-app@1.3.0"
    }
  },
  "components": [
    {
      "type": "library",
      "bom-ref": "pkg:npm/elliptic@6.5.2",
      "name": "elliptic",
      "version": "6.5.2",
      "purl": "pkg:npm/elliptic@6.5.2"
    }
  ],
  "vulnerabilities": [
    {
      "bom-ref": "vex-stmt-1",
      "id": "CVE-2020-28498",
      "source": { "name": "NVD", "url": "https://nvd.nist.gov/vuln/detail/CVE-2020-28498" },
      "analysis": {
        "state": "not_affected",
        "justification": "code_not_reachable",
        "detail": "The app never calls the affected signature verification path in elliptic.",
        "firstIssued": "2026-10-20T09:00:00Z",
        "lastUpdated": "2026-10-20T09:00:00Z"
      },
      "affects": [
        { "ref": "pkg:npm/example-web-app@1.3.0" }
      ]
    }
  ]
}

The design choice to notice is affects. CycloneDX's affects[].ref points at the thing the analysis is about. We point it at the product, which matches the VEX sense of "the product is not affected". Some publishers point it at the library instead; that states a different fact (the library is not affected), and it is a frequent source of confusion when converting to OpenVEX or SPDX 3, which keep product and subcomponent in separate fields.

If you publish this as a VEX only BOM instead of inside the SBOM, drop components and make affects[].ref a BOM-Link to the SBOM, in the form urn:cdx:<serialNumber>/<version>#<bom-ref>.

The CycloneDX CLI validates a BOM against a chosen schema version and can fail a build:

cyclonedx validate --input-file web-app.vex.cdx.json --input-version v1_6 --fail-on-errors

Step 4: CSAF 2.0 with the VEX profile

This validates against csaf_json_schema.json from the OASIS CSAF 2.0 release.

{
  "document": {
    "category": "csaf_vex",
    "csaf_version": "2.0",
    "title": "Example Corp web app 1.3.0 and CVE-2020-28498",
    "publisher": {
      "category": "vendor",
      "name": "Example Corp",
      "namespace": "https://example.com"
    },
    "tracking": {
      "id": "EXAMPLE-VEX-2026-0001",
      "status": "final",
      "version": "1",
      "initial_release_date": "2026-10-20T09:00:00.000Z",
      "current_release_date": "2026-10-20T09:00:00.000Z",
      "revision_history": [
        { "date": "2026-10-20T09:00:00.000Z", "number": "1", "summary": "Initial release." }
      ]
    }
  },
  "product_tree": {
    "branches": [
      {
        "category": "vendor",
        "name": "Example Corp",
        "branches": [
          {
            "category": "product_name",
            "name": "example-web-app",
            "branches": [
              {
                "category": "product_version",
                "name": "1.3.0",
                "product": {
                  "name": "Example Corp example-web-app 1.3.0",
                  "product_id": "CSAFPID-0001",
                  "product_identification_helper": {
                    "purl": "pkg:npm/example-web-app@1.3.0"
                  }
                }
              }
            ]
          }
        ]
      }
    ]
  },
  "vulnerabilities": [
    {
      "cve": "CVE-2020-28498",
      "notes": [
        {
          "category": "description",
          "text": "Cryptographic weakness in the elliptic npm package before 6.5.4.",
          "title": "Vulnerability description"
        }
      ],
      "product_status": {
        "known_not_affected": ["CSAFPID-0001"]
      },
      "flags": [
        {
          "label": "vulnerable_code_not_in_execute_path",
          "product_ids": ["CSAFPID-0001"]
        }
      ],
      "threats": [
        {
          "category": "impact",
          "details": "The app never calls the affected signature verification path in elliptic.",
          "product_ids": ["CSAFPID-0001"]
        }
      ]
    }
  ]
}

The CSAF version is the longest, and every part of that length is required somewhere. Profile 5 requires a product_tree listing every product referenced, a vulnerabilities list, at least one product status bucket, at least one of cve, ids or notes, and, for each product in known_not_affected, either a flag or an impact threat. We included both. The base profile requires the publisher, the tracking block and its revision history.

JSON schema validation is only the first gate for CSAF. Section 6.1 of the standard lists mandatory tests that a valid document must not fail, such as checking that every product ID referenced in product_status is defined in the product tree. Use a CSAF validator that implements those tests, not just a schema check.

We also left the subcomponent out of the CSAF version on purpose. To express "elliptic 6.5.2 is a component of example-web-app 1.3.0", you add elliptic as a second product and a product_tree.relationships entry of category default_component_of, which creates a third, combined product ID. That is how CSAF models components, and it is one reason CSAF documents grow quickly.

Step 5: validate all four in CI

Here is the Python we used to check the four examples against their published schemas. It works on any machine with the jsonschema package and local copies of the schema files.

import json
from jsonschema import validators
from referencing import Registry, Resource

def load(path):
    with open(path) as f:
        return json.load(f)

# CycloneDX 1.6 references two sibling schemas by relative name,
# resolved against its $id (http://cyclonedx.org/schema/).
registry = Registry()
for name in ["spdx.schema.json", "jsf-0.82.schema.json"]:
    resource = Resource.from_contents(load(name))
    registry = registry.with_resource(name, resource)
    registry = registry.with_resource("http://cyclonedx.org/schema/" + name, resource)

pairs = [
    ("spdx-json-schema.json", "web-app.vex.spdx3.json"),
    ("openvex_json_schema.json", "web-app.vex.json"),
    ("bom-1.6.schema.json", "web-app.vex.cdx.json"),
    ("csaf_json_schema.json", "web-app.vex.csaf.json"),
]
for schema_path, doc_path in pairs:
    schema = load(schema_path)
    validator = validators.validator_for(schema)(schema, registry=registry)
    errors = list(validator.iter_errors(load(doc_path)))
    print(doc_path, "OK" if not errors else [e.message for e in errors[:3]])

And a GitHub Actions job that runs the format specific tools on every pull request that touches the VEX folder:

name: vex-check
on:
  pull_request:
    paths: ["vex/**"]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "stable"
      - name: OpenVEX
        run: |
          go install github.com/openvex/vexctl@latest
          vexctl validate --strict vex/*.vex.json
      - name: Schema checks for SPDX 3, CycloneDX and CSAF
        run: |
          pip install jsonschema
          python tools/validate_vex.py

Pin the action and tool versions in a real pipeline. The point of the job is that a VEX file is release data: a typo in a justification label silently turns a machine readable claim into one a consumer's tooling ignores.

Step 6: sign and publish

CISA says VEX documents and statements SHOULD be signed, and that the signature must cover all document metadata and every statement. Each format has a natural route:

  • OpenVEX: the vexctl README shows its attest command, with the attach and sign options, writing a signed attestation as a Sigstore bundle and attaching it to a container image through the OCI referrers API.
  • CycloneDX: an embedded JSON Signature Format block, or an external signature over the file.
  • CSAF: the distribution section of CSAF 2.0 includes OpenPGP signature files (Requirement 19) and a public OpenPGP key (Requirement 20) among the requirements provider roles build on.
  • SPDX 3: the VEX classes have no signature property, so sign the serialized file externally, for example with a detached signature or a signed attestation.
vexctl attest --attach --sign web-app.vex.json registry.example.com/example-web-app@sha256:<digest>

Which format fits which publisher

There is no single right answer, and nothing stops a supplier from publishing more than one. This table describes fit by publisher type; it is not a ranking.

PublisherUsually fitsWhy
Open source project or package maintainerOpenVEXSmall files, purl first, easy to keep in the repository, signed attestations attach to release images
Container image or distribution maintainerOpenVEX, or CSAF if you already run an advisory feedStatements per image digest or package version; vexctl filter can apply them to scan results
Device or embedded systems makerCSAFModel numbers, serial numbers, SKUs and product families are first class; fits existing PSIRT advisory processes
Enterprise software vendor with a PSIRTCSAF for advisories, plus CycloneDX or OpenVEX for machine consumersAdvisories and VEX in one feed; customers' tools often consume CycloneDX or OpenVEX directly
Team already shipping CycloneDX SBOMsCycloneDX VEXSame tooling and schema, BOM-Link back to the SBOM, extra states such as false_positive
Team standardised on SPDX, especially SPDX 3SPDX 3 Security profileOne graph for packages, vulnerabilities, scores and VEX; IRIs reference SBOM elements directly
Internal platform team triaging for many product teamsWhatever your scanners and SBOM store already readVEX that tooling ignores helps no one; match the consumer

Two questions settle most cases. First, what do your consumers' tools ingest today? Second, do you need to identify physical products by model or serial number? If the answer to the second is yes, CSAF is hard to beat. If not, the format that matches your SBOM pipeline usually wins. Our posts on which VEX format a vendor should publish and CSAF VEX versus OpenVEX for vendor workflows go further into the vendor side.

When SPDX 3 is the better choice

Of the official open source tools we looked at for this post, vexctl works with OpenVEX and the CycloneDX CLI with CycloneDX; for SPDX 3 VEX we validated with the published JSON schema and SHACL shapes. Check that your consumers' tooling reads SPDX 3 before you choose it. It earns its place when you want vulnerability data, scores and VEX in the same graph as license and build information, when your SBOM pipeline already emits SPDX 3, or when you want to reference SBOM elements by globally unique IRI rather than by a purl that might be ambiguous across rebuilds. It is a weaker fit if your customers' scanners only read OpenVEX or CycloneDX, because converting the graph back to flat statements means resolving every IRI first.

Where Safeguard fits

Safeguard's fit here is on the SBOM and ingestion side, not VEX authoring, and we would rather say that plainly. SBOM generation and analysis is generally available and produces SPDX (JSON, XML and RDF) and CycloneDX (JSON and XML), with more than 30 export formats including CSV, Excel and PDF, and continuous scanning on push, schedule or release. That gives you the product and component identifiers your VEX statements should reference. CSAF and VEX upload is generally available, so supplier statements can sit next to the findings they refer to. Third party risk management, also generally available, lets you onboard vendors and request SBOMs, projects or products from them. The compliance suite, generally available, lists VEX among the supported frameworks alongside EO 14028, NIST SSDF and SLSA. Safeguard does not convert VEX between the four formats described here, and we have not listed which VEX serializations the upload accepts beyond CSAF; ask us before you plan around a specific one.

Common mistakes

  • Pointing the status at the wrong thing. A VEX statement is about the product. Putting the library in the product slot (OpenVEX products, SPDX to, CycloneDX affects) states that the library is not affected, which is usually false and always a different claim.
  • Not affected with no reason. All four formats, and CISA, require a justification or an impact statement. SPDX 3 and OpenVEX schemas will not catch the omission because both fields are optional in cardinality.
  • Mapping protected_at_perimeter to inline mitigations. CISA's inline mitigation label requires protections the user cannot disable. A firewall rule does not meet that bar.
  • Treating false_positive as just another not affected. Carry the reasoning across in an impact statement when you convert out of CycloneDX.
  • Forgetting to bump versions. CISA requires the document version to increase on any change and the statement version to increase on any statement change. OpenVEX statement version defaults to zero and is required once incremented.
  • Timestamps that go backwards. The document's last updated time must be at least as recent as the newest statement, impact or action time inside it.
  • Using the SPDX spec page examples as templates. They omit the security_ prefixes, use an array for suppliedBy, and are written as fragments. Start from the JSON schema and validate.
  • Relying on schema validation alone for CSAF. The mandatory tests in section 6.1 catch broken product references that the schema cannot.
  • Embedding VEX in a released SBOM. Every status change then forces a new SBOM version. Keep VEX separate and link it by identifier unless you have a reason not to.
  • Shipping unsigned VEX. A not affected statement suppresses alerts. An unsigned one lets anyone who can write to the file do that.

FAQ

Does SPDX 2.3 support VEX?

Not as a native structure. SPDX 2.3 can carry security references through external references, but the typed VEX relationships described here arrived with the SPDX 3.0 Security profile.

What is the SPDX 3 equivalent of an OpenVEX statement?

One VexVulnAssessmentRelationship subclass instance: the class sets the status, from is the vulnerability, to lists the products, and security_assessedElement names the subcomponent.

Can one VEX document cover several vulnerabilities?

Yes in all four formats. CISA requires each statement to cover exactly one vulnerability, so a document with several vulnerabilities holds several statements. In SPDX 3 that means several relationships; in CSAF, several entries in vulnerabilities.

Can one statement cover several products?

Yes, as long as the status and other details are correct for every product listed. If one product's status changes, CISA requires a new statement for that subset.

Is CycloneDX "exploitable" the same as VEX "affected"?

It is the usual mapping. CycloneDX defines exploitable as a vulnerability that can be exploited directly or indirectly, while CISA's affected means remediation or mitigation actions are recommended. Pair CycloneDX exploitable with a response and recommendation to meet the CISA action statement rule.

Which VEX format does CISA require?

None. CISA's minimum requirements are format independent and state they do not impose or mandate compliance. They name CSAF, CycloneDX and OpenVEX as formats that can carry VEX; SPDX 3's not affected class cites the same minimum elements.

How do I convert OpenVEX to SPDX 3?

Create a Vulnerability element for each vulnerability name, a package element for each product and subcomponent with the purl as software_packageUrl, then one VEX relationship per statement with the matching class. Justification labels convert by changing snake case to camelCase.

Is CSAF 2.1 out?

As of 23 October 2026 the OASIS server lists CSAF 2.1 committee specification drafts, the latest being csd04 dated 21 October 2026. CSAF 2.0 is the approved OASIS Standard.

Should VEX be signed?

CISA says documents and statements SHOULD be signed and that implementations MUST support current signature mechanisms. Because a not affected statement suppresses alerts downstream, treat signing as required in practice.

Where should I host VEX documents?

Somewhere consumers can find them by product: alongside the SBOM, attached to the release artifact, or in an advisory feed. CISA's document leaves exchange out of scope, so agree the location with your customers.

Fix it without waiting for upstream

A VEX document can tell a customer that a product is affected, but it does not ship the fixed build; someone still has to move the dependency and release. The guidance below is about that second step for the open source components you build and ship, which is where most "affected" statements end up.

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.

3. Gold Registry: start from components that are already fixed

registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingThe Gold scan GitHub Action in CI, plus Safeguard policies and gates
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch (early access)
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would help

None of these demos authors a VEX document. They show the work around one: finding the vulnerable component, fixing it, and giving an assistant controlled access to your findings.

This is a real CLI session. You install the CLI, open the interactive terminal, and ask it to fix the lodash CVE in a repository; it reads the finding and advisory, asks before each command, edits package.json to lodash 4.17.21, runs npm install and npm test with your approval, prints a summary and pushes nothing. That is the step that turns a VEX "affected" statement into a "fixed" one.

Click through it at your own pace, or open it full screen.

This demo sets up the runner on your own machine, approves it with its device code, and then scans from the terminal. Scanning where the code lives is how you get the component inventory that your VEX product and subcomponent identifiers should match.

Click through it at your own pace, or open it full screen.

Here Claude connects to the Safeguard MCP server with OAuth and you choose which tools Claude can use. For VEX triage, that means an assistant can look up the findings behind a draft statement while you decide what it is allowed to touch.

Click through it at your own pace, or open it full screen.

To try it on your own code, download Safeguard or read the CLI documentation.

Quick check

In SPDX 3.0.1, which relationship type must a VexNotAffectedVulnAssessmentRelationship use, and what else must it carry?

It must use doesNotAffect, with the vulnerability in from and the products in to. It must also carry a justificationType or an impactStatement; both are optional in cardinality, but the class page says one of them must be defined to meet the minimum elements for VEX.

What is the CSAF 2.0 equivalent of an OpenVEX statement with status not_affected and justification component_not_present?

The product's ID goes in vulnerabilities[].product_status.known_not_affected, and a vulnerabilities[].flags[] entry with label component_not_present lists the same product ID. The document category must be csaf_vex, and the product must be defined in the product_tree.

Sources

Last reviewed: 23 October 2026. Specifications change; check the linked pages for the current versions.

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.