/** * @factory/observability — span/log PII + secret scrubber (TASK-0135). * * The phase-15 spec: spans must never carry secrets or PII — "no stack, no * email, no token; redact query text to the operation shape" — and the NFR * doc (NFR-P2) makes it a platform invariant. This is the SPAN-side sibling * of the API's log-side `common/log-scrubber.ts` (same rules, span-attr * shape): run it on every span attribute map before the span is exported. * * Applied centrally in `otel.ts` (a global span-processor hook would require * a private SDK API, so each span-creation site calls `spanScrubber` — the * wiring in the four apps is the conformance surface; this module is the * tested behavior). */ /** Attribute keys that are DROPPED wholesale (never exported, at all). */ const DROP_KEYS = new Set([ // stacks + raw payloads "stack", "stacktrace", "error.stack", "sql", "query", "raw", "body", "payload", // credentials "token", "accesstoken", "refreshtoken", "authorization", "auth", "apikey", "api_key", "password", "passwd", "secret", "credential", "credentials", "cookie", "setcookie", "xapikey", // PII "email", "emailaddress", "phone", "phonenumber", "ip", "ipaddress", "clientip", "address", "name", // personal-name attributes — span identity uses ids, not names "username" ]); const REDACT_KEYS = new Set([ "q", // search query text → keep the key so dashboards can still slice on it "searchquery", "text", // free-text content (block text, email subject) — shape only "subject" ]); /** The fixed redaction marker. */ export const REDACTED = "[redacted]"; /** Value-shape patterns (belt-and-braces: even an innocently-named key that * carries a token/email as its VALUE gets masked). */ // NOTE: tool-bug workaround — no `\"` literals in these regexes (the file-edit // JSON layer drops backslashes before quotes); single quotes are safe. const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g; const BEARER_RE = /bearer\s+[a-z0-9._~+\/-]+/gi; const APIKEY_RE = /\b(api[_-]?key|apikey)\b[:=]\s*([a-z0-9_\-]{8,})/gi; const URL_CRED_RE = /:\/\/[^/@\s]+:[^/@\s]+@/g; const JWT_RE = /\beyJ[a-z0-9_-]{6,}\.[a-z0-9._-]{6,}/gi; const HEX64_RE = /\b[a-f0-9]{64}\b/gi; // sha256-hex secrets/tokens function redactValue(v: string): string { let out = v; out = out.replace(URL_CRED_RE, "://[redacted]@"); out = out.replace(JWT_RE, REDACTED); out = out.replace(BEARER_RE, "bearer " + REDACTED); out = out.replace(APIKEY_RE, (_m, k: string) => `${k}: ${REDACTED}`); out = out.replace(HEX64_RE, REDACTED); out = out.replace(EMAIL_RE, REDACTED); return out; } /** * Scrub a span attribute map (or a log-attribute map — same shape) in place * and return the same object (callers hold the reference). * * - keys in `DROP_KEYS` (case-insensitive) are DELETED; * - keys in `REDACT_KEYS` are set to `"[redacted]"`; * - every remaining string value is masked against the value patterns * (email, bearer/apikey/jwt tokens, URL credentials, 64-hex secrets); * - non-string values pass through unchanged (ids, numbers, booleans). */ export function spanScrubber>(attrs: T): T { // Mutate through a plain record (generic `T` is read-only for writes). const rec = attrs as unknown as Record; for (const key of Object.keys(rec)) { const lower = key.toLowerCase(); if (DROP_KEYS.has(lower)) { delete rec[key]; continue; } const value = rec[key]; if (value === undefined) continue; if (REDACT_KEYS.has(lower)) { // The VALUE is the sensitive part (query text, free text) — full // redaction, not just pattern-masking. rec[key] = REDACTED; continue; } if (typeof value === "string") { rec[key] = redactValue(value); continue; } if (Array.isArray(value)) { rec[key] = value.map((v) => (typeof v === "string" ? redactValue(v) : v)); } } return attrs; } /** * Redact a SQL/Prisma query string to its OPERATION SHAPE (the spec: "query * text redacted to the operation shape"). `SELECT ... FROM blocks WHERE ...` * → `SELECT ... FROM blocks WHERE ...` keeps the verb + table, drops every * literal: * * `SELECT id, title FROM blocks WHERE pageId = 'pg_1' LIMIT 50` * → `SELECT id, title FROM blocks WHERE pageId = $1 LIMIT $2` * * The table/verbs survive (that is the "shape"); the data does not. */ export function redactQuery(query: string): string { let out = query; // single-quoted string literals (escape-aware: '' is an escaped quote // inside SQL) → positional placeholders, in order of appearance. const placeholders: string[] = []; const next = (): string => { placeholders.push(String(placeholders.length + 1)); return `$${placeholders[placeholders.length - 1]}`; }; out = out.replace(/'(?:[^']|'')*'/g, () => next()); // numeric literals → positional placeholders (same sequence). out = out.replace(/\b\d+(\.\d+)?\b/g, () => next()); // collapse whitespace return out.replace(/\s+/g, " ").trim(); }