Security Engineering · Supply-Chain Security

Identity in SBOMs: PURLs, CPEs, and Why Names Do Not Join

An SBOM is only as useful as its keys. Component identity schemes disagree about what a component is, and vulnerability matching fails where they disagree.

A software bill of materials is a list of components with names attached, and the entire value of the list depends on the names. A vulnerability scanner consumes an SBOM by looking up each component in a database of advisories; if the name it sees does not resolve to the same entity the advisory names, the lookup returns nothing and the report says the component is clean. Every failure of SBOM-based vulnerability management that is not a missing database entry is an identity failure, and the identity schemes in circulation were designed for different purposes and do not agree.

The three naming systems in play

CPE, the Common Platform Enumeration, was built for the National Vulnerability Database and its diaspora. A CPE is a colon-delimited string of thirteen fields: cpe:2.3:part:vendor:product:version:update:edition:language:sw_edition:target_sw:target_hw:other. Fields support * for any and - for not-applicable, and each may carry a logical value. The part field distinguishes applications, operating systems, and hardware. CPEs are matched against dictionaries maintained by NIST, and those dictionaries are large, curated, and lag behind what actually ships.

CPE’s structural problem is that it describes a product line, not an artifact. cpe:2.3:a:openssl:openssl:3.0.8:*:*:*:*:*:*:* names a version of OpenSSL but says nothing about which build, which distribution patches, or which language binding embedding it. Debian’s patched OpenSSL 3.0.8 and upstream OpenSSL 3.0.8 share a CPE and do not share vulnerable code. Every organization that runs a distribution’s packages has to maintain an exclusion list to correct for this, and those lists are usually wrong in both directions.

PURL, the Package URL, is the newer scheme and is deliberately artifact-shaped. A PURL has the form pkg:type/namespace/name@version?qualifiers#subpath. The type names an ecosystem (npm, pypi, cargo, golang, oci, maven, and so on), and the rest of the fields are defined per type: for npm, the scope goes in the namespace and the name is the package name; for maven, the namespace is the group and the name is the artifact; for oci, the name is the repository and the version is the digest or tag. Qualifiers such as arch and distro carry the information CPE cannot, which is why pkg:deb/debian/openssl@3.0.8-1?arch=amd64 and pkg:generic/openssl@3.0.8 are distinguishable.

Names within a type must be normalized before representation: npm names are lowercased, PyPI names have runs of -, _, and . replaced by a single -, and Maven coordinates are case-sensitive. The specification requires that the canonical form be used and that consumers attempt a normalization fallback when comparing. This normalization requirement is the specification admitting the central problem in advance: two tools that emit the same package differently will produce SBOMs that fail to join.

SPDX identifiers are a third kind, defined by the SPDX specification for license and element identification. An SPDX document assigns each element an SPDXID local to the document, and packages may also carry PackageDownloadLocation and external references. The SPDXID is unique only within its document, so it cannot be the join key across SBOMs; cross-document identity has to go through an external reference such as a PURL or CPE. Nor does an SBOM resolve anything: it records what a resolution produced, and the constraints that produced it live in the manifests described in how dependency resolvers turn version ranges into a fixed graph. CycloneDX takes the same position, defining a bom-ref that is unique within the BOM and recommending PURL for component identity.

Why identity is not transitive

The practical failure is a three-hop join: an SBOM names a component with a PURL, the advisory database indexes by CPE, and the mapping between the two lives in yet another data set that is maintained by someone else and updated on a different schedule. Each hop is a place where the match can fail silently.

The mapping problem has a precise shape. A PURL names a distributed artifact. A CPE names a product version. Several PURLs can map to one CPE (the amd64 and arm64 builds of the same Debian package), and one PURL can correspond to several CPEs when a project ships multiple products from a single repository. Advisories that describe a vulnerability in a shared library affect every PURL that statically links that library and no PURL that links it dynamically without shipping it. The SBOM records what was bundled; the advisory records what source code was affected. Reconciling those requires either source-level reachability analysis or an explicit statement about linkage, and neither is present in the SBOM by default.

The consequence for scanning is that a negative result from an SBOM-driven scanner means “no advisory matched under this mapping”, not “no vulnerability present”. The gap is measurable in principle and rarely measured: coverage depends on how many components in a given SBOM carry a PURL that appears in the mapping data, and static-linkage cases are systematically undercounted.

An SBOM is a record of a build, not of a deployment

The second structural issue is scope. An SBOM describes what went into an artifact at the time the artifact was produced. A deployed system is a different object: containers add base images, operators install sidecars, and language runtimes load plugins at startup. The deployed composition is the union of the SBOMs of every artifact present, plus anything installed after the fact.

This matters for identity because each artifact’s SBOM is authored by a different producer with a different naming discipline. A base image SBOM may list Debian packages by CPE, an application SBOM may list npm packages by PURL and Python packages by PURL with a pypi type, and a vendored C library may appear as a bare name with a version string. Joining those into one inventory requires normalizing all of them into a common key, and the only scheme designed to be that common key is PURL, which is why SBOM generation guidance converges on it even though the specification does not mandate it.

There is also a versioning subtlety. An SBOM for a container image should identify the image by digest, not tag, because tags are mutable. The same rule applies to components whose versions are not unique: a container pulled by tag latest at two different times may be two different images, and the SBOM record for each must be distinguished by digest. An inventory keyed by tag reports a single component where there were two.

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.5",
  "components": [
    {
      "type": "library",
      "bom-ref": "pkg:npm/%40scope/util@2.3.1",
      "name": "@scope/util",
      "version": "2.3.1",
      "purl": "pkg:npm/%40scope/util@2.3.1",
      "hashes": [{ "alg": "SHA-256", "content": "…" }]
    },
    {
      "type": "library",
      "bom-ref": "pkg:deb/debian/zlib@1:1.2.13.dfsg-1?arch=amd64&distro=debian-12",
      "name": "zlib",
      "version": "1:1.2.13.dfsg-1",
      "purl": "pkg:deb/debian/zlib@1:1.2.13.dfsg-1?arch=amd64&distro=debian-12"
    }
  ]
}

Note the percent-encoding in the npm entry: the @ that begins a scope is encoded because a literal @ inside the path would be read as the version separator. A producer that emits the raw @ produces a string that a strict parser interprets differently, which is how two correct-looking SBOMs end up with non-matching keys.

Dependency graphs versus component lists

Both major formats can express relationships, not just a flat list. CycloneDX has a dependencies array naming each component and its direct dependencies; SPDX has Relationship elements with types such as DEPENDS_ON, CONTAINS, and GENERATED_FROM. The graph is what allows an analysis to answer questions the list cannot: which component introduced a transitive dependency, whether a vulnerable library is reachable from an entry point, and whether a license obligation propagates.

The graph is also where SBOMs from different producers diverge most. Nested builds flatten relationships, vendored code loses its provenance entirely, and generated code has no upstream package to point at. A flat list with correct PURLs is more useful than a graph with invented relationships, and consumers should treat an incomplete graph as a list rather than filling in edges by inference.

What follows for consumers

Three practices follow directly from the identity problem. Emit PURLs with ecosystem-correct normalization and include qualifiers that distinguish architectures and distributions, because the qualifiers are what let a scanner exclude distribution-patched versions correctly. Include digests alongside names, since a digest survives renaming and a name does not. Record the exact artifact digest for anything deployable, because tags and version strings that both refer to mutable objects are not identities at all.

The remaining problem, mapping advisories onto components, is the same problem attestations solve for provenance: rather than comparing names after the fact, have the producer commit to an identity at build time and have the consumer verify that commitment. How that binding works is the subject of provenance attestation and digest binding, and why the component list must itself be signed is covered in keyless signing and transparency logs.

References

  1. Package URL (PURL) specification — package-url project
  2. SPDX Specification — SPDX project, Linux Foundation
  3. Common Platform Enumeration: Naming Specification — NIST IR 7695
  4. CycloneDX Specification — OWASP CycloneDX project