jay-tank/gha-audit
GitHub: jay-tank/gha-audit
一个用 Go 编写的 GitHub Actions 工作流静态安全分析器,通过 14 条规则离线检测 CI/CD 配置中的供应链风险模式。
Stars: 0 | Forks: 0
# gha-audit
[](https://go.dev)
[](LICENSE)
[](#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工具, 云安全监控, 日志审计, 自动笔记, 静态分析