This script is a response to the Miasma Worm, a supply-chain attack that targets AI coding agents operating on GitHub repositories.
- The worm pushes one or more commits to existing open pull request branches (or any active branches) in your org.
- Each injected commit appends
[skip ci]to the previous commit message to suppress CI pipelines. - The commit introduces two types of files:
.github/setup.js— an obfuscated JavaScript payload, executed vianode- Agent instruction files (
.claude,.gemini,.cursor, and similar) — these instruct AI coding agents to runnode .github/setup.jsas part of "project setup", causing the agent to unwittingly execute the malware
- Separately, the worm plants short-lived
snapshot-*branches in target repos containing a deployment-triggered workflow (.github/workflows/release.yml) — see below.
Any AI coding agent (Claude, Gemini, Cursor, etc.) that opens an affected branch and follows the injected instructions will execute the payload.
The snapshot-* branches carry a workflow named "Dependabot Updates" that triggers on the deployment event — meaning it fires whenever GitHub records a deployment against the repository, such as when a legitimate release workflow runs.
The intent is timing camouflage: by piggybacking on a real deployment, the malicious workflow publishes to the npm registry at nearly the same moment as a legitimate release. Unexpected new package versions are far less likely to be noticed when they appear alongside expected ones.
The workflow requests id-token: write (OIDC) and contents: read, then runs bun run _index.js with environment variables that identify the target npm package and repository — giving the payload everything it needs to mint a token and publish a poisoned package version:
# .github/workflows/release.yml (found on snapshot-* branches)
name: Dependabot Updates
run-name: Dependabot Updates
on:
deployment
permissions:
id-token: write
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
- name: prepare
run: bun run _index.js
env:
OIDC_PACKAGES: "<target-package>"
WORKFLOW_ID: "release.yml"
REPO_ID_SUFFIX: "<org>/<target-repo>"Note: The workflow only runs if the branch is merged or the workflow file is present on the default branch. The
snapshot-*branches are likely kept short-lived and deleted after use to reduce visibility.
- File scan mode (default): scans every repository across all branches to detect the presence of
.github/setup.js(or any other file you specify), and optionally remediates affected branches by resetting them to the last clean commit. - Branch existence mode (
--branch-exists): scans every repository to find branches matching a glob pattern (e.g.snapshot-*), and optionally remediates by deleting the matched branches.
Uses the GitHub REST API exclusively — no git cloning required.
pip install -r requirements.txtPython 3.10+ required.
Two auth methods are supported. GitHub App takes priority if its env vars are present.
Rate limit: 15,000 requests/hour
export GITHUB_APP_ID=123456
export GITHUB_APP_PRIVATE_KEY_PATH=/path/to/private-key.pem
# Optional — only needed if you want to target a specific org and skip auto-discovery
export GITHUB_APP_INSTALLATION_ID=78901234When GITHUB_APP_INSTALLATION_ID is not set and --org is provided, the installation ID is discovered automatically. When --org is also omitted, all organisations where the app is installed are scanned in sequence.
To find these values:
- App ID: GitHub → Settings → Developer settings → GitHub Apps → your app → App ID
- Private key: same page → Generate a private key → download the
.pemfile - Installation ID (optional):
https://github.com/organizations/<org>/settings/installations→ click your app → the numeric ID in the URL
Rate limit: 5,000 requests/hour
export GITHUB_TOKEN=ghp_...The PAT needs the repo scope for private repositories, or public_repo for public-only.
Note: With PAT auth,
--orgor--useris always required. Multi-org auto-discovery requires GitHub App auth.
python scanner.py [--org <org> | --user <username>] [options]
When using GitHub App auth and --org is omitted, the script automatically discovers all organisations where the app is installed and scans them all in sequence. Each org gets its own report file derived from the --output template:
--output report.json → report_org-a.json
report_org-b.json
report_org-c.json
| Flag | Default | Description |
|---|---|---|
--org ORG |
(optional with App auth) | GitHub organisation name. Omit to scan all installed orgs automatically. |
--user USERNAME |
— | GitHub username to scan repos for. Mutually exclusive with --org. Requires PAT auth. |
--file PATH |
.github/setup.js |
File path to search for in each branch. Cannot be combined with --branch-exists. |
--branch-exists PATTERN |
— | Check if any branch matching the glob pattern exists (e.g. snapshot-*). Cannot be combined with --file. Supports --remediate to delete matched branches. |
--output FILE |
report.json |
Output report path. In multi-org mode, used as a template: report_<org>.json |
--exclude-repo REPO |
— | Skip a repo by name or org/repo; repeatable |
--repository REPO |
— | Limit scan to a single repo (short name or org/repo) |
--branch BRANCH |
— | Limit scan to a single branch (requires --repository) |
--remediate |
— | File mode: reset affected branches to the last clean commit. Branch mode: delete matched branches. |
--dry-run |
— | With --remediate: show what would happen without making changes |
# Scan all orgs where the App is installed (GitHub App auth only)
python scanner.py
# Scan a specific org
python scanner.py --org my-org
# Scan a personal user account (PAT auth required)
python scanner.py --user my-github-username
# Scan for a different file
python scanner.py --org my-org --file .github/workflows/setup.yml
# Skip specific repos
python scanner.py --org my-org --exclude-repo infra-repo --exclude-repo my-org/legacy-repo
# Test on a single repo and branch before running org-wide
python scanner.py --org my-org --repository my-repo --branch main --remediate --dry-run
# Dry-run remediation across the whole org
python scanner.py --org my-org --remediate --dry-run
# Remediate for real
python scanner.py --org my-org --remediate
# Find all snapshot-* branches (worm indicator) across the org
python scanner.py --org my-org --branch-exists 'snapshot-*'
# Preview deletion of all snapshot-* branches
python scanner.py --org my-org --branch-exists 'snapshot-*' --remediate --dry-run
# Delete all snapshot-* branches
python scanner.py --org my-org --branch-exists 'snapshot-*' --remediateThe malware pattern this script targets:
[skip ci]appended to one or more recent commit messages (to suppress CI)- A malicious file (e.g.
.github/setup.js) added in those commits
Remediation steps (all via GitHub API, no local git):
- Walk commits on the branch newest-first
- Find the first commit whose message does not contain
[skip ci] - Force-update the branch ref to that SHA — equivalent to
git reset --hard <sha> && git push --force
If every commit on the branch contains [skip ci], the branch is skipped with a no-clean-commit warning.
[AUTH] GitHub App (id=123456, installation=78901234) — rate limit: 15,000 req/hour
Scanning 42 repo(s) in 'my-org' (1 excluded) for '.github/setup.js' [DRY-RUN]...
[1/42] my-org/clean-repo ... no matches
[2/42] my-org/affected-repo ...
[FOUND] my-org/affected-repo @ main
[DRY-RUN] my-org/affected-repo @ main: would reset to abc12345 "chore: update deps" (wipes 2 commit(s))
============================================================
Scan complete (dry-run).
Affected repos : 1 / 42
Report saved to: report.json
============================================================
| Prefix | Meaning |
|---|---|
[FOUND] |
Affected file or branch detected |
[DRY-RUN] |
Would reset/delete (no changes made) |
[FIXED] |
Branch successfully reset to clean commit |
[DELETED] |
Branch successfully deleted |
[WARNING] |
No clean commit found on this branch — skipped |
[ERROR] |
Operation failed (e.g. protected branch, insufficient permissions) |
[RATE LIMIT] |
GitHub rate limit hit — waiting for reset |
The script always writes a JSON report to --output (default: report.json).
{
"org": "my-org",
"user": null,
"file_checked": ".github/setup.js",
"scanned_at": "2026-06-05T12:00:00+00:00",
"remediation": null,
"total_repos": 42,
"affected_repos": 1,
"results": [
{
"repo": "my-org/affected-repo",
"branches": ["main", "feature/branch"]
}
]
}branches becomes a list of objects. remediation is "dry-run" or "enabled".
{
"org": "my-org",
"user": null,
"file_checked": ".github/setup.js",
"scanned_at": "2026-06-05T12:00:00+00:00",
"remediation": "dry-run",
"total_repos": 42,
"affected_repos": 1,
"results": [
{
"repo": "my-org/affected-repo",
"branches": [
{
"name": "main",
"status": "dry-run",
"reset_to_sha": "abc123def456...",
"reset_to_message": "chore: update deps",
"commits_to_wipe": [
{
"sha": "def456abc123...",
"message": "feat: add feature [skip ci]"
}
]
}
]
}
]
}| Status | Meaning |
|---|---|
found |
File present / branch matched (scan-only mode) |
dry-run |
Would be reset or deleted (dry-run mode) |
remediated |
Branch successfully reset to clean commit |
deleted |
Branch successfully deleted |
failed |
Operation failed |
no-clean-commit |
Every commit on the branch contains [skip ci] |
{
"org": "my-org",
"branch_pattern": "snapshot-*",
"scanned_at": "2026-06-05T12:00:00+00:00",
"remediation": "dry-run",
"total_repos": 42,
"matched_repos": 2,
"results": [
{
"repo": "my-org/affected-repo",
"branches": [
{ "name": "snapshot-3d1a42f3", "status": "dry-run" }
]
}
]
}remediation is null (scan-only), "dry-run", or "enabled". Branch status is found, dry-run, deleted, or failed.