mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
44 lines
3.3 KiB
TypeScript
44 lines
3.3 KiB
TypeScript
import { lstat, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { inspectEmbeddedReport, inspectZip } from "./evidence.js";
|
|
import { diagnosticSecrets, type DiagnosticSecretExpansion } from "./private-diagnostics.js";
|
|
import { findSecretLeak, redactText, sanitizeJson } from "./redaction.js";
|
|
export interface ReporterPrivacyIssue { file: string; disposition: "redacted" | "omitted"; reason: string }
|
|
const sources = ["server.log", "playwright.log", "junit.xml", "html-report", "blob-report", "playwright-output"];
|
|
const text = new Set([".log", ".txt", ".md", ".xml", ".html", ".json", ".js", ".css"]);
|
|
/** Only reporter/diagnostic sinks are touched, after independent product oracles
|
|
* finish. Product API snapshots and work-product bytes are never pre-sanitized.
|
|
* Uninspectable/leaking compressed reports are omitted rather than fabricated. */
|
|
export async function sanitizePrivateReporterArtifacts(root: string, values: readonly string[] | DiagnosticSecretExpansion): Promise<ReporterPrivacyIssue[]> {
|
|
const secrets = diagnosticSecrets(values).values; const issues: ReporterPrivacyIssue[] = [];
|
|
const visit = async (relative: string): Promise<void> => {
|
|
const filename = path.join(root, relative); const info = await lstat(filename).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; throw error; });
|
|
if (!info) return;
|
|
const safeName = redactText(relative, secrets);
|
|
if (info.isSymbolicLink()) { await rm(filename); issues.push({ file: safeName, disposition: "omitted", reason: "reporter symlink cannot be inspected" }); return; }
|
|
if (info.isDirectory()) { for (const name of await readdir(filename)) await visit(path.join(relative, name)); return; }
|
|
if (!info.isFile()) return;
|
|
if (safeName !== relative) { await rm(filename); issues.push({ file: safeName, disposition: "omitted", reason: "credential in reporter filename" }); return; }
|
|
const extension = path.extname(filename).toLowerCase();
|
|
if (extension === ".zip") {
|
|
const reason = await inspectZip(filename, secrets);
|
|
if (reason) { await rm(filename); issues.push({ file: safeName, disposition: "omitted", reason }); }
|
|
} else if (text.has(extension)) {
|
|
if (info.size > 128 * 1024 * 1024) { await rm(filename); issues.push({ file: safeName, disposition: "omitted", reason: "reporter text exceeds inspection bound" }); return; }
|
|
const raw = await readFile(filename, "utf8");
|
|
const opaque = extension === ".html" ? await inspectEmbeddedReport(raw, secrets) : null;
|
|
if (opaque) { await rm(filename); issues.push({ file: safeName, disposition: "omitted", reason: opaque }); return; }
|
|
const leak = findSecretLeak(raw, secrets);
|
|
let safe: string;
|
|
if (extension === ".json") {
|
|
try { safe = `${JSON.stringify(sanitizeJson(JSON.parse(raw), secrets), null, 2)}\n`; }
|
|
catch { await rm(filename); issues.push({ file: safeName, disposition: "omitted", reason: "reporter JSON could not be safely parsed" }); return; }
|
|
} else safe = redactText(raw, secrets);
|
|
if (safe !== raw) await writeFile(filename, safe, { mode: 0o600 });
|
|
if (leak) issues.push({ file: safeName, disposition: "redacted", reason: leak });
|
|
}
|
|
};
|
|
for (const source of sources) await visit(source);
|
|
return issues;
|
|
}
|