From 6af35213f5f3f429bd9ad05a86f59d9a5ba49872 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 03:56:03 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20WORKFLOW=5FLIST=20=E5=9B=9E=E6=98=BE?= =?UTF-8?q?=20workflow=20name=20=E4=B8=8E=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 ---- 首次 live 审计中三个仓库同时报 `[WORKFLOW_LIST] active workflow 缺少合法 path`, 但该信息不含任何标识,无法判断是哪个 workflow,也无法区分"项目把 workflow 放错 位置"(该改项目)与"GitHub 平台内建的 dynamic/ workflow 不在仓库里"(该改基线 跳过列表)。错误码存在的意义是定位公共 MUST,这条做不到。 改动 ---- - path 不合法分支回显 workflow name 与 path。 - 平台内建跳过列表抽出为 SKIPPED_PLATFORM_WORKFLOWS,未知 dynamic/ 路径保持 fail-closed,不放宽边界。 - 新增 fixture dynamic-unknown-workflow,复现 CodeQL default setup 形态并断言 name 与 path 都出现在输出中。 - 同步 contracts/error-codes.md、README 与 CHANGELOG。 验证 ---- - bash tests/test-audit.sh 全绿;ruff、shellcheck 通过。 - 新错误信息实测输出: [WORKFLOW_LIST] active workflow 'CodeQL' 的 path 不在 .github/workflows/ 下: 'dynamic/github-code-scanning/codeql' Claude-Session: https://claude.ai/code/session_01HqomJN4HxRYdoenUgZ1KfP --- CHANGELOG.md | 8 ++++++++ README.md | 2 +- contracts/error-codes.md | 2 +- scripts/audit_github_baseline.py | 14 ++++++++++++-- .../dynamic-unknown-workflow/workflows.json | 8 ++++++++ tests/test-audit.sh | 5 +++++ 6 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/dynamic-unknown-workflow/workflows.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c0cf07a..53c2bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,14 @@ 不回显可能包含 secret 名称的审计输出。 - 审计输出首行回显基线版本与受保护分支。 +### 可观测性 + +- `[WORKFLOW_LIST]` 的"path 不合法"分支改为回显 workflow name 与 path。此前只输出 + 一句无主语的错误,维护者无法区分"项目把 workflow 放错位置"与"GitHub 平台内建的 + `dynamic/` workflow 不在仓库里",也就无从判断该改项目还是改基线。 +- 平台内建跳过列表抽出为 `SKIPPED_PLATFORM_WORKFLOWS`;未知 `dynamic/` 路径保持 + fail-closed。 + ### 测试 - 新增 `tests/unit/`:对表达式解析器、secret 静态可枚举性、`gh api` 重试与分支 diff --git a/README.md b/README.md index 817013f..5c09b70 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ 审计只验证可由结构化 YAML 与仓库配置可靠判断的高层约束,逐条 MUST 见[公共契约](contracts/ci-cd-baseline.md): - active PR/release workflow 的事件、权限、runner、Environment 与 secret 边界; -- 受控 release 之外发现任一发布能力即 fail-closed;GitHub 内建的 `dynamic/dependabot/update-graph` 明确跳过,其他 active workflow 读不到即 fail-closed; +- 受控 release 之外发现任一发布能力即 fail-closed;仅 `SKIPPED_PLATFORM_WORKFLOWS` 中明确列出的 GitHub 内建 workflow 会跳过,其他 `dynamic/` 路径与读不到的 workflow 一律 fail-closed,错误信息回显 name 与 path 供定位; - 外部 Action 的 40 位 SHA、版本注释、`docker://` 内容摘要,以及 workflow 与本地 Action 闭包中的全部 `uses:` 引用; - `.github/release-safety.yml` 的 schema、语义 job/step 引用、依赖、condition 映射与步骤全序,以及中央独立验证的 publish/deploy/finalize 公共语义; - 项目发布安全契约 wrapper 是 mode `100755` 的普通文件,且被唯一的 `PR container validation` job 以单行 `run` 精确调用; diff --git a/contracts/error-codes.md b/contracts/error-codes.md index a98baba..b32ee49 100644 --- a/contracts/error-codes.md +++ b/contracts/error-codes.md @@ -41,7 +41,7 @@ | 错误码 | 含义 | |---|---| -| `[WORKFLOW_LIST]` | active workflow 缺少合法 path,或无法从受保护分支读取 | +| `[WORKFLOW_LIST]` | active workflow 的 path 不在 `.github/workflows/` 下(错误信息回显 name 与 path),或无法从受保护分支读取 | | `[WORKFLOW_STRUCTURE]` | workflow 或本地 Action 的 YAML 结构不满足最低约束 | | `[ACTION_TREE]` | recursive tree 被截断或条目结构无效,拒绝不完整审计 | diff --git a/scripts/audit_github_baseline.py b/scripts/audit_github_baseline.py index 9768b30..e1a80ac 100755 --- a/scripts/audit_github_baseline.py +++ b/scripts/audit_github_baseline.py @@ -43,6 +43,9 @@ "docker/login-action": ("dbcb813823bdd20940b903addbd779551569679f", "v4.6.0"), } ALLOWED_AUX_PERMISSIONS = {"contents": "read", "security-events": "write"} +# GitHub 平台内建、不存在于仓库中的 workflow:无法读取内容,也无法被项目删除。 +# 只跳过明确已知的路径,未知的 dynamic/ 路径仍然 fail-closed。 +SKIPPED_PLATFORM_WORKFLOWS = frozenset({"dynamic/dependabot/update-graph"}) HTTP_STATUS_PATTERN = re.compile(r"\(HTTP (\d{3})\)") RETRYABLE_HTTP_STATUSES = frozenset({408, 429, 500, 502, 503, 504}) GH_API_MAX_ATTEMPTS = 4 @@ -603,10 +606,17 @@ def build_source(repo: str) -> RepositorySource: if workflow.get("state") != "active": continue path = workflow.get("path") - if path == "dynamic/dependabot/update-graph": + if path in SKIPPED_PLATFORM_WORKFLOWS: continue if not isinstance(path, str) or not path.startswith(".github/workflows/"): - source.errors.append("[WORKFLOW_LIST] active workflow 缺少合法 path") + # 必须回显 name/path:否则维护者拿到这条错误无法判断是哪个 workflow, + # 也无法区分"项目放错位置"与"GitHub 平台内建的 dynamic/ workflow"。 + name = workflow.get("name") + identity = f"{name!r} " if isinstance(name, str) else "" + source.errors.append( + f"[WORKFLOW_LIST] active workflow {identity}的 path 不在 " + f".github/workflows/ 下:{path!r}" + ) continue try: text = repository_content(repo, path) diff --git a/tests/fixtures/dynamic-unknown-workflow/workflows.json b/tests/fixtures/dynamic-unknown-workflow/workflows.json new file mode 100644 index 0000000..3b0ad78 --- /dev/null +++ b/tests/fixtures/dynamic-unknown-workflow/workflows.json @@ -0,0 +1,8 @@ +{ + "total_count": 3, + "workflows": [ + {"name": "PR validation", "path": ".github/workflows/pr-ci.yml", "state": "active"}, + {"name": "Master release", "path": ".github/workflows/release.yml", "state": "active"}, + {"name": "CodeQL", "path": "dynamic/github-code-scanning/codeql", "state": "active"} + ] +} diff --git a/tests/test-audit.sh b/tests/test-audit.sh index 08d61ca..81cf30e 100755 --- a/tests/test-audit.sh +++ b/tests/test-audit.sh @@ -730,6 +730,11 @@ run_expect_failure release-dynamic-secret "[SECRET_ENV_BOUNDARY]" run_expect_failure release-secrets-inherit "[SECRET_ENV_BOUNDARY]" run_expect_success dynamic-platform-workflow run_expect_failure stale-active-workflow "[WORKFLOW_LIST]" +# 未知的 dynamic/ workflow 仍然 fail-closed,但必须回显 name 与 path 供定位。 +run_expect_failure_codes dynamic-unknown-workflow \ + "[WORKFLOW_LIST]" \ + "'CodeQL'" \ + "'dynamic/github-code-scanning/codeql'" run_expect_failure auxiliary-missing-permissions "[AUX_PR_PERMISSION]" check_expression_unit_tests check_templates From 9c47b285349d1637a6e789067c7a6978bcb6c9da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 04:26:59 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20REPOSITORY=5FME?= =?UTF-8?q?RGE=5FPOLICY=20=E5=81=87=E9=98=B3=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 ---- 首次 live 审计报四个仓库都需要关闭 Auto-merge,但实际四个仓库的 Allow auto-merge 本来就没有勾选。 根因:判定写作 `metadata.get("allow_auto_merge") is not False`。字段缺失时 `.get()` 返回 `None`,`None is not False` 成立,于是报漂移。而 GitHub 只在认证身份具备 admin 权限时才返回这组合并策略字段,只读审计 App 拿不到,导致连续数周误报,并把维护者 指引去关一个本来就关着的开关——真正该查的是 App 权限。 这与本轮引入的分级原则一致:判定不了不等于漂移。 改动 ---- - Auto-merge 判定区分三态:`true` 报 `[REPOSITORY_MERGE_POLICY]`;`false` 通过; 缺失或非布尔值报 `[API_UNAVAILABLE]`,并在信息中给出实际值与权限排查方向。 - 新增 RepositorySource.unavailable:判定不了的项改为收集而非抛异常。此前的实现 会中断整个仓库审计、丢掉其余 findings;现在其余检查照常执行,一次就能看到全部 问题,仅整体结论标记为不可信。 - 新增 fixture repository-auto-merge-unknown,断言缺失字段走退出码 3 而非漂移。 - default-branch-main fixture 补上 allow_auto_merge,恢复单一条件隔离。 - 同步 contracts/error-codes.md 与 CHANGELOG。 验证 ---- - bash tests/test-audit.sh 全绿;ruff、shellcheck 通过。 - 组合场景实测:缺字段 + default_branch 不符时,两条都出现在输出中,退出码 3。 Claude-Session: https://claude.ai/code/session_01HqomJN4HxRYdoenUgZ1KfP --- CHANGELOG.md | 9 +++++++++ contracts/error-codes.md | 4 ++-- scripts/audit_github_baseline.py | 18 ++++++++++++++++-- tests/fixtures/default-branch-main/repo.json | 2 +- .../repository-auto-merge-unknown/repo.json | 1 + tests/test-audit.sh | 2 ++ 6 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/repository-auto-merge-unknown/repo.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c2bf7..946cc55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,15 @@ - 平台内建跳过列表抽出为 `SKIPPED_PLATFORM_WORKFLOWS`;未知 `dynamic/` 路径保持 fail-closed。 +### 修复(第二轮 live 审计发现) + +- **`[REPOSITORY_MERGE_POLICY]` 的假阳性。** 原判定是 + `metadata.get("allow_auto_merge") is not False`,字段缺失时 `.get()` 返回 `None`, + `None is not False` 成立,于是报告"仓库必须关闭 Auto-merge"。而 GitHub 只在认证 + 身份具备 admin 权限时才返回这组合并策略字段,只读审计 App 很可能拿不到。结果是 + 四个仓库连续数周被误报,维护者被指引去关一个本来就关着的开关。现在区分三种情况: + `true` 报漂移、`false` 通过、缺失报 `[API_UNAVAILABLE]` 并指向 App 权限。 + ### 测试 - 新增 `tests/unit/`:对表达式解析器、secret 静态可枚举性、`gh api` 重试与分支 diff --git a/contracts/error-codes.md b/contracts/error-codes.md index b32ee49..f8672e6 100644 --- a/contracts/error-codes.md +++ b/contracts/error-codes.md @@ -25,14 +25,14 @@ |---|---| | `[ARGUMENT]` | 仓库参数不是 `owner/repo`,或 `BASELINE_DEFAULT_BRANCH` 不是合法分支名 | | `[API]` | GitHub 响应结构不符合预期,属于可判定的异常 | -| `[API_UNAVAILABLE]` | 重试后仍无法读取 GitHub:网络、5xx/429 或响应不是合法 JSON | +| `[API_UNAVAILABLE]` | 重试后仍无法读取 GitHub:网络、5xx/429、响应不是合法 JSON,或 admin 门控字段(如 `allow_auto_merge`)未返回导致无法判定 | ## 仓库与分支 | 错误码 | 含义 | |---|---| | `[DEFAULT_BRANCH]` | 仓库 default_branch 不等于基线受保护分支 | -| `[REPOSITORY_MERGE_POLICY]` | 官方 Review 尚非 required check,仓库必须关闭 Auto-merge | +| `[REPOSITORY_MERGE_POLICY]` | `allow_auto_merge` 显式为 `true`。字段缺失表示无法判定,走 `[API_UNAVAILABLE]`,不算漂移 | | `[BRANCH_PROTECTION]` | strict、PR review、管理员保护、对话解决、force push 或删除保护缺失 | | `[REQUIRED_CHECK]` | 受保护分支未要求 `PR container validation` | | `[REQUIRED_CHECK_APP]` | required check 未绑定 GitHub Actions app,或 checks 结构无效 | diff --git a/scripts/audit_github_baseline.py b/scripts/audit_github_baseline.py index e1a80ac..547a76c 100755 --- a/scripts/audit_github_baseline.py +++ b/scripts/audit_github_baseline.py @@ -86,6 +86,9 @@ class RepositorySource: local_actions: dict[str, ParsedYaml] = field(default_factory=dict) release_manifest: ParsedYaml | None = None errors: list[str] = field(default_factory=list) + # 判定不了的项:不是漂移,但会让本仓结论不可信。收集而不是抛出, + # 这样其余检查照常执行,维护者一次就能看到全部问题。 + unavailable: list[str] = field(default_factory=list) @dataclass(frozen=True) @@ -596,10 +599,20 @@ def build_source(repo: str) -> RepositorySource: branch = default_branch() if metadata.get("default_branch") != branch: source.errors.append(f"[DEFAULT_BRANCH] 仓库 default_branch 必须是 {branch}") - if metadata.get("allow_auto_merge") is not False: + # GitHub 只在认证身份具备 admin 权限时返回 allow_auto_merge 这组合并策略字段。 + # 字段缺失说明"看不到",不等于"开着";把两者混为一谈会让维护者去关一个本来就 + # 关着的开关,而真正该修的是令牌权限。 + auto_merge = metadata.get("allow_auto_merge") + if auto_merge is True: source.errors.append( "[REPOSITORY_MERGE_POLICY] 官方 Review 尚非 required check,仓库必须关闭 Auto-merge" ) + elif auto_merge is not False: + source.unavailable.append( + "[API_UNAVAILABLE] 无法判定 Auto-merge:GET repos/" + f"{repo} 未返回 allow_auto_merge(实际值 {auto_merge!r})。" + "该字段需要 admin 级读取权限,请确认审计 App 的 Administration 权限已授予该仓库" + ) workflows = gh_api_paginated(repo, "actions/workflows", "workflows") for workflow in workflows: @@ -2203,7 +2216,8 @@ def audit_repository(repo: str) -> tuple[list[str], bool]: "[REPO_SECRET_BOUNDARY] repository scope 仍有 secret 名称:" + ", ".join(sorted(repository_secret_names)) ) - return errors, False + # 判定不了的项排在最后,并使本仓结论整体不可信;其余检查已照常执行完毕。 + return errors + source.unavailable, bool(source.unavailable) except AuditUnavailable as error: return [f"[API_UNAVAILABLE] {error}"], True except AuditError as error: diff --git a/tests/fixtures/default-branch-main/repo.json b/tests/fixtures/default-branch-main/repo.json index 1a570e3..3a9cfa6 100644 --- a/tests/fixtures/default-branch-main/repo.json +++ b/tests/fixtures/default-branch-main/repo.json @@ -1 +1 @@ -{"full_name": "example/project", "default_branch": "main"} +{"full_name": "example/project", "default_branch": "main", "allow_auto_merge": false} diff --git a/tests/fixtures/repository-auto-merge-unknown/repo.json b/tests/fixtures/repository-auto-merge-unknown/repo.json new file mode 100644 index 0000000..b30edcb --- /dev/null +++ b/tests/fixtures/repository-auto-merge-unknown/repo.json @@ -0,0 +1 @@ +{"full_name": "example/project", "default_branch": "master"} diff --git a/tests/test-audit.sh b/tests/test-audit.sh index 81cf30e..04bae5b 100755 --- a/tests/test-audit.sh +++ b/tests/test-audit.sh @@ -599,6 +599,8 @@ PY run_expect_success good run_expect_failure repository-auto-merge-enabled "[REPOSITORY_MERGE_POLICY]" +# 字段缺失是"看不到"而非"开着":必须报审计未完成,不能伪装成漂移。 +run_expect_unavailable repository-auto-merge-unknown run_expect_failure code-review-rules-missing "[CODE_REVIEW_RULES]" run_expect_failure code-review-rules-symlink "[CODE_REVIEW_RULES]" run_expect_failure code-review-rules-heading-missing "[CODE_REVIEW_RULES]"