Scope. This is a specification explainer with measurements from public data. We read the CVE Record Format schema on the CVEProject/cve-schema main branch (default dataVersion 5.2.0), RFD 0002 in the same repository, and the Package URL specification in the package-url/purl-spec repository. We counted fields across every record in the cvelistV5 release cve_2026-11-05_0400Z (the “all CVEs at midnight” bundle for 5 November 2026) with Python 3.13, and ran the join script below against that data. The script is illustrative.
The short answer
Since CVE Record Format 5.2.0, each entry in a record’s affected array can carry a packageURL:
{
"vendor": "fast-uri",
"product": "fast-uri",
"packageURL": "pkg:npm/fast-uri",
"defaultStatus": "unaffected",
"versions": [
{"versionType": "semver", "status": "affected", "version": "2.4.2", "lessThan": "2.4.8"}
]
}
That fragment is from CVE-2026-110180 (trimmed to its first version range). Three rules decide how you use it:
- No version in the purl. The schema says the Package URL “MUST NOT include a version”. Strip the version from your SBOM purl before you look it up.
- The versions array still decides. A purl match finds the records about your package. Whether your version is affected comes from
versionsanddefaultStatus, read with the record’sversionType. - It is optional and still rare. 1.6% of published records carry it today, from 12 CNAs. Treat it as a fast, precise path where it exists, and keep your CPE and name-based matching for the rest.
What the schema says
The field definition in the 5.2.0 schema:
“A Package URL, a unified URL specification for identifying packages hosted by known package hosts. The Package URL MUST NOT include a version.”
The schema types it as a URI and lists examples such as pkg:deb/debian/curl?arch=i386&distro=jessie, pkg:npm/%40angular/animation and pkg:golang/google.golang.org/genproto#googleapis/api/annotations. So qualifiers and subpaths are allowed; only the version is out.
packageURL does not satisfy the identifier requirement on its own. The product definition still requires either vendor and product or collectionURL and packageName, plus either versions or defaultStatus. In our scan, every one of the 7,324 entries with a purl also had one of those identifier pairs.
Why it was added
RFD 0002, “Supporting Package URLs in CVE”, is the design record. It argues that the existing identifier fields “are of limited value for automated applicability matching” and that Package URLs “are better suited than CPEs for capturing distinctions between different distributions of particular open source software”.
Two design choices in the RFD matter to consumers:
- Validation is mostly outside the schema. The JSON schema only checks that the value is a URI; the RFD says it “would be further constrained by CVE Services” to ensure the values are valid purls, because encoding purl rules in a regular expression “would be excessive and difficult to maintain”. Do not assume the schema has validated purl syntax for you.
- Versions live in one place. “All version information should only be included within the existing
versionsfield.” That keeps one version-matching code path for consumers.
The cve-schema repository tags v5.2.0; the tagged commit is dated 3 September 2025. Records that declare dataVersion 5.1 cannot use the field. In our snapshot, 144,619 published records (36.1%) declared 5.2 and 255,826 declared 5.1.
What the data shows
We read every record in the 5 November 2026 bundle: 400,445 published and 18,890 rejected. Only CNA containers carried the field; no ADP container had a packageURL.
| Measure | Count |
|---|---|
Published records with a CNA packageURL | 6,284 (1.57%) |
affected entries with a packageURL | 7,324 |
| CNAs that use it | 12 |
| Purls that embed a version | 0 |
| Purls with qualifiers | 76 |
Entries with both packageURL and cpes | 863 |
Who writes it
| CNA (short name) | Records |
|---|---|
| VulnCheck | 4,949 |
| apache | 455 |
| EEF | 387 |
| CPANSec | 199 |
| openjs | 147 |
| mitre | 61 |
| bcorg | 51 |
| RTI | 31 |
| Four others | 1 each |
VulnCheck accounts for 78.8% of records with a purl. If you build matching around this field, most of your hits will come from one CNA’s conventions, so test against its records.
Which purl types
The most common types across the 7,324 entries: github 2,371, npm 1,729, pypi 805, maven 533, golang 498, composer 314, hex 309, cargo 242, cpan 210, otp 82, software-id 72, nuget 40, gem 31, generic 31.
Two of those need care:
pkg:githubidentifies a source repository, not a registry package. VulnCheck wrote 1,917 of the GitHub entries and EEF 413. Your SBOM will usually describe the same software aspkg:npm/...orpkg:pypi/..., so apkg:githubrecord will not match it directly. You need a mapping from repository to registry package, or a fallback oncollectionURLandpackageName.pkg:software-idappears 72 times (for examplepkg:software-id/erlang.org/otp), but the purl-spec decision record that registers it, dated 21 October 2026, still has the status “proposed”. Expect parsers to reject it until the type is registered.
How fast it is growing
By the month each record was first published (as the records stand today, so later updates are included):
| Month | Records with a purl |
|---|---|
| March 2026 | 256 |
| June 2026 | 447 |
| August 2026 | 976 |
| September 2026 | 1,155 |
| October 2026 | 2,000 |
October’s 2,000 are 11.7% of the 17,058 records first published that month.
Normalize before you join
The purl specification makes the type case-insensitive with lowercase as the canonical form, and some types go further. The type definitions say a GitHub namespace and name are “not case sensitive and shall be lowercased”, and a PyPI name “shall be lowercased and underscore _ replaced with a dash -”. npm names are case-sensitive.
CVE records do not always follow those rules:
- 756 of the 2,371
pkg:githubentries (31.9%) contain uppercase letters, for examplepkg:github/Finsys/dockhand. - 9
pkg:pypientries have uppercase letters and 24 contain an underscore. - GitPython is a concrete case. In the 34 records we found for it, 28 use
pkg:pypi/gitpythonand 6 usepkg:pypi/GitPython. An exact string join misses one set or the other, depending on how your SBOM tool spells it.
So normalize both sides the same way before comparing: lowercase the type; lowercase github and pypi paths; replace _ with - for pypi; drop the version and qualifiers from the SBOM purl. Leave npm, Maven and other case-sensitive types alone.
A tested join
Two scripts. The first builds an index from the cvelistV5 bundle; the second joins a CycloneDX SBOM to it and applies the versions ranges.
#!/usr/bin/env python3
"""build_index.py: index CNA affected[] entries that carry packageURL,
keyed by a normalized purl (no version, no qualifiers)."""
import json, sys, zipfile
def norm(purl):
core = purl.split("#")[0].split("?")[0]
if "@" in core.rsplit("/", 1)[-1]: # drop a version (SBOM side)
core = core.rsplit("@", 1)[0]
scheme, rest = core.split(":", 1)
ptype, _, path = rest.partition("/")
ptype = ptype.lower()
if ptype in ("github", "pypi"): # case-insensitive types per purl-spec
path = path.lower()
if ptype == "pypi":
path = path.replace("_", "-")
return f"pkg:{ptype}/{path}"
def main(zip_path, out_path):
z = zipfile.ZipFile(zip_path)
with open(out_path, "w") as out:
for n in z.namelist():
if not n.endswith(".json") or "/CVE-" not in n:
continue
rec = json.loads(z.read(n))
meta = rec["cveMetadata"]
if meta.get("state") != "PUBLISHED":
continue
for a in rec["containers"].get("cna", {}).get("affected", []):
if a.get("packageURL"):
out.write(json.dumps({"key": norm(a["packageURL"]), "cve": meta["cveId"],
"defaultStatus": a.get("defaultStatus"), "versions": a.get("versions", [])}) + "\n")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
#!/usr/bin/env python3
"""join_sbom.py: join CycloneDX component purls to the CVE packageURL index, then apply versions[] ranges.
Illustrative: only semver-like versionType values are compared; everything else is flagged for review."""
import json, sys
from packaging.version import Version, InvalidVersion
from build_index import norm
def status_for(ver, entry):
try:
v = Version(ver)
except InvalidVersion:
return "review: unparseable version"
for r in entry["versions"]:
if r.get("versionType") not in ("semver", "maven", "python", None):
return f"review: versionType={r.get('versionType')}"
try:
lo = Version(r["version"]) if r["version"] not in ("0", "*") else Version("0")
if "lessThan" in r:
inside = lo <= v < Version(r["lessThan"])
elif "lessThanOrEqual" in r:
inside = lo <= v <= Version(r["lessThanOrEqual"])
else:
inside = v == lo
except InvalidVersion:
return "review: unparseable range"
if inside:
return r["status"]
return entry.get("defaultStatus") or "unknown"
def main(index_path, sbom_path):
index = {}
for line in open(index_path):
e = json.loads(line)
index.setdefault(e["key"], []).append(e)
for c in json.load(open(sbom_path))["components"]:
for e in index.get(norm(c["purl"]), []):
print(f'{c["purl"]}\t{e["cve"]}\t{status_for(c["version"], e)}')
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
Test run
python3 build_index.py cves.zip purl-index.jsonl took about 30 seconds and wrote 7,324 lines, one per entry. We then ran python3 join_sbom.py purl-index.jsonl sbom.cdx.json (packaging 26.3) with a three-component CycloneDX 1.6 SBOM:
| SBOM purl | Records matched | Affected | Unaffected |
|---|---|---|---|
pkg:npm/[email protected] | 17 | 10 | 7 |
pkg:pypi/[email protected] | 34 | 6 | 28 |
pkg:maven/org.bouncycastle/[email protected]?type=jar | 23 | 0 | 23 |
GitPython 3.1.62 comes out affected by the six records whose ranges end at lessThan: 3.2.0, and unaffected by the 28 whose fixes landed at or before 3.1.62. Without normalization, the SBOM spelling GitPython would have matched 6 records and missed 28. The ?type=jar qualifier on the Maven purl is dropped before lookup.
What this does not handle: versionType values such as custom, git or distribution schemes (it flags them for review), changes arrays inside version ranges, purl types that need a repository-to-package mapping, and the order sensitivity of overlapping ranges. The CVE affected versions guide covers those rules in detail.
When the CVE has no purl
For the other 98% of records, you still need the older paths. The cpeApplicability explainer covers CPE-based matching, and collectionURL plus packageName is often enough to build a purl yourself: https://www.npmjs.com/package/x with packageName x is pkg:npm/x. Record which path produced each match, because a purl match and a name match deserve different confidence.
A match is only the start of the patch decision. In our vulnerability prioritization workflow, the purl join is the “does this apply to us” step; KEV, EPSS and SSVC decide how fast. If your supplier also ships VEX, apply it after the join; our CSAF VEX guide shows how to resolve its product IDs to purls.
Limits of this article
The counts describe one snapshot (the 5 November 2026 midnight bundle); CNAs update records, so the numbers will move. Month counts use each record’s original datePublished, so a purl added later to an old record is counted in the month the record was first published. We checked purl spelling only for the github and pypi rules; other types have their own normalization rules we did not test. We did not check how CVE Services validates purls at submission time. The join script uses Python’s packaging version ordering, which is close to, but not the same as, every ecosystem’s own ordering.
Sources and verification
| Important claim | Source | Status |
|---|---|---|
| packageURL definition, “MUST NOT include a version”, examples; identifier and versions/defaultStatus requirements | CVE Record Format schema (main) | Verified |
| Rationale, CVE Services validation, versions only in versions field | RFD 0002: Supporting Package URLs in CVE | Verified |
| v5.2.0 tag commit dated 3 September 2025 | cve-schema tag v5.2.0 | Qualified (commit date, not an announcement) |
| All counts (records, CNAs, types, normalization issues, monthly figures) | cvelistV5 release cve_2026-11-05_0400Z, counted with Python | Verified (our count) |
| CVE-2026-110180 affected fragment | CVE-2026-110180 record | Verified |
| GitHub and PyPI normalization rules; npm case sensitivity | purl-spec type definitions | Verified |
| purl type is case-insensitive, lowercase canonical form | purl-spec, Clause 5 | Verified |
| software-id type decision status “proposed”, dated 21 October 2026 | purl-spec decision 001 | Verified |
Frequently asked questions
What is packageURL in a CVE record?
It is an optional field on each object in the affected array of a CVE JSON 5.2 record. It holds a Package URL (purl) such as pkg:npm/fast-uri that identifies the affected package. The schema forbids a version inside it; the affected versions are stated in the same entry's versions array.
Which CVE schema version added packageURL?
CVE Record Format 5.2.0. The proposal is RFD 0002, Supporting Package URLs in CVE, in the CVEProject/cve-schema repository, and the field is present in the 5.2.0 schema. Records that declare dataVersion 5.1 cannot carry it.
Can I match a CVE to my SBOM using only the purl?
You can find candidate records that way, but you still have to evaluate the versions array and defaultStatus to know whether your version is affected. The CVE purl has no version, so a purl match only says the record is about your package.
Why does my purl not match the purl in a CVE record?
Most often because of case or normalization differences (pkg:pypi/GitPython against pkg:pypi/gitpython, or uppercase GitHub owners), because your SBOM purl has qualifiers like arch or distro, or because the CNA used a different purl type, such as pkg:github instead of the package registry type.
CveCve Json 5PurlPackage UrlSbomVulnerability ManagementVersion MatchingAppSec
