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