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.
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.