diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 2dca6f0..e7746d4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -6,7 +6,7 @@ name: verify # format:check -> lint -> typecheck -> test -> build -> validate:provenance -> # validate:skill -> validate:reading -> validate:docs -> smoke -> forward:test -> # package:hosts -> verify:hosts -> verify:install -> check:doc-counts -> -# scan:deps -> scan:licenses -> scan:secrets +# scan:deps -> scan:licenses -> validate:sbom -> scan:secrets # # The full local gate is `pnpm run verify:all = verify:cloud + scan:incident`. Its precise # incident tokens intentionally live only in a controlled local file and must never be copied to diff --git a/AGENTS.md b/AGENTS.md index bbe0fdd..07a28ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ pnpm run build # rebuild scripts/dist/engine.mjs + sbom.cdx.json (commit `verify:cloud` runs the reproducible, non-sensitive stages: `format:check → lint → typecheck → test → build → validate:provenance → validate:skill → validate:reading → validate:docs → smoke → forward:test → package:hosts → verify:hosts → verify:install → check:doc-counts → scan:deps → -scan:licenses → scan:secrets`. `verify:all` then adds `scan:incident`; its precise token file is ignored, must never +scan:licenses → validate:sbom → scan:secrets`. `verify:all` then adds `scan:incident`; its precise token file is ignored, must never enter CI, and its absence is intentionally fail-closed. If you change the test count, update `docs/STATUS.md` and `docs/VALIDATION.md` from a real run (never by hand). diff --git a/README.md b/README.md index 6d66344..6676c8f 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ pnpm run package # 生成 dist/*.zip + .sha256(自校验完整性) `verify:cloud` 依次运行:`format:check → lint → typecheck → test → build → validate:provenance → validate:skill → validate:reading → validate:docs → smoke → forward:test → package:hosts → -verify:hosts → verify:install → check:doc-counts → scan:deps → scan:licenses → scan:secrets`。`verify:all` 只在其后追加 +verify:hosts → verify:install → check:doc-counts → scan:deps → scan:licenses → validate:sbom → scan:secrets`。`verify:all` 只在其后追加 `scan:incident`,该扫描的私密 token 文件绝不进入 CI;缺失时必须 fail-closed。本仓库当前的真实测试计数由 GitHub Actions 的 `verify` job 与 [`docs/VALIDATION.md`](docs/VALIDATION.md) 的门禁段落负责同步(不再在 README 内维护一个易过期的静态数字)。 深入阅读:[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · [`docs/VALIDATION.md`](docs/VALIDATION.md) · diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e5ebc3f..51d68e0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,6 +66,13 @@ contracts <- time-location <- orchestrator -> engine-entry (esbuild) -> - its packed TZDB) into `scripts/dist/engine.mjs`, and writes `sbom.cdx.json`. The published Skill depends on neither `packages/` nor `node_modules`, and performs no install or network at runtime. +Both SBOMs (`sbom.cdx.json` CycloneDX and `sbom.spdx.json` SPDX 2.3) are derived from the esbuild +metafile's `inputs` list via `tools/lib/bundle-closure.ts` — there is no hand-maintained package +list. If a new third-party package ends up in the bundle, both SBOMs pick it up; if a package +disappears, both SBOMs drop it. `pnpm run validate:sbom` re-runs esbuild independently in +`verify:cloud` and requires an exact match (name/version/purl/license) between the fresh closure +and both committed SBOMs, fail-closed on any drift. + ## Time & location (the critical layer) Local civil time is normalized exactly once: parse wall clock → resolve against historical IANA diff --git a/docs/LICENSE_AUDIT.md b/docs/LICENSE_AUDIT.md index 0081f53..3657479 100644 --- a/docs/LICENSE_AUDIT.md +++ b/docs/LICENSE_AUDIT.md @@ -7,6 +7,11 @@ - **Enforced in the gate**: `pnpm run scan:licenses` (in `verify:cloud`) checks the whole production dependency closure against this policy offline and cross-checks the committed SBOM license claims; it fails closed. +- **Bundle-closure gate**: `pnpm run validate:sbom` (also in `verify:cloud`) re-runs esbuild, + derives the actual third-party runtime closure from the metafile, and requires both + `sbom.cdx.json` and `sbom.spdx.json` to record exactly that closure with matching + name/version/purl/license. The SBOM is derived from the metafile at build time — no + hand-maintained package list can drift from bundle reality. - This file is not legal advice; re-verify before any commercial release. ## Bundled into the published engine (`scripts/dist/engine.mjs`) diff --git a/docs/STATUS.md b/docs/STATUS.md index 0636464..ea1a8f4 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -179,7 +179,7 @@ never by hand. | Command | Result | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pnpm run typecheck` | clean (tsc strict over packages, tools, tests) | -| `pnpm run test` | 472 tests / 29 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | +| `pnpm run test` | 556 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | | `pnpm run build` | `engine.mjs` ?2.8 MB + `sbom.cdx.json` + `sbom.spdx.json` (6 runtime deps) | | `pnpm run validate:skill` | 40 / 40 (incl. scripts/ no-stray-files guard + CycloneDX/SPDX SBOM checks + validate-answer/lint-reading gate-workflow doc checks) | | `pnpm run validate:reading` | 53 / 53 (topic example libraries + output-spec structure + 无术语区 firewall; offline, no LLM) | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 2e9e954..dd62121 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -34,6 +34,7 @@ Before a release or a visibility change, run `pnpm run verify:all` and | Doc counts | `pnpm run check:doc-counts` | Re-runs the suite; fails if a doc's `N tests / M files` drifts from the run. | | Dep vuln scan | `pnpm run scan:deps` | Local: WARN + exit 0 if offline. CI: `DEPENDENCY_AUDIT_STRICT=1` fails closed on unreachable/parse. | | License scan | `pnpm run scan:licenses` | Offline `pnpm licenses` policy gate (LICENSE_AUDIT allowlist) + SBOM license cross-check; fail-closed. | +| SBOM validate | `pnpm run validate:sbom` | Fresh esbuild bundle closure vs both committed SBOMs; any drift/ghost/byte diff fails closed. | | Secret scan | `pnpm run scan:secrets` | Dependency-free scan of tracked files; fails on a leaked credential. | | Incident scan | local `pnpm run verify:all` | Exact incident tokens; fail-closed if the controlled token file is unavailable. | @@ -58,7 +59,7 @@ the identical table in [STATUS.md](./STATUS.md) ("Commands & results"). Do not h resolve a disagreement; re-run the suite and copy the actual count. `pnpm run check:doc-counts` re-runs the suite and fails if either doc's `N tests / M files` count drifts from the real run. -- Typecheck: clean. Tests: **472 tests / 29 files ?all passing**. The Western provider +- Typecheck: clean. Tests: **556 tests / 31 files ?all passing**. The Western provider (astronomy-engine, VSOP87 + NOVAS) passes the ADR-0003 ??gate two ways: wrapper-consistency (vs astronomy-engine's own output) plus an **independent JPL Horizons golden** (10 bodies × 3 technical epochs fetched from the NASA/JPL Horizons service, query recorded in diff --git a/package.json b/package.json index 182d59d..e3a0948 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "validate:reading": "node tools/validate-reading-examples.ts", "validate:docs": "node tools/validate-current-docs.ts", "validate:provenance": "node tools/validate-provenance.ts", + "validate:sbom": "node tools/validate-sbom.ts", "smoke": "node tools/smoke-clean-dir.ts", "forward:test": "node tools/forward-test.ts", "example": "node tools/gen-example.ts", @@ -37,7 +38,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint packages", - "verify:cloud": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && pnpm run validate:provenance && pnpm run validate:skill && pnpm run validate:reading && pnpm run validate:docs && pnpm run smoke && pnpm run forward:test && pnpm run package:hosts && pnpm run verify:hosts && pnpm run verify:install && pnpm run check:doc-counts && pnpm run scan:deps && pnpm run scan:licenses && pnpm run scan:secrets", + "verify:cloud": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && pnpm run validate:provenance && pnpm run validate:skill && pnpm run validate:reading && pnpm run validate:docs && pnpm run smoke && pnpm run forward:test && pnpm run package:hosts && pnpm run verify:hosts && pnpm run verify:install && pnpm run check:doc-counts && pnpm run scan:deps && pnpm run scan:licenses && pnpm run validate:sbom && pnpm run scan:secrets", "verify:all": "pnpm run verify:cloud && pnpm run scan:incident" }, "devDependencies": { diff --git a/skills/calculate-birth-charts/sbom.cdx.json b/skills/calculate-birth-charts/sbom.cdx.json index b8d8878..4b2ccc4 100644 --- a/skills/calculate-birth-charts/sbom.cdx.json +++ b/skills/calculate-birth-charts/sbom.cdx.json @@ -18,9 +18,9 @@ "components": [ { "type": "library", - "name": "zod", - "version": "4.4.3", - "purl": "pkg:npm/zod@4.4.3", + "name": "astronomy-engine", + "version": "2.1.19", + "purl": "pkg:npm/astronomy-engine@2.1.19", "licenses": [ { "license": { @@ -31,9 +31,61 @@ }, { "type": "library", - "name": "moment-timezone", - "version": "0.6.3", - "purl": "pkg:npm/moment-timezone@0.6.3", + "name": "dayjs", + "version": "1.11.21", + "purl": "pkg:npm/dayjs@1.11.21", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + { + "type": "library", + "name": "i18next", + "version": "23.16.8", + "purl": "pkg:npm/i18next@23.16.8", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + { + "type": "library", + "name": "iztro", + "version": "2.5.8", + "purl": "pkg:npm/iztro@2.5.8", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + { + "type": "library", + "name": "lunar-lite", + "version": "0.2.8", + "purl": "pkg:npm/lunar-lite@0.2.8", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + { + "type": "library", + "name": "lunar-typescript", + "version": "1.8.6", + "purl": "pkg:npm/lunar-typescript@1.8.6", "licenses": [ { "license": { @@ -57,9 +109,9 @@ }, { "type": "library", - "name": "tyme4ts", - "version": "1.5.2", - "purl": "pkg:npm/tyme4ts@1.5.2", + "name": "moment-timezone", + "version": "0.6.3", + "purl": "pkg:npm/moment-timezone@0.6.3", "licenses": [ { "license": { @@ -70,9 +122,9 @@ }, { "type": "library", - "name": "iztro", - "version": "2.5.8", - "purl": "pkg:npm/iztro@2.5.8", + "name": "tyme4ts", + "version": "1.5.2", + "purl": "pkg:npm/tyme4ts@1.5.2", "licenses": [ { "license": { @@ -83,9 +135,9 @@ }, { "type": "library", - "name": "astronomy-engine", - "version": "2.1.19", - "purl": "pkg:npm/astronomy-engine@2.1.19", + "name": "zod", + "version": "4.4.3", + "purl": "pkg:npm/zod@4.4.3", "licenses": [ { "license": { diff --git a/skills/calculate-birth-charts/sbom.spdx.json b/skills/calculate-birth-charts/sbom.spdx.json index 11e0d79..063d718 100644 --- a/skills/calculate-birth-charts/sbom.spdx.json +++ b/skills/calculate-birth-charts/sbom.spdx.json @@ -22,9 +22,9 @@ "licenseDeclared": "MIT" }, { - "name": "zod", - "SPDXID": "SPDXRef-Package-zod", - "versionInfo": "4.4.3", + "name": "astronomy-engine", + "SPDXID": "SPDXRef-Package-astronomy-engine", + "versionInfo": "2.1.19", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", @@ -33,14 +33,14 @@ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:npm/zod@4.4.3" + "referenceLocator": "pkg:npm/astronomy-engine@2.1.19" } ] }, { - "name": "moment-timezone", - "SPDXID": "SPDXRef-Package-moment-timezone", - "versionInfo": "0.6.3", + "name": "dayjs", + "SPDXID": "SPDXRef-Package-dayjs", + "versionInfo": "1.11.21", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", @@ -49,7 +49,71 @@ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:npm/moment-timezone@0.6.3" + "referenceLocator": "pkg:npm/dayjs@1.11.21" + } + ] + }, + { + "name": "i18next", + "SPDXID": "SPDXRef-Package-i18next", + "versionInfo": "23.16.8", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/i18next@23.16.8" + } + ] + }, + { + "name": "iztro", + "SPDXID": "SPDXRef-Package-iztro", + "versionInfo": "2.5.8", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/iztro@2.5.8" + } + ] + }, + { + "name": "lunar-lite", + "SPDXID": "SPDXRef-Package-lunar-lite", + "versionInfo": "0.2.8", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/lunar-lite@0.2.8" + } + ] + }, + { + "name": "lunar-typescript", + "SPDXID": "SPDXRef-Package-lunar-typescript", + "versionInfo": "1.8.6", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/lunar-typescript@1.8.6" } ] }, @@ -70,9 +134,9 @@ ] }, { - "name": "tyme4ts", - "SPDXID": "SPDXRef-Package-tyme4ts", - "versionInfo": "1.5.2", + "name": "moment-timezone", + "SPDXID": "SPDXRef-Package-moment-timezone", + "versionInfo": "0.6.3", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", @@ -81,14 +145,14 @@ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:npm/tyme4ts@1.5.2" + "referenceLocator": "pkg:npm/moment-timezone@0.6.3" } ] }, { - "name": "iztro", - "SPDXID": "SPDXRef-Package-iztro", - "versionInfo": "2.5.8", + "name": "tyme4ts", + "SPDXID": "SPDXRef-Package-tyme4ts", + "versionInfo": "1.5.2", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", @@ -97,14 +161,14 @@ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:npm/iztro@2.5.8" + "referenceLocator": "pkg:npm/tyme4ts@1.5.2" } ] }, { - "name": "astronomy-engine", - "SPDXID": "SPDXRef-Package-astronomy-engine", - "versionInfo": "2.1.19", + "name": "zod", + "SPDXID": "SPDXRef-Package-zod", + "versionInfo": "4.4.3", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", @@ -113,7 +177,7 @@ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:npm/astronomy-engine@2.1.19" + "referenceLocator": "pkg:npm/zod@4.4.3" } ] } @@ -127,12 +191,32 @@ { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", "relationshipType": "DEPENDS_ON", - "relatedSpdxElement": "SPDXRef-Package-zod" + "relatedSpdxElement": "SPDXRef-Package-astronomy-engine" }, { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", "relationshipType": "DEPENDS_ON", - "relatedSpdxElement": "SPDXRef-Package-moment-timezone" + "relatedSpdxElement": "SPDXRef-Package-dayjs" + }, + { + "spdxElementId": "SPDXRef-Package-calculate-birth-charts", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-i18next" + }, + { + "spdxElementId": "SPDXRef-Package-calculate-birth-charts", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-iztro" + }, + { + "spdxElementId": "SPDXRef-Package-calculate-birth-charts", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-lunar-lite" + }, + { + "spdxElementId": "SPDXRef-Package-calculate-birth-charts", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-lunar-typescript" }, { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", @@ -142,17 +226,17 @@ { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", "relationshipType": "DEPENDS_ON", - "relatedSpdxElement": "SPDXRef-Package-tyme4ts" + "relatedSpdxElement": "SPDXRef-Package-moment-timezone" }, { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", "relationshipType": "DEPENDS_ON", - "relatedSpdxElement": "SPDXRef-Package-iztro" + "relatedSpdxElement": "SPDXRef-Package-tyme4ts" }, { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", "relationshipType": "DEPENDS_ON", - "relatedSpdxElement": "SPDXRef-Package-astronomy-engine" + "relatedSpdxElement": "SPDXRef-Package-zod" } ] } diff --git a/tools/build-skill.ts b/tools/build-skill.ts index 2aa81ea..e5a6270 100644 --- a/tools/build-skill.ts +++ b/tools/build-skill.ts @@ -1,15 +1,32 @@ import { build } from 'esbuild'; -import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { mkdirSync, statSync, writeFileSync } from 'node:fs'; +import { + computeBundleClosure, + cycloneDxLicenses, + type BundlePackage, +} from './lib/bundle-closure.ts'; /** * Bundle the deterministic engine into the Skill as a single self-contained ESM - * file (scripts/dist/engine.mjs) and emit a CycloneDX SBOM plus an SPDX 2.3 SBOM. - * The bundle inlines zod + moment-timezone (including its packed TZDB), so the - * published Skill has no dependency on the repo's packages/ or on `npm install` - * (handoff §3.1, §12). + * file (scripts/dist/engine.mjs) and emit a CycloneDX SBOM plus an SPDX 2.3 + * SBOM. The bundle inlines every runtime dependency into engine.mjs, so the + * published Skill has no dependency on the repo's packages/ or on + * `npm install` (handoff §3.1, §12). + * + * The two SBOMs are derived from the esbuild metafile's `inputs` list via + * `computeBundleClosure` (see tools/lib/bundle-closure.ts). There is no + * hand-maintained package list here — if a new third-party package ends up in + * the bundle, both SBOMs pick it up automatically; conversely, if a package + * disappears from the bundle, both SBOMs drop it. The closure derivation is + * fail-closed on missing / unresolvable license metadata, so an unaudited + * dependency cannot silently ship. + * + * Byte-stability: components and packages are sorted by name; the SPDX + * `created` timestamp is pinned to a fixed value so committed SBOM artifacts + * are byte-identical across rebuilds. `pnpm run validate:sbom` later verifies + * this end-to-end against a fresh esbuild pass. */ const here = dirname(fileURLToPath(import.meta.url)); @@ -18,21 +35,20 @@ const skillDir = join(root, 'skills', 'calculate-birth-charts'); const entry = join(root, 'packages', 'orchestrator', 'src', 'engine-entry.ts'); const outfile = join(skillDir, 'scripts', 'dist', 'engine.mjs'); -function resolveVersion(name: string, fromPkgJson: string): { version: string; dir: string } { - // Resolve the package entry point, then walk up to package.json (some packages - // don't export './package.json' in their "exports" map — e.g. tyme4ts). - const requireFrom = createRequire(fromPkgJson); - const entryPath = requireFrom.resolve(name); - // Walk up from the resolved entry to find the package root. - let current = dirname(entryPath); - while (!existsSync(join(current, 'package.json'))) { - const parent = dirname(current); - if (parent === current) throw new Error(`Cannot find package root for ${name}`); - current = parent; - } - const pkgPath = join(current, 'package.json'); - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; - return { version: pkg.version, dir: current }; +const APP_NAME = 'calculate-birth-charts'; +const APP_VERSION = '0.1.0'; +// SPDX requires creationInfo.created; a wall-clock value would break the +// v0.1.2 byte-reproducibility of committed build artifacts, so this is a +// FIXED deterministic build timestamp (not the real build instant — the +// pinned dependency versions in `components` are the record). +const SPDX_FIXED_CREATED = '2026-01-01T00:00:00Z'; + +interface CycloneDxComponent { + type: 'library'; + name: string; + version: string; + purl: string; + licenses: unknown[]; } async function main(): Promise { @@ -55,83 +71,70 @@ async function main(): Promise { const bytes = statSync(outfile).size; console.log(`engine.mjs bundled: ${(bytes / 1024).toFixed(1)} KiB`); - // --- CycloneDX SBOM from the actual bundled runtime dependencies --- - const timeLocPkg = join(root, 'packages', 'time-location', 'package.json'); - const baziPkg = join(root, 'packages', 'bazi', 'package.json'); - const ziweiPkg = join(root, 'packages', 'ziwei', 'package.json'); - const westernPkg = join(root, 'packages', 'western', 'package.json'); - const zod = resolveVersion('zod', timeLocPkg); - const momentTz = resolveVersion('moment-timezone', timeLocPkg); - const moment = resolveVersion('moment', join(momentTz.dir, 'package.json')); - const tyme = resolveVersion('tyme4ts', baziPkg); - const iztro = resolveVersion('iztro', ziweiPkg); - const astroEngine = resolveVersion('astronomy-engine', westernPkg); + // --- Derive the actual third-party runtime closure from esbuild metafile --- + const closure = computeBundleClosure(result.metafile, { root }); + console.log( + `bundle closure: ${closure.packages.length} third-party package(s); ` + + `ignored ${closure.ignored.repoInternal.length} repo-internal, ` + + `${closure.ignored.nodeBuiltin.length} node builtin, ` + + `${closure.ignored.virtual.length} virtual input(s).`, + ); - const components = [ - { name: 'zod', version: zod.version, license: 'MIT' }, - { name: 'moment-timezone', version: momentTz.version, license: 'MIT' }, - { name: 'moment', version: moment.version, license: 'MIT' }, - { name: 'tyme4ts', version: tyme.version, license: 'MIT' }, - { name: 'iztro', version: iztro.version, license: 'MIT' }, - { name: 'astronomy-engine', version: astroEngine.version, license: 'MIT' }, - ].map((c) => ({ + const components: CycloneDxComponent[] = closure.packages.map((p: BundlePackage) => ({ type: 'library', - name: c.name, - version: c.version, - purl: `pkg:npm/${c.name}@${c.version}`, - licenses: [{ license: { id: c.license } }], + name: p.name, + version: p.version, + purl: p.purl, + licenses: cycloneDxLicenses(p.license), })); - const sbom = { + const sbomCdx = { bomFormat: 'CycloneDX', specVersion: '1.5', version: 1, metadata: { - // No wall-clock timestamp: the SBOM is a COMMITTED build artifact and must stay - // byte-stable across rebuilds/machines (v0.1.2 reproducibility). The pinned dependency - // versions below are the authoritative record. + // No wall-clock timestamp: the SBOM is a COMMITTED build artifact and + // must stay byte-stable across rebuilds/machines (v0.1.2 + // reproducibility). The pinned dependency versions below are the + // authoritative record. component: { type: 'application', - name: 'calculate-birth-charts', - version: '0.1.0', + name: APP_NAME, + version: APP_VERSION, }, tools: [{ name: 'ming-build-skill', version: '0.1.0' }], }, components, }; - writeFileSync(join(skillDir, 'sbom.cdx.json'), `${JSON.stringify(sbom, null, 2)}\n`, 'utf8'); + writeFileSync(join(skillDir, 'sbom.cdx.json'), `${JSON.stringify(sbomCdx, null, 2)}\n`, 'utf8'); console.log( - `sbom.cdx.json written (zod ${zod.version}, moment-timezone ${momentTz.version}, moment ${moment.version}, tyme4ts ${tyme.version}, iztro ${iztro.version}, astronomy-engine ${astroEngine.version})`, + `sbom.cdx.json written (${closure.packages.map((p) => `${p.name} ${p.version}`).join(', ')})`, ); - // --- SPDX 2.3 SBOM from the SAME component data (Phase 6: second standard format) --- - // SPDX requires creationInfo.created; a wall-clock value would break the v0.1.2 - // byte-reproducibility of committed build artifacts, so this is a FIXED deterministic - // build timestamp (not the real build instant — the pinned versions are the record). - const SPDX_FIXED_CREATED = '2026-01-01T00:00:00Z'; - const spdxPackages = components.map((c) => ({ - name: c.name, - SPDXID: `SPDXRef-Package-${c.name.replace(/[^A-Za-z0-9.-]/g, '-')}`, - versionInfo: c.version, + // --- SPDX 2.3 SBOM from the SAME closure (Phase 6: second standard format) --- + const spdxPackages = closure.packages.map((p) => ({ + name: p.name, + SPDXID: `SPDXRef-Package-${p.name.replace(/[^A-Za-z0-9.-]/g, '-')}`, + versionInfo: p.version, downloadLocation: 'NOASSERTION', filesAnalyzed: false, - licenseConcluded: c.licenses[0]!.license.id, - licenseDeclared: c.licenses[0]!.license.id, + licenseConcluded: p.license, + licenseDeclared: p.license, externalRefs: [ { referenceCategory: 'PACKAGE-MANAGER', referenceType: 'purl', - referenceLocator: c.purl, + referenceLocator: p.purl, }, ], })); - const spdx = { + const sbomSpdx = { spdxVersion: 'SPDX-2.3', dataLicense: 'CC0-1.0', SPDXID: 'SPDXRef-DOCUMENT', - name: 'calculate-birth-charts-0.1.0', - documentNamespace: 'https://github.com/Jowitt13/ming-engine/spdx/calculate-birth-charts-0.1.0', + name: `${APP_NAME}-${APP_VERSION}`, + documentNamespace: `https://github.com/Jowitt13/ming-engine/spdx/${APP_NAME}-${APP_VERSION}`, creationInfo: { created: SPDX_FIXED_CREATED, creators: ['Tool: ming-build-skill-0.1.0'], @@ -140,9 +143,9 @@ async function main(): Promise { }, packages: [ { - name: 'calculate-birth-charts', - SPDXID: 'SPDXRef-Package-calculate-birth-charts', - versionInfo: '0.1.0', + name: APP_NAME, + SPDXID: `SPDXRef-Package-${APP_NAME}`, + versionInfo: APP_VERSION, downloadLocation: 'NOASSERTION', filesAnalyzed: false, licenseConcluded: 'MIT', @@ -154,16 +157,16 @@ async function main(): Promise { { spdxElementId: 'SPDXRef-DOCUMENT', relationshipType: 'DESCRIBES', - relatedSpdxElement: 'SPDXRef-Package-calculate-birth-charts', + relatedSpdxElement: `SPDXRef-Package-${APP_NAME}`, }, ...spdxPackages.map((p) => ({ - spdxElementId: 'SPDXRef-Package-calculate-birth-charts', + spdxElementId: `SPDXRef-Package-${APP_NAME}`, relationshipType: 'DEPENDS_ON', relatedSpdxElement: p.SPDXID, })), ], }; - writeFileSync(join(skillDir, 'sbom.spdx.json'), `${JSON.stringify(spdx, null, 2)}\n`, 'utf8'); + writeFileSync(join(skillDir, 'sbom.spdx.json'), `${JSON.stringify(sbomSpdx, null, 2)}\n`, 'utf8'); console.log(`sbom.spdx.json written (SPDX 2.3, ${spdxPackages.length} dependency packages)`); const outputs = Object.keys(result.metafile.outputs); diff --git a/tools/lib/bundle-closure.test.ts b/tools/lib/bundle-closure.test.ts new file mode 100644 index 0000000..610695e --- /dev/null +++ b/tools/lib/bundle-closure.test.ts @@ -0,0 +1,448 @@ +// Offline unit tests for tools/lib/bundle-closure.ts. +// Every test uses either an injected in-memory readPackageJson OR a tmpdir +// synthetic node_modules layout — never the real repo tree. +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + computeBundleClosure, + cycloneDxLicenses, + extractLicense, + npmPurl, + spdxKind, +} from './bundle-closure.ts'; + +/** + * In-memory readPackageJson factory: returns a function that answers + * package.json lookups for the given absolute-directory -> parsed JSON map. + * Any dir not present in the map returns null (equivalent to "no package.json"). + */ +function makeReader(map: Map): (dir: string) => unknown { + return (dir: string) => map.get(dir) ?? null; +} + +/** Build a virtual root path so tests stay OS-independent. */ +const ROOT = process.platform === 'win32' ? 'C:\\repo' : '/repo'; +const nm = (p: string): string => join(ROOT, ...p.split('/')); + +describe('bundle-closure: extractLicense', () => { + it('accepts modern SPDX string', () => { + expect(extractLicense({ license: 'MIT' })).toBe('MIT'); + }); + it('accepts legacy object license.type', () => { + expect(extractLicense({ license: { type: 'Apache-2.0' } })).toBe('Apache-2.0'); + }); + it('builds OR expression from legacy licenses[] array', () => { + expect( + extractLicense({ + licenses: [{ type: 'MIT' }, { type: 'Apache-2.0' }], + }), + ).toBe('(MIT OR Apache-2.0)'); + }); + it('rejects UNLICENSED', () => { + expect(() => extractLicense({ license: 'UNLICENSED' })).toThrow(/unresolvable license/); + }); + it('rejects "SEE LICENSE IN "', () => { + expect(() => extractLicense({ license: 'SEE LICENSE IN COPYING' })).toThrow( + /unresolvable license/, + ); + }); + it('rejects missing license field entirely', () => { + expect(() => extractLicense({})).toThrow(/unresolvable license/); + }); +}); + +describe('bundle-closure: computeBundleClosure', () => { + const dirZod = nm('node_modules/zod'); + const dirScoped = nm('node_modules/@scope/pkg'); + const dirPnpmFoo = nm('node_modules/.pnpm/foo@1.2.3/node_modules/foo'); + const dirPnpmScoped = nm('node_modules/.pnpm/@scope+bar@2.0.0/node_modules/@scope/bar'); + const dirMoment = nm('node_modules/moment'); + + const pkgMap = new Map([ + [dirZod, { name: 'zod', version: '4.4.3', license: 'MIT' }], + [dirScoped, { name: '@scope/pkg', version: '0.1.0', license: 'MIT' }], + [dirPnpmFoo, { name: 'foo', version: '1.2.3', license: 'MIT' }], + [dirPnpmScoped, { name: '@scope/bar', version: '2.0.0', license: 'MIT' }], + [dirMoment, { name: 'moment', version: '2.30.1', license: 'MIT' }], + ]); + const readPackageJson = makeReader(pkgMap); + + function run(paths: string[]) { + const inputs: Record = {}; + for (const p of paths) inputs[p] = {}; + return computeBundleClosure({ inputs }, { root: ROOT, readPackageJson }); + } + + it('1. direct dependency at node_modules/', () => { + const r = run(['node_modules/zod/index.js']); + expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['zod@4.4.3']); + expect(r.packages[0]!.purl).toBe('pkg:npm/zod@4.4.3'); + expect(r.packages[0]!.license).toBe('MIT'); + }); + + it('2. transitive dependency alongside a direct one', () => { + const r = run(['node_modules/moment/moment.js', 'node_modules/zod/index.js']); + expect(r.packages.map((p) => p.name)).toEqual(['moment', 'zod']); + }); + + it('3. scoped package at node_modules/@scope/', () => { + const r = run(['node_modules/@scope/pkg/index.js']); + expect(r.packages[0]!.name).toBe('@scope/pkg'); + // Canonical purl: the scope's `@` is percent-encoded per the purl spec. + expect(r.packages[0]!.purl).toBe('pkg:npm/%40scope/pkg@0.1.0'); + }); + + it('4. pnpm nested layout attributes to the real package, not the hash dir', () => { + const r = run([ + 'node_modules/.pnpm/foo@1.2.3/node_modules/foo/lib/a.js', + 'node_modules/.pnpm/foo@1.2.3/node_modules/foo/lib/b.js', + ]); + expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['foo@1.2.3']); + expect(r.packages[0]!.inputs).toHaveLength(2); + }); + + it('5. pnpm nested + scoped', () => { + const r = run(['node_modules/.pnpm/@scope+bar@2.0.0/node_modules/@scope/bar/index.js']); + expect(r.packages[0]!.name).toBe('@scope/bar'); + expect(r.packages[0]!.version).toBe('2.0.0'); + }); + + it('6. repo-internal packages/ are ignored', () => { + const r = run(['packages/orchestrator/src/engine-entry.ts', 'node_modules/zod/index.js']); + expect(r.packages.map((p) => p.name)).toEqual(['zod']); + expect(r.ignored.repoInternal).toContain('packages/orchestrator/src/engine-entry.ts'); + }); + + it('7. Node built-in node:crypto is ignored', () => { + const r = run(['node:crypto', 'node_modules/zod/index.js']); + expect(r.ignored.nodeBuiltin).toEqual(['node:crypto']); + expect(r.packages.map((p) => p.name)).toEqual(['zod']); + }); + + it('8. esbuild synthetic input is ignored', () => { + const r = run(['', 'node_modules/zod/index.js']); + expect(r.ignored.virtual).toEqual(['']); + expect(r.packages.map((p) => p.name)).toEqual(['zod']); + }); + + it('9. multiple inputs for the same package deduplicate and sort inputs', () => { + const r = run(['node_modules/zod/z.js', 'node_modules/zod/a.js', 'node_modules/zod/m.js']); + expect(r.packages).toHaveLength(1); + expect(r.packages[0]!.inputs).toEqual([ + 'node_modules/zod/a.js', + 'node_modules/zod/m.js', + 'node_modules/zod/z.js', + ]); + }); + + it('10. same package name at two different roots with different versions -> throw', () => { + // Simulate a second `foo` package.json at a different pnpm hash dir. + const dirFooOther = nm('node_modules/.pnpm/foo@9.9.9/node_modules/foo'); + const map = new Map(pkgMap); + map.set(dirFooOther, { name: 'foo', version: '9.9.9', license: 'MIT' }); + expect(() => + computeBundleClosure( + { + inputs: { + 'node_modules/.pnpm/foo@1.2.3/node_modules/foo/a.js': {}, + 'node_modules/.pnpm/foo@9.9.9/node_modules/foo/b.js': {}, + }, + }, + { root: ROOT, readPackageJson: makeReader(map) }, + ), + ).toThrow(/multiple versions/); + }); + + it('11. missing license field -> throw', () => { + const dirBad = nm('node_modules/badpkg'); + const map = new Map([[dirBad, { name: 'badpkg', version: '1.0.0' }]]); + expect(() => + computeBundleClosure( + { inputs: { 'node_modules/badpkg/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ), + ).toThrow(/unresolvable license/); + }); + + it('12. license "UNLICENSED" -> throw', () => { + const dirBad = nm('node_modules/unl'); + const map = new Map([ + [dirBad, { name: 'unl', version: '1.0.0', license: 'UNLICENSED' }], + ]); + expect(() => + computeBundleClosure( + { inputs: { 'node_modules/unl/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ), + ).toThrow(/unresolvable license/); + }); + + it('13. legacy licenses[] array yields OR expression', () => { + const dirDual = nm('node_modules/dual'); + const map = new Map([ + [ + dirDual, + { + name: 'dual', + version: '3.0.0', + licenses: [{ type: 'MIT' }, { type: 'Apache-2.0' }], + }, + ], + ]); + const r = computeBundleClosure( + { inputs: { 'node_modules/dual/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages[0]!.license).toBe('(MIT OR Apache-2.0)'); + }); + + it('14. output is deterministic: same metafile -> byte-identical JSON', () => { + const inputs = { + 'node_modules/zod/index.js': {}, + 'node_modules/moment/moment.js': {}, + 'node_modules/@scope/pkg/index.js': {}, + }; + const a = computeBundleClosure({ inputs }, { root: ROOT, readPackageJson }); + const b = computeBundleClosure({ inputs }, { root: ROOT, readPackageJson }); + expect(JSON.stringify(a.packages)).toBe(JSON.stringify(b.packages)); + }); + + it('15. package.json living outside node_modules -> throw (never attribute)', () => { + // Simulate a stray package.json in the repo root that a naive walker + // would trip on. The classifier must land the input into thirdParty (path + // contains node_modules) and refuse to attribute to a non-node_modules root. + const dirStray = nm('foo'); + const map = new Map([ + [dirStray, { name: 'foo', version: '0.0.0', license: 'MIT' }], + ]); + // Also provide a proper node_modules entry so the walk-up would otherwise + // pass; here we pass an input whose *only* package.json is at the stray dir. + expect(() => + computeBundleClosure( + { inputs: { 'node_modules/no-such-pkg/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ), + ).toThrow(/could not resolve package root/); + }); + + // --- Regression: classifyInput priority bug (P1-fix) -------------------- + // Pre-fix, classifyInput ran `repoInternal` before `thirdParty` and used a + // substring `.includes('/packages/')`, so any third-party package with its + // own `packages/` subdirectory got silently swept into `ignored.repoInternal` + // and the bundle closure lost the package. These tests lock in the fix. + + it('16. classifyInput priority: node_modules/foo/packages/... resolves to foo (not repoInternal)', () => { + // Exact repro from the P1-fix report. + const dirSynth = nm('node_modules/synthetic-dep'); + const map = new Map([ + [dirSynth, { name: 'synthetic-dep', version: '1.0.0', license: 'MIT' }], + ]); + const r = computeBundleClosure( + { inputs: { 'node_modules/synthetic-dep/packages/runtime/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['synthetic-dep@1.0.0']); + // Must NOT be swept into repoInternal even though the path contains 'packages/'. + expect(r.ignored.repoInternal).toEqual([]); + }); + + it('17. classifyInput priority: scoped node_modules/@scope/pkg/packages/... resolves to @scope/pkg', () => { + const dirScopedInner = nm('node_modules/@scope/inner'); + const map = new Map([ + [dirScopedInner, { name: '@scope/inner', version: '3.4.5', license: 'MIT' }], + ]); + const r = computeBundleClosure( + { inputs: { 'node_modules/@scope/inner/packages/runtime/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages[0]!.name).toBe('@scope/inner'); + expect(r.packages[0]!.version).toBe('3.4.5'); + expect(r.ignored.repoInternal).toEqual([]); + }); + + it('18. classifyInput priority: pnpm-nested pkg with internal packages/ subdir resolves to the pkg', () => { + const dirPnpmDeep = nm('node_modules/.pnpm/deep@1.2.3/node_modules/deep'); + const map = new Map([ + [dirPnpmDeep, { name: 'deep', version: '1.2.3', license: 'MIT' }], + ]); + const r = computeBundleClosure( + { + inputs: { + 'node_modules/.pnpm/deep@1.2.3/node_modules/deep/packages/runtime/index.js': {}, + }, + }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['deep@1.2.3']); + expect(r.ignored.repoInternal).toEqual([]); + }); + + it('19. repo-internal packages/ still routes to ignored.repoInternal (no regression)', () => { + const r = run(['packages/orchestrator/src/x.ts']); + expect(r.packages).toEqual([]); + expect(r.ignored.repoInternal).toEqual(['packages/orchestrator/src/x.ts']); + }); + + it('20. unknown path (neither node_modules nor packages/) -> throw (fail-closed)', () => { + expect(() => + computeBundleClosure( + { inputs: { 'somewhere/random/file.js': {} } }, + { root: ROOT, readPackageJson }, + ), + ).toThrow(/could not classify metafile input/); + }); + + it('21. Windows backslash path normalises so segment logic still runs', () => { + // A metafile that carries `\\`-separated paths (unusual but possible on + // Windows or in captured fixtures) must still be classified via segment + // matching, not fold to repoInternal via `\packages\`. + const map = new Map([ + [nm('node_modules/backslashy'), { name: 'backslashy', version: '0.0.9', license: 'MIT' }], + ]); + const r = computeBundleClosure( + { + inputs: { + [`node_modules\\backslashy\\packages\\runtime\\x.js`]: {}, + }, + }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['backslashy@0.0.9']); + expect(r.ignored.repoInternal).toEqual([]); + }); + + it('22. mixed-separator input resolves via file-system path (not just classification)', () => { + // Forward + backward slashes mixed in the same key. Must survive both + // classifyInput (segment logic) AND resolvePackageRoot (dirname walk). + // If the backslash-to-forward-slash normalisation only happened in + // classifyInput but NOT in the file-system resolution stage, this test + // would throw on Linux. + const dirMixed = nm('node_modules/mixedpkg'); + const map = new Map([ + [dirMixed, { name: 'mixedpkg', version: '2.0.0', license: 'MIT' }], + ]); + const r = computeBundleClosure( + { inputs: { 'node_modules/mixedpkg\\src/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages[0]!.name).toBe('mixedpkg'); + }); +}); + +describe('bundle-closure: npmPurl (canonical purl)', () => { + it('unscoped package keeps the plain form', () => { + expect(npmPurl('zod', '4.4.3')).toBe('pkg:npm/zod@4.4.3'); + }); + it('scoped package percent-encodes the scope @', () => { + expect(npmPurl('@scope/pkg', '1.0.0')).toBe('pkg:npm/%40scope/pkg@1.0.0'); + }); + it('deeply-named scope works (@a-b.c/x)', () => { + expect(npmPurl('@a-b.c/x', '0.0.1')).toBe('pkg:npm/%40a-b.c/x@0.0.1'); + }); + it('malformed scoped name (no slash) -> throw', () => { + expect(() => npmPurl('@scopeonly', '1.0.0')).toThrow(/invalid scoped npm package name/); + }); + it('malformed scoped name (trailing slash) -> throw', () => { + expect(() => npmPurl('@scope/', '1.0.0')).toThrow(/invalid scoped npm package name/); + }); +}); + +describe('bundle-closure: spdxKind / cycloneDxLicenses', () => { + it('single SPDX id -> id', () => { + expect(spdxKind('MIT')).toBe('id'); + expect(spdxKind('Apache-2.0')).toBe('id'); + expect(spdxKind('GPL-2.0+')).toBe('id'); + }); + it('parenthesised OR expression -> expression', () => { + expect(spdxKind('(MIT OR Apache-2.0)')).toBe('expression'); + }); + it('bare OR expression WITHOUT parens -> expression (regression: was mis-typed as id)', () => { + expect(spdxKind('MIT OR Apache-2.0')).toBe('expression'); + }); + it('AND / WITH operators -> expression', () => { + expect(spdxKind('MIT AND BSD-3-Clause')).toBe('expression'); + expect(spdxKind('GPL-2.0 WITH Classpath-exception-2.0')).toBe('expression'); + }); + it('arbitrary prose -> throw (never silently treated as SPDX id)', () => { + expect(() => spdxKind('see license file')).toThrow(/SPDX parse error/); + expect(() => spdxKind('Custom License v2!')).toThrow(/SPDX parse error/); + }); + it('dangling operator -> throw', () => { + expect(() => spdxKind('MIT OR')).toThrow(/SPDX parse error/); + }); + it('empty -> throw', () => { + expect(() => spdxKind(' ')).toThrow(/empty SPDX license/); + }); + // --- P1-fix-3 regression: recursive-descent parser catches malformed expressions --- + it('consecutive operators (MIT OR OR Apache-2.0) -> throw', () => { + expect(() => spdxKind('MIT OR OR Apache-2.0')).toThrow(/SPDX parse error/); + }); + it('trailing operator + consecutive ids (MIT Apache-2.0 OR) -> throw', () => { + expect(() => spdxKind('MIT Apache-2.0 OR')).toThrow(/SPDX parse error/); + }); + it('unmatched parens ((MIT OR Apache-2.0) -> throw', () => { + expect(() => spdxKind('((MIT OR Apache-2.0)')).toThrow(/SPDX parse error/); + }); + it('leading operator (OR MIT) -> throw', () => { + expect(() => spdxKind('OR MIT')).toThrow(/SPDX parse error/); + }); + it('empty parens () -> throw', () => { + expect(() => spdxKind('()')).toThrow(/SPDX parse error/); + }); + it('dangling WITH (MIT WITH) -> throw', () => { + expect(() => spdxKind('MIT WITH')).toThrow(/SPDX parse error/); + }); + it('nested parens (A OR (B AND C)) -> expression (valid)', () => { + expect(spdxKind('(A OR (B AND C))')).toBe('expression'); + }); + // --- P1-fix-4: WITH position restriction --- + it('WITH on compound expression -> throw', () => { + expect(() => spdxKind('(MIT OR Apache-2.0) WITH Classpath-exception-2.0')).toThrow( + /WITH can only follow a bare SPDX id/, + ); + }); + it('WITH exception is parens -> throw', () => { + expect(() => spdxKind('MIT WITH (A OR B)')).toThrow(/SPDX parse error/); + }); + it('double WITH -> throw', () => { + expect(() => spdxKind('MIT WITH WITH X')).toThrow(/SPDX parse error/); + }); + it('bare id WITH exception then OR continuation -> expression (valid)', () => { + expect(spdxKind('MIT WITH Classpath-exception-2.0 OR Apache-2.0')).toBe('expression'); + }); + it('cycloneDxLicenses: id form -> license.id entry', () => { + expect(cycloneDxLicenses('MIT')).toEqual([{ license: { id: 'MIT' } }]); + }); + it('cycloneDxLicenses: unparenthesised expression -> expression entry', () => { + expect(cycloneDxLicenses('MIT OR Apache-2.0')).toEqual([{ expression: 'MIT OR Apache-2.0' }]); + }); + it('cycloneDxLicenses: parenthesised expression -> expression entry', () => { + expect(cycloneDxLicenses('(MIT OR Apache-2.0)')).toEqual([ + { expression: '(MIT OR Apache-2.0)' }, + ]); + }); +}); + +describe('bundle-closure: real disk smoke via tmpdir', () => { + it('resolves against a real node_modules layout without touching the repo tree', () => { + const dir = mkdtempSync(join(tmpdir(), 'bc-')); + try { + const pkgDir = join(dir, 'node_modules', 'realfoo'); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify({ name: 'realfoo', version: '0.0.1', license: 'MIT' }), + ); + writeFileSync(join(pkgDir, 'index.js'), '// stub'); + const rel = ['node_modules', 'realfoo', 'index.js'].join('/'); + const inputs: Record = { [rel]: {} }; + const r = computeBundleClosure({ inputs }, { root: dir }); + expect(r.packages.map((p) => p.name + '@' + p.version)).toEqual(['realfoo@0.0.1']); + expect(r.packages[0]!.inputs[0]!.split(sep).join('/')).toBe(rel); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/lib/bundle-closure.ts b/tools/lib/bundle-closure.ts new file mode 100644 index 0000000..7fd57c9 --- /dev/null +++ b/tools/lib/bundle-closure.ts @@ -0,0 +1,492 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; + +/** + * Derive the third-party runtime dependency closure of a bundle from an esbuild + * metafile. The result is what `sbom.cdx.json` and `sbom.spdx.json` MUST + * describe: every third-party npm package whose source ended up in + * `scripts/dist/engine.mjs`. + * + * The function is pure over `(metafile, root, readPackageJson)` — no filesystem + * writes, no network. Callers inject `readPackageJson` in tests so nothing on + * disk is touched. The real build wires the default disk reader. + * + * Fail-closed policy: + * - Any third-party input path whose containing package cannot be resolved + * (missing package.json, missing `name` or `version`, ambiguous package + * layout, `.pnpm` hash directory misidentified as a package root, same + * name resolved to two different versions) causes a hard throw. + * - Any package with a license that cannot be resolved into an SPDX id or + * SPDX-`OR` expression (`UNLICENSED`, `SEE LICENSE IN LICENSE`, missing) + * causes a hard throw. + * - Path types we intentionally exclude are collected in `ignored` for + * observability but never silently discarded. + * + * The returned `packages` array is sorted by `name`; each package's `inputs` + * array is sorted lexicographically. Same metafile in → byte-identical + * `JSON.stringify(packages)` out. + */ + +export interface BundlePackage { + /** Full npm package name, including `@scope/` for scoped packages. */ + name: string; + /** Exact version read from the package's `package.json`. */ + version: string; + /** + * SPDX license id (`"MIT"`) or an OR expression built from a legacy + * `licenses` array (`"(MIT OR Apache-2.0)"`). Never `undefined`. + */ + license: string; + /** Package URL derived from `name` and `version`. */ + purl: string; + /** Absolute directory containing the package's `package.json`. */ + packageRoot: string; + /** + * All metafile input paths (relative to `root`) that resolved into this + * package, sorted lexicographically. Useful for diagnostics. + */ + inputs: string[]; +} + +export interface BundleClosureResult { + packages: BundlePackage[]; + ignored: { + repoInternal: string[]; + nodeBuiltin: string[]; + virtual: string[]; + }; +} + +export interface ComputeBundleClosureOptions { + root: string; + /** + * Read the package.json at the given absolute directory, returning its + * parsed JSON value or `null` if not present. Default: read from disk. + */ + readPackageJson?: (dir: string) => unknown; +} + +/** Parse a package.json value into a bare `{ name, version, license }` triple. */ +interface RawPackageJson { + name?: unknown; + version?: unknown; + license?: unknown; + licenses?: unknown; +} + +function defaultReadPackageJson(dir: string): unknown { + const p = join(dir, 'package.json'); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, 'utf8')); + } catch { + return null; + } +} + +/** + * Extract an SPDX id or an SPDX-OR expression from a `package.json` license + * field. Fail-closed on anything unresolvable. + */ +export function extractLicense(pkg: RawPackageJson): string { + const bad = (why: string): never => { + throw new Error(`unresolvable license: ${why}`); + }; + + // Modern SPDX form. + if (typeof pkg.license === 'string') { + const v = pkg.license.trim(); + if (v === '' || /^UNLICENSED$/i.test(v) || /^SEE LICENSE IN /i.test(v)) { + bad(`license string "${pkg.license}"`); + } + return v; + } + // Legacy single-object form: { license: { type: "MIT" } }. + if ( + pkg.license !== null && + typeof pkg.license === 'object' && + typeof (pkg.license as { type?: unknown }).type === 'string' + ) { + const t = ((pkg.license as { type: string }).type ?? '').trim(); + if (t === '') bad('empty license.type'); + return t; + } + // Legacy array form: { licenses: [{ type: "MIT" }, { type: "Apache-2.0" }] }. + if (Array.isArray(pkg.licenses)) { + const ids: string[] = []; + for (const entry of pkg.licenses) { + if ( + entry !== null && + typeof entry === 'object' && + typeof (entry as { type?: unknown }).type === 'string' + ) { + const t = ((entry as { type: string }).type ?? '').trim(); + if (t === '') bad('empty licenses[i].type'); + ids.push(t); + } else { + bad('malformed licenses[] entry'); + } + } + if (ids.length === 0) bad('empty licenses[]'); + if (ids.length === 1) return ids[0]!; + return `(${ids.join(' OR ')})`; + } + bad('no license or licenses field'); + // Unreachable; `bad` throws. + return ''; +} + +/** + * Canonical npm Package URL (purl) per the purl spec + * (https://github.com/package-url/purl-spec): for scoped packages the scope + * is the purl namespace and its `@` MUST be percent-encoded, e.g. + * pkg:npm/%40scope/name@1.2.3 + * Unscoped packages keep the plain form: + * pkg:npm/name@1.2.3 + * + * This is the ONLY place npm purls are generated — build-skill, validate-sbom + * and test fixtures all call this function so no caller can hand-write a + * divergent (non-canonical) purl string. + */ +export function npmPurl(name: string, version: string): string { + if (name.startsWith('@')) { + const slash = name.indexOf('/'); + if (slash <= 1 || slash === name.length - 1) { + throw new Error(`invalid scoped npm package name: ${name}`); + } + const scope = name.slice(1, slash); // without the leading '@' + const rest = name.slice(slash + 1); + return `pkg:npm/%40${scope}/${rest}@${version}`; + } + return `pkg:npm/${name}@${version}`; +} + +/** One bare SPDX id: letters, digits, dot, dash, optional trailing `+`. */ +const SPDX_ID_RE = /^[A-Za-z0-9.-]+\+?$/; + +/** + * Distinguish a single SPDX license id from an SPDX license expression using + * a proper recursive-descent parser that validates token sequence, bracket + * pairing, and operator grammar. This replaces the previous count-based + * heuristic which accepted malformed expressions such as + * `MIT OR OR Apache-2.0`, `MIT Apache-2.0 OR`, and `((MIT OR Apache-2.0)`. + * + * Grammar (simplified SPDX compound expression): + * + * expr := term ((OR | AND) term)* + * term := atom (WITH atom)? + * atom := id | '(' expr ')' + * + * Where: + * id = SPDX_ID_RE (letters, digits, dot, dash, optional trailing `+`) + * + * Rejects: consecutive operators, consecutive ids without an operator, + * unmatched parens, empty parens, trailing/leading operators, dangling WITH. + * + * Returns: + * 'id' if the entire string is a single SPDX id + * 'expression' if it is a well-formed compound expression + * throws on anything else (fail-closed) + */ +export function spdxKind(license: string): 'id' | 'expression' { + const t = license.trim(); + if (t === '') throw new Error('empty SPDX license'); + if (SPDX_ID_RE.test(t)) return 'id'; // fast path: single bare id + + // Tokenize: insert spaces around parens, split, filter empties. + const tokens: string[] = t + .replace(/[()]/g, (m) => ` ${m} `) + .split(/\s+/) + .filter((x) => x.length > 0); + if (tokens.length === 0) throw new Error('empty SPDX license'); + if (tokens.length === 1 && SPDX_ID_RE.test(tokens[0]!)) return 'id'; + + // Recursive-descent parser. + let pos = 0; + const peek = (): string | undefined => tokens[pos]; + const advance = (): string => { + if (pos >= tokens.length) { + throw new Error(`SPDX parse error in "${license}": unexpected end of expression`); + } + return tokens[pos++]!; + }; + const isOp = (x: string): boolean => x === 'OR' || x === 'AND'; + + function parseExpr(): void { + parseTerm(); + while (pos < tokens.length && peek() !== ')' && isOp(peek()!)) { + advance(); // consume OR | AND + parseTerm(); + } + } + + function parseTerm(): void { + const atomKind = parseAtom(); + // Optional WITH clause (a license exception like `GPL-2.0 WITH Classpath-exception-2.0`). + // Per SPDX spec, WITH can only follow a BARE license id, not a compound + // (parenthesized) expression. `(MIT OR Apache-2.0) WITH X` is invalid. + if (pos < tokens.length && peek() === 'WITH') { + if (atomKind !== 'id') { + throw new Error( + `SPDX parse error in "${license}": WITH can only follow a bare SPDX id, not a compound expression`, + ); + } + advance(); // consume WITH + // The WITH operand must be a single SPDX id (not a sub-expression or parens). + const ex = advance(); + if (!SPDX_ID_RE.test(ex)) { + throw new Error( + `SPDX parse error in "${license}": expected exception id after WITH, got "${ex}"`, + ); + } + } + } + + function parseAtom(): 'id' | 'paren' { + const tok = advance(); + if (tok === '(') { + if (peek() === ')') { + throw new Error(`SPDX parse error in "${license}": empty parentheses`); + } + parseExpr(); + const close = advance(); + if (close !== ')') { + throw new Error(`SPDX parse error in "${license}": expected ')' but got "${close}"`); + } + return 'paren'; + } + if (tok === ')') { + throw new Error(`SPDX parse error in "${license}": unexpected ')'`); + } + if (isOp(tok) || tok === 'WITH') { + throw new Error( + `SPDX parse error in "${license}": unexpected operator "${tok}" where id was expected`, + ); + } + if (!SPDX_ID_RE.test(tok)) { + throw new Error(`SPDX parse error in "${license}": "${tok}" is not a valid SPDX id`); + } + return 'id'; + } + + parseExpr(); + if (pos !== tokens.length) { + throw new Error(`SPDX parse error in "${license}": unexpected trailing token "${tokens[pos]}"`); + } + return 'expression'; +} + +/** + * Build the CycloneDX `licenses` array for one component. Single SPDX ids go + * into `{ license: { id } }`; every expression (with or without outer parens) + * goes into `{ expression }`. Unrecognized forms throw via spdxKind. + */ +export function cycloneDxLicenses(license: string): unknown[] { + const t = license.trim(); + return spdxKind(t) === 'id' ? [{ license: { id: t } }] : [{ expression: t }]; +} + +/** + * Classify a metafile input path into one of four categories, or 'unknown' + * if the path cannot be safely attributed. The classifier uses + * PATH-SEGMENT precise matching, never a substring `includes()`, so a + * third-party package with its own `packages/` subdirectory (a very common + * pattern in monorepo-shipped packages) is not silently swept into the + * repo-internal category. + * + * Order matters: + * 1. virtual (esbuild synthetic inputs like `` and ``) + * 2. nodeBuiltin (`node:` prefixed identifiers) + * 3. thirdParty (ANY path with `node_modules` as a real path segment) — + * even when the path contains later segments named `packages`. + * 4. repoInternal (path whose FIRST segment is exactly `packages`) — the + * workspace's own `packages//…` source files. + * 5. unknown — anything that does not match the above. Callers must treat + * this as a hard error rather than a silent skip. + */ +type Category = + | { kind: 'virtual' } + | { kind: 'nodeBuiltin' } + | { kind: 'repoInternal' } + | { kind: 'thirdParty' } + | { kind: 'unknown' }; + +function classifyInput(input: string): Category { + // esbuild emits synthetic entries like `` and ``. + if (input.startsWith('<') || input.includes('\x00')) return { kind: 'virtual' }; + if (input.startsWith('node:')) return { kind: 'nodeBuiltin' }; + // Normalise BOTH backslashes and the OS separator to `/` so segment + // matching works uniformly on Windows, POSIX and any mixed-separator input + // that a metafile might carry. + const norm = input.replace(/\\/g, '/'); + const parts = norm.split('/').filter((p) => p.length > 0); + + // PRIORITY: a path with `node_modules` as a real path SEGMENT is + // third-party — even if a later segment happens to be named `packages/`. + // Substring matching (`.includes('/packages/')`) would false-positive on + // `node_modules/foo/packages/runtime/index.js`. + if (parts.includes('node_modules')) return { kind: 'thirdParty' }; + + // repoInternal: only when the FIRST segment is exactly `packages`. + if (parts[0] === 'packages') return { kind: 'repoInternal' }; + + // Anything else cannot be safely attributed — fail-closed at the caller. + return { kind: 'unknown' }; +} + +/** + * Walk up from a metafile input's absolute path, looking for the containing + * package.json. Returns the package root and its parsed JSON. Fail-closed if + * the path is not truly inside a valid npm package layout. + */ +function resolvePackageRoot( + inputAbs: string, + opts: { root: string; readPackageJson: (dir: string) => unknown }, +): { dir: string; json: RawPackageJson } { + let cur = dirname(inputAbs); + const stopAt = opts.root; + while (cur.length >= stopAt.length) { + const parsed = opts.readPackageJson(cur); + if (parsed !== null && parsed !== undefined && typeof parsed === 'object') { + const pkg = parsed as RawPackageJson; + if (typeof pkg.name === 'string' && typeof pkg.version === 'string') { + // Verify layout: the directory containing package.json must live + // directly under a node_modules dir (allowing `@scope/name`). + const rel = relative(opts.root, cur).split(sep).join('/'); + const parts = rel.split('/'); + const nmIdx = parts.lastIndexOf('node_modules'); + if (nmIdx < 0) { + throw new Error( + `package.json at ${rel} is not under node_modules; refusing to attribute input ${relative(opts.root, inputAbs)}`, + ); + } + const after = parts.slice(nmIdx + 1); + // Either ['name'] or ['@scope', 'name']. + const expected = + after.length === 1 + ? after[0] + : after.length === 2 && after[0]!.startsWith('@') + ? `${after[0]}/${after[1]}` + : null; + if (expected === null || expected !== pkg.name) { + throw new Error( + `package layout mismatch for input ${relative(opts.root, inputAbs)}: package.json name "${pkg.name}" but directory tail "${after.join('/')}"`, + ); + } + return { dir: cur, json: pkg }; + } + } + const next = dirname(cur); + if (next === cur) break; + cur = next; + } + throw new Error(`could not resolve package root for input ${relative(opts.root, inputAbs)}`); +} + +/** Sort a string array in place with a stable, locale-independent comparator. */ +function sortAscii(xs: string[]): string[] { + xs.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + return xs; +} + +export function computeBundleClosure( + metafile: { inputs: Record }, + opts: ComputeBundleClosureOptions, +): BundleClosureResult { + const readPackageJson = opts.readPackageJson ?? defaultReadPackageJson; + if (!isAbsolute(opts.root)) { + throw new Error(`root must be absolute, got ${opts.root}`); + } + + const ignored: BundleClosureResult['ignored'] = { + repoInternal: [], + nodeBuiltin: [], + virtual: [], + }; + /** Package root dir -> aggregated data. Keyed by absolute dir. */ + const byRoot = new Map(); + + const inputs = Object.keys(metafile.inputs); + for (const raw of inputs) { + const c = classifyInput(raw); + if (c.kind === 'virtual') { + ignored.virtual.push(raw); + continue; + } + if (c.kind === 'nodeBuiltin') { + ignored.nodeBuiltin.push(raw); + continue; + } + if (c.kind === 'repoInternal') { + ignored.repoInternal.push(raw); + continue; + } + if (c.kind === 'unknown') { + // Fail-closed: any metafile input that is neither obviously virtual, + // a Node built-in, a repo `packages/*` source, nor located under a + // real `node_modules` path segment is refused. Silently sweeping such + // paths into repoInternal (the pre-fix behaviour) hid third-party + // inputs whose paths happened to include a `packages/` subdirectory. + throw new Error(`could not classify metafile input: ${raw}`); + } + // Third-party. Resolve to an absolute path relative to root, then walk up. + // Normalise the raw metafile key to forward slashes before handing it to + // path.join/path.isAbsolute, because Linux does not interpret `\` as a + // separator. The original `raw` is preserved for error messages, the + // `ignored` arrays, and the deterministic `inputs` field. + const fsPath = raw.replace(/\\/g, '/'); + const abs = isAbsolute(fsPath) ? fsPath : join(opts.root, fsPath); + const { dir, json } = resolvePackageRoot(abs, { + root: opts.root, + readPackageJson, + }); + const license = extractLicense(json); + const name = json.name as string; + const version = json.version as string; + const existing = byRoot.get(dir); + if (existing) { + if (existing.version !== version || existing.license !== license) { + throw new Error( + `package ${name} resolved to conflicting metadata at ${dir}: ` + + `${existing.version}/${existing.license} vs ${version}/${license}`, + ); + } + existing.inputs.push(relative(opts.root, abs).split(sep).join('/')); + continue; + } + // Also fail-closed if a *different* dir maps to the *same* package name + // but a different version — we detect this later via the flattened list. + byRoot.set(dir, { + name, + version, + license, + purl: npmPurl(name, version), + packageRoot: dir, + inputs: [relative(opts.root, abs).split(sep).join('/')], + }); + } + + // Detect name-collision-across-roots after aggregation. + const packages = [...byRoot.values()]; + const byName = new Map(); + for (const p of packages) { + const seen = byName.get(p.name); + if (seen && (seen.version !== p.version || seen.license !== p.license)) { + throw new Error( + `package ${p.name} resolved to multiple versions in the bundle: ` + + `${seen.version}/${seen.license} at ${seen.packageRoot} and ` + + `${p.version}/${p.license} at ${p.packageRoot}`, + ); + } + if (!seen) byName.set(p.name, p); + } + + for (const p of packages) sortAscii(p.inputs); + packages.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + + sortAscii(ignored.repoInternal); + sortAscii(ignored.nodeBuiltin); + sortAscii(ignored.virtual); + + return { packages, ignored }; +} diff --git a/tools/validate-current-docs.test.ts b/tools/validate-current-docs.test.ts index 6519cd3..212088a 100644 --- a/tools/validate-current-docs.test.ts +++ b/tools/validate-current-docs.test.ts @@ -28,7 +28,7 @@ function baseFixture(): Map { '', '需要脚本执行 能力。', '', - 'verify:cloud 依次运行 scan:deps → scan:licenses → scan:secrets.', + 'verify:cloud 依次运行 scan:deps → scan:licenses → validate:sbom → scan:secrets.', '', '指向 GitHub Release `v0.1.6`.', ].join('\n'), @@ -100,7 +100,7 @@ function baseFixture(): Map { files.set( 'AGENTS.md', - 'render disabled exit 3. verify:cloud runs scan:deps → scan:licenses → scan:secrets.', + 'render disabled exit 3. verify:cloud runs scan:deps → scan:licenses → validate:sbom → scan:secrets.', ); files.set('docs/PRODUCT_SPEC.md', 'JSON output only.'); @@ -130,14 +130,15 @@ function baseFixture(): Map { ); files.set('docs/installers/doubao.md', 'doubao installer'); - // Workflow with the accurate full chain including validate:provenance and scan:licenses. + // Workflow with the accurate full chain including validate:provenance, + // scan:licenses and validate:sbom. files.set( '.github/workflows/verify.yml', [ '# format:check -> lint -> typecheck -> test -> build -> validate:provenance ->', '# validate:skill -> validate:reading -> validate:docs -> smoke -> forward:test ->', '# package:hosts -> verify:hosts -> verify:install -> check:doc-counts ->', - '# scan:deps -> scan:licenses -> scan:secrets', + '# scan:deps -> scan:licenses -> validate:sbom -> scan:secrets', 'env:', " DEPENDENCY_AUDIT_STRICT: '1'", ].join('\n'), @@ -238,7 +239,10 @@ describe('validate-current-docs: injected reader', () => { const cur = files.get('README.md') as string; files.set( 'README.md', - cur.replace('scan:deps → scan:licenses → scan:secrets', 'scan:deps → scan:secrets'), + cur.replace( + 'scan:deps → scan:licenses → validate:sbom → scan:secrets', + 'scan:deps → scan:secrets', + ), ); const { failed } = runChecks(readerOf(files)); expect(failed.map((f) => f.name)).toContain( @@ -347,6 +351,49 @@ describe('validate-current-docs: injected reader', () => { ); }); + it('16. workflow header missing validate:sbom -> matching FAIL (new guard)', () => { + const files = baseFixture(); + files.set( + '.github/workflows/verify.yml', + [ + '# format:check -> ... -> validate:provenance -> ... -> scan:licenses -> scan:secrets', + "env:\n DEPENDENCY_AUDIT_STRICT: '1'", + ].join('\n'), + ); + const { failed } = runChecks(readerOf(files)); + expect(failed.map((f) => f.name)).toContain( + 'workflow: header comment chain lists validate:sbom', + ); + }); + + it('17. README verify:cloud chain missing validate:sbom -> matching FAIL (new guard)', () => { + const files = baseFixture(); + const cur = files.get('README.md') as string; + files.set( + 'README.md', + cur.replace( + 'scan:deps → scan:licenses → validate:sbom → scan:secrets', + 'scan:deps → scan:licenses → scan:secrets', + ), + ); + const { failed } = runChecks(readerOf(files)); + expect(failed.map((f) => f.name)).toContain( + 'README.md: verify:cloud chain includes validate:sbom', + ); + }); + + it('18. AGENTS.md verify:cloud chain missing validate:sbom -> matching FAIL (new guard)', () => { + const files = baseFixture(); + files.set( + 'AGENTS.md', + 'render disabled exit 3. verify:cloud runs scan:deps → scan:licenses → scan:secrets.', + ); + const { failed } = runChecks(readerOf(files)); + expect(failed.map((f) => f.name)).toContain( + 'AGENTS.md: verify:cloud chain includes validate:sbom', + ); + }); + it('14. README contains "tests-N passing" shield badge -> matching FAIL (new guard)', () => { const files = baseFixture(); const cur = files.get('README.md') as string; diff --git a/tools/validate-current-docs.ts b/tools/validate-current-docs.ts index b981daf..853dec1 100644 --- a/tools/validate-current-docs.ts +++ b/tools/validate-current-docs.ts @@ -429,6 +429,7 @@ export function runChecks(readDoc: DocReader = defaultReadDoc): { /validate:provenance/.test(workflow), ); add('workflow: header comment chain lists scan:licenses', /scan:licenses/.test(workflow)); + add('workflow: header comment chain lists validate:sbom', /validate:sbom/.test(workflow)); } const validationMd = readDoc('docs/VALIDATION.md'); if (validationMd) { @@ -459,6 +460,10 @@ export function runChecks(readDoc: DocReader = defaultReadDoc): { 'README.md: verify:cloud chain includes scan:licenses', /scan:deps[\s\S]*scan:licenses[\s\S]*scan:secrets/.test(readmeMd), ); + add( + 'README.md: verify:cloud chain includes validate:sbom', + /scan:licenses[\s\S]*validate:sbom[\s\S]*scan:secrets/.test(readmeMd), + ); // Static test counts drift the moment the suite grows; the real count lives // on GitHub Actions `verify` + docs/VALIDATION.md, not in README. Match both // bold Markdown form (`**471 tests / 29 files**`) and plain text form @@ -478,6 +483,10 @@ export function runChecks(readDoc: DocReader = defaultReadDoc): { 'AGENTS.md: verify:cloud chain includes scan:licenses', /scan:deps[\s\S]*scan:licenses[\s\S]*scan:secrets/.test(agentsMd), ); + add( + 'AGENTS.md: verify:cloud chain includes validate:sbom', + /scan:licenses[\s\S]*validate:sbom[\s\S]*scan:secrets/.test(agentsMd), + ); } // scan-deps.ts must not promise that CI has network. The fail-closed guarantee // is DEPENDENCY_AUDIT_STRICT=1, not an implicit assumption about network reach. diff --git a/tools/validate-sbom.test.ts b/tools/validate-sbom.test.ts new file mode 100644 index 0000000..2a96a8b --- /dev/null +++ b/tools/validate-sbom.test.ts @@ -0,0 +1,620 @@ +// Offline tests for tools/validate-sbom.ts. +// Every case builds synthetic `closure` + synthetic SBOM strings entirely in +// memory. Nothing on disk is read or written; no real SBOM is touched. +import { describe, expect, it } from 'vitest'; +import { validateSbom, type ValidateSbomInputs } from './validate-sbom.ts'; +import { npmPurl } from './lib/bundle-closure.ts'; +import type { BundlePackage } from './lib/bundle-closure.ts'; + +/** Build a canonical BundlePackage; individual tests can override fields. */ +function pkg(name: string, version: string, license = 'MIT'): BundlePackage { + return { + name, + version, + license, + purl: npmPurl(name, version), + packageRoot: `/fake/${name}`, + inputs: [`node_modules/${name}/index.js`], + }; +} + +const APP_NAME = 'calculate-birth-charts'; +const APP_VERSION = '0.1.0'; + +function cdx( + components: { + name: string; + version: string; + purl?: string; + license?: string; + expression?: string; + type?: string; + }[], +) { + return { + bomFormat: 'CycloneDX', + specVersion: '1.5', + version: 1, + metadata: { + component: { type: 'application', name: APP_NAME, version: APP_VERSION }, + tools: [{ name: 'ming-build-skill', version: '0.1.0' }], + }, + components: components.map((c) => ({ + type: c.type ?? 'library', + name: c.name, + version: c.version, + purl: c.purl ?? npmPurl(c.name, c.version), + licenses: c.expression + ? [{ expression: c.expression }] + : [{ license: { id: c.license ?? 'MIT' } }], + })), + }; +} + +function spdx(components: { name: string; version: string; purl?: string; license?: string }[]) { + const spdxPackages = components.map((c) => ({ + name: c.name, + SPDXID: `SPDXRef-Package-${c.name.replace(/[^A-Za-z0-9.-]/g, '-')}`, + versionInfo: c.version, + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: c.license ?? 'MIT', + licenseDeclared: c.license ?? 'MIT', + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: c.purl ?? npmPurl(c.name, c.version), + }, + ], + })); + return { + spdxVersion: 'SPDX-2.3', + dataLicense: 'CC0-1.0', + SPDXID: 'SPDXRef-DOCUMENT', + name: `${APP_NAME}-${APP_VERSION}`, + documentNamespace: `https://github.com/Jowitt13/ming-engine/spdx/${APP_NAME}-${APP_VERSION}`, + creationInfo: { + created: '2026-01-01T00:00:00Z', + creators: ['Tool: ming-build-skill-0.1.0'], + comment: 'test fixture', + }, + packages: [ + { + name: APP_NAME, + SPDXID: `SPDXRef-Package-${APP_NAME}`, + versionInfo: APP_VERSION, + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'MIT', + licenseDeclared: 'MIT', + }, + ...spdxPackages, + ], + relationships: [ + { + spdxElementId: 'SPDXRef-DOCUMENT', + relationshipType: 'DESCRIBES', + relatedSpdxElement: `SPDXRef-Package-${APP_NAME}`, + }, + ...spdxPackages.map((p) => ({ + spdxElementId: `SPDXRef-Package-${APP_NAME}`, + relationshipType: 'DEPENDS_ON', + relatedSpdxElement: p.SPDXID, + })), + ], + }; +} + +/** Serialize as the build tool does: `JSON.stringify(x, null, 2) + '\n'`. */ +function ser(obj: unknown): string { + return `${JSON.stringify(obj, null, 2)}\n`; +} + +/** Build a valid triple (closure + matching CycloneDX + matching SPDX). */ +function goodTriple(): ValidateSbomInputs { + const closure = [pkg('zod', '4.4.3'), pkg('moment', '2.30.1')]; + const cdxObj = cdx(closure.map((p) => ({ name: p.name, version: p.version }))); + const spdxObj = spdx(closure.map((p) => ({ name: p.name, version: p.version }))); + return { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; +} + +function failedNames(input: ValidateSbomInputs): string[] { + return validateSbom(input) + .filter((c) => !c.ok) + .map((c) => c.name); +} + +describe('validate-sbom (offline synthetic)', () => { + it('1. clean triple -> zero failures', () => { + expect(failedNames(goodTriple())).toEqual([]); + }); + + it('2. metafile has an extra package that SBOM does not list -> FAIL both cyclonedx and spdx has X', () => { + const t = goodTriple(); + t.closure.push(pkg('astronomy-engine', '2.1.19')); + const failed = failedNames(t); + expect(failed).toContain('cyclonedx has astronomy-engine'); + expect(failed).toContain('spdx has astronomy-engine'); + }); + + it('3. SBOMs list a ghost package not in closure -> FAIL', () => { + const t = goodTriple(); + const cdxObj = cdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'ghost', version: '1.0.0' }, + ]); + const spdxObj = spdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'ghost', version: '1.0.0' }, + ]); + t.cdxText = ser(cdxObj); + t.spdxText = ser(spdxObj); + const failed = failedNames(t); + expect(failed).toContain('cyclonedx has no ghost package: ghost'); + expect(failed).toContain('spdx has no ghost package: ghost'); + }); + + it('4. CycloneDX version differs from SPDX for the same package -> FAIL', () => { + const t = goodTriple(); + const cdxObj = cdx([ + { name: 'zod', version: '4.4.3' }, + { name: 'moment', version: '2.30.1' }, + ]); + const spdxObj = spdx([ + { name: 'zod', version: '4.4.99' }, + { name: 'moment', version: '2.30.1' }, + ]); + t.cdxText = ser(cdxObj); + t.spdxText = ser(spdxObj); + const failed = failedNames(t); + expect(failed).toContain('spdx version matches: zod'); + expect(failed).toContain('cyclonedx set equals spdx set'); + }); + + it('5. license mismatch vs closure -> FAIL both formats', () => { + const t = goodTriple(); + const cdxObj = cdx([ + { name: 'zod', version: '4.4.3', license: 'Apache-2.0' }, + { name: 'moment', version: '2.30.1' }, + ]); + t.cdxText = ser(cdxObj); + const failed = failedNames(t); + expect(failed).toContain('cyclonedx license matches: zod'); + expect(failed).toContain('cyclonedx set equals spdx set'); + }); + + it('6. purl not equal to name@version -> FAIL', () => { + const t = goodTriple(); + const cdxObj = cdx([ + { name: 'zod', version: '4.4.3', purl: 'pkg:npm/zod@9.9.9' }, + { name: 'moment', version: '2.30.1' }, + ]); + t.cdxText = ser(cdxObj); + const failed = failedNames(t); + expect(failed).toContain('cyclonedx purl is canonical: zod'); + }); + + it('7. application component name drift -> FAIL', () => { + const t = goodTriple(); + const cdxObj: { + metadata?: { component?: { name?: string; version?: string } }; + } = JSON.parse(t.cdxText); + if (cdxObj.metadata?.component) cdxObj.metadata.component.name = 'other-app'; + t.cdxText = ser(cdxObj); + const failed = failedNames(t); + expect(failed).toContain(`cyclonedx application component is ${APP_NAME}`); + }); + + it('8. byte-instability: extra whitespace on disk -> FAIL byte-stable check', () => { + const t = goodTriple(); + // Prepend a whitespace character so the disk bytes do not match a re-serialization. + t.cdxText = '\n' + t.cdxText; + const failed = failedNames(t); + expect(failed).toContain('cyclonedx SBOM is byte-stable (reserialization matches disk)'); + }); + + it('9. same closure package present via multiple inputs -> not double-counted', () => { + const t = goodTriple(); + // Duplicate the closure entry as if two different metafile inputs mapped + // to it. The dedupe happens upstream in computeBundleClosure; here we + // simply assert validate-sbom does not emit spurious extra "has X" FAILs. + // For robustness we also add a second copy with a different `packageRoot` + // but the same name/version/license (which validate-sbom is agnostic to). + const dup = { ...pkg('zod', '4.4.3'), packageRoot: '/fake/zod-dup' }; + t.closure.push(dup); + const failed = failedNames(t); + // Validate-sbom builds a Map by name; the duplicate is idempotent. + expect(failed.filter((n) => n.startsWith('cyclonedx has zod'))).toEqual([]); + expect(failed.filter((n) => n.startsWith('spdx has zod'))).toEqual([]); + }); + + it('10. documented exception permits a non-closure package with matching version', () => { + const t = goodTriple(); + // Both SBOMs list a package that is not in the closure but the exceptions + // file permits it explicitly. The gate should pass. + const cdxObj = cdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'legacy-tool', version: '0.0.1' }, + ]); + const spdxObj = spdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'legacy-tool', version: '0.0.1' }, + ]); + t.cdxText = ser(cdxObj); + t.spdxText = ser(spdxObj); + t.exceptions = [ + { + name: 'legacy-tool', + version: '0.0.1', + reason: 'documented ship of hand-audited fixture', + expires: '2099-12-31', + }, + ]; + const failed = failedNames(t); + // 'has no ghost' checks turn into 'extra allowed' PASSes. + expect(failed).not.toContain('cyclonedx has no ghost package: legacy-tool'); + expect(failed).not.toContain('spdx has no ghost package: legacy-tool'); + }); + + it('10b. exception name matches but version differs -> FAIL (still catches drift)', () => { + const t = goodTriple(); + const cdxObj = cdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'legacy-tool', version: '0.0.2' }, + ]); + const spdxObj = spdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'legacy-tool', version: '0.0.2' }, + ]); + t.cdxText = ser(cdxObj); + t.spdxText = ser(spdxObj); + t.exceptions = [ + { name: 'legacy-tool', version: '0.0.1', reason: 'doc', expires: '2099-12-31' }, + ]; + const failed = failedNames(t); + expect(failed).toContain('cyclonedx extra allowed: legacy-tool@0.0.2'); + expect(failed).toContain('spdx extra allowed: legacy-tool@0.0.2'); + }); + + it('11. only CycloneDX has a package (SPDX missing it) -> set-mismatch FAIL', () => { + const t = goodTriple(); + const cdxObj = cdx(t.closure.map((p) => ({ name: p.name, version: p.version }))); + // Remove `moment` from SPDX. + const spdxObj = spdx([{ name: 'zod', version: '4.4.3' }]); + t.cdxText = ser(cdxObj); + t.spdxText = ser(spdxObj); + const failed = failedNames(t); + expect(failed).toContain('spdx has moment'); + expect(failed).toContain('cyclonedx set equals spdx set'); + }); + + // --- P1-fix-2: canonical purl + CycloneDX SPDX-expression form ----------- + + it('12. scoped package: canonical %40-encoded purl passes end-to-end in both SBOMs', () => { + const closure = [pkg('@scope/pkg', '1.0.0')]; + // Fixture helpers derive purls via the same shared npmPurl -> canonical. + const cdxObj = cdx([{ name: '@scope/pkg', version: '1.0.0' }]); + const spdxObj = spdx([{ name: '@scope/pkg', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; + expect(failedNames(t)).toEqual([]); + // Sanity: the canonical purl really is the %40-encoded form. + expect(closure[0]!.purl).toBe('pkg:npm/%40scope/pkg@1.0.0'); + }); + + it('13. scoped package with NON-canonical raw-@ purl in both SBOMs -> canonical checks FAIL', () => { + const closure = [pkg('@scope/pkg', '1.0.0')]; + const raw = 'pkg:npm/@scope/pkg@1.0.0'; // NOT percent-encoded — non-canonical + const cdxObj = cdx([{ name: '@scope/pkg', version: '1.0.0', purl: raw }]); + const spdxObj = spdx([{ name: '@scope/pkg', version: '1.0.0', purl: raw }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; + const failed = failedNames(t); + expect(failed).toContain('cyclonedx purl is canonical: @scope/pkg'); + expect(failed).toContain('spdx purl is canonical: @scope/pkg'); + }); + + it('14. bare OR expression license correctly in CycloneDX expression field -> pass', () => { + const closure = [pkg('dual', '3.0.0', 'MIT OR Apache-2.0')]; + const cdxObj = cdx([{ name: 'dual', version: '3.0.0', expression: 'MIT OR Apache-2.0' }]); + const spdxObj = spdx([{ name: 'dual', version: '3.0.0', license: 'MIT OR Apache-2.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; + expect(failedNames(t)).toEqual([]); + }); + + it('15. expression smuggled into license.id -> form check FAILs', () => { + const closure = [pkg('dual', '3.0.0', 'MIT OR Apache-2.0')]; + // Fixture writes the expression as license.id (the old buggy behaviour of + // the paren-only detector). validate-sbom must reject the disguise. + const cdxObj = cdx([{ name: 'dual', version: '3.0.0', license: 'MIT OR Apache-2.0' }]); + const spdxObj = spdx([{ name: 'dual', version: '3.0.0', license: 'MIT OR Apache-2.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; + const failed = failedNames(t); + expect(failed).toContain('cyclonedx license form is correct: dual'); + }); + + it('16. malformed/unknown license in closure -> validateSbom throws (fail-closed)', () => { + const closure = [pkg('weird', '1.0.0', 'see license file')]; + const cdxObj = cdx([{ name: 'weird', version: '1.0.0', license: 'see license file' }]); + const spdxObj = spdx([{ name: 'weird', version: '1.0.0', license: 'see license file' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; + expect(() => validateSbom(t)).toThrow(/SPDX parse error/); + }); + + // --- P1-fix-3: duplicate component/package detection ----------------------- + + it('17. CycloneDX with two same-name library components -> FAIL duplicate', () => { + const closure = [pkg('foo', '1.0.0')]; + // Build CycloneDX with `foo` listed twice. + const cdxObj = { + ...JSON.parse(ser(cdx([{ name: 'foo', version: '1.0.0' }]))), + components: [ + { + type: 'library', + name: 'foo', + version: '1.0.0', + purl: npmPurl('foo', '1.0.0'), + licenses: [{ license: { id: 'MIT' } }], + }, + { + type: 'library', + name: 'foo', + version: '1.0.0', + purl: npmPurl('foo', '1.0.0'), + licenses: [{ license: { id: 'MIT' } }], + }, + ], + }; + const spdxObj = spdx([{ name: 'foo', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/cdx', + spdxPath: '/mem/spdx', + }; + const failed = failedNames(t); + expect(failed).toContain('cyclonedx no duplicate library component: foo'); + }); + + it('18. SPDX with two same-name third-party packages -> FAIL duplicate', () => { + const closure = [pkg('bar', '2.0.0')]; + const cdxObj = cdx([{ name: 'bar', version: '2.0.0' }]); + // Build SPDX with `bar` listed twice. + const baseSpdx = JSON.parse(ser(spdx([{ name: 'bar', version: '2.0.0' }]))) as Record< + string, + unknown + >; + const barPkg = (baseSpdx.packages as unknown[]).find((p: any) => p.name === 'bar'); + (baseSpdx.packages as unknown[]).push(barPkg); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(baseSpdx), + exceptions: [], + cdxPath: '/mem/cdx', + spdxPath: '/mem/spdx', + }; + const failed = failedNames(t); + expect(failed).toContain('spdx no duplicate third-party package: bar'); + }); + + // --- P1-fix-4: structural completeness (missing identity fields) ----------- + + it('19. CycloneDX library component missing name -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = JSON.parse(ser(cdx([{ name: 'foo', version: '1.0.0' }]))); + // Inject a nameless library component. + cdxObj.components.push({ + type: 'library', + version: '1.0.0', + purl: 'pkg:npm/x@1.0.0', + licenses: [{ license: { id: 'MIT' } }], + }); + const spdxObj = spdx([{ name: 'foo', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has name'))).toBe(true); + }); + + it('20. CycloneDX library component missing version -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = JSON.parse(ser(cdx([{ name: 'foo', version: '1.0.0' }]))); + cdxObj.components.push({ + type: 'library', + name: 'bar', + purl: 'pkg:npm/bar@1.0.0', + licenses: [{ license: { id: 'MIT' } }], + }); + const spdxObj = spdx([{ name: 'foo', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has version'))).toBe(true); + }); + + it('21. CycloneDX library component missing purl -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = JSON.parse(ser(cdx([{ name: 'foo', version: '1.0.0' }]))); + cdxObj.components.push({ + type: 'library', + name: 'bar', + version: '1.0.0', + licenses: [{ license: { id: 'MIT' } }], + }); + const spdxObj = spdx([{ name: 'foo', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has purl'))).toBe(true); + }); + + it('22. CycloneDX library component missing license -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = JSON.parse(ser(cdx([{ name: 'foo', version: '1.0.0' }]))); + cdxObj.components.push({ + type: 'library', + name: 'bar', + version: '1.0.0', + purl: 'pkg:npm/bar@1.0.0', + }); + const spdxObj = spdx([{ name: 'foo', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has license'))).toBe(true); + }); + + it('23. SPDX third-party package missing versionInfo -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = cdx([{ name: 'foo', version: '1.0.0' }]); + const baseSpdx = JSON.parse(ser(spdx([{ name: 'foo', version: '1.0.0' }]))) as any; + baseSpdx.packages.push({ + name: 'bar', + SPDXID: 'SPDXRef-Package-bar', + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'MIT', + licenseDeclared: 'MIT', + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: 'pkg:npm/bar@1.0.0', + }, + ], + }); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(baseSpdx), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has versionInfo'))).toBe(true); + }); + + it('24. SPDX third-party package missing purl -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = cdx([{ name: 'foo', version: '1.0.0' }]); + const baseSpdx = JSON.parse(ser(spdx([{ name: 'foo', version: '1.0.0' }]))) as any; + baseSpdx.packages.push({ + name: 'bar', + SPDXID: 'SPDXRef-Package-bar', + versionInfo: '1.0.0', + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'MIT', + licenseDeclared: 'MIT', + }); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(baseSpdx), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has purl'))).toBe(true); + }); + + it('25. SPDX third-party package missing license -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = cdx([{ name: 'foo', version: '1.0.0' }]); + const baseSpdx = JSON.parse(ser(spdx([{ name: 'foo', version: '1.0.0' }]))) as any; + baseSpdx.packages.push({ + name: 'bar', + SPDXID: 'SPDXRef-Package-bar', + versionInfo: '1.0.0', + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: 'pkg:npm/bar@1.0.0', + }, + ], + }); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(baseSpdx), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has license'))).toBe(true); + }); +}); diff --git a/tools/validate-sbom.ts b/tools/validate-sbom.ts new file mode 100644 index 0000000..694cb89 --- /dev/null +++ b/tools/validate-sbom.ts @@ -0,0 +1,493 @@ +import { build } from 'esbuild'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + computeBundleClosure, + npmPurl, + spdxKind, + type BundlePackage, +} from './lib/bundle-closure.ts'; + +/** + * Reverse gate for the CycloneDX + SPDX SBOMs shipped with the Skill. + * + * The `build` step derives both SBOMs from the esbuild metafile via + * `computeBundleClosure`. This tool runs a fresh esbuild pass, recomputes the + * closure independently, and asserts that BOTH committed SBOMs describe + * EXACTLY that closure: no missing packages, no extra packages, per-package + * name/version/purl/license match, and the two SBOMs match each other. + * + * It also re-serializes each SBOM and requires the result to be byte-identical + * to disk, so a hand-edit that breaks reproducibility is caught here too. + * + * Flags (used by tests): + * --metafile read a captured esbuild metafile JSON instead of + * spawning a fresh build. Purely for offline unit tests. + * --root override the repo root (default: parent of this file). + * --sbom-cdx path to CycloneDX SBOM (default: + * skills/calculate-birth-charts/sbom.cdx.json). + * --sbom-spdx path to SPDX SBOM (default: + * skills/calculate-birth-charts/sbom.spdx.json). + * + * There is a documented exceptions file (tools/validate-sbom.exceptions.json) + * — currently absent; if ever present it must be a JSON array of + * { name, version, reason, expires: YYYY-MM-DD } and the expires date must be + * a real calendar date at or after today (fail-closed otherwise). + */ + +const here = dirname(fileURLToPath(import.meta.url)); + +// --- Arg parsing -------------------------------------------------------------- +const argv = process.argv.slice(2); +const getFlag = (name: string): string | undefined => { + const i = argv.indexOf(name); + return i >= 0 ? argv[i + 1] : undefined; +}; +const rootArg = getFlag('--root'); +const root = rootArg + ? rootArg.startsWith('.') + ? join(process.cwd(), rootArg) + : rootArg + : join(here, '..'); +const metafileArg = getFlag('--metafile'); +const sbomCdxArg = getFlag('--sbom-cdx'); +const sbomSpdxArg = getFlag('--sbom-spdx'); +const APP_NAME = 'calculate-birth-charts'; + +interface Check { + name: string; + ok: boolean; + detail?: string; +} + +/** Real-date check identical to scan-deps.ts's isRealDate. */ +function isRealDate(s: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false; + const [y, m, d] = s.split('-').map(Number); + const dt = new Date(Date.UTC(y!, (m ?? 1) - 1, d!)); + return dt.getUTCFullYear() === y && dt.getUTCMonth() === (m ?? 1) - 1 && dt.getUTCDate() === d; +} + +interface Exception { + name: string; + version: string; + reason: string; + expires: string; +} +function loadExceptions(): Exception[] { + const p = join(root, 'tools', 'validate-sbom.exceptions.json'); + if (!existsSync(p)) return []; + const raw = JSON.parse(readFileSync(p, 'utf8')); + if (!Array.isArray(raw)) throw new Error(`${p}: exceptions must be a JSON array`); + const today = new Date().toISOString().slice(0, 10); + const seen = new Set(); + for (const [i, e] of raw.entries()) { + if (typeof e !== 'object' || e === null || Array.isArray(e)) { + throw new Error(`${p}: exceptions[${i}] is not an object`); + } + const en = e as Record; + if (typeof en.name !== 'string' || en.name.trim().length === 0) + throw new Error(`${p}: exceptions[${i}].name missing`); + if (typeof en.version !== 'string' || en.version.trim().length === 0) + throw new Error(`${p}: exceptions[${i}].version missing`); + if (typeof en.reason !== 'string' || en.reason.trim().length === 0) + throw new Error(`${p}: exceptions[${i}].reason missing/empty`); + if (typeof en.expires !== 'string' || !isRealDate(en.expires)) + throw new Error(`${p}: exceptions[${i}].expires not a real YYYY-MM-DD date`); + if (en.expires < today) + throw new Error(`${p}: exceptions[${i}] "${en.name}@${en.version}" expired on ${en.expires}`); + const key = `${en.name}@${en.version}`; + if (seen.has(key)) throw new Error(`${p}: duplicate exception ${key}`); + seen.add(key); + } + return raw as Exception[]; +} + +// --- SBOM shapes we consume -------------------------------------------------- +interface CdxLicense { + license?: { id?: string }; + expression?: string; +} +interface CdxComponent { + type?: string; + name?: string; + version?: string; + purl?: string; + licenses?: CdxLicense[]; +} +interface CdxSbom { + metadata?: { component?: { name?: string; version?: string } }; + components?: CdxComponent[]; +} + +interface SpdxPackage { + name?: string; + SPDXID?: string; + versionInfo?: string; + licenseConcluded?: string; + licenseDeclared?: string; + externalRefs?: { referenceType?: string; referenceLocator?: string }[]; +} +interface SpdxSbom { + packages?: SpdxPackage[]; +} + +/** Read a CycloneDX SBOM and normalise the third-party components. + * `form` records HOW the license was written: `id` (license.id), `expression` + * (SPDX expression field) or `none`. validate-sbom uses it to reject SBOMs + * that smuggle an SPDX expression into `license.id`. */ +function extractCdxPackages( + sbom: CdxSbom, +): Map< + string, + { version: string; purl: string; license: string; form: 'id' | 'expression' | 'none' } +> { + const out = new Map< + string, + { version: string; purl: string; license: string; form: 'id' | 'expression' | 'none' } + >(); + for (const c of sbom.components ?? []) { + if (c.type !== 'library') continue; + if (typeof c.name !== 'string' || typeof c.version !== 'string' || typeof c.purl !== 'string') + continue; + const lic = c.licenses?.[0]; + const form: 'id' | 'expression' | 'none' = + lic?.expression !== undefined ? 'expression' : lic?.license?.id !== undefined ? 'id' : 'none'; + const license = lic?.expression ?? lic?.license?.id ?? ''; + out.set(c.name, { version: c.version, purl: c.purl, license, form }); + } + return out; +} + +/** Read an SPDX SBOM and normalise the third-party packages (excluding the application). */ +function extractSpdxPackages( + sbom: SpdxSbom, +): Map { + const out = new Map(); + for (const p of sbom.packages ?? []) { + if (typeof p.name !== 'string') continue; + if (p.name === APP_NAME) continue; + const purl = p.externalRefs?.find((r) => r.referenceType === 'purl')?.referenceLocator ?? ''; + out.set(p.name, { + version: p.versionInfo ?? '', + purl, + license: p.licenseConcluded ?? p.licenseDeclared ?? '', + }); + } + return out; +} + +// --- Closure source ---------------------------------------------------------- +async function computeTruthClosure(): Promise { + let metafile: { inputs: Record }; + if (metafileArg) { + const p = join(root, metafileArg); + metafile = JSON.parse(readFileSync(p, 'utf8')) as { + inputs: Record; + }; + } else { + const result = await build({ + entryPoints: [join(root, 'packages', 'orchestrator', 'src', 'engine-entry.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node22', + write: false, + metafile: true, + logLevel: 'silent', + }); + metafile = result.metafile; + } + return computeBundleClosure(metafile, { root }).packages; +} + +/** + * Re-serialize a JSON object with the exact same formatting the build tool + * uses (`JSON.stringify(x, null, 2) + '\n'`), so byte-comparison against the + * on-disk file catches hand edits or non-deterministic tooling. + */ +function reserialize(obj: unknown): string { + return `${JSON.stringify(obj, null, 2)}\n`; +} + +// --- Assertions ------------------------------------------------------------- +export interface ValidateSbomInputs { + closure: BundlePackage[]; + cdxText: string; + spdxText: string; + exceptions: Exception[]; + cdxPath: string; + spdxPath: string; +} +export function validateSbom(inputs: ValidateSbomInputs): Check[] { + const checks: Check[] = []; + const add = (name: string, ok: boolean, detail?: string): void => { + checks.push(detail === undefined ? { name, ok } : { name, ok, detail }); + }; + + const cdx: CdxSbom = JSON.parse(inputs.cdxText); + const spdx: SpdxSbom = JSON.parse(inputs.spdxText); + const cdxMap = extractCdxPackages(cdx); + const spdxMap = extractSpdxPackages(spdx); + const truthByName = new Map(); + for (const p of inputs.closure) truthByName.set(p.name, p); + const exemptedNames = new Set(inputs.exceptions.map((e) => e.name)); + + // 0. Duplicate-name detection: any SBOM that lists the same third-party + // package name more than once is malformed. Previous code used Map.set + // which silently overwrote duplicates; now we fail-closed. + const cdxLibNames = (cdx.components ?? []) + .filter((c) => c.type === 'library') + .map((c) => c.name) + .filter((n): n is string => typeof n === 'string'); + const cdxDups = [...new Set(cdxLibNames.filter((n, i) => cdxLibNames.indexOf(n) !== i))]; + for (const d of cdxDups) { + add(`cyclonedx no duplicate library component: ${d}`, false, 'appears more than once'); + } + const spdxPkgNames = (spdx.packages ?? []) + .filter((p) => typeof p.name === 'string' && p.name !== APP_NAME) + .map((p) => p.name as string); + const spdxDups = [...new Set(spdxPkgNames.filter((n, i) => spdxPkgNames.indexOf(n) !== i))]; + for (const d of spdxDups) { + add(`spdx no duplicate third-party package: ${d}`, false, 'appears more than once'); + } + + // 0b. Structural completeness: every CycloneDX library component must carry + // all required identity fields. A component that lacks any of these was + // silently skipped by the old extract-and-Map logic; now it's a hard FAIL. + for (const [i, c] of (cdx.components ?? []).entries()) { + if (c.type !== 'library') continue; + if (typeof c.name !== 'string' || c.name === '') + add(`cyclonedx component[${i}] has name`, false, 'missing or empty'); + if (typeof c.version !== 'string' || c.version === '') + add(`cyclonedx component[${i}] has version`, false, 'missing or empty'); + if (typeof c.purl !== 'string' || c.purl === '') + add(`cyclonedx component[${i}] has purl`, false, 'missing or empty'); + const lic = c.licenses?.[0]; + if (!lic || (lic.license?.id === undefined && lic.expression === undefined)) + add(`cyclonedx component[${i}] has license`, false, 'no license.id or expression'); + } + + // 0c. Structural completeness for SPDX third-party packages. + for (const [i, p] of (spdx.packages ?? []).entries()) { + if (typeof p.name !== 'string') { + add(`spdx package[${i}] has name`, false, 'missing'); + continue; + } + if (p.name === APP_NAME) continue; + if (!p.versionInfo) + add(`spdx package[${i}] "${p.name}" has versionInfo`, false, 'missing or empty'); + const purl = p.externalRefs?.find((r) => r.referenceType === 'purl')?.referenceLocator; + if (!purl) add(`spdx package[${i}] "${p.name}" has purl`, false, 'no purl in externalRefs'); + if (!p.licenseConcluded && !p.licenseDeclared) + add( + `spdx package[${i}] "${p.name}" has license`, + false, + 'no licenseConcluded or licenseDeclared', + ); + } + + // 1. Every truth-set package must exist in both SBOMs with matching fields. + // Purls are checked against the CANONICAL form recomputed here from + // (name, version) via the shared npmPurl — not merely string-equality + // with the closure — so a systematically wrong purl generator can never + // self-certify. CycloneDX license FORM is checked against spdxKind so an + // expression can never masquerade as `license.id`. + for (const p of inputs.closure) { + const canonicalPurl = npmPurl(p.name, p.version); + const expectedForm = spdxKind(p.license); // throws on unrecognized license (fail-closed) + const c = cdxMap.get(p.name); + add( + `cyclonedx has ${p.name}`, + c !== undefined, + c === undefined ? 'missing' : `${c.version} ${c.license}`, + ); + if (c) { + add( + `cyclonedx version matches: ${p.name}`, + c.version === p.version, + `sbom=${c.version} closure=${p.version}`, + ); + add( + `cyclonedx purl is canonical: ${p.name}`, + c.purl === canonicalPurl, + `sbom=${c.purl} canonical=${canonicalPurl}`, + ); + add( + `cyclonedx license matches: ${p.name}`, + c.license === p.license, + `sbom=${c.license} closure=${p.license}`, + ); + add( + `cyclonedx license form is correct: ${p.name}`, + c.form === expectedForm, + `sbom-form=${c.form} expected=${expectedForm}`, + ); + } + const s = spdxMap.get(p.name); + add( + `spdx has ${p.name}`, + s !== undefined, + s === undefined ? 'missing' : `${s.version} ${s.license}`, + ); + if (s) { + add( + `spdx version matches: ${p.name}`, + s.version === p.version, + `sbom=${s.version} closure=${p.version}`, + ); + add( + `spdx purl is canonical: ${p.name}`, + s.purl === canonicalPurl, + `sbom=${s.purl} canonical=${canonicalPurl}`, + ); + add( + `spdx license matches: ${p.name}`, + s.license === p.license, + `sbom=${s.license} closure=${p.license}`, + ); + } + } + + // 2. Both SBOMs must not carry extra packages that are not in the truth set, + // unless there is an active documented exception. + for (const [name, entry] of cdxMap) { + if (truthByName.has(name)) continue; + if (exemptedNames.has(name)) { + // exceptions apply to a specific (name, version) pair. + const ok = inputs.exceptions.some((e) => e.name === name && e.version === entry.version); + add( + `cyclonedx extra allowed: ${name}@${entry.version}`, + ok, + ok ? 'documented exception' : 'name allowlisted but version mismatch', + ); + continue; + } + add( + `cyclonedx has no ghost package: ${name}`, + false, + `${entry.version} ${entry.license} — not in bundle closure`, + ); + } + for (const [name, entry] of spdxMap) { + if (truthByName.has(name)) continue; + if (exemptedNames.has(name)) { + const ok = inputs.exceptions.some((e) => e.name === name && e.version === entry.version); + add( + `spdx extra allowed: ${name}@${entry.version}`, + ok, + ok ? 'documented exception' : 'name allowlisted but version mismatch', + ); + continue; + } + add( + `spdx has no ghost package: ${name}`, + false, + `${entry.version} ${entry.license} — not in bundle closure`, + ); + } + + // 3. The two SBOMs must agree on the third-party set (name+version+purl+license). + const cdxKeys = new Set( + [...cdxMap.entries()].map(([n, v]) => `${n}|${v.version}|${v.purl}|${v.license}`), + ); + const spdxKeys = new Set( + [...spdxMap.entries()].map(([n, v]) => `${n}|${v.version}|${v.purl}|${v.license}`), + ); + const onlyInCdx = [...cdxKeys].filter((k) => !spdxKeys.has(k)); + const onlyInSpdx = [...spdxKeys].filter((k) => !cdxKeys.has(k)); + add( + 'cyclonedx set equals spdx set', + onlyInCdx.length === 0 && onlyInSpdx.length === 0, + `only-cdx=${onlyInCdx.length} only-spdx=${onlyInSpdx.length}`, + ); + + // 4. Application component identity must agree between both SBOMs. + const cdxApp = cdx.metadata?.component; + const spdxApp = spdx.packages?.find((p) => p.name === APP_NAME); + add( + `cyclonedx application component is ${APP_NAME}`, + cdxApp?.name === APP_NAME, + `got ${cdxApp?.name ?? '(missing)'}`, + ); + add( + `spdx application package is ${APP_NAME}`, + spdxApp?.name === APP_NAME, + `got ${spdxApp?.name ?? '(missing)'}`, + ); + add( + 'application version matches across both SBOMs', + cdxApp?.version !== undefined && cdxApp.version === spdxApp?.versionInfo, + `cdx=${cdxApp?.version ?? '?'} spdx=${spdxApp?.versionInfo ?? '?'}`, + ); + + // 5. Byte-stability: re-serialize and compare with disk. This catches + // non-deterministic writers and hand edits that break reproducibility. + add( + 'cyclonedx SBOM is byte-stable (reserialization matches disk)', + reserialize(cdx) === inputs.cdxText, + `path=${relative(root, inputs.cdxPath)}`, + ); + add( + 'spdx SBOM is byte-stable (reserialization matches disk)', + reserialize(spdx) === inputs.spdxText, + `path=${relative(root, inputs.spdxPath)}`, + ); + + return checks; +} + +// --- Runner ------------------------------------------------------------------ +async function main(): Promise { + const cdxPath = sbomCdxArg + ? sbomCdxArg.startsWith('.') + ? join(root, sbomCdxArg) + : sbomCdxArg + : join(root, 'skills', 'calculate-birth-charts', 'sbom.cdx.json'); + const spdxPath = sbomSpdxArg + ? sbomSpdxArg.startsWith('.') + ? join(root, sbomSpdxArg) + : sbomSpdxArg + : join(root, 'skills', 'calculate-birth-charts', 'sbom.spdx.json'); + if (!existsSync(cdxPath)) { + process.stdout.write(`[FAIL] cyclonedx SBOM not found: ${relative(root, cdxPath)}\n`); + process.exit(1); + } + if (!existsSync(spdxPath)) { + process.stdout.write(`[FAIL] spdx SBOM not found: ${relative(root, spdxPath)}\n`); + process.exit(1); + } + const cdxText = readFileSync(cdxPath, 'utf8'); + const spdxText = readFileSync(spdxPath, 'utf8'); + const exceptions = loadExceptions(); + const closure = await computeTruthClosure(); + const checks = validateSbom({ closure, cdxText, spdxText, exceptions, cdxPath, spdxPath }); + + const failed = checks.filter((c) => !c.ok); + for (const c of checks) { + process.stdout.write( + `[${c.ok ? 'PASS' : 'FAIL'}] ${c.name}${c.detail ? ` (${c.detail})` : ''}\n`, + ); + } + process.stdout.write( + `\n${checks.length - failed.length}/${checks.length} sbom validation checks passed.\n`, + ); + if (failed.length > 0) { + process.stdout.write( + 'The committed SBOMs do not accurately describe the actual esbuild bundle closure.\n' + + 'Rebuild with `pnpm run build` and commit the regenerated sbom.cdx.json / sbom.spdx.json,\n' + + 'or add a documented, time-boxed entry to tools/validate-sbom.exceptions.json.\n', + ); + process.exit(1); + } +} + +const invokedAsScript = + process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]; +if (invokedAsScript) { + main().catch((err: unknown) => { + process.stdout.write(`[FAIL] validate-sbom crashed: ${(err as Error).message}\n`); + process.exit(1); + }); +}