sanitize.ts218 lines · main
1export 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 */
20export 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}