test(privacy): 新增隐私扫描护栏,并接入全量回归

首次推送前的人工审计一次扫出六类个人数据,而且**反复漏**:
  · 第一轮漏了机型编号的**大写变体**(规则只写了小写)
  · 第二轮漏了**裸用户名**(规则只写了长形式 xxxdeMacBook-Pro-2.local)
  · 第三轮漏了真实账号邮箱 —— 因为臣用 head -10 截断了扫描输出,
    前面十条全是测试假邮箱,真邮箱被挤到看不见
三次都是"扫全之后回看"才发现的。人工审计靠不住,固化成脚本。

判据分两档(关键设计):
  · FAIL:高置信(公网 IP / .cn 域名 / /Users/<名字> / SSH 私钥路径 /
    私钥块 / 手机号 / 疑似真实密钥赋值)
  · WARN:需人判断(邮箱 —— 测试夹具与真实邮箱长得一样)。
    只列不判红,避免假阳性变成噪音、进而被整体忽略。
白名单刻意**窄**:Flutter 官方中国镜像域名、明显合成的口令值、单字母域名的假邮箱。

扫描范围:工作树(被跟踪文件)+ **全部提交** + 全部提交信息 ——
只扫工作树是不够的,`git log -p` 照样能把脱敏前的原文翻出来。

本脚本**不内置任何真实值**(内置就等于把要防的东西写进仓库);
项目特定敏感串写到本机的 ~/.config/inkwatch/privacy-denylist.txt(不入库)。

用法:node tests/privacy-scan.js(全量) / --worktree-only(快,只扫工作树)
This commit is contained in:
PMon Dev committed 2026-09-16 21:12:22 +08:00
1 parent b3db58df06
commit 1c692ebd1d
2 files changed
+148

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
/**
* 隐私扫描 —— 提交前拦一道(2026-09-16 立)
*
* ── 为什么要有它 ──────────────────────────────────────────────────
* 首次推送到 GitHub 前做人工审计,一次扫出六类个人数据(服务器 IP、域名体系、
* SSH 私钥路径、Linux 用户名、真实主机名、设备 ID 与账号邮箱),而且反复漏:
* · 第一轮漏了**机型编号的大写变体**(规则只写了小写)
* · 第二轮漏了**裸用户名**(规则只写了长形式 `xxxdeMacBook-Pro-2.local`)
* · 第三轮漏了**真实账号邮箱** —— 因为臣用 `head -10` 截断了扫描输出,
* 前面十条全是测试假邮箱,真邮箱被挤到看不见
* 三次都是"扫全之后回看"才发现的。人工审计靠不住,故固化成脚本。
*
* ── 判据分两档(重要)────────────────────────────────────────────
* · **FAIL**:高置信、几乎不可能是测试数据 —— 公网 IP、`.cn` 域名、
* `/Users/<名字>`、SSH 私钥路径、中国手机号、私钥块。
* · **WARN**:需要人判断 —— 邮箱(测试夹具里的 `a@b.com` 与真实邮箱长得一样)。
* 只列出来、不判红,避免把假阳性变成噪音从而被整体忽略。
*
* ── 认不出的怎么办 ───────────────────────────────────────────────
* 本脚本**不内置任何真实值**(内置就等于把要防的东西写进了仓库)。
* 项目特定的敏感串请写到**本机**的 denylist(不入库):
* ~/.config/inkwatch/privacy-denylist.txt (一行一个,`#` 开头为注释)
*
* 运行:cd tests && node privacy-scan.js (扫工作树 + 全部提交)
* node privacy-scan.js --worktree-only (只扫工作树,很快)
*/
'use strict';
const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const WORKTREE_ONLY = process.argv.includes('--worktree-only');
const DENYLIST = path.join(os.homedir(), '.config/inkwatch/privacy-denylist.txt');
/** 高置信规则 → 命中即 FAIL */
const FAIL_RULES = [
{ name: '公网 IPv4', re: /\b(?!10\.|127\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0)(\d{1,3}\.){3}\d{1,3}\b/g,
// RFC 5737 文档专用段(203.0.113.0/24 等)是**刻意**用来当占位符的,放行
allow: (m) => /^(203\.0\.113\.|198\.51\.100\.|192\.0\.2\.|255\.|224\.)/.test(m) || m === '1.2.3.4' },
{ name: '.cn 域名', re: /\b[a-zA-Z0-9][a-zA-Z0-9-]*(\.[a-zA-Z0-9-]+)*\.cn\b/g,
// 放行公共基础设施域名(不是个人资产):Flutter 官方中国镜像等。
// 白名单要**窄**(精确到域名),否则等于把整类判据废掉。
allow: (m) => /(^|\.)flutter-io\.cn$/.test(m) },
{ name: 'macOS 家目录(含用户名)', re: /\/Users\/(?!me\b|shared\b|you\b|\$)[A-Za-z0-9._-]{2,}/g },
{ name: 'SSH 私钥路径', re: /(~|\/[\w/~.-]*)\/\.ssh\/(id_rsa|id_ed25519|id_ecdsa)[\w.]*/g },
{ name: '私钥块', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
{ name: '中国手机号', re: /\b1[3-9]\d{9}\b/g },
{ name: '疑似真实密钥赋值',
re: /(secret|password|passwd|api[_-]?key|access[_-]?key)\s*[:=]\s*["'][A-Za-z0-9/+_-]{16,}["']/gi,
// 放行**明显是合成**的值:测试探针、占位符、经典示例口令。
// 判据:值里含 probe-/test/fake/example/change_me/hunter2/dummy 等词。
allow: (m) => /(probe-|test|fake|example|change[_-]?me|hunter2|dummy|placeholder)/i.test(m) },
];
/** 需要人判断 → 只 WARN */
const WARN_RULES = [
{ name: '邮箱', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
// 放行:保留域、localhost、*.test,以及**单字母域名**(`a@b.com`)——
// 真实邮箱的域名不会只有一个字母,这类是测试夹具。
allow: (m) => /@(example\.(com|org|net)|localhost|.*\.test)$/.test(m) ||
/^[a-z]{1,3}@[a-z]\.[a-z]{2,4}$/i.test(m) },
];
const readDenylist = () => {
try {
return fs.readFileSync(DENYLIST, 'utf8').split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'));
} catch { return []; }
};
const DENY = readDenylist();
/** 扫描一段文本,累积命中 */
function scanText(label, text, hits) {
for (const r of FAIL_RULES) {
const re = new RegExp(r.re.source, r.re.flags);
let m;
while ((m = re.exec(text)) !== null) {
if (r.allow && r.allow(m[0])) continue;
hits.fail.push(`${label} [${r.name}] ${m[0]}`);
}
}
for (const r of WARN_RULES) {
const re = new RegExp(r.re.source, r.re.flags);
let m;
while ((m = re.exec(text)) !== null) {
if (r.allow && r.allow(m[0])) continue;
hits.warn.push(`${label} [${r.name}] ${m[0]}`);
}
}
for (const d of DENY) {
if (text.includes(d)) hits.fail.push(`${label} [denylist] ${d}`);
}
}
const hits = { fail: [], warn: [] };
// ── ① 工作树(只扫被 git 跟踪的文件 —— 未跟踪的多是构建产物)──
const tracked = execSync('git ls-files', { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
for (const f of tracked) {
let t;
try { t = fs.readFileSync(f, 'utf8'); } catch { continue; } // 二进制跳过
scanText(f, t, hits);
}
// ── ② 全部提交(历史里同样会泄露,`git log -p` 就能翻出来)──
if (!WORKTREE_ONLY) {
const revs = execSync('git rev-list --all', { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
for (const rev of revs) {
let out;
try {
out = execSync(`git grep -I -n -E "." ${rev} -- . || true`,
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
} catch { continue; }
scanText(rev.slice(0, 7), out, hits);
}
// 提交信息也可能带个人信息
try {
scanText('commit-msg', execSync('git log --all --format=%B', { encoding: 'utf8' }), hits);
} catch { /* ignore */ }
}
// ── 输出 ──
const uniq = (xs) => [...new Set(xs)];
const fails = uniq(hits.fail);
const warns = uniq(hits.warn);
if (warns.length) {
console.log(`\n ⚠️ 需人工判断(${warns.length} 条,测试夹具与真实邮箱长得一样):`);
for (const w of warns.slice(0, 20)) console.log(' ' + w);
if (warns.length > 20) console.log(` …另有 ${warns.length - 20} 条`);
}
if (fails.length) {
console.log(`\n ✗ 高置信个人数据 ${fails.length} 条:`);
for (const f of fails.slice(0, 40)) console.log(' ' + f);
console.log('\n 修法:脱敏后**必须连历史一起重写**(只改工作树的话 git log -p 仍能看到)。');
console.log(' 项目特定值请写入本机 denylist:' + DENYLIST);
process.exit(1);
}
console.log(`\n ✓ 未发现高置信个人数据(扫描 ${tracked.length} 个跟踪文件` +
`${WORKTREE_ONLY ? '' : ' + 全部提交'})`);
process.exit(0);
+3
View File
@@ -77,6 +77,9 @@ run "协议契约(34 项)" node protocol-test.js
run "编解码与体积(64 项)" node codec-test.js
# 配对安全的离线契约段:不上任何服务也能拦住"把码比对删掉但留着测试"的退化改动
run "配对安全·源码契约(23 项)" node pairing-security-test.js --offline-only
# 隐私扫描:提交/推送前拦一道。工作树 + **全部提交**都扫 ——
# 只改工作树是不够的,`git log -p` 照样能把脱敏前的原文翻出来。
run "隐私扫描(工作树+全历史)" node privacy-scan.js
# 设备身份归并判据(纯函数):判"两条记录是不是同一台机"的唯一真相源。
# 判错的代价是把两台设备的授权与历史搅在一起且不可逆,故离线穷举推演。
run "设备身份判据(23 项)" node device-identity-test.js