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。
This commit is contained in:
姚章浩
2026-10-12 00:10:51 +08:00
parent 2fcd0ed684
commit a3a1319a11
7 changed files with 1248 additions and 24 deletions
+4
View File
@@ -21,6 +21,10 @@
.vscode/
.DS_Store
# 门禁产物(每次运行重写;不是证据,证据是运行日志与 Gitea 的 run)
/ci-report.json
/.ci-manifests.json
# Local artifacts (binaries, staging scripts, backups)
/release-diag
/worker
+58 -23
View File
@@ -1,47 +1,82 @@
GO ?= go
PY ?= python3
# 版本号的唯一来源是仓库根的 CHANGELOG.md(正本 §6.1:版本号只有一个来源)。
# 刻意**不用** `git describe --tags`:CI 检出是 `git fetch --depth 50`,取不到 tag,
# 那会让版本号在本地正常、在 CI 上静默退化(见 docs/agents/traps.md trap-5)。
VERSION ?= $(shell python3 scripts/version.py 2>/dev/null || echo dev)
VERSION ?= $(shell $(PY) scripts/version.py 2>/dev/null || echo dev)
COMMIT_SHA ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)
BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X knowledge-governance/internal/version.Number=$(VERSION) -X knowledge-governance/internal/version.CommitSHA=$(COMMIT_SHA) -X knowledge-governance/internal/version.BuildTime=$(BUILD_TIME)
.PHONY: test vet build web-test web-typecheck web-build verify version changelog-check version-contract image
.PHONY: verify checks list \
format static-go static-web deps-install build-go build-web \
tests-go tests-web contracts security manifests-locked \
test vet build web-test web-typecheck web-build \
version changelog-check version-contract image
test:
$(GO) test ./...
# ── 唯一权威门禁入口(正本 §3.1)────────────────────────────────────────
# 一条命令跑完全部必需检查、**串行**、失败即停,并产出 ci-report.json。
# CI 的 build 作业跑的就是这一条(`.gitea/workflows/build.yml` 里的 `make verify`)——
# 本地与 CI 同一份逻辑,不存在"两边各写一套,迟早只改一边"的问题。
verify:
$(PY) scripts/ci_checks.py
vet:
$(GO) vet ./...
# 清单:哪些是必需项、在哪个档案(local / ci)下必需
list:
$(PY) scripts/ci_checks.py --list
build:
$(GO) build -trimpath -ldflags "$(LDFLAGS)" ./cmd/...
# 门禁自身的反向自检(汇总逻辑:必需项未运行/被跳过时**不得**报全绿)
checks:
$(PY) scripts/ci_checks.py --self-test
web-test:
npm --prefix web test
# ── 单项(调试用)──────────────────────────────────────────────────────
# ⚠️ `make <单项>` 只跑子集,**不算全绿**:单项命令会自己在输出里说明这一点。
# 写这一组是为了"红了要知道红在哪一项",不是为了跳过整个门禁。
format:
$(PY) scripts/ci_checks.py --only format
static-go:
$(PY) scripts/ci_checks.py --only static-go
static-web:
$(PY) scripts/ci_checks.py --only static-web
deps-install:
$(PY) scripts/ci_checks.py --only deps-install
build-go:
$(PY) scripts/ci_checks.py --only build-go
build-web:
$(PY) scripts/ci_checks.py --only build-web
tests-go:
$(PY) scripts/ci_checks.py --only tests-go
tests-web:
$(PY) scripts/ci_checks.py --only tests-web
contracts:
$(PY) scripts/ci_checks.py --only contracts
security:
$(PY) scripts/ci_checks.py --only security-secrets,security-deps
manifests-locked:
$(PY) scripts/ci_checks.py --only manifests-locked
web-typecheck:
npm --prefix web run typecheck
web-build:
npm --prefix web run build
# 本地门禁:一条命令跑完、**串行**、失败即停(正本 §3.1)。CI 的 build 作业跑同一套。
verify: changelog-check test vet build version-contract web-test web-typecheck web-build
# ── 旧名字保留为别名:文档和习惯都还在引用它们 ─────────────────────────
test: tests-go
vet: static-go
build: build-go
web-test: tests-web
web-typecheck: static-web
web-build: build-web
# 打印当前版本号(唯一来源:CHANGELOG.md 顶部那个已发布版本)
version:
@python3 scripts/version.py
@$(PY) scripts/version.py
# 变更记录结构自检:每个已发布版本都必须有「改了什么」「怎么验收」两节
changelog-check:
@python3 scripts/check_changelog.py
@$(PY) scripts/check_changelog.py
# 版本契约:注入进二进制的号必须等于 CHANGELOG 顶部(不注入时该用例会 SKIP,属正常)
# 版本契约:注入进二进制的号必须等于 CHANGELOG 顶部。
# ⚠️ 不注入时该用例会 SKIP —— 那是"本地不想被迫先构建"的便利,但**CI 必须断言它真的 PASS**
# (门禁的 contracts 项就是这么做的;被跳过的断言等于没有断言)。
version-contract:
$(GO) test -ldflags "$(LDFLAGS)" -run TestNumberMatchesChangelog -v ./internal/version/...
$(GO) test -ldflags "-X knowledge-governance/internal/version.Number=$(VERSION)" \
-count=1 -run TestNumberMatchesChangelog -v ./internal/version/...
# 构建镜像。三个 build-arg 必须显式传入 —— 不传就落成 dev/unknown(故意的:
# 一个显眼的 dev 好过一个看起来正常的假版本号)。compose.yaml 的 args 透传见 PR TODO。
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""依赖漏洞检查(Go + 前端)—— 判据 → 退出码。
两个扫描器:
· Go :`govulncheck`(**可达性分析**:只报真的会被调用到的漏洞),版本锁死。
· 前端 :`npm audit`(按 severity 分级;生产依赖与仅开发依赖分开判)。
分级与例外(正本 §4.5):
· 生产依赖出现 high/critical ⇒ **阻断**。
· 仅开发依赖出现 high/critical ⇒ 阻断,**除非**在 `security/dependency-exceptions.json`
里有一条**有效**例外(必须有理由 / 责任人 / 批准人 / 到期日,且未过期)。
· moderate 及以下 ⇒ 只记录,不阻断。
· govulncheck 的命中**一律阻断**(它已经做过可达性分析),除非有有效例外。
· 扫描器缺工具、连不上漏洞库、输出解析不了 ⇒ **失败**,绝不当成"没有漏洞"。
为什么例外要写全字段:没有到期日的例外会永久留在仓库里,而半年后没人记得为什么。
批准人字段是给人看的责任锚点 —— 本脚本**不会**替任何人填它。
用法:
python3 scripts/check_deps_vuln.py # 真跑两个扫描器
python3 scripts/check_deps_vuln.py --self-test # 只跑分级逻辑的反向自检(用合成夹具)
python3 scripts/check_deps_vuln.py --skip-go # 只查前端(调试用,不算全绿)
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
WEB = ROOT / "web"
EXCEPTIONS = ROOT / "security" / "dependency-exceptions.json"
GOVULNCHECK = "golang.org/x/vuln/cmd/govulncheck@v1.1.4" # 锁版本:不静默升级工具
BLOCKING_SEV = {"critical", "high"}
EXCEPTION_FIELDS = ("id", "severity", "scope", "reason", "owner", "approved_by", "expires_on")
def parse_json_stream(text: str) -> list[dict]:
"""govulncheck 的 JSON 是**多个 pretty-printed 对象首尾相接**,不是行分隔。
按行解析会崩(实测:'str' object has no attribute 'keys')。"""
dec = json.JSONDecoder()
out, i, n = [], 0, len(text)
while i < n:
while i < n and text[i] in " \t\r\n":
i += 1
if i >= n:
break
try:
obj, j = dec.raw_decode(text, i)
except json.JSONDecodeError:
break
if isinstance(obj, dict):
out.append(obj)
i = j
return out
def load_exceptions(path: Path = EXCEPTIONS) -> tuple[list[dict], list[str]]:
"""返回 (条目, 加载问题)。文件缺失或坏掉都算问题 —— 不能静默当成"没有例外"。"""
if not path.is_file():
return [], [f"{path.relative_to(ROOT)} 不存在(例外机制应当显式存在,哪怕里面是空的)"]
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
return [], [f"{path.relative_to(ROOT)} 解析失败:{e}"]
entries = raw.get("exceptions") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return [], [f"{path.relative_to(ROOT)} 里没有 exceptions 列表"]
return [e for e in entries if isinstance(e, dict)], []
def exception_state(entry: dict, today: dt.date) -> tuple[bool, str]:
"""一条例外是否**有效**。无效的例外等于没有例外(不许拿它换绿灯)。"""
missing = [f for f in EXCEPTION_FIELDS if not str(entry.get(f) or "").strip()]
if missing:
return False, f"例外缺少字段 {missing}"
try:
expires = dt.date.fromisoformat(str(entry["expires_on"]))
except ValueError:
return False, f"例外 {entry.get('id')} 的 expires_on 不是 YYYY-MM-DD"
if expires < today:
return False, f"例外 {entry.get('id')} 已于 {expires} 过期(过期即失效,要重新批)"
if str(entry["approved_by"]).strip().lower() in {"", "todo", "tbd", "pending", "self"}:
return False, f"例外 {entry.get('id')} 的 approved_by 是占位值(必须由人批)"
return True, ""
def matches(entry: dict, finding: dict) -> bool:
if str(entry.get("tool") or "") != finding["tool"]:
return False
if entry.get("package") and entry["package"] != finding.get("package"):
return False
return not (entry.get("id") and entry["id"] != finding.get("id"))
def grade(findings: list[dict], entries: list[dict], today: dt.date) -> dict:
"""纯函数:给定命中与例外,判定阻断 / 豁免 / 仅记录。--self-test 直接打这个函数。"""
blocking, exempted, notes = [], [], []
for f in findings:
sev = str(f.get("severity") or "").lower()
if f["tool"] == "npm-audit" and sev not in BLOCKING_SEV:
notes.append(f"{f['tool']} {f.get('package')} severity={sev} —— 低于阻断线,仅记录")
continue
if f["tool"] == "npm-audit" and f.get("scope") == "dev" and sev in BLOCKING_SEV:
pass # 开发依赖也走同一条路:要么修,要么有有效例外
cands = [e for e in entries if matches(e, f)]
if not cands:
blocking.append({**f, "reason": f"{sev or '未标severity'} 命中,且没有对应例外"})
continue
ok, why = exception_state(cands[0], today)
if ok:
exempted.append({**f, "reason": f"例外 {cands[0]['id']}(到期 {cands[0]['expires_on']})"})
else:
blocking.append({**f, "reason": f"例外无效:{why}"})
return {"blocking": blocking, "exempted": exempted, "notes": notes}
# ── 扫描器 ────────────────────────────────────────────────────────────────
def scan_go() -> tuple[list[dict], str | None, str]:
go = shutil.which("go") or "/opt/citools/go/bin/go"
if not (Path(go).exists() or shutil.which("go")):
return [], "找不到 go,govulncheck 无法运行(工具缺失 ⇒ 失败,不当成无漏洞)", ""
env = dict(os.environ)
env.setdefault("GOFLAGS", "-mod=readonly")
env.setdefault("GOPROXY", "https://goproxy.cn,direct")
env.setdefault("GOTOOLCHAIN", "local")
try:
p = subprocess.run([go, "run", GOVULNCHECK, "-format", "json", "./..."],
cwd=ROOT, capture_output=True, text=True, env=env, timeout=900)
except subprocess.TimeoutExpired:
return [], "govulncheck 超时(漏洞库不可达时会这样)—— 未完成 ⇒ 失败", ""
objs = parse_json_stream(p.stdout)
if not objs:
return [], (f"govulncheck 没有产出任何可解析的 JSON(exit={p.returncode}):"
f"{(p.stderr or p.stdout)[:300]}"), ""
cfg = next((o["config"] for o in objs if isinstance(o.get("config"), dict)), {})
findings = []
for o in objs:
f = o.get("finding")
if not isinstance(f, dict) or not isinstance(f.get("osv"), dict):
continue
osv = f["osv"]
pkg = ""
for fr in f.get("trace") or []:
if isinstance(fr, dict) and fr.get("module"):
pkg = fr["module"]
break
findings.append({"tool": "govulncheck", "id": osv.get("id", ""), "package": pkg,
"severity": "", "scope": "build",
"detail": (osv.get("summary") or "")[:200]})
info = f"govulncheck {cfg.get('scanner_version', '?')}(DB {cfg.get('db_last_modified', '?')})"
return findings, None, info
def npm_audit(args: list[str]) -> tuple[dict, str | None]:
if not (WEB / "package-lock.json").is_file():
return {}, "web/package-lock.json 不存在 ⇒ npm audit 无法按锁文件判定(先补锁文件)"
try:
p = subprocess.run(["npm", "audit", "--json", *args], cwd=WEB,
capture_output=True, text=True, timeout=600)
except subprocess.TimeoutExpired:
return {}, "npm audit 超时(registry 不可达?)—— 未完成 ⇒ 失败"
try:
return json.loads(p.stdout or "{}"), None
except json.JSONDecodeError:
return {}, f"npm audit 输出解析不了(exit={p.returncode}):{(p.stdout or p.stderr)[:300]}"
def audit_findings(data: dict, scope: str) -> list[dict]:
out = []
for name, adv in (data.get("vulnerabilities") or {}).items():
if not isinstance(adv, dict):
continue
sev = str(adv.get("severity") or "").lower()
ids = [v.get("url", "").rsplit("/", 1)[-1]
for v in (adv.get("via") or []) if isinstance(v, dict) and v.get("url")]
out.append({"tool": "npm-audit", "id": ids[0] if ids else "", "package": name,
"severity": sev, "scope": scope,
"detail": f"isDirect={adv.get('isDirect')} range={adv.get('range')}"})
return out
# ── 反向自检:分级逻辑必须真的会红、也必须真的会豁免 ──────────────────────
def self_test() -> int:
today = dt.date(2026, 10, 11)
good_exc = {"tool": "npm-audit", "id": "GHSA-aaaa", "package": "tinypool", "severity": "critical",
"scope": "dev", "reason": "仅测试工具链,不进产物", "owner": "yao",
"approved_by": "yao", "expires_on": "2026-12-31"}
cases: list[tuple[str, list[dict], list[dict], dict]] = [
("生产依赖 high", [{"tool": "npm-audit", "id": "G1", "package": "vite", "severity": "high",
"scope": "prod"}], [], {"blocking": 1, "exempted": 0}),
("开发依赖 critical 且无例外", [{"tool": "npm-audit", "id": "G2", "package": "tinypool",
"severity": "critical", "scope": "dev"}], [], {"blocking": 1, "exempted": 0}),
("开发依赖 critical + 有效例外", [{"tool": "npm-audit", "id": "G3", "package": "tinypool",
"severity": "critical", "scope": "dev"}],
[{**good_exc, "id": "G3", "package": "tinypool"}],
{"blocking": 0, "exempted": 1}),
("例外已过期", [{"tool": "npm-audit", "id": "G4", "package": "tinypool", "severity": "critical",
"scope": "dev"}], [{**good_exc, "id": "G4", "expires_on": "2026-01-01"}],
{"blocking": 1, "exempted": 0}),
("例外缺批准人", [{"tool": "npm-audit", "id": "G5", "package": "tinypool", "severity": "critical",
"scope": "dev"}], [{**good_exc, "id": "G5", "approved_by": "TBD"}],
{"blocking": 1, "exempted": 0}),
("例外包名不匹配", [{"tool": "npm-audit", "id": "G6", "package": "other-pkg", "severity": "high",
"scope": "dev"}], [good_exc], {"blocking": 1, "exempted": 0}),
("moderate 一律只记录", [{"tool": "npm-audit", "id": "G7", "package": "x", "severity": "moderate",
"scope": "dev"}], [], {"blocking": 0, "exempted": 0}),
("govulncheck 命中一律阻断", [{"tool": "govulncheck", "id": "GO-2026-0001", "package": "stdlib",
"severity": "", "scope": "build"}], [], {"blocking": 1, "exempted": 0}),
("govulncheck 命中 + 有效例外", [{"tool": "govulncheck", "id": "GO-2026-0002", "package": "stdlib",
"severity": "", "scope": "build"}],
[{**good_exc, "tool": "govulncheck", "id": "GO-2026-0002", "package": "stdlib"}],
{"blocking": 0, "exempted": 1}),
]
problems: list[str] = []
for name, findings, excs, want in cases:
got = grade(findings, excs, today)
if len(got["blocking"]) != want["blocking"] or len(got["exempted"]) != want["exempted"]:
problems.append(f"{name}:期望 阻断{want['blocking']}/豁免{want['exempted']},"
f"实际 阻断{len(got['blocking'])}/豁免{len(got['exempted'])}")
# 解析器自检:govulncheck 的 JSON 是"多个 pretty 对象首尾相接"
stream = ('{"config": {"scanner_version": "v1.1.4"}}\n'
'{\n "finding": {"osv": {"id": "GO-1", "summary": "s"}, '
'"trace": [{"module": "example.com/m"}]}\n}\n')
objs = parse_json_stream(stream)
if len(objs) != 2:
problems.append(f"JSON 流解析器没认出两个对象(得到 {len(objs)} 个)")
if problems:
print("❌ 依赖漏洞分级自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print(f"✅ 依赖漏洞分级自检通过:{len(cases)} 个判据 + JSON 流解析器全部符合预期")
return 0
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--self-test", action="store_true")
ap.add_argument("--skip-go", action="store_true", help="跳过 govulncheck(调试用,不算全绿)")
args = ap.parse_args(argv)
if args.self_test:
return self_test()
entries, exc_problems = load_exceptions()
today = dt.date.today()
findings: list[dict] = []
info: list[str] = []
tool_errors: list[str] = []
if not args.skip_go:
go_findings, err, go_info = scan_go()
if err:
tool_errors.append(err)
findings += go_findings
if go_info:
info.append(go_info)
prod, err = npm_audit(["--omit=dev"])
if err:
tool_errors.append(f"生产依赖:{err}")
else:
info.append(f"npm audit 生产依赖:{json.dumps((prod.get('metadata') or {}).get('vulnerabilities'), ensure_ascii=False)}")
findings += audit_findings(prod, "prod")
full, err = npm_audit([])
if err:
tool_errors.append(f"全量依赖:{err}")
else:
info.append(f"npm audit 全量:{json.dumps((full.get('metadata') or {}).get('vulnerabilities'), ensure_ascii=False)}")
prod_names = {f["package"] for f in findings if f["tool"] == "npm-audit" and f["scope"] == "prod"}
findings += [f for f in audit_findings(full, "dev") if f["package"] not in prod_names]
print("── 依赖漏洞检查 ──")
for line in info:
print(f" 工具/数据源:{line}")
for line in exc_problems:
print(f" ⚠️ 例外加载:{line}", file=sys.stderr)
verdict = grade(findings, entries, today)
for f in verdict["exempted"]:
print(f" ⚪ 已豁免:{f['tool']} {f.get('id') or f.get('package')} —— {f['reason']}")
for f in verdict["notes"]:
print(f" · 仅记录:{f}")
fail = bool(tool_errors) or bool(exc_problems) or bool(verdict["blocking"])
if tool_errors:
print("❌ 扫描器没跑成 —— 这**不是**\"没有漏洞\"(正本 §4.5):", file=sys.stderr)
for e in tool_errors:
print(f" · {e}", file=sys.stderr)
if exc_problems:
print("❌ 例外文件有问题,无法据它做分级判定:", file=sys.stderr)
if verdict["blocking"]:
print("❌ 阻断级依赖漏洞:", file=sys.stderr)
for f in verdict["blocking"]:
print(f" {f['tool']} {f.get('id') or '?'} {f.get('package') or '?'} "
f"severity={f.get('severity') or 'n/a'} scope={f.get('scope')} —— {f['reason']}", file=sys.stderr)
print(" 处理:升级到修复版本(改 package.json/go.mod + 同步锁文件,独立提交);"
"确有理由不修的,写进 security/dependency-exceptions.json 并写清责任人、批准人、到期日。",
file=sys.stderr)
if not fail:
print(f"✅ 依赖漏洞检查通过(阻断级 0;豁免 {len(verdict['exempted'])};仅记录 {len(verdict['notes'])})")
return 1 if fail else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""格式门禁:Go(gofmt)+ 前端(prettier)。
判据(两条独立的账):
· Go :`gofmt -l` 对**被跟踪的 .go 文件**输出必须为空 —— 一条都不许欠。
· 前端:`prettier --list-different` 的输出必须**完全落在基线里**(`web/.prettier-baseline.json`)。
基线是**一次性账面债**,只能减不能增:
· 出现基线之外的文件 ⇒ 红(新写的代码必须格式化过);
· 基线里的文件已格式化 ⇒ 提示删条目(不判红);
· 基线到期(review_by)⇒ 红 —— 要么一次清干净、要么由人重新批准,**不许无声续命**。
为什么前端不一刀切全量格式化:本仓有一部分是刻意压行的紧凑写法,一次性重排约 2 万行,
那会把"接入 CI"变成"重写前端"。所以先用**列明 + 只能减 + 到期**的基线接住存量,
把增量卡住;全量清理留作独立提交(由人决定何时做)。
用法:
python3 scripts/check_format.py
python3 scripts/check_format.py --self-test # 只跑判定逻辑的反向自检
python3 scripts/check_format.py --web-only # 只查前端(调试用,不算全绿)
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
WEB = ROOT / "web"
BASELINE = WEB / ".prettier-baseline.json"
def compare(unformatted: list[str], baseline: dict, today: dt.date) -> tuple[list[str], list[str]]:
"""纯判定:返回 (问题, 提示)。--self-test 直接打这个函数。"""
problems: list[str] = []
notes: list[str] = []
listed = baseline.get("unformatted")
if not isinstance(listed, list):
return [f"{BASELINE.name} 里没有 unformatted 列表 —— 基线坏掉时不能静默放行"], notes
review_by = baseline.get("review_by")
try:
due = dt.date.fromisoformat(str(review_by))
except (TypeError, ValueError):
problems.append(f"{BASELINE.name} 的 review_by 不是 YYYY-MM-DD —— 没有到期日的基线会永久留在仓库里")
due = None
if due and today > due:
problems.append(f"前端格式债基线已于 {due} 到期:要么 `npm --prefix web run format` 一次清干净,"
f"要么由人重新批准并更新 review_by(基线不许无声续命)")
known, now = set(listed), set(unformatted)
for f in sorted(now - known):
problems.append(f"新出现未格式化的文件:{f} —— 跑 `npm --prefix web run format` 再提交")
for f in sorted(known - now):
notes.append(f"基线里的 {f} 已经格式化,可以删掉这条")
notes.append(f"存量格式债:基线 {len(known)} 个文件,实际未格式化 {len(now)} 个")
return problems, notes
def self_test() -> int:
today = dt.date(2026, 10, 11)
good = {"review_by": "2026-12-31", "unformatted": ["a.ts", "b.ts"]}
cases = [
("存量文件仍欠 = 只提示不红", ["a.ts", "b.ts"], good, 0),
("出现基线外的新欠账 = 红", ["a.ts", "c.ts"], good, 1),
("基线里已修好的条目 = 只提示不红", ["a.ts"], good, 0),
("基线到期 = 红", ["a.ts", "b.ts"], {**good, "review_by": "2026-01-01"}, 1),
("基线没有 unformatted = 红", ["a.ts"], {"review_by": "2026-12-31"}, 1),
("基线没有到期日 = 红", ["a.ts"], {"unformatted": ["a.ts"]}, 1),
]
problems: list[str] = []
for name, unformatted, baseline, want in cases:
got, _ = compare(unformatted, baseline, today)
if len(got) != want:
problems.append(f"{name}:期望 {want} 个问题,实际 {len(got)} 个 {got}")
if problems:
print("❌ 格式门禁自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print(f"✅ 格式门禁自检通过:{len(cases)} 个判定分支全部符合预期")
return 0
def check_go() -> tuple[bool, list[str]]:
files = subprocess.run(["git", "ls-files", "*.go"], cwd=ROOT,
capture_output=True, text=True).stdout.split()
if not files:
return True, ["没有 .go 文件,跳过(本仓不是 Go 仓时这里应当为空)"]
gofmt = shutil.which("gofmt") or "/opt/citools/go/bin/gofmt"
if not (shutil.which("gofmt") or Path(gofmt).exists()):
return False, ["找不到 gofmt —— 工具缺失 ⇒ 失败,不当成'格式没问题'"]
p = subprocess.run([gofmt, "-l", *files], cwd=ROOT, capture_output=True, text=True)
# ⚠️ gofmt -l 列出文件时**退出码仍是 0** —— 只看退出码就是永远的假绿。
bad = [l for l in p.stdout.splitlines() if l.strip()]
if p.returncode != 0:
return False, [f"gofmt 执行失败(exit={p.returncode}):{p.stderr[:300]}"]
if bad:
return False, [f"{f} 未格式化" for f in bad] + ["跑 `gofmt -w <文件>` 再提交"]
return True, [f"gofmt 干净({len(files)} 个文件)"]
def check_web() -> tuple[bool, list[str]]:
if not (WEB / "package-lock.json").is_file():
return False, ["web/package-lock.json 不存在 ⇒ 依赖没按锁文件装,格式门禁不可信"]
prettier = WEB / "node_modules" / ".bin" / "prettier"
if not prettier.exists():
return False, ["web/node_modules 里没有 prettier —— 先 `npm --prefix web ci`(工具缺失 ⇒ 失败)"]
p = subprocess.run([str(prettier), "--list-different", "."], cwd=WEB,
capture_output=True, text=True)
if p.returncode not in (0, 1):
return False, [f"prettier 执行失败(exit={p.returncode}):{(p.stderr or p.stdout)[:300]}"]
unformatted = [l.strip() for l in p.stdout.splitlines() if l.strip()]
if not BASELINE.is_file():
return False, [f"找不到 {BASELINE.relative_to(ROOT)} —— 没有基线就不能判断'新欠账'"]
try:
baseline = json.loads(BASELINE.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
return False, [f"{BASELINE.name} 解析失败:{e}"]
problems, notes = compare(unformatted, baseline, dt.date.today())
for n in notes:
print(f" · {n}")
return not problems, problems
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--self-test", action="store_true")
ap.add_argument("--web-only", action="store_true", help="只查前端(调试用,不算全绿)")
args = ap.parse_args(argv)
if args.self_test:
return self_test()
ok = True
print("── 格式检查 ──")
if not args.web_only:
go_ok, go_msgs = check_go()
print(" Go(gofmt):" + ("✅" if go_ok else "❌"))
for m in go_msgs:
print((" " if go_ok else " ❌ ") + m, file=sys.stdout if go_ok else sys.stderr)
ok = ok and go_ok
if not go_ok:
print(" ⇒ 后续检查继续跑(格式债不阻塞其它结论,但总体已经不可能全绿)")
web_ok, web_msgs = check_web()
print(" 前端(prettier):" + ("✅" if web_ok else "❌"))
for m in web_msgs:
print((" " if web_ok else " ❌ ") + m, file=sys.stdout if web_ok else sys.stderr)
ok = ok and web_ok
if args.web_only:
print(" ⚠️ 只跑了前端(--web-only):**不算全绿**")
print("✅ 格式检查通过" if ok else "❌ 格式检查未通过")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+198
View File
@@ -0,0 +1,198 @@
#!/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:]))
+498
View File
@@ -0,0 +1,498 @@
#!/usr/bin/env python3
"""本仓**唯一权威门禁入口**(正本 §3.1 / 试点草案 §4)。
make verify # = python3 scripts/ci_checks.py
python3 scripts/ci_checks.py --list
python3 scripts/ci_checks.py --only tests-web # 单跑一项(**不算全绿**)
python3 scripts/ci_checks.py --self-test # 只跑汇总逻辑的反向自检
一条命令跑完全部必需检查,**串行、失败即停**,并产出结构化报告 `ci-report.json`。
CI 跑的就是这一条(`.gitea/workflows/build.yml` 里 `make verify`)—— 本地与 CI 同一份逻辑,
不做第二套步骤(正本 §3.1:两边各写一套,迟早只改一边)。
三条纪律写进代码里:
1. **失败即停,但汇总照做**:第一个必需项失败后,剩下的记为 `not_run`,
报告如实写"未运行"而不是留空 —— 留空会被下游当成"没有这一项"。
2. **被跳过的必需项不算过**:`aggregate()` 只认 `success`;
不适用要**在清单里声明**(`required_in`),不能在运行时悄悄 skip。
3. **工具缺失 / 依赖没按锁装 / 输出解析不了 ⇒ 失败**,不当成"检查通过"。
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
ROOT = Path(__file__).resolve().parent.parent
WEB = ROOT / "web"
REPORT_DEFAULT = ROOT / "ci-report.json"
POLICY_REVISION = "ci-checks/1" # 判据变更时改这里,报告里跟着变
MANIFESTS = ["go.mod", "go.sum", "web/package.json", "web/package-lock.json"]
SUCCESS, FAILURE, SKIPPED, NOT_RUN = "success", "failure", "skipped", "not_run"
# ── 工具解析:找不到就报错,不回落成"-- 跳过"(正本 §4.4)──────────────
def tool(name: str, *extra: str) -> str:
for cand in (shutil.which(name), *extra):
if cand and Path(cand).exists():
return cand
raise FileNotFoundError(name)
def go_bin() -> str:
return tool("go", "/opt/citools/go/bin/go")
def gofmt_bin() -> str:
return tool("gofmt", "/opt/citools/go/bin/gofmt")
def npm_bin() -> str:
return tool("npm")
def py(*args: str) -> list[str]:
return [sys.executable or "python3", *args]
def go_env() -> dict:
env = dict(os.environ)
env["GOFLAGS"] = "-mod=readonly" # 禁止隐式改写 go.mod / go.sum
env.setdefault("GOPROXY", "https://goproxy.cn,direct")
env.setdefault("GOTOOLCHAIN", "local")
# 缓存位置是**环境相关**的(CI 用主机挂载的 /opt/citools-cache),而缓存只加速、
# 不作为通过证据,所以这里"可用就用、不可用就用 Go 自己的默认",不写死。
cache = Path("/opt/citools-cache")
if cache.is_dir() and os.access(cache, os.W_OK):
for key, sub in (("GOCACHE", "gocache"), ("GOMODCACHE", "gomod"), ("GOPATH", "gopath")):
path = cache / sub
try:
path.mkdir(parents=True, exist_ok=True)
except OSError:
continue
env.setdefault(key, str(path))
return env
# ── 判定函数:拿到 (退出码, stdout, stderr) → (状态, 说明) ──────────────
def plain(exit_code: int, out: str, err: str) -> tuple[str, str]:
if exit_code == 0:
return SUCCESS, "退出码 0"
tail = (err or out).strip().splitlines()[-12:]
return FAILURE, f"退出码 {exit_code}|" + " / ".join(tail)
def judge_go_tests(exit_code: int, out: str, err: str) -> tuple[str, str]:
"""`go test -json` 的盘点:必须**真的执行过**用例,且把跳过的原因说清楚。
为什么不用退出码了事:退出码 0 只说明"没有失败",一个用例都没跑的包也是 0。
被跳过的断言等于没有断言(正本 §5.5)。"""
passed = failed = skipped = 0
reasons: list[str] = []
pending = ""
pkgs: set[str] = set()
for line in out.splitlines():
line = line.strip()
if not line.startswith("{"):
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("Package"):
pkgs.add(ev["Package"])
action, test = ev.get("Action"), ev.get("Test")
if test and action in ("pass", "fail", "skip"):
passed += action == "pass"
failed += action == "fail"
skipped += action == "skip"
chunk = ev.get("Output") or ""
m = re.match(r"\s*\S+_test\.go:\d+:\s*(.+)$", chunk)
if m:
pending = m.group(1).strip()
if "--- SKIP:" in chunk and pending and pending not in reasons:
reasons.append(pending)
detail = f"执行包 {len(pkgs)}|用例 通过 {passed} / 失败 {failed} / 跳过 {skipped}"
if skipped:
detail += f"|跳过的原因(前 3 条):{reasons[:3] or '未取到'}"
if exit_code != 0 or failed:
tail = (err or out).strip().splitlines()
return FAILURE, detail + (f"|{tail[-1][:200]}" if tail else "")
if passed == 0:
return FAILURE, detail + "|一个用例都没真的执行 —— 无测试不能算全绿"
return SUCCESS, detail
def judge_vitest_file(path: Path) -> tuple[str, str]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
return FAILURE, f"前端测试报告读不到/解析不了({e})—— 未完成不算过"
total = int(data.get("numTotalTests") or 0)
failed = int(data.get("numFailedTests") or 0)
suites = int(data.get("numTotalTestSuites") or 0)
passed = int(data.get("numPassedTests") or 0)
detail = f"套件 {suites}|用例 通过 {passed} / 失败 {failed} / 共 {total}"
if failed:
return FAILURE, detail
if total == 0 or suites == 0:
return FAILURE, detail + "|一个用例都没执行(空套件不能算通过)"
return SUCCESS, detail
# ── 检查清单(唯一一份定义;Makefile / CI / 文档都指它)─────────────────
@dataclass
class Check:
id: str
title: str
argv: list[str]
cwd: Path = ROOT
env: dict = field(default_factory=dict)
judge: Callable[[int, str, str], tuple[str, str]] = plain
required_in: tuple[str, ...] = ("local", "ci")
note: str = ""
def checks() -> list[Check]:
g, gf, npm, py3 = go_bin(), gofmt_bin(), npm_bin(), sys.executable or "python3"
env = go_env()
out_dir = Path(os.environ.get("CI_BUILD_OUT") or (ROOT / "bin"))
return [
Check("identity", "检出身份(实际 HEAD == 事件完整 SHA)",
[], required_in=("ci",),
note="本地没有事件负载 ⇒ 记录为 skipped 并在这里说明;CI 上它是必需项"),
Check("commits", "提交信息规范(本次推送的范围)",
[py3, ".gitea/scripts/ci_gate.py"]),
Check("deps-install", "依赖按锁文件安装(缺锁即失败,不退化)",
[py3, "scripts/ci_checks.py", "--internal-deps", str(out_dir)],
env=env),
Check("format", "格式(Go gofmt + 前端 prettier)", [py3, "scripts/check_format.py"], env=env),
Check("static-go", "Go 静态检查(go vet)", [g, "vet", "./..."], env=env),
Check("static-web", "前端类型检查(tsc)", [npm, "--prefix", "web", "run", "typecheck"], env=env),
Check("build-go", "Go 构建(带版本注入)",
[py3, "scripts/ci_checks.py", "--internal-build", str(out_dir)], env=env),
Check("build-web", "前端构建(vite build)", [npm, "--prefix", "web", "run", "build"], env=env),
Check("tests-go", "Go 测试(禁用测试缓存,强制真跑)",
[g, "test", "-count=1", "-json", "./..."], env=env, judge=judge_go_tests),
Check("tests-web", "前端测试(vitest,必须真跑)",
[py3, "scripts/ci_checks.py", "--internal-web-test"], env=env),
Check("contracts", "版本与变更记录契约(不许 SKIP 后算过)",
[py3, "scripts/ci_checks.py", "--internal-contracts", g], env=env),
Check("security-secrets", "密钥泄漏扫描(含扫描器自检)",
[py3, "scripts/check_secrets.py"]),
Check("security-deps", "依赖漏洞(govulncheck + npm audit,分级 + 例外)",
[py3, "scripts/check_deps_vuln.py"], env=env),
Check("manifests-locked", "依赖清单在这次门禁里没有被改写",
[py3, "scripts/ci_checks.py", "--internal-manifests"]),
]
# ── 内部子命令(把"一条复杂命令"收进脚本,YAML/清单里只留一行)─────────
def cmd_internal_deps(out_dir: str) -> int:
"""按锁文件安装依赖。缺锁**不**退化成 `npm install`(那会静默改写清单)。"""
lock = WEB / "package-lock.json"
if not lock.is_file():
print("❌ web/package-lock.json 不存在:不接受无约束安装,请先把锁文件入库", file=sys.stderr)
return 1
if subprocess.run([npm_bin(), "ci", "--prefix", "web"], cwd=ROOT, env=go_env()).returncode:
print("❌ npm ci 失败:锁文件与 package.json 不一致(先跑 npm install 并提交锁文件)", file=sys.stderr)
return 1
p = subprocess.run([go_bin(), "mod", "download"], cwd=ROOT, env=go_env(),
capture_output=True, text=True)
if p.returncode:
print(f"❌ go mod download 失败:{(p.stderr or p.stdout)[:400]}", file=sys.stderr)
return 1
print("✅ 依赖按锁文件安装完成(npm ci + go mod download)")
return 0
def cmd_internal_build(out_dir: str) -> int:
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
version = subprocess.run(py("scripts/version.py"), cwd=ROOT, capture_output=True,
text=True).stdout.strip() or "dev"
sha = subprocess.run(["git", "rev-parse", "--short=12", "HEAD"], cwd=ROOT,
capture_output=True, text=True).stdout.strip() or "unknown"
ts = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
ldflags = " ".join([
f"-X knowledge-governance/internal/version.Number={version}",
f"-X knowledge-governance/internal/version.CommitSHA={sha}",
f"-X knowledge-governance/internal/version.BuildTime={ts}",
])
p = subprocess.run([go_bin(), "build", "-trimpath", "-ldflags", ldflags, "-o", f"{out}/", "./cmd/..."],
cwd=ROOT, env=go_env(), capture_output=True, text=True)
print(f"-- go build(版本 {version} / 提交 {sha})--")
print(p.stdout[-2000:] if p.stdout else "", end="")
if p.returncode:
print(f"❌ 构建失败:{(p.stderr or '')[:2000]}", file=sys.stderr)
return 1
print(f"✅ 构建完成:{out}")
return 0
def cmd_internal_web_test() -> int:
"""前端测试必须真跑(空套件不算通过),所以拿 vitest 的 JSON 报告看数字。"""
with tempfile.TemporaryDirectory() as d:
rep = Path(d) / "vitest.json"
p = subprocess.run([npm_bin(), "--prefix", "web", "run", "test", "--",
"--reporter=json", f"--outputFile={rep}"],
cwd=ROOT, env=go_env(), capture_output=True, text=True)
print(p.stdout[-3000:], end="")
if p.stderr.strip():
print(p.stderr[-2000:], end="", file=sys.stderr)
status, detail = judge_vitest_file(rep)
print(("✅ " if status == SUCCESS else "❌ ") + detail, file=sys.stdout if status == SUCCESS else sys.stderr)
return 0 if status == SUCCESS and p.returncode == 0 else 1
def cmd_internal_contracts(go: str) -> int:
"""版本契约:注入进二进制的号必须等于 CHANGELOG 顶部,而且**必须真的 PASS**。"""
rc = subprocess.run(py("scripts/check_changelog.py"), cwd=ROOT).returncode
if rc:
return 1
v = subprocess.run(py("scripts/version.py"), cwd=ROOT, capture_output=True, text=True)
version = v.stdout.strip()
if v.returncode or not version:
print("❌ CHANGELOG 顶部解析不到版本号:没有条目就没有版本号,不许回落成假版本", file=sys.stderr)
return 1
print(f"-- 版本契约:CHANGELOG 顶部 = {version} --")
p = subprocess.run([go, "test", f"-ldflags=-X knowledge-governance/internal/version.Number={version}",
"-count=1", "-run", "TestNumberMatchesChangelog", "-v", "-json",
"./internal/version/..."],
cwd=ROOT, env=go_env(), capture_output=True, text=True)
text = p.stdout + p.stderr
print(text[-3000:], end="")
# ⚠️ 被跳过的断言等于没有断言:必须看到 PASS,SKIP 一律判红。
# 用 in 而不是 `| grep -q`:pipefail 下 grep -q 会因 SIGPIPE 谎报失败(trap-7)。
if '--- PASS: TestNumberMatchesChangelog' not in text.replace("\\n", "\n"):
print("❌ 版本契约没有真正 PASS(被 SKIP 或没跑到)—— 缺 -ldflags,或 CHANGELOG 解析失败", file=sys.stderr)
return 1
if "--- SKIP: TestNumberMatchesChangelog" in text:
print("❌ 版本契约被跳过(未注入版本号)—— 跳过不算通过", file=sys.stderr)
return 1
print("✅ 版本契约成立:注入 == CHANGELOG 顶部,且断言真的执行了")
return 0
def cmd_internal_manifests() -> int:
"""比对门禁开始前后依赖清单的摘要 —— 抓'某一步顺手改了 go.mod/锁文件'(静默漂移)。"""
baseline_path = Path(os.environ.get("CI_MANIFEST_BASELINE", str(ROOT / ".ci-manifests.json")))
if not baseline_path.is_file():
print("❌ 找不到门禁开始时的清单摘要(说明汇总流程被改坏了)—— 判红", file=sys.stderr)
return 1
before = json.loads(baseline_path.read_text(encoding="utf-8"))
after = {f: digest(ROOT / f) for f in MANIFESTS}
drift = [f for f in MANIFESTS if before.get(f) != after.get(f)]
if drift:
print(f"❌ 这些依赖清单在门禁过程中被改写了:{drift}", file=sys.stderr)
print(" (GOFLAGS=-mod=readonly / npm ci 就是为了防这个;改写必须由人显式提交)", file=sys.stderr)
return 1
print(f"✅ 依赖清单未被改写({len(MANIFESTS)} 个文件摘要一致)")
return 0
def digest(p: Path) -> str:
return hashlib.sha256(p.read_bytes()).hexdigest() if p.is_file() else "missing"
# ── 汇总:纯函数,--self-test 直接打它 ────────────────────────────────────
def aggregate(results: list[dict], profile: str) -> tuple[str, list[str]]:
problems = []
for r in results:
declared = set(r["required_in"])
if profile not in declared:
if r["status"] in (FAILURE, "unknown"):
problems.append(f"{r['id']} 在 {profile} 档案下不要求,但它状态是 {r['status']} —— 不静默放过")
continue
if r["status"] != SUCCESS:
# 摘要只留前 200 字符:完整原因在报告 JSON 的 reason 字段里
why = f"|{r['reason'][:200]}" if r.get("reason") else ""
problems.append(f"{r['id']} 状态 {r['status']}{why}")
return (SUCCESS if not problems else FAILURE), problems
def self_test() -> int:
def R(cid, status, required=("local", "ci")):
return {"id": cid, "status": status, "required_in": list(required), "reason": ""}
cases = [
("全绿", [R("a", SUCCESS), R("b", SUCCESS)], "local", 0),
("一项失败", [R("a", SUCCESS), R("b", FAILURE)], "local", 1),
("一项未运行(失败即停的后果)", [R("a", FAILURE), R("b", NOT_RUN)], "local", 2),
("必需项被跳过", [R("a", SKIPPED)], "local", 1),
("某项在本地档案下不要求且被跳过 ⇒ 不算问题", [R("identity", SKIPPED, ("ci",))], "local", 0),
("但它在 ci 档案下就是问题", [R("identity", SKIPPED, ("ci",))], "ci", 1),
("ci 档案下本地不要求的项 unknown 也要拦", [R("identity", "unknown", ("ci",))], "ci", 1),
]
problems = []
for name, results, profile, want in cases:
_, got = aggregate(results, profile)
if len(got) != want:
problems.append(f"{name}:期望 {want} 个问题,实际 {len(got)} 个 {got}")
if problems:
print("❌ 汇总逻辑自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print(f"✅ 汇总逻辑自检通过:{len(cases)} 个分支(含'必需项未运行/被跳过不得报全绿')")
return 0
# ── 身份检查(只在 CI 上有意义,所以单独一段)──────────────────────────
def identity_result() -> tuple[str, str]:
want = (os.environ.get("GITHUB_SHA") or "").strip()
if not want:
return SKIPPED, "本地运行没有事件负载(GITHUB_SHA 未设置);CI 上这一项是必需项"
got = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT,
capture_output=True, text=True).stdout.strip()
if got != want:
return FAILURE, f"实际 HEAD {got[:12]} != 事件 SHA {want[:12]}(分支在推送后又前进了?)"
return SUCCESS, f"检出 {got[:12]} == 事件完整 SHA"
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("--list", action="store_true")
ap.add_argument("--only", default="")
ap.add_argument("--self-test", action="store_true")
ap.add_argument("--report", default=str(REPORT_DEFAULT))
ap.add_argument("--profile", default="ci" if os.environ.get("GITHUB_ACTIONS") else "local",
choices=["local", "ci"])
ap.add_argument("--internal-deps", default="")
ap.add_argument("--internal-build", default="")
ap.add_argument("--internal-web-test", action="store_true")
ap.add_argument("--internal-contracts", default="")
ap.add_argument("--internal-manifests", action="store_true")
args, _ = ap.parse_known_args(argv)
# 内部子命令:把"一条复杂命令"收进本文件,清单与 YAML 里只留一行。
# 先处理它们,避免被下面的门禁流程当成一次普通运行。
if args.internal_deps:
return cmd_internal_deps(args.internal_deps)
if args.internal_build:
return cmd_internal_build(args.internal_build)
if args.internal_contracts:
return cmd_internal_contracts(args.internal_contracts)
if args.internal_web_test:
return cmd_internal_web_test()
if args.internal_manifests:
return cmd_internal_manifests()
if args.self_test:
return self_test()
table = checks()
if args.list:
print(f"── 本仓必需检查清单(策略版本 {POLICY_REVISION})──")
for c in table:
print(f" {c.id:<18} {'/'.join(c.required_in):<12} {c.title}")
if c.note:
print(f" {'':<18} {'':<12} ⚠️ {c.note}")
return 0
if args.only:
want = [x for x in args.only.split(",") if x]
unknown = [x for x in want if x not in {c.id for c in table}]
if unknown:
print(f"❌ 没有这些检查 id:{unknown}", file=sys.stderr)
return 2
table = [c for c in table if c.id in want]
print(f"⚠️ --only {args.only}:只跑了子集,**不算全绿**(报告里 overall_status 会写 not_run_only)")
# 门禁开始前记录依赖清单摘要:末尾断言"没有任何一步改写了它们"
baseline = ROOT / ".ci-manifests.json"
baseline.write_text(json.dumps({f: digest(ROOT / f) for f in MANIFESTS}), encoding="utf-8")
os.environ["CI_MANIFEST_BASELINE"] = str(baseline)
results: list[dict] = []
failed_early = False
started = time.time()
for c in table:
if failed_early:
results.append({"id": c.id, "title": c.title, "required_in": list(c.required_in),
"status": NOT_RUN, "reason": "前一项失败即停,未运行", "command": "",
"exit_code": None, "duration_s": 0})
continue
if c.id == "identity":
status, reason = identity_result()
results.append({"id": c.id, "title": c.title, "required_in": list(c.required_in),
"status": status, "reason": reason, "command": "git rev-parse HEAD",
"exit_code": 0 if status == SUCCESS else 1, "duration_s": 0})
failed_early = status == FAILURE
print(f"\n── [{c.id}] {c.title} ──\n {'✅' if status == SUCCESS else ('⏭️' if status == SKIPPED else '❌')} {reason}")
continue
print(f"\n── [{c.id}] {c.title} ──")
print(" $ " + " ".join(c.argv))
t0 = time.time()
try:
p = subprocess.run(c.argv, cwd=c.cwd, env={**os.environ, **c.env},
capture_output=True, text=True)
exit_code, out, err = p.returncode, p.stdout, p.stderr
except FileNotFoundError as e:
exit_code, out, err = 127, "", f"工具缺失:{e}(工具缺失 ⇒ 失败,不当成检查通过)"
dur = round(time.time() - t0, 2)
status, reason = c.judge(exit_code, out, err)
if out.strip():
print("\n".join(" | " + l for l in out.strip().splitlines()[-40:]))
if err.strip() and status != SUCCESS:
print("\n".join(" ! " + l for l in err.strip().splitlines()[-20:]), file=sys.stderr)
print(f" {'✅' if status == SUCCESS else '❌'} {reason}({dur}s)")
results.append({"id": c.id, "title": c.title, "required_in": list(c.required_in),
"status": status, "reason": reason, "command": " ".join(c.argv),
"exit_code": exit_code, "duration_s": dur})
failed_early = status != SUCCESS
overall, problems = aggregate(results, args.profile)
if args.only:
overall = "not_run_only"
report = {
"schema": "ci-report/1",
"policy_revision": POLICY_REVISION,
"repository": os.environ.get("GITHUB_REPOSITORY", ""),
"commit_sha": subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, capture_output=True,
text=True).stdout.strip(),
"event_sha": os.environ.get("GITHUB_SHA", ""),
"branch": os.environ.get("GITHUB_REF_NAME", ""),
"run_id": os.environ.get("GITHUB_RUN_ID", ""),
"event": os.environ.get("GITHUB_EVENT_NAME", "local"),
"profile": args.profile,
"started_at": dt.datetime.fromtimestamp(started, dt.timezone.utc).isoformat(timespec="seconds"),
"duration_s": round(time.time() - started, 2),
"checks": results,
"overall_status": overall,
"problems": problems,
"notification_status": "not_attempted",
"deployed": False,
}
Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
baseline.unlink(missing_ok=True)
print("\n════════ 汇总 ════════")
n = len(results)
print(f" 检查 {n} 项:" + "|".join(f"{r['id']}={r['status']}" for r in results))
print(f" 总体:{overall}(档案 {args.profile}) 报告:{args.report}")
if problems:
for p in problems:
print(f" ❌ {p}", file=sys.stderr)
print(" ⚠️ 注意:'未运行'不是'通过' —— 失败即停之后剩下的项一律不参与'全绿'判定。", file=sys.stderr)
return 1
if args.only:
return 0 if all(r["status"] == SUCCESS for r in results) else 1
print(" ✅ 全部必需检查通过(本结论含全部必需项,不含任何未运行项)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+18
View File
@@ -0,0 +1,18 @@
{
"_doc": [
"依赖漏洞**例外**登记表。判据与分级在 scripts/check_deps_vuln.py,CI 会跑它。",
"只有 high/critical(以及 govulncheck 的任何命中)才需要例外;moderate 及以下只记录。",
"一条例外要**全部**字段齐全才算有效,缺一个就当成没有例外:",
" tool : govulncheck | npm-audit",
" id : 漏洞编号(govulncheck 用 GO-xxxx-yyyy,npm 用 GHSA-xxxx-xxxx-xxxx)",
" package : 包名(给 npm 用;govulncheck 填模块名)",
" severity : critical | high(govulncheck 的命中填空字符串)",
" scope : prod | dev | build",
" reason : 为什么这个风险在本仓不适用 / 不可修",
" owner : 谁负责在到期前处理",
" approved_by : 谁批准的(必须是**人**;写 TBD/pending/self 一律判为无效)",
" expires_on : YYYY-MM-DD,过期当天即失效 —— 过期不是\"继续有效\",是\"重新走一遍审批\"",
"纪律:例外是记账,不是免检。到期没人处理 ⇒ 门禁红 ⇒ 必须有人做决定。"
],
"exceptions": []
}