reverse-skills-max: initial public release (restored, redacted, hardened)

This commit is contained in:
baradok609hecl1pq6qi
2026-10-20 02:17:15 +03:00
commit da24c2bd20
2227 changed files with 443931 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
function parseArgs(argv) {
const args = { trace: '', summary: '', json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--trace') args.trace = argv[++i] || '';
else if (a === '--summary') args.summary = argv[++i] || '';
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/analyze_trace.js --trace case/tmp/env-trace.jsonl --summary case/tmp/missing-env.json --markdown
node scripts/analyze_trace.js --trace case/tmp/env-trace.jsonl --json`;
}
function readJsonMaybe(file, fallback) {
if (!file) return fallback;
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } catch { return fallback; }
}
function readTrace(file) {
if (!file) return [];
const text = fs.readFileSync(file, 'utf8');
return text.split(/\r?\n/).map(s => s.trim()).filter(Boolean).map((line, idx) => {
try { return JSON.parse(line); }
catch (err) { return { type: 'parse-error', path: `line:${idx + 1}`, message: err.message }; }
});
}
function moduleOf(pathText) {
const p = String(pathText || '');
if (/^(window|self|globalThis|top|parent)(\.|$)/.test(p)) return 'base-env';
if (/^location(\.|$)|^URL(SearchParams)?(\.|$)/.test(p)) return 'location-url-env';
if (/^navigator(\.|$)/.test(p)) return 'navigator-env';
if (/^screen(\.|$)|devicePixelRatio/.test(p)) return 'screen-env';
if (/^document\.cookie/.test(p)) return 'document-cookie-env';
if (/^document\.all/.test(p)) return 'document-all-addon';
if (/^document(\.|$)|^HTMLElement|^HTML/.test(p)) return 'document-dom-env';
if (/^(localStorage|sessionStorage)(\.|$)/.test(p)) return 'storage-env';
if (/^crypto(\.|$)|getRandomValues|randomUUID/.test(p)) return 'crypto-env';
if (/^performance(\.|$)|^Date(\.|$)|Date\.now|Math\.random/.test(p)) return 'time-random-env';
if (/fetch|XMLHttpRequest|Headers|Request|Response/.test(p)) return 'network-stub-env';
if (/Canvas|canvas|WebGL|OffscreenCanvas/.test(p)) return 'canvas-webgl-env';
if (/Worker|postMessage|MessageChannel|onmessage/.test(p)) return 'worker-message-env';
if (/WebAssembly|\.wasm|wasm/i.test(p)) return 'wasm-boundary-env';
if (/toString|getOwnPropertyDescriptor|getPrototypeOf|ownKeys|toPrimitive/.test(p)) return 'native-protection';
return 'target-specific-env';
}
function priorityOf(moduleName) {
if (['base-env', 'location-url-env', 'storage-env', 'document-cookie-env'].includes(moduleName)) return 1;
if (['navigator-env', 'time-random-env', 'crypto-env', 'network-stub-env'].includes(moduleName)) return 2;
if (['native-protection', 'document-all-addon', 'canvas-webgl-env'].includes(moduleName)) return 3;
if (['worker-message-env', 'wasm-boundary-env'].includes(moduleName)) return 4;
return 5;
}
function analyze(events, summary) {
const modules = new Map();
const riskSignals = new Set(summary.proxyRiskSignals || []);
const paths = new Set();
for (const e of events) {
const pathText = e.path || e.prop || '';
if (pathText) paths.add(pathText);
const moduleName = moduleOf(pathText || e.type);
if (!modules.has(moduleName)) modules.set(moduleName, { module: moduleName, priority: priorityOf(moduleName), count: 0, examples: [], eventTypes: new Set() });
const item = modules.get(moduleName);
item.count += 1;
item.eventTypes.add(e.type || 'unknown');
if (item.examples.length < 8 && pathText) item.examples.push(pathText);
if (['ownKeys', 'getOwnPropertyDescriptor', 'getPrototypeOf', 'toPrimitive'].includes(e.type)) riskSignals.add(`${e.type}:${pathText}`);
if (String(pathText).endsWith('.toString')) riskSignals.add(`toString:${pathText}`);
}
for (const name of summary.missingGlobals || []) {
const moduleName = moduleOf(name);
if (!modules.has(moduleName)) modules.set(moduleName, { module: moduleName, priority: priorityOf(moduleName), count: 0, examples: [], eventTypes: new Set() });
modules.get(moduleName).examples.push(name);
}
const moduleList = Array.from(modules.values()).map(m => ({ ...m, eventTypes: Array.from(m.eventTypes), examples: Array.from(new Set(m.examples)) }))
.sort((a, b) => a.priority - b.priority || b.count - a.count || a.module.localeCompare(b.module));
return {
eventCount: events.length,
uniquePathCount: paths.size,
modulePriorities: moduleList,
runtimeErrors: summary.runtimeErrors || [],
missingGlobals: summary.missingGlobals || [],
missingMethods: summary.missingMethods || [],
missingProperties: summary.missingProperties || [],
specialObjects: summary.specialObjects || [],
proxyRiskSignals: Array.from(riskSignals).slice(0, 100),
recommendation: moduleList.slice(0, 5).map(m => `优先补齐 ${m.module}`),
};
}
function renderMarkdown(result) {
const lines = ['# trace 分析与补齐优先级', '', `- trace 事件数:${result.eventCount}`, `- 唯一路径数:${result.uniquePathCount}`, `- 运行错误数:${result.runtimeErrors.length}`];
lines.push('', '## 模块优先级');
if (!result.modulePriorities.length) lines.push('- 未发现可归类的环境访问');
for (const m of result.modulePriorities) {
lines.push(`- P${m.priority} ${m.module}:${m.count} 次;示例:${m.examples.slice(0, 5).join(', ') || '无'}`);
}
if (result.proxyRiskSignals.length) {
lines.push('', '## Proxy / native-like 风险');
for (const s of result.proxyRiskSignals.slice(0, 20)) lines.push(`- ${s}`);
}
if (result.runtimeErrors.length) {
lines.push('', '## 运行错误');
for (const e of result.runtimeErrors) lines.push(`- ${e.type || 'error'}:${e.message || JSON.stringify(e)}`);
}
lines.push('', '## 建议下一步');
for (const r of result.recommendation) lines.push(`- ${r}`);
if (!result.recommendation.length) lines.push('- 先确认入口函数是否执行、fixture 是否完整。');
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
if (!args.trace && !args.summary) throw new Error('必须提供 --trace 或 --summary');
const events = readTrace(args.trace);
const summary = readJsonMaybe(args.summary, {});
const result = analyze(events, summary);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,384 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: '',
traces: [],
maxLines: 200000,
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--trace' || arg === '--input' || arg === '-i') args.traces.push(argv[++i] || '');
else if (arg === '--max-lines') args.maxLines = Number(argv[++i] || args.maxLines);
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/analyze_trace_complexity.js --case-dir case --markdown
node scripts/analyze_trace_complexity.js --trace case/ruyi-trace/logs/trace.ndjson --json
node scripts/analyze_trace_complexity.js --trace case/tmp/env-trace.jsonl --markdown
说明:基于 RuyiTrace / Node trace 日志评估补环境复杂度、风险点和优先级。复杂度评估只用于理解项目,不自动决定是否使用 isolated-vm、vm 或 jsEnv。`;
}
function exists(p) {
try {
fs.accessSync(p);
return true;
} catch {
return false;
}
}
function stat(p) {
try {
return fs.statSync(p);
} catch {
return null;
}
}
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try {
names = fs.readdirSync(p);
} catch {
names = [];
}
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) {
out.push(p);
}
return out;
}
function rel(root, p) {
return (path.relative(root, p) || '.').replace(/\\/g, '/');
}
function uniq(arr) {
return [...new Set(arr)].filter(Boolean).sort();
}
const CATEGORY_PATTERNS = [
['navigator', /\bnavigator\b|Navigator\.prototype|userAgent|webdriver|plugins|mimeTypes|hardwareConcurrency|deviceMemory/ig],
['document', /\bdocument\b|Document\.prototype|document\.cookie|querySelector|createElement|getElementById|documentElement|document\.all/ig],
['iframe-realm', /iframe|contentWindow|contentDocument|defaultView|srcdoc|document\.write|document\.close/ig],
['location', /\blocation\b|Location\.prototype|href|origin|hostname|pathname|search|hash/ig],
['screen', /\bscreen\b|Screen\.prototype|availWidth|availHeight|colorDepth|pixelDepth|orientation/ig],
['storage', /localStorage|sessionStorage|Storage\.prototype|getItem|setItem|removeItem/ig],
['cookie', /document\.cookie|Set-Cookie|\bcookie\b/ig],
['crypto', /\bcrypto\b|getRandomValues|randomUUID|SubtleCrypto|crypto\.subtle/ig],
['performance-time', /performance|performance\.now|timeOrigin|Date\.now|new Date|setTimeout|requestAnimationFrame/ig],
['canvas', /HTMLCanvasElement|CanvasRenderingContext2D|toDataURL|toBlob|getImageData|measureText|canvas/ig],
['webgl', /WebGLRenderingContext|WebGL2RenderingContext|getParameter|readPixels|getSupportedExtensions|getShaderPrecisionFormat|webgl/ig],
['webgpu', /navigator\.gpu|GPUAdapter|requestAdapter|webgpu/ig],
['audio', /AudioContext|OfflineAudioContext|AnalyserNode|startRendering|getChannelData/ig],
['dom-geometry', /getBoundingClientRect|DOMRect|offsetWidth|offsetHeight|clientWidth|clientHeight|getClientRects/ig],
['dom-cssom', /DOMParser|innerHTML|HTMLUnknownElement|HTMLTextAreaElement|HTMLOptionElement|HTMLSelectElement|HTMLCollection|ShadowRoot|CSSStyleSheet|CSSStyleRule|CSSRuleList|getComputedStyle/ig],
['worker', /\bWorker\b|importScripts|postMessage|MessageChannel|MessagePort|BroadcastChannel/ig],
['wasm', /WebAssembly|instantiateStreaming|compileStreaming|\.wasm/ig],
['network', /\bfetch\b|XMLHttpRequest|Request\.prototype|Response\.prototype|Headers\.prototype|sendBeacon/ig],
['xhr-fetch-session-bridge', /live-session-bridge|offline-fixture|XMLHttpRequest|fetch|sendBeacon|curl_cffi|curl-cffi|CycleTLS|impers/ig],
['object-shape', /Object\.keys|Object\.getOwnPropertyNames|Object\.getOwnPropertyDescriptor|Object\.getOwnPropertySymbols|Reflect\.ownKeys|hasOwnProperty|propertyIsEnumerable|brand check|prototype walk/ig],
['private-state-leakage', /__readyState|__headers|__children|__parentNode|__responseHeaders|Object\.defineProperty\s*\([^)]*['"]_{1,2}[A-Za-z]|\bthis\s*\.\s*_{1,2}[A-Za-z]/ig],
['indexedDB', /indexedDB|IDBFactory|IDBRequest|IDBOpenDBRequest|IDBKeyRange|IDBDatabase/ig],
['history', /\bhistory\b|pushState|replaceState|popstate|referrer/ig],
['event-clone-error', /EventTarget|structuredClone|DataCloneError|ownKeys|ownNames|Reflect\.ownKeys|getOwnPropertyNames/ig],
['clock-timer', /Date\.now|performance\.now|setTimeout|clearTimeout|queueMicrotask|requestAnimationFrame/ig],
['writer-branch', /reload writer|form writer|final writer|HTMLFormElement\.submit|Location\.reload|cf-chl-gen|cf-chl-out|generatedForm/ig],
['performance-timeline', /PerformanceObserver|PerformanceResourceTiming|PerformanceEntry|PerformanceObserverEntryList|getEntries|PerformancePaintTiming|performance\.mark/ig],
];
const REALISM_PATTERNS = [
['Function.prototype.toString', /Function\.prototype\.toString|\.toString\s*\(\)/ig],
['Object.prototype.toString', /Object\.prototype\.toString|\[object\s+[A-Za-z]+]/ig],
['descriptor', /Object\.getOwnPropertyDescriptor|Object\.getOwnPropertyDescriptors|defineProperty|getOwnPropertyDescriptor/ig],
['prototype', /Object\.getPrototypeOf|setPrototypeOf|__proto__|prototype/ig],
['ownKeys', /Reflect\.ownKeys|Object\.keys|getOwnPropertyNames|getOwnPropertySymbols/ig],
['instanceof', /\binstanceof\b/ig],
['constructor.name', /constructor\.name|\.constructor\b/ig],
['Symbol.toStringTag', /Symbol\.toStringTag|@@toStringTag/ig],
];
const ASYNC_PATTERNS = [
['Promise', /\bPromise\b|queueMicrotask|then\(/ig],
['timer', /setTimeout|setInterval|requestAnimationFrame|cancelAnimationFrame/ig],
['message', /postMessage|MessageChannel|BroadcastChannel|message event/ig],
['observer', /MutationObserver|IntersectionObserver|ResizeObserver|PerformanceObserver/ig],
['xhr-fetch', /\bfetch\b|XMLHttpRequest|readystatechange|loadend/ig],
['indexedDB-async', /IDBRequest|onsuccess|onerror|transaction|openCursor/ig],
];
function countMatches(text, pattern) {
pattern.lastIndex = 0;
let count = 0;
while (pattern.exec(text)) count += 1;
return count;
}
function emptySignalMap(items) {
return Object.fromEntries(items.map(([name]) => [name, 0]));
}
function discoverTraceFiles(args) {
const files = [];
for (const item of args.traces) {
const resolved = path.resolve(item);
if (exists(resolved)) files.push(resolved);
}
if (args.caseDir) {
const caseDir = path.resolve(args.caseDir);
const candidates = [
path.join(caseDir, 'ruyi-trace', 'logs'),
path.join(caseDir, 'tmp'),
path.join(caseDir, 'notes'),
];
for (const candidate of candidates) {
for (const file of walk(candidate)) {
if (/\.(ndjson|jsonl|json)$/i.test(file) && /trace|missing-env|ruyi|env/i.test(path.basename(file))) {
files.push(file);
}
}
}
}
return uniq(files);
}
function inspectLine(text, summary) {
for (const [name, pattern] of CATEGORY_PATTERNS) {
summary.categories[name] += countMatches(text, pattern);
}
for (const [name, pattern] of REALISM_PATTERNS) {
summary.realism[name] += countMatches(text, pattern);
}
for (const [name, pattern] of ASYNC_PATTERNS) {
summary.async[name] += countMatches(text, pattern);
}
const stackMatches = text.match(/(?:stack\.file|filename|file|url)["']?\s*[:=]\s*["']?([^"',\s)]+)/ig);
if (stackMatches) {
for (const raw of stackMatches.slice(0, 20)) summary.stackSignals.add(raw.slice(0, 160));
}
}
function analyzeFile(file, args) {
const result = {
file,
lines: 0,
bytes: 0,
truncatedByMaxLines: false,
parseErrors: 0,
};
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
result.bytes = Buffer.byteLength(text);
const lines = text.split(/\r?\n/);
for (const line of lines) {
if (!line.trim()) continue;
if (result.lines >= args.maxLines) {
result.truncatedByMaxLines = true;
break;
}
result.lines += 1;
}
return { result, lines: lines.slice(0, args.maxLines) };
}
function scoreSummary(summary) {
const hitCategories = Object.entries(summary.categories).filter(([, count]) => count > 0).map(([name]) => name);
const hitRealism = Object.entries(summary.realism).filter(([, count]) => count > 0).map(([name]) => name);
const hitAsync = Object.entries(summary.async).filter(([, count]) => count > 0).map(([name]) => name);
const fingerprintHits = ['canvas', 'webgl', 'webgpu', 'audio', 'dom-geometry', 'dom-cssom'].filter(name => summary.categories[name] > 0);
const stateHits = ['storage', 'cookie', 'indexedDB', 'history', 'performance-time', 'performance-timeline', 'crypto', 'clock-timer'].filter(name => summary.categories[name] > 0);
const envMatrixHitMap = [
['iframe-realm', 'iframe-realm'],
['worker', 'worker-task'],
['performance-timeline', 'performance-timeline'],
['dom-cssom', 'dom-cssom'],
['event-clone-error', 'event-clone-error'],
['xhr-fetch-session-bridge', 'xhr-fetch-session-bridge'],
['object-shape', 'object-shape'],
['private-state-leakage', 'private-state-leakage'],
['clock-timer', 'clock-timer'],
['writer-branch', 'writer-branch'],
];
const envMatrixHits = envMatrixHitMap
.filter(([source]) => summary.categories[source] > 0)
.map(([, id]) => id);
let score = 0;
score += hitCategories.length * 2;
score += hitRealism.length * 3;
score += fingerprintHits.length * 4;
score += hitAsync.length * 2;
score += stateHits.length * 2;
score += envMatrixHits.length * 3;
if (summary.stackSignals.size >= 10) score += 6;
else if (summary.stackSignals.size >= 3) score += 3;
let level = '未知';
if (summary.files.length === 0 || summary.totalLines === 0) level = '未知';
else if (score >= 45 || fingerprintHits.length >= 3 || hitRealism.length >= 5) level = '高';
else if (score >= 18 || fingerprintHits.length >= 1 || hitRealism.length >= 2) level = '中';
else level = '低';
return {
score,
level,
hitCategories,
hitRealism,
hitAsync,
fingerprintHits,
stateHits,
envMatrixHits,
stackSignalCount: summary.stackSignals.size,
};
}
function analyze(args) {
const files = discoverTraceFiles(args);
const root = args.caseDir ? path.resolve(args.caseDir) : process.cwd();
const summary = {
files: files.map(file => rel(root, file)),
totalLines: 0,
totalBytes: 0,
categories: emptySignalMap(CATEGORY_PATTERNS),
realism: emptySignalMap(REALISM_PATTERNS),
async: emptySignalMap(ASYNC_PATTERNS),
fileSummaries: [],
stackSignals: new Set(),
parseErrors: 0,
};
for (const file of files) {
const { result, lines } = analyzeFile(file, args);
summary.fileSummaries.push({
...result,
file: rel(root, file),
});
summary.totalLines += result.lines;
summary.totalBytes += result.bytes;
for (const line of lines) {
if (!line.trim()) continue;
inspectLine(line, summary);
}
}
const score = scoreSummary(summary);
return {
passed: true,
complexity: score.level,
score: score.score,
source: {
files: summary.files,
totalLines: summary.totalLines,
totalBytes: summary.totalBytes,
fileSummaries: summary.fileSummaries,
},
signals: {
categories: summary.categories,
realism: summary.realism,
async: summary.async,
hitCategories: score.hitCategories,
hitRealism: score.hitRealism,
hitAsync: score.hitAsync,
fingerprintHits: score.fingerprintHits,
stateHits: score.stateHits,
envMatrixHits: score.envMatrixHits,
stackSignalCount: score.stackSignalCount,
},
conclusion: {
frameworkBinding: false,
note: 'Trace 复杂度评估只用于补环境范围、风险点和优先级,不自动决定是否使用 isolated-vm、vm 或 jsEnv。',
},
};
}
function renderMarkdown(result) {
const lines = [];
lines.push('# Trace 复杂度评估报告');
lines.push('');
lines.push(`- 复杂度等级:${result.complexity}`);
lines.push(`- 分数:${result.score}`);
lines.push(`- Trace 文件数:${result.source.files.length}`);
lines.push(`- Trace 总行数:${result.source.totalLines}`);
lines.push('- 与补环境框架关系:复杂度评估不决定框架选择,是否使用 isolated-vm / vm / jsEnv 仍以用户确认为准。');
lines.push('');
if (!result.source.files.length) {
lines.push('未发现可用 Trace 日志,无法基于日志评估复杂度。');
lines.push('');
return lines.join('\n');
}
lines.push('## 命中的 WebAPI 类别');
lines.push('');
lines.push(result.signals.hitCategories.length ? result.signals.hitCategories.map(x => `- ${x}:${result.signals.categories[x]}`).join('\n') : '- 无明显命中');
lines.push('');
lines.push('## 真实性检测信号');
lines.push('');
lines.push(result.signals.hitRealism.length ? result.signals.hitRealism.map(x => `- ${x}:${result.signals.realism[x]}`).join('\n') : '- 无明显命中');
lines.push('');
lines.push('## 指纹、异步和状态风险');
lines.push('');
lines.push(`- 指纹 API:${result.signals.fingerprintHits.join('、') || '无明显命中'}`);
lines.push(`- 异步链路:${result.signals.hitAsync.join('、') || '无明显命中'}`);
lines.push(`- 状态依赖:${result.signals.stateHits.join('、') || '无明显命中'}`);
lines.push(`- WebAPI 环境检测矩阵触发类:${result.signals.envMatrixHits.join('、') || '无明显命中'}`);
lines.push(`- 调用栈分散信号数:${result.signals.stackSignalCount}`);
lines.push('');
lines.push('## 文件摘要');
lines.push('');
lines.push('| 文件 | 行数 | 字节数 | 达到最大行限制 |');
lines.push('|---|---:|---:|---|');
for (const item of result.source.fileSummaries) {
lines.push(`| ${item.file} | ${item.lines} | ${item.bytes} | ${item.truncatedByMaxLines ? '是' : '否'} |`);
}
lines.push('');
lines.push('## 使用说明');
lines.push('');
lines.push('- 该报告用于补环境范围、优先级和阶段报告。');
lines.push('- 不要把复杂度等级写成自动选择补环境框架的依据。');
lines.push('- 补环境框架选择仍然必须由用户确认;用户未选择时默认不使用框架。');
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
const result = analyze(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
else console.log(renderMarkdown(result));
}
try {
main();
} catch (error) {
console.error(error && error.message ? error.message : String(error));
process.exitCode = 2;
}

View File

@@ -0,0 +1,398 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const STATEFUL_API_PATTERN = /XMLHttpRequest|fetch|sendBeacon|readyState|readystatechange|loadend|abort|timeout|location\.reload|beforeunload|pagehide|visibilitychange|unload|Worker|SharedWorker|MessagePort|MessageChannel|postMessage|terminate|\.close|appendChild|removeChild|insertBefore|replaceChild|replaceChildren|innerHTML|textContent|querySelector|MutationObserver/i;
const MAX_TIMELINE_SEQUENCE = 10000;
function parseArgs(argv) {
const args = {
caseDir: '',
traces: [],
out: '',
baselineId: '',
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--trace' || arg === '--input' || arg === '-i') args.traces.push(argv[++i] || '');
else if (arg === '--out') args.out = argv[++i] || '';
else if (arg === '--baseline-id') args.baselineId = argv[++i] || '';
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/build_trace_runtime_contract.js --case-dir case --markdown
node scripts/build_trace_runtime_contract.js --trace case/ruyi-trace/logs/trace.ndjson --out case/notes/trace-runtime-contract.json --baseline-id fp-001 --json
说明:从原始 Trace 生成逐 API、realm、receiver、访问类型和行为观测组成的 runtime 契约。该契约是后续 Node audit 的机器可验证输入,不是人工填写的覆盖清单。`;
}
function exists(file) {
try { return fs.existsSync(file); } catch { return false; }
}
function stat(file) {
try { return fs.statSync(file); } catch { return null; }
}
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 sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
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 digestValue(value) {
if (typeof value === 'undefined') return '';
try { return sha256(JSON.stringify(stable(value))); } catch { return sha256(String(value)); }
}
function rel(root, file) {
return (path.relative(root, file) || '.').replace(/\\/g, '/');
}
function discoverTraceFiles(args, caseDir) {
const files = [];
for (const item of args.traces) {
const file = path.resolve(item);
if (exists(file)) files.push(file);
}
if (!args.traces.length) {
const roots = [
path.join(caseDir, 'ruyi-trace', 'logs'),
path.join(caseDir, 'tmp'),
];
for (const root of roots) {
for (const file of walk(root)) {
const name = path.basename(file);
if (/\.(ndjson|jsonl|json)$/i.test(file) && /trace|ruyi|env/i.test(name) && !/audit|contract|summary/i.test(name)) {
files.push(file);
}
}
}
}
return [...new Set(files.map(file => path.resolve(file)))].sort();
}
function parseTraceFile(file) {
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
if (/\.json$/i.test(file)) {
const raw = JSON.parse(text);
if (Array.isArray(raw)) return raw;
if (Array.isArray(raw.events)) return raw.events;
if (Array.isArray(raw.items)) return raw.items;
if (Array.isArray(raw.logs)) return raw.logs;
return [raw];
}
const events = [];
for (const [index, line] of text.split(/\r?\n/).entries()) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
event.__line = index + 1;
events.push(event);
} catch (err) {
events.push({ type: 'parse-error', api: `line:${index + 1}`, error: { name: 'SyntaxError', message: err.message }, __line: index + 1 });
}
}
return events;
}
function firstValue(object, keys, fallback) {
if (arguments.length < 3) fallback = '';
for (const key of keys) {
if (object
&& Object.prototype.hasOwnProperty.call(object, key)
&& typeof object[key] !== 'undefined'
&& object[key] !== null
&& object[key] !== '') return object[key];
}
return fallback;
}
function normalizeText(value) {
if (typeof value === 'string') return value;
if (value === null || typeof value === 'undefined') return '';
if (typeof value === 'object') {
return firstValue(value, ['class', 'interface', 'name', 'type', 'id', 'path', 'value'], JSON.stringify(stable(value)));
}
return String(value);
}
function normalizeTraceMember(value) {
const text = normalizeText(value);
return text.replace(/^(get|set|call|construct)\s+/i, '').trim();
}
function normalizeStack(event) {
const stack = Array.isArray(event.stack) ? event.stack : [];
const frame = stack.find(item => item && (item.file || item.filename || item.url || item.line || item.lineNumber));
if (!frame) return '';
const file = firstValue(frame, ['file', 'filename', 'url'], '');
const line = firstValue(frame, ['line', 'lineNumber'], '');
const col = firstValue(frame, ['col', 'column', 'columnNumber'], '');
return [file, line, col].filter(value => value !== '').join(':');
}
function priorityOf(api, event) {
const explicit = normalizeText(firstValue(event, ['priority', 'level'], '')).toUpperCase();
if (/^P[0-2]$/.test(explicit)) return explicit;
if (/XMLHttpRequest|fetch|sendBeacon|window|document|location|navigator|cookie|Storage|Function\.prototype\.toString|reload|writer/i.test(api)) return 'P0';
if (/Object\.|Reflect\.|prototype|descriptor|EventTarget|Performance|Worker|Message|Canvas|WebGL|Audio|screen|Permissions|Plugin|MimeType|DOM|CSS/i.test(api)) return 'P1';
return 'P2';
}
function normalizeObservation(event) {
const interfaceName = normalizeText(firstValue(event, ['interface', 'interfaceName'], ''));
const member = normalizeTraceMember(firstValue(event, ['member', 'property', 'method'], ''));
const explicitApi = normalizeText(firstValue(event, ['api', 'path', 'name', 'prop'], ''));
const api = explicitApi || [interfaceName, member].filter(Boolean).join('.');
if (!api) return null;
const accessType = normalizeText(firstValue(event, ['accessType', 'operation', 'op', 'type'], 'unknown'));
const realm = normalizeText(firstValue(event, ['realmId', 'realm', 'contextId', 'frameId', 'workerId'], 'main'));
const receiver = normalizeText(firstValue(event, ['receiver', 'receiverType', 'thisType', 'target', 'object', 'thisObj'], ''));
const phase = normalizeText(firstValue(event, ['phase', 'writer', 'navigationPhase', 'stage'], ''));
const descriptor = firstValue(event, ['descriptor', 'propertyDescriptor'], undefined);
const prototypeChain = firstValue(event, ['prototypeChain', 'prototypes', 'protoChain'], undefined);
const ownKeys = firstValue(event, ['ownKeys', 'reflectOwnKeys'], undefined);
const ownPropertyNames = firstValue(event, ['ownPropertyNames', 'propertyNames'], undefined);
const ownSymbols = firstValue(event, ['ownSymbols', 'ownPropertySymbols'], undefined);
const result = firstValue(event, ['returnValue', 'result', 'value'], undefined);
const args = firstValue(event, ['arguments', 'args', 'parameters'], undefined);
const error = firstValue(event, ['error', 'exception', 'throw'], undefined);
const brand = normalizeText(firstValue(event, ['brand', 'toStringTag', 'objectToString'], ''));
const constructorName = normalizeText(firstValue(event, ['constructorName', 'constructor'], ''));
const owner = normalizeText(firstValue(event, ['owner', 'definedOn', 'prototypeOwner'], ''));
const sideEffects = firstValue(event, ['sideEffects', 'effects', 'mutations'], undefined);
return {
api,
accessType,
realm,
receiver,
phase,
owner,
brand,
constructorName,
descriptor,
prototypeChain,
ownKeys,
ownPropertyNames,
ownSymbols,
argsDigest: digestValue(args),
resultDigest: digestValue(result),
errorDigest: digestValue(error),
sideEffectsDigest: digestValue(sideEffects),
stack: normalizeStack(event),
sequence: Number(firstValue(event, ['sequence', 'seq', 'index', '__line'], 0)) || 0,
};
}
function contractKey(item) {
return [item.api, item.accessType, item.realm, item.receiver, item.phase].join('\u001f');
}
function addDistinct(target, value, limit = 20) {
if (value === '' || typeof value === 'undefined') return;
const digest = digestValue(value);
if (target.some(item => item.digest === digest)) return;
if (target.length < limit) target.push({ digest, value });
}
function orderStatefulTimeline(events) {
const sourceFiles = [...new Set(events.map(event => event.sourceFile))];
if (sourceFiles.length <= 1) {
return [...events].sort((a, b) => a.fileOrder - b.fileOrder);
}
const sequences = events.map(event => event.traceSequence);
const unique = new Set(sequences);
if (sequences.some(sequence => !Number.isFinite(sequence) || sequence <= 0)
|| unique.size !== sequences.length) {
throw new Error('多文件 Trace 无法建立唯一全局时间线;请合并为单一完整 Trace,或确保所有事件包含跨文件唯一递增 sequence。');
}
return [...events].sort((a, b) => a.traceSequence - b.traceSequence);
}
function buildStatefulTimeline(events) {
const ordered = orderStatefulTimeline(events);
const sequence = [];
for (const event of ordered) {
if (sequence[sequence.length - 1] !== event.contractId) sequence.push(event.contractId);
}
return {
schemaVersion: 'trace-runtime-timeline/v1',
required: sequence.length > 0,
eventCount: ordered.length,
collapsedCount: sequence.length,
truncated: sequence.length > MAX_TIMELINE_SEQUENCE,
sourceFiles: [...new Set(ordered.map(event => event.sourceFile))],
sequence: sequence.slice(0, MAX_TIMELINE_SEQUENCE),
sequenceSha256: sha256(JSON.stringify(sequence)),
};
}
function buildContract(args) {
const caseDir = path.resolve(args.caseDir || '.');
const files = discoverTraceFiles(args, caseDir);
if (!files.length) throw new Error('未找到原始 Trace 文件;请使用 --trace 指定,或把日志放入 case/ruyi-trace/logs/。');
if (!args.traces.length && files.length > 1) {
throw new Error(`自动发现 ${files.length} 份原始 Trace;禁止跨历史采集静默合并。请使用 --trace 显式选择当前 baseline 的完整 Trace,分片文件可重复传入 --trace。`);
}
const sourceFiles = [];
const groups = new Map();
const statefulTimelineEvents = [];
let eventCount = 0;
for (const file of files) {
const text = fs.readFileSync(file);
sourceFiles.push({ file: rel(caseDir, file), sha256: sha256(text), bytes: text.length });
const events = parseTraceFile(file);
for (const [fileEventOrder, event] of events.entries()) {
eventCount += 1;
const item = normalizeObservation(event);
if (!item) continue;
const key = contractKey(item);
if (!groups.has(key)) {
groups.set(key, {
id: sha256(key).slice(0, 24),
api: item.api,
accessType: item.accessType,
realm: item.realm,
receiver: item.receiver,
phase: item.phase,
priority: priorityOf(item.api, event),
count: 0,
sequences: [],
stacks: [],
assertions: {
owner: [],
brand: [],
constructorName: [],
descriptor: [],
prototypeChain: [],
ownKeys: [],
ownPropertyNames: [],
ownSymbols: [],
argsDigest: [],
resultDigest: [],
errorDigest: [],
sideEffectsDigest: [],
},
});
}
const group = groups.get(key);
group.count += 1;
if (item.sequence && group.sequences.length < 50) group.sequences.push(item.sequence);
if (item.stack && !group.stacks.includes(item.stack) && group.stacks.length < 10) group.stacks.push(item.stack);
for (const field of Object.keys(group.assertions)) addDistinct(group.assertions[field], item[field]);
if (STATEFUL_API_PATTERN.test(item.api)) {
statefulTimelineEvents.push({
contractId: group.id,
sourceFile: rel(caseDir, file),
fileOrder: fileEventOrder,
traceSequence: item.sequence,
realm: item.realm,
phase: item.phase,
api: item.api,
});
}
}
}
const contracts = [...groups.values()].sort((a, b) =>
a.priority.localeCompare(b.priority) || a.api.localeCompare(b.api) || a.realm.localeCompare(b.realm)
);
const traceSourceHash = sha256(JSON.stringify(sourceFiles.map(item => [item.file, item.sha256])));
const timeline = buildStatefulTimeline(statefulTimelineEvents);
const contract = {
schemaVersion: 'trace-runtime-contract/v3',
generatedBy: 'build_trace_runtime_contract.js',
generatedAt: new Date().toISOString(),
baselineId: args.baselineId || '',
traceSourceHash,
sourceFiles,
eventCount,
contractCount: contracts.length,
contracts,
timeline,
};
contract.contractHash = sha256(JSON.stringify(stable({
schemaVersion: contract.schemaVersion,
baselineId: contract.baselineId,
traceSourceHash: contract.traceSourceHash,
contracts: contract.contracts,
timeline: contract.timeline,
})));
const out = path.resolve(args.out || path.join(caseDir, 'notes', 'trace-runtime-contract.json'));
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
return { caseDir, out, contract };
}
function renderMarkdown(result) {
const p0 = result.contract.contracts.filter(item => item.priority === 'P0').length;
const p1 = result.contract.contracts.filter(item => item.priority === 'P1').length;
return [
'# Trace-runtime 行为契约生成结果',
'',
`- 输出:${result.out}`,
`- baselineId:${result.contract.baselineId || '未指定'}`,
`- Trace 文件数:${result.contract.sourceFiles.length}`,
`- Trace 事件数:${result.contract.eventCount}`,
`- 行为契约数:${result.contract.contractCount}`,
`- P0:${p0}`,
`- P1:${p1}`,
`- 状态事件:${result.contract.timeline.eventCount}`,
`- 折叠时间线:${result.contract.timeline.collapsedCount}`,
`- traceSourceHash:${result.contract.traceSourceHash}`,
`- contractHash:${result.contract.contractHash}`,
'',
'下一步必须运行 Node audit 并使用 check_trace_runtime_conformance.js 深度比较;不得手工把契约状态改成 matched。',
].join('\n') + '\n';
}
if (require.main === module) {
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = buildContract(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
}
module.exports = { buildContract, normalizeObservation, digestValue, stable };

View File

@@ -0,0 +1,284 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
function parseArgs(argv) {
const args = {
url: '',
caseDir: 'case',
outDir: '',
profileDir: '',
ruyitraceHome: '',
ruyitraceExe: '',
duration: 60,
limit: 200000,
dryRun: false,
importAfter: false,
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--url') args.url = argv[++i] || '';
else if (a === '--case-dir' || a === '--dir') args.caseDir = argv[++i] || '';
else if (a === '--out-dir') args.outDir = argv[++i] || '';
else if (a === '--profile-dir') args.profileDir = argv[++i] || '';
else if (a === '--ruyitrace-home') args.ruyitraceHome = argv[++i] || '';
else if (a === '--ruyitrace-exe') args.ruyitraceExe = argv[++i] || '';
else if (a === '--duration') args.duration = Number(argv[++i] || '60');
else if (a === '--limit') args.limit = Number(argv[++i] || '200000');
else if (a === '--dry-run') args.dryRun = true;
else if (a === '--import-after') args.importAfter = 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;
if (!Number.isFinite(args.duration) || args.duration <= 0) args.duration = 60;
if (!Number.isFinite(args.limit) || args.limit <= 0) args.limit = 200000;
return args;
}
function usage() {
return `用法:
node scripts/capture_ruyitrace_log.js --url <target-page-url> --case-dir case --ruyitrace-home <RuyiTrace-dir> --markdown
node scripts/capture_ruyitrace_log.js --url <target-page-url> --case-dir case --duration 90 --import-after --markdown
node scripts/capture_ruyitrace_log.js --url <target-page-url> --case-dir case --dry-run --json
说明:确认 RuyiTrace 已安装后,优先使用随 RuyiTrace 提供的 trace Firefox 和 MOZ_DOM_TRACE 环境变量自动捕获 NDJSON。自动捕获失败、需要登录/验证码/权限交互、或目标路径未覆盖时,才要求用户手动协助。`;
}
function exists(p) {
try { return !!p && fs.existsSync(p); } catch { return false; }
}
function isDir(p) {
try { return !!p && fs.statSync(p).isDirectory(); } catch { return false; }
}
function ensureDir(p) {
fs.mkdirSync(p, { recursive: true });
}
function run(cmd, args, timeout = 8000) {
const ret = spawnSync(cmd, args, { encoding: 'utf8', timeout, windowsHide: true });
return { ok: ret.status === 0, stdout: ret.stdout || '', stderr: ret.stderr || '' };
}
function whereCommand(name) {
const cmd = process.platform === 'win32' ? 'where' : 'which';
const ret = run(cmd, [name]);
return ret.ok ? ret.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : [];
}
function normalizeTraceHome(args) {
if (args.ruyitraceHome) return path.resolve(args.ruyitraceHome);
if (args.ruyitraceExe) return path.dirname(path.resolve(args.ruyitraceExe));
if (process.env.RUYI_TRACE_HOME) return path.resolve(process.env.RUYI_TRACE_HOME);
if (process.env.RUYITRACE_HOME) return path.resolve(process.env.RUYITRACE_HOME);
const found = whereCommand(process.platform === 'win32' ? 'RuyiTrace.exe' : 'RuyiTrace');
return found.length ? path.dirname(found[0]) : '';
}
function detectRuyiTrace(args) {
const home = normalizeTraceHome(args);
const exeName = process.platform === 'win32' ? 'RuyiTrace.exe' : 'RuyiTrace';
const exe = args.ruyitraceExe ? path.resolve(args.ruyitraceExe) : (home ? path.join(home, exeName) : '');
const firefoxExe = home ? path.join(home, 'firefox', process.platform === 'win32' ? 'firefox.exe' : 'firefox') : '';
const marker = home ? path.join(home, 'firefox', 'RUYI_DOMTRACE.txt') : '';
const installed = exists(exe) && exists(firefoxExe) && exists(marker);
return {
installed,
home,
exe,
exeExists: exists(exe),
firefoxExe,
firefoxExists: exists(firefoxExe),
marker,
markerExists: exists(marker),
reason: installed ? '' : 'RuyiTrace 不完整:需要 RuyiTrace 可执行文件、firefox 可执行文件以及 firefox/RUYI_DOMTRACE.txt',
};
}
function timestamp() {
return new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14);
}
function buildPlan(args, trace) {
const caseDir = path.resolve(args.caseDir || 'case');
const outDir = path.resolve(args.outDir || path.join(caseDir, 'ruyi-trace', 'logs'));
const profileDir = path.resolve(args.profileDir || path.join(caseDir, 'tmp', 'ruyitrace-profile'));
const traceFile = path.join(outDir, `trace-${timestamp()}.ndjson`);
const firefoxArgs = ['-no-remote', '-new-instance', '-profile', profileDir];
if (args.url) firefoxArgs.push(args.url);
return {
caseDir,
outDir,
profileDir,
traceFile,
firefoxExe: trace.firefoxExe,
firefoxArgs,
env: {
MOZ_DOM_TRACE: '1',
MOZ_DOM_TRACE_FILE: traceFile,
MOZ_DOM_TRACE_LIMIT: String(args.limit),
MOZ_DISABLE_LAUNCHER_PROCESS: '1',
},
};
}
function listNdjsonFiles(dir, sinceMs) {
if (!isDir(dir)) return [];
return fs.readdirSync(dir)
.filter((name) => name.toLowerCase().endsWith('.ndjson'))
.map((name) => path.join(dir, name))
.filter((file) => {
try { return fs.statSync(file).mtimeMs >= sinceMs - 1000; } catch { return false; }
})
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function waitForExit(child, timeoutMs) {
return new Promise((resolve) => {
let done = false;
const finish = (value) => {
if (done) return;
done = true;
resolve(value);
};
child.once('exit', (code, signal) => finish({ exited: true, code, signal }));
setTimeout(() => finish({ exited: false, code: null, signal: null }), timeoutMs).unref();
});
}
function importLog(caseDir, file, markdown) {
const script = path.join(__dirname, 'import_ruyitrace_log.js');
const args = [script, '--input', file, '--case-dir', caseDir, '--truncation-threshold', '3900', markdown ? '--markdown' : '--json'];
const ret = spawnSync(process.execPath, args, { encoding: 'utf8', windowsHide: true });
return { ok: ret.status === 0, status: ret.status, stdout: ret.stdout || '', stderr: ret.stderr || '' };
}
async function capture(args, plan) {
ensureDir(plan.outDir);
ensureDir(plan.profileDir);
const startedAt = Date.now();
const child = spawn(plan.firefoxExe, plan.firefoxArgs, {
env: { ...process.env, ...plan.env },
stdio: 'ignore',
windowsHide: false,
});
const result = {
launched: true,
pid: child.pid,
waitedSeconds: args.duration,
killAttempted: false,
exit: null,
logs: [],
importResult: null,
};
child.on('error', (err) => { result.launchError = err.message || String(err); });
await wait(args.duration * 1000);
const exitBeforeKill = await waitForExit(child, 200);
if (!exitBeforeKill.exited) {
result.killAttempted = true;
try { child.kill(); } catch (err) { result.killError = err.message || String(err); }
result.exit = await waitForExit(child, 3000);
} else {
result.exit = exitBeforeKill;
}
result.logs = listNdjsonFiles(plan.outDir, startedAt);
if (args.importAfter && result.logs.length) {
result.importResult = importLog(plan.caseDir, result.logs[0], args.markdown);
}
return result;
}
function renderMarkdown(obj) {
const { args, trace, plan, result } = obj;
const lines = ['# RuyiTrace 自动捕获日志', ''];
lines.push(`- RuyiTrace 检测结果:${trace.installed ? '通过' : '不通过'}`);
if (trace.home) lines.push(`- RuyiTrace 目录:${trace.home}`);
if (trace.exe) lines.push(`- RuyiTrace 可执行文件:${trace.exeExists ? '存在' : '不存在'} - ${trace.exe}`);
if (trace.firefoxExe) lines.push(`- trace Firefox:${trace.firefoxExists ? '存在' : '不存在'} - ${trace.firefoxExe}`);
if (trace.marker) lines.push(`- trace 标志文件:${trace.markerExists ? '存在' : '不存在'} - ${trace.marker}`);
if (trace.reason) lines.push(`- 原因:${trace.reason}`);
lines.push('', '## 自动捕获计划');
lines.push(`- 目标页面:${args.url || '未提供'}`);
lines.push(`- 输出目录:${plan.outDir}`);
lines.push(`- Profile 目录:${plan.profileDir}`);
lines.push(`- 计划 trace 文件:${plan.traceFile}`);
lines.push(`- 采集时长:${args.duration} 秒`);
lines.push(`- DOM trace 行数上限:${args.limit}`);
lines.push(`- 启动参数:${[plan.firefoxExe].concat(plan.firefoxArgs).join(' ')}`);
lines.push('- 环境变量:MOZ_DOM_TRACE=1,MOZ_DOM_TRACE_FILE=<case trace file>,MOZ_DOM_TRACE_LIMIT=<limit>,MOZ_DISABLE_LAUNCHER_PROCESS=1');
if (args.dryRun) {
lines.push('', '## Dry-run 结果');
lines.push('- 未启动浏览器,未创建日志文件。');
if (trace.installed) {
lines.push('- RuyiTrace 检测通过且用户授权后,应优先执行本脚本自动捕获 NDJSON,而不是默认等待用户手动 trace。');
} else {
lines.push('- RuyiTrace 检测未通过,不能进入自动捕获;应先让用户安装 / 提供 RuyiTrace 路径,或明确确认降级为仅 ruyiPage。');
}
return lines.join('\n') + '\n';
}
lines.push('', '## 捕获结果');
if (result.launchError) lines.push(`- 启动错误:${result.launchError}`);
lines.push(`- 是否已启动:${result.launched ? '是' : '否'}`);
if (result.pid) lines.push(`- 进程 PID:${result.pid}`);
lines.push(`- 是否尝试结束进程:${result.killAttempted ? '是' : '否'}`);
lines.push(`- 发现 NDJSON 数量:${result.logs.length}`);
for (const file of result.logs) lines.push(` - ${file}`);
if (!result.logs.length) {
lines.push('- 未发现 NDJSON:应检查 RuyiTrace trace Firefox 是否能写入日志、目标页面是否触发了环境访问、是否需要登录/验证码/权限交互;自动捕获失败后才要求用户手动协助采集。');
}
if (result.importResult) {
lines.push('', '## 导入结果');
lines.push(`- 导入是否成功:${result.importResult.ok ? '是' : '否'}`);
if (result.importResult.stdout.trim()) lines.push('', '```text', result.importResult.stdout.trim(), '```');
if (result.importResult.stderr.trim()) lines.push('', '```text', result.importResult.stderr.trim(), '```');
}
return lines.join('\n') + '\n';
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
if (!args.url) throw new Error('缺少 --url;自动捕获需要目标页面 URL。');
const trace = detectRuyiTrace(args);
const plan = buildPlan(args, trace);
if (!trace.installed) {
const obj = { args, trace, plan, result: { launched: false, logs: [] } };
if (args.json) process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
if (args.markdown) process.stdout.write(renderMarkdown(obj));
process.exitCode = 2;
return;
}
if (args.dryRun) {
const obj = { args, trace, plan, result: { launched: false, logs: [] } };
if (args.json) process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
if (args.markdown) process.stdout.write(renderMarkdown(obj));
return;
}
const result = await capture(args, plan);
const obj = { args, trace, plan, result };
if (args.json) process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
if (args.markdown) process.stdout.write(renderMarkdown(obj));
if (!result.logs.length) process.exitCode = 3;
}
main().catch((err) => {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
});

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const REQUIRED_FIELDS = [
'修改前逻辑',
'问题证据',
'本次修改',
'修改理由',
'已失败尝试',
'禁止回退',
'验证命令',
'验证结果',
'当前验证范围',
'遗留风险',
'当前状态',
];
function parseArgs(argv) {
const args = {
caseDir: 'case',
changed: [],
init: false,
requireEntry: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--changed' || a === '--file') args.changed.push(argv[++i] || '');
else if (a === '--init') args.init = true;
else if (a === '--require-entry') args.requireEntry = 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}`);
}
return args;
}
function usage() {
return `用法:
node scripts/check_change_memory.js --case-dir case --init --markdown
node scripts/check_change_memory.js --case-dir case --markdown
node scripts/check_change_memory.js --case-dir case --changed result/src/env/install-env.js --require-entry --markdown
node scripts/check_change_memory.js --case-dir case --changed result/src/signer/index.js --json
说明:检查复杂 case 是否维护 case/notes/代码变更记忆.md,并确认关键源码修改后记录了修改原因、失败尝试、禁止回退、验证范围和遗留风险。`;
}
function normalizeSlash(v) {
return String(v || '').replace(/\\/g, '/');
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function memoryPath(caseDir) {
return path.join(path.resolve(caseDir), 'notes', '代码变更记忆.md');
}
function createTemplate() {
return `# 代码变更记忆
本文件由通用代码变更记忆机制维护,用于记录复杂 case 中关键源码修改的事实、原因、失败尝试、禁止回退、验证范围和遗留风险。不要把“当前报错消失”直接写成永久结论。
## 使用规则
- 修改关键源码前先读取本文件,并搜索文件名、函数名、错误关键词和参数名。
- 修改关键源码后立即追加一条“变更”记录。
- 不删除失败记录;如历史记录被新证据替代,新增记录说明替代原因。
- 状态只能使用:临时修复 / 当前验证通过 / 已失败 / 被替代 / 稳定基线。
## 变更 001 - 待填写
- 时间:YYYY-MM-DD HH:mm
- 涉及文件:
- 涉及函数 / 模块:
- 当前状态:临时修复
### 修改前逻辑
待填写。
### 问题证据
待填写。
### 本次修改
待填写。
### 修改理由
待填写。
### 已失败尝试
待填写。
### 禁止回退
待填写。
### 验证命令
\`\`\`bash
# 待填写
\`\`\`
### 验证结果
待填写。
### 当前验证范围
待填写。
### 遗留风险
待填写。
`;
}
function initMemory(file) {
fs.mkdirSync(path.dirname(file), { recursive: true });
if (!exists(file)) {
fs.writeFileSync(file, createTemplate(), 'utf8');
return 'created';
}
return 'kept';
}
function hasEntry(text) {
return /^##\s+变更\s+\d+/m.test(text);
}
function fieldMissing(text) {
return REQUIRED_FIELDS.filter(field => !text.includes(field));
}
function changedMatched(text, changedFile) {
const normalized = normalizeSlash(changedFile).replace(/^\.\//, '');
const basename = path.basename(normalized);
const textSlash = normalizeSlash(text);
if (normalized && textSlash.includes(normalized)) return true;
if (basename && textSlash.includes(basename)) return true;
return false;
}
function check(args) {
const caseDir = path.resolve(args.caseDir || 'case');
const file = memoryPath(caseDir);
const problems = [];
const warnings = [];
const actions = [];
if (args.init) actions.push({ action: initMemory(file), path: file });
if (!exists(file)) {
problems.push(`未找到通用代码变更记忆文件:${file}`);
return {
caseDir,
memoryFile: file,
exists: false,
clean: false,
actions,
requiredFields: REQUIRED_FIELDS,
missingFields: REQUIRED_FIELDS.slice(),
changed: args.changed,
matchedChanged: [],
problems,
warnings,
};
}
const text = fs.readFileSync(file, 'utf8');
const missingFields = fieldMissing(text);
if (missingFields.length) problems.push(`代码变更记忆缺少必要字段:${missingFields.join('、')}`);
const entryExists = hasEntry(text);
if (args.requireEntry && !entryExists) problems.push('已要求至少一条变更记录,但未发现 “## 变更 001” 这类记录。');
if (!entryExists) warnings.push('尚未发现正式变更记录;如果已经修改关键源码,请立即追加记录。');
const matchedChanged = [];
for (const changed of args.changed.filter(Boolean)) {
const matched = changedMatched(text, changed);
matchedChanged.push({ file: changed, matched });
if (args.requireEntry && !matched) {
problems.push(`已修改关键文件但代码变更记忆中未提到该文件:${changed}`);
} else if (!matched) {
warnings.push(`代码变更记忆中未提到变更文件:${changed}`);
}
}
if (/正确方案/.test(text)) {
warnings.push('发现“正确方案”表述;建议改为“当前验证通过 / 稳定基线”,并写明验证范围和遗留风险。');
}
return {
caseDir,
memoryFile: file,
exists: true,
clean: problems.length === 0,
actions,
requiredFields: REQUIRED_FIELDS,
missingFields,
hasEntry: entryExists,
changed: args.changed,
matchedChanged,
problems,
warnings,
};
}
function renderMarkdown(result) {
const lines = [
'# 通用代码变更记忆检查',
'',
`- case 目录:${result.caseDir}`,
`- 记忆文件:${result.memoryFile}`,
`- 文件存在:${result.exists ? '是' : '否'}`,
`- 是否通过:${result.clean ? '是' : '否'}`,
'',
];
if (result.actions.length) {
lines.push('## 初始化动作');
for (const item of result.actions) {
lines.push(`- ${item.action === 'created' ? '已创建' : '已存在'}:${item.path}`);
}
lines.push('');
}
lines.push('## 必要字段');
for (const field of result.requiredFields) {
lines.push(`- ${field}:${result.missingFields.includes(field) ? '缺失' : '存在'}`);
}
lines.push('');
if (result.matchedChanged && result.matchedChanged.length) {
lines.push('## 变更文件命中');
for (const item of result.matchedChanged) {
lines.push(`- ${item.file}:${item.matched ? '已记录' : '未记录'}`);
}
lines.push('');
}
if (result.problems.length) {
lines.push('## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
lines.push('');
}
if (result.warnings.length) {
lines.push('## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
lines.push('');
}
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 || !args.json) 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);
}

View File

@@ -0,0 +1,516 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: '',
dir: '',
file: '',
maxLineLength: 180,
maxFileLines: 500,
maxFunctionLines: 90,
json: false,
markdown: 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 === '--file' || a === '-f') args.file = argv[++i] || '';
else if (a === '--max-line-length') args.maxLineLength = Number(argv[++i] || args.maxLineLength);
else if (a === '--max-file-lines') args.maxFileLines = Number(argv[++i] || args.maxFileLines);
else if (a === '--max-function-lines') args.maxFunctionLines = Number(argv[++i] || args.maxFunctionLines);
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_code_quality.js --case-dir case --markdown
node scripts/check_code_quality.js --dir case/result --json
node scripts/check_code_quality.js --file case/result/src/env/install-env.js --markdown
说明:检查最终补环境代码是否简洁、可读、模块化,并验证中文注释为 UTF-8、无乱码、无连续问号、中文注释不含问号。`;
}
function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
function stat(p) { try { return fs.statSync(p); } catch { return null; } }
function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
function ext(p) { return path.extname(p).toLowerCase(); }
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) out.push(p);
return out;
}
function isCodeFile(p) {
return ['.js', '.mjs', '.cjs', '.py'].includes(ext(p));
}
function shouldSkipFile(root, p) {
const r = rel(root, p).toLowerCase();
if (/(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party)(\/|$)/.test(r)) return true;
if (/(^|\/)src\/target\/(original|vendor|bundle|bundles)(\/|$)/.test(r)) return true;
if (/(\.min\.js|bundle\.js|vendor\.js|package-lock\.json)$/i.test(r)) return true;
return false;
}
function readUtf8Strict(file) {
const buf = fs.readFileSync(file);
const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
const text = buf.toString('utf8');
return { text: text.replace(/^\uFEFF/, ''), hasBom };
}
function hasChinese(s) {
return /[\u4e00-\u9fff]/.test(String(s || ''));
}
function extractJsComments(text) {
const comments = [];
const blockRe = /\/\*[\s\S]*?\*\//g;
let m;
while ((m = blockRe.exec(text))) {
for (const line of m[0].split(/\r?\n/)) comments.push(line.replace(/^\/\*+|\*+\/$/g, '').replace(/^\s*\*\s?/, '').trim());
}
const lineRe = /(^|\s)\/\/(.*)$/gm;
while ((m = lineRe.exec(text))) comments.push((m[2] || '').trim());
return comments.filter(Boolean);
}
function extractPyComments(text) {
const comments = [];
const lineRe = /^\s*#(.*)$/gm;
let m;
while ((m = lineRe.exec(text))) comments.push((m[1] || '').trim());
return comments.filter(Boolean);
}
function extractComments(file, text) {
return ext(file) === '.py' ? extractPyComments(text) : extractJsComments(text);
}
function firstNonEmptyLines(text, count = 8) {
return text.split(/\r?\n/).map(x => x.trim()).filter(Boolean).slice(0, count);
}
function stripJsLine(line) {
return line
.replace(/\/\*.*?\*\//g, '')
.replace(/\/\/.*$/g, '')
.trim();
}
function codeLineCount(file, lines) {
if (ext(file) === '.py') return lines.filter(line => line.trim() && !line.trim().startsWith('#')).length;
return lines.filter(line => stripJsLine(line)).length;
}
function maxJsBraceDepth(lines) {
let depth = 0;
let maxDepth = 0;
for (const raw of lines) {
const line = stripJsLine(raw);
for (const ch of line) {
if (ch === '{') {
depth += 1;
if (depth > maxDepth) maxDepth = depth;
} else if (ch === '}') {
depth = Math.max(0, depth - 1);
}
}
}
return maxDepth;
}
function maxPyIndentDepth(lines) {
let maxDepth = 0;
for (const raw of lines) {
if (!raw.trim() || raw.trim().startsWith('#')) continue;
const spaces = raw.match(/^\s*/)[0].replace(/\t/g, ' ').length;
maxDepth = Math.max(maxDepth, Math.floor(spaces / 2));
}
return maxDepth;
}
function inspectJsFunctions(lines, maxFunctionLines) {
const problems = [];
const warnings = [];
const starters = [
/\bfunction\s+([A-Za-z_$][\w$]*)?\s*\([^)]*\)\s*\{/,
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{/,
/\b(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/
];
for (let i = 0; i < lines.length; i++) {
const line = stripJsLine(lines[i]);
if (!line.includes('{')) continue;
if (!starters.some(re => re.test(line))) continue;
let depth = 0;
let end = i;
let seenOpen = false;
for (let j = i; j < lines.length; j++) {
const cur = stripJsLine(lines[j]);
for (const ch of cur) {
if (ch === '{') { depth += 1; seenOpen = true; }
else if (ch === '}') depth -= 1;
}
if (seenOpen && depth <= 0) { end = j; break; }
}
const size = end - i + 1;
if (size > maxFunctionLines) problems.push(`第 ${i + 1} 行附近函数过长:${size} 行,建议拆分到 ${maxFunctionLines} 行以内。`);
const prev = lines.slice(Math.max(0, i - 3), i).join('\n');
if (size > 15 && !hasChinese(prev)) warnings.push(`第 ${i + 1} 行附近函数较长但前置中文说明不足,建议补充职责、输入和输出说明。`);
}
return { problems, warnings };
}
function inspectPyFunctions(lines, maxFunctionLines) {
const problems = [];
const warnings = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(\s*)def\s+([A-Za-z_]\w*)\s*\(/);
if (!m) continue;
const baseIndent = m[1].replace(/\t/g, ' ').length;
let end = i;
for (let j = i + 1; j < lines.length; j++) {
if (!lines[j].trim()) { end = j; continue; }
const indent = lines[j].match(/^\s*/)[0].replace(/\t/g, ' ').length;
if (indent <= baseIndent) break;
end = j;
}
const size = end - i + 1;
if (size > maxFunctionLines) problems.push(`第 ${i + 1} 行附近函数过长:${size} 行,建议拆分到 ${maxFunctionLines} 行以内。`);
const prev = lines.slice(Math.max(0, i - 3), i).join('\n');
if (size > 15 && !hasChinese(prev)) warnings.push(`第 ${i + 1} 行附近函数较长但前置中文说明不足,建议补充职责、输入和输出说明。`);
}
return { problems, warnings };
}
function inspectEmbeddedScriptStrings(relFile, text) {
const problems = [];
const warnings = [];
const normalized = relFile.replace(/\\/g, '/');
const fileName = path.basename(normalized).toLowerCase();
const isEnvFile = /(^|\/)src\/env\//i.test(normalized) || /(^|\/)env\//i.test(normalized);
if (!/\.(?:js|mjs|cjs)$/i.test(normalized)) return { problems, warnings };
const scriptRe = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*(?:SCRIPT|SOURCE|CODE)[A-Za-z_$]*)\s*=\s*String\.raw`([\s\S]*?)`/g;
let match;
while ((match = scriptRe.exec(text))) {
const name = match[1];
const body = match[2] || '';
const bodyLines = body.split(/\r?\n/).length;
const looksLikeWebApi = /\b(?:Navigator|Document|Window|Location|History|Screen|XMLHttpRequest|HTMLCanvasElement|WebGL|navigator|document|window|xbs\.|createProtoChains|createNativeFunction|createGetter|createSetter|createNativeCollection|xbs\.dom\.createDocument)\b/.test(body);
if (isEnvFile && (bodyLines > 40 || looksLikeWebApi)) {
problems.push('发现大段 isolated-vm 补环境字符串 `' + name + ' = String.raw 模板字符串`(约 ' + bodyLines + ' 行)。补环境源码必须拆成真实 .js 文件,例如 browser-objects/navigator.js、document.js、fingerprint/canvas.js,再由 runtime.runFile/runFiles 注入 Context。');
} else if (bodyLines > 80) {
warnings.push(`发现较大的 String.raw 字符串 \`${name}\`(约 ${bodyLines} 行),请确认不是补环境 WebAPI 主实现。`);
}
}
if (isEnvFile && /^script-(?:core|browser|.*objects|.*env).*\.js$/i.test(fileName) && /String\.raw`/.test(text)) {
problems.push('发现 script-*.js 聚合字符串补环境文件。isolated-vm 交付应使用真实模块文件,不应把主要 WebAPI 放入 script-core.js 或 script-browser-objects.js。');
}
if (isEnvFile && /\b[A-Z][A-Z0-9_]*SCRIPT\b[\s\S]{0,200}\.join\(\s*['"]\\n['"]\s*\)/.test(text)) {
problems.push('发现把多个 *_SCRIPT 字符串 join 后执行的补环境聚合方式。请改为 runtime.runFiles([...]) 按真实文件注入。');
}
if (isEnvFile && /module\.exports\s*=\s*\{[\s\S]{0,300}\b[A-Z][A-Z0-9_]*SCRIPT\b/.test(text)) {
warnings.push('发现导出 *_SCRIPT 字符串。除极小 bootstrap 外,补环境实现应导出安装函数或通过真实文件加载。');
}
return { problems, warnings };
}
const WEBAPI_DOMAIN_PATTERNS = [
['window/global', /\b(?:window|globalThis|self|top|parent|frames|innerWidth|outerWidth|devicePixelRatio)\b/],
['navigator', /\b(?:navigator|Navigator|PluginArray|MimeTypeArray|permissions|sendBeacon|hardwareConcurrency|webdriver|mimeTypes|plugins)\b/],
['document/dom', /\b(?:document|Document|HTMLDocument|Element|HTMLElement|createElement|createTextNode|currentScript|DOMRect|MutationObserver|HTMLCollection|NodeList)\b/],
['location/history', /\b(?:location|Location|history|History|pushState|replaceState)\b/],
['screen', /\b(?:screen|Screen|availWidth|availHeight|colorDepth|pixelDepth)\b/],
['storage/cookie', /\b(?:localStorage|sessionStorage|Storage|cookie|Cookie|document\.cookie)\b/],
['network', /\b(?:XMLHttpRequest|fetch|Request|Response|Headers|sendBeacon|open\s*\(|setRequestHeader|readyState)\b/],
['canvas', /\b(?:HTMLCanvasElement|CanvasRenderingContext2D|toDataURL|getImageData|measureText|canvas)\b/],
['webgl', /\b(?:WebGLRenderingContext|WebGL2RenderingContext|WebGLShaderPrecisionFormat|getParameter|readPixels|getSupportedExtensions|webgl)\b/],
['audio', /\b(?:AudioContext|OfflineAudioContext|AudioBuffer|HTMLAudioElement|getChannelData|startRendering|audio)\b/],
['performance/timing', /\b(?:performance|Performance|PerformanceNavigationTiming|timeOrigin|navigationStart|requestAnimationFrame)\b/],
['events', /\b(?:EventTarget|Event|MouseEvent|KeyboardEvent|CustomEvent|addEventListener|dispatchEvent|isTrusted)\b/],
['worker/wasm/message', /\b(?:Worker|postMessage|MessageChannel|MessagePort|WebAssembly|BroadcastChannel)\b/],
];
function detectWebApiDomains(text) {
const found = [];
for (const [name, pattern] of WEBAPI_DOMAIN_PATTERNS) {
if (pattern.test(text)) found.push(name);
}
return found;
}
function isEnvModulePath(relFile) {
return /(^|\/)src\/(?:env|node-runtime\/env)\//i.test(relFile) || /(^|\/)env\//i.test(relFile);
}
function inspectWebApiModuleBoundary(relFile, text, lines, args) {
const problems = [];
const warnings = [];
const normalized = relFile.replace(/\\/g, '/');
if (!/\.(?:js|mjs|cjs)$/i.test(normalized)) return { problems, warnings };
const domains = detectWebApiDomains(text);
if (!domains.length) return { problems, warnings };
const envModule = isEnvModulePath(normalized);
const orchestrationDir = /(^|\/)src\/(?:signer|request|resources)\//i.test(normalized);
const signerFile = /(^|\/)src\/signer\//i.test(normalized);
const probeLikeFile = /(^|\/)[^/]*(?:probe|diagnostic|runtime-runner|runtime_probe|runtime-probe)[^/]*\.(?:js|mjs|cjs)$/i.test(normalized);
const codeLines = codeLineCount(normalized, lines);
const implementationLike = /\b(?:installBrowserEnv|install[A-Z][\w$]*Env|create[A-Z][\w$]*(?:Environment|Constructors?|Env)|createNativeHelpers|defineValue\s*\(|defineGetter\s*\(|defineAccessor\s*\(|Object\.defineProperty|Object\.defineProperties|nativeApi\.fn|nativeApi\.getter|HTMLCanvasElement|WebGLRenderingContext|XMLHttpRequest)\b/.test(text);
const domainText = domains.join('、');
if (signerFile && domains.length >= 3 && implementationLike) {
problems.push(`src/signer 文件承载了 ${domains.length} 类 WebAPI 补环境主体(${domainText})。signer 只允许做入口编排、调用目标 signer 和整理输出;请把 WebAPI 安装逻辑拆到 src/env/ 或 src/node-runtime/env/ 下的真实模块。`);
}
if (probeLikeFile && !envModule && domains.length >= 4 && codeLines > 180 && implementationLike) {
problems.push(`probe / runtime 入口文件承载了 ${domains.length} 类 WebAPI 补环境主体(${domainText})。probe 只能是薄入口,建议少于 150 行;请拆分为 browser-objects、fingerprint、network、bootstrap 等模块。`);
}
if (!envModule && domains.length >= 5 && codeLines > args.maxFileLines && implementationLike) {
problems.push(`单个非 env 文件同时实现 ${domains.length} 类 WebAPI 且超过 ${args.maxFileLines} 行(${domainText}),属于补环境主体堆叠。请先规划目录并拆分模块后再继续验证。`);
}
if (orchestrationDir && domains.length >= 2 && codeLines > 240 && implementationLike && !signerFile) {
warnings.push(`编排目录文件包含多类 WebAPI 实现迹象(${domainText})。请确认它只做请求、资源或摘要编排;WebAPI 主体必须放入 src/env/ 或 src/node-runtime/env/。`);
}
return { problems, warnings };
}
function inspectProjectStructure(root, files) {
const problems = [];
const warnings = [];
const codeFiles = files.filter(isCodeFile);
const fileEntries = codeFiles.map(filePath => {
let text = '';
try { text = readUtf8Strict(filePath).text; } catch { text = ''; }
return { filePath, relFile: rel(root, filePath), text };
});
const isolatedVmUsed = fileEntries.some(entry => /\b(?:isolated_vm\.node|xbs-isolated-vm|createXbsRuntime|createIsolatedVmRuntime|window\.xbs|xbs\.dom\.createDocument)\b/.test(entry.text));
if (!isolatedVmUsed) return { problems, warnings };
const envEntries = fileEntries.filter(entry => /(^|\/)src\/env\//i.test(entry.relFile));
const embeddedEnvScripts = envEntries.filter(entry => /String\.raw`/.test(entry.text) && /\b(?:Navigator|Document|Window|XMLHttpRequest|HTMLCanvasElement|WebGL|xbs\.|navigator|document|window)\b/.test(entry.text));
if (embeddedEnvScripts.length) {
problems.push('选择 isolated-vm 时发现补环境 WebAPI 仍以 String.raw 聚合字符串实现:' + embeddedEnvScripts.map(entry => entry.relFile).join('、') + '。请拆分为真实模块文件并用 runtime.runFile/runFiles 加载。');
}
const hasBrowserObjectModule = envEntries.some(entry => /(^|\/)src\/env\/browser-objects\/[^/]+\.js$/i.test(entry.relFile));
const touchesBrowserObjects = envEntries.some(entry => /\b(?:navigator|document|window|location|screen|XMLHttpRequest|history|storage)\b/i.test(entry.text));
if (touchesBrowserObjects && !hasBrowserObjectModule) {
problems.push('选择 isolated-vm 且补了浏览器对象,但未发现 src/env/browser-objects/*.js 模块。请至少按 navigator.js、document.js、window.js 等职责拆分。');
}
const hasRunFiles = fileEntries.some(entry => /\brunFiles\s*\(|\brunFile\s*\(/.test(entry.text));
if (embeddedEnvScripts.length && !hasRunFiles) {
warnings.push('未发现 runtime.runFile/runFiles 文件化加载入口。建议在 xbs runtime 中提供按文件注入 Context 的能力。');
}
return { problems, warnings };
}
function inspectFile(root, file, args) {
const relFile = rel(root, file);
const problems = [];
const warnings = [];
const { text, hasBom } = readUtf8Strict(file);
const lines = text.split(/\r?\n/);
const comments = extractComments(file, text);
const chineseComments = comments.filter(hasChinese);
const codeLines = codeLineCount(file, lines);
const embeddedScriptCheck = inspectEmbeddedScriptStrings(relFile, text);
problems.push(...embeddedScriptCheck.problems);
warnings.push(...embeddedScriptCheck.warnings);
const webApiBoundaryCheck = inspectWebApiModuleBoundary(relFile, text, lines, args);
problems.push(...webApiBoundaryCheck.problems);
warnings.push(...webApiBoundaryCheck.warnings);
if (hasBom) problems.push('文件带 UTF-8 BOM,建议使用无 BOM UTF-8。');
if (/\uFFFD/.test(text)) problems.push('文件包含替换字符,疑似 UTF-8 解码或写入异常。');
if (/\?{6,}/.test(text)) problems.push('文件包含连续问号,疑似中文编码问题。');
for (let i = 0; i < comments.length; i++) {
const c = comments[i];
if (hasChinese(c) && /[??]/.test(c)) problems.push(`中文注释包含问号:第 ${i + 1} 条注释“${c.slice(0, 60)}”。`);
if (/\uFFFD|\?{3,}/.test(c)) problems.push(`注释疑似乱码:第 ${i + 1} 条注释“${c.slice(0, 60)}”。`);
}
const header = firstNonEmptyLines(text, 8).join('\n');
if (!hasChinese(header) || !/^\s*(\/\/|\/\*|\*|#)/m.test(header)) {
problems.push('文件开头缺少中文职责注释;请在文件顶部说明模块用途、数据来源和边界。');
}
if (codeLines > 20 && chineseComments.length === 0) problems.push('代码超过 20 行但没有中文注释。');
if (codeLines > 80 && chineseComments.length < Math.ceil(codeLines / 120)) {
problems.push(`中文注释过少:代码约 ${codeLines} 行,中文注释 ${chineseComments.length} 条。`);
}
if (lines.length > args.maxFileLines) problems.push(`文件过大:${lines.length} 行,建议拆分到 ${args.maxFileLines} 行以内。`);
lines.forEach((line, idx) => {
if (line.length > args.maxLineLength) problems.push(`第 ${idx + 1} 行过长:${line.length} 字符,建议拆分到 ${args.maxLineLength} 字符以内。`);
if (line.length > 320) problems.push(`第 ${idx + 1} 行疑似压缩或堆叠代码。`);
});
const semicolonDense = lines.filter(line => (line.match(/;/g) || []).length >= 6);
if (semicolonDense.length) problems.push(`发现 ${semicolonDense.length} 行包含大量分号,疑似压缩或多语句堆叠。`);
const denseLineProblems = [];
lines.forEach((rawLine, idx) => {
const line = stripJsLine(rawLine);
if (!line) return;
const lineNo = idx + 1;
const defineCount = (line.match(/\b(?:Object\.defineProperty|Object\.defineProperties|defineValue|defineNativeValue|defineNativeGetter|defineNativeSetter|defineNativeAccessor)\s*\(/g) || []).length;
if (defineCount >= 2) {
denseLineProblems.push(`第 ${lineNo} 行同时包含 ${defineCount} 个属性定义调用,应拆成多行并补充 descriptor 说明。`);
}
if (/(?:Object\.defineProperty|Object\.defineProperties)\s*\([^;]+\{[^{}\n]*(?:value|get|set|writable|enumerable|configurable)[^{}\n]*\}[^;]*;?$/.test(line) && line.length > 110) {
denseLineProblems.push(`第 ${lineNo} 行把属性描述符压在一行,建议展开 value/get/set/writable/enumerable/configurable。`);
}
if (/\bObject\.assign\s*\([^;]*\{.*\{.*\}/.test(line)) {
denseLineProblems.push(`第 ${lineNo} 行疑似用 Object.assign 堆叠对象和方法,补环境代码应拆为 createProtoChains 与 defineProperty。`);
}
if (/\b(?:ctx|window|globalThis|globalObject|self)\s*(?:\.|\[).*=.*\{.*(?:function\b|=>|\b[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{)/.test(line)) {
denseLineProblems.push(`第 ${lineNo} 行把全局 WebAPI 对象、方法或函数堆在一行,建议拆成模块化安装函数。`);
}
if (/\b(?:if|for|while|try|catch|finally)\b[^{;\n]*\{[^{}\n]*;[^{}\n]*\}/.test(line) && line.length > 120) {
denseLineProblems.push(`第 ${lineNo} 行存在较长的单行控制流代码块,建议展开为多行。`);
}
if (/\bfunction\b[^{;\n]*\{[^{}\n]*;[^{}\n]*\}/.test(line) && line.length > 120) {
denseLineProblems.push(`第 ${lineNo} 行存在单行函数体,建议提取为具名函数并展开实现。`);
}
});
if (denseLineProblems.length) problems.push(...denseLineProblems.slice(0, 30));
if (denseLineProblems.length > 30) problems.push(`还有 ${denseLineProblems.length - 30} 个疑似单行堆叠问题未逐条展示,请先格式化源码后重新检查。`);
if (/\bdebugger\s*;/.test(text)) problems.push('存在 debugger 语句,最终代码不得保留调试断点。');
if (/TODO|FIXME|临时|随便|测试用|先这样|debug/i.test(text)) warnings.push('发现 TODO/FIXME/临时/调试类标记,交付前建议清理或改为正式说明。');
if (/\b(?:var\s+[a-z]\b|function\s+[a-z]\s*\(|const\s+[a-z]\s*=|let\s+[a-z]\s*=)/.test(text)) warnings.push('发现过短变量或函数名,建议使用表达业务含义的命名。');
const depth = ext(file) === '.py' ? maxPyIndentDepth(lines) : maxJsBraceDepth(lines);
if (depth > 8) problems.push(`嵌套层级过深:最大层级 ${depth},建议拆分函数或提前返回。`);
else if (depth > 6) warnings.push(`嵌套层级偏深:最大层级 ${depth},建议优化结构。`);
const funcs = ext(file) === '.py' ? inspectPyFunctions(lines, args.maxFunctionLines) : inspectJsFunctions(lines, args.maxFunctionLines);
problems.push(...funcs.problems);
warnings.push(...funcs.warnings);
const anonymousCount = (text.match(/\bfunction\s*\(/g) || []).length + (text.match(/=>\s*\{/g) || []).length;
if (anonymousCount > 8) warnings.push(`匿名函数较多:${anonymousCount} 个,建议提取为具名函数提升可读性。`);
return {
file: relFile,
clean: problems.length === 0,
lines: lines.length,
codeLines,
chineseCommentCount: chineseComments.length,
maxDepth: depth,
problems,
warnings,
};
}
function check(args) {
const root = args.file
? path.resolve(path.dirname(args.file))
: args.dir
? path.resolve(args.dir)
: path.join(path.resolve(args.caseDir || 'case'), 'result');
const files = args.file ? [path.resolve(args.file)] : walk(root).filter(p => isCodeFile(p) && !shouldSkipFile(root, p));
const problems = [];
const warnings = [];
if (!files.length) problems.push(`未找到可检查的最终代码文件:${root}`);
const fileResults = files.map(f => inspectFile(root, f, args));
for (const r of fileResults) {
for (const p of r.problems) problems.push(`${r.file}: ${p}`);
for (const w of r.warnings) warnings.push(`${r.file}: ${w}`);
}
const structure = inspectProjectStructure(root, files);
for (const p of structure.problems) problems.push(p);
for (const w of structure.warnings) warnings.push(w);
return {
root,
clean: problems.length === 0,
filesChecked: fileResults.length,
limits: {
maxLineLength: args.maxLineLength,
maxFileLines: args.maxFileLines,
maxFunctionLines: args.maxFunctionLines,
},
problems,
warnings,
fileResults,
};
}
function renderMarkdown(result) {
const lines = [
'# 补环境代码质量检查结果',
'',
`检查范围:${result.root}`,
`是否通过:${result.clean ? '是' : '否'}`,
`检查文件数:${result.filesChecked}`,
'',
'## 质量规则',
`- 单行长度上限:${result.limits.maxLineLength}`,
`- 单文件行数上限:${result.limits.maxFileLines}`,
`- 单函数行数上限:${result.limits.maxFunctionLines}`,
'- 必须有中文职责注释,中文注释不得包含问号、连续问号或乱码。',
'- 禁止压缩代码、过度堆叠语句、调试断点和临时测试标记。',
'- signer / probe / runtime 入口不得承载 navigator、document、canvas、webgl、performance 等多域 WebAPI 补环境主体。',
'- XHR/fetch 实现应拆入 env/network 并通过 live session bridge;浏览器对象私有状态不得以 _ / __ own property 泄露。',
'- isolated-vm 补环境不得以大段 String.raw / *_SCRIPT 字符串作为主要交付形态,必须拆成真实文件模块并通过 runFile/runFiles 注入。',
'',
];
if (result.problems.length) {
lines.push('## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
lines.push('');
}
if (result.warnings.length) {
lines.push('## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
lines.push('');
}
lines.push('## 文件摘要');
for (const f of result.fileResults) {
lines.push(`- ${f.file}:${f.clean ? '通过' : '失败'},总行数 ${f.lines},代码行 ${f.codeLines},中文注释 ${f.chineseCommentCount},最大嵌套 ${f.maxDepth}`);
}
if (!result.fileResults.length) lines.push('- 无');
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, inspectFile, extractComments, inspectEmbeddedScriptStrings, inspectWebApiModuleBoundary, inspectProjectStructure };

View File

@@ -0,0 +1,335 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function parseArgs(argv) {
const args = {
caseDir: 'case',
manifest: '',
requireRuntimeRefresh: 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] || 'case';
else if (a === '--manifest') args.manifest = argv[++i] || '';
else if (a === '--require-runtime-refresh') args.requireRuntimeRefresh = 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_dynamic_resources.js --case-dir case --markdown
node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --markdown
node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --json
说明:检查 case/notes/resource-manifest.json 中的动态 HTML / JS / challenge 资源是否只作为分析快照,并确认影响最终参数生成的动态资源已设计运行时刷新模块;同时检查 result/ 中是否混入动态快照文件。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function stat(p) {
try { return fs.statSync(p); } catch { return null; }
}
function rel(root, p) {
return (path.relative(root, p) || '.').replace(/\\/g, '/');
}
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) out.push(p);
return out;
}
function sha256File(file) {
try {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
} catch {
return '';
}
}
function normalizeBool(v) {
return v === true || v === 'true' || v === 1 || v === '1';
}
function isDynamicValue(v) {
if (typeof v === 'boolean') return v;
if (typeof v === 'string') return ['true', 'yes', 'dynamic', 'high-risk', '高风险', '动态'].includes(v.toLowerCase());
return false;
}
function getHash(resource) {
return resource.sha256 || resource.bodySha256 || resource.bodyHash || resource.hash || '';
}
function sanitizeRel(p) {
return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '');
}
function resolveCasePath(caseDir, maybePath) {
const raw = String(maybePath || '').trim();
if (!raw) return [];
if (path.isAbsolute(raw)) return [raw];
const normalized = sanitizeRel(raw);
const parent = path.dirname(caseDir);
const withoutCase = normalized.replace(/^case\//i, '');
const candidates = [
path.join(caseDir, normalized),
path.join(parent, normalized),
path.join(caseDir, withoutCase),
];
if (!/^result\//i.test(normalized)) candidates.push(path.join(caseDir, 'result', normalized));
return [...new Set(candidates.map(p => path.normalize(p)))];
}
function firstExisting(paths) {
return paths.find(p => exists(p)) || '';
}
function readManifest(manifestPath) {
const text = fs.readFileSync(manifestPath, 'utf8').replace(/^\uFEFF/, '');
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) return { raw: parsed, resources: parsed };
const resources = Array.isArray(parsed.resources) ? parsed.resources : [];
return { raw: parsed, resources };
}
function finalEntryFiles(resultDir) {
const files = [];
for (const name of ['final.js', 'final.mjs', 'final.cjs', 'final.py']) {
const p = path.join(resultDir, name);
if (exists(p)) files.push(p);
}
return files;
}
function readTextSafe(file) {
try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
}
function inspect(caseDir, args) {
const problems = [];
const warnings = [];
const manifestPath = args.manifest ? path.resolve(args.manifest) : path.join(caseDir, 'notes', 'resource-manifest.json');
const resultDir = path.join(caseDir, 'result');
const snapshotsDir = path.join(caseDir, 'js', 'snapshots');
const report = {
ok: false,
caseDir,
manifestPath,
manifestExists: exists(manifestPath),
resources: [],
dynamicCount: 0,
requiredDynamicCount: 0,
runtimeRefreshCount: 0,
problems,
warnings,
};
if (!report.manifestExists) {
const hasSnapshots = exists(snapshotsDir) && walk(snapshotsDir).length > 0;
const msg = `未找到资源清单:${rel(caseDir, manifestPath)}`;
if (args.requireRuntimeRefresh || hasSnapshots) problems.push(hasSnapshots ? `${msg};但已存在 js/snapshots 动态快照目录,必须补写 resource-manifest.json。` : `${msg};当前要求强制检查运行时刷新。`);
else warnings.push(`${msg};如果本 case 没有下载 / 保存 HTML、JS 或 challenge 资源,可以忽略。`);
report.ok = problems.length === 0;
return report;
}
let manifest;
try {
manifest = readManifest(manifestPath);
} catch (err) {
problems.push(`resource-manifest.json 解析失败:${err.message}`);
report.ok = false;
return report;
}
if (!Array.isArray(manifest.resources)) {
problems.push('resource-manifest.json 缺少 resources 数组。');
report.ok = false;
return report;
}
const resultFiles = walk(resultDir).filter(p => stat(p)?.isFile());
const resultHashes = new Map();
for (const file of resultFiles) resultHashes.set(file, sha256File(file));
const resultRelList = resultFiles.map(file => rel(caseDir, file));
for (let i = 0; i < manifest.resources.length; i++) {
const r = manifest.resources[i] || {};
const item = {
index: i,
url: r.url || '',
type: r.type || '',
file: r.file || '',
sha256: getHash(r),
dynamic: isDynamicValue(r.dynamic),
requiredForFinal: normalizeBool(r.requiredForFinal),
runtimeRefresh: normalizeBool(r.runtimeRefresh) || r.use === 'runtime-refresh',
refreshEntry: r.refreshEntry || '',
use: r.use || '',
problems: [],
warnings: [],
};
report.resources.push(item);
const prefix = `resources[${i}]`;
if (!item.url) item.problems.push(`${prefix} 缺少 url。`);
if (!item.sha256) item.problems.push(`${prefix} 缺少 sha256 / bodySha256 / hash。`);
if (typeof r.dynamic === 'undefined') item.problems.push(`${prefix} 缺少 dynamic 字段,必须明确 true / false。`);
if (!item.use) item.problems.push(`${prefix} 缺少 use 字段,应标明 analysis-snapshot / runtime-refresh / static 等。`);
if (item.dynamic) report.dynamicCount += 1;
if (item.dynamic && item.requiredForFinal) report.requiredDynamicCount += 1;
if (item.runtimeRefresh) report.runtimeRefreshCount += 1;
if (item.dynamic && item.file && !/analysis-snapshot|runtime-refresh/i.test(item.use)) {
item.warnings.push(`${prefix} 是动态资源,但 use 未标明 analysis-snapshot 或 runtime-refresh。`);
}
if (item.dynamic && item.requiredForFinal) {
if (!item.runtimeRefresh) item.problems.push(`${prefix} 是影响最终参数生成的动态资源,但 runtimeRefresh 未设置为 true。`);
if (!item.refreshEntry) item.problems.push(`${prefix} 是影响最终参数生成的动态资源,但缺少 refreshEntry。`);
if (args.requireRuntimeRefresh && item.refreshEntry) {
const refreshPath = firstExisting(resolveCasePath(caseDir, item.refreshEntry));
if (!refreshPath) item.problems.push(`${prefix} 的 refreshEntry 不存在:${item.refreshEntry}。`);
}
}
if (item.dynamic && item.file) {
const resourcePath = firstExisting(resolveCasePath(caseDir, item.file));
const resourceHash = resourcePath ? sha256File(resourcePath) : item.sha256;
const resourceBase = path.basename(item.file).toLowerCase();
for (const file of resultFiles) {
const rr = rel(caseDir, file);
const rrLower = rr.toLowerCase();
const sameHash = resourceHash && resultHashes.get(file) === resourceHash;
const snapshotPath = /(^|\/)result\/(?:.*\/)?(?:js\/)?snapshots\//i.test(rrLower) || /(^|\/)snapshots\//i.test(rrLower);
if (sameHash) item.problems.push(`${prefix} 的动态快照内容疑似被复制到最终产物:${rr}。`);
else if (snapshotPath) item.problems.push(`最终产物包含 snapshots 路径,不应交付动态快照:${rr}。`);
else if (resourceBase && path.basename(file).toLowerCase() === resourceBase && /snapshot|challenge|dynamic/i.test(resourceBase)) {
item.warnings.push(`${prefix} 的文件名与 result/ 中 ${rr} 相同,请确认不是复制动态快照。`);
}
}
}
for (const p of item.problems) problems.push(p);
for (const w of item.warnings) warnings.push(w);
}
if (args.requireRuntimeRefresh && report.requiredDynamicCount > 0) {
const entries = finalEntryFiles(resultDir);
if (entries.length === 0) {
warnings.push('result/ 中未发现 final.js / final.py;无法检查最终入口是否调用运行时刷新模块。');
} else {
const finalText = entries.map(readTextSafe).join('\n');
const refreshNames = report.resources
.filter(r => r.dynamic && r.requiredForFinal && r.refreshEntry)
.map(r => path.basename(r.refreshEntry).replace(/\.[^.]+$/, ''))
.filter(Boolean);
const hasRefreshSignal = /fetchRuntimeResources|refreshRuntimeResources|loadRuntimeResources|resourceManifest|运行时刷新|动态资源/.test(finalText)
|| refreshNames.some(name => new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(finalText));
if (!hasRefreshSignal) {
problems.push('最终入口未发现运行时刷新调用痕迹;动态 HTML / JS 影响最终参数生成时,final.js / final.py 必须先刷新当前资源再生成参数。');
}
}
}
if (manifest.resources.length === 0) warnings.push('resource-manifest.json 中 resources 为空;如果已下载 HTML / JS,请补充清单。');
if (resultRelList.some(x => /(^|\/)js\/snapshots\//i.test(x) || /(^|\/)snapshots\//i.test(x))) {
problems.push('最终 result/ 中存在 snapshots 目录或文件;动态快照不得进入最终产物。');
}
report.ok = problems.length === 0;
return report;
}
function toMarkdown(report) {
const lines = [];
lines.push('# 动态资源保鲜检查');
lines.push('');
lines.push(`- 检查结果:${report.ok ? '通过' : '未通过'}`);
lines.push(`- case 目录:${report.caseDir}`);
lines.push(`- manifest:${report.manifestExists ? report.manifestPath : '未找到'}`);
lines.push(`- 资源数量:${report.resources.length}`);
lines.push(`- 动态资源数量:${report.dynamicCount}`);
lines.push(`- 影响最终参数生成的动态资源:${report.requiredDynamicCount}`);
lines.push(`- 已声明运行时刷新的资源:${report.runtimeRefreshCount}`);
lines.push('');
if (report.resources.length) {
lines.push('## 资源概览');
lines.push('');
lines.push('| 序号 | 类型 | 动态 | 最终依赖 | 运行时刷新 | use | refreshEntry | URL |');
lines.push('|---|---|---|---|---|---|---|---|');
for (const r of report.resources) {
lines.push(`| ${r.index} | ${r.type || '-'} | ${r.dynamic ? '是' : '否'} | ${r.requiredForFinal ? '是' : '否'} | ${r.runtimeRefresh ? '是' : '否'} | ${r.use || '-'} | ${r.refreshEntry || '-'} | ${String(r.url || '').replace(/\|/g, '%7C')} |`);
}
lines.push('');
}
if (report.problems.length) {
lines.push('## 必须修复');
lines.push('');
for (const p of report.problems) lines.push(`- ${p}`);
lines.push('');
}
if (report.warnings.length) {
lines.push('## 提醒');
lines.push('');
for (const w of report.warnings) lines.push(`- ${w}`);
lines.push('');
}
if (report.ok) {
lines.push('## 结论');
lines.push('');
lines.push('- 未发现动态资源固定化交付问题。');
if (report.requiredDynamicCount > 0) lines.push('- 影响最终参数生成的动态资源已声明运行时刷新方案。');
else lines.push('- 当前 manifest 未声明影响最终参数生成的动态资源。');
}
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
const caseDir = path.resolve(args.caseDir || 'case');
const report = inspect(caseDir, args);
if (args.json) console.log(JSON.stringify(report, null, 2));
else console.log(toMarkdown(report));
if (!report.ok) process.exitCode = 1;
}
try {
main();
} catch (err) {
console.error(`动态资源检查失败:${err.message}`);
process.exit(1);
}

View File

@@ -0,0 +1,356 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: '',
file: '',
requireDocumentAll: false,
requireRuyiTrace: false,
requireFingerprintFixture: false,
requireAddonFirst: true,
addonFirstOptOut: false,
fingerprintFixture: '',
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--file' || a === '-f') args.file = argv[++i] || '';
else if (a === '--require-document-all') args.requireDocumentAll = true;
else if (a === '--require-ruyitrace') args.requireRuyiTrace = true;
else if (a === '--require-fingerprint-fixture') args.requireFingerprintFixture = true;
else if (a === '--require-addon-first') args.requireAddonFirst = true;
else if (a === '--no-require-addon-first' || a === '--allow-no-addon-first') {
args.requireAddonFirst = false;
args.addonFirstOptOut = true;
}
else if (a === '--fingerprint-fixture') args.fingerprintFixture = argv[++i] || '';
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_env_realism.js --case-dir case --markdown
node scripts/check_env_realism.js --case-dir case --no-require-addon-first --markdown
node scripts/check_env_realism.js --case-dir case --require-document-all --require-ruyitrace --require-fingerprint-fixture --require-addon-first --json
node scripts/check_env_realism.js --file case/result/src/env/index.js --markdown
说明:检查补环境交付代码是否体现原型链、属性描述符、访问器、函数 / 访问器 / 实例对象 toString 保护、addon-first/native fallback 规则、document.all 特殊对象处理、指纹终端 API 值回放策略,以及选择 RuyiTrace 时是否沉淀 NDJSON 证据。addon-first 是默认硬性要求;如果选择 xbs isolated-vm,则 window.xbs / globalThis.xbs 视为 native-first 证据;只有用户明确要求不使用 addon / 不做 addon-first 时,才允许传入 --no-require-addon-first 并在总结中记录豁免原因。`;
}
function exists(p) { try { fs.accessSync(p); return true; } 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 rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
function ext(p) { return path.extname(p).toLowerCase(); }
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) out.push(p);
return out;
}
function codeFiles(root) {
return walk(root).filter(p => ['.js', '.mjs', '.cjs', '.py'].includes(ext(p)));
}
function has(pattern, text) { return pattern.test(text); }
function any(pattern, files) { return files.some(f => has(pattern, readText(f))); }
function matchingFiles(pattern, files, root) {
return files.filter(f => has(pattern, readText(f))).map(f => rel(root, f));
}
function listNdjson(caseDir) {
const dir = path.join(caseDir, 'ruyi-trace', 'logs');
if (!exists(dir)) return [];
return walk(dir).filter(p => p.toLowerCase().endsWith('.ndjson'));
}
function inspectRuyiTrace(caseDir, requireRuyiTrace) {
const problems = [];
const warnings = [];
const ndjson = listNdjson(caseDir);
const shouldCheck = requireRuyiTrace || ndjson.length > 0;
const summary = path.join(caseDir, 'notes', 'ruyitrace-summary.md');
const priority = path.join(caseDir, 'notes', 'missing-env-priority.md');
const result = {
required: shouldCheck,
ndjson: ndjson.map(p => rel(caseDir, p)),
summary: exists(summary) ? rel(caseDir, summary) : '',
priority: exists(priority) ? rel(caseDir, priority) : '',
};
if (!shouldCheck) return { result, problems, warnings };
if (!ndjson.length) problems.push('已要求 RuyiTrace 优先诊断,但 case/ruyi-trace/logs/ 下未找到 NDJSON 日志。');
if (!exists(summary)) problems.push('已要求 RuyiTrace 优先诊断,但未找到 notes/ruyitrace-summary.md;应先运行 import_ruyitrace_log.js。');
else {
const text = readText(summary);
if (!/API|api|调用栈|stack|类别统计|RuyiTrace/i.test(text)) warnings.push('ruyitrace-summary.md 内容较弱,建议包含 API 统计、stack.file 和环境模块分类。');
}
if (!exists(priority)) problems.push('已选择 / 提供 RuyiTrace 日志,但未找到 notes/missing-env-priority.md;补环境前应把 NDJSON 命中的 api、stack.file、line、col 和补齐优先级写入该文件。');
else {
const text = readText(priority);
if (!/RuyiTrace|NDJSON|api|stack\.file|line|col|证据/.test(text)) {
problems.push('missing-env-priority.md 未体现 RuyiTrace/NDJSON 证据、api 或 stack.file/line/col;不能证明补环境阶段持续参考了 trace 日志。');
}
}
return { result, problems, warnings };
}
function readJsonSafe(file) {
try { return JSON.parse(readText(file)); } catch (err) { return { __error: err.message || String(err) }; }
}
function countArray(v) { return Array.isArray(v) ? v.length : 0; }
function fingerprintFixturePath(caseDir, explicit) {
if (explicit) return path.resolve(explicit);
const candidates = [
path.join(caseDir, 'fixtures', 'fingerprint.fixture.json'),
path.join(caseDir, 'fixtures', 'sample.fixture.json'),
];
return candidates.find(exists) || candidates[0];
}
function inspectFingerprint(caseDir, files, args) {
const problems = [];
const warnings = [];
const allText = files.map(f => readText(f)).join('\n');
const terminalApiMentioned = /toDataURL|getImageData|measureText|getParameter|getSupportedExtensions|getShaderPrecisionFormat|readPixels|getBoundingClientRect|offsetWidth|offsetHeight|requestAdapter|OfflineAudioContext/.test(allText);
const valueReplayMentioned = /fingerprint|回放|replay|fixture|findReplay|installFingerprintValueReplay|fingerprint\.fixture/i.test(allText);
const badRenderLib = /\b(require|import)\s*\(?\s*['"](?:canvas|node-canvas|gl|headless-gl)['"]|from\s+['"](?:canvas|node-canvas|gl|headless-gl)['"]/i.test(allText);
const automationForFingerprint = /\b(playwright|puppeteer|selenium|cloakbrowser|ruyipage|page\.goto|browser\.launch|chromium\.launch)\b/i.test(allText);
const fixtureFile = fingerprintFixturePath(caseDir, args.fingerprintFixture);
let fixtureExists = exists(fixtureFile);
let counts = {};
if (fixtureExists) {
const raw = readJsonSafe(fixtureFile);
if (raw.__error) {
problems.push(`指纹 fixture 解析失败:${raw.__error}`);
} else {
const fp = raw.fingerprint && typeof raw.fingerprint === 'object' ? raw.fingerprint : raw;
counts = {
canvas: countArray(fp.canvas && fp.canvas.toDataURL) + countArray(fp.canvas && fp.canvas.measureText) + countArray(fp.canvas && fp.canvas.getImageData) + countArray(fp.canvas && fp.canvas.toBlob),
webgl: countArray(fp.webgl && fp.webgl.getParameter) + countArray(fp.webgl && fp.webgl.getShaderPrecisionFormat) + countArray(fp.webgl && fp.webgl.readPixels) + (fp.webgl && fp.webgl.getSupportedExtensions ? 1 : 0),
webgpu: countArray(fp.webgpu && fp.webgpu.requestAdapter),
audio: countArray(fp.audio && fp.audio.startRendering) + countArray(fp.audio && fp.audio.getChannelData),
domGeometry: countArray(fp.domGeometry && fp.domGeometry.getBoundingClientRect) + countArray(fp.domGeometry && fp.domGeometry.offset),
};
}
}
if (badRenderLib) problems.push('发现 node-canvas / headless-gl 等渲染库依赖;指纹补环境应优先真实浏览器采样值回放,不要在 Node.js 中强行模拟渲染管线。');
if (automationForFingerprint) problems.push('补环境源码疑似包含浏览器自动化库或 page.goto / browser.launch;自动化只能用于前置取证采样,不能进入最终 env。');
if ((args.requireFingerprintFixture || terminalApiMentioned) && !fixtureExists) {
problems.push(`已涉及或要求指纹终端 API 值回放,但未找到指纹 fixture:${fixtureFile}`);
}
if (terminalApiMentioned && !valueReplayMentioned) {
warnings.push('源码涉及指纹终端 API,但未明显体现 fingerprint fixture / replay;请确认不是静默伪造默认值。');
}
if (args.requireFingerprintFixture && fixtureExists && Object.values(counts).reduce((a, b) => a + b, 0) === 0) {
problems.push('已要求指纹 fixture,但未发现 Canvas / WebGL / WebGPU / Audio / DOM 几何终端 API 采样值。');
}
return {
result: {
required: !!args.requireFingerprintFixture || terminalApiMentioned,
fixture: fixtureExists ? rel(caseDir, fixtureFile) : '',
terminalApiMentioned,
valueReplayMentioned,
badRenderLib,
automationForFingerprint,
counts,
},
problems,
warnings,
};
}
function inspectAddonFirst(files, requireAddonFirst) {
const allText = files.map(f => readText(f)).join('\n');
const result = {
required: !!requireAddonFirst,
usesNativeProtectFallback: /NativeProtect|markNativeFunction\s*\(|markObjectToString\s*\(|setNativeFunc\s*\(|setObjFunc\s*\(|Function\.prototype\.toString|Object\.prototype\.toString/.test(allText),
usesNativeLikeCreation: /\b(?:createNativeFunction|createNativeConstructor|createNativeGetter|createNativeSetter|defineNativeValue|defineNativeGetter|defineNativeSetter|defineNativeAccessor|createUndetectable|createInterceptor|createNativeObject|createProtoChains|createNativeCollection|getMimeTypesAndPlugins|createDocument|getProtoChainRegistry|deleteProtoChainRegistryEntry|clearProtoChainRegistry|setPrivate|getPrivate|hasPrivate|deletePrivate|throwTypeError)\s*\(/.test(allText),
directAddonApi: /(?:\b(?:addon|nativeAddon|addonApi)\.|\bxbs\s*\.\s*(?:dom\s*\.\s*)?|\b(?:window|globalThis)\s*\.\s*xbs\s*\.\s*(?:dom\s*\.\s*)?)(?:createNativeFunction|createGetter|createSetter|createNativeObject|createProtoChains|createUndetectable|createInterceptor|createNativeCollection|getMimeTypesAndPlugins|getProtoChainRegistry|deleteProtoChainRegistryEntry|clearProtoChainRegistry|getPrivate|setPrivate|hasPrivate|deletePrivate|throwTypeError|createDocument)\s*\(/.test(allText),
addonAwareHelper: /\b(?:createNativeFunction|createNativeConstructor|createNativeGetter|createNativeSetter|defineNativeValue|defineNativeGetter|defineNativeSetter|defineNativeAccessor|createUndetectable|createInterceptor|createNativeObject|createProtoChains|createNativeCollection|getMimeTypesAndPlugins|createDocument|getProtoChainRegistry|deleteProtoChainRegistryEntry|clearProtoChainRegistry|setPrivate|getPrivate|hasPrivate|deletePrivate|throwTypeError)\s*\([\s\S]{0,260}\b(?:addon|nativeAddon|options\.addon|addonResult|xbs|options\.xbs|xbsApi)\b/.test(allText),
normalizesAddonResult: /\b(?:normalizeAddon|getAddonApi)\s*\(|\baddonLike\.addon\b|\baddonResult\.addon\b|\baddon\s*&&\s*addon\.addon\b/.test(allText),
loadsAddon: /loadNativeAddon\s*\(|load_native_addon|WEB_JS_ENV_PATCHER_ADDON|WEB_JS_ENV_PATCHER_XBS_ISOLATED_VM|assets[\\/]+native-addon|native-addon|addon\.node|xbs-isolated-vm|isolated_vm\.node|window\.xbs|globalThis\.xbs|require\s*\([^)]*\.node/.test(allText),
acceptsAddonInput: /\boptions\.(?:addon|xbs|xbsIsolatedVmPath)\b|function\s+\w+\s*\([^)]*\b(?:addon|xbs)\b|,\s*(?:addon|xbs)\s*\)|\{\s*(?:addon|xbs)\s*\}/.test(allText),
fallbackDocumented: /fallback|降级|addon 不可用|addon不可用|NativeProtect fallback|JS fallback/.test(allText),
newProtoChainsApi: /\bcreateProtoChains\s*\(\s*\[|\.(?:createProtoChains)\s*\(\s*\[/.test(allText),
oldStyleCreateProtoChains: /\bcreateProtoChains\s*\(\s*['"][^'"]+['"]\s*,/.test(allText),
oldStyleCreateNativeObject: /\bcreateNativeObject\s*\(\s*['"][^'"]+['"]\s*,/.test(allText),
};
result.addonFirstEvidence = result.directAddonApi || result.addonAwareHelper || result.normalizesAddonResult || result.newProtoChainsApi;
result.needsAddonFirst = result.usesNativeProtectFallback || result.usesNativeLikeCreation;
const problems = [];
const warnings = [];
if (requireAddonFirst && result.needsAddonFirst && !result.addonFirstEvidence) {
problems.push('已要求 addon-first,但源码使用 native-like / NativeProtect 相关能力时未发现先尝试 addon.node 或 xbs native API 的证据;创建函数、getter、setter、document.all、原型链等应先走 native API,native 能力不可用时才降级 NativeProtect。');
}
if (requireAddonFirst && result.needsAddonFirst && !result.loadsAddon && !result.acceptsAddonInput) {
problems.push('已要求 addon-first,但源码既未加载 addon.node / xbs isolated-vm,也未显式接收 addon/options.addon/options.xbs;无法保证补环境时优先使用 native API。');
}
if ((requireAddonFirst || result.usesNativeProtectFallback) && result.usesNativeProtectFallback && !result.addonFirstEvidence && !result.fallbackDocumented) {
warnings.push('源码使用 NativeProtect / JS fallback,但未明显记录 addon.node / xbs native API 不可用或调用失败的降级原因;建议输出 native 能力可用性和降级说明。');
}
if (result.oldStyleCreateProtoChains) {
const msg = '发现旧式 createProtoChains(name, chain) 调用;新补环境代码应迁移为 createProtoChains(descriptors),旧式形态只能出现在兼容层并记录迁移 / fallback 原因。';
if (/旧式|兼容|fallback|迁移|历史/.test(allText)) warnings.push(msg);
else problems.push(msg);
}
if (result.oldStyleCreateNativeObject) {
const msg = '发现旧式 createNativeObject(tag, proto, properties) 调用;新补环境代码应使用 createNativeObject(options) 或优先 createProtoChains(descriptors),旧式形态只能出现在兼容层并记录迁移 / fallback 原因。';
if (/旧式|兼容|fallback|迁移|历史/.test(allText)) warnings.push(msg);
else problems.push(msg);
}
return { result, problems, warnings };
}
function check(args) {
if (!args.caseDir && !args.file) throw new Error('必须提供 --case-dir 或 --file');
const caseDir = args.caseDir ? path.resolve(args.caseDir) : path.resolve(path.dirname(args.file), '..', '..');
const root = args.file ? path.dirname(path.resolve(args.file)) : path.join(caseDir, 'result');
const files = args.file ? [path.resolve(args.file)] : codeFiles(root);
const allText = files.map(f => readText(f)).join('\n');
const problems = [];
const warnings = [];
if (!files.length) problems.push(`未找到可检查的补环境源码文件:${root}`);
const checks = {
descriptors: any(/Object\.definePropert(?:y|ies)\s*\(/, files),
prototypeChain: any(/Object\.setPrototypeOf\s*\(|Object\.create\s*\([^\n;]*\.prototype|createProtoChains\s*\(/, files),
functionToString: any(/NativeProtect|Function\.prototype\.toString|createNativeFunction\s*\(|createNativeConstructor\s*\(|markNativeFunction\s*\(|setNativeFunc\s*\(/, files),
accessorToString: any(/createGetter\s*\(|createSetter\s*\(|createNativeGetter\s*\(|createNativeSetter\s*\(|defineNativeGetter\s*\(|defineNativeSetter\s*\(|defineNativeAccessor\s*\(|setNativeFunc\s*\([^\n]*(get|set)\s+/i, files),
instanceToString: any(/setObjFunc\s*\(|markObjectToString\s*\(|Symbol\.toStringTag|createNativeObject\s*\(|createProtoChains\s*\(/, files),
documentAllExact: any(/(?:createUndetectable\s*\(|xbs\s*\.\s*dom\s*\.\s*createDocument\s*\(|\bdom\s*\.\s*createDocument\s*\()/, files),
documentAllMentioned: /document\.all|['"]all['"]/.test(allText),
fingerprintValueReplay: any(/installFingerprintValueReplay|findReplay|fingerprint\.fixture|指纹.*回放|value replay/i, files),
};
if (!checks.descriptors) problems.push('未发现 Object.defineProperty / defineProperties;补环境不能只用普通赋值,必须显式属性描述符。');
if (!checks.prototypeChain) problems.push('未发现 Object.create(...prototype) / Object.setPrototypeOf / createProtoChains;需要按规则补构造函数和原型链。');
if (!checks.functionToString) problems.push('未发现函数 toString 保护(NativeProtect / createNativeFunction / markNativeFunction 等)。');
if (!checks.accessorToString) problems.push('未发现访问器 getter/setter 的 toString 保护(createGetter/createSetter/defineNativeGetter/defineNativeSetter 等)。');
if (!checks.instanceToString) problems.push('未发现实例对象 Object.prototype.toString 保护(Symbol.toStringTag / setObjFunc / createNativeObject 等)。');
if (args.requireDocumentAll && !checks.documentAllExact) problems.push('本 case 要求 document.all,但未发现 xbs.dom.createDocument 或 createUndetectable;document.all 不应仅用普通对象或 undefined 近似,isolated-vm 下优先 xbs.dom.createDocument,手写 document 时才用 xbs/createUndetectable handlers。');
if (!args.requireDocumentAll && checks.documentAllMentioned && !checks.documentAllExact) warnings.push('源码提到 document.all 但未发现 xbs.dom.createDocument 或 createUndetectable;如目标检测 HTMLDDA,isolated-vm 下优先 xbs.dom.createDocument,手写 document 时才用 xbs/createUndetectable handlers。');
const addonFirst = inspectAddonFirst(files, args.requireAddonFirst);
problems.push(...addonFirst.problems);
warnings.push(...addonFirst.warnings);
const ruyi = inspectRuyiTrace(caseDir, args.requireRuyiTrace);
problems.push(...ruyi.problems);
warnings.push(...ruyi.warnings);
const fingerprint = inspectFingerprint(caseDir, files, args);
problems.push(...fingerprint.problems);
warnings.push(...fingerprint.warnings);
return {
caseDir,
checkedRoot: root,
clean: problems.length === 0,
checks,
matching: {
descriptors: matchingFiles(/Object\.definePropert(?:y|ies)\s*\(/, files, caseDir),
prototypeChain: matchingFiles(/Object\.setPrototypeOf\s*\(|Object\.create\s*\([^\n;]*\.prototype|createProtoChains\s*\(/, files, caseDir),
functionToString: matchingFiles(/NativeProtect|Function\.prototype\.toString|createNativeFunction\s*\(|createNativeConstructor\s*\(|markNativeFunction\s*\(|setNativeFunc\s*\(/, files, caseDir),
accessorToString: matchingFiles(/createGetter\s*\(|createSetter\s*\(|createNativeGetter\s*\(|createNativeSetter\s*\(|defineNativeGetter\s*\(|defineNativeSetter\s*\(|defineNativeAccessor\s*\(|setNativeFunc\s*\([^\n]*(get|set)\s+/i, files, caseDir),
instanceToString: matchingFiles(/setObjFunc\s*\(|markObjectToString\s*\(|Symbol\.toStringTag|createNativeObject\s*\(|createProtoChains\s*\(/, files, caseDir),
documentAllExact: matchingFiles(/(?:createUndetectable\s*\(|xbs\s*\.\s*dom\s*\.\s*createDocument\s*\(|\bdom\s*\.\s*createDocument\s*\()/, files, caseDir),
fingerprintValueReplay: matchingFiles(/installFingerprintValueReplay|findReplay|fingerprint\.fixture|指纹.*回放|value replay/i, files, caseDir),
addonFirstEvidence: matchingFiles(/(?:\b(?:addon|nativeAddon|addonApi)\.|\bxbs\s*\.\s*(?:dom\s*\.\s*)?|\b(?:window|globalThis)\s*\.\s*xbs\s*\.\s*(?:dom\s*\.\s*)?)(?:createNativeFunction|createGetter|createSetter|createNativeObject|createProtoChains|createUndetectable|createInterceptor|createNativeCollection|getMimeTypesAndPlugins|getProtoChainRegistry|deleteProtoChainRegistryEntry|clearProtoChainRegistry|getPrivate|setPrivate|hasPrivate|deletePrivate|throwTypeError|createDocument)\s*\(|\b(?:createNativeFunction|createNativeConstructor|createNativeGetter|createNativeSetter|defineNativeValue|defineNativeGetter|defineNativeSetter|defineNativeAccessor|createUndetectable|createInterceptor|createNativeObject|createProtoChains|createNativeCollection|getMimeTypesAndPlugins|createDocument|getProtoChainRegistry|deleteProtoChainRegistryEntry|clearProtoChainRegistry|setPrivate|getPrivate|hasPrivate|deletePrivate|throwTypeError)\s*\([\s\S]{0,260}\b(?:addon|nativeAddon|options\.addon|addonResult|xbs|options\.xbs|xbsApi)\b|\b(?:normalizeAddon|getAddonApi)\s*\(|\bcreateProtoChains\s*\(\s*\[/, files, caseDir),
},
addonFirst: addonFirst.result,
addonFirstOptOut: args.addonFirstOptOut,
ruyiTrace: ruyi.result,
fingerprint: fingerprint.result,
problems,
warnings,
files: files.map(f => rel(caseDir, f)),
};
}
function renderMarkdown(result) {
const lines = [
'# 补环境真实性与 RuyiTrace 证据检查',
'',
`case 目录:${result.caseDir}`,
`检查范围:${result.checkedRoot}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 真实性检查项',
`- 属性描述符:${result.checks.descriptors ? '通过' : '缺失'}`,
`- 原型链 / 构造函数:${result.checks.prototypeChain ? '通过' : '缺失'}`,
`- 函数 toString 保护:${result.checks.functionToString ? '通过' : '缺失'}`,
`- 访问器 toString 保护:${result.checks.accessorToString ? '通过' : '缺失'}`,
`- 实例对象 toString 保护:${result.checks.instanceToString ? '通过' : '缺失'}`,
`- addon-first 规则:${result.addonFirst.required ? (result.addonFirst.addonFirstEvidence ? '已发现先尝试 addon.node / xbs native API 的证据' : '缺失 native 优先证据') : (result.addonFirstOptOut ? '用户明确豁免,未强制检查' : '未强制检查')}`,
` - 直接 addon API:${result.addonFirst.directAddonApi ? '是' : '否'};addon-aware helper:${result.addonFirst.addonAwareHelper ? '是' : '否'};加载 addon/xbs:${result.addonFirst.loadsAddon ? '是' : '否'};接收 addon/xbs 输入:${result.addonFirst.acceptsAddonInput ? '是' : '否'};新版 createProtoChains:${result.addonFirst.newProtoChainsApi ? '是' : '否'}`,
` - 旧式 API:createProtoChains(name, chain)=${result.addonFirst.oldStyleCreateProtoChains ? '发现' : '未发现'};createNativeObject(tag, proto, properties)=${result.addonFirst.oldStyleCreateNativeObject ? '发现' : '未发现'}`,
`- document.all 不可检测对象:${result.checks.documentAllExact ? '已使用 xbs.dom.createDocument / createUndetectable' : (result.checks.documentAllMentioned ? '提到但未精确处理' : '未涉及')}`,
`- 指纹终端 API 值回放:${result.checks.fingerprintValueReplay ? '已体现' : (result.fingerprint.terminalApiMentioned ? '涉及指纹 API 但未明显体现回放' : '未涉及')}`,
'',
'## RuyiTrace 证据检查',
`- 是否要求检查:${result.ruyiTrace.required ? '是' : '否'}`,
`- NDJSON:${result.ruyiTrace.ndjson.length ? result.ruyiTrace.ndjson.join('、') : '未发现'}`,
`- 摘要:${result.ruyiTrace.summary || '未发现'}`,
`- 优先级笔记:${result.ruyiTrace.priority || '未发现'}`,
'',
'## 指纹值回放检查',
`- 是否要求检查:${result.fingerprint.required ? '是' : '否'}`,
`- 指纹 fixture:${result.fingerprint.fixture || '未发现'}`,
`- 是否涉及终端 API:${result.fingerprint.terminalApiMentioned ? '是' : '否'}`,
`- 是否体现 fixture / replay:${result.fingerprint.valueReplayMentioned ? '是' : '否'}`,
`- 是否发现渲染库依赖:${result.fingerprint.badRenderLib ? '是' : '否'}`,
`- 是否发现自动化代码:${result.fingerprint.automationForFingerprint ? '是' : '否'}`,
`- 样本计数:${JSON.stringify(result.fingerprint.counts || {})}`,
'',
];
if (result.problems.length) {
lines.push('## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
lines.push('');
}
if (result.warnings.length) {
lines.push('## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
lines.push('');
}
lines.push('## 检查文件');
for (const f of result.files) lines.push(`- ${f}`);
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);
}

View File

@@ -0,0 +1,360 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
function parseArgs(argv) {
const args = {
caseDir: '',
requireLive: false,
tlsClient: '',
beforeRealRequest: false,
strictP2: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--require-live') args.requireLive = true;
else if (arg === '--tls-client') args.tlsClient = argv[++i] || '';
else if (arg === '--before-real-request') args.beforeRealRequest = true;
else if (arg === '--strict-p2') args.strictP2 = true;
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_environment_closure.js --case-dir case --before-real-request --markdown
node scripts/check_environment_closure.js --case-dir case --before-real-request --require-live --tls-client curl_cffi --json
说明:统一执行 Trace-runtime、WebAPI 行为、对象形状和 XHR/fetch 请求语义闭环。真实请求前必须使用 --before-real-request,任一组件失败都会阻断。`;
}
function exists(file) {
try { return fs.existsSync(file); } catch { return false; }
}
function stat(file) {
try { return fs.statSync(file); } catch { return null; }
}
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 hasTraceEvidence(caseDir) {
const fixed = [
path.join(caseDir, 'ruyi-trace'),
path.join(caseDir, 'tmp', 'env-trace.jsonl'),
path.join(caseDir, 'tmp', 'missing-env.json'),
path.join(caseDir, 'notes', 'trace-api-inventory.json'),
path.join(caseDir, 'notes', 'trace-runtime-contract.json'),
];
if (fixed.some(exists)) return true;
return walk(caseDir).some(file => {
const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/');
if (/(^|\/)(node_modules|dist|build|coverage|vendor)(\/|$)/i.test(relative)) return false;
return /(?:^|\/)(?:trace|ruyitrace|missing-env)[^/]*\.(?:json|jsonl|ndjson|log|txt)$/i.test(relative);
});
}
function evidenceFiles(caseDir) {
const roots = ['阶段报告', 'notes', 'tmp', 'fixtures', 'ruyi-trace', 'result'];
const files = [];
for (const root of roots) {
for (const file of walk(path.join(caseDir, root))) {
const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/');
if (/(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party)(\/|$)/i.test(relative)) continue;
if (!/\.(?:md|txt|log|json|jsonl|ndjson|js|mjs|cjs|ts|py|html)$/i.test(file)) continue;
files.push(file);
if (files.length >= 2000) return files;
}
}
return files;
}
function scanCaseEvidence(caseDir) {
const resultDir = path.join(caseDir, 'result');
let hasBrowserEnv = false;
let hasNetworkImpl = false;
let networkEvidence = false;
let webapiEvidence = false;
let realmLifecycleEvidence = false;
let domCrudEvidence = false;
let objectShapeEvidence = false;
const hits = {
network: [],
webapi: [],
realmLifecycle: [],
domCrud: [],
objectShape: [],
};
const recordHit = (type, file) => {
const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/');
if (hits[type].length < 8 && !hits[type].includes(relative)) hits[type].push(relative);
};
for (const file of evidenceFiles(caseDir)) {
let text = '';
try { text = fs.readFileSync(file, 'utf8').slice(0, 300000); } catch { continue; }
const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/');
const inResult = relative.startsWith('result/');
if (/\b(XMLHttpRequest|fetch|sendBeacon|Request|Response|Headers)\b|network-transcript|xhr-fetch/i.test(text)
|| /(?:browser|node)-network-transcript\.(?:json|ndjson)$/i.test(relative)) {
networkEvidence = true;
recordHit('network', file);
if (inResult && /\.(?:js|mjs|cjs|ts|py)$/i.test(file)) hasNetworkImpl = true;
}
if (/\b(window|document|navigator|EventTarget|Storage|Performance|Element|Node)\b/.test(text)
|| /(?:browser|node)-env-detection-(?:baseline|audit)\.json$/i.test(relative)) {
webapiEvidence = true;
recordHit('webapi', file);
if (inResult && /\.(?:js|mjs|cjs|ts|py)$/i.test(file)) hasBrowserEnv = true;
}
if (/iframe|contentWindow|contentDocument|defaultView|\bWorker\b|WorkerGlobalScope|MessageChannel|MessagePort|postMessage|terminate/i.test(text)) {
realmLifecycleEvidence = true;
webapiEvidence = true;
recordHit('realmLifecycle', file);
recordHit('webapi', file);
}
if (/appendChild|removeChild|insertBefore|replaceChild|replaceChildren|cloneNode|importNode|adoptNode|innerHTML|DOMParser|querySelector|MutationObserver|childNodes|getElementsBy/i.test(text)) {
domCrudEvidence = true;
webapiEvidence = true;
recordHit('domCrud', file);
recordHit('webapi', file);
}
if (/Object\.keys|Object\.getOwnPropertyNames|Object\.getOwnPropertyDescriptor|Reflect\.ownKeys|prototypeChain|ownKeys|private-state-leakage/i.test(text)) {
objectShapeEvidence = true;
recordHit('objectShape', file);
}
}
for (const file of walk(resultDir)) {
if (!/\.(js|mjs|cjs|ts|py)$/i.test(file)) continue;
const relative = (path.relative(resultDir, file) || '.').replace(/\\/g, '/');
if (/(^|\/)(node_modules|dist|build|coverage)(\/|$)/i.test(relative)) continue;
let text = '';
try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
if (/(^|\/)src\/(?:env|node-runtime\/env)\//i.test(relative)
|| /\b(window|document|navigator|EventTarget|Storage|Performance|Element|Node)\b/.test(text)) {
hasBrowserEnv = true;
}
if (/\b(XMLHttpRequest|fetch|sendBeacon|Request|Response|Headers)\b/.test(text)) hasNetworkImpl = true;
}
return {
resultDir,
hasBrowserEnv,
hasNetworkImpl,
networkEvidence,
webapiEvidence,
realmLifecycleEvidence,
domCrudEvidence,
objectShapeEvidence,
hits,
};
}
function collectMetrics(value, out = {}, depth = 0) {
if (!value || typeof value !== 'object' || depth > 6) return out;
const keys = new Set([
'baselineId',
'traceSourceHash',
'contractHash',
'runtimeSourceHash',
'blockingMismatches',
'mismatched',
'networkMismatches',
'extraNodeRequests',
'extraNodeEvents',
'networkAttempts',
'sameSessionVerified',
'roundTripCount',
]);
if (Array.isArray(value)) {
for (const item of value.slice(0, 50)) collectMetrics(item, out, depth + 1);
return out;
}
for (const [key, item] of Object.entries(value)) {
if (keys.has(key) && item !== '' && item !== null && typeof item !== 'undefined') {
if (!out[key]) out[key] = [];
const encoded = typeof item === 'object' ? JSON.stringify(item) : String(item);
if (!out[key].includes(encoded) && out[key].length < 10) out[key].push(encoded);
}
if (item && typeof item === 'object') collectMetrics(item, out, depth + 1);
}
return out;
}
function summarizeComponentData(data) {
if (!data || typeof data !== 'object') return data;
return {
schemaVersion: data.schemaVersion || '',
clean: data.clean,
passed: data.passed,
metrics: collectMetrics(data),
summary: data.summary && typeof data.summary === 'object' ? data.summary : undefined,
problems: Array.isArray(data.problems) ? data.problems.slice(0, 20) : [],
warnings: Array.isArray(data.warnings) ? data.warnings.slice(0, 10) : [],
};
}
function runJson(scriptDir, script, args) {
const command = [path.join(scriptDir, script), ...args, '--json'];
const ret = spawnSync(process.execPath, command, {
encoding: 'utf8',
windowsHide: true,
timeout: 180000,
});
let rawData = null;
try { rawData = JSON.parse(String(ret.stdout || '').trim()); } catch {}
return {
script,
exitCode: ret.status,
clean: ret.status === 0 && rawData && rawData.clean !== false && rawData.passed !== false,
data: summarizeComponentData(rawData),
stderr: String(ret.stderr || '').trim().slice(0, 2000),
stdoutPreview: rawData ? '' : String(ret.stdout || '').trim().slice(0, 2000),
};
}
function check(args) {
const caseDir = path.resolve(args.caseDir || '.');
const scriptDir = __dirname;
const signals = scanCaseEvidence(caseDir);
const trace = hasTraceEvidence(caseDir);
const components = [];
if (trace) {
const traceArgs = ['--case-dir', caseDir, '--require-runtime-closure'];
if (args.beforeRealRequest) traceArgs.push('--require-stage-audit');
components.push(runJson(scriptDir, 'check_trace_api_coverage.js', traceArgs));
}
if (signals.hasBrowserEnv
|| signals.webapiEvidence
|| signals.objectShapeEvidence
|| trace
|| args.beforeRealRequest) {
components.push(runJson(scriptDir, 'check_object_shape_audit.js', ['--case-dir', caseDir, '--require']));
components.push(runJson(scriptDir, 'check_webapi_env_detection_matrix.js', ['--case-dir', caseDir, '--require']));
}
if (signals.hasBrowserEnv || signals.hasNetworkImpl || signals.webapiEvidence || args.beforeRealRequest) {
components.push(runJson(scriptDir, 'check_code_quality.js', ['--case-dir', caseDir]));
}
if (signals.hasBrowserEnv || signals.hasNetworkImpl || signals.webapiEvidence) {
components.push(runJson(scriptDir, 'check_webapi_addon_coverage.js', ['--case-dir', caseDir, '--warnings-as-errors']));
}
if (signals.hasNetworkImpl || signals.networkEvidence || args.requireLive) {
const semanticsArgs = ['--case-dir', caseDir, '--require', '--require-no-send', '--out', path.join(caseDir, 'tmp', 'xhr-fetch-semantics-audit.json')];
components.push(runJson(scriptDir, 'check_xhr_fetch_semantics.js', semanticsArgs));
const bridgeArgs = ['--case-dir', caseDir];
if (args.requireLive || args.beforeRealRequest) bridgeArgs.push('--require-live');
if (args.tlsClient) bridgeArgs.push('--tls-client', args.tlsClient);
components.push(runJson(scriptDir, 'check_xhr_fetch_session_bridge.js', bridgeArgs));
}
const problems = [];
for (const component of components) {
if (!component.clean) {
const detail = component.data && Array.isArray(component.data.problems)
? component.data.problems.slice(0, 20).join(';')
: (component.stderr || component.stdoutPreview || `exit=${component.exitCode}`);
problems.push(`${component.script} 未通过:${detail}`);
}
}
if (args.beforeRealRequest && !components.length) problems.push('真实请求前没有触发任何环境闭环组件,说明 case 结构或检测信号缺失');
return {
schemaVersion: 'environment-closure/v3',
generatedBy: 'check_environment_closure.js',
caseDir,
clean: problems.length === 0,
beforeRealRequest: args.beforeRealRequest,
requireLive: args.requireLive,
tlsClient: args.tlsClient || '',
signals: {
trace,
browserEnv: signals.hasBrowserEnv,
networkImpl: signals.hasNetworkImpl,
networkEvidence: signals.networkEvidence,
webapiEvidence: signals.webapiEvidence,
realmLifecycleEvidence: signals.realmLifecycleEvidence,
domCrudEvidence: signals.domCrudEvidence,
objectShapeEvidence: signals.objectShapeEvidence,
evidenceFiles: signals.hits,
},
components,
problems,
};
}
function renderMarkdown(result) {
const lines = [
'# 补环境闭环总门禁',
'',
`- case:${result.caseDir}`,
`- 真实请求前模式:${result.beforeRealRequest ? '是' : '否'}`,
`- 要求 live session:${result.requireLive ? '是' : '否'}`,
`- TLS 客户端:${result.tlsClient || '未指定'}`,
`- 是否通过:${result.clean ? '是' : '否'}`,
'',
'## 检测信号',
`- Trace:${result.signals.trace ? '是' : '否'}`,
`- 浏览器环境实现:${result.signals.browserEnv ? '是' : '否'}`,
`- XHR/fetch 实现:${result.signals.networkImpl ? '是' : '否'}`,
`- 网络证据:${result.signals.networkEvidence ? '是' : '否'}`,
`- Realm / 消息生命周期证据:${result.signals.realmLifecycleEvidence ? '是' : '否'}`,
`- DOM CRUD 证据:${result.signals.domCrudEvidence ? '是' : '否'}`,
'',
'## 组件',
];
if (!result.components.length) lines.push('- 无');
for (const component of result.components) {
lines.push(`- ${component.script}:${component.clean ? '通过' : '失败'},exit=${component.exitCode}`);
const metrics = component.data && component.data.metrics ? component.data.metrics : {};
const metricText = Object.entries(metrics)
.map(([key, values]) => `${key}=${values.join('|')}`)
.join(';');
if (metricText) lines.push(` ${metricText}`);
}
if (result.problems.length) {
lines.push('', '## 阻断问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
}
return `${lines.join('\n')}\n`;
}
if (require.main === module) {
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 };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,632 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: null,
file: null,
requireFinalSummary: true,
finalSummaryOptOut: false,
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i];
else if (a === '--file' || a === '-f') args.file = argv[++i];
else if (a === '--no-require-final-summary' || a === '--allow-no-final-summary') {
args.requireFinalSummary = false;
args.finalSummaryOptOut = 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_final_artifact.js --case-dir case --markdown
node scripts/check_final_artifact.js --case-dir case --no-require-final-summary --markdown
node scripts/check_final_artifact.js --case-dir case --file case/result/final.js --json
说明:检查最终交付项目是否是规范目录、只有一个执行入口、无浏览器自动化代码,并确认入口链路最终由已确认的 TLS 指纹兼容 Node.js / Python Session 请求客户端发起少量模拟请求,或明确不发真实请求;同时检查是否硬编码 / 复用了 cURL 或 fixture 中的样本加密参数值。最终总结 result/最终项目总结.md 是默认硬性要求,且必须包含 Trace-runtime 可执行闭环、XHR/fetch Session Bridge、XHR/fetch 请求语义审计、对象形状审计矩阵、WebAPI 环境检测矩阵和高强度环境检测覆盖矩阵;只有用户明确要求不生成最终总结时,才允许传入 --no-require-final-summary 并在阶段输出中记录原因。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } 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 walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
}
out.push(p);
return out;
}
function rel(root, p) {
return (path.relative(root, p) || '.').replace(/\\/g, '/');
}
function ext(p) {
return path.extname(p).toLowerCase();
}
const AUTOMATION_PATTERNS = [
/\bruyipage\b/i,
/\bFirefoxPage\b/,
/\bRuyiTrace\b/i,
/\bplaywright\b/i,
/\bpuppeteer\b/i,
/\bpyppeteer\b/i,
/\bselenium\b/i,
/\bwebdriver\b/i,
/\bCloakBrowser\b/i,
/\bcloakbrowser\b/i,
/\bcamoufox\b/i,
/\bcamoufox_reverse_mcp\b/i,
/\bCamoufox\b/,
/\bAsyncCamoufox\b/,
/\bbrowser\.new_page\b/i,
/\bbrowser\.newPage\b/i,
/\blaunch_browser\s*\(/i,
/\bnetwork_capture\s*\(/i,
/\bbrowser-use\b/i,
/\bchromium\.launch\b/i,
/\bfirefox\.launch\b/i,
/\bbrowser\.launch\b/i,
/\bpage\.goto\s*\(/i,
/\bpage\.capture\b/i,
/\bCDP\b/,
/\bMarionette\b/i,
];
const JS_REQUEST_PATTERNS = [
/\bcycleTLS\s*\(/i,
/\bCycleTLS\b/,
/\bcycletls\b/i,
/\bimpers\b/i,
/\bimpersFetch\b/i,
/\bcurl-cffi\b/i,
/\bcurl-cffi-node\b/i,
/require\s*\(\s*['"]curl-cffi['"]\s*\)/,
/from\s+['"]curl-cffi['"]/,
/\bCurlSession\b/,
/\bCurlRequest\b/,
/\breq\.request\s*\(/,
];
const REQUEST_SESSION_PATTERNS = [
/\bcreateRequestSession\s*\(/i,
/\bcreate_request_session\s*\(/i,
/\bSession\s*\(/,
/\brequests\.Session\s*\(/,
/\bCurlSession\b/,
/\bCookieJar\b/i,
/\btough-cookie\b/i,
/\bcookieJar\b/,
/\bsession\.request\s*\(/i,
/\bsession\.close\s*\(/i,
/\bjar\.setCookie/i,
/Set-Cookie/i,
/销毁\s*session/i,
/清理\s*Cookie\s*jar/i,
];
const PY_REQUEST_PATTERNS = [
/\bcurl_cffi\b/,
/\bcffi_curl\b/,
/\bcyCronet\b/i,
/\bcycronet\b/i,
];
const NO_REAL_REQUEST_PATTERNS = [
/不发真实请求/,
/不发送真实请求/,
/只输出本地\s*(sign|参数)/i,
/仅输出本地\s*(sign|参数)/i,
/\bnoRealRequest\b/i,
/\bno-real-request\b/i,
/\bdryRunOnly\b/i,
/\blocalSignOnly\b/i,
];
const FINGERPRINT_RENDER_PATTERNS = [
/\b(require|import)\s*\(?\s*['"](?:canvas|node-canvas|gl|headless-gl)['"]|from\s+['"](?:canvas|node-canvas|gl|headless-gl)['"]/i,
/__WEB_JS_ENV_PATCHER_FINGERPRINT__/,
/generate_fingerprint_hook/i,
];
function findMatches(text, patterns) {
const hits = [];
for (const pattern of patterns) {
const m = text.match(pattern);
if (m) hits.push(pattern.toString());
}
return hits;
}
function isTextLikeFile(p) {
return ['.js', '.mjs', '.cjs', '.py', '.json', '.md', '.txt', '.yaml', '.yml', '.curl', '.http', '.har'].includes(ext(p));
}
function isCodeLikeFile(p) {
return ['.js', '.mjs', '.cjs', '.py', '.json'].includes(ext(p));
}
function isTempOrTestFile(p) {
const n = path.basename(p).toLowerCase();
const normalized = String(p).replace(/\\/g, '/').toLowerCase();
if (/(^|\/)(tmp|\.tmp|temp|\.temp|cache|\.cache|browser-profile|firefox-profile|chrome-profile|profile|screenshots|hooks|ruyi-trace|logs|trace)(\/|$)/.test(normalized)) return true;
if (/\.(tmp|temp|log|jsonl|har|png|jpg|jpeg|webp|trace|cache|bak|old|orig|retry|partial|download|crdownload)$/i.test(n)) return true;
if (/^(test-|tmp-|temp-|debug-|scratch-)/i.test(n)) return true;
if (/\b(test|spec|debug|scratch|trace|hook|fixture|mock)\b/i.test(n)) return true;
return false;
}
function isRootSecondEntry(resultDir, p, primary) {
if (path.dirname(p) !== resultDir) return false;
if (path.resolve(p) === path.resolve(primary)) return false;
const n = path.basename(p).toLowerCase();
if (n === 'package.json' || n === 'requirements.txt' || n === 'config.example.json') return false;
return ['.js', '.mjs', '.cjs', '.py'].includes(ext(p));
}
function inspectPackageJson(resultDir, problems, warnings) {
const pkg = path.join(resultDir, 'package.json');
if (!exists(pkg)) return;
let data;
try {
data = JSON.parse(readText(pkg));
} catch (err) {
problems.push(`package.json 解析失败:${err.message}`);
return;
}
const scripts = data.scripts || {};
for (const [name, cmd] of Object.entries(scripts)) {
const lowerName = name.toLowerCase();
const lowerCmd = String(cmd).toLowerCase();
if (lowerName === 'start') {
if (!/\bnode\s+(\.\/)?final\.js\b/.test(lowerCmd)) warnings.push('package.json 的 start 脚本建议只指向 node final.js');
} else if (/(test|debug|dev|server|serve|watch|browser|playwright|puppeteer)/i.test(lowerName + ' ' + lowerCmd)) {
problems.push(`package.json 存在非交付用途脚本:${name}=${cmd}`);
}
}
}
const CRYPTO_PARAM_NAME_RE = /^(?:sign|signature|_signature|x[-_]?sign|x[-_]?s|x[-_]?t|a[_-]?bogus|h5st|mtgsig|w[_-]?rid|x[-_]?bogus|x[-_]?(?:ladon|argus|gorgon|khronos|ss[-_]?stub|mini[-_]?wua|umt)|token|access[-_]?token|auth[-_]?token|x[-_]?token|csrf[-_]?token|csrftoken|verify|digest|hash|hmac|mac|nonce|sig)$/i;
function isPlaceholderValue(value) {
const s = String(value || '');
return !s || s.length < 8 || /^(?:xxx+|test+|example|placeholder|todo|replace|redacted|your[_-]?)/i.test(s) || /^[*]+$/.test(s);
}
function isLikelyConcatenationFragment(value) {
const s = String(value || '');
const t = s.trim();
return /(?:^|\s)\+\s*[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\s*\+\s*)?$/.test(s)
|| /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\+(?:\s|$)/.test(t)
|| (/^\+/.test(t) || /\+$/.test(t)) && /\s/.test(s);
}
function maskCryptoValue(value) {
const s = String(value || '');
if (s.length <= 8) return '*'.repeat(s.length);
return `${s.slice(0, 4)}...${s.slice(-4)}(len=${s.length})`;
}
function addSampleCryptoValue(out, name, value, sourceFile, sourcePath) {
if (!CRYPTO_PARAM_NAME_RE.test(String(name || ''))) return;
if (isPlaceholderValue(value)) return;
const key = `${String(name).toLowerCase()}:${String(value)}`;
if (out.has(key)) return;
out.set(key, { name: String(name), value: String(value), masked: maskCryptoValue(value), sourceFile, sourcePath });
}
function decodeURIComponentSafe(s) { try { return decodeURIComponent(s); } catch { return s; } }
function extractJsonCryptoValues(obj, out, sourceFile, prefix = 'json') {
if (!obj || typeof obj !== 'object') return;
for (const [k, v] of Object.entries(obj)) {
const p = `${prefix}.${k}`;
if (v && typeof v === 'object') extractJsonCryptoValues(v, out, sourceFile, p);
else addSampleCryptoValue(out, k, v, sourceFile, p);
}
}
function extractSampleCryptoValuesFromText(text, sourceFile) {
const out = new Map();
const urlRe = /https?:\/\/[^\s'"<>]+/g;
let m;
while ((m = urlRe.exec(text))) {
try {
const u = new URL(m[0]);
for (const [k, v] of u.searchParams.entries()) addSampleCryptoValue(out, k, v, sourceFile, `query.${k}`);
} catch (_) {}
}
try { extractJsonCryptoValues(JSON.parse(text), out, sourceFile, 'json'); } catch (_) {}
const pairRe = /["']?([A-Za-z_$][\w$-]{0,80})["']?\s*(?::|=)\s*["']([^"'\r\n&;,}\s]{8,})["']/g;
while ((m = pairRe.exec(text))) addSampleCryptoValue(out, m[1], m[2], sourceFile, `pair.${m[1]}`);
const formRe = /(?:^|[?&\s])([A-Za-z_$][\w$-]{0,80})=([^&\s'"<>]{8,})/g;
while ((m = formRe.exec(text))) addSampleCryptoValue(out, m[1], decodeURIComponentSafe(m[2]), sourceFile, `form.${m[1]}`);
const headerRe = /(?:^|\s)(?:-H|--header)\s+['"]([^:'"]{1,100})\s*:\s*([^'"\r\n]+)['"]/g;
while ((m = headerRe.exec(text))) {
const headerName = m[1].trim();
const headerValue = m[2].trim();
addSampleCryptoValue(out, headerName, headerValue, sourceFile, `header.${headerName}`);
if (headerName.toLowerCase() === 'cookie') {
for (const part of headerValue.split(';')) {
const idx = part.indexOf('=');
if (idx !== -1) {
const cookieName = part.slice(0, idx).trim();
const cookieValue = part.slice(idx + 1).trim();
addSampleCryptoValue(out, cookieName, cookieValue, sourceFile, `cookie.${cookieName}`);
}
}
}
}
return [...out.values()];
}
function collectSampleCryptoValues(caseDir) {
const roots = ['requests', 'fixtures'].map(d => path.join(caseDir, d)).filter(exists);
const values = [];
for (const root of roots) {
for (const f of walk(root).filter(p => stat(p) && stat(p).isFile() && isTextLikeFile(p))) {
for (const item of extractSampleCryptoValuesFromText(readText(f), rel(caseDir, f))) values.push(item);
}
}
return values;
}
function inspectReusedSampleCryptoValues(caseDir, resultFiles, textFiles) {
const sampleValues = collectSampleCryptoValues(caseDir);
const reused = [];
for (const sample of sampleValues) {
for (const f of textFiles) {
const text = readText(f);
if (text.includes(sample.value)) reused.push({ file: rel(caseDir, f), name: sample.name, value: sample.masked, source: sample.sourceFile, sourcePath: sample.sourcePath });
}
}
const hardcoded = [];
const hardcodedRe = /["']?([A-Za-z_$][\w$-]{0,80})["']?\s*(?::|=)\s*["']([^"'\r\n]{8,})["']/g;
for (const f of resultFiles.filter(isCodeLikeFile)) {
const text = readText(f);
let m;
while ((m = hardcodedRe.exec(text))) {
const name = m[1];
const value = m[2];
if (CRYPTO_PARAM_NAME_RE.test(name) && !isPlaceholderValue(value) && !isLikelyConcatenationFragment(value)) {
hardcoded.push({ file: rel(caseDir, f), name, value: maskCryptoValue(value) });
}
}
}
const generationEvidence = [];
const generationRe = /install.*env|补环境|signer|makeSign|generate(?:Sign|Token|Params?)|compute(?:Sign|Token|Params?)|build(?:Sign|Token|Params?)|runTarget|target[\\/]entry|src[\\/]signer|vm\.Script|load.*(?:bundle|target|js)|createContext/i;
for (const f of resultFiles.filter(isCodeLikeFile)) {
const text = readText(f);
if (generationRe.test(text)) generationEvidence.push(rel(caseDir, f));
}
return {
sampleValues: sampleValues.map(x => ({ name: x.name, value: x.masked, source: x.sourceFile, sourcePath: x.sourcePath })),
reused,
hardcoded,
generationEvidence,
};
}
function inspectStageReports(caseDir) {
const stageDir = path.join(caseDir, '阶段报告');
const result = { dir: stageDir, present: exists(stageDir), files: [], initialPresent: false, chineseFileNames: true, mojibakeSuspected: false };
const problems = [];
const warnings = [];
if (!result.present) {
problems.push('缺少阶段报告目录 case/阶段报告;至少应在前置阶段生成中文命名阶段报告 01-需求信息确认.md。');
return { result, problems, warnings };
}
let names = [];
try { names = fs.readdirSync(stageDir); } catch { names = []; }
for (const name of names) {
const file = path.join(stageDir, name);
const st = stat(file);
if (!st || !st.isFile() || ext(file) !== '.md') continue;
const item = { file, chineseFileName: /[\u4e00-\u9fff]/.test(name), utf8Readable: false, mojibakeSuspected: false };
if (!item.chineseFileName) result.chineseFileNames = false;
try {
const text = readText(file);
item.utf8Readable = true;
const questionRuns = text.match(/\?{3,}/g) || [];
item.mojibakeSuspected = text.includes('\uFFFD') || (questionRuns.reduce((n, x) => n + x.length, 0) >= 8 && !/[\u4e00-\u9fff]/.test(text));
if (item.mojibakeSuspected) result.mojibakeSuspected = true;
} catch (_) {}
result.files.push(item);
}
result.initialPresent = result.files.some(x => path.basename(x.file) === '01-需求信息确认.md');
if (!result.files.length) problems.push('case/阶段报告 中没有 Markdown 阶段报告。');
if (!result.initialPresent) problems.push('缺少前置阶段报告:case/阶段报告/01-需求信息确认.md。');
if (!result.chineseFileNames) problems.push('阶段报告文件名必须包含中文,不能只使用英文文件名。');
if (result.mojibakeSuspected) problems.push('阶段报告疑似存在中文乱码或连续问号。');
return { result, problems, warnings };
}
const FINAL_SUMMARY_REQUIRED_SECTIONS = [
/阶段报告索引/,
/native addon\s*\/\s*NativeProtect 使用情况/i,
/环境与指纹\s*API\s*调用回放明细/i,
/Trace-runtime 可执行闭环/i,
/XHR\/fetch Session Bridge/i,
/XHR\/fetch 请求语义审计/i,
/对象形状审计矩阵与私有状态泄露/,
/WebAPI 环境检测矩阵/,
/高强度环境检测覆盖矩阵/,
/加密参数生成与样本复用检查/,
/代码质量与中文注释/,
/最终交付结构/,
/指纹基线一致性/,
/Session\s*请求链|Session 模式|TLS 请求验证与 Session 请求链/i,
/测试结果/,
/清理结果/,
];
function inspectFinalSummary(resultDir, requireFinalSummary) {
const finalSummary = path.join(resultDir, '最终项目总结.md');
const legacyFinalSummary = path.join(resultDir, 'final-summary.md');
const result = {
required: !!requireFinalSummary,
file: exists(finalSummary) ? finalSummary : '',
legacyFile: exists(legacyFinalSummary) ? legacyFinalSummary : '',
present: exists(finalSummary),
utf8Readable: false,
mojibakeSuspected: false,
missingSections: [],
};
const problems = [];
const warnings = [];
if (!requireFinalSummary) return { result, problems, warnings };
if (!result.present) {
if (exists(legacyFinalSummary)) problems.push('最终总结必须使用中文文件名 result/最终项目总结.md;当前只检测到旧文件名 result/final-summary.md,请改名或重新写入。');
else problems.push('项目完成后必须默认生成最终总结 result/最终项目总结.md;只有用户明确要求不生成时才可跳过,并需运行检查脚本时传入 --no-require-final-summary。');
return { result, problems, warnings };
}
let text = '';
try {
text = readText(finalSummary);
result.utf8Readable = true;
} catch (err) {
problems.push(`最终总结无法按 UTF-8 读取:${err.message || String(err)}`);
return { result, problems, warnings };
}
if (/\uFFFD/.test(text) || /\?{6,}/.test(text) || (!/[\u4e00-\u9fff]/.test(text) && /[?]{3,}/.test(text))) {
result.mojibakeSuspected = true;
problems.push('最终总结疑似存在中文编码乱码或连续问号,请使用 write_markdown_utf8.js 重新生成 UTF-8 Markdown。');
}
for (const pattern of FINAL_SUMMARY_REQUIRED_SECTIONS) {
if (!pattern.test(text)) result.missingSections.push(pattern.toString());
}
if (result.missingSections.length) {
problems.push(`最终总结缺少必要章节:${result.missingSections.join('、')}。项目完成后的总结必须包含 native addon / NativeProtect 使用情况、指纹基线一致性、环境与指纹 API 调用回放明细、Trace-runtime 可执行闭环、XHR/fetch Session Bridge、XHR/fetch 请求语义审计、对象形状审计矩阵与私有状态泄露、WebAPI 环境检测矩阵、高强度环境检测覆盖矩阵、最终请求 Session 请求链、加密参数生成与样本复用检查、代码质量与中文注释、最终交付结构、测试结果和清理结果。`);
}
if (!/^#\s+/.test(text.trim())) warnings.push('最终总结建议以一级标题开头。');
return { result, problems, warnings };
}
function check(args) {
if (!args.caseDir && !args.file) throw new Error('必须提供 --case-dir 或 --file');
const caseDir = args.caseDir ? path.resolve(args.caseDir) : path.resolve(path.dirname(args.file), '..');
const resultDir = path.join(caseDir, 'result');
const problems = [];
const warnings = [];
if (!exists(caseDir)) problems.push(`case 目录不存在:${caseDir}`);
if (!exists(resultDir)) problems.push(`结果目录不存在:${resultDir}`);
const candidate = args.file ? path.resolve(args.file) : null;
let primary = candidate;
if (!primary && exists(resultDir)) {
const candidates = ['final.js', 'final.py'].map(name => path.join(resultDir, name)).filter(exists);
if (candidates.length === 1) primary = candidates[0];
else if (candidates.length === 0) problems.push('未找到唯一执行入口:result/final.js 或 result/final.py');
else problems.push('同时存在 final.js 和 final.py;最终项目只能有一个执行入口');
}
const resultFiles = exists(resultDir) ? walk(resultDir).filter(p => stat(p) && stat(p).isFile()) : [];
const textFiles = resultFiles.filter(isTextLikeFile);
const codeFiles = resultFiles.filter(isCodeLikeFile);
if (primary && !exists(primary)) problems.push(`指定执行入口不存在:${primary}`);
if (primary && exists(primary)) {
const primaryExt = ext(primary);
if (!['.js', '.py'].includes(primaryExt)) problems.push('执行入口必须是 final.js 或 final.py');
if (path.dirname(primary) !== resultDir) warnings.push('执行入口不在 case/result/ 目录中,建议移动到 result/final.js 或 result/final.py');
const text = readText(primary);
if (primaryExt === '.js' && !/require\.main\s*===\s*module|import\.meta\.url|main\s*\(\s*\)\.catch|await\s+main\s*\(/.test(text)) {
warnings.push('未检测到清晰的 Node.js 直接运行入口;建议提供 main() 并在命令行运行时调用');
}
if (primaryExt === '.py' && !/if\s+__name__\s*==\s*['"]__main__['"]/.test(text)) {
warnings.push('未检测到 Python 直接运行入口 if __name__ == "__main__"');
}
const rootSecondEntries = resultFiles.filter(p => isRootSecondEntry(resultDir, p, primary));
if (rootSecondEntries.length) {
problems.push(`结果目录根部存在多个疑似执行入口:${rootSecondEntries.map(p => rel(caseDir, p)).join('、')}`);
}
const requestPatterns = primaryExt === '.py' ? PY_REQUEST_PATTERNS : JS_REQUEST_PATTERNS;
const requestSearchFiles = codeFiles.filter(p => primaryExt === '.py' ? ext(p) === '.py' : ['.js', '.mjs', '.cjs'].includes(ext(p)));
const requestHits = [];
const noRealRequestHits = [];
for (const f of requestSearchFiles) {
const text = readText(f);
const hits = findMatches(text, requestPatterns);
if (hits.length) requestHits.push({ file: rel(caseDir, f), hits });
const noRealHits = findMatches(text, NO_REAL_REQUEST_PATTERNS);
if (noRealHits.length) noRealRequestHits.push({ file: rel(caseDir, f), hits: noRealHits });
}
const sessionHits = [];
for (const f of requestSearchFiles) {
const text = readText(f);
const hits = findMatches(text, REQUEST_SESSION_PATTERNS);
if (hits.length) sessionHits.push({ file: rel(caseDir, f), hits });
}
if (!requestHits.length && !noRealRequestHits.length) {
problems.push('未检测到已确认的 TLS 指纹兼容请求客户端(Node.js CycleTLS / impers / curl-cffi-node,或 Python curl_cffi / cffi_curl / cyCronet);最终验证不能依赖普通 fetch/requests 或浏览器自动化。如用户不发真实请求,入口必须明确只输出本地 sign / 参数。');
}
if (requestHits.length && !noRealRequestHits.length && !sessionHits.length) {
problems.push('最终请求必须使用 Session 模式:即使只有一个请求,也要创建 session client,复用 Cookie jar / Header / UA / Client Hints / TLS 指纹,并在成功或失败后销毁 session。未检测到 createRequestSession / requests.Session / CookieJar / session.close 等证据。');
}
}
const automationHits = [];
for (const f of codeFiles) {
const hits = findMatches(readText(f), AUTOMATION_PATTERNS);
if (hits.length) automationHits.push({ file: rel(caseDir, f), hits });
}
if (automationHits.length) {
problems.push(`最终项目源码疑似包含浏览器自动化 / 取证代码:${automationHits.map(x => `${x.file}(${x.hits.join('、')})`).join(';')}`);
}
const fingerprintRenderHits = [];
for (const f of codeFiles) {
const hits = findMatches(readText(f), FINGERPRINT_RENDER_PATTERNS);
if (hits.length) fingerprintRenderHits.push({ file: rel(caseDir, f), hits });
}
if (fingerprintRenderHits.length) {
problems.push(`最终项目源码疑似包含指纹采样 Hook 或 Node.js 渲染库:${fingerprintRenderHits.map(x => `${x.file}(${x.hits.join('、')})`).join(';')}。指纹应由真实浏览器采样 fixture + 终端 API 值回放实现,采样 Hook 不得进入 result/。`);
}
const reuse = exists(resultDir) ? inspectReusedSampleCryptoValues(caseDir, resultFiles, textFiles) : { sampleValues: [], reused: [], hardcoded: [], generationEvidence: [] };
if (reuse.reused.length) {
problems.push(`最终项目疑似直接复用了请求样本 / fixture 中的加密参数值:${reuse.reused.map(x => `${x.file} 中 ${x.name}=${x.value}(来源 ${x.source}:${x.sourcePath})`).join(';')}。这些值只能作为 expected fixture,必须通过补环境重新生成。`);
}
if (reuse.hardcoded.length) {
problems.push(`最终项目疑似硬编码加密参数字面量:${reuse.hardcoded.map(x => `${x.file} 中 ${x.name}=${x.value}`).join(';')}。应调用补环境后的目标 JS 入口或 signer 模块生成,不能写死 cURL 里的值。`);
}
if (reuse.sampleValues.length && !reuse.generationEvidence.length) {
warnings.push('发现请求样本中存在可疑加密参数,但最终项目未明显体现补环境 / signer / 目标 JS 入口调用痕迹;请人工确认不是直接复用样本参数。');
}
const tempLike = resultFiles.filter(p => isTempOrTestFile(rel(resultDir, p)));
if (tempLike.length) problems.push(`结果目录存在临时 / 测试 / 调试产物:${tempLike.map(p => rel(caseDir, p)).join('、')}`);
inspectPackageJson(resultDir, problems, warnings);
const stageReports = inspectStageReports(caseDir);
problems.push(...stageReports.problems);
warnings.push(...stageReports.warnings);
const finalSummary = exists(resultDir) ? inspectFinalSummary(resultDir, args.requireFinalSummary) : {
result: { required: !!args.requireFinalSummary, file: '', present: false, utf8Readable: false, mojibakeSuspected: false, missingSections: [] },
problems: [],
warnings: [],
};
problems.push(...finalSummary.problems);
warnings.push(...finalSummary.warnings);
return {
caseDir,
resultDir,
entryFile: primary || null,
clean: problems.length === 0,
problems,
warnings,
reusedCryptoCheck: reuse,
finalSummary: finalSummary.result,
stageReports: stageReports.result,
finalSummaryOptOut: args.finalSummaryOptOut,
resultFiles: resultFiles.map(p => rel(caseDir, p)),
};
}
function renderMarkdown(result) {
const lines = [
'# 最终项目检查结果',
'',
`case 目录:${result.caseDir}`,
`结果目录:${result.resultDir}`,
`唯一执行入口:${result.entryFile || '未找到'}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 检查项',
`- 是否只有一个执行入口:${result.problems.some(p => p.includes('执行入口')) ? '否' : '是'}`,
`- 是否不含浏览器自动化代码:${result.problems.some(p => p.includes('自动化')) ? '否' : '是'}`,
`- 是否检测到 TLS 指纹兼容请求客户端或明确不发真实请求:${result.problems.some(p => p.includes('TLS 指纹兼容请求客户端')) ? '否' : '是'}`,
`- 是否使用 Session 模式并具备销毁逻辑:${result.problems.some(p => p.includes('Session 模式')) ? '否' : '是'}`,
`- 是否不含指纹采样 Hook / Node.js 渲染库:${result.problems.some(p => p.includes('指纹采样 Hook') || p.includes('渲染库')) ? '否' : '是'}`,
`- 是否未复用 cURL / fixture 中的加密参数样本值:${result.reusedCryptoCheck.reused.length || result.reusedCryptoCheck.hardcoded.length ? '否' : '是'}`,
`- 是否已生成中文命名最终总结且包含 API 调用回放明细、Trace-runtime 闭环、XHR/fetch Session Bridge、请求语义审计、对象形状审计矩阵、WebAPI 环境检测矩阵和高强度检测矩阵:${result.finalSummary.required ? (result.finalSummary.present && !result.finalSummary.mojibakeSuspected && !result.finalSummary.missingSections.length ? '是' : '否') : (result.finalSummaryOptOut ? '用户明确豁免' : '未强制检查')}`,
`- result 目录是否无临时 / 测试产物:${result.problems.some(p => p.includes('临时') || p.includes('测试')) ? '否' : '是'}`,
'',
'## 样本加密参数复用检查',
`- 样本中发现的可疑加密参数值数量:${result.reusedCryptoCheck.sampleValues.length}`,
`- 直接复用样本值:${result.reusedCryptoCheck.reused.length ? '是' : '否'}`,
`- 硬编码加密参数字面量:${result.reusedCryptoCheck.hardcoded.length ? '是' : '否'}`,
`- 补环境 / signer 生成痕迹:${result.reusedCryptoCheck.generationEvidence.length ? result.reusedCryptoCheck.generationEvidence.join('、') : '未明显发现'}`,
'',
'## 阶段报告检查',
`- 目录:${result.stageReports.dir}`,
`- 是否存在 01-需求信息确认.md:${result.stageReports.initialPresent ? '是' : '否'}`,
`- 文件名是否均含中文:${result.stageReports.chineseFileNames ? '是' : '否'}`,
`- 疑似乱码:${result.stageReports.mojibakeSuspected ? '是' : '否'}`,
`- 报告数量:${result.stageReports.files.length}`,
'',
'## 最终总结检查',
`- 默认要求:${result.finalSummary.required ? '是' : '否'}`,
`- 文件:${result.finalSummary.file ? rel(result.resultDir, result.finalSummary.file) : '未发现,应为 最终项目总结.md'}`,
`- UTF-8 可读:${result.finalSummary.utf8Readable ? '是' : '否'}`,
`- 疑似乱码:${result.finalSummary.mojibakeSuspected ? '是' : '否'}`,
`- 缺少章节:${result.finalSummary.missingSections.length ? result.finalSummary.missingSections.join('、') : '无'}`,
'',
];
if (result.problems.length) {
lines.push('## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
lines.push('');
}
if (result.warnings.length) {
lines.push('## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
lines.push('');
}
lines.push('## result 文件列表');
if (result.resultFiles.length) for (const f of result.resultFiles) lines.push(`- ${f}`);
else lines.push('- 无');
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, extractSampleCryptoValuesFromText };

View File

@@ -0,0 +1,518 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { caseDir: '', fixture: '', envFile: '', require: '', json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--fixture') args.fixture = argv[++i] || '';
else if (a === '--env-file') args.envFile = argv[++i] || '';
else if (a === '--require') args.require = argv[++i] || '';
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_fingerprint_fixture.js --case-dir case --require canvas,webgl --markdown
node scripts/check_fingerprint_fixture.js --fixture case/fixtures/fingerprint.fixture.json --env-file case/result/src/env/fingerprint-env.js --json
说明:检查浏览器指纹 fixture 是否覆盖 Canvas / WebGL / WebGPU / Audio / DOM 几何等终端 API,是否绑定同一 fingerprint baseline,并检查最终 env 是否避免 node-canvas / headless-gl / 自动化浏览器等错误方向。`;
}
function exists(p) { try { fs.accessSync(p); return true; } 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(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) out.push(p);
return out;
}
function countArray(v) { return Array.isArray(v) ? v.length : 0; }
function hasResultObject(v) { return !!v && typeof v === 'object' && Object.prototype.hasOwnProperty.call(v, 'result'); }
function pickBaselineId(obj) {
if (!obj || typeof obj !== 'object') return '';
return String(obj.baselineId || (obj.source && obj.source.baselineId) || '');
}
const BAD_VALUE_SOURCE_RE = /(AI\s*(?:推断|猜|生成|guess)|猜测|编造|伪造|默认值|随机值|mock|fake|placeholder|static\s*analysis|静态分析|inferred|guessed|random\s*value|jsdom|node-canvas|headless-gl)/i;
const REAL_VALUE_SOURCE_RE = /(RuyiTrace|NDJSON|ruyiPage|Camoufox|CloakBrowser|手动|真实浏览器|browser|Hook|HAR|MCP|用户提供|console|network)/i;
const TRACE_TOOL_RE = /(RuyiTrace|NDJSON)/i;
const AUTOMATION_OR_USER_TOOL_RE = /(ruyiPage|Camoufox|CloakBrowser|手动|真实浏览器|browser|Hook|HAR|MCP|用户提供|console|network)/i;
const LONG_VALUE_THRESHOLD = 3900;
const BAD_TRUNCATED_KEY_RE = /^(visiblePreview|visibleSha256|sha256OfVisible|visibleValue|visibleSnippet|preview|clippedPreview)$/i;
const BAD_TRUNCATED_TEXT_RE = /(\.\.\.<(?:clipped|truncated)>|<clipped>|<truncated>|__ruyiTraceLongString__|sha256OfVisible|visibleSha256|visiblePreview|realLength["']?\s*[:=]\s*["']?unknown)/i;
const CHUNK_ENCODING_RE = /^(string-chunks|base64-chunks)$/;
function jsonText(v) {
try { return JSON.stringify(v || {}); } catch { return String(v || ''); }
}
function sourceLabel(source) {
if (!source || typeof source !== 'object') return '';
return [source.capturedBy, source.tool, source.mode, source.sourceType, source.type, source.origin, source.evidence]
.filter(Boolean).map(String).join(' ');
}
function traceStatusOf(source, record) {
return String((record && record.traceStatus) || (source && source.traceStatus) || '');
}
function hasSourceMeta(source) {
if (!source || typeof source !== 'object') return false;
return !!(source.capturedBy || source.tool || source.mode || source.sourceType || source.type || source.origin || source.evidence);
}
function isTruncatedTraceStatus(status) {
const s = String(status || '');
if (/used-untruncated|untruncated|not-truncated/i.test(s)) return false;
return /(^|[-_\s])truncated($|[-_\s])|疑似截断|截断/i.test(s);
}
function isTraceValueSource(source, record) {
const status = traceStatusOf(source, record);
const label = sourceLabel(source);
if (/used-untruncated/i.test(status)) return true;
if (/truncated|missing|not-covered|baseline-conflict|unused/i.test(status) && AUTOMATION_OR_USER_TOOL_RE.test(label) && !TRACE_TOOL_RE.test(String(source && source.capturedBy || ''))) return false;
return TRACE_TOOL_RE.test(label);
}
function collectStringLengths(value, out = []) {
if (typeof value === 'string') out.push(value.length);
else if (Array.isArray(value)) for (const item of value) collectStringLengths(item, out);
else if (value && typeof value === 'object') for (const item of Object.values(value)) collectStringLengths(item, out);
return out;
}
function isChunkedValue(value) {
return !!(
value &&
typeof value === 'object' &&
CHUNK_ENCODING_RE.test(String(value.encoding || '')) &&
Array.isArray(value.chunks)
);
}
function hasOwn(obj, key) {
return !!(obj && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key));
}
function hasLengthAndHashOn(value, record, source) {
if (isChunkedValue(value)) {
return Number.isFinite(value.valueLength) && typeof value.sha256 === 'string' && /^[a-f0-9]{64}$/i.test(value.sha256);
}
if (value && typeof value === 'object') {
const metrics = collectValueMetrics(value);
if (metrics.chunked.length) {
return metrics.chunked.every(info =>
Number.isFinite(info.value.valueLength) &&
typeof info.value.sha256 === 'string' &&
/^[a-f0-9]{64}$/i.test(info.value.sha256)
);
}
}
return (
(hasOwn(record, 'valueLength') || hasOwn(source, 'valueLength')) &&
(hasOwn(record, 'sha256') || hasOwn(source, 'sha256'))
);
}
function collectValueMetrics(value, out = { maxLen: 0, chunked: [], badMarkers: [] }, pathParts = []) {
const label = pathParts.join('.') || 'result';
if (typeof value === 'string') {
out.maxLen = Math.max(out.maxLen, value.length);
if (BAD_TRUNCATED_TEXT_RE.test(value)) out.badMarkers.push(`${label} 包含截断标记或可见片段标记`);
} else if (Array.isArray(value)) {
value.forEach((item, idx) => collectValueMetrics(item, out, pathParts.concat(String(idx))));
} else if (value && typeof value === 'object') {
if (isChunkedValue(value)) {
const joined = value.chunks.join('');
out.maxLen = Math.max(out.maxLen, Number(value.valueLength) || joined.length);
out.chunked.push({ path: label, value, joinedLength: joined.length });
}
for (const [key, item] of Object.entries(value)) {
if (BAD_TRUNCATED_KEY_RE.test(key)) out.badMarkers.push(`${label}.${key} 使用了 Trace 可见片段字段,不能作为最终值`);
collectValueMetrics(item, out, pathParts.concat(key));
}
}
return out;
}
function validateChunkedValue(itemPath, chunk) {
const problems = [];
const warnings = [];
const encoding = String(chunk.encoding || '');
const joined = Array.isArray(chunk.chunks) ? chunk.chunks.join('') : '';
if (!CHUNK_ENCODING_RE.test(encoding)) problems.push(`样本 ${itemPath} 的分片 encoding 不合法:${encoding || '未指定'}。`);
if (!Array.isArray(chunk.chunks) || chunk.chunks.some(v => typeof v !== 'string')) problems.push(`样本 ${itemPath} 的 chunks 必须是字符串数组。`);
if (!Number.isFinite(chunk.valueLength)) problems.push(`样本 ${itemPath} 的分片结果缺少数值型 valueLength。`);
if (!Number.isFinite(chunk.chunkSize) || chunk.chunkSize <= 0) problems.push(`样本 ${itemPath} 的分片结果缺少有效 chunkSize。`);
if (chunk.truncated !== false) problems.push(`样本 ${itemPath} 的分片结果必须显式 truncated:false。`);
if (Number.isFinite(chunk.valueLength) && joined.length !== chunk.valueLength) {
problems.push(`样本 ${itemPath} 的 chunks 拼接长度 ${joined.length} 与 valueLength ${chunk.valueLength} 不一致。`);
}
if (Number.isFinite(chunk.chunkSize)) {
const tooLarge = (chunk.chunks || []).find(v => v.length > chunk.chunkSize);
if (tooLarge) warnings.push(`样本 ${itemPath} 存在超过 chunkSize 的分片;请确认分片写入逻辑没有异常。`);
}
if (typeof chunk.sha256 !== 'string' || !/^[a-f0-9]{64}$/i.test(chunk.sha256)) {
problems.push(`样本 ${itemPath} 的长指纹分片缺少完整 sha256;必须先在真实浏览器采样 Hook 中 finalize,再写入 fixture。`);
}
if (BAD_TRUNCATED_TEXT_RE.test(joined)) problems.push(`样本 ${itemPath} 的分片拼接结果包含截断标记,不能作为最终值。`);
return { problems, warnings };
}
function collectResultRecords(node, out = [], pathParts = []) {
if (!node || typeof node !== 'object') return out;
if (Object.prototype.hasOwnProperty.call(node, 'result')) {
out.push({ path: pathParts.join('.') || '$', record: node, value: node.result });
}
for (const [key, value] of Object.entries(node)) {
if (key === 'source' || key === 'valueSource' || key === 'match') continue;
if (value && typeof value === 'object') collectResultRecords(value, out, pathParts.concat(key));
}
return out;
}
function validateValueSources(fp) {
const problems = [];
const warnings = [];
const sourceSummary = {
totalRecords: 0,
inheritedSource: 0,
explicitSource: 0,
traceSource: 0,
sampledSource: 0,
longValueRecords: 0,
chunkedLongValueRecords: 0,
blockedTruncatedResultRecords: 0,
badSourceRecords: 0
};
const rootSource = fp && fp.source;
function validateSource(source, label, record, inherited) {
if (!source || typeof source !== 'object') {
problems.push(`${label} 缺少真实值来源 source / capturedBy;必须优先使用未截断 Trace,Trace 不可用时用已确认取证工具采样,不能由 AI 猜值。`);
return;
}
const sourceText = jsonText(source);
const labelText = sourceLabel(source);
const isFixtureRoot = label === 'fixture.source';
if (!hasSourceMeta(source)) problems.push(`${label} 的 source 缺少 mode / capturedBy / tool / sourceType 等来源字段。`);
if (!REAL_VALUE_SOURCE_RE.test(sourceText)) problems.push(`${label} 的 source 未体现真实浏览器证据来源;请记录 RuyiTrace 未截断值,或 ruyiPage / Camoufox / CloakBrowser / 手动浏览器采样来源。`);
if (BAD_VALUE_SOURCE_RE.test(sourceText)) {
if (!isFixtureRoot) sourceSummary.badSourceRecords += 1;
problems.push(`${label} 的 source 疑似为 AI 猜值、静态分析、默认值、随机值、mock 值或 Node.js 模拟库结果,不能作为最终指纹回放值。`);
}
const status = traceStatusOf(source, record);
if (!status) warnings.push(`${label} 缺少 traceStatus;建议记录 used-untruncated / unused / missing / not-covered / truncated / baseline-conflict,明确是否优先检查 Trace。`);
if (!isFixtureRoot && isTraceValueSource(source, record)) sourceSummary.traceSource += 1;
if (!isFixtureRoot && AUTOMATION_OR_USER_TOOL_RE.test(labelText)) sourceSummary.sampledSource += 1;
}
validateSource(rootSource, 'fixture.source', null, false);
const records = collectResultRecords(fp);
sourceSummary.totalRecords = records.length;
for (const item of records) {
const source = item.record.source || item.record.valueSource || rootSource;
const inherited = !(item.record.source || item.record.valueSource);
if (inherited) sourceSummary.inheritedSource += 1;
else sourceSummary.explicitSource += 1;
validateSource(source, `样本 ${item.path}`, item.record, inherited);
const metrics = collectValueMetrics(item.value);
const maxLen = metrics.maxLen;
if (maxLen >= LONG_VALUE_THRESHOLD) sourceSummary.longValueRecords += 1;
if (metrics.chunked.length) sourceSummary.chunkedLongValueRecords += metrics.chunked.length;
for (const marker of metrics.badMarkers) {
sourceSummary.blockedTruncatedResultRecords += 1;
problems.push(`样本 ${item.path} ${marker};不能把 RuyiTrace 可见片段、clip 片段或未知真实长度写入最终 fixture。`);
}
for (const chunkInfo of metrics.chunked) {
const chunkResult = validateChunkedValue(`${item.path}.${chunkInfo.path}`, chunkInfo.value);
problems.push(...chunkResult.problems);
warnings.push(...chunkResult.warnings);
}
const valueIsMarkedTruncated = item.record.truncated === true || (source && source.truncated === true);
const status = traceStatusOf(source, item.record);
if (valueIsMarkedTruncated) problems.push(`样本 ${item.path} 标记为 truncated=true,不能作为最终回放值;请用同一 baseline 下的取证工具补采完整值。`);
if (isTraceValueSource(source, item.record) && (maxLen >= LONG_VALUE_THRESHOLD || isTruncatedTraceStatus(status) || valueIsMarkedTruncated)) {
sourceSummary.blockedTruncatedResultRecords += 1;
problems.push(`样本 ${item.path} 来自 Trace 且疑似长字段截断;不能把 RuyiTrace 4000 / 4096 可见片段作为完整指纹值,请用同一 baseline 下的 ruyiPage / Camoufox / CloakBrowser / 手动浏览器补采完整值。`);
}
if (maxLen >= LONG_VALUE_THRESHOLD && inherited) {
problems.push(`样本 ${item.path} 是长字符串 / 大结果但未记录每条样本自己的 source、valueLength 和 sha256;请补充完整浏览器采样来源,避免误把 Trace 4000 / 4096 字符可见片段当成真实值。`);
}
if (maxLen >= LONG_VALUE_THRESHOLD && !hasLengthAndHashOn(item.value, item.record, source)) {
problems.push(`样本 ${item.path} 长度达到 ${maxLen},但缺少完整 valueLength 与 sha256;长指纹必须通过真实浏览器完整采样或分片保存并记录完整 hash。`);
}
}
return { problems, warnings, sourceSummary };
}
function inspectFixture(file) {
const problems = [];
const warnings = [];
const counts = {
canvasToDataURL: 0,
canvasToBlob: 0,
canvasMeasureText: 0,
canvasGetImageData: 0,
webglGetParameter: 0,
webglGetSupportedExtensions: 0,
webglGetExtension: 0,
webglGetShaderPrecisionFormat: 0,
webglReadPixels: 0,
webgpuRequestAdapter: 0,
audioStartRendering: 0,
audioGetChannelData: 0,
domGetBoundingClientRect: 0,
domOffset: 0,
};
let fixture = null;
if (!file || !exists(file)) {
problems.push(`未找到指纹 fixture:${file || '未指定'}`);
return { fixture, counts, problems, warnings };
}
try { fixture = readJson(file); } catch (err) {
problems.push(`指纹 fixture JSON 解析失败:${err.message}`);
return { fixture, counts, problems, warnings };
}
const fp = fixture.fingerprint && typeof fixture.fingerprint === 'object' ? fixture.fingerprint : fixture;
counts.canvasToDataURL = countArray(fp.canvas && fp.canvas.toDataURL);
counts.canvasToBlob = countArray(fp.canvas && fp.canvas.toBlob);
counts.canvasMeasureText = countArray(fp.canvas && fp.canvas.measureText);
counts.canvasGetImageData = countArray(fp.canvas && fp.canvas.getImageData);
counts.webglGetParameter = countArray(fp.webgl && fp.webgl.getParameter);
counts.webglGetSupportedExtensions = hasResultObject(fp.webgl && fp.webgl.getSupportedExtensions) ? 1 : countArray(fp.webgl && fp.webgl.getSupportedExtensions);
counts.webglGetExtension = countArray(fp.webgl && fp.webgl.getExtension);
counts.webglGetShaderPrecisionFormat = countArray(fp.webgl && fp.webgl.getShaderPrecisionFormat);
counts.webglReadPixels = countArray(fp.webgl && fp.webgl.readPixels);
counts.webgpuRequestAdapter = countArray(fp.webgpu && fp.webgpu.requestAdapter);
counts.audioStartRendering = countArray(fp.audio && fp.audio.startRendering);
counts.audioGetChannelData = countArray(fp.audio && fp.audio.getChannelData);
counts.domGetBoundingClientRect = countArray(fp.domGeometry && fp.domGeometry.getBoundingClientRect);
counts.domOffset = countArray(fp.domGeometry && fp.domGeometry.offset);
const baselineId = pickBaselineId(fp);
if (!baselineId) problems.push('指纹 fixture 缺少 baselineId;必须先创建 case/notes/fingerprint-baseline.json,并让 fixture 绑定同一 baselineId。');
if (!fp.source) problems.push('指纹 fixture 缺少 source 字段;必须记录真实值来源,优先使用未截断 Trace,Trace 不可用时使用已确认取证工具采样。');
const sourceResult = validateValueSources(fp);
problems.push(...sourceResult.problems);
warnings.push(...sourceResult.warnings);
return { fixture: fp, baselineId, counts, problems, warnings, sourceSummary: sourceResult.sourceSummary };
}
function inspectBaseline(caseDir, fixtureResult) {
const problems = [];
const warnings = [];
const baselineFile = path.join(caseDir, 'notes', 'fingerprint-baseline.json');
const result = { file: baselineFile, present: exists(baselineFile), baselineId: '', conflicts: [] };
if (!result.present) {
problems.push('缺少指纹基线文件 case/notes/fingerprint-baseline.json;涉及指纹采样时必须先固定同一 case 的 fingerprint baseline。');
return { result, problems, warnings };
}
let baseline;
try { baseline = readJson(baselineFile); } catch (err) {
problems.push(`指纹基线 JSON 解析失败:${err.message}`);
return { result, problems, warnings };
}
result.baselineId = String(baseline.baselineId || '');
if (!result.baselineId) problems.push('fingerprint-baseline.json 缺少 baselineId。');
if (fixtureResult.baselineId && result.baselineId && fixtureResult.baselineId !== result.baselineId) {
problems.push(`指纹 fixture baselineId 与基线不一致:fixture=${fixtureResult.baselineId},baseline=${result.baselineId}。不得混用不同随机指纹样本。`);
}
const fp = fixtureResult.fixture || {};
const source = fp.source || {};
const checks = [
['userAgent', source.userAgent, baseline.navigator && baseline.navigator.userAgent],
['timezone', source.timezone, baseline.network && baseline.network.timezone],
['locale', source.locale, baseline.network && baseline.network.locale],
];
for (const [name, a, b] of checks) {
if (a && b && String(a) !== String(b)) result.conflicts.push({ field: name, fixture: String(a), baseline: String(b) });
}
if (result.conflicts.length) {
problems.push(`指纹 fixture 与 baseline 核心字段冲突:${result.conflicts.map(x => `${x.field}: fixture=${x.fixture}, baseline=${x.baseline}`).join(';')}。请重新采样或生成新 baseline。`);
}
return { result, problems, warnings };
}
const REQUIRE_RULES = {
canvas: ['canvasToDataURL', 'canvasMeasureText', 'canvasGetImageData'],
webgl: ['webglGetParameter', 'webglGetSupportedExtensions', 'webglGetShaderPrecisionFormat', 'webglReadPixels'],
webgpu: ['webgpuRequestAdapter'],
audio: ['audioStartRendering', 'audioGetChannelData'],
'dom-geometry': ['domGetBoundingClientRect', 'domOffset'],
dom: ['domGetBoundingClientRect', 'domOffset'],
font: ['canvasMeasureText', 'domOffset'],
fonts: ['canvasMeasureText', 'domOffset'],
};
const BAD_RENDER_PATTERNS = [
{ name: 'node-canvas/canvas', pattern: /\b(require|import)\s*\(?\s*['"](?:canvas|node-canvas)['"]|from\s+['"](?:canvas|node-canvas)['"]/i },
{ name: 'headless-gl/gl', pattern: /\b(require|import)\s*\(?\s*['"](?:gl|headless-gl)['"]|from\s+['"](?:gl|headless-gl)['"]/i },
{ name: '浏览器自动化', pattern: /\b(playwright|puppeteer|selenium|cloakbrowser|ruyipage|page\.goto|browser\.launch|chromium\.launch)\b/i },
];
function inspectEnvCode(files, root) {
const problems = [];
const warnings = [];
const hits = [];
for (const file of files) {
if (!exists(file)) continue;
const text = readText(file);
for (const item of BAD_RENDER_PATTERNS) {
if (item.pattern.test(text)) hits.push({ file: rel(root, file), type: item.name });
}
if (/toDataURL|getImageData|measureText|getParameter|getBoundingClientRect/.test(text) && !/fingerprint|回放|replay|fixture/i.test(text)) {
warnings.push(`${rel(root, file)} 涉及指纹终端 API,但未明显体现 fixture / replay;请确认不是在 Node.js 中伪造渲染过程。`);
}
}
if (hits.length) {
problems.push(`发现不推荐的指纹实现方向:${hits.map(h => `${h.file}(${h.type})`).join('、')}。应使用真实浏览器采样值回放,不要在最终项目中依赖渲染库或自动化浏览器计算指纹。`);
}
return { problems, warnings, hits };
}
function defaultFixture(caseDir) {
const candidates = [
path.join(caseDir, 'fixtures', 'fingerprint.fixture.json'),
path.join(caseDir, 'fixtures', 'sample.fixture.json'),
];
return candidates.find(exists) || candidates[0];
}
function defaultEnvFiles(caseDir) {
const result = path.join(caseDir, 'result');
return walk(result).filter(p => ['.js', '.mjs', '.cjs'].includes(ext(p)));
}
function check(args) {
const caseDir = args.caseDir ? path.resolve(args.caseDir) : (args.fixture ? path.resolve(path.dirname(args.fixture), '..') : process.cwd());
const fixtureFile = args.fixture ? path.resolve(args.fixture) : defaultFixture(caseDir);
const envFiles = args.envFile ? [path.resolve(args.envFile)] : defaultEnvFiles(caseDir);
const required = String(args.require || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const problems = [];
const warnings = [];
const fixtureResult = inspectFixture(fixtureFile);
problems.push(...fixtureResult.problems);
warnings.push(...fixtureResult.warnings);
for (const item of required) {
const rules = REQUIRE_RULES[item] || [];
if (!rules.length) warnings.push(`未知 require 类型:${item}`);
const ok = rules.some(k => fixtureResult.counts[k] > 0);
if (!ok) problems.push(`要求 ${item} 指纹样本,但 fixture 中未发现对应终端 API 返回值。`);
}
const baselineResult = inspectBaseline(caseDir, fixtureResult);
problems.push(...baselineResult.problems);
warnings.push(...baselineResult.warnings);
const envResult = inspectEnvCode(envFiles, caseDir);
problems.push(...envResult.problems);
warnings.push(...envResult.warnings);
return {
caseDir,
fixtureFile,
envFiles: envFiles.map(p => rel(caseDir, p)),
required,
clean: problems.length === 0,
baseline: baselineResult.result,
fixtureBaselineId: fixtureResult.baselineId || '',
counts: fixtureResult.counts,
sourceSummary: fixtureResult.sourceSummary || {
totalRecords: 0,
inheritedSource: 0,
explicitSource: 0,
traceSource: 0,
sampledSource: 0,
longValueRecords: 0,
chunkedLongValueRecords: 0,
blockedTruncatedResultRecords: 0,
badSourceRecords: 0
},
badImplementationHits: envResult.hits,
problems,
warnings,
};
}
function renderMarkdown(result) {
const lines = [
'# 指纹 fixture 与回放实现检查',
'',
`case 目录:${result.caseDir}`,
`fixture:${result.fixtureFile}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 指纹基线',
`- baseline 文件:${result.baseline.file}`,
`- baseline 是否存在:${result.baseline.present ? '是' : '否'}`,
`- baselineId:${result.baseline.baselineId || '未发现'}`,
`- fixture baselineId:${result.fixtureBaselineId || '未发现'}`,
`- 核心字段冲突:${result.baseline.conflicts.length ? '是' : '否'}`,
'',
'## 样本覆盖统计',
];
for (const [k, v] of Object.entries(result.counts)) lines.push(`- ${k}:${v}`);
lines.push('', '## 指纹值来源检查');
lines.push(`- result 样本数量:${result.sourceSummary.totalRecords}`);
lines.push(`- 显式 source 样本:${result.sourceSummary.explicitSource}`);
lines.push(`- 继承全局 source 样本:${result.sourceSummary.inheritedSource}`);
lines.push(`- Trace 来源样本:${result.sourceSummary.traceSource}`);
lines.push(`- 自动化 / 手动浏览器采样来源样本:${result.sourceSummary.sampledSource}`);
lines.push(`- 长字段样本:${result.sourceSummary.longValueRecords}`);
lines.push(`- 分片长字段样本:${result.sourceSummary.chunkedLongValueRecords}`);
lines.push(`- 被阻断的截断 / 可见片段样本:${result.sourceSummary.blockedTruncatedResultRecords}`);
lines.push(`- 疑似猜值 / 默认值 / 模拟库来源样本:${result.sourceSummary.badSourceRecords}`);
lines.push('', '## 检查的 env 文件');
if (result.envFiles.length) for (const f of result.envFiles) lines.push(`- ${f}`);
else lines.push('- 未发现 result 下的 JS env 文件');
if (result.problems.length) {
lines.push('', '## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
}
if (result.warnings.length) {
lines.push('', '## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
}
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);
}

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { input: null, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--input' || a === '-i') args.input = argv[++i];
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 `用法:\n node scripts/check_intake.js --input task.md --markdown\n node scripts/check_intake.js --input task.md --json\n\n不提供 --input 时会从标准输入读取内容。`;
}
function readInput(input) {
if (input) return fs.readFileSync(input, 'utf8');
return fs.readFileSync(0, 'utf8');
}
function cleanValue(v) {
if (!v) return '';
return v.trim().replace(/^`+|`+$/g, '').trim();
}
function isPlaceholder(v) {
const s = cleanValue(v).toLowerCase();
return !s || ['无', '暂无', '未知', '不确定', 'todo', 'tbd', 'n/a', 'na', 'none', 'null', '-', ':', ':'].includes(s) || /^[.。…]+$/.test(s);
}
function lineValue(text, labels) {
const lines = text.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
for (const label of labels) {
const pattern = new RegExp(`^(?:[-*+]\\s*)?(?:\\[[ xX]\\]\\s*)?${label}\\s*[::]\\s*(.+?)\\s*$`, 'i');
const m = trimmed.match(pattern);
if (m && !isPlaceholder(m[1])) return cleanValue(m[1]);
}
}
return '';
}
function firstUrlNear(text, labels) {
const direct = lineValue(text, labels);
if (direct) {
const m = direct.match(/https?:\/\/[^\s`'"<>]+/i);
if (m) return m[0];
if (/^[\w.-]+\.[a-z]{2,}/i.test(direct)) return direct;
}
for (const label of labels) {
const re = new RegExp(`${label}[^\n\r]*(https?:\\/\\/[^\\s\`'"<>]+)`, 'i');
const m = text.match(re);
if (m) return m[1];
}
const domain = text.match(/(?:目标|网站|站点|页面|target)[^\n\r]{0,40}?([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?:\/[^\s`'"<>]*)?)/i);
if (domain) return domain[1];
return '';
}
function detectMethod(text) {
const direct = lineValue(text, ['请求方法', 'Method', 'HTTP Method', 'Request Method']);
const src = direct || text;
const m = src.match(/\b(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\b/i);
return m ? m[1].toUpperCase() : '';
}
function detectLocation(text) {
const direct = lineValue(text, ['参数出现位置', '参数位置', '加密参数位置', 'Parameter Location', 'Location']);
const src = direct || text;
const locations = ['Query', 'Header', 'Body', 'Cookie'];
for (const loc of locations) {
if (new RegExp(`\\b${loc}\\b`, 'i').test(src)) return loc;
}
const zh = [ ['query', /查询|URL参数|URL 参数/i], ['header', /请求头|Header/i], ['body', /请求体|Body|表单|JSON/i], ['cookie', /Cookie/i] ];
for (const [name, re] of zh) if (re.test(src)) return name[0].toUpperCase() + name.slice(1);
return '';
}
function detectParam(text) {
const direct = lineValue(text, ['目标加密参数名', '目标加密参数', '加密参数名', '加密参数', '参数名', 'Encrypted Parameter', 'Parameter']);
if (direct) {
const m = direct.match(/[A-Za-z_$][\w$-]{0,80}/);
if (m) return m[0];
return direct;
}
const common = ['__zse_ck', 'zse_ck', 'a_bogus', 'h5st', 'x-s', 'x-t', 'x-sign', 'sign', 'signature', 'token'];
for (const p of common) {
if (new RegExp(`(^|[^\\w-])${p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\w-]|$)`, 'i').test(text)) return p;
}
return '';
}
function hasRequestSample(text) {
if (/copy\s+as\s+curl/i.test(text) && /(已提供|提供|粘贴|curl)/i.test(text)) return true;
if (/\bcurl\b\s+['"]?https?:\/\//i.test(text)) return true;
if (/\bcurl\b[\s\S]{0,300}https?:\/\//i.test(text)) return true;
if (/^(GET|POST|PUT|DELETE|PATCH)\s+https?:\/\//im.test(text)) return true;
const v = lineValue(text, ['成功请求样本', '请求样本', 'Request Sample', 'cURL', 'curl']);
return !!v && !/未提供|没有|无/.test(v);
}
function hasResponseSample(text) {
if (/响应样本[::]\s*(已提供|有|是)/i.test(text)) return true;
if (/```json[\s\S]*?```/i.test(text) && /响应|response/i.test(text)) return true;
return false;
}
function hasKnownJs(text) {
if (/https?:\/\/[^\s`'"<>]+\.js(?:\?[^\s`'"<>]*)?/i.test(text)) return true;
if (/JS\s*文件[::]\s*(已提供|有|是)/i.test(text)) return true;
return false;
}
function detectAcquisitionMode(text) {
const direct = lineValue(text, ['取证模式', '浏览器取证模式', '浏览器模式', '取证工具', '浏览器工具', 'Ruyi 工具选择', 'Ruyi工具选择', 'Acquisition Mode']);
const checked = text.match(/^\s*[-*+]\s*\[[xX]\]\s*(ruyiPage\s*\+\s*RuyiTrace|仅\s*ruyiPage|只用\s*ruyiPage|Camoufox\s*\+\s*camoufox-reverse-mcp|仅\s*Camoufox|只用\s*Camoufox|CloakBrowser|用户手动取证|手动取证|AI\s*自行决定)[^\n\r]*/im);
const src = direct || (checked ? checked[1] : '');
if (/ruyiPage\s*\+\s*RuyiTrace|ruyiPage.*RuyiTrace|如意\s*Trace/i.test(src)) return 'ruyiPage + RuyiTrace';
if (/仅\s*ruyiPage|只用\s*ruyiPage|ruyiPage/i.test(src)) return '仅 ruyiPage';
if (/Camoufox\s*\+\s*camoufox-reverse-mcp|Camoufox.*MCP|camoufox-reverse-mcp/i.test(src)) return 'Camoufox + camoufox-reverse-mcp';
if (/仅\s*Camoufox|只用\s*Camoufox|Camoufox/i.test(src)) return '仅 Camoufox';
if (/CloakBrowser/i.test(src)) return 'CloakBrowser';
if (/用户手动取证|手动取证|离线材料|手动提供|手动浏览器/i.test(src)) return '用户手动取证';
if (/AI\s*自行决定|自行决定|你来决定|自动选择/i.test(src)) return 'AI 自行决定';
if (/(选择|使用|采用|取证模式是|用)\s*(ruyiPage\s*\+\s*RuyiTrace|ruyiPage.*RuyiTrace)/i.test(text)) return 'ruyiPage + RuyiTrace';
if (/(选择|使用|采用|取证模式是|用)\s*(仅\s*)?ruyiPage/i.test(text)) return '仅 ruyiPage';
if (/(选择|使用|采用|取证模式是|用).*(Camoufox\s*\+\s*camoufox-reverse-mcp|Camoufox.*MCP|camoufox-reverse-mcp)/i.test(text)) return 'Camoufox + camoufox-reverse-mcp';
if (/(选择|使用|采用|取证模式是|用).*(仅\s*)?Camoufox/i.test(text)) return '仅 Camoufox';
if (/(选择|使用|采用|取证模式是|用)\s*CloakBrowser/i.test(text)) return 'CloakBrowser';
if (/(选择|使用|采用|取证模式是|用).*(用户手动取证|手动取证|离线材料|手动提供)/i.test(text)) return '用户手动取证';
if (/(选择|使用|采用|取证模式是|用).*(AI\s*自行决定|自行决定|你来决定|自动选择)/i.test(text)) return 'AI 自行决定';
return '';
}
function detectTlsClientStrategy(text) {
const direct = lineValue(text, ['最终请求 TLS 指纹兼容客户端', 'TLS 指纹兼容客户端', 'TLS 请求客户端', '最终请求客户端', '请求客户端', 'TLS Strategy', 'TLS Client']);
const src = direct || text;
if (/Node\.?js.*CycleTLS|CycleTLS|cycleTls|cycletls/i.test(src)) return 'Node.js CycleTLS';
if (/Node\.?js.*impers|\bimpers\b/i.test(src)) return 'Node.js impers';
if (/Node\.?js.*curl-cffi|curl-cffi-node|curl-cffi/i.test(src)) return 'Node.js curl-cffi';
if (/Python.*curl_cffi|curl_cffi/i.test(src)) return 'Python curl_cffi';
if (/Python.*cffi_curl|cffi_curl/i.test(src)) return 'Python cffi_curl';
if (/Python.*cyCronet|cyCronet|cycronet/i.test(src)) return 'Python cyCronet';
if (/不发真实请求|不发送真实请求|只输出本地\s*(sign|参数)|仅输出本地\s*(sign|参数)|只做本地验证/i.test(src)) return '不发真实请求,只输出本地 sign / 参数';
return '';
}
function analyze(text) {
const fields = {
targetSiteUrl: firstUrlNear(text, ['目标网站 URL', '目标网站', '目标站点', '目标页面', '网站 URL', 'Target Website', 'Target Site', 'Page URL']),
apiUrl: firstUrlNear(text, ['目标 API URL', '目标 API', '目标接口 API', '目标接口', '接口 API', '请求 URL', 'API URL', 'Request URL', 'Target API']),
method: detectMethod(text),
encryptedParam: detectParam(text),
paramLocation: detectLocation(text),
requestSample: hasRequestSample(text),
responseSample: hasResponseSample(text),
knownJsAssets: hasKnownJs(text),
loginRequired: /需要登录[::]?\s*(是|true|yes)|登录后|已登录|login required/i.test(text),
authScopeMentioned: /授权|测试权限|permission|authorized/i.test(text),
acquisitionMode: detectAcquisitionMode(text),
tlsClientStrategy: detectTlsClientStrategy(text),
};
// If API URL duplicated from target site and a more specific URL exists in curl, use the curl URL as apiUrl.
const curlUrl = (text.match(/\bcurl\b[\s\S]{0,500}?(https?:\/\/[^\s`'"<>]+)/i) || [])[1];
if (curlUrl && (!fields.apiUrl || fields.apiUrl === fields.targetSiteUrl)) fields.apiUrl = curlUrl;
if (fields.apiUrl === fields.targetSiteUrl && !/(目标接口|接口\s*(API|URL)|API\s*(URL|地址)?|请求\s*URL|Request URL|Target API)/i.test(text)) fields.apiUrl = '';
const required = [
['targetSiteUrl', '目标网站/页面 URL'],
['apiUrl', '目标接口 API URL'],
['method', '请求方法'],
['encryptedParam', '目标加密参数名'],
['paramLocation', '参数出现位置 Query/Header/Body/Cookie'],
['requestSample', '成功请求样本(优先 Copy as cURL 或 HAR)'],
['acquisitionMode', '取证模式(ruyiPage + RuyiTrace / 仅 ruyiPage / Camoufox + camoufox-reverse-mcp / 仅 Camoufox / CloakBrowser / 用户手动取证 / AI 自行决定)'],
['tlsClientStrategy', '最终请求 TLS 指纹兼容客户端(Node.js CycleTLS / Node.js impers / Node.js curl-cffi / Python curl_cffi / Python cffi_curl / Python cyCronet / 不发真实请求)'],
];
const missing = required.filter(([key]) => !fields[key]).map(([, label]) => label);
const complete = missing.length === 0;
return { complete, missing, fields };
}
function renderMarkdown(result) {
const f = result.fields;
const yesNo = v => v ? '是' : '否';
const lines = [];
lines.push('# Web JS 补环境信息完整性检查结果');
lines.push('');
lines.push(`- 是否满足开始前置分析的最低要求:${result.complete ? '是' : '否'}`);
lines.push(`- 目标网站/页面 URL:${f.targetSiteUrl || '未提供'}`);
lines.push(`- 目标接口 API URL:${f.apiUrl || '未提供'}`);
lines.push(`- 请求方法:${f.method || '未提供'}`);
lines.push(`- 目标加密参数:${f.encryptedParam || '未提供'}`);
lines.push(`- 参数位置:${f.paramLocation || '未提供'}`);
lines.push(`- 已提供成功请求样本:${yesNo(f.requestSample)}`);
lines.push(`- 已提供成功响应样本:${yesNo(f.responseSample)}`);
lines.push(`- 已知 JS 文件:${yesNo(f.knownJsAssets)}`);
lines.push(`- 是否提到登录:${yesNo(f.loginRequired)}`);
lines.push(`- 取证模式:${f.acquisitionMode || '未提供'}`);
lines.push(`- 最终请求 TLS 指纹兼容客户端:${f.tlsClientStrategy || '未提供'}`);
lines.push('');
if (!result.complete) {
lines.push('## 当前缺少的必要信息');
for (const item of result.missing) lines.push(`- [ ] ${item}`);
lines.push('');
lines.push('请先补充以上必要信息;补齐前不要进入逆向分析或补环境代码阶段。');
} else {
lines.push('## 下一步');
lines.push('请先把以上关键信息、取证模式和最终请求 TLS 指纹兼容客户端整理给用户确认;用户确认后再校验请求样本、加密参数和 JS 文件可获取性。后续所有浏览器取证动作必须沿用用户确认的取证模式;最终真实请求只能使用用户确认的 Node.js / Python 请求客户端。');
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const text = readInput(args.input);
const result = analyze(text);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env node
'use strict';
const NODE_CAPABILITY_GLOBALS = [
'process', 'Buffer', 'global', 'setImmediate', 'clearImmediate',
];
const NODE_WEB_API_GLOBALS = [
'navigator', 'localStorage', 'sessionStorage', 'performance', 'crypto', 'Crypto', 'SubtleCrypto',
'fetch', 'Headers', 'Request', 'Response', 'FormData', 'File', 'Blob', 'WebSocket', 'EventSource',
'BroadcastChannel', 'MessageChannel', 'MessagePort', 'CompressionStream', 'DecompressionStream',
'URLPattern', 'CloseEvent', 'ErrorEvent', 'AbortController', 'AbortSignal', 'Event', 'EventTarget',
'CustomEvent', 'MessageEvent', 'DOMException', 'structuredClone', 'atob', 'btoa', 'URL',
'URLSearchParams', 'TextEncoder', 'TextDecoder', 'TextEncoderStream', 'TextDecoderStream',
'ReadableStream', 'WritableStream', 'TransformStream', 'PerformanceEntry', 'PerformanceMark',
'PerformanceMeasure', 'PerformanceObserver', 'PerformanceResourceTiming', 'CryptoKey',
'WebAssembly', 'queueMicrotask',
];
const MODULE_SCOPE_NAMES = ['require', 'module', 'exports', '__dirname', '__filename'];
const DENY_LIST = [
'process', 'Buffer', 'require', 'module', 'exports', 'global', '__dirname', '__filename',
'setImmediate', 'clearImmediate', 'Error.prepareStackTrace', 'Node 专属堆栈路径',
'宿主 navigator', 'navigator.userAgent=Node.js/<major>', 'navigator.locks',
'宿主 localStorage', '宿主 sessionStorage',
'performance.nodeTiming', 'performance.eventLoopUtilization', 'performance.timerify', 'performance.markResourceTiming',
'宿主 fetch / undici', '宿主 WebSocket', '宿主 BroadcastChannel / MessageChannel',
'宿主 URL / TextEncoder / Streams / Events / crypto 等浏览器兼容层直接透传',
];
function parseArgs(argv) {
const args = { json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
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_node_leakage.js --markdown
node scripts/check_node_leakage.js --json
说明:检查当前 Node 宿主中常见泄露变量,并给出目标 JS 运行上下文的阻断清单。该上下文可为 vm、独立 Node 进程或显式隔离的 global。`;
}
function safeGet(fn, fallback = undefined) {
try { return fn(); } catch (err) { return fallback === undefined ? `访问失败:${err.message || String(err)}` : fallback; }
}
function descriptorInfo(name) {
const d = safeGet(() => Object.getOwnPropertyDescriptor(globalThis, name), null);
if (!d) return null;
return {
enumerable: !!d.enumerable,
configurable: !!d.configurable,
writable: Object.prototype.hasOwnProperty.call(d, 'writable') ? !!d.writable : undefined,
hasGetter: typeof d.get === 'function',
hasSetter: typeof d.set === 'function',
valueType: Object.prototype.hasOwnProperty.call(d, 'value') ? typeof d.value : undefined,
};
}
function globalStatus(name) {
const type = safeGet(() => typeof globalThis[name], '访问失败');
const accessFailed = String(type).startsWith('访问失败');
return {
name,
visibleOnGlobalThis: type !== 'undefined' && !accessFailed,
type,
descriptor: descriptorInfo(name),
};
}
function collectNavigatorSnapshot() {
if (typeof globalThis.navigator === 'undefined') return { visible: false };
const nav = globalThis.navigator;
const userAgent = safeGet(() => nav.userAgent, '访问失败');
return {
visible: true,
constructorName: safeGet(() => nav.constructor && nav.constructor.name, ''),
userAgent,
isNodeUserAgent: /^Node\.js\//.test(String(userAgent || '')),
platform: safeGet(() => nav.platform, ''),
language: safeGet(() => nav.language, ''),
languages: safeGet(() => Array.isArray(nav.languages) ? Array.from(nav.languages) : nav.languages, ''),
hardwareConcurrency: safeGet(() => nav.hardwareConcurrency, ''),
locksType: safeGet(() => typeof nav.locks, 'undefined'),
prototypeKeys: safeGet(() => Object.getOwnPropertyNames(Object.getPrototypeOf(nav)).sort(), []),
};
}
function collectPerformanceSnapshot() {
if (typeof globalThis.performance === 'undefined') return { visible: false };
const perf = globalThis.performance;
return {
visible: true,
constructorName: safeGet(() => perf.constructor && perf.constructor.name, ''),
hasNodeTiming: safeGet(() => 'nodeTiming' in perf, false),
hasEventLoopUtilization: safeGet(() => 'eventLoopUtilization' in perf, false),
hasTimerify: safeGet(() => 'timerify' in perf, false),
hasMarkResourceTiming: safeGet(() => 'markResourceTiming' in perf, false),
prototypeKeys: safeGet(() => Object.getOwnPropertyNames(Object.getPrototypeOf(perf)).sort(), []),
};
}
function collectStorageSnapshot(name) {
const type = safeGet(() => typeof globalThis[name], '访问失败');
if (type === 'undefined' || String(type).startsWith('访问失败')) return { visible: false, name, type };
const storage = safeGet(() => globalThis[name], null);
return {
visible: true,
name,
type,
constructorName: safeGet(() => storage && storage.constructor && storage.constructor.name, ''),
descriptor: descriptorInfo(name),
};
}
function collect() {
const moduleScope = Object.fromEntries(MODULE_SCOPE_NAMES.map(name => [name, safeGet(() => eval(`typeof ${name}`) !== 'undefined', false)]));
const capabilityGlobals = NODE_CAPABILITY_GLOBALS.map(globalStatus);
const webApiGlobals = NODE_WEB_API_GLOBALS.map(globalStatus);
const navigatorSnapshot = collectNavigatorSnapshot();
const performanceSnapshot = collectPerformanceSnapshot();
const storageSnapshots = [collectStorageSnapshot('localStorage'), collectStorageSnapshot('sessionStorage')];
const hostSignals = [];
if (navigatorSnapshot.visible && navigatorSnapshot.isNodeUserAgent) hostSignals.push(`navigator.userAgent=${navigatorSnapshot.userAgent}`);
if (navigatorSnapshot.visible && navigatorSnapshot.locksType !== 'undefined') hostSignals.push('navigator.locks 可见');
if (performanceSnapshot.hasNodeTiming) hostSignals.push('performance.nodeTiming 可见');
if (performanceSnapshot.hasEventLoopUtilization) hostSignals.push('performance.eventLoopUtilization 可见');
if (performanceSnapshot.hasTimerify) hostSignals.push('performance.timerify 可见');
if (typeof process !== 'undefined' && process.versions && process.versions.undici) hostSignals.push(`process.versions.undici=${process.versions.undici}`);
return {
note: '当前脚本运行在 Node 宿主中,存在这些变量是正常现象;目标网页 JS 所在运行上下文中不应暴露 Node 能力,也不应直接复用宿主 Node Web API 兼容层。',
host: { node: process.version, platform: process.platform, arch: process.arch, v8: process.versions.v8, undici: process.versions.undici || '' },
capabilityGlobals,
webApiGlobals,
moduleScope,
navigator: navigatorSnapshot,
performance: performanceSnapshot,
storage: storageSnapshots,
hostSignals,
denyList: DENY_LIST,
contextExpectations: {
process: 'undefined',
Buffer: 'undefined',
require: 'undefined',
module: 'undefined',
global: 'undefined',
functionProcess: 'Function("return typeof process")() 应为 "undefined"',
navigatorUserAgent: '不能以 Node.js/ 开头,必须来自浏览器采样或 fixture',
navigatorLocks: '不应暴露 Node 的 navigator.locks,除非已按浏览器样本明确实现',
performanceNodeTiming: 'performance.nodeTiming 应不存在',
performanceEventLoopUtilization: 'performance.eventLoopUtilization 应不存在',
performanceTimerify: 'performance.timerify 应不存在',
storage: 'localStorage/sessionStorage 必须是浏览器页面级 Storage 实现,不得复用 Node 宿主 Storage',
network: 'fetch/WebSocket 等不得透传宿主实现,探测模式用桩函数,最终请求用已确认 TLS 指纹客户端',
webCompatGlobals: 'URL/TextEncoder/Streams/Events/crypto/WebAssembly 等浏览器同名 API 如参与检测,也必须按浏览器样本或补环境实现安装,不得盲目透传宿主构造器',
},
recommendations: [
'不要把宿主函数、宿主数组、宿主 URL/TextEncoder 构造器直接塞进目标 JS 运行上下文。',
'Node 官方文档显示 navigator 是 v21.0.0 新增,不是 Node 20;如存在宿主 navigator,先删除或隔离,再安装浏览器式 Navigator。',
'Node 22.4+ 如存在宿主 localStorage/sessionStorage,先删除或隔离,再安装浏览器式 Storage。',
'不要透传宿主 performance;需移除 nodeTiming、eventLoopUtilization、timerify 等 Node 专属能力。',
'Web Streams、Events、URL、TextEncoder、crypto、WebAssembly、queueMicrotask 等同名 API 也要按目标浏览器样本决定是否覆盖,不能默认复用 Node 宿主对象。',
'在目标 JS 运行上下文内部定义 fetch、XHR、atob、console 等函数,避免 constructor.constructor 泄露。',
'目标 JS 运行前检查 Function("return typeof process")()。',
'最终 runner 中不要为了调试暴露 require、process、Buffer。',
],
};
}
function renderGlobalList(lines, title, items) {
lines.push('', title);
for (const g of items) lines.push(`- ${g.name}:${g.visibleOnGlobalThis ? '宿主可见' : '宿主不可见'}(${g.type})`);
}
function renderMarkdown(result) {
const lines = ['# Node 泄露阻断检查', '', `- Node.js:${result.host.node}`, `- 平台:${result.host.platform}-${result.host.arch}`, `- undici:${result.host.undici || '未暴露'}`, '', `说明:${result.note}`];
renderGlobalList(lines, '## 宿主 Node 能力变量状态', result.capabilityGlobals);
renderGlobalList(lines, '## 宿主 Node Web API 兼容层状态', result.webApiGlobals);
lines.push('', '## 模块作用域变量');
for (const [k, v] of Object.entries(result.moduleScope)) lines.push(`- ${k}:${v ? '存在' : '不存在'}`);
lines.push('', '## navigator 宿主信号');
if (!result.navigator.visible) lines.push('- 宿主 navigator 不可见');
else {
lines.push(`- userAgent:${result.navigator.userAgent}`);
lines.push(`- 是否 Node userAgent:${result.navigator.isNodeUserAgent ? '是' : '否'}`);
lines.push(`- platform:${result.navigator.platform}`);
lines.push(`- language:${result.navigator.language}`);
lines.push(`- hardwareConcurrency:${result.navigator.hardwareConcurrency}`);
lines.push(`- locks:${result.navigator.locksType}`);
}
lines.push('', '## performance 宿主信号');
if (!result.performance.visible) lines.push('- 宿主 performance 不可见');
else {
lines.push(`- nodeTiming:${result.performance.hasNodeTiming ? '可见' : '不可见'}`);
lines.push(`- eventLoopUtilization:${result.performance.hasEventLoopUtilization ? '可见' : '不可见'}`);
lines.push(`- timerify:${result.performance.hasTimerify ? '可见' : '不可见'}`);
lines.push(`- markResourceTiming:${result.performance.hasMarkResourceTiming ? '可见' : '不可见'}`);
}
if (result.hostSignals.length) {
lines.push('', '## 已发现宿主特征信号');
for (const item of result.hostSignals) lines.push(`- ${item}`);
}
lines.push('', '## 目标运行上下文阻断清单');
for (const name of result.denyList) lines.push(`- ${name}`);
lines.push('', '## 建议');
for (const item of result.recommendations) lines.push(`- ${item}`);
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = collect();
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env node
'use strict';
const { execFileSync } = require('child_process');
const os = require('os');
const TARGETS = {
addon: {
label: 'native addon.node',
version: '25.8.2',
nextCheck: 'node scripts/load_native_addon.js --json',
},
'isolated-vm': {
label: 'xbs isolated-vm',
version: '26.3.1',
nextCheck: 'node scripts/check_xbs_isolated_vm.js --strict --json',
},
xbs: {
label: 'xbs isolated-vm',
version: '26.3.1',
nextCheck: 'node scripts/check_xbs_isolated_vm.js --strict --json',
},
};
function parseArgs(argv) {
const args = { target: 'addon', requiredVersion: '', json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--target') args.target = argv[++i] || '';
else if (a === '--required-version') args.requiredVersion = (argv[++i] || '').replace(/^v/i, '');
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_node_runtime_compat.js --target addon --markdown',
' node scripts/check_node_runtime_compat.js --target isolated-vm --json',
' node scripts/check_node_runtime_compat.js --required-version 25.8.2 --markdown',
'',
'说明:只检测当前 Node / ABI / nvm 可用性并输出中文恢复建议,不会安装或切换 Node。',
].join('\n');
}
function tryExec(command, args, options = {}) {
try {
return {
ok: true,
stdout: execFileSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: options.timeout || 5000,
}).trim(),
error: '',
};
} catch (error) {
return { ok: false, stdout: '', error: String(error && error.message ? error.message : error) };
}
}
function detectNvm() {
if (process.platform === 'win32') {
const where = tryExec('where.exe', ['nvm']);
const version = tryExec('nvm', ['version']);
const root = tryExec('nvm', ['root']);
return {
available: version.ok,
kind: 'nvm-windows',
path: where.stdout.split(/\r?\n/).filter(Boolean)[0] || '',
version: version.stdout,
root: root.stdout,
error: version.ok ? '' : (where.error || version.error),
};
}
const shell = tryExec('bash', ['-lc', 'if command -v nvm >/dev/null 2>&1; then nvm --version; elif [ -s "$NVM_DIR/nvm.sh" ]; then . "$NVM_DIR/nvm.sh" && nvm --version; elif [ -s "$HOME/.nvm/nvm.sh" ]; then . "$HOME/.nvm/nvm.sh" && nvm --version; else exit 127; fi']);
return {
available: shell.ok,
kind: 'nvm-sh',
path: shell.ok ? '$NVM_DIR/nvm.sh 或 $HOME/.nvm/nvm.sh' : '',
version: shell.stdout,
root: '',
error: shell.error,
};
}
function buildResult(args) {
const target = TARGETS[args.target] || null;
const requiredVersion = args.requiredVersion || (target && target.version) || args.target.replace(/^v/i, '');
const currentVersion = process.version.replace(/^v/i, '');
const nvm = detectNvm();
const label = target ? target.label : '自定义 Node native 组件';
const nextCheck = target ? target.nextCheck : '';
return {
tool: 'check_node_runtime_compat',
target: args.target,
label,
requiredVersion,
currentVersion,
currentVersionWithV: process.version,
versionMatched: currentVersion === requiredVersion,
nodeAbi: process.versions && process.versions.modules ? process.versions.modules : '',
platform: process.platform,
arch: process.arch,
os: os.platform() + '-' + os.arch(),
nvm,
needsUserConfirmation: currentVersion !== requiredVersion,
installCommands: requiredVersion ? [
`nvm install ${requiredVersion}`,
`nvm use ${requiredVersion}`,
'node -v',
'node -p "process.versions.modules"',
nextCheck,
].filter(Boolean) : [],
};
}
function renderMarkdown(result) {
const lines = [];
lines.push('# Node 版本兼容检查');
lines.push('');
lines.push('- 目标组件:' + result.label);
lines.push('- 需要 Node:v' + result.requiredVersion);
lines.push('- 当前 Node:' + result.currentVersionWithV);
lines.push('- 当前 ABI:' + result.nodeAbi);
lines.push('- 平台:' + result.platform + '-' + result.arch);
lines.push('- 版本是否匹配:' + (result.versionMatched ? '是' : '否'));
lines.push('- nvm 是否可用:' + (result.nvm.available ? '是' : '否'));
if (result.nvm.version) lines.push('- nvm 版本:' + result.nvm.version);
if (result.nvm.path) lines.push('- nvm 路径:' + result.nvm.path);
if (result.nvm.root) lines.push('- nvm root:' + result.nvm.root);
if (!result.versionMatched) {
lines.push('');
lines.push('## 处理建议');
lines.push('');
lines.push('- 不要直接降级;先让用户确认是否通过 nvm 安装 / 切换兼容 Node。');
lines.push('- 用户同意后再执行以下命令:');
lines.push('');
lines.push('```bash');
for (const cmd of result.installCommands) lines.push(cmd);
lines.push('```');
if (!result.nvm.available) {
lines.push('');
lines.push('- 当前未检测到 nvm。先让用户选择自动安装 nvm、手动安装 nvm、提供已安装 nvm 路径,或拒绝安装后再进入降级 / 改选流程。');
}
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = buildResult(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (error) {
console.error(error && error.message ? error.message : String(error));
console.error(usage());
process.exit(1);
}

View File

@@ -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 };

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const STAGE_FILES = {
'需求信息确认': '01-需求信息确认.md',
'intake': '01-需求信息确认.md',
'取证方案确认': '02-取证方案确认.md',
'forensics': '02-取证方案确认.md',
'请求样本与可疑参数确认': '03-请求样本与可疑参数确认.md',
'params': '03-请求样本与可疑参数确认.md',
'JS文件与入口定位': '04-JS文件与入口定位.md',
'entry': '04-JS文件与入口定位.md',
'补环境前置分析': '05-补环境前置分析.md',
'pre-env': '05-补环境前置分析.md',
'补环境实现记录': '06-补环境实现记录.md',
'env': '06-补环境实现记录.md',
'验证与清理记录': '07-验证与清理记录.md',
'validation': '07-验证与清理记录.md',
};
const DYNAMIC_REQUIRED_SECTIONS = [
'当前阶段目标',
'当前项目进展',
'本阶段修改文件',
'Trace 计划内首轮实现 / 调整的 WebAPI',
'计划外新增 WebAPI 与原因',
'Trace-runtime 可执行闭环',
'XHR/fetch Session Bridge',
'XHR/fetch 请求语义审计',
'WebAPI 环境检测矩阵',
'对象形状审计矩阵',
'本阶段新增功能',
'本阶段修复的 Bug',
'本阶段新增 / 修改的指纹能力',
'真实性保护变化',
'本阶段测试内容与结果',
'清理情况',
'风险与遗留问题',
'下一步计划',
];
const CAPABILITY_SECTIONS = [
'Trace 计划内首轮实现 / 调整的 WebAPI',
'计划外新增 WebAPI 与原因',
'Trace-runtime 可执行闭环',
'XHR/fetch Session Bridge',
'XHR/fetch 请求语义审计',
'WebAPI 环境检测矩阵',
'对象形状审计矩阵',
'本阶段新增功能',
'本阶段修复的 Bug',
'本阶段新增 / 修改的指纹能力',
'真实性保护变化',
'本阶段测试内容与结果',
];
function parseArgs(argv) {
const args = { caseDir: '', requiredStages: [], requireDynamicFields: false, requireCapabilityReport: false, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--require-stage') args.requiredStages.push(argv[++i] || '');
else if (a === '--require-initial') args.requiredStages.push('需求信息确认');
else if (a === '--require-dynamic-fields') args.requireDynamicFields = true;
else if (a === '--require-capability-report') args.requireCapabilityReport = 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.requiredStages.length) args.requiredStages.push('需求信息确认');
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_stage_reports.js --case-dir case --require-stage 需求信息确认 --markdown
node scripts/check_stage_reports.js --case-dir case --require-stage WebAPI补齐阶段报告 --require-dynamic-fields --markdown
node scripts/check_stage_reports.js --case-dir case --require-stage 需求信息确认 --require-stage 请求样本与可疑参数确认 --json
说明:检查阶段报告是否使用中文文件名、UTF-8 编码,并确认必要阶段报告存在。使用 --require-dynamic-fields 时会校验动态阶段报告是否包含 Trace 计划内 WebAPI、计划外新增原因、Trace-runtime 可执行闭环、XHR/fetch Session Bridge、XHR/fetch 请求语义审计、WebAPI 环境检测矩阵、对象形状审计矩阵、功能、Bug、指纹、测试、清理和风险等章节。`;
}
function exists(p) { try { fs.accessSync(p); return true; } 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 hasChinese(s) { return /[\u4e00-\u9fff]/.test(String(s || '')); }
function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function listMarkdown(dir) {
if (!exists(dir)) return [];
const out = [];
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
const st = stat(p);
if (st && st.isFile() && path.extname(name).toLowerCase() === '.md') out.push(p);
}
return out.sort();
}
function fixedStageFileName(stage) {
const s = String(stage || '').trim();
return STAGE_FILES[s] || '';
}
function stageTitle(stage) {
const fixed = fixedStageFileName(stage);
if (!fixed) return String(stage || '').trim();
return fixed.replace(/^\d+-/, '').replace(/\.md$/i, '');
}
function locateStageReport(stageDir, stage) {
const s = String(stage || '').trim();
if (!s) return '';
const fixed = fixedStageFileName(s);
if (fixed) {
const p = path.join(stageDir, fixed);
if (exists(p)) return p;
return '';
}
if (!hasChinese(s)) return '';
const exact = path.join(stageDir, `${s}.md`);
if (exists(exact)) return exact;
const pattern = new RegExp(`^(\\d{1,3}-)?${escapeRegExp(s)}\\.md$`, 'i');
const files = listMarkdown(stageDir);
return files.find(file => pattern.test(path.basename(file))) || files.find(file => path.basename(file).includes(s)) || '';
}
function hasMojibake(text) {
const questionRuns = text.match(/\?{3,}/g) || [];
return text.includes('\uFFFD') || questionRuns.length > 0;
}
function missingSections(text, sections) {
return sections.filter(section => !text.includes(section));
}
function isDynamicCandidate(name, text) {
if (/WebAPI|指纹|Bug|补环境|addon|能力|修复|回归|阶段报告/i.test(name)) return true;
const sectionHits = DYNAMIC_REQUIRED_SECTIONS.filter(section => text.includes(section)).length;
return sectionHits >= 4;
}
function check(args) {
if (!args.caseDir) throw new Error('必须提供 --case-dir');
const caseDir = path.resolve(args.caseDir);
const stageDir = path.join(caseDir, '阶段报告');
const reports = listMarkdown(stageDir);
const problems = [];
const warnings = [];
if (!exists(stageDir)) problems.push('缺少阶段报告目录:case/阶段报告');
if (!reports.length) problems.push('缺少阶段报告 Markdown 文件,至少应生成 01-需求信息确认.md');
const reportResults = [];
for (const file of reports) {
const name = path.basename(file);
const item = {
file: rel(caseDir, file),
chineseFileName: hasChinese(name),
utf8Readable: false,
mojibakeSuspected: false,
dynamicCandidate: false,
initialRequiredFieldsMissing: [],
dynamicFieldsMissing: [],
capabilityFieldsMissing: [],
};
if (!item.chineseFileName) problems.push(`阶段报告文件名必须包含中文:${item.file}`);
let text = '';
try { text = readText(file); item.utf8Readable = true; } catch (err) { problems.push(`阶段报告无法按 UTF-8 读取:${item.file}:${err.message}`); }
if (text) {
item.mojibakeSuspected = hasMojibake(text);
if (item.mojibakeSuspected) problems.push(`阶段报告疑似乱码或包含连续问号:${item.file}`);
if (name === '01-需求信息确认.md') {
for (const key of ['目标网站 URL', '目标 API', '取证模式', '加密参数', '已知 JS 文件']) {
if (!text.includes(key)) item.initialRequiredFieldsMissing.push(key);
}
if (item.initialRequiredFieldsMissing.length) problems.push(`需求信息确认报告缺少字段:${item.initialRequiredFieldsMissing.join('、')}`);
}
item.dynamicCandidate = isDynamicCandidate(name, text);
const simpleFixedStage = /^0[1-4]-/.test(name);
const shouldCheckDynamic = item.dynamicCandidate || (args.requireDynamicFields && !simpleFixedStage);
if (shouldCheckDynamic) {
item.dynamicFieldsMissing = missingSections(text, DYNAMIC_REQUIRED_SECTIONS);
if (item.dynamicFieldsMissing.length) problems.push(`动态阶段报告缺少章节:${item.file}:${item.dynamicFieldsMissing.join('、')}`);
}
if (args.requireCapabilityReport && shouldCheckDynamic) {
item.capabilityFieldsMissing = missingSections(text, CAPABILITY_SECTIONS);
if (item.capabilityFieldsMissing.length) problems.push(`阶段报告缺少能力增量章节:${item.file}:${item.capabilityFieldsMissing.join('、')}`);
}
}
reportResults.push(item);
}
for (const stage of args.requiredStages) {
const file = locateStageReport(stageDir, stage);
if (!file) {
const title = stageTitle(stage);
if (!title || !hasChinese(title)) problems.push(`未知必需阶段:${stage}`);
else problems.push(`缺少必需阶段报告:${title}`);
}
}
return { caseDir, stageDir, clean: problems.length === 0, problems, warnings, reports: reportResults };
}
function renderMarkdown(result) {
const lines = ['# 阶段报告检查结果', '', `case 目录:${result.caseDir}`, `阶段报告目录:${result.stageDir}`, `是否通过:${result.clean ? '是' : '否'}`, ''];
lines.push('## 报告列表');
if (result.reports.length) {
for (const r of result.reports) {
lines.push(`- ${r.file}:中文文件名=${r.chineseFileName ? '是' : '否'},UTF-8=${r.utf8Readable ? '是' : '否'},疑似乱码=${r.mojibakeSuspected ? '是' : '否'},动态报告=${r.dynamicCandidate ? '是' : '否'}`);
if (r.dynamicFieldsMissing.length) lines.push(` - 缺少动态章节:${r.dynamicFieldsMissing.join('、')}`);
if (r.capabilityFieldsMissing.length) lines.push(` - 缺少能力章节:${r.capabilityFieldsMissing.join('、')}`);
}
} else lines.push('- 无');
if (result.problems.length) {
lines.push('', '## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
}
if (result.warnings.length) {
lines.push('', '## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
}
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); }

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env node
'use strict';
const { spawnSync } = require('child_process');
function parseArgs(argv) {
const args = { python: '', json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--python') args.python = argv[++i] || '';
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_tls_clients.js --markdown
node scripts/check_tls_clients.js --python python --json
说明:检测最终请求阶段可选的 TLS 指纹兼容客户端:Node.js CycleTLS / impers / curl-cffi-node、Python curl_cffi / cffi_curl / cyCronet;真实请求交付时必须再封装为 Session 模式。`;
}
function run(cmd, args, timeout = 12000) {
const ret = spawnSync(cmd, args, { encoding: 'utf8', timeout, windowsHide: true });
return {
ok: ret.status === 0,
status: ret.status,
stdout: (ret.stdout || '').trim(),
stderr: (ret.stderr || '').trim(),
error: ret.error ? ret.error.message : '',
command: [cmd].concat(args).join(' '),
};
}
function nodeRequire(name) {
const code = `
(async()=>{
const name=${JSON.stringify(name)};
try {
const m=require(name);
console.log(JSON.stringify({ok:true, loader:"require", keys:Object.keys(m).slice(0,20)}));
} catch (e1) {
try {
const m=await import(name);
console.log(JSON.stringify({ok:true, loader:"import", keys:Object.keys(m).slice(0,20)}));
} catch (e2) {
console.log(JSON.stringify({ok:false,error:e1.message + " / " + e2.message}));
process.exit(2);
}
}
})();`;
const ret = run(process.execPath, ['-e', code]);
try {
const parsed = JSON.parse(ret.stdout.replace(/^\uFEFF/, ''));
return { name, installed: !!parsed.ok, loader: parsed.loader || '', keys: parsed.keys || [], error: parsed.error || '' };
} catch {
return { name, installed: false, loader: '', keys: [], error: ret.stderr || ret.error || '检测失败' };
}
}
function pythonCandidates(explicit) {
const out = [];
if (explicit) out.push({ cmd: explicit, prefix: [] });
out.push({ cmd: 'python', prefix: [] });
out.push({ cmd: 'python3', prefix: [] });
out.push({ cmd: 'py', prefix: ['-3'] });
const seen = new Set();
return out.filter(x => {
const k = x.cmd + ' ' + x.prefix.join(' ');
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
function detectPythonModules(explicitPython) {
const modules = ['curl_cffi', 'cffi_curl', 'cycronet', 'cyCronet'];
const code = [
'import importlib, json',
`mods=${JSON.stringify(modules)}`,
'out={}',
'for name in mods:',
' try:',
' m=importlib.import_module(name)',
' out[name]={"installed": True, "version": getattr(m, "__version__", "")}',
' except Exception as e:',
' out[name]={"installed": False, "error": str(e)}',
'print(json.dumps(out, ensure_ascii=False))',
].join('\n');
for (const c of pythonCandidates(explicitPython)) {
const ret = run(c.cmd, c.prefix.concat(['-c', code]));
if (!ret.ok && !ret.stdout) continue;
try {
const parsed = JSON.parse(ret.stdout.replace(/^\uFEFF/, ''));
return { python: c.cmd, pythonArgsPrefix: c.prefix, modules: parsed };
} catch {}
}
return {
python: '',
pythonArgsPrefix: [],
modules: Object.fromEntries(modules.map(m => [m, { installed: false, error: '未找到可用 Python 或检测失败' }])),
};
}
function detect(args) {
const nodePackages = ['cycletls', '@luminati-io/cycletls', 'cycle-tls', 'cycleTls', 'impers', 'curl-cffi', 'curl-cffi-node'].map(nodeRequire);
const python = detectPythonModules(args.python);
const cycleTlsAvailable = nodePackages.some(p => p.installed && /cycle/i.test(p.name));
const impersAvailable = nodePackages.some(p => p.installed && p.name === 'impers');
const curlCffiNodeAvailable = nodePackages.some(p => p.installed && p.name === 'curl-cffi');
return {
node: {
executable: process.execPath,
packages: nodePackages,
cycleTlsAvailable,
impersAvailable,
curlCffiNodeAvailable,
nodeTlsAvailable: cycleTlsAvailable || impersAvailable || curlCffiNodeAvailable,
},
python,
pythonTlsAvailable: Object.values(python.modules).some(v => v && v.installed),
note: '这些客户端只用于授权范围内的最终低频请求验证;如果本 case 需要最终发送真实请求,应在前置阶段先选择 Node.js CycleTLS/impers/curl-cffi-node 或 Python curl_cffi/cyCronet,并在最终项目中封装为 Session 模式,不要等普通客户端失败后才考虑。',
};
}
function renderMarkdown(result) {
const lines = ['# TLS 指纹兼容客户端检测结果', '', `说明:${result.note}`, '', '## Node.js / CycleTLS / impers'];
lines.push(`- Node.js:${result.node.executable}`);
lines.push(`- 是否检测到 CycleTLS:${result.node.cycleTlsAvailable ? '是' : '否'}`);
lines.push(`- 是否检测到 impers:${result.node.impersAvailable ? '是' : '否'}`);
lines.push(`- 是否检测到 Node.js TLS 指纹兼容客户端:${result.node.nodeTlsAvailable ? '是' : '否'}`);
for (const p of result.node.packages) {
lines.push(`- ${p.name}:${p.installed ? '已安装' : '未安装'}${p.loader ? `(${p.loader})` : ''}${p.error ? `(${p.error})` : ''}`);
}
lines.push('', '## Python / curl_cffi / cffi_curl / cyCronet');
lines.push(`- Python:${result.python.python ? `${result.python.python} ${result.python.pythonArgsPrefix.join(' ')}`.trim() : '未检测到'}`);
lines.push(`- 是否检测到 Python TLS 客户端:${result.pythonTlsAvailable ? '是' : '否'}`);
for (const [name, info] of Object.entries(result.python.modules)) {
lines.push(`- ${name}:${info.installed ? '已安装' : '未安装'}${info.version ? `(版本 ${info.version})` : ''}${info.error && !info.installed ? `(${info.error})` : ''}`);
}
lines.push('', '## 下一步');
if (!result.node.nodeTlsAvailable && !result.pythonTlsAvailable) {
lines.push('- 未检测到可用 TLS 指纹兼容客户端;如果本 case 需要最终发送真实请求,需在前置阶段询问用户安装 CycleTLS / impers / curl_cffi / cyCronet,或改为只输出本地 sign 结果。');
} else {
lines.push('- 在任务确认阶段选择一个最终请求客户端,并限制为少量授权验证请求;最终项目中只保留所选客户端的 Session 请求逻辑。');
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = detect(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,345 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { check: checkRuntimeConformance } = require('./check_trace_runtime_conformance');
const ALLOWED_STATUSES = new Set([
'planned-first-pass',
'implemented-first-pass',
'needs-baseline-sampling',
'deferred-not-mounted',
'native-capability-gap',
'live-discovered',
'missed-from-trace',
]);
const PRIORITIES_REQUIRING_DECISION = new Set(['P0', 'P1']);
function parseArgs(argv) {
const args = {
caseDir: '',
inventory: '',
matrix: '',
contract: '',
nodeAudit: '',
requireRuntimeClosure: false,
requireStageAudit: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--inventory') args.inventory = argv[++i] || '';
else if (a === '--matrix') args.matrix = argv[++i] || '';
else if (a === '--contract') args.contract = argv[++i] || '';
else if (a === '--node-audit') args.nodeAudit = argv[++i] || '';
else if (a === '--require-runtime-closure') args.requireRuntimeClosure = true;
else if (a === '--require-stage-audit') args.requireStageAudit = 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_trace_api_coverage.js --case-dir case --markdown
node scripts/check_trace_api_coverage.js --case-dir case --require-stage-audit --require-runtime-closure --json
node scripts/check_trace_api_coverage.js --inventory case/notes/trace-api-inventory.json --matrix case/notes/env-coverage-matrix.md --markdown
说明:检查 Trace API inventory、env coverage matrix、实现文件和 Trace-runtime 行为闭环,以及后续阶段报告中计划外新增 WebAPI 是否说明原因。`;
}
function exists(p) {
try { return fs.existsSync(p); } catch { return false; }
}
function readText(p) {
return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
}
function readJson(p) {
return JSON.parse(readText(p));
}
function rel(root, file) {
return (path.relative(root, file) || '.').replace(/\\/g, '/');
}
function normalizePriority(value) {
const text = String(value || '').trim().toUpperCase();
if (/^P?[0-2]$/.test(text)) return text.startsWith('P') ? text : `P${text}`;
return text || 'UNKNOWN';
}
function normalizeStatus(value) {
return String(value || '').trim();
}
function normalizeInventory(raw) {
if (Array.isArray(raw)) return raw;
if (Array.isArray(raw.apis)) return raw.apis;
if (raw.inventory && Array.isArray(raw.inventory.apis)) return raw.inventory.apis;
if (raw.apiInventory && Array.isArray(raw.apiInventory)) return raw.apiInventory;
return [];
}
function implementationPath(caseDir, file) {
if (!file) return '';
return path.isAbsolute(file) ? file : path.resolve(caseDir, file);
}
function listMarkdown(dir) {
if (!exists(dir)) return [];
const out = [];
for (const name of fs.readdirSync(dir)) {
const file = path.join(dir, name);
try {
if (fs.statSync(file).isFile() && name.toLowerCase().endsWith('.md')) out.push(file);
} catch {}
}
return out.sort();
}
function hasTraceEvidence(caseDir) {
const candidates = [
path.join(caseDir, 'ruyi-trace'),
path.join(caseDir, 'notes', 'ruyitrace-summary.md'),
path.join(caseDir, 'tmp', 'env-trace.jsonl'),
path.join(caseDir, 'tmp', 'missing-env.json'),
];
return candidates.some(exists);
}
function auditMatrixText(text) {
const required = ['Trace', 'baselineId', 'P0', 'P1', '首轮', '采样', '暂不挂载', '计划外'];
return required.filter(key => !text.includes(key));
}
function auditStageReports(caseDir) {
const stageDir = path.join(caseDir, '阶段报告');
const files = listMarkdown(stageDir);
const findings = [];
const reasonPattern = /trace-not-covered|dynamic-resource-new-branch|baseline-mismatch|trace-truncated|native-gap|missed-from-trace|Trace 未覆盖|动态资源新分支|baseline|截断|能力缺口|矩阵遗漏|流程缺陷/;
for (const file of files) {
const text = readText(file);
const mentionsOldHeading = text.includes('本阶段新增 / 修改的 WebAPI');
const hasPlanned = text.includes('Trace 计划内') || text.includes('计划内首轮');
const hasUnplanned = text.includes('计划外新增');
if (mentionsOldHeading && !hasPlanned && !hasUnplanned) {
findings.push({
file: rel(caseDir, file),
problem: '阶段报告仍使用笼统的“本阶段新增 / 修改的 WebAPI”,未拆分 Trace 计划内与计划外新增。',
});
}
if (hasUnplanned) {
const section = text.slice(text.indexOf('计划外新增'));
if (!reasonPattern.test(section)) {
findings.push({
file: rel(caseDir, file),
problem: '计划外新增 WebAPI 缺少原因枚举或证据说明。',
});
}
}
}
return findings;
}
function check(args) {
const caseDir = args.caseDir ? path.resolve(args.caseDir) : process.cwd();
const notesDir = path.join(caseDir, 'notes');
const inventoryPath = path.resolve(args.inventory || path.join(notesDir, 'trace-api-inventory.json'));
const matrixPath = path.resolve(args.matrix || path.join(notesDir, 'env-coverage-matrix.md'));
const contractPath = path.resolve(args.contract || path.join(notesDir, 'trace-runtime-contract.json'));
const nodeAuditPath = path.resolve(args.nodeAudit || path.join(caseDir, 'tmp', 'node-trace-runtime-audit.json'));
const problems = [];
const warnings = [];
const traceDetected = hasTraceEvidence(caseDir);
if (traceDetected && !exists(inventoryPath)) problems.push(`存在 Trace 证据,但缺少 ${rel(caseDir, inventoryPath)}`);
if (traceDetected && !exists(matrixPath)) problems.push(`存在 Trace 证据,但缺少 ${rel(caseDir, matrixPath)}`);
let apis = [];
let inventoryMeta = {};
if (exists(inventoryPath)) {
try {
const raw = readJson(inventoryPath);
inventoryMeta = {
schemaVersion: raw.schemaVersion || '',
source: raw.source || '',
baselineId: raw.baselineId || '',
};
apis = normalizeInventory(raw);
if (!apis.length) problems.push('trace-api-inventory.json 没有可识别的 apis 数组');
} catch (err) {
problems.push(`trace-api-inventory.json 无法解析为 JSON:${err.message}`);
}
}
const apiResults = [];
for (const item of apis) {
const api = item.api || item.path || item.name || '(unknown)';
const priority = normalizePriority(item.priority || item.level);
const status = normalizeStatus(item.implementationStatus || item.status || item.coverageStatus);
const reason = String(item.reason || item.decisionReason || '').trim();
const implementationFile = String(item.implementationFile || item.file || '').trim();
const samplingRequired = Boolean(item.samplingRequired || status === 'needs-baseline-sampling');
const resolvedImplementationFile = implementationPath(caseDir, implementationFile);
const result = { api, priority, status, reason, implementationFile, resolvedImplementationFile, samplingRequired, clean: true, problems: [] };
if (!status || status === 'unplanned') {
result.problems.push('缺少实现状态或状态为 unplanned');
} else if (!ALLOWED_STATUSES.has(status)) {
result.problems.push(`未知状态:${status}`);
}
if (PRIORITIES_REQUIRING_DECISION.has(priority) && status === 'planned-first-pass' && !implementationFile) {
result.problems.push('P0/P1 首轮实现项缺少 implementationFile');
}
if (status === 'implemented-first-pass' && (!implementationFile || !exists(resolvedImplementationFile))) {
result.problems.push(`implemented-first-pass 对应实现文件不存在:${implementationFile || '未记录'}`);
}
if (args.requireRuntimeClosure && PRIORITIES_REQUIRING_DECISION.has(priority) && status === 'planned-first-pass') {
result.problems.push('进入 runtime closure 阶段后 P0/P1 不得仍为 planned-first-pass');
}
if (['needs-baseline-sampling', 'deferred-not-mounted', 'native-capability-gap', 'missed-from-trace', 'live-discovered'].includes(status) && !reason) {
result.problems.push(`${status} 必须写明 reason`);
}
if (status === 'missed-from-trace') {
result.problems.push('Trace 已命中但矩阵遗漏,必须作为流程缺陷修复后再继续');
}
if (samplingRequired && status !== 'needs-baseline-sampling' && !reason) {
result.problems.push('samplingRequired=true 时必须说明采样策略或阻塞原因');
}
if (result.problems.length) {
result.clean = false;
for (const p of result.problems) problems.push(`${api}:${p}`);
}
apiResults.push(result);
}
let matrixMissing = [];
if (exists(matrixPath)) {
const text = readText(matrixPath);
matrixMissing = auditMatrixText(text);
if (matrixMissing.length) problems.push(`env-coverage-matrix.md 缺少关键内容:${matrixMissing.join('、')}`);
}
const stageFindings = args.requireStageAudit ? auditStageReports(caseDir) : [];
for (const finding of stageFindings) problems.push(`${finding.file}:${finding.problem}`);
let runtimeConformance = null;
if (args.requireRuntimeClosure) {
runtimeConformance = checkRuntimeConformance({
caseDir,
contract: contractPath,
nodeAudit: nodeAuditPath,
strictP2: false,
});
for (const problem of runtimeConformance.problems) problems.push(`Trace-runtime:${problem}`);
const inventoryApis = new Set(apiResults.map(item => item.api));
if (exists(contractPath)) {
try {
const contractRaw = readJson(contractPath);
const contractItems = normalizeInventory({ apis: contractRaw.contracts || [] });
const missingFromInventory = [...new Set(contractItems.map(item => item.api || item.path || item.name).filter(Boolean))]
.filter(api => !inventoryApis.has(api));
if (missingFromInventory.length) {
problems.push(`trace-api-inventory.json 缺少 runtime contract 中的 API:${missingFromInventory.slice(0, 50).join('、')}`);
}
} catch (err) {
problems.push(`无法核对 runtime contract 与 inventory:${err.message}`);
}
}
}
return {
caseDir,
traceDetected,
inventoryPath,
matrixPath,
contractPath,
nodeAuditPath,
inventoryMeta,
clean: problems.length === 0,
problems,
warnings,
apiCount: apiResults.length,
apiResults,
matrixMissing,
stageFindings,
runtimeConformance,
};
}
function renderMarkdown(result) {
const lines = [
'# Trace API 覆盖矩阵检查结果',
'',
`case 目录:${result.caseDir}`,
`检测到 Trace 证据:${result.traceDetected ? '是' : '否'}`,
`inventory:${result.inventoryPath}`,
`matrix:${result.matrixPath}`,
`runtime contract:${result.contractPath}`,
`Node runtime audit:${result.nodeAuditPath}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## Inventory 摘要',
`- schemaVersion:${result.inventoryMeta.schemaVersion || '未记录'}`,
`- source:${result.inventoryMeta.source || '未记录'}`,
`- baselineId:${result.inventoryMeta.baselineId || '未记录'}`,
`- API 数量:${result.apiCount}`,
];
if (result.apiResults.length) {
lines.push('', '## API 状态');
for (const item of result.apiResults.slice(0, 80)) {
lines.push(`- ${item.clean ? '通过' : '失败'} ${item.priority} ${item.api}:${item.status || '未记录'}${item.implementationFile ? `;文件 ${item.implementationFile}` : ''}`);
for (const problem of item.problems) lines.push(` - ${problem}`);
}
if (result.apiResults.length > 80) lines.push(`- 其余 ${result.apiResults.length - 80} 条略。`);
}
if (result.matrixMissing.length) {
lines.push('', '## Matrix 缺失项');
for (const item of result.matrixMissing) lines.push(`- ${item}`);
}
if (result.stageFindings.length) {
lines.push('', '## 阶段报告问题');
for (const item of result.stageFindings) lines.push(`- ${item.file}:${item.problem}`);
}
if (result.runtimeConformance) {
lines.push('', '## Trace-runtime 闭环');
lines.push(`- 是否通过:${result.runtimeConformance.clean ? '是' : '否'}`);
lines.push(`- 契约项:${result.runtimeConformance.summary.contractCount ?? 0}`);
lines.push(`- 阻断差异:${result.runtimeConformance.summary.blockingMismatches ?? 0}`);
}
if (result.problems.length) {
lines.push('', '## 问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
}
if (!result.problems.length) lines.push('', '## 结论', '- Trace API 覆盖矩阵检查通过。');
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);
}

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const BLOCKING_PRIORITIES = new Set(['P0', 'P1']);
const ASSERTION_FIELDS = [
'owner',
'brand',
'constructorName',
'descriptor',
'prototypeChain',
'ownKeys',
'ownPropertyNames',
'ownSymbols',
'argsDigest',
'resultDigest',
'errorDigest',
'sideEffectsDigest',
];
function parseArgs(argv) {
const args = {
caseDir: '',
contract: '',
nodeAudit: '',
strictP2: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--contract') args.contract = argv[++i] || '';
else if (arg === '--node-audit') args.nodeAudit = argv[++i] || '';
else if (arg === '--strict-p2') args.strictP2 = true;
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_trace_runtime_conformance.js --case-dir case --markdown
node scripts/check_trace_runtime_conformance.js --contract case/notes/trace-runtime-contract.json --node-audit case/tmp/node-trace-runtime-audit.json --strict-p2 --json
说明:逐项比较 Trace 行为契约与 Node runtime audit。P0/P1 缺失或行为不一致会直接阻断,不能用手工填写 matched 绕过。`;
}
function exists(file) {
try { return fs.existsSync(file); } catch { return false; }
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
}
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
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 digest(value) {
if (typeof value === 'undefined') return '';
if (value && typeof value === 'object' && typeof value.digest === 'string') return value.digest;
if (typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)) return value.toLowerCase();
try { return sha256(JSON.stringify(stable(value))); } catch { return sha256(String(value)); }
}
function normalizeList(value) {
if (typeof value === 'undefined') return [];
const list = Array.isArray(value) ? value : [value];
return list.map(item => {
if (item && typeof item === 'object' && typeof item.digest === 'string') return item.digest.toLowerCase();
return digest(item);
}).filter(Boolean).sort();
}
function itemKey(item) {
if (item.id) return `id:${item.id}`;
return ['key', item.api || item.path || item.name || '', item.accessType || item.operation || item.type || 'unknown', item.realm || item.realmId || 'main', item.receiver || item.receiverType || '', item.phase || ''].join('\u001f');
}
function normalizeItems(raw) {
if (Array.isArray(raw)) return raw;
for (const key of ['contracts', 'observations', 'items', 'apis', 'events']) {
if (Array.isArray(raw && raw[key])) return raw[key];
}
return [];
}
function contractHash(contract) {
if (contract.contractHash) return String(contract.contractHash);
return sha256(JSON.stringify(stable({
schemaVersion: contract.schemaVersion,
baselineId: contract.baselineId,
traceSourceHash: contract.traceSourceHash,
contracts: normalizeItems(contract),
timeline: contract.timeline || {},
})));
}
function expectedDigests(contractItem, field) {
const assertions = contractItem.assertions || {};
return normalizeList(assertions[field]);
}
function observedDigests(auditItem, field) {
const observations = auditItem.observations || {};
if (typeof observations[field] !== 'undefined') return normalizeList(observations[field]);
if (typeof auditItem[field] !== 'undefined') return normalizeList(auditItem[field]);
return [];
}
function compareItem(contractItem, auditItem) {
const diffs = [];
for (const field of ASSERTION_FIELDS) {
const expected = expectedDigests(contractItem, field);
if (!expected.length) continue;
const observed = observedDigests(auditItem, field);
if (!observed.length) {
diffs.push({ field, type: 'missing-observation', expected, observed });
continue;
}
const missing = expected.filter(item => !observed.includes(item));
if (missing.length) diffs.push({ field, type: 'value-mismatch', expected, observed, missing });
}
if (Number(contractItem.count || 0) > 1) {
const observedSequences = Array.isArray(auditItem.sequences)
? auditItem.sequences
: (typeof auditItem.sequence !== 'undefined' ? [auditItem.sequence] : []);
if (observedSequences.length < Math.min(Number(contractItem.count), 2)) {
diffs.push({
field: 'sequences',
type: 'insufficient-sequence-evidence',
expected: Math.min(Number(contractItem.count), 2),
observed: observedSequences.length,
});
}
}
return diffs;
}
function compareTimeline(contract, audit, problems) {
const expected = contract.timeline || {};
if (!expected.required) return { required: false, clean: true, expectedHash: '', observedHash: '' };
const observed = audit.timeline || {};
if (observed.generatedFrom !== 'runtime-event-timeline') {
problems.push('Node audit timeline 不是由原始 runtime event timeline 生成;分组 observations 不能证明 happens-before 顺序');
}
if (observed.valid !== true) {
problems.push(`Node audit timeline 含无效项或重复 sequence:invalid=${observed.invalidEntries || 0},duplicate=${observed.duplicateSequences || 0}`);
}
const observedSequence = Array.isArray(observed.sequence) ? observed.sequence.map(String) : [];
if (!observedSequence.length) {
problems.push('Trace contract 包含状态型 API 时间线,但 Node audit 没有 timeline.sequence;逐 API matched 不能替代生命周期顺序');
return {
required: true,
clean: false,
expectedHash: expected.sequenceSha256 || '',
observedHash: observed.sequenceSha256 || '',
};
}
const observedHash = sha256(JSON.stringify(observedSequence));
const expectedHash = String(expected.sequenceSha256 || '');
if (!expectedHash) {
problems.push('Trace contract timeline 缺少 sequenceSha256,必须重新生成 v3 contract');
} else if (observedHash !== expectedHash) {
problems.push(`状态型 API 时间线不一致:${observedHash} != ${expectedHash}`);
}
return {
required: true,
clean: Boolean(expectedHash)
&& observed.generatedFrom === 'runtime-event-timeline'
&& observed.valid === true
&& observedHash === expectedHash,
expectedHash,
observedHash,
expectedCount: Number(expected.collapsedCount || 0),
observedCount: observedSequence.length,
};
}
function check(args) {
const caseDir = path.resolve(args.caseDir || '.');
const contractPath = path.resolve(args.contract || path.join(caseDir, 'notes', 'trace-runtime-contract.json'));
const auditPath = path.resolve(args.nodeAudit || path.join(caseDir, 'tmp', 'node-trace-runtime-audit.json'));
const problems = [];
const warnings = [];
if (!exists(contractPath)) problems.push(`缺少 Trace runtime contract:${contractPath}`);
if (!exists(auditPath)) problems.push(`缺少 Node runtime audit:${auditPath}`);
if (problems.length) {
return { caseDir, contractPath, auditPath, clean: false, problems, warnings, results: [], summary: {} };
}
let contract;
let audit;
try { contract = readJson(contractPath); } catch (err) { problems.push(`Trace runtime contract 无法解析:${err.message}`); }
try { audit = readJson(auditPath); } catch (err) { problems.push(`Node runtime audit 无法解析:${err.message}`); }
if (!contract || !audit) return { caseDir, contractPath, auditPath, clean: false, problems, warnings, results: [], summary: {} };
if (contract.schemaVersion !== 'trace-runtime-contract/v3') problems.push(`Trace contract 必须使用 trace-runtime-contract/v3,当前为:${contract.schemaVersion || '未记录'};旧 contract 没有可信状态时间线`);
if (!/^node-trace-runtime-audit\/v3$/.test(String(audit.schemaVersion || ''))) {
problems.push(`Node audit 必须使用 node-trace-runtime-audit/v3,当前为:${audit.schemaVersion || '未记录'}`);
}
if (audit.generatedBy !== 'run_trace_runtime_audit.js' && audit.generatedBy !== 'runtime-audit-recorder/v3') {
problems.push(`Node audit generatedBy 不可信:${audit.generatedBy || '未记录'}`);
}
const expectedContractHash = contractHash(contract);
if (!audit.contractHash || audit.contractHash !== expectedContractHash) {
problems.push(`Node audit contractHash 与当前 contract 不一致:${audit.contractHash || '未记录'} != ${expectedContractHash}`);
}
if (contract.baselineId && audit.baselineId !== contract.baselineId) {
problems.push(`baselineId 不一致:${contract.baselineId} != ${audit.baselineId || '未记录'}`);
}
if (!audit.runtimeSourceHash) problems.push('Node audit 缺少 runtimeSourceHash,无法判断审计是否已因源码变化失效');
if (!audit.probeVersion) problems.push('Node audit 缺少 probeVersion');
if (audit.networkMode !== 'no-send') problems.push(`Trace runtime audit 必须在 no-send 模式执行,当前为:${audit.networkMode || '未记录'}`);
if (!Object.prototype.hasOwnProperty.call(audit, 'networkAttempts') || Number(audit.networkAttempts) !== 0) {
problems.push(`Trace runtime audit 必须明确记录 networkAttempts=0,当前为:${Object.prototype.hasOwnProperty.call(audit, 'networkAttempts') ? audit.networkAttempts : '未记录'}`);
}
const contractItems = normalizeItems(contract);
const auditItems = normalizeItems(audit);
const auditByKey = new Map();
for (const item of auditItems) {
auditByKey.set(itemKey(item), item);
if (item.id) auditByKey.set(`id:${item.id}`, item);
}
const results = [];
for (const contractItem of contractItems) {
const key = contractItem.id ? `id:${contractItem.id}` : itemKey(contractItem);
const auditItem = auditByKey.get(key) || auditByKey.get(itemKey(contractItem));
const priority = String(contractItem.priority || 'P2').toUpperCase();
const blocking = BLOCKING_PRIORITIES.has(priority) || args.strictP2;
const diffs = auditItem ? compareItem(contractItem, auditItem) : [{ field: '*', type: 'missing-runtime-observation' }];
const clean = diffs.length === 0;
if (!clean) {
const message = `${priority} ${contractItem.api || contractItem.name || contractItem.id}:${diffs.map(item => `${item.field}:${item.type}`).join('、')}`;
if (blocking) problems.push(message);
else warnings.push(message);
}
results.push({
id: contractItem.id || '',
api: contractItem.api || contractItem.name || '',
priority,
clean,
blocking,
diffs,
});
}
const unexpectedAuditItems = auditItems.filter(item => {
const key = item.id ? `id:${item.id}` : itemKey(item);
return !contractItems.some(contractItem => (contractItem.id ? `id:${contractItem.id}` : itemKey(contractItem)) === key);
});
if (unexpectedAuditItems.length) warnings.push(`Node audit 出现 ${unexpectedAuditItems.length} 个 Trace contract 未记录的 runtime 观测项,必须分类为新动态分支或宿主泄露`);
const timeline = compareTimeline(contract, audit, problems);
const summary = {
contractCount: contractItems.length,
auditCount: auditItems.length,
matched: results.filter(item => item.clean).length,
mismatched: results.filter(item => !item.clean).length,
blockingMismatches: results.filter(item => !item.clean && item.blocking).length,
unexpectedAuditItems: unexpectedAuditItems.length,
baselineId: contract.baselineId || audit.baselineId || '',
traceSourceHash: contract.traceSourceHash || audit.traceSourceHash || '',
contractHash: expectedContractHash,
runtimeSourceHash: audit.runtimeSourceHash || '',
networkAttempts: audit.networkAttempts,
timeline,
};
return { caseDir, contractPath, auditPath, clean: problems.length === 0, problems, warnings, results, summary };
}
function renderMarkdown(result) {
const lines = [
'# Trace-runtime 一致性检查结果',
'',
`- contract:${result.contractPath}`,
`- Node audit:${result.auditPath}`,
`- 是否通过:${result.clean ? '是' : '否'}`,
];
if (result.summary && typeof result.summary.contractCount !== 'undefined') {
lines.push(`- 契约项:${result.summary.contractCount}`);
lines.push(`- Node audit 项:${result.summary.auditCount}`);
lines.push(`- matched:${result.summary.matched}`);
lines.push(`- mismatch:${result.summary.mismatched}`);
lines.push(`- P0/P1 阻断差异:${result.summary.blockingMismatches}`);
lines.push(`- baselineId:${result.summary.baselineId || '未记录'}`);
lines.push(`- traceSourceHash:${result.summary.traceSourceHash || '未记录'}`);
lines.push(`- runtimeSourceHash:${result.summary.runtimeSourceHash || '未记录'}`);
lines.push(`- 真实网络尝试:${String(result.summary.networkAttempts ?? '未记录')}`);
if (result.summary.timeline && result.summary.timeline.required) {
lines.push(`- 状态时间线:${result.summary.timeline.clean ? 'matched' : 'mismatch'}`);
lines.push(`- 状态时间线项:browser=${result.summary.timeline.expectedCount || 0},Node=${result.summary.timeline.observedCount || 0}`);
}
}
const bad = result.results.filter(item => !item.clean);
if (bad.length) {
lines.push('', '## 差异');
for (const item of bad.slice(0, 100)) {
lines.push(`- ${item.priority} ${item.api || item.id}:${item.diffs.map(diff => `${diff.field}:${diff.type}`).join('、')}`);
}
}
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`;
}
if (require.main === module) {
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, compareItem, contractHash };

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
function parseArgs(argv) {
const args = {
skillDir: '',
skillsRoot: '',
require: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--skill-dir') args.skillDir = argv[++i] || '';
else if (a === '--skills-root') args.skillsRoot = 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_web_verify_patcher.js --markdown
node scripts/check_web_verify_patcher.js --require --json
node scripts/check_web_verify_patcher.js --skill-dir <web-verify-patcher-dir> --markdown
node scripts/check_web_verify_patcher.js --skills-root <skills-root> --require --markdown
说明:在把验证码识别、轨迹生成或验证提交任务交给 web-verify-patcher 前,检查该 Skill 是否已安装,并输出未安装时的中文安装指引。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function readText(p) {
try { return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''); } catch { return ''; }
}
function unique(list) {
return [...new Set(list.filter(Boolean).map(p => path.normalize(p)))];
}
function defaultCandidates(args) {
if (args.skillDir) return [path.resolve(args.skillDir)];
if (args.skillsRoot) return [path.join(path.resolve(args.skillsRoot), 'web-verify-patcher')];
const roots = [];
if (process.env.CODEX_HOME) roots.push(path.join(process.env.CODEX_HOME, 'skills'));
if (process.env.AGENTS_HOME) roots.push(path.join(process.env.AGENTS_HOME, 'skills'));
const home = os.homedir();
roots.push(path.join(home, '.codex', 'skills'));
roots.push(path.join(home, '.agents', 'skills'));
roots.push(path.join(process.cwd(), 'skills'));
return unique(roots).map(root => path.join(root, 'web-verify-patcher'));
}
function inspectSkill(dir) {
const skillMd = path.join(dir, 'SKILL.md');
const text = readText(skillMd);
const nameMatch = text.match(/^name:\s*([^\r\n]+)/m);
const descriptionMatch = text.match(/^description:\s*(.+)$/m);
return {
dir,
skillMd,
exists: exists(skillMd),
name: nameMatch ? nameMatch[1].replace(/["']/g, '').trim() : '',
hasExpectedName: /name:\s*web-verify-patcher\b/m.test(text),
description: descriptionMatch ? descriptionMatch[1].trim().slice(0, 160) : '',
};
}
function inspect(args) {
const candidates = defaultCandidates(args).map(inspectSkill);
const installed = candidates.find(c => c.exists && c.hasExpectedName) || null;
const report = {
ok: Boolean(installed),
installed: Boolean(installed),
selected: installed,
candidates,
installRepo: 'https://github.com/lwjjike/xbsReverseSkill',
expectedFolder: 'web-verify-patcher',
instructions: [
'自动安装前先让用户确认安装目录;克隆仓库后必须检查是否存在 web-verify-patcher/ 目录。',
'如果仓库当前分支没有 web-verify-patcher/,不得假装安装成功,要求用户提供正确分支、正确仓库、压缩包或本地目录。',
'自行安装时,将 web-verify-patcher 文件夹放到 $HOME/.codex/skills/web-verify-patcher;Windows 可使用 %USERPROFILE%\\.codex\\skills\\web-verify-patcher。',
'如当前环境使用 .agents/skills,也可同步放到 $HOME/.agents/skills/web-verify-patcher。安装后重启或刷新 Codex 再检测。',
],
};
return report;
}
function toMarkdown(report) {
const lines = [];
lines.push('# web-verify-patcher 安装检测');
lines.push('');
lines.push(`- 检测结果:${report.ok ? '已安装' : '未检测到可用安装'}`);
if (report.selected) lines.push(`- 命中目录:${report.selected.dir}`);
lines.push(`- 预期 Skill 名称:web-verify-patcher`);
lines.push(`- 建议仓库:${report.installRepo}`);
lines.push('');
lines.push('## 检测路径');
lines.push('');
for (const c of report.candidates) {
lines.push(`- ${c.exists ? '存在' : '不存在'}:${c.skillMd}${c.exists ? `,name=${c.name || '未识别'}` : ''}`);
}
if (!report.ok) {
lines.push('');
lines.push('## 安装指引');
lines.push('');
for (const item of report.instructions) lines.push(`- ${item}`);
}
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
const report = inspect(args);
if (args.json) console.log(JSON.stringify(report, null, 2));
else console.log(toMarkdown(report));
if (args.require && !report.ok) process.exitCode = 1;
}
try {
main();
} catch (err) {
console.error(`web-verify-patcher 检测失败:${err.message}`);
process.exit(1);
}

View File

@@ -0,0 +1,738 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: '',
dir: '',
file: '',
json: false,
markdown: false,
warningsAsErrors: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--dir') args.dir = argv[++i] || '';
else if (arg === '--file' || arg === '-f') args.file = argv[++i] || '';
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--warnings-as-errors') args.warningsAsErrors = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_webapi_addon_coverage.js --case-dir case --markdown
node scripts/check_webapi_addon_coverage.js --dir case/result/src/env --json
node scripts/check_webapi_addon_coverage.js --file case/result/src/env/install-env.js --markdown
说明:检查补环境代码是否存在普通函数、普通对象、直接复用宿主 Web API、prototype 对象字面量等绕过 addon-first 的写法。该检查用于交付门禁,失败时不得交付最终项目。`;
}
function exists(filePath) {
try {
fs.accessSync(filePath);
return true;
} catch {
return false;
}
}
function stat(filePath) {
try {
return fs.statSync(filePath);
} catch {
return null;
}
}
function rel(root, filePath) {
return (path.relative(root, filePath) || '.').replace(/\\/g, '/');
}
function readText(filePath) {
return fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
}
function walk(filePath, out = []) {
if (!exists(filePath)) return out;
const st = stat(filePath);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try {
names = fs.readdirSync(filePath);
} catch {
names = [];
}
for (const name of names) walk(path.join(filePath, name), out);
} else if (st.isFile()) {
out.push(filePath);
}
return out;
}
function isJsFile(filePath) {
return ['.js', '.mjs', '.cjs'].includes(path.extname(filePath).toLowerCase());
}
function shouldSkip(root, filePath) {
const relative = rel(root, filePath).toLowerCase();
if (/(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party)(\/|$)/.test(relative)) return true;
if (/(^|\/)src\/target\/(original|vendor|bundle|bundles)(\/|$)/.test(relative)) return true;
if (/(\.min\.js|bundle\.js|vendor\.js|package-lock\.json)$/i.test(relative)) return true;
return false;
}
function lineOf(text, index) {
return text.slice(0, index).split(/\r?\n/).length;
}
function lineText(text, line) {
const lines = text.split(/\r?\n/);
return (lines[line - 1] || '').trim();
}
function addIssue(list, severity, file, line, type, message, suggestion, snippet) {
list.push({
severity,
file,
line,
type,
message,
suggestion,
snippet: String(snippet || '').slice(0, 220),
});
}
function targetExpr(name) {
return `(?:ctx|window|self|globalThis|globalObject|global)\\s*(?:\\.\\s*${name}|\\[\\s*['"]${name}['"]\\s*\\])`;
}
function escapedJoin(names) {
return names.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
}
const CONSTRUCTORS = [
'Blob',
'File',
'FormData',
'Event',
'CustomEvent',
'MessageEvent',
'XMLHttpRequest',
'Worker',
'Image',
'MutationObserver',
'IntersectionObserver',
'ResizeObserver',
'BroadcastChannel',
'MessageChannel',
'MessagePort',
'AudioContext',
'OfflineAudioContext',
'Screen',
'ScreenOrientation',
'URL',
'Headers',
'Request',
'Response',
'WebSocket',
'IDBFactory',
'IDBOpenDBRequest',
'IDBRequest',
'IDBDatabase',
'IDBTransaction',
'IDBObjectStore',
'IDBIndex',
'IDBCursor',
'IDBKeyRange',
'HTMLCanvasElement',
'CanvasRenderingContext2D',
'WebGLRenderingContext',
'WebGL2RenderingContext',
'CSSStyleDeclaration',
'HTMLCollection',
'NodeList',
'PluginArray',
'MimeTypeArray',
'Plugin',
'MimeType',
'DOMTokenList',
'StyleSheetList',
];
const OBJECT_LITERAL_TARGETS = [
'screen',
'orientation',
'indexedDB',
'IDBKeyRange',
'CSS',
'navigator',
'document',
'location',
'localStorage',
'sessionStorage',
'crypto',
'performance',
'history',
'visualViewport',
];
const WEBAPI_METHODS = [
'open',
'deleteDatabase',
'cmp',
'databases',
'createObjectURL',
'revokeObjectURL',
'supports',
'escape',
'getContext',
'toDataURL',
'toBlob',
'getImageData',
'putImageData',
'measureText',
'getParameter',
'readPixels',
'getSupportedExtensions',
'getShaderPrecisionFormat',
'startRendering',
'createElement',
'querySelector',
'querySelectorAll',
'getElementById',
'addEventListener',
'removeEventListener',
'dispatchEvent',
'postMessage',
'close',
'send',
'abort',
'setRequestHeader',
'getResponseHeader',
'getAllResponseHeaders',
'item',
'namedItem',
];
const HOST_REUSE = [
'TextEncoder',
'TextDecoder',
'URL',
'URLSearchParams',
'WebAssembly',
'fetch',
'Headers',
'Request',
'Response',
'Event',
'EventTarget',
'MessageChannel',
'BroadcastChannel',
'crypto',
'ReadableStream',
'WritableStream',
'TransformStream',
'Blob',
'File',
'FormData',
];
const COLLECTION_CONSTRUCTORS = [
'HTMLCollection',
'NodeList',
'PluginArray',
'MimeTypeArray',
'DOMTokenList',
'StyleSheetList',
];
const HELPER_EVIDENCE = /(?:createNativeFunction|createNativeConstructor|createGetter|createSetter|createNativeGetter|createNativeSetter|defineNativeGetter|defineNativeSetter|defineNativeAccessor|createProtoChains|createNativeObject|createNativeCollection|getMimeTypesAndPlugins|createInterceptor|createUndetectable|setPrivate|getPrivate|hasPrivate|deletePrivate|nativeApi|addon\.|xbs\.|window\.xbs\.|globalThis\.xbs\.)\s*\(/;
function scanMatches(text, pattern, onMatch) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(text))) {
onMatch(match);
if (match[0].length === 0) pattern.lastIndex += 1;
}
}
function hasConstructorAddonEvidence(text, name) {
const quoted = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const createNative = new RegExp(`createNativeFunction\\s*\\(\\s*true\\s*,\\s*['"]${quoted}['"]`);
const createCtor = new RegExp(`createNativeConstructor\\s*\\(\\s*['"]${quoted}['"]`);
const protoChains = new RegExp(`createProtoChains\\s*\\([\\s\\S]{0,1200}name\\s*:\\s*['"]${quoted}['"]`);
return createNative.test(text) || createCtor.test(text) || protoChains.test(text);
}
function inspectFile(root, filePath) {
const relative = rel(root, filePath);
const text = readText(filePath);
const issues = [];
const warnings = [];
for (const name of CONSTRUCTORS) {
const directFunction = new RegExp(`${targetExpr(name)}\\s*=\\s*(?:async\\s*)?function\\b`, 'g');
scanMatches(text, directFunction, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'webapi-constructor-plain-function',
`${name} 被直接赋值为普通 function,绕过了 addon-first 的构造函数与 toString 保护。`,
`使用 addon.createProtoChains([{ name: '${name}', ... }]) 或 addon.createNativeFunction(true, '${name}', length, callback) 创建,并设置 prototype.constructor、Symbol.toStringTag、非法构造行为和描述符。`,
lineText(text, line)
);
});
const classDeclaration = new RegExp(`\\bclass\\s+${name}\\b`, 'g');
scanMatches(text, classDeclaration, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'webapi-constructor-plain-class',
`${name} 使用普通 class 实现,默认不具备浏览器 native-like 构造函数行为。`,
`优先使用 addon.createProtoChains 或 addon.createNativeFunction(true, '${name}', ...);addon 不可用时才用 JS fallback,并记录降级原因。`,
lineText(text, line)
);
});
const identifierAssign = new RegExp(`${targetExpr(name)}\\s*=\\s*${name}\\s*;`, 'g');
scanMatches(text, identifierAssign, match => {
if (hasConstructorAddonEvidence(text, name)) return;
const line = lineOf(text, match.index);
addIssue(
warnings,
'warning',
relative,
line,
'webapi-constructor-no-addon-evidence',
`${name} 被挂载到全局,但当前文件没有明显的 createProtoChains / createNativeFunction(true, '${name}') 证据。`,
`确认 ${name} 的构造函数来自 addon-first helper;如果由其他模块创建,应在本文件或阶段报告中记录来源,并确保交付前整体运行本检查。`,
lineText(text, line)
);
});
}
for (const name of OBJECT_LITERAL_TARGETS) {
const objectLiteral = new RegExp(`${targetExpr(name)}\\s*=\\s*\\{`, 'g');
scanMatches(text, objectLiteral, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'webapi-instance-plain-object',
`${name} 被直接赋值为普通对象,缺少浏览器原型链、实例 toString、descriptor 和 addon-first 证据。`,
`为 ${name} 建立对应构造函数和 prototype,例如 Screen / IDBFactory / CSS 等;使用 createProtoChains 创建实例,再用 defineProperty 安装属性和 native-like 方法。`,
lineText(text, line)
);
});
}
const pluginMimeInstall = /\b(?:plugins|mimeTypes)\b/;
const installsNavigatorPluginMime = /Object\s*\.\s*defineProperty\s*\(\s*(?:navigator|Navigator\s*\.\s*prototype)[\s\S]{0,260}['"](?:plugins|mimeTypes)['"]|(?:navigator|Navigator\s*\.\s*prototype)\s*\.\s*(?:plugins|mimeTypes)\s*=/.test(text);
const hasMimePluginAddon = /\b(?:getMimeTypesAndPlugins|addon\s*\.\s*getMimeTypesAndPlugins|nativeApi\s*\.\s*getMimeTypesAndPlugins|xbs\s*\.\s*getMimeTypesAndPlugins|window\s*\.\s*xbs\s*\.\s*getMimeTypesAndPlugins|globalThis\s*\.\s*xbs\s*\.\s*getMimeTypesAndPlugins)\s*\(/.test(text);
if (pluginMimeInstall && installsNavigatorPluginMime && !hasMimePluginAddon) {
const index = text.search(/(?:plugins|mimeTypes)/);
const line = lineOf(text, index >= 0 ? index : 0);
addIssue(
issues,
'error',
relative,
line,
'navigator-plugins-mimetypes-without-addon',
'发现 navigator.plugins / navigator.mimeTypes 安装逻辑,但未发现 getMimeTypesAndPlugins addon-first 证据。',
'addon.node 或 xbs isolated-vm 可用时必须优先使用 addon.getMimeTypesAndPlugins(config) / xbs.getMimeTypesAndPlugins(config) 或 addon-first helper;真实浏览器插件数据不同则传入 config。只有 native 能力不可用或用户明确禁用时才允许 JS fallback,并记录原因。',
lineText(text, line)
);
}
const pluginMimePlainArray = /(?:plugins|mimeTypes)\s*[:=]\s*\[\s*\]/g;
scanMatches(text, pluginMimePlainArray, match => {
const nearby = text.slice(Math.max(0, match.index - 240), Math.min(text.length, match.index + 240));
if (/fallback|降级|addon 不可用|addon不可用|getMimeTypesAndPlugins/.test(nearby)) return;
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'navigator-plugins-mimetypes-plain-array',
'plugins / mimeTypes 被实现为普通数组,缺少 PluginArray / MimeTypeArray 原型、item / namedItem、索引访问和 native-like 行为。',
'使用 addon.getMimeTypesAndPlugins(config) 或 xbs.getMimeTypesAndPlugins(config);native 能力不可用时至少通过 createNativeCollection fallback 并记录差异。',
lineText(text, line)
);
});
for (const name of COLLECTION_CONSTRUCTORS) {
const mentionsCollection = new RegExp(`\\b${name}\\b`).test(text);
if (!mentionsCollection) continue;
const fromMimePluginApi = ['PluginArray', 'MimeTypeArray', 'Plugin', 'MimeType'].includes(name) && /getMimeTypesAndPlugins\s*\(/.test(text);
const hasCollectionAddon = new RegExp(`createNativeCollection\\s*\\([\\s\\S]{0,800}name\\s*:\\s*['"]${name}['"]`).test(text) || fromMimePluginApi;
const plainCollection = new RegExp(`(?:class\\s+${name}\\b|function\\s+${name}\\s*\\(|${targetExpr(name)}\\s*=\\s*(?:function|class|\\{)|\\b${name}\\s*=\\s*\\[)`, 'g');
scanMatches(text, plainCollection, match => {
if (hasCollectionAddon) return;
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'webapi-collection-without-createNativeCollection',
`${name} 使用普通 JS 结构实现,未体现 createNativeCollection / getMimeTypesAndPlugins addon-first。`,
`浏览器集合对象 ${name} 应优先使用 addon.createNativeCollection({ name: '${name}', ... });PluginArray / MimeTypeArray 应优先由 addon.getMimeTypesAndPlugins(config) 返回。`,
lineText(text, line)
);
});
}
const methodNames = escapedJoin(WEBAPI_METHODS);
const methodAssign = new RegExp(`(?:\\.|['"])(${methodNames})(?:['"])?\\s*(?:=|:)\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*=>|[A-Za-z_$][\\w$]*\\s*\\([^)]*\\)\\s*\\{)`, 'g');
scanMatches(text, methodAssign, match => {
const index = match.index;
const line = lineOf(text, index);
const snippet = lineText(text, line);
if (HELPER_EVIDENCE.test(snippet)) return;
if (/^\s*\/\/|^\s*\*/.test(snippet)) return;
addIssue(
issues,
'error',
relative,
line,
'webapi-method-plain-function',
`WebAPI 方法 ${match[1]} 使用普通函数或对象字面量方法,未体现 addon-first/native-like 创建。`,
`将 ${match[1]} 提取为具名函数,并通过 addon.createNativeFunction(false, '${match[1]}', length, callback) 或 addon-first helper 安装到正确 prototype / 对象描述符上。`,
snippet
);
});
const urlStatic = /(?:ctx|window|self|globalThis|globalObject|global)\s*\.\s*URL\s*\.\s*(createObjectURL|revokeObjectURL)\s*=\s*(?:async\s*)?function\b/g;
scanMatches(text, urlStatic, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'url-static-method-plain-function',
`URL.${match[1]} 被直接赋值为普通 function。`,
`使用 addon.createNativeFunction(false, '${match[1]}', ...) 创建,并用 Object.defineProperty 安装 descriptor。`,
lineText(text, line)
);
});
const prototypeLiteral = /\b(?:[A-Z][A-Za-z0-9_$]*\s*\.\s*)?prototype\s*=\s*\{/g;
scanMatches(text, prototypeLiteral, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'prototype-object-literal',
'发现 prototype 直接替换为对象字面量,容易丢失 constructor、属性描述符、Symbol.toStringTag 和 native-like 方法。',
'使用 addon.createProtoChains 建立原型链,或至少 Object.defineProperties 安装 prototype.constructor、Symbol.toStringTag 和每个 addon-first 方法。',
lineText(text, line)
);
});
const hostNames = escapedJoin(HOST_REUSE);
const hostReuse = new RegExp(`(?:ctx|window|self|globalThis|globalObject|global)\\s*(?:\\.\\s*(${hostNames})|\\[\\s*['"](${hostNames})['"]\\s*\\])\\s*=\\s*(?:globalThis|global|window)\\s*\\.\\s*(?:\\1|\\2)`, 'g');
scanMatches(text, hostReuse, match => {
const name = match[1] || match[2];
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'host-webapi-direct-reuse',
`${name} 直接复用宿主同名 Web API,可能泄露 Node.js / undici / 宿主实现特征。`,
`不要盲目透传宿主 ${name};按浏览器样本、RuyiTrace 证据和 addon-first helper 创建可控实现。`,
lineText(text, line)
);
});
const atobBtoa = /\b(?:atob|btoa)\s*=\s*(?:async\s*)?function[\s\S]{0,240}\bBuffer\s*\.\s*from\b/g;
scanMatches(text, atobBtoa, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'buffer-backed-atob-btoa',
'atob / btoa 使用 Buffer.from 实现,可能暴露 Node.js 语义与错误信息差异。',
'为 atob / btoa 使用浏览器样本校验过的实现,并通过 addon.createNativeFunction 或 addon-first helper 做 native-like 包装;不得在目标上下文暴露 Buffer。',
lineText(text, line)
);
});
const objectAssignWebApi = /Object\s*\.\s*assign\s*\([^)]*\{[\s\S]{0,1200}\b(?:history|indexedDB|CSS|screen)\s*:\s*\{[\s\S]{0,500}\b[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/g;
scanMatches(text, objectAssignWebApi, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'object-assign-webapi-methods',
'Object.assign 中发现 WebAPI 对象字面量和普通方法,绕过 descriptor、原型链与 addon-first。',
'拆分为对应模块:先 createProtoChains 创建构造函数和实例,再用 Object.defineProperty 安装 addon native-like 方法。',
lineText(text, line)
);
});
const genericIllegalConstructor = /throw\s+new\s+TypeError\s*\(\s*['"]Illegal constructor['"]\s*\)/g;
scanMatches(text, genericIllegalConstructor, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'constructor-error-generic-message',
'构造函数报错信息使用了泛化的 Illegal constructor,未证明与目标浏览器的错误类型和错误信息一致。',
'先用已确认取证浏览器采样直接调用和 new 调用的错误:error.name、constructor.name、message、stack 首行;再用 addon.throwTypeError 或 throwBrowserTypeError 复现对应 TypeError / DOMException 等类型和 message。',
lineText(text, line)
);
});
const nonTypeErrorIllegalConstructor = /throw\s+new\s+(?!TypeError\b)([A-Za-z_$][\w$]*)\s*\(\s*['"][^'"]*(?:Illegal constructor|Please use the 'new' operator|constructor cannot be called)/g;
scanMatches(text, nonTypeErrorIllegalConstructor, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'constructor-error-type-mismatch-risk',
`构造函数相关报错使用 ${match[1]},存在与浏览器 TypeError / DOMException 类型不一致的风险。`,
'按浏览器样本确认错误类型;多数 DOM 构造函数错误应为 TypeError,但必须以目标浏览器采样为准。',
lineText(text, line)
);
});
const hardcodedConstructorMessage = /Failed to construct ['"][^'"]+['"]:\s*(?:Illegal constructor|Please use the ['"]new['"] operator)/g;
const hasConstructorErrorProfile = /constructorErrors|constructor-errors\.fixture|构造函数报错样本|browserErrorProfile|errorProfile/.test(text);
scanMatches(text, hardcodedConstructorMessage, match => {
if (hasConstructorErrorProfile) return;
const line = lineOf(text, match.index);
addIssue(
warnings,
'warning',
relative,
line,
'constructor-error-profile-missing',
'源码硬编码了浏览器构造函数错误信息,但未发现构造函数报错样本或 error profile 证据。',
'建议保存 constructor-errors fixture 或在阶段报告中记录目标浏览器采样结果,避免把看似合理的报错信息误当成浏览器一致。',
lineText(text, line)
);
});
const emptyNativeConstructor = /createNativeConstructor\s*\(\s*['"]([A-Z][A-Za-z0-9_$]*)['"]\s*,\s*\d+\s*,\s*function(?:\s+[A-Za-z_$][\w$]*)?\s*\([^)]*\)\s*\{\s*\}/g;
scanMatches(text, emptyNativeConstructor, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'constructor-empty-behavior',
`${match[1]} 构造函数 callback 为空,未体现直接调用 / new 调用行为、非法构造报错或实例初始化。`,
'按浏览器采样补齐构造函数行为:可构造对象要初始化实例状态;不可构造对象要复现错误类型和 message。',
lineText(text, line)
);
});
const emptyAddonConstructor = /createNativeFunction\s*\(\s*true\s*,\s*['"]([A-Z][A-Za-z0-9_$]*)['"]\s*,\s*\d+\s*,\s*function(?:\s+[A-Za-z_$][\w$]*)?\s*\([^)]*\)\s*\{\s*\}/g;
scanMatches(text, emptyAddonConstructor, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'constructor-empty-behavior',
`${match[1]} addon 构造函数 callback 为空,无法证明构造行为与浏览器一致。`,
'按浏览器采样实现 new / call 分支、错误类型和 message,或通过实例工厂初始化可构造对象状态。',
lineText(text, line)
);
});
const unknownMarkObjectType = /\b(?:native|nativeApi|protect)?\.?\s*markObjectType\s*\(/g;
scanMatches(text, unknownMarkObjectType, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'unknown-mark-object-type',
'发现 markObjectType 调用;该名称不是本 Skill 批准的 addon-first API,语义不明确。',
'如果对象由 addon.createProtoChains / addon 构造函数实例工厂创建,不要额外标记对象类型;addon 已负责实例 Object.prototype.toString。只有 JS fallback 对象才允许使用 Symbol.toStringTag / markObjectToString,并记录 fallback 原因。',
lineText(text, line)
);
});
const markObjectToStringCall = /\b(?:native|nativeApi|protect)\s*\.\s*markObjectToString\s*\(/g;
scanMatches(text, markObjectToStringCall, match => {
const nearby = text.slice(Math.max(0, match.index - 240), Math.min(text.length, match.index + 240));
if (/fallback|降级|addon 不可用|addon不可用|JS fallback|NativeProtect/.test(nearby)) return;
const line = lineOf(text, match.index);
addIssue(
warnings,
'warning',
relative,
line,
'mark-object-to-string-without-fallback-reason',
'发现 markObjectToString 调用但附近没有 fallback 原因;它只能作为 addon 不可用时的 JS fallback。',
'优先用 addon 构造函数或 createProtoChains 实例工厂创建对象。addon 已创建的实例不应再用 markObjectToString 叠加伪装;只有普通 JS fallback 对象才需要该函数。',
lineText(text, line)
);
});
const hasPlainWebApiPattern = issues.some(issue => issue.file === relative);
const usesAnyNativeHelper = /createNativeFunction|createGetter|createSetter|createProtoChains|createNativeCollection|getMimeTypesAndPlugins|createInterceptor|createUndetectable|setPrivate|getPrivate|hasPrivate|deletePrivate|loadNativeAddon|WEB_JS_ENV_PATCHER_ADDON|WEB_JS_ENV_PATCHER_XBS_ISOLATED_VM|addon\.node|isolated_vm\.node|xbs-isolated-vm|xbs\.|window\.xbs|globalThis\.xbs/.test(text);
if (hasPlainWebApiPattern && !usesAnyNativeHelper) {
addIssue(
warnings,
'warning',
relative,
1,
'file-no-native-helper',
'文件中存在 WebAPI 普通实现问题,且未发现任何 addon-first / native helper 证据。',
'在模块初始化阶段注入 nativeApi / addon,并统一通过 helper 创建函数、访问器、构造函数和实例对象。',
''
);
}
return { issues, warnings };
}
function resolveInputs(args) {
if (args.file) {
const file = path.resolve(args.file);
return { root: path.dirname(file), files: [file] };
}
if (args.dir) {
const root = path.resolve(args.dir);
return { root, files: walk(root).filter(file => isJsFile(file) && !shouldSkip(root, file)) };
}
if (args.caseDir) {
const caseDir = path.resolve(args.caseDir);
const preferred = path.join(caseDir, 'result');
const root = exists(preferred) ? preferred : caseDir;
return { root, files: walk(root).filter(file => isJsFile(file) && !shouldSkip(root, file)) };
}
throw new Error('必须指定 --case-dir、--dir 或 --file。');
}
function renderMarkdown(result) {
const lines = [];
lines.push('# WebAPI addon 覆盖检查报告');
lines.push('');
lines.push(`- 检查结果:${result.passed ? '通过' : '不通过'}`);
lines.push(`- 扫描文件数:${result.scannedFiles.length}`);
lines.push(`- 错误数:${result.issueCount}`);
lines.push(`- 警告数:${result.warningCount}`);
lines.push('');
if (result.issues.length) {
lines.push('## 错误');
lines.push('');
lines.push('| 文件 | 行号 | 类型 | 问题 | 修复建议 |');
lines.push('|---|---:|---|---|---|');
for (const issue of result.issues) {
lines.push(`| ${issue.file} | ${issue.line || ''} | ${issue.type} | ${issue.message} | ${issue.suggestion} |`);
}
lines.push('');
}
if (result.warnings.length) {
lines.push('## 警告');
lines.push('');
lines.push('| 文件 | 行号 | 类型 | 提醒 | 建议 |');
lines.push('|---|---:|---|---|---|');
for (const warning of result.warnings) {
lines.push(`| ${warning.file} | ${warning.line || ''} | ${warning.type} | ${warning.message} | ${warning.suggestion} |`);
}
lines.push('');
}
if (!result.issues.length && !result.warnings.length) {
lines.push('未发现 WebAPI 普通函数、普通对象、宿主透传或 prototype 对象字面量等 addon-first 覆盖问题。');
lines.push('');
}
lines.push('## 交付门禁说明');
lines.push('');
lines.push('- 该检查失败时不得交付最终补环境项目。');
lines.push('- 修复方向不是“删除检查”,而是把 WebAPI 构造函数、普通方法、getter、setter、实例对象、集合对象、plugins/mimeTypes 和特殊对象统一迁移到 addon-first helper。');
lines.push('- addon.node / xbs native API 不可用时才允许 NativeProtect / JS fallback,并必须在阶段报告与最终总结中记录原因。');
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
const { root, files } = resolveInputs(args);
const jsFiles = files.filter(file => exists(file) && isJsFile(file));
const issues = [];
const warnings = [];
for (const file of jsFiles) {
const inspected = inspectFile(root, file);
issues.push(...inspected.issues);
warnings.push(...inspected.warnings);
}
const warningCount = warnings.length;
const issueCount = issues.length + (args.warningsAsErrors ? warningCount : 0);
const result = {
passed: issueCount === 0,
root,
scannedFiles: jsFiles.map(file => rel(root, file)),
issueCount,
warningCount,
warningsAsErrors: args.warningsAsErrors,
issues,
warnings,
};
if (args.json) console.log(JSON.stringify(result, null, 2));
else console.log(renderMarkdown(result));
if (!result.passed) process.exitCode = 1;
}
try {
main();
} catch (err) {
console.error(err && err.message ? err.message : String(err));
process.exitCode = 2;
}

View File

@@ -0,0 +1,656 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const CATEGORIES = [
{
id: 'iframe-realm',
label: 'iframe / Window realm',
patterns: [/iframe/i, /contentWindow/i, /contentDocument/i, /defaultView/i, /frameElement/i, /srcdoc/i, /document\.write/i, /document\.close/i],
},
{
id: 'worker-task',
label: 'Worker / Message task queue',
patterns: [/\bWorker\b/i, /\bSharedWorker\b/i, /WorkerGlobalScope/i, /DedicatedWorkerGlobalScope/i, /SharedWorkerGlobalScope/i, /importScripts/i, /terminate/i],
},
{
id: 'message-lifecycle',
label: 'MessagePort / postMessage lifecycle',
patterns: [/postMessage/i, /MessageChannel/i, /MessagePort/i, /entangled/i, /structuredClone/i, /\.close\s*\(/i],
},
{
id: 'performance-timeline',
label: 'Performance timeline',
patterns: [/PerformanceObserver/i, /PerformanceResourceTiming/i, /PerformanceEntry/i, /getEntries/i, /PerformancePaintTiming/i, /performance\.mark/i],
},
{
id: 'dom-cssom',
label: 'DOM / CSSOM',
patterns: [/DOMParser/i, /innerHTML/i, /Comment/i, /ShadowRoot/i, /CSSStyleSheet/i, /CSSRuleList/i, /getComputedStyle/i, /document\.links/i, /document\.images/i, /document\.forms/i],
},
{
id: 'dom-crud',
label: 'DOM CRUD state machine',
patterns: [
/appendChild/i, /removeChild/i, /insertBefore/i, /replaceChild/i, /replaceChildren/i,
/cloneNode/i, /importNode/i, /adoptNode/i, /isConnected/i, /ownerDocument/i,
/childNodes/i, /\bchildren\b/i, /querySelector/i, /getElementById/i,
/getElementsBy/i, /\.matches\s*\(/i, /\.closest\s*\(/i, /MutationObserver/i,
],
},
{
id: 'event-clone-error',
label: 'Event / clone / native shape',
patterns: [/EventTarget/i, /structuredClone/i, /DataCloneError/i, /ownKeys/i, /ownNames/i, /getOwnPropertyNames/i, /Reflect\.ownKeys/i, /toString/i],
},
{
id: 'xhr-fetch-session-bridge',
label: 'XHR / fetch session bridge',
patterns: [/\bXMLHttpRequest\b/i, /\bfetch\s*\(/i, /\bRequest\b/i, /\bResponse\b/i, /\bHeaders\b/i, /\bsendBeacon\b/i, /live-session-bridge/i, /offline-fixture/i, /curl_cffi/i, /curl-cffi/i],
},
{
id: 'object-shape',
label: 'Object shape audit',
patterns: [/Object\.keys/i, /Object\.getOwnPropertyNames/i, /Object\.getOwnPropertyDescriptor/i, /Object\.getOwnPropertySymbols/i, /Reflect\.ownKeys/i, /hasOwnProperty/i, /propertyIsEnumerable/i, /prototype walk/i, /brand check/i],
},
{
id: 'private-state-leakage',
label: 'Private state leakage',
patterns: [/__readyState/i, /__headers/i, /__children/i, /__parentNode/i, /__responseHeaders/i, /private state/i, /私有状态/i, /Object\.defineProperty\s*\([^)]*['"]_{1,2}[A-Za-z]/i, /\bthis\s*\.\s*_{1,2}[A-Za-z]/i],
},
{
id: 'clock-timer',
label: 'Clock / timer',
patterns: [/Date\.now/i, /performance\.now/i, /setTimeout/i, /timer/i, /queueMicrotask/i],
},
{
id: 'writer-branch',
label: 'Writer branch',
patterns: [/reload writer/i, /form writer/i, /final writer/i, /HTMLFormElement\.submit/i, /Location\.reload/i, /generatedForm/i, /cf-chl-gen/i, /cf-chl-out/i],
},
];
const REQUIRED_CAPABILITIES = {
'iframe-realm': [
'realm-global-relations',
'realm-ecma-constructor-isolation',
'realm-webapi-constructor-isolation',
'realm-object-isolation',
'realm-document-relations',
'realm-navigation-lifecycle',
],
'worker-task': [
'worker-global-surface',
'worker-constructor-isolation',
'worker-object-isolation',
'worker-message-order',
'worker-terminate-immediate',
'worker-terminate-deferred',
],
'message-lifecycle': [
'message-order',
'message-port-start',
'message-port-close-sender',
'message-port-close-receiver',
'message-port-transfer',
],
'dom-crud': [
'dom-tree-mutation',
'dom-live-collections',
'dom-selector-validity',
'dom-html-parsing',
'dom-document-relations',
'dom-mutation-observer',
],
};
const OBSERVATION_KEYS = ['observation', 'observed', 'result', 'value', 'output', 'expected'];
const BLOCKING_STATUSES = new Set([
'needs-browser-baseline',
'needs-node-audit',
'mismatch',
'native-capability-gap',
'unknown',
]);
const SELF_OUTPUT_FILES = new Set([
'webapi-env-detection-matrix.md',
'node-env-detection-audit.json',
]);
function parseArgs(argv) {
const args = {
caseDir: '',
matrix: '',
browserBaseline: '',
nodeAudit: '',
require: false,
requireWriterBranch: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = 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 === '--require-writer-branch') args.requireWriterBranch = 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_webapi_env_detection_matrix.js --case-dir case --markdown
node scripts/check_webapi_env_detection_matrix.js --case-dir case --require --require-writer-branch --json
node scripts/check_webapi_env_detection_matrix.js --matrix case/notes/webapi-env-detection-matrix.md --browser-baseline case/fixtures/browser-env-detection-baseline.json --node-audit case/tmp/node-env-detection-audit.json --markdown
说明:检查 WebAPI 环境检测矩阵、真实浏览器 baseline、Node audit,以及 iframe / Worker / Performance / DOM-CSSOM / XHR/fetch session bridge / 对象形状 / 私有状态泄露 / writer 分支等行为 diff 是否闭环。`;
}
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 sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function rel(root, file) {
return (path.relative(root, file) || '.').replace(/\\/g, '/');
}
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 listCandidateFiles(caseDir) {
const dirs = ['阶段报告', 'notes', 'tmp', 'ruyi-trace', 'result'];
const files = [];
for (const dir of dirs) {
const full = path.join(caseDir, dir);
for (const file of walk(full)) {
const relative = rel(caseDir, file);
if (/(^|\/)(node_modules|dist|build|coverage)(\/|$)/i.test(relative)) continue;
if (/\.(md|json|jsonl|ndjson|txt|js|mjs|cjs|ts|py|html)$/i.test(file)) files.push(file);
}
}
return files.filter(file => !SELF_OUTPUT_FILES.has(path.basename(file).toLowerCase()));
}
function scanTriggers(caseDir) {
const hits = new Map(CATEGORIES.map(category => [category.id, { ...category, count: 0, files: [] }]));
for (const file of listCandidateFiles(caseDir)) {
let text = '';
try {
text = readText(file).slice(0, 200000);
} catch {
continue;
}
for (const category of CATEGORIES) {
let matched = 0;
for (const pattern of category.patterns) {
const result = text.match(pattern);
if (result) matched += result.length || 1;
}
if (matched) {
const entry = hits.get(category.id);
entry.count += matched;
if (entry.files.length < 8) entry.files.push(rel(caseDir, file));
}
}
}
return [...hits.values()].filter(item => item.count > 0);
}
function stripStatusDocumentation(text) {
const lines = text.split(/\r?\n/);
const kept = [];
let inFence = false;
let skippingStatusEnum = false;
for (const line of lines) {
const trimmed = line.trim();
if (/^```/.test(trimmed)) {
inFence = !inFence;
if (!skippingStatusEnum) kept.push(line);
continue;
}
if (!inFence && /^#{1,6}\s*状态枚举\b/.test(trimmed)) {
skippingStatusEnum = true;
continue;
}
if (skippingStatusEnum && !inFence && /^#{1,6}\s+/.test(trimmed)) {
skippingStatusEnum = false;
}
if (!skippingStatusEnum) kept.push(line);
}
return kept.join('\n');
}
function extractStatuses(text) {
const auditText = stripStatusDocumentation(text);
const statuses = [];
const allowed = [
'matched',
'accepted-diff',
'not-involved',
'needs-browser-baseline',
'needs-node-audit',
'mismatch',
'native-capability-gap',
'unknown',
];
for (const status of allowed) {
const re = new RegExp(`\\b${status}\\b`, 'g');
const matches = auditText.match(re);
if (matches) statuses.push({ status, count: matches.length });
}
return statuses;
}
function normalizeCategoriesFromJson(raw) {
if (!raw || typeof raw !== 'object') return [];
if (Array.isArray(raw.categories)) return raw.categories.map(String);
if (raw.categories && typeof raw.categories === 'object') return Object.keys(raw.categories);
if (raw.checks && typeof raw.checks === 'object') return Object.keys(raw.checks);
if (Array.isArray(raw.items)) return raw.items.map(item => item.category || item.type).filter(Boolean);
return [];
}
function normalizeCategoryRecords(raw) {
if (!raw || typeof raw !== 'object') return {};
if (raw.categories && typeof raw.categories === 'object' && !Array.isArray(raw.categories)) return raw.categories;
if (raw.checks && typeof raw.checks === 'object' && !Array.isArray(raw.checks)) return raw.checks;
if (Array.isArray(raw.items)) {
const out = {};
for (const item of raw.items) {
const category = item.category || item.type;
if (!category) continue;
if (!out[category]) out[category] = [];
out[category].push(item);
}
return out;
}
return {};
}
function stable(value, ignoreRecordMetadata = false) {
if (Array.isArray(value)) return value.map(item => stable(item, ignoreRecordMetadata));
if (!value || typeof value !== 'object') return value;
const ignored = new Set(['status', 'evidence', 'file', 'notes', 'note', 'handling', 'updatedAt', 'capturedAt']);
return Object.fromEntries(
Object.keys(value)
.filter(key => !ignoreRecordMetadata || !ignored.has(key))
.sort()
.map(key => [key, stable(value[key], ignoreRecordMetadata)]),
);
}
function recordId(item, index) {
return String(item && (item.id || item.name || item.probe || item.path || item.api) || index);
}
function recordPayload(item) {
if (!item || typeof item !== 'object') return item;
for (const key of ['observation', 'observed', 'result', 'value', 'output', 'expected']) {
if (typeof item[key] !== 'undefined') return stable(item[key]);
}
return stable(item, true);
}
function assertJsonCategoryCoverage(source, label, expectedCategories, problems, warnings) {
if (!source.parseOk || expectedCategories.length === 0) return;
if (!source.categories.length) {
problems.push(`${label} 未记录 categories / checks / items 类别,无法确认已覆盖触发的 WebAPI 行为类别`);
return;
}
const missing = expectedCategories.filter(id => !source.categories.includes(id));
if (missing.length) problems.push(`${label} 缺少触发类别:${missing.join('、')}`);
}
function checkJsonFile(caseDir, file, label, problems, warnings) {
if (!exists(file)) {
problems.push(`缺少 ${label}:${rel(caseDir, file)}`);
return {
exists: false, parseOk: false, baselineId: '', categories: [], categoryRecords: {},
runtimeSourceHash: '', generatedBy: '', capturedBy: '', probeVersion: '',
probeSuiteVersion: '', probeSourceHash: '', probeSourceFile: '',
};
}
try {
const raw = readJson(file);
return {
exists: true,
parseOk: true,
baselineId: raw.baselineId || '',
categories: normalizeCategoriesFromJson(raw),
categoryRecords: normalizeCategoryRecords(raw),
schemaVersion: raw.schemaVersion || '',
runtimeSourceHash: raw.runtimeSourceHash || '',
generatedBy: raw.generatedBy || '',
capturedBy: raw.capturedBy || '',
probeVersion: raw.probeVersion || '',
probeSuiteVersion: raw.probeSuiteVersion || '',
probeSourceHash: raw.probeSourceHash || '',
probeSourceFile: raw.probeSourceFile || '',
};
} catch (err) {
problems.push(`${label} 无法解析为 JSON:${rel(caseDir, file)}:${err.message}`);
return {
exists: true, parseOk: false, baselineId: '', categories: [], categoryRecords: {},
runtimeSourceHash: '', generatedBy: '', capturedBy: '', probeVersion: '',
probeSuiteVersion: '', probeSourceHash: '', probeSourceFile: '',
};
}
}
function actualObservation(item) {
if (!item || typeof item !== 'object') return { present: false, value: undefined };
for (const key of OBSERVATION_KEYS) {
if (typeof item[key] !== 'undefined') return { present: true, value: stable(item[key]), key };
}
return { present: false, value: undefined, key: '' };
}
function recordCapabilities(item) {
if (!item || typeof item !== 'object') return [];
const values = [];
if (Array.isArray(item.capabilities)) values.push(...item.capabilities);
for (const key of ['capability', 'probeKind', 'behavior']) {
if (item[key]) values.push(item[key]);
}
return [...new Set(values.map(String).filter(Boolean))];
}
function validateObservationRecords(source, label, expectedCategories, problems) {
if (!source.parseOk) return;
for (const category of expectedCategories) {
const records = Array.isArray(source.categoryRecords[category]) ? source.categoryRecords[category] : [];
for (const [index, item] of records.entries()) {
const id = recordId(item, index);
if (!actualObservation(item).present) {
problems.push(`${label} 的 ${category}/${id} 只有 probe/status/evidence,没有实际 observation;证据文本不能作为行为一致性证明`);
}
}
}
}
function validateRequiredCapabilities(source, label, expectedCategories, problems) {
if (!source.parseOk) return;
for (const category of expectedCategories) {
const required = REQUIRED_CAPABILITIES[category] || [];
if (!required.length) continue;
const records = Array.isArray(source.categoryRecords[category]) ? source.categoryRecords[category] : [];
const covered = new Set(records.flatMap(recordCapabilities));
const missing = required.filter(capability => !covered.has(capability));
if (missing.length) {
problems.push(`${label} 的 ${category} 缺少强制行为能力:${missing.join('、')}`);
}
}
}
function validateProbeSourceFile(caseDir, source, label, problems) {
if (!source.probeSourceFile) {
problems.push(`${label} 缺少 probeSourceFile`);
return;
}
const file = path.resolve(caseDir, source.probeSourceFile);
const relative = path.relative(caseDir, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
problems.push(`${label} probeSourceFile 必须位于 case 内:${source.probeSourceFile}`);
return;
}
if (!exists(file)) {
problems.push(`${label} probeSourceFile 不存在:${source.probeSourceFile}`);
return;
}
const actual = sha256(fs.readFileSync(file));
if (actual !== String(source.probeSourceHash || '').toLowerCase()) {
problems.push(`${label} probeSourceHash 与实际源文件不一致:${source.probeSourceHash || '未记录'} != ${actual}`);
}
}
function validateProbeProvenance(caseDir, browser, nodeAudit, problems) {
if (!browser.parseOk || !nodeAudit.parseOk) return;
if (!browser.probeSuiteVersion) problems.push('浏览器环境 baseline 缺少 probeSuiteVersion');
if (!nodeAudit.probeSuiteVersion) problems.push('Node 环境 audit 缺少 probeSuiteVersion');
if (browser.probeSuiteVersion && nodeAudit.probeSuiteVersion
&& browser.probeSuiteVersion !== nodeAudit.probeSuiteVersion) {
problems.push(`browser/node probeSuiteVersion 不一致:${browser.probeSuiteVersion} != ${nodeAudit.probeSuiteVersion}`);
}
if (!browser.probeSourceHash) problems.push('浏览器环境 baseline 缺少 probeSourceHash,无法证明 browser/node 使用同一 probe 源码');
if (!nodeAudit.probeSourceHash) problems.push('Node 环境 audit 缺少 probeSourceHash,无法证明 browser/node 使用同一 probe 源码');
if (browser.probeSourceHash && nodeAudit.probeSourceHash
&& browser.probeSourceHash !== nodeAudit.probeSourceHash) {
problems.push(`browser/node probeSourceHash 不一致:${browser.probeSourceHash} != ${nodeAudit.probeSourceHash}`);
}
validateProbeSourceFile(caseDir, browser, '浏览器环境 baseline', problems);
validateProbeSourceFile(caseDir, nodeAudit, 'Node 环境 audit', problems);
if (!browser.generatedBy && !browser.capturedBy) {
problems.push('浏览器环境 baseline 缺少 generatedBy/capturedBy');
}
}
function compareCategoryRecords(browser, nodeAudit, expectedCategories, problems) {
for (const category of expectedCategories) {
const expected = Array.isArray(browser.categoryRecords[category]) ? browser.categoryRecords[category] : [];
const observed = Array.isArray(nodeAudit.categoryRecords[category]) ? nodeAudit.categoryRecords[category] : [];
if (!expected.length) {
problems.push(`浏览器环境 baseline 的 ${category} 类别没有 probe 观测值,空数组不能作为已覆盖`);
continue;
}
if (!observed.length) {
problems.push(`Node 环境 audit 的 ${category} 类别没有 probe 观测值`);
continue;
}
const observedById = new Map(observed.map((item, index) => [recordId(item, index), item]));
for (const [index, expectedItem] of expected.entries()) {
const id = recordId(expectedItem, index);
const observedItem = observedById.get(id);
if (!observedItem) {
problems.push(`Node 环境 audit 缺少 ${category}/${id}`);
continue;
}
const expectedObservation = actualObservation(expectedItem);
const observedObservation = actualObservation(observedItem);
if (!expectedObservation.present || !observedObservation.present) continue;
if (JSON.stringify(expectedObservation.value) !== JSON.stringify(observedObservation.value)) {
problems.push(`WebAPI 行为不一致:${category}/${id}`);
}
}
}
}
function check(args) {
const caseDir = args.caseDir ? path.resolve(args.caseDir) : process.cwd();
const matrixPath = path.resolve(args.matrix || path.join(caseDir, 'notes', 'webapi-env-detection-matrix.md'));
const browserPath = path.resolve(args.browserBaseline || path.join(caseDir, 'fixtures', 'browser-env-detection-baseline.json'));
const nodeAuditPath = path.resolve(args.nodeAudit || path.join(caseDir, 'tmp', 'node-env-detection-audit.json'));
const triggered = scanTriggers(caseDir);
const problems = [];
const warnings = [];
const requireMatrix = args.require || triggered.length > 0;
if (requireMatrix && !exists(matrixPath)) {
problems.push(`检测到 WebAPI 环境检测信号,但缺少矩阵:${rel(caseDir, matrixPath)}`);
}
const browser = requireMatrix
? checkJsonFile(caseDir, browserPath, '浏览器环境 baseline', problems, warnings)
: {
exists: exists(browserPath), parseOk: false, baselineId: '', categories: [], categoryRecords: {},
runtimeSourceHash: '', generatedBy: '', capturedBy: '', probeVersion: '',
probeSuiteVersion: '', probeSourceHash: '', probeSourceFile: '',
};
const nodeAudit = requireMatrix
? checkJsonFile(caseDir, nodeAuditPath, 'Node 环境 audit', problems, warnings)
: {
exists: exists(nodeAuditPath), parseOk: false, baselineId: '', categories: [], categoryRecords: {},
runtimeSourceHash: '', generatedBy: '', capturedBy: '', probeVersion: '',
probeSuiteVersion: '', probeSourceHash: '', probeSourceFile: '',
};
const expectedCategories = triggered.length
? triggered.map(item => item.id)
: [...new Set([...browser.categories, ...nodeAudit.categories])];
if (args.require && !expectedCategories.length) {
problems.push('要求 WebAPI 行为审计,但未发现触发类别,且 baseline/audit 没有 categories;不得用空矩阵通过');
}
let matrix = {
exists: exists(matrixPath),
missingCategories: [],
missingKeywords: [],
statuses: [],
blockingStatuses: [],
};
if (matrix.exists) {
const text = readText(matrixPath);
const requiredKeywords = ['baselineId', '浏览器', 'Node', '状态', '证据'];
matrix.missingKeywords = requiredKeywords.filter(key => !text.includes(key));
for (const key of matrix.missingKeywords) problems.push(`矩阵缺少关键字段:${key}`);
matrix.missingCategories = expectedCategories.filter(id => !text.includes(id));
for (const id of matrix.missingCategories) problems.push(`矩阵缺少触发类别:${id}`);
matrix.statuses = extractStatuses(text);
matrix.blockingStatuses = matrix.statuses.filter(item => BLOCKING_STATUSES.has(item.status));
for (const item of matrix.blockingStatuses) {
problems.push(`矩阵存在阻断状态 ${item.status},数量 ${item.count}`);
}
if (args.requireWriterBranch && !text.includes('writer-branch')) {
problems.push('要求 writer 分支审计,但矩阵缺少 writer-branch');
}
if (args.requireWriterBranch && !/reload writer|form writer|final writer|continuation/i.test(text)) {
problems.push('要求 writer 分支审计,但矩阵缺少 writer 类型证据');
}
}
assertJsonCategoryCoverage(browser, '浏览器环境 baseline', expectedCategories, problems, warnings);
assertJsonCategoryCoverage(nodeAudit, 'Node 环境 audit', expectedCategories, problems, warnings);
if (requireMatrix && browser.parseOk && nodeAudit.parseOk) {
validateProbeProvenance(caseDir, browser, nodeAudit, problems);
validateObservationRecords(browser, '浏览器环境 baseline', expectedCategories, problems);
validateObservationRecords(nodeAudit, 'Node 环境 audit', expectedCategories, problems);
validateRequiredCapabilities(browser, '浏览器环境 baseline', expectedCategories, problems);
validateRequiredCapabilities(nodeAudit, 'Node 环境 audit', expectedCategories, problems);
compareCategoryRecords(browser, nodeAudit, expectedCategories, 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 (browser.baselineId && nodeAudit.baselineId && browser.baselineId !== nodeAudit.baselineId) {
problems.push(`浏览器 baselineId 与 Node audit baselineId 不一致:${browser.baselineId} != ${nodeAudit.baselineId}`);
}
return {
caseDir,
clean: problems.length === 0,
requireMatrix,
matrixPath,
browserPath,
nodeAuditPath,
triggered,
requiredCapabilities: Object.fromEntries(
expectedCategories
.filter(category => REQUIRED_CAPABILITIES[category])
.map(category => [category, REQUIRED_CAPABILITIES[category]]),
),
matrix,
browser,
nodeAudit,
problems,
warnings,
};
}
function renderMarkdown(result) {
const lines = [
'# WebAPI 环境检测矩阵检查结果',
'',
`case 目录:${result.caseDir}`,
`是否需要矩阵:${result.requireMatrix ? '是' : '否'}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 文件',
`- 矩阵:${result.matrixPath}`,
`- 浏览器 baseline:${result.browserPath}`,
`- Node audit:${result.nodeAuditPath}`,
'',
'## 触发类别',
];
if (result.triggered.length) {
for (const item of result.triggered) {
lines.push(`- ${item.id}:${item.count};证据 ${item.files.join('、') || '未记录'}`);
}
} else {
lines.push('- 未在阶段报告 / notes / tmp / ruyi-trace 中发现明显触发信号。');
}
lines.push('', '## Baseline 摘要');
lines.push(`- 浏览器 baseline:存在=${result.browser.exists ? '是' : '否'},JSON=${result.browser.parseOk ? '是' : '否'},baselineId=${result.browser.baselineId || '未记录'}`);
lines.push(`- Node audit:存在=${result.nodeAudit.exists ? '是' : '否'},JSON=${result.nodeAudit.parseOk ? '是' : '否'},baselineId=${result.nodeAudit.baselineId || '未记录'}`);
if (result.matrix.exists) {
lines.push('', '## 矩阵摘要');
lines.push(`- 缺少类别:${result.matrix.missingCategories.join('、') || '无'}`);
lines.push(`- 缺少关键字段:${result.matrix.missingKeywords.join('、') || '无'}`);
lines.push(`- 状态:${result.matrix.statuses.map(item => `${item.status}=${item.count}`).join('、') || '未记录'}`);
}
if (result.problems.length) {
lines.push('', '## 问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
} else {
lines.push('', '## 结论', '- WebAPI 环境检测矩阵检查通过。');
}
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);
}

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env node
'use strict';
const path = require('path');
const XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION = '26.3.1';
const {
EXPECTED_XBS_APIS,
createIsolatedVmRuntime,
getBundledXbsIsolatedVmPath,
isAbiMismatch,
resolveXbsIsolatedVmPath,
} = require('../assets/runtime-frameworks/isolated-vm-runtime');
function parseArgs(argv) {
const args = { binary: '', json: false, markdown: false, strict: false, timeoutMs: 5000 };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--binary' || a === '--xbs-isolated-vm' || a === '--path') args.binary = argv[++i] || '';
else if (a === '--timeout-ms') args.timeoutMs = Number(argv[++i] || 5000);
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--strict') args.strict = 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_xbs_isolated_vm.js --markdown',
' node scripts/check_xbs_isolated_vm.js --json',
' node scripts/check_xbs_isolated_vm.js --binary <isolated_vm.node> --strict --json',
'',
'说明:检测随包魔改 xbs isolated-vm 是否能在当前 Node / 平台 / ABI 下加载,并在 Context 内自检 window.xbs 的 17 个核心 API、xbs.dom.createDocument 和基础 DOM smoke test。默认遇到平台缺失或 ABI 不匹配时输出中文解释但不崩溃;真实 case 已选择 isolated-vm 时可加 --strict 作为阻断门禁。',
].join('\n');
}
function baseResult(args) {
const binaryPath = path.resolve(args.binary || resolveXbsIsolatedVmPath({}));
return {
tool: 'check_xbs_isolated_vm',
nodeVersion: process.version,
nodeAbi: process.versions && process.versions.modules ? process.versions.modules : '',
platform: process.platform,
arch: process.arch,
platformKey: process.platform + '-' + process.arch,
defaultBinary: getBundledXbsIsolatedVmPath(),
binaryPath,
expectedApis: EXPECTED_XBS_APIS.slice(),
compatibleNodeVersion: XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION,
requiresUserConfirmation: false,
loaded: false,
selfCheckPassed: false,
abiMismatch: false,
explainableFailure: false,
status: 'not-run',
error: '',
xbs: null,
dom: null,
};
}
async function runCheck(args) {
const result = baseResult(args);
let runtime = null;
try {
runtime = createIsolatedVmRuntime({ xbsIsolatedVmPath: args.binary || '', timeoutMs: args.timeoutMs, requireXbs: true });
result.loaded = true;
result.binaryPath = runtime.binaryPath || result.binaryPath;
const initialized = await runtime.initialize({});
result.xbs = initialized.xbs || runtime.getXbsApiSummary();
result.dom = result.xbs && result.xbs.dom ? result.xbs.dom : null;
result.selfCheckPassed = !!(result.xbs && result.xbs.ok);
result.status = result.selfCheckPassed ? 'ok' : 'xbs-api-missing';
if (!result.selfCheckPassed) result.error = '已加载二进制,但 Context 内 window.xbs 自检未通过。';
} catch (error) {
result.error = String(error && error.message ? error.message : error || '未知错误');
result.abiMismatch = isAbiMismatch(error) || !!(error && error.abiMismatch);
result.explainableFailure = result.abiMismatch || /未找到 isolated_vm\.node|未找到|no such file|cannot find/i.test(result.error);
result.status = result.abiMismatch ? 'abi-mismatch' : (result.explainableFailure ? 'binary-missing' : 'load-failed');
result.requiresUserConfirmation = !!result.abiMismatch;
if (result.abiMismatch) {
result.recovery = {
rule: '不得直接改用 npm 原版 isolated-vm,也不得桥接旧 addon.node;先询问用户是否通过 nvm 安装 / 切换兼容 Node。',
commands: [
`nvm install ${XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION}`,
`nvm use ${XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION}`,
'node -v',
'node -p "process.versions.modules"',
'node --no-node-snapshot scripts/check_xbs_isolated_vm.js --strict --json',
],
};
}
} finally {
if (runtime && typeof runtime.dispose === 'function') await runtime.dispose().catch(() => {});
}
result.passed = !!(result.loaded && result.selfCheckPassed);
return result;
}
function renderMarkdown(result) {
const lines = [];
lines.push('# xbs isolated-vm 自检报告');
lines.push('');
lines.push('- 检查结果:' + (result.passed ? '通过' : '未通过'));
lines.push('- 状态:' + result.status);
lines.push('- 当前 Node:' + result.nodeVersion);
lines.push('- 当前 ABI:' + result.nodeAbi);
lines.push('- xbs isolated-vm 兼容 Node:v' + (result.compatibleNodeVersion || XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION));
lines.push('- 当前平台:' + result.platformKey);
lines.push('- 二进制路径:' + result.binaryPath);
lines.push('- 是否加载成功:' + (result.loaded ? '是' : '否'));
lines.push('- Context 内 xbs API + DOM 自检:' + (result.selfCheckPassed ? '通过' : '未通过'));
if (result.xbs) {
lines.push('- window === globalThis:' + (result.xbs.windowIsGlobal ? '是' : '否'));
lines.push('- window instanceof Window:' + (result.xbs.windowInstanceOfWindow ? '是' : '否'));
lines.push('- 缺失核心 API:' + (result.xbs.missing && result.xbs.missing.length ? result.xbs.missing.join('、') : '无'));
lines.push('- 额外 API:' + (result.xbs.extra && result.xbs.extra.length ? result.xbs.extra.join('、') : '无'));
lines.push('- xbs.dom:' + (result.xbs.hasXbsDom ? '存在' : '不存在'));
lines.push('- xbs.dom.createDocument:' + (result.xbs.hasCreateDocument ? '可用' : '不可用'));
if (result.xbs.dom) {
lines.push('- DOM smoke test:' + (result.xbs.dom.ok ? '通过' : '未通过'));
lines.push('- document.constructor.name:' + (result.xbs.dom.documentCtor || '未知'));
lines.push('- document 默认预挂载到 window:' + (result.xbs.dom.windowDocumentWasPreinstalled ? '是' : '否'));
lines.push('- document.all 类型:' + (result.xbs.dom.allType || '未知'));
lines.push('- document.all == null:' + (result.xbs.dom.allLooseNull ? '是' : '否'));
lines.push('- document.all === undefined:' + (result.xbs.dom.allStrictUndefined ? '是' : '否'));
lines.push('- Boolean(document.all):' + String(result.xbs.dom.allBoolean));
lines.push('- document.all.length 类型:' + (result.xbs.dom.allLengthType || '未知'));
lines.push('- document.all.item 类型:' + (result.xbs.dom.allItemType || '未知'));
lines.push('- document.all.namedItem 类型:' + (result.xbs.dom.allNamedItemType || '未知'));
lines.push('- Object.prototype.toString.call(document.all):' + (result.xbs.dom.allObjectToString || '未知'));
lines.push('- document.all.length 是自有属性:' + (result.xbs.dom.allOwnLength ? '是' : '否'));
lines.push('- document.all.item 是自有属性:' + (result.xbs.dom.allOwnItem ? '是' : '否'));
lines.push('- document.all.namedItem 是自有属性:' + (result.xbs.dom.allOwnNamedItem ? '是' : '否'));
lines.push('- document.all 引用稳定:' + (result.xbs.dom.allSameReference ? '是' : '否'));
lines.push('- omitApis 禁用 document.all:' + (result.xbs.dom.omitAllWorks ? '通过' : '未通过'));
lines.push('- iframe contentDocument:' + (result.xbs.dom.iframeContentDocument ? '可用' : '未确认'));
}
}
if (result.error) {
lines.push('');
lines.push('## 说明');
lines.push('');
lines.push(result.error.replace(/\n/g, '\n\n'));
}
if (!result.passed && result.explainableFailure) {
lines.push('');
lines.push('## 处理建议');
lines.push('');
if (result.abiMismatch) {
lines.push('- ABI 不兼容时不要直接降级。先让用户确认是否通过 nvm 安装 / 切换 Node.js v' + (result.compatibleNodeVersion || XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION) + '。');
lines.push('- 用户拒绝切换 Node 时,再让用户提供当前平台 / 架构 / Node ABI 匹配的魔改 xbs isolated-vm 构建产物,或改选不使用框架 / Node.js 内置 vm / jsEnv。');
} else {
lines.push('- 如果用户已选择 isolated-vm,请让用户提供与当前平台 / 架构 / Node ABI 匹配的魔改 xbs isolated-vm 构建产物,或按 Node 兼容恢复流程切换匹配版本。');
}
lines.push('- 不要自动降级到 npm 原版 isolated-vm,也不要桥接旧 addon.node。');
}
if (result.recovery && result.recovery.commands) {
lines.push('');
lines.push('## ABI 恢复命令');
lines.push('');
lines.push(result.recovery.rule);
lines.push('');
lines.push('```bash');
for (const cmd of result.recovery.commands) lines.push(cmd);
lines.push('```');
}
return lines.join('\n') + '\n';
}
(async () => {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); return; }
const result = await runCheck(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
if (!result.passed) process.exitCode = args.strict || !result.explainableFailure ? 1 : 0;
})().catch(error => {
console.error(error && error.message ? error.message : String(error));
process.exitCode = 2;
});

View File

@@ -0,0 +1,733 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const AUDIT_SCHEMA = 'xhr-fetch-semantics-audit/v3';
const TRANSCRIPT_SCHEMA = 'network-transcript/v3';
const SHA256_RE = /^[a-f0-9]{64}$/i;
const COMMON_FIELDS = ['actor', 'realmId', 'navigationEpoch', 'sessionId'];
const REQUEST_FIELDS = ['method', 'url', 'credentials', 'referrer'];
const RESPONSE_FIELDS = [
'status',
'statusText',
'responseURL',
'responseType',
'bodyLength',
'bodySha256',
];
const OPTIONAL_REQUEST_FIELDS = [
'taskId',
'origin',
'cookiePolicy',
'bodyType',
'redirect',
'cache',
'timeout',
'withCredentials',
'async',
];
function parseArgs(argv) {
const args = {
caseDir: '',
browser: '',
node: '',
out: '',
require: false,
requireNoSend: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--browser') args.browser = argv[++i] || '';
else if (arg === '--node') args.node = argv[++i] || '';
else if (arg === '--out') args.out = argv[++i] || '';
else if (arg === '--require') args.require = true;
else if (arg === '--require-no-send') args.requireNoSend = true;
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_xhr_fetch_semantics.js --case-dir case --require --require-no-send --markdown
node scripts/check_xhr_fetch_semantics.js --browser case/fixtures/browser-network-transcript.ndjson --node case/tmp/node-network-transcript.ndjson --out case/tmp/xhr-fetch-semantics-audit.json --json
说明:先验证 browser/Node transcript 是否包含完整的真实观测字段,再逐事件比较 XHR/fetch/navigation 的请求、响应、Header 顺序、body 摘要、Session 与生命周期。缺失字段不会被默认值掩盖。`;
}
function exists(file) {
try { return fs.existsSync(file); } catch { return false; }
}
function hasOwn(value, key) {
return Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
}
function firstOwn(value, keys) {
for (const key of keys) {
if (hasOwn(value, key)) return { present: true, key, value: value[key] };
}
return { present: false, key: '', value: undefined };
}
function parseTranscript(file) {
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
if (/\.json$/i.test(file)) {
const raw = JSON.parse(text);
return {
meta: raw.meta || raw.metadata || (Array.isArray(raw) ? {} : raw),
events: Array.isArray(raw.events)
? raw.events
: (Array.isArray(raw.items) ? raw.items : (Array.isArray(raw) ? raw : [])),
};
}
const events = [];
let meta = {};
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.type === 'meta' || event.event === 'meta') meta = { ...meta, ...event };
else events.push(event);
}
return { meta, events };
}
function sha256Text(value) {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
function normalizeHeaderValue(value) {
if (typeof value === 'string') {
return {
sha256: sha256Text(value),
length: Buffer.byteLength(value, 'utf8'),
redacted: false,
};
}
if (value && typeof value === 'object') {
return {
sha256: String(value.sha256 || '').toLowerCase(),
length: Number(value.length),
redacted: value.redacted === true,
};
}
return {
sha256: '',
length: Number.NaN,
redacted: false,
};
}
function headerEntryParts(item) {
if (Array.isArray(item)) return { name: item[0], value: item[1] };
if (item && typeof item === 'object') {
return {
name: hasOwn(item, 'name') ? item.name : item.key,
value: item.value,
};
}
return { name: undefined, value: undefined };
}
function validateAndNormalizeHeaders(value, label, problems, options = {}) {
if (!Array.isArray(value)) {
problems.push(`${label} 必须是保留顺序与重复字段的 Header 数组`);
return [];
}
if (options.requireNonEmpty && value.length === 0) {
problems.push(`${label} 不能为空;完整 network transcript 必须记录实际发送的 Header`);
}
const normalized = [];
for (const [index, item] of value.entries()) {
const parts = headerEntryParts(item);
const name = typeof parts.name === 'string' ? parts.name.trim().toLowerCase() : '';
if (!name) problems.push(`${label}[${index}] 缺少 Header 名称`);
const headerValue = normalizeHeaderValue(parts.value);
if (typeof parts.value === 'string') {
if (parts.value.length === 0) {
problems.push(`${label}[${index}] ${name || '<unknown>'} 的值为空;应记录真实值或脱敏摘要`);
}
} else if (parts.value && typeof parts.value === 'object') {
if (parts.value.redacted !== true
|| !SHA256_RE.test(String(parts.value.sha256 || ''))
|| !Number.isInteger(Number(parts.value.length))
|| Number(parts.value.length) < 0) {
problems.push(`${label}[${index}] ${name || '<unknown>'} 的脱敏值必须包含 redacted=true、length 和 SHA-256`);
}
} else {
problems.push(`${label}[${index}] ${name || '<unknown>'} 缺少 Header 值`);
}
normalized.push([name, headerValue]);
}
return normalized;
}
function kindValue(event) {
return String(firstOwn(event, ['kind', 'event', 'type']).value || '').toLowerCase();
}
function eventClass(kind) {
if (/^(request|send|network-request)$/.test(kind) || /-request$/.test(kind)) return 'request';
if (/^(response|complete|loadend|network-response)$/.test(kind) || /-response$/.test(kind)) return 'response';
if (/realm-destroyed|navigation|reload|pagehide|unload|lifecycle/.test(kind)) return 'lifecycle';
return 'unknown';
}
function normalizeActor(actor) {
const value = String(actor || '').toLowerCase();
return value === 'xmlhttprequest' ? 'xhr' : value;
}
function normalizeEvent(event, index, normalizedHeaders = {}) {
const body = event.body && typeof event.body === 'object' ? event.body : {};
const navigationEpoch = firstOwn(event, ['navigationEpoch', 'epoch']);
const sequence = firstOwn(event, ['sequence', 'seq']);
return {
index,
pairKey: String(firstOwn(event, ['pairKey', 'requestKey', 'logicalId']).value || ''),
kind: kindValue(event),
actor: normalizeActor(firstOwn(event, ['actor', 'initiator', 'api']).value),
realmId: String(firstOwn(event, ['realmId', 'realm']).value || ''),
navigationEpoch: Number(navigationEpoch.value),
taskId: String(event.taskId || ''),
method: String(event.method || '').toUpperCase(),
url: String(firstOwn(event, ['url', 'requestURL']).value || ''),
credentials: String(event.credentials || ''),
referrer: String(event.referrer || ''),
origin: String(event.origin || ''),
cookiePolicy: String(event.cookiePolicy || ''),
bodyType: String(event.bodyType || body.type || ''),
contentType: String(event.contentType || body.contentType || ''),
bodyLength: Number(firstOwn(event, ['bodyLength']).present ? event.bodyLength : (body.length ?? body.byteLength)),
bodySha256: String(event.bodySha256 || body.sha256 || '').toLowerCase(),
headers: normalizedHeaders.headers || [],
responseHeaders: normalizedHeaders.responseHeaders || [],
status: Number(event.status),
statusText: String(hasOwn(event, 'statusText') ? event.statusText : ''),
responseURL: String(hasOwn(event, 'responseURL') ? event.responseURL : ''),
responseType: String(hasOwn(event, 'responseType') ? event.responseType : ''),
lifecycle: Array.isArray(event.lifecycle) ? event.lifecycle.map(String) : [],
validRealm: event.validRealm !== false,
cancelledReason: String(event.cancelledReason || event.cancelReason || ''),
sessionId: String(event.sessionId || ''),
sequence: Number(sequence.value),
redirect: event.redirect,
cache: event.cache,
timeout: event.timeout,
withCredentials: event.withCredentials,
async: event.async,
};
}
function requireField(event, aliases, label, problems, options = {}) {
const found = firstOwn(event, aliases);
if (!found.present) {
problems.push(`${label} 缺少 ${aliases[0]}`);
return undefined;
}
if (options.nonEmpty && String(found.value || '') === '') {
problems.push(`${label} 的 ${aliases[0]} 不能为空`);
}
return found.value;
}
function validateSha256(value, label, problems) {
if (!SHA256_RE.test(String(value || ''))) problems.push(`${label} 必须是完整 SHA-256`);
}
function validateLength(value, label, problems) {
const number = Number(value);
if (!Number.isInteger(number) || number < 0) problems.push(`${label} 必须是非负整数`);
}
function requestHasBody(event) {
const method = String(event.method || '').toUpperCase();
const body = event.body && typeof event.body === 'object' ? event.body : {};
return /^(POST|PUT|PATCH)$/.test(method)
|| hasOwn(event, 'bodyLength')
|| hasOwn(event, 'bodySha256')
|| hasOwn(event, 'contentType')
|| Object.keys(body).length > 0;
}
function validateEvent(event, index, sourceLabel, problems) {
const label = `${sourceLabel} event[${index}]`;
const problemStart = problems.length;
if (!event || typeof event !== 'object' || Array.isArray(event)) {
problems.push(`${label} 必须是对象`);
const normalized = normalizeEvent({}, index);
normalized.validationProblems = problems.slice(problemStart);
return normalized;
}
const kind = requireField(event, ['kind', 'event'], label, problems, { nonEmpty: true });
const cls = eventClass(String(kind || '').toLowerCase());
if (cls === 'unknown') problems.push(`${label} 的 kind 无法归类为 request/response/lifecycle`);
requireField(event, ['pairKey', 'requestKey', 'logicalId'], label, problems, { nonEmpty: true });
requireField(event, ['actor', 'initiator', 'api'], label, problems, { nonEmpty: true });
requireField(event, ['realmId', 'realm'], label, problems, { nonEmpty: true });
const epoch = requireField(event, ['navigationEpoch', 'epoch'], label, problems);
if (!Number.isInteger(Number(epoch)) || Number(epoch) < 0) problems.push(`${label} 的 navigationEpoch 必须是非负整数`);
const sequence = requireField(event, ['sequence', 'seq'], label, problems);
if (!Number.isFinite(Number(sequence))) problems.push(`${label} 的 sequence 必须是数字`);
requireField(event, ['sessionId'], label, problems, { nonEmpty: true });
const normalizedHeaders = {};
if (cls === 'request') {
requireField(event, ['method'], label, problems, { nonEmpty: true });
requireField(event, ['url', 'requestURL'], label, problems, { nonEmpty: true });
requireField(event, ['credentials'], label, problems, { nonEmpty: true });
requireField(event, ['referrer'], label, problems);
const headers = requireField(event, ['headers', 'requestHeaders'], label, problems);
normalizedHeaders.headers = validateAndNormalizeHeaders(
headers,
`${label}.headers`,
problems,
{ requireNonEmpty: true },
);
if (requestHasBody(event)) {
const bodyLength = requireField(event, ['bodyLength'], label, problems);
const bodySha256 = requireField(event, ['bodySha256'], label, problems);
const contentType = requireField(event, ['contentType'], label, problems);
validateLength(bodyLength, `${label}.bodyLength`, problems);
validateSha256(bodySha256, `${label}.bodySha256`, problems);
if (Number(bodyLength) > 0 && String(contentType || '') === '') {
problems.push(`${label} 有请求体但 contentType 为空`);
}
}
} else if (cls === 'response') {
const status = requireField(event, ['status'], label, problems);
requireField(event, ['statusText'], label, problems);
const responseURL = requireField(event, ['responseURL'], label, problems);
requireField(event, ['responseType'], label, problems);
const bodyLength = requireField(event, ['bodyLength'], label, problems);
const bodySha256 = requireField(event, ['bodySha256'], label, problems);
const responseHeaders = requireField(event, ['responseHeaders'], label, problems);
const lifecycle = requireField(event, ['lifecycle'], label, problems);
if (!Number.isInteger(Number(status)) || Number(status) < 0 || Number(status) > 599) {
problems.push(`${label}.status 必须是 0 到 599 的整数`);
}
if (Number(status) > 0 && String(responseURL || '') === '') {
problems.push(`${label} status>0 时必须记录最终 responseURL`);
}
validateLength(bodyLength, `${label}.bodyLength`, problems);
validateSha256(bodySha256, `${label}.bodySha256`, problems);
normalizedHeaders.responseHeaders = validateAndNormalizeHeaders(
responseHeaders,
`${label}.responseHeaders`,
problems,
);
if (!Array.isArray(lifecycle) || lifecycle.length === 0) {
problems.push(`${label}.lifecycle 必须记录 readyState/event/Promise 顺序`);
}
} else if (cls === 'lifecycle') {
const lifecycle = requireField(event, ['lifecycle'], label, problems);
if (!Array.isArray(lifecycle) || lifecycle.length === 0) {
problems.push(`${label}.lifecycle 必须是非空数组`);
}
}
const normalized = normalizeEvent(event, index, normalizedHeaders);
normalized.validationProblems = problems.slice(problemStart);
return normalized;
}
function validateMeta(meta, sourceLabel, options, problems) {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
problems.push(`${sourceLabel} transcript 缺少 meta 对象`);
return;
}
if (meta.schemaVersion !== TRANSCRIPT_SCHEMA) {
problems.push(`${sourceLabel} transcript schemaVersion 必须是 ${TRANSCRIPT_SCHEMA}`);
}
if (!String(meta.baselineId || '')) problems.push(`${sourceLabel} transcript 缺少 baselineId`);
const producer = String(meta.generatedBy || meta.capturedBy || '');
if (!producer) {
problems.push(`${sourceLabel} transcript 缺少 generatedBy/capturedBy`);
} else if (options.browser && !/browser|ruyi|camoufox|cloak|firefox|chrome|chromium|webkit|manual/i.test(producer)) {
problems.push(`${sourceLabel} transcript 生成来源不可识别:${producer}`);
} else if (!options.browser && !/node|runtime|audit|probe|recorder/i.test(producer)) {
problems.push(`${sourceLabel} transcript generatedBy 不可信:${producer}`);
}
if (options.browser) {
if (meta.mode !== 'browser-baseline') {
problems.push(`${sourceLabel} transcript mode 必须是 browser-baseline`);
}
} else {
if (!SHA256_RE.test(String(meta.runtimeSourceHash || ''))) {
problems.push(`${sourceLabel} transcript 缺少有效 runtimeSourceHash`);
}
if (options.requireNoSend) {
if (meta.mode !== 'no-send' && meta.networkMode !== 'no-send') {
problems.push(`${sourceLabel} transcript 必须来自 no-send 模式`);
}
if (!hasOwn(meta, 'networkAttempts') || Number(meta.networkAttempts) !== 0) {
problems.push(`${sourceLabel} transcript 必须明确记录 networkAttempts=0`);
}
}
}
}
function validateTranscript(raw, sourceLabel, options, problems) {
validateMeta(raw.meta, sourceLabel, options, problems);
if (!Array.isArray(raw.events) || raw.events.length === 0) {
problems.push(`${sourceLabel} transcript 没有事件`);
return [];
}
const events = raw.events.map((event, index) => validateEvent(event, index, sourceLabel, problems));
let previous = Number.NEGATIVE_INFINITY;
const seen = new Set();
for (const event of events) {
if (seen.has(event.sequence)) problems.push(`${sourceLabel} transcript sequence 重复:${event.sequence}`);
seen.add(event.sequence);
if (!(event.sequence > previous)) {
problems.push(`${sourceLabel} transcript 事件必须按严格递增 sequence 写入`);
}
previous = event.sequence;
}
return events;
}
function compareField(expected, observed, field, diffs) {
if (!Object.is(expected[field], observed[field])) {
diffs.push({ field, expected: expected[field], observed: observed[field] });
}
}
function compareOptionalField(expectedRaw, observedRaw, expected, observed, field, diffs) {
const expectedPresent = hasOwn(expectedRaw, field);
const observedPresent = hasOwn(observedRaw, field);
if (!expectedPresent && !observedPresent) return;
if (expectedPresent !== observedPresent || expected[field] !== observed[field]) {
diffs.push({
field,
expected: expectedPresent ? expected[field] : '<missing>',
observed: observedPresent ? observed[field] : '<missing>',
});
}
}
function compareHeaders(expected, observed, field, diffs) {
if (JSON.stringify(expected[field]) !== JSON.stringify(observed[field])) {
diffs.push({ field, expected: expected[field], observed: observed[field] });
}
}
function compareLifecycle(expected, observed, diffs) {
if (JSON.stringify(expected.lifecycle) !== JSON.stringify(observed.lifecycle)) {
diffs.push({ field: 'lifecycle', expected: expected.lifecycle, observed: observed.lifecycle });
}
}
function eventKey(event) {
return `${eventClass(event.kind)}:${event.pairKey}`;
}
function hardInvariants(events) {
const problems = [];
const destroyedRealms = new Map();
for (const event of events) {
const cls = eventClass(event.kind);
if (/realm-destroyed|navigation-commit|reload-commit/.test(event.kind)) {
destroyedRealms.set(`${event.realmId}:${event.navigationEpoch}`, event.sequence);
if (event.actor === 'xhr') {
problems.push(`${event.pairKey}:document navigation/reload 被错误归类为 XHR`);
}
continue;
}
if (cls === 'response' && event.status === 0 && event.responseURL !== '') {
problems.push(`${event.pairKey}:status=0 时 responseURL 必须为空,当前为 ${event.responseURL}`);
}
const destroyedAt = destroyedRealms.get(`${event.realmId}:${event.navigationEpoch}`);
if (typeof destroyedAt !== 'undefined' && event.sequence > destroyedAt && !event.cancelledReason) {
problems.push(`${event.pairKey}:旧 realm ${event.realmId}/${event.navigationEpoch} 销毁后仍产生未取消的 timer/XHR/task`);
}
if (event.validRealm === false && !event.cancelledReason) {
problems.push(`${event.pairKey}:失效 realm 的事件缺少取消原因`);
}
}
return problems;
}
function compareEvent(expectedRaw, observedRaw, expected, observed) {
const diffs = [];
if (expected.validationProblems.length) {
diffs.push({
field: 'browserTranscriptCompleteness',
expected: 'complete',
observed: `${expected.validationProblems.length} problem(s)`,
});
}
if (observed.validationProblems.length) {
diffs.push({
field: 'nodeTranscriptCompleteness',
expected: 'complete',
observed: `${observed.validationProblems.length} problem(s)`,
});
}
for (const field of COMMON_FIELDS) compareField(expected, observed, field, diffs);
const cls = eventClass(expected.kind);
if (cls !== eventClass(observed.kind)) {
diffs.push({ field: 'kindClass', expected: cls, observed: eventClass(observed.kind) });
return diffs;
}
if (cls === 'request') {
for (const field of REQUEST_FIELDS) compareField(expected, observed, field, diffs);
compareHeaders(expected, observed, 'headers', diffs);
const expectedBody = requestHasBody(expectedRaw);
const observedBody = requestHasBody(observedRaw);
if (expectedBody !== observedBody) {
diffs.push({ field: 'bodyPresence', expected: expectedBody, observed: observedBody });
} else if (expectedBody) {
for (const field of ['contentType', 'bodyLength', 'bodySha256']) {
compareField(expected, observed, field, diffs);
}
}
for (const field of OPTIONAL_REQUEST_FIELDS) {
compareOptionalField(expectedRaw, observedRaw, expected, observed, field, diffs);
}
} else if (cls === 'response') {
for (const field of RESPONSE_FIELDS) compareField(expected, observed, field, diffs);
compareHeaders(expected, observed, 'responseHeaders', diffs);
compareLifecycle(expected, observed, diffs);
} else if (cls === 'lifecycle') {
compareLifecycle(expected, observed, diffs);
for (const field of ['method', 'url']) {
compareOptionalField(expectedRaw, observedRaw, expected, observed, field, diffs);
}
}
return diffs;
}
function emptyResult(caseDir, browserPath, nodePath, problems, warnings = []) {
return {
schemaVersion: AUDIT_SCHEMA,
generatedBy: 'check_xhr_fetch_semantics.js',
clean: false,
caseDir,
browserPath,
nodePath,
problems,
warnings,
results: [],
summary: {},
};
}
function extraEventsByKey(expectedEvents, observedEvents) {
const remaining = new Map();
for (const event of expectedEvents) {
const key = eventKey(event);
remaining.set(key, (remaining.get(key) || 0) + 1);
}
const extra = [];
for (const event of observedEvents) {
const key = eventKey(event);
const count = remaining.get(key) || 0;
if (count > 0) remaining.set(key, count - 1);
else extra.push(event);
}
return extra;
}
function check(args) {
const caseDir = path.resolve(args.caseDir || '.');
const browserPath = path.resolve(args.browser || path.join(caseDir, 'fixtures', 'browser-network-transcript.ndjson'));
const nodePath = path.resolve(args.node || path.join(caseDir, 'tmp', 'node-network-transcript.ndjson'));
const problems = [];
const warnings = [];
if ((args.require || exists(browserPath) || exists(nodePath)) && !exists(browserPath)) {
problems.push(`缺少浏览器 network transcript:${browserPath}`);
}
if ((args.require || exists(browserPath) || exists(nodePath)) && !exists(nodePath)) {
problems.push(`缺少 Node network transcript:${nodePath}`);
}
if (problems.length) return emptyResult(caseDir, browserPath, nodePath, problems, warnings);
if (!exists(browserPath) && !exists(nodePath)) {
return {
...emptyResult(caseDir, browserPath, nodePath, [], ['未触发 network transcript 审计']),
clean: true,
};
}
let browserRaw;
let nodeRaw;
try { browserRaw = parseTranscript(browserPath); } catch (err) { problems.push(`浏览器 transcript 无法解析:${err.message}`); }
try { nodeRaw = parseTranscript(nodePath); } catch (err) { problems.push(`Node transcript 无法解析:${err.message}`); }
if (!browserRaw || !nodeRaw) return emptyResult(caseDir, browserPath, nodePath, problems, warnings);
const browserEvents = validateTranscript(browserRaw, '浏览器', { browser: true }, problems);
const nodeEvents = validateTranscript(
nodeRaw,
'Node',
{ browser: false, requireNoSend: args.requireNoSend },
problems,
);
if (browserRaw.meta.baselineId && nodeRaw.meta.baselineId
&& nodeRaw.meta.baselineId !== browserRaw.meta.baselineId) {
problems.push(`network transcript baselineId 不一致:${browserRaw.meta.baselineId} != ${nodeRaw.meta.baselineId}`);
}
for (const invariant of hardInvariants(browserEvents)) problems.push(`浏览器 transcript:${invariant}`);
for (const invariant of hardInvariants(nodeEvents)) problems.push(`Node transcript:${invariant}`);
const browserTimeline = browserEvents.map(eventKey);
const nodeTimeline = nodeEvents.map(eventKey);
if (JSON.stringify(browserTimeline) !== JSON.stringify(nodeTimeline)) {
problems.push('browser/Node network event timeline 不一致;请求、响应或生命周期顺序发生变化');
}
const results = [];
const count = Math.max(browserEvents.length, nodeEvents.length);
for (let index = 0; index < count; index++) {
const expected = browserEvents[index];
const observed = nodeEvents[index];
if (!expected || !observed) {
const key = expected ? eventKey(expected) : eventKey(observed);
const diffs = [{
field: '*',
expected: expected ? 'browser event' : 'missing',
observed: observed ? 'node event' : 'missing',
}];
results.push({ key, index, clean: false, diffs });
continue;
}
const diffs = compareEvent(
browserRaw.events[index],
nodeRaw.events[index],
expected,
observed,
);
if (diffs.length) {
problems.push(`${eventKey(expected)}:${diffs.map(diff => diff.field).join('、')} 不一致`);
}
results.push({ key: eventKey(expected), index, clean: diffs.length === 0, diffs });
}
const extraNode = extraEventsByKey(browserEvents, nodeEvents);
const missingNode = extraEventsByKey(nodeEvents, browserEvents);
const extraNodeEvents = extraNode.length;
const extraNodeRequests = extraNode.filter(event => eventClass(event.kind) === 'request').length;
if (extraNodeEvents) {
problems.push(`Node transcript 比浏览器多出 ${extraNodeEvents} 个事件,其中 ${extraNodeRequests} 个请求`);
}
if (missingNode.length) {
problems.push(`Node transcript 比浏览器少 ${missingNode.length} 个事件`);
}
const browserSessionIds = [...new Set(browserEvents.map(event => event.sessionId).filter(Boolean))];
const nodeSessionIds = [...new Set(nodeEvents.map(event => event.sessionId).filter(Boolean))];
if (browserSessionIds.length !== 1) {
problems.push(`浏览器 transcript 必须使用一个 sessionId,当前为 ${browserSessionIds.join('、') || '未记录'}`);
}
if (nodeSessionIds.length !== 1) {
problems.push(`Node transcript 必须使用一个 sessionId,当前为 ${nodeSessionIds.join('、') || '未记录'}`);
}
const summary = {
browserEvents: browserEvents.length,
nodeEvents: nodeEvents.length,
matched: results.filter(item => item.clean).length,
mismatched: results.filter(item => !item.clean).length,
networkMismatches: results.filter(item => !item.clean).length,
extraNodeEvents,
extraNodeRequests,
browserSessionIds,
nodeSessionIds,
baselineId: browserRaw.meta.baselineId || nodeRaw.meta.baselineId || '',
runtimeSourceHash: nodeRaw.meta.runtimeSourceHash || '',
networkMode: nodeRaw.meta.mode || nodeRaw.meta.networkMode || '',
networkAttempts: nodeRaw.meta.networkAttempts,
};
return {
schemaVersion: AUDIT_SCHEMA,
generatedBy: 'check_xhr_fetch_semantics.js',
clean: problems.length === 0,
caseDir,
browserPath,
nodePath,
problems,
warnings,
results,
summary,
};
}
function renderMarkdown(result) {
const lines = [
'# XHR/fetch 请求语义审计结果',
'',
`- 浏览器 transcript:${result.browserPath}`,
`- Node transcript:${result.nodePath}`,
`- 是否通过:${result.clean ? '是' : '否'}`,
];
if (result.summary && typeof result.summary.browserEvents !== 'undefined') {
lines.push(`- 浏览器事件:${result.summary.browserEvents}`);
lines.push(`- Node 事件:${result.summary.nodeEvents}`);
lines.push(`- matched:${result.summary.matched}`);
lines.push(`- mismatch:${result.summary.mismatched}`);
lines.push(`- Node 多余事件:${result.summary.extraNodeEvents}`);
lines.push(`- Node 多余请求:${result.summary.extraNodeRequests}`);
lines.push(`- Node sessionId:${result.summary.nodeSessionIds.join('、') || '未记录'}`);
lines.push(`- no-send 网络尝试:${String(result.summary.networkAttempts ?? '未记录')}`);
}
const bad = result.results.filter(item => !item.clean);
if (bad.length) {
lines.push('', '## 逐项差异');
for (const item of bad.slice(0, 100)) {
lines.push(`- ${item.index}/${item.key}:${item.diffs.map(diff => diff.field).join('、')}`);
}
}
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`;
}
if (require.main === module) {
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = check(args);
if (args.out) {
const out = path.resolve(args.out);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
}
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,
eventClass,
hardInvariants,
normalizeEvent,
validateAndNormalizeHeaders,
validateTranscript,
};

View File

@@ -0,0 +1,313 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: '',
dir: '',
tlsClient: '',
requireLive: 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 === '--tls-client') args.tlsClient = argv[++i] || '';
else if (a === '--require-live') args.requireLive = 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_xhr_fetch_session_bridge.js --case-dir case --markdown
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --require-live --json
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --tls-client curl_cffi --require-live --markdown
说明:检查 XMLHttpRequest / fetch / sendBeacon 是否只是 fixture/mock/default 200,或是否已通过 live session bridge 复用同一 TLS 指纹兼容 Session。`;
}
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 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', '.py', '.ts'].includes(ext(file));
}
function shouldSkip(file) {
const n = file.replace(/\\/g, '/').toLowerCase();
return /(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party)(\/|$)/.test(n)
|| /(\.min\.js|bundle\.js|vendor\.js)$/i.test(n);
}
function findLines(text, pattern, limit = 10) {
const out = [];
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (pattern.test(lines[i])) out.push({ line: i + 1, text: lines[i].trim().slice(0, 180) });
if (out.length >= limit) break;
}
return out;
}
const NETWORK_API_RE = /\b(XMLHttpRequest|fetch|Request|Response|Headers|sendBeacon)\b/;
const XHR_IMPL_RE = /\b(class|function)\s+XMLHttpRequest\b|XMLHttpRequest\.prototype|\bXMLHttpRequest\s*=\s*(?:class|function)|\bopen\s*\([^)]*\)\s*\{[\s\S]{0,400}\bsend\s*\(/;
const FETCH_IMPL_RE = /\b(?:window|globalThis|ctx|self)\.fetch\s*=|\bfunction\s+fetch\s*\(|\basync\s+function\s+fetch\s*\(|\bfetch\s*:\s*(?:async\s*)?function|\binstallFetch\b/i;
const OFFLINE_RE = /offline-fixture|diagnostic-only|fixture|mock|defaultResponse|default response|xhrResponseForRequest|responseForRequest|fake|stub|不发送真实网络请求|不发真实请求|does not send real network|默认\s*200|status\s*[:=]\s*200/i;
const LIVE_BRIDGE_RE = /live-session-bridge|xhr-fetch-session-bridge|sessionBridge|bridgeRequest|sendViaSession|requestThroughSession|bridge envelope|IPC|stdio|process\.send|parentPort|createRequestSession|create_request_session|requests\.Session|curl_cffi|curl-cffi|cffi_curl|cyCronet|cycronet|CycleTLS|cycletls|impers/i;
const ORDINARY_CLIENT_RE = /\b(?:http|https)\.request\s*\(|\baxios\s*\(|\bundici\b|\bnode-fetch\b|\bglobalThis\.fetch\s*\(|\bfetch\s*\(\s*(?:url|requestUrl|targetUrl)|\brequests\.request\s*\(/i;
const PYTHON_SESSION_BRIDGE_RE = /final\.py|create_request_session|requests\.Session|curl_cffi|session\.request\s*\(|subprocess\.Popen|stdin|stdout|IPC|bridge/i;
function inspectFiles(root, files) {
const result = {
networkFiles: [],
offlineHits: [],
liveBridgeHits: [],
ordinaryClientHits: [],
xhrImplHits: [],
fetchImplHits: [],
pythonSessionBridgeHits: [],
};
for (const file of files) {
const text = readText(file);
const r = rel(root, file);
if (!NETWORK_API_RE.test(text) && !/curl_cffi|CycleTLS|impers|sessionBridge|bridge/i.test(text)) continue;
result.networkFiles.push(r);
const offline = findLines(text, OFFLINE_RE, 5);
if (offline.length) result.offlineHits.push({ file: r, lines: offline });
const live = findLines(text, LIVE_BRIDGE_RE, 8);
if (live.length) result.liveBridgeHits.push({ file: r, lines: live });
const ordinary = findLines(text, ORDINARY_CLIENT_RE, 5);
if (ordinary.length) result.ordinaryClientHits.push({ file: r, lines: ordinary });
const xhr = findLines(text, XHR_IMPL_RE, 5);
if (xhr.length) result.xhrImplHits.push({ file: r, lines: xhr });
const fetch = findLines(text, FETCH_IMPL_RE, 5);
if (fetch.length) result.fetchImplHits.push({ file: r, lines: fetch });
const pyBridge = findLines(text, PYTHON_SESSION_BRIDGE_RE, 8);
if (pyBridge.length) result.pythonSessionBridgeHits.push({ file: r, lines: pyBridge });
}
return result;
}
function inspectBridgeNotes(caseDir) {
const notesFile = path.join(caseDir, 'notes', 'xhr-fetch-session-bridge.md');
const auditFile = path.join(caseDir, 'tmp', 'xhr-fetch-bridge-audit.json');
const semanticsFile = path.join(caseDir, 'tmp', 'xhr-fetch-semantics-audit.json');
const notes = {
notesFile,
auditFile,
semanticsFile,
notesPresent: exists(notesFile),
auditPresent: exists(auditFile),
semanticsPresent: exists(semanticsFile),
mode: '',
auditOk: false,
semanticsOk: false,
audit: {},
semantics: {},
};
if (notes.notesPresent) {
const text = readText(notesFile);
if (/live-session-bridge/i.test(text)) notes.mode = 'live-session-bridge';
else if (/offline-fixture/i.test(text)) notes.mode = 'offline-fixture';
}
if (notes.auditPresent) {
try {
const raw = JSON.parse(readText(auditFile));
notes.audit = raw;
notes.auditOk = raw.schemaVersion === 'xhr-fetch-session-bridge-audit/v2'
&& /runtime|bridge/i.test(String(raw.generatedBy || ''))
&& raw.mode === 'live-session-bridge'
&& raw.sameSessionVerified === true
&& Number(raw.transportRoundTrips || 0) > 0
&& Boolean(raw.sessionId)
&& Boolean(raw.runtimeSourceHash);
if (!notes.mode && raw.mode) notes.mode = String(raw.mode);
} catch (_) {
notes.auditOk = false;
}
}
if (notes.semanticsPresent) {
try {
const raw = JSON.parse(readText(semanticsFile));
notes.semantics = raw;
notes.semanticsOk = raw.schemaVersion === 'xhr-fetch-semantics-audit/v2'
&& raw.generatedBy === 'check_xhr_fetch_semantics.js'
&& raw.clean === true
&& raw.summary
&& raw.summary.networkMode === 'no-send'
&& Boolean(raw.summary.runtimeSourceHash);
} catch (_) {
notes.semanticsOk = false;
}
}
return notes;
}
function isCurlCffiClient(tlsClient) {
return /curl[_-]?cffi|cffi_curl/i.test(String(tlsClient || ''));
}
function check(args) {
const caseDir = path.resolve(args.caseDir || '.');
const root = path.resolve(args.dir || path.join(caseDir, 'result'));
const files = walk(root).filter(file => stat(file) && stat(file).isFile() && isCodeFile(file) && !shouldSkip(file));
const problems = [];
const warnings = [];
const evidence = inspectFiles(root, files);
const bridgeNotes = inspectBridgeNotes(caseDir);
const hasNetworkImpl = evidence.xhrImplHits.length > 0 || evidence.fetchImplHits.length > 0;
const hasLiveBridgeCodeEvidence = evidence.liveBridgeHits.length > 0;
const hasLiveBridge = bridgeNotes.auditOk && bridgeNotes.semanticsOk;
const hasOffline = bridgeNotes.mode === 'offline-fixture' || evidence.offlineHits.length > 0;
if (!files.length) problems.push(`未找到可检查的 result 代码文件:${root}`);
if (hasNetworkImpl && evidence.ordinaryClientHits.length) {
problems.push('XHR/fetch 网络对象疑似直接调用普通 Node/Python HTTP 客户端,必须改为 live session bridge 复用已确认 TLS Session。');
}
if (args.requireLive && hasNetworkImpl && !hasLiveBridge) {
problems.push('要求 live session bridge,但缺少可信运行时 bridge audit 或 no-send 请求语义审计;代码关键词和手工 matched 不能作为通过证据。');
}
if (args.requireLive && hasOffline && !hasLiveBridge) {
problems.push('检测到 offline fixture / mock / 默认响应,但缺少 live session bridge。真实请求场景不得把 fixture 响应写成 TLS 已解决。');
}
if (isCurlCffiClient(args.tlsClient) && hasNetworkImpl) {
const audit = bridgeNotes.audit || {};
if (!evidence.pythonSessionBridgeHits.length) {
problems.push('最终 TLS 客户端为 curl_cffi / cffi_curl,但未检测到 final.py 持有 Session 并服务 Node IPC bridge 的代码线索。');
}
if (audit.sessionOwner !== 'final.py' || !/curl[_-]?cffi|cffi_curl/i.test(String(audit.tlsClient || '')) || audit.sameSessionVerified !== true) {
problems.push('curl_cffi 场景缺少可信运行时证明:sessionOwner 必须为 final.py、tlsClient 必须匹配,且 sameSessionVerified=true。');
}
}
if (hasNetworkImpl && !bridgeNotes.notesPresent) {
warnings.push('建议生成 case/notes/xhr-fetch-session-bridge.md,记录 offline-fixture 或 live-session-bridge 模式、Session 持有者和 Cookie 同步策略。');
}
if (!hasNetworkImpl && args.requireLive) {
problems.push('传入了 --require-live,但未检测到明确 XHR/fetch 实现;不得以“可能由外部模块提供”作为豁免。');
}
if (args.requireLive && !bridgeNotes.auditPresent) {
problems.push('真实请求模式缺少 case/tmp/xhr-fetch-bridge-audit.json。');
}
if (args.requireLive && !bridgeNotes.semanticsPresent) {
problems.push('真实请求模式缺少 case/tmp/xhr-fetch-semantics-audit.json。');
}
if (args.requireLive && hasLiveBridgeCodeEvidence && !bridgeNotes.auditOk) {
warnings.push('发现 live bridge 代码关键词,但运行时 bridge audit 未通过;静态关键词不计入验收。');
}
return {
caseDir,
root,
clean: problems.length === 0,
requireLive: args.requireLive,
tlsClient: args.tlsClient || '',
hasNetworkImpl,
hasLiveBridge,
hasLiveBridgeCodeEvidence,
hasOffline,
filesChecked: files.length,
bridgeNotes,
evidence,
problems,
warnings,
};
}
function renderHitList(items) {
if (!items.length) return ['- 无'];
const out = [];
for (const item of items) {
out.push(`- ${item.file}`);
for (const line of item.lines) out.push(` - 第 ${line.line} 行:${line.text}`);
}
return out;
}
function renderMarkdown(result) {
const lines = [
'# XHR/fetch Session Bridge 检查结果',
'',
`case 目录:${result.caseDir}`,
`检查范围:${result.root}`,
`是否要求 live bridge:${result.requireLive ? '是' : '否'}`,
`TLS 客户端:${result.tlsClient || '未指定'}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 摘要',
`- 检查文件数:${result.filesChecked}`,
`- 检测到 XHR/fetch 实现:${result.hasNetworkImpl ? '是' : '否'}`,
`- 检测到 live session bridge 证据:${result.hasLiveBridge ? '是' : '否'}`,
`- 仅检测到 live bridge 代码关键词:${result.hasLiveBridgeCodeEvidence ? '是' : '否'}`,
`- 检测到 offline fixture / mock 证据:${result.hasOffline ? '是' : '否'}`,
`- bridge 记录:${result.bridgeNotes.notesPresent ? result.bridgeNotes.notesFile : '未发现'}`,
`- bridge audit:${result.bridgeNotes.auditPresent ? result.bridgeNotes.auditFile : '未发现'}`,
`- 请求语义 audit:${result.bridgeNotes.semanticsPresent ? result.bridgeNotes.semanticsFile : '未发现'}`,
'',
'## XHR 实现证据',
...renderHitList(result.evidence.xhrImplHits),
'',
'## fetch 实现证据',
...renderHitList(result.evidence.fetchImplHits),
'',
'## live bridge 证据',
...renderHitList(result.evidence.liveBridgeHits),
'',
'## offline fixture / mock 证据',
...renderHitList(result.evidence.offlineHits),
];
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 };

View File

@@ -0,0 +1,396 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: null,
dryRun: false,
force: false,
includeProfiles: false,
json: false,
markdown: false,
pruneEmpty: true,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i];
else if (a === '--dry-run') args.dryRun = true;
else if (a === '--force') args.force = true;
else if (a === '--include-profiles') args.includeProfiles = true;
else if (a === '--no-prune-empty') args.pruneEmpty = false;
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/clean_case.js --case-dir case --dry-run --markdown
node scripts/clean_case.js --case-dir case --force --markdown
node scripts/clean_case.js --case-dir case --force --include-profiles --markdown
node scripts/clean_case.js --case-dir case --force --no-prune-empty --json
说明:清理 case 内测试文件、临时文件、缓存文件、中间产物和空目录。默认不删除疑似登录态 Profile / Cookie / IndexedDB。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function stat(p) {
try { return fs.statSync(p); } catch { return null; }
}
function isDangerousDir(p) {
const root = path.parse(p).root;
const normalized = path.resolve(p);
return normalized === root || normalized.length <= root.length + 2;
}
function normalizeSlash(p) {
return String(p).replace(/\\/g, '/');
}
function relPath(caseDir, p) {
const rel = path.relative(caseDir, p) || '.';
return normalizeSlash(rel);
}
function isInside(parent, child) {
const rel = path.relative(parent, child);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function isFirefoxProfileDir(p) {
const st = stat(p);
if (!st || !st.isDirectory()) return false;
let names = [];
try { names = fs.readdirSync(p).map(name => name.toLowerCase()); } catch { return false; }
const nameSet = new Set(names);
const markerHits = [
'prefs.js',
'cookies.sqlite',
'places.sqlite',
'storage',
'cache2',
'cert9.db',
'key4.db',
'logins.db',
'webappsstore.sqlite',
'parent.lock',
'sessionstore-backups',
].filter(name => nameSet.has(name)).length;
return markerHits >= 2;
}
function isProfilePath(p) {
const lower = normalizeSlash(p).toLowerCase();
if (/(^|\/)(cloak-profile|camoufox-profile|camoufox-user-data|browser-profile|user-data-dir|user-data|firefox-profile|chrome-profile|ruyipage-profile|profile)(\/|$)/.test(lower)
|| /(^|\/)[^/]*profile[^/]*(\/|$)/.test(lower)
|| /(^|\/)(cookies|local storage|indexeddb|session storage)(\/|$)/.test(lower)
|| /\b(cookie|localstorage|sessionstorage|authorization|token)\b/i.test(lower)) return true;
let cur = path.resolve(p);
const st = stat(cur);
if (!st || !st.isDirectory()) cur = path.dirname(cur);
while (true) {
if (isFirefoxProfileDir(cur)) return true;
const parent = path.dirname(cur);
if (parent === cur) break;
cur = parent;
}
return false;
}
function isDisposableDirName(name) {
const n = name.toLowerCase();
return [
'tmp', '.tmp', 'temp', '.temp', 'cache', '.cache',
'__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache',
'browser-temp', 'camoufox-trace', 'camoufox-values', 'failed', '.downloads', '.tmp-downloads',
].includes(n);
}
function isDisposableFileName(name) {
const n = name.toLowerCase();
if (['.ds_store', 'thumbs.db', 'desktop.ini'].includes(n)) return true;
if (/\.(tmp|temp|bak|old|orig|retry|partial|download|crdownload|cache)$/i.test(n)) return true;
if (/^(env-trace\.jsonl|missing-env\.json|node-output\.json|run-output\.json)$/i.test(n)) return true;
if (/^(fingerprint-hook|.*-fingerprint-hook|hook-fingerprint).*\.(js|mjs|cjs)$/i.test(n)) return true;
if (/^(test-|tmp-|temp-|debug-|scratch-).+\.(js|mjs|cjs|json|jsonl|log|txt|md|html|png|jpg|jpeg|webp|har)$/i.test(n)) return true;
if (/(\.test-output|\.debug-output|\.tmp-output)\./i.test(n)) return true;
return false;
}
function listTree(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) listTree(path.join(p, name), out);
}
out.push(p);
return out;
}
function listDirsDeepFirst(p) {
const out = [];
function visit(dir) {
if (!exists(dir)) return;
let names = [];
try { names = fs.readdirSync(dir); } catch { names = []; }
for (const name of names) {
const child = path.join(dir, name);
const st = stat(child);
if (st && st.isDirectory()) visit(child);
}
out.push(dir);
}
visit(p);
return out.sort((a, b) => b.length - a.length);
}
function hasProfileInside(p) {
return listTree(p).some(isProfilePath);
}
function isProfileProtectedContainer(p, includeProfiles) {
if (includeProfiles) return false;
const st = stat(p);
return !!st && st.isDirectory() && !isProfilePath(p) && hasProfileInside(p);
}
function hasOnlyProfileChildren(dir) {
const st = stat(dir);
if (!st || !st.isDirectory()) return false;
let names = [];
try { names = fs.readdirSync(dir); } catch { return false; }
if (names.length === 0) return false;
return names.every(name => {
const child = path.join(dir, name);
return isProfilePath(child) || hasProfileInside(child);
});
}
function addAction(actions, action, target, reason) {
actions.push({ action, path: target, reason: reason || '' });
}
function removePath(target, dryRun, recursive = true) {
if (dryRun) return;
const st = stat(target);
if (!st) return;
if (st.isDirectory() && recursive) fs.rmSync(target, { recursive: true, force: true });
else if (st.isDirectory()) fs.rmdirSync(target);
else fs.rmSync(target, { force: true });
}
function cleanDisposableDir(caseDir, dir, args, actions) {
if (!exists(dir)) return;
const st = stat(dir);
if (!st || !st.isDirectory()) return;
if (isProfilePath(dir) && !args.includeProfiles) {
addAction(actions, 'skip-profile', dir, '疑似登录态或浏览器 Profile,默认保留');
return;
}
if (isProfileProtectedContainer(dir, args.includeProfiles)) {
addAction(actions, 'keep-profile-container', dir, '目录内包含疑似 Profile,仅清理非敏感子项');
let names = [];
try { names = fs.readdirSync(dir); } catch { names = []; }
for (const name of names) {
const child = path.join(dir, name);
if (isProfilePath(child) || hasProfileInside(child)) {
addAction(actions, 'skip-profile', child, '疑似登录态或浏览器 Profile,默认保留');
continue;
}
addAction(actions, args.dryRun ? 'would-delete' : 'delete', child, '清理临时目录中的非敏感子项');
removePath(child, args.dryRun, true);
}
return;
}
addAction(actions, args.dryRun ? 'would-delete' : 'delete', dir, '清理临时 / 缓存 / 中间产物目录');
removePath(dir, args.dryRun, true);
}
function isTempLikePath(caseDir, p, includeProfiles) {
if (p === caseDir) return false;
if (!includeProfiles && isProfilePath(p)) return false;
const rel = relPath(caseDir, p).toLowerCase();
const base = path.basename(p).toLowerCase();
const tempLike = rel.split('/').some(part => isDisposableDirName(part)) || isDisposableFileName(base);
if (!tempLike) return false;
const st = stat(p);
if (!includeProfiles && st && st.isDirectory() && isProfileProtectedContainer(p, includeProfiles) && hasOnlyProfileChildren(p)) {
return false;
}
return true;
}
function collectRemainingTempLike(caseDir, includeProfiles) {
return listTree(caseDir).filter(p => exists(p) && isTempLikePath(caseDir, p, includeProfiles));
}
function retryRemainingCleanup(caseDir, args, actions) {
if (args.dryRun) return;
for (let round = 0; round < 2; round++) {
const remaining = collectRemainingTempLike(caseDir, args.includeProfiles);
if (!remaining.length) return;
for (const p of remaining.sort((a, b) => b.length - a.length)) {
if (!exists(p) || p === caseDir || !isInside(caseDir, p)) continue;
const st = stat(p);
if (!st) continue;
if (!args.includeProfiles && isProfilePath(p)) continue;
if (!args.includeProfiles && st.isDirectory() && isProfileProtectedContainer(p, args.includeProfiles)) {
cleanDisposableDir(caseDir, p, args, actions);
continue;
}
if (st.isDirectory()) {
addAction(actions, 'delete', p, '二次清理残留临时目录');
removePath(p, false, true);
} else if (isDisposableFileName(path.basename(p)) || relPath(caseDir, p).toLowerCase().split('/').some(part => isDisposableDirName(part))) {
addAction(actions, 'delete', p, '二次清理残留临时文件');
removePath(p, false, false);
}
}
pruneEmptyDirs(caseDir, args, actions);
}
}
function collectDisposableDirs(caseDir) {
const dirs = [];
const direct = [
'tmp', '.tmp', 'temp', '.temp', 'browser-temp', 'cache', '.cache',
'downloads/failed', 'downloads/.tmp', 'downloads/.cache',
'logs/tmp', 'logs/.tmp', 'trace/tmp', 'trace/.tmp',
'ruyi-trace/tmp', 'ruyi-trace/.tmp', 'camoufox-trace', 'camoufox-values',
'camoufox-trace/tmp', 'camoufox-values/tmp', 'screenshots/tmp', 'screenshots/.tmp',
];
for (const rel of direct) dirs.push(path.join(caseDir, rel));
for (const p of listTree(caseDir)) {
const st = stat(p);
if (!st || !st.isDirectory()) continue;
if (p === caseDir) continue;
if (isDisposableDirName(path.basename(p))) dirs.push(p);
}
return Array.from(new Set(dirs.map(p => path.resolve(p)))).sort((a, b) => b.length - a.length);
}
function cleanDisposableFiles(caseDir, args, actions) {
for (const p of listTree(caseDir)) {
if (!exists(p)) continue;
const st = stat(p);
if (!st || !st.isFile()) continue;
if (isProfilePath(p) && !args.includeProfiles) {
addAction(actions, 'skip-sensitive-file', p, '疑似 Cookie / token / 登录态文件,默认保留');
continue;
}
const rel = relPath(caseDir, p).toLowerCase();
const inTempLikeDir = rel.split('/').some(part => isDisposableDirName(part));
const shouldDelete = isDisposableFileName(path.basename(p)) || (st.size === 0 && inTempLikeDir);
if (!shouldDelete) continue;
addAction(actions, args.dryRun ? 'would-delete' : 'delete', p, st.size === 0 ? '清理空临时文件' : '清理临时 / 测试 / 缓存文件');
removePath(p, args.dryRun, false);
}
}
function pruneEmptyDirs(caseDir, args, actions) {
if (!args.pruneEmpty) return;
for (const dir of listDirsDeepFirst(caseDir)) {
if (dir === caseDir) continue;
if (!exists(dir)) continue;
if (isProfilePath(dir) && !args.includeProfiles) continue;
let names = [];
try { names = fs.readdirSync(dir); } catch { continue; }
if (names.length !== 0) continue;
addAction(actions, args.dryRun ? 'would-remove-empty-dir' : 'remove-empty-dir', dir, '清理空目录');
removePath(dir, args.dryRun, false);
}
}
function cleanup(args) {
if (!args.caseDir) throw new Error('必须提供 --case-dir');
const caseDir = path.resolve(args.caseDir);
if (!exists(caseDir)) throw new Error(`case 目录不存在:${caseDir}`);
const caseStat = stat(caseDir);
if (!caseStat || !caseStat.isDirectory()) throw new Error(`case 路径不是目录:${caseDir}`);
if (isDangerousDir(caseDir)) throw new Error(`拒绝清理危险目录:${caseDir}`);
if (!args.dryRun && !args.force) throw new Error('未提供 --force,拒绝删除;请先使用 --dry-run 预览');
const actions = [];
const disposableDirs = collectDisposableDirs(caseDir);
for (const dir of disposableDirs) {
if (!exists(dir)) continue;
if (dir === caseDir || !isInside(caseDir, dir)) continue;
cleanDisposableDir(caseDir, dir, args, actions);
}
cleanDisposableFiles(caseDir, args, actions);
pruneEmptyDirs(caseDir, args, actions);
retryRemainingCleanup(caseDir, args, actions);
const remainingTempLike = collectRemainingTempLike(caseDir, args.includeProfiles);
return {
caseDir,
dryRun: args.dryRun,
includeProfiles: args.includeProfiles,
pruneEmpty: args.pruneEmpty,
actions,
remainingTempLike,
clean: remainingTempLike.length === 0,
};
}
function renderMarkdown(result) {
const label = {
'skip-profile': '跳过 Profile',
'skip-sensitive-file': '跳过敏感文件',
'keep-profile-container': '保留 Profile 容器目录',
'would-delete': '将删除',
'delete': '已删除',
'would-remove-empty-dir': '将删除空目录',
'remove-empty-dir': '已删除空目录',
};
const lines = ['# 清理结果', '', `case 目录:${result.caseDir}`, `dry-run:${result.dryRun ? '是' : '否'}`, `是否包含 Profile:${result.includeProfiles ? '是' : '否'}`, `是否清理空目录:${result.pruneEmpty ? '是' : '否'}`, ''];
if (result.actions.length) {
lines.push('## 操作列表');
for (const a of result.actions) {
lines.push(`- ${label[a.action] || a.action}:${a.path}${a.reason ? `(${a.reason})` : ''}`);
}
} else {
lines.push('## 操作列表', '- 没有需要清理的内容');
}
lines.push('', '## 清理后检查');
lines.push(`- 是否仍存在普通临时 / 缓存 / 中间产物:${result.clean ? '否' : '是'}`);
if (result.remainingTempLike.length) {
for (const p of result.remainingTempLike) lines.push(` - ${p}`);
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = cleanup(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
function parseArgs(argv) {
const args = { fixture: '', actual: '', field: '', expectedPath: '', actualPath: '', json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--fixture') args.fixture = argv[++i] || '';
else if (a === '--actual') args.actual = argv[++i] || '';
else if (a === '--field') args.field = argv[++i] || '';
else if (a === '--expected-path') args.expectedPath = argv[++i] || '';
else if (a === '--actual-path') args.actualPath = argv[++i] || '';
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/compare_fixture.js --fixture sample.fixture.json --actual node-output.json --field sign --markdown
node scripts/compare_fixture.js --fixture sample.fixture.json --actual node-output.json --expected-path expected.sign --actual-path output.sign --json`;
}
function readJson(p, label) {
if (!p) throw new Error(`必须提供 ${label}`);
return JSON.parse(fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''));
}
function getPath(obj, p) {
if (!p) return undefined;
let cur = obj;
for (const part of p.split('.').filter(Boolean)) {
if (cur == null) return undefined;
cur = cur[part];
}
return cur;
}
function firstDefined(values) {
for (const v of values) if (v !== undefined) return v;
return undefined;
}
function normalize(v) {
if (v === undefined) return undefined;
if (typeof v === 'string') return v;
return JSON.stringify(v);
}
function compare(args) {
const fixture = readJson(args.fixture, '--fixture');
const actual = readJson(args.actual, '--actual');
const field = args.field || fixture.param || '';
const expectedPath = args.expectedPath || (field ? `expected.${field}` : 'expected');
const actualPath = args.actualPath || '';
const expected = firstDefined([getPath(fixture, expectedPath), field ? getPath(fixture, `expected.${field}`) : undefined, fixture.expectedValue]);
const actualValue = actualPath ? getPath(actual, actualPath) : firstDefined([
field ? getPath(actual, `output.${field}`) : undefined,
field ? getPath(actual, field) : undefined,
getPath(actual, 'output'),
getPath(actual, 'result'),
]);
const expectedText = normalize(expected);
const actualText = normalize(actualValue);
const pass = expected !== undefined && actualValue !== undefined && expectedText === actualText;
return { pass, field, expectedPath, actualPath: actualPath || '(自动识别)', expected, actual: actualValue, expectedText, actualText, fixture: args.fixture, actualFile: args.actual };
}
function renderMarkdown(result) {
const lines = ['# fixtures 对比结果', '', `- 结果:${result.pass ? '通过' : '失败'}`, `- 字段:${result.field || '未指定'}`, `- 期望路径:${result.expectedPath}`, `- 实际路径:${result.actualPath}`, `- 浏览器期望值:${result.expectedText === undefined ? '未找到' : result.expectedText}`, `- Node.js 实际值:${result.actualText === undefined ? '未找到' : result.actualText}`];
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = compare(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
if (!result.pass) process.exit(2);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');
const REPOS = {
ruyitrace: { owner: 'LoseNine', repo: 'Firefox-FingerPrint-Analyzer', asset: /RuyiTrace\.zip$/i },
'ruyipage-firefox': { owner: 'LoseNine', repo: 'ruyipage', asset: null },
};
function parseArgs(argv) {
const args = { tool: '', dest: '', dryRun: false, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--tool') args.tool = argv[++i] || '';
else if (a === '--dest') args.dest = argv[++i] || '';
else if (a === '--dry-run') args.dryRun = 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/download_ruyi_tool.js --tool ruyitrace --dest <download-dir> --dry-run --markdown
node scripts/download_ruyi_tool.js --tool ruyitrace --dest <download-dir> --markdown
node scripts/download_ruyi_tool.js --tool ruyipage-firefox --dest <download-dir> --dry-run --markdown
说明:仅在用户确认后下载。下载后仍需要用户解压 / 安装或确认可执行文件路径。`;
}
function getJson(url) {
return new Promise((resolve, reject) => {
const req = https.get(url, { headers: { 'User-Agent': 'web-js-env-patcher-skill' } }, res => {
let data = '';
res.setEncoding('utf8');
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
getJson(res.headers.location).then(resolve, reject);
return;
}
if (res.statusCode !== 200) reject(new Error(`请求失败 ${res.statusCode}: ${url}`));
else {
try { resolve(JSON.parse(data)); } catch (err) { reject(err); }
}
});
});
req.on('error', reject);
req.setTimeout(60000, () => req.destroy(new Error('请求超时')));
});
}
function pickRuyiPageAsset(assets) {
if (process.platform === 'win32') return assets.find(a => /win64\.zip$/i.test(a.name));
if (process.platform === 'linux' && process.arch === 'x64') return assets.find(a => /linux.*x86_64.*\.tar\.xz$/i.test(a.name));
return assets.find(a => /firefox/i.test(a.name));
}
function selectAsset(tool, assets) {
if (tool === 'ruyipage-firefox') return pickRuyiPageAsset(assets);
const rule = REPOS[tool].asset;
return assets.find(a => rule.test(a.name));
}
function downloadFile(url, file) {
return new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const out = fs.createWriteStream(file);
function request(u) {
https.get(u, { headers: { 'User-Agent': 'web-js-env-patcher-skill' } }, res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
request(res.headers.location);
return;
}
if (res.statusCode !== 200) {
out.close();
reject(new Error(`下载失败 ${res.statusCode}: ${u}`));
return;
}
res.pipe(out);
out.on('finish', () => out.close(() => resolve(file)));
}).on('error', err => {
out.close();
reject(err);
});
}
request(url);
});
}
async function plan(args) {
if (!args.tool || !REPOS[args.tool]) throw new Error(`必须提供 --tool,可选:${Object.keys(REPOS).join(', ')}`);
if (!args.dest) throw new Error('必须提供 --dest');
const repo = REPOS[args.tool];
const release = await getJson(`https://api.github.com/repos/${repo.owner}/${repo.repo}/releases/latest`);
const asset = selectAsset(args.tool, release.assets || []);
if (!asset) throw new Error(`未找到适合当前工具 / 平台的 release asset:${args.tool}`);
const destDir = path.resolve(args.dest);
const file = path.join(destDir, asset.name);
const result = {
tool: args.tool,
repo: `${repo.owner}/${repo.repo}`,
releaseName: release.name || '',
tagName: release.tag_name || '',
releaseUrl: release.html_url || '',
assetName: asset.name,
assetSize: asset.size,
downloadUrl: asset.browser_download_url,
destFile: file,
dryRun: args.dryRun,
downloaded: false,
};
if (!args.dryRun) {
await downloadFile(asset.browser_download_url, file);
result.downloaded = true;
}
return result;
}
function renderMarkdown(result) {
const lines = ['# Ruyi 工具下载结果', '', `- 工具:${result.tool}`, `- 仓库:${result.repo}`, `- Release:${result.releaseName || result.tagName}`, `- Release URL:${result.releaseUrl}`, `- 资产:${result.assetName}`, `- 大小:${result.assetSize}`, `- 目标文件:${result.destFile}`, `- dry-run:${result.dryRun ? '是' : '否'}`, `- 是否已下载:${result.downloaded ? '是' : '否'}`];
lines.push('', '## 下一步');
if (result.dryRun) lines.push('- 当前只是下载计划;只有用户确认后再去掉 `--dry-run` 下载。');
else lines.push('- 下载完成。请用户解压 / 安装后,提供工具目录并重新运行检测脚本。');
return lines.join('\n') + '\n';
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); return; }
const result = await plan(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
}
main().catch(err => {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
});

View File

@@ -0,0 +1,488 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
types: 'canvas,webgl,dom-geometry',
out: '',
apiPattern: '',
chunkSize: 3000,
deprecatedMaxDataUrlLength: null,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--types') args.types = argv[++i] || args.types;
else if (a === '--out') args.out = argv[++i] || '';
else if (a === '--api-pattern') args.apiPattern = argv[++i] || '';
else if (a === '--chunk-size') args.chunkSize = Number(argv[++i] || args.chunkSize);
else if (a === '--max-data-url-length') {
// 兼容旧参数名:历史版本用它裁剪 dataURL;当前版本严禁裁剪,只把它当作“分片大小”。
args.deprecatedMaxDataUrlLength = Number(argv[++i] || args.chunkSize);
args.chunkSize = args.deprecatedMaxDataUrlLength;
}
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!Number.isFinite(args.chunkSize) || args.chunkSize < 512) args.chunkSize = 3000;
return args;
}
function usage() {
return `用法:
node scripts/generate_fingerprint_hook.js --types canvas,webgl,dom-geometry --out case/hooks/fingerprint-hook.js
node scripts/generate_fingerprint_hook.js --types canvas,webgl,webgpu,audio,dom-geometry --chunk-size 3000
说明:
生成浏览器侧指纹终端 API 采样 Hook。该脚本只用于前置取证,不得进入最终 result/ 交付目录。
长指纹值不会被裁剪为 4000 / 4096 字符;Canvas / WebGL / WebGPU / Audio 等大结果会完整保存或按 chunks 分片保存。
旧参数 --max-data-url-length 已废弃裁剪语义,仅兼容为分片大小,不能用于截断最终值。`;
}
function q(v) { return JSON.stringify(String(v)); }
function header(args) {
return `// 指纹终端 API 采样 Hook,仅用于授权前置取证。
// 使用方式:在用户已确认的取证工具中注入,触发最少量业务动作后执行:
// copy(JSON.stringify(await window.__WEB_JS_ENV_PATCHER_FINALIZE_FINGERPRINT__(), null, 2))
// 如果控制台不支持 await,可先执行:
// window.__WEB_JS_ENV_PATCHER_FINALIZE_FINGERPRINT__().then(v => copy(JSON.stringify(v, null, 2)))
// 注意:长指纹值必须完整保存或分片保存,不得裁剪为 4000 / 4096 字符,也不得把 Trace 可见片段当最终值。
// 不要把本 Hook 放入最终 result/ 交付目录。
(function installFingerprintSampler() {
if (window.__WEB_JS_ENV_PATCHER_FINGERPRINT_HOOKED__) return;
Object.defineProperty(window, "__WEB_JS_ENV_PATCHER_FINGERPRINT_HOOKED__", { value: true, configurable: true });
const chunkSize = ${Number(args.chunkSize) || 3000};
const apiPattern = ${q(args.apiPattern)};
const store = window.__WEB_JS_ENV_PATCHER_FINGERPRINT__ = window.__WEB_JS_ENV_PATCHER_FINGERPRINT__ || {
version: 2,
source: {
pageUrl: location.href,
userAgent: navigator.userAgent,
language: navigator.language,
languages: Array.from(navigator.languages || []),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
capturedAt: new Date().toISOString(),
capturedBy: "真实浏览器指纹采样 Hook",
traceStatus: "browser-complete-sampled",
apiPattern
},
canvas: { toDataURL: [], toBlob: [], measureText: [], getImageData: [] },
webgl: { getParameter: [], getSupportedExtensions: null, getExtension: [], getShaderPrecisionFormat: [], readPixels: [] },
webgpu: { requestAdapter: [] },
audio: { startRendering: [], getChannelData: [] },
domGeometry: { getBoundingClientRect: [], getClientRects: [], offset: [] }
};
function stack() { try { return new Error().stack || ""; } catch(e) { return ""; } }
function chunkString(value, encoding) {
const text = String(value == null ? "" : value);
const chunks = [];
for (let i = 0; i < text.length; i += chunkSize) chunks.push(text.slice(i, i + chunkSize));
return {
encoding,
valueLength: text.length,
chunkSize,
chunks,
truncated: false,
sha256: "",
sha256Of: encoding === "base64-chunks" ? "base64-string" : "string",
hashStatus: "pending-finalize"
};
}
function b64FromArrayLike(arr) {
try {
const bytes = new Uint8Array(arr.buffer || arr, arr.byteOffset || 0, arr.byteLength || arr.length || 0);
let s = "";
const step = 0x8000;
for (let i = 0; i < bytes.length; i += step) {
const sub = bytes.subarray(i, Math.min(i + step, bytes.length));
s += String.fromCharCode.apply(null, sub);
}
return btoa(s);
} catch(e) { return ""; }
}
function chunkArrayLikeAsBase64(arr) {
const encoded = b64FromArrayLike(arr);
const out = chunkString(encoded, "base64-chunks");
try {
const bytes = new Uint8Array(arr.buffer || arr, arr.byteOffset || 0, arr.byteLength || arr.length || 0);
out.rawByteLength = bytes.byteLength;
} catch(e) {}
return out;
}
function clonePlain(value, depth) {
if (depth <= 0) return String(value);
if (value == null) return value;
if (typeof value === "string") return value.length >= 3900 ? chunkString(value, "string-chunks") : value;
if (typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) return value.map(v => clonePlain(v, depth - 1));
if (value instanceof Set) return Array.from(value).map(v => clonePlain(v, depth - 1));
if (value instanceof Map) return Array.from(value.entries()).map(([k, v]) => [clonePlain(k, depth - 1), clonePlain(v, depth - 1)]);
if (typeof value === "object") {
const out = {};
for (const key of Object.keys(value)) {
try { out[key] = clonePlain(value[key], depth - 1); } catch(e) { out[key] = "<read-error>"; }
}
return out;
}
return String(value);
}
function selectorOf(el) {
try {
if (!el) return "";
if (el.id) return "#" + el.id;
if (el.className && typeof el.className === "string") return "." + el.className.trim().split(/\\s+/)[0];
return String(el.tagName || "").toLowerCase();
} catch(e) { return ""; }
}
async function sha256Text(text) {
const input = new TextEncoder().encode(String(text));
const digest = await crypto.subtle.digest("SHA-256", input);
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, "0")).join("");
}
async function fillChunkHashes(node) {
if (!node || typeof node !== "object") return;
if ((node.encoding === "string-chunks" || node.encoding === "base64-chunks") && Array.isArray(node.chunks)) {
const text = node.chunks.join("");
node.valueLength = text.length;
node.truncated = false;
try {
node.sha256 = await sha256Text(text);
node.hashStatus = "ok";
} catch(e) {
node.hashStatus = "failed:" + (e && e.message || String(e));
}
}
if (Array.isArray(node)) {
for (const item of node) await fillChunkHashes(item);
} else {
for (const key of Object.keys(node)) await fillChunkHashes(node[key]);
}
}
window.__WEB_JS_ENV_PATCHER_FINALIZE_FINGERPRINT__ = async function finalizeFingerprint() {
await fillChunkHashes(store);
store.source.finalizedAt = new Date().toISOString();
store.source.finalizeNote = "长指纹值已完整保存或分片保存,sha256 针对完整字符串或完整 base64 字符串计算,truncated 必须为 false。";
return store;
};
`;
}
function footer() {
return ` console.info("[web-js-env-patcher] 指纹采样 Hook 已安装。触发目标动作后执行:copy(JSON.stringify(await window.__WEB_JS_ENV_PATCHER_FINALIZE_FINGERPRINT__(), null, 2))");
})();`;
}
function canvasSnippet() {
return `
// ===== Canvas 终端 API 采样 =====
if (window.HTMLCanvasElement) {
const rawToDataURL = HTMLCanvasElement.prototype.toDataURL;
if (rawToDataURL && !rawToDataURL.__wjep_hooked__) {
HTMLCanvasElement.prototype.toDataURL = function patchedToDataURL(type, quality) {
const result = rawToDataURL.apply(this, arguments);
store.canvas.toDataURL.push({
match: { width: this.width, height: this.height, type: type || "image/png" },
result: chunkString(result, "string-chunks"),
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return result;
};
HTMLCanvasElement.prototype.toDataURL.__wjep_hooked__ = true;
}
const rawToBlob = HTMLCanvasElement.prototype.toBlob;
if (rawToBlob && !rawToBlob.__wjep_hooked__) {
HTMLCanvasElement.prototype.toBlob = function patchedToBlob(callback, type, quality) {
const width = this.width, height = this.height, callStack = stack();
return rawToBlob.call(this, function(blob) {
store.canvas.toBlob.push({
match: { width, height, type: type || (blob && blob.type) || "image/png" },
result: { type: blob && blob.type || type || "", size: blob && blob.size || 0 },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: callStack
});
return callback && callback.apply(this, arguments);
}, type, quality);
};
HTMLCanvasElement.prototype.toBlob.__wjep_hooked__ = true;
}
}
if (window.CanvasRenderingContext2D) {
const p = CanvasRenderingContext2D.prototype;
const rawMeasureText = p.measureText;
if (rawMeasureText && !rawMeasureText.__wjep_hooked__) {
p.measureText = function patchedMeasureText(text) {
const ret = rawMeasureText.apply(this, arguments);
store.canvas.measureText.push({
match: { text: String(text), font: this.font },
result: {
width: ret.width,
actualBoundingBoxLeft: ret.actualBoundingBoxLeft,
actualBoundingBoxRight: ret.actualBoundingBoxRight,
actualBoundingBoxAscent: ret.actualBoundingBoxAscent,
actualBoundingBoxDescent: ret.actualBoundingBoxDescent,
fontBoundingBoxAscent: ret.fontBoundingBoxAscent,
fontBoundingBoxDescent: ret.fontBoundingBoxDescent
},
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.measureText.__wjep_hooked__ = true;
}
const rawGetImageData = p.getImageData;
if (rawGetImageData && !rawGetImageData.__wjep_hooked__) {
p.getImageData = function patchedGetImageData(sx, sy, sw, sh) {
const ret = rawGetImageData.apply(this, arguments);
store.canvas.getImageData.push({
match: { sx, sy, sw, sh },
result: { width: ret.width, height: ret.height, dataBase64: chunkArrayLikeAsBase64(ret.data) },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getImageData.__wjep_hooked__ = true;
}
}
`;
}
function webglSnippet() {
return `
// ===== WebGL 终端 API 采样 =====
for (const Ctor of [window.WebGLRenderingContext, window.WebGL2RenderingContext].filter(Boolean)) {
const p = Ctor.prototype;
if (p.getParameter && !p.getParameter.__wjep_hooked__) {
const raw = p.getParameter;
p.getParameter = function patchedGetParameter(pname) {
const ret = raw.apply(this, arguments);
store.webgl.getParameter.push({
match: { pname },
result: clonePlain(ret, 3),
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getParameter.__wjep_hooked__ = true;
}
if (p.getSupportedExtensions && !p.getSupportedExtensions.__wjep_hooked__) {
const raw = p.getSupportedExtensions;
p.getSupportedExtensions = function patchedGetSupportedExtensions() {
const ret = raw.apply(this, arguments);
store.webgl.getSupportedExtensions = {
result: Array.from(ret || []),
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
};
return ret;
};
p.getSupportedExtensions.__wjep_hooked__ = true;
}
if (p.getExtension && !p.getExtension.__wjep_hooked__) {
const raw = p.getExtension;
p.getExtension = function patchedGetExtension(name) {
const ret = raw.apply(this, arguments);
store.webgl.getExtension.push({
match: { name: String(name) },
result: ret ? { exists: true, keys: Object.keys(ret).slice(0, 50) } : null,
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getExtension.__wjep_hooked__ = true;
}
if (p.getShaderPrecisionFormat && !p.getShaderPrecisionFormat.__wjep_hooked__) {
const raw = p.getShaderPrecisionFormat;
p.getShaderPrecisionFormat = function patchedGetShaderPrecisionFormat(shaderType, precisionType) {
const ret = raw.apply(this, arguments);
store.webgl.getShaderPrecisionFormat.push({
match: { shaderType, precisionType },
result: ret ? { rangeMin: ret.rangeMin, rangeMax: ret.rangeMax, precision: ret.precision } : null,
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getShaderPrecisionFormat.__wjep_hooked__ = true;
}
if (p.readPixels && !p.readPixels.__wjep_hooked__) {
const raw = p.readPixels;
p.readPixels = function patchedReadPixels(x, y, width, height, format, type, pixels) {
const ret = raw.apply(this, arguments);
store.webgl.readPixels.push({
match: { x, y, width, height, format, type },
result: { dataBase64: pixels ? chunkArrayLikeAsBase64(pixels) : chunkString("", "base64-chunks") },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.readPixels.__wjep_hooked__ = true;
}
}
`;
}
function webgpuSnippet() {
return `
// ===== WebGPU 终端 API 采样 =====
if (navigator.gpu && navigator.gpu.requestAdapter && !navigator.gpu.requestAdapter.__wjep_hooked__) {
const rawRequestAdapter = navigator.gpu.requestAdapter.bind(navigator.gpu);
navigator.gpu.requestAdapter = async function patchedRequestAdapter(options) {
const adapter = await rawRequestAdapter(options);
const info = adapter && adapter.info ? clonePlain(adapter.info, 4) : null;
store.webgpu.requestAdapter.push({
match: { options: clonePlain(options || {}, 4) },
result: {
info,
features: adapter && adapter.features ? Array.from(adapter.features) : [],
limits: adapter && adapter.limits ? clonePlain(adapter.limits, 3) : {}
},
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return adapter;
};
navigator.gpu.requestAdapter.__wjep_hooked__ = true;
}
`;
}
function audioSnippet() {
return `
// ===== Audio 指纹终端 API 采样 =====
if (window.AudioBuffer && AudioBuffer.prototype.getChannelData && !AudioBuffer.prototype.getChannelData.__wjep_hooked__) {
const rawGetChannelData = AudioBuffer.prototype.getChannelData;
AudioBuffer.prototype.getChannelData = function patchedGetChannelData(channel) {
const ret = rawGetChannelData.apply(this, arguments);
try {
store.audio.getChannelData.push({
match: { channel, length: this.length, sampleRate: this.sampleRate, numberOfChannels: this.numberOfChannels },
result: { channelBase64: chunkArrayLikeAsBase64(ret) },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
} catch(e) {}
return ret;
};
AudioBuffer.prototype.getChannelData.__wjep_hooked__ = true;
}
if (window.OfflineAudioContext && OfflineAudioContext.prototype.startRendering && !OfflineAudioContext.prototype.startRendering.__wjep_hooked__) {
const rawStartRendering = OfflineAudioContext.prototype.startRendering;
OfflineAudioContext.prototype.startRendering = function patchedStartRendering() {
const callStack = stack();
const ret = rawStartRendering.apply(this, arguments);
if (ret && typeof ret.then === "function") {
return ret.then(buffer => {
try {
const data = buffer.getChannelData(0);
store.audio.startRendering.push({
match: { length: buffer.length, sampleRate: buffer.sampleRate, numberOfChannels: buffer.numberOfChannels },
result: { channel0Base64: chunkArrayLikeAsBase64(data) },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: callStack
});
} catch(e) {}
return buffer;
});
}
return ret;
};
OfflineAudioContext.prototype.startRendering.__wjep_hooked__ = true;
}
`;
}
function domSnippet() {
return `
// ===== DOM 几何 / 字体探测终端 API 采样 =====
if (window.Element) {
const p = Element.prototype;
const rawRect = p.getBoundingClientRect;
if (rawRect && !rawRect.__wjep_hooked__) {
p.getBoundingClientRect = function patchedGetBoundingClientRect() {
const ret = rawRect.apply(this, arguments);
store.domGeometry.getBoundingClientRect.push({
match: { selector: selectorOf(this) },
result: { x: ret.x, y: ret.y, width: ret.width, height: ret.height, top: ret.top, left: ret.left, right: ret.right, bottom: ret.bottom },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getBoundingClientRect.__wjep_hooked__ = true;
}
const rawRects = p.getClientRects;
if (rawRects && !rawRects.__wjep_hooked__) {
p.getClientRects = function patchedGetClientRects() {
const ret = rawRects.apply(this, arguments);
store.domGeometry.getClientRects.push({
match: { selector: selectorOf(this) },
result: Array.from(ret || []).map(r => ({ x: r.x, y: r.y, width: r.width, height: r.height, top: r.top, left: r.left, right: r.right, bottom: r.bottom })),
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getClientRects.__wjep_hooked__ = true;
}
for (const key of ["offsetWidth", "offsetHeight", "scrollWidth", "scrollHeight", "clientWidth", "clientHeight"]) {
const desc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, key) || Object.getOwnPropertyDescriptor(Element.prototype, key);
if (!desc || !desc.get || desc.get.__wjep_hooked__) continue;
Object.defineProperty(HTMLElement.prototype, key, {
get: function patchedGeometryGetter() {
const value = desc.get.call(this);
store.domGeometry.offset.push({
match: { selector: selectorOf(this) },
result: { [key]: value },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return value;
},
enumerable: desc.enumerable,
configurable: true
});
try { Object.getOwnPropertyDescriptor(HTMLElement.prototype, key).get.__wjep_hooked__ = true; } catch(e) {}
}
}
`;
}
function build(args) {
const types = new Set(String(args.types || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
const parts = [header(args)];
if (types.has('canvas')) parts.push(canvasSnippet());
if (types.has('webgl')) parts.push(webglSnippet());
if (types.has('webgpu')) parts.push(webgpuSnippet());
if (types.has('audio')) parts.push(audioSnippet());
if (types.has('dom') || types.has('dom-geometry') || types.has('font') || types.has('fonts')) parts.push(domSnippet());
parts.push(footer());
return parts.join('\n');
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const code = build(args);
if (args.out) {
const out = path.resolve(args.out);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, code, 'utf8');
const compatNote = args.deprecatedMaxDataUrlLength ? '\n- 兼容提醒:--max-data-url-length 已按分片大小处理,没有裁剪任何值。' : '';
console.log(`# 指纹采样 Hook 已生成\n- 输出文件:${out}\n- 类型:${args.types}\n- 分片大小:${args.chunkSize}\n- 提醒:该 Hook 只用于前置取证,不得放入最终 result/ 目录;长指纹值必须 finalize 后完整分片保存。${compatNote}`);
} else {
process.stdout.write(code + '\n');
}
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,253 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { param: 'sign', apiPattern: '/api/', types: 'fetch,xhr,urlsearchparams,cookie,storage,time-random,dynamic-code,websocket,postmessage', out: '' };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--param') args.param = argv[++i] || 'sign';
else if (a === '--api-pattern') args.apiPattern = argv[++i] || '/api/';
else if (a === '--types') args.types = argv[++i] || args.types;
else if (a === '--out') args.out = argv[++i] || '';
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
return args;
}
function usage() {
return `用法:
node scripts/generate_hook_templates.js --param sign --api-pattern /api/ --types fetch,xhr,cookie,storage
node scripts/generate_hook_templates.js --param a_bogus --api-pattern /web/api --out case/hooks/hooks.js`;
}
function q(v) { return JSON.stringify(String(v)); }
function section(name, code) { return `\n// ===== ${name} =====\n${code.trim()}\n`; }
function snippets(ctx) {
return {
fetch: section('fetch 请求写入定位', `
(() => {
const targetParam = ${q(ctx.param)};
const apiKeyword = ${q(ctx.apiPattern)};
const rawFetch = window.fetch;
window.fetch = function patchedFetch(input, init) {
const url = typeof input === "string" ? input : (input && input.url) || "";
const body = init && init.body;
if (String(url).includes(apiKeyword) || String(url).includes(targetParam) || String(body || "").includes(targetParam)) {
console.group("[fetch 命中]", url);
console.log("input =", input);
console.log("init =", init);
console.trace("fetch 调用栈");
console.groupEnd();
debugger;
}
return rawFetch.apply(this, arguments);
};
})();`),
xhr: section('XMLHttpRequest 请求写入定位', `
(() => {
const targetParam = ${q(ctx.param)};
const apiKeyword = ${q(ctx.apiPattern)};
const rawOpen = XMLHttpRequest.prototype.open;
const rawSend = XMLHttpRequest.prototype.send;
const rawSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
this.__reverse_method = method;
this.__reverse_url = String(url || "");
return rawOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function patchedSetRequestHeader(name, value) {
if (String(name).includes(targetParam) || String(value).includes(targetParam)) {
console.group("[XHR Header 命中]", this.__reverse_url);
console.log(name, value);
console.trace("setRequestHeader 调用栈");
console.groupEnd();
debugger;
}
return rawSetHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function patchedSend(body) {
const url = this.__reverse_url || "";
if (url.includes(apiKeyword) || url.includes(targetParam) || String(body || "").includes(targetParam)) {
console.group("[XHR send 命中]", this.__reverse_method, url);
console.log("body =", body);
console.trace("send 调用栈");
console.groupEnd();
debugger;
}
return rawSend.apply(this, arguments);
};
})();`),
urlsearchparams: section('URLSearchParams 与 FormData 参数写入定位', `
(() => {
const targetParam = ${q(ctx.param)};
for (const Ctor of [window.URLSearchParams, window.FormData].filter(Boolean)) {
for (const method of ["append", "set"]) {
const raw = Ctor.prototype[method];
if (typeof raw !== "function") continue;
Ctor.prototype[method] = function patchedParamWrite(name, value) {
if (String(name) === targetParam || String(value).includes(targetParam)) {
console.group("[" + Ctor.name + "." + method + " 命中]", name);
console.log("value =", value);
console.trace("参数写入调用栈");
console.groupEnd();
debugger;
}
return raw.apply(this, arguments);
};
}
}
})();`),
cookie: section('Cookie 读写定位', `
(() => {
const targetParam = ${q(ctx.param)};
const desc = Object.getOwnPropertyDescriptor(Document.prototype, "cookie");
if (!desc || !desc.set || !desc.get) return;
Object.defineProperty(document, "cookie", {
configurable: true,
enumerable: desc.enumerable,
get() {
const value = desc.get.call(document);
if (String(value).includes(targetParam)) console.trace("document.cookie 读取命中");
return value;
},
set(value) {
if (String(value).includes(targetParam)) {
console.group("[cookie 写入命中]");
console.log(value);
console.trace("cookie 写入调用栈");
console.groupEnd();
debugger;
}
return desc.set.call(document, value);
}
});
})();`),
storage: section('Storage 读写定位', `
(() => {
const keys = [${q(ctx.param)}, "token", "device", "fingerprint"];
for (const storageName of ["localStorage", "sessionStorage"]) {
const storage = window[storageName];
if (!storage) continue;
for (const method of ["getItem", "setItem", "removeItem"]) {
const raw = storage[method];
storage[method] = function patchedStorage(key, value) {
if (keys.some(k => String(key).includes(k))) {
console.group("[" + storageName + "." + method + " 命中]", key);
console.log("value =", value);
console.trace("Storage 调用栈");
console.groupEnd();
}
return raw.apply(this, arguments);
};
}
}
})();`),
'time-random': section('时间与随机数定位', `
(() => {
const rawDateNow = Date.now;
Date.now = function patchedDateNow() {
const ret = rawDateNow.apply(this, arguments);
console.log("[Date.now]", ret);
console.trace("Date.now 调用栈");
return ret;
};
const rawRandom = Math.random;
Math.random = function patchedMathRandom() {
const ret = rawRandom.apply(this, arguments);
console.log("[Math.random]", ret);
console.trace("Math.random 调用栈");
return ret;
};
if (window.crypto && crypto.getRandomValues) {
const rawGetRandomValues = crypto.getRandomValues;
crypto.getRandomValues = function patchedGetRandomValues(arr) {
const ret = rawGetRandomValues.apply(this, arguments);
console.log("[crypto.getRandomValues]", Array.from(arr));
console.trace("crypto.getRandomValues 调用栈");
return ret;
};
}
})();`),
'dynamic-code': section('动态代码定位', `
(() => {
const rawEval = window.eval;
window.eval = function patchedEval(code) {
console.log("[eval]", String(code).slice(0, 500));
console.trace("eval 调用栈");
debugger;
return rawEval.apply(this, arguments);
};
const rawFunction = window.Function;
window.Function = new Proxy(rawFunction, {
construct(target, args) {
console.log("[new Function]", args);
console.trace("new Function 调用栈");
debugger;
return Reflect.construct(target, args);
},
apply(target, thisArg, args) {
console.log("[Function]", args);
console.trace("Function 调用栈");
debugger;
return Reflect.apply(target, thisArg, args);
}
});
})();`),
websocket: section('WebSocket 定位', `
(() => {
if (!window.WebSocket) return;
const rawWS = window.WebSocket;
window.WebSocket = new Proxy(rawWS, {
construct(target, args) {
console.log("[WebSocket 创建]", args);
console.trace("WebSocket 调用栈");
return Reflect.construct(target, args);
}
});
})();`),
postmessage: section('postMessage 定位', `
(() => {
const rawPostMessage = window.postMessage;
if (!rawPostMessage) return;
window.postMessage = function patchedPostMessage(message, targetOrigin, transfer) {
console.log("[postMessage]", message, targetOrigin);
console.trace("postMessage 调用栈");
return rawPostMessage.apply(this, arguments);
};
})();`),
};
}
function build(args) {
const map = snippets({ param: args.param, apiPattern: args.apiPattern });
const chosen = args.types.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const unknown = chosen.filter(t => !map[t]);
if (unknown.length) throw new Error(`未知 Hook 类型:${unknown.join(', ')}`);
return `// Web JS 逆向定位 Hook 模板
// 目标参数:${args.param}
// API 关键字:${args.apiPattern}
// 使用后请及时移除;只用于授权调试和调用栈定位。
${chosen.map(t => map[t]).join('\n')}`;
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const content = build(args);
if (args.out) {
fs.mkdirSync(path.dirname(path.resolve(args.out)), { recursive: true });
fs.writeFileSync(args.out, content, 'utf8');
console.log(`已写入 Hook 模板:${path.resolve(args.out)}`);
} else {
process.stdout.write(content);
}
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,277 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const crypto = require('crypto');
function parseArgs(argv) {
const args = {
input: '',
caseDir: '',
name: '',
maxExamples: 10,
truncationThreshold: 3900,
maxTruncationExamples: 50,
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--input') args.input = argv[++i] || '';
else if (a === '--case-dir' || a === '--dir') args.caseDir = argv[++i] || '';
else if (a === '--name') args.name = argv[++i] || '';
else if (a === '--max-examples') args.maxExamples = Number(argv[++i] || '10');
else if (a === '--truncation-threshold') args.truncationThreshold = Number(argv[++i] || '3900');
else if (a === '--max-truncation-examples') args.maxTruncationExamples = Number(argv[++i] || '50');
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 (!Number.isFinite(args.truncationThreshold) || args.truncationThreshold < 1) args.truncationThreshold = 3900;
if (!Number.isFinite(args.maxTruncationExamples) || args.maxTruncationExamples < 1) args.maxTruncationExamples = 50;
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/import_ruyitrace_log.js --input <trace.ndjson> --case-dir case --markdown
node scripts/import_ruyitrace_log.js --input <trace.ndjson> --case-dir case --truncation-threshold 3900 --json
说明:复制 RuyiTrace NDJSON 日志到 case/ruyi-trace/logs/,生成 notes/ruyitrace-summary.md,并标记接近 4000 / 4096 字符的字段为“疑似被 RuyiTrace 截断”。`;
}
function exists(p) {
try { return fs.existsSync(p); } catch { return false; }
}
function safeName(name) {
return String(name || '').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').slice(0, 180);
}
function inc(map, key) {
key = key || '(空)';
map.set(key, (map.get(key) || 0) + 1);
}
function top(map, n) {
return Array.from(map.entries()).sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]))).slice(0, n).map(([key, count]) => ({ key, count }));
}
function classifyApi(api) {
api = String(api || '');
if (/Canvas|CanvasRenderingContext2D|OffscreenCanvas/.test(api)) return 'canvas';
if (/WebGL|GLRenderingContext/.test(api)) return 'webgl';
if (/Audio|Oscillator|Analyser|OfflineAudioContext/.test(api)) return 'audio';
if (/Navigator|navigator/.test(api)) return 'navigator';
if (/Screen|screen/.test(api)) return 'screen';
if (/Crypto|getRandomValues|randomUUID/.test(api)) return 'crypto';
if (/Performance|performance/.test(api)) return 'performance';
if (/Storage|localStorage|sessionStorage|IndexedDB|IDB/.test(api)) return 'storage';
if (/WebRTC|RTCPeerConnection|MediaDevices/.test(api)) return 'webrtc';
if (/Worker|ServiceWorker|postMessage|MessageChannel/.test(api)) return 'worker-message';
if (/Document|Element|Node|CSS|Style|Layout|DOMRect/.test(api)) return 'dom-layout';
return 'other';
}
function visibleHash(value) {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
function preview(value, side = 80) {
const text = String(value || '');
if (text.length <= side * 2) return text;
return `${text.slice(0, side)}...${text.slice(-side)}`;
}
function stackBrief(evt) {
const stack = Array.isArray(evt && evt.stack) ? evt.stack : [];
const first = stack.find(s => s && (s.file || s.line || s.col));
if (!first) return '';
const loc = [first.file || '', first.line || '', first.col || ''].filter(v => v !== '').join(':');
return loc;
}
function walkStrings(value, visitor, currentPath = '') {
if (typeof value === 'string') {
visitor(currentPath || '$', value);
return;
}
if (!value || typeof value !== 'object') return;
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) walkStrings(value[i], visitor, `${currentPath}[${i}]`);
return;
}
for (const [key, child] of Object.entries(value)) {
const nextPath = currentPath ? `${currentPath}.${key}` : key;
walkStrings(child, visitor, nextPath);
}
}
function collectTruncationSignals(evt, lineNo, threshold, maxExamples, state) {
const api = evt.api || evt.name || evt.path || '';
walkStrings(evt, (fieldPath, value) => {
const visibleLength = value.length;
if (visibleLength < threshold) return;
state.totalSuspectedFields++;
state.maxVisibleLength = Math.max(state.maxVisibleLength, visibleLength);
inc(state.byFieldPath, fieldPath);
inc(state.byApi, api || '(空)');
if (state.examples.length >= maxExamples) return;
state.examples.push({
line: lineNo,
api: api || '(空)',
fieldPath,
visibleLength,
minLength: visibleLength,
actualLength: 'unknown',
truncationSuspected: true,
reason: `字段可见长度达到阈值 ${threshold},RuyiTrace 可能已截断长字符串,不能把可见长度当成真实长度。`,
stack: stackBrief(evt),
visibleSha256: visibleHash(value),
visiblePreview: preview(value),
});
});
}
function sanitizeLongStrings(value, threshold, currentPath = '') {
if (typeof value === 'string') {
if (value.length < threshold) return value;
return {
__ruyiTraceLongString__: true,
fieldPath: currentPath || '$',
visibleLength: value.length,
minLength: value.length,
actualLength: 'unknown',
truncationSuspected: true,
visibleSha256: visibleHash(value),
visiblePreview: preview(value),
note: '该字符串接近或超过 RuyiTrace 截断阈值,示例中不保留完整可见内容,真实长度未知。',
};
}
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map((item, index) => sanitizeLongStrings(item, threshold, `${currentPath}[${index}]`));
const out = {};
for (const [key, child] of Object.entries(value)) {
const nextPath = currentPath ? `${currentPath}.${key}` : key;
out[key] = sanitizeLongStrings(child, threshold, nextPath);
}
return out;
}
async function summarizeNdjson(file, options) {
const apiCounts = new Map();
const typeCounts = new Map();
const categoryCounts = new Map();
const fileCounts = new Map();
const examples = [];
const truncationState = { totalSuspectedFields: 0, maxVisibleLength: 0, byFieldPath: new Map(), byApi: new Map(), examples: [] };
let lines = 0, parsed = 0, invalid = 0;
const rl = readline.createInterface({ input: fs.createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
for await (const raw of rl) {
const line = raw.replace(/^\uFEFF/, '').trim();
if (!line) continue;
lines++;
let evt;
try { evt = JSON.parse(line); parsed++; } catch { invalid++; continue; }
const api = evt.api || evt.name || evt.path || '';
inc(apiCounts, api);
inc(typeCounts, evt.t || evt.type || '');
inc(categoryCounts, classifyApi(api));
const stack = Array.isArray(evt.stack) ? evt.stack : [];
for (const s of stack) if (s && s.file) inc(fileCounts, s.file);
collectTruncationSignals(evt, lines, options.truncationThreshold, options.maxTruncationExamples, truncationState);
if (examples.length < options.maxExamples) examples.push(sanitizeLongStrings(evt, options.truncationThreshold));
}
return {
lines,
parsed,
invalid,
topApis: top(apiCounts, 30),
topTypes: top(typeCounts, 20),
topCategories: top(categoryCounts, 20),
topStackFiles: top(fileCounts, 30),
truncation: {
threshold: options.truncationThreshold,
totalSuspectedFields: truncationState.totalSuspectedFields,
maxVisibleLength: truncationState.maxVisibleLength,
topFieldPaths: top(truncationState.byFieldPath, 20),
topApis: top(truncationState.byApi, 20),
examples: truncationState.examples,
rule: '可见长度达到阈值的字符串一律按疑似截断处理;Canvas / WebGL / WebGPU / Audio 等长指纹值不得使用日志可见片段作为最终值,真实长度为 unknown,只能确认至少达到可见长度。',
},
examples,
};
}
function renderMarkdown(result) {
const lines = ['# RuyiTrace 日志导入摘要', '', `- 原始日志:${result.input}`, `- 复制后日志:${result.copiedTo}`, `- 行数:${result.summary.lines}`, `- 成功解析:${result.summary.parsed}`, `- 解析失败:${result.summary.invalid}`];
lines.push('', '## API 类别统计');
for (const item of result.summary.topCategories) lines.push(`- ${item.key}:${item.count}`);
lines.push('', '## 高频 API');
for (const item of result.summary.topApis.slice(0, 20)) lines.push(`- ${item.key}:${item.count}`);
lines.push('', '## 高频调用栈文件');
if (!result.summary.topStackFiles.length) lines.push('- 未发现 stack.file');
for (const item of result.summary.topStackFiles.slice(0, 20)) lines.push(`- ${item.key}:${item.count}`);
const truncation = result.summary.truncation;
lines.push('', '## 长字段截断风险');
lines.push(`- 检测阈值:${truncation.threshold}`);
lines.push(`- 疑似截断字段数:${truncation.totalSuspectedFields}`);
lines.push(`- 最大可见长度:${truncation.maxVisibleLength}`);
lines.push('- 规则:达到或接近阈值的字符串只能说明“至少达到该可见长度”,真实长度记为 unknown,不能把 4000 或可见长度当成真实长度。');
if (!truncation.examples.length) {
lines.push('- 未发现达到阈值的长字符串字段。');
} else {
lines.push('', '| 行号 | API | 字段路径 | 可见长度 | 真实长度判断 | 调用栈 | 可见值 SHA256 |');
lines.push('|---|---|---|---|---|---|---|');
for (const item of truncation.examples.slice(0, 20)) {
lines.push(`| ${item.line} | ${item.api} | ${item.fieldPath} | ${item.visibleLength} | unknown,疑似被 RuyiTrace 截断 | ${item.stack || '未记录'} | ${item.visibleSha256} |`);
}
lines.push('', '### 长字段补采要求');
lines.push('- 不要根据 RuyiTrace 中的 4000 / 4096 字符可见值判断完整加密参数、长 token、长 Cookie、长 body、Canvas dataURL、WebGL readPixels、WebGPU adapter 信息或 Audio channel data 的真实长度。');
lines.push('- 如果该字段影响签名、补环境验证或指纹回放,必须通过 HAR/cURL、ruyiPage / Camoufox / CloakBrowser / 手动浏览器采样、专用 Hook 分片落盘、或最终 Node.js signer 输出重新确认完整值。');
lines.push('- 写入 `notes/missing-env-priority.md` 时标明 `actualLength: unknown`、`minLength: 可见长度`、`truncationSuspected: true`。');
}
lines.push('', '## 建议下一步');
lines.push('- 将高频 API 映射到 `env-module-levels.md` 的 Level 1/2/3 环境模块。');
lines.push('- 结合 stack.file / line / col 更新 `notes/entry-chain.md` 和 `notes/missing-env-priority.md`。');
lines.push('- 对长字段优先补采完整值或记录 hash / 长度 / 前后片段,避免把 RuyiTrace 的截断值误当作完整值。');
lines.push('- 仅把摘要写入最终报告,原始 NDJSON 作为本地证据文件保存或由用户确认删除。');
return lines.join('\n') + '\n';
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); return; }
if (!args.input) throw new Error('必须提供 --input');
if (!args.caseDir) throw new Error('必须提供 --case-dir');
const input = path.resolve(args.input);
const caseDir = path.resolve(args.caseDir);
if (!exists(input)) throw new Error(`日志文件不存在:${input}`);
if (!exists(caseDir)) fs.mkdirSync(caseDir, { recursive: true });
const logDir = path.join(caseDir, 'ruyi-trace', 'logs');
const notesDir = path.join(caseDir, 'notes');
fs.mkdirSync(logDir, { recursive: true });
fs.mkdirSync(notesDir, { recursive: true });
const dstName = safeName(args.name || path.basename(input) || `trace-${Date.now()}.ndjson`);
const copiedTo = path.join(logDir, dstName.endsWith('.ndjson') ? dstName : `${dstName}.ndjson`);
fs.copyFileSync(input, copiedTo);
const summary = await summarizeNdjson(copiedTo, args);
const result = { input, copiedTo, summary };
const md = renderMarkdown(result);
fs.writeFileSync(path.join(notesDir, 'ruyitrace-summary.md'), md, 'utf8');
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(md);
}
main().catch(err => {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
});

View File

@@ -0,0 +1,362 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { caseDir: '', target: '', entry: '', param: '', api: '', force: false, json: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--target') args.target = argv[++i] || '';
else if (a === '--entry') args.entry = argv[++i] || '';
else if (a === '--param') args.param = argv[++i] || '';
else if (a === '--api') args.api = argv[++i] || '';
else if (a === '--force') args.force = true;
else if (a === '--json') args.json = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
return args;
}
function usage() {
return `用法:
node scripts/init_env_case.js --case-dir case --target app.js --entry window.makeSign --param sign --api https://example.com/api/search
说明:已有文件默认不覆盖;如需覆盖模板文件,请显式添加 --force。`;
}
function isDangerousDir(p) {
const resolved = path.resolve(p);
const root = path.parse(resolved).root;
return resolved === root || resolved.length <= root.length + 2;
}
function ensureDir(p, actions) {
if (!fs.existsSync(p)) {
fs.mkdirSync(p, { recursive: true });
actions.push({ action: 'create-dir', path: p });
} else {
actions.push({ action: 'keep-dir', path: p });
}
}
function writeFileIfNeeded(p, content, force, actions) {
if (fs.existsSync(p) && !force) {
actions.push({ action: 'keep-file', path: p });
return;
}
fs.mkdirSync(path.dirname(p), { recursive: true });
const existed = fs.existsSync(p);
fs.writeFileSync(p, content, 'utf8');
actions.push({ action: existed ? 'write-file' : 'create-file', path: p });
}
function initCase(args) {
if (!args.caseDir) throw new Error('必须提供 --case-dir');
const caseDir = path.resolve(args.caseDir);
if (isDangerousDir(caseDir)) throw new Error(`拒绝在危险目录中初始化:${caseDir}`);
const actions = [];
const dirs = ['js/original', 'js/pretty', 'js/extracted', 'requests', 'fixtures', 'notes', 'hooks', 'env', 'ruyi-trace/logs', 'browser/ruyipage', 'result', 'tmp'];
ensureDir(caseDir, actions);
for (const d of dirs) ensureDir(path.join(caseDir, d), actions);
const fixture = {
name: 'sample-001',
pageUrl: '',
apiUrl: args.api || '',
method: '',
param: args.param || '',
paramLocation: '',
browser: {
userAgent: '',
language: 'zh-CN',
timezone: 'Asia/Shanghai',
screen: { width: 1920, height: 1080 },
},
request: { query: {}, headers: {}, body: {}, cookies: {}, localStorage: {}, sessionStorage: {} },
runtime: { now: null, performanceNow: null, randomBytes: [] },
fingerprint: {
source: { mode: '', pageUrl: '', userAgent: '', timezone: '', locale: '', capturedAt: '' },
canvas: { toDataURL: [], toBlob: [], measureText: [], getImageData: [] },
webgl: { getParameter: [], getSupportedExtensions: null, getExtension: [], getShaderPrecisionFormat: [], readPixels: [] },
webgpu: { requestAdapter: [] },
audio: { startRendering: [], getChannelData: [] },
domGeometry: { getBoundingClientRect: [], getClientRects: [], offset: [] },
},
entry: args.entry || '',
args: [],
expected: args.param ? { [args.param]: '' } : {},
};
writeFileIfNeeded(path.join(caseDir, 'fixtures', 'sample.fixture.json'), JSON.stringify(fixture, null, 2) + '\n', args.force, actions);
const fingerprintFixture = {
version: 1,
source: { mode: '', pageUrl: '', userAgent: '', timezone: '', locale: '', capturedAt: '' },
canvas: { toDataURL: [], toBlob: [], measureText: [], getImageData: [] },
webgl: { getParameter: [], getSupportedExtensions: null, getExtension: [], getShaderPrecisionFormat: [], readPixels: [] },
webgpu: { requestAdapter: [] },
audio: { startRendering: [], getChannelData: [] },
domGeometry: { getBoundingClientRect: [], getClientRects: [], offset: [] },
};
writeFileIfNeeded(path.join(caseDir, 'fixtures', 'fingerprint.fixture.json'), JSON.stringify(fingerprintFixture, null, 2) + '\n', args.force, actions);
const notes = `# Node.js 补环境任务笔记
## 基本信息
- 目标 API:${args.api || ''}
- 目标 JS:${args.target || ''}
- 入口函数:${args.entry || ''}
- 目标参数:${args.param || ''}
## 前置材料检查
- [ ] 请求样本已确认
- [ ] 已列出请求样本中的全部可疑加密参数
- [ ] 用户已确认本次要分析哪些加密参数
- [ ] 加密参数已在请求中确认
- [ ] 已输出 source / entry / builder / writer 四层链路
- [ ] 目标 JS 文件已保存
- [ ] 加密入口已定位
- [ ] 浏览器取证模式已由用户选择:ruyiPage + RuyiTrace / ruyiPage / CloakBrowser / 手动取证 / AI 自行决定
- [ ] 如选择 RuyiTrace,NDJSON 日志已导入或确认不需要
- [ ] Node 泄露阻断已检查
- [ ] 补环境初始化阶段已加载 / 检测 addon,并记录可用性、导出 API 或降级原因
- [ ] 默认启用 addon-first;如用户明确要求不使用 addon,已记录豁免原因
- [ ] 默认启用 toString / 属性描述符 / 原型链 / 访问器 / 实例对象保护;如用户明确要求关闭,已记录原因
- [ ] 已读取 code-style.md,并先规划 result 目录、文件职责和调用关系
- [ ] 补环境代码默认要求简洁、模块化、具名函数清晰,并写入中文注释
- [ ] 中文注释使用 UTF-8 正常显示,没有问号、连续问号或乱码
- [ ] 已创建 / 读取 notes/代码变更记忆.md;修改关键源码前已搜索历史记录,修改后已记录失败尝试、禁止回退、验证范围和遗留风险
- [ ] 六项纯计算预检已执行或明确不需要
- [ ] 如目标访问 Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何指纹,已采集真实浏览器终端 API 返回值并写入 fixtures/fingerprint.fixture.json
- [ ] 用户已确认进入补环境阶段
## 缺失环境追踪
- trace 文件:tmp/env-trace.jsonl
- 缺失环境摘要:tmp/missing-env.json
- trace 分析:notes/missing-env-priority.md
## 可疑加密参数确认
| 参数名 | 位置 | 样本值摘要 | 疑似原因 | 用户是否确认分析 |
|---|---|---|---|---|
> cURL / HAR 中已有的加密参数值只能作为 fixture expected,最终产物必须通过补环境重新生成,不能直接复用。
## 补齐记录
| 轮次 | 缺失环境 | 处理方式 | 验证结果 |
|---|---|---|---|
## 通用代码变更记忆
- 记录文件:notes/代码变更记忆.md
- [ ] 修改关键源码前已读取该文件
- [ ] 修改关键源码后已追加本轮变更记录
- [ ] 已记录修改前逻辑、问题证据、本次修改、修改理由、已失败尝试、禁止回退、验证命令、验证结果、当前验证范围、遗留风险和当前状态
- [ ] 已运行 scripts/check_change_memory.js --case-dir case --markdown
## 代码质量
- [ ] 已运行 scripts/check_code_quality.js --case-dir case --markdown
- [ ] 已修复超长行、超长函数、压缩堆叠代码和过深嵌套
- [ ] 已确认每个手写源码文件顶部都有中文职责注释
- [ ] 已确认关键 WebAPI、getter / setter、addon-first、fallback、指纹回放和加密入口有中文说明
- [ ] 已确认中文注释没有问号、连续问号或乱码
## 指纹值回放
- [ ] 已确认哪些指纹终端 API 被目标 JS 访问
- [ ] 已使用真实浏览器采样值回放,而不是在 Node.js 中真实模拟渲染
- [ ] 未把浏览器自动化代码放入最终 result 目录
- [ ] 缺失指纹样本时已阻塞并提示补采样,没有静默伪造默认值
## 最终请求验证
- [ ] fixtures 已通过
- [ ] 用户已确认是否发起真实请求
- [ ] 如普通客户端疑似 TLS 指纹不一致,已确认是否使用 CycleTLS / curl_cffi / cffi_curl / cyCronet
- [ ] 请求次数、状态码、响应关键字段已记录
- [ ] 临时响应和敏感请求副本已清理
## 最终总结
- [ ] 已从前置阶段开始生成 case/阶段报告/01-需求信息确认.md 等中文命名阶段报告
- [ ] 项目完成后已默认生成 result/最终项目总结.md
- [ ] 最终总结使用 UTF-8 写入,中文正常显示
- [ ] 总结包含 native addon / NativeProtect 使用情况、加密参数生成与样本复用检查、最终交付结构、测试结果和清理结果
- [ ] 如用户明确要求不生成最终总结,已记录原因
## 清理检查
- [ ] 已删除临时 trace / 日志
- [ ] 已删除失败 runner
- [ ] 已保留必要 fixtures 和 notes
`;
writeFileIfNeeded(path.join(caseDir, 'notes', 'env-task.md'), notes, args.force, actions);
const codeChangeMemory = `# 代码变更记忆
本文件由通用代码变更记忆机制维护,用于记录复杂 case 中关键源码修改的事实、原因、失败尝试、禁止回退、验证范围和遗留风险。不要把“当前报错消失”直接写成永久结论。
## 使用规则
- 修改关键源码前先读取本文件,并搜索文件名、函数名、错误关键词和参数名。
- 修改关键源码后立即追加一条“变更”记录。
- 不删除失败记录;如历史记录被新证据替代,新增记录说明替代原因。
- 状态只能使用:临时修复 / 当前验证通过 / 已失败 / 被替代 / 稳定基线。
## 变更 001 - 待填写
- 时间:YYYY-MM-DD HH:mm
- 涉及文件:
- 涉及函数 / 模块:
- 当前状态:临时修复
### 修改前逻辑
待填写。
### 问题证据
待填写。
### 本次修改
待填写。
### 修改理由
待填写。
### 已失败尝试
待填写。
### 禁止回退
待填写。
### 验证命令
\`\`\`bash
# 待填写
\`\`\`
### 验证结果
待填写。
### 当前验证范围
待填写。
### 遗留风险
待填写。
`;
writeFileIfNeeded(path.join(caseDir, 'notes', '代码变更记忆.md'), codeChangeMemory, args.force, actions);
const entryChain = `# 加密入口链路记录
## writer 最终写入
- 类型:fetch / XHR / URLSearchParams / Header / Cookie / Body
- 文件与行列号:
- 调用栈:
- 写入前对象快照:
- 写入后对象快照:
## builder 请求构造
- 函数 / 方法:
- 所在模块:
- 输入:
- 输出:
- 是否经过拦截器:
## entry 加密入口
- 函数名 / 表达式:${args.entry || ''}
- 文件 / chunk / module id:${args.target || ''}
- 行列号:
- 入参:
- 返回:
- 是否需要初始化:
## source 数据源
- URL / Query:
- Body:
- Cookie:
- localStorage / sessionStorage:
- 时间 / 随机数:
- navigator / screen / document / canvas / WebGL:
- Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何指纹 fixture:
- 其他:
## 可信度
- 证据来源:
- 是否可进入补环境:否
- 阻塞点:
`;
writeFileIfNeeded(path.join(caseDir, 'notes', 'entry-chain.md'), entryChain, args.force, actions);
const silentChecklist = `# 静默失败排查清单
当目标 JS 不报错但 ${args.param || '目标参数'} 不一致时逐项检查。
- [ ] 请求 URL、Query、Body、Header 与浏览器样本一致
- [ ] SDK 初始化参数、版本、appId、页面配置一致
- [ ] Cookie、localStorage、sessionStorage 已按 fixture 注入
- [ ] Date.now / new Date / performance.now 可复现
- [ ] Math.random / crypto.getRandomValues 可复现
- [ ] Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何等指纹终端 API 返回值来自真实浏览器采样并可回放
- [ ] navigator / screen / timezone / language 与浏览器样本一致
- [ ] JS 加载顺序、runtime chunk、动态 chunk 完整
- [ ] Function.prototype.toString / Object.prototype.toString 已处理
- [ ] 属性描述符与原型链已记录
- [ ] document.all 等特殊对象已处理或确认未触发
- [ ] Worker / WASM / postMessage 已排查
`;
writeFileIfNeeded(path.join(caseDir, 'notes', 'silent-failure-checklist.md'), silentChecklist, args.force, actions);
const trustMatrix = `# 证据可信度矩阵
| 项目 | 当前值 / 结论 | 来源 | 等级 A/B/C/D | 待验证 |
|---|---|---|---|---|
| 目标 API | ${args.api || ''} | 用户输入 | B | |
| 目标参数 | ${args.param || ''} | 用户输入 | B | |
| 加密入口 | ${args.entry || ''} | 用户输入 / 待验证 | C | |
| JS 文件 | ${args.target || ''} | 用户输入 / 待验证 | C | |
| Cookie / token | 仅记录键名和脱敏摘要 | fixture | A/B | |
`;
writeFileIfNeeded(path.join(caseDir, 'notes', 'trust-matrix.md'), trustMatrix, args.force, actions);
return { caseDir, actions };
}
function render(result) {
const label = { 'create-dir': '创建目录', 'keep-dir': '保留目录', 'create-file': '创建文件', 'write-file': '写入文件', 'keep-file': '保留已有文件' };
const lines = ['# 补环境 case 初始化结果', '', `- case 目录:${result.caseDir}`];
for (const a of result.actions) lines.push(`- ${label[a.action] || a.action}:${a.path}`);
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = initCase(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
else process.stdout.write(render(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
function parseArgs(argv) {
const args = { python: 'python', installDir: '', install: false, installPackage: false, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--python') args.python = argv[++i] || 'python';
else if (a === '--install-dir') args.installDir = argv[++i] || '';
else if (a === '--install') args.install = true;
else if (a === '--install-package') args.installPackage = 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/install_ruyipage_runtime.js --python python --install-dir <ruyipage-browsers-dir> --markdown
node scripts/install_ruyipage_runtime.js --python python --install-dir <ruyipage-browsers-dir> --install --markdown
node scripts/install_ruyipage_runtime.js --python python --install-dir <ruyipage-browsers-dir> --install-package --install --markdown
说明:默认只输出安装计划,不执行下载 / 安装。只有用户明确确认并提供安装目录后,才添加 --install。
注意:--install-package 会修改当前 Python 环境,必须先获得用户确认。`;
}
function run(cmd, args, timeout = 120000) {
const ret = spawnSync(cmd, args, { encoding: 'utf8', timeout, windowsHide: true });
return {
ok: ret.status === 0,
status: ret.status,
stdout: (ret.stdout || '').trim(),
stderr: (ret.stderr || '').trim(),
error: ret.error ? ret.error.message : '',
command: [cmd].concat(args.map(quoteIfNeeded)).join(' '),
};
}
function quoteIfNeeded(s) {
s = String(s);
return /\s/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
}
function exists(p) {
try { return !!p && fs.existsSync(p); } catch { return false; }
}
function checkPackage(python) {
const code = 'import ruyipage, json; print(json.dumps({"ok": True, "version": getattr(ruyipage, "__version__", "")}, ensure_ascii=False))';
const ret = run(python, ['-c', code], 20000);
if (!ret.ok) return { ok: false, version: '', error: ret.stderr || ret.error || ret.stdout };
try {
const parsed = JSON.parse(ret.stdout.replace(/^\uFEFF/, ''));
return { ok: true, version: parsed.version || '', error: '' };
} catch (err) {
return { ok: false, version: '', error: err.message || String(err) };
}
}
function assertInstallDir(dir) {
if (!dir) throw new Error('必须提供 --install-dir;不要在未确认目录时安装 ruyiPage 定制 Firefox runtime。');
const resolved = path.resolve(dir);
const parsed = path.parse(resolved);
if (resolved === parsed.root) throw new Error('安装目录不能是磁盘根目录。请提供专用目录,例如 <工作目录>/tools/ruyipage-browsers。');
return resolved;
}
function verifyAfterInstall(python, installDir) {
const script = path.join(__dirname, 'check_external_tools.js');
const ret = run(process.execPath, [script, '--python', python, '--ruyipage-install-dir', installDir, '--json'], 60000);
let parsed = null;
try { parsed = JSON.parse(ret.stdout.replace(/^\uFEFF/, '')); } catch { parsed = null; }
return { command: ret.command, ok: ret.ok, stdout: ret.stdout, stderr: ret.stderr || ret.error, parsed };
}
function commandPlan(args, installDir, pkg) {
const commands = [];
if (!pkg.ok) commands.push(`${args.python} -m pip install ruyiPage requests --upgrade`);
else commands.push(`${args.python} -m pip install requests --upgrade`);
commands.push(`${args.python} -m ruyipage install --install-dir "${installDir}"`);
commands.push(`node scripts/check_external_tools.js --python ${args.python} --ruyipage-install-dir "${installDir}" --markdown`);
return commands;
}
function main() {
const args = parseArgs(process.argv);
if (args.help) return { help: usage() };
const installDir = assertInstallDir(args.installDir);
const pkgBefore = checkPackage(args.python);
const result = {
dryRun: !args.install,
python: args.python,
installDir,
packageBefore: pkgBefore,
packageInstallAttempted: false,
runtimeInstallAttempted: false,
commands: commandPlan(args, installDir, pkgBefore),
steps: [],
verify: null,
success: false,
};
if (!args.install) {
result.steps.push('当前为安装计划,不执行下载或安装。请用户确认后再添加 --install。');
result.success = true;
return result;
}
fs.mkdirSync(installDir, { recursive: true });
let pkg = pkgBefore;
if (!pkg.ok && args.installPackage) {
result.packageInstallAttempted = true;
const pip = run(args.python, ['-m', 'pip', 'install', 'ruyiPage', 'requests', '--upgrade'], 180000);
result.steps.push(`安装 ruyiPage Python 包与 requests 依赖:${pip.ok ? '成功' : '失败'}\n${pip.stdout || pip.stderr || pip.error}`.trim());
pkg = checkPackage(args.python);
} else if (pkg.ok && args.installPackage) {
result.packageInstallAttempted = true;
const pip = run(args.python, ['-m', 'pip', 'install', 'requests', '--upgrade'], 180000);
result.steps.push(`安装 / 更新 smart_fingerprint requests 依赖:${pip.ok ? '成功' : '失败'}\n${pip.stdout || pip.stderr || pip.error}`.trim());
}
if (!pkg.ok) {
result.steps.push('未安装 ruyiPage Python 包,已停止 runtime 安装。若用户确认允许修改 Python 环境,请重新添加 --install-package。');
result.verify = verifyAfterInstall(args.python, installDir);
result.success = false;
return result;
}
result.runtimeInstallAttempted = true;
const install = run(args.python, ['-m', 'ruyipage', 'install', '--install-dir', installDir], 900000);
result.steps.push(`安装 ruyiPage 定制 Firefox runtime:${install.ok ? '成功' : '失败'}\n${install.stdout || install.stderr || install.error}`.trim());
result.verify = verifyAfterInstall(args.python, installDir);
result.success = !!(result.verify && result.verify.parsed && result.verify.parsed.ruyiPage && result.verify.parsed.ruyiPage.managedRuntimeVerified);
return result;
}
function renderMarkdown(result) {
if (result.help) return result.help + '\n';
const lines = ['# ruyiPage 定制 Firefox runtime 安装流程', ''];
lines.push(`- dry-run:${result.dryRun ? '是' : '否'}`);
lines.push(`- Python:${result.python}`);
lines.push(`- 安装目录:${result.installDir}`);
lines.push(`- ruyiPage Python 包:${result.packageBefore.ok ? '已安装' : '未检测到'}`);
if (result.packageBefore.version) lines.push(`- ruyiPage 版本:${result.packageBefore.version}`);
lines.push('', '## 将执行 / 已执行的命令');
for (const cmd of result.commands) lines.push('- `' + cmd + '`');
if (result.steps.length) {
lines.push('', '## 执行记录');
for (const step of result.steps) lines.push(`- ${step.replace(/\n/g, '\n ')}`);
}
if (result.verify) {
lines.push('', '## 安装后验证');
lines.push('- 验证命令:`' + result.verify.command + '`');
lines.push(`- 验证命令是否成功:${result.verify.ok ? '是' : '否'}`);
const rp = result.verify.parsed && result.verify.parsed.ruyiPage;
if (rp) {
lines.push(`- 定制 Firefox runtime 是否通过验证:${rp.managedRuntimeVerified ? '是' : '否'}`);
if (rp.runtimeExecutable) lines.push(`- 已验证 Firefox:${rp.runtimeExecutable}`);
if (rp.conclusion) lines.push(`- 结论:${rp.conclusion}`);
} else if (result.verify.stderr) {
lines.push(`- 验证错误:${result.verify.stderr}`);
}
}
lines.push('', `## 结果:${result.success ? '通过' : '未通过 / 待用户确认'}`);
if (result.dryRun) lines.push('- 当前仅输出计划;若用户确认未安装并提供该目录,再运行带 --install 的命令。');
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
const result = args.help ? { help: usage() } : main();
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const ADDON_COMPATIBLE_NODE_VERSION = '25.8.2';
const REQUIRED_EXPORTS = [
'createNativeObject',
'createNativeFunction',
'createProtoChains',
'getProtoChainRegistry',
'deleteProtoChainRegistryEntry',
'clearProtoChainRegistry',
'getPrivate',
'setPrivate',
'hasPrivate',
'deletePrivate',
'createInterceptor',
'createNativeCollection',
'getMimeTypesAndPlugins',
'createGetter',
'createSetter',
'createUndetectable',
'throwTypeError',
];
function parseArgs(argv) {
const args = { addon: '', json: false, strict: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--addon' || a === '--path') args.addon = argv[++i] || '';
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--strict') args.strict = 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/load_native_addon.js --json
node scripts/load_native_addon.js --addon <path-to-addon.node> --json
说明:不指定 --addon 时,会尝试从当前 Skill 的 assets/native-addon/<platform>-<arch>/addon.node 加载;也可用 WEB_JS_ENV_PATCHER_ADDON 显式覆盖。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function resolvePortablePath(input, skillDir) {
if (!input) return '';
if (path.isAbsolute(input)) return path.resolve(input);
const cwdPath = path.resolve(input);
if (exists(cwdPath)) return cwdPath;
return path.resolve(skillDir, input);
}
function candidatePaths(explicitPath) {
const out = [];
const skillDir = path.resolve(__dirname, '..');
if (explicitPath) out.push(resolvePortablePath(explicitPath, skillDir));
if (process.env.WEB_JS_ENV_PATCHER_ADDON) out.push(resolvePortablePath(process.env.WEB_JS_ENV_PATCHER_ADDON, skillDir));
const platformArch = `${process.platform}-${process.arch}`;
out.push(path.join(skillDir, 'assets', 'native-addon', platformArch, 'addon.node'));
out.push(path.join(skillDir, 'assets', 'native-addon', platformArch, `addon-${platformArch}.node`));
out.push(path.join(skillDir, 'assets', 'native-addon', 'addon.node'));
return [...new Set(out)];
}
function isAbiMismatchMessage(message) {
return /NODE_MODULE_VERSION|different Node\.js version|compiled against|Module did not self-register|The module was compiled/i.test(String(message || ''));
}
function loadNativeAddon(options = {}) {
const paths = candidatePaths(options.addon || options.path || '');
const attempts = [];
for (const p of paths) {
if (!exists(p)) {
attempts.push({ path: p, ok: false, reason: '文件不存在' });
continue;
}
try {
const addon = require(p);
const exports = Object.keys(addon).sort();
const missingExports = REQUIRED_EXPORTS.filter(k => !(k in addon));
return {
available: true,
addon,
path: p,
exports,
missingExports,
platform: process.platform,
arch: process.arch,
node: process.version,
modules: process.versions.modules,
abiMismatch: false,
compatibleNodeVersion: ADDON_COMPATIBLE_NODE_VERSION,
requiresUserConfirmation: false,
attempts,
};
} catch (err) {
const reason = err && err.message ? err.message : String(err);
attempts.push({ path: p, ok: false, reason, abiMismatch: isAbiMismatchMessage(reason) });
}
}
const abiMismatch = attempts.some(a => a.abiMismatch || isAbiMismatchMessage(a.reason));
return {
available: false,
addon: null,
path: '',
exports: [],
missingExports: REQUIRED_EXPORTS.slice(),
platform: process.platform,
arch: process.arch,
node: process.version,
modules: process.versions.modules,
abiMismatch,
compatibleNodeVersion: ADDON_COMPATIBLE_NODE_VERSION,
requiresUserConfirmation: abiMismatch,
attempts,
reason: abiMismatch
? `addon.node 与当前 Node ABI 不兼容;兼容 Node.js 版本为 v${ADDON_COMPATIBLE_NODE_VERSION}`
: '未找到可加载的 addon.node,或当前平台缺少随包产物',
recovery: abiMismatch ? {
rule: '不得直接降级;先询问用户是否通过 nvm 安装 / 切换兼容 Node。用户拒绝后才允许 NativeProtect / JS fallback。',
commands: [
`nvm install ${ADDON_COMPATIBLE_NODE_VERSION}`,
`nvm use ${ADDON_COMPATIBLE_NODE_VERSION}`,
'node -v',
'node -p "process.versions.modules"',
'node scripts/load_native_addon.js --json',
],
} : null,
};
}
function publicResult(result) {
const { addon, ...rest } = result;
return rest;
}
function renderMarkdown(result) {
const lines = [];
lines.push('# native addon 加载检查');
lines.push('');
lines.push(`- 是否可用:${result.available ? '是' : '否'}`);
lines.push(`- 平台:${result.platform}-${result.arch}`);
lines.push(`- Node.js:${result.node}`);
lines.push(`- Node ABI:${result.modules}`);
if (result.path) lines.push(`- 加载路径:${result.path}`);
if (result.reason) lines.push(`- 原因:${result.reason}`);
lines.push(`- addon 兼容 Node:v${result.compatibleNodeVersion || ADDON_COMPATIBLE_NODE_VERSION}`);
if (result.abiMismatch) lines.push('- ABI 不兼容:是,必须先征得用户同意是否通过 nvm 安装 / 切换兼容 Node,不得直接降级');
if (result.exports && result.exports.length) lines.push(`- 导出 API:${result.exports.join(', ')}`);
if (result.missingExports && result.missingExports.length) lines.push(`- 缺少预期 API:${result.missingExports.join(', ')}`);
if (result.attempts && result.attempts.length) {
lines.push('');
lines.push('## 尝试记录');
for (const a of result.attempts) lines.push(`- ${a.path}:${a.reason}${a.abiMismatch ? '(疑似 ABI 不兼容)' : ''}`);
}
if (result.recovery && result.recovery.commands) {
lines.push('');
lines.push('## ABI 恢复流程');
lines.push('');
lines.push(result.recovery.rule);
lines.push('');
lines.push('```bash');
for (const cmd of result.recovery.commands) lines.push(cmd);
lines.push('```');
}
return lines.join('\n') + '\n';
}
if (require.main === module) {
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = loadNativeAddon({ addon: args.addon });
const output = publicResult(result);
if (args.json) console.log(JSON.stringify(output, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(output));
if (args.strict && !result.available) process.exit(2);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
}
module.exports = { loadNativeAddon, REQUIRED_EXPORTS, ADDON_COMPATIBLE_NODE_VERSION, isAbiMismatchMessage };

View File

@@ -0,0 +1,313 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
function parseArgs(argv) {
const args = { input: null, param: null, position: null, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--input' || a === '--curl-file' || a === '-i') args.input = argv[++i];
else if (a === '--param' || a === '-p') args.param = argv[++i];
else if (a === '--position') args.position = argv[++i];
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 `用法:\n node scripts/parse_curl.js --input request.curl --param sign --position header --markdown\n node scripts/parse_curl.js --input request.curl --json\n\n不提供 --input 时会从标准输入读取内容。脚本会同时列出请求样本中的所有可疑加密参数;这些值只能作为浏览器样本 / fixture,最终产物必须通过补环境重新生成,不能直接复用。`;
}
function readInput(input) {
if (input) return fs.readFileSync(input, 'utf8');
return fs.readFileSync(0, 'utf8');
}
function normalizeCurl(s) {
return s.replace(/\r\n/g, '\n')
.replace(/\\\n/g, ' ')
.replace(/\^\n/g, ' ')
.replace(/`\n/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function tokenize(s) {
const out = [];
let cur = '';
let quote = null;
let esc = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (esc) { cur += ch; esc = false; continue; }
if (ch === '\\' && quote !== "'") { esc = true; continue; }
if (quote) {
if (ch === quote) quote = null;
else cur += ch;
continue;
}
if (ch === "'" || ch === '"') { quote = ch; continue; }
if (/\s/.test(ch)) {
if (cur) { out.push(cur); cur = ''; }
continue;
}
cur += ch;
}
if (cur) out.push(cur);
return out;
}
function parseHeader(v) {
const idx = v.indexOf(':');
if (idx === -1) return null;
return [v.slice(0, idx).trim(), v.slice(idx + 1).trim()];
}
function parseCurl(text) {
const tokens = tokenize(normalizeCurl(text));
const result = { url: '', method: '', headers: {}, body: '', cookies: {}, rawTokens: tokens };
let sawCurl = false;
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t === 'curl' || t.endsWith('/curl')) { sawCurl = true; continue; }
if (['-X', '--request'].includes(t)) { result.method = (tokens[++i] || '').toUpperCase(); continue; }
if (['-H', '--header'].includes(t)) {
const h = parseHeader(tokens[++i] || '');
if (h) result.headers[h[0]] = h[1];
continue;
}
if (['-d', '--data', '--data-raw', '--data-binary', '--data-urlencode', '--form'].includes(t)) {
const val = tokens[++i] || '';
result.body = result.body ? result.body + '&' + val : val;
continue;
}
if (t === '-b' || t === '--cookie') {
result.headers.Cookie = tokens[++i] || '';
continue;
}
if (!t.startsWith('-') && /^https?:\/\//i.test(t) && !result.url) result.url = t;
}
if (!sawCurl && !/^https?:\/\//i.test(result.url)) throw new Error('输入内容不像有效的 cURL 命令');
if (!result.method) result.method = result.body ? 'POST' : 'GET';
const cookieHeader = Object.entries(result.headers).find(([k]) => k.toLowerCase() === 'cookie');
if (cookieHeader) {
for (const part of cookieHeader[1].split(';')) {
const idx = part.indexOf('=');
if (idx !== -1) result.cookies[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
}
}
return result;
}
function headerHas(headers, param) {
const p = param.toLowerCase();
return Object.keys(headers).some(k => k.toLowerCase() === p);
}
function bodyHas(body, param) {
if (!body) return false;
const p = param.toLowerCase();
try {
const obj = JSON.parse(body);
const stack = [obj];
while (stack.length) {
const cur = stack.pop();
if (cur && typeof cur === 'object') {
for (const [k, v] of Object.entries(cur)) {
if (k.toLowerCase() === p) return true;
if (v && typeof v === 'object') stack.push(v);
}
}
}
} catch (_) {}
try {
const sp = new URLSearchParams(body);
for (const k of sp.keys()) if (k.toLowerCase() === p) return true;
} catch (_) {}
return new RegExp(`(^|[&?\\s"'])${param.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*[:=]`, 'i').test(body);
}
function queryHas(url, param) {
try {
const u = new URL(url);
for (const k of u.searchParams.keys()) if (k.toLowerCase() === param.toLowerCase()) return true;
} catch (_) {}
return false;
}
function cookieHas(cookies, param) {
const p = param.toLowerCase();
return Object.keys(cookies).some(k => k.toLowerCase() === p);
}
function maskValue(value) {
const s = String(value ?? '');
if (!s) return '';
if (s.length <= 8) return '*'.repeat(s.length);
return `${s.slice(0, 4)}...${s.slice(-4)}(len=${s.length})`;
}
function sha256(value) {
try { return require('crypto').createHash('sha256').update(String(value)).digest('hex'); }
catch { return ''; }
}
const BENIGN_PARAM_NAMES = new Set(['accept','accept-language','accept-encoding','content-type','content-length','origin','referer','host','connection','user-agent','sec-ch-ua','sec-fetch-site','sec-fetch-mode','sec-fetch-dest']);
const STRONG_CRYPTO_NAME = /^(?:sign|signature|_signature|x[-_]?sign|x[-_]?s|x[-_]?t|a[_-]?bogus|h5st|mtgsig|w[_-]?rid|x[-_]?bogus|token|access[-_]?token|auth[-_]?token|x[-_]?token|csrf[-_]?token|csrftoken|verify|digest|hash|hmac|mac|nonce|sig)$/i;
const WEAK_CRYPTO_NAME = /(^|[-_])(sign|signature|token|csrf|verify|digest|hash|hmac|mac|nonce|timestamp|ts|random|device|fp|fingerprint)([-_]|$)/i;
function looksEncodedOrCrypto(value) {
const s = String(value || '');
if (s.length >= 32) return true;
if (s.length >= 24 && /^[A-Za-z0-9+/=_-]+$/.test(s)) return true;
if (s.length >= 24 && /^[a-f0-9]+$/i.test(s)) return true;
if (s.length >= 20 && /%[0-9a-f]{2}/i.test(s)) return true;
return false;
}
function scoreSuspiciousParam(name, value, position) {
const n = String(name || '').trim();
const v = String(value ?? '');
const reasons = [];
let score = 0;
if (!n || BENIGN_PARAM_NAMES.has(n.toLowerCase())) return { score: 0, reasons: ['常规请求字段,默认排除'] };
if (STRONG_CRYPTO_NAME.test(n)) { score += 75; reasons.push('参数名命中常见加密 / 签名 / token 规则'); }
else if (WEAK_CRYPTO_NAME.test(n)) { score += 45; reasons.push('参数名疑似签名、token、随机数、设备或指纹字段'); }
if (looksEncodedOrCrypto(v)) { score += 35; reasons.push('样本值长度或编码形态像签名 / token'); }
if (position === 'Header') score += 8;
if (position === 'Cookie') { score += 8; reasons.push('位于 Cookie,需区分登录态与 JS 生成型 Cookie'); }
if (/^\d{10,13}$/.test(v) && !STRONG_CRYPTO_NAME.test(n)) { score -= 20; reasons.push('值像纯时间戳,需结合链路确认'); }
if (v.length <= 6 && !STRONG_CRYPTO_NAME.test(n)) score -= 20;
return { score, reasons };
}
function addSuspiciousCandidate(map, name, value, position, sourcePath) {
if (value === undefined || value === null || value === '') return;
const { score, reasons } = scoreSuspiciousParam(name, value, position);
if (score < 35) return;
const key = `${position}:${String(name).toLowerCase()}:${sourcePath}`;
if (map.has(key)) return;
map.set(key, {
name: String(name),
position,
sourcePath,
samplePreview: maskValue(value),
valueLength: String(value).length,
valueSha256: sha256(value),
score,
reasons,
mustGenerate: true,
});
}
function collectJsonSuspicious(obj, map, prefix = 'body') {
if (!obj || typeof obj !== 'object') return;
for (const [k, v] of Object.entries(obj)) {
const p = `${prefix}.${k}`;
if (v && typeof v === 'object') collectJsonSuspicious(v, map, p);
else addSuspiciousCandidate(map, k, v, 'Body', p);
}
}
function collectBodySuspicious(body, map) {
if (!body) return;
try { collectJsonSuspicious(JSON.parse(body), map, 'body'); return; } catch (_) {}
try {
const sp = new URLSearchParams(body);
for (const [k, v] of sp.entries()) addSuspiciousCandidate(map, k, v, 'Body', `body.${k}`);
} catch (_) {}
const re = /["']?([A-Za-z_$][\w$-]{0,80})["']?\s*[:=]\s*["']?([^&\s"',}]{4,})/g;
let m;
while ((m = re.exec(body))) addSuspiciousCandidate(map, m[1], m[2], 'Body', `body.${m[1]}`);
}
function discoverSuspiciousParams(req) {
const map = new Map();
try {
const u = new URL(req.url);
for (const [k, v] of u.searchParams.entries()) addSuspiciousCandidate(map, k, v, 'Query', `query.${k}`);
} catch (_) {}
for (const [k, v] of Object.entries(req.headers || {})) addSuspiciousCandidate(map, k, v, 'Header', `header.${k}`);
for (const [k, v] of Object.entries(req.cookies || {})) addSuspiciousCandidate(map, k, v, 'Cookie', `cookie.${k}`);
collectBodySuspicious(req.body, map);
return [...map.values()].sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
}
function analyze(text, param, position) {
const req = parseCurl(text);
let paramPresence = null;
if (param) {
paramPresence = {
query: queryHas(req.url, param),
header: headerHas(req.headers, param),
body: bodyHas(req.body, param),
cookie: cookieHas(req.cookies, param),
};
if (position) {
const key = position.toLowerCase();
paramPresence.expectedPosition = key;
paramPresence.foundAtExpectedPosition = !!paramPresence[key];
}
}
const suspiciousParams = discoverSuspiciousParams(req);
return { request: req, param, position, paramPresence, suspiciousParams };
}
function renderMarkdown(result) {
const req = result.request;
const lines = [];
lines.push('# cURL 请求样本检查结果');
lines.push('');
lines.push(`- URL:${req.url || '未识别'}`);
lines.push(`- 请求方法:${req.method || '未识别'}`);
lines.push(`- 请求头数量:${Object.keys(req.headers).length}`);
lines.push(`- 是否有请求体:${req.body ? '是' : '否'}`);
lines.push(`- Cookie 数量:${Object.keys(req.cookies).length}`);
if (result.param) {
const p = result.paramPresence;
lines.push('');
lines.push(`## 目标参数 \`${result.param}\` 检查`);
lines.push(`- Query:${p.query ? '找到' : '未找到'}`);
lines.push(`- Header:${p.header ? '找到' : '未找到'}`);
lines.push(`- Body:${p.body ? '找到' : '未找到'}`);
lines.push(`- Cookie:${p.cookie ? '找到' : '未找到'}`);
if (result.position) lines.push(`- 期望位置 ${result.position}:${p.foundAtExpectedPosition ? '匹配' : '不匹配'}`);
}
lines.push('');
lines.push('## 发现的可疑加密参数(需用户确认)');
if (!result.suspiciousParams.length) {
lines.push('- 未发现明显可疑加密参数;请结合 Network / Hook / HAR 继续确认。');
} else {
lines.push('| 参数名 | 位置 | 样本值摘要 | 分数 | 命中原因 |');
lines.push('|---|---|---|---:|---|');
for (const item of result.suspiciousParams) {
lines.push(`| ${item.name} | ${item.position} | ${item.samplePreview} | ${item.score} | ${item.reasons.join(';')} |`);
}
lines.push('');
lines.push('请让用户确认哪些参数需要分析。进入正式补环境前,必须把确认后的参数写入任务摘要;未确认前不要开始补环境。');
}
lines.push('');
lines.push('> 注意:cURL 中已有的 sign / token / a_bogus / h5st 等值只能作为浏览器真实样本或 fixture 期望值,最终产物必须通过补环境调用目标 JS 入口重新生成,不能把样本值硬编码或直接复用。');
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const text = readInput(args.input);
const result = analyze(text, args.param, args.position);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
module.exports = { parseCurl, analyze, discoverSuspiciousParams, maskValue };

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
function parseArgs(argv) {
const args = { json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
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/precheck_runtime.js --markdown
node scripts/precheck_runtime.js --json
说明:输出 Node.js 侧纯计算预检结果;浏览器侧需运行同类片段后人工比对。`;
}
function collect() {
const emoji = '中文🙂𠮷';
const params = new URLSearchParams();
params.append('b', '2');
params.append('a', '1 2');
params.append('emoji', '🙂');
const encoder = new TextEncoder();
const bytes = Array.from(encoder.encode(emoji));
const random = new Uint8Array(8);
if (globalThis.crypto && typeof globalThis.crypto.getRandomValues === 'function') globalThis.crypto.getRandomValues(random);
else random.set(crypto.randomBytes(8));
return {
node: { version: process.version, platform: process.platform, arch: process.arch, v8: process.versions.v8 },
math: {
imul: Math.imul(0x7fffffff, 31),
fround: Math.fround(0.1),
pow: Math.pow(2, 53) - 1,
trig: Number(Math.sin(Math.PI / 6).toFixed(12)),
},
stringUnicode: {
sample: emoji,
length: emoji.length,
codePoints: Array.from(emoji).map(ch => ch.codePointAt(0).toString(16)),
normalized: emoji.normalize('NFC'),
},
arrayObject: {
sort: ['10', '2', '1'].sort(),
numericSort: [10, 2, 1].sort((a, b) => a - b),
jsonOrder: JSON.stringify({ b: 2, a: 1, 1: 'one', 0: 'zero' }),
keys: Object.keys({ b: 2, a: 1, 1: 'one', 0: 'zero' }),
},
dateTimezone: {
timezoneOffset: new Date('2020-01-02T03:04:05Z').getTimezoneOffset(),
iso: new Date(1577934245000).toISOString(),
locale: new Date(1577934245000).toString(),
},
encoding: {
textEncoderBytes: bytes,
urlSearchParams: params.toString(),
encodeURIComponent: encodeURIComponent(emoji),
btoaAscii: Buffer.from('abc123', 'binary').toString('base64'),
atobAscii: Buffer.from('YWJjMTIz', 'base64').toString('binary'),
},
random: {
mathRandomType: typeof Math.random(),
cryptoBytesSample: Array.from(random),
note: '随机值只用于确认 API 可用;正式 fixtures 应固定随机源。',
},
};
}
function renderMarkdown(result) {
const lines = ['# Node.js 纯计算预检结果', '', `- Node.js:${result.node.version}`, `- 平台:${result.node.platform}-${result.node.arch}`, `- V8:${result.node.v8}`];
for (const [section, value] of Object.entries(result)) {
if (section === 'node') continue;
lines.push('', `## ${section}`);
for (const [k, v] of Object.entries(value)) lines.push(`- ${k}:${Array.isArray(v) ? JSON.stringify(v) : String(v)}`);
}
lines.push('', '## 下一步');
lines.push('- 在浏览器控制台运行同类预检片段,保存为 `notes/runtime-precheck-browser.json`。');
lines.push('- 若 Node 与浏览器基础计算一致,再继续排查环境对象、初始化参数和样本差异。');
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = collect();
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,287 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { contractHash } = require('./check_trace_runtime_conformance');
function parseArgs(argv) {
const args = {
caseDir: '',
entry: '',
contract: '',
out: '',
timeoutMs: 120000,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--entry') args.entry = argv[++i] || '';
else if (arg === '--contract') args.contract = argv[++i] || '';
else if (arg === '--out') args.out = argv[++i] || '';
else if (arg === '--timeout-ms') args.timeoutMs = Number(argv[++i] || args.timeoutMs);
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/run_trace_runtime_audit.js --case-dir case --entry case/result/final.js --markdown
node scripts/run_trace_runtime_audit.js --case-dir case --entry case/result/final.py --timeout-ms 180000 --json
说明:在强制 no-send 模式下运行项目审计入口。入口必须读取 WEB_JS_ENV_PATCHER_TRACE_AUDIT_OUT 并写出 observations;本脚本负责绑定 contractHash、runtimeSourceHash 和 probeVersion。`;
}
function exists(file) {
try { return fs.existsSync(file); } catch { return false; }
}
function stat(file) {
try { return fs.statSync(file); } catch { return null; }
}
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 sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function runtimeSourceHash(resultDir) {
const entries = [];
for (const file of walk(resultDir)) {
const relative = (path.relative(resultDir, file) || '.').replace(/\\/g, '/');
if (/(^|\/)(node_modules|dist|build|coverage|tmp)(\/|$)/i.test(relative)) continue;
if (!/\.(js|mjs|cjs|ts|py|json)$/i.test(file)) continue;
entries.push([relative, sha256(fs.readFileSync(file))]);
}
entries.sort((a, b) => a[0].localeCompare(b[0]));
return sha256(JSON.stringify(entries));
}
function resolveCommand(entry) {
if (/\.py$/i.test(entry)) return { command: process.env.PYTHON || 'python', args: [entry, '--audit-only'] };
return { command: process.execPath, args: [entry, '--audit-only'] };
}
function normalizeObservations(raw) {
if (Array.isArray(raw)) return raw;
for (const key of ['observations', 'items', 'events', 'apis']) {
if (Array.isArray(raw && raw[key])) return raw[key];
}
return [];
}
function normalizeTimeline(raw, observations) {
const source = raw && (raw.timeline || raw.eventTimeline || raw.sequenceTimeline);
let entries = [];
if (Array.isArray(source)) {
entries = source;
} else if (source && Array.isArray(source.sequence)) {
entries = source.sequence;
}
let ordered = [];
let invalidEntries = 0;
if (entries.length) {
ordered = entries.map((item, index) => ({
id: String(item && typeof item === 'object'
? (item.contractId || item.id || item.key || '')
: item),
sequence: Number(item && typeof item === 'object'
? (typeof item.sequence !== 'undefined' ? item.sequence : (typeof item.seq !== 'undefined' ? item.seq : index))
: index),
})).filter(item => {
const valid = Boolean(item.id) && Number.isFinite(item.sequence);
if (!valid) invalidEntries += 1;
return valid;
}).sort((a, b) => a.sequence - b.sequence);
} else {
for (const observation of observations) {
const id = String(observation && (observation.id || observation.contractId || '') || '');
if (!id) continue;
const sequences = Array.isArray(observation.sequences)
? observation.sequences
: (typeof observation.sequence !== 'undefined' ? [observation.sequence] : []);
for (const sequence of sequences) {
ordered.push({ id, sequence: Number(sequence) || 0 });
}
}
ordered.sort((a, b) => a.sequence - b.sequence);
}
const sequence = [];
for (const item of ordered) {
if (sequence[sequence.length - 1] !== item.id) sequence.push(item.id);
}
const uniqueSequences = new Set(ordered.map(item => item.sequence));
const duplicateSequences = ordered.length - uniqueSequences.size;
return {
schemaVersion: 'trace-runtime-timeline/v1',
generatedFrom: entries.length ? 'runtime-event-timeline' : 'grouped-observations',
valid: invalidEntries === 0 && duplicateSequences === 0,
invalidEntries,
duplicateSequences,
eventCount: ordered.length,
collapsedCount: sequence.length,
truncated: false,
sequence,
sequenceSha256: sha256(JSON.stringify(sequence)),
};
}
function runAudit(args) {
const caseDir = path.resolve(args.caseDir || '.');
const resultDir = path.join(caseDir, 'result');
const entry = path.resolve(args.entry || path.join(resultDir, 'final.js'));
const contractPath = path.resolve(args.contract || path.join(caseDir, 'notes', 'trace-runtime-contract.json'));
const out = path.resolve(args.out || path.join(caseDir, 'tmp', 'node-trace-runtime-audit.json'));
if (!exists(entry)) throw new Error(`审计入口不存在:${entry}`);
if (!exists(contractPath)) throw new Error(`Trace runtime contract 不存在:${contractPath}`);
const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8').replace(/^\uFEFF/, ''));
fs.mkdirSync(path.dirname(out), { recursive: true });
try { if (exists(out)) fs.rmSync(out); } catch {}
const command = resolveCommand(entry);
const env = {
...process.env,
WEB_JS_ENV_PATCHER_AUDIT_ONLY: '1',
WEB_JS_ENV_PATCHER_NO_NETWORK: '1',
WEB_JS_ENV_PATCHER_NETWORK_MODE: 'no-send',
WEB_JS_ENV_PATCHER_TRACE_CONTRACT: contractPath,
WEB_JS_ENV_PATCHER_TRACE_AUDIT_OUT: out,
};
const child = spawnSync(command.command, command.args, {
cwd: resultDir,
env,
encoding: 'utf8',
timeout: Number.isFinite(args.timeoutMs) && args.timeoutMs > 0 ? args.timeoutMs : 120000,
windowsHide: true,
});
const problems = [];
if (child.error) problems.push(`审计入口执行失败:${child.error.message}`);
if (child.status !== 0) problems.push(`审计入口退出码不是 0:${child.status}; stderr=${String(child.stderr || '').slice(0, 1000)}`);
if (!exists(out)) problems.push(`审计入口没有写出 ${out}`);
if (problems.length) {
return { clean: false, caseDir, entry, contractPath, out, command, problems, stdout: child.stdout || '', stderr: child.stderr || '' };
}
let raw;
try { raw = JSON.parse(fs.readFileSync(out, 'utf8').replace(/^\uFEFF/, '')); }
catch (err) {
return { clean: false, caseDir, entry, contractPath, out, command, problems: [`Node audit 无法解析:${err.message}`], stdout: child.stdout || '', stderr: child.stderr || '' };
}
const observations = normalizeObservations(raw);
if (!observations.length) problems.push('Node audit 没有 observations / items / events');
const hasNetworkAttempts = raw
&& (Object.prototype.hasOwnProperty.call(raw, 'networkAttempts')
|| Object.prototype.hasOwnProperty.call(raw, 'realNetworkRequests'));
if (!hasNetworkAttempts) problems.push('Node audit 必须显式记录 networkAttempts=0;缺失字段不能默认视为未访问网络');
const networkAttempts = Number(raw.networkAttempts || raw.realNetworkRequests || 0);
if (networkAttempts > 0) problems.push(`audit-only 阶段检测到 ${networkAttempts} 次真实网络尝试`);
const timeline = normalizeTimeline(raw, observations);
if (!timeline.valid) {
problems.push(`Node audit timeline 含无效项或重复 sequence:invalid=${timeline.invalidEntries},duplicate=${timeline.duplicateSequences}`);
}
if (contract.timeline && contract.timeline.required
&& timeline.generatedFrom !== 'runtime-event-timeline') {
problems.push('Trace contract 要求状态时间线,但审计入口没有输出原始 runtime event timeline;禁止从分组 observations 反推全局顺序');
}
const audit = {
schemaVersion: 'node-trace-runtime-audit/v3',
generatedBy: 'run_trace_runtime_audit.js',
generatedAt: new Date().toISOString(),
baselineId: contract.baselineId || raw.baselineId || '',
contractHash: contractHash(contract),
traceSourceHash: contract.traceSourceHash || '',
runtimeSourceHash: runtimeSourceHash(resultDir),
probeVersion: raw.probeVersion || 'runtime-audit/v3',
networkMode: 'no-send',
networkAttempts,
observations,
timeline,
runtimeMeta: raw.runtimeMeta || {},
};
fs.writeFileSync(out, `${JSON.stringify(audit, null, 2)}\n`, 'utf8');
return {
clean: problems.length === 0,
caseDir,
entry,
contractPath,
out,
command,
problems,
audit: {
observationCount: observations.length,
contractHash: audit.contractHash,
runtimeSourceHash: audit.runtimeSourceHash,
networkAttempts,
timelineEvents: timeline.eventCount,
timelineCollapsed: timeline.collapsedCount,
},
stdout: child.stdout || '',
stderr: child.stderr || '',
};
}
function renderMarkdown(result) {
const lines = [
'# Node Trace-runtime audit 执行结果',
'',
`- 入口:${result.entry}`,
`- contract:${result.contractPath}`,
`- audit:${result.out}`,
`- 是否通过:${result.clean ? '是' : '否'}`,
];
if (result.audit) {
lines.push(`- 观测项:${result.audit.observationCount}`);
lines.push(`- contractHash:${result.audit.contractHash}`);
lines.push(`- runtimeSourceHash:${result.audit.runtimeSourceHash}`);
lines.push(`- 真实网络尝试:${result.audit.networkAttempts}`);
lines.push(`- 状态时间线事件:${result.audit.timelineEvents}`);
lines.push(`- 状态时间线折叠项:${result.audit.timelineCollapsed}`);
}
if (result.problems.length) {
lines.push('', '## 问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
}
return `${lines.join('\n')}\n`;
}
if (require.main === module) {
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = runAudit(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 = { runAudit, runtimeSourceHash };

View File

@@ -0,0 +1,496 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { loadNativeAddon } = require('./load_native_addon');
function parseArgs(argv) {
const args = { target: '', entry: '', fixture: '', trace: '', summary: '', output: '', addon: '', timeout: 5000, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--target') args.target = argv[++i] || '';
else if (a === '--entry') args.entry = argv[++i] || '';
else if (a === '--fixture') args.fixture = argv[++i] || '';
else if (a === '--trace') args.trace = argv[++i] || '';
else if (a === '--summary') args.summary = argv[++i] || '';
else if (a === '--output') args.output = argv[++i] || '';
else if (a === '--addon') args.addon = argv[++i] || '';
else if (a === '--timeout') args.timeout = Number(argv[++i] || '5000');
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/run_with_trace.js --target case/js/original/app.js --entry window.makeSign --fixture case/fixtures/sample.fixture.json --trace case/tmp/env-trace.jsonl --summary case/tmp/missing-env.json --output case/tmp/node-output.json
说明:该脚本用于探测模式。默认在 vm 探测上下文内定义浏览器桩函数,避免把宿主 require/process/Buffer 以及 Node 自带 navigator/performance/localStorage 等 Web API 兼容层暴露给目标 JS;正式交付不强制只能使用 vm。`;
}
function readJson(p) { return p ? JSON.parse(fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, '')) : {}; }
function ensureParent(p) { if (p) fs.mkdirSync(path.dirname(path.resolve(p)), { recursive: true }); }
function bootstrapSource(fixture) {
const fixtureText = JSON.stringify(JSON.stringify(fixture || {}));
return `
'use strict';
const __fixture = JSON.parse(${fixtureText});
Object.defineProperty(globalThis, '__events', { value: [], enumerable: false, configurable: true });
function __safe(v) {
if (v == null || ['string', 'number', 'boolean'].includes(typeof v)) return v;
if (Array.isArray(v)) return v.slice(0, 20).map(__safe);
if (typeof v === 'function') return '[Function ' + (v.name || 'anonymous') + ']';
if (typeof v === 'object') return '[Object ' + ((v.constructor && v.constructor.name) || 'Object') + ']';
return '[' + typeof v + ']';
}
function __push(e) {
try {
e.ts = Date.now();
__events.push(e);
} catch (_) {}
}
Object.defineProperty(globalThis, '__push', { value: __push, enumerable: false, configurable: true });
function traceProxy(name, options) {
options = options || {};
const target = function traceProxyFunction() {};
const cache = Object.create(null);
const primitive = options.primitive == null ? '' : options.primitive;
return new Proxy(target, {
get(t, prop) {
if (prop === Symbol.toPrimitive) return function(hint) { __push({ type: 'toPrimitive', path: name, hint: hint }); return hint === 'number' ? 0 : primitive; };
if (prop === Symbol.toStringTag) return options.tag || name.split('.').pop() || 'Object';
if (prop === 'then') return undefined;
if (prop === 'toString') return function toString() { __push({ type: 'call', path: name + '.toString', args: [] }); return String(primitive || '[object ' + (options.tag || 'Object') + ']'); };
if (prop === 'valueOf') return function valueOf() { __push({ type: 'call', path: name + '.valueOf', args: [] }); return primitive || 0; };
const key = String(prop);
__push({ type: 'get', path: name + '.' + key });
if (!cache[key]) cache[key] = traceProxy(name + '.' + key);
return cache[key];
},
set(t, prop, value) { const key = String(prop); __push({ type: 'set', path: name + '.' + key, value: __safe(value), valueType: typeof value }); cache[key] = value; return true; },
has(t, prop) { __push({ type: 'has', path: name, prop: String(prop) }); return Object.prototype.hasOwnProperty.call(cache, String(prop)); },
ownKeys() { __push({ type: 'ownKeys', path: name }); return Object.keys(cache); },
getOwnPropertyDescriptor(t, prop) { __push({ type: 'getOwnPropertyDescriptor', path: name, prop: String(prop) }); return cache[String(prop)] ? { value: cache[String(prop)], enumerable: true, configurable: true, writable: true } : undefined; },
getPrototypeOf() { __push({ type: 'getPrototypeOf', path: name }); return Function.prototype; },
apply(t, thisArg, args) { __push({ type: 'call', path: name, args: Array.from(args).map(v => typeof v) }); return traceProxy(name + '()'); },
construct(t, args) { __push({ type: 'new', path: name, args: Array.from(args).map(v => typeof v) }); return traceProxy('new ' + name); },
});
}
function makeStorage(name, initial) {
const data = new Map(Object.entries(initial || {}).map(([k, v]) => [String(k), String(v)]));
const storage = {
get length() { __push({ type: 'get', path: name + '.length' }); return data.size; },
key(i) { __push({ type: 'call', path: name + '.key', args: ['number'] }); return Array.from(data.keys())[Number(i)] ?? null; },
getItem(k) { __push({ type: 'call', path: name + '.getItem', args: ['string'] }); k = String(k); return data.has(k) ? data.get(k) : null; },
setItem(k, v) { __push({ type: 'call', path: name + '.setItem', args: ['string', 'string'] }); data.set(String(k), String(v)); },
removeItem(k) { __push({ type: 'call', path: name + '.removeItem', args: ['string'] }); data.delete(String(k)); },
clear() { __push({ type: 'call', path: name + '.clear', args: [] }); data.clear(); },
};
Object.defineProperty(storage, '__data', { value: data, enumerable: false });
return storage;
}
function parseLocation(raw) {
const s = String(raw || 'https://example.com/');
const m = s.match(/^([a-z][a-z0-9+.-]*:)\\/\\/([^\\/?#]*)([^?#]*)(\\?[^#]*)?(#.*)?$/i);
if (!m) return { href: s, origin: '', protocol: '', host: '', hostname: '', port: '', pathname: '', search: '', hash: '', toString() { return this.href; } };
const protocol = m[1], host = m[2], pathname = m[3] || '/', search = m[4] || '', hash = m[5] || '';
const hp = host.split(':');
return { href: protocol + '//' + host + pathname + search + hash, origin: protocol + '//' + host, protocol, host, hostname: hp[0], port: hp[1] || '', pathname, search, hash, toString() { return this.href; } };
}
class URLSearchParams {
constructor(init) {
this._pairs = [];
if (typeof init === 'string') {
const text = init.replace(/^\\?/, '');
if (text) for (const part of text.split('&')) {
if (!part) continue;
const idx = part.indexOf('=');
const k = idx >= 0 ? part.slice(0, idx) : part;
const v = idx >= 0 ? part.slice(idx + 1) : '';
this.append(decodeURIComponent(k.replace(/\\+/g, ' ')), decodeURIComponent(v.replace(/\\+/g, ' ')));
}
} else if (init && typeof init === 'object') {
for (const k of Object.keys(init)) this.append(k, init[k]);
}
}
append(k, v) { this._pairs.push([String(k), String(v)]); }
set(k, v) { k = String(k); this._pairs = this._pairs.filter(p => p[0] !== k); this.append(k, v); }
get(k) { k = String(k); const p = this._pairs.find(x => x[0] === k); return p ? p[1] : null; }
has(k) { k = String(k); return this._pairs.some(x => x[0] === k); }
toString() { return this._pairs.map(p => encodeURIComponent(p[0]) + '=' + encodeURIComponent(p[1])).join('&'); }
}
globalThis.URLSearchParams = URLSearchParams;
class URL {
constructor(input, base) {
const loc = parseLocation(input || base || 'https://example.com/');
Object.assign(this, loc);
this.searchParams = new URLSearchParams(this.search);
}
toString() { return this.href; }
}
globalThis.URL = URL;
class TextEncoder {
encode(input) {
const s = unescape(encodeURIComponent(String(input)));
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
}
}
class TextDecoder {
decode(input) {
const arr = Array.from(input || []);
return decodeURIComponent(escape(String.fromCharCode.apply(null, arr)));
}
}
globalThis.TextEncoder = TextEncoder;
globalThis.TextDecoder = TextDecoder;
const __b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
function btoa(input) {
let str = String(input), output = '';
for (let block = 0, charCode, i = 0, map = __b64chars; str.charAt(i | 0) || (map = '=', i % 1); output += map.charAt(63 & block >> 8 - i % 1 * 8)) {
charCode = str.charCodeAt(i += 3 / 4);
if (charCode > 0xFF) throw new Error('字符超出 Latin1 范围');
block = block << 8 | charCode;
}
return output;
}
function atob(input) {
let str = String(input).replace(/=+$/, ''), output = '';
if (str.length % 4 === 1) throw new Error('无效 base64');
for (let bc = 0, bs = 0, buffer, i = 0; buffer = str.charAt(i++); ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0) {
buffer = __b64chars.indexOf(buffer);
}
return output;
}
globalThis.atob = atob;
globalThis.btoa = btoa;
const console = {};
for (const name of ['log', 'warn', 'error', 'info', 'debug', 'trace', 'group', 'groupEnd']) {
console[name] = function() { __push({ type: 'console', path: 'console.' + name, args: Array.from(arguments).map(v => typeof v) }); };
}
globalThis.console = console;
let __timerId = 1;
globalThis.setTimeout = function(fn, delay) { __push({ type: 'call', path: 'setTimeout', args: [typeof fn, typeof delay] }); return __timerId++; };
globalThis.clearTimeout = function(id) { __push({ type: 'call', path: 'clearTimeout', args: [typeof id] }); };
globalThis.setInterval = function(fn, delay) { __push({ type: 'call', path: 'setInterval', args: [typeof fn, typeof delay] }); return __timerId++; };
globalThis.clearInterval = function(id) { __push({ type: 'call', path: 'clearInterval', args: [typeof id] }); };
globalThis.window = globalThis;
globalThis.self = globalThis;
globalThis.top = globalThis;
globalThis.parent = globalThis;
const browser = __fixture.browser || {};
const request = __fixture.request || {};
const runtime = __fixture.runtime || {};
globalThis.location = parseLocation(__fixture.pageUrl || __fixture.url || __fixture.apiUrl || 'https://example.com/');
globalThis.screen = Object.assign({ width: 1920, height: 1080, availWidth: 1920, availHeight: 1040, colorDepth: 24, pixelDepth: 24 }, browser.screen || {});
globalThis.devicePixelRatio = browser.devicePixelRatio || 1;
globalThis.history = traceProxy('history');
const __navigatorData = {
userAgent: browser.userAgent || '',
language: browser.language || 'zh-CN',
languages: browser.languages || [browser.language || 'zh-CN', 'zh'],
platform: browser.platform || 'Win32',
hardwareConcurrency: browser.hardwareConcurrency || 8,
deviceMemory: browser.deviceMemory || 8,
webdriver: false,
plugins: traceProxy('navigator.plugins', { tag: 'PluginArray' }),
mimeTypes: traceProxy('navigator.mimeTypes', { tag: 'MimeTypeArray' }),
};
globalThis.navigator = {};
for (const key of Object.keys(__navigatorData)) {
Object.defineProperty(globalThis.navigator, key, {
get() { __push({ type: 'get', path: 'navigator.' + key }); return __navigatorData[key]; },
enumerable: true,
configurable: true,
});
}
class Document {}
class HTMLDocument extends Document {}
globalThis.Document = Document;
globalThis.HTMLDocument = HTMLDocument;
const document = new HTMLDocument();
let cookieText = request.cookies && typeof request.cookies === 'object' ? Object.entries(request.cookies).map(([k, v]) => k + '=' + v).join('; ') : '';
Object.defineProperty(Document.prototype, 'cookie', {
get() { __push({ type: 'get', path: 'document.cookie' }); return cookieText; },
set(v) { __push({ type: 'set', path: 'document.cookie', valueType: typeof v }); cookieText = cookieText ? cookieText + '; ' + String(v).split(';')[0] : String(v).split(';')[0]; },
enumerable: true,
configurable: true,
});
document.URL = globalThis.location.href;
document.referrer = __fixture.referrer || '';
document.createElement = function(tag) { __push({ type: 'call', path: 'document.createElement', args: ['string'] }); return traceProxy('document.createElement(' + String(tag) + ')', { tag: String(tag).toUpperCase() }); };
document.querySelector = function() { __push({ type: 'call', path: 'document.querySelector', args: ['string'] }); return null; };
document.querySelectorAll = function() { __push({ type: 'call', path: 'document.querySelectorAll', args: ['string'] }); return []; };
Object.defineProperty(document, 'all', { get() { __push({ type: 'get', path: 'document.all' }); return undefined; }, enumerable: false, configurable: true });
globalThis.document = document;
globalThis.localStorage = makeStorage('localStorage', request.localStorage || __fixture.localStorage || {});
globalThis.sessionStorage = makeStorage('sessionStorage', request.sessionStorage || __fixture.sessionStorage || {});
const randomBytes = Array.isArray(runtime.randomBytes) ? runtime.randomBytes : [];
globalThis.crypto = {
getRandomValues(arr) { __push({ type: 'call', path: 'crypto.getRandomValues', args: [arr && arr.constructor && arr.constructor.name || typeof arr] }); for (let i = 0; i < arr.length; i++) arr[i] = randomBytes[i] ?? 0; return arr; },
randomUUID() { __push({ type: 'call', path: 'crypto.randomUUID', args: [] }); return runtime.randomUUID || '00000000-0000-4000-8000-000000000000'; },
subtle: traceProxy('crypto.subtle'),
};
globalThis.performance = {
now() { __push({ type: 'call', path: 'performance.now', args: [] }); return runtime.performanceNow ?? 0; },
timeOrigin: runtime.timeOrigin ?? runtime.now ?? Date.now(),
};
globalThis.fetch = function fetchStub() { __push({ type: 'call', path: 'fetch', args: Array.from(arguments).map(v => typeof v) }); return Promise.reject(new Error('补环境探测模式默认不发起真实网络请求')); };
for (const name of ['XMLHttpRequest', 'Image', 'HTMLElement', 'HTMLCanvasElement', 'WebGLRenderingContext', 'Headers', 'Request', 'Response', 'WebSocket', 'Worker', 'MessageChannel']) {
globalThis[name] = traceProxy(name);
}
if (runtime.now) {
const RealDate = Date;
function FixedDate() {
const args = Array.from(arguments);
if (!(this instanceof FixedDate)) return new RealDate(args.length ? args[0] : runtime.now).toString();
return new RealDate(args.length ? args[0] : runtime.now);
}
FixedDate.now = function() { return runtime.now; };
FixedDate.parse = RealDate.parse;
FixedDate.UTC = RealDate.UTC;
FixedDate.prototype = RealDate.prototype;
globalThis.Date = FixedDate;
}
function __resolveEntry(entry) {
const parts = String(entry || '').split('.').filter(Boolean);
if (parts[0] === 'globalThis' || parts[0] === 'window') parts.shift();
let cur = globalThis;
for (const part of parts) { if (cur == null) return undefined; cur = cur[part]; }
return cur;
}
function __serialize(v) {
if (v == null || ['string', 'number', 'boolean'].includes(typeof v)) return v;
if (typeof v === 'bigint') return String(v);
if (typeof v === 'function') return '[Function ' + (v.name || 'anonymous') + ']';
try { return JSON.parse(JSON.stringify(v)); } catch (_) { return String(v); }
}
Object.defineProperty(globalThis, '__callEntry', { value: function(entry, argsJson) {
const fn = __resolveEntry(entry);
if (typeof fn !== 'function') return JSON.stringify({ entryFound: false, output: undefined, error: { name: 'EntryError', message: '入口不是函数或不存在:' + entry } });
try {
const args = JSON.parse(argsJson || '[]');
const ret = fn.apply(globalThis, Array.isArray(args) ? args : []);
if (ret && typeof ret.then === 'function') return JSON.stringify({ entryFound: true, output: '[Promise]', async: true });
return JSON.stringify({ entryFound: true, output: __serialize(ret), outputType: typeof ret });
} catch (err) {
return JSON.stringify({ entryFound: true, output: undefined, error: { name: err && err.name || 'Error', message: err && err.message || String(err) } });
}
}, enumerable: false, configurable: true });
Object.defineProperty(globalThis, '__dumpEvents', { value: function() { return JSON.stringify(__events); }, enumerable: false, configurable: true });
Object.defineProperty(globalThis, '__leakageCheck', { value: function() {
let functionProcess = 'error';
let objectCtorProcess = 'error';
let arrayCtorProcess = 'error';
try { functionProcess = Function('return typeof process')(); } catch (err) { functionProcess = 'error:' + err.message; }
try { objectCtorProcess = ({}).constructor.constructor('return typeof process')(); } catch (err) { objectCtorProcess = 'error:' + err.message; }
try { arrayCtorProcess = ([]).constructor.constructor('return typeof process')(); } catch (err) { arrayCtorProcess = 'error:' + err.message; }
const navUserAgent = typeof navigator !== 'undefined' && navigator ? String(navigator.userAgent || '') : 'undefined';
const perfHas = function(name) { try { return typeof performance !== 'undefined' && performance ? (name in performance) : false; } catch (_) { return false; } };
return JSON.stringify({
process: typeof process,
Buffer: typeof Buffer,
require: typeof require,
module: typeof module,
exports: typeof exports,
global: typeof global,
setImmediate: typeof setImmediate,
clearImmediate: typeof clearImmediate,
functionProcess,
objectCtorProcess,
arrayCtorProcess,
navigator: typeof navigator,
navigatorUserAgent: navUserAgent,
navigatorIsNodeUserAgent: /^Node\\.js\\//.test(navUserAgent),
navigatorLocks: typeof navigator !== 'undefined' && navigator ? typeof navigator.locks : 'undefined',
performance: typeof performance,
performanceNodeTiming: perfHas('nodeTiming') ? 'present' : 'absent',
performanceEventLoopUtilization: perfHas('eventLoopUtilization') ? 'present' : 'absent',
performanceTimerify: perfHas('timerify') ? 'present' : 'absent',
performanceMarkResourceTiming: perfHas('markResourceTiming') ? 'present' : 'absent',
localStorage: typeof localStorage,
localStorageConstructor: typeof localStorage !== 'undefined' && localStorage && localStorage.constructor ? localStorage.constructor.name : 'undefined',
sessionStorage: typeof sessionStorage,
sessionStorageConstructor: typeof sessionStorage !== 'undefined' && sessionStorage && sessionStorage.constructor ? sessionStorage.constructor.name : 'undefined',
fetch: typeof fetch,
webSocket: typeof WebSocket,
broadcastChannel: typeof BroadcastChannel,
messageChannel: typeof MessageChannel,
crypto: typeof crypto,
cryptoConstructor: typeof crypto !== 'undefined' && crypto && crypto.constructor ? crypto.constructor.name : 'undefined',
subtleCrypto: typeof crypto !== 'undefined' && crypto && crypto.subtle ? typeof crypto.subtle : 'undefined',
URL: typeof URL,
URLConstructor: typeof URL !== 'undefined' && URL ? URL.name : 'undefined',
URLSearchParams: typeof URLSearchParams,
TextEncoder: typeof TextEncoder,
TextDecoder: typeof TextDecoder,
ReadableStream: typeof ReadableStream,
EventTarget: typeof EventTarget,
AbortController: typeof AbortController,
WebAssembly: typeof WebAssembly,
queueMicrotask: typeof queueMicrotask,
});
}, enumerable: false, configurable: true });
`;
}
function classifyError(err) {
const message = err && err.message ? err.message : String(err);
const name = err && err.name ? err.name : 'Error';
let m = message.match(/^([A-Za-z_$][\w$]*) is not defined$/);
if (name === 'ReferenceError' && m) return { type: 'missing-global', name, message, missing: m[1] };
m = message.match(/Cannot read (?:properties|property) of undefined \(reading ['"]([^'"]+)['"]\)/);
if (m) return { type: 'missing-property', name, message, missing: m[1] };
m = message.match(/([A-Za-z0-9_$\.]+) is not a function/);
if (m) return { type: 'missing-method', name, message, missing: m[1] };
return { type: 'runtime-error', name, message };
}
function summarize(events, errors, addonResult, leakageCheck) {
const missingGlobals = [], missingMethods = [], missingProperties = [], specialObjects = [], proxyRiskSignals = [];
for (const e of errors) {
if (e.type === 'missing-global') missingGlobals.push(e.missing);
if (e.type === 'missing-method') missingMethods.push(e.missing);
if (e.type === 'missing-property') missingProperties.push(e.missing);
}
for (const e of events) {
if (e.path === 'document.all' || (e.path && e.path.startsWith('document.all.'))) specialObjects.push('document.all');
if (['ownKeys', 'getOwnPropertyDescriptor', 'getPrototypeOf', 'toPrimitive'].includes(e.type)) proxyRiskSignals.push(`${e.type}:${e.path || e.prop || ''}`);
if (e.path && e.path.endsWith('.toString')) proxyRiskSignals.push(`toString:${e.path}`);
}
const uniq = a => [...new Set(a)].filter(Boolean);
return {
eventCount: events.length,
missingGlobals: uniq(missingGlobals),
missingMethods: uniq(missingMethods),
missingProperties: uniq(missingProperties),
specialObjects: uniq(specialObjects),
nativeAddon: { available: !!(addonResult && addonResult.available), path: addonResult && addonResult.path || '', exports: addonResult && addonResult.exports || [] },
nodeLeakage: leakageCheck || {},
proxyRiskSignals: uniq(proxyRiskSignals).slice(0, 100),
runtimeErrors: errors,
};
}
function run(args) {
if (!args.target) throw new Error('必须提供 --target');
const targetFiles = args.target.split(',').map(s => s.trim()).filter(Boolean).map(p => path.resolve(p));
for (const f of targetFiles) if (!fs.existsSync(f)) throw new Error(`目标 JS 文件不存在:${f}`);
const fixture = readJson(args.fixture);
const addonResult = loadNativeAddon({ addon: args.addon });
const context = vm.createContext({}, { name: 'web-js-env-patcher' });
const errors = [];
let entryResult;
let entryFound = false;
new vm.Script(bootstrapSource(fixture), { filename: 'web-js-env-patcher-bootstrap.js' }).runInContext(context, { timeout: args.timeout });
if (addonResult && addonResult.available && addonResult.addon && addonResult.addon.createUndetectable) {
try {
context.__documentAllAddon = addonResult.addon.createUndetectable(function () { return undefined; });
vm.runInContext("Object.defineProperty(document, 'all', { get() { __push({ type: 'get', path: 'document.all' }); return globalThis.__documentAllAddon; }, enumerable: false, configurable: true });", context, { timeout: args.timeout });
} catch (err) {
errors.push({ type: 'native-addon-error', name: err.name || 'Error', message: err.message || String(err) });
}
}
for (const file of targetFiles) {
try {
const script = new vm.Script(fs.readFileSync(file, 'utf8'), { filename: file });
script.runInContext(context, { timeout: args.timeout });
} catch (err) { errors.push(classifyError(err)); }
}
const entry = args.entry || fixture.entry || '';
if (entry) {
try {
const callCode = `__callEntry(${JSON.stringify(entry)}, ${JSON.stringify(JSON.stringify(Array.isArray(fixture.args) ? fixture.args : []))})`;
const callResult = JSON.parse(vm.runInContext(callCode, context, { timeout: args.timeout }));
entryFound = !!callResult.entryFound;
entryResult = callResult.output;
if (!entryFound) errors.push({ type: 'entry-not-found', name: 'EntryError', message: `入口不是函数或不存在:${entry}`, missing: entry });
if (callResult.error) errors.push(classifyError(callResult.error));
} catch (err) { errors.push(classifyError(err)); }
}
let events = [];
let leakageCheck = {};
try { events = JSON.parse(vm.runInContext('__dumpEvents()', context, { timeout: args.timeout })); } catch (_) { events = []; }
try { leakageCheck = JSON.parse(vm.runInContext('__leakageCheck()', context, { timeout: args.timeout })); } catch (_) { leakageCheck = {}; }
const output = { entry, entryFound, output: entryResult, errors };
const summary = summarize(events, errors, addonResult, leakageCheck);
if (args.trace) { ensureParent(args.trace); fs.writeFileSync(args.trace, events.map(e => JSON.stringify(e)).join('\n') + (events.length ? '\n' : ''), 'utf8'); }
if (args.summary) { ensureParent(args.summary); fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + '\n', 'utf8'); }
if (args.output) { ensureParent(args.output); fs.writeFileSync(args.output, JSON.stringify(output, null, 2) + '\n', 'utf8'); }
return { targetFiles, fixture: args.fixture || '', trace: args.trace || '', summaryFile: args.summary || '', outputFile: args.output || '', output, summary };
}
function renderMarkdown(result) {
const lines = [];
lines.push('# Node.js 缺失环境追踪结果');
lines.push('');
lines.push(`- 目标 JS:${result.targetFiles.join(', ')}`);
lines.push(`- fixture:${result.fixture || '未提供'}`);
lines.push(`- 入口函数:${result.output.entry || '未指定'}`);
lines.push(`- 入口是否找到:${result.output.entry ? (result.output.entryFound ? '是' : '否') : '未指定'}`);
lines.push(`- trace 文件:${result.trace || '未写入'}`);
lines.push(`- 摘要文件:${result.summaryFile || '未写入'}`);
lines.push(`- 输出文件:${result.outputFile || '未写入'}`);
lines.push(`- native addon:${result.summary.nativeAddon.available ? '可用' : '不可用或未启用'}`);
lines.push(`- trace 事件数量:${result.summary.eventCount}`);
lines.push(`- 运行错误数量:${result.summary.runtimeErrors.length}`);
if (result.summary.nodeLeakage && Object.keys(result.summary.nodeLeakage).length) {
lines.push('');
lines.push('## Node 泄露自检');
for (const [k, v] of Object.entries(result.summary.nodeLeakage)) lines.push(`- ${k}:${v}`);
}
if (result.summary.runtimeErrors.length) {
lines.push('');
lines.push('## 运行错误');
for (const e of result.summary.runtimeErrors) lines.push(`- ${e.type}:${e.message}`);
}
if (result.summary.proxyRiskSignals.length) {
lines.push('');
lines.push('## Proxy 检测风险信号');
for (const e of result.summary.proxyRiskSignals.slice(0, 20)) lines.push(`- ${e}`);
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = run(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
if (result.output.errors.some(e => e.type === 'entry-not-found')) process.exit(2);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { input: '', out: '', stdout: false, append: false, requireChineseName: false, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--input' || a === '-i') args.input = argv[++i] || '';
else if (a === '--out' || a === '-o') args.out = argv[++i] || '';
else if (a === '--stdout') args.stdout = true;
else if (a === '--append') args.append = true;
else if (a === '--require-chinese-name') args.requireChineseName = 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}`);
}
return args;
}
function usage() {
return `用法:
node scripts/write_markdown_utf8.js --input 草稿.md --out 最终项目总结.md --require-chinese-name --markdown
node scripts/write_markdown_utf8.js --out 阶段报告.md --require-chinese-name --markdown < 草稿.md
说明:使用 UTF-8 写入 Markdown,避免 Windows PowerShell / cmd 默认编码导致中文变成问号。使用 --require-chinese-name 可强制输出 Markdown 文件名包含中文。`;
}
function readAllStdin() {
try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
}
function ensureParent(file) {
const dir = path.dirname(path.resolve(file));
fs.mkdirSync(dir, { recursive: true });
}
function hasChinese(text) {
return /[\u4e00-\u9fff]/.test(String(text || ''));
}
function hasReplacementQuestionMarks(text) {
const replacementRunPattern = new RegExp('\\?{3,}', 'g');
const runs = text.match(replacementRunPattern) || [];
const questionCharCount = runs.reduce((n, item) => n + item.length, 0);
const hasChinese = /[\u4e00-\u9fff]/.test(text);
return questionCharCount >= 8 && !hasChinese;
}
function main() {
const args = parseArgs(process.argv);
if (args.help || (!args.out && !args.stdout)) {
console.log(usage());
process.exit(args.help ? 0 : 1);
}
const input = (args.input ? fs.readFileSync(args.input, 'utf8') : readAllStdin()).replace(/^\uFEFF/, '');
if (!input) throw new Error('缺少要写入的 Markdown 内容');
if (hasReplacementQuestionMarks(input)) {
throw new Error('输入内容疑似已经发生中文编码损坏:检测到大量问号且没有中文字符;请从原始结论重新生成后再写入。');
}
if (args.stdout) process.stdout.write(input, 'utf8');
if (args.out) {
if (args.requireChineseName && !hasChinese(path.basename(args.out))) throw new Error(`输出 Markdown 文件名必须包含中文:${args.out}`);
ensureParent(args.out);
if (args.append) fs.appendFileSync(args.out, input, 'utf8');
else fs.writeFileSync(args.out, input, 'utf8');
}
const result = { out: args.out || '', bytes: Buffer.byteLength(input, 'utf8'), encoding: 'utf8', chineseFileName: args.out ? hasChinese(path.basename(args.out)) : true, ok: true };
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) {
console.log('# Markdown UTF-8 写入结果');
console.log('');
console.log(`- 输出文件:${result.out || 'stdout'}`);
console.log(`- 编码:${result.encoding}`);
console.log(`- 中文文件名:${result.chineseFileName ? '是' : '否'}`);
console.log(`- 字节数:${result.bytes}`);
console.log('- 状态:写入完成,中文应正常显示');
}
}
try {
main();
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,419 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const STAGES = {
'需求信息确认': { index: '01', title: '需求信息确认' },
'intake': { index: '01', title: '需求信息确认' },
'取证方案确认': { index: '02', title: '取证方案确认' },
'forensics': { index: '02', title: '取证方案确认' },
'请求样本与可疑参数确认': { index: '03', title: '请求样本与可疑参数确认' },
'params': { index: '03', title: '请求样本与可疑参数确认' },
'JS文件与入口定位': { index: '04', title: 'JS文件与入口定位' },
'entry': { index: '04', title: 'JS文件与入口定位' },
'补环境前置分析': { index: '05', title: '补环境前置分析' },
'pre-env': { index: '05', title: '补环境前置分析' },
'补环境实现记录': { index: '06', title: '补环境实现记录' },
'env': { index: '06', title: '补环境实现记录' },
'验证与清理记录': { index: '07', title: '验证与清理记录' },
'validation': { index: '07', title: '验证与清理记录' },
};
function parseArgs(argv) {
const args = {
caseDir: '',
stage: '',
index: '',
template: '',
input: '',
data: '',
out: '',
append: false,
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--stage' || a === '-s') args.stage = argv[++i] || '';
else if (a === '--index') args.index = argv[++i] || '';
else if (a === '--template') args.template = argv[++i] || '';
else if (a === '--input' || a === '-i') args.input = argv[++i] || '';
else if (a === '--data') args.data = argv[++i] || '';
else if (a === '--out' || a === '-o') args.out = argv[++i] || '';
else if (a === '--append') args.append = 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/write_stage_report.js --case-dir case --stage 需求信息确认 --data case/notes/需求信息.json --markdown
node scripts/write_stage_report.js --case-dir case --stage 请求样本与可疑参数确认 --input case/tmp/可疑参数草稿.md --markdown
node scripts/write_stage_report.js --case-dir case --stage WebAPI补齐阶段报告 --index 08 --data case/notes/阶段进展.json --markdown
node scripts/write_stage_report.js --case-dir case --stage Bug修复与回归测试报告 --index 11 --append --input case/tmp/回归测试.md --json
说明:以 UTF-8 写入中文命名阶段报告,固定阶段默认输出到 case/阶段报告/<编号-阶段名>.md;自定义中文阶段可用 --index 生成 <编号-阶段名>.md。`;
}
function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
function readText(file) { return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); }
function readStdin() { try { return fs.readFileSync(0, 'utf8').replace(/^\uFEFF/, ''); } catch { return ''; } }
function hasChinese(s) { return /[\u4e00-\u9fff]/.test(String(s || '')); }
function ensureParent(file) { fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); }
function hasBadQuestionMarks(text) {
const runs = text.match(/\?{3,}/g) || [];
const count = runs.reduce((n, x) => n + x.length, 0);
return count >= 8 && !hasChinese(text);
}
function loadJson(file) {
if (!file) return {};
return JSON.parse(readText(file));
}
function normalizeIndex(index) {
const s = String(index || '').trim();
if (!s) return '';
if (!/^\d{1,3}$/.test(s)) throw new Error(`阶段编号必须是 1 到 3 位数字:${s}`);
return s.padStart(2, '0');
}
function normalizeStage(stage, index) {
const s = String(stage || '').trim();
const ret = STAGES[s];
const normalizedIndex = normalizeIndex(index);
if (ret) return normalizedIndex ? { index: normalizedIndex, title: ret.title } : ret;
if (!s) throw new Error('必须提供 --stage');
if (!hasChinese(s)) throw new Error(`未知阶段:${s}。自定义阶段名称必须包含中文。`);
return { index: normalizedIndex || '自定义', title: s };
}
function defaultOut(caseDir, stage) {
const file = stage.index === '自定义' ? `${stage.title}.md` : `${stage.index}-${stage.title}.md`;
return path.join(caseDir, '阶段报告', file);
}
function maskSensitiveString(s) {
return String(s)
.replace(/(authorization\s*[:=]\s*)([^\s;]+)/ig, '$1已脱敏')
.replace(/(cookie\s*[:=]\s*)([^\n]+)/ig, '$1已脱敏')
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, (m) => `${m.slice(0, 6)}...${m.slice(-6)}(已脱敏)`)
.replace(/\b[A-Za-z0-9_-]{32,}\b/g, (m) => /\d/.test(m) ? `${m.slice(0, 4)}...${m.slice(-4)}(已脱敏)` : m);
}
function safeString(value) {
if (value === undefined || value === null || value === '') return '未提供';
if (Array.isArray(value)) return value.length ? value.map(safeString).join('、') : '未提供';
if (typeof value === 'object') return Object.keys(value).length ? maskSensitiveString(JSON.stringify(value, null, 2)) : '未提供';
const s = String(value);
if (/^(authorization|cookie|token)$/i.test(s)) return '已脱敏';
return maskSensitiveString(s);
}
function asArray(value) {
if (value === undefined || value === null || value === '') return [];
if (Array.isArray(value)) return value;
return [value];
}
function bulletLines(value, empty = '无') {
const arr = asArray(value);
if (!arr.length) return [`- ${empty}`];
const lines = [];
for (const item of arr) {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const text = Object.entries(item).map(([k, v]) => `${k}:${safeString(v)}`).join(';');
lines.push(`- ${text || empty}`);
} else {
lines.push(`- ${safeString(item)}`);
}
}
return lines;
}
function numberedLines(value, empty = '待补充') {
const arr = asArray(value);
if (!arr.length) return [`1. ${empty}`];
return arr.map((item, idx) => `${idx + 1}. ${safeString(item)}`);
}
function firstValue(obj, keys, fallback = '') {
if (!obj || typeof obj !== 'object') return fallback;
for (const key of keys) {
if (obj[key] !== undefined && obj[key] !== null && obj[key] !== '') return obj[key];
}
return fallback;
}
function tableLines(rows, columns, emptyText) {
const normalizedRows = asArray(rows).filter(Boolean);
const header = `| ${columns.map(c => c.title).join(' | ')} |`;
const sep = `| ${columns.map(() => '---').join(' | ')} |`;
const lines = [header, sep];
if (!normalizedRows.length) {
lines.push(`| ${columns.map((_, i) => i === 0 ? emptyText : '未提供').join(' | ')} |`);
return lines;
}
for (const row of normalizedRows) {
if (typeof row === 'object' && !Array.isArray(row)) {
lines.push(`| ${columns.map(c => safeString(firstValue(row, c.keys))).join(' | ')} |`);
} else {
lines.push(`| ${columns.map((_, i) => i === 0 ? safeString(row) : '未提供').join(' | ')} |`);
}
}
return lines;
}
function renderProgress(progress) {
const p = progress && typeof progress === 'object' && !Array.isArray(progress) ? progress : { done: progress };
return [
`- 已完成:${safeString(firstValue(p, ['done', 'finished', 'completed'], '未提供'))}`,
`- 进行中:${safeString(firstValue(p, ['doing', 'current', 'inProgress'], '未提供'))}`,
`- 尚未开始:${safeString(firstValue(p, ['todo', 'pending', 'notStarted'], '未提供'))}`,
`- 阻塞点:${safeString(firstValue(p, ['blockers', 'blocked', 'issues'], '无'))}`,
];
}
function renderIntakeReport(stage, data) {
const lines = [
`# 阶段报告:${stage.title}`,
'',
`生成时间:${new Date().toISOString()}`,
`阶段状态:${safeString(data.status || data.stageStatus || '待确认')}`,
'',
'## 1. 用户已提供信息',
'',
];
const fields = [
['目标网站 URL', data.targetUrl || data.siteUrl || data.url],
['目标页面 URL', data.pageUrl || data.page],
['目标 API', data.apiUrl || data.api],
['请求方法', data.method],
['加密参数', data.cryptoParams || data.params || data.param],
['参数位置', data.paramLocation || data.position],
['取证模式', data.acquisitionMode || data.forensicsMode],
['最终请求 TLS 指纹兼容客户端', data.tlsClient],
['已知 JS 文件 / 加密文件', data.jsFiles || data.cryptoFiles],
['是否需要登录', data.loginRequired],
];
for (const [k, v] of fields) lines.push(`- ${k}:${safeString(v)}`);
lines.push('', '## 2. 已提供样本与证据', '');
for (const [k, v] of [
['cURL / HAR', data.requestSample || data.curl || data.har],
['响应样本', data.responseSample],
['浏览器 fixture', data.fixture],
['RuyiTrace NDJSON', data.ruyiTrace],
['Camoufox / CloakBrowser / ruyiPage 取证记录', data.browserEvidence],
]) lines.push(`- ${k}:${safeString(v)}`);
lines.push('', '## 3. 缺失信息与阻塞点', '');
lines.push(`- 缺失项:${safeString(data.missingItems || data.missing)}`);
lines.push(`- 阻塞原因:${safeString(data.blockers)}`);
lines.push(`- 需要用户确认:${safeString(data.needUserConfirm || data.confirmation)}`);
lines.push('', '## 4. 下一步计划', '');
const next = data.nextSteps || ['校验请求样本完整性', '列出所有可疑加密参数并等待用户确认', '确认取证工具和 TLS 请求客户端可用性'];
lines.push(...numberedLines(next));
return lines.join('\n') + '\n';
}
function renderDynamicReport(stage, data) {
const title = stage.title;
const protection = data.realismProtection || data.protection || {};
const cleanup = data.cleanup || {};
const lines = [
`# 阶段报告:${title}`,
'',
`生成时间:${new Date().toISOString()}`,
`阶段状态:${safeString(data.status || data.stageStatus || '待确认')}`,
'',
'## 1. 当前阶段目标',
'',
`- 本阶段要解决的问题:${safeString(data.goal || data.target || data.summary || data.conclusion)}`,
`- 本阶段范围:${safeString(data.scope || data.range)}`,
`- 不在本阶段处理的内容:${safeString(data.outOfScope || data.exclude)}`,
'',
'## 2. 当前项目进展',
'',
];
lines.push(...renderProgress(data.progress));
lines.push('', '## 3. 本阶段修改文件', '');
lines.push(...tableLines(data.changedFiles || data.files, [
{ title: '文件', keys: ['file', 'path', 'name'] },
{ title: '修改类型', keys: ['type', 'changeType', 'action'] },
{ title: '修改原因', keys: ['reason', 'why'] },
{ title: '影响范围', keys: ['impact', 'scope'] },
], '无修改文件'));
lines.push('', '## 4. Trace 计划内首轮实现 / 调整的 WebAPI', '');
lines.push(...tableLines(data.traceWebApis || data.plannedWebApis || data.webApis || data.webAPIs || data.apis, [
{ title: 'WebAPI', keys: ['api', 'webApi', 'name'] },
{ title: '挂载位置', keys: ['mount', 'mountPoint', 'location'] },
{ title: '类型', keys: ['type', 'kind'] },
{ title: '实现方式', keys: ['implementation', 'impl', 'strategy'] },
{ title: '是否 addon-first', keys: ['addonFirst', 'addon', 'addon_first'] },
{ title: 'Trace 矩阵状态', keys: ['traceStatus', 'matrixStatus', 'status'] },
{ title: '证据来源', keys: ['evidence', 'source'] },
{ title: '测试结果', keys: ['test', 'result', 'testResult'] },
], '无 Trace 计划内 WebAPI 调整'));
lines.push('', '## 4a. 计划外新增 WebAPI 与原因', '');
lines.push(...tableLines(data.unplannedWebApis || data.unplannedAPIs || data.unplannedApis, [
{ title: 'WebAPI', keys: ['api', 'webApi', 'name'] },
{ title: '新增原因', keys: ['reason', 'cause'] },
{ title: '为什么未在 Trace 覆盖矩阵首轮处理', keys: ['whyMissed', 'missedReason'] },
{ title: '证据', keys: ['evidence', 'source'] },
{ title: '处理结果', keys: ['result', 'handling'] },
], '无计划外新增 WebAPI'));
const traceRuntime = data.traceRuntimeClosure || data.traceRuntime || {};
lines.push('', '## 4b. Trace-runtime 可执行闭环', '');
if (typeof traceRuntime === 'object' && !Array.isArray(traceRuntime)) {
lines.push(`- contract:${safeString(firstValue(traceRuntime, ['contract', 'contractFile']))}`);
lines.push(`- Node audit:${safeString(firstValue(traceRuntime, ['nodeAudit', 'audit']))}`);
lines.push(`- traceSourceHash:${safeString(firstValue(traceRuntime, ['traceSourceHash']))}`);
lines.push(`- contractHash:${safeString(firstValue(traceRuntime, ['contractHash']))}`);
lines.push(`- runtimeSourceHash:${safeString(firstValue(traceRuntime, ['runtimeSourceHash']))}`);
lines.push(`- baselineId:${safeString(firstValue(traceRuntime, ['baselineId']))}`);
lines.push(`- P0/P1 mismatch:${safeString(firstValue(traceRuntime, ['blockingMismatches', 'mismatches']))}`);
lines.push(`- audit-only 真实网络尝试:${safeString(firstValue(traceRuntime, ['networkAttempts']))}`);
lines.push(`- 检查结果:${safeString(firstValue(traceRuntime, ['checkResult', 'result']))}`);
} else {
lines.push(...bulletLines(traceRuntime, '无 Trace'));
}
const xhrBridge = data.xhrFetchBridge || data.networkBridge || {};
lines.push('', '## 5. XHR/fetch Session Bridge', '');
if (typeof xhrBridge === 'object' && !Array.isArray(xhrBridge)) {
lines.push(`- 网络模式:${safeString(firstValue(xhrBridge, ['mode', 'networkMode'], '未涉及'))}`);
lines.push(`- Session 持有者:${safeString(firstValue(xhrBridge, ['sessionOwner', 'owner']))}`);
lines.push(`- TLS 客户端:${safeString(firstValue(xhrBridge, ['tlsClient', 'client']))}`);
lines.push(`- JS bridge 文件:${safeString(firstValue(xhrBridge, ['bridgeFile', 'jsBridgeFile']))}`);
lines.push(`- Cookie / Set-Cookie 同步:${safeString(firstValue(xhrBridge, ['cookieSync', 'cookies']))}`);
lines.push(`- 检查结果:${safeString(firstValue(xhrBridge, ['checkResult', 'result']))}`);
} else {
lines.push(...bulletLines(xhrBridge, '未涉及'));
}
const xhrSemantics = data.xhrFetchSemantics || data.xhrSemantics || data.networkSemantics || {};
lines.push('', '## 5a. XHR/fetch 请求语义审计', '');
if (typeof xhrSemantics === 'object' && !Array.isArray(xhrSemantics)) {
lines.push(`- 浏览器 transcript:${safeString(firstValue(xhrSemantics, ['browserTranscript', 'browser']))}`);
lines.push(`- Node transcript:${safeString(firstValue(xhrSemantics, ['nodeTranscript', 'node']))}`);
lines.push(`- no-send 模式:${safeString(firstValue(xhrSemantics, ['networkMode', 'mode']))}`);
lines.push(`- runtimeSourceHash:${safeString(firstValue(xhrSemantics, ['runtimeSourceHash']))}`);
lines.push(`- Header/body mismatch:${safeString(firstValue(xhrSemantics, ['requestMismatches', 'mismatches']))}`);
lines.push(`- 多余 Node 请求:${safeString(firstValue(xhrSemantics, ['extraNodeEvents', 'extraRequests']))}`);
lines.push(`- status=0 / responseURL:${safeString(firstValue(xhrSemantics, ['failureSemantics', 'statusResponseUrl']))}`);
lines.push(`- reload realm 清理:${safeString(firstValue(xhrSemantics, ['realmCleanup', 'reloadCleanup']))}`);
lines.push(`- 检查结果:${safeString(firstValue(xhrSemantics, ['checkResult', 'result']))}`);
} else {
lines.push(...bulletLines(xhrSemantics, '未涉及'));
}
const webapiMatrix = data.webapiEnvMatrix || data.webApiEnvMatrix || data.webapiMatrix || {};
lines.push('', '## 5b. WebAPI 环境检测矩阵', '');
if (typeof webapiMatrix === 'object' && !Array.isArray(webapiMatrix)) {
lines.push(`- 矩阵文件:${safeString(firstValue(webapiMatrix, ['matrix', 'matrixFile']))}`);
lines.push(`- 浏览器 baseline:${safeString(firstValue(webapiMatrix, ['browserBaseline', 'baseline']))}`);
lines.push(`- Node audit:${safeString(firstValue(webapiMatrix, ['nodeAudit', 'audit']))}`);
lines.push(`- baselineId:${safeString(firstValue(webapiMatrix, ['baselineId']))}`);
lines.push(`- 触发类别:${safeString(firstValue(webapiMatrix, ['categories', 'triggered']))}`);
lines.push(`- 阻断项:${safeString(firstValue(webapiMatrix, ['blockers', 'blocking']))}`);
} else {
lines.push(...bulletLines(webapiMatrix, '未触发'));
}
const objectShape = data.objectShapeAudit || data.shapeAudit || {};
lines.push('', '## 5c. 对象形状审计矩阵', '');
if (typeof objectShape === 'object' && !Array.isArray(objectShape)) {
lines.push(`- 矩阵文件:${safeString(firstValue(objectShape, ['matrix', 'matrixFile']))}`);
lines.push(`- 浏览器 baseline:${safeString(firstValue(objectShape, ['browserBaseline', 'baseline']))}`);
lines.push(`- Node audit:${safeString(firstValue(objectShape, ['nodeAudit', 'audit']))}`);
lines.push(`- 私有状态实现:${safeString(firstValue(objectShape, ['privateState', 'stateStrategy']))}`);
lines.push(`- _ / __ 自有属性泄露:${safeString(firstValue(objectShape, ['privateLeakage', 'leakage']))}`);
lines.push(`- 检查结果:${safeString(firstValue(objectShape, ['checkResult', 'result']))}`);
} else {
lines.push(...bulletLines(objectShape, '未触发'));
}
lines.push('', '## 6. 本阶段新增功能', '');
lines.push(...bulletLines(data.features || data.newFeatures, '无新增功能'));
lines.push('', '## 7. 本阶段修复的 Bug', '');
lines.push(...tableLines(data.bugs || data.bugFixes || data.fixes, [
{ title: 'Bug', keys: ['bug', 'title', 'issue'] },
{ title: '原因', keys: ['reason', 'cause'] },
{ title: '修复方式', keys: ['fix', 'solution'] },
{ title: '涉及文件', keys: ['files', 'file'] },
{ title: '验证结果', keys: ['test', 'result', 'validation'] },
{ title: '防回退记录', keys: ['memory', 'changeMemory', 'note'] },
], '无 Bug 修复'));
lines.push('', '## 8. 本阶段新增 / 修改的指纹能力', '');
lines.push(...tableLines(data.fingerprints || data.fingerprintCapabilities, [
{ title: '指纹类型', keys: ['type', 'fingerprintType'] },
{ title: 'API', keys: ['api', 'apis'] },
{ title: '实现策略', keys: ['strategy', 'implementation'] },
{ title: '样本来源', keys: ['source', 'sampleSource'] },
{ title: '回放方式', keys: ['replay', 'replayMode'] },
{ title: '风险', keys: ['risk', 'risks'] },
], '无指纹能力变化'));
lines.push('', '## 9. 真实性保护变化', '');
if (typeof protection === 'object' && !Array.isArray(protection)) {
lines.push(`- 函数 toString 保护:${safeString(firstValue(protection, ['functionToString', 'funcToString', 'function']))}`);
lines.push(`- 访问器 toString 保护:${safeString(firstValue(protection, ['accessorToString', 'accessor']))}`);
lines.push(`- 属性描述符:${safeString(firstValue(protection, ['descriptors', 'descriptor']))}`);
lines.push(`- 原型链:${safeString(firstValue(protection, ['prototypeChain', 'protoChain', 'prototype']))}`);
lines.push(`- 实例对象 [object Xxx]:${safeString(firstValue(protection, ['objectToString', 'instanceToString', 'instance']))}`);
lines.push(`- document.all / HTMLDDA:${safeString(firstValue(protection, ['documentAll', 'htmlDDA', 'htmldda']))}`);
lines.push(`- addon 使用情况:${safeString(firstValue(protection, ['addonUsage', 'addon']))}`);
lines.push(`- fallback 原因:${safeString(firstValue(protection, ['fallbackReason', 'fallback']))}`);
} else {
lines.push(...bulletLines(protection, '无真实性保护变化'));
}
lines.push('', '## 10. 本阶段测试内容与结果', '');
lines.push(...tableLines(data.tests || data.validations, [
{ title: '测试项', keys: ['name', 'test', 'item'] },
{ title: '命令 / 方法', keys: ['command', 'method', 'cmd'] },
{ title: '结果', keys: ['result', 'status'] },
{ title: '备注', keys: ['note', 'remark'] },
], '无测试记录'));
lines.push('', '## 11. 清理情况', '');
if (typeof cleanup === 'object' && !Array.isArray(cleanup)) {
lines.push(`- 已清理:${safeString(cleanup.removed || cleanup.cleaned)}`);
lines.push(`- 保留证据:${safeString(cleanup.kept || cleanup.evidence)}`);
lines.push(`- 敏感材料处理:${safeString(cleanup.sensitive || cleanup.sensitiveHandling)}`);
} else {
lines.push(...bulletLines(cleanup, '未记录清理情况'));
}
lines.push('', '## 12. 风险与遗留问题', '');
lines.push(...bulletLines(data.risks || data.leftoverRisks, '无'));
if (data.uncoveredSamples) lines.push(`- 未覆盖样本:${safeString(data.uncoveredSamples)}`);
if (data.needUserConfirm) lines.push(`- 需要用户确认:${safeString(data.needUserConfirm)}`);
lines.push('', '## 13. 下一步计划', '');
lines.push(...numberedLines(data.nextSteps, '待补充'));
return lines.join('\n') + '\n';
}
function renderDataReport(stage, data, template) {
const mode = String(template || '').trim().toLowerCase();
if (stage.title === '需求信息确认' && mode !== 'dynamic') return renderIntakeReport(stage, data);
return renderDynamicReport(stage, data);
}
function main() {
const args = parseArgs(process.argv);
if (args.help || !args.caseDir || !args.stage) {
console.log(usage());
process.exit(args.help ? 0 : 1);
}
const caseDir = path.resolve(args.caseDir);
const stage = normalizeStage(args.stage, args.index);
const out = path.resolve(args.out || defaultOut(caseDir, stage));
if (!hasChinese(path.basename(out))) throw new Error(`阶段报告文件名必须包含中文:${out}`);
let content = '';
if (args.input) content = readText(args.input);
else if (args.data) content = renderDataReport(stage, loadJson(args.data), args.template);
else content = readStdin();
if (!content.trim()) content = renderDataReport(stage, {}, args.template);
if (hasBadQuestionMarks(content) || content.includes('\uFFFD')) throw new Error('阶段报告内容疑似存在中文编码损坏,请重新生成草稿。');
ensureParent(out);
if (args.append && exists(out)) fs.appendFileSync(out, '\n' + content, 'utf8');
else fs.writeFileSync(out, content, 'utf8');
const result = { ok: true, stage: stage.title, index: stage.index, out, bytes: Buffer.byteLength(content, 'utf8'), encoding: 'utf8', chineseFileName: hasChinese(path.basename(out)) };
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) {
console.log('# 阶段报告写入结果');
console.log('');
console.log(`- 阶段:${result.stage}`);
console.log(`- 阶段编号:${result.index}`);
console.log(`- 输出文件:${result.out}`);
console.log(`- 中文文件名:${result.chineseFileName ? '是' : '否'}`);
console.log(`- 编码:${result.encoding}`);
console.log('- 状态:写入完成');
}
}
try { main(); } catch (err) { console.error(err.message || String(err)); console.error(usage()); process.exit(1); }