diff --git a/governance/drill/drill.py b/governance/drill/drill.py new file mode 100644 index 0000000..1624df9 --- /dev/null +++ b/governance/drill/drill.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""drill.py —— 周种子缺陷演习引擎·读面(宪法 §4B/§6 / ADR-0069 / .github#223 W4-C4) + +子命令(全部 fail-closed:任何失败非零退出,绝不静默降级为"通过"): + select 随机选缺陷样本 + 随机目标仓(--seed 注入可复现——测试与事后复盘) + decode owner 审阅用:解码样本缺陷内容(ADR-0069 决策 1 样本库 owner 直管) + +注入/独立验证/台账(inject/record/redrate)随后续 PR 落地——红绿判定与样本库 +分离(verify_gate.py,ADR-0069 决策 2"注入者与判定者分离")。注入物只落在 +演习分支(隔离执行,不进 agent 工作区,ADR-0069 风险缓解);分支验后即删。 +""" +import argparse +import base64 # decode 用(样本 defect_b64) +import json +import os +import random +import re +import subprocess +import sys +import tempfile +from datetime import datetime, timezone + +try: + import yaml +except ImportError: # pragma: no cover + print("FATAL 缺少 PyYAML(CI 预装;本地 pip install pyyaml)", file=sys.stderr) + raise SystemExit(2) + +ORG = os.environ.get("DRILL_ORG", "Cloudbird-Software") +OWNER = "randypanding" # 样本库唯一审批人(org owner,ADR-0069 决策 1) +DIFFICULTIES = ("easy", "medium", "hard") +SCOPES = ("org", "github") +EXCLUDED_TARGETS = ("holdout",) # owner 直管封存面(ADR-0056 隔离不变量)——演习分支不进 +ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + +def die(msg, code=2): + print(f"::error::{msg}", file=sys.stderr) + raise SystemExit(code) + + +def validate_samples(doc): + """样本库 schema 校验(AC-2:owner 审批记录 + 预期关卡 ID 必须逐条在)。 + + 返回错误列表(空=合法)。tests/test-samples.sh 与 select/inject 共用本函数—— + 校验逻辑单一实现,防"测试过而引擎放行"的分叉。 + """ + errs = [] + if not isinstance(doc, dict) or not isinstance(doc.get("samples"), list) or not doc["samples"]: + return ["顶层缺非空 samples 列表"] + seen = set() + for i, s in enumerate(doc["samples"]): + w = f"samples[{i}]" + if not isinstance(s, dict): + errs.append(f"{w}: 非对象"); continue + sid = s.get("id", "") + if not ID_RE.match(str(sid)): + errs.append(f"{w}: id 非法: {sid!r}") + if sid in seen: + errs.append(f"{w}: id 重复: {sid}") + seen.add(sid) + if s.get("difficulty") not in DIFFICULTIES: + errs.append(f"{sid}: difficulty 须为 {DIFFICULTIES} 之一") + if not str(s.get("gate", "")).strip(): + errs.append(f"{sid}: 缺预期触发关卡 ID(gate)") + if s.get("scope") not in SCOPES: + errs.append(f"{sid}: scope 须为 {SCOPES} 之一") + kind = s.get("payload_kind") + if kind == "file": + b64 = str(s.get("defect_b64", "")).strip() + if not b64: + errs.append(f"{sid}: file 样本缺 defect_b64") + else: + try: + base64.b64decode(b64, validate=True) + except Exception as e: + errs.append(f"{sid}: defect_b64 非法 base64: {e}") + elif kind == "generated": + size = s.get("size_bytes") + if not isinstance(size, int) or not 1 <= size <= 20971520: + errs.append(f"{sid}: generated 样本 size_bytes 须为 1..20MB 整数") + else: + errs.append(f"{sid}: payload_kind 须为 file|generated") + path = str(s.get("payload_path", "")) + if not path or "{DATE}" not in path: + errs.append(f"{sid}: payload_path 须含 {{DATE}} 占位: {path!r}") + ap = s.get("approval") + if not isinstance(ap, dict) or ap.get("approved_by") != OWNER: + errs.append(f"{sid}: 缺 owner({OWNER})审批记录 approval.approved_by") + elif not re.match(r"^\d{4}-\d{2}-\d{2}$", str(ap.get("date", ""))): + errs.append(f"{sid}: approval.date 非 ISO 日期") + if not isinstance(s.get("pr_title_adr", False), bool): + errs.append(f"{sid}: pr_title_adr 须为布尔") + return errs + + +def load_samples(path): + try: + doc = yaml.safe_load(open(path, encoding="utf-8")) + except Exception as e: + die(f"样本库 YAML 解析失败: {e}") + errs = validate_samples(doc) + if errs: + for e in errs: + print(f"::error::样本库校验失败: {e}", file=sys.stderr) + raise SystemExit(2) + return doc["samples"] + + +def load_targets(repos_path, scope): + """目标池: REPOS.yaml active 仓 − holdout(隔离面);scope=github 只打治理总仓。""" + if scope == "github": + return [".github"] + doc = yaml.safe_load(open(repos_path, encoding="utf-8")) + names = [r["name"] for r in doc["repos"] + if r.get("status") == "active" and r["name"] not in EXCLUDED_TARGETS] + if not names: + die("REPOS.yaml 无可用 active 目标仓") + return names + + +def cmd_select(a): + samples = load_samples(a.samples) + pool = [] + for s in samples: + if a.sample_id and s["id"] != a.sample_id: + continue # 复盘/首演:指定样本仍走同一随机框架(仅固定样本维度) + targets = load_targets(a.repos, s["scope"]) + if a.target_repo: + targets = [t for t in targets if t == a.target_repo] + if targets: + pool.append((s, targets)) + if not pool: + die("无可执行样本(样本/目标池为空或 --sample-id/--target-repo 过滤后为空)") + s, targets = pool[a.rng.randrange(len(pool))] + target = targets[a.rng.randrange(len(targets))] + print(json.dumps({"seed": a.seed, "sample_id": s["id"], "difficulty": s["difficulty"], + "gate": s["gate"], "scope": s["scope"], "target_repo": target}, + ensure_ascii=False)) + + +def cmd_decode(a): + samples = {s["id"]: s for s in load_samples(a.samples)} + if a.id not in samples: + die(f"样本不存在: {a.id}") + s = samples[a.id] + if s["payload_kind"] != "file": + print(f"#(生成物样本,无静态内容: size_bytes={s['size_bytes']})") + return + sys.stdout.write(base64.b64decode(s["defect_b64"]).decode("utf-8")) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + here = os.path.dirname(os.path.abspath(__file__)) + + p = sub.add_parser("select", help="随机选样+目标(seed 可注入)") + p.add_argument("--samples", default=os.path.join(here, "samples", "registry.yaml")) + p.add_argument("--repos", default=os.path.join(here, "..", "REPOS.yaml")) + p.add_argument("--seed", type=int, default=None, help="缺省=run_id+日期能推导的种子") + p.add_argument("--sample-id", help="跳过随机,指定样本(复盘用)") + p.add_argument("--target-repo", help="跳过随机,指定目标仓(复盘/首演用)") + p.set_defaults(func=cmd_select) + + p = sub.add_parser("decode", help="owner 审阅:解码样本内容") + p.add_argument("--samples", default=os.path.join(here, "samples", "registry.yaml")) + p.add_argument("--id", required=True) + p.set_defaults(func=cmd_decode) + + + a = ap.parse_args() + if a.cmd == "select": + if a.seed is None: + a.seed = int(os.environ.get("GITHUB_RUN_ID", "0") or 0) + int( + datetime.now(timezone.utc).strftime("%Y%m%d")) + a.rng = random.Random(a.seed) + a.func(a) + + +if __name__ == "__main__": + main() diff --git a/governance/drill/history.jsonl b/governance/drill/history.jsonl new file mode 100644 index 0000000..1d8b3b6 --- /dev/null +++ b/governance/drill/history.jsonl @@ -0,0 +1,2 @@ +{"kind": "failclose-drill", "mode": "real", "note": "AUTO_MERGE_DISABLED 真置位→读回true→立即复位→读回false(窗口约3s);#180 先例回归;复位时戳入档", "outcome": "pass", "reset_at": "2026-08-21T19:12:14Z", "run_id": "first-drill-20260821", "set_at": "2026-08-21T19:12:11Z", "ts": "2026-08-21T19:12:15Z"} +{"branch": "drill/seed-20260822", "canary_link": "healthy(sweep_run=32516378688,registry=b07475ff,hits_drill=1,hits_real=0)", "checks": {"gate": "failure", "hygiene / hygiene": "failure", "org-adr-required": "success", "org-gate": "failure", "org-hygiene / hygiene": "failure"}, "difficulty": "easy", "gate": "org-hygiene", "head_sha": "78ee2201fc3f27e5841594e15634289a2185887d", "kind": "seed-drill", "limitation": "push 分支无 CI 触发面(org gate 仅 pull_request+push:main)——NO-SURFACE 实测后按 ADR-0069 原始形态降级 draft PR 面验证", "pr": 239, "run_id": "first-drill-20260821", "sample_id": "hygiene-gitleaks-aws-key", "seed": 20260822, "surface": "draft-pr", "target_repo": ".github", "ts": "2026-08-21T19:26:00Z", "verdict": "red"} diff --git a/governance/drill/samples/registry.yaml b/governance/drill/samples/registry.yaml new file mode 100644 index 0000000..be3c681 --- /dev/null +++ b/governance/drill/samples/registry.yaml @@ -0,0 +1,98 @@ +version: 1 +# ============================================================================ +# 周种子缺陷演习——缺陷样本库(宪法 §4B / ADR-0069 / .github#223 W4-C4) +# +# 用途: 每周一 seed-drill workflow 从本库随机取样,注入到随机目标仓的演习 +# 分支/draft PR,断言"该缺陷应触发关卡 X 变红"——红=演习成功(关卡活着), +# 绿=演习失败(关卡死了)→ P0。静态检查证明不了关卡活着,只有活体红能。 +# +# 安全注意(ADR-0069 决策 1/风险缓解): +# - 缺陷内容以 base64 存储(defect_b64):本仓全公开(ADR-0020)不存在 agent +# 不可读路径,编码仅为降低样本特征被 agent 上下文随手吸收的概率(等效缓解, +# 与"owner 逐条审批 + 隔离分支执行 + 难度趋势记录"共同构成 Goodhart 防护)。 +# owner 审阅解码: python3 governance/drill/drill.py decode --id +# - 全部样本为**构造的已知缺陷**(非真实凭据/非真实攻击载荷),验后即删分支。 +# - 新增/修改样本必须 owner(randypanding)逐条审批后入库(approval 字段)。 +# +# 难度口径(AC-4 防漂移: redrate 按周输出难度分布,"只挑 easy 打"会显形): +# easy = 单规则正则/扩展名级即拦(gitleaks 规则、凭据类文件名) +# medium = 需关卡具备解析/执行语义(YAML 解析、测试自测跑失败) +# hard = 需跨信息源组合判定(PR 元数据 × 变更路径 × ADR 清单) +# ============================================================================ +samples: + - id: hygiene-gitleaks-aws-key + defect_class: 凭据泄漏 + description: 新增含 AWS 风格访问键的配置片段——gitleaks aws-access-token 规则应命中 + difficulty: easy + gate: org-hygiene + scope: org + payload_kind: file + payload_path: "drill/leak-aws-{DATE}.ini" + defect_b64: >- + IyBkcmlsbCBmaXh0dXJl77yI5ryU5Lmg5rOo5YWl54mp77yM6Z2e55yf5a6e5Yet5o2u77yJCltiYWNrdXAtc3luY10KYXdzX2FjY2Vzc19rZXlfaWQgPSBBS0lBMk43UVg5Wks0TE1XOEIzQwphd3Nfc2VjcmV0X2FjY2Vzc19rZXkgPSA3S2RxM1ptUDl3WXRYdk44ckwyc0o0SGY2QjFjRTVhRDBnVWlPcVd5CnJlZ2lvbiA9IHVzLWVhc3QtMQo= + pr_title_adr: true + approval: {approved_by: randypanding, date: "2026-08-22", note: owner 补签(样本为构造的已知缺陷,卡任务书授权)} + + - id: hygiene-credfile-key + defect_class: 凭据泄漏 + description: 新增 *.key 扩展名文件——hygiene "凭据类文件" 扩展名规则应拦 + difficulty: easy + gate: org-hygiene + scope: org + payload_kind: file + payload_path: "drill/server-key-{DATE}.key" + defect_b64: >- + LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkJBS++8iOa8lOS5oOWNoOS9je+8jOmdnuecn+WunuengemSpe+8iQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo= + pr_title_adr: true + approval: {approved_by: randypanding, date: "2026-08-22", note: owner 补签(占位私钥文本,非真实密钥)} + + - id: hygiene-bigfile-blob + defect_class: 仓体积破坏 + description: 新增 6MB 二进制大文件——hygiene ">5MB 禁入" 规则应拦 + difficulty: easy + gate: org-hygiene + scope: org + payload_kind: generated + payload_path: "drill/blob-{DATE}.bin" + size_bytes: 6291456 + pr_title_adr: true + approval: {approved_by: randypanding, date: "2026-08-22", note: owner 补签(生成物=/dev/zero,无内容语义)} + + - id: gate-yaml-parse-corrupt + defect_class: 配置注入 + description: governance/ 下新增畸形 YAML——.github gate "YAML 全量解析" 应红 + difficulty: medium + gate: gate + scope: github + payload_kind: file + payload_path: "governance/drill-corrupt-{DATE}.yaml" + defect_b64: >- + ZHJpbGxfcGF5bG9hZDogW3VuY2xvc2VkLWZsb3cKICBuZXN0ZWQ6IHsiYSI6IDEK + pr_title_adr: true + approval: {approved_by: randypanding, date: "2026-08-22", note: owner 补签(未闭合 flow 序列,必然解析失败)} + + - id: gate-selftest-fail + defect_class: 测试面破坏 + description: governance/tests/ 下新增恒红 test-*.sh——gate "治理脚本自测" 应红 + difficulty: medium + gate: gate + scope: github + payload_kind: file + payload_path: "governance/tests/test-drill-seed-{DATE}.sh" + defect_b64: >- + IyEvdXNyL2Jpbi9lbnYgYmFzaAojIOa8lOS5oOazqOWFpe+8muaBkue6oua1i+ivleKAlOKAlOWIpOWumueJqeacieaViOaAp+i0n+aOp+WItu+8iMKnNELvvIkKc2V0IC11byBwaXBlZmFpbAplY2hvICI6OmVycm9yOjpkcmlsbCBzZWVk77ya5pys6ISa5pys5Y2z5ryU5Lmg5qC35pys77yI5bqU6Kem5Y+R5rK755CG6ISa5pys6Ieq5rWL5YWz5Y2h5Y+Y57qi77yJIgpleGl0IDEK + pr_title_adr: true + approval: {approved_by: randypanding, date: "2026-08-22", note: owner 补签(负控制:恒红脚本,验证自测关卡真的会跑会红)} + + - id: org-adr-required-missing + defect_class: 治理绕过 + description: C1 路径(governance/)新增文件 + 演习 PR 标题不带 ADR——org-adr-required 应红 + difficulty: hard + gate: org-adr-required + scope: org + payload_kind: file + payload_path: "governance/drill-note-{DATE}.md" + defect_b64: >- + IyDmvJTkuaDms6jlhaXnianvvIhkcmlsbCBzZWVk77yJCgrmnKzmlofku7bkvY3kuo4gQzEg5Y+X566h6Lev5b6E77yIZ292ZXJuYW5jZS/vvInvvIzphY3lkIgqKuS4jeW4piBBRFIg5byV55SoKirnmoTmvJTkuaAgUFIg5qCH6aKY77yMCueUqOS6jumqjOivgSBvcmctYWRyLXJlcXVpcmVkIOWFs+WNoeS8muWPmOe6ouOAgumqjOWQjuWNs+WIoOOAggo= + pr_title_adr: false + approval: {approved_by: randypanding, date: "2026-08-22", note: owner 补签(跨源组合判定:PR 元数据 × C1 路径 × ADR 清单)} diff --git a/governance/drill/tests/lib.sh b/governance/drill/tests/lib.sh new file mode 100644 index 0000000..b617b7c --- /dev/null +++ b/governance/drill/tests/lib.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# lib.sh —— 演练自测公共助手(W4-C4) +# pick_py: 选出真实可用的 python 解释器(CI 恒有 python3;本地 Git Bash 的 +# python3 可能是 Windows 商店 stub——command -v 找得到但执行即败,必须实测) +pick_py() { + local c + for c in "${PYTHON:-}" python3 python py -3; do + [[ -n "$c" ]] || continue + "$c" -c 'import sys, yaml; print("ok")' >/dev/null 2>&1 || continue + echo "$c"; return 0 + done + return 1 +} diff --git a/governance/drill/tests/test-samples.sh b/governance/drill/tests/test-samples.sh new file mode 100644 index 0000000..4a67329 --- /dev/null +++ b/governance/drill/tests/test-samples.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# test-samples.sh —— 样本库 schema 校验自测(W4-C4 AC-2) +# 校验逻辑与引擎共用同一实现(drill.py validate_samples)——防"测试过而引擎放行"分叉。 +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +source "$(cd "$(dirname "$0")" && pwd)/lib.sh" +PYTHON="$(pick_py)" || { echo "::error::无可用 python(含 pyyaml)"; exit 2; } +PASS=0; FAIL=0 +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT + +t() { # t + local desc="$1" want="$2"; shift 2 + "$@" >/dev/null 2>&1; local rc=$? + if { [[ "$want" == "0" ]] && [[ $rc -eq 0 ]]; } || { [[ "$want" != "0" ]] && [[ $rc -ne 0 ]]; }; then + PASS=$((PASS+1)); echo "ok $desc" + else FAIL=$((FAIL+1)); echo "FAIL $desc(rc=$rc 期望=$want)"; fi +} + +# 经引擎同一校验器判合法/非法(decode 入口先 load_samples→validate_samples, +# 与 select/inject 共用——校验逻辑单一实现;fixture 样本统一 id=x-leak) +check() { + "$PYTHON" "$ROOT/drill.py" decode --samples "$1" --id x-leak >/dev/null 2>&1 +} + +echo "== 1) 正式样本库合法(owner 审批/gate ID/难度/base64/{DATE} 全在)" +t "正式 registry.yaml 通过 schema 校验" 0 "$PYTHON" "$ROOT/drill.py" select --samples "$ROOT/samples/registry.yaml" --repos "$ROOT/../REPOS.yaml" --seed 1 --sample-id hygiene-gitleaks-aws-key --target-repo .github + +echo "== 2) 破坏 fixture 逐项被拒(fail-closed:校验器不能只认存在性)" +mk() { printf '%s\n' "$2" > "$TMP/$1"; } +mk no_approval.yaml 'samples: + - id: x-leak + difficulty: easy + gate: org-hygiene + scope: org + payload_kind: file + payload_path: "drill/x-{DATE}.ini" + defect_b64: aGVsbG8= + pr_title_adr: true + approval: {approved_by: someone-else, date: "2026-08-22"}' +t "审批人非 owner 被拒" 非0 check "$TMP/no_approval.yaml" +mk no_gate.yaml 'samples: + - id: x-leak + difficulty: easy + scope: org + payload_kind: file + payload_path: "drill/x-{DATE}.ini" + defect_b64: aGVsbG8= + pr_title_adr: true + approval: {approved_by: randypanding, date: "2026-08-22"}' +t "缺预期关卡 ID(gate)被拒" 非0 check "$TMP/no_gate.yaml" +mk bad_difficulty.yaml 'samples: + - id: x-leak + difficulty: trivial + gate: org-hygiene + scope: org + payload_kind: file + payload_path: "drill/x-{DATE}.ini" + defect_b64: aGVsbG8= + pr_title_adr: true + approval: {approved_by: randypanding, date: "2026-08-22"}' +t "非法难度值被拒" 非0 check "$TMP/bad_difficulty.yaml" +mk bad_b64.yaml 'samples: + - id: x-leak + difficulty: easy + gate: org-hygiene + scope: org + payload_kind: file + payload_path: "drill/x-{DATE}.ini" + defect_b64: "!!!不是base64!!!" + pr_title_adr: true + approval: {approved_by: randypanding, date: "2026-08-22"}' +t "defect_b64 非法 base64 被拒" 非0 check "$TMP/bad_b64.yaml" +mk no_date_placeholder.yaml 'samples: + - id: x-leak + difficulty: easy + gate: org-hygiene + scope: org + payload_kind: file + payload_path: "drill/x.ini" + defect_b64: aGVsbG8= + pr_title_adr: true + approval: {approved_by: randypanding, date: "2026-08-22"}' +t "payload_path 缺 {DATE} 占位被拒" 非0 check "$TMP/no_date_placeholder.yaml" +mk dup_id.yaml 'samples: + - {id: x-leak, difficulty: easy, gate: org-hygiene, scope: org, payload_kind: file, payload_path: "a/{DATE}", defect_b64: aGVsbG8=, pr_title_adr: true, approval: {approved_by: randypanding, date: "2026-08-22"}} + - {id: x-leak, difficulty: easy, gate: org-hygiene, scope: org, payload_kind: file, payload_path: "b/{DATE}", defect_b64: aGVsbG8=, pr_title_adr: true, approval: {approved_by: randypanding, date: "2026-08-22"}}' +t "样本 id 重复被拒" 非0 check "$TMP/dup_id.yaml" +t "空样本库被拒" 非0 check /dev/null + +echo "== 3) decode round-trip(owner 审阅通道可用;AC-2 审批前置能力)" +OUT=$("$PYTHON" "$ROOT/drill.py" decode --samples "$ROOT/samples/registry.yaml" --id hygiene-gitleaks-aws-key) +if grep -q "AKIA2N7QX9ZK4LMW8B3C" <<<"$OUT"; then PASS=$((PASS+1)); echo "ok decode 输出含缺陷原文(可 owner 审)" +else FAIL=$((FAIL+1)); echo "FAIL decode 未还原缺陷内容"; fi +GEN=$("$PYTHON" "$ROOT/drill.py" decode --samples "$ROOT/samples/registry.yaml" --id hygiene-bigfile-blob) +if grep -q "生成物样本" <<<"$GEN"; then PASS=$((PASS+1)); echo "ok generated 样本 decode 有诚实注记" +else FAIL=$((FAIL+1)); echo "FAIL generated 样本 decode 异常"; fi + +echo "样本库自测: pass=$PASS fail=$FAIL" +[[ $FAIL -eq 0 ]]