polin-guard
v0.3.2
Published
Block obfuscated build/commit-time code-injection payloads (hidden long-line JS stagers) before they enter your repo. Zero dependencies. Works as a pre-commit hook, in CI, or standalone.
Maintainers
Readme
The problem it solves
Modern supply-chain attacks hide a malicious payload on a single, space-padded
line inside an ordinary-looking config or entry file — tailwind.config.js,
ecosystem.config.js, .eslintrc.js, postcss.config.js, src/index.ts, etc.
The line is hundreds of spaces wide, so the payload scrolls off-screen in your
editor and sails through code review:
plugins: [tailwindcssAnimate];
}; global['!']='…';var d=String.fromCharCode(127);…require;…Function(…)(…)
// ^ legitimate code ^ hundreds of spaces hide this →→→ obfuscated payloadWhen the project is built, that line runs with full Node.js access — reading
your environment variables, .env, SSH keys, and tokens, and pulling a second
stage. Because it sits in a file that's required during dev/build/test, it
executes silently and automatically.
polin-guard catches it before it can ever be committed.
Built after a real incident in which this exact payload was committed across multiple repositories and reached production branches.
How it detects (beyond signatures)
polin-guard doesn't just match known payload strings — those are trivially renamed. It detects the necessary conditions of the attack and combines independent signals into a weighted risk score. To stay hidden and execute at build time, a payload is forced to do several of these at once:
| Signal | What it catches | Weight |
|--------|-----------------|-------:|
| concealment | code hidden after a long mid-line whitespace gap (the off-screen trick) | 80 |
| signature | known-family markers (global['!'], global[_$_…], re-exposed require, fromCharCode(127)) | 60 |
| escape-density | ≥25 \xNN/\uNNNN escapes on a line (obfuscated blob) | 50 |
| exec-sink | dynamic execution: Function() / eval | 40 |
| long-token | unbroken ≥120-char token (encoded blob) | 35 |
| ctor-chain | constructor.constructor reach to Function | 35 |
| oversized-line | line > 1000 chars (+30 more if it carries exec/require tokens) | 25 |
| entropy | long, high-entropy line | 25 |
| indirect-require · dyn-timer · vm-module | require(<var>), setTimeout("…"), require('vm') | 25 |
| net-exec-combo | network + code-exec/file-write together (runtime-fetched payload) | 30 |
| network · capability | fetch/http(s), or process.env/fs/child_process | 15 / 10 |
| autoloaded-context | the above inside an auto-loaded config/entry file | +20 |
A file-level pass also aggregates across lines, so a payload split across many lines or fetched at runtime still trips the score.
Block at score ≥ 70, warn at ≥ 35 (configurable). Because the signals are
independent, evading one (rename, split, runtime-fetch, drop the padding) still
trips the others — so evasion becomes self-defeating: visible in review, inert,
readable, or capability-less. Lockfiles, minified bundles, source maps, and
node_modules are skipped to keep false positives near zero.
Evasion-tested. The suite proves that a renamed (signature-free), split-across-lines, and runtime-fetched payload are all still blocked, while legitimate long-data lines and ordinary dynamic
require()are not.
Quick start
npm install --save-dev polin-guardScan right now:
npx polin-guard --all # scan every tracked file in the repoBlock it on every commit (husky)
npm install --save-dev polin-guard husky
npx husky init
echo 'npx --no-install polin-guard --staged' > .husky/pre-commitThat's it. The hook runs on every commit and every git commit --amend, and
scans the exact content being committed. A malicious payload makes the commit fail.
Add the CI backstop (recommended)
A local hook can be skipped (git commit --no-verify) or sidestepped by a
force-push from a compromised machine. Re-scan on the server, where it can't be
skipped — copy examples/github-action.yml to
.github/workflows/polin-guard.yml.
No Node? Use the standalone script
Drop scan-injection.sh into your repo (works with the
pre-commit framework too):
./scan-injection.sh --stagedUsage
polin-guard [scan] [options] [paths...] Scan files for injected payloads (default)
polin-guard install-audit [--strict] Audit dependencies for supply-chain risk
polin-guard harden [--fix] Check/enable install-time hardening
Scan options:
--staged Scan staged content (default; for pre-commit hooks; covers --amend)
--all Scan all git-tracked files
--ci Alias for --all (use in CI)
[paths...] Scan specific files (no git required)
--strict Treat warnings as blocking too
-h, --help · -v, --version
Exit 0 = clean · 1 = blocking finding · 2 = usage errorSupply-chain audit (root-cause defense)
The scanner catches a payload that's already in your tree. install-audit
attacks the entry point — the malicious dependency that runs code at
npm install/build time — without installing anything:
npx polin-guard install-auditIt flags, by reading package.json and your lockfile:
- 🔴 malicious lifecycle scripts —
postinstall/prepare/… that pipe the network to a shell,eval,node -e, base64, raw-IP URLs,child_process - 🔴 non-default registry resolutions in the lockfile (registry hijack)
- 🔴 typosquat / homoglyph dependency names (one edit away from popular packages)
- 🟡 non-registry sources (git/http/tarball/file) dependencies
- ℹ️ a count of packages that declare install/build scripts — your real attack surface
Then shut the door so dependency scripts can't execute at all:
npx polin-guard harden --fix # writes ignore-scripts=true to .npmrcRun install-audit in CI too — see examples/github-action.yml.
Configuration
Optional .polinguardrc.json in your repo root:
{
"maxLineLength": 1000,
"maxEscapes": 25,
"excludeDirs": ["node_modules", "dist", "build", "vendor"],
"includeExtensions": [".js", ".cjs", ".mjs", ".jsx", ".ts", ".tsx", ".vue", ".json", ".bat", ".cmd", ".ps1", ".sh"]
}Acknowledging a verified false positive (e.g. a legitimate inline blob):
- add
// polinguard-allow-lineon the same line, or - add
// polinguard-allow-next-lineon the line above it, or - raise
maxLineLength/ exclude the path in.polinguardrc.json.
Never use
git commit --no-verifyto push past a finding you don't understand.
Audit an existing repo or whole org
# one repo
npx polin-guard --all
# every branch of every repo in a GitHub org
for r in $(gh repo list YOUR_ORG --limit 200 --json name --jq '.[].name'); do
git clone -q "https://github.com/YOUR_ORG/$r.git" "/tmp/scan/$r" || continue
( cd "/tmp/scan/$r"
for b in $(git branch -r | grep -v HEAD | sed 's# *origin/##'); do
git checkout -q "$b" 2>/dev/null && { npx --yes polin-guard --all || echo "FOUND in $r @ $b"; }
done )
doneHow it works
Pure Node, zero dependencies (so the security tool adds no supply-chain risk
of its own). For each candidate file it reads the staged blob (git show :file)
or the working copy, analyzes every line against the rules above, and exits
non-zero on any critical finding so your hook or CI step fails.
Links
- 📦 npm: https://www.npmjs.com/package/polin-guard
- 🐙 GitHub: https://github.com/Valentin-Shyaka/polin-guard
- 🐛 Issues: https://github.com/Valentin-Shyaka/polin-guard/issues
- 🔒 Security policy: SECURITY.md
- 🤝 Contributing: CONTRIBUTING.md
License
MIT © Valentin Shyaka
