Files
knowledge-governance/scripts/check_secrets.py
T
姚章浩 a3a1319a11 feat(ci): 唯一权威门禁 make verify(14 项必需检查,汇总不认"未运行")
一次性把本地与 CI 的门禁收成一条命令:make verify = python3 scripts/ci_checks.py。
两边跑的是同一份逻辑,不再各写一套步骤(两边各写一套的结局是迟早只改一边)。

14 项必需检查:identity(检出 == 事件完整 SHA,只在 CI 必需)、commits、deps-install、
format、static-go、static-web、build-go、build-web、tests-go、tests-web、contracts、
security-secrets、security-deps、manifests-locked。make list 打印清单。

三条判据写死在代码里:
- 失败即停,但剩下的记 not_run 并如实写进报告;aggregate() 只认 success,
  未运行/被跳过都不算通过(不适用要在清单里声明 required_in,不许运行时悄悄 skip)。
- 工具缺失、依赖没按锁装、输出解析不了 ⇒ 失败,不当成"检查通过"。
- 测试必须真跑:go test 加 -count=1 并盘点通过/跳过数;前端读 vitest 的 JSON 报告,
  空套件不算通过。跳过原因会打进报告(当前 72 个集成用例因缺 TEST_DATABASE_URL 跳过)。

新增四个自带反向自检的脚本(--self-test 故意弄坏必须红):
scripts/check_format.py(Go 判输出而非退出码 + 前端只能减的基线)、
scripts/check_secrets.py(只打印位置与类别,绝不打印值)、
scripts/check_deps_vuln.py(govulncheck 锁 v1.1.4 + npm audit 分级 + 到期例外)、
scripts/ci_checks.py(门禁 + 汇总)。

验证(本机实跑,全部为真实退出码):
make verify → 总体 success(档案 local),identity=skipped 已在清单里声明为 CI 必需;
tests-go 执行 31 个包、通过 549 / 失败 0 / 跳过 72;tests-web 106 个套件、191 个用例全过;
security-deps:govulncheck v1.1.4 0 命中、npm audit 生产与全量均 0。
2026-10-12 00:10:51 +08:00

198 lines
9.5 KiB
Python
Raw 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 python3
"""密钥泄漏扫描(检出树 → 退出码)。
为什么自己写而不是用 gitleaks:
· 作业容器里没有 gitleaks,本机也没有;在 CI 运行时联网下载一个二进制,
等于把"扫描器本身"变成供应链风险,而且违反"工具缺失直接失败、不在运行中静默安装"。
· 本仓不需要把源码发给任何外部服务(正本 §7:不得发送项目源码给未授权外部服务)。
⚠️ 代价:规则集比 gitleaks 小。这是**已知缺口**,不是等价替代(见 README/交付说明)。
纪律:
1. **只打印位置与类别,绝不打印命中的值**(正本 §4.5)。掩码也不打 ——
掩码 + 上下文足以在聊天记录里复原短密钥。
2. 扫描范围 = `git ls-files`(检出树里的**被跟踪文件**)。未跟踪的 `.env`、
`secrets/`、`node_modules/` 不在范围里 —— 它们要么被忽略,要么根本不该在检出树里。
3. 每次运行先跑 `--self-test`(用合成密钥夹具 + 良性对照):
一个不再命中的扫描器比没有扫描器更糟,它会给人"已经查过"的错觉(正本 §0-2)。
用法:
python3 scripts/check_secrets.py # 扫描 + 先跑自检
python3 scripts/check_secrets.py --no-self-test # 只扫描(调试用)
python3 scripts/check_secrets.py --self-test # 只跑自检
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# ── 规则集:每条都必须能定位到具体文件/行,且必须能被 --self-test 证明"真的会命中" ──
# 形状:(rule_id, 类别, 正则, 密钥分组名或 None)
# 「密钥分组」= 命中里"真的是秘密"的那一段,用来判断它是不是占位符 / 模板表达式。
# 没有分组的规则(私钥标记、固定前缀的令牌)整段都算秘密。
RULES: list[tuple[str, str, re.Pattern[str], str | None]] = [
("private-key", "私钥块",
re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"), None),
("aws-ak", "AWS Access Key ID", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), None),
("aliyun-ak", "阿里云 AccessKey ID", re.compile(r"\bLTAI[0-9A-Za-z]{12,}\b"), None),
("forge-token", "代码托管平台令牌",
re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|glpat-[A-Za-z0-9_\-]{20,})\b"), None),
("jwt", "JWT",
re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b"), None),
# 口令段只收"干净字符":`***`(脱敏)、`{u.quote(password)}`(模板表达式)、
# `$PGPASSWORD`(变量)都不该命中 —— 那三种在 scripts/*.sh 里到处都是,收了就是永远的假红。
("pg-url", "带口令的数据库连接串",
re.compile(r"\bpostgres(?:ql)?://[A-Za-z0-9_.\-]+:(?P<secret>[A-Za-z0-9+/=_\-]{6,})@"), "secret"),
# 前缀不写 `\b`:`DINGTALK_APP_SECRET` 这类"带前缀的名字"才是最常见的写法,
# 而 `_` 是 word 字符,`\b` 会把它们全部漏掉。
("assigned-secret", "被赋值的密钥字面量",
re.compile(r"(?i)\b[A-Za-z0-9_]*(?:api[_-]?key|app[_-]?secret|client[_-]?secret|access[_-]?key"
r"|secret|token|passwd|password)\b\s*[:=]\s*[\"'](?P<secret>[A-Za-z0-9+/=_\-]{16,})[\"']"), "secret"),
]
# 占位符:整段值就是这些形状时不算密钥(否则模板 / 示例文件会把门禁撑成永远的假红)
PLACEHOLDER = re.compile(
r"(?i)^(?:"
r"<[^>]*>|\{\{[^}]*\}\}|\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|"
r"x{3,}|\.{3,}|\*{3,}|-+|_+|"
r"(?:your|my|some|test|dummy|example|sample|placeholder|fake|redacted|changeme|change[_-]?me|"
r"secret|token|password|api[_-]?key|none|null|todo|tbd)[A-Za-z0-9_\-]*"
r")$"
)
TEXT_EXT = {
".go", ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".yaml", ".yml",
".md", ".sh", ".sql", ".css", ".html", ".env", ".example", ".txt", ".toml", ".ini",
".cfg", ".conf", ".properties", ".xml", ".tsv", ".csv",
}
MAX_BYTES = 2_000_000
def tracked_files() -> list[str]:
"""被跟踪的文件(含未跟踪但已 add 的)。检出树里没有的东西不扫。"""
out = subprocess.run(["git", "ls-files"], cwd=ROOT, capture_output=True, text=True)
if out.returncode != 0:
print("❌ 拿不到 git ls-files —— 扫描范围无法确定,直接失败", file=sys.stderr)
sys.exit(2)
return [l for l in out.stdout.splitlines() if l.strip()]
def looks_textual(path: Path) -> bool:
if path.suffix.lower() not in TEXT_EXT and path.name not in {".env.example", "Makefile", "Dockerfile"}:
return False
try:
if path.stat().st_size > MAX_BYTES:
return False
return b"\x00" not in path.read_bytes()[:4096]
except OSError:
return False
def mask_scan(text: str) -> list[tuple[str, str, int]]:
"""返回 [(rule_id, 类别, 行号)]。**不返回命中的值**。"""
hits = []
for rule_id, label, rx, secret_group in RULES:
for m in rx.finditer(text):
value = (m.group(secret_group) if secret_group else m.group(0)) or ""
if PLACEHOLDER.match(value.strip().strip("\"'")):
continue
hits.append((rule_id, label, text.count("\n", 0, m.start()) + 1))
return hits
def scan(files: list[str]) -> list[tuple[str, str, int]]:
found = []
for rel in files:
p = ROOT / rel
if not looks_textual(p):
continue
try:
text = p.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for rule_id, label, line in mask_scan(text):
found.append((rel, f"{rule_id}({label})", line))
return found
# ── 反向自检:合成密钥必须命中,良性对照必须干净 ──
def self_test() -> int:
problems: list[str] = []
# 值都在运行期拼出来:否则本文件自己就会成为一条真命中
synthetic = {
"aws-ak": "AKIA" + "Q7ZP2M4XR9TLB6VW",
"aliyun-ak": "LTAI" + "5tG8kQ2mZx7Vb3Np",
"forge-token": "ghp_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8",
"jwt": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0." + "s1gn4tur3Pad1234567890ab",
"pg-url": "postgres://kg_admin:" + "hunter2xyz" + "@db.internal:5432/kg",
"assigned-secret": 'DINGTALK_APP_SECRET = "' + "9f2a7c1e4b8d3065" + '"',
# 拼起来写:本机的 pre-commit 钩子会拦"整段私钥头"的字面量 —— 那是**对的**,
# 所以不绕过它,而是让本文件里不出现那段连续文本(扫描逻辑本身不受影响)。
"private-key": "-----BEGIN " + "RSA PRIVATE KEY-----",
}
benign = "\n".join([
'token = os.environ["CI_BRIDGE_TOKEN"]',
'api_key: ${DOBOTAI_API_KEY}',
'password = "<your-password>"',
'X-CI-Token: ${BRIDGE_TOKEN}',
'const url = `${API_BASE}/healthz`',
'PGURL=postgres://user@host/db',
'self.assertEqual(result.Skipped, 1)',
# 下面三条都是**真实误报过**的形状(scripts/*.sh 与 Go 夹具),锁进对照防回归:
'print(f"postgres://{u.quote(user)}:{u.quote(password)}@{host}:{port}/{name}")',
'"postgres://governance:***@postgres/governance"',
'DINGTALK_APP_SECRET = "${DINGTALK_APP_SECRET}"',
])
with tempfile.TemporaryDirectory() as d:
for rule_id, value in synthetic.items():
probe = Path(d) / "probe.txt"
probe.write_text(f"setting {rule_id} here\n{value}\n", encoding="utf-8")
hit_ids = {r for r, _, _ in mask_scan(probe.read_text(encoding="utf-8"))}
if not hit_ids:
problems.append(f"合成夹具没有任何规则命中({rule_id})—— 这条规则是死的")
elif rule_id not in hit_ids:
problems.append(f"合成夹具命中的是 {sorted(hit_ids)},而不是 {rule_id} —— 规则串了")
clean = mask_scan(benign)
if clean:
problems.append(f"良性对照被误报:{clean} —— 门禁会因为正常代码变红(假红)")
if problems:
print("❌ 密钥扫描器自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print(f"✅ 密钥扫描器自检通过:{len(synthetic)} 条合成密钥全部命中,良性对照零误报")
return 0
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("--self-test", action="store_true", help="只跑反向自检")
ap.add_argument("--no-self-test", action="store_true", help="跳过自检(调试用,不算全绿)")
args = ap.parse_args(argv)
if args.self_test:
return self_test()
if not args.no_self_test and self_test() != 0:
return 1
files = tracked_files()
found = scan(files)
print(f"── 密钥扫描 ── 范围:{len(files)} 个被跟踪文件(只打印位置与类别,不打印值)")
if found:
print("❌ 疑似密钥泄漏:", file=sys.stderr)
for rel, label, line in sorted(found):
print(f" {rel}:{line} {label}", file=sys.stderr)
print(" 处理:把值挪到 Gitea Secrets / 目标机文件;已入库的要轮换密钥,"
"光删提交不够(历史里还在)。", file=sys.stderr)
return 1
print("✅ 未发现密钥泄漏")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))