On 8 October, in about two hours, someone pushed a workflow file called github_actions_security.yml into more than 30,000 GitHub repositories. The commits came from the real accounts of roughly 1,900 developers, using stolen tokens.
This is GhostAction's third wave. GitGuardian first found it in September 2025, saw it return on 31 August 2026, and then watched the October burst. GitHub started blocking the malicious workflow on 9 October, so the later waves on 10 and 12 October didn't execute. But the earlier ones did, and one detail in GitGuardian's write-up should worry every team: some October victims had also been hit in 2025 and in August. Their secrets were never rotated.
I wrote a small script that checks a repo for the campaign's traces and lists every secret sitting in your git history, which is exactly what the new payload hunts for. It's below, tested, standard library only. First, how the attack works, because the fix depends on it.
How GhostAction works
- Get a token. Attackers use GitHub personal access tokens and credentials from infostealer logs and old dumps.
- Push a workflow as you. The commit uses the victim's own identity. Names seen so far:
github_actions_security.yml("Add Github Actions Security workflow"),security-check.yml("Add security check workflow") and, per Socket,security-audit.yml. In 92 cases it modified an existing workflow instead. - Steal what CI can see. The job, named
send-secrets, lists the secret names it found in your real workflows and POSTs their values with onecurlto a server. In 2026 that server was193.32.204.199, with batch tags like/?c=blingand/?c=blingnew. - New in October: dig through history. The payload now greps the working tree and git history with 11 secret patterns: AWS key IDs, GitHub and GitLab tokens, AI provider keys and other cloud keys. Version 2 grabbed two lines either side of every
AKIA/ASIAmatch, so it could pair a key ID with its secret. Version 3 raised the history limit to 200,000 lines.
That last step is the important change. A key you deleted from config.js in 2023 is still in history, and the payload now reads history.
GitGuardian's numbers on what got targeted in the 2026 wave, by secret name: 446 SSH and deployment credentials, 218 Azure, 142 container registry, 112 database, 106 AWS, 92 FTP, 80 Google Cloud and Firebase, 66 GitHub tokens, plus Slack, Telegram, Discord, Cloudflare, npm, PyPI and AI provider keys.
And the cleanup has been poor. Only 16% of affected repos had actually cleaned the malicious commits out of public history by 5 October.
The script
ghostaction_audit.py does five things:
- Looks at current workflow files for GhostAction file names, the exfiltration endpoints,
send-secretsjobs andtoJSON(secrets)dumps. - Searches every commit message on every branch for the campaign's commit messages.
- Lists every workflow file ever added, with author and commit, so you can spot one you didn't write.
- Streams
git log -ponce and finds secrets in added lines: the same families the payload hunts for. - Flags hardening gaps: no
permissions:block,pull_request_target, actions not pinned to a commit SHA.
#!/usr/bin/env python3 """ghostaction_audit.py: check a git repo for GhostAction traces and leaked secrets. Usage: python3 ghostaction_audit.py [path-to-repo] [--no-history] Exit 2 if a GhostAction indicator is found, 1 if secrets are found, else 0.""" import re, subprocess, sys from collections import defaultdict from pathlib import Path IOC_FILES = {"github_actions_security.yml", "security-check.yml", "security-audit.yml"} IOC_COMMIT_MSGS = [ "add github actions security workflow", "update github actions security workflow", "add security check workflow", "chore(ci): github security actions", ] IOC_CONTENT = [ r"193\.32\.204\.199", r"170\.39\.218\.2\b", r"45-139-104-115\.plesk\.page", r"carte-avantage\.com", r"\.oast\.fun", r"/api/workflow/receive", r"[?&]inj=", r"/\?c=(bling|blingnew|monami|new|mnsft|security)\b", r"^\s*send-secrets\s*:", r"toJSON\(\s*secrets\s*\)", ] SECRET_PATTERNS = { "AWS access key ID": r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b", "GitHub token": r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{60,}\b", "GitLab token": r"\bglpat-[A-Za-z0-9_-]{20,}\b", "Anthropic key": r"\bsk-ant-[A-Za-z0-9_-]{20,}\b", "OpenAI key": r"\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b", "Google API key": r"\bAIza[0-9A-Za-z_-]{35}\b", "Slack token": r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b", "npm token": r"\bnpm_[A-Za-z0-9]{36}\b", "PyPI token": r"\bpypi-AgE[A-Za-z0-9_-]{50,}\b", "Private key": r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----", } IOC_RE = [re.compile(p, re.I | re.M) for p in IOC_CONTENT] SECRET_RE = {k: re.compile(v) for k, v in SECRET_PATTERNS.items()} def git(repo, *args): out = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, errors="replace") if out.returncode != 0: sys.exit(f"git {' '.join(args)} failed: {out.stderr.strip()}") return out.stdout def redact(s): return s[:6] + "..." + s[-4:] if len(s) > 14 else s[:4] + "..." def check_tree_workflows(repo, findings): wf_dir = repo / ".github" / "workflows" if not wf_dir.is_dir(): return [] files = sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))) for f in files: text = f.read_text(errors="replace") rel = f.relative_to(repo) if f.name in IOC_FILES: findings["ioc"].append(f"{rel}: file name matches GhostAction ({f.name})") for rx in IOC_RE: m = rx.search(text) if m: findings["ioc"].append(f"{rel}: contains '{m.group(0).strip()}'") if not re.search(r"^permissions\s*:", text, re.M): findings["harden"].append(f"{rel}: no top-level 'permissions:' block (token gets default scope)") if re.search(r"^\s*pull_request_target\s*:", text, re.M) or "pull_request_target" in text.split("jobs:")[0]: findings["harden"].append(f"{rel}: uses pull_request_target (runs with secrets on fork PRs)") for m in re.finditer(r"uses:\s*([\w.-]+/[\w./-]+)@([\w.-]+)", text): action, ref = m.groups() if not re.fullmatch(r"[0-9a-f]{40}", ref) and not action.startswith("./"): findings["harden"].append(f"{rel}: {action}@{ref} is not pinned to a commit SHA") return files def check_commits(repo, findings): log = git(repo, "log", "--all", "--format=%H%x09%an <%ae>%x09%ad%x09%s", "--date=short") for line in log.splitlines(): sha, author, date, subject = (line.split("\t", 3) + ["", "", ""])[:4] if any(msg in subject.lower() for msg in IOC_COMMIT_MSGS): findings["ioc"].append(f"commit {sha[:10]} {date} by {author}: '{subject}'") added = git(repo, "log", "--all", "--diff-filter=A", "--name-only", "--date=short", "--format=@@%H%x09%an <%ae>%x09%ad", "--", ".github/workflows") cur = None for line in added.splitlines(): if line.startswith("@@"): cur = line[2:].split("\t") elif line.strip() and cur: name = Path(line).name tag = " <-- GhostAction name" if name in IOC_FILES else "" findings["new_wf"].append(f"{cur[2]} {line} added by {cur[1]} ({cur[0][:10]}){tag}") if name in IOC_FILES: findings["ioc"].append(f"history: {line} added in {cur[0][:10]} by {cur[1]}") def scan_history(repo, findings): """Stream `git log -p` once; check added lines for secrets and IOC strings.""" proc = subprocess.Popen(["git", "-C", str(repo), "log", "--all", "-p", "--no-color", "--format=@@COMMIT %H"], stdout=subprocess.PIPE, text=True, errors="replace") sha, path, seen = None, None, set() for line in proc.stdout: if line.startswith("@@COMMIT "): sha = line.split()[1][:10] elif line.startswith("+++ b/"): path = line[6:].rstrip("\n") elif line.startswith("+") and not line.startswith("+++"): body = line[1:] for kind, rx in SECRET_RE.items(): for m in rx.finditer(body): key = (kind, m.group(0)) if key in seen: continue seen.add(key) findings["secrets"][kind].append(f"{redact(m.group(0))} first seen in {path} @ {sha}") if path and path.startswith(".github/workflows/"): for rx in IOC_RE: m = rx.search(body) if m: findings["ioc"].append(f"history: {path} @ {sha} contained '{m.group(0).strip()}'") proc.wait() def main(): args = [a for a in sys.argv[1:] if not a.startswith("--")] repo = Path(args[0] if args else ".").resolve() if not (repo / ".git").exists(): sys.exit(f"{repo} is not a git repository") findings = {"ioc": [], "harden": [], "new_wf": [], "secrets": defaultdict(list)} files = check_tree_workflows(repo, findings) check_commits(repo, findings) if "--no-history" not in sys.argv: scan_history(repo, findings) findings["ioc"] = list(dict.fromkeys(findings["ioc"])) print(f"Repo: {repo}\nWorkflow files now: {len(files)}\n") print("== 1. GhostAction indicators ==") print("\n".join(" !! " + x for x in findings["ioc"]) or " none found") print("\n== 2. Workflow files ever added (review anything you don't recognise) ==") print("\n".join(" " + x for x in findings["new_wf"][:25]) or " none") print("\n== 3. Secrets found in git history (rotate ALL of these, deleting the file is not enough) ==") if findings["secrets"]: for kind, hits in findings["secrets"].items(): print(f" {kind}: {len(hits)}") for h in hits[:10]: print(" " + h) else: print(" none found by these patterns (not proof there are none)") print("\n== 4. Hardening gaps ==") print("\n".join(" - " + x for x in findings["harden"]) or " none") if findings["ioc"]: print("\nACTION: revoke the GitHub token or session that pushed the workflow first, " "then rotate every secret above and in repo settings.") sys.exit(2) sys.exit(1 if findings["secrets"] else 0) if __name__ == "__main__": main() Run it from anywhere:
python3 ghostaction_audit.py ~/code/my-repo To check every repo in a folder:
for d in ~/code/*/; do python3 ghostaction_audit.py "$d" > /dev/null 2>&1; echo "$? $d"; done | sort -r Exit code 2 means a GhostAction indicator, 1 means secrets in history, 0 means clean.
Testing it
I built a test repo that replays the attack: a normal ci.yml, a fake AWS key and a fake GitHub token committed and then deleted, and a GhostAction-style workflow committed and then removed again (what a hurried cleanup looks like).
== 1. GhostAction indicators == !! commit 23addef113 2026-10-17 by Dev <dev@example.com>: 'Add Github Actions Security workflow' !! history: .github/workflows/github_actions_security.yml added in 23addef113 by Dev <dev@example.com> !! history: .github/workflows/github_actions_security.yml @ 23addef113 contained 'send-secrets:' !! history: .github/workflows/github_actions_security.yml @ 23addef113 contained '193.32.204.199' !! history: .github/workflows/github_actions_security.yml @ 23addef113 contained '/?c=bling' == 3. Secrets found in git history (rotate ALL of these, deleting the file is not enough) == AWS access key ID: 1 AKIAQ3...KE12 first seen in config.js @ 30d823985c GitHub token: 1 ghp_a1...a1B2 first seen in config.js @ 30d823985c == 4. Hardening gaps == - .github/workflows/ci.yml: no t...