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,VexFixedVulnAssessmentRelationshipandVexUnderInvestigationVulnAssessmentRelationship. Each is locked to one relationship type:affects,doesNotAffect,fixedInorunderInvestigationFor. - The status values line up cleanly across all four formats. CycloneDX names them differently (
exploitable,resolved,in_triage) and addsfalse_positive; CSAF adds extra product status buckets such asfirst_fixedandrecommended. - 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-refvalues or BOM-Links, and SPDX 3 uses IRIs forspdxIdwith 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.
| Date | Publication or version | What it settled for VEX |
|---|---|---|
| April 2022 | CISA, VEX Use Cases | Common scenarios, including multiple products and vulnerabilities in one document |
| June 2022 | CISA, VEX Status Justifications | The five "not affected" justification labels used by SPDX 3, CSAF and OpenVEX |
| 18 November 2022 | OASIS CSAF 2.0 approved as an OASIS Standard | Profile 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 response | CycloneDX VEX; the same six states and nine justifications are still in the 1.6 and 1.7 schemas |
| April 2023 | CISA, 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 specification | Lightweight JSON-LD format written to meet the April 2023 minimum requirements |
| SPDX 3.0.1 (current model) | SPDX Security profile | Four VEX relationship classes plus CVSS v2, v3, v4, EPSS, SSVC and exploit catalog assessments |
| 21 October 2026 | CSAF 2.1, fourth committee specification draft (csd04) on the OASIS docs server | Still 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 class | Required relationshipType | VEX status | Extra property |
|---|---|---|---|
VexAffectedVulnAssessmentRelationship | affects | affected | actionStatement (required, 1..1), actionStatementTime |
VexNotAffectedVulnAssessmentRelationship | doesNotAffect | not_affected | justificationType, impactStatement, impactStatementTime (each 0..1, but one of justification or impact statement must be present) |
VexFixedVulnAssessmentRelationship | fixedIn | fixed | none beyond the shared ones |
VexUnderInvestigationVulnAssessmentRelationship | underInvestigationFor | under_investigation | none 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 on | Cardinality | What it holds |
|---|---|---|---|
from | Relationship | 1..1 | The Vulnerability element |
to | Relationship | 1..* | The products the status applies to |
security_assessedElement | VulnAssessmentRelationship | 0..1 | The component inside the product where the vulnerability was detected |
suppliedBy | VulnAssessmentRelationship | 0..1 | The agent who supplied the assessment |
security_publishedTime | VulnAssessmentRelationship | 0..1 | When the assessment was published |
security_modifiedTime | VulnAssessmentRelationship | 0..1 | When it last changed |
security_withdrawnTime | VulnAssessmentRelationship | 0..1 | When it was withdrawn |
security_vexVersion | VexVulnAssessmentRelationship | 0..1 | Version of the VEX statement |
security_statusNotes | VexVulnAssessmentRelationship | 0..1 | How the status was determined |
security_justificationType | VexNotAffected | 0..1 | One of five justification values |
security_impactStatement | VexNotAffected | 0..1 | Free text explaining why not affected |
security_impactStatementTime | VexNotAffected | 0..1 | When the impact statement was issued |
security_actionStatement | VexAffected | 1..1 | What the consumer should do |
security_actionStatementTime | VexAffected | 0..1 | When 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 status | SPDX 3.0.1 | CSAF 2.0 (product_status) | OpenVEX v0.2.0 | CycloneDX 1.6 (analysis.state) |
|---|---|---|---|---|
| not_affected | VexNotAffectedVulnAssessmentRelationship, doesNotAffect | known_not_affected | not_affected | not_affected |
| affected | VexAffectedVulnAssessmentRelationship, affects | known_affected (also first_affected, last_affected) | affected | exploitable |
| fixed | VexFixedVulnAssessmentRelationship, fixedIn | fixed (also first_fixed) | fixed | resolved or resolved_with_pedigree |
| under_investigation | VexUnderInvestigationVulnAssessmentRelationship, underInvestigationFor | under_investigation | under_investigation | in_triage |
| no CISA equivalent | none | recommended | none | false_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 label | SPDX 3.0.1 VexJustificationType | CSAF 2.0 flag label | OpenVEX | Nearest CycloneDX 1.6 |
|---|---|---|---|---|
| Component_not_present | componentNotPresent | component_not_present | component_not_present | No direct value; usually the component is simply absent from the BOM, or use detail |
| Vulnerable_code_not_present | vulnerableCodeNotPresent | vulnerable_code_not_present | vulnerable_code_not_present | code_not_present |
| Vulnerable_code_not_in_execute_path | vulnerableCodeNotInExecutePath | vulnerable_code_not_in_execute_path | vulnerable_code_not_in_execute_path | code_not_reachable |
| Vulnerable_code_cannot_be_controlled_by_adversary | vulnerableCodeCannotBeControlledByAdversary | vulnerable_code_cannot_be_controlled_by_adversary | vulnerable_code_cannot_be_controlled_by_adversary | No direct value; requires_configuration or requires_environment sometimes fit |
| Inline_mitigations_already_exist | inlineMitigationsAlreadyExist | inline_mitigations_already_exist | inline_mitigations_already_exist | protected_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 element | Requirement | SPDX 3.0.1 | CSAF 2.0 | OpenVEX v0.2.0 | CycloneDX 1.6 |
|---|---|---|---|---|---|
[doc_id] | MUST | SpdxDocument spdxId | document.tracking.id | @id | serialNumber |
[doc_version] | MUST | No dedicated document version property in the model pages we read; encode it in the document IRI or use security_vexVersion per statement | document.tracking.version plus revision_history | version | version |
[author] | MUST | CreationInfo.createdBy (agents) | document.publisher.name and namespace | author | metadata.authors |
[author_role] | MAY | No direct field; agent type and relationships such as coordinatedBy cover some cases | document.publisher.category | role | No direct field |
[tooling] | MAY | CreationInfo.createdUsing | document.tracking.generator | tooling | metadata.tools |
[doc_time_first_issued] | MUST | CreationInfo.created | document.tracking.initial_release_date | timestamp | metadata.timestamp |
[doc_time_last_updated] | MUST | New created value on each regenerated document | document.tracking.current_release_date | last_updated | metadata.timestamp on the new BOM version |
[statement_id] | SHOULD | The relationship's spdxId | No per statement ID; each vulnerabilities[] entry plus product IDs acts as the statement | statement @id | vulnerabilities[].bom-ref |
[statement_version] | MUST | security_vexVersion | Inherited from tracking.version | statement version | Inherited from BOM version |
[statement_time_first_issued] | MUST | security_publishedTime (else inherited) | Inherited, or vulnerabilities[].release_date | statement timestamp (else inherited) | analysis.firstIssued |
[statement_time_last_updated] | MUST | security_modifiedTime | Inherited from current_release_date | statement last_updated | analysis.lastUpdated |
[product_id] | MUST | to list of element IRIs | Product IDs in product_status, defined in product_tree | products[].@id and identifiers | affects[].ref |
[subcomponent_id] | MAY | security_assessedElement | product_tree.relationships such as default_component_of | products[].subcomponents | The component holding the vulnerability, by bom-ref |
[supplier] | SHOULD | suppliedBy on the package element | Vendor branch in product_tree.branches | statement supplier | component.supplier |
[vul_id] | MUST | Vulnerability with externalIdentifier | cve or ids | vulnerability.name (plus aliases) | vulnerabilities[].id and source |
[vul_description] | MUST (include or reference) | Vulnerability.description or externalRef | notes with category description | vulnerability.description or @id link | description, references, advisories |
[status] | MUST | Class plus relationshipType | product_status bucket | status | analysis.state |
[justification] | SHOULD for not_affected | security_justificationType | flags[].label | justification | analysis.justification |
[impact_statement] | MUST if no justification | security_impactStatement | threats[] with category impact | impact_statement | analysis.detail |
[action_statement] | MUST for affected | security_actionStatement | remediations[] | action_statement | analysis.response plus recommendation |
[status_notes] | MAY | security_statusNotes | notes | status_notes | analysis.detail |
| Signatures | SHOULD sign; implementations MUST support | No 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 attestation | Embedded 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.
| Format | Primary product identifier | Other identifiers supported | Product sets and ranges |
|---|---|---|---|
| SPDX 3.0.1 | spdxId IRI of an element | purl via software_packageUrl; externalIdentifier types including cpe22, cpe23, packageUrl, swid, swhid, gitoid; hashes via verifiedUsing | One relationship can list many products in to; no version range syntax in the VEX classes themselves |
| CSAF 2.0 | Product ID string (for example CSAFPID-0001) defined in product_tree | product_identification_helper with cpe, hashes, model numbers, purl, SBOM URLs, serial numbers, SKUs and generic URIs | Product groups, branches by vendor, product name and version, and version ranges in the branch tree |
| OpenVEX v0.2.0 | products[].@id, an IRI; purl recommended | identifiers map (purl, cpe22, cpe23 and others), hashes map keyed by IANA hash names | Many products per statement; sets expressed by listing |
| CycloneDX 1.6 | bom-ref of a component in the same BOM, or a BOM-Link to another BOM | Component purl, cpe, swid, hashes; affects[].versions with version or range and an affected, unaffected or unknown status | Version ranges per affected entry |
The practical consequences:
- OpenVEX is the most direct to match. If the
@idis 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. - 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.
- CycloneDX is strongest when the VEX travels with the BOM. A
bom-refis 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. - 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.
| Placement | SPDX 3.0.1 | CSAF 2.0 | OpenVEX | CycloneDX |
|---|---|---|---|---|
| Embedded in the SBOM | Yes; VEX relationships live in the same graph as the packages | No | No (but OpenVEX defines encapsulating documents that can carry its statements) | Yes; vulnerabilities[] inside the same BOM |
| Separate document linked by identifier | Yes; separate SpdxDocument referencing element IRIs | Yes; sbom_urls helper can point to an SBOM | Yes; purl or IRI references | Yes; a VEX only BOM using BOM-Link references |
| Attached as a signed attestation | Possible with any format | Possible | Common; vexctl attest writes signed attestations | Possible |
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-appversion 1.3.0, purlpkg:npm/example-web-app@1.3.0 - Subcomponent:
elliptic6.5.2, purlpkg: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.
| Publisher | Usually fits | Why |
|---|---|---|
| Open source project or package maintainer | OpenVEX | Small files, purl first, easy to keep in the repository, signed attestations attach to release images |
| Container image or distribution maintainer | OpenVEX, or CSAF if you already run an advisory feed | Statements per image digest or package version; vexctl filter can apply them to scan results |
| Device or embedded systems maker | CSAF | Model numbers, serial numbers, SKUs and product families are first class; fits existing PSIRT advisory processes |
| Enterprise software vendor with a PSIRT | CSAF for advisories, plus CycloneDX or OpenVEX for machine consumers | Advisories and VEX in one feed; customers' tools often consume CycloneDX or OpenVEX directly |
| Team already shipping CycloneDX SBOMs | CycloneDX VEX | Same tooling and schema, BOM-Link back to the SBOM, extra states such as false_positive |
| Team standardised on SPDX, especially SPDX 3 | SPDX 3 Security profile | One graph for packages, vulnerabilities, scores and VEX; IRIs reference SBOM elements directly |
| Internal platform team triaging for many product teams | Whatever your scanners and SBOM store already read | VEX 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, SPDXto, CycloneDXaffects) 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_perimeterto inline mitigations. CISA's inline mitigation label requires protections the user cannot disable. A firewall rule does not meet that bar. - Treating
false_positiveas 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
versiondefaults 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 forsuppliedBy, 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 situation | What to use |
|---|---|
| A fixed version exists | Autonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version |
| You need to know today whether you are affected | Gold Open Source lookup and "Am I affected?" checker, free and without login |
| You want vulnerable versions to stop shipping | The Gold scan GitHub Action in CI, plus Safeguard policies and gates |
| Upstream has no fix yet, or will not ship one | A custom Gold request; fork and patch (early access) |
| You want to start clean on new projects | Pull 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
- SPDX 3.0.1, Security profile overview
- SPDX 3.0.1, VexNotAffectedVulnAssessmentRelationship
- SPDX 3.0.1, VexAffectedVulnAssessmentRelationship
- SPDX 3.0.1, VexFixedVulnAssessmentRelationship
- SPDX 3.0.1, VexUnderInvestigationVulnAssessmentRelationship
- SPDX 3.0.1, VexVulnAssessmentRelationship
- SPDX 3.0.1, VulnAssessmentRelationship
- SPDX 3.0.1, Vulnerability
- SPDX 3.0.1, VexJustificationType
- SPDX 3.0.1, RelationshipType
- SPDX 3.0.1, Model and serializations
- SPDX 3.0.1 JSON schema
- SPDX 3.0.1 model and SHACL shapes
- SPDX, Security area of interest
- OASIS, Common Security Advisory Framework Version 2.0
- OASIS, CSAF 2.0 JSON schema
- OASIS, CSAF 2.1 document directory
- OpenVEX specification v0.2.0
- OpenVEX JSON schema
- vexctl README
- CycloneDX, VEX capability
- CycloneDX, Vulnerability exploitability use case
- CycloneDX, BOM-Link
- CycloneDX 1.6 JSON schema
- CycloneDX 1.7 JSON schema
- CycloneDX CLI README
- CISA, Minimum Requirements for Vulnerability Exploitability eXchange (VEX), April 2023
- CISA, VEX Status Justifications, June 2022
- CISA, VEX Use Cases, April 2022
Last reviewed: 23 October 2026. Specifications change; check the linked pages for the current versions.