Sachin Chaurasiya

DevSecOps Part 2 of 5 · Software Supply Chain Security

Generate an SBOM with Syft and Read What It Tells You

Run Syft from its container against a small Node.js image, read the CycloneDX output package by package, see where each one came from, learn what an SBOM does not prove, and store it next to the artifact.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
8 min read
Difficulty
intermediate

Reviewed Tested with Syft 1.51.1 (anchore/syft image), ci-demo:1.4.0 image on node:22.20.0-alpine3.22, Docker 29.1, Python 3.14 for reading the JSON

On this page

Overview

The image in this part is the sample application from the CI/CD Engineering path: a Node.js service with one dependency (semver), built with a two-stage Dockerfile on node:22.20.0-alpine3.22. The application’s package-lock.json lists exactly one package. Syft finds 234. Reading the SBOM to find out where the other 233 came from is the whole lesson: an SBOM describes the artifact, not the source, and the artifact is mostly things you did not write.

Everything below was run with Syft 1.51.1 from the anchore/syft image; the counts and package names are from that run. The SBOM lab does the same for a Python image with both CycloneDX and SPDX output and is the place to practise.

Prerequisites

  • Docker; Syft runs from its image, nothing is installed
  • A local image to inventory. The commands use ci-demo:1.4.0, built from the CI/CD path sample app with docker build -t ci-demo:1.4.0 .; any small image works
  • A JSON reader: jq, or Python

Generate the SBOM

Syft can read an image from a registry, from a Docker daemon, or from a saved tarball. For a locally built image the daemon is simplest; mount the socket read-only:

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -v "$PWD:/out" \
  anchore/syft:v1.51.1 ci-demo:1.4.0 \
  -o cyclonedx-json=/out/ci-demo-1.4.0.cdx.json \
  -o table

Two outputs from one scan: a CycloneDX JSON file to keep, and a table to read now. The table starts like this:

NAME                                VERSION      TYPE
@isaacs/cliui                       8.0.2        npm
@isaacs/fs-minipass                 4.0.1        npm
@isaacs/string-locale-compare       1.1.0        npm
@npmcli/agent                       3.0.0        npm
@npmcli/arborist                    8.0.1        npm
...
alpine-baselayout                   3.7.0-r0     apk
alpine-keys                         2.5-r0       apk
alpine-release                      3.22.2-r0    apk
...

216 lines of it. One format is enough for a pipeline; CycloneDX is the one Grype, Cosign and most registries consume. Generate SPDX as well only if a consumer asks for it; the lab shows the flag.

Read the SBOM

The file is JSON with a components array. Count what is in it:

python3 - <<'PY'
import json, collections
d = json.load(open("ci-demo-1.4.0.cdx.json"))
c = d["components"]
print(d["bomFormat"], d["specVersion"], "components:", len(c))
print("by type:", collections.Counter(x["type"] for x in c))
print("by ecosystem:", collections.Counter(x["purl"].split(":")[1].split("/")[0] for x in c if "purl" in x))
PY
CycloneDX 1.7 components: 534
by type: Counter({'file': 299, 'library': 233, 'application': 1, 'operating-system': 1})
by ecosystem: Counter({'npm': 215, 'apk': 18, 'generic': 1})

534 components, but 299 of them are file entries (Syft records the files it inspected as evidence) and one is the operating system. The packages are the 233 libraries plus the application itself: 215 npm, 18 apk, 1 generic (the node binary, identified from its contents rather than from a package manager). The application declared one npm dependency. Where did the other 214 come from?

Syft records a location for every package as a property. Group by the directory:

python3 - <<'PY'
import json, collections
d = json.load(open("ci-demo-1.4.0.cdx.json"))
locs = collections.Counter()
for x in d["components"]:
    if x["type"] != "library": continue
    for p in x.get("properties", []):
        if p["name"].startswith("syft:location:") and p["name"].endswith(":path"):
            v = p["value"]
            locs["/app/node_modules" if v.startswith("/app/node_modules")
                 else "/usr/local/lib/node_modules/npm" if v.startswith("/usr/local/lib/node_modules/npm")
                 else v.rsplit("/", 1)[0]] += 1
            break
for k, n in locs.most_common(): print(f"{n:4d}  {k}")
PY
 211  /usr/local/lib/node_modules/npm
  18  /lib/apk/db
   1  /app
   1  /usr/local/lib/node_modules/corepack
   1  /app/node_modules
   1  /opt/yarn-v1.22.22

211 of the 215 npm packages belong to npm itself. The official Node image ships the npm CLI, corepack and yarn, and each is an npm package with its own dependency tree. (/app is the application’s own package.json, catalogued as a package; the node binary has no package-manager location and is not in this grouping.) The runtime image runs node src/server.js and never invokes any of them, but they are in the filesystem, so they are in the inventory, and (part 3) they are in the vulnerability report. The application contributed semver; the base image contributed everything else.

This is the recurring finding when teams first generate SBOMs, and it is why the inventory has to be read, not just stored. The remedy is in part 3; the SBOM is what made the problem visible.

The metadata block

The top of the file identifies what was scanned and by what:

python3 -c 'import json; d=json.load(open("ci-demo-1.4.0.cdx.json")); m=d["metadata"]; print(m["component"]["name"], m["component"]["version"], m["component"]["type"]); print(d["serialNumber"]); print([t["name"]+" "+t["version"] for t in m["tools"]["components"]])'
ci-demo 1.4.0 container
urn:uuid:87699d55-1655-43f0-a47d-0b7f44a2ceb1
['syft 1.51.1']

serialNumber is unique per SBOM document; metadata.component names the artifact. What the block does not contain, because Syft read a local image by name, is the image digest. Part 4 makes the digest the identity and part 5 binds the SBOM to it; until then the file’s link to the image is the name you gave it.

What the SBOM does not prove

An SBOM is an inventory taken by a tool at a moment. Be precise about its limits before relying on it.

  • It is only as complete as the catalogers. Syft identifies packages from package-manager databases (apk, dpkg, rpm), ecosystem manifests and lockfiles (package.json, requirements.txt, go.sum), and a set of known binaries (node above). A statically linked binary with no metadata, a vendored library copied into the source tree, or a package installed by curl | sh may be missing. Absence from the SBOM is not absence from the image.
  • It describes the filesystem, not what runs. The 211 npm packages are real files in the image. Nothing in the SBOM says whether server.js ever loads them. Reachability is a different analysis.
  • It says nothing about the source. The SBOM of an image built from a compromised repository is a faithful inventory of a compromised image. Source controls are part 1’s first link.
  • It is not signed. A JSON file next to an image can be edited. Part 5 attaches it to the digest as an attestation so that editing it breaks a signature.

Store it with the artifact

The value of an SBOM arrives later, when a CVE is published for a package you may or may not have. With the file kept next to the artifact, the question is a query:

python3 -c 'import json; d=json.load(open("ci-demo-1.4.0.cdx.json")); print([(c["name"], c["version"]) for c in d["components"] if c["name"] in ("tar", "minimatch")])'
[('minimatch', '9.0.5'), ('tar', '6.2.1'), ('tar', '7.4.3'), ('tar', '7.4.3')]

Two copies of tar 7.4.3 at different paths, one 6.2.1, one minimatch: the answer, with versions, in under a second.

Without it, the same answer requires pulling every deployed image and scanning it again. So the SBOM belongs where the artifact is:

  • As a pipeline artifact, produced by the same job that builds the image, named by the image digest, kept for the artifact’s retention period. The CI/CD Engineering path covers artifact retention.
  • In the registry, attached to the image as an attestation (part 5), which is what registries, admission controllers and cosign tree can find without knowing your pipeline.

Keep both. The pipeline copy is easy to search across every build; the registry copy is the one a verifier trusts.

Security Considerations

  • Run Syft against the image you will deploy, after the final build stage, not against the build stage or the source tree; the runtime image is the one that matters.
  • Pin the Syft version (anchore/syft:v1.51.1) so catalogers do not change between builds without a reviewed bump; a new cataloger can add or remove hundreds of components and make two SBOMs of the same image differ.
  • The Docker socket mount gives the Syft container control of the daemon. Read-only mounts limit it; scanning a saved tarball (docker save ci-demo:1.4.0 | syft …) or a registry reference avoids the socket entirely.

Troubleshooting

SymptomCauseFix
Far more packages than the application declaresBase image ships its own tooling (npm, pip, apt caches)Expected; read the locations, then remove what the runtime does not need (part 3)
A known library is missingVendored, statically linked, or installed without a package managerCheck syft … -o syft-json for file evidence; consider a manual entry or a different base
permission denied on the Docker socketRootless Docker or a socket path that differsScan docker save output or a registry reference instead
Two SBOMs of the same image differDifferent Syft versions or cataloger configPin the version; compare with syft diff semantics in mind, not byte for byte

Conclusion

One declared dependency, 234 packages, 211 of them from a package manager the runtime never runs. The SBOM did not fix that; it made it visible, which is the precondition. Part 3 scans this inventory, fixes what it finds, and shows the count fall.

References

Keep reading