Safeguard
Supply Chain Security

CI/CD pipeline hardening against supply chain attacks

23,000+ repos were exposed when tj-actions/changed-files was compromised in March 2025 — pinned SHAs and OIDC would have stopped it cold.

Safeguard Research Team
Research
7 min read

On March 14, 2025, an attacker retroactively rewrote every version tag on tj-actions/changed-files — from v1 through v45.0.7 — to point at a malicious commit, turning a GitHub Action used in more than 23,000 repositories into a credential harvester overnight. The injected code scanned CI runner memory and printed secrets straight into workflow logs, where anyone could read them on repositories with public log visibility. CISA issued a joint advisory four days later alongside a parallel compromise of reviewdog/action-setup (CVE-2025-30154), and the upstream maintainers shipped a fix in v46.0.1. What makes this incident worth studying two years later isn't the exotic payload — it's how ordinary the entry point was. Every affected workflow referenced the Action by a floating tag like uses: tj-actions/changed-files@v45, which meant a single tag rewrite silently changed what code every one of those pipelines executed on its next run, with no diff, no PR, and no review. This post walks through the concrete, mechanical changes — SHA pinning, least-privilege token scopes, and OIDC-based cloud auth — that convert a CI/CD pipeline from one that trusts whatever a dependency currently resolves to into one that only runs code it explicitly reviewed.

Why does pinning Actions to a commit SHA matter more than pinning to a version tag?

A git tag is just a mutable pointer, and anyone with push access to the upstream repository — including an attacker who has compromised a maintainer's account or npm-style publish token — can move it to a different commit at any time. That's exactly what happened to tj-actions/changed-files: every @v1, @v44, @v45, and dozens of other tags were repointed to the same malicious commit within roughly a day, and workflows that referenced those tags picked up the new code on their very next run without a single commit landing in the consuming repository. A commit SHA, by contrast, is a cryptographic hash of the exact tree contents — uses: tj-actions/changed-files@0e58ed8 (44 hex characters) cannot be silently repointed the way @v45 can. GitHub's own Actions security hardening documentation recommends pinning to a full-length SHA rather than a tag for precisely this reason. The trade-off is that SHA pins don't auto-update, so teams need a process — Dependabot or Renovate both support SHA-pinned Action updates — to periodically bump pins after reviewing the diff, rather than silently trusting whatever a tag currently means.

What does least-privilege scoping of GITHUB_TOKEN actually prevent?

By default, GitHub issues every workflow run a GITHUB_TOKEN scoped to the permissions configured for the repository, which historically defaulted to broad read/write access across issues, pull requests, contents, and packages. If a step in that workflow is compromised — through a malicious dependency, a poisoned Action, or a supply-chain attack like tj-actions — the attacker inherits whatever that token can do, not just what the workflow author intended it to do. The fix is to declare permissions explicitly at the top of the workflow file, defaulting to contents: read or even {}, and then granting a narrower write scope only on the specific job that needs it, such as pull-requests: write on a job that comments on PRs. GitHub changed the default token permissions for newly created repositories to read-only in 2023, but any repository created before that change, or any organization that hasn't audited its settings, may still be running with the old, broader default. A token scoped to contents: read cannot push a malicious commit or create a release even if the step that holds it is fully compromised — the blast radius is capped by the scope, not by hoping the compromise never happens.

Why is OIDC a stronger pattern than storing cloud credentials as repo secrets?

Long-lived cloud credentials stored as repository secrets — an AWS_SECRET_ACCESS_KEY or a GCP service account JSON key — are valid indefinitely until someone manually rotates them, and the tj-actions incident demonstrated exactly how they leak: a compromised step that can write to workflow logs can print any secret the job has access to, and GitHub's secret masking only redacts exact string matches, not values that have been base64-encoded, split, or reformatted before printing. GitHub's OpenID Connect (OIDC) integration replaces that stored secret with a short-lived token minted fresh for each workflow run: the job requests id-token: write permission, exchanges a GitHub-issued JWT for temporary cloud credentials via a trust policy configured in AWS IAM, GCP Workload Identity Federation, or Azure AD, and that token expires in roughly an hour whether or not anyone ever sees it. AWS, Google Cloud, and Microsoft Azure all publish official GitHub Actions guides for configuring OIDC trust relationships. Even in the worst case — a step that dumps process memory to logs — there is no static, indefinitely-valid secret sitting in repo settings for the attacker to steal, only a token that is already close to expiring.

What role do pull_request_target and fork-based workflows play in supply chain risk?

The pull_request_target trigger runs with the permissions and secrets of the base repository rather than the restricted, read-only token GitHub grants to workflows triggered by pull_request from a fork — a deliberate design choice so that trusted automation, like auto-labeling, can act on external contributions. The risk appears when a pull_request_target workflow also checks out and executes the fork's untrusted code, for example running actions/checkout against the PR head ref and then executing a build script from it: that combination hands the fork author execution with your repository's real secrets and write access. GitHub's own documentation on securing workflows flags this specific combination as one of the most common ways public repositories get compromised through external contributions, and the guidance is unambiguous — if a workflow needs to both react to fork PRs and run untrusted code, do it in two separate jobs, with the privileged job only ever consuming the artifact output of the sandboxed one, never its source directly.

Why do SBOM generation and provenance checks matter even after the other controls are in place?

SHA pinning, scoped tokens, and OIDC all reduce how much an attacker can do if they compromise a step, but none of them tell you, after the fact, whether a given build artifact actually corresponds to the source you reviewed. That's the gap SBOMs and build provenance close: a Software Bill of Materials generated at build time — in CycloneDX or SPDX format — records every dependency and its exact resolved version that went into a specific artifact, so if a CVE is disclosed in a transitive dependency next month, a team can query "which of our production artifacts contain this" in minutes instead of re-scanning every repository from scratch. Safeguard's CI/CD integration documents this pattern directly: a safeguard-sh/sbom-action step generates an SBOM on every GitHub Actions, GitLab CI, Jenkins, Azure DevOps, or CircleCI build, and a companion gate-check step can fail the pipeline on critical findings before an artifact ever reaches deployment. Generating that record on every single build, rather than as a periodic audit exercise, is what turns "we think our supply chain is clean" into an answer you can actually prove.

How Safeguard helps

Safeguard treats CI/CD hardening as something to enforce in the pipeline itself, not just document in a wiki. The SBOM-generation and security-gate steps described in Safeguard's CI/CD integration run on every build across GitHub Actions, GitLab CI, Jenkins, Azure DevOps, CircleCI, and Bitbucket Pipelines, producing a CycloneDX inventory and blocking deployment when a policy threshold — such as any critical-severity finding — is crossed. Because that SBOM is generated fresh on every build rather than sampled periodically, a team that has already adopted SHA pinning, least-privilege token scopes, and OIDC gets a fourth layer for free: proof, per artifact, of exactly what shipped, so that when the next tj-actions-style compromise makes headlines, the question "were we affected, and which builds" has an answer measured in minutes rather than days of manual repository archaeology.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.