template-injection | Any ${{ }} interpolation directly inside a run: block (or actions/github-script script:) whose value is not a static/matrix value — this includes inputs.* and steps.*.outputs.* in action.yml composite actions, not just github.event.* sources. Common attacker-controlled sources: github.event.pull_request.title/body/head.ref/head.label, github.event.issue.title/body, github.event.comment.body, github.event.review.body, github.event.discussion.title/body, github.head_ref, github.event.workflow_run.head_branch/head_commit.message, and any ${{ inputs.* }} used in a composite action's run: step | Pass through a step-level env: var instead of direct interpolation, then reference as "${VAR_NAME}" (quoted) in the shell |
excessive-permissions | permissions: write-all anywhere; contents: write at workflow level (not job level); any write permission a job doesn't actually need; a job with no permissions: block at all (defaults to broad token permissions) | Move to job level; start from permissions: {} or permissions: contents: read; grant only what each job needs. Add an explicit permissions: block to every job, even if it's just contents: read |
unpinned-uses | Any uses: not pinned to a full 40-char SHA — branches (@main), mutable tags (@v4, @v1.2.3) are all flagged | Replace with SHA pin. Fetch: gh api repos/<owner>/<repo>/git/ref/tags/<tag> --jq '.object.sha'; if annotated tag: dereference with gh api repos/<owner>/<repo>/git/tags/<sha> --jq '.object.sha' |
artipacked | actions/checkout without persist-credentials: false — flagged unconditionally, not only when upload-artifact is present | Add persist-credentials: false to the checkout step |
secrets-inherit | secrets: inherit in a reusable workflow call | Replace with explicit named secrets only |
dangerous-triggers | pull_request_target or workflow_run AND any step checks out with PR head ref or runs shell using github.event.pull_request.* directly. Ignore if file matches config ignore list | Pin checkout ref: to github.sha; add persist-credentials: false; move untrusted input to env vars |
github-env | Writing untrusted event data to $GITHUB_ENV or $GITHUB_PATH in privileged trigger context | Use $GITHUB_OUTPUT for inter-step data; never write raw event data to GITHUB_ENV |
github-app | GitHub App token passed via ${{ steps.*.outputs.token }} directly in shell interpolation | Always pass via env: var, never interpolate directly |
secrets-outside-env | Deployment/publish job uses ${{ secrets.* }} but has no environment: declaration | Add environment: block — apply judgment, not every workflow needs it |
bot-conditions | if: checks github.actor == 'dependabot[bot]' or similar string comparison | Use github.actor_id == '49699333' or github.event.sender.type == 'Bot' |
unsound-contains | contains() with attacker-controlled input as haystack in security-sensitive condition | Use exact equality == instead |
unsound-condition | if: expression always evaluates to true due to type coercion (e.g. ${{ inputs.flag }} where string "false" is truthy) | Use explicit string comparison: inputs.flag == 'true' |
misfeature | continue-on-error: true on security-sensitive steps (scan, lint, auth); workflow_dispatch inputs used unsafely in shell | Remove continue-on-error from sensitive steps; pass dispatch inputs via env: |
unredacted-secrets | Secret value transformed or concatenated in shell (substring, base64, concat) — bypasses GitHub's auto-redaction | Avoid transforming secrets in shell; if unavoidable, mask result with ::add-mask:: |
concurrency-limits | Workflow triggered by push or pull_request with no concurrency: block | Add concurrency group — see workflow-patterns.md → Concurrency |
insecure-commands | ACTIONS_ALLOW_UNSECURE_COMMANDS: true in any env: block | Remove it; use $GITHUB_OUTPUT, $GITHUB_ENV, $GITHUB_STEP_SUMMARY |
hardcoded-container-credentials | Literal credential string in registry-password:, password:, or similar field | Move to ${{ secrets.* }} |
unpinned-images | container.image or services.*.image uses mutable tag (ubuntu:latest, postgres:15) | Pin to SHA256 digest: ubuntu@sha256:<digest> |
unpinned-tools | run: step does curl | bash, pip install <pkg> without version, npm install -g <pkg> without @version | Pin tool versions explicitly or use SHA-verified download |
cache-poisoning | Release workflow uses actions/cache with cache key derived from untrusted input | Use only deterministic keys (e.g. hash of lock files) |
overprovisioned-secrets | secrets: ${{ secrets }} — whole secrets context passed | Pass only named secrets the callee needs |
archived-uses | uses: references an archived GitHub repository | Replace with maintained alternative or vendor locally. Cannot auto-fix — report to user |
impostor-commit | SHA pin that cannot be verified as a real commit in the upstream repo — most common cause: tag object SHA used instead of commit SHA (annotated tags) | Fix: dereference the tag — gh api repos/<owner>/<repo>/git/ref/tags/<tag> --jq '.object.sha', then if .object.type == "tag" dereference again: gh api repos/<owner>/<repo>/git/tags/<sha> --jq '.object.sha' — use the final commit SHA |
known-vulnerable-actions | uses: references action version with known CVE or GitHub Security Advisory | Cannot auto-fix — report action name and version to user |
obfuscation | run: contains base64 -d, eval, or piped decode patterns | Cannot auto-fix — report to user for manual review |
ref-confusion | uses: references a ref that exists as both branch and tag in upstream repo | Pin to full SHA |
ref-version-mismatch | SHA pin comment doesn't match the actual tag for that SHA | Two cases: (1) annotated tag — the SHA is a tag object, not a commit: dereference with gh api repos/<owner>/<repo>/git/tags/<sha> --jq '.tag'. (2) SHA is a valid commit but not what the tag in the comment points to (common — e.g. a later untagged commit was pinned): resolve the tag's actual commit with gh api repos/<owner>/<repo>/git/refs/tags/<tag> --jq '.object.sha' and repin to that SHA, OR list gh api repos/<owner>/<repo>/tags --jq '.[] | .name + " " + .commit.sha' and find the tag whose commit matches the pinned SHA, updating the comment to that tag |
superfluous-actions | uses: calls an action whose functionality is already in the runner image | Remove the step. Cannot always auto-fix — report to user |
use-trusted-publishing | Publishing to PyPI/npm/RubyGems using ${{ secrets.PYPI_TOKEN }} etc. when registry supports OIDC | Migrate to OIDC trusted publishing with id-token: write |