diff --git a/CHANGELOG.md b/CHANGELOG.md index c0cf07a..946cc55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,23 @@ 不回显可能包含 secret 名称的审计输出。 - 审计输出首行回显基线版本与受保护分支。 +### 可观测性 + +- `[WORKFLOW_LIST]` 的"path 不合法"分支改为回显 workflow name 与 path。此前只输出 + 一句无主语的错误,维护者无法区分"项目把 workflow 放错位置"与"GitHub 平台内建的 + `dynamic/` workflow 不在仓库里",也就无从判断该改项目还是改基线。 +- 平台内建跳过列表抽出为 `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/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..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 结构无效 | @@ -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..547a76c 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 @@ -83,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) @@ -593,20 +599,37 @@ 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: 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) @@ -2193,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/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/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 08d61ca..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]" @@ -730,6 +732,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