From 1c692ebd1de1f56fc78a70ba62f4decce10bf4c6 Mon Sep 17 00:00:00 2001 From: PMon Dev Date: Wed, 16 Sep 2026 21:12:22 +0800 Subject: [PATCH] =?UTF-8?q?test(privacy):=20=E6=96=B0=E5=A2=9E=E9=9A=90?= =?UTF-8?q?=E7=A7=81=E6=89=AB=E6=8F=8F=E6=8A=A4=E6=A0=8F=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=85=A8=E9=87=8F=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首次推送前的人工审计一次扫出六类个人数据,而且**反复漏**: · 第一轮漏了机型编号的**大写变体**(规则只写了小写) · 第二轮漏了**裸用户名**(规则只写了长形式 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(快,只扫工作树) --- tests/privacy-scan.js | 145 ++++++++++++++++++++++++++++++++++++++++++ tests/run-all.sh | 3 + 2 files changed, 148 insertions(+) create mode 100644 tests/privacy-scan.js diff --git a/tests/privacy-scan.js b/tests/privacy-scan.js new file mode 100644 index 0000000..2febef3 --- /dev/null +++ b/tests/privacy-scan.js @@ -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); diff --git a/tests/run-all.sh b/tests/run-all.sh index f379836..100a2d5 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -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