CLI ยท commit 0c31ef5

actionguard

GitHub Actions workflow linter & supply-chain hardener

Download actionguard.zip
Quickstart
unzip actionguard.zip && cd actionguard
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

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

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