UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

221 lines (220 loc) • 8.43 kB
/** * PII Detection Utility * * Standalone utility for detecting and redacting personally identifiable * information (PII) from text. Used by generate() and stream() directly. * * @module piiDetector */ import { logger } from "./logger.js"; // ============================================================================ // Built-in patterns // ============================================================================ const PII_PATTERNS = { email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/gi, phone: /\b(\+?1[-.\s]?)?(\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}\b/g, ssn: /\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/g, creditCard: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g, ipAddress: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, address: /\b\d+\s+[\w\s]+(?:street|st|avenue|ave|road|rd|boulevard|blvd|drive|dr|lane|ln|court|ct)\b/gi, name: /\b[A-Z][a-z]+\s+[A-Z][a-z]+\b/g, dateOfBirth: /\b(?:0?[1-9]|1[0-2])[-/](?:0?[1-9]|[12]\d|3[01])[-/](?:19|20)\d{2}\b/g, passport: /\b[A-Z]{1,2}\d{6,9}\b/g, driversLicense: /\b[A-Z]{1,2}\d{5,8}\b/g, }; const DEFAULT_PII_TYPES = ["email", "phone", "ssn", "creditCard"]; // ============================================================================ // Catastrophic backtracking detection (Bug C10) // ============================================================================ /** * Heuristic check for patterns that can cause catastrophic backtracking. * Looks for nested quantifiers such as (a+)+ or (a*)* and similar structures. */ function hasCatastrophicBacktracking(pattern) { const src = pattern.source; // Simple string-based check for nested quantifiers: (X+)+ (X*)+ etc. // Avoids using a regex here (CodeQL flags polynomial regex on untrusted input). for (let i = 0; i < src.length - 1; i++) { if (src[i] === ")" && (src[i + 1] === "+" || src[i + 1] === "*")) { // Found a closing group followed by a quantifier — check if the group // itself contains a quantifier. const groupEnd = i; let depth = 1; let j = groupEnd - 1; while (j >= 0 && depth > 0) { if (src[j] === ")") { depth++; } if (src[j] === "(") { depth--; } j--; } const groupStart = j + 1; const groupBody = src.slice(groupStart + 1, groupEnd); if (groupBody.includes("+") || groupBody.includes("*")) { return true; } } } return false; } /** * Validate a custom RegExp before use (Bug C10). * Returns an error string if invalid, or null if safe. */ function validateCustomPattern(pattern) { if (!(pattern instanceof RegExp)) { return "Custom pattern is not a RegExp instance"; } if (hasCatastrophicBacktracking(pattern)) { return `Custom pattern /${pattern.source}/ contains nested quantifiers that may cause catastrophic backtracking`; } return null; } // ============================================================================ // Per-field detection helpers (Bug C3 fix) // ============================================================================ /** * Scan a single field's text with one regex pattern. * Returns DetectedPII entries with field-local offsets. */ function scanField(fieldText, fieldName, type, pattern) { // Reset lastIndex to avoid cross-call state leaking between scans. // We mutate the pattern's lastIndex directly rather than constructing a new // RegExp from .source/.flags (which CodeQL flags as regex injection). pattern.lastIndex = 0; const regex = pattern; const results = []; let match = regex.exec(fieldText); while (match !== null) { results.push({ type, value: match[0], position: { start: match.index, end: match.index + match[0].length }, field: fieldName, }); // Guard against zero-width matches causing infinite loops if (match[0].length === 0) { regex.lastIndex++; } match = regex.exec(fieldText); } return results; } /** * Apply redactions to a single field's text given its DetectedPII entries. * Redacts in reverse position order so earlier offsets stay valid. */ function redactField(fieldText, hits, redactionText) { // Sort descending by start position const sorted = [...hits].sort((a, b) => b.position.start - a.position.start); let result = fieldText; for (const hit of sorted) { result = result.slice(0, hit.position.start) + redactionText + result.slice(hit.position.end); } return result; } // ============================================================================ // Public API // ============================================================================ /** * Detect and optionally redact PII from a text string. * * Detection runs per-field (each named segment scanned independently) so that * offset positions always refer to the correct field's text — not a concatenated * blob (Bug C3 fix). * * Custom patterns are validated before execution to prevent catastrophic * backtracking (Bug C10 fix). * * @param text - The input text to scan * @param config - PII detection configuration * @returns Detection result with (optionally) redacted text and found PII */ export async function detectAndRedactPII(text, config) { const { action, detectTypes = DEFAULT_PII_TYPES, customPatterns = [], allowList = [], redactionText = "[REDACTED]", } = config; if (!text) { return { text, detectedPII: [], action: "continue" }; } // Validate custom patterns up-front (Bug C10) const validatedCustomPatterns = []; for (const pattern of customPatterns) { const err = validateCustomPattern(pattern); if (err) { logger.warn(`piiDetector: skipping invalid custom pattern — ${err}`); continue; } validatedCustomPatterns.push(pattern); } const allDetected = []; // Scan built-in PII types (Bug C3: scan the field in isolation) for (const piiType of detectTypes) { if (allowList.includes(piiType)) { continue; } const pattern = PII_PATTERNS[piiType]; if (!pattern) { continue; } const hits = scanField(text, "text", piiType, pattern); allDetected.push(...hits); } // Scan custom patterns for (const pattern of validatedCustomPatterns) { let hits = []; try { hits = scanField(text, "text", "custom", pattern); } catch (err) { logger.warn(`piiDetector: custom pattern /${pattern.source}/ threw during execution — skipping`, err); } allDetected.push(...hits); } if (allDetected.length === 0) { if (logger.shouldLog("debug")) { logger.debug("piiDetector: no PII detected in input"); } return { text, detectedPII: [], action: "continue" }; } if (logger.shouldLog("debug")) { logger.debug(`piiDetector: detected ${allDetected.length} PII occurrence(s) — action=${action}`); } // Summarise findings for feedback const typeCounts = allDetected.reduce((acc, d) => { acc[d.type] = (acc[d.type] ?? 0) + 1; return acc; }, {}); const summary = Object.entries(typeCounts) .map(([t, n]) => `${t} (${n})`) .join(", "); if (action === "abort") { return { text, detectedPII: allDetected, action: "abort", feedback: `PII detected: ${summary}. Request blocked for security.`, }; } if (action === "redact") { // Redact per-field (Bug C3: field hits already carry field-local offsets) const fieldHits = allDetected.filter((d) => d.field === "text"); const redactedText = redactField(text, fieldHits, redactionText); return { text: redactedText, detectedPII: allDetected, action: "continue", feedback: `PII redacted: ${summary}.`, }; } // action === "warn" return { text, detectedPII: allDetected, action: "continue", feedback: `PII detected (not redacted): ${summary}.`, }; }