Skip to content

feat(sbom): derive and verify bundle dependency closure - #16

Merged
Jowitt13 merged 6 commits into
mainfrom
p1/sbom-bundle-closure
Jul 28, 2026
Merged

feat(sbom): derive and verify bundle dependency closure#16
Jowitt13 merged 6 commits into
mainfrom
p1/sbom-bundle-closure

Conversation

@Jowitt13

Copy link
Copy Markdown
Owner

内容

  • 以 esbuild metafile 为唯一事实来源,生成 CycloneDX 与 SPDX 两份 SBOM。
  • 新增反向校验:两份 SBOM 必须完整、规范且与 bundle 依赖闭包一致。
  • 修复包路径分类、scoped npm purl、SPDX 表达式解析、重复条目与缺失身份字段的 fail-closed 问题。

原因

此前 SBOM 依赖手工维护,且若干畸形 SPDX/SBOM 条目可能被宽松解析或静默跳过,无法可靠反映最终 bundle。

影响

只加强构建与供应链门禁;不改变排盘算法、facts、schema、ruleset、依赖锁、Release 或安装清单。

验证

  • pnpm vitest run tools/lib/bundle-closure.test.ts tools/validate-sbom.test.ts — 80/80
  • pnpm run verify:cloud — 555 tests / 31 files;SBOM 96/96
  • 直接验证:非法 WITH 表达式拒绝,合法表达式保持通过。

本 PR 保持草稿,未合并、未发版、未打 tag。

Jowitt13 added 6 commits July 28, 2026 02:16
…lidate:sbom

P1 objective: prove that every third-party runtime package actually in
skills/calculate-birth-charts/scripts/dist/engine.mjs is recorded in both
CycloneDX and SPDX SBOMs, and that the SBOM source is the real esbuild
bundle closure — not a hand-maintained list.

Findings of note:
  The previous hand-authored SBOM listed 6 packages. The metafile-derived
  closure of the same bundle contains 10: astronomy-engine, dayjs, i18next,
  iztro, lunar-lite, lunar-typescript, moment, moment-timezone, tyme4ts, zod.
  Four packages (dayjs, i18next, lunar-lite, lunar-typescript) were shipping
  in engine.mjs but were absent from both SBOMs and from scan:licenses'
  cross-check. All four are MIT under docs/LICENSE_AUDIT.md policy; no
  policy violation, but a genuine supply-chain provenance gap that this P1
  work closes.

Changes:

1. tools/lib/bundle-closure.ts (new)
   Pure function computeBundleClosure(metafile, {root, readPackageJson?}).
   Classifies every esbuild metafile input into repo-internal (packages/*),
   Node built-in (node:*), virtual (<define:...>), or third-party under
   node_modules. Walks each third-party path up to its package.json; verifies
   the directory tail is exactly `node_modules/<name>` or
   `node_modules/@scope/<name>` (so pnpm hash dirs never masquerade as
   packages). extractLicense() accepts SPDX-string, legacy license.type,
   and legacy licenses[] array forms; fail-closed on UNLICENSED /
   "SEE LICENSE IN <file>" / missing. Same-name-different-version and
   same-root-inconsistent-metadata both throw. Output is deterministic:
   packages sorted by name, inputs sorted lexicographically.

2. tools/lib/bundle-closure.test.ts (new, 22 offline cases)
   Direct dep, transitive, scoped, pnpm-nested, pnpm-nested-scoped,
   repo-internal, node built-in, virtual, dedupe, version conflict, missing
   license, UNLICENSED, licenses[] OR expression, deterministic output,
   stray-package-json-outside-node_modules refusal, and a tmpdir end-to-end
   smoke via a real synthetic node_modules layout. No real repo file read
   or written.

3. tools/build-skill.ts
   Removed the 6-name resolveVersion() list and the hardcoded "MIT" array.
   After esbuild returns, compute the closure and drive both SBOMs from it.
   CycloneDX license field distinguishes SPDX id vs OR-expression form.
   SPDX 2.3 creationInfo.created remains pinned to 2026-01-01T00:00:00Z for
   byte reproducibility. Build fails closed if the closure derivation
   throws — no broken SBOM ever ships.

4. tools/validate-sbom.ts (new)
   Reverse gate: re-runs esbuild independently (write: false, metafile:
   true), recomputes the truth-set closure, then asserts every package
   appears in both SBOMs with matching name/version/purl/license; that
   neither SBOM carries a ghost third-party package not in the closure;
   that CycloneDX ↔ SPDX name+version+purl+license sets agree exactly; that
   the application component identity matches between formats; and that
   re-serializing each SBOM matches the on-disk bytes (catches hand-edits
   and non-deterministic writers). Optional
   tools/validate-sbom.exceptions.json permits documented,
   time-boxed exceptions with real-date expires and non-empty reason
   (currently absent). CLI supports --metafile/--sbom-cdx/--sbom-spdx
   injection for tests.

5. tools/validate-sbom.test.ts (new, 12 offline cases)
   Clean triple; closure has extra pkg SBOM missing it; SBOM has ghost pkg;
   CycloneDX vs SPDX version mismatch; license mismatch; purl mismatch;
   application-name drift; byte-instability; multi-input dedupe; documented
   exception permits; exception name matches but version differs (still
   fails); one-side-only pkg → set-mismatch. All inputs synthetic; no real
   SBOM read.

6. package.json + verify:cloud
   Adds "validate:sbom": "node tools/validate-sbom.ts" and inserts
   `pnpm run validate:sbom` between scan:licenses and scan:secrets in
   verify:cloud.

7. Chain listings + anti-drift guards
   .github/workflows/verify.yml top comment, README.md, AGENTS.md and the
   docs/VALIDATION.md table all list `validate:sbom` in the new position.
   tools/validate-current-docs.ts gains three new guards (workflow header,
   README chain, AGENTS chain must include validate:sbom); guards are
   exercised by three new isolated tests in
   tools/validate-current-docs.test.ts using the in-memory reader.
   validate:docs now runs 126/126 checks.

8. Doc prose
   docs/ARCHITECTURE.md: SBOM derivation via bundle-closure.ts and
   validate:sbom described in Build & packaging.
   docs/LICENSE_AUDIT.md: adds "Bundle-closure gate" bullet describing
   validate:sbom as the second half of the license enforcement cross-check.

9. Committed SBOM regeneration
   sbom.cdx.json / sbom.spdx.json rebuilt from the real closure (6 → 10
   packages, all MIT). engine.mjs is byte-identical to the pre-P1 build.
   Confirmed via SHA-256: engine.mjs 8b26c7bca1e36474; sbom.cdx.json
   eb4b7bced3da42b1; sbom.spdx.json 7139883154cdde14 — same bytes across
   two consecutive `pnpm run build` runs.

10. Test count sync
    Real run 472/29 -> 509/31 (+22 bundle-closure tests, +12 validate-sbom
    tests, +3 validate-current-docs tests, +2 new test files). Synced to
    docs/STATUS.md + docs/VALIDATION.md via byte-level patch; surrounding
    legacy mojibake preserved. check:doc-counts 12/12.

Local:
  pnpm vitest run tools/lib/bundle-closure.test.ts
    tools/validate-sbom.test.ts tools/validate-current-docs.test.ts -> 53/53.
  pnpm run test -> 509/31 all pass.
  pnpm run validate:sbom -> 86/86 pass.
  pnpm run verify:cloud -> green end-to-end (format:check, lint, typecheck,
    tests, build, validate:provenance, validate:skill 40/40,
    validate:reading 53/53, validate:docs 126/126, smoke, forward:test,
    hosts, verify:install 106/106, check:doc-counts 12/12,
    scan:deps LOCAL 0 advisories, scan:licenses 20/20, validate:sbom 86/86,
    scan:secrets clean over 223 files).
  pnpm run build x2 -> engine.mjs + both SBOMs byte-identical after each run
    (SHA-256 verified).
  git diff --check -> exit 0.

Not executed (deliberately, per instructions):
  No push. No PR creation. No merge. No git tag or GitHub Release. No
  runtime dependency added. No lockfile change. No incident-token access.
  No algorithm/facts/schema/ruleset/interpretation-rule change.
…nt beats packages/ substring

Independent review reproduced a silent-miss bug in tools/lib/bundle-closure.ts.
For the synthetic metafile input
    node_modules/synthetic-dep/packages/runtime/index.js
computeBundleClosure returned:
    packages: []
    ignored.repoInternal: ["node_modules/synthetic-dep/packages/runtime/index.js"]

Root cause: classifyInput ran the `repoInternal` check with a broad
`norm.includes('/packages/')` BEFORE the `node_modules` check, so any
third-party package with its own `packages/` subdirectory (a common pattern
in monorepo-shipped packages) got silently swept into repoInternal and lost
from the closure. That is exactly the class of failure this P1 SBOM work
was meant to prevent.

Fix:

1. tools/lib/bundle-closure.ts
   - classifyInput rewritten with path-SEGMENT precise matching
     (`parts.includes('node_modules')`, not substring includes) and reordered
     so third-party wins over repoInternal.
   - repoInternal now only matches when the FIRST path segment is exactly
     `packages` (this repo's `packages/<workspace>/…` sources).
   - Backslashes and OS separators both normalise to `/` so segment logic
     works on Windows, POSIX and mixed-separator captures.
   - New `unknown` Category variant; anything unclassifiable is a hard
     error at computeBundleClosure. ignored.repoInternal from now on never
     carries a path with `node_modules` as a segment.

2. tools/lib/bundle-closure.test.ts (+6 offline cases)
   #16: exact repro — node_modules/synthetic-dep/packages/runtime/index.js
        resolves to synthetic-dep@1.0.0; ignored.repoInternal is empty.
   #17: scoped variant — node_modules/@scope/inner/packages/runtime/index.js
        resolves to @scope/inner.
   #18: pnpm-nested variant —
        node_modules/.pnpm/deep@1.2.3/node_modules/deep/packages/runtime/index.js
        resolves to deep@1.2.3.
   #19: repo-internal packages/orchestrator/src/x.ts still ignored as
        repoInternal (no regression).
   #20: unknown-shape path (somewhere/random/file.js) throws.
   #21: Windows-backslash `node_modules\\backslashy\\packages\\…` still
        resolves via segment normalisation.

3. docs/STATUS.md + docs/VALIDATION.md test count sync 509/31 -> 515/31
   via byte-level patch; surrounding legacy mojibake preserved.
   check:doc-counts 12/12 confirms match.

Real bundle metafile has no `node_modules/<pkg>/packages/` inputs today,
so the fix is invisible to the actual SBOM output. Verified by SHA-256:
  engine.mjs      8b26c7bca1e36474 (unchanged vs 61ad624)
  sbom.cdx.json   eb4b7bced3da42b1 (unchanged vs 61ad624)
  sbom.spdx.json  7139883154cdde14 (unchanged vs 61ad624)
No SBOM regeneration needed; the value of this commit is preventing a
future silent miss and turning it into a `validate:sbom` fail-closed.

Local:
  pnpm vitest run tools/lib/bundle-closure.test.ts
    tools/validate-sbom.test.ts tools/validate-current-docs.test.ts -> 59/59.
  pnpm run test -> 515 tests / 31 files.
  pnpm run validate:sbom -> 86/86.
  pnpm run verify:cloud -> green end-to-end (validate:docs 126/126,
    check:doc-counts 12/12, scan:licenses 20/20, validate:sbom 86/86,
    scan:secrets clean over 223 files).
  pnpm run build x2 -> SBOM/engine SHA-256 identical vs 61ad624 and across runs.
  git diff --check -> exit 0.

Not executed (deliberately, per instructions):
  No push. No PR. No merge. No tag or Release. No runtime dep added.
  No lockfile change. No incident-token access. No algorithm/facts/schema
  /ruleset/interpretation-rule change.
…pression form

Two SBOM standards-compliance fixes on top of the P1 bundle-closure work.

1. Canonical npm purl (purl-spec compliance)
   bundle-closure.ts previously emitted `pkg:npm/${name}@${version}`
   verbatim. For scoped packages that yields `pkg:npm/@scope/pkg@1.0.0`,
   which is NOT a canonical purl: the scope is the purl namespace and its
   `@` must be percent-encoded (`pkg:npm/%40scope/pkg@1.0.0`), otherwise
   SCA tools may fail to parse or match the component.

   - New single source of truth: npmPurl(name, version) exported from
     tools/lib/bundle-closure.ts. Unscoped -> plain form; scoped ->
     %40-encoded namespace; malformed scoped names (no slash / trailing
     slash) throw.
   - The closure builder, build-skill (via closure.purl), validate-sbom
     and ALL test fixtures now derive purls exclusively through npmPurl —
     no caller hand-writes purl strings anymore.
   - validate-sbom now checks each SBOM purl against the CANONICAL purl
     recomputed from (name, version) — check renamed to
     "cyclonedx/spdx purl is canonical: <name>" — so a systematically
     wrong generator can never self-certify by matching its own output.

2. CycloneDX license id vs expression (CycloneDX 1.5 compliance)
   build-skill's old buildCycloneDxLicenseField treated only
   `startsWith('(')` as an expression, so a legal bare SPDX expression
   like `MIT OR Apache-2.0` (no outer parens) was mis-emitted as
   `license.id`.

   - New shared helpers in bundle-closure.ts:
       spdxKind(license): 'id' | 'expression' — single SPDX id charset
       check; expressions require well-formed OR/AND/WITH token structure
       with every operand a valid id; anything else throws (fail-closed —
       arbitrary prose is never silently treated as an SPDX id).
       cycloneDxLicenses(license): id -> [{ license: { id } }];
       any expression (with or without outer parens) -> [{ expression }].
   - build-skill.ts deletes its local paren-only helper and imports the
     shared one.
   - validate-sbom now records WHICH CycloneDX form each component used
     (license.id vs expression) and adds
     "cyclonedx license form is correct: <name>" — an SPDX expression
     smuggled into license.id is rejected. Unrecognized license forms in
     the closure make validateSbom throw (fail-closed).

3. Offline synthetic tests (+20)
   bundle-closure.test.ts (+15): npmPurl unscoped/scoped/deep-scope/
   malformed x2; spdxKind id x3 / parenthesised expression / bare
   expression (the regression) / AND + WITH / prose x2 -> throw /
   dangling operator -> throw / empty -> throw; cycloneDxLicenses id /
   bare expression / parenthesised expression. Existing scoped-purl
   assertion updated to the canonical %40 form.
   validate-sbom.test.ts (+5): #12 scoped canonical purl passes in both
   SBOMs end-to-end; #13 non-canonical raw-@ purl FAILs both canonical
   checks; #14 bare OR expression correctly in CycloneDX expression field
   passes; #15 expression smuggled into license.id FAILs the form check;
   #16 malformed license in closure -> validateSbom throws.
   All fixtures now import npmPurl instead of hand-writing purls.

4. Real-SBOM impact: NONE (verified)
   The current closure has 10 unscoped, single-MIT packages, so canonical
   purls and license forms are unchanged. SHA-256 identical vs d5f6211
   and across two consecutive builds:
     engine.mjs      8b26c7bca1e36474
     sbom.cdx.json   eb4b7bced3da42b1
     sbom.spdx.json  7139883154cdde14

5. Test count sync: 515/31 -> 535/31 in docs/STATUS.md +
   docs/VALIDATION.md (byte-level patch, mojibake preserved);
   check:doc-counts 12/12.

Local results:
  pnpm vitest run tools/lib/bundle-closure.test.ts
    tools/validate-sbom.test.ts -> 60/60 (43 + 17).
  pnpm run test -> 535 tests / 31 files pass.
  pnpm run validate:sbom -> 96/96 (was 86; +10 from the new canonical purl
    and license-form checks over the 10-package closure).
  pnpm run verify:cloud -> green end-to-end, exit 0 (one earlier
    verify:cloud attempt failed in the vitest stage; two standalone
    `pnpm run test` runs and the re-run of verify:cloud all passed
    535/535 — flake did not reproduce).
  pnpm run build x2 -> zero drift (hashes above).
  git diff --check -> exit 0.

Not executed (deliberately, per instructions):
  No push. No PR. No merge. No tag or Release. No runtime dep added.
  No lockfile change. No incident-token access. No algorithm/facts/
  schema/ruleset/interpretation-rule change. README carries no static
  test count.
…e detection

Bug A: spdxKind() accepted malformed SPDX expressions because it only
counted operators vs operands. Replaced with a recursive-descent parser
that validates token sequence, bracket pairing, and operator grammar.
Rejects: MIT OR OR Apache-2.0, MIT Apache-2.0 OR, ((MIT OR Apache-2.0),
OR MIT, (), MIT WITH (dangling), consecutive ids/ops, unmatched parens.
Accepts: MIT OR Apache-2.0, (MIT OR Apache-2.0), GPL-2.0 WITH
Classpath-exception-2.0, (A OR (B AND C)).

Bug B: validate-sbom's extractCdxPackages/extractSpdxPackages silently
overwrote duplicates via Map.set. Added explicit duplicate-name scans
(fail-closed: any third-party name appearing >=2 times is a hard FAIL).

Tests: +9 offline synthetic (+7 spdxKind rejection/acceptance + 2
validate-sbom duplicate detection). Count 535->544/31.
SBOM SHA-256 unchanged vs 31ec7c7 (8b26c7bc/eb4b7bce/71398831).
verify:cloud green, build x2 zero-drift, git diff --check clean.
Local commit only; not pushed.
…ral field checks

1. spdxKind parser: parseAtom now returns 'id'|'paren'; parseTerm only
   permits WITH when the preceding atom is a bare SPDX id. A compound
   (parenthesized) expression followed by WITH is rejected:
     (MIT OR Apache-2.0) WITH X -> throw
     MIT WITH (A OR B)          -> throw (exception must be bare id)
     MIT WITH WITH X            -> throw (double WITH)
   Valid uses unchanged: MIT WITH X, MIT WITH X OR Y, GPL-2.0 WITH Y.

2. validate-sbom structural completeness: after duplicate-detection and
   before per-package matching, every CycloneDX library component and
   every SPDX third-party package is checked for all required identity
   fields (name, version, purl, license). Missing/empty fields are a hard
   FAIL; previously they were silently skipped via continue.

Tests: +11 offline synthetic (4 WITH-position + 7 structural-field).
Count 544->555/31. SBOM SHA-256 unchanged (8b26c7bc/eb4b7bce/71398831).
verify:cloud green. build x2 zero-drift. git diff --check clean.
Local commit only; not pushed.
…nux CI)

computeBundleClosure passed the raw metafile key directly to path.join
when constructing the absolute path for resolvePackageRoot. On Linux,
backslash characters in the key (e.g. node_modules\\foo\\packages\\x.js)
are treated as literal filename characters, not separators, causing
dirname to skip directly to the root and resolvePackageRoot to throw
'could not resolve package root'. Windows worked because its path module
accepts both \\ and / as separators.

Fix: replace \\ with / in the raw key before handing it to
isAbsolute/join. The original raw string is preserved for error messages,
the ignored arrays, and the deterministic inputs field in BundlePackage.

Added test #22: a mixed-separator input (forward + backslash in the same
key) that exercises the full resolvePackageRoot walk — would throw on
Linux without this fix.

Count 555->556/31. SBOM SHA-256 unchanged. verify:cloud green.
build x2 zero-drift. git diff --check clean.
@Jowitt13
Jowitt13 marked this pull request as ready for review July 28, 2026 03:09
@Jowitt13
Jowitt13 merged commit 7731266 into main Jul 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant