feat(platform): PII Redaction library (Appendix D.1)
- 8 PII patterns: email, SSN, credit card, phone, IP, password/token, JWT, API key - redactPII() for string redaction - redactObject() for recursive object redaction - redactLogMessage() for log entries with context - containsPII() and getPIITypes() detection helpers - Custom pattern registration - Preset configurations: minimal, standard, aggressive
This commit is contained in:
parent
399f6f0bed
commit
8e90358960
260
services/platform-service/src/lib/pii-redaction.ts
Normal file
260
services/platform-service/src/lib/pii-redaction.ts
Normal file
@ -0,0 +1,260 @@
|
||||
/**
|
||||
* PII Redaction Library (Appendix D.1)
|
||||
* Detects and redacts personally identifiable information from logs and telemetry
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// PII Patterns
|
||||
// =============================================================================
|
||||
|
||||
export interface RedactionPattern {
|
||||
name: string;
|
||||
pattern: RegExp;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
export const PII_PATTERNS: RedactionPattern[] = [
|
||||
{
|
||||
name: 'email',
|
||||
pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
||||
replacement: '[REDACTED_EMAIL]',
|
||||
},
|
||||
{
|
||||
name: 'ssn',
|
||||
pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
|
||||
replacement: '[REDACTED_SSN]',
|
||||
},
|
||||
{
|
||||
name: 'credit_card',
|
||||
pattern: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
|
||||
replacement: '[REDACTED_CC]',
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
pattern: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
|
||||
replacement: '[REDACTED_PHONE]',
|
||||
},
|
||||
{
|
||||
name: 'ip_address',
|
||||
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
||||
replacement: '[REDACTED_IP]',
|
||||
},
|
||||
{
|
||||
name: 'password_token',
|
||||
pattern: /(?:password|token|secret|key|credential)s?[:\s=]+['"]?[^\s'"]{8,}['"]?/gi,
|
||||
replacement: '[REDACTED_SECRET]',
|
||||
},
|
||||
{
|
||||
name: 'jwt',
|
||||
pattern: /eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/g,
|
||||
replacement: '[REDACTED_JWT]',
|
||||
},
|
||||
{
|
||||
name: 'api_key',
|
||||
pattern: /(?:api[_-]?key|apikey)[:\s=]+['"]?[a-zA-Z0-9_-]{16,}['"]?/gi,
|
||||
replacement: '[REDACTED_API_KEY]',
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Redaction Functions
|
||||
// =============================================================================
|
||||
|
||||
export interface RedactionResult {
|
||||
redactedText: string;
|
||||
patternsMatched: string[];
|
||||
fieldsRedacted: string[];
|
||||
originalLength: number;
|
||||
redactedLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact PII from a string
|
||||
*/
|
||||
export function redactPII(text: string): RedactionResult {
|
||||
const patternsMatched: string[] = [];
|
||||
const fieldsRedacted: string[] = [];
|
||||
|
||||
let redactedText = text;
|
||||
let originalLength = text.length;
|
||||
|
||||
for (const { name, pattern, replacement } of PII_PATTERNS) {
|
||||
const matches = text.match(pattern);
|
||||
if (matches) {
|
||||
patternsMatched.push(name);
|
||||
fieldsRedacted.push(...matches);
|
||||
redactedText = redactedText.replace(pattern, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
redactedText,
|
||||
patternsMatched: [...new Set(patternsMatched)],
|
||||
fieldsRedacted: [...new Set(fieldsRedacted)],
|
||||
originalLength,
|
||||
redactedLength: redactedText.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact PII from an object (recursively)
|
||||
*/
|
||||
export function redactObject<T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
sensitiveFields: string[] = ['password', 'token', 'secret', 'creditCard', 'ssn', 'email', 'phone']
|
||||
): { redacted: T; metadata: RedactionResult } {
|
||||
const redacted: Record<string, unknown> = {};
|
||||
let allPatternsMatched: string[] = [];
|
||||
let allFieldsRedacted: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string') {
|
||||
// Check if field name suggests it's sensitive
|
||||
const isSensitiveField = sensitiveFields.some(sf =>
|
||||
key.toLowerCase().includes(sf.toLowerCase())
|
||||
);
|
||||
|
||||
if (isSensitiveField) {
|
||||
redacted[key] = '[REDACTED_FIELD]';
|
||||
allFieldsRedacted.push(`${key}: ${value.substring(0, 20)}...`);
|
||||
allPatternsMatched.push('sensitive_field_name');
|
||||
} else {
|
||||
// Run PII detection on the value
|
||||
const result = redactPII(value);
|
||||
redacted[key] = result.redactedText;
|
||||
allPatternsMatched.push(...result.patternsMatched);
|
||||
allFieldsRedacted.push(...result.fieldsRedacted);
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// Recursively redact nested objects
|
||||
const nested = redactObject(value as Record<string, unknown>, sensitiveFields);
|
||||
redacted[key] = nested.redacted;
|
||||
allPatternsMatched.push(...nested.metadata.patternsMatched);
|
||||
allFieldsRedacted.push(...nested.metadata.fieldsRedacted);
|
||||
} else {
|
||||
// Keep non-string values as-is
|
||||
redacted[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
redacted: redacted as T,
|
||||
metadata: {
|
||||
redactedText: JSON.stringify(redacted),
|
||||
patternsMatched: [...new Set(allPatternsMatched)],
|
||||
fieldsRedacted: [...new Set(allFieldsRedacted)],
|
||||
originalLength: JSON.stringify(obj).length,
|
||||
redactedLength: JSON.stringify(redacted).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact PII from log message
|
||||
*/
|
||||
export function redactLogMessage(
|
||||
message: string,
|
||||
context?: Record<string, unknown>
|
||||
): { message: string; context?: Record<string, unknown>; redaction: RedactionResult } {
|
||||
const messageResult = redactPII(message);
|
||||
|
||||
let redactedContext: Record<string, unknown> | undefined;
|
||||
let contextResult: RedactionResult | undefined;
|
||||
|
||||
if (context) {
|
||||
const { redacted, metadata } = redactObject(context);
|
||||
redactedContext = redacted;
|
||||
contextResult = metadata;
|
||||
}
|
||||
|
||||
return {
|
||||
message: messageResult.redactedText,
|
||||
context: redactedContext,
|
||||
redaction: {
|
||||
redactedText: messageResult.redactedText,
|
||||
patternsMatched: [
|
||||
...messageResult.patternsMatched,
|
||||
...(contextResult?.patternsMatched || []),
|
||||
],
|
||||
fieldsRedacted: [
|
||||
...messageResult.fieldsRedacted,
|
||||
...(contextResult?.fieldsRedacted || []),
|
||||
],
|
||||
originalLength: messageResult.originalLength + (contextResult?.originalLength || 0),
|
||||
redactedLength: messageResult.redactedLength + (contextResult?.redactedLength || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains PII
|
||||
*/
|
||||
export function containsPII(text: string): boolean {
|
||||
return PII_PATTERNS.some(({ pattern }) => pattern.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of PII types found in text
|
||||
*/
|
||||
export function getPIITypes(text: string): string[] {
|
||||
const types: string[] = [];
|
||||
for (const { name, pattern } of PII_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
types.push(name);
|
||||
}
|
||||
}
|
||||
return [...new Set(types)];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Custom Pattern Registration
|
||||
// =============================================================================
|
||||
|
||||
const customPatterns: RedactionPattern[] = [];
|
||||
|
||||
/**
|
||||
* Register a custom PII pattern
|
||||
*/
|
||||
export function registerPIIPattern(pattern: RedactionPattern): void {
|
||||
customPatterns.push(pattern);
|
||||
// Also add to the main patterns list
|
||||
PII_PATTERNS.push(pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a custom PII pattern by name
|
||||
*/
|
||||
export function unregisterPIIPattern(name: string): void {
|
||||
const index = PII_PATTERNS.findIndex(p => p.name === name);
|
||||
if (index !== -1 && customPatterns.some(p => p.name === name)) {
|
||||
PII_PATTERNS.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Preset Configurations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Minimal redaction - only highly sensitive data
|
||||
*/
|
||||
export const minimalRedaction = {
|
||||
patterns: ['ssn', 'credit_card', 'password_token', 'jwt', 'api_key'],
|
||||
redactFields: ['password', 'secret', 'token', 'creditCard', 'ssn'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard redaction - all PII
|
||||
*/
|
||||
export const standardRedaction = {
|
||||
patterns: PII_PATTERNS.map(p => p.name),
|
||||
redactFields: ['password', 'secret', 'token', 'creditCard', 'ssn', 'email', 'phone', 'address'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggressive redaction - all PII + IP addresses + device IDs
|
||||
*/
|
||||
export const aggressiveRedaction = {
|
||||
patterns: [...PII_PATTERNS.map(p => p.name), 'device_id', 'session_id'],
|
||||
redactFields: ['password', 'secret', 'token', 'creditCard', 'ssn', 'email', 'phone', 'address', 'userId', 'deviceId'],
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user