Skip to content

Commit 2f1734c

Browse files
authored
fix(core,webapp): redact sensitive fields in logs by default and cap their size (#4401)
1 parent 8ebc8a4 commit 2f1734c

5 files changed

Lines changed: 436 additions & 36 deletions

File tree

apps/webapp/app/services/logger.server.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { LogLevel } from "@trigger.dev/core/logger";
2-
import { Logger } from "@trigger.dev/core/logger";
2+
import { Logger, redact } from "@trigger.dev/core/logger";
33
import { patchConsoleToTelnet, startTelnetLogServer } from "@trigger.dev/core/v3/telnetLogServer";
44
import { sensitiveDataReplacer } from "./sensitiveDataReplacer";
55
import { AsyncLocalStorage } from "async_hooks";
@@ -12,24 +12,40 @@ export function trace<T>(fields: Record<string, unknown>, fn: () => T): T {
1212
return currentFieldsStore.run(fields, fn);
1313
}
1414

15+
// The keys below aren't already in the Logger's default deny-list. Passing them here means the
16+
// extra data sent to Sentry gets the same redaction as the stdout line, instead of bypassing it.
17+
const SENTRY_EXTRA_FILTERED_KEYS = ["examples", "connectionString"];
18+
1519
Logger.onError = (message, ...args) => {
1620
const error = extractErrorFromArgs(args);
21+
const extra = redact(flattenArgs(args), SENTRY_EXTRA_FILTERED_KEYS) as Record<string, unknown>;
1722

1823
if (error) {
19-
captureException(error, {
24+
captureException(redactError(error), {
2025
extra: {
2126
message,
22-
...flattenArgs(args),
27+
...extra,
2328
},
2429
});
2530
} else {
2631
captureMessage(message, {
2732
level: "error",
28-
extra: flattenArgs(args),
33+
extra,
2934
});
3035
}
3136
};
3237

38+
function redactError(error: Error): Error {
39+
const redactedError = new Error(redact(error.message) as string);
40+
redactedError.name = error.name;
41+
42+
if (error.stack) {
43+
redactedError.stack = redact(error.stack) as string;
44+
}
45+
46+
return redactedError;
47+
}
48+
3349
function extractErrorFromArgs(args: Array<Record<string, unknown> | undefined>) {
3450
for (const arg of args) {
3551
if (arg && "error" in arg && arg.error instanceof Error) {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const captureExceptionMock = vi.fn();
4+
const captureMessageMock = vi.fn();
5+
6+
vi.mock("@sentry/remix", () => ({
7+
captureException: captureExceptionMock,
8+
captureMessage: captureMessageMock,
9+
}));
10+
11+
describe("logger.server Logger.onError", () => {
12+
beforeEach(() => {
13+
captureExceptionMock.mockClear();
14+
captureMessageMock.mockClear();
15+
vi.spyOn(console, "error").mockImplementation(() => {});
16+
});
17+
18+
it("redacts the extra payload sent to Sentry on the captureMessage path", async () => {
19+
const { logger } = await import("~/services/logger.server");
20+
21+
logger.error("something failed", {
22+
payload: { secret: "do-not-leak" },
23+
apiKey: "tr_prod_should_not_leak",
24+
});
25+
26+
expect(captureMessageMock).toHaveBeenCalledTimes(1);
27+
const [, options] = captureMessageMock.mock.calls[0] as [string, { extra: unknown }];
28+
29+
expect(JSON.stringify(options.extra)).not.toContain("do-not-leak");
30+
expect(JSON.stringify(options.extra)).not.toContain("tr_prod_should_not_leak");
31+
});
32+
33+
it("redacts the exception and extra payload sent to Sentry", async () => {
34+
const { logger } = await import("~/services/logger.server");
35+
const error = new Error("tr_prod_should_not_leak");
36+
error.stack = "Bearer secret-token";
37+
38+
logger.error("boom", {
39+
error,
40+
payload: { secret: "do-not-leak" },
41+
});
42+
43+
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
44+
const [capturedError, options] = captureExceptionMock.mock.calls[0] as [
45+
Error,
46+
{ extra: unknown },
47+
];
48+
49+
expect(capturedError).not.toBe(error);
50+
expect(capturedError.message).not.toContain("tr_prod_should_not_leak");
51+
expect(capturedError.stack).not.toContain("secret-token");
52+
expect(JSON.stringify(options.extra)).not.toContain("do-not-leak");
53+
});
54+
55+
it("still forwards non-sensitive extra fields", async () => {
56+
const { logger } = await import("~/services/logger.server");
57+
58+
logger.error("something failed", { runId: "run_123", keep: "this stays" });
59+
60+
expect(captureMessageMock).toHaveBeenCalledTimes(1);
61+
const [, options] = captureMessageMock.mock.calls[0] as [
62+
string,
63+
{ extra: Record<string, unknown> },
64+
];
65+
66+
expect(options.extra.runId).toBe("run_123");
67+
expect(options.extra.keep).toBe("this stays");
68+
});
69+
});

packages/core/src/logger.ts

Lines changed: 132 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,53 @@ export type LogLevel = "log" | "error" | "warn" | "info" | "debug" | "verbose";
1616

1717
const logLevels: Array<LogLevel> = ["log", "error", "warn", "info", "debug", "verbose"];
1818

19+
// Applied to every Logger instance, on top of whatever a caller passes in as `filteredKeys`.
20+
// Keeps the previous "opt-in, per-instance" list from being the only thing standing between a
21+
// logged object and a credential or piece of customer content that happens to share its name.
22+
const DEFAULT_FILTERED_KEYS = [
23+
"authorization",
24+
"token",
25+
"apikey",
26+
"secretkey",
27+
"accesstoken",
28+
"refreshtoken",
29+
"password",
30+
"jwt",
31+
"payload",
32+
"output",
33+
"metadata",
34+
"seedmetadata",
35+
"input",
36+
"email",
37+
"headers",
38+
"completedwaitpoints",
39+
];
40+
41+
// Belt-and-braces value-shape check: catches secrets anywhere in values that land under a field
42+
// name we didn't think to deny-list (a trigger.dev API key, bearer token, or OpenAI-style key).
43+
const SECRET_VALUE_PATTERN = /(tr_[a-zA-Z0-9_-]{4,}|sk-[a-zA-Z0-9_-]{4,}|Bearer\s+\S+)/;
44+
45+
// Per-field and per-structure caps so a single unbounded object (a run payload, a batch of
46+
// items, a DB row) can't blow up log line size or CPU. Truncation keeps the field present and
47+
// queryable rather than dropping it.
48+
const MAX_STRING_LENGTH = 8192;
49+
const MAX_ARRAY_LENGTH = 100;
50+
const MAX_DEPTH = 10;
51+
52+
function buildFilteredKeySet(filteredKeys: string[]): Set<string> {
53+
const set = new Set(DEFAULT_FILTERED_KEYS);
54+
55+
for (const key of filteredKeys) {
56+
set.add(key.toLowerCase());
57+
}
58+
59+
return set;
60+
}
61+
1962
export class Logger {
2063
#name: string;
2164
readonly #level: number;
22-
#filteredKeys: string[] = [];
65+
#filteredKeys: Set<string> = new Set(DEFAULT_FILTERED_KEYS);
2366
#jsonReplacer?: (key: string, value: unknown) => unknown;
2467
#additionalFields: () => Record<string, unknown>;
2568

@@ -39,7 +82,7 @@ export class Logger {
3982
) {
4083
this.#name = name;
4184
this.#level = logLevels.indexOf((env.TRIGGER_LOG_LEVEL ?? level) as LogLevel);
42-
this.#filteredKeys = filteredKeys;
85+
this.#filteredKeys = buildFilteredKeySet(filteredKeys);
4386
this.#jsonReplacer = createReplacer(jsonReplacer);
4487
this.#additionalFields = additionalFields ?? (() => ({}));
4588
}
@@ -48,7 +91,7 @@ export class Logger {
4891
return new Logger(
4992
this.#name,
5093
logLevels[this.#level],
51-
this.#filteredKeys,
94+
Array.from(this.#filteredKeys),
5295
this.#jsonReplacer,
5396
() => ({ ...this.#additionalFields(), ...fields })
5497
);
@@ -115,8 +158,8 @@ export class Logger {
115158
// Get the current context from trace if it exists
116159
const currentSpan = trace.getSpan(context.active());
117160

118-
const structuredError = extractStructuredErrorFromArgs(...args);
119-
const structuredMessage = extractStructuredMessageFromArgs(...args);
161+
const structuredError = extractStructuredErrorFromArgs(this.#filteredKeys, ...args);
162+
const structuredMessage = extractStructuredMessageFromArgs(this.#filteredKeys, ...args);
120163

121164
const structuredLog = {
122165
...structureArgs(safeJsonClone(args) as Record<string, unknown>[], this.#filteredKeys),
@@ -153,38 +196,52 @@ export class Logger {
153196
// Detect if args is an error object
154197
// Or if args contains an error object at the "error" key
155198
// In both cases, return the error object as a structured error
156-
function extractStructuredErrorFromArgs(...args: Array<Record<string, unknown> | undefined>) {
157-
const error = args.find((arg) => arg instanceof Error) as Error | undefined;
199+
// Run every field through the same filter/truncation used for the rest of the log line, so an
200+
// error's message/stack/metadata (which can embed request or row data verbatim) gets the same
201+
// treatment as everything else, instead of bypassing it.
202+
function extractStructuredErrorFromArgs(
203+
filteredKeys: Set<string>,
204+
...args: Array<Record<string, unknown> | undefined>
205+
) {
206+
const error = args.find((arg) => arg instanceof Error) as
207+
| (Error & { metadata?: unknown })
208+
| undefined;
158209

159210
if (error) {
160211
return {
161-
message: error.message,
162-
stack: error.stack,
212+
message: filterKeys(error.message, filteredKeys),
213+
stack: filterKeys(error.stack, filteredKeys),
163214
name: error.name,
164-
metadata: "metadata" in error ? error.metadata : undefined,
215+
metadata: "metadata" in error ? filterKeys(error.metadata, filteredKeys) : undefined,
165216
};
166217
}
167218

168219
const structuredError = args.find((arg) => arg?.error);
169220

170221
if (structuredError && structuredError.error instanceof Error) {
222+
const nestedError = structuredError.error as Error & { metadata?: unknown };
223+
171224
return {
172-
message: structuredError.error.message,
173-
stack: structuredError.error.stack,
174-
name: structuredError.error.name,
175-
metadata: "metadata" in structuredError.error ? structuredError.error.metadata : undefined,
225+
message: filterKeys(nestedError.message, filteredKeys),
226+
stack: filterKeys(nestedError.stack, filteredKeys),
227+
name: nestedError.name,
228+
metadata:
229+
"metadata" in nestedError ? filterKeys(nestedError.metadata, filteredKeys) : undefined,
176230
};
177231
}
178232

179233
return;
180234
}
181235

182-
function extractStructuredMessageFromArgs(...args: Array<Record<string, unknown> | undefined>) {
236+
function extractStructuredMessageFromArgs(
237+
filteredKeys: Set<string>,
238+
...args: Array<Record<string, unknown> | undefined>
239+
) {
183240
// Check to see if there is a `message` key in the args, and if so, return it
184241
const structuredMessage = args.find((arg) => arg?.message);
185242

186243
if (structuredMessage) {
187-
return structuredMessage.message;
244+
return filterKeys(structuredMessage.message, filteredKeys);
188245
}
189246

190247
return;
@@ -221,37 +278,65 @@ function safeJsonClone(obj: unknown) {
221278
}
222279
}
223280

224-
// If args is has a single item that is an object, return that object
225-
function structureArgs(args: Array<Record<string, unknown>>, filteredKeys: string[] = []) {
226-
if (!args) {
281+
// `args` has already been through safeJsonClone, so this only has to filter/truncate it, not
282+
// clone it again. If there's exactly one arg, return it directly (unwrapped) so it can be spread
283+
// onto the structured log; otherwise filter every arg and return the array. Filtering runs
284+
// regardless of arg count, so a multi-arg call gets the same redaction as the common single-arg
285+
// case.
286+
function structureArgs(
287+
args: Array<Record<string, unknown>> | undefined,
288+
filteredKeys: Set<string> = new Set()
289+
) {
290+
if (!args || args.length === 0) {
227291
return;
228292
}
229293

230-
if (args.length === 0) {
231-
return;
232-
}
294+
const filteredArgs = args.map((arg) => filterKeys(arg, filteredKeys));
233295

234-
if (args.length === 1 && typeof args[0] === "object") {
235-
return filterKeys(JSON.parse(JSON.stringify(args[0], bigIntReplacer)), filteredKeys);
296+
if (filteredArgs.length === 1) {
297+
return filteredArgs[0];
236298
}
237299

238-
return args;
300+
return filteredArgs;
239301
}
240302

241-
// Recursively filter out keys from an object, including nested objects, and arrays
242-
function filterKeys(obj: unknown, keys: string[]): any {
303+
// Recursively filter out keys from an object, including nested objects and arrays. Also caps
304+
// string length, array length and recursion depth, and redacts string values that look like a
305+
// secret regardless of which key they were found under.
306+
function filterKeys(obj: unknown, keys: Set<string>, depth = 0): any {
307+
if (typeof obj === "string") {
308+
if (SECRET_VALUE_PATTERN.test(obj)) {
309+
return `[filtered ${prettyPrintBytes(obj)}]`;
310+
}
311+
312+
return truncateString(obj);
313+
}
314+
243315
if (typeof obj !== "object" || obj === null) {
244316
return obj;
245317
}
246318

319+
if (depth >= MAX_DEPTH) {
320+
return "[max depth exceeded]";
321+
}
322+
247323
if (Array.isArray(obj)) {
248-
return obj.map((item) => filterKeys(item, keys));
324+
const isTruncated = obj.length > MAX_ARRAY_LENGTH;
325+
const items = (isTruncated ? obj.slice(0, MAX_ARRAY_LENGTH) : obj).map((item) =>
326+
filterKeys(item, keys, depth + 1)
327+
);
328+
329+
if (isTruncated) {
330+
items.push(`[truncated ${obj.length - MAX_ARRAY_LENGTH} more items]`);
331+
}
332+
333+
return items;
249334
}
250335

251336
const filteredObj: any = {};
252337

253338
for (const [key, value] of Object.entries(obj)) {
254-
if (keys.includes(key)) {
339+
if (keys.has(key.toLowerCase())) {
255340
if (value) {
256341
filteredObj[key] = `[filtered ${prettyPrintBytes(value)}]`;
257342
} else {
@@ -260,12 +345,29 @@ function filterKeys(obj: unknown, keys: string[]): any {
260345
continue;
261346
}
262347

263-
filteredObj[key] = filterKeys(value, keys);
348+
filteredObj[key] = filterKeys(value, keys, depth + 1);
264349
}
265350

266351
return filteredObj;
267352
}
268353

354+
function truncateString(value: string): string {
355+
if (value.length <= MAX_STRING_LENGTH) {
356+
return value;
357+
}
358+
359+
return `${value.slice(0, MAX_STRING_LENGTH)}...[truncated ${
360+
value.length - MAX_STRING_LENGTH
361+
} chars]`;
362+
}
363+
364+
// Runs a value through the same default-deny-list + truncation pipeline every Logger applies to
365+
// its own log lines. For destinations that receive log arguments through a side channel (e.g. an
366+
// error reporting `onError` hook) rather than through `Logger#structuredLog` itself.
367+
export function redact(value: unknown, filteredKeys: string[] = []): unknown {
368+
return filterKeys(value, buildFilteredKeySet(filteredKeys));
369+
}
370+
269371
function prettyPrintBytes(value: unknown): string {
270372
if (env.NODE_ENV === "production") {
271373
return "skipped size";

0 commit comments

Comments
 (0)