From 473bcdd20476f25596bfdad9e9cd6c80c70a41bc Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:28:24 +0000 Subject: [PATCH] fix(ci): bind dependency evidence to original release inputs --- .github/workflows/ci.yml | 2 +- .github/workflows/release-recovery.yml | 8 ++- .github/workflows/release.yml | 4 +- docs/releasing.md | 4 +- scripts/ci/dependency-security.mjs | 4 +- scripts/generate-third-party-inventory.mjs | 6 +- scripts/lib/npm-locked-packages.mjs | 3 - scripts/lib/release-package-install.d.mts | 26 +++++++ scripts/lib/release-package-install.mjs | 66 ++++++++++++++++- scripts/verify-published-package.mjs | 40 +++++------ scripts/verify-release-artifact.mjs | 40 +++++------ .../dependencySecurityWorkflow.test.ts | 70 ++++++++++++++++++- .../filesystem/thirdPartyInventory.test.ts | 17 +++++ 13 files changed, 228 insertions(+), 62 deletions(-) create mode 100644 scripts/lib/release-package-install.d.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6004b2359..3c1cba9698 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1126,7 +1126,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: dependency-security + name: dependency-security-${{ github.run_attempt }} path: .cache/dependency-security/ if-no-files-found: warn retention-days: 14 diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml index 4efa449bd8..44d64931e6 100644 --- a/.github/workflows/release-recovery.yml +++ b/.github/workflows/release-recovery.yml @@ -77,11 +77,13 @@ 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" --dependency-security=.cache/dependency-security + RECOVERY_SOURCE_SHA: ${{ steps.release.outputs.source_sha }} + run: node scripts/verify-release-artifact.mjs .cache/verified-package/package.tgz "$RECOVERY_VERSION" --dependency-security=.cache/dependency-security --dependency-source=.cache/release-source --dependency-source-sha="$RECOVERY_SOURCE_SHA" - name: Verify published CLI, MCP and update env: RECOVERY_VERSION: ${{ steps.release.outputs.version }} - run: node scripts/verify-published-package.mjs "$RECOVERY_VERSION" --dependency-security=.cache/dependency-security + RECOVERY_SOURCE_SHA: ${{ steps.release.outputs.source_sha }} + run: node scripts/verify-published-package.mjs "$RECOVERY_VERSION" --dependency-security=.cache/dependency-security --dependency-source=.cache/release-source --dependency-source-sha="$RECOVERY_SOURCE_SHA" - name: Validate released registry metadata working-directory: .cache/release-source run: npm run mcp-registry:check @@ -90,7 +92,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: recovery-consumer-security + name: recovery-consumer-security-${{ github.run_attempt }} path: .cache/dependency-security/ if-no-files-found: warn retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 909bc40f2e..acff75ee30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -348,7 +348,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: qualified-consumer-security + name: qualified-consumer-security-${{ github.run_attempt }} path: .cache/dependency-security/ if-no-files-found: warn retention-days: 14 @@ -430,7 +430,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: published-consumer-security + name: published-consumer-security-${{ github.run_attempt }} path: .cache/dependency-security/ if-no-files-found: warn retention-days: 14 diff --git a/docs/releasing.md b/docs/releasing.md index 2c4a406430..5d67edf258 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -41,7 +41,9 @@ 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 +the source lock. Recovery supplies the original released source directory and +SHA independently of the corrected controller checkout. +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 diff --git a/scripts/ci/dependency-security.mjs b/scripts/ci/dependency-security.mjs index a6064ad0ed..0675b07ebe 100644 --- a/scripts/ci/dependency-security.mjs +++ b/scripts/ci/dependency-security.mjs @@ -62,13 +62,13 @@ if (values.mode === "preflight") { npm_config_maxsockets: "2", npm_config_prefer_online: "true", }; - await installCanaryPackage( + const installation = await installCanaryPackage( `rea-agents@${product.version}`, workspace, environment, ); report = await checkDependencySecurity({ - directory: join(workspace, "installed"), + directory: installation.directory, referenceLockfile, evidenceDirectory: values.evidence, mode: "consumer", diff --git a/scripts/generate-third-party-inventory.mjs b/scripts/generate-third-party-inventory.mjs index 2088f21e85..5944850f2b 100644 --- a/scripts/generate-third-party-inventory.mjs +++ b/scripts/generate-third-party-inventory.mjs @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { lockedPackages } from "./lib/npm-locked-packages.mjs"; +import { redactTransportAuthentication } from "./lib/transport-authentication.mjs"; import { ensureGeneratedFile } from "./lib/generated-file.mjs"; const arguments_ = new Set(process.argv.slice(2)); @@ -14,7 +15,10 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const lock = JSON.parse( await readFile(join(root, "package-lock.json"), "utf8"), ); -const entries = lockedPackages(lock); +const entries = lockedPackages(lock).map((entry) => ({ + ...entry, + resolved: redactTransportAuthentication(entry.resolved), +})); const runtime = entries.filter(({ dev }) => !dev); const runtimeFingerprint = createHash("sha256") .update(JSON.stringify(runtime)) diff --git a/scripts/lib/npm-locked-packages.mjs b/scripts/lib/npm-locked-packages.mjs index 85ce730eb4..9888d6288f 100644 --- a/scripts/lib/npm-locked-packages.mjs +++ b/scripts/lib/npm-locked-packages.mjs @@ -1,5 +1,3 @@ -import { redactTransportAuthentication } from "./transport-authentication.mjs"; - /** Parse the root npm lock inventory once for notices and security evidence. */ export function lockedPackages(lock) { if ( @@ -37,7 +35,6 @@ export function lockedPackages(lock) { 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-install.d.mts b/scripts/lib/release-package-install.d.mts new file mode 100644 index 0000000000..624c795cc3 --- /dev/null +++ b/scripts/lib/release-package-install.d.mts @@ -0,0 +1,26 @@ +export interface ConsumerSecurityContext { + evidenceDirectory: string; + referenceLockfile: string; + sourceSha: string | null; +} +export const consumerSecurityOptions: Record< + "dependency-security" | "dependency-source" | "dependency-source-sha", + { type: "string" } +>; +export function consumerSecurityContext( + values: Record, + directory?: string, + checkoutSha?: string, +): ConsumerSecurityContext | null; +export function installCanaryPackage( + specifier: string, + workspace: string, + environment: NodeJS.ProcessEnv, +): Promise<{ directory: string; packageRoot: string }>; +export function verifyCanaryDependencies( + installation: { directory: string }, + context: ConsumerSecurityContext | null, + version: string, + environment: NodeJS.ProcessEnv, + qualifiedTarball?: string | null, +): Promise; diff --git a/scripts/lib/release-package-install.mjs b/scripts/lib/release-package-install.mjs index c71c4cb6b8..e542a5ab6c 100644 --- a/scripts/lib/release-package-install.mjs +++ b/scripts/lib/release-package-install.mjs @@ -1,5 +1,5 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { execNpm } from "./npm-command.mjs"; /** Install the selected archive or registry specifier before any protocol deadline. */ @@ -29,5 +29,67 @@ export async function installCanaryPackage(specifier, workspace, environment) { ], { cwd: workspace, env: environment, maxBuffer: 16 * 1024 * 1024 }, ); - return join(prefix, "node_modules", "rea-agents"); + return { + directory: prefix, + packageRoot: join(prefix, "node_modules", "rea-agents"), + }; +} + +export const consumerSecurityOptions = { + "dependency-security": { type: "string" }, + "dependency-source": { type: "string" }, + "dependency-source-sha": { type: "string" }, +}; + +/** The source that owns a release may differ from the controller's checkout. */ +export function consumerSecurityContext( + values, + directory = process.cwd(), + checkoutSha = process.env.GITHUB_SHA, +) { + const evidenceDirectory = values["dependency-security"]; + const selectedSource = values["dependency-source"], + selectedSha = values["dependency-source-sha"]; + if ( + (selectedSource === undefined) !== (selectedSha === undefined) || + (selectedSource !== undefined && evidenceDirectory === undefined) + ) + throw new Error( + "Dependency source overrides require both source directory and SHA with security evidence enabled", + ); + if (evidenceDirectory === undefined) return null; + const sourceSha = selectedSha ?? checkoutSha ?? null; + if (sourceSha !== null && !/^[a-f0-9]{40}$/u.test(sourceSha)) + throw new Error("Dependency source SHA must be a full commit identity"); + return { + evidenceDirectory, + referenceLockfile: join( + resolve(directory, selectedSource ?? "."), + "package-lock.json", + ), + sourceSha, + }; +} + +export async function verifyCanaryDependencies( + installation, + context, + version, + environment, + qualifiedTarball = null, +) { + if (context === null) return; + const { checkDependencySecurity } = await import("./dependency-security.mjs"); + const report = await checkDependencySecurity({ + ...context, + directory: installation.directory, + mode: "consumer", + expectedVersion: version, + environment, + qualifiedTarball, + }); + if (!report.checks_passed) + throw new Error( + `Consumer dependency checks failed: ${report.evidence_directory}`, + ); } diff --git a/scripts/verify-published-package.mjs b/scripts/verify-published-package.mjs index 2be36085de..ba930b1849 100644 --- a/scripts/verify-published-package.mjs +++ b/scripts/verify-published-package.mjs @@ -1,8 +1,13 @@ -import { installCanaryPackage } from "./lib/release-package-install.mjs"; +import { + installCanaryPackage, + consumerSecurityOptions, + consumerSecurityContext, + verifyCanaryDependencies, +} 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, resolve } from "node:path"; +import { delimiter, join } from "node:path"; import { parseArgs, promisify } from "node:util"; import { pathToFileURL } from "node:url"; @@ -16,11 +21,10 @@ const verifierRun = createVerifierRun(); const execFileAsync = promisify(execFile); const { values, positionals } = parseArgs({ allowPositionals: true, - options: { - "dependency-security": { type: "string" }, - }, + options: consumerSecurityOptions, }); const [version] = positionals; +const security = consumerSecurityContext(values); if ( typeof version !== "string" || valid(version) !== version || @@ -39,28 +43,18 @@ delete serverEnvironment.HOPPER_TARGET_PATH; let surface; let update; try { - const publishedRoot = await installCanaryPackage( + const installation = await installCanaryPackage( `rea-agents@${version}`, 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}`, - ); - } + const { packageRoot: publishedRoot } = installation; + await verifyCanaryDependencies( + installation, + security, + version, + serverEnvironment, + ); surface = await verifyInstalledCanaryPackage( publishedRoot, version, diff --git a/scripts/verify-release-artifact.mjs b/scripts/verify-release-artifact.mjs index 7a00623208..f0fc590435 100644 --- a/scripts/verify-release-artifact.mjs +++ b/scripts/verify-release-artifact.mjs @@ -1,4 +1,9 @@ -import { installCanaryPackage } from "./lib/release-package-install.mjs"; +import { + installCanaryPackage, + consumerSecurityOptions, + consumerSecurityContext, + verifyCanaryDependencies, +} from "./lib/release-package-install.mjs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -9,11 +14,10 @@ import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; const { values, positionals } = parseArgs({ allowPositionals: true, - options: { - "dependency-security": { type: "string" }, - }, + options: consumerSecurityOptions, }); const [tarball, version, ...extra] = positionals; +const security = consumerSecurityContext(values); if ( tarball === undefined || typeof version !== "string" || @@ -33,29 +37,19 @@ const environment = { delete environment.HOPPER_TARGET_PATH; let result; try { - const packageRoot = await installCanaryPackage( + const installation = await installCanaryPackage( source, 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}`, - ); - } + const { packageRoot: packageRoot } = installation; + await verifyCanaryDependencies( + installation, + security, + version, + environment, + source, + ); result = await verifyInstalledCanaryPackage( packageRoot, version, diff --git a/tests/boundary/filesystem/dependencySecurityWorkflow.test.ts b/tests/boundary/filesystem/dependencySecurityWorkflow.test.ts index 0903dc32d9..f09aca87a9 100644 --- a/tests/boundary/filesystem/dependencySecurityWorkflow.test.ts +++ b/tests/boundary/filesystem/dependencySecurityWorkflow.test.ts @@ -1,10 +1,11 @@ import { execFile } from "node:child_process"; import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { expect, it } from "vitest"; import { parse } from "yaml"; import { z } from "zod"; +import { consumerSecurityContext } from "../../../scripts/lib/release-package-install.mjs"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const execute = promisify(execFile); @@ -72,6 +73,9 @@ it("requires fresh read-only registry checks with bounded installation and retai ); expect(evidence?.if).toBe("always()"); expect(evidence?.with?.path).toBe(".cache/dependency-security/"); + expect(evidence?.with?.name).toBe( + "dependency-security-${{ github.run_attempt }}", + ); }); it.each([ ["release", "rehearsal"], @@ -88,12 +92,29 @@ it.each([ ), ) ?? []; expect(canaries.length).toBeGreaterThan(0); + expect( + job?.steps?.find( + (entry) => entry.with?.path === ".cache/dependency-security/", + )?.with?.name, + ).toEqual(expect.stringContaining("${{ github.run_attempt }}")); for (const canary of canaries) { expect(canary.run).toContain( "--dependency-security=.cache/dependency-security", ); expect(canary.if).toBeUndefined(); expect(canary["continue-on-error"]).toBeUndefined(); + if (workflow === "release-recovery") { + expect(canary.run).toContain( + "--dependency-source=.cache/release-source", + ); + expect(canary.run).toContain( + '--dependency-source-sha="$RECOVERY_SOURCE_SHA"', + ); + expect(canary.env).toEqual({ + RECOVERY_VERSION: "${{ steps.release.outputs.version }}", + RECOVERY_SOURCE_SHA: "${{ steps.release.outputs.source_sha }}", + }); + } } expect( job?.steps?.find( @@ -139,6 +160,13 @@ it("validates lock identities before npm installation without importing installe { cwd: directory }, ); expect((await run()).stdout).toContain("supported npm registry"); + lock.packages["node_modules/a"].resolved = + "https://user:fixture-secret@registry.npmjs.org/a.tgz"; + await writeFile(lockfile, JSON.stringify(lock)); + await expect(run()).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("Unsupported registry"), + }); lock.packages["node_modules/a"].resolved = "https://other.example/a.tgz"; await writeFile(lockfile, JSON.stringify(lock)); await expect(run()).rejects.toMatchObject({ @@ -146,3 +174,43 @@ it("validates lock identities before npm installation without importing installe stderr: expect.stringContaining("Unsupported registry"), }); }); + +it("binds recovery evidence to the original release source rather than the controller checkout", () => { + const controllerSha = "a".repeat(40), + releasedSha = "b".repeat(40); + const controller = resolve("controller-fixture"), + releasedSource = resolve("released-source-fixture"); + const values = { "dependency-security": "evidence" }; + expect(consumerSecurityContext(values, controller, controllerSha)).toEqual({ + evidenceDirectory: "evidence", + referenceLockfile: join(controller, "package-lock.json"), + sourceSha: controllerSha, + }); + expect( + consumerSecurityContext( + { + ...values, + "dependency-source": releasedSource, + "dependency-source-sha": releasedSha, + }, + controller, + controllerSha, + ), + ).toEqual({ + evidenceDirectory: "evidence", + referenceLockfile: join(releasedSource, "package-lock.json"), + sourceSha: releasedSha, + }); + for (const incomplete of [ + { ...values, "dependency-source": releasedSource }, + { ...values, "dependency-source-sha": releasedSha }, + { + "dependency-source": releasedSource, + "dependency-source-sha": releasedSha, + }, + ]) + expect(() => consumerSecurityContext(incomplete)).toThrow("require both"); + expect( + consumerSecurityContext({}, controller, "legacy-unused-sha"), + ).toBeNull(); +}); diff --git a/tests/boundary/filesystem/thirdPartyInventory.test.ts b/tests/boundary/filesystem/thirdPartyInventory.test.ts index 04ff74b967..93dbff3642 100644 --- a/tests/boundary/filesystem/thirdPartyInventory.test.ts +++ b/tests/boundary/filesystem/thirdPartyInventory.test.ts @@ -117,4 +117,21 @@ test("generates locked inventory and rejects stale notice metadata or preserved expect( await readFile(resolve(root, "licenses/npm-dependencies.txt"), "utf8"), ).toBe(inventory); + const locked = JSON.parse( + await readFile(resolve(root, "package-lock.json"), "utf8"), + ) as { + packages: Record; + }; + const selected = locked.packages["node_modules/fixture-runtime"]; + if (!selected) throw new Error("Missing fixture package"); + selected.resolved = runtime.resolved.replace( + "https://", + "https://user:fixture-secret@", + ); + await writeFile(resolve(root, "package-lock.json"), JSON.stringify(locked)); + const redacted = await processes.run(process.execPath, [script]); + expect(redacted.exitCode, redacted.stderr).toBe(0); + expect( + await readFile(resolve(root, "licenses/npm-dependencies.txt"), "utf8"), + ).toBe(inventory); });