Skip to content

Repository files navigation

📋 SBOM Generator Action

Linux Foundation Source Code License pre-commit.ci status badge OpenSSF Scorecard

Generates CycloneDX Software Bill of Materials (SBOM) reports for projects in any language/ecosystem.

sbom-action

This action provides a common interface over pluggable SBOM generation backends. The default backend wraps syft, which performs static analysis of lockfiles and filesystem content, covering Go modules, Node.js/npm, Rust, containers, binaries and further ecosystems with a single tool. Syft shares a vendor with Grype, which the reusable workflows in this organisation use to audit the generated SBOM in a separate, downstream job.

A second backend runs the CycloneDX project's own build-tool plugins for Java projects, where static analysis falls short. See Backends for how to choose between them.

The interface mirrors python-sbom-action, giving callers one contract across the actions estate. Future backends (such as cyclonedx-npm or cyclonedx-gomod) can slot in behind the same interface through the backend input, without changes to calling workflows.

Backends

A backend names the tool that produces the document, not the build system it drives.

Backend Tool Build tools driven Needs a toolchain Use for
syft (default) syft static analysis none No Go, Node.js, Rust, containers, binaries
cyclonedx CycloneDX build-tool plugins Maven JDK + build tool Java projects

That distinction matters for what comes next. cyclonedx-gradle-plugin is the same tool family solving the same problem, so Gradle support joins the existing cyclonedx backend rather than arriving as a separate gradle one. Callers keep the same backend value and the action detects the build system from the project.

Because the backend name no longer identifies the build system, the dependency_manager output reports which one actually ran (maven today). It stays empty for syft, which drives no build tool.

The cyclonedx backend fails fast when it cannot find a build system it supports, rather than attempting an invocation that cannot work.

Why Java needs a different backend

The syft backend reads files. For most ecosystems the file it reads is already a resolved dependency graph — go.mod records indirect requirements, and package-lock.json and uv.lock are complete resolved graphs by construction. Static analysis suits those cases.

Maven breaks that assumption. pom.xml is an input to dependency resolution rather than a product of it, so two categories of dependency stay invisible to any tool that parses it as text:

  • Transitive dependencies. A single spring-boot-starter-web entry stays one component instead of expanding to the artifacts Maven actually puts on the classpath. Most vulnerabilities live in transitive dependencies.
  • BOM-managed versions. Where a version comes from dependencyManagement or an imported BOM, it does not exist until Maven resolves it, and syft emits the component with version UNKNOWN. A vulnerability database cannot match a component without a version, so it escapes scanning rather than merely losing precision. Centrally managed versions are the norm in enterprise Java.

The cyclonedx backend invokes the plugin's makeAggregateBom goal directly, so the consumer's pom.xml needs no plugin entry and the build leaves no target/ directories behind. makeAggregateBom runs once at the reactor root and covers every module, which multi-module projects require. The output is CycloneDX, so nothing downstream of the SBOM changes.

The action does write its report files, which land in the checkout whenever output_directory points there — as the default . does. What it leaves alone is the project itself: it edits no POM or build script, and produces no build output.

The goal resolves dependencies but does not compile, so it succeeds against a source tree with no prior build. It fails when dependency resolution fails, which is the correct signal — an unresolvable graph means there is nothing meaningful to scan.

The plugin also emits the CycloneDX dependencies graph, which syft does not. That records which direct dependency pulled in a given transitive component, the question people actually ask when triaging a finding.

Trust Boundary

⚠️ The cyclonedx backend requires a trusted checkout. Running Maven against a project treats that project as executable input rather than as inert metadata.

Before the SBOM goal runs, Maven will:

  • load .mvn/extensions.xml from the project as JVM core extensions;
  • load build extensions declared in the POM;
  • read command-line arguments from .mvn/maven.config.

No command-line flag turns any of that off, and it happens even though the goal never compiles source or runs tests. A project supplying a core extension thus executes code in the job.

This is a real difference from the syft backend, which reads files and executes nothing from the project.

The practical consequence: do not point the cyclonedx backend at untrusted content. The action defends this by default — see Untrusted checkouts.

Untrusted checkouts

Nothing turns that execution off, so the remaining control is whether to run the backend at all. The untrusted_checkout input decides:

Value Meaning
auto (default) Treat a pull request whose head is outside the base repository as untrusted
true Caller declares the checkout untrusted
false Caller declares the checkout trusted

When the checkout resolves to untrusted, the cyclonedx backend is skipped. The step succeeds, skipped reports true, and the action writes no document — clearing both destination paths first, so a committed or leftover document cannot survive the skip for a caller's artifact glob to collect. This does not affect the syft backend, which executes nothing from the project.

One qualification: a guard protects the clearing. Where a destination holds something the action cannot identify as a CycloneDX document, it refuses rather than removing, and the step fails instead of skipping. For an existing XML destination that classification needs xmllint, which GitHub-hosted runners lack — see Requirements. A fresh checkout has no such file, so this does not arise in ordinary use.

Clearing covers both paths the action owns for its filename_prefix, rather than the format a given run emits. A run emitting JSON alone still clears the sibling XML, because the documented artifact glob (sbom-cyclonedx.*) would otherwise publish a stale XML from an earlier run as though this run had produced it. A caller accumulating formats across separate runs must give each run its own filename_prefix or output_directory.

Three deliberate choices there:

  • Skip rather than fail. An untrusted contribution should not turn into a red build over something the contributor cannot fix. Callers that want a hard gate can branch on the skipped output.
  • Skip rather than fall back to syft. A fallback would emit a document missing the transitive graph and carrying UNKNOWN versions, which reads downstream as a clean scan. No SBOM is honest; a misleading one is worse than nothing.
  • Report nothing rather than zero. component_count and the path outputs stay empty, not 0, so nothing can read a skip as a scan that found nothing.

auto cannot see everything

auto classifies as untrusted a pull request whose head lives outside the base repository, a pull request with no readable head repository, and every merge_group event — a merge queue may hold fork-originated changes and carries no head repository to check them against.

The test is cross-repository, not unmerged. Someone with write access pushed that same-repository branch, and the same access lets them push to the default branch; classifying their pull request as untrusted would disable this backend for pull request scanning without raising a bar they do not already clear. Callers whose threat model includes write-access contributors should set untrusted_checkout: 'true' and scan on the default branch instead.

It will not catch, among others:

  • a Gerrit refspec checkout, which carries unreviewed contributor code with no pull request context at all;
  • a workflow_run triggered by an untrusted workflow;
  • any checkout the caller performed from an arbitrary ref.

Callers that know the context must say so with untrusted_checkout: 'true'. Where a workflow already gathers repository context, repository-metadata-action exposes an is_fork output to wire straight in.

Note that is_fork there derives from head.repo.fork — whether the head repository is itself a fork — a slightly broader test than the cross-repository comparison auto performs.

Usage Example

steps:
  - name: "Generate SBOM"
    id: sbom
    uses: lfreleng-actions/sbom-action@main
    with:
      path_prefix: '.'

For a Java project:

steps:
  - name: "Generate SBOM"
    id: sbom
    uses: lfreleng-actions/sbom-action@main
    with:
      backend: 'cyclonedx'
      path_prefix: '.'
      java_version: '21'

Requirements

The action needs jq, realpath and sort (the latter two from GNU coreutils, for realpath -m and sort -V) and mktemp on the runner. GitHub-hosted Ubuntu runners include these tools; minimal self-hosted or non-Linux runners must provide them. The action checks for them up front and fails with a clear error naming any missing tool.

xmllint (from libxml2-utils) covers one narrow case: an XML file already sitting at the output destination. The action uses it to tell a CycloneDX document from an unrelated XML file before replacing one, and refuses the collision with a message naming the tool when the runner lacks it.

GitHub-hosted runners do not ship xmllint. That does not affect ordinary use — each job checks out afresh, so the destination is empty and no classification happens. It comes up where something already occupies the XML destination, such as a committed sbom-cyclonedx.xml or a second run in the same job writing to the same prefix. Install libxml2-utils, or give each run its own filename_prefix or output_directory.

The syft backend downloads the syft binary via the pinned anchore/sbom-action/download-syft helper, so runners need egress to GitHub release assets.

The cyclonedx backend installs a JDK with actions/setup-java and uses the Maven installation from the runner image, so runners need egress to the JDK distribution and to the Maven repositories the project resolves against (Maven Central by default). Callers running harden-runner in block mode must allow-list those endpoints.

That backend also needs Maven 3.6.1 or later. The plugin itself supports older Maven, but the action passes --no-transfer-progress to keep download chatter out of the log, and that flag arrived in 3.6.1. GitHub-hosted runners ship a far newer Maven; a self-hosted runner pinned below 3.6.1 fails on the flag.

The pinned actions/setup-java v5 is a node24 action, so the cyclonedx backend needs Actions Runner v2.327.1 or later. GitHub-hosted runners meet this; a self-hosted runner below that version fails before generation starts.

The JDK setup runs with overwrite-settings: false, so an existing ~/.m2/settings.xml survives. A caller that configures mirrors, proxies or private repository credentials before invoking this action keeps them; setup-java still writes its default file where none exists.

Inputs

Name Required Default Description
backend False syft SBOM generation backend: syft or cyclonedx
path_prefix False . Project directory; must resolve within the workspace
sbom_format False both SBOM output format: json, xml, or both
sbom_spec_version False 1.5 CycloneDX specification version to use
filename_prefix False sbom-cyclonedx Base filename for SBOM output (without extension)
output_directory False . SBOM report directory, within workspace or runner temp
include_dev False false Include development dependencies (Maven: test scope) in SBOM
fail_on_error False true Fail the action if SBOM generation encounters errors
syft_version False '' Syft version to download (defaults to the installer's pinned version)

CycloneDX backend inputs

These apply when backend is cyclonedx, but the action validates them on every run so a typo surfaces regardless of the backend in use.

Name Required Default Description
java_version False 21 JDK version for the cyclonedx backend
java_distribution False temurin JDK distribution for the cyclonedx backend
maven_plugin_version False 2.9.3 cyclonedx-maven-plugin version; 2.8.0 or newer
maven_args False '' Extra arguments appended to the Maven call, split on whitespace
untrusted_checkout False auto Is the checkout untrusted: auto, true or false

Use maven_args for project-specific resolution needs, for example a managed settings file (-s .mvn/settings.xml) or repository properties.

⚠️ Treat maven_args as trusted input. The value splits on whitespace without shell evaluation and without glob expansion, so it cannot reach a shell — but Maven itself accepts goal coordinates, so a token such as com.example:some-plugin:1.0:goal runs that plugin. Supply this input from the calling workflow, never from pull request content.

The action rejects maven_args values that override the properties it owns (outputFormat, outputName, outputDirectory, schemaVersion, includeTestScope, cyclonedx.skip and cyclonedx.skipAttach); use the corresponding inputs instead. Caller arguments are also placed before those properties on the command line, so the action's values remain authoritative even for an override form the rejection does not recognise. Without that, a redirected outputDirectory would write outside the validated output directory and leave the reported paths and component count pointing at files that were never generated.

Verification After Generation

A zero exit code from either backend does not by itself mean a usable document exists, so the action clears the destination paths before generating and then checks the artefacts before reporting success:

  • The requested documents exist. cyclonedx-maven-plugin honours cyclonedx.skip, which a consumer's own POM can set as a property; the plugin then exits 0 having written nothing.
  • The document declares the requested specification version. The plugin falls back to its own default for a version it does not support rather than failing, so sbom_spec_version: '9.9' would otherwise report success over a document carrying a different version.

Clearing the destinations first is what makes the existence check meaningful: a repository can legitimately contain a committed sbom-cyclonedx.json, and an earlier step in the same job can leave one behind. Without it, a backend that exits 0 without writing would have the stale document validated and published as a fresh result.

The action clears the destinations again when generation fails, including under fail_on_error: false. A rejected document is still a document — the unsupported-spec-version case writes a well-formed BOM carrying the wrong version — and a consumer uploading sbom-cyclonedx.* would otherwise publish and scan a document this action had already rejected.

Either condition routes through the same handling as an outright backend failure, honouring fail_on_error and reporting component_count: 0 where the caller permits failures. A scan that reports nothing without saying so is the failure mode this action exists to avoid, so these conditions count as generation failures rather than quiet successes.

Outputs

Name Description
sbom_json_path Path to generated JSON SBOM file
sbom_xml_path Path to generated XML SBOM file
component_count Number of components in the generated SBOM
backend SBOM generation backend used
dependency_manager Build tool the cyclonedx backend drove
skipped true when the action declined to generate

The action emits sbom_json_path for the json and both formats, and sbom_xml_path for the xml and both formats; the output for a format the caller did not request stays empty.

dependency_manager reports the build system the cyclonedx backend drove, since the backend name identifies the tool rather than the build system. It stays empty for syft.

skipped reports true when the action declined to generate, which today means an untrusted checkout. In that case sbom_json_path, sbom_xml_path, component_count and dependency_manager are all empty; backend still reports the backend the caller selected, as the validation step resolves it before the skip decision. See Untrusted checkouts.

Path Constraints

Relative values for path_prefix and output_directory resolve against GITHUB_WORKSPACE, not the current working directory, so behaviour stays deterministic when a calling workflow sets a custom working directory. The action checks both directory inputs against the runner filesystem before use: path_prefix must resolve within GITHUB_WORKSPACE, and output_directory must resolve within GITHUB_WORKSPACE or RUNNER_TEMP. Paths that escape these locations fail the action, preventing scans or writes against arbitrary runner filesystem locations.

Development Dependency Scoping

The include_dev input controls whether development dependencies appear in the SBOM. The syft backend maps this onto its JavaScript cataloger (SYFT_JAVASCRIPT_INCLUDE_DEV_DEPENDENCIES), so for Node.js projects the SBOM covers production dependencies by default. Go modules have no development scope, so the input has no effect there. Further per-ecosystem scoping options join the mapping as syft exposes them.

The cyclonedx backend maps include_dev onto the plugin's includeTestScope. Maven's test scope is the analogue of npm devDependencies: absent from the running application, and so excluded by default.

The plugin's other scope defaults stay as they are — compile, runtime, provided and system all enabled — so the BOM describes what the application depends on at runtime, a wider set than what the build packages into the artefact. That distinction matters for provided and system: a servlet API or JDBC driver supplied by the container is absent from the JAR yet present when the application runs, and a vulnerability in one carries real risk. Excluding them would hide part of the deployed attack surface.

If your use for the SBOM is strictly "what this build packages", rather than "what this application runs against", pass -DincludeProvidedScope=false -DincludeSystemScope=false through maven_args. The action does not reserve those two properties.

Leaving test scope out is deliberate rather than incidental. Test dependencies are absent from every deployment, so a finding in one carries no production risk, and Java test trees are large and disproportionately stale. Compliance regimes such as the EU Cyber Resilience Act expect a BOM describing the product rather than its build harness. Set include_dev: 'true' where build-system supply-chain visibility matters more.

Note that the plugin's skipNotDeployed default also excludes modules that set maven.deploy.skip, which is consistent with describing the deployed product but can surprise anyone counting components on a project with non-deployed test-harness modules.

Monorepo and Nested Module Support

Point path_prefix at the directory containing the project's lockfile/manifest. This supports repositories where the module is not at the repository root, for example Gerrit-hosted monorepos such as onap/multicloud-k8s, where Go modules live under src/:

steps:
  - name: "Generate SBOM for nested module"
    uses: lfreleng-actions/sbom-action@main
    with:
      path_prefix: 'src/k8splugin'

Workflow Integration

The reusable workflows in this organisation keep SBOM generation and SBOM auditing in separate jobs, connected by an artifact. The generation job runs this action and uploads the results; a downstream job downloads them and audits with Grype. This separation keeps failure modes independent and preserves the SBOM for inspection even when the audit fails:

- name: "Generate SBOM"
  id: sbom
  uses: lfreleng-actions/sbom-action@main

- name: "Upload SBOM artifact"
  uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
  with:
    name: sbom-files
    path: sbom-cyclonedx.*
    if-no-files-found: error

Implementation Details

  1. Input Validation: Validates backend, format, boolean flags, specification version, filename prefix, and the java_version, java_distribution, maven_plugin_version and untrusted_checkout inputs against restricted value sets before use; verifies the project directory exists. maven_args is not constrained this way — the action splits it on whitespace and rejects it solely for overriding action-owned properties, leaving it trusted input (see Inputs). For the cyclonedx backend this step also resolves the trust decision, and detects the build system, failing fast on one it cannot drive before installing any toolchain
  2. Toolchain Setup: For the syft backend, fetches the syft binary via the pinned anchore/sbom-action/download-syft helper action. For the cyclonedx backend, installs a JDK via the pinned actions/setup-java. The selected backend guards each step, so neither costs anything when unused
  3. SBOM Generation: A single step dispatches on the backend. The syft backend runs one scan emitting the requested CycloneDX formats (format@version=path syntax). The cyclonedx backend invokes cyclonedx-maven-plugin's makeAggregateBom goal directly, writing into the resolved output directory. In both cases the JSON document always gets generated internally to compute the component count
  4. Outputs and Summary: Emits output paths for the requested formats, the component count, the build tool used, and a step summary

The plugin writes every requested format into one directory and cannot split them, so a run requesting xml alone generates both formats into a scratch directory under RUNNER_TEMP and moves the XML into place. Writing the JSON into the output directory instead would leave behind a document the caller did not request, which the sbom-cyclonedx.* artifact glob would then collect.

Notes

  • The generated filenames follow the sbom-cyclonedx.* convention the organisation's reusable workflows consume (the sbom-files artifact contract)
  • The syft backend reads lockfiles/manifests without installing project dependencies, so generation is fast and needs no language toolchain. The cyclonedx backend necessarily gives up that property: resolving a Maven dependency graph requires Maven
  • For Python projects, prefer python-sbom-action: its environment-based generation gives higher-fidelity results for resolved Python dependency graphs
  • For Java projects, use backend: cyclonedx rather than the default. The syft backend will produce an SBOM for a Maven project, but one that omits transitive dependencies and reports BOM-managed versions as UNKNOWN

About

Generic action for SBOM generation across project types

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors