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:
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 defeatGHA-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-cacheto 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-004doesn't fire on a callee whose caller declared one. secrets: inherit. When the call site passessecrets: inherit,GHA-019annotates 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
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/@v45shipped 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
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_targetworkflow 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
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.titledirectly into shell. Any commenter or PR author could run arbitrary commands in the maintainer's CI. - Trail of Bits
pwn-requestresearch (2021): demonstrated the same primitive againstpull_request_targetworkflows 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 throughenv:.
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
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
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_URLto a localhost address and use the sentineltest/testaccess 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)
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
torchtritondependency 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)
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
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
AKIAIOSFODNN7EXAMPLEis 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
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
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
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
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-*, orephemeral-pool-*instead of a bareephemerallabel. The check only matches the literalephemeraltoken onruns-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
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
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 applyorkubectl applyagainst a local mock (LocalStack, Moto, kind, k3d) aren't real deploys. The rule auto-suppresses a step whose env carriesAWS_ENDPOINT_URLorKUBE_API_URLpointing 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
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
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)
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
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
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
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
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
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 theDEP_UPDATE_REtooling 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 HIGHto 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
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
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
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:
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
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, ortestare auto-suppressed; bare lines in a production workflow still fire. - Defaults to LOW confidence. Filter with
--min-confidence MEDIUMto 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)
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 similareval "$(<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)
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
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
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
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
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
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
.pipelinecheckignoreand 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
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.pipelinecheckignorerationale.
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
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 }}wherematrix.osis 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.*andenv.*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
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: trueto 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, scopepersist-credentials: trueto a named step, then run the push immediately, then use a freshactions/checkoutwithpersist-credentials: falseso 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
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
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 literalanonymoususername), 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
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
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
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/foorolled 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
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: writewere 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
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/checkoutwith noref:override onpull_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.pipelinecheckignorerationale when the workspace is provably clean.
Seen in the wild
- Trail of Bits
Public PPEwrite-up (2022): demonstrated the primitive againstpull_request_targetworkflows that rannpm installafter checking out PR content. The PR-suppliedpreinstallscript ran with the base repo's secrets in scope. Same shape withpip install -e .(setup.py) andmake(Makefile). - Cycode / Legit Security
Poisoned Pipeline Executionresearch (2022-2023) catalogued dozens of OSS repos where a privileged-trigger workflow's build step executed PR-controlled config:setup.py'scmdclass,build.gradle'sinit.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
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
.pipelinecheckignorerationale; suppress with the caller-list cite.
Seen in the wild
- Snyk
GitHub Actions abuse via workflow_dispatchresearch (2023) showed reusable build workflows that accepted arefinput 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
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>/headas one of the equivalent ways teams shoot themselves in the foot. Auditors checking onlyactions/checkoutmiss 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
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
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_OUTPUTwrite (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:
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:
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-remotethe 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
- Create a new module at
pipeline_check/core/checks/github/rules/ghaNNN_<name>.pyexporting a top-levelRULE = Rule(...)and acheck(path, doc) -> Findingfunction. The orchestrator auto-discoversRULEand callscheckwith the parsed YAML document. - Add a mapping for the new ID in
pipeline_check/core/standards/data/owasp_cicd_top_10.py(and any other standard that applies). - Drop unsafe/safe snippets at
tests/fixtures/per_check/github/GHA-NNN.{unsafe,safe}.ymland add aCheckCaseentry intests/test_per_check_real_examples.py::CASES. - Regenerate this doc: