Skip to content

Commit 3faf669

Browse files
authored
Redact tool output secrets (#85196)
* redact tool output secrets * Expand tool-output secret redaction * fix(security): keep redaction prefilter in sync with expanded defaults - build DEFAULT_REDACT_PREFILTER_RE from sources covering every default pattern family: new vendor prefixes, webhook hosts, bare query/form keys, userinfo/connection-string passwords, and percent/plus/invisible obfuscated keys (including trailing separator splices) - run default-pattern redaction tests through the default options path and redact the vendor corpus per token so prefilter gaps fail tests - fix quoted standalone assignment values containing the other quote char or an unterminated quote; never re-mask *** placeholders - align net-policy URL query-name separator stripping with logging key normalization (Hangul fillers) * fix(security): keep base64-prefix redaction out of media payloads - pure-base64-alphabet token prefixes (gAAAA, AKIA, ASIA, dapi, ATCTT3xFfG, ATATT, ATBB) now require a non-alphanumeric left boundary, skip explicit ;base64, payload spans, and run unchunked so chunk starts cannot fake the boundary or hide the container from the lookbehind - tokens after URL/path delimiters or assignments still mask; data-URL media survives redaction byte-identical (fixes chat media mirror CI) - regression tests: tiny-PNG data URL, in-blob plus boundary, chunk-aligned large data URL, reset-path Fernet token, path AWS key --------- Co-authored-by: Alex Knight <[email protected]>
1 parent 2d404f1 commit 3faf669

7 files changed

Lines changed: 1511 additions & 219 deletions

File tree

packages/net-policy/src/redact-sensitive-url.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ describe("redactSensitiveUrl", () => {
2222
);
2323
});
2424

25+
it("redacts encoded and invisible-spliced sensitive query param names", () => {
26+
expect(
27+
redactSensitiveUrl("https://example.com/mcp?client%5Fse%E2%80%8Bcret=secret&safe=value"),
28+
).toBe("https://example.com/mcp?client_se%E2%80%8Bcret=***&safe=value");
29+
});
30+
31+
it("redacts encoded sensitive query names with decoded whitespace and control separators", () => {
32+
expect(
33+
redactSensitiveUrl("https://example.com/mcp?client%5Fse%20cret=space&client%5Fse%00cret=nul"),
34+
).toBe("https://example.com/mcp?client_se+cret=***&client_se%00cret=***");
35+
});
36+
37+
it("redacts query names with plus-encoded separators", () => {
38+
expect(redactSensitiveUrl("https://example.com/mcp?client_se+cret=secret&safe=value")).toBe(
39+
"https://example.com/mcp?client_se+cret=***&safe=value",
40+
);
41+
});
42+
2543
it("keeps non-sensitive URLs unchanged", () => {
2644
expect(redactSensitiveUrl("https://example.com/mcp?safe=value")).toBe(
2745
"https://example.com/mcp?safe=value",
@@ -36,6 +54,26 @@ describe("redactSensitiveUrlLikeString", () => {
3654
);
3755
});
3856

57+
it("redacts encoded and invisible-spliced query names in invalid URL-like strings", () => {
58+
expect(
59+
redactSensitiveUrlLikeString("//example.com/mcp?client%5Fse%E2%80%8Bcret=secret&safe=value"),
60+
).toBe("//example.com/mcp?client%5Fse%E2%80%8Bcret=***&safe=value");
61+
});
62+
63+
it("redacts encoded query names with decoded whitespace and control separators in invalid URL-like strings", () => {
64+
expect(
65+
redactSensitiveUrlLikeString(
66+
"//example.com/mcp?client%5Fse%20cret=space&client%5Fse%00cret=nul",
67+
),
68+
).toBe("//example.com/mcp?client%5Fse%20cret=***&client%5Fse%00cret=***");
69+
});
70+
71+
it("redacts plus-spliced query names in invalid URL-like strings", () => {
72+
expect(redactSensitiveUrlLikeString("//example.com/mcp?client_se+cret=secret&safe=value")).toBe(
73+
"//example.com/mcp?client_se+cret=***&safe=value",
74+
);
75+
});
76+
3977
it("redacts every URL-like userinfo occurrence in arbitrary text", () => {
4078
expect(
4179
redactSensitiveUrlLikeString(
@@ -61,6 +99,17 @@ describe("isSensitiveUrlQueryParamName", () => {
6199
expect(isSensitiveUrlQueryParamName("hook-token")).toBe(true);
62100
expect(isSensitiveUrlQueryParamName("passwd")).toBe(true);
63101
expect(isSensitiveUrlQueryParamName("signature")).toBe(true);
102+
expect(isSensitiveUrlQueryParamName("code")).toBe(true);
103+
expect(isSensitiveUrlQueryParamName("x-amz-signature")).toBe(true);
104+
expect(isSensitiveUrlQueryParamName("X-Amz-Security-Token")).toBe(true);
105+
expect(isSensitiveUrlQueryParamName("id_token")).toBe(true);
106+
expect(isSensitiveUrlQueryParamName("app_secret")).toBe(true);
107+
expect(isSensitiveUrlQueryParamName("client%5Fse\u200Bcret")).toBe(true);
108+
expect(isSensitiveUrlQueryParamName("client%5Fse%20cret")).toBe(true);
109+
expect(isSensitiveUrlQueryParamName("client%5Fse%00cret")).toBe(true);
110+
expect(isSensitiveUrlQueryParamName("client_se+cret")).toBe(true);
111+
expect(isSensitiveUrlQueryParamName("client_se\u3164cret")).toBe(true);
112+
expect(isSensitiveUrlQueryParamName("credential")).toBe(true);
64113
expect(isSensitiveUrlQueryParamName("safe")).toBe(false);
65114
});
66115
});

packages/net-policy/src/redact-sensitive-url.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,39 @@ const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([
2222
"pass",
2323
"passwd",
2424
"auth",
25+
"jwt",
26+
"session",
27+
"id_token",
28+
"code",
2529
"client_secret",
30+
"app_secret",
2631
"hook_token",
2732
"refresh_token",
2833
"signature",
34+
"x_amz_signature",
35+
"x_amz_security_token",
36+
"private_key",
37+
"credential",
38+
"authorization",
2939
]);
40+
// Keep in sync with FORM_BODY_KEY_SEPARATOR_RE in src/logging/redact.ts: Hangul fillers are
41+
// category Lo, so \p{C}\p{Z} alone would let them splice sensitive key names.
42+
const URL_QUERY_NAME_SEPARATOR_RE = /[\p{C}\p{Z}\u115F\u1160\u3164\uFFA0+]/gu;
43+
44+
function normalizeUrlQueryParamName(name: string): string {
45+
const stripped = name.replace(URL_QUERY_NAME_SEPARATOR_RE, "");
46+
try {
47+
return normalizeLowercaseStringOrEmpty(
48+
decodeURIComponent(stripped).replace(URL_QUERY_NAME_SEPARATOR_RE, ""),
49+
).replaceAll("-", "_");
50+
} catch {
51+
return normalizeLowercaseStringOrEmpty(stripped).replaceAll("-", "_");
52+
}
53+
}
3054

3155
/** True for auth-like URL query parameter names that should be redacted. */
3256
export function isSensitiveUrlQueryParamName(name: string): boolean {
33-
const normalized = normalizeLowercaseStringOrEmpty(name).replaceAll("-", "_");
57+
const normalized = normalizeUrlQueryParamName(name);
3458
return SENSITIVE_URL_QUERY_PARAM_NAMES.has(normalized);
3559
}
3660

src/cli/gateway-cli/run.option-collisions.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,6 @@ describe("gateway run option collisions", () => {
648648
).rejects.toThrow("__exit__:1");
649649
},
650650
);
651-
expect(runtimeErrors[0]).toContain("Use either --passw***d or --password-file.");
651+
expect(runtimeErrors[0]).toContain("Use either --password or --password-file.");
652652
});
653653
});

src/infra/exec-approval-command-display.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,21 @@ describe("sanitizeExecApprovalDisplayText", () => {
104104
expect(result).toContain("https://api.example.com");
105105
});
106106

107+
it("masks newly added vendor token prefixes through the default redaction path", () => {
108+
const token = "glpat-abcdefghijklmnopqrstuv";
109+
const result = sanitizeExecApprovalDisplayText(`deploy --with ${token}`);
110+
expect(result).not.toContain(token);
111+
});
112+
113+
it("does not let contextual secret matches hide split-token bypass detection", () => {
114+
const discordToken = `${"A".repeat(24)}.${"B".repeat(6)}.${"C".repeat(27)}`;
115+
const cmd = `discord sk-abc123\u200B456789012345678 ${discordToken}`;
116+
const result = sanitizeExecApprovalDisplayText(cmd);
117+
expect(result).not.toContain("sk-abc123");
118+
expect(result).not.toContain("456789012345678");
119+
expect(result).not.toContain(discordToken);
120+
});
121+
107122
it("keeps PEM private-key context visible when raw redaction already covers the key (not a bypass)", () => {
108123
const cmd =
109124
"echo -----BEGIN RSA PRIVATE KEY-----\nABCDEF0123456789abcdef\n-----END RSA PRIVATE KEY----- > key.pem";
@@ -157,6 +172,41 @@ describe("sanitizeExecApprovalDisplayText", () => {
157172
expect(result).not.toContain("456789012345678");
158173
expect(result).toContain("remainder");
159174
});
175+
176+
it("masks form body values whose sensitive key is spliced with an invisible character", () => {
177+
const cmd = "client_id=visible&app_se\u200Bcret=opaque-app-secret&safe=value";
178+
const result = sanitizeExecApprovalDisplayText(cmd);
179+
expect(result).not.toContain("opaque-app-secret");
180+
expect(result).toContain("client_id=visible");
181+
expect(result).toContain("safe=value");
182+
});
183+
184+
it("masks form body values whose encoded sensitive key is spliced with an invisible character", () => {
185+
const cmd = "client_id=visible&client%5Fse\u200Bcret=oauth-secret&safe=value";
186+
const result = sanitizeExecApprovalDisplayText(cmd);
187+
expect(result).not.toContain("oauth-secret");
188+
expect(result).toContain("client_id=visible");
189+
expect(result).toContain("safe=value");
190+
});
191+
192+
it("masks form body values whose sensitive key is spliced with a plus separator", () => {
193+
const cmd = "client_id=visible&client_se+cret=oauth-secret&safe=value";
194+
const result = sanitizeExecApprovalDisplayText(cmd);
195+
expect(result).not.toContain("oauth-secret");
196+
expect(result).toContain("client_id=visible");
197+
expect(result).toContain("safe=value");
198+
});
199+
200+
it("keeps parsed form-body secrets masked when a separate spliced token triggers bypass rendering", () => {
201+
const cmd =
202+
"client_id=visible&client%5Fsecret=oauth,secret&safe=1 echo sk-abc123\u200B456789012345678";
203+
const result = sanitizeExecApprovalDisplayText(cmd);
204+
expect(result).not.toContain("oauth,secret");
205+
expect(result).not.toContain(",secret");
206+
expect(result).not.toContain("456789012345678");
207+
expect(result).toContain("client_id=visible");
208+
expect(result).toContain("safe=1");
209+
});
160210
});
161211

162212
describe("sanitizeExecApprovalWarningText", () => {

src/infra/exec-approval-command-display.ts

Lines changed: 15 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
// Sanitizes command text before it is displayed in approval prompts.
2-
import { redactSensitiveText, resolveRedactOptions } from "../logging/redact.js";
2+
import {
3+
computeSensitiveRedactionBitmap,
4+
redactSensitiveText,
5+
resolveRedactOptions,
6+
} from "../logging/redact.js";
37
import type { ExecApprovalRequestPayload } from "./exec-approvals.js";
48

59
// Escape control characters, Unicode format/line/paragraph separators, and non-ASCII space
@@ -60,31 +64,6 @@ function truncateForDisplay(text: string): SanitizedExecApprovalDisplayText {
6064
};
6165
}
6266

63-
// Build a boolean bitmap of positions in `text` that ANY redaction pattern would match.
64-
// Patterns are applied independently to the raw text (not sequentially against a
65-
// progressively-redacted view) so later patterns can still find matches that the in-place
66-
// redaction would have replaced first. That is conservative — it may over-count overlapping
67-
// matches — but that is acceptable for a coverage check. Indices are UTF-16 code-unit
68-
// offsets, matching what `matchAll` returns and aligning with `String#length`.
69-
function computeRedactionBitmap(text: string, patterns: RegExp[]): boolean[] {
70-
const bitmap: boolean[] = Array.from({ length: text.length }, () => false);
71-
for (const pattern of patterns) {
72-
const iter = pattern.flags.includes("g")
73-
? new RegExp(pattern.source, pattern.flags)
74-
: new RegExp(pattern.source, `${pattern.flags}g`);
75-
for (const match of text.matchAll(iter)) {
76-
if (match.index === undefined) {
77-
continue;
78-
}
79-
const end = match.index + match[0].length;
80-
for (let i = match.index; i < end; i++) {
81-
bitmap[i] = true;
82-
}
83-
}
84-
}
85-
return bitmap;
86-
}
87-
8867
// Iterate by full Unicode code point so astral-plane invisibles (e.g. U+E0061 TAG LATIN
8968
// SMALL LETTER A, category Cf) are matched as single characters instead of being seen as a
9069
// surrogate pair whose halves are category Cs and would escape the invisible-char regex.
@@ -126,17 +105,16 @@ function sanitizeExecApprovalDisplayTextInternal(
126105
if (strippedRedacted === stripped) {
127106
return truncateForDisplay(escapeInvisibles(rawRedacted, options));
128107
}
129-
// Detect bypass by position-bitmap coverage. Run each redaction pattern independently on
130-
// both views and map stripped-view match positions back to original coordinates. If every
131-
// position the stripped view would mask is also masked by the raw view, the raw view
132-
// already covered everything — for example, an ordinary multi-line PEM private key where
133-
// raw produces `BEGIN/…redacted…/END` while stripped collapses to `***`. A real bypass
134-
// exists only when the stripped view masks at least one original position raw missed (e.g.
135-
// the tail of an `sk-` token whose prefix-boundary was broken by a spliced zero-width or
136-
// NBSP character).
137-
const { patterns } = resolveRedactOptions({ mode: "tools" });
138-
const rawMask = computeRedactionBitmap(commandText, patterns);
139-
const strippedMask = computeRedactionBitmap(stripped, patterns);
108+
// Detect bypass by position-bitmap coverage. Run the redaction matchers on both views and
109+
// map stripped-view match positions back to original coordinates. If every position the
110+
// stripped view would mask is also masked by the raw view, the raw view already covered
111+
// everything — for example, an ordinary multi-line PEM private key where raw produces
112+
// `BEGIN/…redacted…/END` while stripped collapses to `***`. A real bypass exists only when
113+
// the stripped view masks at least one original position raw missed (e.g. the tail of an
114+
// `sk-` token whose prefix-boundary was broken by a spliced zero-width or NBSP character).
115+
const redaction = resolveRedactOptions({ mode: "tools" });
116+
const rawMask = computeSensitiveRedactionBitmap(commandText, redaction);
117+
const strippedMask = computeSensitiveRedactionBitmap(stripped, redaction);
140118
let bypassDetected = false;
141119
for (let i = 0; i < strippedMask.length; i++) {
142120
if (strippedMask[i] && !rawMask[strippedToOrig[i]]) {

0 commit comments

Comments
 (0)