Supply Chain Security

GhostAction's Fake Audit Workflow Reads the Secrets You Deleted From Git History

Dark cyberpunk illustration of a long corridor of tall metal archive drawers at night on a glowing cyan grid floor, with one drawer pulled open and amber light and embers spilling out of it.

Deleting a credential from a repository does not remove the credential. It moves the credential into git history, where git log -p --all still reads it on any runner that checks the repository out at full depth. The GhostAction campaign that swept GitHub this month is built on that fact, and it is the part of the story most of the coverage skipped.

Three research teams published on the same activity within 48 hours. GitGuardian counted the injection in 772 public repositories belonging to 373 users and organizations between August 31 and September 30, aimed at 2,577 secrets. StepSecurity traced an October 8 burst in which two compromised maintainer accounts pushed the same workflow into 345 repositories, 27 in one window and 318 in a sixteen-minute sweep. Socket reported more than 500 accounts committing the file to what it described as tens of thousands of repositories since October 7.

Whether this one is yours

This affects you if you own a GitHub organization or personal account where Actions is enabled, where maintainers hold personal access tokens, and where any repository has ever had a credential committed to it. The last clause is the one that catches people. The campaign reads your Actions secrets, and then it reads every string in your git history that looks like a key, including the ones a developer committed in 2023 and removed in the next push.

You can stop reading if your code lives on self-hosted GitLab or Bitbucket with no GitHub mirror, or if your GitHub repositories have Actions disabled at the organization level. There is one exception worth thirty seconds of your time: forks. Of the 318 repositories in the October 8 sweep, 279 were forks in a single namespace. A fork you created of an affected upstream can inherit the workflow when it syncs, and if Actions is enabled on your copy, your next push runs the harvester in your account context. Socket's assessment was that private forks and downstream mirrors are the most exposed.

For everyone else, the practical reading of all three reports is the same: treat any repository that gained a workflow named security-audit.yml, github_actions_security.yml, or security-check.yml since August 31 as compromised, and work from there.

What the audit workflow actually collects

The file arrives as a commit titled "Add security audit workflow," unsigned, pushed straight to the default branch under a maintainer's identity with no pull request. It performs no audit. The two observed variants differ in job name - audit in the newer one, send-secrets in the older - and share a trigger block that is the cleanest detection signal in the whole campaign.

on:
  workflow_dispatch:
  push:                     # no branches:, no tags:, no paths: filter

jobs:
  audit:                    # older variant: send-secrets
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0    # every branch and tag, on the runner

An unfiltered push fires on any branch and any tag, so the job runs on work that has nothing to do with the attacker. workflow_dispatch lets the attacker run it by hand from a stolen session. And fetch-depth: 0 exists for one reason: a shallow checkout gives the history sweep nothing to read.

Four collection steps in one job

  • Named Actions secrets. The attacker scanned each repository's existing workflows first, then templated that repository's own secret names into the payload. In the pyxel case the named targets included the PyPI and crates.io publishing credentials.
  • A working-tree sweep across thirteen credential patterns: AWS access key IDs and secret keys and session tokens, Anthropic sk-ant-, OpenAI sk-proj-, OpenRouter sk-or-, GitHub ghp_ and github_pat_, GitLab glpat-, Google and Firebase AIza, Slack xox tokens, and SendGrid SG. keys.
  • The same thirteen patterns against full history via git log -p --all, capped at 200,000 lines. This is the step that recovers credentials your team already rotated out of the working tree but never revoked.
  • AWS pair reconstruction. Around every access key ID hit the payload grabs roughly two lines of context on each side, which is usually enough to carry the matching secret access key along with it.

Collected data goes out as a single HTTP POST with curl to a hard-coded IP address, 193.32.204.199, on port 80 and port 3000. A raw IP over plain HTTP generates no DNS query, which removes the log source most small teams actually have. The request carries its own verdict in the URL: /?c=monami means named Actions secrets were taken, /?c=new means only the tree and history sweep ran. Body markers are REPO=, AKIA_CTX_START, and AKIA_CTX_END. Earlier waves used 45.139.104.115, 170.39.218.2, and *.oast.fun subdomains, so historical proxy logs are worth the same grep.

One detail deserves emphasis because it survives a clean result: every run reports the repository identifier whether or not it finds a credential. Socket's conclusion was that the operator holds a map of reachable execution contexts independent of any theft. A run that found nothing still told the attacker which of your repositories will execute arbitrary YAML on demand.

Find it in your own organization in ten minutes

GitHub code search covers public repositories by marker, and the API covers the rest. Run all three, in this order, with gh authenticated against your organization.

# 1. The campaign markers, scoped to your org
gh search code --owner YOUR_ORG "193.32.204.199" --json path,repository
gh search code --owner YOUR_ORG "AKIA_CTX_START" --json path,repository
gh search code --owner YOUR_ORG "c=monami"       --json path,repository

# 2. Anything that touched .github/workflows since the campaign began,
#    whatever the file is called
for repo in $(gh repo list YOUR_ORG --limit 500 --json nameWithOwner -q '.[].nameWithOwner'); do
  gh api "repos/$repo/commits?path=.github/workflows&since=2026-08-31T00:00:00Z" \
    --jq ".[] | \"$repo \(.sha[0:8]) \(.commit.author.date) \(.commit.message | split(\"\n\")[0])\"" \
    2>/dev/null
done

# 3. For any repo that comes back: did the workflow actually run?
gh api "repos/YOUR_ORG/REPO/actions/runs?per_page=100" \
  --jq '.workflow_runs[]
        | select(.path | test("security-audit|github_actions_security|security-check"))
        | "\(.created_at) \(.status) \(.conclusion) \(.html_url)"'

Step 3 is the one that sets your severity. A run with conclusion success means the POST completed, and both StepSecurity and Socket say to treat that as confirmed exfiltration of the named secrets rather than a maybe. In the uber/athenadriver run the job fetched 13 branches and 20 tags and the collector answered OK four seconds after the run started. Scale check from the same report: as of October 9, GitHub code search still returned roughly 378 repositories carrying the live workflow on their default branch, 182 of them with the history-mining marker and 88 with the secrets marker, and that count excludes forks entirely.

What rotation has to cover

GitGuardian's cleanup numbers are the uncomfortable part of this campaign. As of October 5, 124 of the 772 affected repositories - 16 percent - had been effectively cleaned. In 92 cases the attacker came back and edited a workflow left over from an earlier wave, repointing it at the new collector. A half-finished cleanup is an invitation.

Three things have to happen, and the order matters:

  • Revoke the injection credential first. The personal access token or session that wrote the workflow is the access path. Rotating your Actions secrets while that token still works buys you nothing, and GitGuardian makes the same point.
  • Rotate on the history, not the secrets page. Pull the list of every credential pattern that has ever appeared in git log -p --all and revoke each one at the provider. A key removed from the working tree three releases ago was still in scope for this payload.
  • Delete the workflow from every branch and tag, not just the default branch. The trigger has no branch filter, so one forgotten topic branch is enough to fire it again.

Two caveats on evidence. StepSecurity observed that the AWS secret-key regex in the payload uses a non-capturing group POSIX grep does not support, so that pattern under-matches in practice; a run that returned no AWS secret key is not proof your AWS keys are safe. And no malicious package versions had been published from this activity as of either report, which means the publishing tokens for affected projects are exposed rather than used. Hold releases until the rotation is finished.

Three controls that stopped it where it ran

This campaign has a clean control story, which is rare. In typecho-fans/plugins the attacker pushed an empty commit and then a README edit to re-trigger the workflow. Both runs stalled at action_required, because that repository requires approval before workflow runs execute. No exfiltration. One setting.

  • Require approval for workflow runs. The fork-approval and first-time-contributor gates in GitHub's run-approval settings are what held here. Set the strictest option your release cadence tolerates.
  • Protect the workflow path. A repository ruleset requiring pull-request review for changes under .github/workflows/ turns a silent direct push into a reviewable event. Every injection in this campaign bypassed pull requests.
  • Stop storing long-lived cloud keys as Actions secrets. OIDC federation issues a short-lived token at job runtime, so there is no static AWS or Azure credential in the secrets store for a fake audit job to read. This is the control that changes what a successful run is worth.

Add egress control on the runner if you have the budget for it. A block-by-default policy with an endpoint allowlist, which is what StepSecurity's own Harden-Runner action implements, turns a POST to a hard-coded IP into a failed connection and a log line. On a small team, the cheaper version of the same idea is a scheduled grep of proxy and runner logs for the four addresses above.

Rotate the secrets you already deleted

The judgment call I would make this week, if any of my repositories came back from step 1: assume the history sweep ran, and build the rotation list from git log -p --all rather than from the Actions secrets page. The secrets page is the shorter list, and it is the list that flatters you. Every credential that was ever committed and then quietly deleted belongs on the longer one, and the providers that issued them are the only place they can actually be killed.

Then take the write path away. One ruleset on .github/workflows/, run approval switched on, and OIDC for the cloud credentials, and the next maintainer account that gets phished produces a pull request somebody reviews instead of a harvester that runs on your next commit.

Need a second set of eyes on your build pipeline?

We review source-control and CI/CD configurations for the credential exposure and workflow write paths this campaign used, and we help teams move cloud secrets onto short-lived federated tokens. Book a session to walk through your organization.