When you fix a vulnerability, your customers need to answer one question fast: does this affect the versions we run? If your advisory is a web page, a person has to read it and compare version numbers by hand. CSAF, the Common Security Advisory Framework, is an OASIS standard for publishing the same information as JSON, so that question can be answered by software.
This guide is for product security and engineering teams at software vendors. It covers what CSAF 2.0 requires, a minimal advisory that passes the official schema, how customers discover your advisories automatically, and two small scripts to check your own work.
The short answer
CSAF 2.0 is a JSON format, standardised by OASIS in November 2022, for security advisories and VEX statements, together with rules for publishing them so tools can find them. A document declares one of five profiles, lists products in a product_tree, and states each product's status for each vulnerability (for example known_affected or fixed). Publishing well means a provider-metadata.json file that tools can discover, ideally at /.well-known/csaf/provider-metadata.json and through a CSAF: line in your security.txt, plus a predictable folder layout for the documents themselves.
Why bother now
Three practical reasons.
First, the people who consume advisories are increasingly machines. Vulnerability scanners and SBOM tools can match a CSAF document's product tree against what a customer runs and close out findings that don't apply. A PDF or HTML advisory needs a human in the loop.
Second, it scales your own work. One JSON document can serve your website, your customer portal and every downstream tool, instead of maintaining each separately.
Third, the ecosystem has settled on it. The OASIS technical committee is chaired by people from CISA and Cisco, CISA publishes its own advisories as CSAF on GitHub, and large vendors run full CSAF feeds. If you sell into regulated customers, expect "do you publish CSAF?" to start appearing in security questionnaires. To be clear, the EU Cyber Resilience Act requires manufacturers to share and publicly disclose information about fixed vulnerabilities, but it does not mandate a format. CSAF is a sensible way to meet that duty, not a legal requirement in itself.
A note on versions: CSAF 2.1 exists as a Committee Specification Draft (the third draft was dated 11 September 2026), but it is not yet an OASIS Standard. Publish 2.0 today.
The five profiles
Every CSAF document sets /document/category, which selects a profile. Each profile adds required fields on top of the base.
| Profile | /document/category | Use it for | Adds on top of the base |
|---|---|---|---|
| CSAF Base | anything not reserved below | Catch-all | Core document metadata only |
| Security incident response | csaf_security_incident_response | Responding to a breach or incident | A descriptive note and an external reference |
| Informational advisory | csaf_informational_advisory | Guidance that isn't about a specific vulnerability | Notes and references, and no vulnerabilities section |
| Security advisory | csaf_security_advisory | A vulnerability and its remediation | product_tree, and for each vulnerability notes and product_status |
| VEX | csaf_vex | Stating which products are or are not affected | product_tree, plus at least one of fixed, known affected, known not affected |
The VEX profile has a rule that catches people out. Every product listed as known_not_affected needs a matching impact statement explaining why, either as a flag or as a threat with category impact (mandatory test 6.1.27.9). "Not affected" without a reason isn't valid VEX.
A minimal security advisory that validates
This is the smallest useful csaf_security_advisory: one product with two versions, one vulnerability, one fix. It passes the official CSAF 2.0 JSON schema.
{
"document": {
"category": "csaf_security_advisory",
"csaf_version": "2.0",
"publisher": {
"category": "vendor",
"name": "Example Corp Product Security",
"namespace": "https://example.com"
},
"title": "Example Gateway: authentication bypass in the admin API",
"tracking": {
"id": "EXAMPLE-SA-2026-0042",
"status": "final",
"version": "1",
"initial_release_date": "2026-10-13T09:00:00Z",
"current_release_date": "2026-10-13T09:00:00Z",
"revision_history": [
{"number": "1", "date": "2026-10-13T09:00:00Z", "summary": "Initial release."}
]
}
},
"product_tree": {
"branches": [{
"category": "vendor", "name": "Example Corp",
"branches": [{
"category": "product_name", "name": "Example Gateway",
"branches": [
{"category": "product_version", "name": "4.2.0",
"product": {"product_id": "EG-4.2.0", "name": "Example Gateway 4.2.0"}},
{"category": "product_version", "name": "4.2.1",
"product": {"product_id": "EG-4.2.1", "name": "Example Gateway 4.2.1"}}
]
}]
}]
},
"vulnerabilities": [{
"title": "Authentication bypass in the admin API",
"cwe": {"id": "CWE-287", "name": "Improper Authentication"},
"notes": [
{"category": "description", "text": "A crafted header lets an unauthenticated request reach admin API routes."}
],
"product_status": {
"known_affected": ["EG-4.2.0"],
"fixed": ["EG-4.2.1"]
},
"remediations": [
{"category": "vendor_fix", "details": "Upgrade to Example Gateway 4.2.1.", "product_ids": ["EG-4.2.0"]}
]
}]
}
A few things to notice:
product_treeis the backbone. Products are defined once, with stableproduct_idvalues, and every other section refers to those IDs. Version branches let a tool match "Example Gateway 4.2.0" to what is installed.trackingis versioned. Each update to the advisory adds arevision_historyentry and bumpsversionandcurrent_release_date. Tools use these to notice that an advisory changed.- CVE is optional. If the vulnerability has a CVE, add
"cve": "CVE-YYYY-NNNN"to the vulnerability. Many vendors publish before a CVE is assigned.
The filename is not free choice either. Section 5.1 of the standard says to lower-case /document/tracking/id, replace any run of characters outside a-z, 0-9, + and - with a single underscore, and add .json. So EXAMPLE-SA-2026-0042 must be published as example-sa-2026-0042.json.
Check a document before you publish it
This script validates a document against the official schema, prints the filename the standard requires, and adds the two security-advisory profile checks the schema itself doesn't enforce. It needs pip install jsonschema.
#!/usr/bin/env python3
"""Schema-check a CSAF 2.0 document and print the filename the standard requires.
Usage: python3 csaf_check.py advisory.json (needs: pip install jsonschema)"""
import json, re, sys, urllib.request
from jsonschema import Draft202012Validator
SCHEMA = "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf_json_schema.json"
schema = json.load(urllib.request.urlopen(SCHEMA, timeout=20))
doc = json.load(open(sys.argv[1]))
errors = sorted(Draft202012Validator(schema).iter_errors(doc), key=lambda e: list(e.path))
for e in errors:
print("SCHEMA", "/" + "/".join(map(str, e.path)), e.message[:120])
# Section 5.1: lower-case the tracking ID, collapse anything outside [+\-a-z0-9] to "_", add .json
filename = re.sub(r"[^+\-a-z0-9]+", "_", doc["document"]["tracking"]["id"].lower()) + ".json"
print("filename:", filename)
# Two profile checks for csaf_security_advisory (section 4.4) that the schema alone does not enforce
profile = []
if doc["document"]["category"] == "csaf_security_advisory":
for i, v in enumerate(doc.get("vulnerabilities", [])):
for field in ("notes", "product_status"):
if field not in v:
profile.append(f"vulnerabilities[{i}] is missing {field}")
for p in profile:
print("PROFILE", p)
print(f"schema errors: {len(errors)}, profile errors: {len(profile)}")
sys.exit(1 if errors or profile else 0)
On the example above:
filename: example-sa-2026-0042.json
schema errors: 0, profile errors: 0
On a copy with tracking.status and the vulnerability notes deleted:
SCHEMA /document/tracking 'status' is a required property
filename: example-sa-2026-0042.json
PROFILE vulnerabilities[0] is missing notes
schema errors: 1, profile errors: 1
A schema check is the floor, not the ceiling. The standard defines many more mandatory tests (for example, that every product ID you reference is actually defined). For full conformance, run the open-source csaf_validator from the gocsaf/csaf project, or author documents in Secvisogram, a web editor that validates as you type.
Publishing so tools can find you
A valid document on a random web page is only half the job. CSAF defines roles that describe how complete your publishing is:
| Role | What it means in practice |
|---|---|
| CSAF publisher | Valid documents, correct filenames, served over TLS, TLP:WHITE documents freely accessible |
| CSAF provider | All of the above, plus access control for TLP:AMBER and TLP:RED, a provider-metadata.json, at least one discovery method, and a predictable distribution (folders or ROLIE feeds) |
| CSAF trusted provider | All of the above, plus hash files and OpenPGP signatures for each document and a published public key |
For a provider, the metadata file is what a tool finds first. The standard gives three discovery routes, and you need at least one:
- a
CSAF:field insecurity.txtpointing toprovider-metadata.json(the field is in the IANA registry) - the file served at
https://<your-domain>/.well-known/csaf/provider-metadata.json - a host named
csaf.data.security.<your-domain>that serves it
For the documents themselves, the simplest layout is directory-based. Put each document in a folder named after the year of its initial release, for example 2026/example-sa-2026-0042.json. Next to those folders, keep an index.txt listing every file and a changes.csv listing each file with its current_release_date, newest first. ROLIE feeds are the alternative for teams that already run Atom-style feeds.
See it working: discover a real provider
This script follows the same discovery routes a tool would, and summarises what it finds. It uses only the Python standard library.
#!/usr/bin/env python3
"""Find a vendor's CSAF provider metadata and summarise it.
Usage: python3 csaf_discover.py redhat.com"""
import json, sys, urllib.request
def get(url):
req = urllib.request.Request(url, headers={"User-Agent": "csaf-discover/1.0"})
with urllib.request.urlopen(req, timeout=20) as r:
return r.read().decode("utf-8", "replace")
domain = sys.argv[1]
candidates = []
try: # requirement 8: CSAF field in security.txt
for line in get(f"https://{domain}/.well-known/security.txt").splitlines():
if line.lower().startswith("csaf:"):
candidates.append(line.split(":", 1)[1].strip())
except Exception:
pass
candidates += [f"https://{domain}/.well-known/csaf/provider-metadata.json", # requirement 9
f"https://csaf.data.security.{domain}/provider-metadata.json"] # requirement 10 (path assumed)
for url in candidates:
try:
meta = json.loads(get(url))
except Exception as e:
print(f" no metadata at {url} ({type(e).__name__})")
continue
pub = meta.get("publisher", {})
print(f"found: {url}")
print(f"canonical: {meta.get('canonical_url')}")
print(f"publisher: {pub.get('name')} ({pub.get('category')}), namespace {pub.get('namespace')}")
print(f"role: {meta.get('role')}")
print(f"updated: {meta.get('last_updated')}")
print(f"keys: {len(meta.get('public_openpgp_keys', []))} OpenPGP key(s)")
for d in meta.get("distributions", []):
if "directory_url" in d:
base = d["directory_url"].rstrip("/") + "/"
try:
n = len(get(base + "index.txt").splitlines())
print(f"directory: {base} ({n} documents in index.txt)")
except Exception as e:
print(f"directory: {base} (index.txt not readable: {type(e).__name__})")
if "rolie" in d:
for feed in d["rolie"].get("feeds", []):
print(f"ROLIE feed: {feed.get('url')} ({feed.get('tlp_label')})")
break
else:
print(f"{domain}: no CSAF provider metadata found")
Run against Red Hat at the time of writing, it found the metadata through Red Hat's security.txt and reported:
found: https://security.access.redhat.com/data/csaf/v2/provider-metadata.json
canonical: https://security.access.redhat.com/data/csaf/v2/provider-metadata.json
publisher: Red Hat Product Security (vendor), namespace https://www.redhat.com
role: csaf_trusted_provider
updated: 2024-07-10T14:00:00.0Z
keys: 1 OpenPGP key(s)
directory: https://security.access.redhat.com/data/csaf/v2/advisories/ (29968 documents in index.txt)
directory: https://security.access.redhat.com/data/csaf/v2/vex/ (71329 documents in index.txt)
That is a trusted provider with separate directories for advisories and VEX documents, both with tens of thousands of entries. Your first version can be one advisory in a 2026/ folder with a three-line index.txt. The structure is the same.
Mistakes to avoid
Hand-editing JSON. Generate CSAF from the same source as your human-readable advisory, or author it in an editor that validates. A hand-typed product ID that doesn't match the product tree silently breaks matching for every customer.
Changing a document without a revision. If you correct an affected version range, add a revision_history entry and bump version. Tools cache documents, and an unversioned change may never reach them.
Product names that don't match what customers run. The product tree is only useful if its names and versions line up with what scanners detect. Where you can, add identifiers such as a CPE or a package URL (purl) through the product's product_identification_helper.
Using redirects. Requirement 6 says redirects should not be used, and if they are unavoidable, only HTTP header redirects are allowed. Some clients don't follow anything else.
When CSAF is more than you need
It is fair to say CSAF is heavy for a small open-source library. If your project's advisories already go through GitHub Security Advisories, they flow into the GitHub Advisory Database and OSV, which most scanners consume, and that may be enough. CSAF earns its keep when you ship versioned products to customers who run their own vulnerability management: appliances, on-prem software, firmware, anything where "which versions are affected" is the question every customer asks after every advisory.
For those vendors, the step that pays off first is simple. Publish one valid security advisory in the right place, point your security.txt at your metadata, and your next advisory can be answered by your customers' tools instead of their inboxes.