feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* v0.8.2 P1-5: 敏感值脱敏单源工具测试
|
||||
*
|
||||
* 锁定契约:
|
||||
* - 键名归一化匹配(api_key / authKey / Authorization 等形态均命中)
|
||||
* - 长值保留后 4 位、短值完全掩码
|
||||
* - 嵌套对象/数组递归;非敏感字段原样保留
|
||||
* - 循环引用与深度上限防护(防御恶意构造的工具参数)
|
||||
* - 原对象不被修改
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deepMaskSensitive, maskSensitiveValue } from '../mask';
|
||||
|
||||
describe('maskSensitiveValue', () => {
|
||||
it('长值保留后 4 位', () => {
|
||||
expect(maskSensitiveValue('sk-abcdefgh12345678')).toBe('***5678');
|
||||
});
|
||||
it('短值(≤4)完全掩码', () => {
|
||||
expect(maskSensitiveValue('abc')).toBe('***');
|
||||
expect(maskSensitiveValue('abcd')).toBe('***');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepMaskSensitive', () => {
|
||||
it('顶层与嵌套的敏感键均被掩码', () => {
|
||||
const input = {
|
||||
url: 'https://api.example.com',
|
||||
headers: {
|
||||
Authorization: 'Bearer sk-abcdefgh12345678',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
api_key: 'sk-abcdefgh12345678',
|
||||
nested: { authToken: 'token-1234567890' },
|
||||
};
|
||||
const out = deepMaskSensitive(input) as typeof input;
|
||||
expect(out.url).toBe('https://api.example.com');
|
||||
expect(out['api_key']).toBe('***5678');
|
||||
expect((out.headers as Record<string, string>).Authorization).toBe('***5678');
|
||||
expect((out.headers as Record<string, string>)['content-type']).toBe('application/json');
|
||||
expect((out.nested as { authToken: string }).authToken).toBe('***7890');
|
||||
});
|
||||
|
||||
it('数组内对象同样脱敏', () => {
|
||||
const out = deepMaskSensitive([{ secret: 'supersecret-value-42' }, { ok: 1 }]) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(out[0].secret).toBe('***e-42');
|
||||
expect(out[1].ok).toBe(1);
|
||||
});
|
||||
|
||||
it('原对象不被修改', () => {
|
||||
const input = { api_key: 'sk-abcdefgh12345678' };
|
||||
deepMaskSensitive(input);
|
||||
expect(input.api_key).toBe('sk-abcdefgh12345678');
|
||||
});
|
||||
|
||||
it('循环引用与深度上限不抛错', () => {
|
||||
const a: Record<string, unknown> = { name: 'a' };
|
||||
a.self = a;
|
||||
const out = deepMaskSensitive(a) as Record<string, unknown>;
|
||||
expect(out.name).toBe('a');
|
||||
expect(out.self).toBe('[circular]');
|
||||
|
||||
const deep: Record<string, unknown> = { v: 0 };
|
||||
let cur = deep;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
cur.next = { v: i + 1 };
|
||||
cur = cur.next as Record<string, unknown>;
|
||||
}
|
||||
const deepOut = deepMaskSensitive(deep) as Record<string, unknown>;
|
||||
expect(deepOut.v).toBe(0);
|
||||
expect(JSON.stringify(deepOut)).toContain('depth-limit');
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,26 @@ const zh: Record<string, string> = {
|
||||
'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写',
|
||||
'config.error.workspaceSaveFailed': '工作空间路径保存失败:{{message}}',
|
||||
// ===== 记忆维护(v0.8.1 P1-2) =====
|
||||
'memory.maintain.auditTitle': 'MEMORY.md 记忆整理',
|
||||
// ===== v0.8.2 P2-5 i18n 收口第三期:托盘 / 系统对话框 / 更新 / 工作空间校验 =====
|
||||
'tray.menu.showWindow': '显示窗口',
|
||||
'tray.menu.newSession': '新建会话',
|
||||
'tray.menu.quit': '退出',
|
||||
'tray.menu.status': '状态: {{status}}',
|
||||
'tray.status.idle': '空闲',
|
||||
'tray.status.thinking': '思考中',
|
||||
'tray.status.executing': '执行中',
|
||||
'tray.status.error': '异常',
|
||||
'app.update.disabled': '自动更新未启用(未配置更新源或开发模式)',
|
||||
'app.dialog.selectWorkspace.title': '选择工作空间目录',
|
||||
'app.dialog.startupFailed.title': 'MetonaAI Desktop 启动失败',
|
||||
'app.dialog.startupFailed.body':
|
||||
'初始化过程中发生错误,应用即将退出。\n\n{{message}}\n\n可能原因:工作空间目录不可写、数据库文件损坏。\n可尝试在设置中切换工作空间路径后重新启动。',
|
||||
'workspace.reason.empty': '路径不能为空',
|
||||
'workspace.reason.notExists': '目录不存在,将在切换后自动创建',
|
||||
'workspace.reason.notDirectory': '路径不是目录',
|
||||
'workspace.reason.inaccessible': '无法访问路径',
|
||||
'workspace.reason.inheritFailed': '继承文件失败:{{message}}',
|
||||
'workspace.reason.restartRequired': '工作空间已切换,重启应用后生效',
|
||||
};
|
||||
|
||||
const en: Record<string, string> = {
|
||||
@@ -112,7 +131,26 @@ const en: Record<string, string> = {
|
||||
'config.error.configIncomplete':
|
||||
'LLM config incomplete. Check that Provider, API Key, Base URL and Model are all filled in.',
|
||||
'config.error.workspaceSaveFailed': 'Failed to save workspace path: {{message}}',
|
||||
'memory.maintain.auditTitle': 'MEMORY.md maintenance',
|
||||
// ===== v0.8.2 P2-5: tray / system dialogs / update / workspace validation =====
|
||||
'tray.menu.showWindow': 'Show Window',
|
||||
'tray.menu.newSession': 'New Session',
|
||||
'tray.menu.quit': 'Quit',
|
||||
'tray.menu.status': 'Status: {{status}}',
|
||||
'tray.status.idle': 'Idle',
|
||||
'tray.status.thinking': 'Thinking',
|
||||
'tray.status.executing': 'Executing',
|
||||
'tray.status.error': 'Error',
|
||||
'app.update.disabled': 'Auto-update is not enabled (no feed URL configured, or dev mode)',
|
||||
'app.dialog.selectWorkspace.title': 'Select workspace directory',
|
||||
'app.dialog.startupFailed.title': 'MetonaAI Desktop failed to start',
|
||||
'app.dialog.startupFailed.body':
|
||||
'An error occurred during initialization and the app will exit.\n\n{{message}}\n\nPossible causes: the workspace directory is not writable, or the database file is corrupted.\nTry switching the workspace path in Settings and restart.',
|
||||
'workspace.reason.empty': 'Path must not be empty',
|
||||
'workspace.reason.notExists': 'Directory does not exist — it will be created after switching',
|
||||
'workspace.reason.notDirectory': 'Path is not a directory',
|
||||
'workspace.reason.inaccessible': 'Path is not accessible',
|
||||
'workspace.reason.inheritFailed': 'Failed to inherit files: {{message}}',
|
||||
'workspace.reason.restartRequired': 'Workspace switched — restart the app to apply',
|
||||
};
|
||||
|
||||
const DICTS: Record<MainLocale, Record<string, string>> = { 'zh-CN': zh, 'en-US': en };
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* v0.8.2 P1-5: 敏感值脱敏单源工具
|
||||
*
|
||||
* 背景:审计日志 logToolCall 原样落库完整工具 args —— 工具参数中携带的
|
||||
* API Key / 鉴权头 / token(如 http_request 的 headers.authorization、
|
||||
* MCP 工具的鉴权参数)以明文进入 audit_logs 表,密钥链加密(safeStorage)
|
||||
* 只保护配置层,不保护审计层,构成敏感信息二次扩散面。
|
||||
*
|
||||
* 本模块提供与配置层同源(isSensitiveConfigKey 的归一化键名匹配)的脱敏:
|
||||
* - maskSensitiveValue:字符串值掩码(长值保留后 4 位,短值完全掩码)
|
||||
* - deepMaskSensitive:递归遍历对象/数组,按键名匹配掩码字符串值
|
||||
* (循环引用防护 + 深度上限,防御恶意构造的参数结构)
|
||||
*/
|
||||
|
||||
import { isSensitiveConfigKey } from './secure-config';
|
||||
|
||||
/** 长值保留后 4 位,短值(≤4 字符)完全掩码 —— 与 ipc/shared.maskSensitive 同口径 */
|
||||
export function maskSensitiveValue(value: string): string {
|
||||
return value.length > 4 ? '***' + value.slice(-4) : '***';
|
||||
}
|
||||
|
||||
const MAX_MASK_DEPTH = 6;
|
||||
|
||||
/**
|
||||
* 深度脱敏:返回脱敏后的副本(原对象不修改)。
|
||||
* 对象/数组递归;键名命中敏感模式(归一化匹配,api_key/authKey/token/secret/
|
||||
* password/credential 等)时对字符串值掩码;其余值原样保留。
|
||||
*/
|
||||
export function deepMaskSensitive<T>(input: T, depth = 0, seen?: Set<object>): T {
|
||||
if (input === null || typeof input !== 'object') return input;
|
||||
if (depth >= MAX_MASK_DEPTH) return '[depth-limit]' as unknown as T;
|
||||
const seenSet = seen ?? new Set<object>();
|
||||
if (seenSet.has(input as object)) return '[circular]' as unknown as T;
|
||||
seenSet.add(input as object);
|
||||
|
||||
try {
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => deepMaskSensitive(item, depth + 1, seenSet)) as unknown as T;
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) {
|
||||
out[key] = maskSensitiveValue(value);
|
||||
} else {
|
||||
out[key] = deepMaskSensitive(value, depth + 1, seenSet);
|
||||
}
|
||||
}
|
||||
return out as unknown as T;
|
||||
} finally {
|
||||
seenSet.delete(input as object);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,9 @@ export function resetEncryptionUsableForTests(): void {
|
||||
encryptionUsable = null;
|
||||
}
|
||||
|
||||
/** 敏感配置 key 匹配模式(与 IPC 层审计脱敏规则保持一致) */
|
||||
/** 敏感配置 key 匹配模式(与 IPC 层审计脱敏规则保持一致)
|
||||
* v0.8.2 P1-5: 补 authorization / authkey / credential —— 审计 args 深度脱敏
|
||||
* 复用本表,HTTP 标准鉴权头(Authorization)此前不命中导致明文入库 */
|
||||
const SENSITIVE_KEY_PATTERNS = [
|
||||
'apikey',
|
||||
'api_key',
|
||||
@@ -69,6 +71,9 @@ const SENSITIVE_KEY_PATTERNS = [
|
||||
'secret',
|
||||
'password',
|
||||
'auth_key',
|
||||
'authkey',
|
||||
'authorization',
|
||||
'credential',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user