/** * `piiDetection` - block / rewrite when the value contains common PII * patterns. The default catalogue covers email, credit card (Luhn + * major-network leading digit), IBAN, US SSN, US phone, and Bitcoin * address shapes; it is not * exhaustive and is intentionally English-locale-friendly. The * outbound `withRedaction` provider middleware (Phase 06) covers the * "no sensitive values in non-local LLM prompts" half of the same * problem with a richer catalogue. The OTLP `RedactionValidator` * (DEC-141 / ADR-035) shares the pattern-shape contract; both layers * compose orthogonally for defence in depth. * * Reference: the project's security architecture, § Guardrails + * threat-model boundary § OTLP outbound (LLM02). * * @packageDocumentation */ import { defineInputGuardrail, defineOutputGuardrail } from '../builders.js'; import { normalizeForPiiMatching } from '../normalize.js'; import type { GuardrailDefinition, GuardrailResult, InputGuardrail, OutputGuardrail, } from '../types.js'; /** * One pattern in the catalogue. The `kind` discriminator surfaces in * audit metadata so SIEM dashboards can filter by sensitive type. * * @stable */ export interface PiiPattern { readonly kind: string; readonly pattern: RegExp; /** Optional post-match validator (e.g. Luhn check for credit cards). */ readonly validate?: (match: string) => boolean; } /** * Default catalogue of PII patterns. * * @stable */ export const DEFAULT_PII_PATTERNS: ReadonlyArray = Object.freeze([ Object.freeze({ kind: 'email', pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, }), Object.freeze({ kind: 'us-ssn', pattern: /\b\d{3}-\d{2}-\d{4}\b/g, }), Object.freeze({ kind: 'us-phone', // The lookarounds pin the match to a standalone number: a 10-digit // window inside a longer digit run (epoch timestamp, snowflake id) // or next to a decimal point is not a phone number. pattern: /(?; /** Replace the default catalogue entirely. */ readonly patterns?: ReadonlyArray; /** * Action to take on a match. Defaults to `'rewrite'` (mask the * detected substring with `[REDACTED:]`). */ readonly action?: 'block' | 'warn' | 'rewrite'; /** Stage the guardrail applies to. Defaults to `'input'`. */ readonly stage?: 'input' | 'output'; /** Override guardrail name. */ readonly name?: string; } /** * FIDES-lattice: does `text` contain any catalogued PII (email, SSN, * phone, Luhn-valid card, …)? A pure, allocation-light predicate that returns on * the first valid match and honours per-pattern `validate` (e.g. Luhn). Used to * feed user/PII content into the dataflow taint ledger's `sensitiveSeen` leg so * PII exfiltration trips the lethal-trifecta gate even without a `'secret'` tag. * * @stable */ export function containsPii( text: string, patterns: ReadonlyArray = DEFAULT_PII_PATTERNS, ): boolean { // W-150: apply the shared obfuscation pre-pass (NFKC + zero-width // strip, case-preserving - see normalizeForPiiMatching) so // zero-width-split emails / fullwidth digits cannot dodge the // sensitiveSeen taint leg the way they already cannot dodge the // injection catalogue. Offsets are irrelevant here: this is a pure // boolean predicate, unlike the guardrail's redaction path, which // must keep matching the RAW text it rewrites. const normalized = normalizeForPiiMatching(text); for (const pat of patterns) { const re = new RegExp( pat.pattern.source, pat.pattern.flags.includes('g') ? pat.pattern.flags : `${pat.pattern.flags}g`, ); let m = re.exec(normalized); while (m !== null) { if (pat.validate === undefined || pat.validate(m[0])) return true; // Validator rejected this match (e.g. Luhn fail); the global regex has // already advanced lastIndex, so continue scanning for the next hit. m = re.exec(normalized); } } return false; } /** * Construct the PII detection guardrail. * * Note on normalization: the boolean detect predicate * ({@link containsPii}) matches against the NFKC + zero-width-stripped * form of the text, so cheap character-injection obfuscation cannot * dodge detection. The guardrail's REWRITE path (`redactText` / * `redactValue`) deliberately keeps matching the raw text: offset-based * replacement needs the original string, and a normalized-offset remap * is not worth the complexity for a best-effort redactor. * * @stable */ export function piiDetection( opts: PiiDetectionOptions = {}, ): GuardrailDefinition { const patterns = Object.freeze([ ...(opts.patterns ?? DEFAULT_PII_PATTERNS), ...(opts.extraPatterns ?? []), ]); const action = opts.action ?? 'rewrite'; const stage = opts.stage ?? 'input'; const name = opts.name ?? 'piiDetection'; /** Redact every pattern match in one string; collects matched kinds. */ const redactText = (input: string, matchedKinds: string[]): string => { let text = input; for (const pat of patterns) { const re = new RegExp( pat.pattern.source, pat.pattern.flags.includes('g') ? pat.pattern.flags : `${pat.pattern.flags}g`, ); let m = re.exec(text); while (m !== null) { if (pat.validate && !pat.validate(m[0])) { m = re.exec(text); continue; } matchedKinds.push(pat.kind); const span = jsonSafeReplacement(text, m.index, m[0].length, `[REDACTED:${pat.kind}]`); text = text.slice(0, span.start) + span.text + text.slice(span.end); re.lastIndex = span.start + span.text.length; m = re.exec(text); } } return text; }; /** * Deep-walk string leaves so structured values are REDACTED, * never reported-redacted-but-returned-verbatim. Arrays/plain objects * recurse; other values pass through untouched. */ const redactValue = (input: unknown, matchedKinds: string[]): unknown => { if (typeof input === 'string') return redactText(input, matchedKinds); if (Array.isArray(input)) return input.map((item) => redactValue(item, matchedKinds)); if (input !== null && typeof input === 'object') { const out: Record = {}; for (const [key, val] of Object.entries(input as Record)) { out[key] = redactValue(val, matchedKinds); } return out; } return input; }; const spec = { name, check: (value: TValue): GuardrailResult => { const matchedKinds: string[] = []; const redacted = redactValue(value, matchedKinds); if (matchedKinds.length === 0) return { ok: true }; const result: GuardrailResult = { ok: false, action, message: `value contains ${matchedKinds.length} PII match(es): ${[...new Set(matchedKinds)].join(', ')}`, // SDF-6 invariant: a rewrite is never the unredacted input - // string leaves with matches were replaced above for every // value shape (string, array, object). ...(action === 'rewrite' ? { rewrite: redacted as TValue } : {}), metadata: Object.freeze({ matchedKinds }), }; return result; }, }; return stage === 'input' ? (defineInputGuardrail(spec) as InputGuardrail) : (defineOutputGuardrail(spec) as OutputGuardrail); } /** JSON insignificant whitespace (RFC 8259). */ const JSON_WS = new Set([' ', '\t', '\n', '\r']); /** * Grammar-preserving replacement placement - local twin of * `jsonSafeSpan` in `@graphorin/observability/redaction/patterns` * (security does not depend on observability). When the matched span * occupies a bare JSON value position, the replacement is wrapped in * double quotes so masking a raw numeric leaf keeps the document * parseable; a leading minus sign is absorbed into the returned span * (`start` moves onto the `-`), because a stranded sign before a quoted * replacement would not parse. Everywhere else the replacement is * returned unquoted and the span covers exactly the match. A text that * consists solely of the match is indistinguishable from a single-value * JSON document and gets the quoted form - safe in both readings. */ function jsonSafeReplacement( source: string, matchIndex: number, matchLength: number, replacement: string, ): { readonly start: number; readonly end: number; readonly text: string } { const end = matchIndex + matchLength; let start = matchIndex; let i = matchIndex - 1; while (i >= 0 && JSON_WS.has(source[i] as string)) i -= 1; const left = i < 0 ? undefined : source[i]; if (left === '-') { let k = i - 1; while (k >= 0 && JSON_WS.has(source[k] as string)) k -= 1; const beforeSign = k < 0 ? undefined : source[k]; if ( !(beforeSign === undefined || beforeSign === ':' || beforeSign === ',' || beforeSign === '[') ) { return { start, end, text: replacement }; } start = i; } else if (!(left === undefined || left === ':' || left === ',' || left === '[')) { return { start, end, text: replacement }; } let j = end; while (j < source.length && JSON_WS.has(source[j] as string)) j += 1; const right = j >= source.length ? undefined : source[j]; if (!(right === undefined || right === ',' || right === '}' || right === ']')) { return { start: matchIndex, end, text: replacement }; } return { start, end, text: `"${replacement}"` }; } /** * Default `credit-card` validator: Luhn checksum + a major-network leading * digit (2 = Mir / Mastercard 2-series, 3 = JCB / Amex / Diners, 4 = Visa, * 5 = Mastercard / Maestro, 6 = Discover / UnionPay / RuPay). Runs leading * with 0 / 1 / 7 / 8 / 9 - epoch timestamps, snowflake ids, most order * numbers - are never treated as PANs; deployments handling the rare * exceptions (UATP `1...`, petroleum `7...`, RuPay `81/82`, Troy `9792`) * should register a custom pattern via `extraPatterns`. */ function likelyPan(value: string): boolean { const lead = value.charCodeAt(0) - 48; // pattern matches start with a digit if (lead < 2 || lead > 6) return false; return luhn(value); } /** * Luhn checksum for credit-card validation. * * @stable */ export function luhn(value: string): boolean { const digits = value.replace(/[\s-]/g, ''); if (!/^\d{13,19}$/.test(digits)) return false; let sum = 0; let alt = false; for (let i = digits.length - 1; i >= 0; i -= 1) { let n = Number(digits[i]); if (alt) { n *= 2; if (n > 9) n -= 9; } sum += n; alt = !alt; } return sum % 10 === 0; }