import crypto from 'node:crypto'; const RISK_ORDER = ['none', 'low', 'medium', 'high', 'critical']; const HTML_TAG_PATTERN = /<[^>]+>/; const PRIVATE_URL_PATTERN = /(file:\/\/[^\s"'<>]+|\/api\/mindspace\/v1\/assets\/[a-z0-9-]+(?:\/[a-z-]+)?|\/users\/[^\s"'<>]+)/gi; const TEXT_RULES = [ { type: 'id_card', label: '身份证号', riskLevel: 'high', blocking: true, pattern: /(? `${value.slice(0, 6)}********${value.slice(-4)}`, replacement: (value) => `${value.slice(0, 6)}********${value.slice(-4)}`, }, { type: 'bank_card', label: '银行卡号', riskLevel: 'high', blocking: true, pattern: /(? `${value.slice(0, 4)} **** **** ${value.slice(-4)}`, replacement: (value) => `${value.slice(0, 4)} **** **** ${value.slice(-4)}`, }, { type: 'phone', label: '手机号', riskLevel: 'medium', blocking: false, pattern: /(? `${value.slice(0, 3)}****${value.slice(-4)}`, replacement: (value) => `${value.slice(0, 3)}****${value.slice(-4)}`, }, { type: 'email', label: '邮箱地址', riskLevel: 'medium', blocking: false, pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, mask: (value) => `${value.slice(0, 2)}***@***`, replacement: () => '[邮箱地址]', }, { type: 'api_key', label: 'API Key', riskLevel: 'critical', blocking: true, pattern: /\b(?:sk|rk|pk)_(?:test_|live_|proj_)?[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}\b|ghp_[A-Za-z0-9]{20,}\b|AIza[0-9A-Za-z\-_]{20,}\b/g, mask: (value) => `${value.slice(0, 4)}********${value.slice(-4)}`, replacement: () => '[API_KEY]', }, { type: 'access_token', label: '访问令牌', riskLevel: 'critical', blocking: true, pattern: /\b(?:access[_-]?token|refresh[_-]?token|authorization)\b\s*[:=]?\s*(?:bearer\s+)?[A-Za-z0-9\-_=+.]{16,}/gi, mask: (value) => `${value.slice(0, 10)}********`, replacement: (value) => value.replace(/[A-Za-z0-9\-_=+.]{16,}$/i, '[TOKEN]'), }, { type: 'private_key', label: '私钥', riskLevel: 'critical', blocking: true, pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, mask: () => '-----BEGIN PRIVATE KEY-----********', replacement: () => '[PRIVATE_KEY]', }, { type: 'external_link', label: '外部链接', riskLevel: 'low', blocking: false, pattern: /https?:\/\/[^\s<>"')]+/gi, mask: (value) => value.slice(0, 80), replacement: (value) => value, }, ]; const HTML_RULES = [ { type: 'html_script', label: '脚本标签', riskLevel: 'critical', blocking: true, pattern: //gi, mask: () => '', replacement: () => '', }, { type: 'html_external_script', label: '外部 CDN 脚本', riskLevel: 'high', blocking: true, pattern: /]*\bsrc\s*=\s*(['"])((?:https?:)?\/\/[^'"]+)\1/gi, mask: (_value, _quote, src) => String(src).slice(0, 72), replacement: () => '', }, { type: 'html_inline_handler', label: '内联事件', riskLevel: 'high', blocking: true, pattern: /\son[a-z]+\s*=\s*(['"]).*?\1/gi, mask: () => 'on*=…', replacement: () => '', }, { type: 'html_javascript_url', label: 'javascript 链接', riskLevel: 'high', blocking: true, pattern: /(href|src)\s*=\s*(['"])\s*javascript:[\s\S]*?\2/gi, mask: (value) => value.slice(0, 40), replacement: (_value, match) => `${match[1]}=${match[2]}#${match[2]}`, }, { type: 'html_forbidden_embed', label: '嵌入式外部内容', riskLevel: 'high', blocking: true, pattern: /<(iframe|object|embed)\b[\s\S]*?>[\s\S]*?(?:<\/\1>|$)/gi, mask: (value, match) => `<${match[1]}>…`, replacement: () => '', }, { type: 'html_form_action', label: '表单提交', riskLevel: 'high', blocking: true, pattern: /[\s\S]*?<\/form>/gi, mask: () => '
', replacement: () => '', }, { type: 'html_meta_refresh', label: '页面跳转', riskLevel: 'medium', blocking: true, pattern: /]*http-equiv\s*=\s*(['"])refresh\1[^>]*>/gi, mask: () => '', replacement: () => '', }, { type: 'private_resource_reference', label: '私有资源引用', riskLevel: 'high', blocking: true, pattern: PRIVATE_URL_PATTERN, mask: (value) => value.slice(0, 48), replacement: () => '#private-resource-redacted', }, ]; const ACKNOWLEDGEABLE_HTML_ACTIVE_TYPES = new Set([ 'html_script', 'html_inline_handler', 'html_form_action', ]); const TRUSTED_EXTERNAL_HOSTS = new Set([ 'fonts.googleapis.com', 'fonts.gstatic.com', 'images.unsplash.com', ]); export function replacePrivateResourceReferences(content, replacement) { const source = String(content ?? ''); if (!source) return source; const replacer = typeof replacement === 'function' ? replacement : () => String(replacement ?? ''); return source.replace(PRIVATE_URL_PATTERN, (...args) => replacer(...args)); } function inferFormat(content, format) { if (format === 'html') return 'html'; return HTML_TAG_PATTERN.test(String(content ?? '')) ? 'html' : 'text'; } function isTrustedExternalUrl(value) { try { return TRUSTED_EXTERNAL_HOSTS.has(new URL(value).hostname); } catch { return false; } } function riskMax(left, right) { return RISK_ORDER.indexOf(right) > RISK_ORDER.indexOf(left) ? right : left; } function uniqueMatches(content, pattern) { return [ ...new Map( [...String(content).matchAll(pattern)].map((match) => [ match[0].toLowerCase(), match, ]), ).values(), ]; } function applyRule(content, rule, findings, redactions, options = {}) { let matches = uniqueMatches(content, rule.pattern); if (rule.type === 'external_link') { matches = matches.filter((match) => !isTrustedExternalUrl(match[0])); } if (!matches.length) return content; const blocking = options.allowHtmlActiveContent && ACKNOWLEDGEABLE_HTML_ACTIVE_TYPES.has(rule.type) ? false : rule.blocking; findings.push({ id: crypto .createHash('sha256') .update(`${rule.type}:${matches[0][0]}`) .digest('hex') .slice(0, 24), type: rule.type, label: rule.label, riskLevel: rule.riskLevel, occurrenceCount: matches.length, sampleMasked: rule.mask(matches[0][0], matches[0]), blocking, }); let next = String(content); if (typeof rule.replacement === 'function') { next = next.replace(rule.pattern, (...args) => rule.replacement(...args)); redactions.push(rule.type); } return next; } function finalizeResult(findings) { const riskLevel = findings.reduce((highest, finding) => riskMax(highest, finding.riskLevel), 'none'); return { status: findings.some((finding) => finding.blocking) ? 'blocked' : findings.length ? 'warned' : 'passed', riskLevel, findings, allowed: !findings.some((finding) => finding.blocking), }; } export function scanContent(content, options = {}) { const format = inferFormat(content, options.format); const findings = []; const working = String(content ?? ''); for (const rule of TEXT_RULES) { applyRule(working, rule, findings, [], options); } if (format === 'html') { for (const rule of HTML_RULES) { applyRule(working, rule, findings, [], options); } } return finalizeResult(findings); } export function redactContent(content, options = {}) { const format = inferFormat(content, options.format); const findings = []; const redactions = []; let next = String(content ?? ''); for (const rule of TEXT_RULES) { next = applyRule(next, rule, findings, redactions); } if (format === 'html') { for (const rule of HTML_RULES) { next = applyRule(next, rule, findings, redactions); } } const result = finalizeResult(findings); return { ...result, content: next, format, redactionCount: redactions.length, changed: next !== String(content ?? ''), }; }