Files
multi-simadmin/packages/test-fixtures/src/redactor.ts
T

83 lines
4.6 KiB
TypeScript

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;i<node.length;i++)node[i]=typedRedact(node[i]);return node.length;}
return node.reduce((n,item)=>n+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<T>(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<T>(value:T):T { return new Context().value(value) as T; }