All writing
Updated 7 min readNaren

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 --version

What 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" --version

Set 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=2

That 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.0

Pin 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.json

Your 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

.github/workflows/sbom.yml
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.json

Those 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.

Source tree and lockfiles feed cdxgen, which writes bom.json. That file goes to Dependency-Track for continuous monitoring, and separately into evinse, which reads the source tree as well and writes an enriched SBOM containing call stacks. Source tree and lockfiles cdxgen bom.json, CycloneDX 1.7 Dependency-Track evinse, needs Java 21 bom.evinse.json with call stacks

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.