SBOM Generation
gadriel code sbom produces a Software Bill of Materials (SBOM) for the scanned
project. A single canonical component set is discovered once and then emitted in
two industry-standard formats, so the two artifacts can never disagree on what
was scanned.
Related pages: Reports & outputs · Compliance reporting · Troubleshooting.
Usage
bashgadriel code sbom [PATH] [--format both|spdx|cyclonedx] [--diff <git-ref>]
| Flag | Meaning |
|---|---|
PATH | Directory to scan (defaults to the current directory). |
--format | both (default), spdx, or cyclonedx. |
--diff <git-ref> | Compare against a prior SBOM snapshot (e.g. HEAD~1, a tag, or a short SHA). See Diffing. |
Outputs
Both files are written into .security/:
| File | Format | Spec version |
|---|---|---|
.security/sbom.spdx.json | SPDX | SPDX-2.3 (dataLicense: CC0-1.0) |
.security/sbom.cyclonedx.json | CycloneDX | 1.5 (bomFormat: CycloneDX) |
Both are generated from the same discovered component set, so a component
that appears in one always appears in the other with the same identity. The
CycloneDX bom-ref for each component is set equal to its PURL, which is also
the key used for diffing.
Enrichment: reachability and dependency health
Beyond a plain component inventory, Gadriel annotates each component with two signals unique to the tool. Because SPDX and CycloneDX model extension data differently, the same information is attached through each format's native mechanism.
CycloneDX — as components[*].properties[] name/value pairs:
| Property | Values |
|---|---|
gadriel:reachability | reachable · not-reachable · unknown |
gadriel:health_score | healthy · poor · abandoned · unknown |
gadriel:direct | true · false |
gadriel:dependency_type | direct · transitive |
gadriel:manifest | lockfile path the component came from |
gadriel:license_source | lockfile · bundled-pypi-top-N · online-pypi-pending (when known) |
SPDX — as a single OTHER annotation per package whose comment carries the
same fields (note the gadriel. dot namespace here, versus gadriel: colon in
CycloneDX):
gadriel.reachability=<v>; gadriel.health=<v>; gadriel.direct=<v>; gadriel.manifest=<v>
- Reachability answers "is this dependency actually reachable from your
code?" It is computed by building a call graph, linking lockfile dependencies
into it, and consulting a reachability oracle per dependency. If the graph
cannot be built, every component is left
unknown. - Dependency health is a categorical bucket (
healthy/poor/abandoned/unknown) rather than a numeric score.
Honest limits on enrichment. In the standalone gadriel code sbom path,
dependency health is currently always unknown (the metadata pipeline
that populates it has not yet landed), and reachability is only non-unknown
when an on-demand call-graph build succeeds — otherwise it, too, is
unknown. Populated reachability and health are surfaced most reliably when
the SBOM is produced as part of a full scan that has already built the graph.
Diffing against a prior snapshot
bashgadriel code sbom --diff HEAD~1
--diff compares the current component set against a previously stored SPDX
snapshot and prints what changed. Snapshots are written per commit to
.security/history/sbom-<commit-sha>.spdx.json (the short SHA comes from
git rev-parse --short HEAD, falling back to uncommitted).
Reference resolution: an exact sbom-<ref>.spdx.json match is tried first; if
the ref is HEAD or begins with HEAD~, the newest snapshot by modification
time is used. An unresolvable ref is an error.
The diff is keyed by PURL (with the version stripped, so version bumps are detected rather than reported as add+remove) and prints a summary plus per-change lines:
→ SBOM diff against HEAD~1 (.security/history/sbom-<sha>.spdx.json)
+N added, -N removed, ↑N version-bumped, ⟳N cve-changed
+ pkg:npm/left-pad@1.3.0 (cves=0)
- pkg:pypi/requests@2.28.1
↑ lodash 4.17.20 → 4.17.21 (resolved=1, introduced=0)
⟳ pkg:cargo/openssl@0.10.55 (resolved=2, introduced=1)
Change kinds are Added, Removed, VersionBumped, and CvesChanged.
Diff limits. Diffing is SPDX-only — there is no CycloneDX diff path — and there is no "reachability changed" section (a component whose reachability flipped is not reported as a change).
Ecosystem coverage
Nine ecosystems have a defined PURL type mapping:
| Ecosystem | PURL type | OSV name |
|---|---|---|
| npm | npm | npm |
| PyPI | pypi | PyPI |
| crates.io | cargo | crates.io |
| Go | golang | Go |
| Maven | maven | Maven |
| RubyGems | gem | RubyGems |
| NuGet | nuget | NuGet |
| Packagist | composer | Packagist |
| Conan | conan | ConanCenter |
Parser coverage caveat. All nine ecosystems are PURL-type-mappable, but the SCA scanner's lockfile parsers currently cover PyPI, npm, crates.io, and Go. The other five (Maven, RubyGems, NuGet, Packagist, Conan) are recognized as types but are not yet fed by dedicated lockfile parsers, so components in those ecosystems may not be discovered even though the type mapping exists.
Known limits
Gadriel favors correctness over guessing; several fields are deliberately conservative:
- License passthrough is best-effort, never inferred. When a license is
unknown, SPDX emits
NOASSERTIONfor bothlicenseConcludedandlicenseDeclared, and CycloneDX omits thelicenses[]array entirely. No SPDX-expression normalization is performed (that is considered an upstream SCA concern). PyPI license enrichment comes from a bundled top-N table only; online lookup is deferred and merely stampsonline-pypi-pending. - PURL namespace handling. The PURL itself (including scoped npm names like
@scope/pkg) is preserved inexternalRefs, and the diff tolerates namespace renames as long as the PURL is stable. However, the SPDXSPDXIDfor a scoped package is lossily sanitized (@and/become-), so the SPDXID is not a reliable round-trip of the original name — use the PURL for identity. supplieranddownloadLocationareNOASSERTIONin SPDX by default (download location is redacted as a secret-safety measure).- VEX. CycloneDX always serializes a
vulnerabilities[]array (empty when there are none) and stamps agadriel:vex_statusmetadata property (populated/empty/skipped-osv-bypass/skipped-no-snapshot).
Note on spec version. Some in-code doc comments and the originating design
document still reference CycloneDX 1.4; the shipped serializer emits
1.5. Trust the emitted specVersion field.
