Most core SBOM data in SPDX 3.0.1 maps cleanly to CycloneDX 1.7: names, versions, purls, suppliers, hashes, dependency edges, license expressions and basic VEX status. What gets lost is the SPDX graph: typed relationships, per element creation data, lifecycle scopes and most AI and Dataset fields. No standards body tool converts between them directly, so map, validate and diff yourself.
Key takeaways
- Document fields map almost one to one, with two catches: SPDX
createdmay be a commit date, and an SPDX document IRI is not a valid CycloneDXserialNumber. - Package fields map well. Losses sit at the edges: extra CPEs, some hash algorithms,
builtTime,releaseTime,validUntilTime,supportLevelandadditionalPurpose. - SPDX 3.0.1 has 59 relationship types. CycloneDX 1.7 has
dependsOnandprovides, plus nesting, pedigree and external references. - Declared and concluded licenses map directly through the
acknowledgementfield added in CycloneDX 1.6. - VEX status maps; VEX justifications do not map one to one.
- Much of the AI and Dataset profile detail has no CycloneDX field.
- The CycloneDX CLI converts SPDX JSON 2.3, protobom lists SPDX 3.0 as planned, and SPDX tools-java converts SPDX 2 to 3.0.1. Build a small mapper, validate against the 1.7 schema and diff a round trip.
If you want the short version of SPDX and CycloneDX conversion first, read Can you convert an SBOM between SPDX and CycloneDX without losing data?. For whether to convert at all, see convert SPDX to CycloneDX at intake or keep both formats. This post goes field by field.
Why SPDX 3 to CycloneDX is a different problem from SPDX 2.3
Most published mapping work was built for SPDX 2.x, where a document held packages, files and a relationship list under one set of creation fields. That shape is close to a CycloneDX BOM.
SPDX 3.0 rebuilt the model. Everything is an Element with a globally unique IRI (spdxId). An SpdxDocument is one kind of element collection, independent of any serialization. Each element points to its own CreationInfo. Relationships are elements with their own IDs, a completeness value and optional startTime and endTime. VEX statements are relationship subclasses, and licenses are elements linked by hasDeclaredLicense and hasConcludedLicense.
CycloneDX 1.7 stayed a document oriented tree: one metadata block, nestable components, a dependencies array keyed by bom-ref, plus compositions, vulnerabilities, services, formulation, declarations, definitions, citations and annotations. Mapping a graph onto a tree is where most of the loss comes from.
Version timeline
Both formats moved in the last two years, and the next versions are already visible. Dates below come from the standards bodies' own release pages.
| Date | Release | What it means for mapping |
|---|---|---|
| 15 Apr 2024 | SPDX 3.0 released on GitHub | New element and relationship graph, profiles (Core, Software, Security, Licensing, AI, Dataset, Build, Lite, Extension) |
| 9 Apr 2024 | CycloneDX 1.6 | Adds CBOM, attestations (CDXA) and the license acknowledgement field |
| 1 Jul 2024 | ECMA-424 1st edition | CycloneDX newsroom announces CycloneDX 1.6 as an Ecma International standard |
| 17 Dec 2024 | SPDX 3.0.1 release published on GitHub | Current SPDX 3 release; the model pages and JSON schema used in this post |
| 21 Oct 2025 | CycloneDX 1.7 | Citations, patents, expanded cryptography registry, distributionConstraints (TLP), license expressionDetails; announced as the final 1.x release |
| Dec 2025 | ECMA-424 2nd edition | Defines CycloneDX 1.7 |
| 24 Jan 2026 | SPDX 3.1 RC1 | Release candidate; spdx.dev says the 3.1 model expands to safety, service, hardware, supply chain and operations. Not final, may change |
| 5 Aug 2026 | CycloneDX 2.0 announced | CycloneDX newsroom: release expected fall 2026, Ecma ratification in December |
CycloneDX 1.7 is a stable target: the last 1.x schema and an Ecma standard. SPDX 3.1 adds a services area, which will narrow one reverse direction gap. Record the versions you used in your conversion notes.
Document level fields: what maps one to one
The SPDX 3.0.1 CreationInfo class has five properties: specVersion (required), created (required), createdBy (one or more agents), createdUsing (tools) and comment. SpdxDocument adds dataLicense, import, namespaceMap and inherits element, rootElement and profileConformance.
| SPDX 3.0.1 | CycloneDX 1.7 | Fidelity | Notes |
|---|---|---|---|
CreationInfo.specVersion | specVersion | Replaced | Each side states its own format version. Record the source version in a property |
CreationInfo.created | metadata.timestamp | Meaning shift | SPDX notes created is often the date of last change, such as a git commit date, to support reproducible builds. CycloneDX describes the timestamp as when the BOM was created |
createdBy (Person) | metadata.authors | Good | CycloneDX describes authors as the person(s) who created the BOM |
createdBy (Organization) | metadata.manufacturer | Partial | CycloneDX suggests manufacturer for BOMs created by automated means. It holds one organization, not a list |
createdBy (SoftwareAgent) | metadata.tools.services or tools.components | Judgement | Pick one convention and document it |
createdUsing (Tool) | metadata.tools.components | Good | Tool versions in SPDX are often folded into the name. Split them if you can |
SpdxDocument.spdxId (an IRI) | serialNumber | No direct match | serialNumber must match urn:uuid followed by an RFC 4122 UUID. Derive a UUIDv5 from the IRI so the result is repeatable, and keep the IRI in a property |
| (none) | version (integer BOM version) | Gap | SPDX 3 has no BOM revision counter. Start at 1 |
rootElement | metadata.component | Partial | SPDX allows several roots; CycloneDX holds one. Extra roots become top level components |
profileConformance | (none) | Lost | Keep as a property if downstream tools care |
dataLicense | metadata.licenses | Good when present | CycloneDX describes metadata.licenses as the license for the BOM document itself |
namespaceMap | (none) | Lost | Only needed to shorten IRIs; harmless to drop once IRIs are expanded |
import (ExternalMap) | externalReferences of type bom, or BOM-Link | Partial | SPDX imports elements defined elsewhere. CycloneDX can point at another BOM but does not import its elements |
Per element creationInfo | (none) | Lost | CycloneDX has one metadata block. If elements carry different creators, you have to choose |
The serialNumber row catches people often. The schema enforces the urn:uuid pattern, so an https:// IRI fails validation. A UUIDv5 over the document IRI gives the same serial number on every run, which matters because CycloneDX tells consumers to prefer the highest version for a given serial number.
Package and component fields
In SPDX 3.0.1 a software_Package inherits from SoftwareArtifact, Artifact and Element, so its fields come from four classes. The CycloneDX 1.7 component object has 36 properties.
| SPDX 3.0.1 Package | CycloneDX 1.7 component | Fidelity | Notes |
|---|---|---|---|
spdxId | bom-ref | Good | Any string is a valid bom-ref. Reusing the IRI keeps traceability |
name | name | Exact | Required on both sides |
packageVersion | version | Exact | CycloneDX 1.7 also has versionRange, which SPDX lacks |
packageUrl | purl | Exact | |
suppliedBy (Agent) | supplier (organizationalEntity) | Good | CycloneDX notes the supplier may be a distributor or repackager |
originatedBy (Agents, many) | manufacturer or authors | Partial | manufacturer holds one organization; persons go to authors. author (singular) is deprecated in 1.7 |
externalIdentifier cpe22 or cpe23 | cpe | Partial | CycloneDX holds one CPE string. Extra CPEs can go into evidence.identity |
externalIdentifier swhid | swhid | Good | |
externalIdentifier gitoid, or contentIdentifier gitoid | omniborId | Good | SPDX recommends contentIdentifier for artifact gitoids |
externalIdentifier swid | swid | Partial | CycloneDX swid requires tagId and name; an SPDX identifier string alone is not enough |
verifiedUsing Hash | hashes | Mostly good | See the algorithm table below |
downloadLocation | externalReferences type distribution | Good | |
homePage | externalReferences type website | Good | |
copyrightText | copyright | Exact | |
attributionText | (none) | Lost | Keep as a property or in a NOTICE file |
sourceInfo | (none) | Lost | Keep as a property or in description |
primaryPurpose | type | Partial | Vocabulary differs; see below |
additionalPurpose | (none) | Lost | CycloneDX has a single type |
builtTime, releaseTime, validUntilTime | (none on component) | Lost | Properties, or releaseNotes.timestamp for release time if you build release notes |
supportLevel | (none) | Lost | Properties |
standardName | (none on component) | Lost | CycloneDX 1.7 models standards under definitions.standards, which is a different construct |
summary, description, comment | description | Partial | Three fields collapse into one |
Identifiers: purl, CPE, SWHID and gitoid
SPDX 3.0.1 ExternalIdentifierType has 11 values, from cpe22 and cpe23 to swhid, swid and gitoid. CycloneDX gives purl, CPE, OmniBOR and SWHID their own fields. The common loss is the second CPE, which can break CPE based matching for C, C++ and firmware components.
Hash algorithms
SPDX 3.0.1 lists 22 hash algorithm values; CycloneDX 1.7 lists 14.
SPDX 3.0.1 algorithm | CycloneDX 1.7 alg |
|---|---|
md5, sha1, sha256, sha384, sha512 | MD5, SHA-1, SHA-256, SHA-384, SHA-512 |
sha3_256, sha3_384, sha3_512 | SHA3-256, SHA3-384, SHA3-512 |
blake2b256, blake2b384, blake2b512, blake3 | BLAKE2b-256, BLAKE2b-384, BLAKE2b-512, BLAKE3 |
sha224, sha3_224, md2, md4, md6, adler32 | No enum value: lost |
crystalsKyber, crystalsDilithium, falcon, other | No enum value: lost |
| No SPDX value | Streebog-256, Streebog-512 |
Purpose to component type
SPDX 3.0.1 SoftwarePurpose has 29 values; CycloneDX 1.7 component type has 13. Direct pairs include application, framework, library, container, platform, device, firmware and file, plus operatingSystem, deviceDriver, model and data to their hyphenated or renamed CycloneDX forms. Purposes such as archive, diskImage, install, module, patch, source and test need a fallback type plus a property recording the original.
Relationships, the dependency graph and compositions
SPDX 3.0.1 RelationshipType has 59 values. Grouped by family, here is where each lands.
| SPDX 3.0.1 relationship family | Examples | CycloneDX 1.7 landing spot | Fidelity |
|---|---|---|---|
| Runtime and build dependencies | dependsOn, hasOptionalDependency, hasProvidedDependency, hasStaticLink, hasDynamicLink, hasPrerequisite | dependencies[].dependsOn | Edge kept, edge type lost. Optional can become scope: optional on the target |
| Containment | contains, hasDataFile, expandsTo, packagedBy | Nested components[].components or dependsOn | Good if you nest; nesting is a tree, so shared children duplicate |
| Description | describes | metadata.component | Good for one root |
| Lineage | ancestorOf, descendantOf, hasVariant, copiedTo, patchedBy, modifiedBy, amendedBy | pedigree.ancestors, descendants, variants, patches | Partial; pedigree embeds components instead of pointing at them |
| Implementation | (no SPDX type) | dependencies[].provides | CycloneDX only; added for things like a crypto library that implements an algorithm |
| Licensing | hasDeclaredLicense, hasConcludedLicense | licenses[] with acknowledgement | Good |
| Security and VEX | affects, doesNotAffect, fixedIn, underInvestigationFor, hasAssessmentFor, hasAssociatedVulnerability, foundBy, reportedBy, coordinatedBy, exploitCreatedBy, fixedBy, publishedBy, republishedBy | vulnerabilities[] with affects, analysis, credits, source | Status good; agent roles partial |
| Build and provenance | generates, hasInput, hasOutput, invokedBy, delegatedTo, usesTool, configures, hasHost | formulation (workflows, tasks, steps) | Different model; usually kept as properties or dropped |
| Documentation and evidence | hasDocumentation, hasSpecification, hasRequirement, hasEvidence, hasExample, hasMetadata, hasDependencyManifest, hasDistributionArtifact | externalReferences (documentation, evidence and so on) | Partial; the target must be a URL, not an element |
| Testing | hasTest, hasTestCase, testedOn | (none) | Lost unless kept as properties |
| AI and data | trainedOn, testedOn | modelCard.modelParameters.datasets (by ref) | Partial |
| File change | hasAddedFile, hasDeletedFile | pedigree.patches or diff | Partial |
| Catch all | other, availableFrom, serializedInArtifact, hasOptionalComponent | Properties | Judgement |
Two facts drive the table. CycloneDX's dependency object has exactly three properties: ref, dependsOn and provides. And both ref and dependsOn entries are plain bom-ref references inside the same BOM, so an SPDX edge to an element brought in through import has no target after conversion.
Completeness versus compositions
SPDX 3.0.1 puts completeness on each relationship: complete, incomplete or noAssertion. The NoneElement individual in to says no such relationship exists. CycloneDX 1.7 states completeness separately in compositions, each with an aggregate value (complete, incomplete, six first and third party variants, unknown, not_specified) and a list of assemblies, dependencies or vulnerabilities. A complete SPDX dependsOn becomes a dependency entry plus a complete composition naming the from component. A converter that skips compositions turns "we know this list is complete" into "nobody said".
Lifecycle scope and time bounds
SPDX 3.0.1 LifecycleScopedRelationship carries a scope of design, development, build, test, runtime or other. CycloneDX 1.7 has no per edge lifecycle; the closest tools are component scope (required, optional, excluded) and document level metadata.lifecycles. A build only dependency becomes an ordinary dependency unless you split BOMs by phase. Time bounds are lost.
Licensing: declared versus concluded, expressions and LicenseRef
SPDX 3.0.1 SimpleLicensing holds LicenseExpression (licenseExpression, licenseListVersion, customIdToUri); ExpandedLicensing models expressions as objects. CycloneDX 1.7 licenses items are either a license object or an expression object, each with acknowledgement set to declared or concluded.
| SPDX 3.0.1 | CycloneDX 1.7 | Notes |
|---|---|---|
hasDeclaredLicense to a LicenseExpression | licenses[] item with expression and acknowledgement: declared | Exact, if both sides use valid SPDX expression syntax |
hasConcludedLicense | Same, with acknowledgement: concluded | Exact |
Single listed license, such as MIT | license.id or expression | license.id is checked against the SPDX license list enum in CycloneDX's bundled spdx.schema.json |
LicenseRef-acme-eula inside an expression | expression (valid) or license.name | LicenseRef identifiers are not valid license.id values; they belong in an expression or a named license |
customIdToUri | expressionDetails[].licenseIdentifier with a URL, or license.url | New in 1.7; older converters drop it |
licenseListVersion | (none) | Lost. Matters when an identifier was deprecated between list versions |
SimpleLicensingText (full text) | license.text | Good |
| ExpandedLicensing object trees | Flatten to an expression string | Structure lost; meaning kept if serialized correctly |
NoAssertionLicense, NoneLicense individuals | Omit licenses, or a property | No exact CycloneDX equivalent for "the creator asserts none" |
dataLicense on the document | metadata.licenses | Good |
Output that puts a LicenseRef into license.id fails CycloneDX 1.7 validation, so make validation a gate. For the license side in brief, see the SPDX vs CycloneDX cheat sheet.
Vulnerabilities and VEX: Security profile to CycloneDX vulnerabilities
The SPDX 3.0.1 Security profile attaches assessments to a Vulnerability element as relationship subclasses: VEX affected, not affected, fixed and under investigation, plus CVSS v2, v3 and v4, EPSS, SSVC and exploit catalog assessments. CycloneDX 1.7 keeps a top level vulnerabilities array whose analysis object holds state, justification, response, detail, firstIssued and lastUpdated.
Status
| SPDX 3.0.1 class | CycloneDX 1.7 analysis.state | Notes |
|---|---|---|
| VexAffected | exploitable | actionStatement goes to recommendation or analysis.detail; response is an enum (can_not_fix, will_not_fix, update, rollback, workaround_available) that you must infer |
| VexNotAffected | not_affected | Justification mapping below |
| VexFixed | resolved | |
| VexUnderInvestigation | in_triage | |
| (none) | false_positive | CycloneDX only; no SPDX status on the way back |
| (none) | resolved_with_pedigree | CycloneDX only; means the fix is shown in the component's pedigree |
Justifications
SPDX 3.0.1 has five justification values; CycloneDX 1.7 has nine. The mapping below is our reading of the two definitions, not a published crosswalk.
SPDX 3.0.1 justificationType | Closest CycloneDX 1.7 justification | Fit |
|---|---|---|
componentNotPresent | code_not_present | Approximate; CycloneDX usually expresses an absent component by not listing it |
vulnerableCodeNotPresent | code_not_present | Good |
vulnerableCodeNotInExecutePath | code_not_reachable | Good |
vulnerableCodeCannotBeControlledByAdversary | No clean match; requires_configuration or requires_environment depending on the reason | Weak; keep the impactStatement text |
inlineMitigationsAlreadyExist | protected_by_mitigating_control, protected_at_runtime or protected_by_compiler | One to many; needs the impact statement to choose |
On the way back, CycloneDX's requires_dependency and protected_at_perimeter have no SPDX value. A tool that guesses will turn a precise statement into a vague one.
Scores, exploitation and timestamps
CVSS maps to ratings[] with method CVSSv2, CVSSv3, CVSSv31 or CVSSv4; SSVC to method SSVC. EPSS has no scoreMethod and exploit catalog listings have no field, so both become other ratings, advisories or properties. publishedTime and modifiedTime map to firstIssued and lastUpdated; withdrawnTime, vexVersion and per statement suppliedBy are lost. If VEX is your main reason to convert, keep the supplier's statements in their original format and load them separately.
AI and Dataset profiles to modelCard and data components
SPDX 3.0.1 ai_AIPackage adds fields such as typeOfModel, hyperparameter, metric, limitation, energyConsumption, safetyRiskAssessment and autonomyType. CycloneDX 1.7 uses a machine-learning-model component with a modelCard of modelParameters, quantitativeAnalysis and considerations.
| SPDX 3.0.1 AIPackage | CycloneDX 1.7 | Fidelity |
|---|---|---|
| Package core fields | component of type machine-learning-model | Good |
typeOfModel | modelParameters.architectureFamily or modelArchitecture | Partial |
informationAboutTraining | modelParameters.approach.type (enum: supervised, unsupervised, reinforcement-learning, semi-supervised, self-supervised) plus free text in a property | Partial |
domain, informationAboutApplication | considerations.useCases | Partial |
limitation | considerations.technicalLimitations | Good |
metric | quantitativeAnalysis.performanceMetrics[] (type, value) | Good |
metricDecisionThreshold | (none) | Lost; properties |
energyConsumption | considerations.environmentalConsiderations.energyConsumptions | Partial; the two energy models differ |
trainedOn relationship to a DatasetPackage | modelParameters.datasets[] with ref | Good |
hyperparameter | (none) | Lost; properties |
modelDataPreprocessing, modelExplainability | (none) | Lost |
safetyRiskAssessment | (none) | Lost; ethicalConsiderations is not equivalent |
autonomyType, useSensitivePersonalInformation | (none) | Lost |
standardCompliance | (none on the model card) | Lost |
For datasets, CycloneDX 1.7 uses the data array of a component of type data (the schema says it must not appear on other types). Each entry has type, name, contents, classification, sensitiveData, graphics, description and governance.
| SPDX 3.0.1 DatasetPackage | CycloneDX 1.7 | Fidelity |
|---|---|---|
| Core package fields | component of type data | Good |
datasetType | data[].type is dataset; the SPDX dataset kind goes to a property | Partial |
confidentialityLevel | data[].classification | Partial |
hasSensitivePersonalInformation | data[].sensitiveData | Partial |
intendedUse, dataCollectionProcess | data[].description | Collapsed |
knownBias | Model level fairnessAssessments at best | Weak |
anonymizationMethodUsed, datasetSize, datasetAvailability, datasetNoise, datasetUpdateMechanism, sensor | (none) | Lost |
originatedBy | data[].governance.owners | Judgement |
If you need these fields for an EU AI Act or procurement question, keep the SPDX original as the record.
What CycloneDX 1.7 carries that SPDX 3.0.1 cannot receive
These CycloneDX 1.7 areas have no SPDX 3.0.1 home, so they vanish on a round trip:
services(SaaSBOM): endpoints, data flows, trust zones. SPDX 3.1 RC1 adds a services area; 3.0.1 does not have one.formulation: how the product was built, as workflows, tasks and steps. SPDX's Build profile overlaps but is shaped differently.declarationsanddefinitions: attestations, claims, evidence and the standards they refer to.citations(new in 1.7): who supplied which piece of data, through which process.- Patents and patent families (new in 1.7).
cryptoProperties(CBOM) and the 1.7 cryptography registry of algorithm families and elliptic curves.metadata.distributionConstraints.tlp, the Traffic Light Protocol label.evidence.occurrences,callstackand identity evidence with confidence values.- Component
scope,isExternalandversionRange. - The integer BOM
versionand JSON signatures (JSF).
The SPDX cdx2spdx README says unmapped CycloneDX properties become SPDX annotations of type OTHER with a comment starting MISSING_CDX_PROPERTY:, which gives a searchable record of the loss.
One SBOM in both formats
Here is the same small SBOM in both formats: an application, acme-web 2.4.0, that depends on lodash 4.17.21, with declared and concluded MIT licenses, a SHA-512 hash and a VEX fixed statement for CVE-2021-23337 (OSV lists 4.17.21 as the fixed version). The SHA-512 value is the real npm integrity hash for that tarball, converted from base64 to hex.
This SPDX document validates against the SPDX 3.0.1 JSON schema published at spdx.org/schema/3.0.1/spdx-json-schema.json. We checked it with Python jsonschema 4.25.1, including negative tests that confirm the schema rejects unknown properties and bad enum values.
{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"@graph": [
{
"type": "CreationInfo",
"@id": "_:creationinfo",
"specVersion": "3.0.1",
"created": "2026-10-20T09:00:00Z",
"createdBy": ["https://example.com/spdx/acme-web-2.4.0#org-acme"],
"createdUsing": ["https://example.com/spdx/acme-web-2.4.0#tool-gen"]
},
{
"type": "Organization",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#org-acme",
"creationInfo": "_:creationinfo",
"name": "Acme Corp"
},
{
"type": "Tool",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#tool-gen",
"creationInfo": "_:creationinfo",
"name": "example-sbom-generator 1.0.0"
},
{
"type": "SpdxDocument",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#doc",
"creationInfo": "_:creationinfo",
"name": "acme-web 2.4.0 SBOM",
"profileConformance": ["core", "software", "simpleLicensing", "security"],
"rootElement": ["https://example.com/spdx/acme-web-2.4.0#pkg-app"],
"element": [
"https://example.com/spdx/acme-web-2.4.0#org-acme",
"https://example.com/spdx/acme-web-2.4.0#tool-gen",
"https://example.com/spdx/acme-web-2.4.0#pkg-app",
"https://example.com/spdx/acme-web-2.4.0#pkg-lodash",
"https://example.com/spdx/acme-web-2.4.0#rel-dep",
"https://example.com/spdx/acme-web-2.4.0#lic-mit",
"https://example.com/spdx/acme-web-2.4.0#rel-declared",
"https://example.com/spdx/acme-web-2.4.0#rel-concluded",
"https://example.com/spdx/acme-web-2.4.0#vuln-1",
"https://example.com/spdx/acme-web-2.4.0#vex-1"
]
},
{
"type": "software_Package",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#pkg-app",
"creationInfo": "_:creationinfo",
"name": "acme-web",
"software_packageVersion": "2.4.0",
"software_primaryPurpose": "application",
"suppliedBy": "https://example.com/spdx/acme-web-2.4.0#org-acme",
"software_packageUrl": "pkg:npm/acme-web@2.4.0"
},
{
"type": "software_Package",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#pkg-lodash",
"creationInfo": "_:creationinfo",
"name": "lodash",
"software_packageVersion": "4.17.21",
"software_primaryPurpose": "library",
"software_packageUrl": "pkg:npm/lodash@4.17.21",
"software_downloadLocation": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"externalIdentifier": [
{
"type": "ExternalIdentifier",
"externalIdentifierType": "cpe23",
"identifier": "cpe:2.3:a:lodash:lodash:4.17.21:*:*:*:*:node.js:*:*"
}
],
"verifiedUsing": [
{
"type": "Hash",
"algorithm": "sha512",
"hashValue": "bf690311ee7b95e713ba568322e3533f2dd1cb880b189e99d4edef13592b81764daec43e2c54c61d5c558dc5cfb35ecb85b65519e74026ff17675b6f8f916f4a"
}
]
},
{
"type": "Relationship",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#rel-dep",
"creationInfo": "_:creationinfo",
"from": "https://example.com/spdx/acme-web-2.4.0#pkg-app",
"relationshipType": "dependsOn",
"to": ["https://example.com/spdx/acme-web-2.4.0#pkg-lodash"],
"completeness": "complete"
},
{
"type": "simplelicensing_LicenseExpression",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#lic-mit",
"creationInfo": "_:creationinfo",
"simplelicensing_licenseExpression": "MIT"
},
{
"type": "Relationship",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#rel-declared",
"creationInfo": "_:creationinfo",
"from": "https://example.com/spdx/acme-web-2.4.0#pkg-lodash",
"relationshipType": "hasDeclaredLicense",
"to": ["https://example.com/spdx/acme-web-2.4.0#lic-mit"]
},
{
"type": "Relationship",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#rel-concluded",
"creationInfo": "_:creationinfo",
"from": "https://example.com/spdx/acme-web-2.4.0#pkg-lodash",
"relationshipType": "hasConcludedLicense",
"to": ["https://example.com/spdx/acme-web-2.4.0#lic-mit"]
},
{
"type": "security_Vulnerability",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#vuln-1",
"creationInfo": "_:creationinfo",
"externalIdentifier": [
{
"type": "ExternalIdentifier",
"externalIdentifierType": "cve",
"identifier": "CVE-2021-23337"
}
]
},
{
"type": "security_VexFixedVulnAssessmentRelationship",
"spdxId": "https://example.com/spdx/acme-web-2.4.0#vex-1",
"creationInfo": "_:creationinfo",
"from": "https://example.com/spdx/acme-web-2.4.0#vuln-1",
"relationshipType": "fixedIn",
"to": ["https://example.com/spdx/acme-web-2.4.0#pkg-lodash"],
"security_assessedElement": "https://example.com/spdx/acme-web-2.4.0#pkg-lodash",
"suppliedBy": "https://example.com/spdx/acme-web-2.4.0#org-acme",
"security_publishedTime": "2026-10-20T09:00:00Z"
}
]
}
This CycloneDX BOM validates against bom-1.7.schema.json from the CycloneDX specification repository, with its bundled spdx.schema.json, jsf-0.82.schema.json and cryptography-defs.schema.json.
{
"$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:5b0c3d6e-8f3a-4e9b-9c41-2a7d1f0e6b55",
"version": 1,
"metadata": {
"timestamp": "2026-10-20T09:00:00Z",
"lifecycles": [{ "phase": "build" }],
"tools": {
"components": [
{ "type": "application", "name": "example-sbom-generator", "version": "1.0.0" }
]
},
"manufacturer": { "name": "Acme Corp" },
"component": {
"type": "application",
"bom-ref": "pkg:npm/acme-web@2.4.0",
"supplier": { "name": "Acme Corp" },
"name": "acme-web",
"version": "2.4.0",
"purl": "pkg:npm/acme-web@2.4.0"
}
},
"components": [
{
"type": "library",
"bom-ref": "pkg:npm/lodash@4.17.21",
"name": "lodash",
"version": "4.17.21",
"purl": "pkg:npm/lodash@4.17.21",
"cpe": "cpe:2.3:a:lodash:lodash:4.17.21:*:*:*:*:node.js:*:*",
"hashes": [
{
"alg": "SHA-512",
"content": "bf690311ee7b95e713ba568322e3533f2dd1cb880b189e99d4edef13592b81764daec43e2c54c61d5c558dc5cfb35ecb85b65519e74026ff17675b6f8f916f4a"
}
],
"licenses": [
{ "license": { "id": "MIT", "acknowledgement": "declared" } },
{ "license": { "id": "MIT", "acknowledgement": "concluded" } }
],
"externalReferences": [
{
"type": "distribution",
"url": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
}
]
}
],
"dependencies": [
{ "ref": "pkg:npm/acme-web@2.4.0", "dependsOn": ["pkg:npm/lodash@4.17.21"] },
{ "ref": "pkg:npm/lodash@4.17.21", "dependsOn": [] }
],
"compositions": [
{ "aggregate": "complete", "dependencies": ["pkg:npm/acme-web@2.4.0"] }
],
"vulnerabilities": [
{
"id": "CVE-2021-23337",
"source": { "name": "NVD", "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-23337" },
"analysis": {
"state": "resolved",
"firstIssued": "2026-10-20T09:00:00Z"
},
"affects": [{ "ref": "pkg:npm/lodash@4.17.21" }]
}
]
}
The SPDX relationship IDs, edge completeness, document IRI and profile list have no slot in the CycloneDX file, and the CycloneDX serialNumber, version and lifecycles were never stated in the SPDX document. A converter has to invent them.
Which open source tools convert, and in which direction
Tool capabilities below come only from each project's own README, as read in October 2026.
| Tool | Home | Directions per its README | SPDX 3.0.1 | CycloneDX 1.7 |
|---|---|---|---|---|
| CycloneDX CLI (v0.33.1) | CycloneDX GitHub organisation | Converts between CycloneDX XML, JSON, Protobuf, CSV and SPDX JSON v2.3 | No | Yes; output version selectable up to v1_7, and validate defaults to v1.7 |
| SPDX tools-java (v2.0.7) | SPDX GitHub organisation | Converts between SPDX formats (Tag, RDF/XML, XLSX, XLS, JSON, XML, YAML, JSON-LD); converts SPDX 2 to SPDX 3.0.1 | Yes, as output from SPDX 2 | No |
| SPDX tools-python | SPDX GitHub organisation | Parses, converts and validates SPDX 2.2 and 2.3; experimental SPDX 3 support that can convert 2.x to 3.0 and write JSON-LD; the README says v0.8 writes but does not read SPDX 3 | Experimental, write only | No |
| cdx2spdx | SPDX GitHub organisation | CycloneDX to SPDX; marked a prototype, "not considered stable"; unmapped CycloneDX data kept as annotations | The README does not state the SPDX version written | Input version not stated; check its test suite |
| protobom (v0.6.2) | OpenSSF sandbox project | Neutral Go library; reads and writes SPDX 2.2 and 2.3 and CycloneDX 1.4 to 1.7 JSON | Listed as "planned" | Yes |
| bomctl | OpenSSF sandbox project | Uses protobom; export formats listed up to SPDX 2.3 and CycloneDX 1.6 | No | Not in its export list |
The CycloneDX CLI README also notes that SPDX conversion "can result in the loss of some information" and points to the CycloneDX .NET library for details. No row in the table goes from SPDX 3.0.1 to CycloneDX 1.7 in one step. Your realistic options are:
- Ask the supplier for CycloneDX 1.7 directly, or for SPDX 2.3 JSON alongside SPDX 3.0.1, and use the CycloneDX CLI on the 2.3 file.
- Write a small mapper for the subset you need (an example follows) and gate it with schema validation and a diff.
- Keep SPDX 3.0.1 as the record and generate CycloneDX from source yourself with the same build, rather than converting someone else's document.
Option 3 often gives the best CycloneDX, because a generator reading the lockfile or image fills scope, evidence and lifecycles from first hand data.
Step by step: convert, then prove what was lost
The walkthrough below uses only tools from the standards bodies' own GitHub organisations plus Python and jq. It assumes the supplier's SPDX 3.0.1 file is called supplier.spdx3.json.
Step 1: keep the original and validate it
Store the supplier file untouched, with its hash, before you do anything else. Then validate it against the SPDX 3.0.1 JSON schema.
mkdir -p sbom-intake && cp supplier.spdx3.json sbom-intake/
shasum -a 256 sbom-intake/supplier.spdx3.json > sbom-intake/supplier.spdx3.json.sha256
python3 -m venv .venv && . .venv/bin/activate
pip install jsonschema
curl -sSLo spdx3-schema.json https://spdx.org/schema/3.0.1/spdx-json-schema.json
python3 - <<'EOF'
import json, jsonschema
schema = json.load(open("spdx3-schema.json"))
doc = json.load(open("sbom-intake/supplier.spdx3.json"))
errors = list(jsonschema.Draft202012Validator(schema).iter_errors(doc))
for e in errors[:20]:
print(list(e.absolute_path), e.message[:200])
raise SystemExit(1 if errors else 0)
EOF
A schema failure here is a supplier problem. Send it back rather than converting a broken file.
Step 2: inventory what the SPDX file actually uses
Before mapping, count what is in the graph. This tells you in advance which rows of the tables above apply.
jq -r '."@graph"[].type' sbom-intake/supplier.spdx3.json | sort | uniq -c | sort -rn
jq -r '."@graph"[] | select(.relationshipType) | .relationshipType' \
sbom-intake/supplier.spdx3.json | sort | uniq -c | sort -rn
jq '[."@graph"[] | select(.type=="SpdxDocument") | .rootElement[]] | length' \
sbom-intake/supplier.spdx3.json
AI, Dataset or Build classes mean heavy loss. Relationship types beyond dependsOn, contains, describes and the two license types need a decision. More than one root needs a choice for metadata.component.
Step 3: map with a script that reports its own losses
This minimal mapper handles packages, dependsOn, declared and concluded licenses, and VEX status. It prints a LOSS: line on stderr for everything it does not carry over. It is a starting point, not a complete converter. Extend the dictionaries and the warning list to match what Step 2 found.
"""Minimal SPDX 3.0.1 JSON-LD to CycloneDX 1.7 JSON mapper (illustrative).
Maps packages, dependsOn, declared/concluded licenses and VEX status.
Everything it does not map is listed on stderr so loss is visible."""
import json, sys, uuid
HASH = {"sha1": "SHA-1", "sha256": "SHA-256", "sha384": "SHA-384",
"sha512": "SHA-512", "sha3_256": "SHA3-256", "sha3_384": "SHA3-384",
"sha3_512": "SHA3-512", "blake2b256": "BLAKE2b-256",
"blake2b384": "BLAKE2b-384", "blake2b512": "BLAKE2b-512",
"blake3": "BLAKE3", "md5": "MD5"}
PURPOSE = {"application": "application", "framework": "framework",
"library": "library", "container": "container",
"operatingSystem": "operating-system", "device": "device",
"deviceDriver": "device-driver", "firmware": "firmware",
"file": "file", "model": "machine-learning-model", "data": "data"}
VEX = {"security_VexAffectedVulnAssessmentRelationship": "exploitable",
"security_VexNotAffectedVulnAssessmentRelationship": "not_affected",
"security_VexFixedVulnAssessmentRelationship": "resolved",
"security_VexUnderInvestigationVulnAssessmentRelationship": "in_triage"}
JUST = {"vulnerableCodeNotPresent": "code_not_present",
"componentNotPresent": "code_not_present",
"vulnerableCodeNotInExecutePath": "code_not_reachable",
"inlineMitigationsAlreadyExist": "protected_by_mitigating_control"}
doc = json.load(open(sys.argv[1]))
g = {o.get("spdxId") or o.get("@id"): o for o in doc["@graph"]}
lost = []
def warn(msg): lost.append(msg)
sdoc = next(o for o in g.values() if o["type"] == "SpdxDocument")
ci = next(o for o in g.values() if o["type"] == "CreationInfo")
roots = sdoc.get("rootElement", [])
if len(roots) != 1:
warn(f"{len(roots)} rootElements; CycloneDX metadata.component holds one")
def component(p):
c = {"type": PURPOSE.get(p.get("software_primaryPurpose"), "library"),
"bom-ref": p["spdxId"], "name": p["name"]}
if "software_packageVersion" in p: c["version"] = p["software_packageVersion"]
if "software_packageUrl" in p: c["purl"] = p["software_packageUrl"]
if "suppliedBy" in p: c["supplier"] = {"name": g[p["suppliedBy"]]["name"]}
cpes = [x["identifier"] for x in p.get("externalIdentifier", [])
if x["externalIdentifierType"] in ("cpe22", "cpe23")]
if cpes: c["cpe"] = cpes[0]
if len(cpes) > 1: warn(f"{p['name']}: {len(cpes)-1} extra CPE(s) dropped")
hashes = []
for h in p.get("verifiedUsing", []):
if h.get("algorithm") in HASH:
hashes.append({"alg": HASH[h["algorithm"]], "content": h["hashValue"]})
else:
warn(f"{p['name']}: hash algorithm {h.get('algorithm')} has no CycloneDX enum")
if hashes: c["hashes"] = hashes
if "software_downloadLocation" in p:
c["externalReferences"] = [{"type": "distribution",
"url": p["software_downloadLocation"]}]
for k in ("builtTime", "releaseTime", "validUntilTime", "supportLevel",
"software_additionalPurpose", "software_sourceInfo", "originatedBy"):
if k in p: warn(f"{p['name']}: {k} not mapped")
return c
comps, deps, vulns = {}, {}, []
for o in g.values():
if o["type"] in ("software_Package", "ai_AIPackage", "dataset_DatasetPackage"):
comps[o["spdxId"]] = component(o)
if o["type"] != "software_Package":
warn(f"{o['name']}: {o['type']} profile fields need modelCard/data mapping")
for o in g.values():
t, rt = o["type"], o.get("relationshipType")
if t == "Relationship" and rt == "dependsOn":
deps.setdefault(o["from"], []).extend(o["to"])
if o.get("completeness") == "complete":
warn(f"completeness=complete on {o['spdxId']} dropped; CycloneDX states this with compositions")
elif t == "Relationship" and rt in ("hasDeclaredLicense", "hasConcludedLicense"):
ack = "declared" if rt == "hasDeclaredLicense" else "concluded"
for lic in o["to"]:
expr = g[lic].get("simplelicensing_licenseExpression")
comps[o["from"]].setdefault("licenses", []).append(
{"expression": expr, "acknowledgement": ack})
elif t in VEX:
v = g[o["from"]]
vid = next((x["identifier"] for x in v.get("externalIdentifier", [])
if x["externalIdentifierType"] == "cve"), v["spdxId"])
a = {"state": VEX[t]}
if o.get("security_justificationType") in JUST:
a["justification"] = JUST[o["security_justificationType"]]
elif "security_justificationType" in o:
warn(f"{vid}: justification {o['security_justificationType']} has no exact match")
if "security_impactStatement" in o: a["detail"] = o["security_impactStatement"]
if "security_publishedTime" in o: a["firstIssued"] = o["security_publishedTime"]
vulns.append({"id": vid, "analysis": a,
"affects": [{"ref": r} for r in o["to"]]})
elif t == "Relationship":
warn(f"relationship {rt} ({o['spdxId']}) not mapped")
root = comps.pop(roots[0]) if roots else None
bom = {"$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json",
"bomFormat": "CycloneDX", "specVersion": "1.7",
"serialNumber": "urn:uuid:" + str(uuid.uuid5(uuid.NAMESPACE_URL, sdoc["spdxId"])),
"version": 1,
"metadata": {"timestamp": ci["created"],
"tools": {"components": [{"type": "application", "name": g[t]["name"]}
for t in ci.get("createdUsing", [])]},
"properties": [{"name": "spdx:spdxId", "value": sdoc["spdxId"]}]},
"components": list(comps.values()),
"dependencies": [{"ref": r, "dependsOn": d} for r, d in deps.items()]}
people = [g[a]["name"] for a in ci["createdBy"] if g[a]["type"] == "Person"]
orgs = [g[a]["name"] for a in ci["createdBy"] if g[a]["type"] == "Organization"]
if people: bom["metadata"]["authors"] = [{"name": n} for n in people]
if orgs: bom["metadata"]["manufacturer"] = {"name": orgs[0]}
if len(orgs) > 1: warn(f"{len(orgs)-1} extra creating organization(s) dropped")
if root: bom["metadata"]["component"] = root
if vulns: bom["vulnerabilities"] = vulns
json.dump(bom, sys.stdout, indent=2)
for m in lost: print("LOSS:", m, file=sys.stderr)
python3 spdx3_to_cdx17.py sbom-intake/supplier.spdx3.json \
> converted.cdx.json 2> conversion-loss.log
cat conversion-loss.log
Run against the example above, the script produced a CycloneDX 1.7 file that passed schema validation and logged one loss: the complete marker on the dependsOn edge. Known and named loss is what you want.
Step 4: validate the CycloneDX output with the CycloneDX CLI
cyclonedx-cli validate --input-file converted.cdx.json --input-format json \
--input-version v1_7 --fail-on-errors
Run it in CI so that an invalid license ID or serial number breaks the build. The same tool reports duplicate versions:
cyclonedx-cli analyze --input-file converted.cdx.json --multiple-component-versions
Step 5: round trip back to SPDX and diff
To see what a full loop does, convert the CycloneDX file back with cdx2spdx, then raise the result to SPDX 3.0.1 with tools-java, and compare with the original.
java -jar cdx2spdx-<version>-jar-with-dependencies.jar converted.cdx.json roundtrip.spdx.json
jq -r '.spdxVersion' roundtrip.spdx.json
grep -c 'MISSING_CDX_PROPERTY' roundtrip.spdx.json
java -jar tools-java-2.0.7-jar-with-dependencies.jar Convert \
roundtrip.spdx.json roundtrip.spdx3.json JSON JSONLD
The jar names follow each README; replace the version placeholder with the release you downloaded. Check the spdxVersion that cdx2spdx wrote before you continue, because its README does not state one. Every MISSING_CDX_PROPERTY annotation is a CycloneDX field that SPDX could not hold.
Now compare the two SPDX 3.0.1 documents on the fields that matter for security and licensing:
pkgs () {
jq -r '."@graph"[] | select(.type=="software_Package")
| [.name, (.software_packageVersion // ""), (.software_packageUrl // "")] | @tsv' "$1" | sort
}
rels () {
jq -r '."@graph"[] | select(.relationshipType) | .relationshipType' "$1" | sort | uniq -c
}
hashes () {
jq -r '."@graph"[] | select(.type=="software_Package") | .name as $n
| (.verifiedUsing // [])[] | [$n, .algorithm, .hashValue] | @tsv' "$1" | sort
}
for f in pkgs rels hashes; do
echo "== $f"; diff <($f sbom-intake/supplier.spdx3.json) <($f roundtrip.spdx3.json)
done
Read the diff by category:
- Disappeared: packages missing from the round trip output, relationship types whose count dropped to zero, hashes for algorithms CycloneDX lacks.
- Altered: versions or purls that changed (often normalisation), license expressions that came back as a different but equivalent string,
createdtimestamps that now carry the conversion time instead of the commit time. - Invented: values the round trip added, such as new IDs, a new document IRI, or tool names for the converter rather than the original generator.
Keep the logs from Steps 3 to 5 next to the original file. The record should name the tool, version, date and operator for each conversion. The NTIA minimum elements mapping post explains why the author of SBOM data changes when you convert.
How the NTIA minimum elements survive the trip
The NTIA 2021 report lists seven baseline data fields: Supplier Name, Component Name, Version of the Component, Other Unique Identifiers, Dependency Relationship, Author of SBOM Data and Timestamp. Here is how each one fares from SPDX 3.0.1 to CycloneDX 1.7.
| NTIA field | SPDX 3.0.1 | CycloneDX 1.7 | Survives? |
|---|---|---|---|
| Supplier Name | suppliedBy | supplier.name | Yes |
| Component Name | name | name | Yes |
| Version of the Component | packageVersion | version | Yes |
| Other Unique Identifiers | packageUrl, externalIdentifier, contentIdentifier | purl, cpe, swhid, omniborId | Mostly; extra CPEs at risk |
| Dependency Relationship | dependsOn and related types | dependencies | Yes for the edge; type and scope often lost |
| Author of SBOM Data | CreationInfo.createdBy | metadata.authors or manufacturer | Yes, but the converter is now an author too |
| Timestamp | CreationInfo.created | metadata.timestamp | Value yes; meaning may shift |
For the wider interoperability picture, see the SBOM interoperability bridge post.
Where Safeguard fits
Safeguard's fit here is indirect. Safeguard is not an SPDX 3.0.1 to CycloneDX 1.7 converter. Its SBOM generation and analysis is generally available and produces SPDX (JSON, XML and RDF) and CycloneDX (JSON and XML), with more than 30 export formats including CSV, Excel and PDF. If you control the build, generating CycloneDX from source sidesteps most of the loss above. Our offering list does not name SPDX 3.0.1 JSON-LD by version, so confirm format coverage for your case before planning around it. The dual support post covers what Safeguard reads and writes today.
Three generally available capabilities help with the problems in this post. CSAF and VEX upload lets you load a supplier's exploitability statements in their own format instead of forcing them through a lossy VEX mapping. License compliance parses SPDX expressions, applies policy and generates NOTICE and attribution files, which helps when a LicenseRef or a declared versus concluded mismatch survives conversion. The Third Party Risk Manager lets you onboard vendors and request SBOMs from them, so the format request can be part of the vendor relationship from the start. For AI assets, AI-BOM discovery is in early access. It inventories AI assets across code, cloud, SaaS and vector databases and describes them as a CycloneDX ML-BOM or with the SPDX AI profile. It does not recover AI profile fields that a conversion has already dropped.
Once you have a converted CycloneDX file, you can scan it like any other SBOM:
safeguard scan --sbom converted.cdx.json
Results are written as JSON under ./output.
Common mistakes
- Trusting a clean exit code. Converters rarely report what they omit. Validate, count and diff.
- Copying the document IRI into
serialNumber. It fails the 1.7 pattern; derive a UUIDv5. - Dropping the second CPE. Many C, C++ and firmware matches depend on it.
- Flattening declared and concluded licenses. Use
acknowledgementto keep both. - Putting
LicenseRefidentifiers inlicense.id. They are only valid inside an expression or as a named license. - Ignoring
completeness. Without compositions, the supplier's claim of an exhaustive list is gone. - Assuming VEX justifications map one to one. Keep the impact statement and record your mapping.
- Converting AI SBOMs and discarding the original. Most AI and Dataset profile detail has no CycloneDX field.
- Replacing the original author. The converter is an additional author of SBOM data, not a replacement.
FAQ
Can the CycloneDX CLI convert SPDX 3.0.1 to CycloneDX 1.7?
Not according to its README. The CLI converts between CycloneDX XML, JSON, Protobuf, CSV and SPDX JSON v2.3. It can write CycloneDX 1.7 output, so an SPDX 2.3 JSON file from the supplier is the easiest route through it.
Is converting SPDX 3 to CycloneDX lossless?
No. Core inventory fields usually survive; typed relationships, per element creation data, lifecycle scopes, some hashes, extra CPEs and most AI and Dataset fields do not.
What is the CycloneDX equivalent of an SPDX relationship?
Dependency edges become dependencies entries, containment becomes nested components and lineage becomes pedigree. Many of the 59 SPDX types have no equivalent.
How do SPDX declared and concluded licenses appear in CycloneDX?
As separate entries in the component's licenses array, each with acknowledgement set to declared or concluded. That field arrived in CycloneDX 1.6 and is still there in 1.7.
Does SPDX 3.0 support VEX?
Yes. The SPDX 3.0.1 Security profile defines VEX affected, not affected, fixed and under investigation assessments as relationship classes, along with CVSS, EPSS, SSVC and exploit catalog assessments.
Does protobom support SPDX 3?
Its README lists SPDX 3.0 and 3.1 JSON as "planned" for both reading and writing, and CycloneDX 1.4 to 1.7 JSON as supported.
How do I map the SPDX AI profile to a CycloneDX ML-BOM?
Map the AIPackage to a component of type machine-learning-model and fill modelCard: typeOfModel to the architecture fields, metric to performanceMetrics, limitation to technicalLimitations, and trainedOn to modelParameters.datasets. Fields like hyperparameter, safetyRiskAssessment and autonomyType have no slot and need properties.
Does a converted SBOM still meet the NTIA minimum elements?
It can. All seven NTIA fields exist in both formats; check that the author and timestamp still describe the original data.
What comes after SPDX 3.0.1 and CycloneDX 1.7?
SPDX 3.1 has been a release candidate since January 2026. CycloneDX announced 2.0 in August 2026, with release expected in fall 2026. Rerun your mapping tests when either becomes final.
Fix it without waiting for upstream
An SBOM, converted or not, only tells you that a vulnerable component is there. Someone still has to ship the fixed build, and the tables above are worth little if the dependency they describe stays unpatched for months.
Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.
1. Autonomous remediation: pull requests, not tickets
When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.
2. Gold Open Source: the safe version in seconds, free
gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.
3. Gold Registry: start from components that are already fixed
registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.
When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.
| Your situation | What to use |
|---|---|
| A fixed version exists | Autonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version |
| You need to know today whether you are affected | Gold Open Source lookup and "Am I affected?" checker, free and without login |
| You want vulnerable versions to stop shipping | The Gold scan GitHub Action in CI, plus Safeguard policies and gates |
| Upstream has no fix yet, or will not ship one | A custom Gold request; fork and patch (early access) |
| You want to start clean on new projects | Pull hardened components and images from Gold Registry |
One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.
See how Safeguard would help
The first demo is a real CLI session: install, run safeguard, ask it to fix the lodash CVE in a repo. It reads the finding and advisory, asks before each command, edits package.json to lodash 4.17.21, runs npm install and npm test with approval, prints a summary and pushes nothing, then shows /permissions, /model and /scan. It is the flow you would use once a converted SBOM points at a vulnerable dependency.
Click through it at your own pace, or open it full screen.
The second demo sets up the runner on your own machine, approves it with its device code, then scans from the terminal. If SBOM intake runs on your own build host, scans of converted files can run there too.
Click through it at your own pace, or open it full screen.
The third demo connects Claude to the Safeguard MCP server with OAuth and chooses which tools Claude can use. After an intake, you can then ask in plain words about the findings in your projects.
Click through it at your own pace, or open it full screen.
Download Safeguard or read the CLI documentation.
Quick check
Why can't you paste an SPDX 3.0.1 document IRI into a CycloneDX 1.7 serialNumber?
The CycloneDX 1.7 schema requires serialNumber to match urn:uuid followed by an RFC 4122 UUID. Derive a deterministic UUIDv5 from the IRI and keep the IRI in a property.
Which CycloneDX 1.7 construct carries SPDX relationship completeness?
The compositions array. Each composition has an aggregate value such as complete or incomplete and lists the assemblies, dependencies or vulnerabilities it covers.
Sources
- SPDX 3.0.1 specification home
- SPDX 3.0.1 CreationInfo
- SPDX 3.0.1 SpdxDocument
- SPDX 3.0.1 Element
- SPDX 3.0.1 Artifact
- SPDX 3.0.1 Package
- SPDX 3.0.1 Relationship
- SPDX 3.0.1 RelationshipType
- SPDX 3.0.1 RelationshipCompleteness
- SPDX 3.0.1 LifecycleScopeType
- SPDX 3.0.1 HashAlgorithm
- SPDX 3.0.1 ExternalIdentifierType
- SPDX 3.0.1 SoftwarePurpose
- SPDX 3.0.1 LicenseExpression
- SPDX 3.0.1 VexNotAffectedVulnAssessmentRelationship
- SPDX 3.0.1 VexAffectedVulnAssessmentRelationship
- SPDX 3.0.1 VexJustificationType
- SPDX 3.0.1 AIPackage
- SPDX 3.0.1 DatasetPackage
- SPDX 3.0.1 JSON schema
- SPDX specification releases on GitHub
- SPDX news (SPDX 3.1 release candidate)
- CycloneDX 1.7 JSON schema
- CycloneDX v1.7 release announcement
- CycloneDX newsroom (1.6, 1.7 and 2.0 announcements)
- ECMA-424 CycloneDX Bill of materials specification
- CycloneDX CLI README
- SPDX tools-java README
- SPDX tools-python README
- SPDX cdx2spdx README
- protobom README
- bomctl README
- NTIA, The Minimum Elements For a Software Bill of Materials (2021)
- OSV entry GHSA-35jh-r3h4-6jhm (CVE-2021-23337)
- npm registry metadata for lodash 4.17.21
Last reviewed: 23 October 2026. Both specifications have newer versions in progress; check the linked specification pages for the latest status.