# 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.

- URL: https://firewallsync.com/posts/cve-packageurl-purl-cve-json-5-2-affected-sbom-matching/
- Site: FirewallSync (https://firewallsync.com/)
- Topic: AppSec & APIs
- Tags: cve, cve json 5, purl, package url, sbom, vulnerability management, version matching, appsec
- Published: 2026-11-05
- Author: Craig Leslie

## Key takeaways

- packageURL is an optional field on each affected[] product in CVE Record Format 5.2.0. The schema says it "MUST NOT include a version"; versions stay in the versions array.
- It does not replace the identifier pair. Every affected entry still needs vendor and product, or collectionURL and packageName. packageURL sits alongside them.
- In the cvelistV5 snapshot of 5 November 2026, 6,284 of 400,445 published records (1.6%) had a packageURL in the CNA container, from 12 CNAs. VulnCheck produced 78.8% of them. No ADP container carried one.
- Adoption is rising fast: 2,000 of the 17,058 records first published in October 2026 (11.7%) carry one. None of the 7,324 purls we found embedded a version.
- Normalize before you join. 756 of 2,371 pkg:github purls (31.9%) used uppercase letters the purl spec says should be lowercased, and GitPython appears as both pkg:pypi/GitPython and pkg:pypi/gitpython across records.



**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`:

```json
{
  "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`.

| 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: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):

| 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: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.

```python
#!/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])
```

```python
#!/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/fast-uri@2.4.5` | 17 | 10 | 7 |
| `pkg:pypi/GitPython@3.1.62` | 34 | 6 | 28 |
| `pkg:maven/org.bouncycastle/bcprov-jdk18on@1.86?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](/posts/cve-affected-versions-lessthan-defaultstatus-versiontype/) 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](/posts/cve-cpeapplicability-cpe-match-nodes-operators-version-ranges/) 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](/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/), 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](/posts/csaf-vex-product-tree-product-status-purl-sbom-matching/) 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)](https://github.com/CVEProject/cve-schema/blob/main/schema/CVE_Record_Format.json) | Verified |
| Rationale, CVE Services validation, versions only in versions field | [RFD 0002: Supporting Package URLs in CVE](https://github.com/CVEProject/cve-schema/blob/main/rfds/0002-supporting-package-urls.md) | Verified |
| v5.2.0 tag commit dated 3 September 2025 | [cve-schema tag v5.2.0](https://github.com/CVEProject/cve-schema/releases/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](https://github.com/CVEProject/cvelistV5/releases/tag/cve_2026-11-05_0400Z), counted with Python | Verified (our count) |
| CVE-2026-110180 affected fragment | [CVE-2026-110180 record](https://github.com/CVEProject/cvelistV5/blob/main/cves/2026/110xxx/CVE-2026-110180.json) | Verified |
| GitHub and PyPI normalization rules; npm case sensitivity | [purl-spec type definitions](https://github.com/package-url/purl-spec/tree/main/types) | Verified |
| purl type is case-insensitive, lowercase canonical form | [purl-spec, Clause 5](https://github.com/package-url/purl-spec/blob/main/docs/specification/standard/Clause-5-Package-URL-Specification.md) | Verified |
| software-id type decision status "proposed", dated 21 October 2026 | [purl-spec decision 001](https://github.com/package-url/purl-spec/blob/main/docs/decisions/001-Register-software-id-PURL-type.md) | 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.


Image credit: Photo by [Andretti Brown](https://unsplash.com/photos/gray-steel-locker-with-numbers-Ul7DGBXznD8) on Unsplash
