reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
#!/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 };
|
||||
Reference in New Issue
Block a user