mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
217 lines
7.5 KiB
TypeScript
217 lines
7.5 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
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);
|
|
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/");
|
|
expect(evidence?.with?.name).toBe(
|
|
"dependency-security-${{ github.run_attempt }}",
|
|
);
|
|
});
|
|
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);
|
|
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(
|
|
(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://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({
|
|
code: 1,
|
|
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();
|
|
});
|