Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c2b05d3
feat(sbom): emit package origin and preserve ingested assertions
bomly-guy Aug 14, 2026
f60fae6
fix(sbom): gate ingested URLs and reject non-userinfo credentials
bomly-guy Aug 14, 2026
7ca8a18
fix(sbom): classify VCS locators before the credential gate
bomly-guy Aug 14, 2026
9ee7d64
fix(sbom): keep yarn checksum fragments, gate SPDX source info
bomly-guy Aug 14, 2026
e39234e
fix(sbom): validate reference types, merge duplicate PURLs, pin revis…
bomly-guy Aug 14, 2026
973b0e3
fix(sbom): require commit-shaped VCS fragments, keep repo alongside a…
bomly-guy Aug 14, 2026
54f4c52
fix(sbom): validate ingested digests, keep reference comments and sum…
bomly-guy Aug 14, 2026
11a5e67
fix(sbom): keep CPE 2.2 labels, urn references, and unioned reference…
bomly-guy Aug 14, 2026
8ef7310
test(sbom): assert digest algorithms, not just counts
bomly-guy Aug 14, 2026
1cb259d
fix(sbom): reject token-shaped revisions, validate BLAKE2b lengths
bomly-guy Aug 14, 2026
c749a9b
fix(sbom): allow benign queries on asserted downloads, union set fields
bomly-guy Aug 14, 2026
03d0f44
fix(sbom): keep VCS syntax in source info, validate CPEs, map BLAKE3
bomly-guy Aug 14, 2026
c534b7d
fix(sbom): sweep the set-valued and algorithm-mapping classes
bomly-guy Aug 14, 2026
4dd09b4
fix(sbom): validate rendered VCS revisions and every CPE component
bomly-guy Aug 14, 2026
09e59b8
fix(sbom): close the remaining gate, union, and vocabulary gaps
bomly-guy Aug 14, 2026
679223f
fix(sbom): parse URLs before splitting revisions
bomly-guy Aug 14, 2026
bd5d539
fix(sbom): close remaining credential shapes and preservation gaps
bomly-guy Aug 14, 2026
0b2830e
fix(sbom): stop promoting marked registry roots on re-ingest
bomly-guy Aug 14, 2026
4bc1fc7
fix(sbom): reject credentials in URL paths, sweep the graph merge layer
bomly-guy Aug 14, 2026
bd394c3
fix(sbom): validate rendered VCS revisions and every CPE component
bomly-guy Aug 14, 2026
c1c3bef
fix(sbom): add the missing CPE fuzz target and close credential paths
bomly-guy Aug 14, 2026
56aa7b0
fix(sbom): scan for credentials instead of splitting on delimiters
bomly-guy Aug 18, 2026
172dd33
fix(sbom): re-validate restored locators, merge digests by algorithm
bomly-guy Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ jobs:
- name: ruby
run: 'TestScan$/scan-bundler'
- name: sbom
run: 'TestScan$/scan-sbom|TestDiff/(diff-sbom$|diff-sbom-detail-change$)|TestLiteScan/lite-scan-sbom|TestScanSBOMSyftJSONRejected$'
run: 'TestScan$/scan-sbom|TestDiff/(diff-sbom$|diff-sbom-detail-change$)|TestLiteScan/lite-scan-sbom|TestScanSBOMSyftJSONRejected$|TestScanSBOMExportDistribution$'
- name: dotnet
run: 'TestScan$/scan-nuget'
dotnet: true
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/update-smoke-goldens.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ jobs:
- name: cpp
run: 'TestScan$/scan-cpp-conan'
- name: sbom
run: 'TestScan$/scan-sbom|TestDiff/(diff-sbom$|diff-sbom-detail-change$)|TestLiteScan/lite-scan-sbom|TestScanSBOMSyftJSONRejected$'
run: 'TestScan$/scan-sbom|TestDiff/(diff-sbom$|diff-sbom-detail-change$)|TestLiteScan/lite-scan-sbom|TestScanSBOMSyftJSONRejected$|TestScanSBOMExportDistribution$'
- name: plugin
run: 'TestPluginWorkflows'
- name: container
Expand Down
31 changes: 31 additions & 0 deletions dev-docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,37 @@ inherit these checkout validation controls.

Reachability data lives on `sdk.Vulnerability.Reachability` rather than on `Finding.Reachability` because `--analyze` must be useful without `--audit`. Matchers populate the OSV-aligned `Vulnerability` record on the PURL-keyed registry package; the analyzer enriches it in place; the output layer resolves the analyzer's annotation by `(Finding.PackageRef, Finding.VulnerabilityID)` when emitting SARIF and the JSON `Finding` projection. This keeps a single source of truth (the registry) and removes the per-manifest sync that the old graph-mutating model required.

### Decision: SBOM distribution data is classified, not passed through

`sdk.Dependency.ResolvedURL` is not a URL. Detectors write whatever their lockfile records: npm/pnpm/yarn/bun write the exact tarball, Bundler writes the `GEM remote:` registry root, Cargo writes a `registry+`/`sparse+`/`git+`-prefixed index or repo, swiftpm writes a repository, and uv, pipenv, pub, and npm link entries can all write a **local filesystem path**. Some private-registry URLs embed a token.

`internal/sbom/locator.go` therefore classifies each value into artifact / VCS / registry-root / nothing before it can reach an SBOM, rather than mapping the field straight onto `PackageDownloadLocation`. Two rules are load-bearing:

1. **A network scheme is required, and credentials are rejected.** This is what keeps build-machine directory layout and private-registry credentials out of a published document. Credentials travel in several places: `user:password@host` userinfo, query parameter names *and* values on signed and private-registry links (`?token=`, `?X-Amz-Signature=`, or a bare `?ghp_…` that parses as a nameless key), fragments, `mailto:` bodies, and the `@<revision>` suffix on a version-control locator. The check is on the value, never on `Source` — uv's `path`/`editable` values arrive under non-`file` sources.

**The gate is deliberately not uniform, and the distinction is the security boundary — do not collapse it.**

- A **detector-derived** value has nothing asserting what it is, so it gets the narrowest gate: `http(s)` only, and *any* query or fragment disqualifies it. Omitting costs nothing there, and a benign parameter cannot be told apart from a credential.
- A value the **source document itself declared** to be a download location or a reference — an SPDX `downloadLocation`, a CycloneDX `distribution`, or any other external reference — is a real assertion, and discarding it wholesale loses data the producer published. Those paths accept `ftp`/`ftps` alongside `http(s)`, and reject a query only when a parameter name or value is credential-shaped (`classifyAssertedDownloadLocation`, `isPublishableReferenceURL`, both via `hasCredentialQuery`). Fragments stay rejected.
- **VCS locators** are exempt from the query gate only because `normalizeVCS` discards query and fragment outright and keeps a revision that is separately validated: a fragment must be commit-shaped hex, and a query-named or metadata-supplied revision must not carry a known credential prefix.

Ingested URLs are untrusted input that gets re-emitted, so they are still gated — a hostile or careless document must not be able to launder a `file://` path or a credential into output Bomly publishes. The relaxation above is about *which* gate applies, never about skipping one.

One subtlety worth stating, because getting it backwards reintroduced a credential leak twice: **parse before splitting a revision.** In a URL, `@` before the host is userinfo and `@` after the path is a revision, and the two are only distinguishable once parsed. Splitting `https://ghp_secret@github.com` on `@` first reads the secret as the host and `github.com` as the revision, which passes every later check and rebuilds the credential. `splitVCSRevision` is the single place that ordering is encoded.
2. **A registry root never becomes a download location.** `https://rubygems.org/` is schema-valid and both validators accept it, so the failure would be silent and plausible: every consumer would read it as the artifact's origin. `NOASSERTION` is the honest answer.

Unrecognized shapes degrade toward the weaker claim (registry root, then nothing) rather than toward the stronger one. `FuzzClassifyResolvedURL` asserts the safety property directly: a classified value is either empty or an absolute network URL with no userinfo.

### Decision: ingested SBOM assertions ride `Dependency.Metadata`

SBOM ingest is not decode-then-encode. `internal/detectors/sbom` decodes to a neutral `Document`, `sbom.ToGraph` converts it to an `sdk.Graph`, the graph flows through the whole pipeline, and export rebuilds a *fresh* document via `FromDepGraph`. Anything not carried onto the `sdk.Dependency` is lost before export — which is why supplier, description, and external references were previously dropped by a format conversion even though both decoders could see them.

They are carried on `Dependency.Metadata` under `bomly.sbom.*` keys, following the precedent of `sdk.SetDetectionLicenses`. This needs no SDK contract change, and consolidation preserves the keys because it clones nodes rather than rebuilding them.

`ToGraph` deliberately does **not** set `Dependency.Source` from an ingested document. `Source` feeds `RegistryMatchEligible()`, so classifying an ingested component as `git` or `url` would quietly make it ineligible for enrichment and break `scan --sbom --enrich`. Setting `ResolvedURL` alone is safe; eligibility never reads it.

Precedence is *detection classifies, ingest corrects, enrichment fills gaps*. Ingested values win over Bomly's own derivation because re-exporting must not silently rewrite another producer's assertion — and because `ToGraph` drops `Source`, re-deriving the bucket would be strictly worse information than the one the source document already chose.

### Decision: external lookups use `Coordinates.EcosystemName()`, never the bare `Name`

`Coordinates` stores identity as `Org` + `Name` following the PURL namespace/name split, so `Name` alone is `postcss` for both `postcss` and `@tailwindcss/postcss`. Anything that leaves the process under a name — Grype's DB search, the OSV name-keyed query, name-derived cache keys, SBOM component names, the bare specifiers `jsreach` matches imports against — must use `EcosystemName()`, which rebuilds the ecosystem-native form (`@org/name` for npm, `org:name` for the Maven family, `org/name` for Go, Composer, Swift, and GitHub Actions).
Expand Down
86 changes: 80 additions & 6 deletions docs/SBOM.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,51 @@ Both formats carry:
knows fixed versions, each vulnerability carries a `recommendation`
("Upgrade <package> to <version>"). No guidance is invented when no fix is
known. SPDX 2.3 has no equivalent field.
- Where each package came from, when the lockfile records it (see below).

### Where a package came from

Lockfiles record very different things in the same field. Some name the exact
file that was downloaded, some name only the registry the ecosystem fetches
from, and some name a directory on the machine that ran the scan. Bomly sorts
each value into one of three kinds — an exact package file, a source
repository, or a registry root — and emits it accordingly. A value that fits
none of them, such as a local path, is not published at all:

| What the lockfile recorded | SPDX | CycloneDX |
| --- | --- | --- |
| The exact package file | `downloadLocation` | `distribution` reference |
| A source repository | `downloadLocation` (`git+` form) | `vcs` reference |
| Only a registry root | `NOASSERTION` | `distribution` reference, marked as a registry root |
| A local path, or nothing usable | `NOASSERTION` | nothing |

Two rules matter here:

- **A registry root is never used as a download location.** `https://rubygems.org/`
is a valid URL, so a validator would accept it, but it is not where that gem
came from. Saying nothing is better than saying something false.
- **Local filesystem paths are never written to an SBOM**, and neither are URLs
carrying a credential. Several lockfile formats record a directory on the
build machine, or a private-registry URL with a token in it — either in the
`user:password@host` position or as a query parameter such as `?token=` on a
signed download link. Any of these is dropped rather than published. The same
check applies to URLs read out of an SBOM Bomly ingests, since those are
untrusted input too.

One fragment is recognized rather than dropped: Yarn appends the artifact's
own checksum to each `resolved` URL (`...-1.4.0.tgz#71ee51fa...`). That is a
fixed-length digest, not a secret, so it is stripped and the download
location is kept.

For a dependency pinned to a source repository, Bomly records the resolved
commit when the lockfile has one, in preference to the branch or tag that was
requested. A branch moves; the commit is what was actually locked.

Coverage follows what each ecosystem actually records. npm, pnpm, yarn, and bun
lockfiles name the exact package archive, so those get a real download location.
Bundler, Cargo, pub, and most Python lockfiles record only a registry or index
root, so those get a registry reference and `NOASSERTION`. Go modules, Maven,
Gradle, NuGet, and Composer record no location at all.

### Document identity

Expand Down Expand Up @@ -151,11 +196,24 @@ 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
when the field is unset. Third-party CRA profile checks will flag the missing
manufacturer/contact metadata until the `sbom` section is configured.

Per-component supplier and description are never invented. Bomly writes them
in exactly one case: when you scan an SBOM that already contains them, they are
carried through to the output so that converting between formats does not throw
away another producer's assertions. On the primary component, a configured
`manufacturer` takes precedence, because that is your own claim about your own
product.

Bomly does not derive supplier or description for third-party packages. Doing so
would need registry metadata that Bomly does not fetch — deps.dev, its enrichment
source for package facts, asserts neither field. Guessing (for example treating a
PURL namespace as a supplier) would put invented claims into a compliance
document, so those fields stay absent instead. This means an enriched scan of a
project does not by itself satisfy the CRA profile's mandatory direct-dependency
supplier check.

When `--enrich` is set, components are enriched from the matching-stage package
registry (keyed by PURL):

Expand All @@ -165,18 +223,34 @@ registry (keyed by PURL):
- Vulnerabilities — CycloneDX as a first-class `vulnerabilities` array (ratings,
CWEs, advisories, `affects`); SPDX as `SECURITY`/`advisory` external references.
- End-of-life status (CycloneDX `bomly:eol*` properties, SPDX package comment).
- The source repository resolved by the OpenSSF Scorecard matcher (CycloneDX
`vcs` external reference, SPDX `PackageSourceInfo`). A repository recorded by
the detector itself is more precise, so it wins when both are known.

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](OUTPUT_FORMATS.md).

### 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. Encoding is
deterministic when the scan timestamp and document identifiers are fixed.
scope, package type, licenses, digests, CPEs, supplier, originator/publisher,
description, download and repository locations, and the enrichment fields
described above when the destination format has an equivalent representation.
Encoding is deterministic when the scan timestamp and document identifiers are
fixed.

Some information necessarily becomes less specific during conversion:

- SPDX 2.3 has no external-reference category for arbitrary links, so CycloneDX
references other than `distribution` and `vcs` (for example `documentation`
or `issue-tracker`) are dropped when converting to SPDX. They survive a
CycloneDX-to-CycloneDX pass.
- A CycloneDX `publisher` is a plain string that the format defines as either a
person or an organization. SPDX has no untyped equivalent — it requires the
value to be declared one or the other — so the publisher is dropped when
converting to SPDX rather than labelled with a guess. It survives a
CycloneDX-to-CycloneDX pass, and an SPDX originator that already states its
type is preserved.

- CycloneDX vulnerability records preserve ratings, CWEs, affected component
references, descriptions, and advisory URLs. SPDX 2.3 represents each
vulnerability as a package security advisory reference, so ratings, affected
Expand Down
Loading
Loading