Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/utils/redactSensitive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_REDACTED_FIELDS, redactSensitive } from "./redactSensitive.js";

const redact = <T>(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<string, unknown> = { 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<string, unknown>;
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]" });
});
});
13 changes: 7 additions & 6 deletions src/utils/redactSensitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ const REDACTED_PLACEHOLDER = "[REDACTED]";

export function redactSensitive<T>(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<string, unknown> {
Expand All @@ -54,22 +54,23 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return proto === Object.prototype || proto === null;
}

function walk(value: unknown, fields: Set<string>, seen: WeakSet<object>): unknown {
function walk(value: unknown, suffixes: string[], seen: WeakSet<object>): 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;
seen.add(value);
const out: Record<string, unknown> = 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 (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;
Expand Down