Files

376 lines
16 KiB
JavaScript
Executable File
Raw Permalink 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.

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const BLOCKING_STATUSES = new Set([
'needs-browser-baseline',
'needs-node-audit',
'mismatch',
'native-capability-gap',
'unknown',
]);
function parseArgs(argv) {
const args = {
caseDir: '',
dir: '',
matrix: '',
browserBaseline: '',
nodeAudit: '',
require: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--case' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--dir') args.dir = argv[++i] || '';
else if (a === '--matrix') args.matrix = argv[++i] || '';
else if (a === '--browser-baseline') args.browserBaseline = argv[++i] || '';
else if (a === '--node-audit') args.nodeAudit = argv[++i] || '';
else if (a === '--require') args.require = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_object_shape_audit.js --case-dir case --markdown
node scripts/check_object_shape_audit.js --case-dir case --require --json
node scripts/check_object_shape_audit.js --dir case/result --matrix case/notes/object-shape-audit.md --browser-baseline case/fixtures/browser-object-shape-baseline.json --node-audit case/tmp/node-object-shape-audit.json --markdown
说明:检查浏览器对象是否泄露 _ / __ 私有状态,并校验对象形状 baseline、Node audit 与 object-shape-audit.md。`;
}
function exists(p) { try { return fs.existsSync(p); } catch { return false; } }
function stat(p) { try { return fs.statSync(p); } catch { return null; } }
function readText(p) { return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''); }
function readJson(p) { return JSON.parse(readText(p)); }
function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
function ext(p) { return path.extname(p).toLowerCase(); }
function walk(root, out = []) {
if (!exists(root)) return out;
const st = stat(root);
if (!st) return out;
if (st.isFile()) {
out.push(root);
return out;
}
if (!st.isDirectory()) return out;
for (const name of fs.readdirSync(root)) walk(path.join(root, name), out);
return out;
}
function isCodeFile(file) {
return ['.js', '.mjs', '.cjs', '.ts'].includes(ext(file));
}
function shouldSkip(root, file) {
const n = rel(root, file).toLowerCase();
return /(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party|src\/target\/original)(\/|$)/.test(n)
|| /(\.min\.js|bundle\.js|vendor\.js)$/i.test(n);
}
function isRelevantBrowserEnvFile(root, file, text) {
const r = rel(root, file);
if (/(^|\/)src\/(?:env|node-runtime\/env)\//i.test(r)) return true;
if (/(^|\/)src\/signer\//i.test(r) && /\b(?:window|document|navigator|XMLHttpRequest|fetch|EventTarget|Storage|Performance|Element|Node)\b/.test(text)) return true;
if (/(?:probe|runtime|runner|diagnostic)/i.test(path.basename(r)) && /\b(?:window|document|navigator|XMLHttpRequest|fetch|EventTarget|Storage|Performance|Element|Node)\b/.test(text)) return true;
return false;
}
const ALLOWED_DOUBLE_UNDERSCORE = new Set([
'__proto__',
'__defineGetter__',
'__defineSetter__',
'__lookupGetter__',
'__lookupSetter__',
'__esModule',
]);
function allowedName(name) {
return ALLOWED_DOUBLE_UNDERSCORE.has(String(name));
}
function stripComments(line) {
return line.replace(/\/\/.*$/g, '').replace(/\/\*.*?\*\//g, '');
}
function inspectPrivateLeaks(root, file) {
const text = readText(file);
if (!isRelevantBrowserEnvFile(root, file, text)) return [];
const leaks = [];
const lines = text.split(/\r?\n/);
const patterns = [
{ id: 'this-dot', re: /\bthis\s*\.\s*(_{1,2}[A-Za-z][\w$]*)\s*=/g, message: '在浏览器实例 this 上写入 _ / __ 私有字段' },
{ id: 'object-dot', re: /\b(?:obj|target|instance|xhr|event|node|element|globalObject|window|document)\s*\.\s*(_{1,2}[A-Za-z][\w$]*)\s*=/g, message: '在浏览器对象上写入 _ / __ 私有字段' },
{ id: 'bracket', re: /\[\s*['"](_{1,2}[A-Za-z][\w$]*)['"]\s*\]\s*=/g, message: '通过 bracket 写入 _ / __ 私有字段' },
{ id: 'define-property', re: /\b(?:Object\.defineProperty|Reflect\.defineProperty)\s*\([^,\n]+,\s*['"](_{1,2}[A-Za-z][\w$]*)['"]/g, message: '通过 defineProperty 暴露 _ / __ 私有字段' },
{ id: 'define-value', re: /\b(?:defineValue|defineNativeValue|defineHidden|defineInternal)\s*\([^,\n]+,\s*['"](_{1,2}[A-Za-z][\w$]*)['"]/g, message: '通过 helper 暴露 _ / __ 私有字段' },
{ id: 'descriptor-object', re: /['"](_{1,2}[A-Za-z][\w$]*)['"]\s*:\s*\{[^}]*\b(?:value|get|set|enumerable|configurable|writable)\b/g, message: '在 descriptor 对象中定义 _ / __ 私有字段' },
];
for (let i = 0; i < lines.length; i++) {
const line = stripComments(lines[i]);
for (const pattern of patterns) {
pattern.re.lastIndex = 0;
let m;
while ((m = pattern.re.exec(line))) {
const name = m[1];
if (allowedName(name)) continue;
leaks.push({
file: rel(root, file),
line: i + 1,
name,
type: pattern.id,
message: pattern.message,
text: lines[i].trim().slice(0, 180),
});
}
}
}
return leaks;
}
function extractStatusesFromMarkdown(text) {
const statuses = [];
for (const status of ['matched', 'accepted-diff', 'not-involved', 'needs-browser-baseline', 'needs-node-audit', 'mismatch', 'native-capability-gap', 'unknown']) {
const matches = text.match(new RegExp(`\\b${status}\\b`, 'g'));
if (matches) statuses.push({ status, count: matches.length });
}
return statuses;
}
function normalizeTargets(raw) {
if (!raw || typeof raw !== 'object') return [];
if (Array.isArray(raw.targets)) return raw.targets.map(item => item.name || item.path || item.target || '').filter(Boolean);
if (Array.isArray(raw.items)) return raw.items.map(item => item.name || item.path || item.target || '').filter(Boolean);
if (raw.targets && typeof raw.targets === 'object') return Object.keys(raw.targets);
return [];
}
function normalizeTargetRecords(raw) {
if (!raw || typeof raw !== 'object') return [];
if (Array.isArray(raw.targets)) return raw.targets;
if (Array.isArray(raw.items)) return raw.items;
if (raw.targets && typeof raw.targets === 'object') {
return Object.entries(raw.targets).map(([name, value]) => ({ name, ...(value && typeof value === 'object' ? value : { value }) }));
}
return [];
}
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
}
function sameValue(left, right) {
return JSON.stringify(stable(left)) === JSON.stringify(stable(right));
}
const SHAPE_FIELDS = [
'objectKeys',
'ownPropertyNames',
'ownSymbols',
'reflectOwnKeys',
'descriptors',
'inChecks',
'forIn',
'prototypeChain',
'toString',
'constructorName',
'instanceof',
];
function inspectJson(file, label, problems) {
if (!exists(file)) {
problems.push(`缺少 ${label}:${file}`);
return { exists: false, parseOk: false, baselineId: '', targets: [] };
}
try {
const raw = readJson(file);
return {
exists: true,
parseOk: true,
baselineId: raw.baselineId || '',
schemaVersion: raw.schemaVersion || '',
targets: normalizeTargets(raw),
targetRecords: normalizeTargetRecords(raw),
runtimeSourceHash: raw.runtimeSourceHash || '',
generatedBy: raw.generatedBy || '',
probeVersion: raw.probeVersion || '',
};
} catch (err) {
problems.push(`${label} 无法解析为 JSON:${err.message}`);
return { exists: true, parseOk: false, baselineId: '', targets: [], targetRecords: [], runtimeSourceHash: '', generatedBy: '', probeVersion: '' };
}
}
function compareTargetRecords(browser, nodeAudit, problems) {
const nodeByName = new Map(nodeAudit.targetRecords.map(item => [item.name || item.path || item.target, item]));
for (const expected of browser.targetRecords) {
const name = expected.name || expected.path || expected.target;
if (!name) {
problems.push('浏览器对象形状 baseline 存在未命名 target');
continue;
}
const observed = nodeByName.get(name);
if (!observed) {
problems.push(`Node 对象形状 audit 缺少 target:${name}`);
continue;
}
for (const field of SHAPE_FIELDS) {
if (typeof expected[field] === 'undefined') continue;
if (typeof observed[field] === 'undefined') {
problems.push(`${name} 缺少 Node 观测字段:${field}`);
} else if (!sameValue(expected[field], observed[field])) {
problems.push(`${name} 对象形状不一致:${field}`);
}
}
}
}
function check(args) {
const caseDir = path.resolve(args.caseDir || '.');
const root = path.resolve(args.dir || path.join(caseDir, 'result'));
const matrixPath = path.resolve(args.matrix || path.join(caseDir, 'notes', 'object-shape-audit.md'));
const browserPath = path.resolve(args.browserBaseline || path.join(caseDir, 'fixtures', 'browser-object-shape-baseline.json'));
const nodeAuditPath = path.resolve(args.nodeAudit || path.join(caseDir, 'tmp', 'node-object-shape-audit.json'));
const files = walk(root).filter(file => stat(file) && stat(file).isFile() && isCodeFile(file) && !shouldSkip(root, file));
const problems = [];
const warnings = [];
const leaks = files.flatMap(file => inspectPrivateLeaks(root, file));
for (const leak of leaks) {
problems.push(`${leak.file}:${leak.line} ${leak.message}:${leak.name}`);
}
const requireArtifacts = args.require || leaks.length > 0 || files.some(file => {
try {
const text = readText(file);
return isRelevantBrowserEnvFile(root, file, text) && /Object\.keys|Object\.getOwnPropertyNames|Reflect\.ownKeys|hasOwnProperty|propertyIsEnumerable/.test(text);
} catch {
return false;
}
});
let matrix = { exists: exists(matrixPath), statuses: [], blockingStatuses: [], missingKeywords: [] };
if (requireArtifacts) {
if (!matrix.exists) problems.push(`缺少对象形状审计矩阵:${matrixPath}`);
if (matrix.exists) {
const text = readText(matrixPath);
matrix.statuses = extractStatusesFromMarkdown(text);
matrix.blockingStatuses = matrix.statuses.filter(item => BLOCKING_STATUSES.has(item.status));
for (const item of matrix.blockingStatuses) problems.push(`对象形状审计矩阵存在阻断状态 ${item.status},数量 ${item.count}`);
matrix.missingKeywords = ['Object.keys', 'Object.getOwnPropertyNames', 'Reflect.ownKeys', 'descriptor', '私有状态'].filter(key => !text.includes(key));
for (const key of matrix.missingKeywords) problems.push(`对象形状审计矩阵缺少关键字段:${key}`);
}
}
const browser = requireArtifacts
? inspectJson(browserPath, '浏览器对象形状 baseline', problems)
: { exists: exists(browserPath), parseOk: false, baselineId: '', targets: [], targetRecords: [], runtimeSourceHash: '', generatedBy: '', probeVersion: '' };
const nodeAudit = requireArtifacts
? inspectJson(nodeAuditPath, 'Node 对象形状 audit', problems)
: { exists: exists(nodeAuditPath), parseOk: false, baselineId: '', targets: [], targetRecords: [], runtimeSourceHash: '', generatedBy: '', probeVersion: '' };
if (browser.baselineId && nodeAudit.baselineId && browser.baselineId !== nodeAudit.baselineId) {
problems.push(`对象形状 baselineId 与 Node audit baselineId 不一致:${browser.baselineId} != ${nodeAudit.baselineId}`);
}
if (requireArtifacts && browser.parseOk && nodeAudit.parseOk) {
const missingTargets = browser.targets.filter(target => !nodeAudit.targets.includes(target));
if (missingTargets.length) problems.push(`Node 对象形状 audit 缺少浏览器 baseline target:${missingTargets.join('、')}`);
compareTargetRecords(browser, nodeAudit, problems);
if (!nodeAudit.runtimeSourceHash) problems.push('Node 对象形状 audit 缺少 runtimeSourceHash,无法判断代码修改后审计是否过期');
if (!nodeAudit.probeVersion) problems.push('Node 对象形状 audit 缺少 probeVersion');
if (!/audit|probe|recorder/i.test(nodeAudit.generatedBy)) problems.push(`Node 对象形状 audit generatedBy 不可信:${nodeAudit.generatedBy || '未记录'}`);
}
if (!files.length) problems.push(`未找到可检查的 result 代码文件:${root}`);
if (!leaks.length && !requireArtifacts) warnings.push('未发现对象形状审计触发信号;如目标存在属性枚举 / descriptor 检测,建议使用 --require 强制检查。');
return {
caseDir,
root,
clean: problems.length === 0,
requireArtifacts,
filesChecked: files.length,
leaks,
matrixPath,
browserPath,
nodeAuditPath,
matrix,
browser,
nodeAudit,
problems,
warnings,
};
}
function renderMarkdown(result) {
const lines = [
'# 对象形状审计检查结果',
'',
`case 目录:${result.caseDir}`,
`检查范围:${result.root}`,
`是否需要审计产物:${result.requireArtifacts ? '是' : '否'}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 文件',
`- 矩阵:${result.matrixPath}`,
`- 浏览器 baseline:${result.browserPath}`,
`- Node audit:${result.nodeAuditPath}`,
'',
'## 摘要',
`- 检查文件数:${result.filesChecked}`,
`- _ / __ 私有状态泄露数量:${result.leaks.length}`,
`- 浏览器 baseline:存在=${result.browser.exists ? '是' : '否'},JSON=${result.browser.parseOk ? '是' : '否'},baselineId=${result.browser.baselineId || '未记录'}`,
`- Node audit:存在=${result.nodeAudit.exists ? '是' : '否'},JSON=${result.nodeAudit.parseOk ? '是' : '否'},baselineId=${result.nodeAudit.baselineId || '未记录'}`,
];
if (result.matrix.exists) {
lines.push(`- 矩阵状态:${result.matrix.statuses.map(item => `${item.status}=${item.count}`).join('、') || '未记录'}`);
}
if (result.leaks.length) {
lines.push('', '## 私有状态泄露');
for (const leak of result.leaks.slice(0, 50)) {
lines.push(`- ${leak.file}:${leak.line} ${leak.name}:${leak.text}`);
}
if (result.leaks.length > 50) lines.push(`- 还有 ${result.leaks.length - 50} 条未展示。`);
}
if (result.problems.length) {
lines.push('', '## 问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
}
if (result.warnings.length) {
lines.push('', '## 提醒');
for (const warning of result.warnings) lines.push(`- ${warning}`);
}
return `${lines.join('\n')}\n`;
}
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
module.exports = { check, inspectPrivateLeaks };