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:
1 parent
b3db58df06
commit
1c692ebd1d
2 files changed
+148
No files matched your search
@@ -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);
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user