Node.js und Logging – Teil 6: Loginhalte filtern (DSGVO)
Logs sind schnell geschrieben – und genauso schnell landen darin sensible Daten. In diesem Artikel zeige ich, wie ich IP-Adressen und E-Mails zentral aus dem Logging entferne, ohne bei jedem Log-Statement neu nachdenken zu müssen. Mit RegEx, einem Winston-Format-Wrapper und einer sauberen Integration in den LogService entsteht eine robuste Lösung für mehr Sicherheit und DSGVO-Bewusstsein.
Im heutigen Artikel kümmern wir uns darum, sensible Informationen aus unseren Logs fernzuhalten. Meine Erfahrung hat mich gelehrt, beim Logging nicht zu überlegen, ob ich gerade sicherheits- oder DSGVO-relevante Informationen zur Hand habe. Um mir diese mentale Last nicht bei jedem Log-Statement aufzubürden, habe ich entschieden, das Problem im LogService zentral zu lösen.
Für diesen Ansatz werden wir folgende Schritte vornehmen:
- Reguläre Ausdrücke für IP- und E-Mail-Adressen definieren.
- Winston-Format-Wrapper zum Ersetzen schreiben.
- Hilfsmethode in das Winston-Logging einbinden.
Bevor wir loslegen, schau dir die bisherigen Artikel dieser Serie an. Du findest die Links im Überblick zur Artikelserie.
1. Reguläre Ausdrücke definieren
Ich habe es mir angewöhnt, generische Dinge am selben Ort – in diesem Fall im Verzeichnis /src/utils – abzulegen. Dort erstellen wir eine Datei namens RegEx.ts. In unserem Fall wollen wir die IP-Adressen-Versionen 4 und 6 erkennen. Letztere sind extrem komplex. Gleiches gilt für E-Mail-Adressen. Die regulären Ausdrücke sind entsprechend umfangreich, haben sich allerdings im Laufe der Jahre bewährt.
Achtung: Die regulären Ausdrücke sollen mit /g enden, damit sie beim Aufruf von replace() alle Vorkommnisse ersetzen.
export const REGEX_IP4_ADDRESS = /(\d{1,3}\.){3}\d{1,3}/g
export const REGEX_IP6_ADDRESS = /^((?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,7}:|(?:[A-Fa-f0-9]{1,4}:){1,6}:[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,5}(?::[A-Fa-f0-9]{1,4}){1,2}|(?:[A-Fa-f0-9]{1,4}:){1,4}(?::[A-Fa-f0-9]{1,4}){1,3}|(?:[A-Fa-f0-9]{1,4}:){1,3}(?::[A-Fa-f0-9]{1,4}){1,4}|(?:[A-Fa-f0-9]{1,4}:){1,2}(?::[A-Fa-f0-9]{1,4}){1,5}|[A-Fa-f0-9]{1,4}:(?:(?::[A-Fa-f0-9]{1,4}){1,6})|:(?:(?::[A-Fa-f0-9]{1,4}){1,7}|:)|fe80:(?::[A-Fa-f0-9]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(?::0{1,4}){0,1}:){0,1}(?:(?:25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(?:25[0-5]|(2[0-4]|1?[0-9])?[0-9])|(?:[A-Fa-f0-9]{1,4}:){1,4}:(?:(?:25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(?:25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/g
export const REGEX_EMAIL = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?<domain>(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\]))/g
2. Winston-Format-Wrapper schreiben
Der Winston-Format-Wrapper ermöglicht den Zugriff auf das Info-Objekt und die Änderung desselben. Um dies zu verdeutlichen, hier die Signatur der Funktion:
TransformFunction = (info: TransformableInfo, opts?: unknown) => TransformableInfo | boolean
Wir bekommen das Info-Objekt vom Typ TransformableInfo hinein und geben es wieder aus. Zwischendurch durchlaufen wir die Metadaten rekursiv und ersetzen dabei die fraglichen Zeichenketten.
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } | object
type Redactable = JsonValue
// This might have performance implications, but logging should not be a bottleneck in normal operations
// So we accept this trade-off for better security and privacy
const redactSensitiveData = winston.format((info) => {
function redact (value: Redactable): Redactable {
if (typeof value === 'string') {
return value
.replace(REGEX_IP4_ADDRESS, 'xxx.xxx.xxx.xxx')
.replace(REGEX_IP6_ADDRESS, 'x:x:x:x:x:x:x:x')
.replace(REGEX_EMAIL, 'xxx@$<domain>')
}
if (Array.isArray(value)) {
return value.map(redact)
}
if (value && typeof value === 'object' && !Buffer.isBuffer(value)) {
const obj = value as Record<string, Redactable>
const result: Record<string, Redactable> = {
}
for (const [key, val] of Object.entries(obj)) {
result[key] = redact(val)
}
return result
}
return value
}
// Redact message
if (info.message) {
info.message = redact(info.message)
}
// Redact entire info object except level, message, timestamp
const {level, message, ...meta} = info
Object.assign(info, redact(meta))
return info
})
3. Hilfsmethode in Logger einbinden
Im LogService definieren wir die Formate abhängig von den Umgebungsvariablen. In den entsprechenden Funktionen wie getDefaultFormat hängen wir den Format-Wrapper als letzten Aufruf vor dem eigentlichen Output-Format. Beispielhaft sieht das wie folgt aus:
winston.format.combine(
// ... important - add after attachMetadata
attachMetadata(),
redactSensitiveData(), // <- add here
logFormat,
)
Fazit
Durch die Verwendung des LogServices haben wir die Logik zur Vermeidung des Loggings sensibler Informationen an zentraler Stelle gekapselt. Als Denkanstoss noch eine Idee:
Solltest du API-Tokens in deiner Applikation nutzen, identifiziere deren Format, notiere den dazugehörigen RegEx (z. B. const REGEX_ACCESS_TOKEN = /token_.{10}/g) und ergänze die Redact-Funktion um ein weiteres Replace (z. B. .replace(REGEX_ACCESS_TOKEN, 'token_xxxxxxxxxx').
Im nächsten Artikel greifen wir das Konzept der Correlation-ID auf. Ich erkläre, warum sie hilfreich ist und wie wir die ID in unsere Endpunkte integrieren.