Skip to content

Commit 8c4f389

Browse files
committed
fix(core,webapp): redact sensitive fields in logs by default and cap their size
The Logger used to only redact keys a caller explicitly listed, and most call sites listed none. It now applies a default set of sensitive key names (tokens, passwords, api keys, payloads, headers, email, and more) to every log line, matched case-insensitively and recursively, no matter how many arguments a log call passes. String values shaped like a bearer token or API key are redacted even under an unlisted key. Logged errors now run their message, stack and metadata through the same redaction and size limits as the rest of the line, instead of being copied through untouched. Long strings and large arrays are truncated with a marker instead of written out in full. The webapp's Sentry reporting now applies the same redaction to the extra data it sends, so a field that gets filtered on stdout is filtered on its way to Sentry too.
1 parent ec562c0 commit 8c4f389

4 files changed

Lines changed: 371 additions & 30 deletions

File tree

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

Lines changed: 8 additions & 3 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,20 +12,25 @@ 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) {
1924
captureException(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
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 extra payload sent to Sentry on the captureException path", async () => {
34+
const { logger } = await import("~/services/logger.server");
35+
36+
logger.error("boom", {
37+
error: new Error("bad thing happened"),
38+
payload: { secret: "do-not-leak" },
39+
});
40+
41+
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
42+
const [, options] = captureExceptionMock.mock.calls[0] as [Error, { extra: unknown }];
43+
44+
expect(JSON.stringify(options.extra)).not.toContain("do-not-leak");
45+
});
46+
47+
it("still forwards non-sensitive extra fields", async () => {
48+
const { logger } = await import("~/services/logger.server");
49+
50+
logger.error("something failed", { runId: "run_123", keep: "this stays" });
51+
52+
expect(captureMessageMock).toHaveBeenCalledTimes(1);
53+
const [, options] = captureMessageMock.mock.calls[0] as [
54+
string,
55+
{ extra: Record<string, unknown> },
56+
];
57+
58+
expect(options.extra.runId).toBe("run_123");
59+
expect(options.extra.keep).toBe("this stays");
60+
});
61+
});

packages/core/src/logger.ts

Lines changed: 125 additions & 27 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 that land under a field name we didn't
42+
// think to deny-list (a live trigger.dev API key, a bearer token, or an OpenAI-style secret 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,7 +158,7 @@ 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);
161+
const structuredError = extractStructuredErrorFromArgs(this.#filteredKeys, ...args);
119162
const structuredMessage = extractStructuredMessageFromArgs(...args);
120163

121164
const structuredLog = {
@@ -153,26 +196,36 @@ 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: "metadata" in nestedError ? filterKeys(nestedError.metadata, filteredKeys) : undefined,
176229
};
177230
}
178231

@@ -221,37 +274,65 @@ function safeJsonClone(obj: unknown) {
221274
}
222275
}
223276

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) {
277+
// `args` has already been through safeJsonClone, so this only has to filter/truncate it, not
278+
// clone it again. If there's exactly one arg, return it directly (unwrapped) so it can be spread
279+
// onto the structured log; otherwise filter every arg and return the array. Filtering runs
280+
// regardless of arg count, so a multi-arg call gets the same redaction as the common single-arg
281+
// case.
282+
function structureArgs(
283+
args: Array<Record<string, unknown>> | undefined,
284+
filteredKeys: Set<string> = new Set()
285+
) {
286+
if (!args || args.length === 0) {
227287
return;
228288
}
229289

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

234-
if (args.length === 1 && typeof args[0] === "object") {
235-
return filterKeys(JSON.parse(JSON.stringify(args[0], bigIntReplacer)), filteredKeys);
292+
if (filteredArgs.length === 1) {
293+
return filteredArgs[0];
236294
}
237295

238-
return args;
296+
return filteredArgs;
239297
}
240298

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

315+
if (depth >= MAX_DEPTH) {
316+
return "[max depth exceeded]";
317+
}
318+
247319
if (Array.isArray(obj)) {
248-
return obj.map((item) => filterKeys(item, keys));
320+
const isTruncated = obj.length > MAX_ARRAY_LENGTH;
321+
const items = (isTruncated ? obj.slice(0, MAX_ARRAY_LENGTH) : obj).map((item) =>
322+
filterKeys(item, keys, depth + 1)
323+
);
324+
325+
if (isTruncated) {
326+
items.push(`[truncated ${obj.length - MAX_ARRAY_LENGTH} more items]`);
327+
}
328+
329+
return items;
249330
}
250331

251332
const filteredObj: any = {};
252333

253334
for (const [key, value] of Object.entries(obj)) {
254-
if (keys.includes(key)) {
335+
if (keys.has(key.toLowerCase())) {
255336
if (value) {
256337
filteredObj[key] = `[filtered ${prettyPrintBytes(value)}]`;
257338
} else {
@@ -260,12 +341,29 @@ function filterKeys(obj: unknown, keys: string[]): any {
260341
continue;
261342
}
262343

263-
filteredObj[key] = filterKeys(value, keys);
344+
filteredObj[key] = filterKeys(value, keys, depth + 1);
264345
}
265346

266347
return filteredObj;
267348
}
268349

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

0 commit comments

Comments
 (0)