diff --git a/lib/redact.js b/lib/redact.js index b5eed50..e3e6b6f 100644 --- a/lib/redact.js +++ b/lib/redact.js @@ -1,6 +1,12 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap +const JSON_SECRET_PATTERNS = [ + /("(?:api[_-]?key|secret|token|passw(?:or)?d|pwd|access[_-]?key|private[_-]?key|client[_-]?secret)"\s*:\s*")(?:\\[^\r\n]|[^"\\\r\n])+(")/gi, + // Match camelCase suffixes without treating words like "monkey" as credentials. + /("[A-Za-z][A-Za-z0-9_-]*(?:Key|Token|Secret|Password|Passwd|Pwd|Credentials?)"\s*:\s*")(?:\\[^\r\n]|[^"\\\r\n])+(")/g, +]; + const SECRET_PATTERNS = [ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, /\b(?:sk|pk|rk)-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g, @@ -20,7 +26,8 @@ export const REDACTED = '[redacted]'; export function withoutSecrets(text) { if (typeof text !== 'string') return ''; - return SECRET_PATTERNS.reduce((cleaned, pattern) => cleaned.replace(pattern, REDACTED), text); + const redactedJson = JSON_SECRET_PATTERNS.reduce((cleaned, pattern) => cleaned.replace(pattern, '$1' + REDACTED + '$2'), text); + return SECRET_PATTERNS.reduce((cleaned, pattern) => cleaned.replace(pattern, REDACTED), redactedJson); } export function containsSecret(text) { diff --git a/test/recall.test.js b/test/recall.test.js index b2caf77..b47e073 100644 --- a/test/recall.test.js +++ b/test/recall.test.js @@ -47,6 +47,19 @@ test('the task context leaves for the Proxy whole when it fits 4096 bytes', asyn assert.equal(searches[0].context.task, task); }); +test('quoted JSON credentials are redacted from the recall query and task context', async () => { + const { proxyFetch, calls, searches } = stubProxy({ assets: [] }); + const prompt = 'Retry the uploader with this config: {"token":"fixtureCredential123456","accessToken":"fixtureAccess123456","mode":"retry"}'; + await recallStrategy(proxyFetch, promptTextOf(prompt), { context: { task: taskTextOf(prompt), trigger: 'turn_start' } }); + assert.equal(calls.length, 1); + assert.equal(searches.length, 1); + for (const body of [calls[0].body, searches[0]]) { + const sent = JSON.stringify(body); + assert.doesNotMatch(sent, /fixture(?:Credential|Access)123456/); + assert.match(sent, /retry/i); + } +}); + test('a task context over the Proxy limit is cut to fit, never dropped', async () => { const { proxyFetch, calls, searches } = stubProxy({ assets: [] }); const task = taskTextOf('上传失败后重试'.repeat(600)); diff --git a/test/redact.test.js b/test/redact.test.js new file mode 100644 index 0000000..2bfe094 --- /dev/null +++ b/test/redact.test.js @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { candidateOf } from '../lib/distill/candidate.js'; +import { distillBody, distillPrompt } from '../lib/distill/prompt.js'; +import { containsSecret, withoutSecrets } from '../lib/redact.js'; + +test('redaction removes the complete JSON string and leaves unrelated fields', () => { + const text = JSON.stringify({ API_KEY: 'fixture "quoted" \\ tail', topic: 'retry' }); + assert.equal(containsSecret(text), true); + assert.deepEqual(JSON.parse(withoutSecrets(text)), { API_KEY: '[redacted]', topic: 'retry' }); + assert.equal(containsSecret(withoutSecrets(text)), false); + assert.equal(withoutSecrets('token=fixtureCredential123456'), '[redacted]'); +}); + +test('camelCase JSON credentials are redacted without removing ordinary keys', () => { + const ordinary = { maxTokens: '4096', monkey: 'example' }; + const text = JSON.stringify({ accessToken: 'fixture access', refreshToken: 'fixture "quoted" \\ tail', dbPassword: 'fixture password', ...ordinary }); + assert.equal(containsSecret(text), true); + const redacted = withoutSecrets(text); + assert.deepEqual(JSON.parse(redacted), { accessToken: '[redacted]', refreshToken: '[redacted]', dbPassword: '[redacted]', ...ordinary }); + assert.equal(containsSecret(redacted), false); + assert.equal(withoutSecrets(redacted), redacted); +}); + +test('the saved candidate, model prompt and Proxy body exclude JSON credentials', () => { + const secret = /fixture(?:Credential|Refresh)123456/; + const detail = JSON.stringify({ password: 'fixtureCredential123456', refreshToken: 'fixtureRefresh123456' }); + const candidate = candidateOf({ + prompt: 'Fix upload retry with ' + detail, + record: { + answer: 'Retry now works with ' + detail, + commands: ['npm test -- ' + detail], + paths: [], + checks: [{ command: 'npm test -- ' + detail, failed: false }], + }, + }); + const body = distillBody({ platform: 'test', sessionId: 'test-session', candidate, args: { summary: 'Repair upload retry' } }); + for (const text of [JSON.stringify(candidate), distillPrompt(candidate, detail), JSON.stringify(body)]) { + assert.doesNotMatch(text, secret); + assert.match(text, /\[redacted\]/); + assert.match(text, /upload retry/); + } +});