const SECRET_KEY = /(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|pin|puk|username|(?:^|[_-])user(?:$|[_-])|account|serial|revision|(?:^|[_-])path(?:$|[_-])|template|token|secret|cookie|authorization|content|body|confirmation[_-]?code)/i; const STRUCTURED_KEY = /^(?:config)$/i; const LOCATION_KEY = /^(?:lat(?:itude)?|lon(?:gitude)?|cell_?id|cid|lac|tac|pci|e?nb|gnb)$/i; const FINGERPRINT_KEY = /^(?:uptime|traffic|temperature|.*(?:rx|tx)[_-]?(?:bytes|packets)|.*percent|(?:created|updated|captured)?_?at|timestamp|time)$/i; const SAFE_BOOLEAN_KEY = /(?:phone_number|sms_center)_is_manual$/i; const PHONE_KEY = /(?:phone|msisdn|recipient|number|smsc|sms_center)/i; const IDENTIFIER_KEY = /(?:iccid|imsi|imei|eid|matching_id)/i; const ADDRESS_KEY = /(?:ssid|bssid|mac|(?:^|_)(?:ip|address)(?:_|$)|hostname|host|url|webhook)/i; export function typedRedact(value:any):any { if(Array.isArray(value)) return value.map(typedRedact); if(value && typeof value==='object') return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,typedRedact(v)])); if(typeof value==='string') return '[REDACTED]'; if(typeof value==='number') return 0; if(typeof value==='boolean') return false; return value; } function looksHighEntropy(s:string){ if(s.length<16 || /\s/.test(s)) return false; const classes=[/[a-z]/,/[A-Z]/,/\d/,/[^A-Za-z0-9]/].filter(r=>r.test(s)).length; return classes>=3 || (classes>=2 && new Set(s).size>=12); } function unsafeString(s:string){ return /https?:\/\//i.test(s) || /(?:^|\s|["'=])(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?/i.test(s) || /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(s) || /(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i.test(s) || /(?:^|[^\w])(?:[0-9a-f]{0,4}:){2,}[0-9a-f:%]+(?:$|[^\w])/i.test(s) || /\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(s) || /(?:^|\D)\+?\d(?:[ ()-]*\d){7,}(?:$|\D)/.test(s) || /(?:\d[ -]){6,}\d/.test(s) || /(?:^|\s)(?:\/Users\/|\/home\/|\/var\/|\/etc\/|[A-Za-z]:\\)\S+/i.test(s) || /\b(?:token|user(?:name)?|account|path|url|host)\s*[=:]\s*\S+/i.test(s) || /\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/.test(s) || looksHighEntropy(s); } class Context { value(value:any,key='',force=false):any { if(SAFE_BOOLEAN_KEY.test(key) && typeof value==='boolean') return value; if(SECRET_KEY.test(key) || LOCATION_KEY.test(key) || FINGERPRINT_KEY.test(key)) return typedRedact(value); if(STRUCTURED_KEY.test(key)) force=true; if(PHONE_KEY.test(key)||IDENTIFIER_KEY.test(key)||ADDRESS_KEY.test(key)) return typedRedact(value); if(Array.isArray(value)) return value.map(v=>this.value(v,'',force)); if(value && typeof value==='object') return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,this.value(v,k,force)])); if(force) return typedRedact(value); if(typeof value==='string'){ const t=value.trim(); if((t.startsWith('{')&&t.endsWith('}'))||(t.startsWith('[')&&t.endsWith(']'))){try{return JSON.stringify(this.value(JSON.parse(value)));}catch{}} return unsafeString(value)?'[REDACTED]':value; } if(typeof value==='number' && Number.isInteger(value) && Math.abs(value)>=100_000_000) return 0; return value; } } export function parseResponseSensitivePath(path:string):string[] { if(typeof path!=='string'||!path.startsWith('$.response')) throw new Error('unsupported response sensitive path'); const rest=path.slice('$.response'.length); if(!rest) return []; if(!/^(?:\.[A-Za-z0-9_-]+|\[\*\])+$/.test(rest)) throw new Error('unsupported response sensitive path'); return [...rest.matchAll(/\.([A-Za-z0-9_-]+)|\[\*\]/g)].map(m=>m[1]||'*'); } export function applySensitivePath(node:any,parts:string[],index=0):number { if(index===parts.length) return 0; const part=parts[index]; if(part==='*'){ if(!Array.isArray(node)) return 0; if(index===parts.length-1){for(let i=0;in+applySensitivePath(item,parts,index+1),0); } if(!node||typeof node!=='object'||!(part in node)) return 0; if(index===parts.length-1){node[part]=typedRedact(node[part]);return 1;} return applySensitivePath(node[part],parts,index+1); } export function redactOperationPayload(value:T,operation:any):T { const clone=structuredClone(value); for(const field of operation?.sensitiveFields||[]){ if(field?.direction!=='response') continue; const parts=parseResponseSensitivePath(field.path); // The decoded HTTP body corresponds to $.response; parts are body-relative // (normally beginning with `data`), so do not add another response wrapper. applySensitivePath(clone,parts); } return new Context().value(clone) as T; } export function redact(value:T):T { return new Context().value(value) as T; }