Files
rea/tests/boundary/filesystem/dependencySecurityWorkflow.test.ts

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();
});