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:

  1. 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 so foo${AG_X}bar stays correct) - inside single quotes: skipped and reported, because $VAR would not expand.
  2. 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.
  3. Adds NAME: ${{ original expression }} to the step's env: (appended directly under an existing block-style env:, otherwise a new env: after the script, aligned with the run: 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.