sanitize.ts218 lines · main
| 1 | export function sanitizeUrlHashParams(url: string): string { |
| 2 | return url.split('#')[0] |
| 3 | } |
| 4 | |
| 5 | /** |
| 6 | * Best-effort sanitizer for arrays of objects. |
| 7 | * - Redacts likely secrets by key name (password, token, apiKey, etc.) |
| 8 | * - Redacts likely secrets by value pattern (IPv4/IPv6, AWS keys, Bearer/JWT, generic long tokens) |
| 9 | * - Recurses into nested arrays/objects up to `maxDepth`; beyond that replaces with a notice |
| 10 | * - Handles circular references |
| 11 | * |
| 12 | * @param {any[]} inputArr - Array of items to sanitize (non-objects are copied as-is). |
| 13 | * @param {Object} [opts] |
| 14 | * @param {number} [opts.maxDepth=3] - Maximum depth to traverse (0 == only top level). |
| 15 | * @param {string} [opts.redaction="[REDACTED]"] - Replacement text for sensitive values. |
| 16 | * @param {string} [opts.truncationNotice="[REDACTED: max depth reached]"] - Used when depth limit is hit. |
| 17 | * @param {string[]} [opts.sensitiveKeys] - Extra key names to treat as sensitive (case-insensitive). |
| 18 | * @returns {any[]} a deeply-sanitized clone of the input array |
| 19 | */ |
| 20 | export function sanitizeArrayOfObjects( |
| 21 | inputArr: unknown[], |
| 22 | opts: { |
| 23 | maxDepth?: number |
| 24 | redaction?: string |
| 25 | truncationNotice?: string |
| 26 | sensitiveKeys?: string[] |
| 27 | } = {} |
| 28 | ): unknown[] { |
| 29 | const { |
| 30 | maxDepth = 3, |
| 31 | redaction = '[REDACTED]', |
| 32 | truncationNotice = '[REDACTED: max depth reached]', |
| 33 | sensitiveKeys = [], |
| 34 | } = opts |
| 35 | |
| 36 | // Common sensitive key names (case-insensitive). Extendable via opts.sensitiveKeys. |
| 37 | const sensitiveKeySet = new Set( |
| 38 | [ |
| 39 | 'password', |
| 40 | 'passwd', |
| 41 | 'pwd', |
| 42 | 'pass', |
| 43 | 'secret', |
| 44 | 'token', |
| 45 | 'id_token', |
| 46 | 'access_token', |
| 47 | 'refresh_token', |
| 48 | 'apikey', |
| 49 | 'api_key', |
| 50 | 'api-key', |
| 51 | 'apiKey', |
| 52 | 'key', |
| 53 | 'privatekey', |
| 54 | 'private_key', |
| 55 | 'client_secret', |
| 56 | 'clientSecret', |
| 57 | 'auth', |
| 58 | 'authorization', |
| 59 | 'ssh_key', |
| 60 | 'sshKey', |
| 61 | 'bearer', |
| 62 | 'session', |
| 63 | 'cookie', |
| 64 | 'csrf', |
| 65 | 'xsrf', |
| 66 | 'ip', |
| 67 | 'ip_address', |
| 68 | 'ipAddress', |
| 69 | 'aws_access_key_id', |
| 70 | 'aws_secret_access_key', |
| 71 | 'gcp_service_account_key', |
| 72 | ...sensitiveKeys, |
| 73 | ].map((k) => k.toLowerCase()) |
| 74 | ) |
| 75 | |
| 76 | // Value patterns that often indicate secrets or PII |
| 77 | const patterns = [ |
| 78 | // IPv4 |
| 79 | { re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, reason: 'ip' }, |
| 80 | // IPv6 (simplified but effective) |
| 81 | { re: /\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}\b/g, reason: 'ip6' }, |
| 82 | // AWS Access Key ID (starts with AKIA/ASIA, 16 remaining upper alnum) |
| 83 | { re: /\b(AKI|ASI)A[0-9A-Z]{16}\b/g, reason: 'aws_access_key_id' }, |
| 84 | // AWS Secret Access Key (40 base64-ish chars) |
| 85 | { re: /\b[0-9A-Za-z/+]{40}\b/g, reason: 'aws_secret_access_key_like' }, |
| 86 | // Bearer tokens |
| 87 | { re: /\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b/g, reason: 'bearer' }, |
| 88 | // JWT (three base64url segments separated by dots) |
| 89 | { re: /\b[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\b/g, reason: 'jwt_like' }, |
| 90 | // Generic long API-ish token (conservative: 24–64 safe chars) |
| 91 | { re: /\b[A-Za-z0-9_\-]{24,64}\b/g, reason: 'long_token' }, |
| 92 | ] |
| 93 | |
| 94 | const seen = new WeakMap() |
| 95 | |
| 96 | function isPlainObject(v: unknown): v is Record<string, unknown> { |
| 97 | if (v === null || typeof v !== 'object') return false |
| 98 | const proto = Object.getPrototypeOf(v) |
| 99 | return proto === Object.prototype || proto === null |
| 100 | } |
| 101 | |
| 102 | function redactString(str: string) { |
| 103 | let out = str |
| 104 | for (const { re } of patterns) out = out.replace(re, redaction) |
| 105 | return out |
| 106 | } |
| 107 | |
| 108 | function shouldRedactByKey(key: string | symbol | number) { |
| 109 | return sensitiveKeySet.has(String(key).toLowerCase()) |
| 110 | } |
| 111 | |
| 112 | function sanitizeValue(value: unknown, depth: number): unknown { |
| 113 | if ( |
| 114 | value == null || |
| 115 | typeof value === 'number' || |
| 116 | typeof value === 'boolean' || |
| 117 | typeof value === 'bigint' |
| 118 | ) { |
| 119 | return value |
| 120 | } |
| 121 | |
| 122 | if (typeof value === 'string') { |
| 123 | return redactString(value) |
| 124 | } |
| 125 | |
| 126 | if (typeof value === 'function') { |
| 127 | return '[Function]' |
| 128 | } |
| 129 | |
| 130 | if (value instanceof Date) { |
| 131 | return value.toISOString() |
| 132 | } |
| 133 | |
| 134 | if (value instanceof RegExp) { |
| 135 | return value.toString() |
| 136 | } |
| 137 | |
| 138 | if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { |
| 139 | return `[TypedArray byteLength=${value.byteLength}]` |
| 140 | } |
| 141 | if (value instanceof ArrayBuffer) { |
| 142 | return `[ArrayBuffer byteLength=${value.byteLength}]` |
| 143 | } |
| 144 | |
| 145 | if (depth >= maxDepth) { |
| 146 | return truncationNotice |
| 147 | } |
| 148 | |
| 149 | if (typeof value === 'object') { |
| 150 | if (seen.has(value)) { |
| 151 | return '[Circular]' |
| 152 | } |
| 153 | |
| 154 | if (Array.isArray(value)) { |
| 155 | const outArr: unknown[] = [] |
| 156 | seen.set(value, outArr) |
| 157 | for (let i = 0; i < value.length; i++) { |
| 158 | outArr[i] = sanitizeValue(value[i], depth + 1) |
| 159 | } |
| 160 | return outArr |
| 161 | } |
| 162 | |
| 163 | if (isPlainObject(value)) { |
| 164 | const outObj: Record<string | symbol | number, unknown> = {} |
| 165 | seen.set(value, outObj) |
| 166 | for (const [k, v] of Object.entries(value)) { |
| 167 | if (shouldRedactByKey(k)) { |
| 168 | outObj[k] = redaction |
| 169 | } else { |
| 170 | outObj[k] = sanitizeValue(v, depth + 1) |
| 171 | } |
| 172 | } |
| 173 | return outObj |
| 174 | } |
| 175 | |
| 176 | if (value instanceof Map) { |
| 177 | const out: unknown[] = [] |
| 178 | seen.set(value, out) |
| 179 | for (const [k, v] of value.entries()) { |
| 180 | const redactedKey = shouldRedactByKey(k) ? redaction : sanitizeValue(k, depth + 1) |
| 181 | const redactedVal = shouldRedactByKey(k) ? redaction : sanitizeValue(v, depth + 1) |
| 182 | out.push([redactedKey, redactedVal]) |
| 183 | } |
| 184 | return out |
| 185 | } |
| 186 | |
| 187 | if (value instanceof Set) { |
| 188 | const out: unknown[] = [] |
| 189 | seen.set(value, out) |
| 190 | for (const v of value.values()) { |
| 191 | out.push(sanitizeValue(v, depth + 1)) |
| 192 | } |
| 193 | return out |
| 194 | } |
| 195 | |
| 196 | if (value instanceof URL) return value.toString() |
| 197 | if (value instanceof Error) { |
| 198 | const o = { |
| 199 | name: value.name, |
| 200 | message: redactString(value.message), |
| 201 | stack: truncationNotice, |
| 202 | } |
| 203 | seen.set(value, o) |
| 204 | return o |
| 205 | } |
| 206 | |
| 207 | try { |
| 208 | return redactString(String(value)) |
| 209 | } catch { |
| 210 | return redactString(Object.prototype.toString.call(value)) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | return redactString(String(value)) |
| 215 | } |
| 216 | |
| 217 | return inputArr.map((item) => sanitizeValue(item, 0)) |
| 218 | } |