actionguard
actionguard audits GitHub Actions workflows and composite actions for supply-chain and injection problems, and fixes the common ones automatically. It parses the YAML with exact line and column tracking, parses ${{ }} expressions into an AST, follows attacker-controlled data through env:, step outputs and job outputs, and reports 27 rules as rich terminal output, JSON, SARIF (GitHub code scanning) or GitHub annotations. actionguard pin rewrites uses: to commit SHAs without touching the rest of your formatting, and actionguard fix adds minimal permissions: and moves injectable expressions into env:.
Features
- 27 rules (see docs/rules.md): unpinned actions and mutable refs, script injection in
run:andgithub-script(direct, viaenv:, via step/job outputs, via matrix), pwn requests, missing/write-allpermissions, secret exposure,secrets: inherit, self-hosted runners on PR triggers,curl | bash, deprecated commands, persisted checkout credentials, cache poisoning, unpinned container images, missing timeouts, deploys without concurrency,continue-on-erroron security steps, hardcoded credentials, always-trueif:conditions, invalid expressions, and more. - Taint tracking:
${{ github.event.issue.title }}stored in an env var and laterevaled, written to$GITHUB_OUTPUTand re-expanded in another step, or passed through a job output into a matrix is still caught. pin: resolves tags and branches to SHAs from a lock file (actionguard.lock, fully offline) or the GitHub API (token optional), keeps# v4as a comment, preserves quotes, comments and line endings.fix: insertspermissions: contents: read, replaceswrite-all, hoists injectable expressions and secrets into stepenv:and rewrites the script to"$VAR". Output is re-parsed before it is written.- Composite actions (
action.yml) are linted too. - Config in
.actionguard.toml, inline# actionguard: ignore=AG003suppression.
Install
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
source .venv/bin/activate
Requires Python 3.12+. Dependencies: ruamel.yaml, click, rich.
Quickstart
actionguard lint examples/repo # audit
actionguard pin examples/repo --offline --dry-run # preview SHA pinning from the lock file
actionguard fix examples/repo --pin --offline --dry-run # preview every automatic fix (drop --dry-run to apply)
actionguard lint examples/repo --format sarif -o actionguard.sarif
Usage
actionguard lint [PATH]
PATH is a repository root, a workflows directory or a single file (default .). It finds .github/workflows/*.y*ml and every action.y*ml. Options: --format rich|json|sarif|github, -o FILE, --select AG003,AG005, --ignore AG018, --fail-on note|warning|error, --config FILE. Exit code 1 when a finding at or above --fail-on (default warning) exists.
Real output for one of the example workflows (examples/repo/.github/workflows/labeler.yml):
labeler.yml
9:1 warning AG007 Workflow has no top-level `permissions:`; jobs inherit the default token scopes
9 | jobs:
17:39 error AG003 Script injection: `github.event.pull_request.title` is attacker-controlled and expanded into the shell script
17 | run: echo "Labeling PR titled ${{ github.event.pull_request.title }} from ${{ github.head_ref }}"
17:83 error AG003 Script injection: `github.head_ref` is attacker-controlled and expanded into the shell script
17 | run: echo "Labeling PR titled ${{ github.event.pull_request.title }} from ${{ github.head_ref }}"
23:22 error AG003 Script injection: `github.event.pull_request.title` is attacker-controlled and expanded into the shell script
23 | echo "kind=${{ github.event.pull_request.title }}" >> "$GITHUB_OUTPUT"
26:48 error AG005 Indirect injection: `steps.classify.outputs.kind` is a tainted step output expanded into the shell script
26 | run: gh pr edit "$NUMBER" --add-label "${{ steps.classify.outputs.kind }}"
32:14 error AG005 Tainted variable `$PR_TITLE` is evaluated as shell code
32 | run: eval "$PR_TITLE"
35:15 warning AG001 `actions/github-script@v7` is not pinned to a commit SHA; tag `v7` can be moved by the action owner
35 | uses: actions/github-script@v7
42:34 error AG004 Script injection: `github.event.pull_request.title` is expanded into github-script JavaScript
42 | body: 'Thanks for "${{ github.event.pull_request.title }}"'
8 findings in 1 file (6 errors, 2 warnings, 0 notes)
Whole example repo: 62 findings in 9 files (22 errors, 35 warnings, 5 notes). After fix --pin: 27 findings in 9 files (10 errors, 12 warnings, 5 notes); what remains needs a human (see below).
GitHub annotations (--format github), for use in a workflow step:
::warning file=.github/actions/setup-node-cached/action.yml,line=15,col=13,title=AG001::`actions/setup-node@v4` is not pinned to a commit SHA; tag `v4` can be moved by the action owner
::warning file=.github/actions/setup-node-cached/action.yml,line=19,col=13,title=AG001::`actions/cache@v4` is not pinned to a commit SHA; tag `v4` can be moved by the action owner
::warning file=.github/actions/setup-node-cached/action.yml,line=22,col=14,title=AG016::Cache `key` contains attacker-controlled `inputs.cache-suffix`
SARIF 2.1.0 (--format sarif) carries every rule with description, help text and a security-severity, so findings show up as code-scanning alerts:
- run: actionguard lint . --format sarif -o actionguard.sarif --fail-on error || true
- uses: github/codeql-action/upload-sarif@<sha>
with:
sarif_file: actionguard.sarif
actionguard pin [PATH]
Resolution order: lock file first, then the GitHub API (unless --offline). --token or GITHUB_TOKEN raises the API rate limit. --update-lock records API results in actionguard.lock. --dry-run prints a diff. Exits 1 if any reference could not be resolved.
$ actionguard pin examples/repo --offline --dry-run
.github/actions/setup-node-cached/action.yml:15 actions/setup-node@v4 -> 49933ea5288c
.github/actions/setup-node-cached/action.yml:19 actions/cache@v4 -> 0057852bfaa8
--- a/.github/actions/setup-node-cached/action.yml
+++ b/.github/actions/setup-node-cached/action.yml
@@ -12,11 +12,11 @@
runs:
using: composite
steps:
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ inputs.node-version }}
...
would pin 21 references, 1 unresolved
.github/workflows/call-shared.yml:12 could not resolve acme-platform/shared-workflows/.github/workflows/build.yml@main (not in lock file)
The lock file is one owner/repo@ref <sha> per line (# comments allowed); examples/repo/actionguard.lock was produced by actionguard pin --update-lock against the real GitHub API. The unresolved reference is deliberate: that repository does not exist.
actionguard fix [PATH]
Applies the permissions and injection fixes; --pin also pins; --dry-run / --check print the diff (--check exits 1 if anything would change).
$ actionguard fix examples/repo/.github/workflows --pin --offline --dry-run # labeler.yml excerpt
.github/workflows/labeler.yml:9 fixed: added top-level `permissions: contents: read`
.github/workflows/labeler.yml:17 fixed: `${{ github.event.pull_request.title }}` -> $AG_PULL_REQUEST_TITLE
...
@@ -14,17 +17,23 @@
- name: Announce
- run: echo "Labeling PR titled ${{ github.event.pull_request.title }} from ${{ github.head_ref }}"
+ run: echo "Labeling PR titled ${AG_PULL_REQUEST_TITLE} from ${AG_HEAD_REF}"
+ env:
+ AG_PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }}
+ AG_HEAD_REF: ${{ github.head_ref }}
examples/hardened/ is examples/repo after actionguard fix examples/repo --pin --offline. Findings the fixer deliberately leaves (they need a decision): eval "$PR_TITLE", JavaScript injection in github-script, the PR head checkout in a privileged workflow, secrets: inherit, curl-pipe-bash, container digests, continue-on-error, hardcoded passwords.
Things to know before running fix on your own repo: replacing write-all or defaulting to contents: read can remove access a job needs (the release example needs contents: write back), and hoisting is skipped, with a message, for quoted YAML scalars, folded > blocks and expressions inside single quotes.
actionguard rules [ID]
Lists the catalog, or shows rationale and fix for one rule (actionguard rules AG006). --markdown prints the catalog that is committed as docs/rules.md.
Configuration
.actionguard.toml (found by walking up from PATH, or --config):
allowed-owners = ["actions", "github"] # skip pinning / secrets-inherit checks for these owners
allowed-actions = ["docker/login-action"] # individual exempt actions
ignore = ["AG018"] # silence rules repo-wide
exclude = ["**/legacy-*.yml"] # globs relative to PATH
fail-on = "warning" # note | warning | error
Inline suppression goes on the offending line or the comment line above it (a comment on a run: | line covers the whole script):
- uses: some/action@v1 # actionguard: ignore=AG001
Architecture
| Module | Role |
|---|---|
expr.py |
Tokenizer and recursive-descent parser for ${{ }}; AST, context chains, validation |
source.py |
Doc: ruamel round-trip load, jobs/steps iteration, position lookup, uses: parsing |
taint.py |
Untrusted-context catalog and the Flow analysis (env, step/job outputs, matrix, $GITHUB_ENV) |
rules.py |
The 27 rules (+ AG000 for parse errors), registered with rationale and fix text |
engine.py |
File discovery, rule execution, inline-ignore handling |
pin.py |
Lock file, GitHub API resolver, uses: line rewriting |
fixes.py, edits.py |
Permission and injection fixers, bottom-up line edit applier |
report.py |
rich / JSON / SARIF / annotations |
cli.py, config.py |
Click commands, .actionguard.toml |
Design write-up with worked examples: docs/design.md. Rule catalog with attack explanations: docs/rules.md.
Tests
.venv/bin/python -m pytest -q # 164 tests, fully offline
Pinning is tested against tests/fixtures/actionguard.lock and a fake GitHub API; nothing in the suite touches the network.
Limitations
- Static analysis of the YAML only: reusable workflows and local composite actions referenced via
uses:are not followed, so taint does not cross into them. - Shell analysis is line-based and heuristic (assignments,
echo name=value >> $GITHUB_OUTPUT,eval,sh -c). Heredocs and multi-line quoting can fool the quote-state used by the fixer. - The untrusted-context list is a curated catalog; custom
repository_dispatchpayload fields are covered viaclient_payload.*only. - AG007 cannot know which scopes a job needs, so the autofix is the safe minimum rather than a complete permission set.
- Pin resolves moving tags at the time it runs; it cannot tell you whether the commit is trustworthy.