Safeguard
DevSecOps

Hardening a Java build pipeline in GitHub Actions

23,000+ repos leaked CI secrets when tj-actions/changed-files was hijacked in March 2025. Here's how to pin, OIDC, and sign a Java pipeline against that.

Safeguard Research Team
Research
6 min read

In March 2025, an attacker compromised the GitHub Action tj-actions/changed-files and retroactively rewrote its version tags — including the widely pinned v35 and v44 — to point at a malicious commit that dumped CI runner memory straight into public build logs. Because more than 23,000 repositories referenced the action by a mutable tag rather than an immutable commit hash, any secret sitting in those runners' environment — cloud credentials, npm tokens, Maven repository passwords — was exposed to anyone who could read the log. GitHub tracked the incident as CVE-2025-30066; investigators later traced it back to an earlier compromise of reviewdog/action-setup@v1, tracked separately as CVE-2025-30154, which appears to have given the attacker their initial foothold days before the tj-actions tags were rewritten. CISA issued an advisory on March 18, 2025. The root cause traced back to a single compromised bot personal access token with write access to the action's repository — a reminder that the workflow file itself, not just your Java source, is part of your attack surface. This post walks through three concrete controls for a Java build pipeline in GitHub Actions: pinning every third-party action to a commit SHA, replacing long-lived cloud secrets with OpenID Connect, and signing build artifacts so a compromised dependency can't silently poison what you ship.

Why isn't pinning an action to a version tag enough?

Version tags like @v4 or @v44 are Git refs, and refs are mutable by design — anyone with push access to the action's repository (including an attacker who steals a maintainer's token, as happened with tj-actions/changed-files) can force-move that tag to point at different, malicious code without your workflow file changing at all. Pinning to a full 40-character commit SHA — uses: actions/checkout@8f4b7f84... instead of uses: actions/checkout@v4 — makes the reference immutable: the SHA is a hash of the exact content, so it cannot be silently repointed. GitHub's own security hardening guide for Actions recommends SHA-pinning third-party actions for this reason. The practical cost is that you no longer get automatic updates, so pair SHA-pinning with a bot (such as Dependabot or Renovate) that opens a PR with the new SHA and its diff whenever the action publishes a release, so a human reviews the change instead of trusting it blindly.

How does OIDC remove long-lived cloud secrets from a workflow?

GitHub Actions supports OpenID Connect so a workflow can request a short-lived, per-run credential from AWS, Azure, GCP, or HashiCorp Vault instead of reading a static access key out of repository secrets. The workflow requests a JSON Web Token signed by GitHub, presents it to the cloud provider's identity federation endpoint, and receives a credential scoped to that single job run — nothing is stored, nothing needs rotation, and nothing survives the runner shutting down. Enabling it requires explicitly granting the id-token: write permission in the workflow, and by default GitHub withholds that write-scoped token from workflows triggered by a pull request coming from a fork, unless a repository admin has explicitly opted in to sending write-scoped tokens to fork PRs — which closes off a common path for stealing credentials via a malicious fork PR. For a Java pipeline that needs to push artifacts to an S3-backed Maven repository or pull secrets from AWS Secrets Manager during a Maven or Gradle build, OIDC federation means there is no AWS_ACCESS_KEY_ID sitting in repo secrets for an attacker to exfiltrate in the first place — see the memory-dump mechanics of CVE-2025-30066 above for why that matters.

What does keyless artifact signing actually protect against?

Keyless signing with Sigstore protects the integrity of your build output even if a step earlier in the pipeline is compromised, by cryptographically binding the signature to the exact workflow identity that produced it rather than to a key someone could steal. Sigstore's Fulcio certificate authority issues a short-lived certificate — valid for minutes — to an ephemeral signing key, and that certificate embeds the verified OIDC identity of the GitHub Actions workflow that requested it (repository, workflow file, and ref). The signing event is recorded in Rekor, Sigstore's public, append-only transparency log, so anyone can later confirm exactly which workflow run produced a given JAR or container digest. There is no long-lived private signing key in a secrets vault to leak, rotate, or forget about. For a Java build, this means cosign sign (or an equivalent step wired to your JAR, WAR, or container image) run inside the same GitHub Actions job that built the artifact leaves a durable, publicly verifiable record that this specific commit, on this specific workflow, produced this specific SHA-256 digest.

How does SLSA provenance fit alongside signing?

SLSA (Supply-chain Levels for Software Artifacts) provenance is the structured metadata — build system, source commit, build steps, and materials consumed — that a signature alone doesn't carry; the signature proves who signed, provenance proves what was actually built. A SLSA Provenance v1 attestation generated in the same job as your mvn package or ./gradlew build step records the exact GitHub Actions workflow, runner type, and inputs used, then gets signed and published to Rekor alongside the artifact signature. Consumers — your own deployment pipeline, or a downstream team pulling your library — can then require a minimum SLSA level before accepting an artifact, rejecting anything unsigned or built by an unrecognized workflow. This is what turns "we signed it" into "we can prove exactly how it was built," which matters when triaging whether a compromised action like tj-actions/changed-files could have tampered with a release that shipped before the fix in v46.0.1.

How Safeguard helps

Safeguard's attestation pipeline signs every artifact your GitHub Actions builds produce using Sigstore keyless signing by default — Fulcio issues a short-lived certificate bound to your workflow's GitHub OIDC identity, and every signature is published to Rekor for public or private transparency-log verification. Alongside the signature, Safeguard generates a SLSA Provenance v1 attestation, a CycloneDX or SPDX SBOM, and a vulnerability-scan snapshot for the same build, all in in-toto v1 format, so a Java JAR or container image ships with a complete, independently verifiable record of what it is and how it was built. On the consumption side, Safeguard's admission controller can require a minimum SLSA level and a signer identity allow-list before deploying an artifact, and safeguard verify gives you the same check from the command line for vendor or third-party dependencies. Combined with build-log secrets scanning that flags credentials leaking into CI output — the exact failure mode of the tj-actions/changed-files compromise — this gives a Java pipeline defense in depth against both a hijacked action and a badly stored credential.

Never miss an update

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