Skip to content

Commit 6f1e40a

Browse files
devkiransteven-tey
andauthored
Mask sensitive fields in API logs response body (dubinc#3790)
Co-authored-by: Steven Tey <stevensteel97@gmail.com>
1 parent bc53f43 commit 6f1e40a

File tree

2 files changed

+88
-0
lines changed

2 files changed

+88
-0
lines changed

apps/web/lib/api-logs/capture-request-log.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { WorkspaceWithUsers } from "@/lib/types";
22
import { TokenCacheItem } from "../auth/token-cache";
33
import { Session } from "../auth/utils";
44
import { HTTP_MUTATION_METHODS, ROUTE_PATTERNS } from "./constants";
5+
import {
6+
maskSensitiveFields,
7+
SENSITIVE_RESPONSE_FIELDS_BY_ROUTE,
8+
} from "./mask-sensitive-fields";
59
import { recordApiLog } from "./record-api-log";
610

711
// Precompile route patterns into regexes at module load
@@ -79,6 +83,19 @@ export async function captureRequestLog({
7983
responseBody = await responseClone.json();
8084
} catch {}
8185

86+
// Mask sensitive fields in the response body
87+
if (responseBody) {
88+
const sensitiveResponseFields =
89+
SENSITIVE_RESPONSE_FIELDS_BY_ROUTE[routePattern];
90+
91+
if (sensitiveResponseFields) {
92+
responseBody = maskSensitiveFields({
93+
body: responseBody,
94+
keys: sensitiveResponseFields,
95+
});
96+
}
97+
}
98+
8299
return await recordApiLog({
83100
workspaceId: workspace.id,
84101
method: req.method,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
const FULL_MASK = "***";
2+
const MIDDLE_MASK = "*****";
3+
const MIN_HIDDEN_CHARS = 4;
4+
const LAST_VISIBLE_CHARS = 6;
5+
6+
// Map of route pattern -> response body fields that should be masked before logging.
7+
export const SENSITIVE_RESPONSE_FIELDS_BY_ROUTE = {
8+
"/tokens/embed/referrals": ["publicToken"],
9+
} as const;
10+
11+
// Stripe-style partial mask: visible prefix through the last `_` (e.g. `sk_live_`),
12+
// a fixed middle mask, and the last 6 characters (e.g. `sk_live_*****xyz123`).
13+
// Values without enough characters between prefix and suffix fully mask.
14+
export function maskSensitiveValue(value: unknown): string {
15+
if (typeof value !== "string" || value.length === 0) {
16+
return FULL_MASK;
17+
}
18+
19+
const suffixLen = Math.min(LAST_VISIBLE_CHARS, value.length);
20+
const suffixStart = value.length - suffixLen;
21+
22+
const lastUnderscore = value.lastIndexOf("_");
23+
const prefixEnd = lastUnderscore >= 0 ? lastUnderscore + 1 : 0;
24+
25+
if (prefixEnd > suffixStart) {
26+
return FULL_MASK;
27+
}
28+
29+
if (suffixStart - prefixEnd < MIN_HIDDEN_CHARS) {
30+
return FULL_MASK;
31+
}
32+
33+
const prefix = value.slice(0, prefixEnd);
34+
const suffix = value.slice(suffixStart);
35+
36+
return `${prefix}${MIDDLE_MASK}${suffix}`;
37+
}
38+
39+
// Recursively mask the given keys in an object/array. Returns a new value and
40+
// does not mutate the input. Non-object values are returned as-is.
41+
export function maskSensitiveFields<T>({
42+
body,
43+
keys,
44+
}: {
45+
body: T;
46+
keys: string[];
47+
}): T {
48+
if (!body || keys.length === 0) {
49+
return body;
50+
}
51+
52+
const keySet = new Set(keys);
53+
54+
const mask = (value: unknown): unknown => {
55+
if (Array.isArray(value)) {
56+
return value.map(mask);
57+
}
58+
59+
if (value && typeof value === "object") {
60+
const result: Record<string, unknown> = {};
61+
for (const [k, v] of Object.entries(value)) {
62+
result[k] = keySet.has(k) ? maskSensitiveValue(v) : mask(v);
63+
}
64+
return result;
65+
}
66+
67+
return value;
68+
};
69+
70+
return mask(body) as T;
71+
}

0 commit comments

Comments
 (0)