From 7fea14089722768e58fde4a5a0a0e8ca6b54f968 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Mon, 5 Oct 2026 11:29:50 +0530 Subject: [PATCH 1/7] Redact short credential values in OpenCode output Signed-off-by: yashrajbasav --- .opencode/tools/skillspector_scan_lib.ts | 2 +- tests/opencode/skillspector_scan_lib.test.ts | 62 ++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/.opencode/tools/skillspector_scan_lib.ts b/.opencode/tools/skillspector_scan_lib.ts index b562042de..6b9c9e279 100644 --- a/.opencode/tools/skillspector_scan_lib.ts +++ b/.opencode/tools/skillspector_scan_lib.ts @@ -44,7 +44,7 @@ export function truncate(text: string, max: number): string { export function redact(text: string, env: Env = process.env): string { const credentialValues = [...new Set( CREDENTIAL_ENV_NAMES.map((name) => env[name]?.trim()).filter( - (value): value is string => Boolean(value && value.length >= 4), + (value): value is string => Boolean(value), ), )].sort((left, right) => right.length - left.length) const credentialPattern = credentialValues.length diff --git a/tests/opencode/skillspector_scan_lib.test.ts b/tests/opencode/skillspector_scan_lib.test.ts index 84a58f7a4..9f96f4616 100644 --- a/tests/opencode/skillspector_scan_lib.test.ts +++ b/tests/opencode/skillspector_scan_lib.test.ts @@ -88,6 +88,33 @@ describe("redact", () => { assert.equal(out.match(/\[REDACTED\]/g)?.length, CREDENTIAL_ENV_NAMES.length) }) + it("redacts one-, two-, and three-character values for every supported credential name", () => { + for (const name of CREDENTIAL_ENV_NAMES) { + for (const value of ["7", "7x", "7xy"]) { + assert.equal(redact(`before ${value} after`, { [name]: value }), "before [REDACTED] after") + } + } + }) + + it("ignores empty and unset credentials without matching every output position", () => { + assert.equal(redact("ordinary output", { + OPENAI_API_KEY: "", + ANTHROPIC_API_KEY: undefined, + NVIDIA_INFERENCE_KEY: " \t\n", + }), "ordinary output") + }) + + it("redacts trimmed, overlapping, and regex-bearing short values in one pass", () => { + assert.equal(redact("xyz xy x .* [ R", { + OPENAI_API_KEY: " xyz ", + ANTHROPIC_API_KEY: "xy", + NVIDIA_INFERENCE_KEY: "x", + AWS_SECRET_ACCESS_KEY: ".*", + AWS_SESSION_TOKEN: "[", + AWS_SECURITY_TOKEN: "R", + }), "[REDACTED] [REDACTED] [REDACTED] [REDACTED] [REDACTED] [REDACTED]") + }) + it("redacts NVIDIA and AWS assignments even when the environment is unavailable", () => { const out = redact( "NVIDIA_INFERENCE_KEY=nvapi-secret AWS_SECRET_ACCESS_KEY: aws-secret", @@ -219,6 +246,27 @@ describe("formatExecError", () => { }) assert.ok(!out.includes("hunter2")) }) + + it("redacts short credentials from every failure output path before truncation", () => { + for (const value of ["7", "7x", "7xy"]) { + const env = { OPENAI_API_KEY: value } + const stdout = `${"o".repeat(MAX_STDOUT - 1)}${value} suffix` + const stderr = `${"e".repeat(MAX_STDERR - 1)}${value} suffix` + for (const failure of [ + { code: 1, stdout, stderr }, + { code: 2, stdout, stderr }, + { killed: true, stdout, stderr }, + { name: "AbortError", stdout, stderr }, + { code: 9, stdout, stderr }, + { code: 9, message: stderr }, + stderr, + { code: 2, stderr: `Usage: skillspector\nError: rejected ${value}` }, + ]) { + const out = formatExecError("bin", failure, env) + assert.ok(!out.includes("7"), `${value} leaked from ${JSON.stringify(failure).slice(0, 40)}`) + } + } + }) }) describe("formatSuccess", () => { @@ -241,6 +289,20 @@ describe("formatSuccess", () => { assert.ok(out.includes("stderr:\nbaseline detected")) assert.ok(out.includes("[truncated ")) }) + + it("redacts short credentials in both successful streams before truncation", () => { + for (const value of ["7", "7x", "7xy"]) { + const out = formatSuccess( + undefined, + `${"o".repeat(MAX_STDOUT - 1)}${value} suffix`, + `${"e".repeat(MAX_STDERR - 1)}${value} suffix`, + { OPENAI_API_KEY: value }, + ) + assert.ok(!out.includes("7")) + assert.ok(out.includes("stderr:\n")) + assert.ok(out.includes("[truncated ")) + } + }) }) describe("permissioned execution", () => { From 4ff755c58388b317ef6b62ebadc66806b37834e4 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Mon, 5 Oct 2026 21:22:02 +0530 Subject: [PATCH 2/7] fix(opencode): preserve whole credentials and JSON during redaction Signed-off-by: yashrajbasav --- .opencode/tools/skillspector_scan_lib.ts | 43 ++++++++++++-------- tests/opencode/skillspector_scan_lib.test.ts | 26 ++++++++++++ 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/.opencode/tools/skillspector_scan_lib.ts b/.opencode/tools/skillspector_scan_lib.ts index 6b9c9e279..a5d725d89 100644 --- a/.opencode/tools/skillspector_scan_lib.ts +++ b/.opencode/tools/skillspector_scan_lib.ts @@ -47,22 +47,33 @@ export function redact(text: string, env: Env = process.env): string { (value): value is string => Boolean(value), ), )].sort((left, right) => right.length - left.length) - const credentialPattern = credentialValues.length - ? new RegExp( - credentialValues - .map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - .join("|"), - "g", - ) - : undefined - const redacted = credentialPattern ? text.replace(credentialPattern, "[REDACTED]") : text - return redacted - .replace(/sk-ant-[A-Za-z0-9_-]+/g, "[REDACTED]") - .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[REDACTED]") - .replace( - /\b([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|ACCESS_KEY_ID|SECRET_ACCESS_KEY|INFERENCE_KEY))(\s*[:=]\s*["']?)[^"'\s,}]+/g, - "$1$2[REDACTED]", - ) + const valuePatterns = credentialValues.map((value) => { + const literal = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + // Short values are usually local-provider placeholders. Treat them as + // tokens so they cannot change words, JSON fields, or longer credentials. + return value.length < 4 ? `(? value.replace( + credentialPattern, + (_match, assignmentPrefix: string | undefined) => `${assignmentPrefix ?? ""}[REDACTED]`, + ) + try { + const report = JSON.parse(text) + if (report === null || typeof report !== "object") return scrub(text) + // Preserve JSON numbers and booleans, including a score equal to a short + // placeholder. Credentials inside JSON strings still receive full scrubbing. + return text.replace(/"(?:[^"\\]|\\.)*"/g, (token) => JSON.stringify(scrub(JSON.parse(token)))) + } catch { + return scrub(text) + } } export function isScpGitTarget(target: string): boolean { diff --git a/tests/opencode/skillspector_scan_lib.test.ts b/tests/opencode/skillspector_scan_lib.test.ts index 9f96f4616..568335b05 100644 --- a/tests/opencode/skillspector_scan_lib.test.ts +++ b/tests/opencode/skillspector_scan_lib.test.ts @@ -96,6 +96,32 @@ describe("redact", () => { } }) + it("redacts whole key-shaped tokens before short or overlapping configured values", () => { + for (const value of ["x", "Q7wE"]) { + assert.equal(redact( + "sk-proj-Q7wErTyxUiOp9AsDfGhJkL sk-ant-Q7wErTyxUiOp9AsDfGhJkL OPENAI_API_KEY=Q7wErTyxUiOp", + { OPENAI_API_KEY: value }, + ), "[REDACTED] [REDACTED] OPENAI_API_KEY=[REDACTED]") + } + }) + + it("keeps JSON metadata readable and parseable with one-character placeholders", () => { + for (const value of ["1", "e", "x"]) { + const report = { + risk_score: 12, + start_line: 1, + safe_to_install: true, + severity: "medium", + code_snippet: `credential ${value} and sk-proj-Q7wErTyxUiOp9AsDfGhJkL`, + } + assert.deepEqual(JSON.parse(redact(JSON.stringify(report), { OPENAI_API_KEY: value })), { + ...report, + code_snippet: "credential [REDACTED] and [REDACTED]", + }) + } + assert.equal(redact("example xyz x", { OPENAI_API_KEY: "x" }), "example xyz [REDACTED]") + }) + it("ignores empty and unset credentials without matching every output position", () => { assert.equal(redact("ordinary output", { OPENAI_API_KEY: "", From 9604577a1b90a025d8d31b7c8d44895fa04297ef Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Mon, 5 Oct 2026 21:22:41 +0530 Subject: [PATCH 3/7] test(opencode): exercise standalone short values at truncation boundaries Signed-off-by: yashrajbasav --- tests/opencode/skillspector_scan_lib.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/opencode/skillspector_scan_lib.test.ts b/tests/opencode/skillspector_scan_lib.test.ts index 568335b05..03598383a 100644 --- a/tests/opencode/skillspector_scan_lib.test.ts +++ b/tests/opencode/skillspector_scan_lib.test.ts @@ -276,8 +276,8 @@ describe("formatExecError", () => { it("redacts short credentials from every failure output path before truncation", () => { for (const value of ["7", "7x", "7xy"]) { const env = { OPENAI_API_KEY: value } - const stdout = `${"o".repeat(MAX_STDOUT - 1)}${value} suffix` - const stderr = `${"e".repeat(MAX_STDERR - 1)}${value} suffix` + const stdout = `${"o".repeat(MAX_STDOUT - 2)} ${value} suffix` + const stderr = `${"e".repeat(MAX_STDERR - 2)} ${value} suffix` for (const failure of [ { code: 1, stdout, stderr }, { code: 2, stdout, stderr }, @@ -320,8 +320,8 @@ describe("formatSuccess", () => { for (const value of ["7", "7x", "7xy"]) { const out = formatSuccess( undefined, - `${"o".repeat(MAX_STDOUT - 1)}${value} suffix`, - `${"e".repeat(MAX_STDERR - 1)}${value} suffix`, + `${"o".repeat(MAX_STDOUT - 2)} ${value} suffix`, + `${"e".repeat(MAX_STDERR - 2)} ${value} suffix`, { OPENAI_API_KEY: value }, ) assert.ok(!out.includes("7")) From 263b76e703ab6381225fe05ddb66f84c550adf42 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Mon, 5 Oct 2026 21:37:21 +0530 Subject: [PATCH 4/7] fix(opencode): cover overlapping credential redaction spans Signed-off-by: yashrajbasav --- .opencode/tools/skillspector_scan_lib.ts | 58 +++++++++++++++++--- tests/opencode/skillspector_scan_lib.test.ts | 16 ++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/.opencode/tools/skillspector_scan_lib.ts b/.opencode/tools/skillspector_scan_lib.ts index a5d725d89..f720eeb3a 100644 --- a/.opencode/tools/skillspector_scan_lib.ts +++ b/.opencode/tools/skillspector_scan_lib.ts @@ -53,18 +53,60 @@ export function redact(text: string, env: Env = process.env): string { // tokens so they cannot change words, JSON fields, or longer credentials. return value.length < 4 ? `(? value.replace( - credentialPattern, - (_match, assignmentPrefix: string | undefined) => `${assignmentPrefix ?? ""}[REDACTED]`, - ) + const scrub = (value: string): string => { + function* spans(pattern: RegExp | undefined, literal: boolean): Generator<[number, number]> { + if (!pattern) return + for (const match of value.matchAll(pattern)) { + const start = match.index + (literal ? 0 : (match[1]?.length ?? 0)) + const end = match.index + (literal ? match[1].length : match[0].length) + yield [start, end] + } + } + const known = spans(credentialPattern, true) + const shaped = spans(keyPattern, false) + let nextKnown = known.next().value + let nextShaped = shaped.next().value + let cursor = 0 + let start = -1 + let end = -1 + const parts: string[] = [] + // Merge two ordered span streams in linear time. A shorter key/assignment + // match cannot expose the tail of a longer configured credential. + while (nextKnown || nextShaped) { + let span: [number, number] + if (nextKnown && (!nextShaped || nextKnown[0] <= nextShaped[0])) { + span = nextKnown + nextKnown = known.next().value + } else { + span = nextShaped! + nextShaped = shaped.next().value + } + if (span[0] > end) { + if (start >= 0) { + parts.push(value.slice(cursor, start), "[REDACTED]") + cursor = end + } + start = span[0] + } + end = Math.max(end, span[1]) + } + if (start >= 0) { + parts.push(value.slice(cursor, start), "[REDACTED]") + cursor = end + } + parts.push(value.slice(cursor)) + return parts.join("") + } try { const report = JSON.parse(text) if (report === null || typeof report !== "object") return scrub(text) diff --git a/tests/opencode/skillspector_scan_lib.test.ts b/tests/opencode/skillspector_scan_lib.test.ts index 03598383a..1ebbf0e7a 100644 --- a/tests/opencode/skillspector_scan_lib.test.ts +++ b/tests/opencode/skillspector_scan_lib.test.ts @@ -105,6 +105,22 @@ describe("redact", () => { } }) + it("merges overlapping known values with shorter key and assignment matches", () => { + for (const [secret, input, expected] of [ + ["alpha,beta", "OPENAI_API_KEY=alpha,beta", "OPENAI_API_KEY=[REDACTED]"], + ["alpha,beta", "OPENAI_API_KEY=prefix-alpha,beta", "OPENAI_API_KEY=[REDACTED]"], + ["sk-ant-alpha.beta", "sk-ant-alpha.beta", "[REDACTED]"], + ]) { + const env = { OPENAI_API_KEY: secret } + assert.equal(redact(input, env), expected) + assert.deepEqual(JSON.parse(redact(JSON.stringify({ code_snippet: input, risk_score: 12 }), env)), { + code_snippet: expected, + risk_score: 12, + }) + } + assert.equal(redact("abcdef", { OPENAI_API_KEY: "abcd", ANTHROPIC_API_KEY: "cdef" }), "[REDACTED]") + }) + it("keeps JSON metadata readable and parseable with one-character placeholders", () => { for (const value of ["1", "e", "x"]) { const report = { From e0f8aa71970caf107d501a4ae64a708429c377cc Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Tue, 6 Oct 2026 12:40:32 +0530 Subject: [PATCH 5/7] fix(opencode): redact credentials adjacent to configured values Signed-off-by: yashrajbasav --- .opencode/tools/skillspector_scan_lib.ts | 44 +++++++++++++------- tests/opencode/skillspector_scan_lib.test.ts | 19 +++++++++ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/.opencode/tools/skillspector_scan_lib.ts b/.opencode/tools/skillspector_scan_lib.ts index c4babcc02..601541235 100644 --- a/.opencode/tools/skillspector_scan_lib.ts +++ b/.opencode/tools/skillspector_scan_lib.ts @@ -67,33 +67,47 @@ export function redact(text: string, env: Env = process.env): string { /\b([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|ACCESS_KEY_ID|SECRET_ACCESS_KEY|INFERENCE_KEY)\s*[:=]\s*["']?)[^"'\s,}]+/.source, ].join("|"), "g") const scrub = (value: string): string => { - function* spans(pattern: RegExp | undefined, literal: boolean): Generator<[number, number]> { + function* spans( + pattern: RegExp | undefined, literal: boolean, source = value, + ): Generator<[number, number]> { if (!pattern) return - for (const match of value.matchAll(pattern)) { + for (const match of source.matchAll(pattern)) { const start = match.index + (literal ? 0 : (match[1]?.length ?? 0)) const end = match.index + (literal ? match[1].length : match[0].length) yield [start, end] } } - const known = spans(credentialPattern, true) - const shaped = spans(keyPattern, false) - let nextKnown = known.next().value - let nextShaped = shaped.next().value + // Mask configured values without changing offsets. This also reveals a + // key boundary when an unrelated credential immediately follows one. + const maskedParts: string[] = [] + let maskedUntil = 0 + for (const [start, end] of spans(credentialPattern, true)) { + if (end <= maskedUntil) continue + if (start > maskedUntil) maskedParts.push(value.slice(maskedUntil, start)) + maskedParts.push(" ".repeat(end - Math.max(start, maskedUntil))) + maskedUntil = end + } + maskedParts.push(value.slice(maskedUntil)) + const streams = [ + spans(credentialPattern, true), + spans(keyPattern, false), + ] + if (maskedUntil) streams.push(spans(keyPattern, false, maskedParts.join(""))) + const pending = streams.map((stream) => stream.next().value) let cursor = 0 let start = -1 let end = -1 const parts: string[] = [] - // Merge two ordered span streams in linear time. A shorter key/assignment + // Merge at most three ordered span streams in linear time. A shorter key/assignment // match cannot expose the tail of a longer configured credential. - while (nextKnown || nextShaped) { - let span: [number, number] - if (nextKnown && (!nextShaped || nextKnown[0] <= nextShaped[0])) { - span = nextKnown - nextKnown = known.next().value - } else { - span = nextShaped! - nextShaped = shaped.next().value + while (true) { + let first = -1 + for (let index = 0; index < pending.length; index++) { + if (pending[index] && (first < 0 || pending[index][0] < pending[first][0])) first = index } + if (first < 0) break + const span = pending[first]! + pending[first] = streams[first].next().value if (span[0] > end) { if (start >= 0) { parts.push(value.slice(cursor, start), "[REDACTED]") diff --git a/tests/opencode/skillspector_scan_lib.test.ts b/tests/opencode/skillspector_scan_lib.test.ts index 02bf953e2..1ee012681 100644 --- a/tests/opencode/skillspector_scan_lib.test.ts +++ b/tests/opencode/skillspector_scan_lib.test.ts @@ -139,6 +139,25 @@ describe("redact", () => { assert.equal(redact("example xyz x", { OPENAI_API_KEY: "x" }), "example xyz [REDACTED]") }) + it("redacts unrelated credentials directly after a configured value", () => { + const env = { NVIDIA_INFERENCE_KEY: "nvapi-ABCD1234efgh" } + for (const [tail, expected] of [ + ["sk-abcdef123456", "[REDACTED]"], + ["sk-ant-abcdef123456", "[REDACTED]"], + ["OPENAI_API_KEY=second-secret", "[REDACTED]OPENAI_API_KEY=[REDACTED]"], + ]) { + const input = env.NVIDIA_INFERENCE_KEY + tail + assert.equal(redact(input, env), expected) + assert.deepEqual(JSON.parse(redact(JSON.stringify({ code_snippet: input, risk_score: 12 }), env)), { + code_snippet: expected, + risk_score: 12, + }) + } + assert.equal(redact("abcdefsk-abcdef123456", { + OPENAI_API_KEY: "abcd", ANTHROPIC_API_KEY: "cdef", + }), "[REDACTED]") + }) + it("ignores empty and unset credentials without matching every output position", () => { assert.equal(redact("ordinary output", { OPENAI_API_KEY: "", From 06b7d6c30c6632b708c905ee7481c68ff3a6a430 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Tue, 6 Oct 2026 12:44:08 +0530 Subject: [PATCH 6/7] fix(opencode): recognize credential boundaries on original output Signed-off-by: yashrajbasav --- .opencode/tools/skillspector_scan_lib.ts | 53 ++++++++++-------------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/.opencode/tools/skillspector_scan_lib.ts b/.opencode/tools/skillspector_scan_lib.ts index 601541235..18821c868 100644 --- a/.opencode/tools/skillspector_scan_lib.ts +++ b/.opencode/tools/skillspector_scan_lib.ts @@ -61,53 +61,44 @@ export function redact(text: string, env: Env = process.env): string { const credentialPattern = valuePatterns.length ? new RegExp(`(?=(${valuePatterns.join("|")}))`, "g") : undefined + // A full configured credential can act as the separator before another key. + // Match on the original text so overlapping values never split that key. + // Short values cannot end here unless their existing token boundary allows it. + const longValues = valuePatterns.filter((_, index) => credentialValues[index].length >= 4) + const keyBoundary = longValues.length ? `(?:\\b|(?<=${longValues.join("|")}))` : "\\b" const keyPattern = new RegExp([ "sk-ant-[A-Za-z0-9_-]+", - "\\bsk-[A-Za-z0-9_-]{6,}\\b", - /\b([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|ACCESS_KEY_ID|SECRET_ACCESS_KEY|INFERENCE_KEY)\s*[:=]\s*["']?)[^"'\s,}]+/.source, + `${keyBoundary}sk-[A-Za-z0-9_-]{6,}\\b`, + keyBoundary + /([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|ACCESS_KEY_ID|SECRET_ACCESS_KEY|INFERENCE_KEY)\s*[:=]\s*["']?)[^"'\s,}]+/.source, ].join("|"), "g") const scrub = (value: string): string => { - function* spans( - pattern: RegExp | undefined, literal: boolean, source = value, - ): Generator<[number, number]> { + function* spans(pattern: RegExp | undefined, literal: boolean): Generator<[number, number]> { if (!pattern) return - for (const match of source.matchAll(pattern)) { + for (const match of value.matchAll(pattern)) { const start = match.index + (literal ? 0 : (match[1]?.length ?? 0)) const end = match.index + (literal ? match[1].length : match[0].length) yield [start, end] } } - // Mask configured values without changing offsets. This also reveals a - // key boundary when an unrelated credential immediately follows one. - const maskedParts: string[] = [] - let maskedUntil = 0 - for (const [start, end] of spans(credentialPattern, true)) { - if (end <= maskedUntil) continue - if (start > maskedUntil) maskedParts.push(value.slice(maskedUntil, start)) - maskedParts.push(" ".repeat(end - Math.max(start, maskedUntil))) - maskedUntil = end - } - maskedParts.push(value.slice(maskedUntil)) - const streams = [ - spans(credentialPattern, true), - spans(keyPattern, false), - ] - if (maskedUntil) streams.push(spans(keyPattern, false, maskedParts.join(""))) - const pending = streams.map((stream) => stream.next().value) + const known = spans(credentialPattern, true) + const shaped = spans(keyPattern, false) + let nextKnown = known.next().value + let nextShaped = shaped.next().value let cursor = 0 let start = -1 let end = -1 const parts: string[] = [] - // Merge at most three ordered span streams in linear time. A shorter key/assignment + // Merge two ordered span streams in linear time. A shorter key/assignment // match cannot expose the tail of a longer configured credential. - while (true) { - let first = -1 - for (let index = 0; index < pending.length; index++) { - if (pending[index] && (first < 0 || pending[index][0] < pending[first][0])) first = index + while (nextKnown || nextShaped) { + let span: [number, number] + if (nextKnown && (!nextShaped || nextKnown[0] <= nextShaped[0])) { + span = nextKnown + nextKnown = known.next().value + } else { + span = nextShaped! + nextShaped = shaped.next().value } - if (first < 0) break - const span = pending[first]! - pending[first] = streams[first].next().value if (span[0] > end) { if (start >= 0) { parts.push(value.slice(cursor, start), "[REDACTED]") From f1c82dec377da6056c9689ceaf5abbe7efa12f69 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Tue, 6 Oct 2026 12:50:41 +0530 Subject: [PATCH 7/7] fix(opencode): scan assignment names once during redaction Signed-off-by: yashrajbasav --- .opencode/tools/skillspector_scan_lib.ts | 19 +++++++++++++++---- tests/opencode/skillspector_scan_lib.test.ts | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.opencode/tools/skillspector_scan_lib.ts b/.opencode/tools/skillspector_scan_lib.ts index 18821c868..ed58b40cd 100644 --- a/.opencode/tools/skillspector_scan_lib.ts +++ b/.opencode/tools/skillspector_scan_lib.ts @@ -69,15 +69,26 @@ export function redact(text: string, env: Env = process.env): string { const keyPattern = new RegExp([ "sk-ant-[A-Za-z0-9_-]+", `${keyBoundary}sk-[A-Za-z0-9_-]{6,}\\b`, - keyBoundary + /([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|ACCESS_KEY_ID|SECRET_ACCESS_KEY|INFERENCE_KEY)\s*[:=]\s*["']?)[^"'\s,}]+/.source, + // Consume each uppercase name once, even when it is not an assignment. + // Otherwise a known value like AAAA permits a costly retry at every A. + `${keyBoundary}([A-Z][A-Z0-9_]*)`, ].join("|"), "g") + const assignmentName = /[A-Z0-9_](?:API_KEY|TOKEN|ACCESS_KEY_ID|SECRET_ACCESS_KEY|INFERENCE_KEY)$/ + const assignmentValue = /(\s*[:=]\s*["']?)[^"'\s,}]+/y const scrub = (value: string): string => { function* spans(pattern: RegExp | undefined, literal: boolean): Generator<[number, number]> { if (!pattern) return for (const match of value.matchAll(pattern)) { - const start = match.index + (literal ? 0 : (match[1]?.length ?? 0)) - const end = match.index + (literal ? match[1].length : match[0].length) - yield [start, end] + if (!literal && match[1]) { + if (!assignmentName.test(match[1])) continue + assignmentValue.lastIndex = match.index + match[0].length + const assigned = assignmentValue.exec(value) + if (assigned) { + yield [assigned.index + assigned[1].length, assigned.index + assigned[0].length] + } + } else { + yield [match.index, match.index + (literal ? match[1].length : match[0].length)] + } } } const known = spans(credentialPattern, true) diff --git a/tests/opencode/skillspector_scan_lib.test.ts b/tests/opencode/skillspector_scan_lib.test.ts index 1ee012681..897449fbe 100644 --- a/tests/opencode/skillspector_scan_lib.test.ts +++ b/tests/opencode/skillspector_scan_lib.test.ts @@ -156,6 +156,21 @@ describe("redact", () => { assert.equal(redact("abcdefsk-abcdef123456", { OPENAI_API_KEY: "abcd", ANTHROPIC_API_KEY: "cdef", }), "[REDACTED]") + for (const prefix of ["AAAA", "lowercase", "0000", "-_._"]) { + assert.equal(redact(`${prefix}OPENAI_API_KEY=second-secret`, { + NVIDIA_INFERENCE_KEY: prefix, + }), "[REDACTED]OPENAI_API_KEY=[REDACTED]") + } + assert.equal(redact("abcdefOPENAI_API_KEY=abcdef", { + OPENAI_API_KEY: "abcd", ANTHROPIC_API_KEY: "cdef", + }), "[REDACTED]OPENAI_API_KEY=[REDACTED]") + }) + + it("bounds assignment scanning with many overlapping credential boundaries", () => { + const input = "A".repeat(128 * 1024) + "!" + const started = performance.now() + assert.equal(redact(input, { OPENAI_API_KEY: "AAAA" }), "[REDACTED]!") + assert.ok(performance.now() - started < 2000, "redaction repeatedly rescanned an uppercase run") }) it("ignores empty and unset credentials without matching every output position", () => {