Skip to content

GitHub Actions provider

Parses workflow YAML files under a .github/workflows directory. No GitHub API token or installed Actions runner is required by default; the scanner stays read-from-disk-only unless --resolve-remote opts in to fetching reusable-workflow callees over HTTPS.

Producer workflow

# --gha-path is auto-detected when .github/workflows exists at cwd;
# the CLI announces the pick on stderr.
pipeline_check --pipeline github

# …or pass it explicitly.
pipeline_check --pipeline github --gha-path .github/workflows

A single workflow file can also be passed directly:

pipeline_check --pipeline github --gha-path .github/workflows/release.yml

All other flags (--output, --severity-threshold, --checks, --standard, …) behave the same as with the AWS and Terraform providers.

Reusable workflow resolution

jobs.<id>.uses: owner/repo/.github/workflows/x.yml@<sha> references a workflow body that runs with the caller's GITHUB_TOKEN and secrets. By default the scanner stops at the call site (it flags the ref via GHA-025 when unpinned and emits a one-line nudge listing how many remote refs were skipped); --resolve-remote opts in to fetching the called body and running the full GHA rule pack against it with the caller's permissions context.

# Fetch via raw.githubusercontent.com (works for public repos).
pipeline_check --pipeline github --resolve-remote

# Private callees: pass a token, or set $GITHUB_TOKEN.
pipeline_check --pipeline github --resolve-remote --gh-token "$GH_PAT"

# Fully offline: search a sibling on-disk checkout instead.
pipeline_check --pipeline github --resolve-remote \
    --gha-search-path ../shared-workflows

Resolution rules:

  • Only SHA-pinned refs are fetched. A tag-pinned ref (@v1, @main) is skipped with a warning, resolution against a movable upstream tag would defeat GHA-025's value.
  • Recursion follows transitive uses: calls to a depth of 3 (configurable with --gha-resolve-depth; hard ceiling 10). Cycles are detected.
  • Cache. Fetched bodies live under ~/.cache/pipeline-check/gha-resolver/ for 7 days. Use --no-cache to bypass.
  • Failure mode. Network errors, 404s, and malformed YAML never abort the scan. They land in the context's warnings stream.
  • Attribution. Findings on a resolved callee carry a synthetic <caller-path> -> <owner>/<repo>/<path>@<ref> resource string so the report points at both the call site and the upstream body.
  • Permissions inheritance. A callee without its own permissions: runs with the caller's; GHA-004 doesn't fire on a callee whose caller declared one.
  • secrets: inherit. When the call site passes secrets: inherit, GHA-019 annotates findings with the inherit note so report readers see the full credential surface.

What it covers

50 checks · 17 have an autofix patch (--fix).

Check Title Severity Fix
GHA-001 Action not pinned to commit SHA HIGH 🔧 fix
GHA-002 pull_request_target checks out PR head CRITICAL 🔧 fix
GHA-003 Script injection via untrusted context HIGH 🔧 fix
GHA-004 Workflow has no explicit permissions block MEDIUM 🔧 fix
GHA-005 AWS auth uses long-lived access keys MEDIUM 🔧 fix
GHA-006 Artifacts not signed (no cosign/sigstore step) MEDIUM
GHA-007 SBOM not produced (no CycloneDX/syft/Trivy-SBOM step) MEDIUM
GHA-008 Credential-shaped literal in workflow body CRITICAL 🔧 fix
GHA-009 workflow_run downloads upstream artifact unverified CRITICAL
GHA-010 Local action (./path) on untrusted-trigger workflow HIGH
GHA-011 Cache key derives from attacker-controllable input MEDIUM
GHA-012 Self-hosted runner without ephemeral marker MEDIUM
GHA-013 issue_comment trigger without author guard HIGH
GHA-014 Deploy job missing environment binding MEDIUM 🔧 fix
GHA-015 Job has no timeout-minutes, unbounded build MEDIUM 🔧 fix
GHA-016 Remote script piped to shell interpreter HIGH 🔧 fix
GHA-017 Docker run with insecure flags (privileged/host mount) CRITICAL 🔧 fix
GHA-018 Package install from insecure source HIGH 🔧 fix
GHA-019 GITHUB_TOKEN written to persistent storage CRITICAL 🔧 fix
GHA-020 No vulnerability scanning step MEDIUM
GHA-021 Package install without lockfile enforcement MEDIUM 🔧 fix
GHA-022 Dependency update command bypasses lockfile pins MEDIUM 🔧 fix
GHA-023 TLS / certificate verification bypass HIGH 🔧 fix
GHA-024 No SLSA provenance attestation produced MEDIUM
GHA-025 Reusable workflow not pinned to commit SHA HIGH
GHA-026 Container job disables isolation via options: HIGH
GHA-027 Workflow contains indicators of malicious activity CRITICAL
GHA-028 Dangerous shell idiom (eval, sh -c variable, backtick exec) HIGH
GHA-029 Package install bypasses registry integrity (git / path / tarball source) MEDIUM
GHA-030 OIDC token requested without environment-protected job HIGH
GHA-031 Workflow uses retired set-output / save-state command HIGH
GHA-032 run: invokes local script on untrusted-trigger workflow CRITICAL
GHA-033 Secret value echoed / printed in a run: block CRITICAL
GHA-034 Reusable workflow called with secrets: inherit MEDIUM 🔧 fix
GHA-035 github-script step interpolates untrusted context HIGH
GHA-036 runs-on interpolates untrusted context HIGH 🔧 fix
GHA-037 actions/checkout persists GITHUB_TOKEN into .git/config HIGH
GHA-038 Workflow re-enables retired ::set-env / ::add-path commands CRITICAL
GHA-039 services / container credentials embedded as literal in workflow CRITICAL
GHA-040 Action reference matches a known-compromised SHA or tag CRITICAL
GHA-041 Action upstream repo has a single contributor MEDIUM
GHA-042 Action upstream repo is newly created MEDIUM
GHA-043 Low-star action runs with sensitive permissions HIGH
GHA-044 Build tool runs lifecycle scripts on untrusted-trigger workflow HIGH
GHA-045 Caller-controlled ref input feeds actions/checkout HIGH
GHA-046 Manual PR-head fetch on untrusted-trigger workflow CRITICAL
GHA-047 Action ref resolves to a recently committed tag or SHA MEDIUM
TAINT-001 Untrusted input flows across step boundaries via step outputs HIGH
TAINT-002 Untrusted input flows across jobs via jobs.<id>.outputs: HIGH
TAINT-003 Untrusted input forwarded into reusable workflow with: HIGH

GHA-001: Action not pinned to commit SHA

HIGH 🔧 autofix CICD-SEC-3 ESF-S-PIN-DEPS ESF-S-VERIFY-DEPS CWE-829

Every uses: reference should pin a specific 40-char commit SHA. Tag and branch refs (@v4, @main) can be silently moved to malicious commits by whoever controls the upstream repository, a third-party action compromise will propagate into the pipeline on the next run.

Seen in the wild

  • tj-actions/changed-files compromise (CVE-2025-30066, March 2025): a malicious commit retagged behind @v1 / @v45 shipped CI-secret exfiltration to roughly 23,000 repos that had pinned the action to a mutable tag instead of a commit SHA.
  • reviewdog/action-setup compromise (CVE-2025-30154, March 2025): same week, similar mechanism. Tag-pinned consumers auto-pulled the malicious version; SHA-pinned consumers were unaffected.

Recommended action

Replace tag/branch references (@v4, @main) with the full 40-char commit SHA. Use Dependabot or StepSecurity to keep the pins fresh.

GHA-002: pull_request_target checks out PR head

CRITICAL 🔧 autofix CICD-SEC-4 ESF-D-INJECTION ESF-D-BUILD-ENV CWE-78

pull_request_target runs with a write-scope GITHUB_TOKEN and access to repository secrets, deliberately so, since it's how labeling and comment-bot workflows work. When the same workflow then explicitly checks out the PR head (ref: ${{ github.event.pull_request.head.sha }} or .ref) it executes attacker-controlled code with those privileges.

Seen in the wild

  • GitHub Security Lab: Preventing pwn requests (2020), the canonical write-up. Demonstrates how a fork PR that lands in a pull_request_target workflow with the PR head checked out runs in the base repo's privileged context.
  • Trail of Bits Codecov-style supply chain via pwn requests (2021): showed the primitive against widely-used Actions workflows. The fix pattern (split the workflow into a privileged labeler + an unprivileged builder) is now standard guidance.

Recommended action

Use pull_request instead of pull_request_target for any workflow that must run untrusted code. If you need write scope, split the workflow: a pull_request_target job that labels the PR, and a separate pull_request-triggered job that builds it with read-only secrets.

GHA-003: Script injection via untrusted context

HIGH 🔧 autofix CICD-SEC-4 ESF-D-INJECTION CWE-78

Interpolating attacker-controlled context fields (PR title/body, issue body, comment body, commit message, discussion body, head branch name, github.ref_name, inputs.*, release metadata, deployment payloads) directly into a run: block is shell injection. GitHub expands ${{ ... }} BEFORE shell quoting, so any backtick, $(), or ; in the source field executes.

Seen in the wild

  • GitHub Security Lab disclosure (2020): a sweep of public Actions found dozens of widely-used workflows interpolating github.event.issue.title / pull_request.title directly into shell. Any commenter or PR author could run arbitrary commands in the maintainer's CI.
  • Trail of Bits pwn-request research (2021): demonstrated the same primitive against pull_request_target workflows where the runner has secrets and a write-scope token; one fork PR could exfiltrate every secret the workflow could see. Mitigation is the same: never interpolate context into shell, route through env:.

Recommended action

Pass untrusted values through an intermediate env: variable and reference that variable from the shell script. GitHub's expression evaluation happens before shell quoting, so inline ${{ github.event.* }} is always unsafe.

GHA-004: Workflow has no explicit permissions block

MEDIUM 🔧 autofix CICD-SEC-5 ESF-C-LEAST-PRIV CWE-250

Without an explicit permissions: block (either top-level or per-job), the GITHUB_TOKEN inherits the repository's default scope, typically write. A compromised step receives far more privilege than it needs.

Known false-positive modes

  • Read-only / lint-only workflows that do not call any write-scoped API often pass without an explicit block because the default token scope on public repos is read. The rule defaults to MEDIUM confidence to reflect this.

Recommended action

Add a top-level permissions: block (start with contents: read) and grant additional scopes only on the specific jobs that need them.

GHA-005: AWS auth uses long-lived access keys

MEDIUM 🔧 autofix CICD-SEC-6 ESF-D-TOKEN-HYGIENE CWE-522

Long-lived AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY secrets in GitHub Actions can't be rotated on a fine-grained schedule and remain valid until manually revoked. OIDC with role-to-assume yields short-lived credentials per workflow run.

Known false-positive modes

  • LocalStack and Moto integration tests set AWS_ENDPOINT_URL to a localhost address and use the sentinel test / test access keys (the LocalStack convention). Those values can't authenticate against real AWS, so the rule auto-suppresses an env block that pairs a localhost endpoint with sentinel keys.

Recommended action

Use aws-actions/configure-aws-credentials with role-to-assume + permissions: id-token: write to obtain short-lived credentials via OIDC. Remove the static AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY secrets.

GHA-006: Artifacts not signed (no cosign/sigstore step)

MEDIUM CICD-SEC-9 ESF-D-SIGN-ARTIFACTS CWE-345

Unsigned artifacts cannot be verified downstream, so a tampered build is indistinguishable from a legitimate one. The check recognizes cosign, sigstore, slsa-github-generator, slsa-framework, and notation-sign as signing tools.

Seen in the wild

  • SolarWinds Orion compromise (December 2020): SUNBURST trojanized builds shipped to ~18,000 customers because no post-build signature could be checked against a trusted signing identity. Cryptographic signing on every release would have given downstream consumers a verifiable break with the upstream key, the absence of which was the ambient signal of compromise.
  • PyTorch nightly compromise (December 2022): the torchtriton dependency was hijacked via PyPI dependency-confusion. Sigstore-style attestation tied to the official publisher would have made the impostor build fail verification rather than silently install.

Recommended action

Add a signing step, e.g. sigstore/cosign-installer followed by cosign sign, or slsa-framework/slsa-github-generator for keyless SLSA provenance. Publish the signature alongside the artifact and verify it at consumption time.

GHA-007: SBOM not produced (no CycloneDX/syft/Trivy-SBOM step)

MEDIUM CICD-SEC-9 ESF-D-SBOM CWE-1104

Without an SBOM, downstream consumers cannot audit the exact set of dependencies shipped in the artifact, delaying vulnerability response when a transitive dep is disclosed. The check recognises CycloneDX, syft, Anchore SBOM action, spdx-sbom-generator, Microsoft sbom-tool, and Trivy in SBOM mode.

Recommended action

Add an SBOM generation step, anchore/sbom-action, syft . -o cyclonedx-json, Trivy with --format cyclonedx, or Microsoft's sbom-tool. Attach the SBOM to the release so consumers can ingest it into their vuln-management pipeline.

GHA-008: Credential-shaped literal in workflow body

CRITICAL 🔧 autofix CICD-SEC-6 ESF-D-SECRETS CWE-798

Every string in the workflow is scanned against a set of credential patterns (AWS access keys, GitHub tokens, Slack tokens, JWTs, Stripe, Google, Anthropic, etc., see --man secrets for the full catalog). A match means a secret was pasted into YAML, the value is visible in every fork and every build log and must be treated as compromised.

Known false-positive modes

  • Test fixtures and documentation blobs sometimes embed credential-shaped strings (JWT samples, AKIAI... examples). The AWS canonical example AKIAIOSFODNN7EXAMPLE is deliberately NOT suppressed, if it appears in a real workflow it almost always means a copy-paste from docs was never substituted. Defaults to LOW confidence.

Seen in the wild

  • Uber 2016 GitHub leak: an AWS access key embedded in a private GitHub repo was reachable to attackers who got at the repo and used it to download driver / rider PII for 57 million accounts. Credential-shaped literals in any source control system (public or private) are one credential-leak away from the same outcome.
  • GitGuardian's annual State of Secrets Sprawl reports consistently find millions of fresh credential leaks per year across public commits, with a median time-to-revocation after disclosure of days, not minutes. Pinning secrets to ${{ secrets.* }} removes the artifact from source control entirely.

Recommended action

Rotate the exposed credential immediately. Move the value to an encrypted repository or environment secret and reference it via ${{ secrets.NAME }}. For cloud access, prefer OIDC federation over long-lived keys.

GHA-009: workflow_run downloads upstream artifact unverified

CRITICAL CICD-SEC-4 ESF-D-INJECTION ESF-S-VERIFY-DEPS CWE-494

on: workflow_run runs in the privileged context of the default branch (write GITHUB_TOKEN, secrets accessible) but consumes artifacts produced by the triggering workflow, which is often a fork PR with no trust boundary. Classic PPE: a malicious PR uploads a tampered artifact, the privileged workflow_run downloads and executes it.

Recommended action

Add a verification step BEFORE consuming the artifact: cosign verify-attestation --type slsaprovenance ..., gh attestation verify --owner $OWNER ./artifact, or publish a checksum manifest from the trusted producer and sha256sum -c it. Treat any download from a fork as untrusted input.

GHA-010: Local action (./path) on untrusted-trigger workflow

HIGH CICD-SEC-4 ESF-D-INJECTION ESF-S-PIN-DEPS CWE-829

uses: ./path/to/action resolves the action against the CHECKED-OUT workspace. On pull_request_target / workflow_run, that workspace can be PR-controlled, meaning the attacker supplies the action.yml that runs with default-branch privilege.

Recommended action

Move the action to a separate repo under your control and reference it by SHA-pinned uses: org/repo@<sha>, or split the workflow so the privileged work runs only on pull_request (read-only token, no secrets) where PR-controlled action.yml can't escalate.

GHA-011: Cache key derives from attacker-controllable input

MEDIUM CICD-SEC-4 ESF-D-INJECTION ESF-S-VERIFY-DEPS CWE-345

actions/cache restores by key (and falls through restore-keys on miss). When the key includes a value the attacker controls (PR title, head ref, workflow_dispatch input), an attacker can plant a poisoned cache entry that a later default-branch run restores and treats as a clean build cache.

Recommended action

Build the cache key from values the attacker can't control: ${{ runner.os }}, ${{ hashFiles('**/*.lock') }} (only when the lockfile is enforced by branch protection), and the workflow file path. Never include github.event.* PR/issue fields, github.head_ref, or inputs.* in the key namespace.

GHA-012: Self-hosted runner without ephemeral marker

MEDIUM CICD-SEC-7 ESF-D-BUILD-ENV ESF-D-PRIV-BUILD CWE-269

Self-hosted runners that don't tear down between jobs leak filesystem and process state. A PR-triggered job writes to /tmp; a subsequent prod-deploy job on the same runner reads it. The mitigation is the runner's --ephemeral mode, the runner exits after one job and re-registers fresh. The check looks for an ephemeral label on the runs-on value; without one, the runner is presumed reusable. Recognises all three runs-on shapes: string, list, and { group, labels } dict form.

Known false-positive modes

  • Organisations using actions-runner-controller (ARC), autoscaled pools, or vendor runner fleets often use labels like arc-*, autoscaled-*, or ephemeral-pool-* instead of a bare ephemeral label. The check only matches the literal ephemeral token on runs-on; extend via a custom allow-prefix config if your fleet uses a different naming convention. Defaults to MEDIUM confidence.

Recommended action

Configure the self-hosted runner to register with --ephemeral (the runner exits after one job and is freshly registered), and add an ephemeral label so this check can verify it. Consider actions-runner-controller for ephemeral pools.

GHA-013: issue_comment trigger without author guard

HIGH CICD-SEC-4 ESF-D-INJECTION CWE-78

on: issue_comment (and discussion_comment) fires for every comment on every issue or discussion in the repository. On public repos this means any GitHub user can trigger workflow execution. If the workflow runs commands, deploys, or accesses secrets, the attacker controls timing and can inject payloads through the comment body.

Recommended action

Add an if: condition that checks github.event.comment.author_association (e.g. contains('OWNER MEMBER COLLABORATOR', ...)), github.event.sender.login, or github.actor against an allowlist. Without a guard, any GitHub user can trigger the workflow by posting a comment.

GHA-014: Deploy job missing environment binding

MEDIUM 🔧 autofix CICD-SEC-1 ESF-C-APPROVAL ESF-C-ENV-SEP CWE-284

Without an environment: binding, a deploy job can't be gated by required reviewers, deployment-branch policies, or wait timers. Any push to the triggering branch will deploy immediately.

Known false-positive modes

  • Integration-test jobs that run terraform apply or kubectl apply against a local mock (LocalStack, Moto, kind, k3d) aren't real deploys. The rule auto-suppresses a step whose env carries AWS_ENDPOINT_URL or KUBE_API_URL pointing at a localhost address.

Recommended action

Add environment: <name> to jobs that deploy. Configure required reviewers, wait timers, and branch-protection rules on the matching GitHub environment.

GHA-015: Job has no timeout-minutes, unbounded build

MEDIUM 🔧 autofix CICD-SEC-7 ESF-D-BUILD-TIMEOUT CWE-400

Without timeout-minutes, the job runs until GitHub's 6-hour default kills it. Explicit timeouts cap blast radius, cost, and the window during which a compromised step has access to secrets.

Recommended action

Add timeout-minutes: to each job, sized to the 95th percentile of historical runtime plus margin. GitHub's default is 360 minutes, an explicitly shorter value limits blast radius and runner cost.

GHA-016: Remote script piped to shell interpreter

HIGH 🔧 autofix CICD-SEC-3 ESF-S-VERIFY-DEPS CWE-494

Detects curl | bash, wget | sh, and similar patterns that pipe remote content directly into a shell interpreter inside a workflow. An attacker who controls the remote endpoint (or poisons DNS / CDN) gains arbitrary code execution in the CI runner.

Known false-positive modes

  • Established vendor installers (get.docker.com, sh.rustup.rs, bun.sh/install, awscli.amazonaws.com, cli.github.com, ...) ship via HTTPS from their own CDN and are idiomatic. This rule defaults to LOW confidence so CI gates can ignore them with --min-confidence MEDIUM; the finding still surfaces so teams that want cryptographic verification can audit.

Seen in the wild

  • Codecov Bash uploader compromise (April 2021): an attacker modified the codecov.io/bash uploader script (commonly fetched via curl -s codecov.io/bash | bash) to exfiltrate environment variables from CI runners (AWS keys, GitHub tokens, signing keys) at thousands of customers for over two months before discovery.
  • Bitwarden / npm install scripts (CVE-2018-7536-class incidents): remote-script execution in CI is the same primitive. The attacker controls bytes the runner executes. Pinning a digest or hosting a vendored copy turns a perpetual ambient risk into a one-time review.

Recommended action

Download the script to a file, verify its checksum, then execute it. Or vendor the script into the repository.

GHA-017: Docker run with insecure flags (privileged/host mount)

CRITICAL 🔧 autofix CICD-SEC-7 ESF-D-BUILD-ENV CWE-250

Flags like --privileged, --cap-add, --net=host, or host-root volume mounts (-v /:/) in a workflow give the container full access to the runner, enabling container escape and lateral movement.

Recommended action

Remove --privileged and --cap-add flags. Use minimal volume mounts. Prefer rootless containers.

GHA-018: Package install from insecure source

HIGH 🔧 autofix CICD-SEC-3 ESF-S-VERIFY-DEPS CWE-494

Detects package-manager invocations that use plain HTTP registries (--index-url http://, --registry=http://) or disable TLS verification (--trusted-host, --no-verify) in a workflow. These patterns allow man-in-the-middle injection of malicious packages.

Recommended action

Use HTTPS registry URLs. Remove --trusted-host and --no-verify flags. Pin to a private registry with TLS.

GHA-019: GITHUB_TOKEN written to persistent storage

CRITICAL 🔧 autofix CICD-SEC-6 ESF-D-SECRETS CWE-522

Detects patterns where GITHUB_TOKEN is written to files, environment files ($GITHUB_ENV), or piped through tee. Persisted tokens survive the step boundary and can be exfiltrated by later steps, uploaded artifacts, or cache entries, turning a scoped credential into a long-lived one.

Recommended action

Never write GITHUB_TOKEN to files, artifacts, or GITHUB_ENV. Use the token inline via ${{ secrets.GITHUB_TOKEN }} in the step that needs it.

GHA-020: No vulnerability scanning step

MEDIUM CICD-SEC-3 ESF-S-VULN-MGMT CWE-1104

Without a vulnerability scanning step, known-vulnerable dependencies ship to production undetected. The check recognises trivy, grype, snyk, npm audit, yarn audit, safety check, pip-audit, osv-scanner, and govulncheck.

Recommended action

Add a vulnerability scanning step, trivy, grype, snyk test, npm audit, pip-audit, or osv-scanner. Publish results so vulnerabilities surface before deployment.

GHA-021: Package install without lockfile enforcement

MEDIUM 🔧 autofix CICD-SEC-3 ESF-S-PIN-DEPS CWE-829

Detects package-manager install commands that do not enforce a lockfile or hash verification. Without lockfile enforcement the resolver pulls whatever version is currently latest, exactly the window a supply-chain attacker exploits.

Recommended action

Use lockfile-enforcing install commands: npm ci instead of npm install, pip install --require-hashes -r requirements.txt, yarn install --frozen-lockfile, bundle install --frozen, and go install tool@v1.2.3.

GHA-022: Dependency update command bypasses lockfile pins

MEDIUM 🔧 autofix CICD-SEC-3 ESF-S-PIN-DEPS CWE-829

Detects pip install --upgrade, npm update, yarn upgrade, bundle update, cargo update, go get -u, and composer update. These commands bypass lockfile pins and pull whatever version is currently latest. Tooling upgrades (pip install --upgrade pip) are exempted.

Known false-positive modes

  • Common build-tool bootstrapping idioms (pip install --upgrade pip, pip install --upgrade setuptools wheel virtualenv) and security-tool installs (pip install --upgrade pip-audit / cyclonedx-bom / semgrep) are exempted by the DEP_UPDATE_RE tooling allowlist. Other tooling-upgrade idioms not yet on the list can still trip the rule. Defaults to MEDIUM confidence so CI gates can require --min-confidence HIGH to ignore.

Recommended action

Remove dependency-update commands from CI. Use lockfile-pinned install commands (npm ci, pip install -r requirements.txt) and update dependencies via a dedicated PR workflow (e.g. Dependabot, Renovate).

GHA-023: TLS / certificate verification bypass

HIGH 🔧 autofix CICD-SEC-3 ESF-S-VERIFY-DEPS CWE-295

Detects patterns that disable TLS certificate verification: git config http.sslVerify false, NODE_TLS_REJECT_UNAUTHORIZED=0, npm config set strict-ssl false, curl -k, wget --no-check-certificate, PYTHONHTTPSVERIFY=0, and GOINSECURE=. Disabling TLS verification allows MITM injection of malicious packages, repositories, or build tools.

Recommended action

Remove TLS verification bypasses. Fix certificate issues at the source (install CA certificates, configure proper trust stores) instead of disabling verification.

GHA-024: No SLSA provenance attestation produced

MEDIUM CICD-SEC-9 ESF-S-PROVENANCE CWE-345

Provenance generation is distinct from signing. A signed artifact proves who published it; a provenance attestation proves where/how it was built. Consumers can then verify the build happened on a trusted runner, from a specific source commit, with known parameters. Without it, a leaked signing key forges identity but a leaked build environment also forges provenance. You need both for the SLSA L3 non-falsifiability guarantee.

Recommended action

Call slsa-framework/slsa-github-generator or actions/attest-build-provenance after the build step to emit an in-toto attestation alongside the artifact. cosign sign alone (covered by GHA-006) signs the artifact but doesn't record how it was built. SLSA Build L3 requires the provenance statement.

GHA-025: Reusable workflow not pinned to commit SHA

HIGH CICD-SEC-3 ESF-S-PIN-DEPS ESF-S-VERIFY-DEPS CWE-829

A reusable workflow runs with the caller's GITHUB_TOKEN and secrets by default. If uses: org/repo/.github/workflows/release.yml@v1 resolves to an attacker-modified commit, their code executes with your repository's permissions. This is the same threat model as unpinned step actions (GHA-001) but over a different uses: surface.

Recommended action

Pin every jobs.<id>.uses: reference to a 40-char commit SHA (owner/repo/.github/workflows/foo.yml@<sha>). Tag refs (@v1, @main) can be silently repointed by whoever controls the callee repository.

GHA-026: Container job disables isolation via options:

HIGH CICD-SEC-7 ESF-D-BUILD-ENV ESF-D-PRIV-BUILD CWE-250 CWE-276

GitHub-hosted runners execute container: jobs inside a Docker container the runner itself manages, normally a hardened, network-namespaced sandbox. options: is a free-text passthrough to docker run; a flag that breaks the sandbox (shares host network/PID, runs privileged, maps the Docker socket) turns the job into an RCE on the runner VM.

Recommended action

Remove --network host, --privileged, --cap-add, --user 0/--user root, --pid host, --ipc host, and host -v bind-mounts from container.options and services.*.options. If a build genuinely needs one of these, move it to a dedicated self-hosted pool with branch protection so the flag doesn't reach PR runs.

GHA-027: Workflow contains indicators of malicious activity

CRITICAL CICD-SEC-4 CICD-SEC-7 ESF-D-INJECTION ESF-S-VERIFY-DEPS CWE-506 CWE-913

Distinct from the hygiene checks. GHA-016 flags curl | bash as a risky default; this rule fires only on concrete indicators, reverse shells, base64-decoded execution, known miner binaries or pool URLs, exfil-channel domains, credential-dump pipes, history-erasure commands. Categories reported: obfuscated-exec, reverse-shell, crypto-miner, exfil-channel, credential-exfil, audit-erasure.

Known false-positive modes

  • Security-training repositories, CTF challenges, and red-team exercise workflows legitimately contain reverse-shell strings or exfil domains as literals. Matches inside YAML keys / HCL attributes whose names contain example, fixture, sample, demo, or test are auto-suppressed; bare lines in a production workflow still fire.
  • Defaults to LOW confidence. Filter with --min-confidence MEDIUM to ignore all matches; the rule still surfaces the hit for teams that want to spot-check.

Recommended action

Treat this as a potential pipeline compromise. Inspect the matching step(s), identify the author and the PR that introduced them, rotate any credentials the workflow has access to, and audit CloudTrail/AuditLogs for exfil. If the match is a legitimate red-team exercise, whitelist via .pipelinecheckignore with an expires: date, never a permanent suppression.

GHA-028: Dangerous shell idiom (eval, sh -c variable, backtick exec)

HIGH CICD-SEC-4 ESF-D-INJECTION CWE-95

eval, sh -c "$X", and `$X` all re-parse the variable's value as shell syntax. If the value contains ;, &&, |, backticks, or $(), those metacharacters execute. Even when the variable source looks controlled today, relocating the script or adding a new caller can silently expose it to untrusted input.

Known false-positive modes

  • eval "$(ssh-agent -s)" and similar eval "$(<literal-tool> <literal-args>)" bootstrap idioms are intentionally NOT flagged, the substituted command is literal, only its output is eval'd. The rule only fires when the substituted command references a variable.

Recommended action

Replace eval "$VAR" / sh -c "$VAR" / backtick exec of variables with direct command invocation. If the command really must be dynamic, pass arguments as array members ("${ARGS[@]}") or validate the input against an allow-list before invocation.

GHA-029: Package install bypasses registry integrity (git / path / tarball source)

MEDIUM CICD-SEC-3 ESF-S-PIN-DEPS ESF-S-VERIFY-DEPS CWE-829

Package installs that pull from git+… without a pinned commit, from a local path (./dir, file:…, absolute paths), or from a direct tarball URL are invisible to the normal lockfile integrity controls. A moving branch head, a sibling checkout the build assumes exists, or a tarball whose hash isn't verified all give an attacker who controls any of those surfaces the ability to substitute code into the build.

Recommended action

Pin git dependencies to a commit SHA (pip install git+https://…/repo@<sha>, cargo install --git … --rev <sha>). Publish private packages to an internal registry instead of installing from a filesystem path or tarball URL.

GHA-030: OIDC token requested without environment-protected job

HIGH CICD-SEC-2 CWE-284

Pairs with IAM-008. IAM-008 verifies the AWS-side trust policy pins audience + subject; this rule verifies the GitHub-side workflow can't request the token from any branch without a deployment gate. A misconfiguration on either side defeats the OIDC story.

Recommended action

Bind every job that exchanges the GHA OIDC token for cloud credentials to a protected environment: (e.g. environment: production). Environment protections layer in branch restrictions, required reviewers, and deployment windows that the IdP-side trust policy cannot enforce alone.

GHA-031: Workflow uses retired set-output / save-state command

HIGH CICD-SEC-4 ESF-D-INJECTION CWE-77

GitHub deprecated ::set-output:: and ::save-state:: in October 2022 because they read from the runner's stdout as a control channel. Any tool whose output happens to contain ::set-output… (a CI job's own diagnostic, a downloaded log, an upstream test framework) silently sets a step output. The replacement workflow commands ($GITHUB_OUTPUT / $GITHUB_STATE files) close that injection channel. Workflows still using the retired commands also depend on a deprecation timer that GitHub has extended several times. They will eventually break.

Recommended action

Replace echo "::set-output name=X::$VALUE" with echo "X=$VALUE" >> "$GITHUB_OUTPUT" and echo "::save-state name=X::$VALUE" with echo "X=$VALUE" >> "$GITHUB_STATE". The old commands stream through the runner's stdout, which lets any log line that happens to start with :: inject into the command channel. The file-redirect forms write to a private file the runner reads after the step exits, no log-line interleaving, no injection.

GHA-032: run: invokes local script on untrusted-trigger workflow

CRITICAL CICD-SEC-4 ESF-D-INJECTION CWE-829 CWE-94

GHA-010 flags uses: ./action, the action form of the same threat. This rule extends to direct shell invocation: run: ./scripts/setup.sh / run: bash scripts/setup.sh / run: python tools/build.py resolve against the checked-out workspace, which on pull_request_target / workflow_run is PR-controlled. The attacker ships an edited script and gets a default-branch-privileged shell.

Recommended action

Either don't run the script under an untrusted trigger, or split the workflow: keep the privileged work on the default branch (push / release triggers, no PR fork content), and run untrusted-trigger steps in a separate workflow with no secrets and a minimal GITHUB_TOKEN scope. Pinning the script via uses: org/repo@<sha> from a separate trusted repo is the canonical fix.

GHA-033: Secret value echoed / printed in a run: block

CRITICAL CICD-SEC-6 ESF-D-SECRETS CWE-532 CWE-200

Two distinct shapes are flagged: (1) printing a secret context expression directly, e.g. echo "${{ secrets.X }}" or cat <<<${{ secrets.X }}; (2) printing an env var whose value comes from a secret, when the surrounding step's env: declares it as X: ${{ secrets.X }}. The first is the obvious foot-gun; the second is the indirect form that slips past lint passes that only scan for ${{ secrets...}} literals.

Recommended action

Don't print secret values from a script. GitHub's log redaction is a best-effort string match. It doesn't catch base64 / urlencoded / partial substrings, and any caller that retrieves the raw log via the API gets the unredacted stream. If you need to confirm the secret exists, log a boolean ([ -n "$X" ] && echo set || echo unset) or a fingerprint (echo "$X" | sha256sum | head -c8), never the value itself.

GHA-034: Reusable workflow called with secrets: inherit

MEDIUM 🔧 autofix CICD-SEC-2 CICD-SEC-6 ESF-D-LEAST-PRIV ESF-D-SECRETS CWE-272

Fires on a jobs.<id>.uses: ... reference whose sibling secrets: value is the literal string inherit. This is distinct from GHA-025 (which gates on the pin of the called workflow): inheritance is a problem even when the call is SHA-pinned, because the surface a compromised callee sees is every caller secret instead of just the named ones. Explicit lists also document the contract, reviewers see exactly which secrets cross the workflow boundary.

Known false-positive modes

  • Single-tenant repos that share their entire secrets set with every reusable workflow by policy. Rare in practice, explicit lists make the secret flow visible and don't add much typing. Suppress with .pipelinecheckignore and a rationale rather than disabling the rule everywhere.

Recommended action

Replace secrets: inherit with an explicit list of just the secrets the called workflow actually needs (secrets: { NPM_TOKEN: ${{ secrets.NPM_TOKEN }} }). inherit passes every secret the caller can see, including ones the downstream workflow has no business reading. A compromised or buggy reusable workflow can then exfiltrate credentials the caller never intended to share.

GHA-035: github-script step interpolates untrusted context

HIGH CICD-SEC-4 ESF-D-INJECTION CWE-94

GHA-003 covers run: blocks where shell expansion is the injection surface. actions/github-script@<ref> runs the script: input as Node.js inside an authenticated Octokit context, same threat model, different language. The rule fires when script: (or the legacy previews: companion for inline JS) contains a ${{ github.event.* }}, ${{ inputs.* }}, ${{ github.head_ref }}, ${{ github.ref_name }}, or any other untrusted context expression, exactly the same catalog GHA-003 uses.

Known false-positive modes

  • Scripts that interpolate ${{ steps.*.outputs.* }} from a trusted upstream step are out of scope (the rule only matches the curated untrusted-context regex). If you intentionally rely on a non-curated context, suppress with a brief .pipelinecheckignore rationale.

Recommended action

Pass attacker-controllable values through env: and read them inside the script via process.env.X instead of interpolating ${{ ... }} directly into the script body. GitHub expands the expression before the JavaScript engine parses the source, so backticks, quotes, and ${...} characters in the source field break out of the surrounding string and execute as JavaScript with the workflow's GITHUB_TOKEN in scope.

GHA-036: runs-on interpolates untrusted context

HIGH 🔧 autofix CICD-SEC-7 ESF-D-BUILD-ENV ESF-D-PRIV-BUILD CWE-345

GHA-012 catches self-hosted runners that aren't ephemeral; this rule catches the upstream targeting choice. When runs-on is computed from an untrusted expression, the caller picks where the workflow runs, including any self-hosted label the org owns. A reusable workflow that declares runs-on: ${{ inputs.runner }} lets a downstream caller route the job onto the production-deploy fleet (or any other privileged label) and execute arbitrary code with the privileges that fleet inherits. The same surface exists via workflow_dispatch inputs and any ${{ github.event.* }} field that an attacker can populate. The rule walks all three runs-on shapes, string scalar, list of labels, and the long-form { group, labels } dict, and matches the same untrusted-context regex GHA-003 / GHA-035 use.

Known false-positive modes

  • Workflows that intentionally select runners by environment via a vetted matrix (runs-on: ${{ matrix.os }} where matrix.os is a hard-coded list inside the workflow) are out of scope, the matrix values are author-controlled, not caller-controlled. The rule only matches the catalog of untrusted contexts (inputs.*, github.event.*, github.head_ref, …); matrix.* and env.* references are intentionally not flagged.

Recommended action

Hard-code runs-on: to a specific runner label or list of labels. If the choice has to be parameterised across callers, validate the input against an allowlist of known-good labels before the job runs (a small if: guard at job level), and never accept ${{ inputs.* }} or any ${{ github.event.* }} field as the runs-on value directly.

GHA-037: actions/checkout persists GITHUB_TOKEN into .git/config

HIGH CICD-SEC-6 CICD-SEC-4 ESF-D-SECRETS ESF-D-CODE-INTEGRITY CWE-522 CWE-552

Detection fires on any step whose uses: starts with actions/checkout@ and whose with: block either omits persist-credentials (the unsafe default) or sets it to true explicitly.

This is the failure pattern Zizmor calls Artipacked and the StepSecurity / harden-runner audit set tracks as persist-credentials-default. Real-world exploit chains (the ultralytics 2024 RCE, multiple Mend / Snyk advisories) exploit exactly this primitive: a first checkout step persists the token, a later run: step (often a build script the attacker can influence via PR contents) reads .git/config and ships the token out.

Sister rule: GHA-019 catches the explicit echo $GITHUB_TOKEN > file shape; GHA-037 catches the implicit checkout-default that doesn't go through a run: line at all.

Known false-positive modes

  • Workflows that genuinely need persist-credentials: true to push back to the repo (a release-tag bot, a docs-deploy job, stefanzweifel/git-auto-commit-action) shouldn't suppress this rule globally; instead, scope persist-credentials: true to a named step, then run the push immediately, then use a fresh actions/checkout with persist-credentials: false so the token doesn't leak into later steps. Suppress on the specific step name only when the scoped pattern is in place.

Recommended action

Set persist-credentials: false on every actions/checkout step that doesn't need to push back to the repo. The default in v3 / v4 is true, which writes the GITHUB_TOKEN into .git/config as an http.https://github.com/.extraheader line. Any subsequent run: step in the same job can read it with git config --get http.https://github.com/.extraheader and exfiltrate the token to a remote endpoint, even if that step's own scope is read-only. If the workflow genuinely needs to push (release publishing, doc-site deploys), do the push as the very next step and immediately follow with a checkout that sets persist-credentials: false so the token doesn't leak into later, less-trusted steps.

GHA-038: Workflow re-enables retired ::set-env / ::add-path commands

CRITICAL CICD-SEC-4 CICD-SEC-7 ESF-D-INJECTION CWE-77 CWE-77

Detection fires when ACTIONS_ALLOW_UNSECURE_COMMANDS is set to any truthy value at the workflow env: level, the job env: level, or any step's env: block. Accepted truthy spellings: true / 1 / yes / on (including quoted forms like "true" and case-insensitive variants like YES / On).

Sister rule GHA-031 catches direct uses of ::set-output:: / ::save-state:: in step scripts. GHA-038 catches the explicit re-enable flag, which is the strictly worse case: it implicitly accepts every ::set-env:: / ::add-path:: line that lands on the runner's stdout from any tool the step invokes, not just the workflow author's own echo commands. A downloaded build log, a container's startup banner, an upstream test runner's output, all become injection vectors.

Known false-positive modes

  • Some legacy actions (last-updated pre-2020) still emit ::set-env:: lines and rely on the override to be set. Replace the action rather than suppressing this rule, the security exposure outweighs the cost of an alternative action.

Recommended action

Drop the ACTIONS_ALLOW_UNSECURE_COMMANDS env definition entirely, then migrate any leftover ::set-env:: / ::add-path:: workflow commands to the file-redirect form (echo "X=$VAL" >> "$GITHUB_ENV" and echo "$DIR" >> "$GITHUB_PATH"). GitHub disabled the legacy commands in 2020 specifically because they share the runner's stdout as a control channel: any log line starting with :: could inject environment variables, prepend to PATH, or set step outputs. Setting the override flag back to true re-opens that injection channel for the entire workflow scope.

GHA-039: services / container credentials embedded as literal in workflow

CRITICAL CICD-SEC-6 ESF-D-SECRETS CWE-798 CWE-522

GitHub Actions accepts a credentials: map on both the job-level container: block (the runner image) and on each services.<name>: entry (sidecar containers). The map is the documented way to pull a private image from a registry that requires auth, and it expects ${{ secrets.* }} references for both fields.

GHA-008 scans the workflow for credential patterns (AWS access keys, JWTs, Slack tokens, etc.) but doesn't trip on a plain password like hunter2 or a registry username like ci-deploy-bot. GHA-039 catches them by position: any literal value in a credentials.username / credentials.password field is by definition a leaked credential, regardless of its shape. Closes parity with Zizmor's hardcoded-container-credentials rule.

Known false-positive modes

  • Workflows that legitimately use a public anonymous registry mirror occasionally hardcode username: anonymous / password: "" for clarity. Both shapes are filtered out automatically (empty / whitespace-only values, plus the literal anonymous username), but if your fixture uses another sentinel for anonymous access, suppress the specific job/service in the ignore-file rather than the rule globally.

Recommended action

Move every services.<name>.credentials.username / credentials.password value (and the same field on a job-level container: block) out of the workflow YAML and into a repository or environment secret. Reference the secret via ${{ secrets.NAME }} from the same credentials block. Anything written as a literal is permanently visible in every fork of the repo, every build log that prints the runner's start banner, and every cached job summary, so the credential must be treated as compromised on the spot. The fix is the rotation, plus the secret reference, plus a check that no other workflow keeps the literal pattern.

GHA-040: Action reference matches a known-compromised SHA or tag

CRITICAL CICD-SEC-3 ESF-S-PIN-DEPS ESF-S-VERIFY-DEPS CWE-829 CWE-506

Walks every workflow's steps[].uses: and jobs.<id>.uses: references against the curated compromised-action registry in pipeline_check.core.checks.github._compromised_actions. Match is case-insensitive on owner / repo and exact on the ref value (commit SHA or tag name). Registry is deliberately small and append-only — refresh by PR with the citing advisory in the commit message; no fetch-from-network registry to avoid taking on a telemetry surface.

Known false-positive modes

  • The registry covers only public, advisory-confirmed compromises. Pre-disclosure compromises and yet-unpublished maintainer-account takeovers do not land until the citing CVE / GHSA exists. Pair with GHA-001 (SHA pinning) and GHA-025 (tag-rewrite detection) for the prevention angle.

Seen in the wild

  • tj-actions/changed-files compromise (CVE-2025-30066, March 2025): the canonical case the registry was built for. Roughly 23,000 tag-pinned repos shipped CI secrets to an exfiltration endpoint over a ~24-hour window before GitHub blocked the malicious commits.
  • reviewdog/action-setup compromise (CVE-2025-30154, March 2025): same week as tj-actions; smaller blast radius but identical mechanism. Tag-pinned consumers were affected; SHA-pinned consumers who happened to match the malicious commit were also affected.

Recommended action

Rotate every secret that may have been reachable to a workflow run that hit the compromised reference, then update the uses: reference to a known-clean SHA published by the upstream maintainer post-incident (usually announced in the advisory body). Audit CI logs for the affected window for any sign that the malicious payload ran against this repo.

GHA-041: Action upstream repo has a single contributor

MEDIUM CICD-SEC-3 ESF-S-VERIFY-DEPS CWE-1357

Reads the contributor count from ctx.action_metadata[owner/repo].contributor_count (populated by the --resolve-remote path; the GitHub REST /contributors endpoint, capped at two entries — the rule only cares about == 1). When the fetch failed or the flag is off, the rule passes silently. Forks and archived repos that ALSO have a single contributor fire the rule; the fork / archived state is part of the same supply-chain risk story.

Known false-positive modes

  • Some well-maintained single-author actions (high-quality personal-account repos that the maintainer simply hasn't open-sourced governance for) are not actually compromised. Suppress via ignore-file when a security review has confirmed the maintainer's identity and 2FA posture.

Seen in the wild

  • tj-actions / reviewdog March 2025 compromises (CVE-2025-30066 / CVE-2025-30154): both upstream repos had a single primary contributor at the time of compromise. The single-maintainer pattern was central to the blast radius (no second pair of eyes on the malicious commit, no auto-rollback when the tag move landed).

Recommended action

Audit the action repo's contributor list. If the repo genuinely has one maintainer, pin to a vendored fork under your org's control (so a future compromise on the upstream doesn't reach your build runtime) or move to a first-party action covering the same surface. The single-maintainer pattern is what made tj-actions / reviewdog one-day compromises so widely-blast.

GHA-042: Action upstream repo is newly created

MEDIUM CICD-SEC-3 ESF-S-VERIFY-DEPS CWE-1357

Reads created_at from ctx.action_metadata[owner/repo] (populated by the --resolve-remote path). Fires when the repo's age in days is below MIN_AGE_DAYS (90). Without the opt-in flag the rule passes silently with a nudge.

Known false-positive modes

  • Newly-released first-party actions from a trusted org (say, a freshly-launched actions/foo rolled out by GitHub itself) fire while they're still young. Suppress via ignore-file with a dated note; the entry expires naturally once the repo crosses the age threshold.

Seen in the wild

  • GitGuardian / StepSecurity typosquat reports (2023-2024) document several action-naming impersonations that appeared as newly-registered repos and reached production CI before the legitimate owner was notified.

Recommended action

Verify the action repo is the real upstream and not a typosquat. Compare the spelling and owner against the intended action (actions/checkout vs actoins/checkout); check the repo description, stars, and prior releases. If the action is genuinely new but trusted, suppress via ignore-file with a dated note; the suppression decays naturally as the repo ages past the 90-day threshold.

GHA-043: Low-star action runs with sensitive permissions

HIGH CICD-SEC-3 CICD-SEC-5 ESF-S-VERIFY-DEPS ESF-C-LEAST-PRIV CWE-829 CWE-250

Reads stargazers_count from ctx.action_metadata[owner/repo] and the effective permissions: block (job-level wins; falls back to workflow-top-level; falls back to the caller's inherited block for resolved reusable workflows). Fires when stars < MAX_STARS (25) AND any of 'contents', 'packages', 'id-token', 'actions', 'deployments' is set to write on the calling job. permissions: write-all is treated as all scopes set to write.

Known false-positive modes

  • Internal first-party actions hosted in a private org repo legitimately have low public star counts; their threat model is different and the rule does not distinguish internal from third-party. Suppress via ignore-file when the action is in-org and trusted.

Seen in the wild

  • GitGuardian 2023 supply-chain audit: a handful of low-popularity actions with contents: write were weaponized via single-PR maintainer-impersonation compromises; the elevated permission was the privilege amplifier that let the attacker push code back to the victim's default branch on the same workflow run.

Recommended action

Either narrow the calling job's permissions: to the minimum the action actually needs (drop contents: write / id-token: write / packages: write / actions: write / deployments: write unless the action's documented surface requires them), or replace the action with a community-reviewed alternative. The rule fires the COMBINATION of low community review and elevated permissions; either side alone is fine.

GHA-044: Build tool runs lifecycle scripts on untrusted-trigger workflow

HIGH CICD-SEC-4 ESF-D-INJECTION CWE-829 CWE-94

Package managers and build tools execute code by design. npm install runs preinstall / install / postinstall from the PR's package.json; pip install . runs the PR's setup.py; make runs the PR's Makefile; mvn / gradle load plugins declared in the PR's pom.xml / build.gradle; cargo build runs build.rs. Under pull_request_target / workflow_run, the surrounding context already has secrets and a write-scope token, so the lifecycle hook is the entire attack.

Known false-positive modes

  • Workflows that pin the workspace to a trusted ref before invoking the build tool (actions/checkout with no ref: override on pull_request_target, or a fresh checkout of a default-branch SHA) aren't actually exposed. The rule fires on the build-tool invocation alone; suppress with a .pipelinecheckignore rationale when the workspace is provably clean.

Seen in the wild

  • Trail of Bits Public PPE write-up (2022): demonstrated the primitive against pull_request_target workflows that ran npm install after checking out PR content. The PR-supplied preinstall script ran with the base repo's secrets in scope. Same shape with pip install -e . (setup.py) and make (Makefile).
  • Cycode / Legit Security Poisoned Pipeline Execution research (2022-2023) catalogued dozens of OSS repos where a privileged-trigger workflow's build step executed PR-controlled config: setup.py's cmdclass, build.gradle's init.gradle, pom.xml's <build><plugins>. The fix pattern is always: don't build untrusted code with secrets in scope.

Recommended action

Don't run install / build commands under pull_request_target or workflow_run against a tree that may be PR-controlled. Split the workflow: keep the privileged work on push / release (no fork content), and run untrusted builds in a separate pull_request workflow with no secrets and a read-only GITHUB_TOKEN. If you must build PR code with secrets, do it inside a container with no network egress and a minimal filesystem, never directly on the runner.

GHA-045: Caller-controlled ref input feeds actions/checkout

HIGH CICD-SEC-4 ESF-D-INJECTION CWE-829 CWE-940

workflow_dispatch / workflow_call inputs land in ${{ inputs.<name> }}. Feeding that directly into the ref: of actions/checkout means the caller picks which commit runs in this workflow's privileged context (secrets, GITHUB_TOKEN, environment approvals already satisfied). The callee can't tell whether the ref points at a vetted branch, a private fork's tip, or an attacker-controlled SHA. The rule fires on ref: values whose expression resolves to an inputs.* reference, walking any ${{ ... }} expression that names an input field.

Known false-positive modes

  • Reusable workflows that ARE the trust boundary (the callee is documented as the authoritative checkout entrypoint and every caller is internal / pinned by SHA) accept this shape by design. The rule still surfaces these so the author can document the contract in a .pipelinecheckignore rationale; suppress with the caller-list cite.

Seen in the wild

  • Snyk GitHub Actions abuse via workflow_dispatch research (2023) showed reusable build workflows that accepted a ref input and checked it out without validation. An attacker with workflow_dispatch permission (commonly granted to broader sets of actors than push) pointed the checkout at a fork SHA and exfiltrated the production deploy credentials.

Recommended action

Validate the ref input against an allow-list (a regex for refs/heads/release-*, an explicit set of permitted tags, or a 40-char SHA match) BEFORE passing it to actions/checkout. If the workflow only needs to build release tags, hard-code the ref or derive it from github.event.release.tag_name (still attacker-influenced, but at least scoped to a release event). For reusable workflows, document that the callee assumes callers have already validated the ref, and pin every caller to a known list of refs.

GHA-046: Manual PR-head fetch on untrusted-trigger workflow

CRITICAL CICD-SEC-4 ESF-D-INJECTION CWE-829

GHA-002 catches actions/checkout with ref: ${{ github.event.pull_request.head.sha }}. The same primitive shows up as gh pr checkout, git fetch origin pull/<N>/head, and git checkout of an attacker-controlled SHA expression inside a run: block. They all land the same bytes in the workspace with the same privileged context active, so they get the same severity.

Seen in the wild

  • GitHub Security Lab: Preventing pwn requests (2020) listed manual git fetch pull/<N>/head as one of the equivalent ways teams shoot themselves in the foot. Auditors checking only actions/checkout miss the shell-level variants entirely.

Recommended action

Don't materialize the PR head in a pull_request_target or workflow_run job. If you need to inspect PR content, split the workflow: a privileged half (with secrets) that uses metadata only (PR number, base ref, label) and an unprivileged pull_request half that builds the code with no secrets in scope.

GHA-047: Action ref resolves to a recently committed tag or SHA

MEDIUM CICD-SEC-3 CICD-SEC-8 ESF-S-VERIFY-DEPS CWE-1357

Reads ref_committed_at from ctx.action_metadata[owner/repo] (populated by the --resolve-remote path via GET /repos/{owner}/{repo}/commits/{ref}). Fires when the referenced ref's commit date is younger than MIN_REF_AGE_DAYS (7). Trusted publishers (actions, aws-actions, azure, ...) are skipped by default to avoid firing on legitimate retags of floating majors; pin to a SHA to opt those back in. Without --resolve-remote the rule passes silently with a discovery nudge.

Known false-positive modes

  • A legitimate first-party action that's outside the default trusted-publisher allowlist (a small vendor org that publishes a real action; you'd like it included) will fire after every release for the cooldown window. Either pin to a SHA (preferred) or suppress via ignore-file with a dated note; the suppression decays once the ref ages past the threshold.

Seen in the wild

  • Multiple action-tag compromises (ua-parser-js npm 2021, tj-actions/changed-files 2025) followed the same shape: a tag was re-pointed at a malicious commit and consumers pulling on the next CI run executed the payload. Cooldown gating turns the community-detection window into a defense.

Recommended action

Wait until the referenced tag or commit has had time to be reviewed by the upstream community before pulling it into CI. The default cooldown is seven days. Either bump the pinned ref to an older release, or wait 7 days and re-run. If the action is internal / first-party and the freshness gate is unwanted, pin to a 40-char commit SHA — SHA pins don't move under a retag and are the preferred long-term mitigation.

TAINT-001: Untrusted input flows across step boundaries via step outputs

HIGH CICD-SEC-4 CICD-SEC-1 ESF-D-INJECTION CWE-78 CWE-829

GHA-003 detects the direct interpolation case (${{ github.event.* }} inside a run: body) and the single-step env-inheritance case. TAINT-001 fills the cross-step gap: a producer step sets a tainted step output, and a consumer step (in the same job) interpolates it via ${{ steps.<id>.outputs.<name> }}. The producer's interpolation is GHA-003's finding; TAINT-001's finding lives at the consumer (the actual injection sink) and carries the full chain in its description so a reader sees both sides at once.

v1 limitations: only same-job step outputs are tracked; jobs.<id>.outputs.* (cross-job propagation) and reusable-workflow input/output forwarding are tracked as future work in ROADMAP.md. The producer pass matches the canonical echo "name=..." >> $GITHUB_OUTPUT shape and the legacy ::set-output name=...:: workflow-command form.

Known false-positive modes

  • If the producer step deliberately runs a sanitiser between the interpolation and the $GITHUB_OUTPUT write (echo "$TITLE" | tr -dc 'a-zA-Z0-9 ' >> $GITHUB_OUTPUT), the consumer is no longer exploitable. The rule's regex doesn't model that transformation and will still fire; suppress via ignore-file scoped to the consumer step name when this is the deliberate shape. The producer's GHA-003 finding then carries the residual signal that the sanitiser is load-bearing.

Recommended action

Sanitise the value at the step that writes the $GITHUB_OUTPUT entry. The canonical pattern is to interpolate the untrusted source into an env: variable on the producer step and reference the env var in the echo: env: TITLE: ${{ github.event.issue.title }} then echo "title=$TITLE" >> $GITHUB_OUTPUT. After that, downstream steps reading steps.<id>.outputs.title see a string-typed value with no GitHub-expression evaluation pass left to exploit. Removing the source entirely is the safest fix; if the value genuinely needs to flow downstream, round-trip it through an env var the way GHA-003 recommends so the shell quoting still applies.

TAINT-002: Untrusted input flows across jobs via jobs.<id>.outputs:

HIGH CICD-SEC-4 CICD-SEC-1 ESF-D-INJECTION CWE-78 CWE-829

TAINT-001 catches step-output flow within a single job; TAINT-002 catches the cross-job transition. Engine shape: walk every job's outputs: mapping looking for values that interpolate either a tainted step output or a direct ${{ github.event.* }} source. Tainted job outputs are matched against every ${{ needs.<job>.outputs.<name> }} reference in any downstream job's run: / with: body. Each match emits a TAINT-002 finding with the full chain in the description.

Same-step interpolations (the producer's own use of ${{ github.event.* }} inside its run:) are still GHA-003's responsibility; TAINT-002's value is the cross-job hop the single-step rule can't see.

Known false-positive modes

  • Sanitisation between the source interpolation and the $GITHUB_OUTPUT write isn't modeled. If the producer step runs echo "$TITLE" | tr -dc 'a-zA-Z0-9 ' before redirecting to GITHUB_OUTPUT, the consumer is no longer exploitable but TAINT-002 will still fire; suppress via ignore-file scoped to the consumer job's workflow file when this is the deliberate shape.

Recommended action

Sanitise the value at the producer step before it lands in $GITHUB_OUTPUT. Once the value is in a job output the consuming job has no expression-level escaping pass left, ${{ needs.<job>.outputs.<name> }} substitutes the string verbatim into the consumer's shell. The canonical safe pattern is to copy the untrusted source into the producer step's env: block, reference the env var quoted in echo "name=$VAR" >> $GITHUB_OUTPUT, and only then surface it through the job output. The consuming job should still treat the value as tainted (use it in env-var form, not interpolated directly into shell).

TAINT-003: Untrusted input forwarded into reusable workflow with:

HIGH CICD-SEC-4 CICD-SEC-1 ESF-D-INJECTION CWE-78 CWE-829

Detection walks every jobs.<id>.uses: <callee> reference, finds every with: value that interpolates an attacker-controllable source (direct ${{ github.event.* }}, a tainted step output via ${{ steps.<id>.outputs.<name> }}, or a cross-job ${{ needs.<job>.outputs.<name> }}), and flags the forward.

When the callee body is loaded into the same scan (local ./.github/workflows/<file>.yml references via --gha-path, or remote refs fetched by --resolve-remote), the rule also checks whether the callee references ${{ inputs.<name> }} unquoted in a sink. Confirmed end-to-end paths get HIGH confidence; caller-side-only forward stay at MEDIUM (still a risk surface, but a future change to the callee could expose it).

Known false-positive modes

  • Callees that wrap the input safely (immediately copy into env, sanitise before use) make the caller-side forward harmless. When the callee body is loaded into the scan, the rule downgrades to MEDIUM confidence on those paths; suppress via ignore-file when the callee's handling is audited and sound. Without --resolve-remote the rule can't see remote callee bodies and every forward stays at MEDIUM, the right default for unverifiable cross-repo flow.

Recommended action

Sanitise the value at the caller before forwarding it across the reusable-workflow boundary. The canonical safe pattern is to copy the untrusted source into a step's env: block, run a sanitiser (tr -dc 'a-zA-Z0-9 ' is enough for a freeform title), surface the sanitised result via echo "name=$VAR" >> $GITHUB_OUTPUT, then forward ${{ steps.<id>.outputs.<name> }} as the with: input. The callee then sees a string-typed value with no expression-evaluation pass left to exploit. If the callee is under your control, also handle the input via env in the callee's run: body (not direct ${{ inputs.<name> }} interpolation).


Adding a new GitHub Actions check

  1. Create a new module at pipeline_check/core/checks/github/rules/ghaNNN_<name>.py exporting a top-level RULE = Rule(...) and a check(path, doc) -> Finding function. The orchestrator auto-discovers RULE and calls check with the parsed YAML document.
  2. Add a mapping for the new ID in pipeline_check/core/standards/data/owasp_cicd_top_10.py (and any other standard that applies).
  3. Drop unsafe/safe snippets at tests/fixtures/per_check/github/GHA-NNN.{unsafe,safe}.yml and add a CheckCase entry in tests/test_per_check_real_examples.py::CASES.
  4. Regenerate this doc:
python scripts/gen_provider_docs.py github