diff --git a/.opencode/tools/skillspector_scan_lib.ts b/.opencode/tools/skillspector_scan_lib.ts index 1da42fd98..ed58b40cd 100644 --- a/.opencode/tools/skillspector_scan_lib.ts +++ b/.opencode/tools/skillspector_scan_lib.ts @@ -47,25 +47,94 @@ 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 - ? new RegExp( - credentialValues - .map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - .join("|"), - "g", - ) + 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 ? `(? credentialValues[index].length >= 4) + const keyBoundary = longValues.length ? `(?:\\b|(?<=${longValues.join("|")}))` : "\\b" + const keyPattern = new RegExp([ + "sk-ant-[A-Za-z0-9_-]+", + `${keyBoundary}sk-[A-Za-z0-9_-]{6,}\\b`, + // 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)) { + 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) + 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) + // 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 7b759cb2b..897449fbe 100644 --- a/tests/opencode/skillspector_scan_lib.test.ts +++ b/tests/opencode/skillspector_scan_lib.test.ts @@ -89,6 +89,109 @@ 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("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("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 = { + 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("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]") + 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", () => { + 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", @@ -220,6 +323,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 - 2)} ${value} suffix` + const stderr = `${"e".repeat(MAX_STDERR - 2)} ${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", () => { @@ -242,6 +366,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 - 2)} ${value} suffix`, + `${"e".repeat(MAX_STDERR - 2)} ${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", () => {