SBOM formats
SPDX 2.3 vs. CycloneDX 1.7, when to pick which, ingest and conversion recipes.
Last updated September 16, 2026View source (v0.25.0)
Bomly reads and writes the two open SBOM standards used in production today. It writes SPDX 2.3 and CycloneDX 1.7, and ingests SPDX 2.3 plus CycloneDX 1.4 through 1.7.
What's an SBOM?
A Software Bill of Materials is a structured list of every package in a piece of software, with enough metadata (versions, licenses, suppliers, hashes) for an outside tool to make decisions about it. It is the dependency graph as a portable file.
You produce an SBOM once and consume it many times: in PR checks, in release artifacts, in supplier audits, in attestation pipelines.
Format comparison
| SPDX 2.3 | CycloneDX 1.7 | |
|---|---|---|
| Steward | Linux Foundation | OWASP |
| Primary use case | Software supply chain and license compliance | Component analysis and vulnerability management |
| Bomly write target | spdx | cyclonedx |
| Encoding | JSON (also Tag-Value and YAML upstream) | JSON (also XML upstream) |
| Vulnerability data | Add-on (SPDX 3.0) | First-class (vulnerabilities array) |
| File hashes | Yes | Yes |
| Relationship edges | Rich DESCRIBES, DEPENDS_ON, etc. | dependencies graph |
| Adoption | NTIA reference, ISO/IEC 5962 | OWASP standard, broad scanner support |
In practice: pick SPDX when a regulator or customer asks for it; pick CycloneDX when a vulnerability scanner is on the other end. Producing both is cheap.
Writing an SBOM
Use --format <format> for the primary stdout output, or -o <format>[=<path>] when you want an SBOM alongside another output. The format alone writes to stdout; format=path writes to a file:
# One format to stdout
bomly scan --format spdx
# One format to a file
bomly scan -o spdx=sbom.spdx.json
# Two formats in one scan
bomly scan \
-o spdx=sbom.spdx.json \
-o cyclonedx=sbom.cdx.json
# One format to stdout, one to a file
bomly scan -o spdx -o cyclonedx=sbom.cdx.json
Constraints:
- At most one
-omay omit=<path>. Two stdout outputs would collide. -o spdx=(empty path) is an error.- When every
-onames a file and--formatis not set, a successful run writes the files and prints nothing — add--format textif you also want the terminal report. --format spdx,--format cyclonedx,-o spdx, and-o cyclonedxare supported byscanonly.- Paths are resolved relative to the current working directory.
Ingesting an SBOM
Skip detection entirely and load an existing SBOM as input:
bomly scan --sbom --path ./vendor.spdx.json
This is fast, offline, and useful for:
- Auditing a vendor SBOM against your policy.
- Re-running policy on an SBOM you produced in a previous CI step.
- Diffing SBOMs across releases.
Format is auto-detected by content. The supported ingest formats are SPDX 2.3 JSON and CycloneDX 1.4–1.7 JSON; anything else is rejected as an unsupported format. Most SBOM producers, including Syft, can emit one of the supported formats directly (for example syft <target> -o spdx-json).
What Bomly refuses to import
An SBOM is refused when its JSON does not have a single unambiguous reading:
- A repeated object member name.
{"purl": "pkg:npm/a@1", "purl": "pkg:npm/b@1"}parses under most JSON readers, and which value wins depends on how the reader is written. Two tools can therefore read two different packages out of one file. For a document whose whole purpose is to state what you depend on, that is a smuggling vector. - Bytes that are not valid UTF-8. Readers usually substitute a replacement character, so what a consumer sees is not what the document carried.
- An escaped character that does not exist.
"\ud800"is half of a UTF-16 pair with no other half. The JSON standard (RFC 8259, section 8.2) says software receiving such a text behaves unpredictably: some readers substitute a replacement character, some fail, and none can agree on what the string was. Bomly refuses the document rather than guess.
All three are refused with an error naming the class, and the repeated member and its path or the byte offset of the bad sequence, so you can find the spot. The fix is to regenerate the document with a producer that emits each member once and writes every string as Unicode text — Bomly will not guess which reading you meant.
Checking for a repeated name means remembering the names already seen in an object, and holding them until that object closes. The check therefore costs memory in proportion to the names open at once — across every object currently being read, not just the widest one. Bomly refuses a document that would hold more than 100,000 of them, or more than 16 MB of them, rather than spend that memory — a count alone is not a size, since a few long names can outweigh very many short ones. Neither format produces anything close: components live in an array, so a document being read has a handful of small objects open at a time, however many components it lists. Passing the largest files through unchecked would put the gap exactly where a crafted one would aim.
A document is also refused when one of its components carries a package URL that cannot be made well-formed. Skipping that component would drop it and every relationship naming it, and a scan of what remained would report clean while a genuinely vulnerable dependency was simply absent from the answer. The error names the component so the fix can be made in the document.
Beyond those, nothing was tightened. A document with unknown members, unusual nesting, many thousands of components, or fields Bomly does not model still imports exactly as before.
What an ingested document keeps
Ingest reads more than package coordinates. What a source document asserted about each component is carried through the scan and written back out: supplier, originator or publisher, description, homepage, checksums, CPEs, and the document's own external references with the category and type it stated.
The document's claims about itself are kept too — its identity, name, data license, timestamp, credited people, organizations and tools, and its comment.
Every one of those values is untrusted input that Bomly re-publishes under its own name, so each is re-checked on the way in and on the way out. A value that cannot be published — a credential in a URL, a local path, a control character, a malformed CPE or digest — is dropped rather than passed along. Email addresses are never retained on a contact.
Diffing SBOMs
Compare two SBOM files without re-running detectors on either side:
bomly diff --sbom --base ./v1.0.spdx.json --head ./v1.1.spdx.json
Useful for release notes, supplier-update reviews, and CI checks on prebuilt SBOMs.
What Bomly puts in the SBOM
Both formats carry:
- Package name, version, PURL.
- Dependency relationships from the detector graph.
- File-level evidence when the detector provided it.
- Content hashes captured at detection time, when the ecosystem records them:
npm/pnpm/yarn/bun lockfile integrity values, Go module
go.sumtree hashes (theh1:SHA-256 dirhash, hex-encoded — the same convention cyclonedx-gomod uses), SHA-256 digests of GitHub Actions workflow and action manifests, and the pinned commit ID of SHA-pinned actions. Values are normalized to lowercase hex so they are schema-valid in both formats. - License identifiers normalized to the current SPDX license list: deprecated
ids such as
GPL-2.0are rewritten to their replacements (GPL-2.0-only) inside expressions, in both formats. See "How licenses are written" below. - The package namespace as CycloneDX
group— an npm scope (@scope), a Go module owner (github.com/google), a Maven group (org.apache.commons). It is taken from the PURL, so the two always agree. SPDX 2.3 has no equivalent field; there the namespace is carried inside the PURL. - An SPDX
primaryPackagePurposefor every package (LIBRARY for registry packages, APPLICATION for the primary component, and so on). - Remediation guidance on CycloneDX vulnerability entries: when enrichment
knows fixed versions, each vulnerability carries a
recommendation("Upgradeto "). No guidance is invented when no fix is known. SPDX 2.3 has no equivalent field. - Where each package came from, when its lockfile says: an exact download location or a source repository. See "Where a package came from" below.
How licenses are written
A license value can be a recognized SPDX identifier, a compound expression, or text that means nothing to a machine. The formats keep these apart and tools score them differently, so Bomly checks each value rather than guessing from where it came:
| The value | CycloneDX | SPDX 2.3 licenseDeclared |
|---|---|---|
One SPDX identifier (MIT) | license.id, spelled canonically | the identifier |
A compound expression (MIT OR Apache-2.0) | expression | the expression |
Anything else (see LICENSE file) | license.name, as free text | a LicenseRef-* identifier, with the original text in hasExtractedLicensingInfos |
| Nothing | no licenses key | NOASSERTION |
When a source records several licenses for one package, it is saying which licenses it found — not whether they all apply or whether you may choose between them. Bomly does not fill that gap in:
- CycloneDX lists them as separate entries, which asserts no relationship.
- SPDX 2.3 has only one expression field and no way to list licenses
without relating them, so it joins them with
AND. That overstates obligations rather than understating them, but it does say more than the source did. This is the one place the two formats differ on purpose.
When a source does state the relationship, it puts it in one value —
Apache-2.0 OR MIT is how registries record dual licensing — and both formats
publish that expression exactly as given. Dual-licensed packages are therefore
unaffected: Bomly never rewrites an OR into an AND.
SPDX licenseConcluded is always NOASSERTION. "Concluded" means the license
the document's author determined for themselves, usually by examining the
package's contents. Bomly reports what a lockfile or registry declares and
analyzes no contents, so it has nothing of its own to conclude — and SPDX
defines NOASSERTION for exactly that. The declared field still carries the
license, and reading the document back recovers it.
License data usually needs --enrich
Most lockfiles do not record licenses. Only npm and pnpm write them into the
lockfile, so for every other ecosystem — Go, Maven, Python, Cargo, and the rest
— a plain bomly scan -o cyclonedx=... produces components with no license
data at all (SPDX writes NOASSERTION).
Add --enrich to fill them in from the deps.dev license matcher:
bomly scan --enrich -o cyclonedx=bom.cdx.json -o spdx=bom.spdx.json
This matters for compliance review. Third-party SBOM quality checkers — the
CRA-oriented profiles among them — report missing license data as an error, and
a reviewer reading the file cannot tell "no license recorded" apart from "not
looked up". If you are producing an SBOM to hand to someone else, run it with
--enrich.
Where a package came from
Each detector reports the origin of the packages it resolves, reading the field its own lockfile records it in. Bomly does not infer origin from the shape of a URL, because the same string means different things in different ecosystems.
A detector reports one of two things, or nothing at all:
| What the lockfile records | SPDX 2.3 | CycloneDX |
|---|---|---|
| The exact file the package was fetched from | downloadLocation | distribution external reference |
| The repository it was resolved from, and the commit when the lockfile pinned one | downloadLocation as git+<url>, with @<revision> when pinned; sourceInfo when a download location is already taken by an artifact | vcs external reference (URL only) |
| Neither | NOASSERTION | no reference |
CycloneDX external references have no field for a revision, so the commit a detector resolved appears only in the SPDX form.
What each ecosystem yields:
- npm, pnpm, yarn, bun — the registry tarball recorded in the lockfile. Yarn Classic appends the package checksum to that URL; it identifies contents rather than a location, so it is dropped. pnpm v9 entries that record only an integrity hash report nothing.
- uv, poetry, pipenv, pip — a repository plus the commit that was locked, or a direct archive URL, depending on the recorded source type.
- cargo, Bundler, SwiftPM, pub — the repository and resolved commit for
git dependencies and source-control pins. SwiftPM and pub report the same
origin whether the build tool ran or Bomly read the committed file: the tools
do not print a commit, so it is read back from
Package.resolvedandpubspec.lock. - Go modules, Maven, Gradle, NuGet, and the other detectors — nothing yet; their manifests do not record a per-package location.
- Packages found by Syft, and packages read from an ingested SBOM, carry no detector origin.
With --enrich, a package can also get a repository it has no lockfile claim
to. The OpenSSF Scorecard matcher resolves a canonical source repository from a
package's identity, and that repository is used whenever the detector reported
no repository of its own — including for the ecosystems listed above as
yielding nothing, for Syft-detected packages, and for packages that already
have a download location. An artifact and a repository answer different
questions (which file was fetched, where the source lives), so a package can
carry both: the artifact stays the SPDX downloadLocation and the CycloneDX
distribution reference, while the repository becomes the vcs reference and,
in SPDX, the package's source info. Only a detector-asserted repository
displaces it.
It is a network lookup keyed on package identity rather than a claim any
manifest made, so it is weaker evidence, and no revision is attached: a
Scorecard repository names a project, not a resolved commit. Without
--enrich, or without the scorecard matcher selected, nothing of this kind
appears.
Four kinds of value are never published, in any ecosystem:
- Registry and index roots (
https://rubygems.org/,https://pub.dev, the crates.io index). They say where an ecosystem fetches from, not where a package came from — and once out of context, a private server URL is indistinguishable from a repository. - Local paths — workspace members, editable installs,
file:andpath:dependencies. These describe the machine that ran the scan. - Non-web locations —
ssh://,git@host:org/repo, and similar remotes that name a transport rather than a fetchable address. - URLs carrying credentials. A lockfile pointing at a private registry can embed a token; publishing it in an SBOM would leak a live secret.
Every origin a detector reports is an absolute http/https URL with a host, a
non-empty path, and no embedded credentials. Values are re-serialized from a
parse rather than copied from the lockfile, and the same check runs again at
export, so origin supplied by a plugin is held to the same rule as origin from a
built-in detector. SPDX then composes the validated repository URL into its
git+<url>@<revision> locator form, which is the only place a revision
appears.
Document identity
Every generated document carries a stable identity:
- A generated
urn:uuidserial number (CycloneDXserialNumber; the same nonce forms the SPDX document namespace, so the two exports of one scan are correlatable). Two cases differ, and both apply only when the scan read SBOMs rather than lockfiles:- One source document (a conversion). The output keeps that document's identity instead of minting a new one, because it restates one document rather than describing a new subject. This is what makes export → ingest → export reproduce the same bytes. A CycloneDX serial can only hold a UUID URN, so an SPDX namespace that is not one is linked instead of adopted (see below). The document name is unaffected: it stays the scanned project's name.
- Several source documents (a merge). The output mints its own identity
— both formats give a document exactly one, and adopting a source's would
name a document that is not this one — and links each source. CycloneDX
writes a document-level external reference of type
bom, carrying a BOM-Link (urn:cdx:<serial>/<version>) for a CycloneDX source or the namespace URI for an SPDX one. SPDX writes anexternalDocumentRefsentry naming the same identity, with a SHA-256 checksum over the source document's bytes, which the specification requires on every entry; the CycloneDX reference carries that checksum too, so the link stays usable if the merged document is later converted to SPDX. The checksum is computed while the source document is being read, because it cannot be recovered from the parsed model afterwards — a source that reached Bomly without those bytes is left unnamed in SPDX rather than written as an invalid reference. People and tools credited by any source are credited by the merged document too. - Source links are read back on import, so provenance survives more than one conversion: converting a merged document again produces a document that still names the documents behind it, in either format.
- The producing tool with its version (CycloneDX
metadata.tools[]; SPDXCreator: Tool: bomly-cli-<version>), plus one tool entry per detector that contributed to the graph. - A project version on the primary component and the project's own
(first-party) modules: the
--refvalue for remote scans, orgit describe --tags --always --dirtyfor local checkouts. When neither is available the version is omitted rather than invented. - A CycloneDX lifecycle phase (
pre-buildfor source scans,post-buildfor container images) and a composition completeness declaration:completefor unfiltered, warning-free scans,incompletewhen a--scopefilter dropped part of the graph,unknownwhen resolution was degraded. SPDX 2.3 has no equivalent fields. - A primary component describing the scanned project. When the dependency
graph has a single root, that root is the primary component. When a scan
discovers multiple manifests (several ecosystems, several workflow files),
Bomly synthesizes a primary component named after the scanned project with a
pkg:genericPURL; it depends on every graph root, so the exported dependency graph is connected and both formats agree on the document's subject. The synthesized component is not repeated in the CycloneDX component inventory, and Bomly skips it when re-ingesting its own SBOMs.
Provenance metadata (EU CRA readiness)
The optional sbom config section embeds producer metadata that regulated
consumers (for example the EU Cyber Resilience Act's SBOM expectations) ask
for:
sbom:
manufacturer: "Example Org" # CRA Art. 13(15)
security_contact: "security@example.com" # CRA Art. 13(6)
vulnerability_disclosure_url: "https://example.com/security" # Art. 13(7)
support_end: "2030-12-31" # CRA Art. 13(8)
CycloneDX: metadata.manufacturer, security-contact / advisories
external references on the primary component, and a bomly:support_end_date
metadata property. SPDX 2.3 has no first-class fields for most of these, so
Bomly emits an Organization creator, the supplier on the primary package,
and the contact fields in the creation-info comment.
When manufacturer is set, it becomes the supplier of the primary component
in both formats (CycloneDX metadata.manufacturer, SPDX PackageSupplier on
the package the document DESCRIBES). Supplier is not defaulted to anything
when the field is unset, and per-component supplier and description data is
never invented: those fields stay absent unless a data source actually
provides them. Third-party CRA profile checks will flag the missing
manufacturer/contact metadata until the sbom section is configured.
When --enrich is set, components are enriched from the matching-stage package
registry (keyed by PURL):
- Licenses learned during matching (preferred over detection-time licenses).
- Content digests as component hashes (CycloneDX
hashes, SPDXchecksums). - CPEs (CycloneDX
cpe, SPDXSECURITY/cpe23Typeexternal references). - Vulnerabilities — CycloneDX as a first-class
vulnerabilitiesarray (ratings, CWEs, advisories,affects); SPDX asSECURITY/advisoryexternal references. - End-of-life status (CycloneDX
bomly:eol*properties, SPDX package comment). - A source repository resolved by the OpenSSF Scorecard matcher, used only when the detector reported no repository of its own (see "Where a package came from" above).
Reachability annotations and other Bomly-specific metadata are emitted in the JSON output (--json or --format json), not in the standard SBOM formats. See Output formats.
Preservation and conversion limits
Bomly preserves component identity (including PURL), dependency edges, roots, scope, package type, licenses, digests, CPEs, and the enrichment fields described above when the destination format has an equivalent representation. An ingested document keeps more than that list — supplier, originator, description, homepage, copyright, its own external references, and the document's own assertions all survive the round trip; see What an ingested document keeps for the full set. Encoding is deterministic when the scan timestamp and document identifiers are fixed.
Some information necessarily becomes less specific during conversion:
- Vulnerabilities are written but never read back. A CycloneDX export carries
ratings, CWEs, affected component references, descriptions, and advisory
URLs; an SPDX 2.3 export carries each vulnerability as a package security
advisory reference. Neither format's importer builds vulnerability records,
so converting a document does not carry its vulnerabilities across — Bomly
re-derives them by scanning with
--enrich. What does survive an SPDX round trip is the advisory reference itself, because it is preserved as an ordinary external reference like any other the source stated. - Scope is a set in Bomly and a single value in both formats. A package
reachable from both a runtime and a development root carries both scopes, so
each format gets Bomly's projection in its native field — runtime wins a
mixed set and maps to CycloneDX
required, development-only maps toexcluded— with the full set written beside it in a carrier the importer prefers: abomly:scopesCycloneDX property, and thescope=field of the SPDX package comment. A Bomly document therefore round-trips its scope set exactly, and a document from any other producer still yields a usable scope. Reading the other way, CycloneDXrequiredbecomes runtime, whileoptionalandexcludedbecome development — the specification defines an optional component as one that is not installed or otherwise reachable, so it is absent from what runs. - A scope carrier naming a token this build does not recognize keeps the scopes it does recognize. The carrier is Bomly's own, so an unreadable token is almost always one a newer Bomly wrote; refusing the whole value would have left the component unscoped, which in SPDX — where there is no native scope field to fall back on — meant losing the scope entirely. The tokens that were not read are reported as a warning naming the file.
- A source document's own scope word is preserved and written back. A
component a CycloneDX document marked
optionalre-exports asoptional, not as Bomly's projection of the set it derives — sooptionalandexcludeddo not collapse intorequiredacross a round trip that asserted neither. The word yields to the projection when Bomly's own scope set stops meaning what the word meant, for example once the package turns out to be reachable from a development root as well. SPDX 2.3 has no scope field, so the word is carried only through a CycloneDX export; an SPDX document still carries the full set in its package comment. - Package origin is written on export but not read back on ingest: scanning an
SBOM produces packages with no origin, so re-exporting that graph emits
NOASSERTIONand no distribution or vcs reference. Origin comes from a lockfile, and an ingested document is not one. This is why references of typedistributionandvcsare the two Bomly does not preserve from a source document: they are the shape Bomly's own origin export takes, and nothing in a document distinguishes Bomly's emission from a third party's — so reading them back would let a detector's guess about where a package came from re-enter as though the document had asserted it. SPDX drops a third type for the same reason — Bomly's ownbomly-package-originreference — and re-derives thepurland CPE references from the component's identity and CPE list, so ingesting them would grow the document on every round trip. Every other reference type is preserved with the category and type the source stated. - Bomly relationship confidence (
direct,transitive, orunknown), source provenance, reachability analysis, policy findings, and run diagnostics are report data rather than portable SBOM fields. Use JSON when those distinctions must survive export and import. - A CycloneDX document has one metadata component. When an input graph has multiple roots, every root remains in the dependency graph and the synthesized primary component (see "Document identity" above) links them. An export that names no project falls back to the first deterministic root instead; the internal benchmark is the only caller that does. The primary component is written with the same detail as an inventory entry, so a package that is both the document's subject and a component describes itself the same way in both places.
- A conversion is a fixed point within a format, not across one. A CycloneDX serial can hold only a UUID URN, so an SPDX source converted to CycloneDX is linked rather than adopted, and converting back produces a different document. See "Document identity" above for how sources are named.
- The CycloneDX
groupnamespace survives a CycloneDX round trip. SPDX 2.3 has no group field, so an SPDX round trip recovers the namespace only from the PURL. - SPDX 2.3 holds one license expression per package, so several licenses are
composed with
ANDthere while CycloneDX lists them (see "How licenses are written" above). Every license is kept either way: a value SPDX cannot hold verbatim becomes aLicenseRef-*, which is a valid expression element, so a mixed set composes rather than losing its members. Reading an SPDX document back gives one license per package — the composed expression, taken from the concluded field when the document states both a concluded and a declared license. A CycloneDX list that went out asA AND Btherefore returns as the single expressionA AND Brather than as two entries. - End-of-life data is written but never read back. Both formats carry it —
bomly:eolproperties in CycloneDX,eol=andeol_date=in the SPDX package comment — and neither importer looks for it, so a Bomly document converted through Bomly loses its EOL fields. Re-run with--enrichto restore them. - A component's own identifier does not survive. Ingest re-mints each node's
identity from its package URL, so the source document's
bom-reforSPDXIDis not carried into the graph and not re-emitted. Anything that compares identifiers across a conversion should compare package URLs instead. - SPDX relationships are flattened on export. Ingest accepts the whole
*_DEPENDENCY_OFfamily, but export writes onlyDEPENDS_ONandDESCRIBES, so a source document'sDEV_DEPENDENCY_OFcomes back asDEPENDS_ON. The development scope itself is preserved separately, through the scope carrier described above. - An SPDX scope carrier that Bomly cannot fully read leaves the component
unscoped. The
scope=comment field decodes all-or-nothing, so one unknown token beside a known one yields no scope at all, and SPDX — unlike CycloneDX — has no native scope field to fall back on (tracked as bomly-dev/bomly-sdk#64).
Before treating a generated file as a release artifact, validate it with the standard validator required by the receiving system. Bomly's tests parse every emitted target back through the corresponding typed codec and exercise round-trip identity and edge preservation; receiving systems can impose additional profile rules beyond the base format.
Format conversion
To convert between formats, run a scan and emit both in one pass:
bomly scan --sbom --path ./in.spdx.json --format cyclonedx > out.cdx.json
Bomly does not advertise a one-shot convert command — the scan pipeline is the conversion path.
See also
- Scan targets — every input Bomly accepts
- Output formats — text, JSON, SARIF, SBOM details
- SBOM detector — ingest specifics