212 lines
6.8 KiB
TypeScript
212 lines
6.8 KiB
TypeScript
import { Logger, redact } from "../src/logger.js";
|
|
import { SimpleStructuredLogger } from "../src/v3/utils/structuredLogger.js";
|
|
|
|
function captureLogLine(fn: () => void): Record<string, any> {
|
|
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
|
|
|
|
try {
|
|
fn();
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
const [line] = spy.mock.calls[0] as [string];
|
|
return JSON.parse(line);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
|
|
function captureErrorLogLine(fn: () => void): Record<string, any> {
|
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
try {
|
|
fn();
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
const [line] = spy.mock.calls[0] as [string];
|
|
return JSON.parse(line);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
|
|
describe("Logger redaction", () => {
|
|
it("redacts default deny-listed keys without any caller-supplied filteredKeys", () => {
|
|
const logger = new Logger("test", "info");
|
|
|
|
const line = captureLogLine(() =>
|
|
logger.info("run started", {
|
|
payload: { secret: "value" },
|
|
apiKey: "tr_prod_should_not_appear",
|
|
harmless: "keep me",
|
|
})
|
|
);
|
|
|
|
expect(line.payload).toMatch(/^\[filtered/);
|
|
expect(line.apiKey).toMatch(/^\[filtered/);
|
|
expect(line.harmless).toBe("keep me");
|
|
expect(JSON.stringify(line)).not.toContain("tr_prod_should_not_appear");
|
|
});
|
|
|
|
it("matches deny-listed keys case-insensitively", () => {
|
|
const logger = new Logger("test", "info");
|
|
|
|
const line = captureLogLine(() =>
|
|
logger.info("case check", {
|
|
ApiKey: "tr_prod_secret",
|
|
AUTHORIZATION: "Bearer abc123",
|
|
})
|
|
);
|
|
|
|
expect(line.ApiKey).toMatch(/^\[filtered/);
|
|
expect(line.AUTHORIZATION).toMatch(/^\[filtered/);
|
|
});
|
|
|
|
it("recurses into nested objects and arrays", () => {
|
|
const logger = new Logger("test", "info");
|
|
|
|
const line = captureLogLine(() =>
|
|
logger.info("nested check", {
|
|
run: {
|
|
items: [{ metadata: { token: "abc" } }, { metadata: { token: "def" } }],
|
|
},
|
|
})
|
|
);
|
|
|
|
for (const item of line.run.items) {
|
|
expect(item.metadata).toMatch(/^\[filtered/);
|
|
}
|
|
});
|
|
|
|
it("still honors caller-supplied filteredKeys in addition to the defaults", () => {
|
|
const logger = new Logger("test", "info", ["connectionString"]);
|
|
|
|
const line = captureLogLine(() =>
|
|
logger.info("db check", { connectionString: "postgres://user:pass@host/db" })
|
|
);
|
|
|
|
expect(line.connectionString).toMatch(/^\[filtered/);
|
|
});
|
|
|
|
it("filters every argument, not only a single object argument", () => {
|
|
const logger = new Logger("test", "info");
|
|
|
|
const line = captureLogLine(() =>
|
|
logger.info("two args", { first: "ok" }, { apiKey: "tr_prod_secret_value" })
|
|
);
|
|
|
|
expect(JSON.stringify(line)).not.toContain("tr_prod_secret_value");
|
|
});
|
|
|
|
it("redacts values that contain a secret even under a non-denied key", () => {
|
|
const logger = new Logger("test", "info");
|
|
|
|
const line = captureLogLine(() =>
|
|
logger.info("value pattern check", {
|
|
someRandomField: "request failed with tr_live_abcdef123456",
|
|
anotherField: "authorization used Bearer some.jwt.value",
|
|
normalField: "just some text",
|
|
})
|
|
);
|
|
|
|
expect(line.someRandomField).toMatch(/^\[filtered/);
|
|
expect(line.anotherField).toMatch(/^\[filtered/);
|
|
expect(line.normalField).toBe("just some text");
|
|
});
|
|
|
|
it("redacts a structured message before assigning it to $message", () => {
|
|
const logger = new Logger("test", "info");
|
|
|
|
const line = captureLogLine(() =>
|
|
logger.info("structured message check", {
|
|
message: "request failed with Bearer secret.jwt.value",
|
|
})
|
|
);
|
|
|
|
expect(line.$message).toMatch(/^\[filtered/);
|
|
expect(JSON.stringify(line)).not.toContain("secret.jwt.value");
|
|
});
|
|
|
|
it("truncates strings longer than the per-field cap", () => {
|
|
const logger = new Logger("test", "info");
|
|
const longValue = "a".repeat(20_000);
|
|
|
|
const line = captureLogLine(() => logger.info("truncation check", { longValue }));
|
|
|
|
expect(line.longValue.length).toBeLessThan(longValue.length);
|
|
expect(line.longValue).toContain("[truncated");
|
|
});
|
|
|
|
it("truncates arrays longer than the max array length", () => {
|
|
const logger = new Logger("test", "info");
|
|
const bigArray = Array.from({ length: 500 }, (_, i) => i);
|
|
|
|
const line = captureLogLine(() => logger.info("array truncation check", { bigArray }));
|
|
|
|
expect(line.bigArray.length).toBeLessThan(bigArray.length);
|
|
expect(line.bigArray[line.bigArray.length - 1]).toContain("truncated");
|
|
});
|
|
|
|
it("runs error metadata through the same key-based redaction as everything else", () => {
|
|
const logger = new Logger("test", "info");
|
|
|
|
const error = new Error("a plain failure message") as Error & { metadata?: unknown };
|
|
error.metadata = { apiKey: "tr_prod_should_not_leak" };
|
|
|
|
const line = captureErrorLogLine(() => logger.error("boom", { error }));
|
|
|
|
expect(line.error.message).toBe("a plain failure message");
|
|
expect(line.error.metadata.apiKey).toMatch(/^\[filtered/);
|
|
expect(JSON.stringify(line)).not.toContain("tr_prod_should_not_leak");
|
|
});
|
|
|
|
it("redacts an error message whose entire value is a bare secret", () => {
|
|
const logger = new Logger("test", "info");
|
|
|
|
const error = new Error("tr_prod_secret_value_leaked");
|
|
|
|
const line = captureErrorLogLine(() => logger.error("boom", { error }));
|
|
|
|
expect(line.error.message).toMatch(/^\[filtered/);
|
|
});
|
|
});
|
|
|
|
describe("SimpleStructuredLogger redaction", () => {
|
|
it("redacts fields and arguments with the default deny-list", () => {
|
|
const logger = new SimpleStructuredLogger("test");
|
|
|
|
const line = captureLogLine(() =>
|
|
logger.child({ headers: { authorization: "Bearer should-not-appear" } }).info("run started", {
|
|
payload: { secret: "value" },
|
|
apiKey: "tr_prod_should_not_appear",
|
|
harmless: "keep me",
|
|
})
|
|
);
|
|
|
|
expect(line.headers).toMatch(/^\[filtered/);
|
|
expect(line.payload).toMatch(/^\[filtered/);
|
|
expect(line.apiKey).toMatch(/^\[filtered/);
|
|
expect(line.harmless).toBe("keep me");
|
|
expect(JSON.stringify(line)).not.toContain("should-not-appear");
|
|
});
|
|
});
|
|
|
|
describe("redact()", () => {
|
|
it("applies the same default deny-list and truncation as the Logger", () => {
|
|
const result = redact({
|
|
apiKey: "tr_prod_secret_value",
|
|
email: "user@example.com",
|
|
keep: "this stays",
|
|
}) as Record<string, unknown>;
|
|
|
|
expect(result.apiKey).toMatch(/^\[filtered/);
|
|
expect(result.email).toMatch(/^\[filtered/);
|
|
expect(result.keep).toBe("this stays");
|
|
});
|
|
|
|
it("merges in caller-supplied filteredKeys", () => {
|
|
const result = redact({ connectionString: "postgres://user:pass@host/db" }, [
|
|
"connectionString",
|
|
]) as Record<string, unknown>;
|
|
|
|
expect(result.connectionString).toMatch(/^\[filtered/);
|
|
});
|
|
});
|