# PolinRider Org Cleanup Tool Scans every repo in a GitHub org for PolinRider malware, removes payloads, and opens a PR per infected repo — all inside an isolated Docker container. > Want to scan a **single repo inside CI** instead of sweeping a whole org? > Use the [**GitHub Action**](#use-as-a-github-action) — it reuses the same > detection engine to fail a check, auto-clean, or open a cleanup PR. ## Contents - [What it does](#what-it-does) - [Use as a GitHub Action](#use-as-a-github-action) - [Modes](#modes) - [Enforce it (block infected merges)](#enforce-it-block-infected-merges) - [Permissions](#permissions) - [Reporting](#reporting) - [Excluding known-legit files](#excluding-known-legit-files-and-scanning-the-scanner) - [Inputs](#inputs) - [Notes & limits](#notes--limits) - [Prerequisites](#prerequisites) - [Setup](#setup) - [Running](#running) - [Security design](#security-design) - [Runtime hardening](#runtime-hardening) - [Options](#options) - [PR branch protection](#pr-branch-protection) - [After merging PRs](#after-merging-prs) - [File structure](#file-structure) ## What it does For each repo in your org: 1. **Clones** the default branch (shallow, `--depth=1`, git hooks disabled) 2. **Scans in-process** — reads files as inert text/bytes and pattern-matches known PolinRider signatures. It **never executes** the files it scans (see [Runtime hardening](#runtime-hardening)). Detection is _content-confirmed_: a repo is marked infected only when a real signature matches. 3. **Surgically remediates** infected repos — removing only what is confirmed malicious and preserving legitimate code, tasks, fonts, and configs: - **Strips** the appended obfuscated payload (original, rotated + EtherHiding variants) from any `.js/.ts/.mjs` file (config files, `App.js`, `vite.config.js`, …), keeping everything before the payload byte-for-byte - **Removes the entire `.vscode` directory** when any task/launch entry is malicious — `curl … | bash`, `runOn: folderOpen` auto-runs, C2 hosts, or running an interpreter against a font/asset (e.g. `node ./public/fonts/x.woff2`) - **Removes font carriers strategically.** A font is a _confirmed carrier_ only when it is unreferenced, fails structural font validation (no valid magic / table directory — real fonts, including commercial `.otf` files with embedded license URLs and binary blobs, are trusted), _and_ contains an appended code payload. When a carrier is found, the scanner removes the carrier plus the whole Font-Awesome-named disguise set (`fa-brands/solid/regular-…`) and its `README.md`, while **preserving clean, non-`fa-` fonts in the same directory** (only the leaf `fonts/` dir is removed if nothing clean remains). `fa-`-named fonts with **no** payload are flagged `suspicious` for manual review — never auto-removed. - **Deletes** `temp_auto_push.bat`, `temp_interactive_push.bat`, `config.bat`, `branch_structure.json` - **Fixes** `.gitignore` (removes all injected lines — `config.bat`, `temp_*.bat`, `branch_structure.json` — re-adds `.env*` patterns) and untracks committed `.env` files - **Flags for manual review** (never auto-edits): impostor npm dependencies, fetch-and-exec lifecycle scripts, unknown-but-obfuscated appended code (any global-flag + encoded-payload + eval/spawn shape), and EtherHiding-style blockchain C2-resolution + detached-persistence patterns 4. **Opens a PR** on a new branch with a precise summary of every change — your branch protection rules apply, nothing merges automatically --- ## Use as a GitHub Action The same engine is published as a reusable Action that scans the repository being built — no clone, no org token. Drop it into any workflow: ```yaml # .github/workflows/malware-scan.yml name: PolinRider scan on: [pull_request] permissions: contents: read jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: RMSoft-ltd/polinrider-cleanup@v1 with: mode: check # check | fix | pr ``` ### Modes | `mode` | What it does | Typical trigger | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | | `check` _(default)_ | Scan only. Exits non-zero when malware is found so a **required status check** blocks the merge. No token needed. | `pull_request` | | `fix` | Surgically cleans the working tree. With `commit: true` it also commits + pushes the fix back to the branch (push events only). | `push` | | `pr` | Cleans on a new branch and opens a cleanup PR (optionally auto-merged). Works where commit-back can't push (fork PRs, protected branches). | `schedule` / `workflow_dispatch` | Ready-to-copy workflows live in [`examples/`](examples/). ### Enforce it (block infected merges) `check` exits `1` on a confirmed infection. To turn that into a merge gate, add the job as a **required status check**: repo → Settings → Branches (or Rulesets) → require the `scan` job to pass. PRs then can't merge while malware is detected. ### Permissions Grant the least your mode needs: | Mode / feature | Required `permissions:` | | ----------------------- | ------------------------------------------ | | `check` (no PR comment) | `contents: read` | | PR comment (any mode) | `pull-requests: write` | | `fix` + `commit: true` | `contents: write` | | `pr` | `contents: write` + `pull-requests: write` | | `sarif-file` upload | `security-events: write` | ### Reporting Every run writes a Markdown report to the **job summary**, emits inline **annotations** on the PR diff for each finding, and — on `pull_request` events with `comment-on-pr: true` (default) — posts the report as a **PR comment** (needs `pull-requests: write`; skipped with a warning otherwise). Set `sarif-file` to also emit a SARIF report for the **Security** tab: ```yaml - uses: RMSoft-ltd/polinrider-cleanup@v1 with: mode: check sarif-file: polinrider.sarif - uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: polinrider.sarif ``` ### Excluding known-legit files (and scanning the scanner) Some repos legitimately contain content that looks like a payload — security tooling, malware test fixtures, vendored bundles. Use `exclude` to skip those paths so real infections elsewhere still fail the check: ```yaml - uses: RMSoft-ltd/polinrider-cleanup@v1 with: mode: check exclude: | test/fixtures/** vendor/** ``` This is how this repo scans **itself** in CI: it excludes only its signature catalog, payload fixtures, and built bundle (`src/signatures.js`, `test/**`, `dist/**`) — everything else (real source, configs, `bin/`) is scanned for real, so an actual infection here would fail the build. Keep the exclude list as tight as possible; anything excluded is a blind spot. ### Inputs | Input | Default | Description | | --------------- | ------------------------ | ------------------------------------------------------------------------- | | `mode` | `check` | `check` · `fix` · `pr` | | `path` | `.` | Directory to scan (relative to the workspace) | | `exclude` | _(none)_ | Comma/newline-separated paths or globs to skip, e.g. `test/**, vendor/**` | | `token` | `${{ github.token }}` | Token for `pr` mode, `fix`+`commit`, and PR comments | | `fail-on` | `infected` | Severity that fails the job: `infected` · `suspicious` · `never` | | `commit` | `false` | `fix` mode: commit + push the cleanup back (push events) | | `comment-on-pr` | `true` | Post the report as a PR comment on `pull_request` events | | `sarif-file` | _(off)_ | Write a SARIF report to this path | | `dry-run` | `false` | Plan changes without writing/pushing | | `auto-merge` | `false` | `pr` mode: auto-merge (respects branch protection) | | `merge-method` | `squash` | `squash` · `merge` · `rebase` | | `branch-prefix` | `fix/polinrider-cleanup` | `pr` mode branch prefix | Several inputs also read a legacy **env var** as a fallback (`DRY_RUN`, `AUTO_MERGE`, `MERGE_METHOD`, `BRANCH_PREFIX`, `POLINRIDER_EXCLUDE`, `SARIF_FILE`), so you can set them in a workflow/job `env:` block instead of `with:` — handy for sharing config across steps. An explicit `with:` value always wins. For a custom token use `with: token:` (it defaults to `${{ github.token }}`): ```yaml jobs: scan: runs-on: ubuntu-latest env: AUTO_MERGE: "true" MERGE_METHOD: rebase steps: - uses: actions/checkout@v4 - uses: RMSoft-ltd/polinrider-cleanup@v1 with: mode: pr token: ${{ secrets.GITHUB_TOKEN }} ``` **Outputs:** `severity`, `infected`, `findings-count`, `changed`, `remediated`, `pr-url`, `sarif-file`. ### Notes & limits - **Pin for security.** `@v1` tracks the latest v1.x; pin a full SHA (`@`) if you want immutability. - **`fix` + `commit` is push-event only.** On `pull_request` it's skipped (detached HEAD; fork PRs can't be pushed) — use `mode: pr` there. Commit-back uses the default `GITHUB_TOKEN`, whose pushes don't retrigger workflows (no loop). - Only **content-confirmed** infections fail by default; impostor deps, fetch-and-exec scripts, and the generalized/EtherHiding-fingerprint heuristics for unknown variants are flagged for manual review and (in `fix`/`pr`) still fail the check since they can't be auto-removed. --- ## Prerequisites - [Docker Desktop](https://www.docker.com/products/docker-desktop/) (or Docker Engine + Compose v2) - A GitHub **Personal Access Token (classic)** with these scopes: - `repo` (full) - `read:org` --- ## Setup ### 1. Clone this tool ```bash git clone cd polinrider-cleanup ``` ### 2. Create your `.env` file (never committed) ```bash cp .env.example .env ``` Edit `.env`: ```env GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx GH_ORG=your-org-name # an organization… # GH_USER=your-username # …OR a personal account (set exactly one) DRY_RUN=false ``` > **DRY_RUN=true** will scan and remediate locally but skip pushing and PR creation. > Use this first to verify it does what you expect. ### 3. Build the container ```bash docker compose build ``` --- ## Running ### Dry run first (recommended) ```bash DRY_RUN=true docker compose run --rm polinrider-cleanup ``` Check the output — it will show every infected file and what would be removed, without touching GitHub. ### Live run ```bash docker compose run --rm polinrider-cleanup ``` The tool will print a summary at the end listing every PR URL opened. --- ## Security design | Concern | Mitigation | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Executing scanned malware | Files read as inert text/bytes; no `require`/`eval`/`exec` of targets; codegen disabled; subprocess allowlist (see below) | | Token exposure | Passed via env var only, never baked into image; injected only for `gh` | | Host disk access | Repos cloned to `tmpfs` (RAM only, wiped on exit) | | Root in container | Non-root user `scanner` (UID 1001) | | Container capabilities | All Linux caps dropped (`cap_drop: ALL`) | | Writable fs | Root fs read-only; only `/workspace` and `/tmp` writable via tmpfs | | Resource abuse | CPU and memory limits set in `docker-compose.yml` | --- ## Runtime hardening The scanner only ever **reads** target files as text/bytes — reading a file never executes it. On top of that architectural guarantee, the tool is hardened in layers so the obfuscated payload cannot run even by accident: - **Subprocess allowlist** — only `git` and `gh` may be spawned, with an allowlisted set of subcommands. `node`, `npm`, `npx`, `bash`, `sh`, `deno`, `python`, etc. are rejected, so a `node -e` / `bash -c` style payload can never be launched. Git runs with hooks disabled (`core.hooksPath`), system/global config ignored, and `--no-verify`. - **Code generation disabled** — the launcher (`bin/polinrider.js`) starts Node with `--disallow-code-generation-from-strings`, so `eval()` and `new Function()` throw. - **In-code guards** — `src/safety.js` (imported first) also neutralizes `eval`, the `Function` family, `require('node:vm')`, and `process.binding`, as a fallback when the flag isn't present. - **No install scripts** — `.npmrc` sets `ignore-scripts=true`; the tool never runs `npm install` or a build in a target repo. - **Container** — non-root, all Linux capabilities dropped, read-only root fs. > The Node permission model (`--permission`) is intentionally not used: the tool > must spawn `git`/`gh`, which would require `--allow-child-process` (Node warns > this invalidates the model), so it adds little over the layers above. Run the tests, including a hardened pass that runs every test with codegen disabled: ```bash npm test npm run test:hardened ``` --- ## Options | Env var | Default | Description | | --------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GH_TOKEN` | required | GitHub PAT | | `GH_ORG` | required\* | Organization name. Scans every repo in the org. | | `GH_USER` | required\* | Personal username. Scans every repo you own (`affiliation=owner`). | | `GH_REPO` | _(all repos)_ | Scope to specific repo(s), comma-separated. Each is `name` (account owner prefixed) or `owner/name`. Skips the account-wide listing — use it to pilot one repo first. | | `DRY_RUN` | `false` | Skip push and PR | | `AUTO_MERGE` | `false` | Auto-merge each cleanup PR after opening it. Respects branch protection (blocked PRs stay open and are reported). | | `MERGE_METHOD` | `squash` | Merge method when `AUTO_MERGE=true`: `squash`, `merge`, or `rebase`. | | `REPORTS_DIR` | `reports` | Where JSON + Markdown run reports are written. Set empty to disable. | | `WORKSPACE` | `/workspace` | Where repos are cloned | | `BRANCH_PREFIX` | `fix/polinrider-cleanup` | PR branch name prefix | \* Set **exactly one** of `GH_ORG` or `GH_USER` — not both, not neither. ### Auto-merge With `AUTO_MERGE=true`, after each PR is opened the tool runs `gh pr merge -- --delete-branch`. It **respects branch protection** — a repo that requires reviews or passing checks is left open and listed in the summary (and report) as "auto-merge blocked"; nothing is bypassed. The token must have merge rights on the repo. ### Reports Every run writes `polinrider-.json` + `.md` (and `latest.json` / `latest.md`) to `REPORTS_DIR` (the mounted `./reports` volume in Docker). The files are rewritten after each repo, so you get a durable, auditable trace — including each repo's findings, what was removed/cleaned, the PR link, and merge status — instead of relying on scrollback. The Markdown includes a clickable **Repository | Pull request | Status** table. --- ## PR branch protection Because your org has branch protection enforced, the tool: - Creates a **new branch** per repo (never pushes to `main` or `master` directly) - Opens a PR for review — a human merges it - PR body explains exactly what was changed and why --- ## After merging PRs 1. Notify affected developers to check their machines for the initial dropper 2. Rotate any secrets that may have been exposed via committed `.env` files 3. Check npm global packages: `npm list -g --depth=0` 4. Check VS Code extensions for anything unfamiliar 5. Revoke and regenerate any PATs for accounts whose machines were compromised --- ## File structure ``` polinrider-remover/ ├── Dockerfile ├── docker-compose.yml ├── package.json / .npmrc ├── action.yml # GitHub Action metadata (uses dist/ci.mjs) ├── dist/ │ └── ci.mjs # bundled Action entrypoint (built from src/ci.js — committed) ├── examples/ # ready-to-copy consumer workflows (check / fix / pr) ├── scripts/ │ └── build-action.mjs # esbuild bundler for the Action ├── bin/ │ └── polinrider.js # hardened launcher (applies V8 flags, then runs src/index.js) ├── src/ │ ├── index.js # org-sweep orchestrator: list org repos → clone → scan → remediate → PR │ ├── ci.js # GitHub Action frontend: scan the checked-out repo → check/fix/pr │ ├── safety.js # runtime guards (eval/Function/vm/process.binding) — imported first │ ├── safe-exec.js # subprocess allowlist (git/gh only, hooks disabled) │ ├── signatures.js # IOC catalog (payload variants, C2 hosts, font magic, impostor deps) │ ├── scanner.js # content-confirmed detection → Findings report │ ├── exclude.js # glob matcher for the scan `exclude` option │ ├── remediator.js # surgical removal driven by Findings │ ├── sarif.js # Findings → SARIF 2.1.0 (for code-scanning upload) │ ├── jsonc.js # tolerant JSONC parser + array splicer (no eval) │ ├── fonts.js # structural font validation + reference analysis │ ├── walk.js # symlink-safe file walker │ └── report.js # console + Markdown PR-body rendering └── test/ # node:test specs + fixtures ``` > **Two frontends, one engine.** `src/index.js` (org sweep, Docker) and `src/ci.js` > (GitHub Action) both drive the same `scanner.js` + `remediator.js` + signatures — > detection logic is defined once.