CVE packageURL: Join CVE JSON 5.2 Records to SBOM purls

CVE Record Format 5.2.0 added an optional packageURL field to affected entries. We counted it across all 400,445 published records: 1.6% carry one, 12 CNAs produce them, and a third of GitHub purls are not in canonical lowercase. Here is what the field means, how to normalize it, and a tested Python join against a CycloneDX SBOM.

Black-and-white close-up of a bank of grey metal mailboxes, each door marked with a box number and a four-digit unit number
Photo by Andretti Brown on Unsplash

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:

  1. 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.
  2. The versions array still decides. A purl match finds the records about your package. Whether your version is affected comes from versions and defaultStatus, read with the record’s versionType.
  3. 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 versions field.” 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.

MeasureCount
Published records with a CNA packageURL6,284 (1.57%)
affected entries with a packageURL7,324
CNAs that use it12
Purls that embed a version0
Purls with qualifiers76
Entries with both packageURL and cpes863

Who writes it

CNA (short name)Records
VulnCheck4,949
apache455
EEF387
CPANSec199
openjs147
mitre61
bcorg51
RTI31
Four others1 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:github identifies 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 as pkg:npm/... or pkg:pypi/..., so a pkg:github record will not match it directly. You need a mapping from repository to registry package, or a fallback on collectionURL and packageName.
  • pkg:software-id appears 72 times (for example pkg: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):

MonthRecords with a purl
March 2026256
June 2026447
August 2026976
September 20261,155
October 20262,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:github entries (31.9%) contain uppercase letters, for example pkg:github/Finsys/dockhand.
  • 9 pkg:pypi entries 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/gitpython and 6 use pkg: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 purlRecords matchedAffectedUnaffected
pkg:npm/[email protected]17107
pkg:pypi/[email protected]34628
pkg:maven/org.bouncycastle/[email protected]?type=jar23023

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 claimSourceStatus
packageURL definition, “MUST NOT include a version”, examples; identifier and versions/defaultStatus requirementsCVE Record Format schema (main)Verified
Rationale, CVE Services validation, versions only in versions fieldRFD 0002: Supporting Package URLs in CVEVerified
v5.2.0 tag commit dated 3 September 2025cve-schema tag v5.2.0Qualified (commit date, not an announcement)
All counts (records, CNAs, types, normalization issues, monthly figures)cvelistV5 release cve_2026-11-05_0400Z, counted with PythonVerified (our count)
CVE-2026-110180 affected fragmentCVE-2026-110180 recordVerified
GitHub and PyPI normalization rules; npm case sensitivitypurl-spec type definitionsVerified
purl type is case-insensitive, lowercase canonical formpurl-spec, Clause 5Verified
software-id type decision status “proposed”, dated 21 October 2026purl-spec decision 001Verified

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