Files
metona-ai-desktop/electron/services/audit.service.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

516 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Audit Service — 审计日志服务(TOOL 层)
*
* 负责将工具调用、权限检查、错误等审计记录写入 SQLite audit_logs 表。
* 设计原则:
* 1. 所有重要操作必须记录
* 2. 日志不可篡改(INSERT-ONLY
* 3. 支持按时间/类型/会话查询
* 4. v0.2.0: 链式哈希防篡改 — 每条日志的 current_hash = SHA256(prev_hash + content)
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 日志分层
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
*/
import type Database from 'better-sqlite3';
import { createHash } from 'crypto';
import log from 'electron-log';
// v0.8.2 P1-5: 审计 args 深度脱敏(与配置层脱敏同源)
import { deepMaskSensitive } from '../utils/mask';
/**
* #36 修复: 稳定序列化,递归按 key 字典序排序后序列化
* JSON.stringify 对对象 key 顺序敏感({a:1,b:2} ≠ {b:2,a:1}),
* 导致相同语义的对象产生不同 hash,审计去重失效。
* stableStringify 保证相同内容始终产生相同字符串。
*/
function stableStringify(obj: unknown): string {
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
if (Array.isArray(obj)) {
return '[' + obj.map(stableStringify).join(',') + ']';
}
const keys = Object.keys(obj as Record<string, unknown>).sort();
return (
'{' +
keys
.map((k) => JSON.stringify(k) + ':' + stableStringify((obj as Record<string, unknown>)[k]))
.join(',') +
'}'
);
}
export type AuditEventType =
| 'tool_call'
| 'permission_check'
| 'error'
| 'llm_request'
| 'llm_response'
| 'session_start'
| 'session_end'
| 'config_change';
export type AuditActor = 'agent' | 'user' | 'system';
export type AuditOutcome = 'success' | 'denied' | 'error';
/** v0.5.0: 审计日志行类型(导出/查询共用) */
interface AuditRecordRow {
id: number;
session_id: string;
iteration: number | null;
event_type: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
duration_ms: number | null;
created_at: number;
prev_hash: string | null;
current_hash: string | null;
}
export interface AuditEntry {
sessionId: string;
iteration?: number;
eventType: AuditEventType;
actor: AuditActor;
target: string;
details?: Record<string, unknown>;
outcome?: AuditOutcome;
durationMs?: number;
}
export class AuditService {
constructor(private getDB: () => Database.Database) {}
/**
* 计算链式哈希
* current_hash = SHA256(prev_hash + 所有内容字段)
* v0.2.0: 纳入 iteration/outcome/durationMs 字段,使用 JSON.stringify 避免分隔符碰撞
* #36 修复: 改用 stableStringify 替代 JSON.stringify,避免对象 key 顺序不稳定导致 hash 不一致
*/
private computeHash(
prevHash: string,
entry: {
sessionId: string;
iteration: number | null;
eventType: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
durationMs: number | null;
createdAt: number;
},
): string {
// #36 修复: 使用 stableStringify 避免分隔符碰撞且保证 key 顺序稳定
const content = stableStringify({
prevHash,
sessionId: entry.sessionId,
iteration: entry.iteration,
eventType: entry.eventType,
actor: entry.actor,
target: entry.target,
details: entry.details,
outcome: entry.outcome,
durationMs: entry.durationMs,
createdAt: entry.createdAt,
});
return createHash('sha256').update(content, 'utf-8').digest('hex');
}
/**
* 获取最后一条日志的 hash(用于链式哈希计算)
* v0.3.0 修复: 使用内存缓存避免每次 log 都查询数据库
*/
private cachedLastHash: string | null = null;
/**
* v0.7.4 P1-5: 重置链式哈希内存缓存。
*
* data:clearAuditLogs 清空审计表后,cachedLastHash 仍持有已删除末行的哈希:
* 下一条 log() 的 prev_hash 引用不存在的行 → audit:verifyChain 从零起始遍历时
* 首行 prev_hash 与零哈希不符 → 误报 TAMPERED(篡改误判)。清空操作后必须调用
* 本方法使缓存失效,下次 log() 重新查询(表为空 → 回退零哈希)。
*/
resetChainCache(): void {
this.cachedLastHash = null;
}
private getLastHash(): string {
if (this.cachedLastHash !== null) return this.cachedLastHash;
const db = this.getDB();
const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as
| { current_hash: string | null }
| undefined;
this.cachedLastHash =
row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
return this.cachedLastHash;
}
/**
* 记录审计条目(带链式哈希)
*/
log(entry: AuditEntry): void {
try {
const db = this.getDB();
const now = Date.now();
const detailsStr = entry.details ? JSON.stringify(entry.details) : null;
const prevHash = this.getLastHash();
const currentHash = this.computeHash(prevHash, {
sessionId: entry.sessionId,
iteration: entry.iteration ?? null,
eventType: entry.eventType,
actor: entry.actor,
target: entry.target,
details: detailsStr,
outcome: entry.outcome ?? null,
durationMs: entry.durationMs ?? null,
createdAt: now,
});
db.prepare(
`
INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
entry.sessionId,
entry.iteration ?? null,
entry.eventType,
entry.actor,
entry.target,
detailsStr,
entry.outcome ?? null,
entry.durationMs ?? null,
now,
prevHash,
currentHash,
);
// v0.3.0 修复: 更新缓存的 lastHash,避免下次 log 再查询数据库
this.cachedLastHash = currentHash;
} catch (error) {
// 审计日志写入失败不应影响主流程
log.error('Audit log write failed:', error);
}
}
/**
* 记录工具调用
*
* v0.8.2 P1-5: args 深度脱敏后落库 —— 工具参数中的密钥/鉴权头/token 此前
* 以明文进入 audit_logssafeStorage 只保护配置层),构成敏感信息二次扩散面。
* 键名匹配与配置层单源(utils/mask → secure-config 归一化匹配)。
*/
logToolCall(params: {
sessionId: string;
iteration: number;
toolName: string;
args: Record<string, unknown>;
outcome: AuditOutcome;
result?: unknown;
error?: string;
durationMs: number;
}): void {
this.log({
sessionId: params.sessionId,
iteration: params.iteration,
eventType: 'tool_call',
actor: 'agent',
target: params.toolName,
details: {
args: deepMaskSensitive(params.args),
result: typeof params.result === 'string' ? params.result.slice(0, 1000) : params.result,
error: params.error,
},
outcome: params.outcome,
durationMs: params.durationMs,
});
}
/**
* 记录 LLM 请求
*/
logLLMRequest(params: {
sessionId: string;
iteration: number;
provider: string;
model: string;
tokenCount: number;
}): void {
this.log({
sessionId: params.sessionId,
iteration: params.iteration,
eventType: 'llm_request',
actor: 'agent',
target: `${params.provider}/${params.model}`,
details: { tokenCount: params.tokenCount },
});
}
/**
* 记录 LLM 响应
*/
logLLMResponse(params: {
sessionId: string;
iteration: number;
provider: string;
model: string;
tokenUsage: { input: number; output: number; total: number };
durationMs: number;
}): void {
this.log({
sessionId: params.sessionId,
iteration: params.iteration,
eventType: 'llm_response',
actor: 'agent',
target: `${params.provider}/${params.model}`,
details: { tokenUsage: params.tokenUsage },
outcome: 'success',
durationMs: params.durationMs,
});
}
/**
* 记录会话开始
*/
logSessionStart(sessionId: string): void {
this.log({
sessionId,
eventType: 'session_start',
actor: 'user',
target: 'session',
});
}
/**
* 记录会话结束
*/
logSessionEnd(params: {
sessionId: string;
totalIterations: number;
totalTokens: number;
durationMs: number;
terminationReason: string;
}): void {
this.log({
sessionId: params.sessionId,
eventType: 'session_end',
actor: 'agent',
target: 'session',
details: {
totalIterations: params.totalIterations,
totalTokens: params.totalTokens,
terminationReason: params.terminationReason,
},
outcome: 'success',
durationMs: params.durationMs,
});
}
/**
* 查询审计日志
*/
query(filters?: { sessionId?: string; eventType?: AuditEventType; limit?: number }): Array<{
id: number;
session_id: string;
iteration: number | null;
event_type: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
duration_ms: number | null;
created_at: number;
prev_hash: string | null;
current_hash: string | null;
}> {
const db = this.getDB();
let sql = 'SELECT * FROM audit_logs WHERE 1=1';
const params: unknown[] = [];
if (filters?.sessionId) {
sql += ' AND session_id = ?';
params.push(filters.sessionId);
}
if (filters?.eventType) {
sql += ' AND event_type = ?';
params.push(filters.eventType);
}
sql += ' ORDER BY created_at DESC';
if (filters?.limit) {
sql += ' LIMIT ?';
params.push(filters.limit);
}
return db.prepare(sql).all(...params) as Array<{
id: number;
session_id: string;
iteration: number | null;
event_type: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
duration_ms: number | null;
created_at: number;
prev_hash: string | null;
current_hash: string | null;
}>;
}
/**
* v0.5.0: 导出审计日志为 JSONL 文本(每行一条 JSON 记录)
*
* @returns JSONL 格式字符串(含全部记录,按 id 升序)
*/
exportJSONL(): string {
const db = this.getDB();
const rows = db
.prepare(
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
)
.all() as AuditRecordRow[];
return rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length > 0 ? '\n' : '');
}
/**
* v0.5.0: 导出审计日志为 CSV 文本
*
* 字段含逗号/引号/换行时按 RFC 4180 转义(双引号包裹 + 内部双引号翻倍)。
*
* @returns CSV 格式字符串(含表头,按 id 升序)
*/
exportCSV(): string {
const db = this.getDB();
const rows = db
.prepare(
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
)
.all() as AuditRecordRow[];
const headers = [
'id',
'session_id',
'iteration',
'event_type',
'actor',
'target',
'details',
'outcome',
'duration_ms',
'created_at',
'prev_hash',
'current_hash',
];
const escapeCsv = (value: string | number | null): string => {
const s = value === null ? '' : String(value);
if (/[",\r\n]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
};
const lines = [headers.join(',')];
for (const r of rows) {
lines.push(
headers
.map((h) => escapeCsv((r as unknown as Record<string, string | number | null>)[h]))
.join(','),
);
}
return lines.join('\n') + '\n';
}
/**
* v0.2.0: 验证链式哈希完整性
*
* 遍历所有审计日志,重新计算每条记录的 hash,与存储的 current_hash 对比。
* 如果任何一条记录的 hash 不匹配,说明日志已被篡改。
*
* @returns 验证结果,包括是否通过、首个篡改位置的 ID
*/
verifyChain(): {
valid: boolean;
totalRecords: number;
verifiedRecords: number;
tamperedId: number | null;
} {
const db = this.getDB();
const rows = db
.prepare(
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
)
.all() as Array<{
id: number;
session_id: string;
iteration: number | null;
event_type: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
duration_ms: number | null;
created_at: number;
prev_hash: string | null;
current_hash: string | null;
}>;
let prevHash = '0000000000000000000000000000000000000000000000000000000000000000';
let verified = 0;
for (const row of rows) {
// 跳过旧数据(current_hash 为 NULL,未回填哈希)
if (row.current_hash === null) {
prevHash = '0'.repeat(64);
continue;
}
const expectedHash = this.computeHash(prevHash, {
sessionId: row.session_id,
iteration: row.iteration,
eventType: row.event_type,
actor: row.actor,
target: row.target,
details: row.details,
outcome: row.outcome,
durationMs: row.duration_ms,
createdAt: row.created_at,
});
if (row.current_hash !== expectedHash) {
// 审查修复: #36 将 JSON.stringify 改为 stableStringify 后,旧记录的 hash 用旧算法生成
// 尝试用旧算法(JSON.stringify)重新计算,如果匹配则跳过(兼容旧数据)
const legacyContent = JSON.stringify({
prevHash,
sessionId: row.session_id,
iteration: row.iteration,
eventType: row.event_type,
actor: row.actor,
target: row.target,
details: row.details,
outcome: row.outcome,
durationMs: row.duration_ms,
createdAt: row.created_at,
});
const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex');
if (row.current_hash === legacyHash) {
prevHash = row.current_hash;
continue;
}
return {
valid: false,
totalRecords: rows.length,
verifiedRecords: verified,
tamperedId: row.id,
};
}
prevHash = row.current_hash;
verified++;
}
return { valid: true, totalRecords: rows.length, verifiedRecords: verified, tamperedId: null };
}
}