Skip to content

Detect component hash/integrity changes in diff (Component.hashes is declared but never parsed or compared) #22

Description

@dmchaledev

Summary

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:25hashes?: 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.

What the source formats provide

CycloneDX components carry a hashes array:

{ "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) — populate Component.hashes

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:

// CycloneDX: c.hashes = [{ alg, content }]
// SPDX:      pkg.checksums = [{ algorithm, checksum }]
hashes: extractHashes(c), // { sha256: "ab12…", sha1: "…" }

2. Types (src/types.ts) — new report shape

export interface HashChange {
  component: Component;
  algorithm: string;   // e.g. "sha256"
  from?: string;       // digest in the old SBOM
  to?: string;         // digest in the new SBOM
}

export interface ChangeReport {
  // …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 (const alg of Object.keys(bComp.hashes ?? {})) {
    const from = aComp.hashes?.[alg];
    const to = 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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions