secret-redactor.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. /**
  2. * Conservative secret redactor: the safety backstop that scrubs credential-like
  3. * values from telemetry content before it leaves the machine.
  4. *
  5. * The redactor never drops a field or line — it only replaces the secret-shaped
  6. * VALUE with a fixed placeholder, so the surrounding structure (keys, package
  7. * names, base URLs, dependency pins) stays intact for the maintainer. It leans
  8. * toward redaction on strong signals (secret-like key names, known token
  9. * shapes, PEM blocks, URL credentials, high-entropy opaque tokens) while
  10. * deliberately leaving low-signal values (package names, versions, git SHAs,
  11. * plain URLs, kebab identifiers) untouched, because those are exactly the
  12. * signal telemetry exists to capture.
  13. *
  14. * @module @deepseek-ai/dsh-telemetry/secret-redactor
  15. */
  16. /** Default text substituted for a detected secret. */
  17. export const DEFAULT_REDACTION_PLACEHOLDER = '[REDACTED]'
  18. /** Default minimum length for the high-entropy opaque-token heuristic. */
  19. export const DEFAULT_MIN_TOKEN_LENGTH = 24
  20. /** Default Shannon-entropy threshold (bits/char) that marks an opaque token secret. */
  21. export const DEFAULT_ENTROPY_THRESHOLD = 4
  22. /** Tuning for {@link SecretRedactor}; every field defaults to a documented constant. */
  23. export interface SecretRedactorOptions {
  24. /** Replacement text for a detected secret. */
  25. placeholder?: string
  26. /** Minimum length before the high-entropy heuristic considers an opaque token. */
  27. minTokenLength?: number
  28. /** Shannon entropy (bits/char) at or above which an opaque token is treated as secret. */
  29. entropyThreshold?: number
  30. }
  31. /**
  32. * Regexes for well-known credential shapes. A match anywhere in a candidate
  33. * token marks it secret regardless of length, so short-but-recognizable tokens
  34. * are caught even when the entropy heuristic would not fire.
  35. */
  36. const KNOWN_SECRET_PATTERNS: readonly RegExp[] = [
  37. /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/, // OpenAI / DeepSeek / Anthropic style
  38. /gh[pousr]_[A-Za-z0-9]{16,}/, // GitHub personal/oauth/server/refresh tokens
  39. /github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT
  40. /xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens
  41. /AKIA[0-9A-Z]{16}/, // AWS access key id
  42. /AIza[0-9A-Za-z_-]{35}/, // Google API key
  43. /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JWT
  44. ]
  45. /**
  46. * Key names (normalized to lowercase, separators stripped) whose value is a
  47. * secret. Split by match strategy so short/ambiguous words do not over-match:
  48. * `author` must not trip the `auth` rule.
  49. */
  50. const KEY_SUBSTRING_INDICATORS: readonly string[] = [
  51. 'password', 'passwd', 'passphrase', 'secret', 'apikey', 'apisecret',
  52. 'clientsecret', 'privatekey', 'secretkey', 'accesskey', 'credential',
  53. 'connectionstring', 'sastoken', 'xapikey', 'authtoken', 'accesstoken',
  54. 'refreshtoken', 'idtoken', 'sessiontoken', 'bearertoken',
  55. ]
  56. const KEY_SUFFIX_INDICATORS: readonly string[] = ['token']
  57. const KEY_EXACT_INDICATORS: readonly string[] = [
  58. 'auth', 'authorization', 'cookie', 'bearer', 'dsn', 'signature',
  59. ]
  60. /**
  61. * Whether a key name marks its value as a secret.
  62. * @param key - raw object key or assignment name.
  63. * @returns whether the value under this key must be redacted.
  64. */
  65. export function keyLooksSecret(key: string): boolean {
  66. const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '')
  67. if (normalized.length === 0) return false
  68. if (KEY_SUBSTRING_INDICATORS.some(indicator => normalized.includes(indicator))) return true
  69. if (KEY_SUFFIX_INDICATORS.some(indicator => normalized.endsWith(indicator))) return true
  70. return KEY_EXACT_INDICATORS.includes(normalized)
  71. }
  72. /** Shannon entropy in bits per character. */
  73. function shannonEntropy(value: string): number {
  74. const counts = new Map<string, number>()
  75. for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1)
  76. let entropy = 0
  77. for (const count of counts.values()) {
  78. const probability = count / value.length
  79. entropy -= probability * Math.log2(probability)
  80. }
  81. return entropy
  82. }
  83. /** Opaque-token character set (base64/base64url plus common token punctuation). */
  84. const OPAQUE_TOKEN = /^[A-Za-z0-9+/=_.-]+$/
  85. /** Version-like leader kept visible (dependency pins, semver). */
  86. const VERSION_LIKE = /^v?\d+(?:\.\d+)+/
  87. /**
  88. * Conservative secret detector and redactor for telemetry content.
  89. * Detection is a pure function of the input; construction only fixes tunables.
  90. */
  91. export class SecretRedactor {
  92. readonly #placeholder: string
  93. readonly #minTokenLength: number
  94. readonly #entropyThreshold: number
  95. /** @param options - placeholder text and heuristic thresholds. */
  96. constructor(options: SecretRedactorOptions = {}) {
  97. this.#placeholder = options.placeholder ?? DEFAULT_REDACTION_PLACEHOLDER
  98. this.#minTokenLength = options.minTokenLength ?? DEFAULT_MIN_TOKEN_LENGTH
  99. this.#entropyThreshold = options.entropyThreshold ?? DEFAULT_ENTROPY_THRESHOLD
  100. }
  101. /**
  102. * Whether a standalone token value looks like a secret.
  103. * @param value - candidate token, already trimmed of surrounding quotes.
  104. * @returns whether the value should be redacted on its own merits.
  105. */
  106. isSecretValue(value: string): boolean {
  107. if (KNOWN_SECRET_PATTERNS.some(pattern => pattern.test(value))) return true
  108. if (value.length < this.#minTokenLength) return false
  109. if (!OPAQUE_TOKEN.test(value)) return false
  110. // Git SHAs and integrity digests are hex and public — never a secret we hide.
  111. if (/^[0-9a-fA-F]+$/.test(value)) return false
  112. if (VERSION_LIKE.test(value)) return false
  113. const classes = (/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0)
  114. return classes >= 3 || shannonEntropy(value) >= this.#entropyThreshold
  115. }
  116. /**
  117. * Deep-redact a parsed value in place-safe fashion, returning a new structure.
  118. * A secret-named key redacts its string value outright; every other string is
  119. * judged on its own shape. Non-string leaves pass through untouched.
  120. * @param value - parsed JSON-like value (object, array, or primitive).
  121. * @returns a structurally identical value with secret strings replaced.
  122. */
  123. redactValue<T>(value: T): T {
  124. return this.#redactNode(value, false) as T
  125. }
  126. #redactNode(value: unknown, keyIsSecret: boolean): unknown {
  127. if (typeof value === 'string') {
  128. return keyIsSecret || this.isSecretValue(value) ? this.#placeholder : value
  129. }
  130. if (Array.isArray(value)) return value.map(item => this.#redactNode(item, false))
  131. if (value !== null && typeof value === 'object') {
  132. return Object.fromEntries(
  133. Object.entries(value).map(([key, child]) => [key, this.#redactNode(child, keyLooksSecret(key))]),
  134. )
  135. }
  136. return value
  137. }
  138. /**
  139. * Redact secrets embedded in raw text (YAML, JSON, or `.env`-style content),
  140. * preserving every line and key while replacing only secret-shaped values.
  141. * @param text - raw file or message text.
  142. * @returns text with detected secrets replaced by the placeholder.
  143. */
  144. redactText(text: string): string {
  145. let output = this.#redactPemBlocks(text)
  146. output = this.#redactAssignments(output)
  147. output = this.#redactUrlCredentials(output)
  148. output = this.#redactBearerTokens(output)
  149. return this.#redactStandaloneTokens(output)
  150. }
  151. #redactPemBlocks(text: string): string {
  152. return text.replace(
  153. /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/g,
  154. this.#placeholder,
  155. )
  156. }
  157. #redactAssignments(text: string): string {
  158. // `key: value`, `key = value`, or `"key": "value"` across YAML/JSON/.env.
  159. return text.replace(
  160. /("?)([A-Za-z0-9_.-]+)\1(\s*[:=]\s*)(["']?)([^\n\r"']+)\4/g,
  161. (match, keyQuote: string, key: string, separator: string, valueQuote: string, value: string) =>
  162. keyLooksSecret(key) && value.trim().length > 0
  163. ? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${this.#placeholder}${valueQuote}`
  164. : match,
  165. )
  166. }
  167. #redactUrlCredentials(text: string): string {
  168. // Redact only the password in `scheme://user:password@host`, keeping host visible.
  169. return text.replace(
  170. /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi,
  171. (_match, prefix: string, _password: string, at: string) => `${prefix}${this.#placeholder}${at}`,
  172. )
  173. }
  174. #redactBearerTokens(text: string): string {
  175. // The candidate must contain a digit: real bearer credentials are never
  176. // letters-only, while prose like "bearer authentication" is.
  177. return text.replace(
  178. /(bearer\s+)((?=[a-z._-]*[0-9])[a-z0-9._-]{8,})/gi,
  179. (_match, prefix: string) => `${prefix}${this.#placeholder}`,
  180. )
  181. }
  182. #redactStandaloneTokens(text: string): string {
  183. // `/` is excluded so package names, file paths, and URLs are never split or
  184. // redacted; a secret containing `/` is still scrubbed piecewise.
  185. return text.replace(/[A-Za-z0-9][A-Za-z0-9+=_.-]{7,}/g, token =>
  186. this.isSecretValue(token) ? this.#placeholder : token)
  187. }
  188. }