From dd61f0df2ca666ccdaaf8770e431c0608619233b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 23:10:46 -0700 Subject: [PATCH 01/20] fix(sdk): redact standalone keys and structured credentials --- sdk/typescript/src/errors.ts | 23 ++++++++---- sdk/typescript/tests-ts/errors.test.ts | 50 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 sdk/typescript/tests-ts/errors.test.ts diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index a8face06..ccb45fa0 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -3,17 +3,26 @@ import { formatUsd, type ScanCost } from "./cost.js"; /** Returns an error message with credential-shaped substrings redacted. */ export function redactedErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); - const withoutPrivateKeys = message.replaceAll( - /(\b[A-Za-z0-9_-]{0,64}private[_-]?key(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)(?:\\?["'])?-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----(?:\\?["'])?|$)/giu, - "$1[redacted]", - ); + const withoutPrivateKeys = message + .replaceAll( + /(\b[A-Za-z0-9_-]{0,64}private[_-]?key(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)(?:\\?["'])?-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----(?:\\?["'])?|$)/giu, + "$1[redacted]", + ) + .replaceAll( + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/giu, + "[redacted]", + ); return redactQuotedCredentialValues(withoutPrivateKeys) + .replaceAll( + /(\b(?:authorization|auth)\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9._~-]{0,63})((?:\s|%20|\+)+)(?=[A-Za-z_][A-Za-z0-9_-]{0,64}\s*=)[^\r\n]+/giu, + "$1$2$3[redacted]", + ) .replaceAll( /(\b[A-Za-z0-9_-]{0,64}(?:authorization|auth)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9._~-]{0,63})((?:\s|%20|\+)+)(?!\[redacted\]|(?!key\s*=)[A-Za-z_][A-Za-z0-9_-]{0,64}\s*[:=]\s*(?=[^=\s"',;}&\\\]]))[^\s"',;}&\\\]]+/giu, "$1$2$3[redacted]", ) .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?!\[redacted\]|[A-Za-z][A-Za-z0-9._~-]{0,63}(?:\s|%20|\+)+\[redacted\])[^\s"',;}&\\\]]+/giu, + /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?keys?|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credentials?|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?!\[redacted\]|[A-Za-z][A-Za-z0-9._~-]{0,63}(?:\s|%20|\+)+\[redacted\])[^\s"',;}&\\\]]+/giu, "$1[redacted]", ) .replaceAll(/sk-(?:proj-)?[A-Za-z0-9_*=-]{8,}/gu, "[redacted]") @@ -25,14 +34,14 @@ export function redactedErrorMessage(error: unknown): string { ) .replaceAll(/((?:https?|ssh|git\+ssh):\/\/)[^\s/@]+@/giu, "$1[redacted]@") .replaceAll( - /((?:[?&]|%3F|%26)(?:(?!%3F|%26|%3D)(?:[A-Za-z0-9_.%-]|\[|\])){0,64}(?:api[_-]?key|access(?:[_-]|%5F|%2D)?key(?:(?:[_-]|%5F|%2D)?id)?|private(?:[_-]|%5F|%2D)?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:(?:[_-]|%5F|%2D)[A-Za-z0-9_.%-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_.%-]{0,48})?(?:\]|%5D)?(?:=|%3D))(?:(?!%26)[^&\s])+/giu, + /((?:[?&]|%3F|%26)(?:(?!%3F|%26|%3D)(?:[A-Za-z0-9_.%-]|\[|\])){0,64}(?:api(?:[_-]|%5F|%2D)?keys?|access(?:[_-]|%5F|%2D)?key(?:(?:[_-]|%5F|%2D)?id)?|private(?:[_-]|%5F|%2D)?key|authorization|auth|token|secret|credentials?|signature|sig|password|passwd)(?:(?:[_-]|%5F|%2D)[A-Za-z0-9_.%-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_.%-]{0,48})?(?:\]|%5D)?(?:=|%3D))(?:(?!%26)[^&\s])+/giu, "$1[redacted]", ); } function redactQuotedCredentialValues(message: string): string { const assignment = - /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\*["'])?\s*[:=]\s*)(\\*)(["'])/giu; + /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?keys?|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credentials?|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\*["'])?\s*[:=]\s*)(\\*)(["'])/giu; let output = ""; let consumed = 0; for ( diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts new file mode 100644 index 00000000..4b165f18 --- /dev/null +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { redactedErrorMessage } from "../src/errors.js"; + +describe("security error redaction", () => { + test("redacts standalone and truncated PEM private-key envelopes", () => { + expect( + redactedErrorMessage( + "provider failed: -----BEGIN PRIVATE KEY-----\nSYNTHETIC_PRIVATE_KEY\n-----END PRIVATE KEY----- safe=value", + ), + ).toBe("provider failed: [redacted] safe=value"); + expect( + redactedErrorMessage( + "provider failed: -----BEGIN RSA PRIVATE KEY-----\nSYNTHETIC_TRUNCATED_KEY", + ), + ).toBe("provider failed: [redacted]"); + }); + + test("redacts every parameter from structured authorization schemes", () => { + expect( + redactedErrorMessage( + 'Authorization: Digest username="example", response=SYNTHETIC_DIGEST_SECRET', + ), + ).toBe("Authorization: Digest [redacted]"); + expect( + redactedErrorMessage("auth=Custom response=SYNTHETIC_AUTH_SECRET"), + ).toBe("auth=Custom [redacted]"); + }); + + test("redacts encoded API-key names without consuming other parameters", () => { + for (const separator of ["%5F", "%2D"]) { + const value = `https://example.test/?api${separator}key%3DSYNTHETIC_API_KEY%26safe%3Dvisible`; + expect(redactedErrorMessage(value)).toBe( + `https://example.test/?api${separator}key%3D[redacted]%26safe%3Dvisible`, + ); + } + }); + + test("redacts plural credential assignments and quoted values", () => { + expect( + redactedErrorMessage( + "credentials=SYNTHETIC_CREDENTIAL clientCredentials=SYNTHETIC_CLIENT apiKeys=SYNTHETIC_KEYS", + ), + ).toBe( + "credentials=[redacted] clientCredentials=[redacted] apiKeys=[redacted]", + ); + expect( + redactedErrorMessage('{"credentials":"correct horse battery staple"}'), + ).toBe('{"credentials":"[redacted]"}'); + }); +}); From 552c382ae40f4474b248fa09eb84da53a6f8b772 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 23:14:10 -0700 Subject: [PATCH 02/20] fix(sdk): preserve diagnostics after structured auth headers --- sdk/typescript/src/errors.ts | 2 +- sdk/typescript/tests-ts/errors.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index ccb45fa0..c6178159 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -14,7 +14,7 @@ export function redactedErrorMessage(error: unknown): string { ); return redactQuotedCredentialValues(withoutPrivateKeys) .replaceAll( - /(\b(?:authorization|auth)\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9._~-]{0,63})((?:\s|%20|\+)+)(?=[A-Za-z_][A-Za-z0-9_-]{0,64}\s*=)[^\r\n]+/giu, + /(\b(?:authorization|auth)\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9._~-]{0,63})((?:\s|%20|\+)+)[A-Za-z_][A-Za-z0-9_-]{0,64}\s*=\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]{0,64}\s*=\s*(?:"[^"]*"|'[^']*'|[^\s,;]+))*/giu, "$1$2$3[redacted]", ) .replaceAll( diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 4b165f18..7edaaa69 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -24,6 +24,11 @@ describe("security error redaction", () => { expect( redactedErrorMessage("auth=Custom response=SYNTHETIC_AUTH_SECRET"), ).toBe("auth=Custom [redacted]"); + expect( + redactedErrorMessage( + "Authorization: Custom key=SYNTHETIC_AUTH_SECRET https://example.test/safe", + ), + ).toBe("Authorization: Custom [redacted] https://example.test/safe"); }); test("redacts encoded API-key names without consuming other parameters", () => { From e8b9589bc3e4bbead1c76e4d47966747362b9460 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 23:24:52 -0700 Subject: [PATCH 03/20] fix(sdk): redact structured auth and credential collections --- sdk/typescript/src/errors.ts | 45 ++++++++++++++++++++++++-- sdk/typescript/tests-ts/errors.test.ts | 25 ++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index c6178159..a06a43ee 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -12,9 +12,11 @@ export function redactedErrorMessage(error: unknown): string { /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/giu, "[redacted]", ); - return redactQuotedCredentialValues(withoutPrivateKeys) + return redactQuotedCredentialValues( + redactCredentialCollections(withoutPrivateKeys), + ) .replaceAll( - /(\b(?:authorization|auth)\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9._~-]{0,63})((?:\s|%20|\+)+)[A-Za-z_][A-Za-z0-9_-]{0,64}\s*=\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]{0,64}\s*=\s*(?:"[^"]*"|'[^']*'|[^\s,;]+))*/giu, + /(\b[A-Za-z0-9_-]{0,64}(?:authorization|auth)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9.+~-]{0,63})((?:\s|%20|\+)+)[A-Za-z_*][A-Za-z0-9_.*-]{0,64}\s*=\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+)(?:\s*,\s*[A-Za-z_*][A-Za-z0-9_.*-]{0,64}\s*=\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+))*/giu, "$1$2$3[redacted]", ) .replaceAll( @@ -39,6 +41,45 @@ export function redactedErrorMessage(error: unknown): string { ); } +function redactCredentialCollections(message: string): string { + const assignment = + /(\b[A-Za-z0-9_-]{0,64}(?:credentials|api[_-]?keys)\b(?:\\*["'])?\s*[:=]\s*)([\[{])/giu; + let output = ""; + let consumed = 0; + for ( + let match = assignment.exec(message); + match !== null; + match = assignment.exec(message) + ) { + const closings = [match[2] === "[" ? "]" : "}"]; + let quote: string | undefined; + let position = assignment.lastIndex; + while (position < message.length && closings.length > 0) { + const character = message[position]!; + if (quote !== undefined) { + if (character === "\\") position += 1; + else if (character === quote) quote = undefined; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === "[") { + closings.push("]"); + } else if (character === "{") { + closings.push("}"); + } else if (character === closings.at(-1)) { + closings.pop(); + } + position += 1; + } + + const prefix = match[1]!; + const replacement = /:\s*$/u.test(prefix) ? '"[redacted]"' : "[redacted]"; + output += `${message.slice(consumed, match.index)}${prefix}${replacement}`; + consumed = closings.length === 0 ? position : message.length; + assignment.lastIndex = consumed; + } + return output + message.slice(consumed); +} + function redactQuotedCredentialValues(message: string): string { const assignment = /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?keys?|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credentials?|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\*["'])?\s*[:=]\s*)(\\*)(["'])/giu; diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 7edaaa69..e6ce41d5 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -29,6 +29,21 @@ describe("security error redaction", () => { "Authorization: Custom key=SYNTHETIC_AUTH_SECRET https://example.test/safe", ), ).toBe("Authorization: Custom [redacted] https://example.test/safe"); + expect( + redactedErrorMessage( + 'Authorization: Digest username="a\\"b", response="SYNTHETIC_DIGEST_SECRET"', + ), + ).toBe("Authorization: Digest [redacted]"); + expect( + redactedErrorMessage( + "Authorization: Digest username*=UTF-8''user, response=SYNTHETIC_DIGEST_SECRET", + ), + ).toBe("Authorization: Digest [redacted]"); + expect( + redactedErrorMessage( + 'client_authorization_value=Digest username="example", response="SYNTHETIC_DIGEST_SECRET"', + ), + ).toBe("client_authorization_value=Digest [redacted]"); }); test("redacts encoded API-key names without consuming other parameters", () => { @@ -51,5 +66,15 @@ describe("security error redaction", () => { expect( redactedErrorMessage('{"credentials":"correct horse battery staple"}'), ).toBe('{"credentials":"[redacted]"}'); + expect( + redactedErrorMessage( + "credentials=[alice:SYNTHETIC_ONE,bob:SYNTHETIC_TWO] safe=visible", + ), + ).toBe("credentials=[redacted] safe=visible"); + expect( + redactedErrorMessage( + '{"credentials":["SYNTHETIC_ONE","SYNTHETIC_TWO"],"safe":"visible"}', + ), + ).toBe('{"credentials":"[redacted]","safe":"visible"}'); }); }); From bf34227cb2aee2fe9467bf54e6e0b6fc6628a88e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 23:37:02 -0700 Subject: [PATCH 04/20] fix(sdk): redact suffixed credential collections --- sdk/typescript/src/errors.ts | 2 +- sdk/typescript/tests-ts/errors.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index a06a43ee..85b0e5a0 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -43,7 +43,7 @@ export function redactedErrorMessage(error: unknown): string { function redactCredentialCollections(message: string): string { const assignment = - /(\b[A-Za-z0-9_-]{0,64}(?:credentials|api[_-]?keys)\b(?:\\*["'])?\s*[:=]\s*)([\[{])/giu; + /(\b[A-Za-z0-9_-]{0,64}(?:credentials|api[_-]?keys)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\*["'])?\s*[:=]\s*)([\[{])/giu; let output = ""; let consumed = 0; for ( diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index e6ce41d5..686b44a3 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -76,5 +76,15 @@ describe("security error redaction", () => { '{"credentials":["SYNTHETIC_ONE","SYNTHETIC_TWO"],"safe":"visible"}', ), ).toBe('{"credentials":"[redacted]","safe":"visible"}'); + expect( + redactedErrorMessage( + 'credentials_value=["SYNTHETIC_ONE","SYNTHETIC_TWO"] safe=visible', + ), + ).toBe("credentials_value=[redacted] safe=visible"); + expect( + redactedErrorMessage( + '{"api_keys_data":[{"key":"SYNTHETIC_ONE"},{"key":"SYNTHETIC_TWO"}]}', + ), + ).toBe('{"api_keys_data":"[redacted]"}'); }); }); From c2d786be76254c158c329634c2e9925bc0bfe937 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 23:53:08 -0700 Subject: [PATCH 05/20] fix(sdk): close credential redaction review edge cases --- sdk/typescript/src/errors.ts | 8 ++++---- sdk/typescript/tests-ts/errors.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 85b0e5a0..4a550e3f 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -5,18 +5,18 @@ export function redactedErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); const withoutPrivateKeys = message .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}private[_-]?key(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)(?:\\?["'])?-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----(?:\\?["'])?|$)/giu, + /(\b[A-Za-z0-9_-]{0,64}private[_-]?key(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)(?:\\?["'])?-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:\\?["'])?|$)/giu, "$1[redacted]", ) .replaceAll( - /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/giu, + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/giu, "[redacted]", ); return redactQuotedCredentialValues( redactCredentialCollections(withoutPrivateKeys), ) .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}(?:authorization|auth)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9.+~-]{0,63})((?:\s|%20|\+)+)[A-Za-z_*][A-Za-z0-9_.*-]{0,64}\s*=\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+)(?:\s*,\s*[A-Za-z_*][A-Za-z0-9_.*-]{0,64}\s*=\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+))*/giu, + /(\b[A-Za-z0-9_-]{0,64}(?:authorization|auth)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z0-9][A-Za-z0-9.+~-]{0,63})((?:\s|%20|\+)+)[!#$%&'*+.^_`|~0-9A-Za-z-]{1,65}\s*=\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+)(?:\s*,\s*[!#$%&'*+.^_`|~0-9A-Za-z-]{1,65}\s*=\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+))*/giu, "$1$2$3[redacted]", ) .replaceAll( @@ -24,7 +24,7 @@ export function redactedErrorMessage(error: unknown): string { "$1$2$3[redacted]", ) .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?keys?|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credentials?|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?!\[redacted\]|[A-Za-z][A-Za-z0-9._~-]{0,63}(?:\s|%20|\+)+\[redacted\])[^\s"',;}&\\\]]+/giu, + /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?keys?|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credentials?|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?!\[redacted\]|[A-Za-z0-9][A-Za-z0-9._~-]{0,63}(?:\s|%20|\+)+\[redacted\])(?:\\(?!["'])|[^\s"',;}&\\\]])+/giu, "$1[redacted]", ) .replaceAll(/sk-(?:proj-)?[A-Za-z0-9_*=-]{8,}/gu, "[redacted]") diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 686b44a3..a48ec914 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -13,6 +13,11 @@ describe("security error redaction", () => { "provider failed: -----BEGIN RSA PRIVATE KEY-----\nSYNTHETIC_TRUNCATED_KEY", ), ).toBe("provider failed: [redacted]"); + expect( + redactedErrorMessage( + "provider failed: -----BEGIN PGP PRIVATE KEY BLOCK-----\nSYNTHETIC_PGP_PRIVATE_KEY\n-----END PGP PRIVATE KEY BLOCK----- safe=value", + ), + ).toBe("provider failed: [redacted] safe=value"); }); test("redacts every parameter from structured authorization schemes", () => { @@ -44,6 +49,11 @@ describe("security error redaction", () => { 'client_authorization_value=Digest username="example", response="SYNTHETIC_DIGEST_SECRET"', ), ).toBe("client_authorization_value=Digest [redacted]"); + expect( + redactedErrorMessage( + "Authorization: 0-custom 0nonce=visible, !response=SYNTHETIC_DIGEST_SECRET", + ), + ).toBe("Authorization: 0-custom [redacted]"); }); test("redacts encoded API-key names without consuming other parameters", () => { @@ -86,5 +96,8 @@ describe("security error redaction", () => { '{"api_keys_data":[{"key":"SYNTHETIC_ONE"},{"key":"SYNTHETIC_TWO"}]}', ), ).toBe('{"api_keys_data":"[redacted]"}'); + expect( + redactedErrorMessage("password=prefix\\SYNTHETIC_SECRET safe=visible"), + ).toBe("password=[redacted] safe=visible"); }); }); From 68b11bfd4c498d1b233dafe2051dd84e9acc4afd Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 03:10:28 -0700 Subject: [PATCH 06/20] refactor(sdk): remove local credential redaction --- README.md | 7 +- SECURITY.md | 3 + sdk/typescript/README.md | 10 +- sdk/typescript/src/api.ts | 6 +- sdk/typescript/src/cli.ts | 49 +++-- sdk/typescript/src/errors.ts | 119 +----------- sdk/typescript/src/multiscan.ts | 4 +- sdk/typescript/src/runtime.ts | 6 +- sdk/typescript/src/scan-dashboard.ts | 10 +- sdk/typescript/tests-ts/api.test.ts | 16 +- sdk/typescript/tests-ts/cli-export.test.ts | 5 +- sdk/typescript/tests-ts/cli-fixtures.ts | 104 +---------- sdk/typescript/tests-ts/cli-launcher.test.ts | 6 +- sdk/typescript/tests-ts/cli-workbench.test.ts | 12 +- sdk/typescript/tests-ts/cli.test.ts | 176 ++++-------------- sdk/typescript/tests-ts/errors.test.ts | 103 ---------- sdk/typescript/tests-ts/multiscan.test.ts | 40 +--- sdk/typescript/tests-ts/runtime.test.ts | 7 +- .../tests-ts/scan-dashboard.test.ts | 6 +- 19 files changed, 105 insertions(+), 584 deletions(-) delete mode 100644 sdk/typescript/tests-ts/errors.test.ts diff --git a/README.md b/README.md index 385769fc..48770c9c 100644 --- a/README.md +++ b/README.md @@ -74,15 +74,16 @@ incomplete or their original location was not reviewed. ## Verbose diagnostics -Add `--verbose` to print redacted scan diagnostics to stderr: +Add `--verbose` to print scan diagnostics to stderr: ```bash npx @openai/codex-security scan . --verbose ``` `CODEX_SECURITY_LOG_LEVEL=debug` also enables diagnostics; -`LOG_LEVEL=debug` is its fallback. JSON results remain on stdout, and -credentials and provider identifiers remain redacted. +`LOG_LEVEL=debug` is its fallback. JSON results remain on stdout. +Diagnostics, scan output, and scan history can contain credentials; keep them +private. ## TypeScript SDK diff --git a/SECURITY.md b/SECURITY.md index b2ca5bf7..67f59395 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,6 +52,9 @@ The product also does not isolate users, tasks, repositories, or scan jobs that share the same operating-system account, credentials, or local state. Do not treat shared local state as a multi-user or multi-tenant system. +Local diagnostics, logs, scan output, and scan history can include credentials +from upstream error messages. Protect this output and review it before sharing. + Trusting a repository does not authorize unrelated actions. Repository contents, model output, patches, service responses, and imported artifacts are data. They are not permission to scan another target, expose a credential, diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index ce302684..7946d60d 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -399,7 +399,7 @@ The CLI and SDK recognize the following user-configurable environment: | Variable | Effect | | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `OPENAI_API_KEY`, `CODEX_API_KEY` | Scan authentication; `OPENAI_API_KEY` wins when both are present. | -| `CODEX_SECURITY_LOG_LEVEL` | CLI-only; set to `debug` for redacted diagnostics. | +| `CODEX_SECURITY_LOG_LEVEL` | CLI-only; set to `debug` for scan diagnostics. | | `LOG_LEVEL` | CLI-only fallback when `CODEX_SECURITY_LOG_LEVEL` is unset. | | `CODEX_SECURITY_STATE_DIR` | Override the private scan-history, workbench, and default artifact directory. | | `CODEX_HOME` | Set the ambient Codex home for file-backed sign-in and default state; defaults to `~/.codex`. | @@ -450,11 +450,11 @@ token and worker counts, estimated cost, the results directory, and the next useful command. Progress and summaries use stderr; structured scan results remain on stdout. -Add `--verbose` or set `CODEX_SECURITY_LOG_LEVEL=debug` to print redacted -lifecycle, authentication, progress, and cost diagnostics to stderr. +Add `--verbose` or set `CODEX_SECURITY_LOG_LEVEL=debug` to print lifecycle, +authentication, progress, and cost diagnostics to stderr. `LOG_LEVEL=debug` is used only when `CODEX_SECURITY_LOG_LEVEL` is unset. -Credentials and provider identifiers remain redacted, and structured JSON -results remain on stdout. +Structured JSON results remain on stdout. Diagnostics, scan output, and scan +history can contain credentials; keep them private. Each scan records its model, tokens, and estimated cost in its JSON result, scan history, and bulk-scan receipt. Estimates use diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index e11352e9..7aaf8e21 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -48,7 +48,7 @@ import { OutputDirectoryError, OutputInsideProtectedRootError, type ProtectedScanPathKind, - redactedErrorMessage, + errorMessage, ScanCostLimitExceededError, ScanInterruptedError, } from "./errors.js"; @@ -1081,10 +1081,8 @@ export class CodexSecurity { "fail-scan", "--scan-id", activeScan.id, - // Redact before truncating: the stored message is read back by - // `scans show` and travels inside the results directory. "--message", - redactedErrorMessage(failure).slice(0, 2400), + errorMessage(failure).slice(0, 2400), ...(snapshot?.cost ? ["--cost-json", JSON.stringify(snapshot.cost)] : []), diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index a97868fb..8f9ef8ba 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -65,7 +65,7 @@ import { OutputDirectoryError, OutputInsideProtectedRootError, PluginPythonUnavailableError, - redactedErrorMessage, + errorMessage, ScanCostLimitExceededError, ScanInterruptedError, } from "./errors.js"; @@ -730,7 +730,7 @@ export async function main( try { return await select(await dependencies.runWorkbench(args)); } catch (error) { - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); exitCode = 2; return undefined; } @@ -936,7 +936,7 @@ export async function main( scanArguments = scanArgumentsFromRecipe(recipe, args.scanId); scanArguments.verbose = options.verbose; } catch (error) { - const message = redactedErrorMessage(error); + const message = errorMessage(error); errorOutput.write(`codex-security: ${message}\n`); exitCode = 2; return incurError({ @@ -999,7 +999,7 @@ export async function main( format, ); } catch (error) { - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); exitCode = 2; return undefined; } @@ -1052,7 +1052,7 @@ export async function main( verbose: z .boolean() .default(false) - .describe("Print redacted scan diagnostics to stderr."), + .describe("Print scan diagnostics to stderr."), path: z .array(optionValue("--path")) .default([]) @@ -1313,7 +1313,7 @@ export async function main( failOnSeverity: options.failOnSeverity, }; } catch (error) { - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); exitCode = 2; return undefined; } @@ -1502,7 +1502,7 @@ export async function main( onProgress: ({ repository, status, attempt, error, warning }) => { const detail = error ?? warning; errorOutput.write( - `codex-security: ${repository} ${status} (attempt ${attempt})${detail === undefined ? "" : `: ${redactedErrorMessage(detail)}`}\n`, + `codex-security: ${repository} ${status} (attempt ${attempt})${detail === undefined ? "" : `: ${errorMessage(detail)}`}\n`, ); }, }); @@ -1516,7 +1516,7 @@ export async function main( (error instanceof Error && error.name === "ExitPromptError" ? 130 : 2); - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); } finally { dependencies.removeSignalListener("SIGINT", onInterrupt); dependencies.removeSignalListener("SIGTERM", onTerminate); @@ -1620,7 +1620,7 @@ export async function main( ); } catch (error) { exitCode = 2; - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); } }, }) @@ -1656,7 +1656,7 @@ export async function main( ); } catch (error) { exitCode = 2; - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); } }, }) @@ -1845,7 +1845,7 @@ export async function main( if (frameworkExit !== undefined) { if (exitCode !== 0) return exitCode; errorOutput.write( - `codex-security: ${redactedErrorMessage(incurErrorMessage(frameworkOutput))}\n`, + `codex-security: ${errorMessage(incurErrorMessage(frameworkOutput))}\n`, ); return 2; } @@ -1854,7 +1854,7 @@ export async function main( await writeCliOutput(output, renderedHistory ?? frameworkOutput); return exitCode; } catch (error) { - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); return 2; } } @@ -2651,7 +2651,7 @@ async function runExport( } return 0; } catch (error) { - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); return 2; } } @@ -2659,7 +2659,7 @@ async function runExport( type VerboseDiagnosticValue = string | number | boolean | null | undefined; function sanitizeDiagnosticValue(value: unknown): string { - return redactedErrorMessage(value) + return errorMessage(value) .replaceAll( /(\b(?:tenant(?:[_-]?id)?|org(?:anization)?(?:[_-]?id)?|project(?:[_-]?id)?|(?:x[_-]?)?(?:request|trace|correlation)[_-]?id)\b(?:\\*["'])?\s*[:=]\s*)(?!\[redacted\])(?:(\\*)(['"])(?:(?!(? displayed.length ? `, +${arguments_.paths.length - displayed.length} more` : ""}`; } @@ -3477,7 +3474,7 @@ function printScanSummary( ? 33 : 36; errorOutput.write( - `\n ${paint("REPORT", "1;36")} ${paint(redactedErrorMessage(result.reportPath), 4)}\n\n` + + `\n ${paint("REPORT", "1;36")} ${paint(errorMessage(result.reportPath), 4)}\n\n` + ` ${paint("FINDINGS", 1)} ${paint(`${findingCount}${severitySummary === "" ? "" : ` (${severitySummary})`}`, findingColor)}\n` + ` ${paint("COVERAGE", 1)} ${result.coverage.completeness}\n` + ` ${paint("ELAPSED", 1)} ${duration}\n`, @@ -3493,7 +3490,7 @@ function printScanSummary( ); } errorOutput.write( - ` ${paint("RESULTS", 1)} ${redactedErrorMessage(result.scanDir)}\n`, + ` ${paint("RESULTS", 1)} ${errorMessage(result.scanDir)}\n`, ); } @@ -3835,7 +3832,7 @@ function interruptedExit( errorOutput.write( scanDir === null ? "codex-security: No partial output was kept.\n" - : `codex-security: Partial output was kept at ${redactedErrorMessage(scanDir)}.\n`, + : `codex-security: Partial output was kept at ${errorMessage(scanDir)}.\n`, ); return ctrlC ? 130 : 143; } @@ -3861,7 +3858,7 @@ if (invokedAsMain()) { process.exitCode = exitCode; }, (error: unknown) => { - process.stderr.write(`codex-security: ${redactedErrorMessage(error)}\n`); + process.stderr.write(`codex-security: ${errorMessage(error)}\n`); process.exitCode = 2; }, ); diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 4a550e3f..132d36ce 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -1,122 +1,7 @@ import { formatUsd, type ScanCost } from "./cost.js"; -/** Returns an error message with credential-shaped substrings redacted. */ -export function redactedErrorMessage(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - const withoutPrivateKeys = message - .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}private[_-]?key(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)(?:\\?["'])?-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:\\?["'])?|$)/giu, - "$1[redacted]", - ) - .replaceAll( - /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/giu, - "[redacted]", - ); - return redactQuotedCredentialValues( - redactCredentialCollections(withoutPrivateKeys), - ) - .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}(?:authorization|auth)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z0-9][A-Za-z0-9.+~-]{0,63})((?:\s|%20|\+)+)[!#$%&'*+.^_`|~0-9A-Za-z-]{1,65}\s*=\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+)(?:\s*,\s*[!#$%&'*+.^_`|~0-9A-Za-z-]{1,65}\s*=\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+))*/giu, - "$1$2$3[redacted]", - ) - .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}(?:authorization|auth)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9._~-]{0,63})((?:\s|%20|\+)+)(?!\[redacted\]|(?!key\s*=)[A-Za-z_][A-Za-z0-9_-]{0,64}\s*[:=]\s*(?=[^=\s"',;}&\\\]]))[^\s"',;}&\\\]]+/giu, - "$1$2$3[redacted]", - ) - .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?keys?|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credentials?|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?!\[redacted\]|[A-Za-z0-9][A-Za-z0-9._~-]{0,63}(?:\s|%20|\+)+\[redacted\])(?:\\(?!["'])|[^\s"',;}&\\\]])+/giu, - "$1[redacted]", - ) - .replaceAll(/sk-(?:proj-)?[A-Za-z0-9_*=-]{8,}/gu, "[redacted]") - .replaceAll(/(?:github_pat_|gh[pousr]_)[A-Za-z0-9_-]{8,}/giu, "[redacted]") - .replaceAll(/npm_[A-Za-z0-9_-]{8,}/giu, "[redacted]") - .replaceAll( - /(^|%20|[^A-Za-z0-9_])(Bearer|Basic|Token)((?:\s|%20|\+)+)[A-Za-z0-9.%_~+/*=-]+/giu, - "$1$2$3[redacted]", - ) - .replaceAll(/((?:https?|ssh|git\+ssh):\/\/)[^\s/@]+@/giu, "$1[redacted]@") - .replaceAll( - /((?:[?&]|%3F|%26)(?:(?!%3F|%26|%3D)(?:[A-Za-z0-9_.%-]|\[|\])){0,64}(?:api(?:[_-]|%5F|%2D)?keys?|access(?:[_-]|%5F|%2D)?key(?:(?:[_-]|%5F|%2D)?id)?|private(?:[_-]|%5F|%2D)?key|authorization|auth|token|secret|credentials?|signature|sig|password|passwd)(?:(?:[_-]|%5F|%2D)[A-Za-z0-9_.%-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_.%-]{0,48})?(?:\]|%5D)?(?:=|%3D))(?:(?!%26)[^&\s])+/giu, - "$1[redacted]", - ); -} - -function redactCredentialCollections(message: string): string { - const assignment = - /(\b[A-Za-z0-9_-]{0,64}(?:credentials|api[_-]?keys)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\*["'])?\s*[:=]\s*)([\[{])/giu; - let output = ""; - let consumed = 0; - for ( - let match = assignment.exec(message); - match !== null; - match = assignment.exec(message) - ) { - const closings = [match[2] === "[" ? "]" : "}"]; - let quote: string | undefined; - let position = assignment.lastIndex; - while (position < message.length && closings.length > 0) { - const character = message[position]!; - if (quote !== undefined) { - if (character === "\\") position += 1; - else if (character === quote) quote = undefined; - } else if (character === '"' || character === "'") { - quote = character; - } else if (character === "[") { - closings.push("]"); - } else if (character === "{") { - closings.push("}"); - } else if (character === closings.at(-1)) { - closings.pop(); - } - position += 1; - } - - const prefix = match[1]!; - const replacement = /:\s*$/u.test(prefix) ? '"[redacted]"' : "[redacted]"; - output += `${message.slice(consumed, match.index)}${prefix}${replacement}`; - consumed = closings.length === 0 ? position : message.length; - assignment.lastIndex = consumed; - } - return output + message.slice(consumed); -} - -function redactQuotedCredentialValues(message: string): string { - const assignment = - /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?keys?|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credentials?|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\*["'])?\s*[:=]\s*)(\\*)(["'])/giu; - let output = ""; - let consumed = 0; - for ( - let match = assignment.exec(message); - match !== null; - match = assignment.exec(message) - ) { - const openingSlashes = match[2]!.length; - const quote = match[3]!; - let position = assignment.lastIndex; - let closed = false; - while (position < message.length) { - const delimiter = message.indexOf(quote, position); - if (delimiter < 0) break; - let preceding = delimiter; - while (preceding > position && message[preceding - 1] === "\\") { - preceding -= 1; - } - if (delimiter - preceding === openingSlashes) { - output += `${message.slice(consumed, assignment.lastIndex)}[redacted]${message.slice(preceding, delimiter + 1)}`; - consumed = delimiter + 1; - assignment.lastIndex = consumed; - closed = true; - break; - } - position = delimiter + 1; - } - if (!closed) { - output += `${message.slice(consumed, assignment.lastIndex)}[redacted]`; - consumed = message.length; - break; - } - } - return output + message.slice(consumed); +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } /** Base error for Codex Security SDK failures. */ diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index 9704014c..ec178cba 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -19,7 +19,7 @@ import Papa from "papaparse"; import type { CodexSecurity } from "./api.js"; import type { CodexSecurityConfig } from "./config.js"; import type { ScanCost } from "./cost.js"; -import { redactedErrorMessage } from "./errors.js"; +import { errorMessage } from "./errors.js"; import type { CoverageDocument } from "./models.js"; import type { ScanMode } from "./targets.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; @@ -246,7 +246,7 @@ async function runCampaign( } } catch (error) { if (options.signal?.aborted === true) options.signal.throwIfAborted(); - failure = redactedErrorMessage(error); + failure = errorMessage(error); } finally { await rm(checkout, { recursive: true, force: true }); } diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 476eaacf..293c12a1 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -43,7 +43,6 @@ import { OutputDirectoryError, PluginBootstrapError, PluginPythonUnavailableError, - redactedErrorMessage, } from "./errors.js"; import type { JsonObject } from "./config.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; @@ -283,10 +282,7 @@ function windowsCredentialAclFailure(error: unknown): string { : error instanceof Error ? error.message : String(error); - const sanitized = redactedErrorMessage(detail) - .replace(/\s+/gu, " ") - .trim() - .slice(0, 512); + const sanitized = detail.replace(/\s+/gu, " ").trim().slice(0, 512); return sanitized === "" ? "" : `. ${sanitized}`; } diff --git a/sdk/typescript/src/scan-dashboard.ts b/sdk/typescript/src/scan-dashboard.ts index 26713d07..293d3a45 100644 --- a/sdk/typescript/src/scan-dashboard.ts +++ b/sdk/typescript/src/scan-dashboard.ts @@ -45,7 +45,6 @@ interface ScanDashboardOptions { maxCostUsd?: number; clock: DashboardClock; color?: boolean; - sanitize?: (value: string) => string; input?: DashboardInput; onInterrupt?: () => void; } @@ -310,10 +309,7 @@ export class ScanDashboard { lines .map((line, index) => { const text = typeof line === "string" ? line : line.text; - const clean = fitLine( - this.#options.sanitize?.(text) ?? text, - width, - ); + const clean = fitLine(text, width); const colored = this.#options.color === true ? styleLine( @@ -333,7 +329,6 @@ export class ScanDashboard { ? styleInlineCode(colored, line.code, line.kind) : colored, line.links, - this.#options.sanitize, ); return `${ERASE_LINE}${formatted}`; }) @@ -449,10 +444,9 @@ function styleInlineCode( function linkActivity( value: string, links: readonly DashboardActivityLink[] | undefined, - sanitize: ((value: string) => string) | undefined, ): string { for (const { label, target } of links ?? []) { - const safe = safeHyperlinkTarget(sanitize?.(target) ?? target); + const safe = safeHyperlinkTarget(target); if (safe !== undefined) { value = value.replace( label, diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 2ea5b331..fcb977f5 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -50,7 +50,7 @@ import { setCodexSecurityCredentialLogout, } from "../src/runtime.js"; import { normalizeTarget } from "../src/targets.js"; -import { REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS } from "./cli-fixtures.js"; +import { SYNTHETIC_CREDENTIALS } from "./cli-fixtures.js"; import { INTEGRATION_TARGET, PLUGIN_ROOT } from "./plugin-root.js"; type ScanObserverName = Parameters< @@ -3145,7 +3145,7 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); - test("redacts credentials from the stored scan failure message", async () => { + test("preserves the original stored scan failure message", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const codexHome = join(root, "codex-home"); @@ -3164,7 +3164,7 @@ describe("CodexSecurity orchestration", () => { const quotedCredential = JSON.stringify({ client_secret_value: "SYNTHETIC correct horse battery staple", }); - const redactedFailure = `${REDACTED_CREDENTIALS} {"client_secret_value":"[redacted]"}`; + const failureMessage = `${SYNTHETIC_CREDENTIALS} ${quotedCredential}`; const client = new TestClient( {}, { @@ -3200,14 +3200,12 @@ describe("CodexSecurity orchestration", () => { }, ); - // The in-memory error keeps its original text; only what leaves the process - // is redacted, so the CLI can still classify the upstream failure. await expect(client.run(repository)).rejects.toThrow(SYNTHETIC_CREDENTIALS); const failure = commands.find((args) => args[0] === "fail-scan"); const scanId = failure?.[2] ?? ""; expect(scanId).toMatch(/^[0-9a-f-]{36}$/); expect(failure?.[3]).toBe("--message"); - expect(failure?.[4]).toBe(redactedFailure); + expect(failure?.[4]).toBe(failureMessage); // `scans show` reads the stored message back through get-scan. const context = await runWorkbench( @@ -3216,13 +3214,11 @@ describe("CodexSecurity orchestration", () => { ); expect(context["scan"]).toMatchObject({ progress: { status: "failed" }, - failureMessage: redactedFailure, + failureMessage, }); - // Every synthetic credential is tagged SYNTHETIC, so the database file - // itself proves nothing was persisted anywhere on the failure path. const database = await readFile(join(stateDirectory, "workbench.sqlite3")); - expect(database.toString("latin1")).not.toContain("SYNTHETIC"); + expect(database.toString("latin1")).toContain(failureMessage); await client.close(); }); diff --git a/sdk/typescript/tests-ts/cli-export.test.ts b/sdk/typescript/tests-ts/cli-export.test.ts index aacfb329..dc784495 100644 --- a/sdk/typescript/tests-ts/cli-export.test.ts +++ b/sdk/typescript/tests-ts/cli-export.test.ts @@ -16,7 +16,6 @@ import { describe, expect, test } from "bun:test"; import { exportEnvironment, main } from "../src/cli.js"; import { CodexSecurityError } from "../src/index.js"; import { - REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS, capture, dependencies, @@ -583,7 +582,7 @@ describe("CLI", () => { ); }); - test("redacts credentials from caught export failures", async () => { + test("preserves caught export failures", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -601,7 +600,7 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toBe( - `codex-security: export failed ${REDACTED_CREDENTIALS}\n`, + `codex-security: export failed ${SYNTHETIC_CREDENTIALS}\n`, ); }); }); diff --git a/sdk/typescript/tests-ts/cli-fixtures.ts b/sdk/typescript/tests-ts/cli-fixtures.ts index 24040954..644e8db9 100644 --- a/sdk/typescript/tests-ts/cli-fixtures.ts +++ b/sdk/typescript/tests-ts/cli-fixtures.ts @@ -21,109 +21,7 @@ import type { UpdateNotice } from "../src/version.js"; type MainDependencies = NonNullable[3]>; -export const SYNTHETIC_CREDENTIALS = [ - "sk-proj-SYNTHETIC_KEY_123", - "Bearer SYNTHETIC_TOKEN_123", - "Authorization: Basic SYNTHETIC_BASIC_123", - "Authorization: Token SYNTHETIC_HEADER_TOKEN_123", - "Authorization: Bearer%20SYNTHETIC%2FENCODED%2BTOKEN_123", - "Authorization%3A%20Bearer%20SYNTHETIC_FULLY_ENCODED_TOKEN_123", - "https://SYNTHETIC_USER:SYNTHETIC_PASSWORD@example.test/private", - "ssh://SYNTHETIC_USER:SYNTHETIC_SSH_PASSWORD@example.test/private", - "git+ssh://SYNTHETIC_USER:SYNTHETIC_GIT_PASSWORD@example.test/private", - "github_pat_SYNTHETIC_GITHUB_PAT_123", - "ghs_SYNTHETIC_GITHUB_TOKEN_123", - "OPENAI_API_KEY=SYNTHETIC_OPENAI_VALUE_123", - "CODEX_API_KEY=SYNTHETIC_CODEX_VALUE_123", - "CODEX_ACCESS_TOKEN=SYNTHETIC_CODEX_ACCESS_TOKEN_123", - "GITHUB_TOKEN=SYNTHETIC_GITHUB_VALUE_123", - "GH_TOKEN=SYNTHETIC_GH_VALUE_123", - '{"OPENAI_API_KEY":"SYNTHETIC_JSON_OPENAI_123","CODEX_API_KEY":"SYNTHETIC_JSON_CODEX_123"}', - '{\\"OPENAI_API_KEY\\":\\"SYNTHETIC_ESCAPED_OPENAI_123\\",\\"CODEX_API_KEY\\":\\"SYNTHETIC_ESCAPED_CODEX_123\\"}', - '{"refresh_token":"SYNTHETIC_REFRESH_TOKEN_123","id_token":"SYNTHETIC_ID_TOKEN_123","clientSecret":"SYNTHETIC_CLIENT_SECRET_123","dbPassword":"SYNTHETIC_PASSWORD_123","passwd":"SYNTHETIC_PASSWD_123"}', - '{\\"refreshToken\\":\\"SYNTHETIC_ESCAPED_REFRESH_123\\",\\"idToken\\":\\"SYNTHETIC_ESCAPED_ID_123\\",\\"clientSecret\\":\\"SYNTHETIC_ESCAPED_SECRET_123\\",\\"password\\":\\"SYNTHETIC_ESCAPED_PASSWORD_123\\"}', - "AWS_SECRET_ACCESS_KEY=SYNTHETIC_AWS_SECRET_123", - "AWS_ACCESS_KEY_ID=SYNTHETIC_AWS_ID_123", - "AWS_SESSION_TOKEN=SYNTHETIC_AWS_SESSION_123", - "NODE_AUTH_TOKEN=SYNTHETIC_NODE_AUTH_123", - "NPM_TOKEN=SYNTHETIC_NPM_TOKEN_123", - "OPENAI_API_KEY=sk-proj-SYNTHETIC_NAMED_OPENAI_123", - "GITHUB_TOKEN=ghs_SYNTHETIC_NAMED_GITHUB_123", - "NPM_TOKEN=npm_SYNTHETIC_NAMED_NPM_123", - "ACTIONS_ID_TOKEN_REQUEST_TOKEN=SYNTHETIC_ACTIONS_TOKEN_123", - "ACTIONS_RUNTIME_TOKEN=SYNTHETIC_ACTIONS_RUNTIME_123", - "GITLAB_TOKEN=SYNTHETIC_GITLAB_TOKEN_123", - "HF_TOKEN=SYNTHETIC_HF_TOKEN_123", - "SLACK_BOT_TOKEN=SYNTHETIC_SLACK_TOKEN_123", - "//registry.npmjs.org/:_authToken=SYNTHETIC_NPMRC_TOKEN_123", - "x-api-key: SYNTHETIC_HEADER_KEY_123", - "access_token=SYNTHETIC_ACCESS_TOKEN_123", - "npm_SYNTHETIC_BARE_TOKEN_123", - "https://example.test/?token=SYNTHETIC_QUERY_123&safe=1", - "https://example.test/?credential=SYNTHETIC_CREDENTIAL_123&safe=1", - "https://example.test/?AWS_ACCESS_KEY_ID=SYNTHETIC_QUERY_AWS_ID_123&safe=1", - "https://example.test/?AWS%5FACCESS%5FKEY%5FID=SYNTHETIC_ENCODED_AWS_ID_123&AWS%2DACCESS%2DKEY%2DID=SYNTHETIC_ENCODED_AWS_DASH_ID_123&safe=1", - "https://example.test/?service-api-key=SYNTHETIC_QUERY_API_KEY_123&service-access-token=SYNTHETIC_QUERY_ACCESS_TOKEN_123&service-token=SYNTHETIC_QUERY_TOKEN_123&service-secret=SYNTHETIC_QUERY_SECRET_123&signature=SYNTHETIC_SIGNATURE_123&safe=1", - "https://example.test/?X-Amz-Signature=SYNTHETIC_AMZ_SIGNATURE_123&X-Amz-Credential=SYNTHETIC_AMZ_CREDENTIAL_123&X-Amz-Security-Token=SYNTHETIC_AMZ_TOKEN_123&safe=1", - "https://example.test/?X-Goog-Signature=SYNTHETIC_GOOG_SIGNATURE_123&X-Goog-Credential=SYNTHETIC_GOOG_CREDENTIAL_123&safe=1", - "https://example.test/?sv=2026-01-01&sig=SYNTHETIC_AZURE_SIG_123&safe=1", - "https://example.test/?password=SYNTHETIC_QUERY_PASSWORD_123&passwd=SYNTHETIC_QUERY_PASSWD_123&safe=1", - "https://example.test/?oauth.refreshToken=SYNTHETIC_DOTTED_TOKEN_123&auth[token]=SYNTHETIC_BRACKET_TOKEN_123&auth%5BclientSecret%5D=SYNTHETIC_ENCODED_SECRET_123&safe=1", - "https://example.test/?access_token%3DSYNTHETIC_ENCODED_ACCESS_123&client_secret%3DSYNTHETIC_ENCODED_CLIENT_123&safe=1", - "https://example.test/?redirect_uri=https%3A%2F%2Finner.test%2Fcb%3Frefresh_token%3DSYNTHETIC_NESTED_REFRESH_123%26password%3DSYNTHETIC_NESTED_PASSWORD_123%26safe%3D1", -].join(" "); - -export const REDACTED_CREDENTIALS = [ - "[redacted]", - "Bearer [redacted]", - "Authorization: Basic [redacted]", - "Authorization: Token [redacted]", - "Authorization: Bearer%20[redacted]", - "Authorization%3A%20Bearer%20[redacted]", - "https://[redacted]@example.test/private", - "ssh://[redacted]@example.test/private", - "git+ssh://[redacted]@example.test/private", - "[redacted]", - "[redacted]", - "OPENAI_API_KEY=[redacted]", - "CODEX_API_KEY=[redacted]", - "CODEX_ACCESS_TOKEN=[redacted]", - "GITHUB_TOKEN=[redacted]", - "GH_TOKEN=[redacted]", - '{"OPENAI_API_KEY":"[redacted]","CODEX_API_KEY":"[redacted]"}', - '{\\"OPENAI_API_KEY\\":\\"[redacted]\\",\\"CODEX_API_KEY\\":\\"[redacted]\\"}', - '{"refresh_token":"[redacted]","id_token":"[redacted]","clientSecret":"[redacted]","dbPassword":"[redacted]","passwd":"[redacted]"}', - '{\\"refreshToken\\":\\"[redacted]\\",\\"idToken\\":\\"[redacted]\\",\\"clientSecret\\":\\"[redacted]\\",\\"password\\":\\"[redacted]\\"}', - "AWS_SECRET_ACCESS_KEY=[redacted]", - "AWS_ACCESS_KEY_ID=[redacted]", - "AWS_SESSION_TOKEN=[redacted]", - "NODE_AUTH_TOKEN=[redacted]", - "NPM_TOKEN=[redacted]", - "OPENAI_API_KEY=[redacted]", - "GITHUB_TOKEN=[redacted]", - "NPM_TOKEN=[redacted]", - "ACTIONS_ID_TOKEN_REQUEST_TOKEN=[redacted]", - "ACTIONS_RUNTIME_TOKEN=[redacted]", - "GITLAB_TOKEN=[redacted]", - "HF_TOKEN=[redacted]", - "SLACK_BOT_TOKEN=[redacted]", - "//registry.npmjs.org/:_authToken=[redacted]", - "x-api-key: [redacted]", - "access_token=[redacted]", - "[redacted]", - "https://example.test/?token=[redacted]&safe=1", - "https://example.test/?credential=[redacted]&safe=1", - "https://example.test/?AWS_ACCESS_KEY_ID=[redacted]&safe=1", - "https://example.test/?AWS%5FACCESS%5FKEY%5FID=[redacted]&AWS%2DACCESS%2DKEY%2DID=[redacted]&safe=1", - "https://example.test/?service-api-key=[redacted]&service-access-token=[redacted]&service-token=[redacted]&service-secret=[redacted]&signature=[redacted]&safe=1", - "https://example.test/?X-Amz-Signature=[redacted]&X-Amz-Credential=[redacted]&X-Amz-Security-Token=[redacted]&safe=1", - "https://example.test/?X-Goog-Signature=[redacted]&X-Goog-Credential=[redacted]&safe=1", - "https://example.test/?sv=2026-01-01&sig=[redacted]&safe=1", - "https://example.test/?password=[redacted]&passwd=[redacted]&safe=1", - "https://example.test/?oauth.refreshToken=[redacted]&auth[token]=[redacted]&auth%5BclientSecret%5D=[redacted]&safe=1", - "https://example.test/?access_token%3D[redacted]&client_secret%3D[redacted]&safe=1", - "https://example.test/?redirect_uri=https%3A%2F%2Finner.test%2Fcb%3Frefresh_token%3D[redacted]%26password%3D[redacted]%26safe%3D1", -].join(" "); +export const SYNTHETIC_CREDENTIALS = "sk-proj-SYNTHETIC_KEY_123"; export function capture(isTTY = false): { stream: Pick & diff --git a/sdk/typescript/tests-ts/cli-launcher.test.ts b/sdk/typescript/tests-ts/cli-launcher.test.ts index 703256df..92a0e777 100644 --- a/sdk/typescript/tests-ts/cli-launcher.test.ts +++ b/sdk/typescript/tests-ts/cli-launcher.test.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, test } from "bun:test"; import { VERSION } from "../src/index.js"; -import { REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS } from "./support/cli.js"; +import { SYNTHETIC_CREDENTIALS } from "./support/cli.js"; const packageRoot = join(import.meta.dir, ".."); @@ -40,7 +40,7 @@ describe("CLI launcher", () => { } }); - test("maps unexpected source-entrypoint failures to exit 2 and redacts credentials", async () => { + test("maps unexpected source-entrypoint failures to exit 2", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-cli-failure-")); try { const preload = join(root, "unavailable-cwd.mjs"); @@ -57,7 +57,7 @@ describe("CLI launcher", () => { expect(child.status).toBe(2); expect(child.stdout).toBe(""); expect(child.stderr).toBe( - `working directory is unavailable: ${REDACTED_CREDENTIALS}\n`, + `working directory is unavailable: ${SYNTHETIC_CREDENTIALS}\n`, ); } finally { await rm(root, { recursive: true, force: true }); diff --git a/sdk/typescript/tests-ts/cli-workbench.test.ts b/sdk/typescript/tests-ts/cli-workbench.test.ts index aea574ba..5d6c559e 100644 --- a/sdk/typescript/tests-ts/cli-workbench.test.ts +++ b/sdk/typescript/tests-ts/cli-workbench.test.ts @@ -3,12 +3,7 @@ import { describe, expect, test } from "bun:test"; import type { CodexSecurityConfig, JsonObject } from "../src/index.js"; import { DiffTarget } from "../src/index.js"; import { main } from "../src/cli.js"; -import { - capture, - dependencies, - REDACTED_CREDENTIALS, - SYNTHETIC_CREDENTIALS, -} from "./support/cli.js"; +import { capture, dependencies, SYNTHETIC_CREDENTIALS } from "./support/cli.js"; describe("CLI workbench", () => { test("lists repository and scan-root history without starting Codex", async () => { @@ -619,7 +614,7 @@ describe("CLI workbench", () => { } }); - test("redacts workbench failures and does not initialize Codex", async () => { + test("reports workbench failures and does not initialize Codex", async () => { const stderr = capture(); let started = false; expect( @@ -637,8 +632,7 @@ describe("CLI workbench", () => { }), ), ).toBe(2); - expect(stderr.text()).toContain(REDACTED_CREDENTIALS); - expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).toContain(SYNTHETIC_CREDENTIALS); expect(started).toBe(false); }); }); diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 5475ef49..b726520b 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -22,7 +22,6 @@ import type { ScanOptions, ScanPreflight, } from "../src/index.js"; -import { redactedErrorMessage } from "../src/errors.js"; import { BUNDLED_PLUGIN_VERSION, CodexSecurityError, @@ -45,7 +44,6 @@ import { } from "../src/config.js"; import { FakeSignals, - REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS, capture, dependencies, @@ -499,7 +497,7 @@ describe("CLI", () => { expect(workbenchCalled).toBe(false); }); - test("redacts false-positive workbench failures", async () => { + test("reports false-positive workbench failures", async () => { const stdout = capture(); const stderr = capture(); let started = false; @@ -529,8 +527,7 @@ describe("CLI", () => { ), ).toBe(2); expect(stdout.text()).toBe(""); - expect(stderr.text()).toContain(REDACTED_CREDENTIALS); - expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).toContain(SYNTHETIC_CREDENTIALS); expect(started).toBe(false); }); @@ -837,7 +834,7 @@ describe("CLI", () => { }, ); - test("preserves the bulk-scan failure summary and redacts progress errors", async () => { + test("preserves the bulk-scan failure summary and progress errors", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-cli-multiscan-")); try { await multiscanInventory(root); @@ -871,8 +868,7 @@ describe("CLI", () => { skipped: 0, }); expect(stderr.text()).toContain("sample failed (attempt 1)"); - expect(stderr.text()).toContain("[redacted]"); - expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).toContain("SYNTHETIC_KEY_123"); } finally { await rm(root, { recursive: true, force: true }); } @@ -1688,8 +1684,9 @@ describe("CLI", () => { ); expect(text).not.toContain("thinking ·"); expect(text).not.toContain("said ·"); - expect(text).toContain('curl -H "Authorization: Bearer [redacted]"'); - expect(text).not.toContain("SYNTHETIC_OPENAI_VALUE_123"); + expect(text).toContain( + 'curl -H "Authorization: Bearer sk-proj-SYNTHETIC_OPENAI_VALUE_123"', + ); expect(text).not.toContain("Building the file inventory"); expect(text).not.toContain("Running a scan command"); expect(text).toContain("3 / 1,258 reviewed"); @@ -3334,7 +3331,7 @@ describe("CLI", () => { } }); - test("redacts verbose output paths and observer diagnostics", async () => { + test("preserves verbose output paths and observer diagnostics", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -3366,16 +3363,15 @@ describe("CLI", () => { ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( - 'codex-security: debug: scan.output_archived archive_dir="/tmp/archive_[redacted]"', + 'codex-security: debug: scan.output_archived archive_dir="/tmp/archive_sk-proj-SYNTHETIC_ARCHIVE_SECRET_123"', ); expect(stderr.text()).toContain( - 'codex-security: debug: scan.output_ready scan_dir="/tmp/scan_[redacted]"', + 'codex-security: debug: scan.output_ready scan_dir="/tmp/scan_sk-proj-SYNTHETIC_OUTPUT_SECRET_123"', ); expect(stderr.text()).toContain( 'codex-security: debug: scan.observer_failed observer="onWorkerStatus"', ); - expect(stderr.text()).toContain("[redacted]"); - expect(stderr.text()).not.toContain("SYNTHETIC"); + expect(stderr.text()).toContain("SYNTHETIC_KEY_123"); }); test("excludes observer failure context from verbose diagnostics", async () => { @@ -3692,7 +3688,7 @@ describe("CLI", () => { } }); - test("redacts credentials in underlying network errors", async () => { + test("preserves underlying network errors", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -3711,106 +3707,12 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toContain( - `network failure ECONNRESET ${REDACTED_CREDENTIALS}`, + `network failure ECONNRESET ${SYNTHETIC_CREDENTIALS}`, ); - expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).toContain("SYNTHETIC_KEY_123"); expect(stderr.text()).not.toContain("model service could not be reached"); }); - test("redacts quoted multiword credentials and private-key assignments", () => { - expect( - redactedErrorMessage( - 'password="correct horse battery staple" private_key=SYNTHETIC_PRIVATE_KEY_123', - ), - ).toBe('password="[redacted]" private_key=[redacted]'); - expect( - redactedErrorMessage( - '{"client_secret_value":"correct horse battery staple","safe":"visible"}', - ), - ).toBe('{"client_secret_value":"[redacted]","safe":"visible"}'); - expect( - redactedErrorMessage( - '{"clientSecretValue":"camel case secret","accessTokenValue":"camel case token"}', - ), - ).toBe( - '{"clientSecretValue":"[redacted]","accessTokenValue":"[redacted]"}', - ); - expect( - redactedErrorMessage( - "clientSecretValue=SYNTHETIC_CAMEL_SECRET accessTokenValue=SYNTHETIC_CAMEL_TOKEN https://example.test/?clientSecretValue=SYNTHETIC_CAMEL_QUERY", - ), - ).toBe( - "clientSecretValue=[redacted] accessTokenValue=[redacted] https://example.test/?clientSecretValue=[redacted]", - ); - expect( - redactedErrorMessage( - '{\\"access_token_value\\":\\"another horse battery staple\\"}', - ), - ).toBe('{\\"access_token_value\\":\\"[redacted]\\"}'); - expect( - redactedErrorMessage( - 'authorization="opaque secret value" _auth=Zm9vOmJhcg== https://example.test/?authorization=opaque%20query%20secret', - ), - ).toBe( - 'authorization="[redacted]" _auth=[redacted] https://example.test/?authorization=[redacted]', - ); - for (const [authorization, redacted] of [ - [ - "Authorization: ApiKey SYNTHETIC_APIKEY_SECRET", - "Authorization: ApiKey [redacted]", - ], - ["auth=Custom%20SYNTHETIC_CUSTOM_SECRET", "auth=Custom%20[redacted]"], - [ - "Authorization: Digest+SYNTHETIC_DIGEST_SECRET", - "Authorization: Digest+[redacted]", - ], - [ - "client_authorization_value=ApiKey SYNTHETIC_SUFFIXED_SECRET", - "client_authorization_value=ApiKey [redacted]", - ], - ["Authorization: ApiKey dGVzdA==", "Authorization: ApiKey [redacted]"], - ["Authorization: ApiKey dGVzdA=", "Authorization: ApiKey [redacted]"], - ["Authorization: ApiKey key=SECRET", "Authorization: ApiKey [redacted]"], - ["auth=Custom key=SECRET", "auth=Custom [redacted]"], - [ - "client_auth_token=Custom dGVzdA==", - "client_auth_token=Custom [redacted]", - ], - ] as const) { - expect(redactedErrorMessage(authorization)).toBe(redacted); - } - expect(redactedErrorMessage('password="correct horse battery staple')).toBe( - 'password="[redacted]', - ); - let encoded: string | { password: string } = { - password: 'foo "bar" baz', - }; - for (let depth = 1; depth <= 3; depth += 1) { - encoded = JSON.stringify(encoded); - const redacted = redactedErrorMessage(encoded); - expect(redacted).not.toContain("foo"); - expect(redacted).not.toContain("bar"); - expect(redacted).not.toContain("baz"); - let decoded: unknown = redacted; - for (let layer = 0; layer < depth; layer += 1) { - decoded = JSON.parse(decoded as string); - } - expect(decoded).toEqual({ password: "[redacted]" }); - } - for (const separator of ["\n", "\\n"]) { - expect( - redactedErrorMessage( - `private_key=-----BEGIN PRIVATE KEY-----${separator}MII_SYNTHETIC_PRIVATE_KEY${separator}-----END PRIVATE KEY----- safe=value`, - ), - ).toBe("private_key=[redacted] safe=value"); - expect( - redactedErrorMessage( - `private_key=-----BEGIN PRIVATE KEY-----${separator}MII_SYNTHETIC_TRUNCATED_PRIVATE_KEY`, - ), - ).toBe("private_key=[redacted]"); - } - }); - test("reports database connection failures without claiming the model network failed", async () => { const stdout = capture(); const stderr = capture(); @@ -3835,7 +3737,7 @@ describe("CLI", () => { expect(stderr.text()).toContain("unable to open database file"); expect(stderr.text()).not.toContain("model service could not be reached"); expect(stderr.text()).not.toContain("Check your network connection"); - expect(stderr.text()).not.toContain("SYNTHETIC_DATABASE_SECRET"); + expect(stderr.text()).toContain("SYNTHETIC_DATABASE_SECRET"); }); test("prints only the completion summary for default scans", async () => { @@ -4020,7 +3922,7 @@ describe("CLI", () => { } }); - test("emits redacted scan warnings in verbose diagnostics", async () => { + test("emits scan warnings in verbose diagnostics", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -4045,12 +3947,11 @@ describe("CLI", () => { ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( - 'codex-security: debug: scan.warning message="Repository HEAD changed during the scan: [redacted]"', + 'codex-security: debug: scan.warning message="Repository HEAD changed during the scan: sk-proj-SYNTHETIC_WARNING_SECRET_123"', ); expect(stderr.text()).toContain( - "codex-security: warning: Repository HEAD changed during the scan: [redacted]", + "codex-security: warning: Repository HEAD changed during the scan: sk-proj-SYNTHETIC_WARNING_SECRET_123", ); - expect(stderr.text()).not.toContain("SYNTHETIC_WARNING_SECRET"); }); test("prints granted trusted cyber access without warning or corrupting JSON scans", async () => { @@ -4175,9 +4076,8 @@ describe("CLI", () => { ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( - `codex-security: warning: onWorkerStatus observer failed: status observer failed ${REDACTED_CREDENTIALS}`, + `codex-security: warning: onWorkerStatus observer failed: status observer failed ${SYNTHETIC_CREDENTIALS}`, ); - expect(stderr.text()).not.toContain("SYNTHETIC_OPENAI_VALUE_123"); }); test("maps failed scan stdout writes to the runtime-error exit code", async () => { @@ -4481,7 +4381,7 @@ describe("CLI", () => { expect(JSON.parse(stdout.text())).toEqual(result.toJSON()); }); - test("keeps scan progress scope and completion paths redacted", async () => { + test("preserves scan progress scope and completion paths", async () => { const stdout = capture(); const stderr = capture(); const result = fakeResult(); @@ -4503,10 +4403,12 @@ describe("CLI", () => { dependencies({ result }), ), ).toBe(0); - expect(stderr.text()).not.toContain("SYNTHETIC_SCOPE_KEY_123"); - expect(stderr.text()).not.toContain("SYNTHETIC_OUTPUT_KEY_123"); - expect(stderr.text()).toContain("src/[redacted]"); - expect(stderr.text()).toContain("/tmp/scan_[redacted]"); + expect(stderr.text()).toContain("SYNTHETIC_SCOPE_KEY_123"); + expect(stderr.text()).toContain("SYNTHETIC_OUTPUT_KEY_123"); + expect(stderr.text()).toContain("src/sk-proj-SYNTHETIC_SCOPE_KEY_123"); + expect(stderr.text()).toContain( + "/tmp/scan_sk-proj-SYNTHETIC_OUTPUT_KEY_123", + ); }); test("reports parent fallback when delegated workers cannot start", async () => { @@ -4649,7 +4551,7 @@ describe("CLI", () => { expect(stderr.text()).not.toContain("Running scan"); }); - test("keeps redacted archive notices on stderr for JSON scans", async () => { + test("keeps archive notices on stderr for JSON scans", async () => { const stdout = capture(); const stderr = capture(); expect( @@ -4682,9 +4584,9 @@ describe("CLI", () => { expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( "[00:00] Preparing scan\n" + - "Moved existing results to: /tmp/[redacted]/results.previous-20260721T031422-1234abcd\n", + "Moved existing results to: /tmp/sk-proj-SYNTHETIC_ARCHIVE_KEY_123/results.previous-20260721T031422-1234abcd\n", ); - expect(stderr.text()).not.toContain("SYNTHETIC_ARCHIVE_KEY_123"); + expect(stderr.text()).toContain("SYNTHETIC_ARCHIVE_KEY_123"); }); test("reports findings by severity and applies the requested policy", async () => { @@ -4953,7 +4855,7 @@ describe("CLI", () => { expect(stderr.text()).not.toContain("codex-security:"); }); - test("redacts credentials embedded in protected-root diagnostics", async () => { + test("preserves protected-root diagnostics", async () => { const stdout = capture(); const stderr = capture(); const protectedRoot = @@ -4978,16 +4880,16 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toContain( - "Resolved path: /private/tmp/worktree_[redacted]/results_[redacted]", + "Resolved path: /private/tmp/worktree_sk-proj-SYNTHETIC_ROOT_KEY_123/results_sk-proj-SYNTHETIC_OUTPUT_KEY_123", ); expect(stderr.text()).toContain( - "Protected root: /private/tmp/worktree_[redacted]", + "Protected root: /private/tmp/worktree_sk-proj-SYNTHETIC_ROOT_KEY_123", ); - expect(stderr.text()).not.toContain("SYNTHETIC_ROOT_KEY"); - expect(stderr.text()).not.toContain("SYNTHETIC_OUTPUT_KEY"); + expect(stderr.text()).toContain("SYNTHETIC_ROOT_KEY"); + expect(stderr.text()).toContain("SYNTHETIC_OUTPUT_KEY"); }); - test("redacts credentials from caught scan and interruption failures", async () => { + test("preserves caught scan and interruption failures", async () => { for (const failure of [ new CodexSecurityError(`scan failed ${SYNTHETIC_CREDENTIALS}`), new ScanInterruptedError( @@ -5011,12 +4913,12 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toBe( - "[00:00] Preparing scan\n" + `scan failed ${REDACTED_CREDENTIALS}\n`, + "[00:00] Preparing scan\n" + `scan failed ${SYNTHETIC_CREDENTIALS}\n`, ); } }); - test("redacts embedded credentials from retained partial-output paths", async () => { + test("preserves retained partial-output paths", async () => { const path = "/private/tmp/scan_sk-proj-SYNTHETIC_PATH_KEY_123/results"; for (const [signal, expectedExit] of [ [null, 2], @@ -5044,9 +4946,9 @@ describe("CLI", () => { ).toBe(expectedExit); expect(stdout.text()).toBe(""); expect(stderr.text()).toContain( - "Partial output was kept at /private/tmp/scan_[redacted]/results.", + "Partial output was kept at /private/tmp/scan_sk-proj-SYNTHETIC_PATH_KEY_123/results.", ); - expect(stderr.text()).not.toContain("SYNTHETIC_PATH_KEY"); + expect(stderr.text()).toContain("SYNTHETIC_PATH_KEY"); } }, 30_000); diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts deleted file mode 100644 index a48ec914..00000000 --- a/sdk/typescript/tests-ts/errors.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { redactedErrorMessage } from "../src/errors.js"; - -describe("security error redaction", () => { - test("redacts standalone and truncated PEM private-key envelopes", () => { - expect( - redactedErrorMessage( - "provider failed: -----BEGIN PRIVATE KEY-----\nSYNTHETIC_PRIVATE_KEY\n-----END PRIVATE KEY----- safe=value", - ), - ).toBe("provider failed: [redacted] safe=value"); - expect( - redactedErrorMessage( - "provider failed: -----BEGIN RSA PRIVATE KEY-----\nSYNTHETIC_TRUNCATED_KEY", - ), - ).toBe("provider failed: [redacted]"); - expect( - redactedErrorMessage( - "provider failed: -----BEGIN PGP PRIVATE KEY BLOCK-----\nSYNTHETIC_PGP_PRIVATE_KEY\n-----END PGP PRIVATE KEY BLOCK----- safe=value", - ), - ).toBe("provider failed: [redacted] safe=value"); - }); - - test("redacts every parameter from structured authorization schemes", () => { - expect( - redactedErrorMessage( - 'Authorization: Digest username="example", response=SYNTHETIC_DIGEST_SECRET', - ), - ).toBe("Authorization: Digest [redacted]"); - expect( - redactedErrorMessage("auth=Custom response=SYNTHETIC_AUTH_SECRET"), - ).toBe("auth=Custom [redacted]"); - expect( - redactedErrorMessage( - "Authorization: Custom key=SYNTHETIC_AUTH_SECRET https://example.test/safe", - ), - ).toBe("Authorization: Custom [redacted] https://example.test/safe"); - expect( - redactedErrorMessage( - 'Authorization: Digest username="a\\"b", response="SYNTHETIC_DIGEST_SECRET"', - ), - ).toBe("Authorization: Digest [redacted]"); - expect( - redactedErrorMessage( - "Authorization: Digest username*=UTF-8''user, response=SYNTHETIC_DIGEST_SECRET", - ), - ).toBe("Authorization: Digest [redacted]"); - expect( - redactedErrorMessage( - 'client_authorization_value=Digest username="example", response="SYNTHETIC_DIGEST_SECRET"', - ), - ).toBe("client_authorization_value=Digest [redacted]"); - expect( - redactedErrorMessage( - "Authorization: 0-custom 0nonce=visible, !response=SYNTHETIC_DIGEST_SECRET", - ), - ).toBe("Authorization: 0-custom [redacted]"); - }); - - test("redacts encoded API-key names without consuming other parameters", () => { - for (const separator of ["%5F", "%2D"]) { - const value = `https://example.test/?api${separator}key%3DSYNTHETIC_API_KEY%26safe%3Dvisible`; - expect(redactedErrorMessage(value)).toBe( - `https://example.test/?api${separator}key%3D[redacted]%26safe%3Dvisible`, - ); - } - }); - - test("redacts plural credential assignments and quoted values", () => { - expect( - redactedErrorMessage( - "credentials=SYNTHETIC_CREDENTIAL clientCredentials=SYNTHETIC_CLIENT apiKeys=SYNTHETIC_KEYS", - ), - ).toBe( - "credentials=[redacted] clientCredentials=[redacted] apiKeys=[redacted]", - ); - expect( - redactedErrorMessage('{"credentials":"correct horse battery staple"}'), - ).toBe('{"credentials":"[redacted]"}'); - expect( - redactedErrorMessage( - "credentials=[alice:SYNTHETIC_ONE,bob:SYNTHETIC_TWO] safe=visible", - ), - ).toBe("credentials=[redacted] safe=visible"); - expect( - redactedErrorMessage( - '{"credentials":["SYNTHETIC_ONE","SYNTHETIC_TWO"],"safe":"visible"}', - ), - ).toBe('{"credentials":"[redacted]","safe":"visible"}'); - expect( - redactedErrorMessage( - 'credentials_value=["SYNTHETIC_ONE","SYNTHETIC_TWO"] safe=visible', - ), - ).toBe("credentials_value=[redacted] safe=visible"); - expect( - redactedErrorMessage( - '{"api_keys_data":[{"key":"SYNTHETIC_ONE"},{"key":"SYNTHETIC_TWO"}]}', - ), - ).toBe('{"api_keys_data":"[redacted]"}'); - expect( - redactedErrorMessage("password=prefix\\SYNTHETIC_SECRET safe=visible"), - ).toBe("password=[redacted] safe=visible"); - }); -}); diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 3ef41a0b..ebd67fc1 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -1223,22 +1223,6 @@ describe("multiscan", () => { const source = await repository(paths.root, "retry"); const secret = "sk-proj-SYNTHETIC_MULTISCAN_SECRET_123"; const knowledgeBasePaths = ["architecture.md"]; - const proxyUrl = - "https://SYNTHETIC_USER:SYNTHETIC_MULTISCAN_PASSWORD@proxy.test/v1/responses"; - const queryUrl = - "https://proxy.test/v1/responses?api_key=SYNTHETIC_MULTISCAN_QUERY_123&safe=1"; - const shortAuthorization = "Bearer abc123"; - const suffixedSecret = "SYNTHETIC_SUFFIXED_CLIENT_SECRET_123"; - const suffixedToken = "SYNTHETIC_SUFFIXED_ACCESS_TOKEN_123"; - const suffixedQuery = "SYNTHETIC_SUFFIXED_QUERY_SECRET_123"; - const quotedSecret = "SYNTHETIC correct horse battery staple"; - const opaqueAuthorization = "SYNTHETIC opaque authorization secret"; - const npmAuthorization = "SYNTHETIC_NPM_AUTH_VALUE_123"; - const customAuthorization = "SYNTHETIC_CUSTOM_AUTHORIZATION_123"; - const suffixedAuthorization = "SYNTHETIC_SUFFIXED_AUTHORIZATION_123"; - const paddedAuthorization = "SYNTHETIC_PADDED_AUTHORIZATION_TOKEN=="; - const keyedAuthorization = "SYNTHETIC_KEYED_AUTHORIZATION_SECRET_123"; - const camelCaseSecret = "SYNTHETIC_CAMEL_CASE_CLIENT_SECRET_123"; await writeFile( paths.input, `id,repository,revision\nretry,${source.path},${source.revision}\n`, @@ -1251,11 +1235,7 @@ describe("multiscan", () => { client(async (_repository, scanOptions = {}) => { expect(scanOptions.knowledgeBasePaths).toEqual(knowledgeBasePaths); attempts += 1; - if (attempts === 1) { - throw new Error( - `temporary failure ${secret} ${shortAuthorization} client_secret_value=${suffixedSecret} access_token_value=${suffixedToken} ${JSON.stringify({ client_secret_value: quotedSecret })} authorization="${opaqueAuthorization}" _auth=${npmAuthorization} Authorization: ApiKey ${customAuthorization} client_authorization_value=ApiKey ${suffixedAuthorization} auth=ApiKey ${paddedAuthorization} Authorization: Custom key=${keyedAuthorization} clientSecretValue=${camelCaseSecret} sending request for url (${proxyUrl}) and ${queryUrl}&client_secret_value=${suffixedQuery}`, - ); - } + if (attempts === 1) throw new Error(`temporary failure ${secret}`); return await completedScan(scanOptions.outputDir!); }), { knowledgeBasePaths }, @@ -1268,23 +1248,7 @@ describe("multiscan", () => { { id: "retry", status: "failed", attempt: 1 }, { id: "retry", status: "completed", attempt: 2 }, ]); - const ledger = await readFile(summary.resultsPath, "utf8"); - expect(ledger).not.toContain(secret); - expect(ledger).not.toContain("SYNTHETIC_MULTISCAN_PASSWORD"); - expect(ledger).not.toContain("SYNTHETIC_MULTISCAN_QUERY_123"); - expect(ledger).not.toContain(suffixedSecret); - expect(ledger).not.toContain(suffixedToken); - expect(ledger).not.toContain(suffixedQuery); - expect(ledger).not.toContain(quotedSecret); - expect(ledger).not.toContain(opaqueAuthorization); - expect(ledger).not.toContain(npmAuthorization); - expect(ledger).not.toContain(customAuthorization); - expect(ledger).not.toContain(suffixedAuthorization); - expect(ledger).not.toContain(paddedAuthorization); - expect(ledger).not.toContain(keyedAuthorization); - expect(ledger).not.toContain(camelCaseSecret); - expect(ledger).not.toContain(shortAuthorization); - expect(ledger).toContain("https://[redacted]@proxy.test/v1/responses"); + expect(await readFile(summary.resultsPath, "utf8")).toContain(secret); }); test("resumes complete bundles, repairs missing output, and rejects manifest drift", async () => { diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 65a26b24..665a377e 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -2790,7 +2790,7 @@ describe("runtime directories and plugin Python boundary", () => { ).toBe(false); }); - test("surfaces redacted Windows ACL subprocess failures", async () => { + test("surfaces Windows ACL subprocess failures", async () => { const root = await temporaryDirectory(); const home = join(root, "home"); await mkdir(home); @@ -2812,9 +2812,8 @@ describe("runtime directories and plugin Python boundary", () => { } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain("core types"); - expect((error as Error).message).toContain("token=[redacted]"); - expect((error as Error).message).not.toContain( - "SYNTHETIC_WINDOWS_ACL_SECRET", + expect((error as Error).message).toContain( + "token=sk-proj-SYNTHETIC_WINDOWS_ACL_SECRET_123", ); expect((error as Error).cause).toBe(underlying); } diff --git a/sdk/typescript/tests-ts/scan-dashboard.test.ts b/sdk/typescript/tests-ts/scan-dashboard.test.ts index 19ec8772..a2071979 100644 --- a/sdk/typescript/tests-ts/scan-dashboard.test.ts +++ b/sdk/typescript/tests-ts/scan-dashboard.test.ts @@ -554,7 +554,7 @@ describe("live scan dashboard", () => { dashboard.stop(); }); - test("redacts external Markdown link targets and rejects unsafe links", () => { + test("preserves external Markdown link targets and rejects unsafe links", () => { const stderr = capture(true); const dashboard = new ScanDashboard( { ...stderr.stream, columns: 120, rows: 18 }, @@ -562,7 +562,6 @@ describe("live scan dashboard", () => { repository: "/code/juice-shop", color: false, clock: fakeClock(), - sanitize: (value) => value.replaceAll("secret-token", "[redacted]"), }, ); @@ -579,9 +578,8 @@ describe("live scan dashboard", () => { const frame = lastFrame(stderr); expect(frame).toContain("See report, unsafe, and control."); expect(stderr.text()).toContain( - "\u001B]8;;https://example.com/report?token=[redacted]\u0007report\u001B]8;;\u0007", + "\u001B]8;;https://example.com/report?token=secret-token\u0007report\u001B]8;;\u0007", ); - expect(stderr.text()).not.toContain("secret-token"); expect(stderr.text()).not.toContain("javascript:"); expect(stderr.text()).not.toContain("spoof"); expect(stderr.text()).not.toContain("\u001B]8;;javascript:"); From 4de605797564819287fa9b7dbe4ec05e0330b888 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 14:21:17 -0700 Subject: [PATCH 07/20] fix(sdk): preserve redaction and cover standalone private keys --- README.md | 7 +- SECURITY.md | 3 - sdk/typescript/README.md | 10 +- sdk/typescript/src/api.ts | 10 +- sdk/typescript/src/cli.ts | 49 ++--- sdk/typescript/src/errors.ts | 74 +++++++- sdk/typescript/src/multiscan.ts | 4 +- sdk/typescript/src/runtime.ts | 6 +- sdk/typescript/src/scan-dashboard.ts | 10 +- sdk/typescript/tests-ts/api.test.ts | 22 ++- sdk/typescript/tests-ts/cli-export.test.ts | 5 +- sdk/typescript/tests-ts/cli-fixtures.ts | 104 ++++++++++- sdk/typescript/tests-ts/cli-launcher.test.ts | 6 +- sdk/typescript/tests-ts/cli-workbench.test.ts | 12 +- sdk/typescript/tests-ts/cli.test.ts | 176 ++++++++++++++---- sdk/typescript/tests-ts/errors.test.ts | 26 +++ sdk/typescript/tests-ts/multiscan.test.ts | 40 +++- sdk/typescript/tests-ts/runtime.test.ts | 7 +- .../tests-ts/scan-dashboard.test.ts | 6 +- 19 files changed, 469 insertions(+), 108 deletions(-) create mode 100644 sdk/typescript/tests-ts/errors.test.ts diff --git a/README.md b/README.md index 57d89662..3b39ddfc 100644 --- a/README.md +++ b/README.md @@ -74,16 +74,15 @@ incomplete or their original location was not reviewed. ## Verbose diagnostics -Add `--verbose` to print scan diagnostics to stderr: +Add `--verbose` to print redacted scan diagnostics to stderr: ```bash npx @openai/codex-security scan . --verbose ``` `CODEX_SECURITY_LOG_LEVEL=debug` also enables diagnostics; -`LOG_LEVEL=debug` is its fallback. JSON results remain on stdout. -Diagnostics, scan output, and scan history can contain credentials; keep them -private. +`LOG_LEVEL=debug` is its fallback. JSON results remain on stdout, and +credentials and provider identifiers remain redacted. ## TypeScript SDK diff --git a/SECURITY.md b/SECURITY.md index 67f59395..b2ca5bf7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,9 +52,6 @@ The product also does not isolate users, tasks, repositories, or scan jobs that share the same operating-system account, credentials, or local state. Do not treat shared local state as a multi-user or multi-tenant system. -Local diagnostics, logs, scan output, and scan history can include credentials -from upstream error messages. Protect this output and review it before sharing. - Trusting a repository does not authorize unrelated actions. Repository contents, model output, patches, service responses, and imported artifacts are data. They are not permission to scan another target, expose a credential, diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index fae59f06..e9d98339 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -399,7 +399,7 @@ The CLI and SDK recognize the following user-configurable environment: | Variable | Effect | | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `OPENAI_API_KEY`, `CODEX_API_KEY` | Scan authentication; `OPENAI_API_KEY` wins when both are present. | -| `CODEX_SECURITY_LOG_LEVEL` | CLI-only; set to `debug` for scan diagnostics. | +| `CODEX_SECURITY_LOG_LEVEL` | CLI-only; set to `debug` for redacted diagnostics. | | `LOG_LEVEL` | CLI-only fallback when `CODEX_SECURITY_LOG_LEVEL` is unset. | | `CODEX_SECURITY_STATE_DIR` | Override the private scan-history, workbench, and default artifact directory. | | `CODEX_HOME` | Set the ambient Codex home for file-backed sign-in and default state; defaults to `~/.codex`. | @@ -450,11 +450,11 @@ token and worker counts, estimated cost, the results directory, and the next useful command. Progress and summaries use stderr; structured scan results remain on stdout. -Add `--verbose` or set `CODEX_SECURITY_LOG_LEVEL=debug` to print lifecycle, -authentication, progress, and cost diagnostics to stderr. +Add `--verbose` or set `CODEX_SECURITY_LOG_LEVEL=debug` to print redacted +lifecycle, authentication, progress, and cost diagnostics to stderr. `LOG_LEVEL=debug` is used only when `CODEX_SECURITY_LOG_LEVEL` is unset. -Structured JSON results remain on stdout. Diagnostics, scan output, and scan -history can contain credentials; keep them private. +Credentials and provider identifiers remain redacted, and structured JSON +results remain on stdout. Each scan records its model, tokens, and estimated cost in its JSON result, scan history, and bulk-scan receipt. Estimates use diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 0516ab6f..0beed57a 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -48,7 +48,7 @@ import { OutputDirectoryError, OutputInsideProtectedRootError, type ProtectedScanPathKind, - errorMessage, + redactedErrorMessage, ScanCostLimitExceededError, ScanInterruptedError, } from "./errors.js"; @@ -1083,8 +1083,10 @@ export class CodexSecurity { "fail-scan", "--scan-id", activeScan.id, + // Redact before truncating: the stored message is read back by + // `scans show` and travels inside the results directory. "--message", - errorMessage(failure).slice(0, 2400), + redactedErrorMessage(failure).slice(0, 2400), ...(snapshot?.cost ? ["--cost-json", JSON.stringify(snapshot.cost)] : []), @@ -1098,12 +1100,12 @@ export class CodexSecurity { throw new CodexSecurityError(turnFailureMessage(event["error"])); } } - } catch { + } catch (postScanError) { notifyObserver( "onWarning", options.onWarning, options.onObserverError, - "Could not run post-scan instructions.", + `Could not run post-scan instructions: ${redactedErrorMessage(postScanError)}`, ); } } diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 7113272e..96cbfd73 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -65,7 +65,7 @@ import { OutputDirectoryError, OutputInsideProtectedRootError, PluginPythonUnavailableError, - errorMessage, + redactedErrorMessage, ScanCostLimitExceededError, ScanInterruptedError, } from "./errors.js"; @@ -730,7 +730,7 @@ export async function main( try { return await select(await dependencies.runWorkbench(args)); } catch (error) { - errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); exitCode = 2; return undefined; } @@ -936,7 +936,7 @@ export async function main( scanArguments = scanArgumentsFromRecipe(recipe, args.scanId); scanArguments.verbose = options.verbose; } catch (error) { - const message = errorMessage(error); + const message = redactedErrorMessage(error); errorOutput.write(`codex-security: ${message}\n`); exitCode = 2; return incurError({ @@ -999,7 +999,7 @@ export async function main( format, ); } catch (error) { - errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); exitCode = 2; return undefined; } @@ -1052,7 +1052,7 @@ export async function main( verbose: z .boolean() .default(false) - .describe("Print scan diagnostics to stderr."), + .describe("Print redacted scan diagnostics to stderr."), path: z .array(optionValue("--path")) .default([]) @@ -1313,7 +1313,7 @@ export async function main( failOnSeverity: options.failOnSeverity, }; } catch (error) { - errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); exitCode = 2; return undefined; } @@ -1502,7 +1502,7 @@ export async function main( onProgress: ({ repository, status, attempt, error, warning }) => { const detail = error ?? warning; errorOutput.write( - `codex-security: ${repository} ${status} (attempt ${attempt})${detail === undefined ? "" : `: ${errorMessage(detail)}`}\n`, + `codex-security: ${repository} ${status} (attempt ${attempt})${detail === undefined ? "" : `: ${redactedErrorMessage(detail)}`}\n`, ); }, }); @@ -1516,7 +1516,7 @@ export async function main( (error instanceof Error && error.name === "ExitPromptError" ? 130 : 2); - errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); } finally { dependencies.removeSignalListener("SIGINT", onInterrupt); dependencies.removeSignalListener("SIGTERM", onTerminate); @@ -1620,7 +1620,7 @@ export async function main( ); } catch (error) { exitCode = 2; - errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); } }, }) @@ -1656,7 +1656,7 @@ export async function main( ); } catch (error) { exitCode = 2; - errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); } }, }) @@ -1845,7 +1845,7 @@ export async function main( if (frameworkExit !== undefined) { if (exitCode !== 0) return exitCode; errorOutput.write( - `codex-security: ${errorMessage(incurErrorMessage(frameworkOutput))}\n`, + `codex-security: ${redactedErrorMessage(incurErrorMessage(frameworkOutput))}\n`, ); return 2; } @@ -1854,7 +1854,7 @@ export async function main( await writeCliOutput(output, renderedHistory ?? frameworkOutput); return exitCode; } catch (error) { - errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); return 2; } } @@ -2651,7 +2651,7 @@ async function runExport( } return 0; } catch (error) { - errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); return 2; } } @@ -2659,7 +2659,7 @@ async function runExport( type VerboseDiagnosticValue = string | number | boolean | null | undefined; function sanitizeDiagnosticValue(value: unknown): string { - return errorMessage(value) + return redactedErrorMessage(value) .replaceAll( /(\b(?:tenant(?:[_-]?id)?|org(?:anization)?(?:[_-]?id)?|project(?:[_-]?id)?|(?:x[_-]?)?(?:request|trace|correlation)[_-]?id)\b(?:\\*["'])?\s*[:=]\s*)(?!\[redacted\])(?:(\\*)(['"])(?:(?!(? displayed.length ? `, +${arguments_.paths.length - displayed.length} more` : ""}`; } @@ -3474,7 +3477,7 @@ function printScanSummary( ? 33 : 36; errorOutput.write( - `\n ${paint("REPORT", "1;36")} ${paint(errorMessage(result.reportPath), 4)}\n\n` + + `\n ${paint("REPORT", "1;36")} ${paint(redactedErrorMessage(result.reportPath), 4)}\n\n` + ` ${paint("FINDINGS", 1)} ${paint(`${findingCount}${severitySummary === "" ? "" : ` (${severitySummary})`}`, findingColor)}\n` + ` ${paint("COVERAGE", 1)} ${result.coverage.completeness}\n` + ` ${paint("ELAPSED", 1)} ${duration}\n`, @@ -3490,7 +3493,7 @@ function printScanSummary( ); } errorOutput.write( - ` ${paint("RESULTS", 1)} ${errorMessage(result.scanDir)}\n`, + ` ${paint("RESULTS", 1)} ${redactedErrorMessage(result.scanDir)}\n`, ); } @@ -3832,7 +3835,7 @@ function interruptedExit( errorOutput.write( scanDir === null ? "codex-security: No partial output was kept.\n" - : `codex-security: Partial output was kept at ${errorMessage(scanDir)}.\n`, + : `codex-security: Partial output was kept at ${redactedErrorMessage(scanDir)}.\n`, ); return ctrlC ? 130 : 143; } @@ -3858,7 +3861,7 @@ if (invokedAsMain()) { process.exitCode = exitCode; }, (error: unknown) => { - process.stderr.write(`codex-security: ${errorMessage(error)}\n`); + process.stderr.write(`codex-security: ${redactedErrorMessage(error)}\n`); process.exitCode = 2; }, ); diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 132d36ce..071140b8 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -1,7 +1,77 @@ import { formatUsd, type ScanCost } from "./cost.js"; -export function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); +/** Returns an error message with credential-shaped substrings redacted. */ +export function redactedErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const withoutPrivateKeys = message + .replaceAll( + /(\b[A-Za-z0-9_-]{0,64}private[_-]?key(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)(?:\\?["'])?-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----(?:\\?["'])?|$)/giu, + "$1[redacted]", + ) + .replaceAll( + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/giu, + "[redacted]", + ); + return redactQuotedCredentialValues(withoutPrivateKeys) + .replaceAll( + /(\b[A-Za-z0-9_-]{0,64}(?:authorization|auth)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9._~-]{0,63})((?:\s|%20|\+)+)(?!\[redacted\]|(?!key\s*=)[A-Za-z_][A-Za-z0-9_-]{0,64}\s*[:=]\s*(?=[^=\s"',;}&\\\]]))[^\s"',;}&\\\]]+/giu, + "$1$2$3[redacted]", + ) + .replaceAll( + /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?!\[redacted\]|[A-Za-z][A-Za-z0-9._~-]{0,63}(?:\s|%20|\+)+\[redacted\])[^\s"',;}&\\\]]+/giu, + "$1[redacted]", + ) + .replaceAll(/sk-(?:proj-)?[A-Za-z0-9_*=-]{8,}/gu, "[redacted]") + .replaceAll(/(?:github_pat_|gh[pousr]_)[A-Za-z0-9_-]{8,}/giu, "[redacted]") + .replaceAll(/npm_[A-Za-z0-9_-]{8,}/giu, "[redacted]") + .replaceAll( + /(^|%20|[^A-Za-z0-9_])(Bearer|Basic|Token)((?:\s|%20|\+)+)[A-Za-z0-9.%_~+/*=-]+/giu, + "$1$2$3[redacted]", + ) + .replaceAll(/((?:https?|ssh|git\+ssh):\/\/)[^\s/@]+@/giu, "$1[redacted]@") + .replaceAll( + /((?:[?&]|%3F|%26)(?:(?!%3F|%26|%3D)(?:[A-Za-z0-9_.%-]|\[|\])){0,64}(?:api[_-]?key|access(?:[_-]|%5F|%2D)?key(?:(?:[_-]|%5F|%2D)?id)?|private(?:[_-]|%5F|%2D)?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:(?:[_-]|%5F|%2D)[A-Za-z0-9_.%-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_.%-]{0,48})?(?:\]|%5D)?(?:=|%3D))(?:(?!%26)[^&\s])+/giu, + "$1[redacted]", + ); +} + +function redactQuotedCredentialValues(message: string): string { + const assignment = + /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\*["'])?\s*[:=]\s*)(\\*)(["'])/giu; + let output = ""; + let consumed = 0; + for ( + let match = assignment.exec(message); + match !== null; + match = assignment.exec(message) + ) { + const openingSlashes = match[2]!.length; + const quote = match[3]!; + let position = assignment.lastIndex; + let closed = false; + while (position < message.length) { + const delimiter = message.indexOf(quote, position); + if (delimiter < 0) break; + let preceding = delimiter; + while (preceding > position && message[preceding - 1] === "\\") { + preceding -= 1; + } + if (delimiter - preceding === openingSlashes) { + output += `${message.slice(consumed, assignment.lastIndex)}[redacted]${message.slice(preceding, delimiter + 1)}`; + consumed = delimiter + 1; + assignment.lastIndex = consumed; + closed = true; + break; + } + position = delimiter + 1; + } + if (!closed) { + output += `${message.slice(consumed, assignment.lastIndex)}[redacted]`; + consumed = message.length; + break; + } + } + return output + message.slice(consumed); } /** Base error for Codex Security SDK failures. */ diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index ec178cba..9704014c 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -19,7 +19,7 @@ import Papa from "papaparse"; import type { CodexSecurity } from "./api.js"; import type { CodexSecurityConfig } from "./config.js"; import type { ScanCost } from "./cost.js"; -import { errorMessage } from "./errors.js"; +import { redactedErrorMessage } from "./errors.js"; import type { CoverageDocument } from "./models.js"; import type { ScanMode } from "./targets.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; @@ -246,7 +246,7 @@ async function runCampaign( } } catch (error) { if (options.signal?.aborted === true) options.signal.throwIfAborted(); - failure = errorMessage(error); + failure = redactedErrorMessage(error); } finally { await rm(checkout, { recursive: true, force: true }); } diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 293c12a1..476eaacf 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -43,6 +43,7 @@ import { OutputDirectoryError, PluginBootstrapError, PluginPythonUnavailableError, + redactedErrorMessage, } from "./errors.js"; import type { JsonObject } from "./config.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; @@ -282,7 +283,10 @@ function windowsCredentialAclFailure(error: unknown): string { : error instanceof Error ? error.message : String(error); - const sanitized = detail.replace(/\s+/gu, " ").trim().slice(0, 512); + const sanitized = redactedErrorMessage(detail) + .replace(/\s+/gu, " ") + .trim() + .slice(0, 512); return sanitized === "" ? "" : `. ${sanitized}`; } diff --git a/sdk/typescript/src/scan-dashboard.ts b/sdk/typescript/src/scan-dashboard.ts index 293d3a45..26713d07 100644 --- a/sdk/typescript/src/scan-dashboard.ts +++ b/sdk/typescript/src/scan-dashboard.ts @@ -45,6 +45,7 @@ interface ScanDashboardOptions { maxCostUsd?: number; clock: DashboardClock; color?: boolean; + sanitize?: (value: string) => string; input?: DashboardInput; onInterrupt?: () => void; } @@ -309,7 +310,10 @@ export class ScanDashboard { lines .map((line, index) => { const text = typeof line === "string" ? line : line.text; - const clean = fitLine(text, width); + const clean = fitLine( + this.#options.sanitize?.(text) ?? text, + width, + ); const colored = this.#options.color === true ? styleLine( @@ -329,6 +333,7 @@ export class ScanDashboard { ? styleInlineCode(colored, line.code, line.kind) : colored, line.links, + this.#options.sanitize, ); return `${ERASE_LINE}${formatted}`; }) @@ -444,9 +449,10 @@ function styleInlineCode( function linkActivity( value: string, links: readonly DashboardActivityLink[] | undefined, + sanitize: ((value: string) => string) | undefined, ): string { for (const { label, target } of links ?? []) { - const safe = safeHyperlinkTarget(target); + const safe = safeHyperlinkTarget(sanitize?.(target) ?? target); if (safe !== undefined) { value = value.replace( label, diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 9d4c06d3..08a0216d 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -51,7 +51,7 @@ import { setCodexSecurityCredentialLogout, } from "../src/runtime.js"; import { normalizeTarget } from "../src/targets.js"; -import { SYNTHETIC_CREDENTIALS } from "./cli-fixtures.js"; +import { REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS } from "./cli-fixtures.js"; import { INTEGRATION_TARGET, PLUGIN_ROOT } from "./plugin-root.js"; type ScanObserverName = Parameters< @@ -2851,7 +2851,11 @@ describe("CodexSecurity orchestration", () => { expect(prompts.at(-1)).toBe("Record the scan cost."); expect(prompts).toHaveLength(2); expect(warnings).toEqual( - followUpFails ? ["Could not run post-scan instructions."] : [], + followUpFails + ? [ + "Could not run post-scan instructions: The post-scan instructions failed.", + ] + : [], ); await client.close(); }, @@ -3240,7 +3244,7 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); - test("preserves the original stored scan failure message", async () => { + test("redacts credentials from the stored scan failure message", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const codexHome = join(root, "codex-home"); @@ -3259,7 +3263,7 @@ describe("CodexSecurity orchestration", () => { const quotedCredential = JSON.stringify({ client_secret_value: "SYNTHETIC correct horse battery staple", }); - const failureMessage = `${SYNTHETIC_CREDENTIALS} ${quotedCredential}`; + const redactedFailure = `${REDACTED_CREDENTIALS} {"client_secret_value":"[redacted]"}`; const client = new TestClient( {}, { @@ -3295,12 +3299,14 @@ describe("CodexSecurity orchestration", () => { }, ); + // The in-memory error keeps its original text; only what leaves the process + // is redacted, so the CLI can still classify the upstream failure. await expect(client.run(repository)).rejects.toThrow(SYNTHETIC_CREDENTIALS); const failure = commands.find((args) => args[0] === "fail-scan"); const scanId = failure?.[2] ?? ""; expect(scanId).toMatch(/^[0-9a-f-]{36}$/); expect(failure?.[3]).toBe("--message"); - expect(failure?.[4]).toBe(failureMessage); + expect(failure?.[4]).toBe(redactedFailure); // `scans show` reads the stored message back through get-scan. const context = await runWorkbench( @@ -3309,11 +3315,13 @@ describe("CodexSecurity orchestration", () => { ); expect(context["scan"]).toMatchObject({ progress: { status: "failed" }, - failureMessage, + failureMessage: redactedFailure, }); + // Every synthetic credential is tagged SYNTHETIC, so the database file + // itself proves nothing was persisted anywhere on the failure path. const database = await readFile(join(stateDirectory, "workbench.sqlite3")); - expect(database.toString("latin1")).toContain(failureMessage); + expect(database.toString("latin1")).not.toContain("SYNTHETIC"); await client.close(); }); diff --git a/sdk/typescript/tests-ts/cli-export.test.ts b/sdk/typescript/tests-ts/cli-export.test.ts index dc784495..aacfb329 100644 --- a/sdk/typescript/tests-ts/cli-export.test.ts +++ b/sdk/typescript/tests-ts/cli-export.test.ts @@ -16,6 +16,7 @@ import { describe, expect, test } from "bun:test"; import { exportEnvironment, main } from "../src/cli.js"; import { CodexSecurityError } from "../src/index.js"; import { + REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS, capture, dependencies, @@ -582,7 +583,7 @@ describe("CLI", () => { ); }); - test("preserves caught export failures", async () => { + test("redacts credentials from caught export failures", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -600,7 +601,7 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toBe( - `codex-security: export failed ${SYNTHETIC_CREDENTIALS}\n`, + `codex-security: export failed ${REDACTED_CREDENTIALS}\n`, ); }); }); diff --git a/sdk/typescript/tests-ts/cli-fixtures.ts b/sdk/typescript/tests-ts/cli-fixtures.ts index 644e8db9..24040954 100644 --- a/sdk/typescript/tests-ts/cli-fixtures.ts +++ b/sdk/typescript/tests-ts/cli-fixtures.ts @@ -21,7 +21,109 @@ import type { UpdateNotice } from "../src/version.js"; type MainDependencies = NonNullable[3]>; -export const SYNTHETIC_CREDENTIALS = "sk-proj-SYNTHETIC_KEY_123"; +export const SYNTHETIC_CREDENTIALS = [ + "sk-proj-SYNTHETIC_KEY_123", + "Bearer SYNTHETIC_TOKEN_123", + "Authorization: Basic SYNTHETIC_BASIC_123", + "Authorization: Token SYNTHETIC_HEADER_TOKEN_123", + "Authorization: Bearer%20SYNTHETIC%2FENCODED%2BTOKEN_123", + "Authorization%3A%20Bearer%20SYNTHETIC_FULLY_ENCODED_TOKEN_123", + "https://SYNTHETIC_USER:SYNTHETIC_PASSWORD@example.test/private", + "ssh://SYNTHETIC_USER:SYNTHETIC_SSH_PASSWORD@example.test/private", + "git+ssh://SYNTHETIC_USER:SYNTHETIC_GIT_PASSWORD@example.test/private", + "github_pat_SYNTHETIC_GITHUB_PAT_123", + "ghs_SYNTHETIC_GITHUB_TOKEN_123", + "OPENAI_API_KEY=SYNTHETIC_OPENAI_VALUE_123", + "CODEX_API_KEY=SYNTHETIC_CODEX_VALUE_123", + "CODEX_ACCESS_TOKEN=SYNTHETIC_CODEX_ACCESS_TOKEN_123", + "GITHUB_TOKEN=SYNTHETIC_GITHUB_VALUE_123", + "GH_TOKEN=SYNTHETIC_GH_VALUE_123", + '{"OPENAI_API_KEY":"SYNTHETIC_JSON_OPENAI_123","CODEX_API_KEY":"SYNTHETIC_JSON_CODEX_123"}', + '{\\"OPENAI_API_KEY\\":\\"SYNTHETIC_ESCAPED_OPENAI_123\\",\\"CODEX_API_KEY\\":\\"SYNTHETIC_ESCAPED_CODEX_123\\"}', + '{"refresh_token":"SYNTHETIC_REFRESH_TOKEN_123","id_token":"SYNTHETIC_ID_TOKEN_123","clientSecret":"SYNTHETIC_CLIENT_SECRET_123","dbPassword":"SYNTHETIC_PASSWORD_123","passwd":"SYNTHETIC_PASSWD_123"}', + '{\\"refreshToken\\":\\"SYNTHETIC_ESCAPED_REFRESH_123\\",\\"idToken\\":\\"SYNTHETIC_ESCAPED_ID_123\\",\\"clientSecret\\":\\"SYNTHETIC_ESCAPED_SECRET_123\\",\\"password\\":\\"SYNTHETIC_ESCAPED_PASSWORD_123\\"}', + "AWS_SECRET_ACCESS_KEY=SYNTHETIC_AWS_SECRET_123", + "AWS_ACCESS_KEY_ID=SYNTHETIC_AWS_ID_123", + "AWS_SESSION_TOKEN=SYNTHETIC_AWS_SESSION_123", + "NODE_AUTH_TOKEN=SYNTHETIC_NODE_AUTH_123", + "NPM_TOKEN=SYNTHETIC_NPM_TOKEN_123", + "OPENAI_API_KEY=sk-proj-SYNTHETIC_NAMED_OPENAI_123", + "GITHUB_TOKEN=ghs_SYNTHETIC_NAMED_GITHUB_123", + "NPM_TOKEN=npm_SYNTHETIC_NAMED_NPM_123", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN=SYNTHETIC_ACTIONS_TOKEN_123", + "ACTIONS_RUNTIME_TOKEN=SYNTHETIC_ACTIONS_RUNTIME_123", + "GITLAB_TOKEN=SYNTHETIC_GITLAB_TOKEN_123", + "HF_TOKEN=SYNTHETIC_HF_TOKEN_123", + "SLACK_BOT_TOKEN=SYNTHETIC_SLACK_TOKEN_123", + "//registry.npmjs.org/:_authToken=SYNTHETIC_NPMRC_TOKEN_123", + "x-api-key: SYNTHETIC_HEADER_KEY_123", + "access_token=SYNTHETIC_ACCESS_TOKEN_123", + "npm_SYNTHETIC_BARE_TOKEN_123", + "https://example.test/?token=SYNTHETIC_QUERY_123&safe=1", + "https://example.test/?credential=SYNTHETIC_CREDENTIAL_123&safe=1", + "https://example.test/?AWS_ACCESS_KEY_ID=SYNTHETIC_QUERY_AWS_ID_123&safe=1", + "https://example.test/?AWS%5FACCESS%5FKEY%5FID=SYNTHETIC_ENCODED_AWS_ID_123&AWS%2DACCESS%2DKEY%2DID=SYNTHETIC_ENCODED_AWS_DASH_ID_123&safe=1", + "https://example.test/?service-api-key=SYNTHETIC_QUERY_API_KEY_123&service-access-token=SYNTHETIC_QUERY_ACCESS_TOKEN_123&service-token=SYNTHETIC_QUERY_TOKEN_123&service-secret=SYNTHETIC_QUERY_SECRET_123&signature=SYNTHETIC_SIGNATURE_123&safe=1", + "https://example.test/?X-Amz-Signature=SYNTHETIC_AMZ_SIGNATURE_123&X-Amz-Credential=SYNTHETIC_AMZ_CREDENTIAL_123&X-Amz-Security-Token=SYNTHETIC_AMZ_TOKEN_123&safe=1", + "https://example.test/?X-Goog-Signature=SYNTHETIC_GOOG_SIGNATURE_123&X-Goog-Credential=SYNTHETIC_GOOG_CREDENTIAL_123&safe=1", + "https://example.test/?sv=2026-01-01&sig=SYNTHETIC_AZURE_SIG_123&safe=1", + "https://example.test/?password=SYNTHETIC_QUERY_PASSWORD_123&passwd=SYNTHETIC_QUERY_PASSWD_123&safe=1", + "https://example.test/?oauth.refreshToken=SYNTHETIC_DOTTED_TOKEN_123&auth[token]=SYNTHETIC_BRACKET_TOKEN_123&auth%5BclientSecret%5D=SYNTHETIC_ENCODED_SECRET_123&safe=1", + "https://example.test/?access_token%3DSYNTHETIC_ENCODED_ACCESS_123&client_secret%3DSYNTHETIC_ENCODED_CLIENT_123&safe=1", + "https://example.test/?redirect_uri=https%3A%2F%2Finner.test%2Fcb%3Frefresh_token%3DSYNTHETIC_NESTED_REFRESH_123%26password%3DSYNTHETIC_NESTED_PASSWORD_123%26safe%3D1", +].join(" "); + +export const REDACTED_CREDENTIALS = [ + "[redacted]", + "Bearer [redacted]", + "Authorization: Basic [redacted]", + "Authorization: Token [redacted]", + "Authorization: Bearer%20[redacted]", + "Authorization%3A%20Bearer%20[redacted]", + "https://[redacted]@example.test/private", + "ssh://[redacted]@example.test/private", + "git+ssh://[redacted]@example.test/private", + "[redacted]", + "[redacted]", + "OPENAI_API_KEY=[redacted]", + "CODEX_API_KEY=[redacted]", + "CODEX_ACCESS_TOKEN=[redacted]", + "GITHUB_TOKEN=[redacted]", + "GH_TOKEN=[redacted]", + '{"OPENAI_API_KEY":"[redacted]","CODEX_API_KEY":"[redacted]"}', + '{\\"OPENAI_API_KEY\\":\\"[redacted]\\",\\"CODEX_API_KEY\\":\\"[redacted]\\"}', + '{"refresh_token":"[redacted]","id_token":"[redacted]","clientSecret":"[redacted]","dbPassword":"[redacted]","passwd":"[redacted]"}', + '{\\"refreshToken\\":\\"[redacted]\\",\\"idToken\\":\\"[redacted]\\",\\"clientSecret\\":\\"[redacted]\\",\\"password\\":\\"[redacted]\\"}', + "AWS_SECRET_ACCESS_KEY=[redacted]", + "AWS_ACCESS_KEY_ID=[redacted]", + "AWS_SESSION_TOKEN=[redacted]", + "NODE_AUTH_TOKEN=[redacted]", + "NPM_TOKEN=[redacted]", + "OPENAI_API_KEY=[redacted]", + "GITHUB_TOKEN=[redacted]", + "NPM_TOKEN=[redacted]", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN=[redacted]", + "ACTIONS_RUNTIME_TOKEN=[redacted]", + "GITLAB_TOKEN=[redacted]", + "HF_TOKEN=[redacted]", + "SLACK_BOT_TOKEN=[redacted]", + "//registry.npmjs.org/:_authToken=[redacted]", + "x-api-key: [redacted]", + "access_token=[redacted]", + "[redacted]", + "https://example.test/?token=[redacted]&safe=1", + "https://example.test/?credential=[redacted]&safe=1", + "https://example.test/?AWS_ACCESS_KEY_ID=[redacted]&safe=1", + "https://example.test/?AWS%5FACCESS%5FKEY%5FID=[redacted]&AWS%2DACCESS%2DKEY%2DID=[redacted]&safe=1", + "https://example.test/?service-api-key=[redacted]&service-access-token=[redacted]&service-token=[redacted]&service-secret=[redacted]&signature=[redacted]&safe=1", + "https://example.test/?X-Amz-Signature=[redacted]&X-Amz-Credential=[redacted]&X-Amz-Security-Token=[redacted]&safe=1", + "https://example.test/?X-Goog-Signature=[redacted]&X-Goog-Credential=[redacted]&safe=1", + "https://example.test/?sv=2026-01-01&sig=[redacted]&safe=1", + "https://example.test/?password=[redacted]&passwd=[redacted]&safe=1", + "https://example.test/?oauth.refreshToken=[redacted]&auth[token]=[redacted]&auth%5BclientSecret%5D=[redacted]&safe=1", + "https://example.test/?access_token%3D[redacted]&client_secret%3D[redacted]&safe=1", + "https://example.test/?redirect_uri=https%3A%2F%2Finner.test%2Fcb%3Frefresh_token%3D[redacted]%26password%3D[redacted]%26safe%3D1", +].join(" "); export function capture(isTTY = false): { stream: Pick & diff --git a/sdk/typescript/tests-ts/cli-launcher.test.ts b/sdk/typescript/tests-ts/cli-launcher.test.ts index 92a0e777..703256df 100644 --- a/sdk/typescript/tests-ts/cli-launcher.test.ts +++ b/sdk/typescript/tests-ts/cli-launcher.test.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, test } from "bun:test"; import { VERSION } from "../src/index.js"; -import { SYNTHETIC_CREDENTIALS } from "./support/cli.js"; +import { REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS } from "./support/cli.js"; const packageRoot = join(import.meta.dir, ".."); @@ -40,7 +40,7 @@ describe("CLI launcher", () => { } }); - test("maps unexpected source-entrypoint failures to exit 2", async () => { + test("maps unexpected source-entrypoint failures to exit 2 and redacts credentials", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-cli-failure-")); try { const preload = join(root, "unavailable-cwd.mjs"); @@ -57,7 +57,7 @@ describe("CLI launcher", () => { expect(child.status).toBe(2); expect(child.stdout).toBe(""); expect(child.stderr).toBe( - `working directory is unavailable: ${SYNTHETIC_CREDENTIALS}\n`, + `working directory is unavailable: ${REDACTED_CREDENTIALS}\n`, ); } finally { await rm(root, { recursive: true, force: true }); diff --git a/sdk/typescript/tests-ts/cli-workbench.test.ts b/sdk/typescript/tests-ts/cli-workbench.test.ts index 5d6c559e..aea574ba 100644 --- a/sdk/typescript/tests-ts/cli-workbench.test.ts +++ b/sdk/typescript/tests-ts/cli-workbench.test.ts @@ -3,7 +3,12 @@ import { describe, expect, test } from "bun:test"; import type { CodexSecurityConfig, JsonObject } from "../src/index.js"; import { DiffTarget } from "../src/index.js"; import { main } from "../src/cli.js"; -import { capture, dependencies, SYNTHETIC_CREDENTIALS } from "./support/cli.js"; +import { + capture, + dependencies, + REDACTED_CREDENTIALS, + SYNTHETIC_CREDENTIALS, +} from "./support/cli.js"; describe("CLI workbench", () => { test("lists repository and scan-root history without starting Codex", async () => { @@ -614,7 +619,7 @@ describe("CLI workbench", () => { } }); - test("reports workbench failures and does not initialize Codex", async () => { + test("redacts workbench failures and does not initialize Codex", async () => { const stderr = capture(); let started = false; expect( @@ -632,7 +637,8 @@ describe("CLI workbench", () => { }), ), ).toBe(2); - expect(stderr.text()).toContain(SYNTHETIC_CREDENTIALS); + expect(stderr.text()).toContain(REDACTED_CREDENTIALS); + expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); expect(started).toBe(false); }); }); diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index b726520b..5475ef49 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -22,6 +22,7 @@ import type { ScanOptions, ScanPreflight, } from "../src/index.js"; +import { redactedErrorMessage } from "../src/errors.js"; import { BUNDLED_PLUGIN_VERSION, CodexSecurityError, @@ -44,6 +45,7 @@ import { } from "../src/config.js"; import { FakeSignals, + REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS, capture, dependencies, @@ -497,7 +499,7 @@ describe("CLI", () => { expect(workbenchCalled).toBe(false); }); - test("reports false-positive workbench failures", async () => { + test("redacts false-positive workbench failures", async () => { const stdout = capture(); const stderr = capture(); let started = false; @@ -527,7 +529,8 @@ describe("CLI", () => { ), ).toBe(2); expect(stdout.text()).toBe(""); - expect(stderr.text()).toContain(SYNTHETIC_CREDENTIALS); + expect(stderr.text()).toContain(REDACTED_CREDENTIALS); + expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); expect(started).toBe(false); }); @@ -834,7 +837,7 @@ describe("CLI", () => { }, ); - test("preserves the bulk-scan failure summary and progress errors", async () => { + test("preserves the bulk-scan failure summary and redacts progress errors", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-cli-multiscan-")); try { await multiscanInventory(root); @@ -868,7 +871,8 @@ describe("CLI", () => { skipped: 0, }); expect(stderr.text()).toContain("sample failed (attempt 1)"); - expect(stderr.text()).toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).toContain("[redacted]"); + expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); } finally { await rm(root, { recursive: true, force: true }); } @@ -1684,9 +1688,8 @@ describe("CLI", () => { ); expect(text).not.toContain("thinking ·"); expect(text).not.toContain("said ·"); - expect(text).toContain( - 'curl -H "Authorization: Bearer sk-proj-SYNTHETIC_OPENAI_VALUE_123"', - ); + expect(text).toContain('curl -H "Authorization: Bearer [redacted]"'); + expect(text).not.toContain("SYNTHETIC_OPENAI_VALUE_123"); expect(text).not.toContain("Building the file inventory"); expect(text).not.toContain("Running a scan command"); expect(text).toContain("3 / 1,258 reviewed"); @@ -3331,7 +3334,7 @@ describe("CLI", () => { } }); - test("preserves verbose output paths and observer diagnostics", async () => { + test("redacts verbose output paths and observer diagnostics", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -3363,15 +3366,16 @@ describe("CLI", () => { ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( - 'codex-security: debug: scan.output_archived archive_dir="/tmp/archive_sk-proj-SYNTHETIC_ARCHIVE_SECRET_123"', + 'codex-security: debug: scan.output_archived archive_dir="/tmp/archive_[redacted]"', ); expect(stderr.text()).toContain( - 'codex-security: debug: scan.output_ready scan_dir="/tmp/scan_sk-proj-SYNTHETIC_OUTPUT_SECRET_123"', + 'codex-security: debug: scan.output_ready scan_dir="/tmp/scan_[redacted]"', ); expect(stderr.text()).toContain( 'codex-security: debug: scan.observer_failed observer="onWorkerStatus"', ); - expect(stderr.text()).toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).toContain("[redacted]"); + expect(stderr.text()).not.toContain("SYNTHETIC"); }); test("excludes observer failure context from verbose diagnostics", async () => { @@ -3688,7 +3692,7 @@ describe("CLI", () => { } }); - test("preserves underlying network errors", async () => { + test("redacts credentials in underlying network errors", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -3707,12 +3711,106 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toContain( - `network failure ECONNRESET ${SYNTHETIC_CREDENTIALS}`, + `network failure ECONNRESET ${REDACTED_CREDENTIALS}`, ); - expect(stderr.text()).toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); expect(stderr.text()).not.toContain("model service could not be reached"); }); + test("redacts quoted multiword credentials and private-key assignments", () => { + expect( + redactedErrorMessage( + 'password="correct horse battery staple" private_key=SYNTHETIC_PRIVATE_KEY_123', + ), + ).toBe('password="[redacted]" private_key=[redacted]'); + expect( + redactedErrorMessage( + '{"client_secret_value":"correct horse battery staple","safe":"visible"}', + ), + ).toBe('{"client_secret_value":"[redacted]","safe":"visible"}'); + expect( + redactedErrorMessage( + '{"clientSecretValue":"camel case secret","accessTokenValue":"camel case token"}', + ), + ).toBe( + '{"clientSecretValue":"[redacted]","accessTokenValue":"[redacted]"}', + ); + expect( + redactedErrorMessage( + "clientSecretValue=SYNTHETIC_CAMEL_SECRET accessTokenValue=SYNTHETIC_CAMEL_TOKEN https://example.test/?clientSecretValue=SYNTHETIC_CAMEL_QUERY", + ), + ).toBe( + "clientSecretValue=[redacted] accessTokenValue=[redacted] https://example.test/?clientSecretValue=[redacted]", + ); + expect( + redactedErrorMessage( + '{\\"access_token_value\\":\\"another horse battery staple\\"}', + ), + ).toBe('{\\"access_token_value\\":\\"[redacted]\\"}'); + expect( + redactedErrorMessage( + 'authorization="opaque secret value" _auth=Zm9vOmJhcg== https://example.test/?authorization=opaque%20query%20secret', + ), + ).toBe( + 'authorization="[redacted]" _auth=[redacted] https://example.test/?authorization=[redacted]', + ); + for (const [authorization, redacted] of [ + [ + "Authorization: ApiKey SYNTHETIC_APIKEY_SECRET", + "Authorization: ApiKey [redacted]", + ], + ["auth=Custom%20SYNTHETIC_CUSTOM_SECRET", "auth=Custom%20[redacted]"], + [ + "Authorization: Digest+SYNTHETIC_DIGEST_SECRET", + "Authorization: Digest+[redacted]", + ], + [ + "client_authorization_value=ApiKey SYNTHETIC_SUFFIXED_SECRET", + "client_authorization_value=ApiKey [redacted]", + ], + ["Authorization: ApiKey dGVzdA==", "Authorization: ApiKey [redacted]"], + ["Authorization: ApiKey dGVzdA=", "Authorization: ApiKey [redacted]"], + ["Authorization: ApiKey key=SECRET", "Authorization: ApiKey [redacted]"], + ["auth=Custom key=SECRET", "auth=Custom [redacted]"], + [ + "client_auth_token=Custom dGVzdA==", + "client_auth_token=Custom [redacted]", + ], + ] as const) { + expect(redactedErrorMessage(authorization)).toBe(redacted); + } + expect(redactedErrorMessage('password="correct horse battery staple')).toBe( + 'password="[redacted]', + ); + let encoded: string | { password: string } = { + password: 'foo "bar" baz', + }; + for (let depth = 1; depth <= 3; depth += 1) { + encoded = JSON.stringify(encoded); + const redacted = redactedErrorMessage(encoded); + expect(redacted).not.toContain("foo"); + expect(redacted).not.toContain("bar"); + expect(redacted).not.toContain("baz"); + let decoded: unknown = redacted; + for (let layer = 0; layer < depth; layer += 1) { + decoded = JSON.parse(decoded as string); + } + expect(decoded).toEqual({ password: "[redacted]" }); + } + for (const separator of ["\n", "\\n"]) { + expect( + redactedErrorMessage( + `private_key=-----BEGIN PRIVATE KEY-----${separator}MII_SYNTHETIC_PRIVATE_KEY${separator}-----END PRIVATE KEY----- safe=value`, + ), + ).toBe("private_key=[redacted] safe=value"); + expect( + redactedErrorMessage( + `private_key=-----BEGIN PRIVATE KEY-----${separator}MII_SYNTHETIC_TRUNCATED_PRIVATE_KEY`, + ), + ).toBe("private_key=[redacted]"); + } + }); + test("reports database connection failures without claiming the model network failed", async () => { const stdout = capture(); const stderr = capture(); @@ -3737,7 +3835,7 @@ describe("CLI", () => { expect(stderr.text()).toContain("unable to open database file"); expect(stderr.text()).not.toContain("model service could not be reached"); expect(stderr.text()).not.toContain("Check your network connection"); - expect(stderr.text()).toContain("SYNTHETIC_DATABASE_SECRET"); + expect(stderr.text()).not.toContain("SYNTHETIC_DATABASE_SECRET"); }); test("prints only the completion summary for default scans", async () => { @@ -3922,7 +4020,7 @@ describe("CLI", () => { } }); - test("emits scan warnings in verbose diagnostics", async () => { + test("emits redacted scan warnings in verbose diagnostics", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -3947,11 +4045,12 @@ describe("CLI", () => { ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( - 'codex-security: debug: scan.warning message="Repository HEAD changed during the scan: sk-proj-SYNTHETIC_WARNING_SECRET_123"', + 'codex-security: debug: scan.warning message="Repository HEAD changed during the scan: [redacted]"', ); expect(stderr.text()).toContain( - "codex-security: warning: Repository HEAD changed during the scan: sk-proj-SYNTHETIC_WARNING_SECRET_123", + "codex-security: warning: Repository HEAD changed during the scan: [redacted]", ); + expect(stderr.text()).not.toContain("SYNTHETIC_WARNING_SECRET"); }); test("prints granted trusted cyber access without warning or corrupting JSON scans", async () => { @@ -4076,8 +4175,9 @@ describe("CLI", () => { ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( - `codex-security: warning: onWorkerStatus observer failed: status observer failed ${SYNTHETIC_CREDENTIALS}`, + `codex-security: warning: onWorkerStatus observer failed: status observer failed ${REDACTED_CREDENTIALS}`, ); + expect(stderr.text()).not.toContain("SYNTHETIC_OPENAI_VALUE_123"); }); test("maps failed scan stdout writes to the runtime-error exit code", async () => { @@ -4381,7 +4481,7 @@ describe("CLI", () => { expect(JSON.parse(stdout.text())).toEqual(result.toJSON()); }); - test("preserves scan progress scope and completion paths", async () => { + test("keeps scan progress scope and completion paths redacted", async () => { const stdout = capture(); const stderr = capture(); const result = fakeResult(); @@ -4403,12 +4503,10 @@ describe("CLI", () => { dependencies({ result }), ), ).toBe(0); - expect(stderr.text()).toContain("SYNTHETIC_SCOPE_KEY_123"); - expect(stderr.text()).toContain("SYNTHETIC_OUTPUT_KEY_123"); - expect(stderr.text()).toContain("src/sk-proj-SYNTHETIC_SCOPE_KEY_123"); - expect(stderr.text()).toContain( - "/tmp/scan_sk-proj-SYNTHETIC_OUTPUT_KEY_123", - ); + expect(stderr.text()).not.toContain("SYNTHETIC_SCOPE_KEY_123"); + expect(stderr.text()).not.toContain("SYNTHETIC_OUTPUT_KEY_123"); + expect(stderr.text()).toContain("src/[redacted]"); + expect(stderr.text()).toContain("/tmp/scan_[redacted]"); }); test("reports parent fallback when delegated workers cannot start", async () => { @@ -4551,7 +4649,7 @@ describe("CLI", () => { expect(stderr.text()).not.toContain("Running scan"); }); - test("keeps archive notices on stderr for JSON scans", async () => { + test("keeps redacted archive notices on stderr for JSON scans", async () => { const stdout = capture(); const stderr = capture(); expect( @@ -4584,9 +4682,9 @@ describe("CLI", () => { expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( "[00:00] Preparing scan\n" + - "Moved existing results to: /tmp/sk-proj-SYNTHETIC_ARCHIVE_KEY_123/results.previous-20260721T031422-1234abcd\n", + "Moved existing results to: /tmp/[redacted]/results.previous-20260721T031422-1234abcd\n", ); - expect(stderr.text()).toContain("SYNTHETIC_ARCHIVE_KEY_123"); + expect(stderr.text()).not.toContain("SYNTHETIC_ARCHIVE_KEY_123"); }); test("reports findings by severity and applies the requested policy", async () => { @@ -4855,7 +4953,7 @@ describe("CLI", () => { expect(stderr.text()).not.toContain("codex-security:"); }); - test("preserves protected-root diagnostics", async () => { + test("redacts credentials embedded in protected-root diagnostics", async () => { const stdout = capture(); const stderr = capture(); const protectedRoot = @@ -4880,16 +4978,16 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toContain( - "Resolved path: /private/tmp/worktree_sk-proj-SYNTHETIC_ROOT_KEY_123/results_sk-proj-SYNTHETIC_OUTPUT_KEY_123", + "Resolved path: /private/tmp/worktree_[redacted]/results_[redacted]", ); expect(stderr.text()).toContain( - "Protected root: /private/tmp/worktree_sk-proj-SYNTHETIC_ROOT_KEY_123", + "Protected root: /private/tmp/worktree_[redacted]", ); - expect(stderr.text()).toContain("SYNTHETIC_ROOT_KEY"); - expect(stderr.text()).toContain("SYNTHETIC_OUTPUT_KEY"); + expect(stderr.text()).not.toContain("SYNTHETIC_ROOT_KEY"); + expect(stderr.text()).not.toContain("SYNTHETIC_OUTPUT_KEY"); }); - test("preserves caught scan and interruption failures", async () => { + test("redacts credentials from caught scan and interruption failures", async () => { for (const failure of [ new CodexSecurityError(`scan failed ${SYNTHETIC_CREDENTIALS}`), new ScanInterruptedError( @@ -4913,12 +5011,12 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toBe( - "[00:00] Preparing scan\n" + `scan failed ${SYNTHETIC_CREDENTIALS}\n`, + "[00:00] Preparing scan\n" + `scan failed ${REDACTED_CREDENTIALS}\n`, ); } }); - test("preserves retained partial-output paths", async () => { + test("redacts embedded credentials from retained partial-output paths", async () => { const path = "/private/tmp/scan_sk-proj-SYNTHETIC_PATH_KEY_123/results"; for (const [signal, expectedExit] of [ [null, 2], @@ -4946,9 +5044,9 @@ describe("CLI", () => { ).toBe(expectedExit); expect(stdout.text()).toBe(""); expect(stderr.text()).toContain( - "Partial output was kept at /private/tmp/scan_sk-proj-SYNTHETIC_PATH_KEY_123/results.", + "Partial output was kept at /private/tmp/scan_[redacted]/results.", ); - expect(stderr.text()).toContain("SYNTHETIC_PATH_KEY"); + expect(stderr.text()).not.toContain("SYNTHETIC_PATH_KEY"); } }, 30_000); diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts new file mode 100644 index 00000000..e0f48e7a --- /dev/null +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { redactedErrorMessage } from "../src/errors.js"; + +describe("credential redaction", () => { + test("redacts standalone private keys without hiding surrounding diagnostics", () => { + const message = [ + "connection failed:", + "-----BEGIN PRIVATE KEY-----", + "synthetic-key-material", + "-----END PRIVATE KEY-----", + "retrying", + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe( + "connection failed:\n[redacted]\nretrying", + ); + }); + + test("redacts truncated standalone private keys", () => { + expect( + redactedErrorMessage( + "upstream failure: -----BEGIN RSA PRIVATE KEY-----\nsynthetic-key-material", + ), + ).toBe("upstream failure: [redacted]"); + }); +}); diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index ebd67fc1..3ef41a0b 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -1223,6 +1223,22 @@ describe("multiscan", () => { const source = await repository(paths.root, "retry"); const secret = "sk-proj-SYNTHETIC_MULTISCAN_SECRET_123"; const knowledgeBasePaths = ["architecture.md"]; + const proxyUrl = + "https://SYNTHETIC_USER:SYNTHETIC_MULTISCAN_PASSWORD@proxy.test/v1/responses"; + const queryUrl = + "https://proxy.test/v1/responses?api_key=SYNTHETIC_MULTISCAN_QUERY_123&safe=1"; + const shortAuthorization = "Bearer abc123"; + const suffixedSecret = "SYNTHETIC_SUFFIXED_CLIENT_SECRET_123"; + const suffixedToken = "SYNTHETIC_SUFFIXED_ACCESS_TOKEN_123"; + const suffixedQuery = "SYNTHETIC_SUFFIXED_QUERY_SECRET_123"; + const quotedSecret = "SYNTHETIC correct horse battery staple"; + const opaqueAuthorization = "SYNTHETIC opaque authorization secret"; + const npmAuthorization = "SYNTHETIC_NPM_AUTH_VALUE_123"; + const customAuthorization = "SYNTHETIC_CUSTOM_AUTHORIZATION_123"; + const suffixedAuthorization = "SYNTHETIC_SUFFIXED_AUTHORIZATION_123"; + const paddedAuthorization = "SYNTHETIC_PADDED_AUTHORIZATION_TOKEN=="; + const keyedAuthorization = "SYNTHETIC_KEYED_AUTHORIZATION_SECRET_123"; + const camelCaseSecret = "SYNTHETIC_CAMEL_CASE_CLIENT_SECRET_123"; await writeFile( paths.input, `id,repository,revision\nretry,${source.path},${source.revision}\n`, @@ -1235,7 +1251,11 @@ describe("multiscan", () => { client(async (_repository, scanOptions = {}) => { expect(scanOptions.knowledgeBasePaths).toEqual(knowledgeBasePaths); attempts += 1; - if (attempts === 1) throw new Error(`temporary failure ${secret}`); + if (attempts === 1) { + throw new Error( + `temporary failure ${secret} ${shortAuthorization} client_secret_value=${suffixedSecret} access_token_value=${suffixedToken} ${JSON.stringify({ client_secret_value: quotedSecret })} authorization="${opaqueAuthorization}" _auth=${npmAuthorization} Authorization: ApiKey ${customAuthorization} client_authorization_value=ApiKey ${suffixedAuthorization} auth=ApiKey ${paddedAuthorization} Authorization: Custom key=${keyedAuthorization} clientSecretValue=${camelCaseSecret} sending request for url (${proxyUrl}) and ${queryUrl}&client_secret_value=${suffixedQuery}`, + ); + } return await completedScan(scanOptions.outputDir!); }), { knowledgeBasePaths }, @@ -1248,7 +1268,23 @@ describe("multiscan", () => { { id: "retry", status: "failed", attempt: 1 }, { id: "retry", status: "completed", attempt: 2 }, ]); - expect(await readFile(summary.resultsPath, "utf8")).toContain(secret); + const ledger = await readFile(summary.resultsPath, "utf8"); + expect(ledger).not.toContain(secret); + expect(ledger).not.toContain("SYNTHETIC_MULTISCAN_PASSWORD"); + expect(ledger).not.toContain("SYNTHETIC_MULTISCAN_QUERY_123"); + expect(ledger).not.toContain(suffixedSecret); + expect(ledger).not.toContain(suffixedToken); + expect(ledger).not.toContain(suffixedQuery); + expect(ledger).not.toContain(quotedSecret); + expect(ledger).not.toContain(opaqueAuthorization); + expect(ledger).not.toContain(npmAuthorization); + expect(ledger).not.toContain(customAuthorization); + expect(ledger).not.toContain(suffixedAuthorization); + expect(ledger).not.toContain(paddedAuthorization); + expect(ledger).not.toContain(keyedAuthorization); + expect(ledger).not.toContain(camelCaseSecret); + expect(ledger).not.toContain(shortAuthorization); + expect(ledger).toContain("https://[redacted]@proxy.test/v1/responses"); }); test("resumes complete bundles, repairs missing output, and rejects manifest drift", async () => { diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 665a377e..65a26b24 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -2790,7 +2790,7 @@ describe("runtime directories and plugin Python boundary", () => { ).toBe(false); }); - test("surfaces Windows ACL subprocess failures", async () => { + test("surfaces redacted Windows ACL subprocess failures", async () => { const root = await temporaryDirectory(); const home = join(root, "home"); await mkdir(home); @@ -2812,8 +2812,9 @@ describe("runtime directories and plugin Python boundary", () => { } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain("core types"); - expect((error as Error).message).toContain( - "token=sk-proj-SYNTHETIC_WINDOWS_ACL_SECRET_123", + expect((error as Error).message).toContain("token=[redacted]"); + expect((error as Error).message).not.toContain( + "SYNTHETIC_WINDOWS_ACL_SECRET", ); expect((error as Error).cause).toBe(underlying); } diff --git a/sdk/typescript/tests-ts/scan-dashboard.test.ts b/sdk/typescript/tests-ts/scan-dashboard.test.ts index a2071979..19ec8772 100644 --- a/sdk/typescript/tests-ts/scan-dashboard.test.ts +++ b/sdk/typescript/tests-ts/scan-dashboard.test.ts @@ -554,7 +554,7 @@ describe("live scan dashboard", () => { dashboard.stop(); }); - test("preserves external Markdown link targets and rejects unsafe links", () => { + test("redacts external Markdown link targets and rejects unsafe links", () => { const stderr = capture(true); const dashboard = new ScanDashboard( { ...stderr.stream, columns: 120, rows: 18 }, @@ -562,6 +562,7 @@ describe("live scan dashboard", () => { repository: "/code/juice-shop", color: false, clock: fakeClock(), + sanitize: (value) => value.replaceAll("secret-token", "[redacted]"), }, ); @@ -578,8 +579,9 @@ describe("live scan dashboard", () => { const frame = lastFrame(stderr); expect(frame).toContain("See report, unsafe, and control."); expect(stderr.text()).toContain( - "\u001B]8;;https://example.com/report?token=secret-token\u0007report\u001B]8;;\u0007", + "\u001B]8;;https://example.com/report?token=[redacted]\u0007report\u001B]8;;\u0007", ); + expect(stderr.text()).not.toContain("secret-token"); expect(stderr.text()).not.toContain("javascript:"); expect(stderr.text()).not.toContain("spoof"); expect(stderr.text()).not.toContain("\u001B]8;;javascript:"); From 179b234689e6e1eacf35d92e05debf19ed934c48 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 14:29:34 -0700 Subject: [PATCH 08/20] fix(sdk): match private-key boundaries before redaction --- sdk/typescript/src/errors.ts | 2 +- sdk/typescript/tests-ts/errors.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 071140b8..b27e9932 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -9,7 +9,7 @@ export function redactedErrorMessage(error: unknown): string { "$1[redacted]", ) .replaceAll( - /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/giu, + /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/giu, "[redacted]", ); return redactQuotedCredentialValues(withoutPrivateKeys) diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index e0f48e7a..ae9f389f 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -23,4 +23,17 @@ describe("credential redaction", () => { ), ).toBe("upstream failure: [redacted]"); }); + + test("does not end a private key at a different key-type delimiter", () => { + const message = [ + "-----BEGIN RSA PRIVATE KEY-----", + "synthetic-before", + "-----END EC PRIVATE KEY-----", + "synthetic-after", + "-----END RSA PRIVATE KEY-----", + "retrying", + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); + }); }); From ae623dcf7644feca7050105b9c1f6633fa43b3c6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 14:44:05 -0700 Subject: [PATCH 09/20] Match assigned private-key opening and closing labels --- sdk/typescript/src/errors.ts | 2 +- sdk/typescript/tests-ts/errors.test.ts | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index b27e9932..b7878c45 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -5,7 +5,7 @@ export function redactedErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); const withoutPrivateKeys = message .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}private[_-]?key(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)(?:\\?["'])?-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----(?:\\?["'])?|$)/giu, + /(\b[A-Za-z0-9_-]{0,64}private[_-]?key(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)(?:\\?["'])?-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \2-----(?:\\?["'])?|$)/giu, "$1[redacted]", ) .replaceAll( diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index ae9f389f..094ede8e 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -25,15 +25,19 @@ describe("credential redaction", () => { }); test("does not end a private key at a different key-type delimiter", () => { - const message = [ - "-----BEGIN RSA PRIVATE KEY-----", - "synthetic-before", - "-----END EC PRIVATE KEY-----", - "synthetic-after", - "-----END RSA PRIVATE KEY-----", - "retrying", - ].join("\n"); + for (const assignment of ["", "private_key="]) { + const message = [ + `${assignment}-----BEGIN RSA PRIVATE KEY-----`, + "synthetic-before", + "-----END EC PRIVATE KEY-----", + "synthetic-after", + "-----END RSA PRIVATE KEY-----", + "retrying", + ].join("\n"); - expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); + expect(redactedErrorMessage(message)).toBe( + `${assignment}[redacted]\nretrying`, + ); + } }); }); From 6577fa680b24860fac6cd3e751f171ab720921b9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 14:56:28 -0700 Subject: [PATCH 10/20] Simplify private-key redaction and preserve overlapping credentials --- sdk/typescript/src/errors.ts | 11 +++----- sdk/typescript/tests-ts/errors.test.ts | 36 +++++++++++++++++++------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index b7878c45..4fc3f930 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -3,16 +3,11 @@ import { formatUsd, type ScanCost } from "./cost.js"; /** Returns an error message with credential-shaped substrings redacted. */ export function redactedErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); - const withoutPrivateKeys = message + return redactQuotedCredentialValues(message) .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}private[_-]?key(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)(?:\\?["'])?-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \2-----(?:\\?["'])?|$)/giu, - "$1[redacted]", - ) - .replaceAll( - /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/giu, + /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/gu, "[redacted]", - ); - return redactQuotedCredentialValues(withoutPrivateKeys) + ) .replaceAll( /(\b[A-Za-z0-9_-]{0,64}(?:authorization|auth)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9._~-]{0,63})((?:\s|%20|\+)+)(?!\[redacted\]|(?!key\s*=)[A-Za-z_][A-Za-z0-9_-]{0,64}\s*[:=]\s*(?=[^=\s"',;}&\\\]]))[^\s"',;}&\\\]]+/giu, "$1$2$3[redacted]", diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 094ede8e..0f5395d5 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -26,18 +26,34 @@ describe("credential redaction", () => { test("does not end a private key at a different key-type delimiter", () => { for (const assignment of ["", "private_key="]) { - const message = [ - `${assignment}-----BEGIN RSA PRIVATE KEY-----`, - "synthetic-before", + for (const terminator of [ "-----END EC PRIVATE KEY-----", - "synthetic-after", - "-----END RSA PRIVATE KEY-----", - "retrying", - ].join("\n"); + "-----END rsa private key-----", + ]) { + const message = [ + `${assignment}-----BEGIN RSA PRIVATE KEY-----`, + "synthetic-before", + terminator, + "synthetic-after", + "-----END RSA PRIVATE KEY-----", + "retrying", + ].join("\n"); - expect(redactedErrorMessage(message)).toBe( - `${assignment}[redacted]\nretrying`, - ); + expect(redactedErrorMessage(message)).toBe( + `${assignment}[redacted]\nretrying`, + ); + } } }); + + test("redacts quoted credentials whose markers overlap a private key", () => { + const message = [ + "-----BEGIN PRIVATE KEY-----", + 'password="', + "-----END PRIVATE KEY-----", + 'SYNTHETIC_PASSWORD_123"', + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe("[redacted]"); + }); }); From 9051e66c98a41c5d93ff7aa6aab0238e7532c414 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:09:13 -0700 Subject: [PATCH 11/20] Merge overlapping private-key and quoted-credential redactions --- sdk/typescript/src/errors.ts | 46 ++++++++++++++++++-------- sdk/typescript/tests-ts/errors.test.ts | 36 +++++++++++++++----- 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 4fc3f930..4115e5df 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -3,11 +3,13 @@ import { formatUsd, type ScanCost } from "./cost.js"; /** Returns an error message with credential-shaped substrings redacted. */ export function redactedErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); - return redactQuotedCredentialValues(message) - .replaceAll( - /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/gu, - "[redacted]", - ) + const sensitiveRanges: Array<[number, number]> = []; + for (const match of message.matchAll( + /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/gu, + )) { + sensitiveRanges.push([match.index, match.index + match[0].length]); + } + return redactQuotedCredentialValues(message, sensitiveRanges) .replaceAll( /(\b[A-Za-z0-9_-]{0,64}(?:authorization|auth)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9._~-]{0,63})((?:\s|%20|\+)+)(?!\[redacted\]|(?!key\s*=)[A-Za-z_][A-Za-z0-9_-]{0,64}\s*[:=]\s*(?=[^=\s"',;}&\\\]]))[^\s"',;}&\\\]]+/giu, "$1$2$3[redacted]", @@ -30,11 +32,12 @@ export function redactedErrorMessage(error: unknown): string { ); } -function redactQuotedCredentialValues(message: string): string { +function redactQuotedCredentialValues( + message: string, + ranges: Array<[number, number]>, +): string { const assignment = /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\*["'])?\s*[:=]\s*)(\\*)(["'])/giu; - let output = ""; - let consumed = 0; for ( let match = assignment.exec(message); match !== null; @@ -52,20 +55,37 @@ function redactQuotedCredentialValues(message: string): string { preceding -= 1; } if (delimiter - preceding === openingSlashes) { - output += `${message.slice(consumed, assignment.lastIndex)}[redacted]${message.slice(preceding, delimiter + 1)}`; - consumed = delimiter + 1; - assignment.lastIndex = consumed; + ranges.push([assignment.lastIndex, preceding]); + assignment.lastIndex = delimiter + 1; closed = true; break; } position = delimiter + 1; } if (!closed) { - output += `${message.slice(consumed, assignment.lastIndex)}[redacted]`; - consumed = message.length; + ranges.push([assignment.lastIndex, message.length]); break; } } + + const merged: Array<[number, number]> = []; + for (const [start, end] of ranges.sort( + ([leftStart], [rightStart]) => leftStart - rightStart, + )) { + const previous = merged.at(-1); + if (previous !== undefined && start <= previous[1]) { + previous[1] = Math.max(previous[1], end); + } else { + merged.push([start, end]); + } + } + + let output = ""; + let consumed = 0; + for (const [start, end] of merged) { + output += `${message.slice(consumed, start)}[redacted]`; + consumed = end; + } return output + message.slice(consumed); } diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 0f5395d5..7cc425b7 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -46,14 +46,32 @@ describe("credential redaction", () => { } }); - test("redacts quoted credentials whose markers overlap a private key", () => { - const message = [ - "-----BEGIN PRIVATE KEY-----", - 'password="', - "-----END PRIVATE KEY-----", - 'SYNTHETIC_PASSWORD_123"', - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe("[redacted]"); + test("redacts quoted credentials and private keys that overlap in either direction", () => { + for (const [lines, credential] of [ + [ + [ + "-----BEGIN PRIVATE KEY-----", + 'password="', + "-----END PRIVATE KEY-----", + 'SYNTHETIC_PASSWORD_123"', + ], + "SYNTHETIC_PASSWORD_123", + ], + [ + [ + 'password="prefix', + "-----BEGIN PRIVATE KEY-----", + 'synthetic-before"', + "SYNTHETIC_KEY_MATERIAL_123", + "-----END PRIVATE KEY-----", + ], + "SYNTHETIC_KEY_MATERIAL_123", + ], + ] as const) { + const redacted = redactedErrorMessage(lines.join("\n")); + expect(redacted).toContain("[redacted]"); + expect(redacted).not.toContain(credential); + expect(redacted).not.toContain("PRIVATE KEY"); + } }); }); From 9fec366e49d8f83f1705e3ffcd8cbcbbdb666a9a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:26:54 -0700 Subject: [PATCH 12/20] fix(sdk): redact overlapping private-key blocks safely --- sdk/typescript/src/errors.ts | 13 +++++++++++-- sdk/typescript/tests-ts/errors.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 4115e5df..7fc9e5a8 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -5,9 +5,18 @@ export function redactedErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); const sensitiveRanges: Array<[number, number]> = []; for (const match of message.matchAll( - /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/gu, + /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----/gu, )) { - sensitiveRanges.push([match.index, match.index + match[0].length]); + const delimiter = new RegExp( + String.raw`(?:\r|\n|\\[nr])-----END ${match[1]}-----(?=$|[\s"']|\\[nr])`, + "gu", + ); + delimiter.lastIndex = match.index + match[0].length; + const end = delimiter.exec(message); + sensitiveRanges.push([ + match.index, + end === null ? message.length : end.index + end[0].length, + ]); } return redactQuotedCredentialValues(message, sensitiveRanges) .replaceAll( diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 7cc425b7..98a3a933 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -46,6 +46,31 @@ describe("credential redaction", () => { } }); + test("redacts overlapping private-key blocks through their own delimiters", () => { + const message = [ + "-----BEGIN RSA PRIVATE KEY-----", + "-----BEGIN EC PRIVATE KEY-----", + "-----END RSA PRIVATE KEY-----", + "SYNTHETIC_EC_KEY_MATERIAL", + "-----END EC PRIVATE KEY-----", + "retrying", + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); + }); + + test("does not end a private key at a delimiter embedded in another line", () => { + const message = [ + "-----BEGIN RSA PRIVATE KEY-----", + "prefix-----END RSA PRIVATE KEY-----suffix", + "SYNTHETIC_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + "retrying", + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); + }); + test("redacts quoted credentials and private keys that overlap in either direction", () => { for (const [lines, credential] of [ [ From be6012e7dc6f82793a819dba71cb1d51570f0a20 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:38:17 -0700 Subject: [PATCH 13/20] fix(sdk): redact nested private keys in a single pass --- sdk/typescript/src/errors.ts | 42 +++++++++++++----- sdk/typescript/tests-ts/errors.test.ts | 59 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 7fc9e5a8..3fa242b5 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -4,19 +4,39 @@ import { formatUsd, type ScanCost } from "./cost.js"; export function redactedErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); const sensitiveRanges: Array<[number, number]> = []; + const privateKeys = new Map(); + const lineEnd = + /(?:$|[\r\n]|\\+[nr]|\\*["']|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; for (const match of message.matchAll( - /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----/gu, + /-----(BEGIN|END) ([A-Z0-9 ]*PRIVATE KEY)-----/giu, )) { - const delimiter = new RegExp( - String.raw`(?:\r|\n|\\[nr])-----END ${match[1]}-----(?=$|[\s"']|\\[nr])`, - "gu", - ); - delimiter.lastIndex = match.index + match[0].length; - const end = delimiter.exec(message); - sensitiveRanges.push([ - match.index, - end === null ? message.length : end.index + end[0].length, - ]); + const label = match[2]!; + if (match[1]!.toUpperCase() === "BEGIN") { + const starts = privateKeys.get(label) ?? []; + starts.push(match.index); + privateKeys.set(label, starts); + continue; + } + + const previous = message[match.index - 1]; + if ( + previous !== "\n" && + previous !== "\r" && + !( + (previous === "n" || previous === "r") && + message[match.index - 2] === "\\" + ) + ) { + continue; + } + const end = match.index + match[0].length; + lineEnd.lastIndex = end; + if (!lineEnd.test(message)) continue; + const start = privateKeys.get(label)?.pop(); + if (start !== undefined) sensitiveRanges.push([start, end]); + } + for (const starts of privateKeys.values()) { + for (const start of starts) sensitiveRanges.push([start, message.length]); } return redactQuotedCredentialValues(message, sensitiveRanges) .replaceAll( diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 98a3a933..e777ae28 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -59,6 +59,19 @@ describe("credential redaction", () => { expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); }); + test("pairs nested private keys with separate matching delimiters", () => { + const message = [ + "-----BEGIN RSA PRIVATE KEY-----", + "-----BEGIN RSA PRIVATE KEY-----", + "-----END RSA PRIVATE KEY-----", + "SYNTHETIC_OUTER_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + "retrying", + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); + }); + test("does not end a private key at a delimiter embedded in another line", () => { const message = [ "-----BEGIN RSA PRIVATE KEY-----", @@ -71,6 +84,52 @@ describe("credential redaction", () => { expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); }); + test("does not end a private key before text on the delimiter line", () => { + const message = [ + "-----BEGIN RSA PRIVATE KEY-----", + "-----END RSA PRIVATE KEY----- NOT_A_BOUNDARY", + "SYNTHETIC_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + "retrying", + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); + }); + + test("preserves diagnostics after repeatedly serialized private keys", () => { + let message: string | { pem: string; safe: string } = { + pem: [ + "-----BEGIN RSA PRIVATE KEY-----", + "SYNTHETIC_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + ].join("\n"), + safe: "visible", + }; + + for (let depth = 1; depth <= 4; depth += 1) { + message = JSON.stringify(message); + let redacted: unknown = redactedErrorMessage(message); + for (let layer = 0; layer < depth; layer += 1) { + redacted = JSON.parse(redacted as string); + } + expect(redacted).toEqual({ pem: "[redacted]", safe: "visible" }); + } + }); + + test("redacts lowercase private-key assignments", () => { + expect( + redactedErrorMessage( + "private_key=-----begin rsa private key-----\nSYNTHETIC_KEY\n-----end rsa private key----- safe=value", + ), + ).toBe("private_key=[redacted] safe=value"); + }); + + test("collapses repeated truncated private-key markers", () => { + expect( + redactedErrorMessage("-----BEGIN PRIVATE KEY-----\n".repeat(1_000)), + ).toBe("[redacted]"); + }); + test("redacts quoted credentials and private keys that overlap in either direction", () => { for (const [lines, credential] of [ [ From ef23ce17ac0fb4bf1f14b47ca555f54b1aa3b197 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:44:52 -0700 Subject: [PATCH 14/20] fix(sdk): verify private-key delimiter boundaries --- sdk/typescript/src/errors.ts | 11 ++++++++++- sdk/typescript/tests-ts/errors.test.ts | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 3fa242b5..b34a5579 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -5,13 +5,22 @@ export function redactedErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); const sensitiveRanges: Array<[number, number]> = []; const privateKeys = new Map(); + const lineStart = /(?:$|[\r\n]|\\+[nr])/uy; const lineEnd = - /(?:$|[\r\n]|\\+[nr]|\\*["']|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; + /(?:$|[\r\n]|\\+[nr]|\\*["'](?=$|[,}\]\r\n])|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; for (const match of message.matchAll( /-----(BEGIN|END) ([A-Z0-9 ]*PRIVATE KEY)-----/giu, )) { const label = match[2]!; if (match[1]!.toUpperCase() === "BEGIN") { + lineStart.lastIndex = match.index + match[0].length; + if (!lineStart.test(message)) { + let previous = match.index - 1; + while (message[previous] === " " || message[previous] === "\t") { + previous -= 1; + } + if (message[previous] !== "=" && message[previous] !== ":") continue; + } const starts = privateKeys.get(label) ?? []; starts.push(match.index); privateKeys.set(label, starts); diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index e777ae28..42d7e8a7 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -96,6 +96,25 @@ describe("credential redaction", () => { expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); }); + test("does not end a private key at an unrelated quote", () => { + const message = [ + "-----BEGIN RSA PRIVATE KEY-----", + '-----END RSA PRIVATE KEY-----"NOT_A_BOUNDARY', + "SYNTHETIC_SECOND_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + "retrying", + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); + }); + + test("preserves text containing an invalid opening delimiter", () => { + const message = + "parser rejected abc-----BEGIN RSA PRIVATE KEY-----NOT_A_PEM; retry=visible"; + + expect(redactedErrorMessage(message)).toBe(message); + }); + test("preserves diagnostics after repeatedly serialized private keys", () => { let message: string | { pem: string; safe: string } = { pem: [ From 1461d023c3cbb1826ccfcebc9c688dfb79311ee5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:54:22 -0700 Subject: [PATCH 15/20] fix(sdk): bind key delimiters to serialized line depth --- sdk/typescript/src/errors.ts | 55 ++++++++++++++++++-------- sdk/typescript/tests-ts/errors.test.ts | 30 ++++++++++---- 2 files changed, 61 insertions(+), 24 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index b34a5579..eba4064d 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -4,17 +4,32 @@ import { formatUsd, type ScanCost } from "./cost.js"; export function redactedErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); const sensitiveRanges: Array<[number, number]> = []; - const privateKeys = new Map(); - const lineStart = /(?:$|[\r\n]|\\+[nr])/uy; + const privateKeys = new Map< + string, + Array<{ start: number; escapedNewline: number }> + >(); + const lineStart = /(?:$|[\r\n]|(\\+)[nr])/uy; const lineEnd = - /(?:$|[\r\n]|\\+[nr]|\\*["'](?=$|[,}\]\r\n])|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; + /(?:$|[\r\n]|\\+[nr]|\\*["'](?=$|[\r\n]|[}\]](?:$|[\r\n,}\]]|\\*["'])|,[ \t]*\\*["'])|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; for (const match of message.matchAll( /-----(BEGIN|END) ([A-Z0-9 ]*PRIVATE KEY)-----/giu, )) { const label = match[2]!; if (match[1]!.toUpperCase() === "BEGIN") { + const preceding = message[match.index - 1]; + if ( + match.index !== 0 && + !/[\s"'=:\\]/u.test(preceding!) && + !( + (preceding === "n" || preceding === "r") && + message[match.index - 2] === "\\" + ) + ) { + continue; + } lineStart.lastIndex = match.index + match[0].length; - if (!lineStart.test(message)) { + const boundary = lineStart.exec(message); + if (boundary === null) { let previous = match.index - 1; while (message[previous] === " " || message[previous] === "\t") { previous -= 1; @@ -22,30 +37,38 @@ export function redactedErrorMessage(error: unknown): string { if (message[previous] !== "=" && message[previous] !== ":") continue; } const starts = privateKeys.get(label) ?? []; - starts.push(match.index); + starts.push({ + start: match.index, + escapedNewline: boundary?.[1]?.length ?? 0, + }); privateKeys.set(label, starts); continue; } + const starts = privateKeys.get(label); + const opening = starts?.at(-1); + if (opening === undefined) continue; const previous = message[match.index - 1]; - if ( - previous !== "\n" && - previous !== "\r" && - !( - (previous === "n" || previous === "r") && - message[match.index - 2] === "\\" - ) - ) { + let escapedNewline = 0; + if (previous === "n" || previous === "r") { + while (message[match.index - escapedNewline - 2] === "\\") { + escapedNewline += 1; + } + } else if (previous !== "\n" && previous !== "\r") { + continue; + } + if (escapedNewline !== opening.escapedNewline) { continue; } const end = match.index + match[0].length; lineEnd.lastIndex = end; if (!lineEnd.test(message)) continue; - const start = privateKeys.get(label)?.pop(); - if (start !== undefined) sensitiveRanges.push([start, end]); + starts!.pop(); + sensitiveRanges.push([opening.start, end]); } for (const starts of privateKeys.values()) { - for (const start of starts) sensitiveRanges.push([start, message.length]); + for (const { start } of starts) + sensitiveRanges.push([start, message.length]); } return redactQuotedCredentialValues(message, sensitiveRanges) .replaceAll( diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 42d7e8a7..a830d379 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -97,9 +97,30 @@ describe("credential redaction", () => { }); test("does not end a private key at an unrelated quote", () => { + for (const suffix of ['"NOT_A_BOUNDARY', '",NOT_A_BOUNDARY']) { + const message = [ + "-----BEGIN RSA PRIVATE KEY-----", + `-----END RSA PRIVATE KEY-----${suffix}`, + "SYNTHETIC_SECOND_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + "retrying", + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); + } + }); + + test("preserves text containing an invalid opening delimiter", () => { + for (const suffix of ["NOT_A_PEM; retry=visible", "\nretry=visible"]) { + const message = `parser rejected abc-----BEGIN RSA PRIVATE KEY-----${suffix}`; + expect(redactedErrorMessage(message)).toBe(message); + } + }); + + test("does not confuse literal escapes with serialized line boundaries", () => { const message = [ "-----BEGIN RSA PRIVATE KEY-----", - '-----END RSA PRIVATE KEY-----"NOT_A_BOUNDARY', + "prefix\\n-----END RSA PRIVATE KEY-----", "SYNTHETIC_SECOND_KEY_MATERIAL", "-----END RSA PRIVATE KEY-----", "retrying", @@ -108,13 +129,6 @@ describe("credential redaction", () => { expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); }); - test("preserves text containing an invalid opening delimiter", () => { - const message = - "parser rejected abc-----BEGIN RSA PRIVATE KEY-----NOT_A_PEM; retry=visible"; - - expect(redactedErrorMessage(message)).toBe(message); - }); - test("preserves diagnostics after repeatedly serialized private keys", () => { let message: string | { pem: string; safe: string } = { pem: [ From 9e2a6e51d91f3a01e47582556bc0ba503f47a905 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 16:04:40 -0700 Subject: [PATCH 16/20] fix(sdk): isolate credential and serialized key boundaries --- sdk/typescript/src/errors.ts | 45 ++++++++++++++++---- sdk/typescript/tests-ts/errors.test.ts | 57 +++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index eba4064d..f0c18858 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -10,19 +10,27 @@ export function redactedErrorMessage(error: unknown): string { >(); const lineStart = /(?:$|[\r\n]|(\\+)[nr])/uy; const lineEnd = - /(?:$|[\r\n]|\\+[nr]|\\*["'](?=$|[\r\n]|[}\]](?:$|[\r\n,}\]]|\\*["'])|,[ \t]*\\*["'])|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; + /(?:$|[\r\n]|(\\+)[nr]|\\*["'](?=$|[\r\n]|[}\]](?:$|[\r\n,}\]]|\\*["'])|,(?:\s|\\+[nr])*\\*["'])|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; for (const match of message.matchAll( /-----(BEGIN|END) ([A-Z0-9 ]*PRIVATE KEY)-----/giu, )) { const label = match[2]!; if (match[1]!.toUpperCase() === "BEGIN") { - const preceding = message[match.index - 1]; + let frame = match.index - 1; + while (message[frame] === " " || message[frame] === "\t") frame -= 1; + const preceding = message[frame]; if ( - match.index !== 0 && - !/[\s"'=:\\]/u.test(preceding!) && + frame >= 0 && + !/[\r\n"'=:]/u.test(preceding!) && !( (preceding === "n" || preceding === "r") && - message[match.index - 2] === "\\" + message[frame - 1] === "\\" + ) && + !/[:=]/u.test( + message.slice( + message.lastIndexOf("\n", match.index) + 1, + match.index, + ), ) ) { continue; @@ -62,7 +70,14 @@ export function redactedErrorMessage(error: unknown): string { } const end = match.index + match[0].length; lineEnd.lastIndex = end; - if (!lineEnd.test(message)) continue; + const closingBoundary = lineEnd.exec(message); + if ( + closingBoundary === null || + (closingBoundary[1] !== undefined && + closingBoundary[1].length !== opening.escapedNewline) + ) { + continue; + } starts!.pop(); sensitiveRanges.push([opening.start, end]); } @@ -76,8 +91,22 @@ export function redactedErrorMessage(error: unknown): string { "$1$2$3[redacted]", ) .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?!\[redacted\]|[A-Za-z][A-Za-z0-9._~-]{0,63}(?:\s|%20|\+)+\[redacted\])[^\s"',;}&\\\]]+/giu, - "$1[redacted]", + /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?!\[redacted\])[^\s"',;}&\\\]]+/giu, + (match: string, prefix: string, offset: number, source: string) => { + if (/(?:authorization|auth)/iu.test(prefix)) { + if ( + /^[A-Za-z][A-Za-z0-9._~-]{0,63}(?:%20|\+)+\[redacted$/iu.test( + match.slice(prefix.length), + ) + ) { + return match; + } + const scheme = /(?:\s|%20|\+)+\[redacted\]/uy; + scheme.lastIndex = offset + match.length; + if (scheme.test(source)) return match; + } + return `${prefix}[redacted]`; + }, ) .replaceAll(/sk-(?:proj-)?[A-Za-z0-9_*=-]{8,}/gu, "[redacted]") .replaceAll(/(?:github_pat_|gh[pousr]_)[A-Za-z0-9_-]{8,}/giu, "[redacted]") diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index a830d379..8e4e9737 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -112,8 +112,10 @@ describe("credential redaction", () => { test("preserves text containing an invalid opening delimiter", () => { for (const suffix of ["NOT_A_PEM; retry=visible", "\nretry=visible"]) { - const message = `parser rejected abc-----BEGIN RSA PRIVATE KEY-----${suffix}`; - expect(redactedErrorMessage(message)).toBe(message); + for (const padding of ["", " "]) { + const message = `parser rejected abc${padding}-----BEGIN RSA PRIVATE KEY-----${suffix}`; + expect(redactedErrorMessage(message)).toBe(message); + } } }); @@ -149,6 +151,57 @@ describe("credential redaction", () => { } }); + test("preserves fields after pretty-printed private-key values", () => { + const message = JSON.stringify( + { + nested: { + pem: [ + "-----BEGIN RSA PRIVATE KEY-----", + "SYNTHETIC_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + ].join("\n"), + safe: "visible", + }, + tail: "kept", + }, + null, + 2, + ); + + expect(JSON.parse(redactedErrorMessage(message))).toEqual({ + nested: { pem: "[redacted]", safe: "visible" }, + tail: "kept", + }); + }); + + test("checks serialized newline depth after private-key delimiters", () => { + const message = JSON.stringify({ + pem: [ + "-----BEGIN RSA PRIVATE KEY-----", + "-----END RSA PRIVATE KEY-----\\nSYNTHETIC_SECOND_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + ].join("\n"), + safe: "visible", + }); + + expect(JSON.parse(redactedErrorMessage(message))).toEqual({ + pem: "[redacted]", + safe: "visible", + }); + }); + + test("redacts unquoted credentials next to a private-key placeholder", () => { + const message = [ + "password=SYNTHETIC_VICTIM_PASSWORD -----BEGIN RSA PRIVATE KEY-----", + "SYNTHETIC_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe( + "password=[redacted] [redacted]", + ); + }); + test("redacts lowercase private-key assignments", () => { expect( redactedErrorMessage( From a444919311e39a23aabb556dbee51a101d20a2dc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 16:13:21 -0700 Subject: [PATCH 17/20] fix(sdk): protect framed private keys and nearby auth values --- sdk/typescript/src/errors.ts | 23 ++++++++---- sdk/typescript/tests-ts/errors.test.ts | 49 ++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index f0c18858..4e167143 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -10,7 +10,7 @@ export function redactedErrorMessage(error: unknown): string { >(); const lineStart = /(?:$|[\r\n]|(\\+)[nr])/uy; const lineEnd = - /(?:$|[\r\n]|(\\+)[nr]|\\*["'](?=$|[\r\n]|[}\]](?:$|[\r\n,}\]]|\\*["'])|,(?:\s|\\+[nr])*\\*["'])|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; + /(?:$|[\r\n]|(\\+)[nr]|\\*["'](?=$|[\r\n]|[}\]](?:$|[\r\n,}\]]|\\*["'])|,(?:\s|\\+[nr])*(?:\\*["']|[\[{0-9-]|true\b|false\b|null\b))|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; for (const match of message.matchAll( /-----(BEGIN|END) ([A-Z0-9 ]*PRIVATE KEY)-----/giu, )) { @@ -26,7 +26,7 @@ export function redactedErrorMessage(error: unknown): string { (preceding === "n" || preceding === "r") && message[frame - 1] === "\\" ) && - !/[:=]/u.test( + !/\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|authorization|auth|token|secret|credential|password|passwd)[A-Za-z0-9_-]*\s*[:=]\s*[^\s;]+[ \t]*$/iu.test( message.slice( message.lastIndexOf("\n", match.index) + 1, match.index, @@ -56,10 +56,12 @@ export function redactedErrorMessage(error: unknown): string { const starts = privateKeys.get(label); const opening = starts?.at(-1); if (opening === undefined) continue; - const previous = message[match.index - 1]; + let frame = match.index - 1; + while (message[frame] === " " || message[frame] === "\t") frame -= 1; + const previous = message[frame]; let escapedNewline = 0; if (previous === "n" || previous === "r") { - while (message[match.index - escapedNewline - 2] === "\\") { + while (message[frame - escapedNewline - 1] === "\\") { escapedNewline += 1; } } else if (previous !== "\n" && previous !== "\r") { @@ -73,6 +75,8 @@ export function redactedErrorMessage(error: unknown): string { const closingBoundary = lineEnd.exec(message); if ( closingBoundary === null || + ((closingBoundary[0] === "\n" || closingBoundary[0] === "\r") && + opening.escapedNewline !== 0) || (closingBoundary[1] !== undefined && closingBoundary[1].length !== opening.escapedNewline) ) { @@ -94,10 +98,15 @@ export function redactedErrorMessage(error: unknown): string { /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?!\[redacted\])[^\s"',;}&\\\]]+/giu, (match: string, prefix: string, offset: number, source: string) => { if (/(?:authorization|auth)/iu.test(prefix)) { + const value = match.slice(prefix.length); + const schemeName = value.split(/(?:%20|\+|\s)/u, 1)[0]; if ( - /^[A-Za-z][A-Za-z0-9._~-]{0,63}(?:%20|\+)+\[redacted$/iu.test( - match.slice(prefix.length), - ) + !/^(?:ApiKey|Basic|Bearer|Custom|Digest|Token)$/iu.test(schemeName!) + ) { + return `${prefix}[redacted]`; + } + if ( + /^[A-Za-z][A-Za-z0-9._~-]{0,63}(?:%20|\+)+\[redacted$/iu.test(value) ) { return match; } diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 8e4e9737..892448a8 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -117,6 +117,9 @@ describe("credential redaction", () => { expect(redactedErrorMessage(message)).toBe(message); } } + const unrelated = + "status=failed; parser saw -----BEGIN RSA PRIVATE KEY-----\nretry=visible"; + expect(redactedErrorMessage(unrelated)).toBe(unrelated); }); test("does not confuse literal escapes with serialized line boundaries", () => { @@ -174,6 +177,22 @@ describe("credential redaction", () => { }); }); + test("preserves non-string array values after private keys", () => { + const pem = [ + "-----BEGIN RSA PRIVATE KEY-----", + "SYNTHETIC_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + ].join("\n"); + for (const value of [123, true, null, {}, []]) { + const message = JSON.stringify([pem, value, "tail"], null, 2); + expect(JSON.parse(redactedErrorMessage(message))).toEqual([ + "[redacted]", + value, + "tail", + ]); + } + }); + test("checks serialized newline depth after private-key delimiters", () => { const message = JSON.stringify({ pem: [ @@ -191,15 +210,33 @@ describe("credential redaction", () => { }); test("redacts unquoted credentials next to a private-key placeholder", () => { + for (const field of [ + "password", + "authorization", + "auth", + "client_authorization_value", + ]) { + const message = [ + `${field}=SYNTHETIC_VICTIM_SECRET -----BEGIN RSA PRIVATE KEY-----`, + "SYNTHETIC_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + ].join("\n"); + + expect(redactedErrorMessage(message)).toBe( + `${field}=[redacted] [redacted]`, + ); + } + }); + + test("preserves diagnostics after indented private-key blocks", () => { const message = [ - "password=SYNTHETIC_VICTIM_PASSWORD -----BEGIN RSA PRIVATE KEY-----", - "SYNTHETIC_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", + " -----BEGIN RSA PRIVATE KEY-----", + " SYNTHETIC_KEY_MATERIAL", + " -----END RSA PRIVATE KEY-----", + "retry=visible", ].join("\n"); - expect(redactedErrorMessage(message)).toBe( - "password=[redacted] [redacted]", - ); + expect(redactedErrorMessage(message)).toBe(" [redacted]\nretry=visible"); }); test("redacts lowercase private-key assignments", () => { From de6293adb95f12c0f95013af0a9f1dbe5a37da80 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 16:25:04 -0700 Subject: [PATCH 18/20] fix(sdk): preserve serialized private-key framing --- sdk/typescript/src/errors.ts | 48 ++++++----- sdk/typescript/tests-ts/errors.test.ts | 106 ++++++++++++++++--------- 2 files changed, 95 insertions(+), 59 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 4e167143..d23f44b7 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -8,16 +8,32 @@ export function redactedErrorMessage(error: unknown): string { string, Array<{ start: number; escapedNewline: number }> >(); + const indentationStart = (index: number, escapedNewline: number): number => { + let cursor = index - 1; + while (cursor >= 0) { + if (message[cursor] === " " || message[cursor] === "\t") { + cursor -= 1; + continue; + } + if (escapedNewline === 0 || message[cursor] !== "t") break; + let slashes = cursor; + while (message[slashes - 1] === "\\") slashes -= 1; + if (cursor - slashes !== escapedNewline) break; + cursor = slashes - 1; + } + return cursor; + }; const lineStart = /(?:$|[\r\n]|(\\+)[nr])/uy; const lineEnd = - /(?:$|[\r\n]|(\\+)[nr]|\\*["'](?=$|[\r\n]|[}\]](?:$|[\r\n,}\]]|\\*["'])|,(?:\s|\\+[nr])*(?:\\*["']|[\[{0-9-]|true\b|false\b|null\b))|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; + /(?:$|[\r\n]|(\\+)[nr]|(\\*)["'](?=$|[\r\n]|[}\]](?:$|[\r\n,}\]]|\\*["'])|,(?:\s|\\+[nrt])*(?:\\*["']|[\[{0-9-]|true\b|false\b|null\b))|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; for (const match of message.matchAll( /-----(BEGIN|END) ([A-Z0-9 ]*PRIVATE KEY)-----/giu, )) { const label = match[2]!; if (match[1]!.toUpperCase() === "BEGIN") { - let frame = match.index - 1; - while (message[frame] === " " || message[frame] === "\t") frame -= 1; + lineStart.lastIndex = match.index + match[0].length; + const boundary = lineStart.exec(message); + const frame = indentationStart(match.index, boundary?.[1]?.length ?? 0); const preceding = message[frame]; if ( frame >= 0 && @@ -26,7 +42,7 @@ export function redactedErrorMessage(error: unknown): string { (preceding === "n" || preceding === "r") && message[frame - 1] === "\\" ) && - !/\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|authorization|auth|token|secret|credential|password|passwd)[A-Za-z0-9_-]*\s*[:=]\s*[^\s;]+[ \t]*$/iu.test( + !/\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|authorization|auth|token|secret|credential|password|passwd)[A-Za-z0-9_-]*\s*[:=]\s*(?:[A-Za-z][A-Za-z0-9._~-]{0,63}[ \t]+)?[^\s;]+[ \t]*$/iu.test( message.slice( message.lastIndexOf("\n", match.index) + 1, match.index, @@ -35,14 +51,8 @@ export function redactedErrorMessage(error: unknown): string { ) { continue; } - lineStart.lastIndex = match.index + match[0].length; - const boundary = lineStart.exec(message); if (boundary === null) { - let previous = match.index - 1; - while (message[previous] === " " || message[previous] === "\t") { - previous -= 1; - } - if (message[previous] !== "=" && message[previous] !== ":") continue; + if (preceding !== "=" && preceding !== ":") continue; } const starts = privateKeys.get(label) ?? []; starts.push({ @@ -56,8 +66,7 @@ export function redactedErrorMessage(error: unknown): string { const starts = privateKeys.get(label); const opening = starts?.at(-1); if (opening === undefined) continue; - let frame = match.index - 1; - while (message[frame] === " " || message[frame] === "\t") frame -= 1; + const frame = indentationStart(match.index, opening.escapedNewline); const previous = message[frame]; let escapedNewline = 0; if (previous === "n" || previous === "r") { @@ -78,7 +87,9 @@ export function redactedErrorMessage(error: unknown): string { ((closingBoundary[0] === "\n" || closingBoundary[0] === "\r") && opening.escapedNewline !== 0) || (closingBoundary[1] !== undefined && - closingBoundary[1].length !== opening.escapedNewline) + closingBoundary[1].length !== opening.escapedNewline) || + (closingBoundary[2] !== undefined && + closingBoundary[2].length !== Math.max(0, opening.escapedNewline - 1)) ) { continue; } @@ -99,17 +110,14 @@ export function redactedErrorMessage(error: unknown): string { (match: string, prefix: string, offset: number, source: string) => { if (/(?:authorization|auth)/iu.test(prefix)) { const value = match.slice(prefix.length); - const schemeName = value.split(/(?:%20|\+|\s)/u, 1)[0]; - if ( - !/^(?:ApiKey|Basic|Bearer|Custom|Digest|Token)$/iu.test(schemeName!) - ) { - return `${prefix}[redacted]`; - } if ( /^[A-Za-z][A-Za-z0-9._~-]{0,63}(?:%20|\+)+\[redacted$/iu.test(value) ) { return match; } + if (!/^(?:ApiKey|Basic|Bearer|Custom|Digest|Token)$/iu.test(value)) { + return `${prefix}[redacted]`; + } const scheme = /(?:\s|%20|\+)+\[redacted\]/uy; scheme.lastIndex = offset + match.length; if (scheme.test(source)) return match; diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 892448a8..8515f598 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -135,46 +135,62 @@ describe("credential redaction", () => { }); test("preserves diagnostics after repeatedly serialized private keys", () => { - let message: string | { pem: string; safe: string } = { - pem: [ - "-----BEGIN RSA PRIVATE KEY-----", - "SYNTHETIC_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - ].join("\n"), - safe: "visible", - }; - - for (let depth = 1; depth <= 4; depth += 1) { - message = JSON.stringify(message); - let redacted: unknown = redactedErrorMessage(message); - for (let layer = 0; layer < depth; layer += 1) { - redacted = JSON.parse(redacted as string); + for (const indentation of ["", "\t"]) { + let message: string | { pem: string; safe: string } = { + pem: [ + "-----BEGIN RSA PRIVATE KEY-----", + "SYNTHETIC_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + ] + .map((line) => `${indentation}${line}`) + .join("\n"), + safe: "visible", + }; + + for (let depth = 1; depth <= 4; depth += 1) { + message = JSON.stringify(message); + let redacted: unknown = redactedErrorMessage(message); + for (let layer = 0; layer < depth; layer += 1) { + redacted = JSON.parse(redacted as string); + } + expect(redacted).toEqual({ + pem: `${indentation}[redacted]`, + safe: "visible", + }); } - expect(redacted).toEqual({ pem: "[redacted]", safe: "visible" }); } }); test("preserves fields after pretty-printed private-key values", () => { - const message = JSON.stringify( - { - nested: { - pem: [ - "-----BEGIN RSA PRIVATE KEY-----", - "SYNTHETIC_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - ].join("\n"), - safe: "visible", + for (const indentation of [2, "\t"]) { + let message = JSON.stringify( + { + nested: { + pem: [ + "-----BEGIN RSA PRIVATE KEY-----", + "SYNTHETIC_KEY_MATERIAL", + "-----END RSA PRIVATE KEY-----", + ].join("\n"), + safe: "visible", + }, + tail: "kept", }, - tail: "kept", - }, - null, - 2, - ); + null, + indentation, + ); - expect(JSON.parse(redactedErrorMessage(message))).toEqual({ - nested: { pem: "[redacted]", safe: "visible" }, - tail: "kept", - }); + for (let depth = 1; depth <= 2; depth += 1) { + if (depth > 1) message = JSON.stringify(message); + let redacted: unknown = redactedErrorMessage(message); + for (let layer = 0; layer < depth; layer += 1) { + redacted = JSON.parse(redacted as string); + } + expect(redacted).toEqual({ + nested: { pem: "[redacted]", safe: "visible" }, + tail: "kept", + }); + } + } }); test("preserves non-string array values after private keys", () => { @@ -184,12 +200,17 @@ describe("credential redaction", () => { "-----END RSA PRIVATE KEY-----", ].join("\n"); for (const value of [123, true, null, {}, []]) { - const message = JSON.stringify([pem, value, "tail"], null, 2); - expect(JSON.parse(redactedErrorMessage(message))).toEqual([ - "[redacted]", - value, - "tail", - ]); + for (const indentation of [2, "\t"]) { + let message = JSON.stringify([pem, value, "tail"], null, indentation); + for (let depth = 1; depth <= 2; depth += 1) { + if (depth > 1) message = JSON.stringify(message); + let redacted: unknown = redactedErrorMessage(message); + for (let layer = 0; layer < depth; layer += 1) { + redacted = JSON.parse(redacted as string); + } + expect(redacted).toEqual(["[redacted]", value, "tail"]); + } + } } }); @@ -247,6 +268,13 @@ describe("credential redaction", () => { ).toBe("private_key=[redacted] safe=value"); }); + test("preserves already-redacted encoded authorization schemes", () => { + for (const scheme of ["Negotiate", "AWS4-HMAC-SHA256", "DPoP"]) { + const message = `authorization=${scheme}%20[redacted]`; + expect(redactedErrorMessage(message)).toBe(message); + } + }); + test("collapses repeated truncated private-key markers", () => { expect( redactedErrorMessage("-----BEGIN PRIVATE KEY-----\n".repeat(1_000)), From 124a84e79d022385aaa5697299d3a4c99f0c2a2c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 16:40:30 -0700 Subject: [PATCH 19/20] fix(sdk): retain valid serialized credential diagnostics --- sdk/typescript/src/errors.ts | 24 +++++++++++++++------- sdk/typescript/tests-ts/errors.test.ts | 28 ++++++++++++++++++-------- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index d23f44b7..a42b9e9a 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -11,7 +11,7 @@ export function redactedErrorMessage(error: unknown): string { const indentationStart = (index: number, escapedNewline: number): number => { let cursor = index - 1; while (cursor >= 0) { - if (message[cursor] === " " || message[cursor] === "\t") { + if (/[^\S\r\n]/u.test(message[cursor]!)) { cursor -= 1; continue; } @@ -25,7 +25,7 @@ export function redactedErrorMessage(error: unknown): string { }; const lineStart = /(?:$|[\r\n]|(\\+)[nr])/uy; const lineEnd = - /(?:$|[\r\n]|(\\+)[nr]|(\\*)["'](?=$|[\r\n]|[}\]](?:$|[\r\n,}\]]|\\*["'])|,(?:\s|\\+[nrt])*(?:\\*["']|[\[{0-9-]|true\b|false\b|null\b))|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; + /(?:$|[\r\n]|(\\+)[nr]|(\\*)["'](?=$|[\r\n]|(?:\s|\\+[nrt])*(?:[}\]](?:$|[\r\n,}\]]|\\*["'])|,(?:\s|\\+[nrt])*(?:\\*["']|[\[{0-9-]|true\b|false\b|null\b)))|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; for (const match of message.matchAll( /-----(BEGIN|END) ([A-Z0-9 ]*PRIVATE KEY)-----/giu, )) { @@ -35,12 +35,19 @@ export function redactedErrorMessage(error: unknown): string { const boundary = lineStart.exec(message); const frame = indentationStart(match.index, boundary?.[1]?.length ?? 0); const preceding = message[frame]; + let precedingEscapes = 0; + if (preceding === "n" || preceding === "r") { + while (message[frame - precedingEscapes - 1] === "\\") { + precedingEscapes += 1; + } + } if ( frame >= 0 && !/[\r\n"'=:]/u.test(preceding!) && !( (preceding === "n" || preceding === "r") && - message[frame - 1] === "\\" + precedingEscapes > 0 && + precedingEscapes === (boundary?.[1]?.length ?? 0) ) && !/\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|authorization|auth|token|secret|credential|password|passwd)[A-Za-z0-9_-]*\s*[:=]\s*(?:[A-Za-z][A-Za-z0-9._~-]{0,63}[ \t]+)?[^\s;]+[ \t]*$/iu.test( message.slice( @@ -115,12 +122,15 @@ export function redactedErrorMessage(error: unknown): string { ) { return match; } - if (!/^(?:ApiKey|Basic|Bearer|Custom|Digest|Token)$/iu.test(value)) { - return `${prefix}[redacted]`; - } const scheme = /(?:\s|%20|\+)+\[redacted\]/uy; scheme.lastIndex = offset + match.length; - if (scheme.test(source)) return match; + if ( + scheme.test(source) && + (/^(?:ApiKey|Basic|Bearer|Custom|Digest|Token)$/iu.test(value) || + message.includes(source.slice(offset, scheme.lastIndex))) + ) { + return match; + } } return `${prefix}[redacted]`; }, diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 8515f598..4a2239e9 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -120,6 +120,11 @@ describe("credential redaction", () => { const unrelated = "status=failed; parser saw -----BEGIN RSA PRIVATE KEY-----\nretry=visible"; expect(redactedErrorMessage(unrelated)).toBe(unrelated); + const serialized = JSON.stringify({ + message: "prefix\\n-----BEGIN RSA PRIVATE KEY-----\nretry=visible", + safe: "kept", + }); + expect(redactedErrorMessage(serialized)).toBe(serialized); }); test("does not confuse literal escapes with serialized line boundaries", () => { @@ -178,6 +183,9 @@ describe("credential redaction", () => { null, indentation, ); + if (indentation === "\t") { + message = message.replaceAll('",', '" \t,'); + } for (let depth = 1; depth <= 2; depth += 1) { if (depth > 1) message = JSON.stringify(message); @@ -261,17 +269,21 @@ describe("credential redaction", () => { }); test("redacts lowercase private-key assignments", () => { - expect( - redactedErrorMessage( - "private_key=-----begin rsa private key-----\nSYNTHETIC_KEY\n-----end rsa private key----- safe=value", - ), - ).toBe("private_key=[redacted] safe=value"); + for (const whitespace of ["", "\u00a0"]) { + expect( + redactedErrorMessage( + `private_key=${whitespace}-----begin rsa private key-----\nSYNTHETIC_KEY\n-----end rsa private key----- safe=value`, + ), + ).toBe(`private_key=${whitespace}[redacted] safe=value`); + } }); - test("preserves already-redacted encoded authorization schemes", () => { + test("preserves already-redacted authorization schemes", () => { for (const scheme of ["Negotiate", "AWS4-HMAC-SHA256", "DPoP"]) { - const message = `authorization=${scheme}%20[redacted]`; - expect(redactedErrorMessage(message)).toBe(message); + for (const separator of ["%20", "+", " "]) { + const message = `authorization=${scheme}${separator}[redacted]`; + expect(redactedErrorMessage(message)).toBe(message); + } } }); From a40e8c0b7ac1c11b97a2f155785951c34f75fae3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 17:07:29 -0700 Subject: [PATCH 20/20] refactor(sdk): remove credential redaction --- README.md | 9 +- SECURITY.md | 5 + sdk/typescript/README.md | 12 +- sdk/typescript/src/api.ts | 17 +- sdk/typescript/src/cli.ts | 69 ++-- sdk/typescript/src/errors.ts | 207 +---------- sdk/typescript/src/multiscan.ts | 4 +- sdk/typescript/src/runtime.ts | 6 +- sdk/typescript/tests-ts/api.test.ts | 19 +- sdk/typescript/tests-ts/cli-export.test.ts | 5 +- sdk/typescript/tests-ts/cli-fixtures.ts | 52 --- sdk/typescript/tests-ts/cli-launcher.test.ts | 6 +- sdk/typescript/tests-ts/cli-workbench.test.ts | 13 +- sdk/typescript/tests-ts/cli.test.ts | 212 +++--------- sdk/typescript/tests-ts/errors.test.ts | 327 +----------------- sdk/typescript/tests-ts/multiscan.test.ts | 32 +- sdk/typescript/tests-ts/runtime.test.ts | 7 +- 17 files changed, 163 insertions(+), 839 deletions(-) diff --git a/README.md b/README.md index 3b39ddfc..4fbb4e7b 100644 --- a/README.md +++ b/README.md @@ -74,15 +74,18 @@ incomplete or their original location was not reviewed. ## Verbose diagnostics -Add `--verbose` to print redacted scan diagnostics to stderr: +Add `--verbose` to print scan diagnostics to stderr: ```bash npx @openai/codex-security scan . --verbose ``` `CODEX_SECURITY_LOG_LEVEL=debug` also enables diagnostics; -`LOG_LEVEL=debug` is its fallback. JSON results remain on stdout, and -credentials and provider identifiers remain redacted. +`LOG_LEVEL=debug` is its fallback. JSON results remain on stdout. + +Diagnostics, errors, and saved scan history are not redacted and may contain +credentials or other sensitive data. Restrict access to local logs and scan +state, and review them before sharing them. ## TypeScript SDK diff --git a/SECURITY.md b/SECURITY.md index b2ca5bf7..a0ac10e2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -75,6 +75,11 @@ credential. Other variables, such as `GITHUB_TOKEN` or `AWS_SECRET_ACCESS_KEY`, can remain available to local subprocesses. Run a scan with only the environment credentials it needs. +Local diagnostics, error messages, and saved scan history are not automatically +redacted. They can contain credentials, repository contents, or other sensitive +data from upstream failures. Limit access to logs and scan state, and remove +sensitive information before sharing or uploading them. + ### Security boundaries A security issue must cross a boundary the product actually provides: diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 1fe6764e..c62b5e3e 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -400,7 +400,7 @@ The CLI and SDK recognize the following user-configurable environment: | Variable | Effect | | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `OPENAI_API_KEY`, `CODEX_API_KEY` | Scan authentication; `OPENAI_API_KEY` wins when both are present. | -| `CODEX_SECURITY_LOG_LEVEL` | CLI-only; set to `debug` for redacted diagnostics. | +| `CODEX_SECURITY_LOG_LEVEL` | CLI-only; set to `debug` for verbose diagnostics. | | `LOG_LEVEL` | CLI-only fallback when `CODEX_SECURITY_LOG_LEVEL` is unset. | | `CODEX_SECURITY_STATE_DIR` | Override the private scan-history, workbench, and default artifact directory. | | `CODEX_HOME` | Set the ambient Codex home for file-backed sign-in and default state; defaults to `~/.codex`. | @@ -451,11 +451,12 @@ token and worker counts, estimated cost, the results directory, and the next useful command. Progress and summaries use stderr; structured scan results remain on stdout. -Add `--verbose` or set `CODEX_SECURITY_LOG_LEVEL=debug` to print redacted +Add `--verbose` or set `CODEX_SECURITY_LOG_LEVEL=debug` to print lifecycle, authentication, progress, and cost diagnostics to stderr. `LOG_LEVEL=debug` is used only when `CODEX_SECURITY_LOG_LEVEL` is unset. -Credentials and provider identifiers remain redacted, and structured JSON -results remain on stdout. +Structured JSON results remain on stdout. Diagnostics and error messages are +not redacted and may contain credentials or other sensitive data; restrict +access to logs and review them before sharing them. Each scan records its model, tokens, and estimated cost in its JSON result, scan history, and bulk-scan receipt. Estimates use @@ -514,7 +515,8 @@ least eight characters. Scan history uses the existing Codex Security workbench database at `$CODEX_HOME/state/plugins/codex-security/workbench.sqlite3`. Set `CODEX_SECURITY_STATE_DIR` to place the database elsewhere. Scan credentials -are never stored in the scan configuration. +are never stored in the scan configuration, but recorded upstream errors and +other scan-history fields are not redacted and may contain credentials. The scan sandbox permits writes to the selected state directory so SQLite can maintain its database and journal files. If the host itself cannot write to the diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index cb90e089..85ce707f 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -48,7 +48,7 @@ import { OutputDirectoryError, OutputInsideProtectedRootError, type ProtectedScanPathKind, - redactedErrorMessage, + errorMessage, ScanCostLimitExceededError, ScanInterruptedError, } from "./errors.js"; @@ -684,7 +684,7 @@ export class CodexSecurity { "onWarning", options.onWarning, options.onObserverError, - `Could not track scan activity: ${redactedErrorMessage(error)}`, + `Could not track scan activity: ${errorMessage(error)}`, ); }; const tracker = new ScanCostTracker({ @@ -1108,10 +1108,9 @@ export class CodexSecurity { "fail-scan", "--scan-id", activeScan.id, - // Redact before truncating: the stored message is read back by - // `scans show` and travels inside the results directory. + // Preserve the upstream message within the workbench field limit. "--message", - redactedErrorMessage(failure).slice(0, 2400), + errorMessage(failure).slice(0, 2400), ...(snapshot?.cost ? ["--cost-json", JSON.stringify(snapshot.cost)] : []), @@ -1130,7 +1129,7 @@ export class CodexSecurity { "onWarning", options.onWarning, options.onObserverError, - `Could not run post-scan instructions: ${redactedErrorMessage(postScanError)}`, + `Could not run post-scan instructions: ${errorMessage(postScanError)}`, ); } } @@ -1705,7 +1704,7 @@ export async function initialCredentialsAvailable( // Reports a cleanup failure without letting it decide the result of the scan. Only the // message is forwarded, and it reaches the onWarning observer alone: unlike the fail-scan -// path it is never written to the workbench, so it adds no persisted, unredacted text. +// path it is never written to the workbench, so it adds no persisted warning text. function warnCleanupFailed( options: Pick, reason: unknown, @@ -2402,8 +2401,8 @@ function reconnectDetails(message: string): ScanReconnectDetails | undefined { // // Only `error.message` is reused, because that is the single shape the previous // code already surfaced. No other shape is forwarded or stringified: this message -// reaches `fail-scan --message` and is stored in `scans.failure_message` without -// redaction, so widening what is copied out of the payload would add a new +// reaches `fail-scan --message` and is stored unchanged in `scans.failure_message`, +// so widening what is copied out of the payload would add a new // credential-disclosure path to persistent scan history. function turnFailureMessage(error: unknown): string { if (isRecord(error) && typeof error["message"] === "string") { diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 1a2a3f4a..fa6cec6a 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -66,7 +66,7 @@ import { OutputDirectoryError, OutputInsideProtectedRootError, PluginPythonUnavailableError, - redactedErrorMessage, + errorMessage, ScanCostLimitExceededError, ScanInterruptedError, } from "./errors.js"; @@ -715,7 +715,7 @@ export async function main( try { return await select(await dependencies.runWorkbench(args)); } catch (error) { - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); exitCode = 2; return undefined; } @@ -921,7 +921,7 @@ export async function main( scanArguments = scanArgumentsFromRecipe(recipe, args.scanId); scanArguments.verbose = options.verbose; } catch (error) { - const message = redactedErrorMessage(error); + const message = errorMessage(error); errorOutput.write(`codex-security: ${message}\n`); exitCode = 2; return incurError({ @@ -984,7 +984,7 @@ export async function main( format, ); } catch (error) { - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); exitCode = 2; return undefined; } @@ -1037,7 +1037,7 @@ export async function main( verbose: z .boolean() .default(false) - .describe("Print redacted scan diagnostics to stderr."), + .describe("Print scan diagnostics to stderr."), path: z .array(optionValue("--path")) .default([]) @@ -1298,7 +1298,7 @@ export async function main( failOnSeverity: options.failOnSeverity, }; } catch (error) { - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); exitCode = 2; return undefined; } @@ -1460,7 +1460,7 @@ export async function main( onProgress: ({ repository, status, attempt, error, warning }) => { const detail = error ?? warning; errorOutput.write( - `codex-security: ${repository} ${status} (attempt ${attempt})${detail === undefined ? "" : `: ${redactedErrorMessage(detail)}`}\n`, + `codex-security: ${repository} ${status} (attempt ${attempt})${detail === undefined ? "" : `: ${errorMessage(detail)}`}\n`, ); }, }); @@ -1474,7 +1474,7 @@ export async function main( (error instanceof Error && error.name === "ExitPromptError" ? 130 : 2); - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); } finally { dependencies.removeSignalListener("SIGINT", onInterrupt); dependencies.removeSignalListener("SIGTERM", onTerminate); @@ -1578,7 +1578,7 @@ export async function main( ); } catch (error) { exitCode = 2; - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); } }, }) @@ -1614,7 +1614,7 @@ export async function main( ); } catch (error) { exitCode = 2; - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); } }, }) @@ -1803,7 +1803,7 @@ export async function main( if (frameworkExit !== undefined) { if (exitCode !== 0) return exitCode; errorOutput.write( - `codex-security: ${redactedErrorMessage(incurErrorMessage(frameworkOutput))}\n`, + `codex-security: ${errorMessage(incurErrorMessage(frameworkOutput))}\n`, ); return 2; } @@ -1812,7 +1812,7 @@ export async function main( await writeCliOutput(output, renderedHistory ?? frameworkOutput); return exitCode; } catch (error) { - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); return 2; } } @@ -2533,20 +2533,18 @@ async function runExport( } return 0; } catch (error) { - errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`); + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); return 2; } } type VerboseDiagnosticValue = string | number | boolean | null | undefined; -function sanitizeDiagnosticValue(value: unknown): string { - return redactedErrorMessage(value) - .replaceAll( - /(\b(?:tenant(?:[_-]?id)?|org(?:anization)?(?:[_-]?id)?|project(?:[_-]?id)?|(?:x[_-]?)?(?:request|trace|correlation)[_-]?id)\b(?:\\*["'])?\s*[:=]\s*)(?!\[redacted\])(?:(\\*)(['"])(?:(?!(? { @@ -2758,7 +2756,6 @@ async function runScan( : { maxCostUsd: arguments_.maxCostUsd }), clock: dependencies, color: dependencies.environment["NO_COLOR"] === undefined, - sanitize: redactedErrorMessage, input: process.stdin, onInterrupt, }); @@ -2858,13 +2855,13 @@ async function runScan( diagnostic("scan.output_archived", { archive_dir: archiveDir }); if (dashboard !== null) { dashboard.note( - `Moved existing results to: ${redactedErrorMessage(archiveDir)}`, + `Moved existing results to: ${errorMessage(archiveDir)}`, ); return; } progress?.stopTimer(); errorOutput.write( - `Moved existing results to: ${redactedErrorMessage(archiveDir)}\n`, + `Moved existing results to: ${errorMessage(archiveDir)}\n`, ); }, signal: preparationAbortController.signal, @@ -3017,7 +3014,7 @@ async function runScan( progress.startTimer(runningMessage()); }, onWarning: (warning, details) => { - const message = sanitizeDiagnosticValue(warning); + const message = diagnosticValue(warning); if (details?.kind === "target_changed") { targetWarnings.push(message); } @@ -3031,7 +3028,7 @@ async function runScan( observer, classification: classifyConnectionFailure(error), }); - const warning = `${observer} observer failed: ${sanitizeDiagnosticValue(error)}`; + const warning = `${observer} observer failed: ${diagnosticValue(error)}`; if (dashboard === null) { writeAboveProgress(() => { errorOutput.write(`codex-security: warning: ${warning}\n`); @@ -3089,7 +3086,7 @@ async function runScan( failure instanceof ScanCostLimitExceededError ? failure : undefined; const message = failure instanceof OutputInsideProtectedRootError - ? redactedErrorMessage(protectedRootErrorMessage(failure)) + ? errorMessage(protectedRootErrorMessage(failure)) : scanFailureMessage(failure, selectedAuthentication); diagnostic("scan.failed", { classification: @@ -3108,7 +3105,7 @@ async function runScan( } if (scanDir !== null) { errorOutput.write( - `Partial output was kept at ${redactedErrorMessage(scanDir)}.\n`, + `Partial output was kept at ${errorMessage(scanDir)}.\n`, ); } return { exitCode: 2, error: message }; @@ -3245,7 +3242,7 @@ function scanFailureMessage( // appending it. That is deliberate: upstream authentication and authorization // errors can name the organization or project, which must not reach stderr or // the JSON error field. - if (isLocalScanFailure(error)) return sanitizeDiagnosticValue(error); + if (isLocalScanFailure(error)) return diagnosticValue(error); switch (classifyConnectionFailure(error)) { case "unauthorized": if (authentication?.method === "aws_credentials") { @@ -3277,7 +3274,7 @@ function scanFailureMessage( case "network_error": case "timeout": case "unknown": - return sanitizeDiagnosticValue(error); + return diagnosticValue(error); } } @@ -3291,9 +3288,7 @@ function scanScope(arguments_: ScanArguments): string | null { portable.startsWith("//") ? portable.split("/").at(-1) ?? portable : portable; - return redactedErrorMessage( - scoped.replaceAll(/[\u0000-\u001F\u007F]/gu, " "), - ); + return errorMessage(scoped.replaceAll(/[\u0000-\u001F\u007F]/gu, " ")); }); return `${displayed.join(", ")}${arguments_.paths.length > displayed.length ? `, +${arguments_.paths.length - displayed.length} more` : ""}`; } @@ -3359,7 +3354,7 @@ function printScanSummary( ? 33 : 36; errorOutput.write( - `\n ${paint("REPORT", "1;36")} ${paint(redactedErrorMessage(result.reportPath), 4)}\n\n` + + `\n ${paint("REPORT", "1;36")} ${paint(errorMessage(result.reportPath), 4)}\n\n` + ` ${paint("FINDINGS", 1)} ${paint(`${findingCount}${severitySummary === "" ? "" : ` (${severitySummary})`}`, findingColor)}\n` + ` ${paint("COVERAGE", 1)} ${result.coverage.completeness}\n` + ` ${paint("ELAPSED", 1)} ${duration}\n`, @@ -3375,7 +3370,7 @@ function printScanSummary( ); } errorOutput.write( - ` ${paint("RESULTS", 1)} ${redactedErrorMessage(result.scanDir)}\n`, + ` ${paint("RESULTS", 1)} ${errorMessage(result.scanDir)}\n`, ); } @@ -3710,7 +3705,7 @@ function interruptedExit( errorOutput.write( scanDir === null ? "codex-security: No partial output was kept.\n" - : `codex-security: Partial output was kept at ${redactedErrorMessage(scanDir)}.\n`, + : `codex-security: Partial output was kept at ${errorMessage(scanDir)}.\n`, ); return ctrlC ? 130 : 143; } @@ -3736,7 +3731,7 @@ if (invokedAsMain()) { process.exitCode = exitCode; }, (error: unknown) => { - process.stderr.write(`codex-security: ${redactedErrorMessage(error)}\n`); + process.stderr.write(`codex-security: ${errorMessage(error)}\n`); process.exitCode = 2; }, ); diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index a42b9e9a..e721e3c8 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -1,209 +1,8 @@ import { formatUsd, type ScanCost } from "./cost.js"; -/** Returns an error message with credential-shaped substrings redacted. */ -export function redactedErrorMessage(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - const sensitiveRanges: Array<[number, number]> = []; - const privateKeys = new Map< - string, - Array<{ start: number; escapedNewline: number }> - >(); - const indentationStart = (index: number, escapedNewline: number): number => { - let cursor = index - 1; - while (cursor >= 0) { - if (/[^\S\r\n]/u.test(message[cursor]!)) { - cursor -= 1; - continue; - } - if (escapedNewline === 0 || message[cursor] !== "t") break; - let slashes = cursor; - while (message[slashes - 1] === "\\") slashes -= 1; - if (cursor - slashes !== escapedNewline) break; - cursor = slashes - 1; - } - return cursor; - }; - const lineStart = /(?:$|[\r\n]|(\\+)[nr])/uy; - const lineEnd = - /(?:$|[\r\n]|(\\+)[nr]|(\\*)["'](?=$|[\r\n]|(?:\s|\\+[nrt])*(?:[}\]](?:$|[\r\n,}\]]|\\*["'])|,(?:\s|\\+[nrt])*(?:\\*["']|[\[{0-9-]|true\b|false\b|null\b)))|[ \t]+[A-Za-z][A-Za-z0-9_-]*=)/uy; - for (const match of message.matchAll( - /-----(BEGIN|END) ([A-Z0-9 ]*PRIVATE KEY)-----/giu, - )) { - const label = match[2]!; - if (match[1]!.toUpperCase() === "BEGIN") { - lineStart.lastIndex = match.index + match[0].length; - const boundary = lineStart.exec(message); - const frame = indentationStart(match.index, boundary?.[1]?.length ?? 0); - const preceding = message[frame]; - let precedingEscapes = 0; - if (preceding === "n" || preceding === "r") { - while (message[frame - precedingEscapes - 1] === "\\") { - precedingEscapes += 1; - } - } - if ( - frame >= 0 && - !/[\r\n"'=:]/u.test(preceding!) && - !( - (preceding === "n" || preceding === "r") && - precedingEscapes > 0 && - precedingEscapes === (boundary?.[1]?.length ?? 0) - ) && - !/\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|authorization|auth|token|secret|credential|password|passwd)[A-Za-z0-9_-]*\s*[:=]\s*(?:[A-Za-z][A-Za-z0-9._~-]{0,63}[ \t]+)?[^\s;]+[ \t]*$/iu.test( - message.slice( - message.lastIndexOf("\n", match.index) + 1, - match.index, - ), - ) - ) { - continue; - } - if (boundary === null) { - if (preceding !== "=" && preceding !== ":") continue; - } - const starts = privateKeys.get(label) ?? []; - starts.push({ - start: match.index, - escapedNewline: boundary?.[1]?.length ?? 0, - }); - privateKeys.set(label, starts); - continue; - } - - const starts = privateKeys.get(label); - const opening = starts?.at(-1); - if (opening === undefined) continue; - const frame = indentationStart(match.index, opening.escapedNewline); - const previous = message[frame]; - let escapedNewline = 0; - if (previous === "n" || previous === "r") { - while (message[frame - escapedNewline - 1] === "\\") { - escapedNewline += 1; - } - } else if (previous !== "\n" && previous !== "\r") { - continue; - } - if (escapedNewline !== opening.escapedNewline) { - continue; - } - const end = match.index + match[0].length; - lineEnd.lastIndex = end; - const closingBoundary = lineEnd.exec(message); - if ( - closingBoundary === null || - ((closingBoundary[0] === "\n" || closingBoundary[0] === "\r") && - opening.escapedNewline !== 0) || - (closingBoundary[1] !== undefined && - closingBoundary[1].length !== opening.escapedNewline) || - (closingBoundary[2] !== undefined && - closingBoundary[2].length !== Math.max(0, opening.escapedNewline - 1)) - ) { - continue; - } - starts!.pop(); - sensitiveRanges.push([opening.start, end]); - } - for (const starts of privateKeys.values()) { - for (const { start } of starts) - sensitiveRanges.push([start, message.length]); - } - return redactQuotedCredentialValues(message, sensitiveRanges) - .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}(?:authorization|auth)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*)([A-Za-z][A-Za-z0-9._~-]{0,63})((?:\s|%20|\+)+)(?!\[redacted\]|(?!key\s*=)[A-Za-z_][A-Za-z0-9_-]{0,64}\s*[:=]\s*(?=[^=\s"',;}&\\\]]))[^\s"',;}&\\\]]+/giu, - "$1$2$3[redacted]", - ) - .replaceAll( - /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?!\[redacted\])[^\s"',;}&\\\]]+/giu, - (match: string, prefix: string, offset: number, source: string) => { - if (/(?:authorization|auth)/iu.test(prefix)) { - const value = match.slice(prefix.length); - if ( - /^[A-Za-z][A-Za-z0-9._~-]{0,63}(?:%20|\+)+\[redacted$/iu.test(value) - ) { - return match; - } - const scheme = /(?:\s|%20|\+)+\[redacted\]/uy; - scheme.lastIndex = offset + match.length; - if ( - scheme.test(source) && - (/^(?:ApiKey|Basic|Bearer|Custom|Digest|Token)$/iu.test(value) || - message.includes(source.slice(offset, scheme.lastIndex))) - ) { - return match; - } - } - return `${prefix}[redacted]`; - }, - ) - .replaceAll(/sk-(?:proj-)?[A-Za-z0-9_*=-]{8,}/gu, "[redacted]") - .replaceAll(/(?:github_pat_|gh[pousr]_)[A-Za-z0-9_-]{8,}/giu, "[redacted]") - .replaceAll(/npm_[A-Za-z0-9_-]{8,}/giu, "[redacted]") - .replaceAll( - /(^|%20|[^A-Za-z0-9_])(Bearer|Basic|Token)((?:\s|%20|\+)+)[A-Za-z0-9.%_~+/*=-]+/giu, - "$1$2$3[redacted]", - ) - .replaceAll(/((?:https?|ssh|git\+ssh):\/\/)[^\s/@]+@/giu, "$1[redacted]@") - .replaceAll( - /((?:[?&]|%3F|%26)(?:(?!%3F|%26|%3D)(?:[A-Za-z0-9_.%-]|\[|\])){0,64}(?:api[_-]?key|access(?:[_-]|%5F|%2D)?key(?:(?:[_-]|%5F|%2D)?id)?|private(?:[_-]|%5F|%2D)?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:(?:[_-]|%5F|%2D)[A-Za-z0-9_.%-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_.%-]{0,48})?(?:\]|%5D)?(?:=|%3D))(?:(?!%26)[^&\s])+/giu, - "$1[redacted]", - ); -} - -function redactQuotedCredentialValues( - message: string, - ranges: Array<[number, number]>, -): string { - const assignment = - /(\b[A-Za-z0-9_-]{0,64}(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|private[_-]?key|authorization|auth|token|secret|credential|signature|sig|password|passwd)(?:[_-][A-Za-z0-9_-]{1,64}|(?:value|data|token|secret|credential|password|header|field|id|key)[A-Za-z0-9_-]{0,48})?\b(?:\\*["'])?\s*[:=]\s*)(\\*)(["'])/giu; - for ( - let match = assignment.exec(message); - match !== null; - match = assignment.exec(message) - ) { - const openingSlashes = match[2]!.length; - const quote = match[3]!; - let position = assignment.lastIndex; - let closed = false; - while (position < message.length) { - const delimiter = message.indexOf(quote, position); - if (delimiter < 0) break; - let preceding = delimiter; - while (preceding > position && message[preceding - 1] === "\\") { - preceding -= 1; - } - if (delimiter - preceding === openingSlashes) { - ranges.push([assignment.lastIndex, preceding]); - assignment.lastIndex = delimiter + 1; - closed = true; - break; - } - position = delimiter + 1; - } - if (!closed) { - ranges.push([assignment.lastIndex, message.length]); - break; - } - } - - const merged: Array<[number, number]> = []; - for (const [start, end] of ranges.sort( - ([leftStart], [rightStart]) => leftStart - rightStart, - )) { - const previous = merged.at(-1); - if (previous !== undefined && start <= previous[1]) { - previous[1] = Math.max(previous[1], end); - } else { - merged.push([start, end]); - } - } - - let output = ""; - let consumed = 0; - for (const [start, end] of merged) { - output += `${message.slice(consumed, start)}[redacted]`; - consumed = end; - } - return output + message.slice(consumed); +/** Returns the original error message without altering its contents. */ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } /** Base error for Codex Security SDK failures. */ diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index e90e78ea..443b9781 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -20,7 +20,7 @@ import Papa from "papaparse"; import type { CodexSecurity } from "./api.js"; import type { CodexSecurityConfig } from "./config.js"; import type { ScanCost } from "./cost.js"; -import { redactedErrorMessage } from "./errors.js"; +import { errorMessage } from "./errors.js"; import type { CoverageDocument } from "./models.js"; import type { ScanMode } from "./targets.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; @@ -247,7 +247,7 @@ async function runCampaign( } } catch (error) { if (options.signal?.aborted === true) options.signal.throwIfAborted(); - failure = redactedErrorMessage(error); + failure = errorMessage(error); } finally { await rm(checkout, { recursive: true, force: true }); } diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index c57ffaba..9226cf47 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -43,7 +43,7 @@ import { OutputDirectoryError, PluginBootstrapError, PluginPythonUnavailableError, - redactedErrorMessage, + errorMessage, } from "./errors.js"; import type { JsonObject } from "./config.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; @@ -279,11 +279,11 @@ function windowsCredentialAclFailure(error: unknown): string { : error instanceof Error ? error.message : String(error); - const sanitized = redactedErrorMessage(detail) + const normalized = errorMessage(detail) .replace(/\s+/gu, " ") .trim() .slice(0, 512); - return sanitized === "" ? "" : `. ${sanitized}`; + return normalized === "" ? "" : `. ${normalized}`; } const WINDOWS_SYSTEM_SID = "S-1-5-18"; diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index ec8076ae..2d55a2e8 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -51,7 +51,7 @@ import { setCodexSecurityCredentialLogout, } from "../src/runtime.js"; import { normalizeTarget } from "../src/targets.js"; -import { REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS } from "./cli-fixtures.js"; +import { SYNTHETIC_CREDENTIALS } from "./cli-fixtures.js"; import { INTEGRATION_TARGET, PLUGIN_ROOT } from "./plugin-root.js"; type ScanObserverName = Parameters< @@ -3303,7 +3303,7 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); - test("redacts credentials from the stored scan failure message", async () => { + test("preserves the original stored scan failure message", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const codexHome = join(root, "codex-home"); @@ -3322,7 +3322,8 @@ describe("CodexSecurity orchestration", () => { const quotedCredential = JSON.stringify({ client_secret_value: "SYNTHETIC correct horse battery staple", }); - const redactedFailure = `${REDACTED_CREDENTIALS} {"client_secret_value":"[redacted]"}`; + const originalFailure = `${SYNTHETIC_CREDENTIALS} ${quotedCredential}`; + const storedFailure = originalFailure.slice(0, 2400); const client = new TestClient( {}, { @@ -3348,7 +3349,7 @@ describe("CodexSecurity orchestration", () => { async function* failingEvents(): AsyncGenerator { yield { type: "error", - message: `${SYNTHETIC_CREDENTIALS} ${quotedCredential}`, + message: originalFailure, }; } return { events: failingEvents() }; @@ -3358,14 +3359,12 @@ describe("CodexSecurity orchestration", () => { }, ); - // The in-memory error keeps its original text; only what leaves the process - // is redacted, so the CLI can still classify the upstream failure. await expect(client.run(repository)).rejects.toThrow(SYNTHETIC_CREDENTIALS); const failure = commands.find((args) => args[0] === "fail-scan"); const scanId = failure?.[2] ?? ""; expect(scanId).toMatch(/^[0-9a-f-]{36}$/); expect(failure?.[3]).toBe("--message"); - expect(failure?.[4]).toBe(redactedFailure); + expect(failure?.[4]).toBe(storedFailure); // `scans show` reads the stored message back through get-scan. const context = await runWorkbench( @@ -3374,13 +3373,11 @@ describe("CodexSecurity orchestration", () => { ); expect(context["scan"]).toMatchObject({ progress: { status: "failed" }, - failureMessage: redactedFailure, + failureMessage: storedFailure, }); - // Every synthetic credential is tagged SYNTHETIC, so the database file - // itself proves nothing was persisted anywhere on the failure path. const database = await readFile(join(stateDirectory, "workbench.sqlite3")); - expect(database.toString("latin1")).not.toContain("SYNTHETIC"); + expect(database.toString("latin1")).toContain("SYNTHETIC"); await client.close(); }); diff --git a/sdk/typescript/tests-ts/cli-export.test.ts b/sdk/typescript/tests-ts/cli-export.test.ts index aacfb329..dc784495 100644 --- a/sdk/typescript/tests-ts/cli-export.test.ts +++ b/sdk/typescript/tests-ts/cli-export.test.ts @@ -16,7 +16,6 @@ import { describe, expect, test } from "bun:test"; import { exportEnvironment, main } from "../src/cli.js"; import { CodexSecurityError } from "../src/index.js"; import { - REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS, capture, dependencies, @@ -583,7 +582,7 @@ describe("CLI", () => { ); }); - test("redacts credentials from caught export failures", async () => { + test("preserves caught export failures", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -601,7 +600,7 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toBe( - `codex-security: export failed ${REDACTED_CREDENTIALS}\n`, + `codex-security: export failed ${SYNTHETIC_CREDENTIALS}\n`, ); }); }); diff --git a/sdk/typescript/tests-ts/cli-fixtures.ts b/sdk/typescript/tests-ts/cli-fixtures.ts index 24040954..4b0335e1 100644 --- a/sdk/typescript/tests-ts/cli-fixtures.ts +++ b/sdk/typescript/tests-ts/cli-fixtures.ts @@ -73,58 +73,6 @@ export const SYNTHETIC_CREDENTIALS = [ "https://example.test/?redirect_uri=https%3A%2F%2Finner.test%2Fcb%3Frefresh_token%3DSYNTHETIC_NESTED_REFRESH_123%26password%3DSYNTHETIC_NESTED_PASSWORD_123%26safe%3D1", ].join(" "); -export const REDACTED_CREDENTIALS = [ - "[redacted]", - "Bearer [redacted]", - "Authorization: Basic [redacted]", - "Authorization: Token [redacted]", - "Authorization: Bearer%20[redacted]", - "Authorization%3A%20Bearer%20[redacted]", - "https://[redacted]@example.test/private", - "ssh://[redacted]@example.test/private", - "git+ssh://[redacted]@example.test/private", - "[redacted]", - "[redacted]", - "OPENAI_API_KEY=[redacted]", - "CODEX_API_KEY=[redacted]", - "CODEX_ACCESS_TOKEN=[redacted]", - "GITHUB_TOKEN=[redacted]", - "GH_TOKEN=[redacted]", - '{"OPENAI_API_KEY":"[redacted]","CODEX_API_KEY":"[redacted]"}', - '{\\"OPENAI_API_KEY\\":\\"[redacted]\\",\\"CODEX_API_KEY\\":\\"[redacted]\\"}', - '{"refresh_token":"[redacted]","id_token":"[redacted]","clientSecret":"[redacted]","dbPassword":"[redacted]","passwd":"[redacted]"}', - '{\\"refreshToken\\":\\"[redacted]\\",\\"idToken\\":\\"[redacted]\\",\\"clientSecret\\":\\"[redacted]\\",\\"password\\":\\"[redacted]\\"}', - "AWS_SECRET_ACCESS_KEY=[redacted]", - "AWS_ACCESS_KEY_ID=[redacted]", - "AWS_SESSION_TOKEN=[redacted]", - "NODE_AUTH_TOKEN=[redacted]", - "NPM_TOKEN=[redacted]", - "OPENAI_API_KEY=[redacted]", - "GITHUB_TOKEN=[redacted]", - "NPM_TOKEN=[redacted]", - "ACTIONS_ID_TOKEN_REQUEST_TOKEN=[redacted]", - "ACTIONS_RUNTIME_TOKEN=[redacted]", - "GITLAB_TOKEN=[redacted]", - "HF_TOKEN=[redacted]", - "SLACK_BOT_TOKEN=[redacted]", - "//registry.npmjs.org/:_authToken=[redacted]", - "x-api-key: [redacted]", - "access_token=[redacted]", - "[redacted]", - "https://example.test/?token=[redacted]&safe=1", - "https://example.test/?credential=[redacted]&safe=1", - "https://example.test/?AWS_ACCESS_KEY_ID=[redacted]&safe=1", - "https://example.test/?AWS%5FACCESS%5FKEY%5FID=[redacted]&AWS%2DACCESS%2DKEY%2DID=[redacted]&safe=1", - "https://example.test/?service-api-key=[redacted]&service-access-token=[redacted]&service-token=[redacted]&service-secret=[redacted]&signature=[redacted]&safe=1", - "https://example.test/?X-Amz-Signature=[redacted]&X-Amz-Credential=[redacted]&X-Amz-Security-Token=[redacted]&safe=1", - "https://example.test/?X-Goog-Signature=[redacted]&X-Goog-Credential=[redacted]&safe=1", - "https://example.test/?sv=2026-01-01&sig=[redacted]&safe=1", - "https://example.test/?password=[redacted]&passwd=[redacted]&safe=1", - "https://example.test/?oauth.refreshToken=[redacted]&auth[token]=[redacted]&auth%5BclientSecret%5D=[redacted]&safe=1", - "https://example.test/?access_token%3D[redacted]&client_secret%3D[redacted]&safe=1", - "https://example.test/?redirect_uri=https%3A%2F%2Finner.test%2Fcb%3Frefresh_token%3D[redacted]%26password%3D[redacted]%26safe%3D1", -].join(" "); - export function capture(isTTY = false): { stream: Pick & Partial>; diff --git a/sdk/typescript/tests-ts/cli-launcher.test.ts b/sdk/typescript/tests-ts/cli-launcher.test.ts index 703256df..92a0e777 100644 --- a/sdk/typescript/tests-ts/cli-launcher.test.ts +++ b/sdk/typescript/tests-ts/cli-launcher.test.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, test } from "bun:test"; import { VERSION } from "../src/index.js"; -import { REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS } from "./support/cli.js"; +import { SYNTHETIC_CREDENTIALS } from "./support/cli.js"; const packageRoot = join(import.meta.dir, ".."); @@ -40,7 +40,7 @@ describe("CLI launcher", () => { } }); - test("maps unexpected source-entrypoint failures to exit 2 and redacts credentials", async () => { + test("maps unexpected source-entrypoint failures to exit 2", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-cli-failure-")); try { const preload = join(root, "unavailable-cwd.mjs"); @@ -57,7 +57,7 @@ describe("CLI launcher", () => { expect(child.status).toBe(2); expect(child.stdout).toBe(""); expect(child.stderr).toBe( - `working directory is unavailable: ${REDACTED_CREDENTIALS}\n`, + `working directory is unavailable: ${SYNTHETIC_CREDENTIALS}\n`, ); } finally { await rm(root, { recursive: true, force: true }); diff --git a/sdk/typescript/tests-ts/cli-workbench.test.ts b/sdk/typescript/tests-ts/cli-workbench.test.ts index aea574ba..c6d49f1a 100644 --- a/sdk/typescript/tests-ts/cli-workbench.test.ts +++ b/sdk/typescript/tests-ts/cli-workbench.test.ts @@ -3,12 +3,7 @@ import { describe, expect, test } from "bun:test"; import type { CodexSecurityConfig, JsonObject } from "../src/index.js"; import { DiffTarget } from "../src/index.js"; import { main } from "../src/cli.js"; -import { - capture, - dependencies, - REDACTED_CREDENTIALS, - SYNTHETIC_CREDENTIALS, -} from "./support/cli.js"; +import { capture, dependencies, SYNTHETIC_CREDENTIALS } from "./support/cli.js"; describe("CLI workbench", () => { test("lists repository and scan-root history without starting Codex", async () => { @@ -619,7 +614,7 @@ describe("CLI workbench", () => { } }); - test("redacts workbench failures and does not initialize Codex", async () => { + test("preserves workbench failures and does not initialize Codex", async () => { const stderr = capture(); let started = false; expect( @@ -637,8 +632,8 @@ describe("CLI workbench", () => { }), ), ).toBe(2); - expect(stderr.text()).toContain(REDACTED_CREDENTIALS); - expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).toContain(SYNTHETIC_CREDENTIALS); + expect(stderr.text()).toContain("SYNTHETIC_KEY_123"); expect(started).toBe(false); }); }); diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index b76522e5..117b905e 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -22,7 +22,6 @@ import type { ScanOptions, ScanPreflight, } from "../src/index.js"; -import { redactedErrorMessage } from "../src/errors.js"; import { BUNDLED_PLUGIN_VERSION, CodexSecurityError, @@ -45,7 +44,6 @@ import { } from "../src/config.js"; import { FakeSignals, - REDACTED_CREDENTIALS, SYNTHETIC_CREDENTIALS, capture, dependencies, @@ -499,7 +497,7 @@ describe("CLI", () => { expect(workbenchCalled).toBe(false); }); - test("redacts false-positive workbench failures", async () => { + test("preserves false-positive workbench failures", async () => { const stdout = capture(); const stderr = capture(); let started = false; @@ -529,8 +527,8 @@ describe("CLI", () => { ), ).toBe(2); expect(stdout.text()).toBe(""); - expect(stderr.text()).toContain(REDACTED_CREDENTIALS); - expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).toContain(SYNTHETIC_CREDENTIALS); + expect(stderr.text()).toContain("SYNTHETIC_KEY_123"); expect(started).toBe(false); }); @@ -837,7 +835,7 @@ describe("CLI", () => { }, ); - test("preserves the bulk-scan failure summary and redacts progress errors", async () => { + test("preserves the bulk-scan failure summary and progress errors", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-cli-multiscan-")); try { await multiscanInventory(root); @@ -871,8 +869,7 @@ describe("CLI", () => { skipped: 0, }); expect(stderr.text()).toContain("sample failed (attempt 1)"); - expect(stderr.text()).toContain("[redacted]"); - expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).toContain("SYNTHETIC_KEY_123"); } finally { await rm(root, { recursive: true, force: true }); } @@ -1707,8 +1704,9 @@ describe("CLI", () => { ); expect(text).not.toContain("thinking ·"); expect(text).not.toContain("said ·"); - expect(text).toContain('curl -H "Authorization: Bearer [redacted]"'); - expect(text).not.toContain("SYNTHETIC_OPENAI_VALUE_123"); + expect(text).toContain( + 'curl -H "Authorization: Bearer sk-proj-SYNTHETIC_OPENAI_VALUE_123"', + ); expect(text).not.toContain("Building the file inventory"); expect(text).not.toContain("Running a scan command"); expect(text).toContain("3 / 1,258 reviewed"); @@ -2246,7 +2244,7 @@ describe("CLI", () => { } }); - test("redacts malformed --codex overrides and accepts large values", () => { + test("does not echo malformed --codex overrides and accepts large values", () => { const secret = "SYNTHETIC_TOML_SECRET_MUST_NOT_ECHO"; let malformed: unknown; try { @@ -3094,7 +3092,7 @@ describe("CLI", () => { } }); - test("redacts verbose provider failures and excludes private provider context", async () => { + test("classifies provider failures without including upstream context", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies({ @@ -3127,7 +3125,7 @@ describe("CLI", () => { expect(stderr.text()).not.toContain("SYNTHETIC_PROVIDER_SECRET"); }); - test("excludes unclassified provider context from verbose failure diagnostics", async () => { + test("keeps unclassified provider context out of structured failure diagnostics", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -3161,12 +3159,12 @@ describe("CLI", () => { expect(failureDiagnostic).not.toContain("tenant-private"); expect(failureDiagnostic).not.toContain("req-internal"); expect(stderr.text()).toContain("Provider failed for"); - expect(stderr.text()).not.toContain("tenant-private"); - expect(stderr.text()).not.toContain("req-internal"); + expect(stderr.text()).toContain("tenant-private"); + expect(stderr.text()).toContain("req-internal"); expect(stdout.text()).toBe(""); }); - test("redacts provider identifier variants in scan failures", async () => { + test("preserves provider identifier variants in scan failures", async () => { const cases = [ { message: @@ -3258,15 +3256,14 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toContain("Provider failed for"); - expect(stderr.text()).toContain("[redacted]"); for (const identifier of identifiers) { - expect(stderr.text()).not.toContain(identifier); + expect(stderr.text()).toContain(identifier); } } } }); - test("redacts provider identifiers from scanner warnings", async () => { + test("preserves provider identifiers in scanner warnings", async () => { for (const verbose of [false, true]) { const stdout = capture(); const stderr = capture(); @@ -3302,10 +3299,9 @@ describe("CLI", () => { expect(stderr.text()).toContain( "codex-security: warning: Provider warning", ); - expect(stderr.text()).toContain("[redacted]"); - expect(stderr.text()).not.toContain("organization private"); - expect(stderr.text()).not.toContain("request private"); - expect(stderr.text()).not.toContain("tenant-private"); + expect(stderr.text()).toContain("organization private"); + expect(stderr.text()).toContain("request private"); + expect(stderr.text()).toContain("tenant-private"); } }); @@ -3356,7 +3352,7 @@ describe("CLI", () => { } }); - test("redacts verbose output paths and observer diagnostics", async () => { + test("preserves verbose output paths and observer diagnostics", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -3388,16 +3384,15 @@ describe("CLI", () => { ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( - 'codex-security: debug: scan.output_archived archive_dir="/tmp/archive_[redacted]"', + 'codex-security: debug: scan.output_archived archive_dir="/tmp/archive_sk-proj-SYNTHETIC_ARCHIVE_SECRET_123"', ); expect(stderr.text()).toContain( - 'codex-security: debug: scan.output_ready scan_dir="/tmp/scan_[redacted]"', + 'codex-security: debug: scan.output_ready scan_dir="/tmp/scan_sk-proj-SYNTHETIC_OUTPUT_SECRET_123"', ); expect(stderr.text()).toContain( 'codex-security: debug: scan.observer_failed observer="onWorkerStatus"', ); - expect(stderr.text()).toContain("[redacted]"); - expect(stderr.text()).not.toContain("SYNTHETIC"); + expect(stderr.text()).toContain("SYNTHETIC"); }); test("excludes observer failure context from verbose diagnostics", async () => { @@ -3441,8 +3436,8 @@ describe("CLI", () => { expect(observerDiagnostic).not.toContain("tenant-private"); expect(observerDiagnostic).not.toContain("req-internal"); expect(stderr.text()).toContain("Observer failed for"); - expect(stderr.text()).not.toContain("tenant-private"); - expect(stderr.text()).not.toContain("req-internal"); + expect(stderr.text()).toContain("tenant-private"); + expect(stderr.text()).toContain("req-internal"); }); test("excludes cleanup failure context from verbose diagnostics", async () => { @@ -3477,8 +3472,8 @@ describe("CLI", () => { } expect(stderr.text()).toContain("Cleanup failed for"); - expect(stderr.text()).not.toContain("tenant-private"); - expect(stderr.text()).not.toContain("req-internal"); + expect(stderr.text()).toContain("tenant-private"); + expect(stderr.text()).toContain("req-internal"); expect(stdout.text()).toBe(""); }); @@ -3714,7 +3709,7 @@ describe("CLI", () => { } }); - test("redacts credentials in underlying network errors", async () => { + test("preserves underlying network errors", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -3733,106 +3728,12 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toContain( - `network failure ECONNRESET ${REDACTED_CREDENTIALS}`, + `network failure ECONNRESET ${SYNTHETIC_CREDENTIALS}`, ); - expect(stderr.text()).not.toContain("SYNTHETIC_KEY_123"); + expect(stderr.text()).toContain("SYNTHETIC_KEY_123"); expect(stderr.text()).not.toContain("model service could not be reached"); }); - test("redacts quoted multiword credentials and private-key assignments", () => { - expect( - redactedErrorMessage( - 'password="correct horse battery staple" private_key=SYNTHETIC_PRIVATE_KEY_123', - ), - ).toBe('password="[redacted]" private_key=[redacted]'); - expect( - redactedErrorMessage( - '{"client_secret_value":"correct horse battery staple","safe":"visible"}', - ), - ).toBe('{"client_secret_value":"[redacted]","safe":"visible"}'); - expect( - redactedErrorMessage( - '{"clientSecretValue":"camel case secret","accessTokenValue":"camel case token"}', - ), - ).toBe( - '{"clientSecretValue":"[redacted]","accessTokenValue":"[redacted]"}', - ); - expect( - redactedErrorMessage( - "clientSecretValue=SYNTHETIC_CAMEL_SECRET accessTokenValue=SYNTHETIC_CAMEL_TOKEN https://example.test/?clientSecretValue=SYNTHETIC_CAMEL_QUERY", - ), - ).toBe( - "clientSecretValue=[redacted] accessTokenValue=[redacted] https://example.test/?clientSecretValue=[redacted]", - ); - expect( - redactedErrorMessage( - '{\\"access_token_value\\":\\"another horse battery staple\\"}', - ), - ).toBe('{\\"access_token_value\\":\\"[redacted]\\"}'); - expect( - redactedErrorMessage( - 'authorization="opaque secret value" _auth=Zm9vOmJhcg== https://example.test/?authorization=opaque%20query%20secret', - ), - ).toBe( - 'authorization="[redacted]" _auth=[redacted] https://example.test/?authorization=[redacted]', - ); - for (const [authorization, redacted] of [ - [ - "Authorization: ApiKey SYNTHETIC_APIKEY_SECRET", - "Authorization: ApiKey [redacted]", - ], - ["auth=Custom%20SYNTHETIC_CUSTOM_SECRET", "auth=Custom%20[redacted]"], - [ - "Authorization: Digest+SYNTHETIC_DIGEST_SECRET", - "Authorization: Digest+[redacted]", - ], - [ - "client_authorization_value=ApiKey SYNTHETIC_SUFFIXED_SECRET", - "client_authorization_value=ApiKey [redacted]", - ], - ["Authorization: ApiKey dGVzdA==", "Authorization: ApiKey [redacted]"], - ["Authorization: ApiKey dGVzdA=", "Authorization: ApiKey [redacted]"], - ["Authorization: ApiKey key=SECRET", "Authorization: ApiKey [redacted]"], - ["auth=Custom key=SECRET", "auth=Custom [redacted]"], - [ - "client_auth_token=Custom dGVzdA==", - "client_auth_token=Custom [redacted]", - ], - ] as const) { - expect(redactedErrorMessage(authorization)).toBe(redacted); - } - expect(redactedErrorMessage('password="correct horse battery staple')).toBe( - 'password="[redacted]', - ); - let encoded: string | { password: string } = { - password: 'foo "bar" baz', - }; - for (let depth = 1; depth <= 3; depth += 1) { - encoded = JSON.stringify(encoded); - const redacted = redactedErrorMessage(encoded); - expect(redacted).not.toContain("foo"); - expect(redacted).not.toContain("bar"); - expect(redacted).not.toContain("baz"); - let decoded: unknown = redacted; - for (let layer = 0; layer < depth; layer += 1) { - decoded = JSON.parse(decoded as string); - } - expect(decoded).toEqual({ password: "[redacted]" }); - } - for (const separator of ["\n", "\\n"]) { - expect( - redactedErrorMessage( - `private_key=-----BEGIN PRIVATE KEY-----${separator}MII_SYNTHETIC_PRIVATE_KEY${separator}-----END PRIVATE KEY----- safe=value`, - ), - ).toBe("private_key=[redacted] safe=value"); - expect( - redactedErrorMessage( - `private_key=-----BEGIN PRIVATE KEY-----${separator}MII_SYNTHETIC_TRUNCATED_PRIVATE_KEY`, - ), - ).toBe("private_key=[redacted]"); - } - }); - test("reports database connection failures without claiming the model network failed", async () => { const stdout = capture(); const stderr = capture(); @@ -3857,7 +3758,7 @@ describe("CLI", () => { expect(stderr.text()).toContain("unable to open database file"); expect(stderr.text()).not.toContain("model service could not be reached"); expect(stderr.text()).not.toContain("Check your network connection"); - expect(stderr.text()).not.toContain("SYNTHETIC_DATABASE_SECRET"); + expect(stderr.text()).toContain("SYNTHETIC_DATABASE_SECRET"); }); test("prints only the completion summary for default scans", async () => { @@ -4042,7 +3943,7 @@ describe("CLI", () => { } }); - test("emits redacted scan warnings in verbose diagnostics", async () => { + test("preserves scan warnings in verbose diagnostics", async () => { const stdout = capture(); const stderr = capture(); const deps = dependencies(); @@ -4067,12 +3968,12 @@ describe("CLI", () => { ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( - 'codex-security: debug: scan.warning message="Repository HEAD changed during the scan: [redacted]"', + 'codex-security: debug: scan.warning message="Repository HEAD changed during the scan: sk-proj-SYNTHETIC_WARNING_SECRET_123"', ); expect(stderr.text()).toContain( - "codex-security: warning: Repository HEAD changed during the scan: [redacted]", + "codex-security: warning: Repository HEAD changed during the scan: sk-proj-SYNTHETIC_WARNING_SECRET_123", ); - expect(stderr.text()).not.toContain("SYNTHETIC_WARNING_SECRET"); + expect(stderr.text()).toContain("SYNTHETIC_WARNING_SECRET"); }); test("prints granted trusted cyber access without warning or corrupting JSON scans", async () => { @@ -4197,9 +4098,9 @@ describe("CLI", () => { ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( - `codex-security: warning: onWorkerStatus observer failed: status observer failed ${REDACTED_CREDENTIALS}`, + `codex-security: warning: onWorkerStatus observer failed: status observer failed ${SYNTHETIC_CREDENTIALS}`, ); - expect(stderr.text()).not.toContain("SYNTHETIC_OPENAI_VALUE_123"); + expect(stderr.text()).toContain("SYNTHETIC_OPENAI_VALUE_123"); }); test("maps failed scan stdout writes to the runtime-error exit code", async () => { @@ -4503,7 +4404,7 @@ describe("CLI", () => { expect(JSON.parse(stdout.text())).toEqual(result.toJSON()); }); - test("keeps scan progress scope and completion paths redacted", async () => { + test("preserves scan progress scope and completion paths", async () => { const stdout = capture(); const stderr = capture(); const result = fakeResult(); @@ -4525,10 +4426,10 @@ describe("CLI", () => { dependencies({ result }), ), ).toBe(0); - expect(stderr.text()).not.toContain("SYNTHETIC_SCOPE_KEY_123"); - expect(stderr.text()).not.toContain("SYNTHETIC_OUTPUT_KEY_123"); - expect(stderr.text()).toContain("src/[redacted]"); - expect(stderr.text()).toContain("/tmp/scan_[redacted]"); + expect(stderr.text()).toContain("src/sk-proj-SYNTHETIC_SCOPE_KEY_123"); + expect(stderr.text()).toContain( + "/tmp/scan_sk-proj-SYNTHETIC_OUTPUT_KEY_123", + ); }); test("reports parent fallback when delegated workers cannot start", async () => { @@ -4671,7 +4572,7 @@ describe("CLI", () => { expect(stderr.text()).not.toContain("Running scan"); }); - test("keeps redacted archive notices on stderr for JSON scans", async () => { + test("keeps original archive notices on stderr for JSON scans", async () => { const stdout = capture(); const stderr = capture(); expect( @@ -4704,9 +4605,9 @@ describe("CLI", () => { expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); expect(stderr.text()).toContain( "[00:00] Preparing scan\n" + - "Moved existing results to: /tmp/[redacted]/results.previous-20260721T031422-1234abcd\n", + "Moved existing results to: /tmp/sk-proj-SYNTHETIC_ARCHIVE_KEY_123/results.previous-20260721T031422-1234abcd\n", ); - expect(stderr.text()).not.toContain("SYNTHETIC_ARCHIVE_KEY_123"); + expect(stderr.text()).toContain("SYNTHETIC_ARCHIVE_KEY_123"); }); test("reports findings by severity and applies the requested policy", async () => { @@ -4975,7 +4876,7 @@ describe("CLI", () => { expect(stderr.text()).not.toContain("codex-security:"); }); - test("redacts credentials embedded in protected-root diagnostics", async () => { + test("preserves complete protected-root diagnostics", async () => { const stdout = capture(); const stderr = capture(); const protectedRoot = @@ -4999,17 +4900,11 @@ describe("CLI", () => { ), ).toBe(2); expect(stdout.text()).toBe(""); - expect(stderr.text()).toContain( - "Resolved path: /private/tmp/worktree_[redacted]/results_[redacted]", - ); - expect(stderr.text()).toContain( - "Protected root: /private/tmp/worktree_[redacted]", - ); - expect(stderr.text()).not.toContain("SYNTHETIC_ROOT_KEY"); - expect(stderr.text()).not.toContain("SYNTHETIC_OUTPUT_KEY"); + expect(stderr.text()).toContain(`Resolved path: ${output}`); + expect(stderr.text()).toContain(`Protected root: ${protectedRoot}`); }); - test("redacts credentials from caught scan and interruption failures", async () => { + test("preserves caught scan and interruption failures", async () => { for (const failure of [ new CodexSecurityError(`scan failed ${SYNTHETIC_CREDENTIALS}`), new ScanInterruptedError( @@ -5033,12 +4928,12 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toBe( - "[00:00] Preparing scan\n" + `scan failed ${REDACTED_CREDENTIALS}\n`, + "[00:00] Preparing scan\n" + `scan failed ${SYNTHETIC_CREDENTIALS}\n`, ); } }); - test("redacts embedded credentials from retained partial-output paths", async () => { + test("preserves retained partial-output paths", async () => { const path = "/private/tmp/scan_sk-proj-SYNTHETIC_PATH_KEY_123/results"; for (const [signal, expectedExit] of [ [null, 2], @@ -5065,10 +4960,7 @@ describe("CLI", () => { await main(["scan", "."], stdout.stream, stderr.stream, deps), ).toBe(expectedExit); expect(stdout.text()).toBe(""); - expect(stderr.text()).toContain( - "Partial output was kept at /private/tmp/scan_[redacted]/results.", - ); - expect(stderr.text()).not.toContain("SYNTHETIC_PATH_KEY"); + expect(stderr.text()).toContain(`Partial output was kept at ${path}.`); } }, 30_000); diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index 4a2239e9..120397c8 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -1,324 +1,15 @@ import { describe, expect, test } from "bun:test"; -import { redactedErrorMessage } from "../src/errors.js"; +import { errorMessage } from "../src/errors.js"; -describe("credential redaction", () => { - test("redacts standalone private keys without hiding surrounding diagnostics", () => { - const message = [ - "connection failed:", - "-----BEGIN PRIVATE KEY-----", - "synthetic-key-material", - "-----END PRIVATE KEY-----", - "retrying", - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe( - "connection failed:\n[redacted]\nretrying", - ); - }); - - test("redacts truncated standalone private keys", () => { - expect( - redactedErrorMessage( - "upstream failure: -----BEGIN RSA PRIVATE KEY-----\nsynthetic-key-material", - ), - ).toBe("upstream failure: [redacted]"); - }); - - test("does not end a private key at a different key-type delimiter", () => { - for (const assignment of ["", "private_key="]) { - for (const terminator of [ - "-----END EC PRIVATE KEY-----", - "-----END rsa private key-----", - ]) { - const message = [ - `${assignment}-----BEGIN RSA PRIVATE KEY-----`, - "synthetic-before", - terminator, - "synthetic-after", - "-----END RSA PRIVATE KEY-----", - "retrying", - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe( - `${assignment}[redacted]\nretrying`, - ); - } - } - }); - - test("redacts overlapping private-key blocks through their own delimiters", () => { - const message = [ - "-----BEGIN RSA PRIVATE KEY-----", - "-----BEGIN EC PRIVATE KEY-----", - "-----END RSA PRIVATE KEY-----", - "SYNTHETIC_EC_KEY_MATERIAL", - "-----END EC PRIVATE KEY-----", - "retrying", - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); - }); - - test("pairs nested private keys with separate matching delimiters", () => { - const message = [ - "-----BEGIN RSA PRIVATE KEY-----", - "-----BEGIN RSA PRIVATE KEY-----", - "-----END RSA PRIVATE KEY-----", - "SYNTHETIC_OUTER_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - "retrying", - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); - }); - - test("does not end a private key at a delimiter embedded in another line", () => { - const message = [ - "-----BEGIN RSA PRIVATE KEY-----", - "prefix-----END RSA PRIVATE KEY-----suffix", - "SYNTHETIC_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - "retrying", - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); - }); - - test("does not end a private key before text on the delimiter line", () => { - const message = [ - "-----BEGIN RSA PRIVATE KEY-----", - "-----END RSA PRIVATE KEY----- NOT_A_BOUNDARY", - "SYNTHETIC_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - "retrying", - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); - }); - - test("does not end a private key at an unrelated quote", () => { - for (const suffix of ['"NOT_A_BOUNDARY', '",NOT_A_BOUNDARY']) { - const message = [ - "-----BEGIN RSA PRIVATE KEY-----", - `-----END RSA PRIVATE KEY-----${suffix}`, - "SYNTHETIC_SECOND_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - "retrying", - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); - } - }); - - test("preserves text containing an invalid opening delimiter", () => { - for (const suffix of ["NOT_A_PEM; retry=visible", "\nretry=visible"]) { - for (const padding of ["", " "]) { - const message = `parser rejected abc${padding}-----BEGIN RSA PRIVATE KEY-----${suffix}`; - expect(redactedErrorMessage(message)).toBe(message); - } - } - const unrelated = - "status=failed; parser saw -----BEGIN RSA PRIVATE KEY-----\nretry=visible"; - expect(redactedErrorMessage(unrelated)).toBe(unrelated); - const serialized = JSON.stringify({ - message: "prefix\\n-----BEGIN RSA PRIVATE KEY-----\nretry=visible", - safe: "kept", - }); - expect(redactedErrorMessage(serialized)).toBe(serialized); - }); - - test("does not confuse literal escapes with serialized line boundaries", () => { - const message = [ - "-----BEGIN RSA PRIVATE KEY-----", - "prefix\\n-----END RSA PRIVATE KEY-----", - "SYNTHETIC_SECOND_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - "retrying", - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe("[redacted]\nretrying"); - }); - - test("preserves diagnostics after repeatedly serialized private keys", () => { - for (const indentation of ["", "\t"]) { - let message: string | { pem: string; safe: string } = { - pem: [ - "-----BEGIN RSA PRIVATE KEY-----", - "SYNTHETIC_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - ] - .map((line) => `${indentation}${line}`) - .join("\n"), - safe: "visible", - }; - - for (let depth = 1; depth <= 4; depth += 1) { - message = JSON.stringify(message); - let redacted: unknown = redactedErrorMessage(message); - for (let layer = 0; layer < depth; layer += 1) { - redacted = JSON.parse(redacted as string); - } - expect(redacted).toEqual({ - pem: `${indentation}[redacted]`, - safe: "visible", - }); - } - } - }); - - test("preserves fields after pretty-printed private-key values", () => { - for (const indentation of [2, "\t"]) { - let message = JSON.stringify( - { - nested: { - pem: [ - "-----BEGIN RSA PRIVATE KEY-----", - "SYNTHETIC_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - ].join("\n"), - safe: "visible", - }, - tail: "kept", - }, - null, - indentation, - ); - if (indentation === "\t") { - message = message.replaceAll('",', '" \t,'); - } - - for (let depth = 1; depth <= 2; depth += 1) { - if (depth > 1) message = JSON.stringify(message); - let redacted: unknown = redactedErrorMessage(message); - for (let layer = 0; layer < depth; layer += 1) { - redacted = JSON.parse(redacted as string); - } - expect(redacted).toEqual({ - nested: { pem: "[redacted]", safe: "visible" }, - tail: "kept", - }); - } - } - }); - - test("preserves non-string array values after private keys", () => { - const pem = [ - "-----BEGIN RSA PRIVATE KEY-----", - "SYNTHETIC_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - ].join("\n"); - for (const value of [123, true, null, {}, []]) { - for (const indentation of [2, "\t"]) { - let message = JSON.stringify([pem, value, "tail"], null, indentation); - for (let depth = 1; depth <= 2; depth += 1) { - if (depth > 1) message = JSON.stringify(message); - let redacted: unknown = redactedErrorMessage(message); - for (let layer = 0; layer < depth; layer += 1) { - redacted = JSON.parse(redacted as string); - } - expect(redacted).toEqual(["[redacted]", value, "tail"]); - } - } - } - }); - - test("checks serialized newline depth after private-key delimiters", () => { - const message = JSON.stringify({ - pem: [ - "-----BEGIN RSA PRIVATE KEY-----", - "-----END RSA PRIVATE KEY-----\\nSYNTHETIC_SECOND_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - ].join("\n"), - safe: "visible", - }); - - expect(JSON.parse(redactedErrorMessage(message))).toEqual({ - pem: "[redacted]", - safe: "visible", - }); - }); - - test("redacts unquoted credentials next to a private-key placeholder", () => { - for (const field of [ - "password", - "authorization", - "auth", - "client_authorization_value", - ]) { - const message = [ - `${field}=SYNTHETIC_VICTIM_SECRET -----BEGIN RSA PRIVATE KEY-----`, - "SYNTHETIC_KEY_MATERIAL", - "-----END RSA PRIVATE KEY-----", - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe( - `${field}=[redacted] [redacted]`, - ); - } - }); - - test("preserves diagnostics after indented private-key blocks", () => { - const message = [ - " -----BEGIN RSA PRIVATE KEY-----", - " SYNTHETIC_KEY_MATERIAL", - " -----END RSA PRIVATE KEY-----", - "retry=visible", - ].join("\n"); - - expect(redactedErrorMessage(message)).toBe(" [redacted]\nretry=visible"); - }); - - test("redacts lowercase private-key assignments", () => { - for (const whitespace of ["", "\u00a0"]) { - expect( - redactedErrorMessage( - `private_key=${whitespace}-----begin rsa private key-----\nSYNTHETIC_KEY\n-----end rsa private key----- safe=value`, - ), - ).toBe(`private_key=${whitespace}[redacted] safe=value`); - } - }); - - test("preserves already-redacted authorization schemes", () => { - for (const scheme of ["Negotiate", "AWS4-HMAC-SHA256", "DPoP"]) { - for (const separator of ["%20", "+", " "]) { - const message = `authorization=${scheme}${separator}[redacted]`; - expect(redactedErrorMessage(message)).toBe(message); - } - } - }); - - test("collapses repeated truncated private-key markers", () => { - expect( - redactedErrorMessage("-----BEGIN PRIVATE KEY-----\n".repeat(1_000)), - ).toBe("[redacted]"); +describe("error messages", () => { + test("preserves error messages exactly", () => { + const message = "request failed: token=SYNTHETIC_TOKEN"; + expect(errorMessage(new Error(message))).toBe(message); + expect(errorMessage(message)).toBe(message); }); - test("redacts quoted credentials and private keys that overlap in either direction", () => { - for (const [lines, credential] of [ - [ - [ - "-----BEGIN PRIVATE KEY-----", - 'password="', - "-----END PRIVATE KEY-----", - 'SYNTHETIC_PASSWORD_123"', - ], - "SYNTHETIC_PASSWORD_123", - ], - [ - [ - 'password="prefix', - "-----BEGIN PRIVATE KEY-----", - 'synthetic-before"', - "SYNTHETIC_KEY_MATERIAL_123", - "-----END PRIVATE KEY-----", - ], - "SYNTHETIC_KEY_MATERIAL_123", - ], - ] as const) { - const redacted = redactedErrorMessage(lines.join("\n")); - expect(redacted).toContain("[redacted]"); - expect(redacted).not.toContain(credential); - expect(redacted).not.toContain("PRIVATE KEY"); - } + test("formats non-error values without parsing them", () => { + expect(errorMessage(42)).toBe("42"); + expect(errorMessage(null)).toBe("null"); }); }); diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index e0594f13..ae879b7e 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -1320,22 +1320,22 @@ describe("multiscan", () => { { id: "retry", status: "completed", attempt: 2 }, ]); const ledger = await readFile(summary.resultsPath, "utf8"); - expect(ledger).not.toContain(secret); - expect(ledger).not.toContain("SYNTHETIC_MULTISCAN_PASSWORD"); - expect(ledger).not.toContain("SYNTHETIC_MULTISCAN_QUERY_123"); - expect(ledger).not.toContain(suffixedSecret); - expect(ledger).not.toContain(suffixedToken); - expect(ledger).not.toContain(suffixedQuery); - expect(ledger).not.toContain(quotedSecret); - expect(ledger).not.toContain(opaqueAuthorization); - expect(ledger).not.toContain(npmAuthorization); - expect(ledger).not.toContain(customAuthorization); - expect(ledger).not.toContain(suffixedAuthorization); - expect(ledger).not.toContain(paddedAuthorization); - expect(ledger).not.toContain(keyedAuthorization); - expect(ledger).not.toContain(camelCaseSecret); - expect(ledger).not.toContain(shortAuthorization); - expect(ledger).toContain("https://[redacted]@proxy.test/v1/responses"); + expect(ledger).toContain(secret); + expect(ledger).toContain("SYNTHETIC_MULTISCAN_PASSWORD"); + expect(ledger).toContain("SYNTHETIC_MULTISCAN_QUERY_123"); + expect(ledger).toContain(suffixedSecret); + expect(ledger).toContain(suffixedToken); + expect(ledger).toContain(suffixedQuery); + expect(ledger).toContain(quotedSecret); + expect(ledger).toContain(opaqueAuthorization); + expect(ledger).toContain(npmAuthorization); + expect(ledger).toContain(customAuthorization); + expect(ledger).toContain(suffixedAuthorization); + expect(ledger).toContain(paddedAuthorization); + expect(ledger).toContain(keyedAuthorization); + expect(ledger).toContain(camelCaseSecret); + expect(ledger).toContain(shortAuthorization); + expect(ledger).toContain(proxyUrl); }); test("resumes complete bundles, repairs missing output, and rejects manifest drift", async () => { diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 22c3060f..7f9e28aa 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -2718,7 +2718,7 @@ describe("runtime directories and plugin Python boundary", () => { ).toBe(false); }); - test("surfaces redacted Windows ACL subprocess failures", async () => { + test("preserves Windows ACL subprocess failures", async () => { const root = await temporaryDirectory(); const home = join(root, "home"); await mkdir(home); @@ -2740,9 +2740,8 @@ describe("runtime directories and plugin Python boundary", () => { } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain("core types"); - expect((error as Error).message).toContain("token=[redacted]"); - expect((error as Error).message).not.toContain( - "SYNTHETIC_WINDOWS_ACL_SECRET", + expect((error as Error).message).toContain( + "token=sk-proj-SYNTHETIC_WINDOWS_ACL_SECRET_123", ); expect((error as Error).cause).toBe(underlying); }