jay-tank/gha-audit

GitHub: jay-tank/gha-audit

一个用 Go 编写的 GitHub Actions 工作流静态安全分析器,通过 14 条规则离线检测 CI/CD 配置中的供应链风险模式。

Stars: 0 | Forks: 0

# gha-audit [![Go](https://img.shields.io/badge/Go-1.23-00ADD8?logo=go)](https://go.dev) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Security](https://img.shields.io/badge/focus-CI%2FCD%20security-red)](#rules) **gha-audit** 是一个开源的 Go CLI(二进制文件:`ghaudit`),它会静态扫描您的 `.github/workflows/*.yml` 文件,以查找危险的 CI/CD 安全模式,并针对每个发现报告具体的补救措施。 CI/CD pipeline 是软件供应链中最主要的攻击媒介之一:workflow 在运行时拥有特权 token,需要处理 secret,并且经常执行来自不受信任的 pull request 的代码。仅仅一个检查了 PR head 代码的 `pull_request_target`,或者一个未固定版本的第三方 action,就可能将您仓库的 `GITHUB_TOKEN` 交到攻击者手中。`ghaudit` 会在它们合并之前捕获这些问题。 **诚实的范围说明:** 这*仅针对 workflow YAML 进行静态分析*。它会解析您的 workflow 文件并应用规则。它是 100% 离线的——它**不会发起任何网络调用**,也**不需要任何凭证或 secret 访问权限**。 ## 快速开始 ``` # install go install github.com/jay-tank/gha-audit/cmd/ghaudit@latest # scan the current repo (looks in ./.github/workflows) ghaudit scan . # scan a specific project directory, or a single workflow file ghaudit scan path/to/project ghaudit scan .github/workflows/ci.yml # JSON output for tooling ghaudit scan --format json . # SARIF output for GitHub code-scanning (Security tab) ghaudit scan --format sarif . > ghaudit.sarif # only report high+ findings, and fail the build on any critical ghaudit scan --min-severity high --fail-on critical . ``` `--format` 接受 `text`(默认)、`json` 或 `sarif`。 ### Flags | Flag | 含义 | |------|---------| | `--format text\|json\|sarif` | 输出格式(默认为 `text`)。 | | `--config PATH` | `.ghaudit.yml` 的路径。如果未设置,将在扫描路径下自动发现。 | | `--min-severity LEVEL` | 丢弃**低于** `LEVEL` 的发现(过滤器)。默认:保留所有。 | | `--fail-on LEVEL` | 当存留的发现**等于或高于** `LEVEL` 时以非零状态退出。默认 `high`。 | | `--baseline PATH` | Baseline 文件(例如 `.ghaudit-baseline.json`)。缺失 → 写入当前发现并以 exit 0 退出。存在 → 仅针对**不在** baseline 中的发现进行拦截。 | | `--update-baseline` | 搭配 `--baseline` 使用,从当前发现重新生成 baseline 快照并以 exit 0 退出。 | Flags 会覆盖配置文件中的值。 ### 退出代码 | 代码 | 含义 | |------|---------| | `0` | 没有等于或高于 `--fail-on`(默认 `high`)的存留发现 | | `2` | 有一个或多个等于或高于 `--fail-on` 的存留发现 | | `1` | 用法或运行时错误 | ## 示例输出 ``` [X] INJECTION .github/workflows/ci.yml · job:build · step:Greet PR · L17 untrusted expression 'github.event.pull_request.title' interpolated directly into a run: script (shell injection) fix: Pass the value through an env: variable and reference it quoted, e.g. env: { TITLE: ${{ github.event.pull_request.title }} } then use "$TITLE". [!] UNPINNED_ACTION .github/workflows/ci.yml · job:build · step:Install third-party action · L21 third-party action 'docker/login-action@v3' is pinned to a mutable ref 'v3' instead of a commit SHA fix: Pin to a full 40-char commit SHA, e.g. uses: docker/login-action@ # v3. Summary: 8 finding(s) across 1 file(s) critical=2 high=3 medium=2 low=1 ``` 严重性图标:`[X]` critical · `[!]` high · `[*]` medium · `[.]` low · `[i]` info。 ## GitHub 代码扫描 (SARIF) `ghaudit` 可以输出 [SARIF v2.1.0](https://sarifweb.azurewebsites.net/),这样发现的结果就会显示在您仓库的 **Security → Code scanning** 标签页中,并精确映射到对应的 workflow 文件和行号。严重性映射到 GitHub 的 `security-severity` 排名(critical `9.0`,high `8.0`,medium `5.0`,low `3.0`)和级别(critical/high → `error`,medium → `warning`,low/info → `note`)。 ``` ghaudit scan --format sarif . > ghaudit.sarif ``` Artifact URI 是相对于仓库的(例如 `.github/workflows/ci.yml`),因此 GitHub 会将每个结果映射到正确的文件。 ### 从 workflow 中上传 ``` permissions: contents: read security-events: write # required to upload SARIF jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: { go-version: "1.23" } - name: Scan workflows run: | go run github.com/jay-tank/gha-audit/cmd/ghaudit@latest \ scan --format sarif . > ghaudit.sarif - name: Upload SARIF if: always() # ghaudit exits non-zero on findings uses: github/codeql-action/upload-sarif@v3 with: sarif_file: ghaudit.sarif category: ghaudit ``` ### 复合 Action 在仓库根目录发布了一个复合 action,因此您可以在一个步骤中运行整个扫描并写入 SARIF 的流程: ``` - name: Run gha-audit uses: jay-tank/gha-audit@v0.1.0 with: path: . # default "." format: sarif # default "sarif" output: ghaudit.sarif # default "ghaudit.sarif" - name: Upload SARIF if: always() uses: github/codeql-action/upload-sarif@v3 with: sarif_file: ghaudit.sarif ``` 注意:即使对于 SARIF,`ghaudit` 也保留其“发现结果即非零退出”的语义,因此请使用 `if: always()` 来控制上传步骤的执行。 ## 规则 | ID | 严重性 | 捕获内容 | |----|----------|-----------------| | `INJECTION` | Critical | 攻击者可控的上下文(`github.event.*.title/body`、`github.head_ref` 等)被直接插入到 `run:` shell 脚本中。 | | `PR_TARGET_CHECKOUT` | Critical | `pull_request_target` 触发器通过带有 `ref:` 的 `actions/checkout` 检查了不受信任的 PR **head** 代码。 | | `UNPINNED_ACTION` | High | 第三方 `uses:` 固定在可变标签/分支(`@v1`、`@main`)而不是 40 字符的 commit SHA 上。 | | `BROAD_PERMISSIONS` | High | 顶层 `permissions: write-all`,或者没有 `permissions:` 块(宽泛的默认权限)。 | | `CURL_BASH` | High | `run:` 将远程下载的内容管道传输给 shell(`curl … \| bash`、`wget … \| sh`、`iwr \| iex`)。 | | `SECRETS_IN_RUN` | Medium | `run:` 打印了 secret(`echo ${{ secrets.* }}`、`env \| grep`)。 | | `CONTINUE_ON_ERROR_SECURITY` | Medium | 在安全/lint 步骤上设置了 `continue-on-error: true`,从而静默禁用了拦截。 | | `SELF_HOSTED_PUBLIC` | Low | 在 `self-hosted` runner 上运行的作业(对于公共仓库需进行审查)。 | | `GITHUB_ENV_INJECTION` | High | 不受信任的上下文(`github.event.*.title/body`、`github.head_ref` 等)通过 `run:` 脚本写入 `$GITHUB_ENV` / `$GITHUB_PATH` 中,将环境变量 / PATH 条目走私到后续步骤中。 | | `UNSCOPED_TOKEN` | Medium | 消耗 `GITHUB_TOKEN`(显式引用或需要 token 的 action)但未声明作业级别 `permissions:` 块的作业。对于仓库范围的姿态,交由 `BROAD_PERMISSIONS` 处理。 | | `CACHE_POISONING` | Medium | 在可触达 fork 的 `pull_request_target` / `workflow_run` 触发器下的 `actions/cache`(或 setup-action 缓存),允许不受信任的代码污染共享缓存。 | | `ARTIFACT_SECRET_LEAK` | Medium | `actions/upload-artifact` 的 `path:` 可能包含 secret(`.env`、`*.pem`、`id_rsa`、`**/secrets*`)或整个工作区(`.`)。 | | `WORKFLOW_RUN_RISK` | High | `on: workflow_run` 检查了触发运行的 head 代码或下载了其(可能是 fork 的)artifact,然后以仓库写入权限和 secret 运行。 | | `OIDC_MISUSE` | Medium | `id-token: write`(OIDC 云凭证)被授予了整个仓库范围,或者一个持有 `id-token: write` 的作业同时检查了不受信任的代码。 | ## 配置与抑制 `ghaudit` 带有合理的默认值运行,并且**不需要配置**——在没有配置文件且没有 flags 的情况下,其行为与单纯的扫描完全相同(任何等于/高于 `high` 的发现都会导致构建失败)。有两种机制可以让您在一个存在许多干扰的真实仓库中采用它,而无需将其关闭。 ### `.ghaudit.yml` 在您的扫描根目录下放入一个 `.ghaudit.yml`(或 `.ghaudit.yaml`)。它会被自动发现;传递 `--config PATH` 可以指向其他位置。 ``` # Globally disable rules (unknown IDs → warning on stderr, non-fatal). ignore: - SELF_HOSTED_PUBLIC # Skip workflow files by repo-relative glob (*, ?, and ** are supported). exclude: - ".github/workflows/generated-*.yml" - "**/experimental/**" # Re-map a rule's severity. severity: UNPINNED_ACTION: medium # Drop findings below this level (a filter). min_severity: low # Default exit-code gate (overridden by --fail-on). fail_on: high ``` ### 内联抑制 从 workflow 内部静默某个发现。指令可以在发现所在的**当前行或其上一行**进行匹配: ``` - run: curl -sSL https://example.com/i.sh | bash # ghaudit:ignore CURL_BASH # ghaudit:ignore INJECTION - name: Greet run: echo "Hi ${{ github.event.pull_request.title }}" ``` - `# ghaudit:ignore` — 抑制该行上的**所有**发现。 - `# ghaudit:ignore RULE_A,RULE_B` — 仅抑制这些规则 ID。 (发现是在其步骤的行号报告的,因此对于多行的 `run:`,请将指令放在该步骤的 `-` 行或其上一行。) ### 优先级与流水线顺序 发现结果会流经一个固定的流水线(已记录以确保结果可预测): ``` rules run → drop `ignore`d rules → drop `exclude`d file paths → drop inline-suppressed findings → apply `severity` overrides → drop findings below `min_severity` → report → exit-code gate on `fail_on` / --fail-on ``` CLI flags 会覆盖配置文件中的值(`--min-severity`、`--fail-on`、`--config`)。`--fail-on` 的默认值为 `high`,这确保了在没有配置或 flags 时能保留原有的行为。 ## Baseline 模式 大型仓库很少从一开始就是干净的。**Baseline 模式**允许您确认一次现有的积压问题,然后 CI 仅针对**新**发现进行拦截,这样团队就可以采用 `ghaudit`,而不必先进行大规模的清理。 ``` # 1. First run: the baseline file doesn't exist yet, so ghaudit writes the # current findings to it and exits 0 (nothing gates on the first snapshot). ghaudit scan --baseline .ghaudit-baseline.json . # 2. Later runs: known findings are shown but marked [baselined] and ignored for # gating; any NEW finding gates per --fail-on (exit 2). ghaudit scan --baseline .ghaudit-baseline.json . # 3. Intentionally refresh the snapshot (e.g. after triaging the backlog): ghaudit scan --baseline .ghaudit-baseline.json --update-baseline . # (equivalently, delete the baseline file and re-run step 1.) ``` 文本输出会将这两组分开,并打印出一行摘要,例如 `1 new, 7 baselined (ignored)`;JSON/SARIF 为每个发现携带一个 `baselined` 标志。 **优先级。** Baseline 在配置 `ignore`/`exclude`/内联抑制和 `min_severity` 过滤器*之后*运行——这些操作丢弃的任何内容都不会进入 baseline。只有存留的发现会被计入 baseline,并且只有非 baseline 化的(新)发现才会拦截退出代码: ``` … → min-severity filter → baseline (segregate new vs known) → report → exit gate ``` **指纹。** 每个发现都由 `rule_id` + 仓库相对文件路径 + 其消息的哈希值来标识。行号被刻意**排除在外**,因此在发现上方插入行不会使其看起来像是新发现(在行号偏移时保持稳定);消息中嵌入了违规的 action/expression/job,因此同一文件中的不同发现仍保持独立性。 ## 添加规则 规则是纯粹且经过表驱动测试的。要添加一个规则: 1. 在 `internal/rules/checks.go` 中添加一个实现 `Rule` 接口的结构体: type MyRule struct{} func (MyRule) ID() string { return "MY_RULE" } func (MyRule) Severity() Severity { return High } func (r MyRule) Check(wf *workflow.Workflow) []Finding { /* ... */ } 使用 `newFinding(...)` 助手函数,以便自动填充严重性字符串和位置。 2. 在 `internal/rules/rules.go` 中的 `Registry()` 里注册它。 3. 为 `internal/rules/checks_test.go` 中的表添加一个正面 + 反面的测试用例。 4. 如果它应该出现在演示中,请在 `testdata/` 下增加覆盖率。 5. 将其记录在上面的表格和 `CHANGELOG.md` 中。 ## 项目结构 ``` cmd/ghaudit/ CLI: scan / version / --format / --config / --min-severity / --fail-on / --baseline internal/workflow/ YAML -> typed workflow model (jobs, steps, permissions, on, uses, run) internal/rules/ Rule interface, 14 rules, Registry() internal/config/ .ghaudit.yml loader: ignore / exclude / severity / min_severity / fail_on internal/suppress/ Inline `# ghaudit:ignore` directive matching internal/baseline/ Baseline snapshot: fingerprints + new-vs-known segregation internal/report/ Aggregate + text/JSON/SARIF renderers, severity counts testdata/ vulnerable-project/ (all rules), safe-project/ (clean), config-project/ (.ghaudit.yml), suppress-project/ (inline ignore) ``` ## 路线图 已发布:核心分析器(14 条规则,文本/JSON,CI 拦截退出代码),用于 GitHub 代码扫描的 SARIF 输出,`.ghaudit.yml` 配置 + 内联抑制,以及 baseline 模式(仅对新发现进行拦截)。 接下来: - 自动修复建议 / PR(例如,将标签固定到 SHA,收紧权限)。 ## 开发 ``` go vet ./... go test ./... go build -o ghaudit ./cmd/ghaudit ``` 内部测试(Dogfooding):此仓库自身的 `.github/workflows/ci.yml` 旨在通过 `ghaudit scan .` 的扫描。 ## 许可证 MIT © 2026 Jay Tank。参见 [LICENSE](LICENSE)。
标签:EVTX分析, GitHub Actions, Go, Ruby工具, 云安全监控, 日志审计, 自动笔记, 静态分析