Design
Pipeline
file -> Doc (ruamel round-trip tree + raw lines)
-> Flow (taint scopes per step) } shared, computed once per Doc
-> rules (Hit: line, col, message)
-> engine (severity, inline ignores, config) -> Findings -> report
Rules never mutate the document. Fixers work on the original text, not on a re-serialised tree: ruamel gives line/column for every key, value and sequence item, and the fixers turn those positions into line edits (edits.py). That is why a fix touches only the lines it must and comments, quoting, blank lines and CRLF endings survive.
Expression parser (expr.py)
extract() scans for ${{, tracks single-quoted string literals so format('}}') does not end the expression early, and returns spans with offsets (the fixer needs them). parse() is a recursive-descent parser with GitHub's precedence:
|| < && < == != < <= > >= < ! < postfix (. [] call)
Nodes: Literal, Name, Prop, Index, Call, Unary, Binary. Context names and functions are case-insensitive and normalised to lowercase. validate() rejects unknown contexts and functions (AG027): github.evnt.name silently evaluates to an empty string at runtime, which can turn a guard into a no-op.
flatten(node) turns github.event.commits[0].message into (Name(github), ("event","commits","0","message")); a non-literal index becomes *.
Taint tracking (taint.py)
Sources
A catalog of attacker-controllable contexts (github.event.issue.title, github.event.pull_request.head.ref, github.head_ref, commit messages and author names, comment/review bodies, client_payload.*, inputs.*, ...) with * wildcards. A chain matches if it equals a pattern, is a prefix of one (toJSON(github.event) contains everything beneath it) or extends a trailing *. github.event_name and github.event.pull_request.number do not match.
Value flow inside an expression
sources(node, scope) answers "can attacker data reach the value this expression produces?":
| Node | Tainted if |
|---|---|
| property chain | it matches a source, a tainted env.X, steps.id.outputs.x, needs.job.outputs.x or matrix.k |
format, join, toJSON, fromJSON |
any argument is |
contains, startsWith, endsWith, status functions, !, comparisons |
never (they produce booleans) |
a || b, a && b |
either operand is |
So ${{ contains(github.event.issue.title, 'bug') }} is clean while ${{ github.event.issue.title || 'none' }} is not.
Flow through a job
Flow walks each job's steps in order, keeping a Scope (tainted env names, tainted outputs per step id, job outputs, tainted matrix keys). Worked example:
env:
TITLE: ${{ github.event.issue.title }} # (1) env TITLE tainted
steps:
- id: a
run: |
copy="$TITLE" # (2) shell var `copy` tainted
echo "t=$copy" >> "$GITHUB_OUTPUT" # (3) output a.t tainted
- run: echo ${{ steps.a.outputs.t }} # (4) AG005 indirect injection
- run: eval "$TITLE" # (5) AG005 evaluated as code
Script analysis (analyze_script) is line based: assignments propagate taint to shell variables, a tainted line that mentions GITHUB_OUTPUT or ::set-output taints the output named before = (or all outputs, *, if the name cannot be read), and the same for GITHUB_ENV, which taints the variable for every later step. Job outputs: that reference tainted step outputs become tainted job outputs; needs.<job>.outputs.<name> consults them. Job outputs are iterated to a fixed point, so job order in the file does not matter. A matrix built from tainted expressions (fromJSON(needs.prepare.outputs.regions)) taints the matrix keys; ${{ matrix.region }} in a script is AG020.
Why direct, indirect and matrix are different rules
Each tainted expression is classified by its most direct source (direct > env > output > needs > matrix) so one expression yields exactly one finding, and the message tells the author where the taint comes from.
Fixers
Pinning (pin.py)
For each uses: owner/repo[/path]@ref that is not a 40-hex SHA and not allow-listed or ignored, the resolver looks up owner/repo/path@ref, then owner/repo@ref, in the lock file, then (online) asks the API: git/ref/tags/<ref>, falling back to git/ref/heads/<ref>; annotated tags (type: tag) are dereferenced to the commit. Rewrite is a regex on that single raw line:
- uses: "actions/setup-node@v4" # setup
+ uses: "actions/setup-node@49933ea5288c...e5" # v4 # setup
Quote style and the existing comment are kept; the old ref becomes the leading comment. Flow-style {uses: x@v1, with: ...} lines are left alone because a comment would swallow the rest of the line.
Permissions
AG007 means no top-level permissions: and at least one job without its own. The fixer inserts
permissions:
contents: read
directly above jobs: (above the comment block attached to it). AG008 write-all (top-level or job-level) becomes a contents: read block at the same indentation.
Injection and secret hoisting
For each run: step the fixer finds expressions that are tainted (any kind) or reference secrets.*, and:
- Chooses the replacement from the quote state at that point of the raw line (a small scanner that understands
",'and backslash escapes): - unquoted:"$AG_X"- inside double quotes:${AG_X}(braces sofoo${AG_X}barstays correct) - inside single quotes: skipped and reported, because$VARwould not expand. - Names the variable:
AG_plus the context path (github.event.pull_request.title->AG_PULL_REQUEST_TITLE), or the secret's own name (secrets.NPM_TOKEN->NPM_TOKEN). The same expression gets the same variable within a step; a name clash with a different value gets_2. - Adds
NAME: ${{ original expression }}to the step'senv:(appended directly under an existing block-styleenv:, otherwise a newenv:after the script, aligned with therun:key).
Not rewritten, with a message: quoted YAML run: "...", folded > blocks, multi-line plain scalars, flow-style env:.
Safety nets: expressions on lines covered by # actionguard: ignore= are skipped, and every fixed file is parsed again before it is written. If the result does not parse, the command aborts and writes nothing for that file. A second fix run changes nothing (tested, including on examples/hardened).
Worked example (see the README for the full diff):
run: gh pr edit "$NUMBER" --add-label "${{ steps.classify.outputs.kind }}"
env:
NUMBER: ${{ github.event.pull_request.number }}
becomes
run: gh pr edit "$NUMBER" --add-label "${AG_STEPS_CLASSIFY_KIND}"
env:
AG_STEPS_CLASSIFY_KIND: ${{ steps.classify.outputs.kind }}
NUMBER: ${{ github.event.pull_request.number }}
Suppression and positions
A hit carries a line (the offending expression, found by searching the raw lines of the scalar, with duplicates counted in order) and an optional anchor line (the run: key). # actionguard: ignore= on either line, or on a comment-only line directly above, suppresses it. SARIF regions use the same 1-based line and column.