mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 23:36:41 +00:00
fix(ci): bind dependency evidence to original release inputs
This commit is contained in:
1 parent
48a1312237
commit
473bcdd204
13 files changed
+228
-62
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>;
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user