Skip to content

Report is anonymous: diff() discards each SBOM's identity (name/version/generatedAt/format), so audit output can't say what was compared #52

Description

@dmchaledev

Summary

parse() already extracts each SBOM's identity — name, version, generatedAt, format, and specVersion (src/parser.ts:45-53, 71-80) — and stores it on the SBOM object. But diff() reads only .components and .vulnerabilities and silently discards every identity field (src/diff.ts:10-61). ChangeReport has no place to carry it (src/types.ts:70-88), and the reporter never renders it (src/reporter.ts). So the report that comes out cannot identify the two artifacts it was produced from.

For a package keyworded supply-chain-security whose README sells "auditable SBOM diff reports for compliance evidence," this is a real evidentiary gap: an audit artifact that doesn't record its own inputs is weak evidence. A reviewer looking at a stored report.md or report.json six months later has no way to know it compared my-app 1.2.0 → 1.3.0, which SBOM formats were involved, or when either SBOM was generated — none of it is in the output, even though all of it was parsed and then thrown away.

This is the same "declared/parsed-but-unused signal" pattern already recognized for license (#9) and hashes (#22) — data the parser captures that never reaches the report. It is distinct from every open PR/issue: the in-flight work changes component/CVE comparison semantics (keying #20/#31/#37/#42/#44/#47, downgrades #24, 0.x #48, license #33, CVSS #18, ordering #25, collisions #50, escalation #46, affects #30) or the parser/CLI/CI plumbing. None of them add document-level provenance to ChangeReport or the rendered output.

Evidence (current main)

Parsed but then dropped:

// src/parser.ts — CycloneDX (mirror for SPDX at :71-80)
return {
  format: 'cyclonedx',
  specVersion: /* ... */,
  name:        /* metadata.component.name */,
  version:     /* metadata.component.version */,
  generatedAt: /* metadata.timestamp */,
  components,
  vulnerabilities,
};

diff() never reads any of those five fields:

// src/diff.ts:10-13
export function diff(a: SBOM, b: SBOM): ChangeReport {
  const aMap = buildComponentMap(a.components);   // only .components
  const bMap = buildComponentMap(b.components);
  // ...a.vulnerabilities / b.vulnerabilities used later; nothing else

And ChangeReport (src/types.ts:70-88) has added/removed/upgraded/newCVEs/fixedCVEs/summary — no field naming the compared subjects — so renderReport() has nothing to print.

Reproduction

import { parse, diff, renderReport } from '@hailbytes/sbom-diff';

const oldSbom = parse(JSON.stringify({
  bomFormat: 'CycloneDX', specVersion: '1.5',
  metadata: { timestamp: '2026-01-01T00:00:00Z',
              component: { name: 'my-app', version: '1.2.0' } },
  components: [{ name: 'lodash', version: '4.17.20', purl: 'pkg:npm/lodash@4.17.20' }],
}));
const newSbom = parse(JSON.stringify({
  bomFormat: 'CycloneDX', specVersion: '1.5',
  metadata: { timestamp: '2026-06-01T00:00:00Z',
              component: { name: 'my-app', version: '1.3.0' } },
  components: [{ name: 'lodash', version: '4.17.21', purl: 'pkg:npm/lodash@4.17.21' }],
}));

console.log(oldSbom.name, oldSbom.version, oldSbom.generatedAt); // my-app 1.2.0 2026-01-01T00:00:00Z  ✅ parsed
console.log(renderReport(diff(oldSbom, newSbom), 'markdown'));

The rendered report begins straight at # SBOM Diff Report / ## Summary"my-app", "1.2.0 → 1.3.0", the timestamps, and the formats appear nowhere. The identity the parser recovered is gone by the time the audit artifact is written.

Proposed change

Carry document-level provenance through diff → report and render it as a header. Purely additive; no change to existing fields or default comparison behavior.

1. Types (src/types.ts)

/** Identity of one SBOM in a comparison, for audit provenance. */
export interface SBOMSubject {
  name?: string;
  version?: string;
  format: SBOMFormat;
  specVersion?: string;
  generatedAt?: string;
}

export interface ChangeReport {
  /** The two documents this report was computed from (old = A, new = B). */
  subjects?: { old: SBOMSubject; new: SBOMSubject };
  // ...existing fields unchanged...
}

Making subjects optional keeps every existing ChangeReport literal (tests, fixtures) valid, so this doesn't force churn on the many in-flight PRs that construct reports.

2. Diff (src/diff.ts)

Populate it from the two inputs already in hand — one small object, no new logic:

return {
  subjects: {
    old: subjectOf(a),
    new: subjectOf(b),
  },
  added, removed, upgraded, newCVEs, fixedCVEs, summary: { /* ... */ },
};

function subjectOf(s: SBOM): SBOMSubject {
  return { name: s.name, version: s.version, format: s.format,
           specVersion: s.specVersion, generatedAt: s.generatedAt };
}

3. Reporter (src/reporter.ts)

Render a provenance header when subjects is present (skip cleanly when absent, so old callers are unaffected). json is automatic. Example markdown:

# SBOM Diff Report

**Old:** my-app 1.2.0 · CycloneDX 1.5 · generated 2026-01-01T00:00:00Z
**New:** my-app 1.3.0 · CycloneDX 1.5 · generated 2026-06-01T00:00:00Z

## Summary
...

(Missing fields degrade gracefully, e.g. unknown / ; SPDX populates format: 'spdx' and generatedAt from creationInfo.created, which the parser already extracts.)

4. Tests (src/__tests__/)

  • diff() copies both subjects' name/version/format/specVersion/generatedAt onto report.subjects (diff test).
  • Reporter renders the old/new header line in text and markdown and includes the subjects in json.
  • A ChangeReport without subjects still renders (back-compat guard for existing literal reports).

Why this is high-leverage

  • Closes an evidentiary gap in the headline use case. "Auditable reports for compliance evidence" requires the artifact to state what it compared; today it cannot, even though the tool parsed exactly that information.
  • Zero new parsing / dependencies. The five fields are already extracted onto SBOM for both CycloneDX and SPDX — this only wires existing-but-discarded data through diff → report, the same fix pattern as Detect license changes in diff (parsed Component.license is currently extracted but never compared) #9 (license) and Detect component hash/integrity changes in diff (Component.hashes is declared but never parsed or compared) #22 (hashes).
  • Backward compatible and low-conflict. subjects is optional and additive; it does not touch component/CVE comparison, so it composes cleanly with the diff-semantics PRs in flight and won't invalidate their report literals.
  • Composable. Once subjects exists, --format markdown PR comments and stored json audit records become self-describing, and downstream tooling can key/deduplicate reports by the compared versions.

Happy to open a focused PR (types + diff + reporter + tests) once the direction is confirmed and whichever ChangeReport-touching PRs the maintainer prefers have landed, to keep diff.ts / reporter.ts conflicts minimal.

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