Generate an SBOM from a container image with Syft
Install Syft, scan an image or a directory, choose an output format, and wire it into CI so every build produces one.
The goal is one file: every package inside a container image, with versions, in a format another tool will read. That file is a software bill of materials, or SBOM: a machine-readable inventory of what went into a build.
A CVE is the public identifier for a known vulnerability. With an SBOM on disk you can ask, six months later, whether a build contains the library that just picked up one.
You answer it without rebuilding or re-downloading anything. What an SBOM is for makes the case for having one.
Syft is Anchore's command-line tool, and Go library, for generating an SBOM from container images and filesystems. Its job is cataloging, not vulnerability matching: it reports what is there and says nothing about whether any of it is broken.
Install it
Syft v1.51.1 is current. syft is in homebrew-core, so the plain formula works: Anchore's
install page still opens with brew tap anchore/syft, and you can skip that line.
# macOS or Linux, via Homebrew (syft is in homebrew-core, no tap needed)
brew install syft
# macOS or Linux, via Anchore's official install script
curl -sSfL https://get.anchore.io/syft | sudo sh -s -- -b /usr/local/bin
# No sudo? Point -b at a directory you already own, then add it to your PATH
curl -sSfL https://get.anchore.io/syft | sh -s -- -b ~/.local/bin
syft versionThe -b flag chooses the install directory.
/usr/local/bin is root-owned on most Linux systems, which is why the documented one-liner has
sudo in it; without it the download succeeds and then the write fails, which reads like a
broken script rather than a permissions problem.
sh -s -- is the plumbing that gets the flag through: -s tells sh to read the script from
the pipe, and everything after -- goes to that script instead of being eaten by sh itself.
The first scan
syft alpine:3.24Alpine's release page lists 3.24 as the current stable branch: released 9 June 2026, supported
until June 2028. With no -o flag Syft prints a syft-table: package name, version and type in
columns.
On Alpine those types are apk, Alpine's own package format, just as deb belongs to Debian
and Ubuntu and rpm to the RHEL and Fedora family.
Every distribution records its installed packages somewhere different, so Syft carries a separate cataloger for each. A cataloger reads one specific kind of evidence and turns it into package entries.
One reads an Alpine package database, another a Go go.mod file, another a Java JAR manifest. A
single scan uses dozens of them.
Syft never runs the image. It reads the layers directly: the compressed filesystem diffs that stack up into a container image, in the OCI (Open Container Initiative) format.
The default squashed scope looks at the result of stacking them in order, so a file deleted in
a later layer never appears in the output.
A bare image reference still reaches for your container runtime, though.
With no scheme in front of it, Syft resolves the target in order: the Docker daemon, then Podman and containerd (two other container runtimes), then a direct registry pull.
On a laptop with Docker running you never notice the difference. In a CI container with no daemon you will.
Choose a format and a destination
# CycloneDX JSON (spec 1.6 by default), written to a file
syft alpine:3.24 -o cyclonedx-json=sbom.cdx.json
# SPDX JSON (spec 2.3 by default), written to a file
syft alpine:3.24 -o spdx-json=sbom.spdx.json
# Omit the "=path" half and the SBOM goes to stdout instead
syft alpine:3.24 -o cyclonedx-json
# Pin an older spec version with @version if a downstream tool needs it
syft alpine:3.24 -o cyclonedx-json@1.5=sbom.cdx.json
# Pass -o more than once to emit several formats in a single scan
syft alpine:3.24 -o syft-table -o cyclonedx-json=sbom.cdx.json -o spdx-json=sbom.spdx.json-o names the format and the file at once, and repeating it gets you a human-readable table
and two standard formats from one pass over the image rather than three scans.
Syft's CLI reference lists cyclonedx-json, cyclonedx-xml, github-json, purls,
spdx-json, spdx-tag-value, syft-json, syft-table, syft-text and template, with
syft-table as the default. Two of them are worth handing to anyone else.
CycloneDX comes from OWASP, the Open Worldwide Application Security Project, and was written with security tooling in mind.
SPDX, short for Software Package Data Exchange, comes from the Linux Foundation; it is older and used heavily for licence compliance.
spdx-tag-value is that same SPDX data in a plain-text encoding, one Key: value pair per
line; you will occasionally meet a tool that takes nothing else.
cyclonedx-json is an alias, not a version. It resolves to the newest CycloneDX spec the
installed Syft knows how to write, which today is 1.6, and spdx-json resolves to SPDX 2.3 the
same way. Those numbers track Syft's releases, not the specifications.
So if your pipeline has promised a consumer a particular spec version, an unversioned alias breaks that promise the day Syft starts writing a newer one.
Append @1.5, or whatever your consumer is pinned to, and the output is pinned with it.
If a consumer wants SPDX and you already emitted CycloneDX, syft convert translates between
the formats without another scan.
Point Syft at something other than a running image
The argument you pass Syft is a source scheme and a target, scheme:target, where the scheme
says what kind of thing you are pointing at.
Leave it off and Syft guesses, which is the resolution order above.
The branch worth getting right is the last one, an image sitting in a file on disk: Syft will not guess at a tarball, so the scheme has to match the tool that wrote it.
# A local source directory (a checked-out repo)
syft dir:. -o spdx-json=sbom.spdx.json
# A tarball produced by `docker save`
docker save ghcr.io/OWNER/REPO:TAG -o image.tar
syft docker-archive:image.tar -o cyclonedx-json=sbom.cdx.json
# An OCI archive (written by skopeo, buildah or another non-Docker image tool)
syft oci-archive:image.tar -o cyclonedx-json=sbom.cdx.json
# An OCI layout directory
syft oci-dir:./image-layout -o cyclonedx-json=sbom.cdx.json
# Pull straight from the registry: no Docker daemon, no container runtime at all
syft registry:ghcr.io/OWNER/REPO:TAG -o syft-json=sbom.syft.jsonSubstitute your own image for ghcr.io/OWNER/REPO:TAG. registry: is the scheme to reach for
in CI, because it pulls the image itself: no daemon, and nothing extra installed in the job.
There is no archive: scheme. Syft cannot resolve it and stops on the source before it scans
anything.
Scanning a repository and scanning that repository's image do not produce the same list, and the difference is intentional. Syft selects a different default cataloger set for each.
An image scan records installed packages: in a Python project, what is sitting in
site-packages. A directory scan also picks up declared dependencies, what the project's
requirements.txt asks for.
Anchore gives the reason plainly: files that merely state an intent to install something "do not have enough information in them to determine the exact version".
So a directory SBOM adds what the project asked for on top of what is sitting in the tree, and an image SBOM records only what it got.
If you are producing one file for someone downstream, scan the image.
Keep out what you never shipped
A directory scan will catalog node_modules, your test fixtures and whatever else is lying
around the working tree, and each one becomes a component somebody has to triage later.
syft dir:. \
--exclude './node_modules/**' \
--exclude './test/fixtures/**' \
--exclude './.git/**' \
-o spdx-json=sbom.spdx.json--exclude takes a glob and can be repeated. Quote the globs, or your shell expands them before
Syft sees them.
This is also where Syft's memory use comes from, and it is easy to tune the wrong thing.
The driver is the number of files and packages cataloged, not the image size in gigabytes and not the layer count.
Anchore's tracker has an out-of-memory kill while cataloging an image with many files under a 1 GB memory limit (issue #2159), and a report of 3 to 5 GB of memory in use while generating an SBOM for a JavaScript project with more than 10,000 packages (issue #4586).
Squashing layers will not help. The --exclude globs above will.
Check that you got a real SBOM
# CycloneDX puts components under .components
jq '.components | length' sbom.cdx.json
jq -r '.components[] | "\(.name) \(.version)"' sbom.cdx.json | head
# SPDX puts them under .packages
jq '.packages | length' sbom.spdx.jsonThat needs jq, a command-line JSON processor, installed. A count of zero means the scan found
nothing, which is nearly always the wrong scheme or an over-broad --exclude glob.
Put it in CI
You want this on every build, not on the afternoon somebody asks for it. Anchore maintains a GitHub Action that wraps Syft, so the job needs no install step.
Everywhere else it is the plain binary in a script; there is no first-party GitLab equivalent.
name: Build & Catalog
on: [push]
jobs:
build-and-sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build the image
run: docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ github.repository }}:${{ github.sha }}
format: cyclonedx-json
artifact-name: sbom.cdx.jsonThe file goes in .github/workflows/. ${{ github.sha }} is the commit SHA of the push, which
gives every build a unique tag. anchore/sbom-action is still on the 0.x line, where v0.24.2 is
the latest.
@v0 is the documented floating pin, not a stale version number.
That workflow leaves out actions/upload-artifact on purpose. The action's own action.yml
gives the upload-artifact input a default of true: it uploads the SBOM as a workflow
artifact by itself, named sbom-<job>-<step-id>.spdx.json unless artifact-name says
otherwise.
Add an upload step on top of that default and you get two artifacts holding the same SBOM, with no hint about which to consume.
If a later step needs the file on disk, ask for it and turn the automatic upload off. Replace
the Generate SBOM step above with these two:
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ github.repository }}:${{ github.sha }}
format: cyclonedx-json
output-file: ./sbom.cdx.json
upload-artifact: false
- uses: actions/upload-artifact@v7
with:
name: sbom-artifact
path: ./sbom.cdx.jsonNothing here signs the file. syft attest produces a signed statement tying one SBOM to one
specific image, so a recipient can tell the pair was not swapped on the way.
Anchore marks it experimental. Signing images with cosign covers doing it for real.
Hand it to a scanner
The cheapest next step is Grype, Anchore's vulnerability matcher: it takes your list of component names and versions and looks each one up against vulnerability databases.
Grype analyses nothing and executes nothing. It is a lookup, which is why it runs against the SBOM and never needs the image again.
brew install grype
# or: curl -sSfL https://get.anchore.io/grype | sudo sh -s -- -b /usr/local/bin
grype sbom:sbom.cdx.jsonGrype v0.118.0 is current. The sbom: prefix is optional, since Grype detects the format, but
it removes any question about whether the argument is a file or an image name.
If you would rather have one tool covering images, filesystems and infrastructure-as-code configuration, scanning with Trivy is the other route.
What Syft will miss
A binary that curl dropped into /usr/local/bin has no package manager record anywhere, and
the only reason it shows up in the SBOM at all is Syft's binary classifier cataloger, which
inspects compiled binaries and identifies them by fingerprint. That is best effort.
Syft misses a binary that no classifier matches, and the SBOM looks just as complete. Treat the classifier as a safety net, not a guarantee.
When Syft's default depth is not enough for what you are shipping, a deeper SBOM out of cdxgen is the next thing to try.