Files
accounted/lib/observability/redact.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

116 lines
4.9 KiB
TypeScript

/**
* PII redaction primitives.
*
* This module is the single source of truth for what must never leave the
* process in clear text. It lives under `lib/observability/` rather than
* inside `lib/logger.ts` so that the logger AND the observability sink share
* one implementation: if the denylist lived in the logger only, a direct
* `captureException()` call would reach a third-party provider unredacted.
* Keeping one copy makes drift between the two paths impossible.
*
* The logs this guards carry personnummer, bank account numbers and financial
* data. Under GDPR that data must not be shipped to an error-tracking vendor,
* so redaction runs on every path into the sink, not just on the log path.
*
* Three mechanisms:
* 1. A key denylist (`REDACT_KEYS`): any object key matching case-insensitively
* has its whole value replaced, however deeply nested.
* 2. A personnummer regex applied to every string. UUIDs are stripped first,
* because a UUID's hex runs can otherwise look like a 10/12-digit
* personnummer and would nuke useful ids. A hit replaces the WHOLE string:
* the digits around a personnummer are usually themselves identifying.
* 3. Substring patterns for emails, Swedish IBANs and gnubok API keys,
* replaced in place with a placeholder naming what was removed. These are
* self-delimiting secrets, so the rest of the string (a log line, a stack
* trace) stays useful.
*
* `redact()` is idempotent: running it twice is safe and produces the same
* result, which is what lets the sink re-redact records the logger already
* cleaned without changing them.
*
* This module must not import anything (least of all the logger): it sits at
* the bottom of the import graph so nothing can create a cycle through it.
*/
export const REDACTED = '[REDACTED]'
export const REDACT_KEYS = new Set([
'password',
'token',
'access_token',
'refresh_token',
'apikey',
'api_key',
'secret',
'authorization',
'cookie',
'bank_account',
'bankaccount',
'iban',
'personnummer',
'ssn',
'credentials',
])
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
const PERSONNUMMER_PATTERN = /\b\d{6}-?\d{4}\b|\b\d{8}-?\d{4}\b/
// gnubok API keys (secret keys and invite tokens). The wire-format prefix is
// permanent (see CLAUDE.md: the gnubok_* prefixes are kept on purpose), so
// this pattern will not rot with the Accounted rename.
const API_KEY_PATTERN = /gnubok_(?:sk|inv)_[A-Za-z0-9_]+/g
// Swedish IBAN: SE + 22 digits, optionally grouped with spaces
// ("SE4550000000058398257466" or "SE45 5000 0000 0583 9825 7466").
const SE_IBAN_PATTERN = /\bSE(?:\s?\d){22}\b/g
// Pragmatic email shape: enough to catch real addresses in log lines without
// matching package specs like "pkg@1.2.3" (the domain must end in a TLD).
const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
export function redactString(value: string): string {
// Strip UUIDs first to avoid false-positive personnummer matches
const stripped = value.replace(UUID_PATTERN, '')
if (PERSONNUMMER_PATTERN.test(stripped)) {
return REDACTED
}
// Substring secrets are replaced in place (distinct placeholders, so an
// adapter or a human can tell WHAT was removed). The placeholders contain
// no digits and no '@', so re-running redactString over them is a no-op:
// this is what keeps redact() idempotent.
return value
.replace(API_KEY_PATTERN, '[REDACTED_API_KEY]')
.replace(SE_IBAN_PATTERN, '[REDACTED_IBAN]')
.replace(EMAIL_PATTERN, '[REDACTED_EMAIL]')
}
export function redact(value: unknown, keyPath = ''): unknown {
if (value === null || value === undefined) return value
if (typeof value === 'string') return redactString(value)
if (typeof value === 'number' || typeof value === 'boolean') return value
if (value instanceof Date) return value.toISOString()
if (value instanceof Error) {
return {
name: value.name,
message: redactString(value.message),
// The stack is kept (redacted like any other string) in EVERY
// environment: the observability sink only runs in production, and a
// stackless event is useless to group on. Production STDOUT still drops
// it: that stripping happens in the logger's emit path (lib/logger.ts),
// which is the only consumer that wants stackless records.
stack: typeof value.stack === 'string' ? redactString(value.stack) : undefined,
code: (value as Error & { code?: unknown }).code,
}
}
if (Array.isArray(value)) return value.map((v, i) => redact(v, `${keyPath}[${i}]`))
if (typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (REDACT_KEYS.has(k.toLowerCase())) {
out[k] = REDACTED
} else {
out[k] = redact(v, keyPath ? `${keyPath}.${k}` : k)
}
}
return out
}
return value
}