SBOM Analysis
Upload a CycloneDX or SPDX document and get back the known vulnerabilities in its components, along with what the scan did not cover.
Endpoints
POST /api/v1/sbom # analyse a document
POST /api/v1/sbom/scan # the same operation, kept for existing callers
POST /api/v1/sbom/time-travel # exposure at a past date
GET /api/v1/sbom/formats # accepted formats, PURL types and limitsNo credential is required for any endpoint.
The document is the request body. It is processed in memory and is not stored.
Request
curl -X POST https://api.vulnpatch.dev/api/v1/sbom/scan \
-H 'Content-Type: application/json' \
--data-binary @sbom.json| Limit | Value |
|---|---|
| Payload | 1 MB |
| Components | 500 (/sbom, /sbom); 100 (/sbom) |
| Rate | 2 requests per 10 seconds per address, enforced per Cloudflare location |
A document with more components than the cap is analysed up to the cap, in document order, and reports the remainder in notAnalyzed with truncated: true and complete: false. Split a larger document and send it in parts. A document of 500 components in which every one has a known vulnerability takes around 40 seconds, because each flagged component's full record is fetched separately; typical documents, in which few components are flagged, return much sooner. GET /api/v1/sbom/formats reports the cap each endpoint enforces.
Response
{
"success": true,
"data": {
"sbom": { "format": "cyclonedx", "version": "1.5", "metadata": {} },
"summary": {
"totalComponents": 50,
"analyzedComponents": 47,
"failedComponents": 0,
"unsupportedComponents": 2,
"noPurlComponents": 1,
"truncated": false,
"notAnalyzed": 0,
"complete": false,
"maliciousScreened": true,
"maliciousSuspects": 2,
"vulnerableComponents": 5,
"totalVulnerabilities": 47,
"severityBreakdown": { "critical": 1, "high": 2, "medium": 1, "low": 1, "unknown": 0 },
"minimumElementsGaps": ["hashes", "supplier"]
},
"vulnerableComponents": [],
"maliciousSuspects": [],
"allComponents": []
}
}A finding inside vulnerableComponents[].vulnerabilities[] carries producerAnalysis when the uploaded CycloneDX document's own top-level vulnerabilities[] array addressed the same vuln ID against the same component with an analysis.state:
{
"id": "CVE-2024-1234",
"severity": "HIGH",
"producerAnalysis": {
"state": "not_affected",
"justification": "code_not_reachable",
"detail": null,
"matchedId": "CVE-2024-1234"
}
}matchedId is whichever of the finding's own id or aliases the document's vulnerabilities[] entry actually carried -- it can differ from the finding's own id (a document written against a GHSA alias of a CVE, for example). state is always one of CycloneDX's own six values; a document using anything else, or a non-string value, is treated as carrying no VEX analysis for that entry rather than passed through.
This is never used to suppress or demote our own finding -- the finding is reported exactly as it would be without it. A producer's not_affected is a claim the document's own author made, not something this service has verified, so it is surfaced for a reader to check rather than trusted. state is one of CycloneDX's own values: not_affected, false_positive, resolved, resolved_with_pedigree, exploitable, in_triage.
Reading the coverage before reading the result
A count of zero vulnerable components means nothing on its own. Compare analyzedComponents against totalComponents first. totalComponents counts every component the document actually lists, whether or not it resolved to a package URL -- a document whose components carry no purl at all (some generators set only bom-ref, which is read as a fallback once it parses as a purl itself) is a real, scoreable result, not a 400.
| Field | Meaning |
|---|---|
analyzedComponents | Components actually examined |
failedComponents | Components whose lookup did not complete. These were not checked, and they are not clean |
unsupportedComponents | Components with a resolvable purl of a type the vulnerability source holds no data for (containers, OS packages, and similar). Reported as not covered, never as clean |
noPurlComponents | Components with no resolvable identifier at all -- nothing to look up |
truncated, notAnalyzed | The document held more components than the cap, and this many were never examined |
maliciousScreened | False when the known-malicious set could not be read. No component was screened, and none may be treated as cleared |
complete | True only when every component was examined: failedComponents, unsupportedComponents and noPurlComponents are all zero and nothing was truncated. This is the field to gate on |
minimumElementsGaps | Informational only, never a reason the scan itself is blocked or degraded. Which of CISA's SBOM minimum-elements fields the document lacks entirely: hashes (no component carries one), licenses, supplier (no component names one), tools (the generating tool is not named -- CycloneDX's metadata, or an SPDX creationInfo entry), subject (what the document describes is not named -- CycloneDX's metadata, or SPDX's documentDescribes/a DESCRIBES relationship to SPDXRef-DOCUMENT). Empty when none are missing |
A scan in which no component could be checked answers 503, not 200, so a step using curl --fail does not pass a scan that read nothing. A partial scan answers 200 with complete: false. A document whose components are entirely unresolvable also answers 200 with complete: false and analyzedComponents: 0 -- an honest empty scan, not a malformed upload.
A vulnerability carries cvssScore: null when the source published a severity word rather than a score. No number is derived from the word.
Malicious package screening
Every component is checked against a local set of known-malicious package names before any network call. A match appears in maliciousSuspects carrying confirmed: false.
Advisories name specific versions and this set holds names, so a match is a prompt to read the advisory rather than a verdict on the version in your document. axios is listed on the strength of two published versions and is otherwise ordinary.
The document's own VEX analysis
A CycloneDX document can carry a top-level vulnerabilities[] array (distinct from components[]) where the producer states its own exploitability analysis for a vuln ID against one or more components. When our own OSV-based scan finds a vulnerability that this array also addresses for the same component, the finding carries producerAnalysis (see above). This is read, never trusted: our finding is always reported, and producerAnalysis is an annotation for a reader to check against the document's own reasoning, not a reason to treat the finding as resolved.
Time travel
curl -X POST https://api.vulnpatch.dev/api/v1/sbom/time-travel \
-H 'Content-Type: application/json' \
-d '{"sbom": { ... }, "targetDate": "2024-01-01"}'Answers what was known about these components on a given date, separating vulnerabilities already disclosed then from those disclosed since.
{
"success": true,
"data": {
"exposure": { "knownAtDate": [], "notYetDisclosed": [], "zeroDaysAtDate": [], "unknownDate": [], "uncertainAtDate": [] },
"summary": {
"targetDate": "2024-01-01",
"sbomFormat": "cyclonedx",
"totalComponents": 50,
"skippedComponents": 1,
"failedComponents": 0,
"totalCves": 12,
"knownAtDate": 7,
"notYetDisclosed": 2,
"zeroDaysAtDate": 2,
"unknownDate": 0,
"uncertainAtDate": 1,
"estimatedFixDateClassifications": 1
}
}
}| Field | Meaning |
|---|---|
totalComponents | Components analysed: those with a resolvable purl, up to the 100-component cap |
skippedComponents | Components with a resolvable purl beyond the 100-component cap, not part of this analysis at all |
failedComponents | Components whose upstream lookup failed. They contributed no CVEs, so every count below understates exposure by an unknown amount |
unknownDate | CVEs with no readable publication date, excluded from every bucket rather than guessed into one |
uncertainAtDate | CVEs whose knownAtDate vs zeroDaysAtDate split would rest on an estimated fix date (see below) rather than a real one, excluded from both exact buckets for the same reason unknownDate is excluded rather than guessed into one |
estimatedFixDateClassifications | Identical to uncertainAtDate, kept for continuity with an earlier response shape |
When every component's lookup fails (failedComponents === totalComponents, with at least one component), this answers 502 with success: false rather than 200 with an empty exposure -- a scan that checked nothing is not a clean result at that date, it is an unchecked one. When some but not all failed, it still answers 200 but adds partial: true alongside success: true so a caller checking only the top-level flag is not told the scan was complete.
knownAtDate vs zeroDaysAtDate depends on when a fix shipped, which OSV does not record as a date. The split would otherwise use the advisory's own last-edited timestamp as a stand-in (fixedDateEstimated: true on the underlying CVE record when this applies), which can drift from the real fix date in either direction -- so a CVE whose split would rest on that estimate is reported in exposure.uncertainAtDate instead of guessed into either exact bucket.
What leaves the worker
Component identifiers, meaning ecosystem, name and version, are sent to OSV.dev for matching. The document itself, its metadata and its dependency structure are not: the parser reduces the document to package URLs before anything is sent. Request bodies are never logged. Each analysis writes one log line of counts only: components submitted, resolved to a purl, within the cap and left unexamined, with the request's status and duration. It names no component.