From daf73cad6fa85951f8f2534be87aa89188983ed6 Mon Sep 17 00:00:00 2001 From: Ian Fayers Date: Tue, 4 Aug 2026 12:12:26 +0100 Subject: [PATCH 1/2] fix: redact provider-prefixed secret fields in API responses --- src/utils/redactSensitive.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/redactSensitive.ts b/src/utils/redactSensitive.ts index d1374fa..98cf0a0 100644 --- a/src/utils/redactSensitive.ts +++ b/src/utils/redactSensitive.ts @@ -66,7 +66,8 @@ function walk(value: unknown, fields: Set, seen: WeakSet): unkno const out: Record = Object.create(null); for (const [key, val] of Object.entries(value)) { if (key === "__proto__" || key === "constructor" || key === "prototype") continue; - if (fields.has(key.toLowerCase())) { + const loweredKey = key.toLowerCase(); + if (fields.has(loweredKey) || [...fields].some((f) => loweredKey.endsWith(f))) { out[key] = val === null || val === undefined ? val : REDACTED_PLACEHOLDER; } else { out[key] = walk(val, fields, seen); From 081ec3c20233aefecbea41dadd8076c66d129a51 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 6 Aug 2026 23:50:25 -0600 Subject: [PATCH 2/2] test: cover redaction suffix matching; precompute suffix list Adds a test suite for redactSensitive covering the provider-prefixed fields from #65 (githubPrivateKey, githubClientSecret, githubWebhookSecret), nesting, arrays, case-insensitivity, null passthrough, circular structures, and prototype-pollution keys. Also hoists the lowered suffix list out of the per-key loop. Co-Authored-By: Claude Fable 5 --- src/utils/redactSensitive.test.ts | 85 +++++++++++++++++++++++++++++++ src/utils/redactSensitive.ts | 12 ++--- 2 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 src/utils/redactSensitive.test.ts diff --git a/src/utils/redactSensitive.test.ts b/src/utils/redactSensitive.test.ts new file mode 100644 index 0000000..09628b3 --- /dev/null +++ b/src/utils/redactSensitive.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_REDACTED_FIELDS, redactSensitive } from "./redactSensitive.js"; + +const redact = (data: T) => redactSensitive(data, DEFAULT_REDACTED_FIELDS); + +describe("redactSensitive", () => { + it("redacts exact default field names", () => { + const result = redact({ password: "hunter2", env: "KEY=value", apiKey: "sk-123" }); + expect(result).toEqual({ password: "[REDACTED]", env: "[REDACTED]", apiKey: "[REDACTED]" }); + }); + + it("redacts provider-prefixed secret fields (issue #65)", () => { + const result = redact({ + githubPrivateKey: "-----BEGIN RSA PRIVATE KEY-----", + githubClientSecret: "ghs_abc", + githubWebhookSecret: "whsec_abc", + gitlabAccessToken: "glpat-abc", + awsSecretAccessKey: "aws-abc", + }); + expect(result).toEqual({ + githubPrivateKey: "[REDACTED]", + githubClientSecret: "[REDACTED]", + githubWebhookSecret: "[REDACTED]", + gitlabAccessToken: "[REDACTED]", + awsSecretAccessKey: "[REDACTED]", + }); + }); + + it("matches case-insensitively", () => { + const result = redact({ PASSWORD: "x", GithubPrivateKey: "y" }); + expect(result).toEqual({ PASSWORD: "[REDACTED]", GithubPrivateKey: "[REDACTED]" }); + }); + + it("preserves null and undefined values in sensitive fields", () => { + const result = redact({ password: null, token: undefined }); + expect(result).toEqual({ password: null, token: undefined }); + }); + + it("redacts inside nested objects and arrays", () => { + const result = redact({ + apps: [{ name: "web", env: "SECRET=1" }, { config: { registryPassword: "p" } }], + }); + expect(result).toEqual({ + apps: [ + { name: "web", env: "[REDACTED]" }, + { config: { registryPassword: "[REDACTED]" } }, + ], + }); + }); + + it("leaves non-sensitive fields untouched", () => { + const data = { appName: "web", domain: "example.com", port: 3000, https: true }; + expect(redact(data)).toEqual(data); + }); + + it("returns data unchanged when the field list is empty", () => { + const data = { password: "visible" }; + expect(redactSensitive(data, [])).toBe(data); + }); + + it("supports custom field lists via suffix match", () => { + const result = redactSensitive({ myCustomField: "x", other: "y" }, ["customField"]); + expect(result).toEqual({ myCustomField: "[REDACTED]", other: "y" }); + }); + + it("does not hang on circular structures", () => { + const data: Record = { name: "a" }; + data.self = data; + expect(() => redact(data)).not.toThrow(); + }); + + it("drops prototype-pollution keys", () => { + const data = JSON.parse('{"__proto__": {"polluted": true}, "name": "safe"}'); + const result = redact(data) as Record; + expect(Object.keys(result)).toEqual(["name"]); + }); + + // Known, accepted collateral of suffix matching: flag-style keys that end in a + // sensitive word (e.g. isSecret) are also redacted. Erring toward redaction is + // intentional for security-sensitive output. + it("redacts flag-like keys ending in a sensitive suffix", () => { + const result = redact({ isSecret: true }); + expect(result).toEqual({ isSecret: "[REDACTED]" }); + }); +}); diff --git a/src/utils/redactSensitive.ts b/src/utils/redactSensitive.ts index 98cf0a0..4b5a8ec 100644 --- a/src/utils/redactSensitive.ts +++ b/src/utils/redactSensitive.ts @@ -44,8 +44,8 @@ const REDACTED_PLACEHOLDER = "[REDACTED]"; export function redactSensitive(data: T, fields: string[]): T { if (fields.length === 0) return data; - const lowered = new Set(fields.map((f) => f.toLowerCase())); - return walk(data, lowered, new WeakSet()) as T; + const suffixes = fields.map((f) => f.toLowerCase()); + return walk(data, suffixes, new WeakSet()) as T; } function isPlainObject(value: unknown): value is Record { @@ -54,11 +54,11 @@ function isPlainObject(value: unknown): value is Record { return proto === Object.prototype || proto === null; } -function walk(value: unknown, fields: Set, seen: WeakSet): unknown { +function walk(value: unknown, suffixes: string[], seen: WeakSet): unknown { if (Array.isArray(value)) { if (seen.has(value)) return value; seen.add(value); - return value.map((item) => walk(item, fields, seen)); + return value.map((item) => walk(item, suffixes, seen)); } if (isPlainObject(value)) { if (seen.has(value)) return value; @@ -67,10 +67,10 @@ function walk(value: unknown, fields: Set, seen: WeakSet): unkno for (const [key, val] of Object.entries(value)) { if (key === "__proto__" || key === "constructor" || key === "prototype") continue; const loweredKey = key.toLowerCase(); - if (fields.has(loweredKey) || [...fields].some((f) => loweredKey.endsWith(f))) { + if (suffixes.some((suffix) => loweredKey.endsWith(suffix))) { out[key] = val === null || val === undefined ? val : REDACTED_PLACEHOLDER; } else { - out[key] = walk(val, fields, seen); + out[key] = walk(val, suffixes, seen); } } return out;