fix(ci): bind dependency evidence to original release inputs

This commit is contained in:
gensokyo-dev committed 2026-10-23 13:28:24 +00:00
1 parent 48a1312237
commit 473bcdd204
13 files changed
+228 -62

No files matched your search

+1 -1
View File
@@ -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
+5 -3
View File
@@ -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
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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
+2 -2
View File
@@ -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",
+5 -1
View File
@@ -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))
-3
View File
@@ -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));
+26
View File
@@ -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<string, string | undefined>,
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<void>;
+64 -2
View File
@@ -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}`,
);
}
+17 -23
View File
@@ -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,
+17 -23
View File
@@ -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,
@@ -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();
});
@@ -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<string, { resolved?: string }>;
};
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);
});