From 48a13122376bf2f245d7d19dc39a32eade9a82d0 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:18:47 +0000 Subject: [PATCH] feat(ci): require live dependency security evidence for releases --- .github/workflows/ci.yml | 41 ++ .github/workflows/release-recovery.yml | 13 +- .github/workflows/release.yml | 22 +- docs/releasing.md | 28 ++ scripts/ci/dependency-security.mjs | 85 ++++ scripts/ci/plan.mjs | 5 + scripts/ci/required.mjs | 5 +- scripts/ci/scopes.mjs | 21 + scripts/generate-third-party-inventory.mjs | 21 +- scripts/lib/dependency-security-facts.d.mts | 56 +++ scripts/lib/dependency-security-facts.mjs | 244 +++++++++ scripts/lib/dependency-security.d.mts | 36 ++ scripts/lib/dependency-security.mjs | 340 +++++++++++++ scripts/lib/npm-command.mjs | 24 + scripts/lib/npm-locked-packages.d.mts | 2 + scripts/lib/npm-locked-packages.mjs | 44 ++ scripts/lib/release-package-canary.mjs | 21 - scripts/lib/release-package-install.mjs | 33 ++ scripts/lib/transport-authentication.d.mts | 1 + scripts/lib/transport-authentication.mjs | 20 + scripts/lib/verify-package-core.mjs | 20 +- scripts/verify-published-package.mjs | 41 +- scripts/verify-release-artifact.mjs | 35 +- .../boundary/filesystem/ciChangeScope.test.ts | 34 ++ .../filesystem/ciQualification.test.ts | 18 +- .../filesystem/ciRequiredLanes.test.ts | 2 + .../filesystem/dependencySecurity.test.ts | 470 ++++++++++++++++++ .../dependencySecurityWorkflow.test.ts | 148 ++++++ .../filesystem/releaseRecovery.test.ts | 4 + .../filesystem/releaseWorkflow.test.ts | 6 + .../filesystem/thirdPartyInventory.test.ts | 3 + .../process/releaseArtifactCanary.test.ts | 10 + 32 files changed, 1773 insertions(+), 80 deletions(-) create mode 100644 scripts/ci/dependency-security.mjs create mode 100644 scripts/lib/dependency-security-facts.d.mts create mode 100644 scripts/lib/dependency-security-facts.mjs create mode 100644 scripts/lib/dependency-security.d.mts create mode 100644 scripts/lib/dependency-security.mjs create mode 100644 scripts/lib/npm-command.mjs create mode 100644 scripts/lib/npm-locked-packages.d.mts create mode 100644 scripts/lib/npm-locked-packages.mjs create mode 100644 scripts/lib/release-package-install.mjs create mode 100644 scripts/lib/transport-authentication.d.mts create mode 100644 scripts/lib/transport-authentication.mjs create mode 100644 tests/boundary/filesystem/dependencySecurity.test.ts create mode 100644 tests/boundary/filesystem/dependencySecurityWorkflow.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89a6c39c45..d6004b2359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,7 @@ jobs: contents: read actions: read outputs: + dependency_security: ${{ steps.plan.outputs.dependency_security }} tier: ${{ steps.plan.outputs.tier }} qualification_run_id: ${{ steps.qualification.outputs.run_id }} code: ${{ steps.plan.outputs.code }} @@ -1091,6 +1092,45 @@ jobs: if: needs.changes.outputs.website == 'true' uses: ./.github/workflows/website-check.yml + dependency-security: + name: Dependency security + needs: changes + if: needs.changes.outputs.dependency_security == 'true' || needs.changes.outputs.tier == 'reused' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + env: + NODE_OPTIONS: --max-old-space-size=1024 + UV_THREADPOOL_SIZE: "1" + TURBO_TOKEN: "" + TURBO_CACHE: local:rw + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ inputs.source_sha || github.sha }} + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + cache: npm + - name: Validate registry identities before installing dependencies + run: node scripts/ci/dependency-security.mjs --mode=preflight + - run: npm ci --ignore-scripts --no-audit --no-fund --maxsockets=2 + - name: Check source lock advisories, installed signatures and identities + run: node scripts/ci/dependency-security.mjs --mode=source + - name: Check a fresh published consumer daily + if: github.event_name == 'schedule' && github.event.schedule == '23 3 * * *' + run: node scripts/ci/dependency-security.mjs --mode=published + - name: Retain dependency security evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dependency-security + path: .cache/dependency-security/ + if-no-files-found: warn + retention-days: 14 + required: name: CI required # Report failed dependencies, but release superseded PR concurrency slots. @@ -1098,6 +1138,7 @@ jobs: needs: [ changes, + dependency-security, static, static-deep, build, diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml index 07a6a2164c..4efa449bd8 100644 --- a/.github/workflows/release-recovery.yml +++ b/.github/workflows/release-recovery.yml @@ -77,15 +77,24 @@ jobs: - name: Rehearse the original qualified tarball env: RECOVERY_VERSION: ${{ steps.release.outputs.version }} - run: node scripts/verify-release-artifact.mjs .cache/verified-package/package.tgz "$RECOVERY_VERSION" + run: node scripts/verify-release-artifact.mjs .cache/verified-package/package.tgz "$RECOVERY_VERSION" --dependency-security=.cache/dependency-security - name: Verify published CLI, MCP and update env: RECOVERY_VERSION: ${{ steps.release.outputs.version }} - run: node scripts/verify-published-package.mjs "$RECOVERY_VERSION" + run: node scripts/verify-published-package.mjs "$RECOVERY_VERSION" --dependency-security=.cache/dependency-security - name: Validate released registry metadata working-directory: .cache/release-source run: npm run mcp-registry:check + - name: Retain recovery consumer dependency security evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: recovery-consumer-security + path: .cache/dependency-security/ + if-no-files-found: warn + retention-days: 14 + publish-mcp: name: Finish MCP Registry publication needs: verify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bab5c35bf9..909bc40f2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -342,7 +342,16 @@ jobs: env: VERIFIED_PACKAGE_TARBALL: ${{ steps.package.outputs.tarball }} VERIFIED_PACKAGE_VERSION: ${{ steps.package.outputs.version }} - run: node scripts/verify-release-artifact.mjs "$VERIFIED_PACKAGE_TARBALL" "$VERIFIED_PACKAGE_VERSION" + run: node scripts/verify-release-artifact.mjs "$VERIFIED_PACKAGE_TARBALL" "$VERIFIED_PACKAGE_VERSION" --dependency-security=.cache/dependency-security + + - name: Retain consumer dependency security evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: qualified-consumer-security + path: .cache/dependency-security/ + if-no-files-found: warn + retention-days: 14 publish: name: Publish to npm @@ -415,7 +424,16 @@ jobs: - name: Verify published CLI from npm env: VERIFIED_PACKAGE_VERSION: ${{ steps.package.outputs.version }} - run: node scripts/verify-published-package.mjs "$VERIFIED_PACKAGE_VERSION" + run: node scripts/verify-published-package.mjs "$VERIFIED_PACKAGE_VERSION" --dependency-security=.cache/dependency-security + + - name: Retain consumer dependency security evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: published-consumer-security + path: .cache/dependency-security/ + if-no-files-found: warn + retention-days: 14 publish-mcp: name: Publish to MCP Registry diff --git a/docs/releasing.md b/docs/releasing.md index b43d80b96b..2c4a406430 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -28,6 +28,34 @@ job rebuilds or repacks the archive. This rehearsal checks package consumption on the Linux release runner; qualification retains its broader native-host and real-provider checks. +Dependency policy: the source lock pins the build, while an npm consumer resolves +the published package's semver ranges. Consumer installations are not pinned by +the source lock. Qualification checks live advisories for the source lock, +signatures for npm-supported installed dependencies, and installed identities. +Hourly qualification reuse still requires fresh dependency evidence; cached +provider qualification is not a current registry security result. The daily +scheduled run also checks a fresh installation of the current npm release. + +Both the qualified-archive rehearsal and published-package verification run the +same checks on their actual installed consumer tree before executing REA. +Installations disable lifecycle scripts and retain the generated consumer lock. +Evidence artifacts contain command output, npm-resolved package identities and +relationships, a content fingerprint, and version/integrity differences against +the source lock. New semver-compatible versions are recorded; changed integrity +for a source-locked identity fails. Unsupported registries, malformed output, +unavailable registry evidence and exceeded output budgets fail the check. +Optional omissions remain explicit. npm does not expose a verified-signature +count here, so it remains unknown; these checks do not establish that every +platform package or transitive lifecycle download is safe, or detect all malware. + +To reproduce the read-only checks locally, use +`node scripts/ci/dependency-security.mjs --mode=source` after installing the +locked dependencies, or `--mode=published` for a fresh current-release consumer. +`--evidence=PATH` selects the retained evidence directory. The CI job limits +registry concurrency to two connections and runs scans sequentially; each +command has a three-minute deadline and a 16 MiB output budget. No prior pass is +used in place of a live advisory or signature check. + Before publishing, npm lookup failures and malformed metadata stop the job. An existing version must match the qualified tarball's SHA-512 and intended `latest`/`next` dist-tag before it can be reused. Publication never overwrites diff --git a/scripts/ci/dependency-security.mjs b/scripts/ci/dependency-security.mjs new file mode 100644 index 0000000000..a6064ad0ed --- /dev/null +++ b/scripts/ci/dependency-security.mjs @@ -0,0 +1,85 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { checkDependencySecurity } from "../lib/dependency-security.mjs"; +import { requireRegistryLock } from "../lib/dependency-security-facts.mjs"; +import { lockedPackages } from "../lib/npm-locked-packages.mjs"; +import { installCanaryPackage } from "../lib/release-package-install.mjs"; + +const { values } = parseArgs({ + options: { + mode: { type: "string", default: "source" }, + evidence: { type: "string", default: ".cache/dependency-security" }, + }, +}); +const referenceLockfile = resolve("package-lock.json"); +// This pre-install check uses only Node built-ins and executes no dependency code. +requireRegistryLock( + lockedPackages(JSON.parse(await readFile(referenceLockfile, "utf8"))), +); +if (values.mode === "preflight") { + console.log( + "Source lock uses the supported npm registry and SHA-512 identities", + ); +} else if (values.mode === "source") { + const report = await checkDependencySecurity({ + directory: process.cwd(), + referenceLockfile, + evidenceDirectory: values.evidence, + sourceSha: process.env.GITHUB_SHA ?? null, + }); + console.log(JSON.stringify(report)); + if (!report.checks_passed) process.exitCode = 1; +} else if (values.mode === "published") { + const response = await fetch("https://registry.npmjs.org/rea-agents/latest", { + signal: AbortSignal.timeout(30_000), + headers: { accept: "application/json" }, + }); + if (!response.ok) + throw new Error(`Published REA metadata unavailable: ${response.status}`); + const product = await response.json(); + if ( + product.name !== "rea-agents" || + typeof product.version !== "string" || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test( + product.version, + ) + ) + throw new Error("Invalid published REA identity"); + const workspace = await mkdtemp(join(tmpdir(), "rea-dependency-consumer-")); + let report; + try { + const userconfig = join(workspace, "user.npmrc"), + globalconfig = join(workspace, "global.npmrc"); + await writeFile(userconfig, "", { mode: 0o600 }); + await writeFile(globalconfig, "", { mode: 0o600 }); + const environment = { + ...process.env, + npm_config_registry: "https://registry.npmjs.org", + npm_config_userconfig: userconfig, + npm_config_globalconfig: globalconfig, + npm_config_maxsockets: "2", + npm_config_prefer_online: "true", + }; + await installCanaryPackage( + `rea-agents@${product.version}`, + workspace, + environment, + ); + report = await checkDependencySecurity({ + directory: join(workspace, "installed"), + referenceLockfile, + evidenceDirectory: values.evidence, + mode: "consumer", + expectedVersion: product.version, + sourceSha: process.env.GITHUB_SHA ?? null, + environment, + }); + } finally { + // A cleanup failure propagates; never report a successful completed run first. + await rm(workspace, { recursive: true, force: true }); + } + console.log(JSON.stringify(report)); + if (!report.checks_passed) process.exitCode = 1; +} else throw new Error("Expected --mode=preflight, source, or published"); diff --git a/scripts/ci/plan.mjs b/scripts/ci/plan.mjs index fb9260bde4..288ba2ef19 100644 --- a/scripts/ci/plan.mjs +++ b/scripts/ci/plan.mjs @@ -204,6 +204,10 @@ if (tier === "reused") { .split("\0") .filter(Boolean); for (const file of files) { + selected.dependency_security ||= matchesAny( + file, + SCOPES.dependency_security, + ); if (matchesAny(file, SCOPES.reverse_tools)) selected.reverse_tools = true; if ( /^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test( @@ -245,6 +249,7 @@ if (tier === "reused") { selected.code = true; selected.runtime = true; const owners = Object.entries(SCOPES) + .filter(([owner]) => owner !== "dependency_security") .filter(([, patterns]) => patterns.some((pattern) => matchesGlob(file, pattern)), ) diff --git a/scripts/ci/required.mjs b/scripts/ci/required.mjs index a4da755d77..16fa2a4560 100644 --- a/scripts/ci/required.mjs +++ b/scripts/ci/required.mjs @@ -28,9 +28,12 @@ if ( ); if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length) throw new Error("Full qualification requires every canonical CI scope"); -const required = reused ? ["changes"] : ["changes", "static", "test"]; +const required = reused + ? ["changes", "dependency-security"] + : ["changes", "static", "test"]; if (outputs.tier === "full") required.push("static-deep"); const lanes = { + dependency_security: ["dependency-security"], code: [ "build", "test-shard", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index f29cda9704..9e1f8d0d93 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -2,6 +2,25 @@ // Shared build inputs are classified once in plan.mjs rather than repeated in // every workflow. export const SCOPES = { + // Cross-cutting live registry evidence; does not replace provider ownership. + dependency_security: [ + "package.json", + "package-lock.json", + ".nvmrc", + ".npmrc", + ".github/workflows/ci.yml", + ".github/workflows/release.yml", + ".github/workflows/release-recovery.yml", + "scripts/ci/dependency-security.mjs", + "scripts/lib/dependency-security*.mjs", + "scripts/lib/npm-locked-packages.mjs", + "scripts/lib/npm-command.mjs", + "scripts/lib/transport-authentication.mjs", + "scripts/lib/release-package-install.mjs", + "scripts/verify-release-artifact.mjs", + "scripts/verify-published-package.mjs", + "tests/boundary/filesystem/dependencySecurity*.test.ts", + ], reverse_tools: [ ".github/workflows/mutation.yml", "src/application/reverse/**", @@ -115,6 +134,8 @@ export const SCOPES = { "scripts/ci/npm-release-state.*", "scripts/verify-release-artifact.mjs", "scripts/lib/release-package-canary.mjs", + "scripts/lib/release-package-install.mjs", + "scripts/lib/npm-command.mjs", "scripts/verify-published-package.mjs", "tests/boundary/filesystem/releaseRecovery.test.ts", "tests/boundary/filesystem/mcpRegistryPublication.test.ts", diff --git a/scripts/generate-third-party-inventory.mjs b/scripts/generate-third-party-inventory.mjs index 57934ebda7..2088f21e85 100644 --- a/scripts/generate-third-party-inventory.mjs +++ b/scripts/generate-third-party-inventory.mjs @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { lockedPackages } from "./lib/npm-locked-packages.mjs"; import { ensureGeneratedFile } from "./lib/generated-file.mjs"; const arguments_ = new Set(process.argv.slice(2)); @@ -13,25 +14,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const lock = JSON.parse( await readFile(join(root, "package-lock.json"), "utf8"), ); -const entries = Object.entries(lock.packages) - .filter(([path]) => path !== "") - .map(([path, record]) => ({ - path, - name: record.name ?? path.slice(path.lastIndexOf("node_modules/") + 13), - version: record.version, - license: record.license ?? "UNDECLARED", - resolved: record.resolved, - integrity: record.integrity, - dev: record.dev === true, - optional: record.optional === true, - })) - .sort((left, right) => - left.path < right.path ? -1 : left.path > right.path ? 1 : 0, - ); -for (const entry of entries) - for (const field of ["name", "version", "resolved", "integrity"]) - if (typeof entry[field] !== "string" || entry[field].length === 0) - throw new Error(`Missing locked ${field}: ${entry.path}`); +const entries = lockedPackages(lock); const runtime = entries.filter(({ dev }) => !dev); const runtimeFingerprint = createHash("sha256") .update(JSON.stringify(runtime)) diff --git a/scripts/lib/dependency-security-facts.d.mts b/scripts/lib/dependency-security-facts.d.mts new file mode 100644 index 0000000000..248ad6577b --- /dev/null +++ b/scripts/lib/dependency-security-facts.d.mts @@ -0,0 +1,56 @@ +export interface InstalledPackage { + id: string; + name: string; + version: string; + integrity: string | null; + source_url: string | null; +} +export interface LockedPackage { + path: string; + name: string; + version: string; + resolved: string; + integrity: string; + dev: boolean; + optional: boolean; + license: unknown; +} +export function registryIdentity( + resolved: unknown, + integrity: unknown, +): boolean; +export function requireRegistryLock(packages: LockedPackage[]): void; +export function advisoryFacts( + value: unknown, + exitCode: number | null, +): { + status: "unavailable" | "failed" | "passed"; + affected_packages: number | null; +}; +export function signatureFacts( + value: unknown, + exitCode: number | null, +): { + status: "unavailable" | "failed" | "passed"; + invalid: number | null; + missing: number | null; + verified: null; +}; +export function dependencyGraph( + value: unknown, + directory: string, + installedPackages: LockedPackage[], +): { + nodes: InstalledPackage[]; + edges: { from: string; name: string; to: string }[]; + omissions: { from: string; name: string; reason: string }[]; + unknowns: { reason: string }[]; + fingerprint: string | null; +}; +export function sourceIdentityDifferences( + nodes: InstalledPackage[], + reference: LockedPackage[], + runtimeOnly: boolean, +): (InstalledPackage & { + reason: "integrity_differs" | "version_not_locked"; +})[]; diff --git a/scripts/lib/dependency-security-facts.mjs b/scripts/lib/dependency-security-facts.mjs new file mode 100644 index 0000000000..2ac686e684 --- /dev/null +++ b/scripts/lib/dependency-security-facts.mjs @@ -0,0 +1,244 @@ +import { createHash } from "node:crypto"; +import { isAbsolute, relative, sep } from "node:path"; + +const record = (value) => + typeof value === "object" && value !== null && !Array.isArray(value); +const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0); +const count = (value) => Number.isSafeInteger(value) && value >= 0; + +export function registryIdentity(resolved, integrity) { + try { + const url = new URL(resolved); + return ( + url.protocol === "https:" && + url.hostname === "registry.npmjs.org" && + url.port === "" && + url.username === "" && + url.password === "" && + /^sha512-[A-Za-z0-9+/]{86}==$/u.test(integrity) + ); + } catch { + return false; + } +} + +export function requireRegistryLock(packages) { + for (const entry of packages) + if (!registryIdentity(entry.resolved, entry.integrity)) + throw new Error(`Unsupported registry or integrity for ${entry.path}`); +} +export function advisoryFacts(value, exitCode) { + const counts = value?.metadata?.vulnerabilities; + const keys = ["info", "low", "moderate", "high", "critical", "total"]; + if ( + !record(counts) || + !keys.every((key) => count(counts[key])) || + keys.slice(0, -1).reduce((sum, key) => sum + counts[key], 0) !== + counts.total || + value.error !== undefined + ) + return { + status: "unavailable", + reason: "invalid_advisory_report", + affected_packages: null, + }; + const status = + counts.total > 0 + ? "failed" + : exitCode === 0 && value.error === undefined + ? "passed" + : "unavailable"; + return { + status, + reason: status === "unavailable" ? "advisory_command_failed" : null, + affected_packages: counts.total, + severity: Object.fromEntries(keys.map((key) => [key, counts[key]])), + }; +} +export function signatureFacts(value, exitCode) { + if ( + !record(value) || + !Array.isArray(value.invalid) || + !Array.isArray(value.missing) + ) + return { + status: "unavailable", + reason: "invalid_signature_report", + invalid: null, + missing: null, + verified: null, + }; + const status = + value.invalid.length || value.missing.length + ? "failed" + : exitCode === 0 && value.error === undefined + ? "passed" + : "unavailable"; + return { + status, + reason: status === "unavailable" ? "signature_command_failed" : null, + invalid: value.invalid.length, + missing: value.missing.length, + verified: null, + }; +} + +/** Use npm's resolved relationships, never implement a second dependency resolver. */ +export function dependencyGraph(value, directory, installedPackages) { + if (!record(value) || value.path !== directory) + throw new Error("Installed tree root does not match the selected project"); + const nodes = new Map(), + edges = new Map(), + omissions = new Map(), + unknowns = []; + const locked = new Map(installedPackages.map((entry) => [entry.path, entry])); + const pending = [{ value, from: null, dependency: null }]; + while (pending.length) { + const item = pending.pop(), + node = item.value; + if ( + !record(node) || + typeof node.path !== "string" || + !isAbsolute(node.path) + ) { + unknowns.push({ + from: item.from, + dependency: item.dependency, + reason: "unresolved_identity", + }); + continue; + } + const path = relative(directory, node.path); + if ( + path === ".." || + path.startsWith(".." + sep) || + isAbsolute(path) || + typeof node.name !== "string" || + node.name.length === 0 || + typeof node.version !== "string" || + node.version.length === 0 + ) { + unknowns.push({ + from: item.from, + dependency: item.dependency, + reason: "invalid_package_identity", + }); + continue; + } + const id = path.split(sep).join("/") || "."; + const selected = locked.get(id); + // npm ls may read package.json without integrity when its hidden cache is + // stale. Join npm's actual identities/edges to the selected project's lock, + // which owns declared integrity, using the exact installation path. + const matched = + selected?.name === node.name && + selected.version === node.version && + (node.resolved === undefined || selected.resolved === node.resolved); + if ( + item.from !== null && + (!matched || + (node.integrity !== undefined && node.integrity !== selected.integrity)) + ) + unknowns.push({ id, reason: "installed_identity_not_bound_to_lock" }); + if ( + node.error !== undefined || + (Array.isArray(node.problems) && node.problems.length) + ) + unknowns.push({ id, reason: "npm_reported_problem" }); + const fact = { + id, + name: node.name, + version: node.version, + integrity: matched ? selected.integrity : null, + source_url: matched + ? selected.resolved + : typeof node.resolved === "string" + ? node.resolved + : null, + }; + if (nodes.has(id) && JSON.stringify(nodes.get(id)) !== JSON.stringify(fact)) + unknowns.push({ id, reason: "conflicting_package_identity" }); + else nodes.set(id, fact); + if (item.from !== null) { + const edge = { from: item.from, name: item.dependency, to: id }; + edges.set(JSON.stringify(edge), edge); + } + if (node.dependencies === undefined) continue; + if (!record(node.dependencies)) { + unknowns.push({ id, reason: "invalid_dependency_relationships" }); + continue; + } + for (const [name, child] of Object.entries(node.dependencies)) { + if ( + record(child) && + Object.keys(child).length === 0 && + (Object.hasOwn(node.optionalDependencies ?? {}, name) || + node.peerDependenciesMeta?.[name]?.optional === true) + ) { + const omission = { from: id, name, reason: "optional_not_reported" }; + omissions.set(JSON.stringify(omission), omission); + } else pending.push({ value: child, from: id, dependency: name }); + } + } + const facts = { + nodes: [...nodes.values()].sort((a, b) => compare(a.id, b.id)), + edges: [...edges.values()].sort((a, b) => + compare(JSON.stringify(a), JSON.stringify(b)), + ), + omissions: [...omissions.values()].sort((a, b) => + compare(JSON.stringify(a), JSON.stringify(b)), + ), + }; + return { + ...facts, + unknowns, + fingerprint: unknowns.length + ? null + : createHash("sha256") + .update( + JSON.stringify({ + ...facts, + // Preserve URLs as evidence, but exclude temporary archive locations + // from the content identity of otherwise identical installed trees. + nodes: facts.nodes.map( + ({ source_url: _source, ...identity }) => identity, + ), + }), + ) + .digest("hex"), + }; +} + +/** Version drift is allowed for consumers; changed bytes of a locked identity are not. */ +export function sourceIdentityDifferences(nodes, reference, runtimeOnly) { + const packages = new Map(); + for (const entry of reference.filter((entry) => !runtimeOnly || !entry.dev)) { + if (!packages.has(entry.name)) packages.set(entry.name, []); + packages + .get(entry.name) + .push({ version: entry.version, integrity: entry.integrity }); + } + return nodes + .filter( + (node) => + node.id !== "." && + (!runtimeOnly || node.id !== "node_modules/rea-agents"), + ) + .flatMap((node) => { + const expected = packages.get(node.name) ?? []; + const sameVersion = expected.filter( + (entry) => entry.version === node.version, + ); + if (sameVersion.some((entry) => entry.integrity === node.integrity)) + return []; + return [ + { + ...node, + source_identities: expected, + reason: sameVersion.length + ? "integrity_differs" + : "version_not_locked", + }, + ]; + }); +} diff --git a/scripts/lib/dependency-security.d.mts b/scripts/lib/dependency-security.d.mts new file mode 100644 index 0000000000..7d6381b504 --- /dev/null +++ b/scripts/lib/dependency-security.d.mts @@ -0,0 +1,36 @@ +export const SECURITY_OUTPUT_BYTES: number; +export interface SecurityReport { + checks_passed: boolean; + evidence_directory: string; + precondition?: { status: string; reason: string }; + advisory: { status: string; affected_packages?: number | null }; + signatures: { status: string; verified: null }; + tree: { + status: string; + content_graph_sha256?: string | null; + versions_outside_source_lock?: number; + }; +} +export interface NpmObservation { + stdout: string; + stderr: string; + exitCode: number | null; + elapsedMs: number; + failure: string | null; +} +export function checkDependencySecurity( + options: { + directory: string; + evidenceDirectory: string; + referenceLockfile: string; + mode?: "source" | "consumer"; + sourceSha?: string | null; + expectedVersion?: string | null; + qualifiedTarball?: string | null; + environment?: NodeJS.ProcessEnv; + }, + run?: ( + args: string[], + options: { cwd: string; env: NodeJS.ProcessEnv }, + ) => Promise, +): Promise; diff --git a/scripts/lib/dependency-security.mjs b/scripts/lib/dependency-security.mjs new file mode 100644 index 0000000000..5906adeb08 --- /dev/null +++ b/scripts/lib/dependency-security.mjs @@ -0,0 +1,340 @@ +import { redactTransportAuthentication } from "./transport-authentication.mjs"; +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, readFile, writeFile, stat } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { verifyPublishedPackageIntegrity } from "../ci/qualified-package-envelope.mjs"; +import { lockedPackages } from "./npm-locked-packages.mjs"; +import { + advisoryFacts, + signatureFacts, + dependencyGraph, + sourceIdentityDifferences, + registryIdentity, + requireRegistryLock, +} from "./dependency-security-facts.mjs"; + +import { execNpm } from "./npm-command.mjs"; +// CI evidence budget, not a package-format limit. Overflow is unavailable evidence. +export const SECURITY_OUTPUT_BYTES = 16 * 1024 * 1024; +const digest = (text) => createHash("sha256").update(text).digest("hex"); +const unavailable = (reason) => ({ status: "unavailable", reason }); + +async function executeNpm(args, options) { + const started = performance.now(); + try { + const result = await execNpm(args, { + ...options, + encoding: "utf8", + maxBuffer: SECURITY_OUTPUT_BYTES, + timeout: 180_000, + }); + return { + ...result, + exitCode: 0, + elapsedMs: Math.round(performance.now() - started), + failure: null, + }; + } catch (error) { + return { + stdout: error.stdout ?? "", + stderr: error.stderr ?? error.message, + exitCode: typeof error.code === "number" ? error.code : null, + elapsedMs: Math.round(performance.now() - started), + failure: String(error.code ?? error.signal ?? "command_failed"), + }; + } +} + +/** One scan implementation for CI source locks and the actual installed release consumer. */ +export async function checkDependencySecurity( + { + directory, + evidenceDirectory, + referenceLockfile, + mode = "source", + sourceSha = null, + expectedVersion = null, + qualifiedTarball = null, + environment = process.env, + }, + run = executeNpm, +) { + directory = resolve(directory); + await mkdir(evidenceDirectory, { recursive: true, mode: 0o700 }); + const evidence = await mkdtemp(join(resolve(evidenceDirectory), "run-")); + const report = { + schema_version: 1, + checks_passed: false, + mode, + started_at: new Date().toISOString(), + source_sha: sourceSha, + node_version: process.version, + npm_version: null, + platform: process.platform, + architecture: process.arch, + evidence_directory: evidence, + reference_lock_sha256: null, + advisory: unavailable("not_observed"), + signatures: { ...unavailable("not_observed"), verified: null }, + tree: unavailable("not_observed"), + coverage: { + advisory: "selected project lock", + signatures: + "npm-supported installed registry dependencies on this platform; verified count not exposed", + graph: + "npm-reported installed identities/relationships bound to selected-lock declared origins/integrities; optional omissions retained; installed files not rehashed", + consumer_policy: + mode === "consumer" + ? "semver resolution; source lock does not pin consumers" + : null, + }, + commands: [], + }; + const finish = async () => { + report.completed_at = new Date().toISOString(); + await writeFile( + join(evidence, "report.json"), + JSON.stringify(report, null, 2) + "\n", + ); + return report; + }; + let reference; + let selectedLockSha; + let installedPackages; + try { + const lockStat = await stat(referenceLockfile); + if (!lockStat.isFile() || lockStat.size > SECURITY_OUTPUT_BYTES) + throw new Error( + "Reference lock exceeds CI evidence budget or is not a file", + ); + const bytes = await readFile(referenceLockfile); + report.reference_lock_sha256 = digest(bytes); + reference = lockedPackages(JSON.parse(bytes)); + requireRegistryLock(reference); + if (!["source", "consumer"].includes(mode)) + throw new Error("Unknown dependency security mode"); + const localLock = join(directory, "package-lock.json"); + const localStat = await stat(localLock); + if (!localStat.isFile() || localStat.size > SECURITY_OUTPUT_BYTES) + throw new Error("Selected project has no consumer lock"); + const selectedBytes = await readFile(localLock); + selectedLockSha = digest(selectedBytes); + installedPackages = lockedPackages(JSON.parse(selectedBytes)); + report.selected_lock_sha256 = selectedLockSha; + const retainedLock = redactTransportAuthentication( + selectedBytes.toString("utf8"), + ); + await writeFile(join(evidence, "selected-package-lock.json"), retainedLock); + report.retained_lock_sha256 = digest(retainedLock); + const product = JSON.parse( + await readFile( + join( + directory, + ...(mode === "consumer" ? ["node_modules", "rea-agents"] : []), + "package.json", + ), + "utf8", + ), + ); + if ( + product.name !== "rea-agents" || + typeof product.version !== "string" || + (expectedVersion !== null && product.version !== expectedVersion) + ) + throw new Error( + "Installed REA identity differs from the selected release", + ); + report.product = { name: product.name, version: product.version }; + } catch (error) { + report.precondition = unavailable(error.message); + return finish(); + } + const userconfig = join(evidence, ".empty-user.npmrc"), + globalconfig = join(evidence, ".empty-global.npmrc"); + await writeFile(userconfig, "", { mode: 0o600 }); + await writeFile(globalconfig, "", { mode: 0o600 }); + const common = [ + "--ignore-scripts", + "--registry=https://registry.npmjs.org", + "--maxsockets=2", + "--prefer-online", + `--userconfig=${userconfig}`, + `--globalconfig=${globalconfig}`, + ]; + const collect = async (name, args, json = true) => { + const result = await run([...args, ...common], { + cwd: directory, + env: environment, + }); + const oversized = + Buffer.byteLength(result.stdout) > SECURITY_OUTPUT_BYTES || + Buffer.byteLength(result.stderr) > SECURITY_OUTPUT_BYTES; + if (oversized) { + result.exitCode = null; + result.failure = "output_limit"; + } + const bounded = (value) => + Buffer.byteLength(value) <= SECURITY_OUTPUT_BYTES + ? value + : Buffer.from(value) + .subarray(0, SECURITY_OUTPUT_BYTES) + .toString("utf8"); + const stdout = redactTransportAuthentication(bounded(result.stdout)), + stderr = redactTransportAuthentication(bounded(result.stderr)); + await writeFile(join(evidence, `${name}.${json ? "json" : "txt"}`), stdout); + await writeFile(join(evidence, `${name}.stderr.txt`), stderr); + report.commands.push({ + name, + exit_code: result.exitCode, + elapsed_ms: result.elapsedMs, + failure: result.failure, + stdout_sha256: digest(stdout), + stderr_sha256: digest(stderr), + }); + let value = null; + try { + value = oversized ? null : json ? JSON.parse(stdout) : stdout.trim(); + } catch { + /* Raw malformed evidence is retained; never interpret it as zero. */ + } + return { value, exitCode: result.exitCode }; + }; + const version = await collect("npm-version", ["--version"], false); + if ( + version.exitCode !== 0 || + typeof version.value !== "string" || + !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/u.test(version.value) + ) { + report.precondition = unavailable("npm_toolchain_unavailable"); + return finish(); + } + report.npm_version = version.value; + const audit = await collect("audit", [ + "audit", + "--package-lock-only", + "--include=dev", + "--include=optional", + "--json", + ]); + report.advisory = advisoryFacts(audit.value, audit.exitCode); + const signatures = await collect("signatures", [ + "audit", + "signatures", + "--include=dev", + "--include=optional", + "--json", + ]); + report.signatures = signatureFacts(signatures.value, signatures.exitCode); + const tree = await collect("installed-tree", [ + "ls", + "--all", + "--include=dev", + "--include=optional", + "--json", + "--long", + ]); + try { + const graph = dependencyGraph(tree.value, directory, installedPackages); + if ( + graph.nodes.length < 2 || + (mode === "source" && + !graph.nodes.some( + (node) => + node.id === "." && + node.name === report.product.name && + node.version === report.product.version, + )) || + (mode === "consumer" && + !graph.nodes.some( + (node) => + node.id === "node_modules/rea-agents" && + node.name === "rea-agents" && + node.version === report.product.version, + )) + ) + throw new Error("Installed REA dependency tree is incomplete"); + if (tree.exitCode !== 0) graph.fingerprint = null; + for (const node of graph.nodes.filter((node) => node.id !== ".")) { + if ( + mode === "consumer" && + node.id === "node_modules/rea-agents" && + qualifiedTarball !== null + ) { + if ( + node.name !== "rea-agents" || + node.version !== report.product.version || + typeof node.source_url !== "string" || + fileURLToPath( + new URL(node.source_url, pathToFileURL(directory + sep)), + ) !== resolve(qualifiedTarball) + ) + throw new Error( + "Consumer archive identity differs from the selected qualified tarball", + ); + await verifyPublishedPackageIntegrity(qualifiedTarball, node.integrity); + report.qualified_archive = { + path: resolve(qualifiedTarball), + integrity: node.integrity, + }; + } else if (!registryIdentity(node.source_url, node.integrity)) { + graph.unknowns.push({ + id: node.id, + reason: "unsupported_registry_or_integrity", + }); + } + } + if (graph.unknowns.length) graph.fingerprint = null; + const differences = sourceIdentityDifferences( + graph.nodes, + reference, + mode === "consumer", + ); + await writeFile( + join(evidence, "tree-facts.json"), + JSON.stringify(graph, null, 2) + "\n", + ); + await writeFile( + join(evidence, "source-identity-differences.json"), + JSON.stringify(differences, null, 2) + "\n", + ); + const byteDifferences = differences.filter( + (entry) => entry.reason === "integrity_differs", + ); + const status = + graph.unknowns.length || tree.exitCode !== 0 + ? "unavailable" + : byteDifferences.length || (mode === "source" && differences.length) + ? "failed" + : "passed"; + report.tree = { + status, + nodes: graph.nodes.length, + edges: graph.edges.length, + optional_not_reported: graph.omissions.length, + unknowns: graph.unknowns.length, + content_graph_sha256: graph.fingerprint, + versions_outside_source_lock: differences.length - byteDifferences.length, + changed_locked_integrities: byteDifferences.length, + }; + } catch (error) { + report.tree = unavailable(error.message); + } + report.checks_passed = [ + report.advisory, + report.signatures, + report.tree, + ].every((value) => value.status === "passed"); + try { + const lock = join(directory, "package-lock.json"); + if ( + (await stat(lock)).size > SECURITY_OUTPUT_BYTES || + digest(await readFile(lock)) !== selectedLockSha + ) + throw new Error("selected_lock_changed_during_scan"); + } catch (error) { + report.checks_passed = false; + report.precondition = unavailable(error.message); + } + return finish(); +} diff --git a/scripts/lib/npm-command.mjs b/scripts/lib/npm-command.mjs new file mode 100644 index 0000000000..e6ea19e51f --- /dev/null +++ b/scripts/lib/npm-command.mjs @@ -0,0 +1,24 @@ +import { execFile } from "node:child_process"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); + +/** Run npm through its JavaScript entry point so Windows command scripts are not required. */ +export const execNpm = (arguments_, options = {}) => { + const environment = options.env ?? process.env; + const selectedEntry = environment.npm_execpath; + if ( + selectedEntry !== undefined && + (typeof selectedEntry !== "string" || selectedEntry.length === 0) + ) + throw new Error("npm_execpath must name the selected npm CLI entry point"); + const entry = + selectedEntry ?? + (process.platform === "win32" + ? join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js") + : undefined); + return entry === undefined + ? exec("npm", arguments_, options) + : exec(process.execPath, [entry, ...arguments_], options); +}; diff --git a/scripts/lib/npm-locked-packages.d.mts b/scripts/lib/npm-locked-packages.d.mts new file mode 100644 index 0000000000..5e017ab0b8 --- /dev/null +++ b/scripts/lib/npm-locked-packages.d.mts @@ -0,0 +1,2 @@ +import type { LockedPackage } from "./dependency-security-facts.mjs"; +export function lockedPackages(value: unknown): LockedPackage[]; diff --git a/scripts/lib/npm-locked-packages.mjs b/scripts/lib/npm-locked-packages.mjs new file mode 100644 index 0000000000..85ce730eb4 --- /dev/null +++ b/scripts/lib/npm-locked-packages.mjs @@ -0,0 +1,44 @@ +import { redactTransportAuthentication } from "./transport-authentication.mjs"; + +/** Parse the root npm lock inventory once for notices and security evidence. */ +export function lockedPackages(lock) { + if ( + !lock || + typeof lock !== "object" || + ![2, 3].includes(lock.lockfileVersion) || + !lock.packages || + typeof lock.packages !== "object" || + Array.isArray(lock.packages) + ) + throw new Error("Expected an npm v2/v3 package lock with package records"); + return Object.entries(lock.packages) + .filter(([path]) => path !== "") + .map(([path, record]) => { + if ( + !path.startsWith("node_modules/") || + path + .split("/") + .some((part) => part === ".." || part === "." || part === "") || + path.includes("\\") + ) + throw new Error(`Unsupported locked package path: ${path}`); + if (!record || typeof record !== "object" || Array.isArray(record)) + throw new Error(`Invalid locked package: ${path}`); + const entry = { + path, + name: record.name ?? path.slice(path.lastIndexOf("node_modules/") + 13), + version: record.version, + license: record.license ?? "UNDECLARED", + resolved: record.resolved, + integrity: record.integrity, + dev: record.dev === true, + optional: record.optional === true, + }; + for (const field of ["name", "version", "resolved", "integrity"]) + if (typeof entry[field] !== "string" || entry[field].length === 0) + throw new Error(`Missing locked ${field}: ${path}`); + entry.resolved = redactTransportAuthentication(entry.resolved); + return entry; + }) + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} diff --git a/scripts/lib/release-package-canary.mjs b/scripts/lib/release-package-canary.mjs index b00bb9788c..96fe17f06e 100644 --- a/scripts/lib/release-package-canary.mjs +++ b/scripts/lib/release-package-canary.mjs @@ -14,27 +14,6 @@ import { const execute = promisify(execFile); -/** Install the selected archive or registry specifier before any protocol deadline. */ -export async function installCanaryPackage(specifier, workspace, environment) { - const prefix = join(workspace, "installed"); - await execute( - "npm", - [ - "install", - "--ignore-scripts", - "--no-save", - "--no-package-lock", - "--no-audit", - "--no-fund", - "--prefix", - prefix, - specifier, - ], - { cwd: workspace, env: environment, maxBuffer: 16 * 1024 * 1024 }, - ); - return join(prefix, "node_modules", "rea-agents"); -} - /** Exercise the same installed CLI/MCP contract before and after publication. */ export async function verifyInstalledCanaryPackage( packageRoot, diff --git a/scripts/lib/release-package-install.mjs b/scripts/lib/release-package-install.mjs new file mode 100644 index 0000000000..c71c4cb6b8 --- /dev/null +++ b/scripts/lib/release-package-install.mjs @@ -0,0 +1,33 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { execNpm } from "./npm-command.mjs"; + +/** Install the selected archive or registry specifier before any protocol deadline. */ +export async function installCanaryPackage(specifier, workspace, environment) { + const prefix = join(workspace, "installed"); + await mkdir(prefix, { recursive: true }); + await writeFile( + join(prefix, "package.json"), + JSON.stringify({ + name: "rea-release-consumer-check", + version: "0.0.0", + private: true, + }) + "\n", + { flag: "wx", mode: 0o600 }, + ); + await execNpm( + [ + "install", + "--ignore-scripts", + "--save-exact", + "--package-lock=true", + "--no-audit", + "--no-fund", + "--prefix", + prefix, + specifier, + ], + { cwd: workspace, env: environment, maxBuffer: 16 * 1024 * 1024 }, + ); + return join(prefix, "node_modules", "rea-agents"); +} diff --git a/scripts/lib/transport-authentication.d.mts b/scripts/lib/transport-authentication.d.mts new file mode 100644 index 0000000000..0fca832490 --- /dev/null +++ b/scripts/lib/transport-authentication.d.mts @@ -0,0 +1 @@ +export function redactTransportAuthentication(text: string): string; diff --git a/scripts/lib/transport-authentication.mjs b/scripts/lib/transport-authentication.mjs new file mode 100644 index 0000000000..7e50e33592 --- /dev/null +++ b/scripts/lib/transport-authentication.mjs @@ -0,0 +1,20 @@ +/** Redact only HTTP URL authentication; keep paths, names, queries and other text. */ +export function redactTransportAuthentication(text) { + return text.replace(/https?:\/\/[^\s"<>\\]+/gu, (value) => { + try { + const parsed = new URL(value); + if (!parsed.username && !parsed.password) return value; + const start = value.indexOf("://") + 3; + const tail = value.slice(start), + end = tail.search(/[/?#]/u); + const authority = end < 0 ? tail : tail.slice(0, end); + return ( + value.slice(0, start) + + authority.slice(authority.lastIndexOf("@") + 1) + + (end < 0 ? "" : tail.slice(end)) + ); + } catch { + return value; + } + }); +} diff --git a/scripts/lib/verify-package-core.mjs b/scripts/lib/verify-package-core.mjs index 71fbe71f53..cbe0645d9a 100644 --- a/scripts/lib/verify-package-core.mjs +++ b/scripts/lib/verify-package-core.mjs @@ -1,6 +1,5 @@ import { execFile } from "node:child_process"; import { lstat } from "node:fs/promises"; -import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { Ajv2020 } from "ajv/dist/2020.js"; @@ -18,24 +17,7 @@ const loadMcpVerifierResults = () => import("./mcp-verifier-results.mjs"); /** Promisified child_process execution helper. */ export const exec = promisify(execFile); -/** Run npm through its JavaScript entry point so Windows command scripts are not required. */ -export const execNpm = (arguments_, options = {}) => { - const environment = options.env ?? process.env; - const selectedEntry = environment.npm_execpath; - if ( - selectedEntry !== undefined && - (typeof selectedEntry !== "string" || selectedEntry.length === 0) - ) - throw new Error("npm_execpath must name the selected npm CLI entry point"); - const entry = - selectedEntry ?? - (process.platform === "win32" - ? join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js") - : undefined); - return entry === undefined - ? exec("npm", arguments_, options) - : exec(process.execPath, [entry, ...arguments_], options); -}; +export { execNpm } from "./npm-command.mjs"; /** Run a command and return its stdout. */ export const run = async (command, args, env) => diff --git a/scripts/verify-published-package.mjs b/scripts/verify-published-package.mjs index 646b62e25c..2be36085de 100644 --- a/scripts/verify-published-package.mjs +++ b/scripts/verify-published-package.mjs @@ -1,28 +1,34 @@ +import { installCanaryPackage } from "./lib/release-package-install.mjs"; import { execFile } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; -import { promisify } from "node:util"; +import { delimiter, join, resolve } from "node:path"; +import { parseArgs, promisify } from "node:util"; import { pathToFileURL } from "node:url"; import { lt, prerelease, rcompare, valid } from "semver"; import { readNpmRelease, stableUpdateVersion } from "./ci/npm-release.mjs"; -import { - installCanaryPackage, - verifyInstalledCanaryPackage, -} from "./lib/release-package-canary.mjs"; +import { verifyInstalledCanaryPackage } from "./lib/release-package-canary.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; const verifierRun = createVerifierRun(); const execFileAsync = promisify(execFile); -const version = process.argv[2]; +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + "dependency-security": { type: "string" }, + }, +}); +const [version] = positionals; if ( typeof version !== "string" || valid(version) !== version || - process.argv.length !== 3 + positionals.length !== 1 ) - throw new Error("Usage: node scripts/verify-published-package.mjs "); + throw new Error( + "Usage: node scripts/verify-published-package.mjs [--dependency-security=directory]", + ); const canaryRoot = await mkdtemp(join(tmpdir(), "rea-published-canary-")); const serverEnvironment = { @@ -38,6 +44,23 @@ try { canaryRoot, serverEnvironment, ); + if (values["dependency-security"] !== undefined) { + const { checkDependencySecurity } = + await import("./lib/dependency-security.mjs"); + const report = await checkDependencySecurity({ + directory: join(canaryRoot, "installed"), + evidenceDirectory: values["dependency-security"], + referenceLockfile: resolve("package-lock.json"), + mode: "consumer", + sourceSha: process.env.GITHUB_SHA ?? null, + expectedVersion: version, + environment: serverEnvironment, + }); + if (!report.checks_passed) + throw new Error( + `Published consumer dependency checks failed: ${report.evidence_directory}`, + ); + } surface = await verifyInstalledCanaryPackage( publishedRoot, version, diff --git a/scripts/verify-release-artifact.mjs b/scripts/verify-release-artifact.mjs index b04f1d0a4f..7a00623208 100644 --- a/scripts/verify-release-artifact.mjs +++ b/scripts/verify-release-artifact.mjs @@ -1,14 +1,19 @@ +import { installCanaryPackage } from "./lib/release-package-install.mjs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { parseArgs } from "node:util"; import { valid } from "semver"; -import { - installCanaryPackage, - verifyInstalledCanaryPackage, -} from "./lib/release-package-canary.mjs"; +import { verifyInstalledCanaryPackage } from "./lib/release-package-canary.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; -const [tarball, version, ...extra] = process.argv.slice(2); +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + "dependency-security": { type: "string" }, + }, +}); +const [tarball, version, ...extra] = positionals; if ( tarball === undefined || typeof version !== "string" || @@ -16,7 +21,7 @@ if ( extra.length ) throw new Error( - "Usage: node scripts/verify-release-artifact.mjs ", + "Usage: node scripts/verify-release-artifact.mjs [--dependency-security=directory]", ); const source = resolve(tarball); const verifierRun = createVerifierRun(); @@ -33,6 +38,24 @@ try { workspace, environment, ); + if (values["dependency-security"] !== undefined) { + const { checkDependencySecurity } = + await import("./lib/dependency-security.mjs"); + const report = await checkDependencySecurity({ + directory: join(workspace, "installed"), + evidenceDirectory: values["dependency-security"], + referenceLockfile: resolve("package-lock.json"), + mode: "consumer", + sourceSha: process.env.GITHUB_SHA ?? null, + expectedVersion: version, + qualifiedTarball: source, + environment, + }); + if (!report.checks_passed) + throw new Error( + `Qualified consumer dependency checks failed: ${report.evidence_directory}`, + ); + } result = await verifyInstalledCanaryPackage( packageRoot, version, diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 6939bbf0a6..295b188c8c 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -1607,3 +1607,37 @@ it.each([ }); }, ); + +it.each([ + "package-lock.json", + ".nvmrc", + "scripts/lib/dependency-security.mjs", + "scripts/lib/npm-command.mjs", + "scripts/ci/dependency-security.mjs", + "scripts/verify-release-artifact.mjs", + "tests/boundary/filesystem/dependencySecurity.test.ts", +])( + "selects live dependency security without losing existing owners for %s", + async (path) => { + const { directory, base } = await fixture(); + const head = await change(directory, [path]); + const plan = await classify(directory, base, head); + expect(plan.dependency_security).toBe(true); + if ( + [ + "package-lock.json", + ".nvmrc", + "scripts/lib/npm-command.mjs", + "scripts/verify-release-artifact.mjs", + ].includes(path) + ) + expect(plan.package).toBe(true); + }, +); +it("keeps unrelated provider and documentation changes outside the selective security lane", async () => { + const { directory, base } = await fixture(); + const head = await change(directory, ["src/provider.ts", "README.md"]); + expect((await classify(directory, base, head)).dependency_security).toBe( + false, + ); +}); diff --git a/tests/boundary/filesystem/ciQualification.test.ts b/tests/boundary/filesystem/ciQualification.test.ts index 673b09d34c..af3026a6c9 100644 --- a/tests/boundary/filesystem/ciQualification.test.ts +++ b/tests/boundary/filesystem/ciQualification.test.ts @@ -254,7 +254,7 @@ async function requiredRun( result: name === overrides.failed ? "failure" - : changes.tier === "reused" + : changes.tier === "reused" && name !== "dependency-security" ? "skipped" : "success", }; @@ -373,3 +373,19 @@ it.each([ expect(await readFile(outcome.output, "utf8")).not.toContain("receipt_"); }, ); + +it("requires fresh security evidence even when exact-source provider qualification is reused", async () => { + const { CI_FLAGS } = await import( + new URL("../../../scripts/ci/scopes.mjs", import.meta.url).href + ); + const changes = { + ...Object.fromEntries(CI_FLAGS.map((flag: string) => [flag, "false"])), + tier: "reused", + qualification_run_id: "7", + }; + const outcome = await requiredRun(changes, { failed: "dependency-security" }); + await expect(outcome.result).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("dependency-security"), + }); +}); diff --git a/tests/boundary/filesystem/ciRequiredLanes.test.ts b/tests/boundary/filesystem/ciRequiredLanes.test.ts index 30fb9e7644..2050931c50 100644 --- a/tests/boundary/filesystem/ciRequiredLanes.test.ts +++ b/tests/boundary/filesystem/ciRequiredLanes.test.ts @@ -155,6 +155,7 @@ it("holds native and provider fan-out behind static validation while portable fe const { jobs } = await workflow(); const earlyJobs = new Set([ "changes", + "dependency-security", "static", "static-deep", "build", @@ -514,6 +515,7 @@ it.each([ async (selected, result, passes) => { const workflow = await requiredNeeds(); for (const [scope, lane] of [ + ["dependency_security", "dependency-security"], ["sqlite", "sqlite-runtime"], ["workflows", "static"], ["ghidra_bridge", "ghidra_bridge"], diff --git a/tests/boundary/filesystem/dependencySecurity.test.ts b/tests/boundary/filesystem/dependencySecurity.test.ts new file mode 100644 index 0000000000..8670f8fed6 --- /dev/null +++ b/tests/boundary/filesystem/dependencySecurity.test.ts @@ -0,0 +1,470 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { + advisoryFacts, + dependencyGraph as resolvedGraph, + registryIdentity, + signatureFacts, +} from "../../../scripts/lib/dependency-security-facts.mjs"; +import { + checkDependencySecurity, + SECURITY_OUTPUT_BYTES, +} from "../../../scripts/lib/dependency-security.mjs"; +import type { NpmObservation } from "../../../scripts/lib/dependency-security.mjs"; +import { lockedPackages } from "../../../scripts/lib/npm-locked-packages.mjs"; +import { redactTransportAuthentication } from "../../../scripts/lib/transport-authentication.mjs"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const sri = `sha512-${Buffer.alloc(64, 1).toString("base64")}`; +const registry = "https://registry.npmjs.org"; +const clean = { + metadata: { + vulnerabilities: { + info: 0, + low: 0, + moderate: 0, + high: 0, + critical: 0, + total: 0, + }, + }, +}; +const observation = (value: unknown, exitCode = 0): NpmObservation => ({ + stdout: JSON.stringify(value), + stderr: "", + exitCode, + elapsedMs: 1, + failure: null, +}); + +it.each([ + null, + {}, + { + metadata: { + vulnerabilities: { ...clean.metadata.vulnerabilities, total: 1 }, + }, + }, + { + metadata: { + vulnerabilities: { ...clean.metadata.vulnerabilities, info: -1 }, + }, + }, + { ...clean, error: {} }, +])( + "keeps missing or contradictory advisory evidence unavailable: %j", + (value) => { + expect(advisoryFacts(value, 0)).toMatchObject({ + status: "unavailable", + affected_packages: null, + }); + }, +); +it("distinguishes known vulnerabilities from command failure and never invents signature counts", () => { + expect(advisoryFacts(clean, 0)).toMatchObject({ + status: "passed", + affected_packages: 0, + }); + expect(advisoryFacts(clean, 1)).toMatchObject({ status: "unavailable" }); + expect( + advisoryFacts( + { + metadata: { + vulnerabilities: { + ...clean.metadata.vulnerabilities, + high: 1, + total: 1, + }, + }, + }, + 1, + ), + ).toMatchObject({ status: "failed", affected_packages: 1 }); + expect(signatureFacts({ invalid: [], missing: [] }, 0)).toEqual({ + status: "passed", + reason: null, + invalid: 0, + missing: 0, + verified: null, + }); + expect(signatureFacts({ invalid: [], missing: [] }, 1).status).toBe( + "unavailable", + ); + expect(signatureFacts({}, 0).status).toBe("unavailable"); + expect(signatureFacts({ invalid: [{}], missing: [] }, 1).status).toBe( + "failed", + ); + expect(signatureFacts({ invalid: [], missing: [{}] }, 1).status).toBe( + "failed", + ); +}); +it("accepts only supported registry identities and redacts only transport authentication", () => { + expect(registryIdentity(`${registry}/a/-/a-1.0.0.tgz`, sri)).toBe(true); + for (const url of [ + "http://registry.npmjs.org/a", + "https://other.example/a", + "https://user:pass@registry.npmjs.org/a", + "file:///a", + ]) + expect(registryIdentity(url, sri)).toBe(false); + expect(registryIdentity(`${registry}/a`, "sha1-legacy")).toBe(false); + const text = + "name=secret token=caller-value /tmp/secret https://user:p%40ss@EXAMPLE.com/a?q=token#keep"; + expect(redactTransportAuthentication(text)).toBe( + "name=secret token=caller-value /tmp/secret https://EXAMPLE.com/a?q=token#keep", + ); + expect(() => + lockedPackages({ + lockfileVersion: 3, + packages: { "node_modules/../a": {} }, + }), + ).toThrow("path"); +}); + +const packageNode = (directory: string, name: string, version = "1.0.0") => ({ + name, + version, + path: join(directory, "node_modules", name), + integrity: sri, + resolved: `${registry}/${name}/-/${name}-${version}.tgz`, +}); +const dependencyGraph = (value: unknown, directory: string) => + resolvedGraph( + value, + directory, + lockedPackages({ + lockfileVersion: 3, + packages: Object.fromEntries( + ["a", "b"].map((name) => [ + `node_modules/${name}`, + packageNode(directory, name), + ]), + ), + }), + ); +it("keeps npm deduplication edges, optional omissions and stable content identities across workspaces", () => { + const make = (directory: string) => { + const a = packageNode(directory, "a"); + return dependencyGraph( + { + name: "rea-agents", + version: "6.7.0", + path: directory, + dependencies: { + a, + b: { + ...packageNode(directory, "b"), + peerDependenciesMeta: { optional: { optional: true } }, + dependencies: { a, optional: {} }, + }, + }, + }, + directory, + ); + }; + const first = make("/tmp/first"), + second = make("/tmp/second"); + expect(first.nodes).toHaveLength(3); + expect(first.edges).toContainEqual({ + from: "node_modules/b", + name: "a", + to: "node_modules/a", + }); + expect(first.omissions).toEqual([ + { + from: "node_modules/b", + name: "optional", + reason: "optional_not_reported", + }, + ]); + expect(first.fingerprint).toBe(second.fingerprint); + expect(first.fingerprint).toMatch(/^[a-f0-9]{64}$/u); +}); +it.each([ + {}, + { name: "a", version: "1.0.0", path: "/outside", integrity: sri }, +])("preserves unresolved installed identities as unknown: %j", (child) => { + const graph = dependencyGraph( + { + name: "rea-agents", + version: "6.7.0", + path: "/tmp/project", + dependencies: { a: child }, + }, + "/tmp/project", + ); + expect(graph.unknowns).toHaveLength(1); + expect(graph.fingerprint).toBeNull(); +}); +it("rejects conflicting identities rather than choosing the last deduplicated record", () => { + const directory = "/tmp/project", + a = packageNode(directory, "a"); + const graph = dependencyGraph( + { + name: "rea-agents", + version: "6.7.0", + path: directory, + dependencies: { + a, + b: { + ...packageNode(directory, "b"), + dependencies: { a: { ...a, version: "2.0.0" } }, + }, + }, + }, + directory, + ); + expect(graph.unknowns).toContainEqual({ + id: "node_modules/a", + reason: "conflicting_package_identity", + }); + expect(graph.fingerprint).toBeNull(); +}); + +async function scanFixture( + options: { + consumer?: boolean; + changedVersion?: boolean; + changedIntegrity?: boolean; + mirror?: boolean; + tarball?: boolean; + } = {}, +) { + const root = await createTestTempDirectory("rea-security-evidence-"); + const directory = join(root, "project"); + await mkdir(directory); + const reference = { + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/a": { + name: "a", + version: "1.0.0", + resolved: `${registry}/a/-/a-1.0.0.tgz`, + integrity: sri, + }, + }, + }; + const referenceLockfile = join(root, "reference.json"); + await writeFile(referenceLockfile, JSON.stringify(reference)); + await writeFile( + join(directory, "package-lock.json"), + JSON.stringify(reference), + ); + await writeFile( + join(directory, "package.json"), + JSON.stringify({ name: "rea-agents", version: "6.7.0" }), + ); + const a = packageNode( + directory, + "a", + options.changedVersion ? "2.0.0" : "1.0.0", + ); + if (options.changedIntegrity) + a.integrity = `sha512-${Buffer.alloc(64, 2).toString("base64")}`; + if (options.mirror) a.resolved = "https://mirror.example/a.tgz"; + const dependencies: Record = { a }; + let qualifiedTarball: string | undefined; + if (options.consumer) { + const product = packageNode(directory, "rea-agents", "6.7.0"); + await mkdir(product.path, { recursive: true }); + await writeFile( + join(product.path, "package.json"), + JSON.stringify({ name: "rea-agents", version: "6.7.0" }), + ); + if (options.tarball) { + qualifiedTarball = join(root, "package.tgz"); + const bytes = Buffer.from("qualified archive bytes"); + await writeFile(qualifiedTarball, bytes); + product.integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + // npm retains a file specifier relative to the consumer lock directory. + product.resolved = "file:../package.tgz"; + } + dependencies["rea-agents"] = product; + } + await writeFile( + join(directory, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: Object.fromEntries( + Object.entries(dependencies).map(([name, record]) => [ + `node_modules/${name}`, + record, + ]), + ), + }), + ); + const results = new Map([ + [ + "npm-version", + { + stdout: "11.16.0", + stderr: "", + exitCode: 0, + elapsedMs: 1, + failure: null, + } satisfies NpmObservation, + ], + ["audit", observation(clean)], + ["signatures", observation({ invalid: [], missing: [] })], + [ + "tree", + observation({ + name: "rea-agents", + version: "6.7.0", + path: directory, + dependencies, + }), + ], + ]); + const calls: string[][] = []; + const run = async (args: string[]) => { + calls.push(args); + const name = + args[0] === "--version" + ? "npm-version" + : args[0] === "ls" + ? "tree" + : args[1] === "signatures" + ? "signatures" + : "audit"; + const value = results.get(name); + if (!value) throw new Error(`Unexpected command ${name}`); + return { ...value }; + }; + const scan = () => + checkDependencySecurity( + { + directory, + referenceLockfile, + evidenceDirectory: join(root, "evidence"), + mode: options.consumer ? "consumer" : "source", + expectedVersion: "6.7.0", + ...(qualifiedTarball === undefined ? {} : { qualifiedTarball }), + environment: { REA_TEST_AMBIENT_MARKER: "do-not-persist" }, + }, + run, + ); + return { scan, results, calls, qualifiedTarball }; +} +it("retains full evidence with compact facts, fresh unique runs, hashes and no ambient environment", async () => { + const fixture = await scanFixture(); + const first = await fixture.scan(), + second = await fixture.scan(); + expect(first.checks_passed).toBe(true); + expect(first.signatures.verified).toBeNull(); + expect(first.evidence_directory).not.toBe(second.evidence_directory); + expect(fixture.calls).toHaveLength(8); + for (const args of fixture.calls) { + expect(args).toContain("--ignore-scripts"); + expect(args).toContain("--registry=https://registry.npmjs.org"); + expect(args).toContain("--maxsockets=2"); + } + const report = await readFile( + join(first.evidence_directory, "report.json"), + "utf8", + ); + expect(report).not.toContain("do-not-persist"); + expect(report).toContain("stdout_sha256"); + expect( + await readFile(join(first.evidence_directory, "audit.json"), "utf8"), + ).toBe(JSON.stringify(clean)); +}); +it.each(["audit", "signatures", "tree"])( + "fails closed for missing %s evidence without overwriting an earlier pass", + async (name) => { + const fixture = await scanFixture(); + const prior = await fixture.scan(); + fixture.results.set(name, { + stdout: "not JSON", + stderr: "network unavailable", + exitCode: 1, + elapsedMs: 1, + failure: "1", + }); + const report = await fixture.scan(); + expect(prior.checks_passed).toBe(true); + expect(report.checks_passed).toBe(false); + expect(report.evidence_directory).not.toBe(prior.evidence_directory); + }, +); +it("does not expose a complete tree fingerprint after a failed npm tree command", async () => { + const fixture = await scanFixture(), + tree = fixture.results.get("tree"); + if (!tree) throw new Error("Missing fixture tree"); + tree.exitCode = 1; + const report = await fixture.scan(); + expect(report.tree).toMatchObject({ + status: "unavailable", + content_graph_sha256: null, + }); + expect(report.checks_passed).toBe(false); +}); +it("bounds retained command output and marks overflow unavailable", async () => { + const fixture = await scanFixture(); + fixture.results.set("audit", { + stdout: "x".repeat(SECURITY_OUTPUT_BYTES + 1), + stderr: "", + exitCode: 0, + elapsedMs: 1, + failure: null, + }); + const report = await fixture.scan(); + expect(report.checks_passed).toBe(false); + expect(report.advisory.status).toBe("unavailable"); + expect( + (await readFile(join(report.evidence_directory, "audit.json"))).length, + ).toBe(SECURITY_OUTPUT_BYTES); +}); +it("records floating consumer versions while rejecting changed bytes and unsupported registries", async () => { + const floating = await ( + await scanFixture({ consumer: true, changedVersion: true }) + ).scan(); + expect(floating.checks_passed).toBe(true); + expect(floating.tree.versions_outside_source_lock).toBe(1); + expect( + (await (await scanFixture({ changedVersion: true })).scan()).checks_passed, + ).toBe(false); + expect( + ( + await ( + await scanFixture({ consumer: true, changedIntegrity: true }) + ).scan() + ).checks_passed, + ).toBe(false); + expect((await (await scanFixture({ mirror: true })).scan()).tree.status).toBe( + "unavailable", + ); +}); +it("binds the local consumer exception to the selected archive's SHA-512 bytes", async () => { + const fixture = await scanFixture({ consumer: true, tarball: true }); + expect((await fixture.scan()).checks_passed).toBe(true); + if (!fixture.qualifiedTarball) throw new Error("Missing fixture tarball"); + await writeFile(fixture.qualifiedTarball, "changed bytes"); + expect((await fixture.scan()).checks_passed).toBe(false); +}); + +it("uses the selected lock for declared integrity when npm reads uncached package metadata", () => { + const directory = "/tmp/project"; + const complete = packageNode(directory, "a"); + const { integrity: _reported, resolved: _origin, ...uncached } = complete; + const root = (child: unknown) => ({ + name: "rea-agents", + version: "6.7.0", + path: directory, + dependencies: { a: child }, + }); + const fromCache = dependencyGraph(root(complete), directory); + const fromPackages = dependencyGraph(root(uncached), directory); + expect(fromPackages.unknowns).toEqual([]); + expect(fromPackages.fingerprint).toBe(fromCache.fingerprint); + expect(fromPackages.nodes.find((node) => node.name === "a")?.integrity).toBe( + sri, + ); + expect( + dependencyGraph(root({ ...uncached, version: "9.0.0" }), directory) + .fingerprint, + ).toBeNull(); +}); diff --git a/tests/boundary/filesystem/dependencySecurityWorkflow.test.ts b/tests/boundary/filesystem/dependencySecurityWorkflow.test.ts new file mode 100644 index 0000000000..0903dc32d9 --- /dev/null +++ b/tests/boundary/filesystem/dependencySecurityWorkflow.test.ts @@ -0,0 +1,148 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; +import { parse } from "yaml"; +import { z } from "zod"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); +const step = z + .object({ + run: z.string().optional(), + if: z.string().optional(), + uses: z.string().optional(), + with: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); +const readWorkflow = async (name: string) => + z + .object({ + jobs: z.record( + z.string(), + z + .object({ + needs: z.union([z.string(), z.array(z.string())]).optional(), + if: z.string().optional(), + permissions: z.record(z.string(), z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + steps: z.array(step).optional(), + }) + .passthrough(), + ), + }) + .parse(parse(await readFile(`.github/workflows/${name}.yml`, "utf8"))); + +it("requires fresh read-only registry checks with bounded installation and retained evidence", async () => { + const { jobs } = await readWorkflow("ci"); + const job = jobs["dependency-security"]; + expect(job?.needs).toBe("changes"); + expect(job?.if).toBe( + "needs.changes.outputs.dependency_security == 'true' || needs.changes.outputs.tier == 'reused'", + ); + expect(job?.permissions).toEqual({ contents: "read" }); + expect(job?.env).toMatchObject({ + TURBO_TOKEN: "", + TURBO_CACHE: "local:rw", + NODE_OPTIONS: "--max-old-space-size=1024", + UV_THREADPOOL_SIZE: "1", + }); + expect(job?.["timeout-minutes"]).toBe(15); + const steps = job?.steps ?? []; + const preflight = steps.findIndex((entry) => + entry.run?.includes("--mode=preflight"), + ); + const install = steps.findIndex((entry) => entry.run?.startsWith("npm ci ")); + const source = steps.findIndex((entry) => + entry.run?.includes("--mode=source"), + ); + expect(preflight).toBeGreaterThanOrEqual(0); + expect(install).toBeGreaterThan(preflight); + expect(steps[install]?.run).toContain("--ignore-scripts"); + expect(steps[install]?.run).toContain("--maxsockets=2"); + expect(source).toBeGreaterThan(install); + expect( + steps.find((entry) => entry.run?.includes("--mode=published"))?.if, + ).toBe( + "github.event_name == 'schedule' && github.event.schedule == '23 3 * * *'", + ); + const evidence = steps.find((entry) => + entry.uses?.startsWith("actions/upload-artifact@"), + ); + expect(evidence?.if).toBe("always()"); + expect(evidence?.with?.path).toBe(".cache/dependency-security/"); +}); +it.each([ + ["release", "rehearsal"], + ["release", "verify-npm"], + ["release-recovery", "verify"], +])( + "retains live consumer checks and failure evidence in %s/%s", + async (workflow, name) => { + const job = (await readWorkflow(workflow)).jobs[name]; + const canaries = + job?.steps?.filter((entry) => + /verify-(?:release-artifact|published-package)\.mjs/u.test( + entry.run ?? "", + ), + ) ?? []; + expect(canaries.length).toBeGreaterThan(0); + for (const canary of canaries) { + expect(canary.run).toContain( + "--dependency-security=.cache/dependency-security", + ); + expect(canary.if).toBeUndefined(); + expect(canary["continue-on-error"]).toBeUndefined(); + } + expect( + job?.steps?.find( + (entry) => entry.with?.path === ".cache/dependency-security/", + )?.if, + ).toBe("always()"); + }, +); + +it("validates lock identities before npm installation without importing installed dependencies", async () => { + const directory = await createTestTempDirectory( + "rea-security-no-dependencies-", + ); + for (const file of [ + "scripts/ci/dependency-security.mjs", + "scripts/ci/qualified-package-envelope.mjs", + "scripts/lib/dependency-security.mjs", + "scripts/lib/dependency-security-facts.mjs", + "scripts/lib/npm-command.mjs", + "scripts/lib/npm-locked-packages.mjs", + "scripts/lib/transport-authentication.mjs", + "scripts/lib/release-package-install.mjs", + ]) { + await mkdir(dirname(join(directory, file)), { recursive: true }); + await copyFile(file, join(directory, file)); + } + const lock = { + lockfileVersion: 3, + packages: { + "node_modules/a": { + version: "1.0.0", + resolved: "https://registry.npmjs.org/a/-/a-1.0.0.tgz", + integrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + }, + }, + }; + const lockfile = join(directory, "package-lock.json"); + await writeFile(lockfile, JSON.stringify(lock)); + const run = () => + execute( + process.execPath, + ["scripts/ci/dependency-security.mjs", "--mode=preflight"], + { cwd: directory }, + ); + expect((await run()).stdout).toContain("supported npm registry"); + lock.packages["node_modules/a"].resolved = "https://other.example/a.tgz"; + await writeFile(lockfile, JSON.stringify(lock)); + await expect(run()).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("Unsupported registry"), + }); +}); diff --git a/tests/boundary/filesystem/releaseRecovery.test.ts b/tests/boundary/filesystem/releaseRecovery.test.ts index 08e483e0e1..dfd5834e77 100644 --- a/tests/boundary/filesystem/releaseRecovery.test.ts +++ b/tests/boundary/filesystem/releaseRecovery.test.ts @@ -238,6 +238,10 @@ it("limits recovery to verification and dependent MCP publication from the relea expect(digest).toBeGreaterThanOrEqual(0); expect(rehearsal).toBeGreaterThan(digest); expect(canary).toBeGreaterThan(rehearsal); + for (const index of [rehearsal, canary]) + expect(steps[index]?.run).toContain( + "--dependency-security=.cache/dependency-security", + ); const publisher = workflow.jobs["publish-mcp"]; expect(publisher.needs).toBe("verify"); expect( diff --git a/tests/boundary/filesystem/releaseWorkflow.test.ts b/tests/boundary/filesystem/releaseWorkflow.test.ts index db841e74e0..cc59ba4b59 100644 --- a/tests/boundary/filesystem/releaseWorkflow.test.ts +++ b/tests/boundary/filesystem/releaseWorkflow.test.ts @@ -696,6 +696,9 @@ it("rehearses the qualified archive before npm authority and retains registry ve VERIFIED_PACKAGE_TARBALL: "${{ steps.package.outputs.tarball }}", VERIFIED_PACKAGE_VERSION: "${{ steps.package.outputs.version }}", }); + expect(exercise?.run).toContain( + "--dependency-security=.cache/dependency-security", + ); expect(exercise?.if).toBeUndefined(); expect(exercise?.["continue-on-error"]).toBeUndefined(); const publish = workflow.jobs.publish; @@ -785,6 +788,9 @@ it("rehearses the qualified archive before npm authority and retains registry ve ); expect(bytes).toBeGreaterThanOrEqual(0); expect(canary).toBeGreaterThan(bytes); + expect(commands[canary]).toContain( + "--dependency-security=.cache/dependency-security", + ); for (const index of [bytes, canary]) { expect(verification.steps[index]?.if).toBeUndefined(); expect(verification.steps[index]?.["continue-on-error"]).toBeUndefined(); diff --git a/tests/boundary/filesystem/thirdPartyInventory.test.ts b/tests/boundary/filesystem/thirdPartyInventory.test.ts index 7e61f883b2..04ff74b967 100644 --- a/tests/boundary/filesystem/thirdPartyInventory.test.ts +++ b/tests/boundary/filesystem/thirdPartyInventory.test.ts @@ -16,6 +16,8 @@ test("generates locked inventory and rejects stale notice metadata or preserved for (const file of [ "scripts/generate-third-party-inventory.mjs", "scripts/lib/generated-file.mjs", + "scripts/lib/npm-locked-packages.mjs", + "scripts/lib/transport-authentication.mjs", ]) await copyFile(resolve(file), resolve(root, file)); const script = resolve(root, "scripts/generate-third-party-inventory.mjs"); @@ -30,6 +32,7 @@ test("generates locked inventory and rejects stale notice metadata or preserved await writeFile( resolve(root, "package-lock.json"), JSON.stringify({ + lockfileVersion: 3, packages: { "": { name: "fixture", version: "1.0.0" }, "node_modules/fixture-runtime": runtime, diff --git a/tests/boundary/process/releaseArtifactCanary.test.ts b/tests/boundary/process/releaseArtifactCanary.test.ts index 690e58a120..6eb486bf8a 100644 --- a/tests/boundary/process/releaseArtifactCanary.test.ts +++ b/tests/boundary/process/releaseArtifactCanary.test.ts @@ -40,6 +40,14 @@ it.skipIf(process.platform === "win32")( } finally { await harness.close(); } + // The replay calls only binary_session. Preserve its real result contract + // and every tool name/input contract, without compiling unused result + // schemas again in each replay client. Real-package canaries keep all schemas. + snapshot.tools.tools = snapshot.tools.tools.map((tool) => { + if (tool.name === "binary_session") return tool; + const { outputSchema: _unused, ...replayed } = tool; + return replayed; + }); await mkdir(join(candidate, "scripts"), { recursive: true }); await writeFile( join(candidate, "package.json"), @@ -84,6 +92,8 @@ if (process.argv[2] === "mcp") { for (const file of [ "scripts/verify-release-artifact.mjs", "scripts/lib/release-package-canary.mjs", + "scripts/lib/release-package-install.mjs", + "scripts/lib/npm-command.mjs", "scripts/lib/mcp-verifier-results.mjs", "scripts/lib/verifier-run.mjs", ]) {