You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Component.hashes is defined in the type model (src/types.ts:25) but is never populated by the parser, never compared by diff(), and never rendered by the reporter. For a package keyworded supply-chain-security and vulnerability-management, this is the single highest-value missing signal: a dependency whose version is unchanged but whose hash changed is the canonical indicator of supply-chain tampering (a re-published/back-doored artifact, a compromised mirror, or a malicious republish under the same name@version — the event-stream / xz class of attack). The tool today is structurally blind to it.
Both CycloneDX and SPDX carry per-component digests, so the data is available on input — it's simply dropped on the floor during parsing.
Evidence (current state)
src/types.ts:25 — hashes?: Record<string, string> is declared on Component.
src/parser.ts:30-36 (CycloneDX) and src/parser.ts:63-68 (SPDX) build Component objects but never read c.hashes / pkg.checksums, so Component.hashes is always undefined.
src/diff.ts:67 keys components and diff() only compares version (src/diff.ts:23); .hashes is never inspected.
src/reporter.ts — no hash column/section in text / json / markdown.
Net result: re-publishing a tampered artifact under the same name@version produces zero output — an empty, green report.
Add a small extractor for each format that normalizes the algorithm name (uppercase, strip dashes → e.g. SHA-256 and SHA256 both become sha256) and maps it to the digest:
exportinterfaceHashChange{component: Component;algorithm: string;// e.g. "sha256"from?: string;// digest in the old SBOMto?: string;// digest in the new SBOM}exportinterfaceChangeReport{// …existing fields…hashChanges: HashChange[];summary: {/* …existing… */totalHashChanges: number};}
3. Diff (src/diff.ts) — compare digests for matched, same-version components
In the existing matched-component branch (same place upgrades are detected), when the version is unchanged but a shared algorithm's digest differs, record a HashChange. Restricting to same-version keeps this orthogonal to the upgrade path (a normal upgrade is expected to change the hash and should not be double-reported). Only algorithms present in both SBOMs are compared, so SBOMs that omit hashes degrade gracefully to today's behavior.
if(aComp.version===bComp.version){for(constalgofObject.keys(bComp.hashes??{})){constfrom=aComp.hashes?.[alg];constto=bComp.hashes?.[alg];if(from&&to&&from!==to){hashChanges.push({component: bComp,algorithm: alg, from, to });}}}
4. Reporter (src/reporter.ts) — surface it loudly
Add a "⚠️ Integrity Changes" section to text and markdown (mirroring the Upgraded section); json is automatic. This is a high-severity signal, so it should render even when nothing else changed. Example markdown:
## ⚠️ Integrity Changes (same version, different hash)
| Component | Version | Algorithm | Old digest | New digest |
|-----------|---------|-----------|------------|------------|
| lodash | 4.17.21 | sha256 | ab12… | cd34… |
5. Tests (src/__tests__/)
CycloneDX hashes and SPDX checksums are parsed into normalized Component.hashes (parser test).
Same name@version, sha256 differs ⇒ one hashChanges entry (diff test).
Same version, identical hash ⇒ none.
Version changed (a normal upgrade) ⇒ reported as upgraded, not as a hashChange (no double-count).
Hash present in only one SBOM ⇒ no false positive.
Why this is high-leverage
Closes a blind spot the headline use case depends on. "Detect newly introduced CVEs" catches known vulns; integrity drift catches unknown / supply-chain tampering that no CVE feed has flagged yet. That's squarely the supply-chain-security remit in the README.
No new dependencies and no new parsing infrastructure — the digests are already in the input documents; this only wires existing fields through parse → diff → report.
Backward compatible — purely additive to ChangeReport; SBOMs without hashes behave exactly as today.
Happy to open a focused PR implementing parser + diff + reporter + tests once the in-flight diff/reporter PRs (#18, #20) land, to avoid conflicts in diff.ts / reporter.ts.
Summary
Component.hashesis defined in the type model (src/types.ts:25) but is never populated by the parser, never compared bydiff(), and never rendered by the reporter. For a package keywordedsupply-chain-securityandvulnerability-management, this is the single highest-value missing signal: a dependency whose version is unchanged but whose hash changed is the canonical indicator of supply-chain tampering (a re-published/back-doored artifact, a compromised mirror, or a malicious republish under the samename@version— the event-stream / xz class of attack). The tool today is structurally blind to it.Both CycloneDX and SPDX carry per-component digests, so the data is available on input — it's simply dropped on the floor during parsing.
Evidence (current state)
src/types.ts:25—hashes?: Record<string, string>is declared onComponent.src/parser.ts:30-36(CycloneDX) andsrc/parser.ts:63-68(SPDX) buildComponentobjects but never readc.hashes/pkg.checksums, soComponent.hashesis alwaysundefined.src/diff.ts:67keys components anddiff()only comparesversion(src/diff.ts:23);.hashesis never inspected.src/reporter.ts— no hash column/section intext/json/markdown.Net result: re-publishing a tampered artifact under the same
name@versionproduces zero output — an empty, green report.What the source formats provide
CycloneDX components carry a
hashesarray:{ "name": "lodash", "version": "4.17.21", "purl": "pkg:npm/lodash@4.17.21", "hashes": [ { "alg": "SHA-256", "content": "ab12…" } ] }SPDX packages carry
checksums:{ "name": "requests", "versionInfo": "2.28.0", "checksums": [ { "algorithm": "SHA256", "checksum": "cd34…" } ] }Proposed change
1. Parser (
src/parser.ts) — populateComponent.hashesAdd a small extractor for each format that normalizes the algorithm name (uppercase, strip dashes → e.g.
SHA-256andSHA256both becomesha256) and maps it to the digest:2. Types (
src/types.ts) — new report shape3. Diff (
src/diff.ts) — compare digests for matched, same-version componentsIn the existing matched-component branch (same place upgrades are detected), when the version is unchanged but a shared algorithm's digest differs, record a
HashChange. Restricting to same-version keeps this orthogonal to the upgrade path (a normal upgrade is expected to change the hash and should not be double-reported). Only algorithms present in both SBOMs are compared, so SBOMs that omit hashes degrade gracefully to today's behavior.4. Reporter (
src/reporter.ts) — surface it loudlyAdd a "⚠️ Integrity Changes" section to
textandmarkdown(mirroring the Upgraded section);jsonis automatic. This is a high-severity signal, so it should render even when nothing else changed. Example markdown:5. Tests (
src/__tests__/)hashesand SPDXchecksumsare parsed into normalizedComponent.hashes(parser test).name@version,sha256differs ⇒ onehashChangesentry (diff test).upgraded, not as ahashChange(no double-count).Why this is high-leverage
supply-chain-securityremit in the README.ChangeReport; SBOMs without hashes behave exactly as today.hashChangesexists,--fail-on integrity-changebecomes a trivial, high-signal gate condition.--fail-on) and issues (Detect license changes in diff (parsedComponent.licenseis currently extracted but never compared) #9 license-diff, parse() silently accepts non-SBOM / wrong-format input, producing a false "pass" for CI gates #21 input validation) don't touch hashes. Like Detect license changes in diff (parsedComponent.licenseis currently extracted but never compared) #9, this turns a declared-but-unused field into a real signal — but with direct tamper-detection value.Happy to open a focused PR implementing parser + diff + reporter + tests once the in-flight diff/reporter PRs (#18, #20) land, to avoid conflicts in
diff.ts/reporter.ts.