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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions