aede1e6fcb
Pre-bundle Chart.js, auto-rewrite common CDN references at publish and serve time, and block unknown external script src during publication scans so interactive dashboards keep working under CSP. Co-authored-by: Cursor <cursoragent@cursor.com>
286 lines
8.1 KiB
JavaScript
286 lines
8.1 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_external_script',
|
|
label: '外部 CDN 脚本',
|
|
riskLevel: 'high',
|
|
blocking: true,
|
|
pattern: /<script\b[^>]*\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]}>…</${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',
|
|
'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 ?? ''),
|
|
};
|
|
}
|