Skip to content

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

malware-cleanup — Miasma Worm Branch Scanner & Remediator

Background

This script is a response to the Miasma Worm, a supply-chain attack that targets AI coding agents operating on GitHub repositories.

How the attack works

  1. The worm pushes one or more commits to existing open pull request branches (or any active branches) in your org.
  2. Each injected commit appends [skip ci] to the previous commit message to suppress CI pipelines.
  3. The commit introduces two types of files:
    • .github/setup.js — an obfuscated JavaScript payload, executed via node
    • Agent instruction files (.claude, .gemini, .cursor, and similar) — these instruct AI coding agents to run node .github/setup.js as part of "project setup", causing the agent to unwittingly execute the malware
  4. 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.

Deployment-triggered supply chain workflow

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.

What this script does

  • 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.


Requirements

pip install -r requirements.txt

Python 3.10+ required.

Authentication

Two auth methods are supported. GitHub App takes priority if its env vars are present.

Option A — GitHub App (recommended for large orgs)

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=78901234

When 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 .pem file
  • Installation ID (optional): https://github.com/organizations/<org>/settings/installations → click your app → the numeric ID in the URL

Option B — Personal Access Token

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, --org or --user is always required. Multi-org auto-discovery requires GitHub App auth.


Usage

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

Flags

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

Examples

# 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-*' --remediate

How remediation works

The 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):

  1. Walk commits on the branch newest-first
  2. Find the first commit whose message does not contain [skip ci]
  3. 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.


Log output

[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
============================================================

Log prefixes

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

Report format

The script always writes a JSON report to --output (default: report.json).

Scan-only (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"]
    }
  ]
}

With --remediate or --remediate --dry-run

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]"
            }
          ]
        }
      ]
    }
  ]
}

Branch status values

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]

Branch existence mode (--branch-exists)

Report format

{
  "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.

About

.github/setup.js cleaner

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Used by

Contributors

Languages