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 top-level 'permissions:' block (token gets default scope)
- .github/workflows/ci.yml: uses pull_request_target (runs with secrets on fork PRs)
- .github/workflows/ci.yml: actions/checkout@v4 is not pinned to a commit SHA
Both "deleted" keys show up, and so does the workflow that was deleted from the current tree. Exit code 2.
Then I ran it on a real project, a full clone of psf/requests with 6,736 commits. It took 3.2 seconds. No GhostAction traces, and no hardening gaps: every workflow there has a permissions: block and pins actions to a full SHA, which is a good model to copy. It found one "private key", a test certificate under tests/certs/. That's a false positive you judge by path, and a reminder that the script tells you where to look, not what to panic about.
If you find something: the order matters
GitGuardian's advice, and my notes on it:
- Revoke the GitHub credential that pushed the workflow first. Look at the commit's author, then check that user's personal access tokens, OAuth apps and active sessions. If you rotate your AWS keys but the attacker still holds the GitHub token, they push the workflow again next week. That's exactly what happened to the repeat victims.
- Rotate every secret the workflow could see. That means every secret in repo and org settings that your workflows reference, not only the ones you think were used.
- Rotate every secret in section 3 of the output. History is now part of the attack surface. Removing a file does nothing for a key that's still in history.
-
Then clean history if the repo is public, with
git filter-repo, and tell people to re-clone. Do this last. Cleaning history doesn't un-leak a key. - Check the Actions tab. Look for runs of workflows you don't recognise around 8 and 9 October, or around 31 August to 30 September for the earlier wave.
Hardening that would have stopped most of this
-
Fine-grained tokens with expiry, scoped to the repos they need. A classic PAT with
repoandworkflowscope that never expires is the master key this campaign runs on. -
Require approval for workflow changes. Protect the default branch and add a CODEOWNERS rule for
.github/workflows/, so a new workflow can't run without review. -
Set
permissions:at the top of every workflow, starting fromcontents: read, and widen only per job. - Use OIDC for cloud access instead of long-lived AWS or Azure keys in secrets. There's nothing static to steal.
- Pin third-party actions to a commit SHA. Tags can be moved; SHAs can't.
- Run secret scanning on history, not just new commits. The attacker does.
The bigger lesson
GhostAction isn't clever. It uses old tokens, a believable file name and one curl. It works because tokens don't expire, secrets don't get rotated and history never forgets. The October payload just made the last part explicit.
If you are raising money or selling a company, this is also the kind of thing an investor's technical due diligence will look for: long-lived keys in history, unpinned actions, CI with write access everywhere. We run those reviews at Axionry, and fix what they find. If you want a second pair of eyes on your CI before someone else's script finds it, that's what we do.
Sources: GitGuardian, "GhostAction returns" (updated 9 and 13 October 2026) and the original GhostAction report (September 2025); The Hacker News, 15 October 2026; Socket's 8 October report as cited by GitGuardian. Script tested on Python 3 with git 2.x; test output above is from a real run.
Top comments (0)