CI/CD workflow audit GitHub Actions workflow auditor — W1-W7 covering pwn-request, workflow_run from forks, script-injection interpolations, unpinned third-party actions, persist-credentials, write-all permissions, self-modifying workflows.
CI/CD is the highest-leverage attack surface in modern development. A compromised workflow runs with secrets, publishes to package registries, and re-keys the production environment. The 2024-2026 attack record (Shai-Hulud, Tj-actions/changed-files, Adam Berman's pwn-request catalog, GitGuardian self-replicating workflows) documents the canonical malicious patterns; this detector codifies them as W1-W7 rules.
What gets parsed
The auditor walks .github/workflows/*.yml under any
root (defaults to cwd) and parses each workflow as YAML.
Per workflow it extracts:
ontriggers (handles the YAML-keywordon:→Truequirk)- per-job step count and runs-on
- per-step
uses:parsed into(owner, repo, ref, sha_pinned, is_trusted_owner, is_local) - per-step
run:scanned for injectable${{ ... }}contexts from the 14-context allowlist persist-credentials: trueon checkoutsecrets.*references inrun:/env:- self-modifying signature (any step writes to
.github/workflows/) - top-level
permissions:block ("write-all"/"default"/"object")
Trusted-owner allowlist
Actions from these owners are not flagged as unpinned- third-party even if they use a moving tag:
actions, github, azure,
docker, google-github-actions,
aws-actions, anthropic-experimental,
anthropics, hashicorp,
actions-rs, advanced-security,
code-scanning.
Operators extend via DIGGER_CI_TRUSTED_ACTION_OWNERS
(comma-separated).
Detection layers (W1–W7)
| ID | Severity | What it catches | MITRE |
|---|---|---|---|
| W1 | critical | Pwn-request: pull_request_target + checkout of PR head ref. Documented by GitHub Security Lab — the canonical CI compromise pattern. | T1199 |
| W2 | high | workflow_run trigger. Re-runs in privileged context with access to the originating workflow's artifacts (often fork-PR test runs). | T1199 |
| W3 | high | Untrusted-input interpolation in run: block (PR title, issue body, comment body, head_ref, commit message). Script injection by anyone who can submit content. | T1059 |
| W4 | medium | Unpinned third-party action (moving tag, not trusted owner). The Tj-actions/changed-files compromise shape. | T1195.002 |
| W5 | medium | persist-credentials: true after checkout — GITHUB_TOKEN baked into .git/config for the rest of the run. | T1552.001 |
| W6 | medium / info | Top-level permissions: = write-all (medium) or no block at all (info — legacy org default). | T1078.004 |
| W7 | critical | Self-modifying workflow — any step writes to .github/workflows/. The worm-persistence primitive (Shai-Hulud, octofiles). | T1195.002 |
A workflow file that the GitHub runner accepts but a YAML parser
rejects produces a workflow_parse_error finding —
itself a smell.
CLI
$ digger ci audit-workflows --case-dir /tmp/case [--roots .,vendor/repo-a]
[ci] workflows audited: 12
[ci] pwn-request pattern: 0
[ci] workflow_run-triggered: 1
[ci] injectable interpolations: 2
[ci] unpinned 3rd-party actions: 4
[ci] self-modifying: 0
[ci] artifacts emitted: 12
Fixing W3 (the common one)
Replace the interpolation with an env var binding and quote-safe shell. The bash interpreter sees a literal variable, not a templated string:
- run: |
# WRONG — script injection via PR title:
echo "${{ github.event.pull_request.title }}"
- run: |
echo "$PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}