GitHub Actions secrets leak: investigating exfiltration
How GitHub Actions secrets leak despite *** masking: poisoned workflows, new branches, encoded output and third-party actions. The evidence to collect and read.
TL;DR. A workflow can read every secret in its scope, and masking only hides the exact value. echo "$KEY" | base64 | base64 prints it in clear. Attackers get there by pushing a modified workflow to a new branch, by abusing pull_request_target, or by compromising a third-party action (tj-actions/changed-files, 2025). The evidence: workflows.created_workflow_run on an unseen branch in the audit log, plus the run log archive, where the step script and the encoded blob are both visible. Download the archives before anyone deletes them, then rotate every secret the workflow could read.
GitHub Actions is where source code turns into cloud access. The workflows that deploy to production hold AWS keys, registry tokens and signing keys as secrets. For an attacker who has a token with the workflow scope, or a foothold in an action you depend on, exfiltrating those secrets is often the actual goal.
How secrets reach the attacker
| Route | What happens | Where it shows |
|---|---|---|
| Workflow pushed to a new branch | Attacker pushes a modified .github/workflows/*.yml to a fresh branch. A push trigger runs it with the repository's secrets | git.push + workflows.created_workflow_run with a new head_branch |
pull_request_target / workflow_run misuse | Untrusted PR code runs in a privileged context | Runs triggered by PRs from forks, see GitHub Security Lab on "pwn requests" |
| Compromised third-party action | A tag you use now points to malicious code | Every run that used the tag, with the same odd output |
| Self-hosted runner | Secrets and tokens read from a persistent host | See self-hosted runner security |
| Environment protection removed | Required reviewers or branch policies deleted, so environment secrets become reachable | environment.remove_protection_rule, environment.delete |
ATT&CK now has a technique for this family, T1677 Poisoned Pipeline Execution. The poisoned pipeline execution glossary entry explains the direct and indirect variants.
A public example
In March 2025, CISA issued an alert about the compromise of tj-actions/changed-files (CVE-2025-30066) and the related reviewdog/action-setup (CVE-2025-30154). Version tags of the action were changed to point to malicious code that dumped CI secrets into the workflow logs. In public repositories, anyone can read those logs. Every organization that used a moved tag had to check its run logs for the affected period. The detection question was: which of our runs printed an encoded blob in that step?
Why masking is not a control
GitHub replaces registered secret values with *** in logs. The secure use reference is explicit: redaction is not guaranteed, and a transformed secret (Base64, URL-encoded) must be registered separately to be masked. So:
echo "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" | base64 -w0 | base64 -w0
prints a long string that no mask matches. Decode it twice and you have the key pair. Variants include xxd, rev, od, splitting the value, toJSON(secrets) or sending the value straight out with curl -d to a request-capture service. The secret masking glossary entry summarises what masking covers.
The evidence, in order of value
1. The run log archive
Repository → Actions → run → ⋯ → Download log archive. The ZIP has one combined log per job and one file per step. It contains the step's script (the ##[group]Run … block echoes the commands) and its output. That is the only place where you can see what the workflow did with a secret. Logs are retained for 90 days by default, and anyone with write access can delete them. Download them first.
The analyzer reads these ZIPs directly. Two rules apply to them:
actions-secret-exfil(critical): a script line that mentions a secret, token, key, env or$variable and pipes it through an encoder (base64,xxd,rev,od), dumps the environment (printenv,env |,toJSON(secrets)), or sends data to an external endpoint (curl -d,wget --post,/dev/tcp/, common request-capture and tunnelling services). Decoding only (base64 -d) without a network call is excluded to limit false positives. If the line names AWS, Google Cloud or Azure credentials, the finding shows the cloud next hop.actions-encoded-output(medium): an output line that contains an unbroken base64 or hex token of 60 characters or more. This is what a double-encoded key looks like.
Each log line appears twice in an archive (combined file and per-step file). The analyzer deduplicates, so one malicious step gives one finding.
2. The workflow run events in the audit log
workflows.created_workflow_run and workflows.completed_workflow_run carry repo, name (the workflow), event (push, pull_request, workflow_dispatch…), head_branch, head_sha, workflow_run_id and the actor. The workflow-new-branch rule (low) flags a workflow that runs on a branch it has never run on, for push and workflow_dispatch triggers. It is noisy on its own, since every feature branch triggers it. It becomes significant when the actor or token has other findings, or when the branch name looks like maintenance (ci/cache-fix, dependabot-fix) and the branch was deleted afterwards.
3. Git events
git.push just before the run, from the same token and IP, ties the workflow change to the credential. Without Git events you still have head_sha, which you can look up in the repository if the branch still exists.
4. Secret metadata changes
repo.create_actions_secret, org.update_actions_secret and environment.create_actions_secret (rule actions-secret-created, low) are routine CI work. They matter when the actor is suspicious. An attacker may replace a deployment secret to redirect artefacts. Note what the audit log does not record: a workflow reading a secret is not an event.
Scoping and remediation
- List every run of the modified workflow, and of any workflow on the attacker's branches, with the
workflow_run_idvalues from the audit log. - Rotate every secret the workflow could read: repository, organization (if the repository has access) and environment secrets. Include the ones that were not printed. A script that runs
envsends everything. - Delete the logs of runs that printed secrets, after archiving them for the case, so the encoded blobs stop being readable in the UI.
- Remove the attacker branches and review
.github/workflowson every remaining branch. - Follow the keys into AWS, Google Cloud or Azure: see leaked cloud keys.
Hardening that actually reduces exposure
- Pin third-party actions to a full commit SHA. GitHub calls it "the only way to use an action as an immutable release".
- Set the default
GITHUB_TOKENpermission to read-only and grant write per job. - Protect deployment secrets with environments that require reviewers and restrict branches. The analyzer flags when that protection is removed (
environment-protection-removed, high). - Replace stored cloud keys with OIDC federation. A short-lived token that is scoped to a repository and branch is worth far less to an attacker.
- Require CODEOWNERS review on
.github/workflows/and protect it with a repository ruleset. - Use the OpenSSF Scorecard checks (token permissions, pinned dependencies, dangerous workflow) to find risky patterns.
FAQ
Can GitHub Actions secrets leak even though they are masked as ***?
Yes. Masking replaces the exact secret value in logs. A script that base64-encodes it, reverses it or splits it prints a string that no longer matches, so it is shown in clear and can be decoded. GitHub's own documentation warns that redaction is not guaranteed for transformed secrets.
Does the audit log show when a workflow reads a secret?
No. The audit log records secrets being created, updated or removed, and workflow runs being created and completed. Reading a secret during a run is not an audit event, so the run log is the evidence of what a step did with it.
Related
- Leaked cloud keys in GitHub Actions: the cloud pivot
- Branch protection disabled? Auditing GitHub rulesets
- The fictional northwind-labs walkthrough: a double-base64 AWS key in a run log
- OWASP Top 10 CI/CD Security Risks