376 lines
16 KiB
JavaScript
Executable File
376 lines
16 KiB
JavaScript
Executable File
#!/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 };
|