paperclip/tests/runner-acceptance/redaction.ts

119 lines
3.4 KiB
TypeScript

import { redactDiagnosticText } from "../../packages/adapter-utils/src/command-redaction.js";
const SECRET_SHAPES = [
/\bsk-ant-[A-Za-z0-9_-]{16,}\b/g,
/\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b/g,
/\bopenrouter[-_]?(?:api)?[-_]?key["'=:\s]+[A-Za-z0-9._-]{12,}\b/gi,
] as const;
const SENSITIVE_KEY = /(?:authorization|api.?key|token|password|secret|credential|private.?key)/i;
export function normalizedSensitiveValues(
values: readonly (string | undefined)[],
) {
return [...new Set(
values
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value)),
)].sort((left, right) => right.length - left.length);
}
function redactKnownValuesAndShapes(
value: string,
sensitiveValues: readonly string[],
) {
let redacted = value;
for (const sensitiveValue of normalizedSensitiveValues(sensitiveValues)) {
redacted = redacted.split(sensitiveValue).join("[REDACTED]");
}
for (const pattern of SECRET_SHAPES) {
pattern.lastIndex = 0;
redacted = redacted.replace(pattern, "[REDACTED_SECRET_SHAPE]");
}
return redacted;
}
export function redactText(
value: string,
sensitiveValues: readonly string[] = [],
) {
return redactKnownValuesAndShapes(
redactDiagnosticText(value, "[REDACTED]"),
sensitiveValues,
);
}
export function findSensitiveValue(
value: string | Buffer,
sensitiveValues: readonly string[] = [],
): string | null {
const text = Buffer.isBuffer(value) ? value.toString("utf8") : value;
for (const sensitiveValue of normalizedSensitiveValues(sensitiveValues)) {
if (text.includes(sensitiveValue)) return "exact sensitive value";
}
for (const pattern of SECRET_SHAPES) {
pattern.lastIndex = 0;
if (pattern.test(text)) return "secret-shaped value";
}
return null;
}
export function findSensitiveJsonValue(
value: unknown,
sensitiveValues: readonly string[] = [],
): string | null {
if (typeof value === "string") return findSensitiveValue(value, sensitiveValues);
if (Array.isArray(value)) {
for (const entry of value) {
const leak = findSensitiveJsonValue(entry, sensitiveValues);
if (leak) return leak;
}
return null;
}
if (value && typeof value === "object") {
for (const [key, entry] of Object.entries(value)) {
if (
SENSITIVE_KEY.test(key)
&& entry !== null
&& entry !== undefined
&& entry !== "[REDACTED]"
&& entry !== "[REDACTED_SECRET_SHAPE]"
) {
return `sensitive field ${key}`;
}
const leak = findSensitiveJsonValue(entry, sensitiveValues);
if (leak) return leak;
}
}
return null;
}
export function sanitizeJson(
value: unknown,
sensitiveValues: readonly string[] = [],
): unknown {
if (typeof value === "string") return redactText(value, sensitiveValues);
if (Array.isArray(value)) {
return value.map((entry) => sanitizeJson(entry, sensitiveValues));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
SENSITIVE_KEY.test(key) && entry !== null && entry !== undefined
? "[REDACTED]"
: sanitizeJson(entry, sensitiveValues),
]),
);
}
return value;
}
export function assertSensitiveValueFree(
value: string | Buffer,
sensitiveValues: readonly string[],
label: string,
) {
const leak = findSensitiveValue(value, sensitiveValues);
if (leak) throw new Error(`Secret leak in ${label}: ${leak}`);
}