feat(sbom): derive and verify bundle dependency closure - #16
Merged
Conversation
…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
marked this pull request as ready for review
July 28, 2026 03:09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
内容
原因
此前 SBOM 依赖手工维护,且若干畸形 SPDX/SBOM 条目可能被宽松解析或静默跳过,无法可靠反映最终 bundle。
影响
只加强构建与供应链门禁;不改变排盘算法、facts、schema、ruleset、依赖锁、Release 或安装清单。
验证
pnpm vitest run tools/lib/bundle-closure.test.ts tools/validate-sbom.test.ts— 80/80pnpm run verify:cloud— 555 tests / 31 files;SBOM 96/96本 PR 保持草稿,未合并、未发版、未打 tag。