# 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](docs/rules.md)): unpinned actions and mutable refs, script injection in `run:` and `github-script` (direct, via `env:`, via step/job outputs, via matrix), pwn requests, missing/`write-all` permissions, 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-error` on security steps, hardcoded credentials, always-true `if:` conditions, invalid expressions, and more.
- Taint tracking: `${{ github.event.issue.title }}` stored in an env var and later `eval`ed, written to `$GITHUB_OUTPUT` and 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 `# v4` as a comment, preserves quotes, comments and line endings.
- `fix`: inserts `permissions: contents: read`, replaces `write-all`, hoists injectable expressions and secrets into step `env:` 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=AG003` suppression.

## Install

```bash
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

```bash
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:

```yaml
- 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/](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`):

```toml
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):

```yaml
- 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](docs/design.md). Rule catalog with attack explanations: [docs/rules.md](docs/rules.md).

## Tests

```bash
.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_dispatch` payload fields are covered via `client_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.
