Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7fea140
Redact short credential values in OpenCode output
yashrajp22 Oct 5, 2026
8612a2a
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
c983e6d
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
ec50c12
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
95c11bb
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
ce36003
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
e732e3f
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
7c95e7d
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
57faa4e
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
b097b84
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
19c3604
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
4ff755c
fix(opencode): preserve whole credentials and JSON during redaction
yashrajp22 Oct 5, 2026
9604577
test(opencode): exercise standalone short values at truncation bounda…
yashrajp22 Oct 5, 2026
263b76e
fix(opencode): cover overlapping credential redaction spans
yashrajp22 Oct 5, 2026
63a128f
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
eda0f7f
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
03af0fb
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
cfee917
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
81ab513
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
9804a56
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
a39c2b7
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 5, 2026
e0f8aa7
fix(opencode): redact credentials adjacent to configured values
yashrajp22 Oct 6, 2026
06b7d6c
fix(opencode): recognize credential boundaries on original output
yashrajp22 Oct 6, 2026
f1c82de
fix(opencode): scan assignment names once during redaction
yashrajp22 Oct 6, 2026
932db0a
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 6, 2026
0ea622e
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 6, 2026
74c09f0
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 6, 2026
608ca41
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 6, 2026
c745ff4
Merge branch 'main' into yashraj/redact-short-opencode-credentials
github-actions[bot] Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 85 additions & 16 deletions .opencode/tools/skillspector_scan_lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Comment thread
yashrajp22 marked this conversation as resolved.
),
)].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 ? `(?<![A-Za-z0-9_-])${literal}(?![A-Za-z0-9_-])` : literal
})
// Look ahead so overlapping configured values are all covered. Matching
// still advances through the original text, never through replacement markers.
const credentialPattern = valuePatterns.length
? new RegExp(`(?=(${valuePatterns.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]",
)
// 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_-]+",
`${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 {
Expand Down
138 changes: 138 additions & 0 deletions tests/opencode/skillspector_scan_lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
Loading