Most teams now produce an SBOM because a customer, a regulator or a procurement questionnaire asked for one. Far fewer check the file before it leaves the building. That gap matters: an SBOM that parses but lists half your dependencies, or lists them without versions, gives the reader a false picture and puts your name on it.
SBOM validation is not one check. It is four, and each catches a different kind of bad file. This post walks through all four for CycloneDX and SPDX, then shows how Vulkro produces and checks an SBOM and the VEX document that goes with it.
Pass 1: schema validity
The first question is whether the file is a well-formed document of the format it claims to be. Both CycloneDX and SPDX publish JSON Schemas for each spec version, so any JSON Schema validator can answer it.
Things to confirm at this stage:
- The declared version matches the schema you validate against (
specVersionin CycloneDX,spdxVersionin SPDX 2.x). A 1.6 document checked against a 1.4 schema will fail for the wrong reasons. - Required top-level fields exist:
bomFormatandspecVersionfor CycloneDX;SPDXID,name,documentNamespaceandcreationInfofor SPDX 2.3. - Identifiers are unique. Duplicate
bom-refvalues in CycloneDX, or duplicateSPDXIDs, break every dependency edge that points at them.
Schema validity is necessary, not sufficient: an SBOM with one component and no versions can be valid.
Pass 2: completeness
Completeness asks whether the fields a consumer needs are actually filled in. The usual baseline is the NTIA minimum elements for an SBOM. Its data fields are:
| NTIA data field | CycloneDX | SPDX 2.3 |
|---|---|---|
| Supplier name | component.supplier |
packages[].supplier |
| Component name | component.name |
packages[].name |
| Version | component.version |
packages[].versionInfo |
| Other unique identifiers | component.purl, cpe |
externalRefs (purl, cpe) |
| Dependency relationship | dependencies[] |
relationships[] |
| Author of SBOM data | metadata.authors or metadata.tools |
creationInfo.creators |
| Timestamp | metadata.timestamp |
creationInfo.created |
Beyond the minimum, a useful SBOM also carries:
- Package URLs on every component. A purl such as
pkg:npm/express@4.19.2is what vulnerability databases match on. Without it, a consumer is left guessing from names. - Licenses, resolved from the manifest rather than a blanket
NOASSERTIONon everything. - Content hashes where the lockfile records them, so a consumer can confirm the artefact is the one you described.
- The tool that generated it, so a reader knows how the inventory was built.
- A dependency graph with transitive edges. A flat list tells you what is present; the graph tells you why, and which direct dependency to upgrade.
Two quick tests catch most incomplete files: count components with no version, and count components that no dependency edge points to. Both numbers should be zero or close to it.
Pass 3: accuracy against the lockfiles
A complete SBOM can still be wrong. The only reliable reference is the lockfile the build actually used: package-lock.json, poetry.lock, go.sum, Cargo.lock and so on.
A simple accuracy check:
- Extract the resolved name and version pairs from each lockfile in the repository.
- Extract the same pairs from the SBOM's components.
- Diff the two sets in both directions.
Anything in the lockfile but missing from the SBOM is a coverage gap, often a second manifest in a subdirectory that the generator never read. Anything in the SBOM but not in a lockfile usually means the SBOM was generated from a different commit, or from declared ranges rather than resolved versions. Version mismatches between the two are the most dangerous case, because they make a vulnerable version look patched.
Generate the SBOM in the same pipeline step that builds the release, from the same checkout. An SBOM built on a developer laptop a week earlier is a description of a different product.
Pass 4: pair it with VEX
An SBOM tells a customer what is inside. The first thing they do with it is match it against vulnerability data, and the first thing that produces is a list of CVEs against your components. Most of those are not exploitable in your product, and without a statement saying so, every one becomes a support ticket.
VEX (Vulnerability Exploitability eXchange) is that statement. For each component and vulnerability it records a status. In OpenVEX the statuses are not_affected, affected, fixed and under_investigation, and a not_affected statement needs a justification, such as vulnerable_code_not_in_execute_path. CycloneDX carries the same idea in a vulnerability's analysis block.
When you validate a VEX document, check that:
- Every product and component it references actually appears in the SBOM it accompanies, with matching identifiers.
- Every
not_affectedstatement has a justification, and the justification is something you can defend. - Nothing is marked
not_affectedsimply because no one has looked. That isunder_investigation.
A not_affected claim you cannot back up is worse than no VEX at all.
How Vulkro produces and checks SBOMs
vulkro sbom writes the SBOM from the project's manifests and lockfiles (npm, PyPI, Go, Cargo and Maven dependencies), on your machine:
vulkro sbom . > sbom.cdx.json # CycloneDX 1.6 (default)
vulkro sbom . --format spdx > sbom.spdx.json # SPDX 2.3
vulkro sbom . --format spdx3 > sbom.spdx3.json # SPDX 3.0.1 JSON-LDComponents carry the license resolved from the manifest, and NOASSERTION only where it cannot be determined. Content hashes are included for packages whose lockfile records one; today that is npm package-lock.json integrity values, direct and transitive. A package without a recorded hash omits the field rather than inventing one.
To check an SBOM, whether Vulkro wrote it or another tool did:
vulkro sbom --check sbom.cdx.json # NTIA minimum (default profile)
vulkro sbom --check sbom.cdx.json --profile cert-in # adds CERT-In v2.0 fields as recommendations--check reads CycloneDX or SPDX JSON and names each missing completeness field (component name, version, purl, dependency relationships, SBOM author, timestamp, content hashes, licenses) with a fix for each. It exits 1 when a required field is missing and 0 when none is, so it works as a CI step. With --profile cert-in, the extra CERT-In fields are reported as recommended and do not change the exit code. It is a completeness check: keep a JSON Schema validator in the pipeline for pass 1.
For VEX, the same command emits per-CVE exploitability statements:
vulkro sbom . --format openvex > vex.json # OpenVEX 0.2.0
vulkro sbom . --format cyclonedx-vex > vex.cdx.json # CycloneDX 1.6 VEXnot_affected is emitted only when reachability analysis shows the vulnerable code is never reached. When there is no reachability signal, the statement stays under_investigation. To match an SBOM against the local CVE bundle, use vulkro match-cve --sbom sbom.cdx.json.
FAQ
What is the difference between an SBOM validator and an SBOM generator?
A generator builds the inventory from your code and lockfiles. A validator checks a finished file: that it matches its schema, that required fields are present, and ideally that it agrees with the lockfiles. Run validation on every SBOM you publish, including ones your own tooling generated.
Does a valid SBOM meet the NTIA minimum elements?
Not necessarily. Schema validity only proves the structure is legal. The NTIA minimum elements need supplier, name, version, a unique identifier, dependency relationships, the author and a timestamp to be filled in, and the schema does not require most of them.
Should an SBOM and a VEX document be in the same file?
Either works. CycloneDX can embed vulnerability analysis in the BOM or ship it as a separate VEX document; OpenVEX is always separate. A separate file is easier to update when your analysis changes without reissuing the SBOM.
More on the formats on our SBOM and VEX page. Related reading: SBOM and VEX explained and CERT-In SBOM guidelines.