| name | polinrider-scan |
| description | Scan a project or machine for the PolinRider DPRK/Lazarus supply-chain malware (March–April 2026 npm + VS Code campaign). Use whenever the user asks to scan, audit, or check for PolinRider, the postcss / tailwind / webpack / next config infection, the `temp_auto_push.bat` / `config.bat` propagation, the StakingGame `.vscode/tasks.json` dropper (UUID `e9b53a7c-2342-4b15-b02d-bd8b8f6a03f9`), or any IOC listed in `references/iocs.md`. Trigger proactively when the user reports unexplained `node -e` processes, `createRequire` showing up in simple ESM build configs, unfamiliar `tailwindcss-*-animate*` packages in `package.json` or lockfiles, `.vscode/tasks.json` with `runOn: folderOpen`, font files that read as plain text, or `*.bat` files appearing in JavaScript repo roots. Also trigger when the user mentions the DPRK / Lazarus npm supply-chain attack or the `OpenSourceMalware/PolinRider` repository. |
PolinRider Malware Scan
PolinRider is a DPRK/Lazarus supply-chain malware (March–April 2026 campaign) targeting JavaScript/Node.js developers. The infection chain:
- A malicious npm package in the
tailwindcss-*-animate* family (or a compromised legitimate package) gets pulled into a project, often transitively.
- During
postinstall / preinstall or the first build, the package appends an obfuscated payload to a build config file — postcss.config.mjs, tailwind.config.js, webpack.config.js, next.config.mjs, vite.config.js, etc. The payload is hidden behind hundreds of trailing spaces on the same line as module.exports = ... so editors render it off-screen.
- Alternative dropper: a malicious
.vscode/tasks.json with runOn: folderOpen runs the payload silently the first time the project is opened in VS Code, Cursor, or any other VS Code-derived IDE.
- Once active, the payload spawns a detached
node -e process that decrypts XOR-encrypted strings, exfiltrates crypto wallets, SSH keys, browser-stored secrets, and env vars to *.vercel.app C2 endpoints and on-chain dead-drops (TRON, Aptos, BSC), and propagates by silently rewriting git history and force-pushing through a dropped temp_auto_push.bat / config.bat script.
- Compiled bundles in build caches (
.next/, .turbo/, .cache/, .parcel-cache/, .vite/) preserve the payload after source cleanup, so the next dev-server start re-infects.
You are the scanner. Run every check below directly via the Bash tool against the user's machine. This skill bundles only documentation — there is nothing to install, fetch, or execute beyond the standard find, grep, ps, lsof, awk, and file utilities already on the system. Read references/iocs.md once at the start of the scan and keep the IOC tables in mind for interpreting ambiguous matches.
Step 1 — Decide the scope
Pick scope in this order:
- If the user passed an argument:
local → scope=local, root=$PWD
global → scope=global, root=$HOME
- An absolute or
~-prefixed path → scope=local, root=that path
- Otherwise, infer from where the skill is installed:
.claude/skills/polinrider-scan/ inside the project → local, root=$PWD
~/.claude/skills/polinrider-scan/ → global, root=$HOME
State the resolved scope and root in one sentence before running checks. Local scope skips Phases 11 and 12 (system persistence and GitHub repo search). Global scope runs everything.
Set the resolved root in your shell environment for the rest of the scan:
ROOT="<resolved path>"
Step 2 — Load the IOC reference
Read references/iocs.md before starting Phase 1. The file lists every signature, decoder name, shuffle seed, XOR key, C2 domain, blockchain address, malicious package name, and dropper artifact. Several phases below grep for these strings; if you see a hit you don't immediately recognize, look it up there.
Step 3 — Run the scan
Run phases in order. Phases 1 and 2 (active threats) take priority — if anything matches there, the infection is currently live and exfiltrating. Capture the findings, finish the scan, then remediate per Step 5.
For all find invocations, use these standard exclusions to avoid scanning irrelevant directories:
-not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" \
-not -path "*/dist/*" -not -path "*/build/*" -not -path "*/.cache/*" \
-not -path "*/.turbo/*" -not -path "*/.parcel-cache/*" -not -path "*/.vite/*"
And exclude every file that legitimately contains IOC strings as detection patterns:
! -path "*/polinrider-scan/*" ! -path "*/skills/polinrider-scan/*" \
! -path "*/.agents/skills/polinrider-scan/*" \
! -name "polinrider-scanner*" \
! -name "settings.local.json" \
! -name "iocs.md" ! -name "SKILL.md"
These cover, in order:
- This skill's source tree in any project
- The skill installed via
npx skills into .agents/skills/ or .claude/skills/
- The OSM project's official
polinrider-scanner.sh (it's a detection tool, not malware, and ships with all IOCs as patterns)
- Claude Code's
settings.local.json permission allowlist (users approve specific grep patterns there, which captures IOC strings as text)
- The skill's own
iocs.md and SKILL.md documentation
If running multiple independent phases, dispatch them as parallel Bash calls in a single message — they don't depend on each other and run faster in parallel.
Phase 1 — Active processes (highest priority)
Look for the malware's runtime fingerprint in the process table:
ps -eo pid,ppid,user,command 2>/dev/null \
| grep -iE "node -e|global\['!'\]|global\['_V'\]|_\\\$_1e42|MDy|rmcej|2857687|1111436|3896884|2667686|temp_auto_push|config\.bat" \
| grep -v grep
Reparented node processes (parent PID 1) — the payload detaches from its parent so it survives the IDE/terminal closing:
ps -eo pid,ppid,command 2>/dev/null | awk '$2 == 1 && /node/'
Any hit is an active infection. Note PIDs and full command lines. Don't kill yet (you'd lose state useful for the rest of the scan) — kill in Step 5 alongside the other cleanup. Exception: if a process is actively writing to a repo config (lsof -p <pid> shows postcss.config.* or similar), kill it now to stop the bleed.
Phase 2 — C2 network connections
Resolve any active connections held by node processes:
lsof -i -n -P 2>/dev/null | grep -i node
Cross-reference established connections against the C2 list in references/iocs.md:
- Vercel HTTP C2:
260120.vercel.app, default-configuration.vercel.app, vscode-settings-bootstrap.vercel.app, vscode-settings-config.vercel.app, vscode-bootstrapper.vercel.app, vscode-load-config.vercel.app
- Blockchain dead-drops:
api.trongrid.io, fullnode.mainnet.aptoslabs.com, bsc-dataseed.binance.org, bsc-rpc.publicnode.com
Any current connection from a node process to one of those endpoints is live exfiltration in progress. If macOS and the user runs Little Snitch or LuLu, also check their logs for outbound connections to those domains over the last 30 days.
Phase 3 — Source code signatures
The IOC list splits into two tiers. Run them as separate greps because they have very different false-positive profiles.
Tier A (high-confidence, broad file scan) — strings that effectively never appear outside PolinRider: the obfuscation markers, decoder function names, the rotated Cot%3t=shtP, the global injection markers, the XOR keys, the StakingGame UUID, and the specific TRON / Aptos exfiltration addresses. grep -F (fixed strings, no regex) is the safest match mode:
find "$ROOT" -type f \
\( -name "*.js" -o -name "*.mjs" -o -name "*.cjs" -o -name "*.ts" -o -name "*.tsx" \
-o -name "*.jsx" -o -name "*.json" -o -name "*.bat" -o -name "*.sh" \) \
-not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" \
-not -path "*/dist/*" -not -path "*/build/*" -not -path "*/.cache/*" \
! -path "*/polinrider-scan/*" ! -path "*/skills/polinrider-scan/*" \
! -path "*/.agents/skills/polinrider-scan/*" \
! -name "polinrider-scanner*" ! -name "settings.local.json" \
! -name "iocs.md" ! -name "SKILL.md" -print0 2>/dev/null \
| xargs -0 grep -lF \
-e 'rmcej%otb%' -e '_$_1e42' \
-e "global['!']" -e "global['r'] = require" -e "global['m'] = module" \
-e 'Cot%3t=shtP' -e 'function MDy' -e 'var MDy=' \
-e "global['_V']" \
-e '2[gWfGj;<:-93Z^C' -e 'm6:tTh^D)cBz?NM]' \
-e 'TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP' \
-e 'TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG' \
-e '0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e' \
-e '0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3' \
-e 'e9b53a7c-2342-4b15-b02d-bd8b8f6a03f9' \
2>/dev/null
Any file returned by Tier A is high suspicion. Open with the Read tool, confirm the match is real (not a quoted string in a documentation file or a deliberately-named test fixture).
Tier B (shuffle seeds, code-files-only) — the four shuffle-seed integers 2857687, 2667686, 1111436, 3896884. These are 7-digit numbers that collide trivially with timestamps, prices, and trading volumes in financial / market data, so a Tier B hit on its own is almost always a false positive. Restrict the search to JS/TS source (skip JSON, lockfiles, bundled chunks):
find "$ROOT" -type f \
\( -name "*.js" -o -name "*.mjs" -o -name "*.cjs" -o -name "*.ts" -o -name "*.tsx" -o -name "*.jsx" \) \
-not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" \
-not -path "*/dist/*" -not -path "*/build/*" -not -path "*/.cache/*" \
-not -path "*/data/klines/*" -not -path "*/.klines-cache/*" \
! -path "*/polinrider-scan/*" ! -path "*/skills/polinrider-scan/*" \
! -path "*/.agents/skills/polinrider-scan/*" \
! -name "polinrider-scanner*" -print0 2>/dev/null \
| xargs -0 grep -lF -e '2857687' -e '2667686' -e '1111436' -e '3896884' 2>/dev/null
A Tier B file is only meaningful if it also appears in Tier A results — that combination is a confirmed infection. Standalone Tier B hits (a file with a seed but no other marker) need a manual read; if the match is in numeric data (timestamps, OHLCV candles, transaction IDs), discard.
Phase 4 — Config file infection (the trailing-whitespace bomb)
This is the primary infection vector. Find candidate config files:
find "$ROOT" -type f \
\( -name "postcss.config.*" -o -name "tailwind.config.*" -o -name "eslint.config.*" \
-o -name "next.config.*" -o -name "vite.config.*" -o -name "webpack.config.*" \
-o -name "astro.config.*" -o -name "gridsome.config.*" -o -name "vue.config.*" \
-o -name "rollup.config.*" -o -name "babel.config.*" -o -name "svelte.config.*" \
-o -name "nuxt.config.*" -o -name "remix.config.*" -o -name "qwik.config.*" \
-o -name "solid.config.*" -o -name "stylelint.config.*" -o -name "prettier.config.*" \
-o -name "commitlint.config.*" -o -name ".eslintrc*" -o -name "truffle.js" \
-o -name "jest.config.*" -o -name "vitest.config.*" \) \
-not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" \
-not -path "*/dist/*" -not -path "*/build/*" -print 2>/dev/null
For each candidate, compute four signals. grep -c exits 1 when nothing matches, so capture the count defensively:
f="<file>"
maxlen=$(awk '{ if(length > max) max=length } END { print max+0 }' "$f" 2>/dev/null)
maxlen=${maxlen:-0}
long_ws=$(grep -cE ' {50,}' "$f" 2>/dev/null); long_ws=${long_ws:-0}
has_sig=$(grep -cF -e "global['!']" -e "global['_V']" -e '_$_1e42' -e 'function MDy' -e 'rmcej' -e 'Cot%3t=shtP' "$f" 2>/dev/null); has_sig=${has_sig:-0}
has_cr=$(grep -c 'createRequire' "$f" 2>/dev/null); has_cr=${has_cr:-0}
echo "$f maxlen=$maxlen ws=$long_ws sig=$has_sig cr=$has_cr"
Wrap in a loop that consumes the candidate list. Use the =${var:-0} pattern to coerce empty output to 0 — that prevents the bad math expression errors that (( )) throws when the variable is unset or contains stray whitespace.
A file is suspect if any of:
- Max line length > 200
- Any line has 50+ consecutive spaces
- Contains an explicit signature
- Contains
createRequire in a context where the original file has no reason to use it (postcss, tailwind, prettier configs are ESM and don't need createRequire)
For each suspect, open with Read and inspect manually. Look at:
- The very last line of the file (
tail -c 4096 "<file>" if the file is large)
- Any line longer than 200 chars
- The structure around
module.exports = ... or export default ...
A real infection has long whitespace, then a string of \x..-encoded or base64-ish gibberish, then a call to Buffer.from(...).toString() and eval(...) or new Function(...). A clean minified bundle does not appear in a *.config.* file under normal circumstances — if you see one, treat it as suspicious anyway.
Phase 5 — Build cache infection
The malware survives source cleanup if the compiled bundle in the build cache still embeds the payload. The cache then runs again on the next build / dev-server start, re-infecting the source:
for cache in .next .cache .turbo .parcel-cache .vite .svelte-kit .nuxt .output; do
find "$ROOT" -type d -name "$cache" -not -path "*/node_modules/*" 2>/dev/null
done
For each cache directory found:
grep -rlF \
-e "global['!']" -e '_$_1e42' -e 'rmcej' -e "global['_V']" \
-e 'function MDy' -e '2857687' -e '1111436' \
"<cache_dir>" 2>/dev/null | head -20
Any hit means the cache must be deleted. Caches do not legitimately contain these strings.
Phase 6 — VS Code / Cursor droppers
.vscode/tasks.json with runOn: folderOpen is the secondary PolinRider dropper — opening the project in VS Code, Cursor, or any VS Code-derived IDE auto-runs whatever is in the task. Find candidates:
find "$ROOT" -path "*/.vscode/tasks.json" -not -path "*/node_modules/*" -print 2>/dev/null
For each, inspect the contents:
grep -nE 'folderOpen|"reveal":\s*"never"|"echo":\s*false|"hide":\s*true|e9b53a7c-2342-4b15-b02d-bd8b8f6a03f9|node -e|powershell|\.bat|\.woff2|260120\.vercel\.app|vscode-bootstrapper|vscode-settings|vscode-load-config' "<file>"
folderOpen alone is benign — many legitimate projects use it for setup. The dropper signature is the combination of folderOpen + hidden output (reveal: never / echo: false) + a download-and-execute pattern, OR any reference to the StakingGame UUID e9b53a7c-2342-4b15-b02d-bd8b8f6a03f9, OR any reference to a Vercel C2 domain. Any of those by itself is sufficient evidence of the dropper.
Also check .vscode/settings.json for unusual terminal.integrated.profiles.* overrides, custom npm.packageManager paths pointing outside /usr/local, or .vscode/extensions.json recommending unfamiliar publishers.
Phase 7 — Fake binaries
The dropper's second stage is sometimes hidden as a font file (.woff2, .ttf, .eot, .otf) or generic binary (.bin, .dat, .ico) in the repo. The file extension is a lie; the content is ASCII script:
find "$ROOT" -type f \
\( -name "*.woff2" -o -name "*.woff" -o -name "*.ttf" -o -name "*.eot" \
-o -name "*.otf" -o -name "*.ico" -o -name "*.dat" -o -name "*.bin" \) \
-not -path "*/node_modules/*" -not -path "*/.git/*" -size -1M -print 2>/dev/null
For each result, sniff the type and first bytes (use od -c -N 16 if xxd is not installed):
file -b "<path>"
xxd -l 16 "<path>" 2>/dev/null || od -An -c -N 16 "<path>"
A real woff2 reports Web Open Font Format (Version 2) data and starts with wOF2 (77 4f 46 32). A real ttf starts with 00 01 00 00 or 74 72 75 65. PolinRider fakes:
file reports ASCII text, UTF-8 text, JavaScript source, or anything text-like
- First bytes are leading spaces (
0x20), the ASCII text global (67 6c 6f 62 61 6c), or printable JavaScript
Any text-typed font/binary is a payload. Read the file and confirm.
Phase 8 — Propagation artifacts and infected hooks
The malware drops batch files used to silently rewrite git history and force-push:
find "$ROOT" \
\( -name "temp_auto_push.bat" -o -name "temp_interactive_push.bat" -o -name "config.bat" \) \
-not -path "*/node_modules/*" -print 2>/dev/null
It also adds those files to .gitignore so git status won't reveal them:
find "$ROOT" -name ".gitignore" -not -path "*/node_modules/*" -print0 2>/dev/null \
| xargs -0 grep -lE 'config\.bat|temp_auto_push|temp_interactive_push' 2>/dev/null
And it injects into git or husky hooks so every git commit triggers the propagation:
find "$ROOT" -path "*/.git/hooks/*" -type f -not -name "*.sample" 2>/dev/null
find "$ROOT" -path "*/.husky/*" -type f 2>/dev/null
For each hook file, check its body:
grep -lE 'config\.bat|temp_auto_push|temp_interactive_push|git push.*--force|amend.*--no-verify|node -e' "<hook>"
Any hit is malicious — none of these patterns belong in a project's git hooks.
Phase 9 — Malicious npm packages
Scan for installed copies of known-malicious packages:
for pkg in tailwindcss-style-animate tailwind-mainanimation tailwind-autoanimation \
tailwind-animationbased tailwindcss-typography-style tailwindcss-style-modify \
tailwindcss-animate-style; do
find "$ROOT" -path "*/node_modules/$pkg" -type d 2>/dev/null
done
Direct dependencies in package.json:
find "$ROOT" -name "package.json" -not -path "*/node_modules/*" -print0 2>/dev/null \
| xargs -0 grep -lE '"tailwindcss-style-animate"|"tailwind-mainanimation"|"tailwind-autoanimation"|"tailwind-animationbased"|"tailwindcss-typography-style"|"tailwindcss-style-modify"|"tailwindcss-animate-style"' 2>/dev/null
Lockfiles (catch installations even if the package was later removed from package.json). The -a flag makes grep treat binary lockfiles like bun.lockb as text:
find "$ROOT" \
\( -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" -o -name "bun.lockb" \) \
-not -path "*/node_modules/*" -print0 2>/dev/null \
| xargs -0 grep -alE 'tailwindcss-style-animate|tailwind-mainanimation|tailwind-autoanimation|tailwind-animationbased|tailwindcss-typography-style|tailwindcss-style-modify|tailwindcss-animate-style' 2>/dev/null
Suspicious lifecycle scripts in any package — postinstall / preinstall / install running node -e, eval, encoded payloads, or shell pipelines:
find "$ROOT" -path "*/node_modules/*/package.json" 2>/dev/null \
| xargs grep -lE '"(post|pre)?install"\s*:\s*"[^"]*(node -e|eval |base64 -d |\| *bash|\| *sh)"' 2>/dev/null
Any returned package.json deserves a manual read — open it and inspect the scripts field.
Phase 10 — Obfuscation heuristics (novel variants)
If the rotated signatures evolve again, the malware will still need to load encrypted code from a config file. Look for any config that does suspicious dynamic execution:
find "$ROOT" \( -name "*.config.*" -o -name ".eslintrc*" \) \
-not -path "*/node_modules/*" -not -path "*/.git/*" -print0 2>/dev/null \
| xargs -0 grep -lE 'eval\(|new Function\(|child_process|spawn\(|exec\(|Buffer\.from\([^)]+,[^)]*'"'"'hex'"'"'\)|Buffer\.from\([^)]+,[^)]*'"'"'base64'"'"'\)' 2>/dev/null
A legitimate *.config.* rarely needs child_process, eval, or Buffer.from(..., 'hex' | 'base64'). Any hit deserves manual review.
Recently-modified configs (last 7 days) — useful baseline of "what's new":
find "$ROOT" -name "*.config.*" -mtime -7 \
-not -path "*/node_modules/*" -not -path "*/.git/*" -print 2>/dev/null
Phase 11 — System persistence (global scope only)
Skip in local scope. Run only when the resolved scope is global.
The malware sets up persistence so it re-runs on login or boot.
macOS:
ls -la ~/Library/LaunchAgents/ 2>/dev/null
ls -la /Library/LaunchAgents/ 2>/dev/null
ls -la /Library/LaunchDaemons/ 2>/dev/null
For every plist file, read it and look for ProgramArguments containing node, /tmp/*, ~/Library/Application Support/<unfamiliar>, base64-encoded payloads, or Vercel C2 domains.
Linux:
ls -la /etc/cron.d /etc/cron.daily /etc/cron.hourly 2>/dev/null
crontab -l 2>/dev/null
systemctl list-units --type=service --state=running --no-pager 2>/dev/null
ls -la ~/.config/systemd/user/ 2>/dev/null
ls -la ~/.config/autostart/ 2>/dev/null
Both platforms — user crontab:
crontab -l 2>/dev/null
Any cron entry running node, a shell pipeline that fetches and pipes to bash/sh, or referencing a C2 domain is malicious.
Shell rc files — the malware can hook cd, alias git, or eval base64 on shell startup:
for rc in ~/.zshrc ~/.bashrc ~/.zprofile ~/.profile ~/.bash_profile ~/.zshenv ~/.config/fish/config.fish; do
[[ -f "$rc" ]] && grep -nE 'eval |\| *bash|\| *sh|node -e|base64 -d|alias git=|alias cd=|cd\s*\(\s*\)' "$rc" 2>/dev/null
done
Git, npm, ssh config:
grep -nE 'core\.hooksPath|credentialHelper' ~/.gitconfig 2>/dev/null
grep -nE 'registry|//.+:_authToken|prefix' ~/.npmrc 2>/dev/null
grep -nE 'ProxyCommand|IdentityFile' ~/.ssh/config 2>/dev/null
ls -la ~/.ssh/authorized_keys 2>/dev/null
A core.hooksPath pointing outside the per-repo .git/hooks/ (e.g., ~/.config/git/hooks) lets the attacker swap in a malicious shared hook. Confirm contents.
Env leaks — the malware exports these to label exfiltrated batches:
env | grep -iE 'LAST_COMMIT|USER_NAME|USER_EMAIL|CURRENT_BRANCH'
Empty output is healthy. A populated set indicates a malicious shell wrapper.
Phase 12 — GitHub repo search (global scope only)
Skip in local scope. Skip if gh is not authenticated.
Confirm gh works:
gh auth status 2>&1 | head -5
If authenticated, search the user's GitHub for known IOCs across all their repos. The exclusion list mirrors the local-scope exclusions so detection-tool documentation and previously-installed copies of this skill don't generate noise:
USER=$(gh api /user --jq '.login')
echo "user=$USER"
EXCLUDE='+-filename:polinrider-scanner.sh+-filename:iocs.md+-filename:SKILL.md+-filename:README.md+-path:polinrider-scan+-path:skills/polinrider-scan+-path:.agents/skills/polinrider-scan+-path:scripts/polinrider-scanner.sh'
for q in \
'_$_1e42' \
"global['!']" \
"global['_V']" \
'rmcej' \
'Cot%3t=shtP' \
'260120.vercel.app' \
'temp_auto_push.bat' \
'tailwindcss-style-animate' \
'e9b53a7c-2342-4b15-b02d-bd8b8f6a03f9'; do
encoded=$(printf '%s' "$q" | jq -sRr @uri)
count=$(gh api "/search/code?q=${encoded}+user:${USER}${EXCLUDE}" --jq '.total_count' 2>/dev/null)
printf '%s\t%s\n' "${count:-?}" "$q"
done
Any non-zero count surviving those exclusions is a real finding. Drill in with the full gh api "/search/code?q=...${EXCLUDE}" --jq '.items[]|"\(.repository.name)/\(.path)"' to surface repo + path. Watch for code_search rate limits (10 / minute on most accounts) — pace the drill-ins.
Step 4 — Interpret findings
Categorize each finding:
- Active threat — anything from Phase 1 (process) or Phase 2 (live C2 connection). Address immediately, before any other remediation.
- Confirmed infection — Phase 3a Tier-A signature in source, Phase 3a + Phase 3b co-hit on the same file, Phase 4 (suspect config with both whitespace bomb AND a signature), Phase 5 (build cache hit), Phase 6 (tasks.json with the dropper combination or StakingGame UUID), Phase 7 (text disguised as binary), Phase 8 (propagation artifact or infected hook).
- Residue from incomplete cleanup — these are real but not actively exploitable: a
.gitignore still listing config.bat / temp_auto_push.bat, a previously-infected build config still importing createRequire even though the malicious payload is gone, an old commit in git history. Worth cleaning up but not a live threat.
- High suspicion — Phase 4 with one suspicious signal but no signature, Phase 9 (malicious npm package without a corresponding source signature — likely caught early), Phase 10 (obfuscation heuristic hit), Phase 3b standalone hit. Open the file and decide.
- Informational — recently-modified configs that look clean, persistence mechanisms that look standard, GitHub matches that resolve to documentation about PolinRider.
False-positive checklist before reporting:
- The skill's own files (
iocs.md, SKILL.md, anything under polinrider-scan/) deliberately reference the patterns. Excluded by default.
- Skill copies installed via
npx skills into .agents/skills/polinrider-scan/ or .claude/skills/polinrider-scan/. Excluded by default.
- The OSM
polinrider-scanner.sh shipped at */scripts/polinrider-scanner.sh and inside Claude plugin marketplaces. Excluded by default.
- Claude Code permission allowlists in
.claude/settings.local.json quoting grep patterns that contain IOCs. Excluded by default.
- Documentation about PolinRider in repos like
OpenSourceMalware/PolinRider is not infection.
- Phase 3b shuffle-seed-only matches (a file with
1111436, 2857687, 2667686, or 3896884 but no Tier-A signature). These are 7-digit numbers that show up in trading volumes, OHLCV candles, transaction IDs, timestamps, and other numeric data. Always cross-check against Tier A before reporting.
- A minified bundle in
dist/ or build/ can trigger max-line-length checks; those folders are excluded by default. If a long line shows up in a .config.* file, that is not a normal minified bundle — config files are not minified output.
- A very long line in
package-lock.json is normal (lockfiles are excluded from Phase 3 grep on purpose).
Step 5 — Remediate
Confirm with the user before destructive actions. Order:
- Kill active processes — for each PID from Phase 1 with IOCs in args:
kill -9 <pid>
- Stop running dev servers (
next dev, vite, webpack-dev-server, nuxt dev, astro dev, etc.) — they re-emit the cache on hot-reload and re-infect.
- Delete infected build caches —
rm -rf each directory flagged by Phase 5.
- Clean infected configs — for each suspect from Phase 4:
- Open with Read.
- Identify the legitimate end of the file (the last meaningful line of
module.exports / export default).
- Truncate to that point.
- Remove any
createRequire import the malware injected — postcss, tailwind, prettier ESM configs do not need it. An orphan createRequire import in a previously-infected config is residue from incomplete cleanup; treat it as confirmed infection history even if the obfuscated payload is no longer there.
- Delete propagation scripts and clean
.gitignore — rm each config.bat, temp_auto_push.bat, temp_interactive_push.bat. Also remove the config.bat / temp_auto_push.bat lines from .gitignore — the malware committed those entries to hide the dropped batch files from git status. A cleanup that deletes the .bat file but leaves the .gitignore line behind is a common partial-cleanup signature; the entry by itself is real residue and should be removed.
- Delete VS Code droppers — remove malicious task entries from
.vscode/tasks.json, or delete the file if it's entirely the dropper. Delete fake-binary payload files identified in Phase 7.
- Clean git/husky hooks — for each infected hook, restore from
*.sample (git) or remove the malicious lines (husky).
- Remove malicious npm packages — drop from
package.json, delete the entire node_modules/ directory, regenerate the lock file (npm install / pnpm install / yarn install / bun install).
- Commit and push fixes — one focused commit per affected repo, message describing the cleanup. Use a normal
git push. Never --force — force-push is exactly the propagation primitive the malware abuses, and a habit of force-pushing trains the user to ignore it in their reflog.
- Tell the user to rotate credentials — anything accessible from this machine should be considered compromised. List candidates explicitly: GitHub Personal Access Tokens, npm tokens, AWS / GCP / Azure credentials, Cloudflare tokens, Vercel tokens, SSH private keys, browser-saved logins, crypto wallet seed phrases, password manager master password if it was unlocked while the malware was active.
- Tell the user to notify collaborators — anyone who cloned an affected repo before cleanup is also exposed and needs the same scan run.
Step 6 — Report
Output this structure. Fill every section, even when empty.
## PolinRider Scan Results
**Scope:** LOCAL or GLOBAL
**Root:** <path>
**Date:** <UTC timestamp>
### Active threats
| Type | Location | Action taken |
|---|---|---|
### Infections found
| Phase | Category | File / Location | Details | Remediated? |
|---|---|---|---|---|
### Clean checks
- [ ] Phase 1 — Active processes
- [ ] Phase 2 — C2 network connections
- [ ] Phase 3 — Source signatures
- [ ] Phase 4 — Config file infection
- [ ] Phase 5 — Build cache infection
- [ ] Phase 6 — VS Code / Cursor droppers
- [ ] Phase 7 — Fake binaries
- [ ] Phase 8 — Propagation artifacts (.bat / .gitignore / hooks)
- [ ] Phase 9 — Malicious npm packages
- [ ] Phase 10 — Obfuscation heuristics
- [ ] Phase 11 — System persistence (global only)
- [ ] Phase 12 — GitHub repo search (global only)
### Recommended actions
- Credentials to rotate: …
- Collaborators to notify: …
- Repos still needing a clean push: …
If everything is clean, say so explicitly and quote concrete numbers — e.g., "No PolinRider IOCs found in ~/Work. Scanned N source files, M config files, K build caches, J node_modules trees."