Files
memind/mindspace-content-scan.mjs
john 9b4a25799f Add smart ACK provider for WeChat MP replies
Replace fixed ackText with a rule-based AckProvider that picks
response templates by message type and intent (translate, summary,
rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync,
zero I/O, auto-falls back to config.ackText on any error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 15:19:03 +08:00

276 lines
7.8 KiB
JavaScript

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: /(?<!\d)\d{17}[\dXx](?!\d)/g,
mask: (value) => `${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: /(?<!\d)\d{16,19}(?!\d)/g,
mask: (value) => `${value.slice(0, 4)} **** **** ${value.slice(-4)}`,
replacement: (value) => `${value.slice(0, 4)} **** **** ${value.slice(-4)}`,
},
{
type: 'phone',
label: '手机号',
riskLevel: 'medium',
blocking: false,
pattern: /(?<!\d)1[3-9]\d{9}(?!\d)/g,
mask: (value) => `${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: /<script\b[\s\S]*?<\/script>/gi,
mask: () => '<script>…</script>',
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]}>…</${match[1]}>`,
replacement: () => '',
},
{
type: 'html_form_action',
label: '表单提交',
riskLevel: 'high',
blocking: true,
pattern: /<form\b[\s\S]*?>[\s\S]*?<\/form>/gi,
mask: () => '<form>…</form>',
replacement: () => '',
},
{
type: 'html_meta_refresh',
label: '页面跳转',
riskLevel: 'medium',
blocking: true,
pattern: /<meta\b[^>]*http-equiv\s*=\s*(['"])refresh\1[^>]*>/gi,
mask: () => '<meta http-equiv="refresh">',
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',
]);
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 ?? ''),
};
}