UNPKG

@logtape/redaction

Version:

Redact sensitive data from log messages

549 lines (468 loc) 15.5 kB
import { suite } from "@alinea/suite"; import type { LogRecord, Sink } from "@logtape/logtape"; import { assert } from "@std/assert/assert"; import { assertEquals } from "@std/assert/equals"; import { assertExists } from "@std/assert/exists"; import { assertFalse } from "@std/assert/false"; import { type FieldPatterns, redactByField, redactProperties, shouldFieldRedacted, } from "./field.ts"; const test = suite(import.meta); test("shouldFieldRedacted()", () => { { // matches string pattern const fieldPatterns: FieldPatterns = ["password", "secret"]; assertEquals(shouldFieldRedacted("password", fieldPatterns), true); assertEquals(shouldFieldRedacted("secret", fieldPatterns), true); assertEquals(shouldFieldRedacted("username", fieldPatterns), false); } { // matches regex pattern const fieldPatterns: FieldPatterns = [/pass/i, /secret/i]; assertEquals(shouldFieldRedacted("password", fieldPatterns), true); assertEquals(shouldFieldRedacted("secretKey", fieldPatterns), true); assertEquals(shouldFieldRedacted("myPassword", fieldPatterns), true); assertEquals(shouldFieldRedacted("username", fieldPatterns), false); } { // case sensitivity in regex const caseSensitivePatterns: FieldPatterns = [/pass/, /secret/]; const caseInsensitivePatterns: FieldPatterns = [/pass/i, /secret/i]; assertEquals(shouldFieldRedacted("Password", caseSensitivePatterns), false); assertEquals( shouldFieldRedacted("Password", caseInsensitivePatterns), true, ); } }); test("redactProperties()", () => { { // delete action (default) const properties = { username: "user123", password: "secret123", email: "user@example.com", message: "Hello world", }; const result = redactProperties(properties, { fieldPatterns: ["password", "email"], }); assert("username" in result); assertFalse("password" in result); assertFalse("email" in result); assert("message" in result); const nestedObject = { ...properties, nested: { foo: "bar", baz: "qux", passphrase: "asdf", }, }; const result2 = redactProperties(nestedObject, { fieldPatterns: ["password", "email", "passphrase"], }); assert("username" in result2); assertFalse("password" in result2); assertFalse("email" in result2); assert("message" in result2); assert("nested" in result2); assert(typeof result2.nested === "object"); assertExists(result2.nested); assert("foo" in result2.nested); assert("baz" in result2.nested); assertFalse("passphrase" in result2.nested); } { // custom action function const properties = { username: "user123", password: "secret123", token: "abc123", message: "Hello world", }; const result = redactProperties(properties, { fieldPatterns: [/password/i, /token/i], action: () => "REDACTED", }); assertEquals(result.username, "user123"); assertEquals(result.password, "REDACTED"); assertEquals(result.token, "REDACTED"); assertEquals(result.message, "Hello world"); } { // preserves other properties const properties = { username: "user123", data: { nested: "value" }, sensitive: "hidden", }; const result = redactProperties(properties, { fieldPatterns: ["sensitive"], }); assertEquals(result.username, "user123"); assertEquals(result.data, { nested: "value" }); assertFalse("sensitive" in result); } { // redacts fields in objects within arrays const properties = { configs: [ { password: "secret", username: "user1" }, { token: "abc", email: "user2@example.com" }, ], }; const result = redactProperties(properties, { fieldPatterns: ["password", "token"], }); // deno-lint-ignore no-explicit-any const configs = result.configs as any; assertEquals(configs.length, 2); assertEquals(configs[0], { username: "user1" }); assertEquals(configs[1], { email: "user2@example.com" }); } { // preserves non-object items in arrays const properties = { data: [ { password: "secret" }, "plain string", 42, { token: "abc" }, ], }; const result = redactProperties(properties, { fieldPatterns: ["password", "token"], }); // deno-lint-ignore no-explicit-any const data = result.data as any; assertEquals(data.length, 4); assertEquals(data[0], {}); assertEquals(data[1], "plain string"); assertEquals(data[2], 42); assertEquals(data[3], {}); } { // redacts nested arrays within objects in arrays const properties = { items: [ { config: { password: "secret", nestedArray: [ { token: "abc", value: 1 }, { key: "xyz", value: 2 }, ], }, }, ], }; const result = redactProperties(properties, { fieldPatterns: ["password", "token", "key"], }); // deno-lint-ignore no-explicit-any const items = result.items as any; // deno-lint-ignore no-explicit-any const first = items[0] as any; // deno-lint-ignore no-explicit-any const nestedArray = first.config.nestedArray as any; assertEquals(items.length, 1); assertEquals(first.config.password, undefined); assertEquals(nestedArray.length, 2); assertEquals(nestedArray[0], { value: 1 }); assertEquals(nestedArray[1], { value: 2 }); } { // uses custom action in arrays const properties = { users: [ { password: "secret1", name: "user1" }, { password: "secret2", name: "user2" }, ], }; const result = redactProperties(properties, { fieldPatterns: ["password"], action: () => "[REDACTED]", }); // deno-lint-ignore no-explicit-any const users = result.users as any; assertEquals(users.length, 2); assertEquals(users[0], { password: "[REDACTED]", name: "user1", }); assertEquals(users[1], { password: "[REDACTED]", name: "user2", }); } }); test("redactByField()", async () => { { // wraps sink and redacts properties const records: LogRecord[] = []; const originalSink: Sink = (record) => records.push(record); const wrappedSink = redactByField(originalSink, { fieldPatterns: ["password", "token"], }); const record: LogRecord = { level: "info", category: ["test"], message: ["Test message"], rawMessage: "Test message", timestamp: Date.now(), properties: { username: "user123", password: "secret123", token: "abc123", }, }; wrappedSink(record); assertEquals(records.length, 1); assert("username" in records[0].properties); assertFalse("password" in records[0].properties); assertFalse("token" in records[0].properties); } { // uses default field patterns when not specified const records: LogRecord[] = []; const originalSink: Sink = (record) => records.push(record); const wrappedSink = redactByField(originalSink); const record: LogRecord = { level: "info", category: ["test"], message: ["Test message"], rawMessage: "Test message", timestamp: Date.now(), properties: { username: "user123", password: "secret123", email: "user@example.com", apiKey: "xyz789", }, }; wrappedSink(record); assertEquals(records.length, 1); assert("username" in records[0].properties); assertFalse("password" in records[0].properties); assertFalse("email" in records[0].properties); assertFalse("apiKey" in records[0].properties); } { // preserves Disposable behavior let disposed = false; const originalSink: Sink & Disposable = Object.assign( (_record: LogRecord) => {}, { [Symbol.dispose]: () => { disposed = true; }, }, ); const wrappedSink = redactByField(originalSink) as Sink & Disposable; assert(Symbol.dispose in wrappedSink); wrappedSink[Symbol.dispose](); assert(disposed); } { // preserves AsyncDisposable behavior let disposed = false; const originalSink: Sink & AsyncDisposable = Object.assign( (_record: LogRecord) => {}, { [Symbol.asyncDispose]: () => { disposed = true; return Promise.resolve(); }, }, ); const wrappedSink = redactByField(originalSink) as Sink & AsyncDisposable; assert(Symbol.asyncDispose in wrappedSink); await wrappedSink[Symbol.asyncDispose](); assert(disposed); } { // redacts fields in arrays from issue #94 const records: LogRecord[] = []; const originalSink: Sink = (record) => records.push(record); const wrappedSink = redactByField(originalSink, { fieldPatterns: ["password"], }); const record: LogRecord = { level: "info", category: ["test"], message: ["Loaded config"], rawMessage: "Loaded config", timestamp: Date.now(), properties: { configs: [{ password: "secret", username: "user" }], }, }; wrappedSink(record); assertEquals(records.length, 1); // deno-lint-ignore no-explicit-any const configs = records[0].properties.configs as any; assertEquals(configs[0], { username: "user" }); } { // redacts values in message array (string template) const records: LogRecord[] = []; const wrappedSink = redactByField((r) => records.push(r), { fieldPatterns: ["password"], action: () => "[REDACTED]", }); wrappedSink({ level: "info", category: ["test"], message: ["Password is ", "supersecret", ""], rawMessage: "Password is {password}", timestamp: Date.now(), properties: { password: "supersecret" }, }); assertEquals(records[0].message, ["Password is ", "[REDACTED]", ""]); assertEquals(records[0].properties.password, "[REDACTED]"); } { // redacts multiple sensitive fields in message const records: LogRecord[] = []; const wrappedSink = redactByField((r) => records.push(r), { fieldPatterns: ["password", "email"], action: () => "[REDACTED]", }); wrappedSink({ level: "info", category: ["test"], message: ["Login: ", "user@example.com", " with ", "secret123", ""], rawMessage: "Login: {email} with {password}", timestamp: Date.now(), properties: { email: "user@example.com", password: "secret123" }, }); assertEquals(records[0].message[1], "[REDACTED]"); assertEquals(records[0].message[3], "[REDACTED]"); } { // redacts nested property path in message const records: LogRecord[] = []; const wrappedSink = redactByField((r) => records.push(r), { fieldPatterns: ["password"], action: () => "[REDACTED]", }); wrappedSink({ level: "info", category: ["test"], message: ["User password: ", "secret", ""], rawMessage: "User password: {user.password}", timestamp: Date.now(), properties: { user: { password: "secret" } }, }); assertEquals(records[0].message[1], "[REDACTED]"); } { // delete action uses empty string in message const records: LogRecord[] = []; const wrappedSink = redactByField((r) => records.push(r), { fieldPatterns: ["password"], }); wrappedSink({ level: "info", category: ["test"], message: ["Password: ", "secret", ""], rawMessage: "Password: {password}", timestamp: Date.now(), properties: { password: "secret" }, }); assertEquals(records[0].message[1], ""); assertFalse("password" in records[0].properties); } { // non-sensitive field in message is not redacted const records: LogRecord[] = []; const wrappedSink = redactByField((r) => records.push(r), { fieldPatterns: ["password"], action: () => "[REDACTED]", }); wrappedSink({ level: "info", category: ["test"], message: ["Username: ", "johndoe", ""], rawMessage: "Username: {username}", timestamp: Date.now(), properties: { username: "johndoe" }, }); assertEquals(records[0].message[1], "johndoe"); } { // wildcard {*} in message uses redacted properties const records: LogRecord[] = []; const wrappedSink = redactByField((r) => records.push(r), { fieldPatterns: ["password"], action: () => "[REDACTED]", }); const props = { username: "john", password: "secret" }; wrappedSink({ level: "info", category: ["test"], message: ["Props: ", props, ""], rawMessage: "Props: {*}", timestamp: Date.now(), properties: props, }); // The {*} should be replaced with redacted properties assertEquals(records[0].message[1], { username: "john", password: "[REDACTED]", }); assertEquals(records[0].properties.password, "[REDACTED]"); } { // escaped braces are not treated as placeholders const records: LogRecord[] = []; const wrappedSink = redactByField((r) => records.push(r), { fieldPatterns: ["password"], action: () => "[REDACTED]", }); wrappedSink({ level: "info", category: ["test"], message: ["Value: ", "secret", ""], rawMessage: "Value: {{password}} {password}", timestamp: Date.now(), properties: { password: "secret" }, }); // Only the second {password} is a placeholder assertEquals(records[0].message[1], "[REDACTED]"); } { // tagged template literal - redacts by comparing values const records: LogRecord[] = []; const wrappedSink = redactByField((r) => records.push(r), { fieldPatterns: ["password"], action: () => "[REDACTED]", }); const rawMessage = ["Password: ", ""] as unknown as TemplateStringsArray; Object.defineProperty(rawMessage, "raw", { value: rawMessage }); wrappedSink({ level: "info", category: ["test"], message: ["Password: ", "secret", ""], rawMessage, timestamp: Date.now(), properties: { password: "secret" }, }); // Message should be redacted by value comparison assertEquals(records[0].message[1], "[REDACTED]"); assertEquals(records[0].properties.password, "[REDACTED]"); } { // array access path in message const records: LogRecord[] = []; const wrappedSink = redactByField((r) => records.push(r), { fieldPatterns: ["password"], action: () => "[REDACTED]", }); wrappedSink({ level: "info", category: ["test"], message: ["First user password: ", "secret1", ""], rawMessage: "First user password: {users[0].password}", timestamp: Date.now(), properties: { users: [{ password: "secret1" }] }, }); assertEquals(records[0].message[1], "[REDACTED]"); } { // regex pattern matches in message placeholder const records: LogRecord[] = []; const wrappedSink = redactByField((r) => records.push(r), { fieldPatterns: [/pass/i], action: () => "[REDACTED]", }); wrappedSink({ level: "info", category: ["test"], message: ["Passphrase: ", "mysecret", ""], rawMessage: "Passphrase: {passphrase}", timestamp: Date.now(), properties: { passphrase: "mysecret" }, }); assertEquals(records[0].message[1], "[REDACTED]"); } });