Files
jkooe b9236dfaf1 chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设备的字面量
按 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。
2026-10-13 22:35:40 +08:00

164 lines
6.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);