Your customers run grype or trivy against the container image you ship and open tickets for every CVE the scan finds. Most of those CVEs are in code paths you do not use, or in components the vulnerability's real attack vector cannot reach. A VEX (Vulnerability Exploitability eXchange) document is the formal way to tell those customers "CVE-XXXX-YYYYY is present in the SBOM but not exploitable in our product" with a machine-readable status. This tutorial walks through producing a CSAF 2.0 VEX profile document, filling it out with real status justifications, validating it against the schema, and publishing it for consumers to consume automatically.
Prerequisites: csaf-tool 1.0+ or the Python csaf_tool package, jq, an SBOM for your product, and a static hosting location such as an S3 bucket or a GitHub Pages site.
Time to complete: About 2 hours for the first document, under 30 minutes per subsequent release.
What is VEX and which format should I publish?
VEX communicates, for each CVE in your product's SBOM, one of four statuses: not_affected, affected, fixed, or under_investigation. The industry has converged on two formats: CSAF 2.0 with the VEX profile (JSON, broad tool support) and OpenVEX (lightweight JSON, rapidly growing support). CycloneDX also supports inline VEX statements inside the SBOM, which is convenient when your consumer already pulls your SBOM.
For B2B distribution to enterprise consumers, publish CSAF 2.0. For direct attachment to container SBOMs, OpenVEX or CycloneDX-embedded is simpler. This tutorial uses CSAF 2.0 because it carries the most structured metadata and it is what most vulnerability management platforms expect.
The key status to get right is not_affected. CSAF defines five allowed justifications: component_not_present, vulnerable_code_not_present, vulnerable_code_not_in_execute_path, vulnerable_code_cannot_be_controlled_by_adversary, and inline_mitigations_already_exist. The justification is what turns VEX from "trust us" into "trust this evidence".
How do I start a CSAF document from an SBOM?
Install csaf-tool and generate the document skeleton from your product SBOM:
pip install csaf-tool==1.4.2
csaf-tool --sbom sbom/app-v2.4.0.cdx.json \
--vendor "Acme Corp" \
--product "Acme API" \
--version "2.4.0" \
--output vex/app-v2.4.0-vex.json
The tool reads the SBOM's component tree, builds the CSAF product tree, and leaves the vulnerabilities array empty. You fill that in per-CVE.
Open the generated document and inspect the header:
jq '.document | {category, csaf_version, title, publisher, tracking}' \
vex/app-v2.4.0-vex.json
Expected fields include category: "csaf_vex", csaf_version: "2.0", a publisher with a namespace (commonly your company's main website), and a tracking section with a unique ID. Fix any empty fields the tool did not infer, especially the publisher namespace, which consumers use to authenticate the document's origin.
How do I populate each vulnerability entry correctly?
For each CVE present in your scan output, add an entry with a clear status and justification. The skeleton for a not_affected entry:
{
"cve": "CVE-2024-28849",
"notes": [
{
"category": "description",
"title": "follow-redirects redirects sensitive headers",
"text": "follow-redirects 1.15.5 does not strip Authorization headers on cross-origin redirects."
}
],
"product_status": {
"known_not_affected": ["CSAFPID-0001"]
},
"threats": [
{
"category": "impact",
"details": "Acme API does not perform cross-origin redirects through follow-redirects; all outbound HTTP clients are configured with maxRedirects=0 and an explicit host allowlist.",
"product_ids": ["CSAFPID-0001"]
}
],
"flags": [
{
"label": "vulnerable_code_not_in_execute_path",
"product_ids": ["CSAFPID-0001"]
}
]
}
The CSAFPID-0001 reference is the product identifier from the document's product tree. Build these entries incrementally as you confirm each status. For under_investigation, set the status and leave the flags out; consumers will see you have acknowledged the CVE and are working on it.
How do I validate the document against the schema?
CSAF 2.0 is strict, and an invalid document will be rejected by downstream tooling. Validate with the official validator:
pip install csaf-validator-lib
csaf-validator validate vex/app-v2.4.0-vex.json
Expected clean output:
Document: vex/app-v2.4.0-vex.json
Schema validation: PASS
Profile validation (csaf_vex): PASS
Mandatory tests: PASS (17/17)
Optional tests: PASS (12/12)
If the validator reports a missing field, the error message points to the JSON path. Common failures are a missing tlp classification in the document.distribution block and an unresolvable product_id in a vulnerability entry. Fix each one and rerun until the output is clean.
How do I publish the document so consumers can find it?
CSAF defines a discovery protocol called the Trusted Provider metadata. At minimum you need three files at stable URLs on your own domain:
https://acme.example/.well-known/csaf/provider-metadata.jsonhttps://acme.example/.well-known/csaf/index.txthttps://acme.example/.well-known/csaf/white/2024/app-v2.4.0-vex.json
The provider-metadata.json describes your CSAF feed, the index.txt lists each document, and the actual VEX documents live under TLP-classified directories (white, green, amber, red). A minimal provider metadata:
{
"canonical_url": "https://acme.example/.well-known/csaf/provider-metadata.json",
"distributions": [
{
"directory_url": "https://acme.example/.well-known/csaf/white/",
"rolie": {
"feeds": [
{
"summary": "Acme CSAF VEX feed",
"tlp_label": "WHITE",
"url": "https://acme.example/.well-known/csaf/white/feed.json"
}
]
}
}
],
"publisher": {
"category": "vendor",
"name": "Acme Corp",
"namespace": "https://acme.example"
}
}
Serve these over HTTPS with a valid TLS certificate matching the namespace. Consumer tools like csaf-downloader and most enterprise vulnerability platforms will walk the feed automatically.
How do I integrate VEX production into my release pipeline?
Make VEX generation a release-gate step rather than a separate project. Add a make vex target that runs against the release SBOM and a stored triage database:
vex:
csaf-tool --sbom sbom/$(APP)-$(VERSION).cdx.json \
--triage-db .triage/triage.yaml \
--vendor "Acme Corp" --product "$(APP)" --version "$(VERSION)" \
--output vex/$(APP)-$(VERSION)-vex.json
csaf-validator validate vex/$(APP)-$(VERSION)-vex.json
The triage database is a YAML file per component that encodes the per-CVE determinations your security team has made. It is the real source of truth: csaf-tool just reformats it into CSAF JSON at release time.
# .triage/triage.yaml
- cve: CVE-2024-28849
status: not_affected
justification: vulnerable_code_not_in_execute_path
evidence: "maxRedirects=0 in lib/http.go line 42"
When a new release cuts, updating the triage database is the only manual step. The VEX document regenerates automatically.
How Safeguard Helps
Safeguard generates VEX documents automatically from the reachability analysis it runs on every SBOM, selecting the correct CSAF justification based on whether the vulnerable function is actually called from your entry points. Griffin AI drafts the threats narrative text in the consumer-ready language you just edited by hand, leaving only a review step for your security team. The CSAF feed hosts on your domain and refreshes on every release, and policy gates ensure that a released product always ships with a matching published VEX document. What this tutorial does for one release manually, Safeguard runs continuously for every product and every customer.