371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
chmod,
|
|
lstat,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
|
|
import { publishPrivatePromotionStaging } from "../../scripts/lib/promotion-stager.ts";
|
|
import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts";
|
|
import {
|
|
PROCESS_HEAVY_TIMEOUT_MS,
|
|
privatePromotionFiles,
|
|
syntheticSignedPromotionBundle,
|
|
} from "./security-followup-fixture.ts";
|
|
|
|
describe("security private promotion staging contracts", () => {
|
|
it("forces exact private staging modes in an isolated child with umask 077", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-umask-"));
|
|
try {
|
|
const stagerUrl = pathToFileURL(
|
|
path.join(process.cwd(), "scripts/lib/promotion-stager.ts"),
|
|
).href;
|
|
const contractsUrl = pathToFileURL(
|
|
path.join(process.cwd(), "scripts/contracts/promotion-artifacts.ts"),
|
|
).href;
|
|
const childPath = path.join(root, "umask-child.mjs");
|
|
const resultPath = path.join(root, "result.json");
|
|
await writeFile(resultPath, "{}\n", { mode: 0o600 });
|
|
await writeFile(
|
|
childPath,
|
|
[
|
|
`import { lstat, writeFile } from "node:fs/promises";`,
|
|
`import path from "node:path";`,
|
|
`import { createHash } from "node:crypto";`,
|
|
`import { cleanupFinalizedPromotion, publishPrivatePromotionStaging } from ${JSON.stringify(stagerUrl)};`,
|
|
`import { PROMOTED_FILE_NAMES } from ${JSON.stringify(contractsUrl)};`,
|
|
`process.umask(Number.parseInt(process.argv[2], 8));`,
|
|
`const runnerTempRoot = process.argv[3];`,
|
|
`const files = PROMOTED_FILE_NAMES.map((name) => { const bytes = Buffer.from(name); return { name, bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; });`,
|
|
`const finalized = await publishPrivatePromotionStaging(runnerTempRoot, { id: "umask", attempt: 1 }, files, () => Buffer.alloc(16, 1));`,
|
|
`const directoryMode = (await lstat(finalized.stagingRoot)).mode & 0o777;`,
|
|
`const fileModes = await Promise.all(PROMOTED_FILE_NAMES.map(async (name) => (await lstat(path.join(finalized.stagingRoot, name))).mode & 0o777));`,
|
|
`await cleanupFinalizedPromotion({ runnerTempRoot, stagingRoot: finalized.stagingRoot, cleanupToken: finalized.cleanupToken, runnerTempIdentity: finalized.runnerTempIdentity, stagingIdentity: finalized.stagingIdentity });`,
|
|
`await writeFile(process.argv[4], JSON.stringify({ directoryMode, fileModes }));`,
|
|
].join("\n"),
|
|
);
|
|
const child = spawnSync(process.execPath, [childPath, "077", root, resultPath], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
});
|
|
expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0);
|
|
expect(JSON.parse(await readFile(resultPath, "utf8"))).toEqual({
|
|
directoryMode: 0o700,
|
|
fileModes: [0o400, 0o400, 0o400, 0o400, 0o400],
|
|
});
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects a staged file unlinked and recreated after its original write", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-recreate-"));
|
|
const files = privatePromotionFiles();
|
|
const token = `promotion-seal-1-${"11".repeat(16)}`;
|
|
try {
|
|
await expect(
|
|
publishPrivatePromotionStaging(
|
|
root,
|
|
{ id: "seal", attempt: 1 },
|
|
files,
|
|
() => Buffer.alloc(16, 0x11),
|
|
undefined,
|
|
async (name) => {
|
|
if (name !== PROMOTED_FILE_NAMES.at(-1)) return;
|
|
const first = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
|
await rm(first);
|
|
await writeFile(first, "replacement bytes\n", { mode: 0o400 });
|
|
},
|
|
),
|
|
).rejects.toThrow(/staged.*digest|inode|seal/u);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects staged mode drift before returning the upload root", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-mode-"));
|
|
const files = privatePromotionFiles();
|
|
const token = `promotion-seal-1-${"12".repeat(16)}`;
|
|
try {
|
|
await expect(
|
|
publishPrivatePromotionStaging(
|
|
root,
|
|
{ id: "seal", attempt: 1 },
|
|
files,
|
|
() => Buffer.alloc(16, 0x12),
|
|
undefined,
|
|
async (name) => {
|
|
if (name === PROMOTED_FILE_NAMES.at(-1)) {
|
|
await chmod(path.join(root, token, PROMOTED_FILE_NAMES[0]), 0o600);
|
|
}
|
|
},
|
|
),
|
|
).rejects.toThrow(/mode.*0400|staged.*mode|seal/u);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects staging leaf replacement between mkdir and descriptor open", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-replace-"));
|
|
const files = privatePromotionFiles();
|
|
const token = `promotion-preopen-1-${"13".repeat(16)}`;
|
|
const displaced = path.join(root, `${token}-displaced`);
|
|
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
|
try {
|
|
await expect(
|
|
publishPrivatePromotionStaging(
|
|
root,
|
|
{ id: "preopen", attempt: 1 },
|
|
files,
|
|
() => Buffer.alloc(16, 0x13),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
async (stagingRoot) => {
|
|
await rename(stagingRoot, displaced);
|
|
await mkdir(stagingRoot, { mode: 0o700 });
|
|
await writeFile(replacementCanary, "external replacement canary\n");
|
|
},
|
|
),
|
|
).rejects.toThrow(/staging leaf.*changed|mkdir.*open|identity/u);
|
|
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
|
"external replacement canary\n",
|
|
);
|
|
await expect(readdir(displaced)).resolves.toEqual([]);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not scan a crowded parent to recover an unverified pre-open leaf", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-bounded-"));
|
|
const files = privatePromotionFiles();
|
|
const token = `promotion-preopen-bound-1-${"14".repeat(16)}`;
|
|
const displaced = path.join(root, `${token}-displaced`);
|
|
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
|
try {
|
|
for (let offset = 0; offset < 4_097; offset += 128) {
|
|
await Promise.all(
|
|
Array.from(
|
|
{ length: Math.min(128, 4_097 - offset) },
|
|
(_, index) =>
|
|
mkdir(
|
|
path.join(
|
|
root,
|
|
`noise-${String(offset + index).padStart(4, "0")}`,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
let failure: unknown;
|
|
try {
|
|
await publishPrivatePromotionStaging(
|
|
root,
|
|
{ id: "preopen-bound", attempt: 1 },
|
|
files,
|
|
() => Buffer.alloc(16, 0x14),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
async (stagingRoot) => {
|
|
await rename(stagingRoot, displaced);
|
|
await mkdir(stagingRoot, { mode: 0o700 });
|
|
await writeFile(replacementCanary, "external replacement canary\n");
|
|
},
|
|
);
|
|
} catch (error) {
|
|
failure = error;
|
|
}
|
|
expect(failure).toBeInstanceOf(Error);
|
|
expect(failure).not.toBeInstanceOf(AggregateError);
|
|
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
|
|
await expect(readdir(displaced)).resolves.toEqual([]);
|
|
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
|
"external replacement canary\n",
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
}, 20_000);
|
|
|
|
it("leaves a non-empty moved original untouched after pre-open mismatch", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-nonempty-"));
|
|
const files = privatePromotionFiles();
|
|
const token = `promotion-preopen-nonempty-1-${"15".repeat(16)}`;
|
|
const displaced = path.join(root, `${token}-displaced`);
|
|
const ownedResidual = path.join(displaced, "owned-residual");
|
|
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
|
try {
|
|
let failure: unknown;
|
|
try {
|
|
await publishPrivatePromotionStaging(
|
|
root,
|
|
{ id: "preopen-nonempty", attempt: 1 },
|
|
files,
|
|
() => Buffer.alloc(16, 0x15),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
async (stagingRoot) => {
|
|
await rename(stagingRoot, displaced);
|
|
await writeFile(ownedResidual, "owned residual\n");
|
|
await mkdir(stagingRoot, { mode: 0o700 });
|
|
await writeFile(replacementCanary, "external replacement canary\n");
|
|
},
|
|
);
|
|
} catch (error) {
|
|
failure = error;
|
|
}
|
|
expect(failure).toBeInstanceOf(Error);
|
|
expect(failure).not.toBeInstanceOf(AggregateError);
|
|
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
|
|
await expect(readFile(ownedResidual, "utf8")).resolves.toBe(
|
|
"owned residual\n",
|
|
);
|
|
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
|
"external replacement canary\n",
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not search outside the parent for a moved unverified original", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-missing-"));
|
|
const outside = await mkdtemp(path.join(tmpdir(), "promotion-preopen-moved-"));
|
|
const files = privatePromotionFiles();
|
|
const token = `promotion-preopen-missing-1-${"16".repeat(16)}`;
|
|
const displaced = path.join(outside, token);
|
|
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
|
try {
|
|
let failure: unknown;
|
|
try {
|
|
await publishPrivatePromotionStaging(
|
|
root,
|
|
{ id: "preopen-missing", attempt: 1 },
|
|
files,
|
|
() => Buffer.alloc(16, 0x16),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
async (stagingRoot) => {
|
|
await rename(stagingRoot, displaced);
|
|
await mkdir(stagingRoot, { mode: 0o700 });
|
|
await writeFile(replacementCanary, "external replacement canary\n");
|
|
},
|
|
);
|
|
} catch (error) {
|
|
failure = error;
|
|
}
|
|
expect(failure).toBeInstanceOf(Error);
|
|
expect(failure).not.toBeInstanceOf(AggregateError);
|
|
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
|
|
await expect(lstat(displaced)).resolves.toEqual(
|
|
expect.objectContaining({ dev: expect.any(Number), ino: expect.any(Number) }),
|
|
);
|
|
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
|
"external replacement canary\n",
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
await rm(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects a fresh signed exact-five bundle replayed under a different expected run", async () => {
|
|
const fixture = syntheticSignedPromotionBundle();
|
|
await expect(
|
|
verifyExactPromotionBundle(fixture.files, {
|
|
...fixture.verification,
|
|
expected: {
|
|
...fixture.verification.expected,
|
|
run: { id: "different-run", attempt: 1 },
|
|
},
|
|
}),
|
|
).rejects.toThrow(/external expected run.*mismatch|expected promotion run/u);
|
|
});
|
|
|
|
it("requires every external expected identity variable at the exact promotion CLI", async () => {
|
|
const fixture = syntheticSignedPromotionBundle();
|
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-replay-cli-"));
|
|
const bundleRoot = path.join(root, "bundle");
|
|
try {
|
|
await mkdir(bundleRoot);
|
|
for (const [name, bytes] of Object.entries(fixture.files)) {
|
|
await writeFile(path.join(bundleRoot, name), bytes);
|
|
}
|
|
await writeFile(path.join(root, "vulnerability.pem"), fixture.vulnerabilityPem);
|
|
await writeFile(path.join(root, "provenance.pem"), fixture.provenancePem);
|
|
const cliPath = path.join(process.cwd(), "scripts/verify-exact-promotion-bundle.ts");
|
|
const baseEnvironment: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
PROMOTION_BUNDLE_ROOT: bundleRoot,
|
|
VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem",
|
|
VULNERABILITY_KEY_ID: "synthetic-vulnerability",
|
|
PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem",
|
|
PROVENANCE_KEY_ID: "synthetic-provenance",
|
|
EXPECTED_PROMOTION_RUN_ID: fixture.verification.expected.run.id,
|
|
EXPECTED_PROMOTION_RUN_ATTEMPT: String(
|
|
fixture.verification.expected.run.attempt,
|
|
),
|
|
EXPECTED_PROMOTION_SOURCE_REVISION:
|
|
fixture.verification.expected.sourceRevision,
|
|
EXPECTED_PROMOTION_ARCHIVE_SHA256:
|
|
fixture.verification.expected.archiveSha256,
|
|
};
|
|
const requiredExpected = [
|
|
"EXPECTED_PROMOTION_RUN_ID",
|
|
"EXPECTED_PROMOTION_RUN_ATTEMPT",
|
|
"EXPECTED_PROMOTION_SOURCE_REVISION",
|
|
"EXPECTED_PROMOTION_ARCHIVE_SHA256",
|
|
] as const;
|
|
for (const missing of requiredExpected) {
|
|
const environment = { ...baseEnvironment };
|
|
delete environment[missing];
|
|
const result = spawnSync(process.execPath, [cliPath], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
env: environment,
|
|
});
|
|
expect(result.status, missing).not.toBe(0);
|
|
expect(result.stderr, missing).toContain(
|
|
`exact promotion verification environment is missing ${missing}`,
|
|
);
|
|
}
|
|
for (const [name, value, diagnostic] of [
|
|
["EXPECTED_PROMOTION_RUN_ID", "different-run", /external expected run.*mismatch/u],
|
|
["EXPECTED_PROMOTION_RUN_ATTEMPT", "2", /external expected run.*mismatch/u],
|
|
["EXPECTED_PROMOTION_SOURCE_REVISION", "f".repeat(40), /external expected source revision.*mismatch/u],
|
|
["EXPECTED_PROMOTION_ARCHIVE_SHA256", "0".repeat(64), /external expected archive digest.*mismatch/u],
|
|
] as const) {
|
|
const result = spawnSync(process.execPath, [cliPath], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
env: { ...baseEnvironment, [name]: value },
|
|
});
|
|
expect(result.status, name).not.toBe(0);
|
|
expect(result.stderr, name).toMatch(diagnostic);
|
|
}
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
}, PROCESS_HEAVY_TIMEOUT_MS);
|
|
});
|