mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
fix(e2e): stream large trace evidence scans
This commit is contained in:
1 parent
879f0f440a
commit
d74b88bad4
2 files changed
+90
-14
No files matched your search
@@ -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<string | null>((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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user