Generate CSAF VEX¶
Use vexcalibur generate --format csaf to write a CSAF 2.0 JSON document with
the csaf_vex profile. CycloneDX remains the default when --format is absent.
CSAF identifies the organization responsible for a document. Before you run the command, choose a tracking ID and title, confirm the publisher name and category, and use an absolute namespace URL controlled by that publisher.
Prerequisites¶
Before you begin:
Install Git, Python 3.10 or newer, and the
uvversion in.tool-versions.Clone this repository at version 0.3.0 or later.
Open a Bash-compatible shell in the repository root.
Confirm that
/tmpis writable, or replace the example output path.
The offline example needs no service credentials. Dependency installation may contact your configured Python package index.
Generate from local inputs¶
Install the locked dependencies from the repository root:
uv sync --frozen
The example below reads committed fixtures. It does not contact GitHub or an OSV service.
uv run --frozen vexcalibur generate \
tests/fixtures/sbom/cyclonedx-json-simple.json \
--offline \
--findings-file tests/fixtures/findings/all-analysis-states.json \
--format csaf \
--csaf-version 2.0 \
--csaf-document-id ACME-VEX-2026-001 \
--csaf-document-title "ACME component exploitability assessment" \
--csaf-publisher-name "ACME Product Security" \
--csaf-publisher-namespace https://security.example.test \
--csaf-publisher-category vendor \
--csaf-document-status final \
--timestamp 2026-07-15T00:00:00Z \
--output /tmp/acme-vex-2026-001.json
The command should exit with status 0 and print nothing. It writes five
vulnerability entries covering two versioned products.
The output basename is not arbitrary. Vexcalibur derives it from the tracking
ID according to the CSAF filename rule. ACME-VEX-2026-001 therefore requires
acme-vex-2026-001.json.
Inspect the result¶
Check the profile, publisher claim, first revision, products, and vulnerability count:
uv run --frozen python - <<'PY'
import json
from pathlib import Path
document = json.loads(Path("/tmp/acme-vex-2026-001.json").read_text())
metadata = document["document"]
assert metadata["category"] == "csaf_vex"
assert metadata["csaf_version"] == "2.0"
assert metadata["publisher"]["name"] == "ACME Product Security"
assert metadata["tracking"]["id"] == "ACME-VEX-2026-001"
assert metadata["tracking"]["version"] == "1"
assert metadata["tracking"]["revision_history"][0]["number"] == "1"
assert len(document["product_tree"]["full_product_names"]) == 2
assert len(document["vulnerabilities"]) == 5
print("generated CSAF 2.0 VEX")
PY
You should see generated CSAF 2.0 VEX.
Repository tests run the generated contract through the pinned OASIS schema and the complete mandatory-test suite. See the CSAF output reference for the exact pins and the limits of schema-only validation.
Choose document metadata¶
Every CSAF document needs an identity and publisher claim:
Option |
What to supply |
|---|---|
|
A stable tracking ID chosen by the publisher; line terminators are rejected. |
|
A human-readable title for this assessment. |
|
The organization responsible for the document. |
|
A normalized absolute HTTP(S) URL controlled by that publisher. |
|
|
|
|
Vexcalibur does not accept translator as a publisher category. The command
produces a new assessment; it does not translate an existing CSAF document.
Use ASCII RFC 3986 syntax for the publisher namespace. Write internationalized hosts in IDNA form and percent-encode non-ASCII path characters.
The first generated document always has tracking version 1 and one revision
history entry numbered 1 with summary Initial version.. The selected
timestamp supplies the initial release, current release, first revision, and
generator dates. Vexcalibur does not create later revisions in this increment.
Supply CSAF evidence¶
CSAF’s VEX profile requires structured evidence for affected and unaffected
products. Put that evidence in the local finding instead of asking the
renderer to infer it from analysis_detail.
Analysis state |
CSAF product status |
Required fields |
|---|---|---|
|
|
|
|
|
|
|
|
None |
|
|
|
|
|
|
For an exploitable product, provide both the action and its machine-readable kind:
{
"id": "CVE-2026-0002",
"component_ref": "pkg:npm/minimist@0.0.8",
"analysis_state": "exploitable",
"analysis_detail": "The affected feature is reachable.",
"action_statement": "Upgrade minimist to version 1.2.8 or later.",
"remediation_category": "vendor_fix"
}
Accepted remediation categories are mitigation, no_fix_planned,
none_available, vendor_fix, and workaround.
For an unaffected product, explain the impact explicitly:
{
"id": "CVE-2026-0005",
"component_ref": "component:django",
"analysis_state": "not_affected",
"analysis_detail": "The affected configuration is disabled.",
"impact_statement": "The deployment does not enable the affected configuration."
}
Both false_positive and not_affected become CSAF known_not_affected.
That mapping loses status precision, so Vexcalibur records the original state
in the vulnerability notes.
Change the inventory or finding source¶
CSAF uses the same inventory and finding sources as the other output formats. Keep the CSAF metadata options, then choose one source mode:
Task |
Change the offline example this way |
|---|---|
Query a private OSV-compatible service |
Remove |
Query public OSV with approved inventory |
Remove |
Fetch a GitHub SBOM and keep local findings |
Remove the input path and |
OSV findings enter the domain as in_triage. They become CSAF
under_investigation entries.
Warning:
--allow-public-osvsends package URLs and versions tohttps://api.osv.dev. CSAF publisher metadata does not change this data-sharing boundary.
See the CycloneDX generation guide for private mirror and GitHub authentication examples.
Name a file from its tracking ID¶
When --output writes a file, derive its basename this way:
Lowercase the document tracking ID.
Replace each run matching
[^+\-a-z0-9]+with one underscore. Existing underscores are replaced and adjacent ones collapse.Append
.json.
For example, ACME VEX:2026/001 becomes acme_vex_2026_001.json. An output
path with another basename is rejected. Standard output has no filename, so
the filename rule does not apply when --output is absent.
Resolve common failures¶
... required with --format csaf means one or more document or publisher
options are missing. Supply all five required values. A must not be empty
error means a supplied value contains no usable text.
requires an action_statement or requires remediation_category means an
exploitable finding lacks structured remediation evidence. Add both fields
or correct the analysis state.
requires an impact_statement means a false_positive or not_affected
finding lacks an explicit impact. Add the field or correct the state.
requires fixed_version means a resolved finding does not confirm the fixed
product version. Set it to the exact version in the emitted product package
URL.
must include a version means the matched component has no version in its
package URL or inventory field. Add a precise component version before making
a CSAF assertion.
An output-filename error means the basename does not match the tracking ID. Apply the filename steps above; changing directories does not affect the required basename.
A contradictory-status error means the input gives one vulnerability and product more than one effective CSAF product status. Correct the assessment so the pair has one status. Multiple same-status records may preserve distinct provenance or evidence and are grouped.
The command writes a local JSON document. It does not read CSAF, convert an existing VEX document, create a trusted-provider feed, sign the result, add distribution or TLP policy, publish it, or revise an earlier document.