diff --git a/src/logging.ts b/src/logging.ts index bb6de60..2e2eb27 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -1,11 +1,15 @@ import { createConsola } from "consola"; import { updateConfig } from "./format.js"; -import type { RedactConfig } from "./redact.js"; +import { + mergeRedactConfigs, + parseEnvRedactConfig, + type RedactConfig, +} from "./redact.js"; import DatadogReporter from "./reporters/datadog.js"; import DiscordReporter from "./reporters/discord.js"; const setupLogging = (config: RedactConfig = {}) => { - updateConfig(config); + updateConfig(mergeRedactConfigs(config, parseEnvRedactConfig())); const consola = createConsola({ fancy: true, level: 5 }); // Set Discord reporter as first so it can remove diff --git a/src/redact.ts b/src/redact.ts index 4b4bd75..9c5007d 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -1,6 +1,7 @@ import { DeepRedact } from "@hackylabs/deep-redact/index.ts"; import { validateMnemonic } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; +import { type } from "arktype"; /** * Configurable redaction for strings that *look like secrets*: @@ -239,5 +240,64 @@ const createRedact = (config: RedactConfig) => { }; }; -export { createRedact }; +const SerializableContextRuleType = type({ + re: "string", + "flags?": "string", + "before?": "number", + "after?": "number", +}).pipe( + (rule): ContextRule => ({ + re: new RegExp(rule.re, rule.flags ?? ""), + before: rule.before, + after: rule.after, + }), +); + +const SerializableDetectorConfigType = type({ + "allow?": SerializableContextRuleType.array(), +}); + +const SerializableRedactConfigType = type({ + "hex?": SerializableDetectorConfigType, + "base64?": SerializableDetectorConfigType, + "base64url?": SerializableDetectorConfigType, + "base58?": SerializableDetectorConfigType, + "mnemonic?": SerializableDetectorConfigType, +}); + +const mergeDetectorConfigs = ( + a?: DetectorConfig, + b?: DetectorConfig, +): DetectorConfig | undefined => { + if (!a && !b) return undefined; + const allowA = a?.allow ?? []; + const allowB = b?.allow ?? []; + const merged = [...allowA, ...allowB]; + return { allow: merged.length > 0 ? merged : undefined }; +}; + +const mergeRedactConfigs = ( + a: RedactConfig, + b: RedactConfig, +): RedactConfig => ({ + hex: mergeDetectorConfigs(a.hex, b.hex), + base64: mergeDetectorConfigs(a.base64, b.base64), + base64url: mergeDetectorConfigs(a.base64url, b.base64url), + base58: mergeDetectorConfigs(a.base58, b.base58), + mnemonic: mergeDetectorConfigs(a.mnemonic, b.mnemonic), +}); + +const parseEnvRedactConfig = (): RedactConfig => { + const raw = process.env.REDACT_CONFIG; + if (!raw) return {}; + + const json = Buffer.from(raw, "base64").toString("utf8"); + const parsed: unknown = JSON.parse(json); + const validated = SerializableRedactConfigType(parsed); + if (validated instanceof type.errors) + throw new Error(`REDACT_CONFIG validation failed: ${validated.summary}`); + return validated; +}; + +export { createRedact, mergeRedactConfigs, parseEnvRedactConfig }; export type { RedactConfig }; diff --git a/tests/redact.test.ts b/tests/redact.test.ts index 2b0a158..e1eb4cf 100644 --- a/tests/redact.test.ts +++ b/tests/redact.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { createRedact } from "../src/redact.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createRedact, + mergeRedactConfigs, + parseEnvRedactConfig, +} from "../src/redact.js"; describe("redact", () => { it("redacts base64url", () => { @@ -54,3 +58,111 @@ describe("redact", () => { ); }); }); + +describe("mergeRedactConfigs", () => { + it("merges allow rules from both configs", () => { + const a = { hex: { allow: [{ re: /\b(event)\b/i }] } }; + const b = { hex: { allow: [{ re: /\b(transfer)\b/i }] } }; + const merged = mergeRedactConfigs(a, b); + const redact = createRedact(merged); + + const eventResult = redact( + "Pushed event fcc6533b59301096a973b8be3e6518f0cd13f73a9821de558cca77ac9b014d6e.1771865100000", + ); + expect(eventResult).toContain( + "fcc6533b59301096a973b8be3e6518f0cd13f73a9821de558cca77ac9b014d6e", + ); + + const transferResult = redact( + "transfer 538845bf2f418e0c7f3798d6bcb632273d46633545a5e261feceb7d378ed0761", + ); + expect(transferResult).toContain( + "538845bf2f418e0c7f3798d6bcb632273d46633545a5e261feceb7d378ed0761", + ); + }); + + it("handles empty configs", () => { + const merged = mergeRedactConfigs({}, {}); + expect(merged).toEqual({ + hex: undefined, + base64: undefined, + base64url: undefined, + base58: undefined, + mnemonic: undefined, + }); + }); + + it("uses only a config when b is empty", () => { + const a = { hex: { allow: [{ re: /\b(event)\b/i }] } }; + const merged = mergeRedactConfigs(a, {}); + const redact = createRedact(merged); + const result = redact( + "Pushed event fcc6533b59301096a973b8be3e6518f0cd13f73a9821de558cca77ac9b014d6e.1771865100000", + ); + expect(result).toContain( + "fcc6533b59301096a973b8be3e6518f0cd13f73a9821de558cca77ac9b014d6e", + ); + }); +}); + +describe("parseEnvRedactConfig", () => { + afterEach(() => { + delete process.env.REDACT_CONFIG; + }); + + it("returns empty config when REDACT_CONFIG is not set", () => { + delete process.env.REDACT_CONFIG; + const config = parseEnvRedactConfig(); + expect(config).toEqual({}); + }); + + it("parses base64-encoded JSON config", () => { + const serializable = { + hex: { allow: [{ re: "\\b(event)\\b", flags: "i" }] }, + }; + process.env.REDACT_CONFIG = Buffer.from( + JSON.stringify(serializable), + ).toString("base64"); + const config = parseEnvRedactConfig(); + + expect(config.hex?.allow).toHaveLength(1); + expect(config.hex?.allow?.[0].re).toBeInstanceOf(RegExp); + expect(config.hex?.allow?.[0].re.source).toBe("\\b(event)\\b"); + expect(config.hex?.allow?.[0].re.flags).toContain("i"); + }); + + it("throws on invalid base64", () => { + process.env.REDACT_CONFIG = "!!!not-valid-base64!!!"; + expect(() => parseEnvRedactConfig()).toThrow(); + }); + + it("throws on invalid JSON", () => { + process.env.REDACT_CONFIG = Buffer.from("{not json}").toString("base64"); + expect(() => parseEnvRedactConfig()).toThrow(); + }); + + it("throws when JSON structure fails arktype validation", () => { + process.env.REDACT_CONFIG = Buffer.from( + JSON.stringify({ hex: { allow: [{ re: 123 }] } }), + ).toString("base64"); + expect(() => parseEnvRedactConfig()).toThrow(); + }); + + it("applies env config allow rules when redacting", () => { + const serializable = { + hex: { allow: [{ re: "\\b(transfer)\\b", flags: "i" }] }, + }; + process.env.REDACT_CONFIG = Buffer.from( + JSON.stringify(serializable), + ).toString("base64"); + const envConfig = parseEnvRedactConfig(); + + const redact = createRedact(envConfig); + const result = redact( + "transfer 538845bf2f418e0c7f3798d6bcb632273d46633545a5e261feceb7d378ed0761", + ); + expect(result).toBe( + "transfer 538845bf2f418e0c7f3798d6bcb632273d46633545a5e261feceb7d378ed0761", + ); + }); +});