7090aafefc
ci-gate.py:Python 的依赖/格式/静态/构建/测试改成真的执行(uv sync --frozen / uv run ruff / compileall / uv run pytest),工具缺失即失败,不再空跑成功;前端测试把子进程退出码当判据(报告写 pass 但退出 1 也判红),框架按 test-scope.json 声明而不是写死 vitest;清单新增 security(密钥扫描 + 依赖漏洞)。预览逻辑进 ci_preview.py:先调 ci_report.verify(),核对不过一个 publish 请求都不发。检出进 ci_checkout.sh:协议原样保留并断言不降级、URL 不放凭据、顺带取事件 before。workflow 收成单作业,不再用 /tmp 跨作业传文件。
198 lines
9.5 KiB
Python
198 lines
9.5 KiB
Python
#!/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:])) |