sanitize.ts 6.3 KB

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