Getting a deeper SBOM out of cdxgen
cdxgen reads your lockfiles and build system rather than the finished image. How to run it, and when that difference matters.
An SBOM, or software bill of materials, is a machine-readable list of every component inside a piece of software, with versions. It can come from two different kinds of evidence.
Syft reads a finished container image. cdxgen reads the thing that produced it: your lockfiles,
your pom.xml, your build tool. A lockfile such as package-lock.json or poetry.lock records
the exact resolved version of every dependency. That includes the transitive ones: the
dependencies of your dependencies, which you never asked for and are in your build anyway. It is
the highest-fidelity input an SBOM tool has.
That choice of evidence decides what each tool can see, and neither view is complete. What Syft finds in a finished image covers the other half.
The goal is one file, bom.json, in the CycloneDX format cdxgen writes by default. Every
command here is cdxgen v13.1.0.
Node 24, then one of three install paths
cdxgen 13.1.0 declares engines: { node: ">=24", pnpm: ">=11" }, and the v13 migration notes
tell anyone on Node 20 or 22 to upgrade before installing. This is the one prerequisite that
fails loudly, so check it before anything else:
# cdxgen v13 needs Node.js 24 or newer. Check before you install.
node --versionWhat moved between v12 and v13
Everything else about v13 fails quietly. Between v12 and v13 cdxgen left the CycloneDX GitHub organisation for its own, and three things moved with it.
The npm package is now @cdxgen/cdxgen, with @cyclonedx/cdxgen frozen at 12.8.4. The old
documentation host, cyclonedx.github.io/cdxgen, returns a 404; the canonical one is
https://cdxgen.github.io/cdxgen/.
The images moved from ghcr.io/cyclonedx/cdxgen to ghcr.io/cdxgen/cdxgen. ghcr.io is
GitHub's container registry, and the path segment after the host is the owning organisation. That
is why an org rename changes every published pull command.
Nothing tells you that you are on v12
npm install -g @cyclonedx/cdxgen still installs, and docker pull ghcr.io/cyclonedx/cdxgen
still pulls; the migration notes say the old images keep working and stop getting updates. So
you get v12 behaviour and none of v13's defaults, and nothing tells you which one you are on.
Check the scope in every cdxgen command you copy, this post included.
The standalone binary
The project recommends the standalone binaries and container images for CI, the build server that runs your tests and builds on every push. Neither has a runtime to install. The binary path is the shortest:
# Path A (recommended for CI): standalone binary, no Node.js needed.
# OS is one of: linux, darwin, windows. ARCH is one of: amd64, arm64.
OS=darwin
ARCH=arm64
BINARY_NAME=cdxgen-$OS-$ARCH
curl -LO "https://github.com/cdxgen/cdxgen/releases/latest/download/$BINARY_NAME"
curl -LO "https://github.com/cdxgen/cdxgen/releases/latest/download/$BINARY_NAME.sha256"
# Verify the download before you run it.
if command -v sha256sum >/dev/null; then
sha256sum -c "$BINARY_NAME.sha256"
else
shasum -a 256 -c "$BINARY_NAME.sha256"
fi
chmod +x "$BINARY_NAME"
./"$BINARY_NAME" --versionSet OS=linux on Linux, and append -musl to BINARY_NAME on a distro without glibc such as
Alpine. Keep the checksum step. It is in the official install instructions, and you are about to
make this file executable.
# Path B: global npm install. Note the @cdxgen scope, not @cyclonedx.
npm install -g @cdxgen/cdxgen --omit=optional --ignore-scripts --min-release-age=2That line is the project's own. --min-release-age=2 refuses any version published in the last
two days. The cooldown gives a compromised release time to be caught and pulled before it
reaches your machine. It needs npm 11.10.0 or newer; older npm warns and ignores it.
# Path C: container image, with a Java runtime and the build toolchains already inside.
docker pull ghcr.io/cdxgen/cdxgen:v13.1.0Pin the tag. Both :latest and :v13 exist, but a patch tag or a digest is what belongs in CI.
The image is the easier answer for compiled languages: the Java runtime and the build toolchains are already inside it.
The rest of this post writes cdxgen. After path A that is ./cdxgen-$OS-$ARCH, until you
move it onto your PATH under that name.
Your first SBOM
# Generate a CycloneDX 1.7 JSON SBOM for the project in the current directory.
cdxgen -o bom.json .Two things in that command are true already: -o defaults to bom.json, and recursion is on.
Pass both explicitly anyway.
The . is the current directory, which is fine while you are standing in the project; cdxgen's
own guidance is to prefer an absolute path wherever the working directory is not obvious from
the command itself.
CycloneDX is an SBOM format maintained by OWASP, the Open Worldwide Application Security
Project, the non-profit that also hosts cdxgen itself and, further down this post,
Dependency-Track. cdxgen v13 defaults to --spec-version 1.7 and accepts 1.6, 1.7 and 2.0. It
rejects 1.4 and 1.5 outright, so a downstream tool that only ingests 1.5 stops working the day
you upgrade.
--validate defaults to true. The other widely used SBOM format is SPDX, out of the Linux
Foundation. cdxgen exports SPDX 3.0.1 JSON-LD too, so choosing one does not lock you out of the
other.
Inside the file, each component carries a purl, or Package URL: a cross-ecosystem identifier
such as pkg:npm/lodash@4.17.21, so two tools agree on which lodash is meant.
A component can also carry a licence expression such as Apache-2.0 OR MIT rather than a
free-text guess, and a package hash: a checksum proving the artefact in your build is
bit-for-bit the one the SBOM describes.
Nothing in the file says whether your code calls a given component; that needs a second pass, at the end of this post.
Which of those fields anyone downstream reads is a separate question: see what an SBOM is for and what CycloneDX is designed to record.
Tell cdxgen what it is looking at
# One ecosystem.
cdxgen -t java -o bom.json .
# A polyglot repo: -t is repeatable, one run, one merged SBOM.
cdxgen -t java -t python -t javascript -o bom.json .-t takes an array, which turns a mixed repository into one invocation instead of a
reconciliation problem.
The JVM is the runtime Java, Kotlin and Scala all compile to, and its types cover java, jvm,
maven, gradle, kotlin and scala, among others.
There are 52 language and platform types in total, and the strings are in the project types table. cdxgen publishes no round number for this, so treat any "supports 30+ languages" line as someone's guess.
The two flags people reach for are the two that mislead
--recurse has defaulted to true for several major versions. Passing it changes nothing, and
tutorials pass it constantly. The flag that changes behaviour is the negative one:
# Recursion is ON by default, so there is nothing to switch on for a monorepo.
cdxgen -o monorepo-bom.json .
# Switch it OFF to scan only the top level - faster, and sometimes what you want.
cdxgen --no-recurse -o top-level-bom.json .--deep is the other one, and it is the more expensive mistake. The CLI reference documents it
as performing "deep searches for components": it looks for binaries and libraries no manifest
ever declared. That is useful when scanning C and C++ applications, a live operating system, or
an OCI image.
OCI is the Open Container Initiative, the standard that container images are built to. --deep
is not a transitive-resolution dial.
Transitive dependencies come out of your lockfile and your build tool's own dependency query, by
default, with no flag. On a Maven or npm project, --deep costs scan time and returns nothing.
Stop it running your installers
The CLI reference documents --install-deps as defaulting to true, which means cdxgen will
invoke your package manager on your behalf. cdxgen turns it off only for container and OCI
scans.
The project's own guidance for CI is blunt: restore dependencies yourself, then pass the negative flag.
# Restore dependencies yourself, then stop cdxgen from doing it behind your back.
npm ci
cdxgen -t javascript --no-install-deps -o bom.json .An SBOM tool that reaches out to the network mid-pipeline is slow and non-deterministic, and it is one more way a package gets into your build.
The same thing from the container
docker run --rm \
-v /tmp:/tmp \
-v "$(pwd)":/app:rw \
-t ghcr.io/cdxgen/cdxgen:v13.1.0 \
-r /app -o /app/bom.jsonYour working tree mounts at /app, and -o /app/bom.json is what gets the result back onto
the host. Everything after the image name is cdxgen's own arguments.
In GitHub Actions
name: Generate Application SBOM
on: [push]
permissions:
contents: read
jobs:
cdxgen-sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
# Restore dependencies first, so the SBOM reflects a resolved tree.
- run: npm ci
- name: Generate SBOM
run: npx @cdxgen/cdxgen -t javascript --no-install-deps -o bom.json .
- uses: actions/upload-artifact@v7
with:
name: application-sbom
path: bom.jsonThose pins are the current majors: checkout v7.0.1, setup-node v7.0.0, upload-artifact v7.0.1. Older pins still resolve, so an old workflow keeps running.
But upload-artifact has shipped three majors since v4, and v6 onward needs a newer Actions
runner.
Watch the scope in the npx line.
npx runs a package straight from the npm registry without installing it, and npx @cyclonedx/cdxgen resolves to the newest version of the old package, which pins your CI to
v12 permanently and never says so.
What consumes bom.json
Two things read that file. Reachability is the question of whether your code actually calls the vulnerable function in a dependency.
Answering it takes a second tool that reads both the SBOM and your source, not an option on the first run.
OWASP Dependency-Track is a platform you host yourself and upload SBOMs to. It re-checks every
component against vulnerability feeds continuously. A CVE is an entry in the public Common
Vulnerabilities and Exposures catalogue, with an identifier like CVE-2021-44228.
When a new one is disclosed against a version you shipped six months ago, Dependency-Track tells you.
Together the two are what people mean by software composition analysis: cdxgen takes the inventory, Dependency-Track keeps checking it against what has been disclosed since. cdxgen can submit directly:
export DTRACK_API_KEY="YOUR_API_KEY"
cdxgen -t java -o bom.json \
--server-url https://DTRACK.EXAMPLE.COM \
--api-key "${DTRACK_API_KEY}" \
--project-name PaymentService \
--project-version 2.1.0 \
--auto-create \
--is-latest \
.The flag to watch is --auto-create. Without it, Dependency-Track rejects any upload to a
project name it does not already know. Mint the key under Administration, Access Management,
Teams: it needs BOM_UPLOAD, plus PROJECT_CREATION_UPLOAD for --auto-create.
Dependency-Track 5.1.0 is the current release.
Reachability is a second command, not a flag
Reachability comes out of a call graph, a map of which functions call which. A package you never invoke is a smaller real risk than one you hit on every request.
No cdxgen flag produces this. The bundled evinse command does, as a separate pass over an SBOM
that already exists:
# Step 1: the ordinary SBOM.
cdxgen -t java -o bom.json .
# Step 2: enrich it with call stacks and reachability. Needs Java 21+.
evinse -i bom.json -o bom.evinse.json .
# Add data-flow evidence as well.
evinse -i bom.json -o bom.evinse.json --with-data-flow .evinse ships in the same package. It needs a Java Development Kit (JDK) at version 21 or
newer, the application source, and an input SBOM newer than spec 1.5.
If you went looking for reachability data in a plain bom.json and decided the tool was broken,
this is why.
Server mode
There is no cdxgen-server command. The package ships 16 binaries, evinse, cdx-sign,
cdx-verify and cdx-validate among them, and server mode is a flag on cdxgen itself:
# Start it. Defaults to 127.0.0.1:9090.
cdxgen --server
# Ask it for an SBOM of a local path.
curl "http://127.0.0.1:9090/sbom?path=/ABSOLUTE/PATH/TO/PROJECT&multiProject=true&type=js"
# Or of a git repo, by POST.
curl -H "Content-Type: application/json" http://127.0.0.1:9090/sbom -XPOST \
-d '{"url": "https://github.com/OWNER/REPO.git", "type": "nodejs", "multiProject": "true"}'--server-host defaults to 127.0.0.1 and --server-port to 9090. From a container,
publish the port with -p 9090:9090 and bind wider with --server-host 0.0.0.0.
What the build cannot tell you
Everything above reports what your build resolved. It does not report what shipped. That is why you run cdxgen and Syft over the same commit rather than choosing between them.