Supply Chain Security

What Is a Software Bill of Materials (SBOM)?

HuntRule Team · · 8 min read

A blank paper label on the side of a sealed shipping carton, lit by a single narrow beam in a dark warehouse
On this page

The first question after CVE-2021-44228 went public was not how the JNDI lookup works. It was which of our systems ship log4j-core. Most teams answered it with a fleet-wide find / -name "log4j*.jar", a spreadsheet, and several days of pinging application owners. An SBOM exists so that the same question takes a jq invocation.

That is the whole value proposition, and it is worth being blunt about the scope. An SBOM is an inventory of the components inside a piece of software. It answers "where is X" across an estate, fast. The other claims made for SBOMs, that they prevent supply chain attacks or prove software is safe, are much weaker. The rest of this post covers the formats, the matching problem that breaks automation in practice, and the two things an SBOM will never tell you.

What is actually inside

The NTIA minimum elements, published on 12 July 2021 under Executive Order 14028, define seven data fields per component: supplier name, component name, component version, other unique identifiers, dependency relationship, author of the SBOM data, and a timestamp. CISA circulated a revised draft in 2025 and took public comment until 3 October 2025. The seven fields remain the floor.

A single component in CycloneDX JSON looks like this:

{
  "type": "library",
  "publisher": "Apache Software Foundation",
  "group": "org.apache.logging.log4j",
  "name": "log4j-core",
  "version": "2.14.1",
  "purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
  "hashes": [
    { "alg": "SHA-256", "content": "<sha256 of the jar as shipped>" }
  ]
}

The purl field is the one that matters for hunting. Everything else is metadata a human reads.

SPDX and CycloneDX

Two formats have survived. Both are worth knowing because you will receive both from vendors.

SPDX

CycloneDX

Steward

Linux Foundation

OWASP and Ecma International (TC54)

Standard

ISO/IEC 5962:2021 (standardises 2.2.1)

ECMA-424

Current version

3.0

1.7

Serialisations

JSON-LD, Turtle, N-Triples, RDF/XML

JSON, XML, Protobuf

Strongest at

licence and file-level provenance

security tooling, dependency graph, VEX

SPDX came out of licence compliance and still carries that shape. It is good at per-file detail, licence expressions and legal provenance, and SPDX 3.0 splits the model into profiles so security data is no longer bolted on. CycloneDX was designed for security use from the start. It carries a proper dependency graph, pedigree records, service inventories and VEX documents in the same schema.

For detection work, prefer CycloneDX. If your vendors send SPDX, convert rather than maintain two pipelines.

syft convert ./sboms/api.spdx.json -o cyclonedx-json=./sboms/api.cdx.json

The identifier problem

Automated matching fails on identifiers more often than on anything else. There are two competing schemes and they do not agree.

purl: pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1
CPE:  cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*

A purl is scheme:type/namespace/name@version?qualifiers#subpath, derived from how the package ecosystem itself names things. It knows that log4j-core and log4j-api are different artifacts. CPE does not. NVD's CPE configuration for CVE-2021-44228 names the product log4j, so naive CPE matching flags hosts that carry only log4j-api, which was never vulnerable to the JNDI lookup. That is a false positive class you will meet on day one.

CPE also has vendor names nobody guesses. OpenSSH lives under cpe:2.3:a:openbsd:openssh, not openssh:openssh. Getting the vendor string wrong produces silence, which is the worse failure mode.

purl is better but not clean. Distro packages carry type-specific qualifiers, so the same Debian curl can appear as pkg:deb/debian/curl@7.88.1-10 from one generator and pkg:deb/debian/curl@7.88.1-10?arch=amd64&distro=debian-12 from another. String equality across an estate assembled from multiple tools will miss rows. Match on type, namespace and name, then compare versions separately.

Two blank paper inventory tags tied to the same sealed crate, only one of them lit

Where the SBOM comes from

An SBOM generated by parsing pom.xml or requirements.txt is a statement of intent. Version ranges resolve at build time, transitive dependencies resolve at build time, and the artifact that ships can differ from what the manifest declares. Lockfiles narrow the gap without closing it.

Build-time SBOMs record what actually got packaged. Generate during the build, attach the result to the artifact, and the SBOM describes bytes rather than wishes.

CVE-2024-3094 is the case that settles the argument. The liblzma backdoor in xz 5.6.0 and 5.6.1 lived in the release tarballs and not in the upstream git repository. A source-time SBOM taken from a git checkout and a build-time SBOM taken from the shipped tarball describe different bytes under the same version string.

A container image needs its own SBOM. Cataloging the repository finds your application dependencies and none of the apk or dpkg packages that arrived in the base layer, which is where most of your CVE count lives. Scan the image by digest.

# application source, misses the base image entirely
syft ./my-project -o cyclonedx-json=./sboms/app.cdx.json

# the built image, pinned by digest
syft registry:ghcr.io/acme/api@sha256:0e3f... -o cyclonedx-json=./sboms/api.cdx.json

Querying the estate

Store one SBOM per artifact, keyed by image digest or build ID, in a directory or object store. When the next advisory lands you grep the estate instead of the fleet.

for f in sboms/*.cdx.json
do
  echo "$f"
  jq -r '.components[]?
         | select(.purl // "" | startswith("pkg:maven/org.apache.logging.log4j/log4j-core"))
         | "  " + .purl' "$f"
done

For known CVEs rather than one named component, feed the SBOM to a scanner instead of writing the matching yourself.

grype sbom:./sboms/api.cdx.json

That query returns a host list. It does not return an answer.

What an SBOM will not tell you

Presence is not reachability. log4j-core on disk in an application that never calls a logger with attacker-controlled input is not exploitable. The documented Log4Shell mitigation was deleting one class from the jar, org/apache/logging/log4j/core/lookup/JndiLookup.class, and an SBOM generated after that deletion still reports log4j-core 2.14.1. Shading cuts the other way. Classes relocated into an uber jar under rewritten package names defeat name-based matching entirely. VEX exists to carry the reachability verdict alongside the inventory, and it is only as good as whoever performed the analysis.

The second gap is the one most SBOM articles skip. An SBOM records identity and version. It says nothing about whether that version is trustworthy.

SUNBURST shipped inside SolarWinds.Orion.Core.BusinessLayer.dll, signed with a valid SolarWinds certificate, in Orion versions 2019.4 HF5 through 2020.2.1 HF1. An SBOM of an affected build lists that DLL, at its correct version, with the correct hash for that build. Every field is accurate. xz has the same shape. 5.6.1 was the genuine upstream version number, published by the project's own maintainer. No inventory query distinguishes a compromised legitimate version from a clean one, because at the level an SBOM operates there is no difference to see.

Closing that gap needs a hash compared against a hash from an independently produced build, which means reproducible builds and signed attestations, and someone who actually performs the comparison. Most SBOM consumption today stops at ingestion.

Coverage is the third gap. Statically linked binaries, vendored source copied into a repository, and ecosystems the cataloger has no parser for all produce silent omissions.

From inventory to detection

The SBOM tells you where to look. Behavioural detection tells you whether anything happened there. For Log4Shell the exploitation step is T1190 and the payload almost always forks a shell (T1059.004), so the hunt on the host list looks like this:

title: Java process spawning a shell or network utility
id: 6c8a1f1e-3a2b-4d7c-9f5a-1b2c3d4e5f60
status: experimental
description: Detects a JVM spawning a shell or download utility, the common second stage after JNDI or deserialisation exploitation of a Java service.
logsource:
    category: process_creation
    product: linux
detection:
    parent:
        ParentImage|endswith:
            - '/java'
            - '/javaw'
    child:
        Image|endswith:
            - '/sh'
            - '/bash'
            - '/curl'
            - '/wget'
            - '/nc'
            - '/python3'
    condition: parent and child
falsepositives:
    - CI build agents and Jenkins nodes, which shell out constantly
    - Elasticsearch and other JVM services that invoke helper scripts at startup
    - Application code that legitimately calls Runtime.exec
level: high

What it catches: a forked second stage from an exploited JVM. What it misses: payloads that stay inside the JVM, load a class and execute in-process, or open a socket in Java without ever forking. On a build fleet the false positive rate makes this a hunting query rather than an alert. Scope it to the hosts the SBOM query returned and it becomes usable.

For a compromised-but-legitimate version (T1195.001 and T1195.002) there is no inventory signal at all. SUNBURST was found through network behaviour, the DGA subdomains under avsvmcloud[.]com resolved by a process with no business making those lookups. That is the general shape. Inventory scopes the hunt, behaviour closes it. The same split runs through the rest of supply chain security.

Summary

An SBOM is an inventory, and it is very good at the one job of telling you which artifacts contain a given component. It is not a vulnerability verdict, not a reachability analysis, and not a trust signal. Generate at build time, keyed to the artifact digest, in CycloneDX. Match on purl parts rather than whole strings, and expect CPE-based matching to produce both false positives and silence. Then hunt on the hosts it returns, because a compromised legitimate version looks exactly like a clean one in every field an SBOM records.

Generate:  syft <target> -o cyclonedx-json=./sboms/<artifact>.cdx.json
Convert:   syft convert <in> -o cyclonedx-json
Scan:      grype sbom:./sboms/<artifact>.cdx.json
Key by:    image digest or build ID, never image tag
Store:     one SBOM per artifact, immutable, retained as long as the artifact runs
Collect:   Linux process_creation (auditd execve or Sysmon for Linux Event ID 1)
Techniques: T1190, T1059.004, T1195.001, T1195.002

Hunting queries for the behavioural half of this, including JVM child process patterns and anomalous outbound resolution, are in the catalog under Linux rules.

Related articles