按 security-reports/ 的隐私审查台账逐项收口。纯脱敏与表述泛化, 不含任何业务逻辑变更;实测结论与排查步骤一字未动。 P1(可定位到具体设备/个人) - scripts/verify-b2.mjs:NA_URL 去掉硬编码缺省值(原指向一台真实机器),改为必填。 这是全仓库唯一「拿着就能连到某台真机」的常量。 - 测试账户名 pdverify → na-test(docs/VM-VERIFICATION.md 7 处、docs/console-plan.md 1 处) - 虚拟机与宿主的真实网段 → RFC 5737 文档保留段 192.0.2.x(含 CLOSE_WAIT 记录) - 跨项目名与私人称呼 → 中性表述(docs/PRD.md、docs/DEVELOPMENT.md、docs/console-plan.md 等) P2(低风险设备指纹) - 虚拟化产品及其 CLI / 访客工具名 → 中性表述(12 个文件 + 1 个工作流注释) - 测试目录 C:\nodeagent-test → C:\na-test - NAS 设备型号与系统名 → NAS 设备 / NAS 系统(docs/DEPLOY-LINUX.md、scripts/install-linux.sh、README.md) - 真机 CPU 型号、真机物理分辨率、代理软件品牌与监听端口、杀软品牌 → 泛化 - 单元测试示例段去掉真机 IP 尾八位:acl-v11 / net-scripts 的 fixture 与断言同步更新 - .gitignore 补 config.json / agent.json(含真实密钥与节点地址,绝不入库) - 顺带移除混入技术文档的「不构成投资建议」字样(与文档主题无关) 验证:pnpm test:unit 340/340 通过;独立扫描器对 235 个跟踪文件做 17 类模式 全量扫描,命中 0。
164 lines
6.5 KiB
JavaScript
164 lines
6.5 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 审计链外部锚定 —— **控制端**工具(v2.0.0)。
|
||
*
|
||
* ## 为什么判定要在控制端做
|
||
* 被控端是**不可信方**:它若被 root,既会重写 audit.log,也会把自己的锚点一并改掉,
|
||
* 并告诉你"一切正常"。所以"锚点存哪、和什么比"必须由**控制端**掌握。
|
||
*
|
||
* ## 两个子命令
|
||
* collect 拉被控端链头 → 生成一条锚点记录(JSONL 一行),供上传到链外
|
||
* verify 拉被控端链头 + 读指定锚点文件 → 给出判定结论(三方一致 / 被重写)
|
||
*
|
||
* ## 判定逻辑不在此处重写
|
||
* 复用 `@nodeagent/protocol` 的 `judgeAnchors`(与 agent 端**同一份实现**)——
|
||
* 若两端各写一份,同一份日志会出现"被控端说一致、控制端说被改"的荒谬局面。
|
||
*
|
||
* ## 为什么走 CLI 而不是直接连
|
||
* CLI 已经装了连接管理(多设备配置、证书钉住、daemon 连接池),薄薄一层复用即可;
|
||
* 自己再实现一遍连接与握手,就是又一次"两份实现"。
|
||
*
|
||
* 用法:
|
||
* node scripts/anchor-audit.mjs collect --node win [--out <文件>] [--note "每日"]
|
||
* node scripts/anchor-audit.mjs verify --node win --anchors <锚点文件> [--head-file <链头 JSON>]
|
||
*/
|
||
|
||
import { execFileSync } from 'node:child_process';
|
||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import { judgeAnchors, parseAnchorsFromJsonl } from '../packages/protocol/dist/index.js';
|
||
|
||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
||
/** 极简参数解析(避免为两个子命令引依赖)。 */
|
||
function parseArgs(argv) {
|
||
const out = { _: [] };
|
||
for (let i = 0; i < argv.length; i += 1) {
|
||
const a = argv[i];
|
||
if (a.startsWith('--')) {
|
||
const k = a.slice(2);
|
||
const v = argv[i + 1];
|
||
if (v === undefined || v.startsWith('--')) out[k] = true;
|
||
else {
|
||
out[k] = v;
|
||
i += 1;
|
||
}
|
||
} else out._.push(a);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function die(msg, code = 1) {
|
||
console.error(`✗ ${msg}`);
|
||
process.exit(code);
|
||
}
|
||
|
||
/** 拉取被控端链头(走 CLI,复用其连接管理)。 */
|
||
/** 链头来源:优先 `--head-file`(便于离线验证/排查),否则走 CLI。 */
|
||
function fetchHead(node, headFile) {
|
||
if (headFile && typeof headFile === 'string') {
|
||
if (!existsSync(headFile)) die(`链头文件不存在:${headFile}`);
|
||
const j = JSON.parse(readFileSync(headFile, 'utf8'));
|
||
return j.data ?? j; // 兼容「CLI 信封」与「裸 data」两种写法
|
||
}
|
||
// ⚠️ CLI 没有 `audit head` 子命令(只有 `audit` 列表与 `audit verify`)——
|
||
// system.audit.head 只能走通用 `invoke`。2026-10-11 在虚拟化平台的 VM 上真跑时才发现:
|
||
// 不实际跑一次,这种"能力存在但 CLI 无子命令"的错配根本暴露不出来。
|
||
const args = ['invoke', 'system.audit.head', '--json'];
|
||
if (node) args.push('--node', node);
|
||
let raw;
|
||
try {
|
||
raw = execFileSync('nodeagent', args, {
|
||
encoding: 'utf8',
|
||
cwd: root,
|
||
timeout: 60_000,
|
||
env: process.env,
|
||
});
|
||
} catch (err) {
|
||
die(`调用 CLI 失败:${err?.message ?? err}(被控端可能离线)`);
|
||
}
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(raw);
|
||
} catch {
|
||
die(`CLI 输出非 JSON:${String(raw).slice(0, 200)}`);
|
||
}
|
||
if (parsed?.status !== 'ok' || !parsed.data) {
|
||
die(`取链头失败:${parsed?.error?.message ?? '未知错误'}`);
|
||
}
|
||
return parsed.data;
|
||
}
|
||
|
||
/** 组一条锚点记录(字段与 protocol 的 AnchorRecord 一致)。 */
|
||
function toAnchorRecord(head, note) {
|
||
return {
|
||
ts: Date.now(),
|
||
entries: head.entries,
|
||
head_hash: head.head_hash ?? null,
|
||
head_ts: head.head_ts ?? null,
|
||
rotated_segments: head.rotated_segments ?? 0,
|
||
...(note ? { note } : {}),
|
||
};
|
||
}
|
||
|
||
// ---------------- collect ----------------
|
||
|
||
function cmdCollect(args) {
|
||
const node = args.node;
|
||
const head = fetchHead(node, args['head-file']);
|
||
const rec = toAnchorRecord(head, typeof args.note === 'string' ? args.note : undefined);
|
||
|
||
const out = args.out
|
||
? String(args.out)
|
||
: join(root, 'release', `audit-anchor-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
||
mkdirSync(dirname(out), { recursive: true });
|
||
if (existsSync(out)) appendFileSync(out, `${JSON.stringify(rec)}\n`, 'utf8');
|
||
else writeFileSync(out, `${JSON.stringify(rec)}\n`, 'utf8');
|
||
|
||
console.log(`✓ 已生成锚点:${rec.entries} 条,链头 ${String(rec.head_hash).slice(0, 16)}…(轮转 ${rec.rotated_segments} 段)`);
|
||
console.log(` 文件:${out}`);
|
||
console.log(` ⤴ 请把该文件上传到**链外**(网盘/另一台机器)—— 留在本机或与被控端同机都等于没锚。`);
|
||
// 便于 automation 直接取用(一行 JSON)
|
||
console.log(`ANCHOR_JSON=${JSON.stringify(rec)}`);
|
||
return 0;
|
||
}
|
||
|
||
// ---------------- verify ----------------
|
||
|
||
function cmdVerify(args) {
|
||
const node = args.node;
|
||
const file = args.anchors;
|
||
if (!file || typeof file !== 'string') die('verify 需要 --anchors <锚点文件>(可从网盘下载后传入)');
|
||
if (!existsSync(file)) die(`锚点文件不存在:${file}`);
|
||
|
||
const anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
|
||
if (!anchors) die('锚点文件为空或全部不可解析');
|
||
|
||
const head = fetchHead(node, args['head-file']);
|
||
const cmp = judgeAnchors(head, anchors);
|
||
|
||
const mark = cmp.ok ? '✓' : '✗';
|
||
console.log(`${mark} 判定:${cmp.verdict}`);
|
||
console.log(` 锚点条数:${cmp.anchors_checked}(最近一条:${new Date(cmp.latest?.ts ?? 0).toLocaleString()})`);
|
||
console.log(` 当前链:${head.entries} 条,链头 ${String(head.head_hash).slice(0, 16)}…,轮转 ${head.rotated_segments} 段`);
|
||
if (cmp.detail) console.log(` 明细:${JSON.stringify(cmp.detail)}`);
|
||
if (!cmp.ok) {
|
||
console.error('');
|
||
console.error(' ⚠️ 与链外锚点不一致 —— 需人工核查被控端审计日志是否被重写。');
|
||
console.error(' 注意:锚点本身也应有历史(多份),只比最近一条不足以定位被改的区间。');
|
||
}
|
||
return cmp.ok ? 0 : 2;
|
||
}
|
||
|
||
// ---------------- 入口 ----------------
|
||
|
||
const args = parseArgs(process.argv.slice(2));
|
||
const cmd = args._[0];
|
||
const code =
|
||
cmd === 'collect' ? cmdCollect(args)
|
||
: cmd === 'verify' ? cmdVerify(args)
|
||
: (console.error('用法: node scripts/anchor-audit.mjs <collect|verify> --node <设备> [...]'), 1);
|
||
process.exit(code);
|