Skip to content

This tool is not affiliated with, endorsed by or sponsored by GitHub, Inc. or Microsoft Corporation. GitHub and GitHub Actions are trademarks of GitHub, Inc. Other names are trademarks of their respective owners.

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.

Published on 7 min 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

RouteWhat happensWhere it shows
Workflow pushed to a new branchAttacker pushes a modified .github/workflows/*.yml to a fresh branch. A push trigger runs it with the repository's secretsgit.push + workflows.created_workflow_run with a new head_branch
pull_request_target / workflow_run misuseUntrusted PR code runs in a privileged contextRuns triggered by PRs from forks, see GitHub Security Lab on "pwn requests"
Compromised third-party actionA tag you use now points to malicious codeEvery run that used the tag, with the same odd output
Self-hosted runnerSecrets and tokens read from a persistent hostSee self-hosted runner security
Environment protection removedRequired reviewers or branch policies deleted, so environment secrets become reachableenvironment.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

  1. List every run of the modified workflow, and of any workflow on the attacker's branches, with the workflow_run_id values from the audit log.
  2. 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 env sends everything.
  3. Delete the logs of runs that printed secrets, after archiving them for the case, so the encoded blobs stop being readable in the UI.
  4. Remove the attacker branches and review .github/workflows on every remaining branch.
  5. 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_TOKEN permission 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 articles

Rogue or compromised GitHub self-hosted runners: the audit log events to check, host evidence in _diag, and how to scope and rebuild after an incident.
A fictional GitHub supply chain attack investigated end to end: stolen PAT, 38 repos cloned, AWS keys printed by a workflow, branch protection removed.
AWS, Google Cloud or Azure keys leaked from GitHub Actions or a repo: contain the key, trace its use in the cloud audit logs, then replace keys with OIDC.

This tool is not affiliated with, endorsed by or sponsored by GitHub, Inc. or Microsoft Corporation. GitHub and GitHub Actions are trademarks of GitHub, Inc. Other names are trademarks of their respective owners.