Adds a third JS_VARIANTS entry for the dot-notation, decoder-array-free payload that slipped past both known-signature matching and the old generic heuristic (which required bracket notation, a decoder array, and eval - all three, none of which this variant used). Also broadens the generic heuristic to OR-based shape categories and adds a separate technique-fingerprint detector for the EtherHiding blockchain C2 pattern, matched against unicode-decoded text so escaping individual strings can't evade it.
387 lines
20 KiB
Markdown
387 lines
20 KiB
Markdown
# 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
|
|
(`@<commit-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 <this-repo>
|
|
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 --<method> --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-<timestamp>.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.
|