diff --git a/extensions/skillspector.ts b/extensions/skillspector.ts index aef050511..99a7acd40 100644 --- a/extensions/skillspector.ts +++ b/extensions/skillspector.ts @@ -41,8 +41,8 @@ function redactSecrets(value: string): string { return value .replace(/(sk-ant-[A-Za-z0-9_-]{12,})/g, "[REDACTED_ANTHROPIC_KEY]") .replace(/(sk-[A-Za-z0-9_-]{20,})/g, "[REDACTED_OPENAI_KEY]") - .replace(/([A-Za-z0-9_]*API_KEY[=:]\s*)[^\s]+/gi, "$1[REDACTED]") - .replace(/([A-Za-z0-9_]*TOKEN[=:]\s*)[^\s]+/gi, "$1[REDACTED]"); + // Start only at a word boundary; retrying at every character is quadratic. + .replace(/\b([A-Za-z0-9_]*(?:API_KEY|TOKEN)[=:]\s*)[^\s]+/gi, "$1[REDACTED]"); } function truncateText(value: string, maxChars = 12000): { text: string; truncated: boolean } { diff --git a/tests/unit/test_pi_extension.mjs b/tests/unit/test_pi_extension.mjs index 3bee78250..5f349c62e 100644 --- a/tests/unit/test_pi_extension.mjs +++ b/tests/unit/test_pi_extension.mjs @@ -9,6 +9,7 @@ import { copyFileSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSyn import { registerHooks } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; import test from "node:test"; import { pathToFileURL } from "node:url"; @@ -74,6 +75,37 @@ test("uses installed absolute executable and preserves scan arguments without ou assert.equal(ctx.calls[0].options.cwd, ctx.workspace); }); +test("redacts long scanner output without retrying every word character", { timeout: 5000 }, async (t) => { + const ctx = await setup(t, () => ({ + code: 0, + stdout: "A".repeat(100_000), + stderr: "B".repeat(100_000), + })); + const started = performance.now(); + const result = await ctx.scan(); + const elapsed = performance.now() - started; + assert.ok(elapsed < 1000, `redaction took ${elapsed.toFixed(1)} ms`); + assert.equal(result.details.stdoutTruncated, true); + assert.equal(result.details.stderrTruncated, true); + assert.ok(result.content[0].text.length < 19_000); +}); + +test("redacts complete secrets before truncating at display boundaries", async (t) => { + const ctx = await setup(t, () => ({ + code: 0, + stdout: " ".repeat(11_990) + "sk-" + "x".repeat(32), + stderr: "NPM_TOKEN=synthetic-token\nCUSTOM_API_KEY: synthetic-key\napi_key=lower-key", + })); + const result = await ctx.scan(); + const text = result.content[0].text; + for (const secret of ["sk-", "synthetic-token", "synthetic-key", "lower-key"]) { + assert.equal(text.includes(secret), false); + } + assert.match(text, /NPM_TOKEN=\[REDACTED\]/); + assert.match(text, /CUSTOM_API_KEY: \[REDACTED\]/); + assert.match(text, /api_key=\[REDACTED\]/); +}); + test("finds the Windows virtualenv executable without a PATH fallback", async (t) => { const ctx = await setup(t); rmSync(ctx.bin);