diff --git a/tests/runner-e2e/evidence.ts b/tests/runner-e2e/evidence.ts index 85f6a13b518..13275067dd3 100644 --- a/tests/runner-e2e/evidence.ts +++ b/tests/runner-e2e/evidence.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { mkdir, readFile, @@ -80,19 +80,48 @@ function isAllowed(relative: string) { return ALLOWED_DIRECTORIES.has(segments[0]); } -function inspectZip(source: string, secrets: readonly string[]) { - try { - const expanded = execFileSync("unzip", ["-p", source], { - maxBuffer: 256 * 1024 * 1024, +async function inspectZip(source: string, secrets: readonly string[]) { + // Failure traces can exceed Node's child-process output buffer. Stream the + // expanded archive through the exact-value scanner with enough overlap to + // detect a credential split across stdout chunks, without retaining the + // archive in memory or weakening fail-closed evidence publication. + const overlap = Math.max( + 256, + ...secrets.map((secret) => Buffer.byteLength(secret, "utf8") + 16), + ); + return new Promise((resolve) => { + const unzip = spawn("unzip", ["-p", source], { + stdio: ["ignore", "pipe", "pipe"], }); - // Traces include loaded application source and therefore deliberate fake - // provider-key fixtures. ZIPs cannot be safely rewritten, so admit them - // only after exact-value scanning against the credentials loaded for this - // campaign; textual snapshots still receive shape scanning and redaction. - return findSecretLeak(expanded, secrets, { includeShapes: false }); - } catch (error) { - return `zip could not be inspected: ${error instanceof Error ? error.message : String(error)}`; - } + let carry = Buffer.alloc(0); + let leak: string | null = null; + let spawnError: Error | null = null; + + unzip.stdout.on("data", (chunk: Buffer) => { + if (leak) return; + const data = Buffer.concat([carry, chunk]); + leak = findSecretLeak(data, secrets, { includeShapes: false }); + carry = data.subarray(Math.max(0, data.length - overlap)); + if (leak) unzip.kill(); + }); + // Drain diagnostics so a noisy unzip cannot block. Error text is withheld + // because archive paths and contents belong to private attempt evidence. + unzip.stderr.resume(); + unzip.on("error", (error) => { + spawnError = error; + }); + unzip.on("close", (code) => { + if (leak) return resolve(leak); + if (spawnError) { + return resolve(`zip could not be inspected: ${spawnError.message}`); + } + return resolve( + code === 0 + ? null + : `zip could not be inspected: unzip exited with code ${String(code)}`, + ); + }); + }); } export async function packageEvidence(input: { @@ -140,7 +169,7 @@ export async function packageEvidence(input: { await writeFile(destination, safe, "utf8"); files.push(relative); } else if (extension === ".zip") { - const leak = inspectZip(source, input.secrets); + const leak = await inspectZip(source, input.secrets); if (leak) { leaks.push({ file: relative, reason: leak }); continue; diff --git a/tests/runner-e2e/support.test.ts b/tests/runner-e2e/support.test.ts index e41f3539353..e1a01e1478c 100644 --- a/tests/runner-e2e/support.test.ts +++ b/tests/runner-e2e/support.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -280,6 +281,13 @@ describe("runner E2E failure policy", () => { new Error("Provider request timed out during generation"), ), ).toBe("transient_infrastructure"); + expect( + classifyFailure( + new Error( + "runner_ingress_unavailable: paperclip-runnerd: cumulative ACK cannot move beyond the produced source cursor", + ), + ), + ).toBe("transient_infrastructure"); expect( shouldRetryFailure(classifyFailure(new Error("invalid API key"))), ).toBe(false); @@ -578,6 +586,45 @@ describe("runner E2E evidence redaction", () => { expect(() => JSON.parse(uploaded)).not.toThrow(); expect(uploaded).toContain("[REDACTED]"); }); + + it("streams large ZIP evidence and detects exact secrets", async () => { + const root = await mkdtemp( + path.join(os.tmpdir(), "runner-e2e-zip-evidence-test-"), + ); + cleanupDirectories.push(root); + const privateDir = path.join(root, "private"); + const uploadDir = path.join(root, "upload"); + const blobDir = path.join(privateDir, "blob-report"); + await mkdir(blobDir, { recursive: true }); + await writeFile( + path.join(blobDir, "trace.txt"), + Buffer.concat([ + Buffer.alloc(2 * 1024 * 1024, "x"), + Buffer.from(secret), + ]), + ); + execFileSync("zip", ["-q", "report.zip", "trace.txt"], { + cwd: blobDir, + }); + await rm(path.join(blobDir, "trace.txt")); + + const packaged = await packageEvidence({ + privateDir, + uploadDir, + secrets: [secret], + expectPassScreenshot: false, + }); + + expect(packaged.leaks).toEqual([ + { + file: path.join("blob-report", "report.zip"), + reason: "exact secret value", + }, + ]); + await expect( + readFile(path.join(uploadDir, "blob-report", "report.zip")), + ).rejects.toThrow(); + }); }); describe("runner E2E macOS shared-memory cleanup", () => {