@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
JavaScript
/**
* 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}.`,
};
}