How Secrets Leak From CI/CD Logs — and Why Masking Doesn’t Save You

The masking illusion

Most teams treat GitHub Actions logs as safe by construction: paste a secret into a step, and GitHub greys it out as *** before anyone sees it. That behavior is real, but it’s a narrower guarantee than it looks — and in March 2025, an incident that touched tens of thousands of repositories showed exactly where it breaks.

What the research says about secrets on GitHub broadly

The scale problem predates CI specifically. A peer-reviewed NDSS 2019 study, “How Bad Can It Git? Characterizing Secret Leakage in Public GitHub Repositories” (Meli, McNiece, and Reaves, North Carolina State University), scanned real-time GitHub commits for six months and a snapshot covering 13% of all public repositories. It found secret leakage touching over 100,000 repositories, with thousands of new, unique secrets leaked every single day. CI/CD pipelines are one of the most efficient ways to turn that steady leak rate into a single high-value event, because a workflow’s whole job is to pull real credentials into a live process and use them.

Diagram: a secret is injected into a CI runner's environment, a build step transforms or encodes it, GitHub's substring-based masking fails to recognize the transformed value, and it prints into a build log that can be public or cached indefinitely.

Masking is substring matching, not a security boundary

GitHub’s own documentation is direct about the limitation. Its security hardening guide for GitHub Actions states plainly: “because there are multiple ways a secret value can be transformed, automatic redaction is not guaranteed.” Masking works by matching the exact literal string registered as a secret. Base64-encode it, URL-encode it, split it across two echo calls, or derive a new token from it, and the masking rule no longer recognizes the output — the transformed value prints in clear text unless a human thought to register that exact transformed string as a secret too. Structured output (JSON, XML, YAML) has the same problem when a secret is embedded inside a larger serialized blob.

A concrete case: tj-actions/changed-files

That gap stopped being theoretical in March 2025. Attackers compromised tj-actions/changed-files, a GitHub Action used by more than 23,000 repositories (CVE-2025-30066). The malicious version dumped the CI runner’s process memory, base64-encoded it, and printed it directly into the workflow’s public build log — a transformation specifically shaped to slide past substring-based masking, since the encoded blob doesn’t contain the literal registered secret string at all. Whatever credentials those workflows had injected — deploy keys, package-registry tokens, cloud credentials — were exposed to anyone who could read the log.

Separately, academic work on CI platform security (“Continuous Intrusion: Characterizing the Security of Continuous Integration Services,” Gu et al., IEEE S&P 2023) found systemic isolation and attack-surface issues across major CI integrations — the tj-actions incident is a specific, dated instance of a broader class of risk these platforms carry structurally, not an isolated fluke.

Where CI/CD sits in the leak surface

GitGuardian’s State of Secrets Sprawl 2026 puts a number on how much of this activity now targets CI specifically: analyzing recent supply-chain compromises, it found 59% of compromised machines were CI/CD runners, not personal developer workstations. Attackers have adjusted their targeting to where the highest concentration of live, usable credentials sits.

What actually helps

Registering every possible transformed variant of a secret with GitHub is not a scalable defense. Two changes move the needle more:

1. Minimize what any single runner can decrypt at once. A broad, shared .env baked into a workflow gives every job — and every compromised dependency of every job — access to every secret. envseal encrypts secrets individually and injects them at runtime, so a workflow can be scoped to decrypt only the keys that specific job actually needs, rather than a single shared blob standing behind everything.

2. Treat CI logs as a leak surface equal to source code. When a build fails, engineers routinely copy the log output — encoded secrets and all — into a Jira ticket or a Confluence runbook while debugging. That’s a second, independent leak path once the secret is already in the log, and it’s exactly the surface Secret Sentinel scans for inside Atlassian tools. Fixing the CI side doesn’t fix this side, and vice versa.

Frequently asked questions

Does GitHub Actions' secret masking prevent secrets from appearing in logs?

No. Masking is a substring match applied to raw log output after a job runs. GitHub's own documentation states that "because there are multiple ways a secret value can be transformed, automatic redaction is not guaranteed" — a base64-encoded, URL-encoded, or otherwise transformed copy of a secret will print in plain text unless that exact transformed string is separately registered as a secret.

What happened in the tj-actions/changed-files incident?

In March 2025, attackers compromised the popular tj-actions/changed-files GitHub Action, used by more than 23,000 repositories. The malicious version dumped CI runner process memory, base64-encoded the contents, and printed it into public build logs — exposing whatever secrets those workflows had injected. It's tracked as CVE-2025-30066 / GHSA-mrrh-fwg8-r2c3.

Is a cloud secrets manager enough to prevent this kind of leak?

Not by itself. The tj-actions compromise didn't steal secrets from storage — it abused a runner that already had legitimate, decrypted access. Any vault architecture that hands a long-lived, broadly-scoped decrypted secret to a CI job is exposed the moment that job's supply chain is compromised, regardless of how well the secret was stored beforehand.