Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion lib/redact.js
Original file line number Diff line number Diff line change
@@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

任意 *Key 字段都会被当作秘密,破坏普通任务上下文

🟠 严重程度:中(P2)
camelCase 正则无条件匹配所有以 Key 结尾的 JSON 键,因此 primaryKey、foreignKey、partitionKey、sortKey、cacheKey、objectKey 等普通标识字段都会被替换为 [redacted]。仅验证小写 monkey 并不能覆盖这些常见良性字段。

Finding:finding_f328919c91f59a1f6af8267c

];

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,
Expand All @@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions test/recall.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
44 changes: 44 additions & 0 deletions test/redact.test.js
Original file line number Diff line number Diff line change
@@ -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/);
}
});
Loading