redaction.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. import { Schema } from "effect"
  2. export const REDACTED = "[REDACTED]"
  3. const DEFAULT_REDACT_HEADERS = [
  4. "authorization",
  5. "cookie",
  6. "proxy-authorization",
  7. "set-cookie",
  8. "x-api-key",
  9. "x-amz-security-token",
  10. "x-goog-api-key",
  11. ]
  12. const DEFAULT_REDACT_QUERY = [
  13. "access_token",
  14. "api-key",
  15. "api_key",
  16. "apikey",
  17. "code",
  18. "key",
  19. "signature",
  20. "sig",
  21. "token",
  22. "x-amz-credential",
  23. "x-amz-security-token",
  24. "x-amz-signature",
  25. ]
  26. const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
  27. { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
  28. { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
  29. { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
  30. { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
  31. { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
  32. { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
  33. { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
  34. ]
  35. const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
  36. const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
  37. const envSecrets = () =>
  38. Object.entries(process.env).flatMap(([name, value]) => {
  39. if (!value) return []
  40. if (!ENV_SECRET_NAMES.test(name)) return []
  41. if (value.length < 12) return []
  42. if (SAFE_ENV_VALUES.has(value.toLowerCase())) return []
  43. return [{ name, value }]
  44. })
  45. const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
  46. const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
  47. if (typeof value === "string") return [{ path: base, value }]
  48. if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
  49. if (value && typeof value === "object") {
  50. return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
  51. }
  52. return []
  53. }
  54. const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
  55. new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
  56. export type UrlRedactor = (url: string) => string
  57. export const redactUrl = (
  58. raw: string,
  59. query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY,
  60. urlRedactor?: UrlRedactor,
  61. ) => {
  62. if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw
  63. const url = new URL(raw)
  64. if (url.username) url.username = REDACTED
  65. if (url.password) url.password = REDACTED
  66. const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
  67. for (const key of url.searchParams.keys()) {
  68. if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
  69. }
  70. return urlRedactor?.(url.toString()) ?? url.toString()
  71. }
  72. export const redactHeaders = (
  73. headers: Record<string, string>,
  74. allow: ReadonlyArray<string>,
  75. redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
  76. ) => {
  77. const allowed = new Set(allow.map((name) => name.toLowerCase()))
  78. const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
  79. return Object.fromEntries(
  80. Object.entries(headers)
  81. .map(([name, value]) => [name.toLowerCase(), value] as const)
  82. .filter(([name]) => allowed.has(name))
  83. .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
  84. .toSorted(([a], [b]) => a.localeCompare(b)),
  85. )
  86. }
  87. export const SecretFindingSchema = Schema.Struct({
  88. path: Schema.String,
  89. reason: Schema.String,
  90. })
  91. export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
  92. export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> => {
  93. const environment = envSecrets()
  94. return stringEntries(value).flatMap((entry) => [
  95. ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
  96. path: entry.path,
  97. reason: item.label,
  98. })),
  99. ...environment
  100. .filter((item) => entry.value.includes(item.value))
  101. .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
  102. ])
  103. }