Skip to content

Harden GitHub Actions workflows against CI/CD supply chain attacks #1854

@strickvl

Description

@strickvl

Context

A recent automated attack campaign (hackerbot-claw) targeted CI/CD pipelines across major open source repositories (Microsoft, DataDog, CNCF projects), achieving remote code execution in multiple targets and exfiltrating a GITHUB_TOKEN with write permissions from one repository with 140k+ stars.

I audited Evidently's GitHub Actions workflows against the specific attack vectors used in this campaign. No signs of compromise were found, but several hardening opportunities exist.

I'd be happy to open a PR for these fixes, but since some changes (particularly adopting Zizmor) would affect your CI/CD process, I thought it best to open an issue for discussion first.

Findings

1. Script Injection via ${{ }} in run: blocks (Medium-High)

Several workflows interpolate user-controlled values directly into shell commands, which enables script injection. An attacker can craft a branch name containing shell metacharacters to execute arbitrary commands.

Affected locations:

File Lines Expression Notes
main.yml 172, 384, 441 ${{ github.event.pull_request.head.ref }} in shell Branch name → shell injection
main.yml 274 ${{ github.event.pull_request.head.ref }} as CLI arg Branch name → argument injection
main.yml 78, 84, 90, 96-97 ${{ steps.changed-files.outputs.*_files }} in echo Filenames from PR → shell injection
deploy-artifacts-to-github-pages.yml 70 ${{ github.event.workflow_run.head_branch }} in shell Most concerningworkflow_run runs with base repo's full write permissions and secrets
docker.yml 16 echo ${{ secrets.DOCKER_PWD }} Secret expanded inline in shell

Fix: Assign ${{ }} values to environment variables, then reference them as "$VAR" in shell. Environment variables are treated as data, not code:

# Before (vulnerable):
run: |
  BRANCH_NAME="${{ github.event.pull_request.head.ref }}"

# After (safe):
env:
  BRANCH_NAME: ${{ github.event.pull_request.head.ref }}
run: |
  BRANCH_NAME="${BRANCH_NAME//\//-}"

2. Missing Top-Level permissions: (Medium)

main.yml, docker.yml, and examples.yml don't set a top-level permissions: key, so the GITHUB_TOKEN gets the repository default (typically write-all).

Fix: Add permissions: read-all at the workflow level and grant specific write permissions only to jobs that need them. release.yml already does this correctly per-job.

3. Unpinned Actions (Medium)

All actions use mutable tag references (@v4, @v5) rather than immutable SHA pins. If a third-party action's repository is compromised, a force-pushed tag would run malicious code in your CI.

Notable concerns:

  • actions/checkout@master in docker.yml — tracks a branch, not even a tag
  • tj-actions/changed-files@v42 — this third-party action was the subject of a real supply chain attack in March 2025 where it was backdoored to exfiltrate CI secrets

Recommendation: Adopt Zizmor (docs) — a static analysis tool purpose-built for GitHub Actions security. It can:

  • Detect all of the above issues (script injection, unpinned actions, permission problems)
  • Run as a GitHub Action in CI to catch regressions
  • Help generate SHA-pinned action references

You can also use Dependabot or Renovate to keep SHA-pinned actions up to date automatically.

4. Docker Secret Handling (Low)

docker.yml uses inline secret expansion for Docker login:

run: echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin

release.yml already uses docker/login-action@v3 for this — the manual workflow should do the same.

Summary

Issue Severity Effort
Script injection in deploy-artifacts-to-github-pages.yml Medium-High Low (env var refactor)
Script injection in main.yml Medium Low (env var refactor)
Missing permissions: read-all Medium Low (add one line per workflow)
Unpinned actions (especially tj-actions/changed-files) Medium Medium (adopt Zizmor + Dependabot)
actions/checkout@master in docker.yml Medium Trivial
Docker secret handling in docker.yml Low Low (use docker/login-action)

Happy to submit a PR for some or all of these if that would be helpful — just let me know which changes you'd prefer to handle internally.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions