Skip to content

Commit 256a45a

Browse files
d-cscarderne
authored andcommitted
fix(webapp): redact secrets & PII from logs (worker secret, API key/JWT, Slack) (#41)
1 parent 7fd8278 commit 256a45a

14 files changed

Lines changed: 300 additions & 22 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Improve redaction of secrets from debug logs

apps/webapp/app/models/orgIntegration.server.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { z } from "zod";
99
import { $transaction, prisma } from "~/db.server";
1010
import { env } from "~/env.server";
1111
import { logger } from "~/services/logger.server";
12+
import { slackSecretLogFields } from "./safeIntegrationLog";
13+
import { slackAccessResultLogFields } from "./slackOAuthResultLog";
1214
import { getSecretStore } from "~/services/secrets/secretStore.server";
1315
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
1416
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
@@ -205,9 +207,8 @@ export class OrgIntegrationRepository {
205207
});
206208

207209
if (result.ok) {
208-
logger.debug("Received slack access token", {
209-
result,
210-
});
210+
// `result` carries Slack tokens; log only non-secret diagnostics.
211+
logger.debug("Received slack access token", slackAccessResultLogFields(result));
211212

212213
if (!result.access_token) {
213214
throw new Error("Failed to get access token");
@@ -230,9 +231,12 @@ export class OrgIntegrationRepository {
230231
raw: result,
231232
};
232233

233-
logger.debug("Setting secret", {
234-
secretValue,
235-
});
234+
// `secretValue` carries the tokens encrypted below; log only
235+
// non-secret fields.
236+
logger.debug(
237+
"Setting secret",
238+
slackSecretLogFields(integrationFriendlyId, secretValue)
239+
);
236240

237241
await secretStore.setSecret(integrationFriendlyId, secretValue);
238242

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Non-secret fields for logging a Slack integration secret: presence booleans
2+
// and scope arrays only, never the token values. Dependency-free so it's
3+
// unit-tested directly.
4+
export type SlackSecretLike = {
5+
botAccessToken?: string;
6+
userAccessToken?: string;
7+
refreshToken?: string;
8+
botScopes?: string[];
9+
userScopes?: string[];
10+
};
11+
12+
export function slackSecretLogFields(friendlyId: string, secret: SlackSecretLike) {
13+
return {
14+
friendlyId,
15+
hasUserToken: !!secret.userAccessToken,
16+
hasRefreshToken: !!secret.refreshToken,
17+
botScopes: secret.botScopes,
18+
userScopes: secret.userScopes,
19+
};
20+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Non-secret fields for logging a Slack `oauth.v2.access` response, which
2+
// otherwise carries bot/user/refresh tokens. Dependency-free so it's
3+
// unit-tested directly.
4+
export type SlackAccessResultLike = {
5+
team?: { id?: string } | null;
6+
scope?: string;
7+
authed_user?: { access_token?: string } | null;
8+
refresh_token?: string;
9+
};
10+
11+
export function slackAccessResultLogFields(result: SlackAccessResultLike) {
12+
return {
13+
teamId: result.team?.id,
14+
scope: result.scope,
15+
hasUserToken: !!result.authed_user?.access_token,
16+
hasRefreshToken: !!result.refresh_token,
17+
};
18+
}

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
} from "~/models/runtimeEnvironment.server";
1515
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
1616
import { logger } from "./logger.server";
17+
import { safeEnvironmentLogFields } from "./safeEnvironmentLog";
18+
import { missingJwtLogContext } from "./safeRequestLogContext";
1719
import {
1820
type PersonalAccessTokenAuthenticationResult,
1921
authenticateApiRequestWithPersonalAccessToken,
@@ -673,9 +675,9 @@ export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
673675
const jwt = request.headers.get("x-trigger-jwt");
674676

675677
if (!jwt) {
676-
logger.debug("Missing JWT token in request", {
677-
headers: Object.fromEntries(request.headers),
678-
});
678+
// Log a safe breadcrumb, not the raw headers (which carry the
679+
// caller's Authorization credential).
680+
logger.debug("Missing JWT token in request", missingJwtLogContext(request));
679681

680682
return;
681683
}
@@ -735,8 +737,9 @@ export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
735737
...payload.data,
736738
});
737739

740+
// The environment carries secret material; log only non-secret fields.
738741
logger.debug("Renewed JWT token from Authorization header API Key", {
739-
environment: authenticatedEnv.environment,
742+
environment: safeEnvironmentLogFields(authenticatedEnv.environment),
740743
payload: payload.data,
741744
});
742745

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Non-secret subset of an AuthenticatedEnvironment for logging (the full shape
2+
// carries the env's apiKey). Dependency-free so it's unit-tested directly.
3+
export type EnvironmentForLog = {
4+
id: string;
5+
slug: string;
6+
type: string;
7+
projectId: string;
8+
organizationId: string;
9+
};
10+
11+
export function safeEnvironmentLogFields(environment: EnvironmentForLog) {
12+
return {
13+
id: environment.id,
14+
slug: environment.slug,
15+
type: environment.type,
16+
projectId: environment.projectId,
17+
organizationId: environment.organizationId,
18+
};
19+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// A safe breadcrumb for logging an inbound API request. Must never include
2+
// header *values*, only presence — the Authorization header carries the
3+
// caller's credential. Dependency-free so it's unit-tested directly.
4+
export function missingJwtLogContext(request: Request): {
5+
method: string;
6+
path: string;
7+
hasAuthorization: boolean;
8+
} {
9+
const url = new URL(request.url);
10+
return {
11+
method: request.method,
12+
path: url.pathname,
13+
hasAuthorization: request.headers.has("authorization"),
14+
};
15+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { WORKER_HEADERS } from "@trigger.dev/core/v3/workers";
2+
3+
// Secret-bearing headers to drop before logging request headers.
4+
// Dependency-free so the redaction is unit-tested directly.
5+
export const SENSITIVE_WORKER_HEADERS = new Set([
6+
"authorization",
7+
"cookie",
8+
WORKER_HEADERS.MANAGED_SECRET.toLowerCase(),
9+
]);
10+
11+
/**
12+
* Copy `headers` into a plain object, dropping any header whose (lower-cased)
13+
* name is in `denylist`. Used before logging request headers.
14+
*/
15+
export function sanitizeWorkerHeaders(
16+
headers: Headers,
17+
denylist: ReadonlySet<string> = SENSITIVE_WORKER_HEADERS
18+
): Partial<Record<string, string>> {
19+
const skip = new Set(Array.from(denylist, (h) => h.toLowerCase()));
20+
const sanitized: Partial<Record<string, string>> = {};
21+
for (const [key, value] of headers.entries()) {
22+
if (!skip.has(key.toLowerCase())) {
23+
sanitized[key] = value;
24+
}
25+
}
26+
return sanitized;
27+
}

apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { fromFriendlyId } from "@trigger.dev/core/v3/isomorphic";
1818
import { WORKER_HEADERS, type WorkerQueueClass } from "@trigger.dev/core/v3/workers";
1919
import type { RuntimeEnvironment, WorkerInstanceGroup } from "@trigger.dev/database";
2020
import { Prisma, WorkerInstanceGroupType } from "@trigger.dev/database";
21+
import { SENSITIVE_WORKER_HEADERS, sanitizeWorkerHeaders } from "./sanitizeWorkerHeaders";
2122
import { createHash, timingSafeEqual } from "crypto";
2223
import { customAlphabet } from "nanoid";
2324
import { z } from "zod";
@@ -187,15 +188,13 @@ export class WorkerGroupTokenService extends WithRunEngine {
187188

188189
if (a.byteLength !== b.byteLength) {
189190
logger.error("[WorkerGroupTokenService] Managed secret length mismatch", {
190-
managedWorkerSecret,
191191
headers: this.sanitizeHeaders(request),
192192
});
193193
return;
194194
}
195195

196196
if (!timingSafeEqual(a, b)) {
197197
logger.error("[WorkerGroupTokenService] Managed secret mismatch", {
198-
managedWorkerSecret,
199198
headers: this.sanitizeHeaders(request),
200199
});
201200
return;
@@ -317,16 +316,10 @@ export class WorkerGroupTokenService extends WithRunEngine {
317316
}
318317
}
319318

320-
private sanitizeHeaders(request: Request, skipHeaders = ["authorization"]) {
321-
const sanitizedHeaders: Partial<Record<string, string>> = {};
322-
323-
for (const [key, value] of request.headers.entries()) {
324-
if (!skipHeaders.includes(key.toLowerCase())) {
325-
sanitizedHeaders[key] = value;
326-
}
327-
}
328-
329-
return sanitizedHeaders;
319+
// Strip sensitive headers before logging request headers — see
320+
// `sanitizeWorkerHeaders`.
321+
private sanitizeHeaders(request: Request, denylist = SENSITIVE_WORKER_HEADERS) {
322+
return sanitizeWorkerHeaders(request.headers, denylist);
330323
}
331324
}
332325

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from "vitest";
2+
import { safeEnvironmentLogFields } from "../app/services/safeEnvironmentLog.js";
3+
4+
// AuthenticatedEnvironment carries `apiKey` (and pkApiKey) — these must never
5+
// reach the logger when an environment is logged.
6+
const environment = {
7+
id: "env_1",
8+
slug: "prod",
9+
type: "PRODUCTION",
10+
projectId: "proj_1",
11+
organizationId: "org_1",
12+
apiKey: "tr_prod_SUPERSECRET",
13+
pkApiKey: "pk_prod_SECRET",
14+
} as any;
15+
16+
describe("safeEnvironmentLogFields", () => {
17+
it("emits only non-secret identity fields, never the api key", () => {
18+
const fields = safeEnvironmentLogFields(environment);
19+
const serialized = JSON.stringify(fields);
20+
expect(serialized).not.toContain("tr_prod_SUPERSECRET");
21+
expect(serialized).not.toContain("pk_prod_SECRET");
22+
expect(serialized).not.toContain("apiKey");
23+
expect(fields).toEqual({
24+
id: "env_1",
25+
slug: "prod",
26+
type: "PRODUCTION",
27+
projectId: "proj_1",
28+
organizationId: "org_1",
29+
});
30+
});
31+
});

0 commit comments

Comments
 (0)