diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index a8face06..a42b9e9a 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -3,18 +3,137 @@ 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]", - ); - return redactQuotedCredentialValues(withoutPrivateKeys) + 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\]|[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)) { + 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]") @@ -30,11 +149,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 +172,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 new file mode 100644 index 00000000..4a2239e9 --- /dev/null +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -0,0 +1,324 @@ +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]"); + }); + + 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]"); + }); + + 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"); + } + }); +});