// ============================================================ // 日志脱敏工具 — 主进程 + 渲染进程共用 // // 设计:在 createEntry 阶段统一对 context 字段脱敏,调用方无感知 // // 脱敏策略(按字段名模式匹配,大小写不敏感): // *token* / *secret* / *password* / *pwd* → [REDACTED] // *email* → 保留首字符 + 域名(t***@qq.com) // *phone* / *mobile* / *tel* → 保留首 3 尾 4(138****5678) // *id / *_id / userId / user_id → SHA256 前 8 位(可追溯但不可读) // *name / username / display_name → 保留首字符 + *** // *balance* / *money* / *amount* → 数值替换为 [FILTERED] // 其他字段 → 原样保留 // ============================================================ import type { LogEntry } from '@shared/types'; // ---------- 脱敏函数 ---------- /** 通过哈希缩短(用于 ID 类字段,可追溯不可读) */ export function hashShort(value: string): string { // 简易哈希(避免引入 crypto 模块,双端兼容) let h = 0; for (let i = 0; i < value.length; i++) { h = (Math.imul(31, h) + value.charCodeAt(i)) | 0; } // 转 hex 前缀,补零到 8 位 const hex = (h >>> 0).toString(16).padStart(8, '0'); return `#${hex}`; } /** 邮箱脱敏:首字符 + ***@域名 */ export function sanitizeEmail(value: string): string { const atIndex = value.indexOf('@'); if (atIndex <= 0) return `${value[0]}***`; return `${value[0]}***@${value.slice(atIndex + 1)}`; } /** 手机号脱敏:保留前 3 后 4 */ export function sanitizePhone(value: string): string { const digits = value.replace(/\D/g, ''); if (digits.length < 7) return `${digits.slice(0, 3)}****`; return `${digits.slice(0, 3)}****${digits.slice(-4)}`; } /** 名称脱敏:保留首字符 + *** */ export function sanitizeName(value: string): string { if (!value || value.length <= 1) return '***'; return `${value[0]}***`; } /** 判断字段名是否匹配某个模式 */ function matchKey(key: string, patterns: string[]): boolean { const lower = key.toLowerCase(); return patterns.some((p) => { if (p.startsWith('*') && p.endsWith('*')) return lower.includes(p.slice(1, -1)); if (p.endsWith('*')) return lower.startsWith(p.slice(0, -1)); if (p.startsWith('*')) return lower.endsWith(p.slice(1)); return lower === p; }); } /** 对单个值脱敏(递归处理嵌套对象) */ function sanitizeValue(key: string, value: unknown): unknown { if (value === null || value === undefined) return value; // 完全隐藏 if (matchKey(key, ['*token*', '*secret*', '*password*', '*pwd*', 'authorization'])) { return '[REDACTED]'; } // 邮箱 if ( matchKey(key, ['*email*']) && typeof value === 'string' && value.includes('@') ) { return sanitizeEmail(value); } // 手机号 if (matchKey(key, ['*phone*', '*mobile*', '*tel*']) && typeof value === 'string') { return sanitizePhone(value); } // ID 类字段(数字或字符串) if ( matchKey(key, ['*id', '*_id', 'userId', 'user_id', 'company_id']) && (typeof value === 'string' || typeof value === 'number') ) { return hashShort(String(value)); } // 名称 if ( matchKey(key, ['*name', 'username', 'display_name', 'real_name']) && typeof value === 'string' ) { return sanitizeName(value); } // 金额 / 余额 if (matchKey(key, ['*balance*', '*money*', '*amount*', '*cent*', '*fee*'])) { return '[FILTERED]'; } // 递归处理嵌套对象 if (typeof value === 'object' && !Array.isArray(value)) { return sanitizeRecord(value as Record); } // 数组:逐个脱敏 if (Array.isArray(value)) { return value.map((v) => sanitizeValue(key, v)); } return value; } /** 脱敏整个 Record */ function sanitizeRecord(record: Record): Record { const result: Record = {}; for (const [key, value] of Object.entries(record)) { result[key] = sanitizeValue(key, value); } return result; } // ---------- 对外 API ---------- /** * 脱敏任意对象(返回新对象,不修改原对象) * * 用法: * import { sanitize } from '@shared/utils/sanitize'; * const safe = sanitize({ userId: 42, email: 'test@qq.com', phone: '13812345678' }); * // → { userId: "#a1b2c3d4", email: "t***@qq.com", phone: "138****5678" } */ export function sanitize(record: Record): Record { return sanitizeRecord(record); } /** * 对 LogEntry 脱敏(原地不修改,返回新对象) * 内部也调用了 sanitizeRecord */ export function sanitizeLogEntry(entry: LogEntry): LogEntry { const sanitized = { ...entry }; // 脱敏 context if (sanitized.context && Object.keys(sanitized.context).length > 0) { sanitized.context = sanitizeRecord(sanitized.context); } // 脱敏 error 中的 message(可能含用户信息,如 "User xxx not found") if (sanitized.error) { sanitized.error = { ...sanitized.error }; // 对 error message 做基础脱敏(邮箱/手机号) if (sanitized.error.message) { sanitized.error.message = sanitizeInlineText(sanitized.error.message); } } // 脱敏 message 字段本身 sanitized.message = sanitizeInlineText(sanitized.message); return sanitized; } /** * 对任意文本中的敏感信息做内联脱敏 * 使用正则匹配邮箱和手机号模式 */ export function sanitizeInlineText(text: string): string { // 邮箱(简单模式) let result = text.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, (match) => sanitizeEmail(match), ); // 手机号(中国大陆 11 位) result = result.replace(/\b1[3-9]\d{9}\b/g, (match) => sanitizePhone(match)); return result; }