polin-guard 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,94 +1,169 @@
1
- # polin-guard
1
+ <h1 align="center">🛡️ polin-guard</h1>
2
+
3
+ <p align="center">
4
+ <strong>Stop obfuscated malware from being committed to your repo — automatically, on every commit.</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/polin-guard"><img alt="npm version" src="https://img.shields.io/npm/v/polin-guard?color=cb3837&logo=npm"></a>
9
+ <a href="https://www.npmjs.com/package/polin-guard"><img alt="npm downloads" src="https://img.shields.io/npm/dm/polin-guard?color=cb3837&logo=npm"></a>
10
+ <a href="https://github.com/Valentin-Shyaka/polin-guard/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/npm/l/polin-guard?color=blue"></a>
11
+ <img alt="dependencies" src="https://img.shields.io/badge/dependencies-0-brightgreen">
12
+ <img alt="node" src="https://img.shields.io/node/v/polin-guard">
13
+ </p>
14
+
15
+ <p align="center">
16
+ <code>npm install --save-dev polin-guard</code>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <img src="https://raw.githubusercontent.com/Valentin-Shyaka/polin-guard/main/assets/demo.svg" alt="polin-guard blocking a commit that contains an obfuscated injected payload" width="760">
21
+ </p>
22
+
23
+ ---
24
+
25
+ ## The problem it solves
26
+
27
+ Modern supply-chain attacks hide a malicious payload on a **single, space-padded
28
+ line** inside an ordinary-looking config or entry file — `tailwind.config.js`,
29
+ `ecosystem.config.js`, `.eslintrc.js`, `postcss.config.js`, `src/index.ts`, etc.
30
+ The line is hundreds of spaces wide, so the payload scrolls **off-screen** in your
31
+ editor and sails through code review:
32
+
33
+ ```js
34
+ plugins: [tailwindcssAnimate];
35
+ }; global['!']='…';var d=String.fromCharCode(127);…require;…Function(…)(…)
36
+ // ^ legitimate code ^ hundreds of spaces hide this →→→ obfuscated payload
37
+ ```
2
38
 
3
- **Block obfuscated build/commit-time code-injection payloads before they ever enter your repo.**
39
+ When the project is built, that line runs with **full Node.js access** — reading
40
+ your environment variables, `.env`, SSH keys, and tokens, and pulling a second
41
+ stage. Because it sits in a file that's `require`d during `dev`/`build`/`test`, it
42
+ executes silently and automatically.
4
43
 
5
- `polin-guard` is a tiny, **zero-dependency** scanner that catches the family of
6
- malicious JavaScript "stagers" that hide a payload on a single, space-padded line
7
- inside an otherwise normal config or entry file — e.g. `tailwind.config.js`,
8
- `ecosystem.config.js`, `.eslintrc.js`, `postcss.config.js`, or `src/index.ts`.
44
+ **polin-guard catches it before it can ever be committed.**
9
45
 
10
- These payloads typically:
46
+ > Built after a real incident in which this exact payload was committed across
47
+ > multiple repositories and reached production branches.
48
+
49
+ ## How it detects (beyond signatures)
50
+
51
+ polin-guard doesn't just match known payload strings — those are trivially
52
+ renamed. It detects the **necessary conditions** of the attack and combines
53
+ independent signals into a weighted **risk score**. To stay hidden *and* execute
54
+ at build time, a payload is forced to do several of these at once:
55
+
56
+ | Signal | What it catches | Weight |
57
+ |--------|-----------------|-------:|
58
+ | `concealment` | code hidden after a long mid-line whitespace gap (the off-screen trick) | 80 |
59
+ | `signature` | known-family markers (`global['!']`, `global[_$_…]`, re-exposed `require`, `fromCharCode(127)`) | 60 |
60
+ | `escape-density` | ≥25 `\xNN`/`\uNNNN` escapes on a line (obfuscated blob) | 50 |
61
+ | `exec-sink` | dynamic execution: `Function()` / `eval` | 40 |
62
+ | `long-token` | unbroken ≥120-char token (encoded blob) | 35 |
63
+ | `ctor-chain` | `constructor.constructor` reach to `Function` | 35 |
64
+ | `oversized-line` | line > 1000 chars (+30 more if it carries exec/require tokens) | 25 |
65
+ | `entropy` | long, high-entropy line | 25 |
66
+ | `indirect-require` · `dyn-timer` · `vm-module` | `require(<var>)`, `setTimeout("…")`, `require('vm')` | 25 |
67
+ | `net-exec-combo` | network **+** code-exec/file-write together (runtime-fetched payload) | 30 |
68
+ | `network` · `capability` | `fetch`/`http(s)`, or `process.env`/`fs`/`child_process` | 15 / 10 |
69
+ | `autoloaded-context` | the above inside an auto-loaded config/entry file | +20 |
70
+
71
+ A **file-level pass** also aggregates across lines, so a payload **split across
72
+ many lines** or **fetched at runtime** still trips the score.
73
+
74
+ **Block at score ≥ 70, warn at ≥ 35** (configurable). Because the signals are
75
+ independent, evading one (rename, split, runtime-fetch, drop the padding) still
76
+ trips the others — so evasion becomes self-defeating: visible in review, inert,
77
+ readable, or capability-less. Lockfiles, minified bundles, source maps, and
78
+ `node_modules` are skipped to keep false positives near zero.
79
+
80
+ > **Evasion-tested.** The suite proves that a **renamed** (signature-free),
81
+ > **split-across-lines**, and **runtime-fetched** payload are all still blocked,
82
+ > while legitimate long-data lines and ordinary dynamic `require()` are not.
83
+
84
+ ## Quick start
11
85
 
12
- - decode strings at runtime with a character-shuffle cipher,
13
- - re-expose Node's `require`/`module` as globals, and
14
- - execute a second stage through a `Function()` constructor —
86
+ ```bash
87
+ npm install --save-dev polin-guard
88
+ ```
15
89
 
16
- …all of which runs **automatically at build/dev/CI time** with full Node.js
17
- access to your environment variables, SSH keys, and tokens. Because the malicious
18
- code sits hundreds of spaces to the right of legitimate code, it is trivially
19
- missed in review. `polin-guard` makes it impossible to miss.
90
+ Scan right now:
20
91
 
21
- > Built after a real incident in which this exact payload was committed across
22
- > multiple repositories. The detection rules are tuned for **high precision** —
23
- > a `CRITICAL` finding should be safe to block a commit on.
92
+ ```bash
93
+ npx polin-guard --all # scan every tracked file in the repo
94
+ ```
24
95
 
25
- ## What it detects
96
+ ### Block it on every commit (husky)
26
97
 
27
- | Rule | Severity | What it catches |
28
- |------|----------|-----------------|
29
- | `global-bang-key` | critical | `global['!']=…` stager marker |
30
- | `global-underscore-handle` | critical | `global[_$_…]=…` obfuscated handle |
31
- | `require-reexposed` | critical | `…]=require; … typeof module` capability escalation |
32
- | `char-shuffle-cipher` | critical | `String.fromCharCode(127)` cipher delimiter |
33
- | `escape-density` | critical | a line with ≥25 `\xNN`/`\uNNNN` escapes (obfuscated blob) |
34
- | `iife-constructor` | critical | immediately-invoked `Function()` on a long line |
35
- | `oversized-line` | critical* | a source line > 1000 chars **with** exec/require tokens (the concealment trick) |
36
- | `oversized-line` | warning | a long line with no exec tokens (review) |
37
- | `eval` / `atob` / `child_process` | warning | weaker indicators |
98
+ ```bash
99
+ npm install --save-dev polin-guard husky
100
+ npx husky init
101
+ echo 'npx --no-install polin-guard --staged' > .husky/pre-commit
102
+ ```
38
103
 
39
- \* A long line **without** exec tokens is only a warning, to keep false positives near zero.
104
+ That's it. The hook runs on **every commit and every `git commit --amend`**, and
105
+ scans the exact content being committed. A malicious payload makes the commit fail.
40
106
 
41
- Lockfiles, minified bundles, source maps, and `node_modules` are excluded automatically.
107
+ ### Add the CI backstop (recommended)
42
108
 
43
- ## Install
109
+ A local hook can be skipped (`git commit --no-verify`) or sidestepped by a
110
+ force-push from a compromised machine. Re-scan on the server, where it can't be
111
+ skipped — copy [`examples/github-action.yml`](examples/github-action.yml) to
112
+ `.github/workflows/polin-guard.yml`.
44
113
 
45
- ```bash
46
- npm install --save-dev polin-guard
47
- ```
114
+ ### No Node? Use the standalone script
48
115
 
49
- Or run it without installing:
116
+ Drop [`scan-injection.sh`](scan-injection.sh) into your repo (works with the
117
+ [pre-commit framework](examples/pre-commit-config.yaml) too):
50
118
 
51
119
  ```bash
52
- npx polin-guard --all
120
+ ./scan-injection.sh --staged
53
121
  ```
54
122
 
55
- No Node? Use the standalone script — copy `scan-injection.sh` into your repo.
56
-
57
123
  ## Usage
58
124
 
59
- ```bash
60
- polin-guard --staged # scan staged content (use in pre-commit; also covers `git commit --amend`)
61
- polin-guard --all # scan every tracked file
62
- polin-guard --ci # same as --all, for CI
63
- polin-guard path/to/file.js ... # scan specific files (no git required)
64
- polin-guard --strict # treat warnings as blocking too
125
+ ```text
126
+ polin-guard [scan] [options] [paths...] Scan files for injected payloads (default)
127
+ polin-guard install-audit [--strict] Audit dependencies for supply-chain risk
128
+ polin-guard harden [--fix] Check/enable install-time hardening
129
+
130
+ Scan options:
131
+ --staged Scan staged content (default; for pre-commit hooks; covers --amend)
132
+ --all Scan all git-tracked files
133
+ --ci Alias for --all (use in CI)
134
+ [paths...] Scan specific files (no git required)
135
+ --strict Treat warnings as blocking too
136
+ -h, --help · -v, --version
137
+
138
+ Exit 0 = clean · 1 = blocking finding · 2 = usage error
65
139
  ```
66
140
 
67
- Exit code `1` means a blocking finding was detected.
141
+ ## Supply-chain audit (root-cause defense)
68
142
 
69
- ### As a pre-commit hook (husky)
143
+ The scanner catches a payload that's already in your tree. **`install-audit`
144
+ attacks the entry point** — the malicious dependency that runs code at
145
+ `npm install`/build time — without installing anything:
70
146
 
71
147
  ```bash
72
- npm install --save-dev polin-guard husky
73
- npx husky init
74
- # add the scan to the hook (runs on commit AND amend):
75
- echo 'npx --no-install polin-guard --staged' > .husky/pre-commit
148
+ npx polin-guard install-audit
76
149
  ```
77
150
 
78
- A ready-made hook is included at `.husky/pre-commit` in this package.
79
-
80
- ### As a pre-commit hook (pre-commit.com framework)
151
+ It flags, by reading `package.json` and your lockfile:
81
152
 
82
- See `examples/pre-commit-config.yaml`. It calls the standalone `scan-injection.sh`,
83
- so it needs no Node.
153
+ - 🔴 **malicious lifecycle scripts** `postinstall`/`prepare`/… that pipe the
154
+ network to a shell, `eval`, `node -e`, base64, raw-IP URLs, `child_process`
155
+ - 🔴 **non-default registry** resolutions in the lockfile (registry hijack)
156
+ - 🔴 **typosquat / homoglyph** dependency names (one edit away from popular packages)
157
+ - 🟡 **non-registry sources** (git/http/tarball/file) dependencies
158
+ - ℹ️ a **count of packages that declare install/build scripts** — your real attack surface
84
159
 
85
- ### In CI (the bypass-proof backstop)
160
+ Then **shut the door** so dependency scripts can't execute at all:
86
161
 
87
- A local hook can be skipped with `git commit --no-verify` or sidestepped by a
88
- force-push from a compromised machine. Add the server-side scan so history is
89
- always re-checked:
162
+ ```bash
163
+ npx polin-guard harden --fix # writes ignore-scripts=true to .npmrc
164
+ ```
90
165
 
91
- Copy `examples/github-action.yml` to `.github/workflows/polin-guard.yml`.
166
+ Run `install-audit` in CI too — see [`examples/github-action.yml`](examples/github-action.yml).
92
167
 
93
168
  ## Configuration
94
169
 
@@ -103,55 +178,45 @@ Optional `.polinguardrc.json` in your repo root:
103
178
  }
104
179
  ```
105
180
 
106
- ### Acknowledging a verified false positive
181
+ **Acknowledging a verified false positive** (e.g. a legitimate inline blob):
107
182
 
108
- If a line is genuinely legitimate (a real minified-in-source blob, say):
109
-
110
- - put `// polinguard-allow-line` on the same line, **or**
111
- - put `// polinguard-allow-next-line` on the line above it, **or**
183
+ - add `// polinguard-allow-line` on the same line, **or**
184
+ - add `// polinguard-allow-next-line` on the line above it, **or**
112
185
  - raise `maxLineLength` / exclude the path in `.polinguardrc.json`.
113
186
 
114
- Never use `git commit --no-verify` to push past a finding you haven't understood.
115
-
116
- ## Audit an existing repo / whole org
187
+ > Never use `git commit --no-verify` to push past a finding you don't understand.
117
188
 
118
- One-off scan of a checked-out repo:
189
+ ## Audit an existing repo or whole org
119
190
 
120
191
  ```bash
192
+ # one repo
121
193
  npx polin-guard --all
122
- # or, without Node:
123
- git grep -nI '.\{1000,\}' # flag any suspiciously long line
124
- ```
125
-
126
- Scan every branch of every repo in a GitHub org:
127
194
 
128
- ```bash
195
+ # every branch of every repo in a GitHub org
129
196
  for r in $(gh repo list YOUR_ORG --limit 200 --json name --jq '.[].name'); do
130
- git clone --quiet "https://github.com/YOUR_ORG/$r.git" "/tmp/scan/$r" || continue
197
+ git clone -q "https://github.com/YOUR_ORG/$r.git" "/tmp/scan/$r" || continue
131
198
  ( cd "/tmp/scan/$r"
132
199
  for b in $(git branch -r | grep -v HEAD | sed 's# *origin/##'); do
133
- git checkout -q "$b" 2>/dev/null || continue
134
- npx --yes polin-guard --all || echo "FOUND in $r @ $b"
200
+ git checkout -q "$b" 2>/dev/null && { npx --yes polin-guard --all || echo "FOUND in $r @ $b"; }
135
201
  done )
136
202
  done
137
203
  ```
138
204
 
139
205
  ## How it works
140
206
 
141
- Pure Node, no dependencies (so the security tool itself adds no supply-chain risk).
142
- For each candidate file it reads the staged blob (`git show :file`) or the working
143
- copy, then analyzes every line for the signatures and concealment patterns above.
144
- It exits non-zero on any `critical` finding so a hook or CI step fails the build.
145
-
146
- ## Development
207
+ Pure Node, **zero dependencies** (so the security tool adds no supply-chain risk
208
+ of its own). For each candidate file it reads the staged blob (`git show :file`)
209
+ or the working copy, analyzes every line against the rules above, and exits
210
+ non-zero on any `critical` finding so your hook or CI step fails.
147
211
 
148
- ```bash
149
- npm test # runs the zero-dependency test suite (clean + inert-malicious fixtures)
150
- ```
212
+ ## Links
151
213
 
152
- The `test/fixtures/malicious.config.js` fixture contains the detection *signatures*
153
- but performs no real decode/exec — it exists only to prove the detector fires.
214
+ - 📦 **npm:** https://www.npmjs.com/package/polin-guard
215
+ - 🐙 **GitHub:** https://github.com/Valentin-Shyaka/polin-guard
216
+ - 🐛 **Issues:** https://github.com/Valentin-Shyaka/polin-guard/issues
217
+ - 🔒 **Security policy:** [SECURITY.md](SECURITY.md)
218
+ - 🤝 **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md)
154
219
 
155
220
  ## License
156
221
 
157
- MIT
222
+ [MIT](LICENSE) © Valentin Shyaka
package/bin/cli.js CHANGED
@@ -2,17 +2,20 @@
2
2
  'use strict';
3
3
 
4
4
  const { run } = require('../src/scan');
5
+ const { auditInstall, harden } = require('../src/supplychain');
5
6
 
6
- const HELP = `polin-guard — block obfuscated code-injection payloads before they are committed
7
+ const HELP = `polin-guard — stop obfuscated injection & malicious dependencies
7
8
 
8
9
  Usage:
9
- polin-guard [options] [paths...]
10
+ polin-guard [scan] [options] [paths...] Scan files for injected payloads (default)
11
+ polin-guard install-audit [options] Audit dependencies for supply-chain risk
12
+ polin-guard harden [--fix] Check/enable install-time hardening
10
13
 
11
- Modes:
12
- --staged Scan files staged for commit (default; use in pre-commit hooks).
14
+ Scan modes:
15
+ --staged Scan files staged for commit (default; for pre-commit hooks).
13
16
  --all Scan all git-tracked files.
14
17
  --ci Alias for --all (use in CI).
15
- [paths...] Scan specific files/globs (no git required).
18
+ [paths...] Scan specific files (no git required).
16
19
 
17
20
  Options:
18
21
  --strict Treat warnings as blocking (exit non-zero on warnings too).
@@ -21,94 +24,113 @@ Options:
21
24
  -h, --help Show this help.
22
25
  -v, --version Show version.
23
26
 
24
- Exit codes:
25
- 0 clean (or only warnings without --strict)
26
- 1 blocking finding(s) detected
27
- 2 usage / runtime error
27
+ Exit codes: 0 clean · 1 blocking finding(s) · 2 usage/runtime error
28
28
 
29
- Docs & allowlisting: see README. Add a config via .polinguardrc.json.`;
29
+ Docs & allowlisting: see README. Scan config via .polinguardrc.json.`;
30
30
 
31
- function parseArgs(argv) {
32
- const o = { mode: 'staged', paths: [], strict: false, quiet: false, color: true };
31
+ function paint(s, code, enabled) {
32
+ const ESC = String.fromCharCode(27);
33
+ return enabled ? ESC + '[' + code + 'm' + s + ESC + '[0m' : s;
34
+ }
35
+ const useColor = (argv) => !argv.includes('--no-color') && process.stdout.isTTY;
36
+
37
+ /** Shared pretty-printer for a list of {severity, ruleId, message, where|file,line}. */
38
+ function printFindings(title, findings, blocking, c) {
39
+ const header = blocking
40
+ ? paint(`✖ ${title}`, '1;31', c)
41
+ : paint(`⚠ ${title}`, '33', c);
42
+ process.stderr.write(`\n${header}\n\n`);
43
+ for (const f of findings) {
44
+ const tag = f.severity === 'critical' ? paint('CRITICAL', '1;31', c)
45
+ : f.severity === 'warning' ? paint('warning ', '33', c)
46
+ : paint('info ', '36', c);
47
+ const loc = f.where || (f.line === 0 ? `${f.file} (file-level)` : `${f.file}:${f.line}`);
48
+ process.stderr.write(` ${tag} ${paint(loc, '36', c)} [${f.ruleId}]\n ${f.message}\n`);
49
+ }
50
+ process.stderr.write('\n');
51
+ }
52
+
53
+ function cmdScan(argv) {
54
+ const o = { mode: 'staged', paths: [], strict: false, quiet: false };
33
55
  for (const a of argv) {
34
56
  if (a === '--staged') o.mode = 'staged';
35
57
  else if (a === '--all' || a === '--ci') o.mode = 'all';
36
58
  else if (a === '--strict') o.strict = true;
37
59
  else if (a === '--quiet') o.quiet = true;
38
- else if (a === '--no-color') o.color = false;
39
- else if (a === '-h' || a === '--help') o.help = true;
40
- else if (a === '-v' || a === '--version') o.version = true;
41
- else if (a.startsWith('-')) { o.unknown = a; }
60
+ else if (a === '--no-color') { /* handled globally */ }
61
+ else if (a.startsWith('-')) { process.stderr.write(`polin-guard: unknown option ${a}\n`); return 2; }
42
62
  else { o.paths.push(a); o.mode = 'paths'; }
43
63
  }
44
- return o;
45
- }
64
+ const c = useColor(argv);
46
65
 
47
- function paint(s, code, enabled) {
48
- const ESC = String.fromCharCode(27);
49
- return enabled ? ESC + "[" + code + "m" + s + ESC + "[0m" : s;
50
- }
51
-
52
- function main() {
53
- const o = parseArgs(process.argv.slice(2));
54
- const c = o.color && process.stdout.isTTY;
66
+ let res;
67
+ try { res = run({ mode: o.mode, paths: o.paths, strict: o.strict }); }
68
+ catch (e) { process.stderr.write(`polin-guard: ${e.message}\n`); return 2; }
55
69
 
56
- if (o.help) { process.stdout.write(HELP + '\n'); return 0; }
57
- if (o.version) {
58
- try { process.stdout.write(require('../package.json').version + '\n'); } catch { process.stdout.write('0.0.0\n'); }
70
+ if (res.findings.length === 0) {
71
+ if (!o.quiet) process.stdout.write(paint('✓ polin-guard: no injection indicators found', '32', c) +
72
+ ` (${res.filesScanned} file${res.filesScanned === 1 ? '' : 's'} scanned)\n`);
59
73
  return 0;
60
74
  }
61
- if (o.unknown) { process.stderr.write(`polin-guard: unknown option ${o.unknown}\n`); return 2; }
75
+ printFindings('polin-guard: potential code injection detected', res.findings, res.blocking, c);
76
+ if (res.blocking) {
77
+ process.stderr.write(
78
+ 'Commit blocked. If this is a genuine attack, do NOT commit — investigate the file.\n' +
79
+ 'Verified false positive? Use a "// polinguard-allow-line" comment or .polinguardrc.json.\n' +
80
+ '(Bypass for one commit: git commit --no-verify.)\n');
81
+ return 1;
82
+ }
83
+ process.stderr.write('Warnings only — not blocking. Use --strict to block on warnings.\n');
84
+ return 0;
85
+ }
62
86
 
87
+ function cmdAudit(argv) {
88
+ const strict = argv.includes('--strict');
89
+ const quiet = argv.includes('--quiet');
90
+ const c = useColor(argv);
63
91
  let res;
64
- try {
65
- res = run({ mode: o.mode, paths: o.paths, strict: o.strict });
66
- } catch (e) {
67
- process.stderr.write(`polin-guard: ${e.message}\n`);
68
- return 2;
69
- }
92
+ try { res = auditInstall(process.cwd()); }
93
+ catch (e) { process.stderr.write(`polin-guard: ${e.message}\n`); return 2; }
70
94
 
95
+ const blocking = strict ? (res.critical.length + res.warnings.length) > 0 : res.critical.length > 0;
71
96
  if (res.findings.length === 0) {
72
- if (!o.quiet) {
73
- process.stdout.write(paint('✓ polin-guard: no injection indicators found', '32', c) +
74
- ` (${res.filesScanned} file${res.filesScanned === 1 ? '' : 's'} scanned)\n`);
75
- }
97
+ if (!quiet) process.stdout.write(paint('✓ polin-guard: no supply-chain risks found', '32', c) + '\n');
76
98
  return 0;
77
99
  }
100
+ printFindings('polin-guard: supply-chain risks detected', res.findings, blocking, c);
101
+ process.stderr.write(`${res.critical.length} critical, ${res.warnings.length} warning(s). ` +
102
+ `Run \`polin-guard harden --fix\` to block install scripts.\n`);
103
+ return blocking ? 1 : 0;
104
+ }
78
105
 
79
- const header = res.blocking
80
- ? paint('✖ polin-guard: potential code injection detected', '1;31', c)
81
- : paint('⚠ polin-guard: review-worthy findings', '33', c);
82
- process.stderr.write(`\n${header}\n\n`);
106
+ function cmdHarden(argv) {
107
+ const fix = argv.includes('--fix');
108
+ const c = useColor(argv);
109
+ let r;
110
+ try { r = harden(process.cwd(), { fix }); }
111
+ catch (e) { process.stderr.write(`polin-guard: ${e.message}\n`); return 2; }
83
112
 
84
- // Group findings by file.
85
- const byFile = new Map();
86
- for (const f of res.findings) {
87
- if (!byFile.has(f.file)) byFile.set(f.file, []);
88
- byFile.get(f.file).push(f);
89
- }
90
- for (const [file, items] of byFile) {
91
- process.stderr.write(paint(file, '36', c) + '\n');
92
- for (const it of items) {
93
- const tag = it.severity === 'critical'
94
- ? paint('CRITICAL', '1;31', c)
95
- : paint('warning ', '33', c);
96
- process.stderr.write(` ${tag} ${file}:${it.line} [${it.ruleId}]\n ${it.message}\n`);
97
- }
98
- process.stderr.write('\n');
99
- }
113
+ if (r.hasIgnore) process.stdout.write(paint('✓ ignore-scripts is already enabled', '32', c) + ` (${r.npmrc})\n`);
114
+ else if (r.applied) process.stdout.write(paint('✓ enabled ignore-scripts=true', '32', c) + ` in ${r.npmrc}\n`);
115
+ else process.stdout.write(paint('⚠ ignore-scripts is NOT enabled', '33', c) + ` — run \`polin-guard harden --fix\` to set it.\n`);
100
116
 
101
- if (res.blocking) {
102
- process.stderr.write(
103
- 'Commit blocked. If this is a genuine attack, do NOT commit — investigate the file.\n' +
104
- 'If this is a verified false positive, acknowledge the line with a\n' +
105
- `"// polinguard-allow-line" comment, an "polinguard-allow-next-line" comment above it,\n` +
106
- 'or adjust .polinguardrc.json. (Bypass for one commit: git commit --no-verify.)\n'
107
- );
108
- return 1;
109
- }
110
- process.stderr.write('Warnings only — not blocking. Use --strict to block on warnings.\n');
117
+ process.stdout.write('\nRecommendations:\n');
118
+ for (const rec of r.recommendations) process.stdout.write(` • ${rec}\n`);
111
119
  return 0;
112
120
  }
113
121
 
122
+ function main() {
123
+ const argv = process.argv.slice(2);
124
+ if (argv.includes('-h') || argv.includes('--help')) { process.stdout.write(HELP + '\n'); return 0; }
125
+ if (argv.includes('-v') || argv.includes('--version')) {
126
+ try { process.stdout.write(require('../package.json').version + '\n'); } catch { process.stdout.write('0.0.0\n'); }
127
+ return 0;
128
+ }
129
+ const sub = argv[0];
130
+ if (sub === 'install-audit' || sub === 'audit') return cmdAudit(argv.slice(1));
131
+ if (sub === 'harden') return cmdHarden(argv.slice(1));
132
+ if (sub === 'scan') return cmdScan(argv.slice(1));
133
+ return cmdScan(argv); // default: scan
134
+ }
135
+
114
136
  process.exit(main());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polin-guard",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "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.",
5
5
  "bin": {
6
6
  "polin-guard": "bin/cli.js"
package/src/patterns.js CHANGED
@@ -1,25 +1,40 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Detection rules for polin-guard.
4
+ * Detection model for polin-guard (v0.2).
5
5
  *
6
- * These target the family of obfuscated build/commit-time JavaScript "stagers"
7
- * that hide a payload on a single, heavily space-padded line inside an otherwise
8
- * legitimate config or entry file (e.g. tailwind.config.js, ecosystem.config.js,
9
- * .eslintrc.js, postcss.config.js, src/index.ts). The payload typically:
10
- * - decodes strings at runtime via a character-shuffle cipher,
11
- * - re-exposes Node's `require`/`module` as globals, and
12
- * - runs a second stage through a Function() constructor.
6
+ * v0.1 was signature-only and therefore evadable. v0.2 detects the *necessary
7
+ * conditions* of the attack class and combines independent signals into a
8
+ * weighted RISK SCORE. To stay hidden yet execute at build time, the payload is
9
+ * forced to do several things at once — and each is a detector here:
13
10
  *
14
- * The goal is HIGH precision: a "critical" finding should almost never be a
15
- * false positive, so it is safe to BLOCK a commit on it.
11
+ * - HIDE visually -> concealment (code after a long whitespace gap),
12
+ * long unbroken tokens, dense escapes, high entropy
13
+ * - EXECUTE implicitly -> dynamic exec sinks (Function/eval/indirect require/
14
+ * constructor.constructor/vm/string-timer) in
15
+ * auto-loaded config/entry files
16
+ * - OBFUSCATE -> entropy / escape / long-token signals (token-agnostic)
17
+ * - REACH SECRETS -> env/fs/child_process + network capability
18
+ *
19
+ * Defeating one detector by renaming/splitting/runtime-fetching still trips the
20
+ * others, so evasion becomes self-defeating (visible, inert, readable, or
21
+ * capability-less). The known-family signatures remain as fast, high-weight hits.
16
22
  */
17
23
 
18
- // Default thresholds (override via .polinguardrc.json).
19
24
  const DEFAULTS = {
20
- maxLineLength: 1000, // a single source line longer than this is suspicious
21
- maxEscapes: 25, // count of \xNN / \uNNNN escapes on one line => obfuscated blob
22
- // Files / directories that legitimately contain long or generated lines.
25
+ // Scoring thresholds.
26
+ criticalScore: 70, // >= this blocks the commit
27
+ warningScore: 35, // >= this is reported (non-blocking unless --strict)
28
+
29
+ // Detector thresholds.
30
+ maxLineLength: 1000, // oversized source line
31
+ maxEscapes: 25, // \xNN / \uNNNN escapes on one line => obfuscated blob
32
+ maxTokenLength: 120, // unbroken non-whitespace run => encoded blob
33
+ minGapWhitespace: 80, // code hidden after this many mid-line spaces/tabs
34
+ entropyMinLen: 200, // only entropy-score lines at least this long
35
+ entropyThreshold: 4.3, // bits/char; obfuscated/encoded content runs high
36
+ fileEscapeTotal: 100, // total escapes across a file (catches split payloads)
37
+
23
38
  excludeDirs: [
24
39
  'node_modules', '.git', 'dist', 'build', 'out', 'coverage',
25
40
  '.next', '.nuxt', '.output', '.turbo', '.cache', 'vendor', '__snapshots__',
@@ -30,63 +45,53 @@ const DEFAULTS = {
30
45
  /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/i,
31
46
  /\.snap$/i,
32
47
  ],
33
- // Only these extensions are scanned. Covers JS/TS, Vue, configs, and the
34
- // Windows batch / shell droppers seen alongside the JS stager.
35
48
  includeExtensions: [
36
49
  '.js', '.cjs', '.mjs', '.jsx', '.ts', '.tsx', '.vue',
37
50
  '.json', '.bat', '.cmd', '.ps1', '.sh',
38
51
  ],
39
52
  };
40
53
 
41
- // Near-unique signatures of the known stager. Each match is CRITICAL on its own.
42
- const SIGNATURES = [
43
- {
44
- id: 'global-bang-key',
45
- re: /global\s*\[\s*['"`]!['"`]\s*\]/,
46
- message: "Assigns to global['!'] a known obfuscated-stager marker.",
47
- },
48
- {
49
- id: 'global-underscore-handle',
50
- re: /global\s*\[\s*_\$_/,
51
- message: 'Assigns to global[_$_…] — obfuscated stager variable handle.',
52
- },
53
- {
54
- id: 'require-reexposed',
55
- re: /\]\s*=\s*require\s*;[\s\S]{0,60}typeof\s+module/,
56
- message: 'Re-exposes require()/module as globals capability-escalation pattern.',
57
- },
58
- {
59
- id: 'char-shuffle-cipher',
60
- // String.fromCharCode(127) used as a sentinel/delimiter in the shuffle cipher.
61
- // Legitimate uses are virtually always inside excluded node_modules (e.g. websocket).
62
- re: /String\.fromCharCode\(\s*127\s*\)/,
63
- message: 'Uses fromCharCode(127) cipher delimiter — stager string-decoder pattern.',
64
- },
65
- ];
66
-
67
- // Immediately-invoked Function() / this[...] constructor: a second-stage exec sink.
68
- const IIFE_CONSTRUCTOR =
69
- /(?:\bFunction\b|this\s*\[[^\]]+\]|global\s*\[[^\]]+\])\s*\([^)]*\)\s*\(/;
70
-
71
- // Tokens that turn an over-long line from "suspicious" into "critical".
72
- const EXEC_TOKENS =
73
- /\b(require|eval|atob|unescape|child_process|execSync|spawnSync|Function)\b|process\s*\.\s*env|global\s*\[|String\.fromCharCode/;
54
+ // Per-signal weights (points added to a line's / file's risk score).
55
+ const WEIGHTS = {
56
+ signature: 60, // a known-family signature
57
+ concealment: 80, // code after a long mid-line whitespace gap (off-screen trick)
58
+ longToken: 35, // unbroken token >= maxTokenLength
59
+ escapeDense: 50, // >= maxEscapes on one line
60
+ entropy: 25, // long, high-entropy line
61
+ oversized: 25, // line longer than maxLineLength
62
+ oversizedExecBonus: 30, // ...and it also carries exec/require tokens
63
+ execSink: 40, // Function()/eval dynamic execution
64
+ indirectRequire: 25, // require(<non-literal>)
65
+ ctorChain: 35, // constructor.constructor / ['constructor']
66
+ dynTimer: 25, // setTimeout/Interval("string")
67
+ vmModule: 25, // require('vm')
68
+ network: 15, // fetch / http(s)/net/dns/tls
69
+ capability: 10, // process.env / fs / child_process
70
+ netExecCombo: 30, // network + exec/file-write on the same line
71
+ autoloadBonus: 20, // exec/capability/network inside an auto-loaded file
72
+ };
74
73
 
75
- // Standalone weaker indicators (reported as warnings, never block on their own).
76
- const SOFT_INDICATORS = [
77
- { id: 'eval-call', re: /\beval\s*\(/, message: 'Contains eval().' },
78
- { id: 'atob-call', re: /\batob\s*\(/, message: 'Contains atob() (base64 decode).' },
79
- {
80
- id: 'child-process-in-config',
81
- re: /require\(\s*['"`]child_process['"`]\s*\)/,
82
- message: "Loads child_process.",
83
- },
74
+ // Known-family signatures (fast, high-confidence). Each adds WEIGHTS.signature.
75
+ const SIGNATURES = [
76
+ { id: 'global-bang-key', re: /global\s*\[\s*['"`]!['"`]\s*\]/, message: "global['!'] stager marker" },
77
+ { id: 'global-underscore-handle', re: /global\s*\[\s*_\$_/, message: 'global[_$_…] obfuscated handle' },
78
+ { id: 'require-reexposed', re: /\]\s*=\s*require\s*;[\s\S]{0,60}typeof\s+module/, message: 'require/module re-exposed as globals' },
79
+ { id: 'char-shuffle-cipher', re: /String\.fromCharCode\(\s*127\s*\)/, message: 'fromCharCode(127) cipher delimiter' },
84
80
  ];
85
81
 
86
- module.exports = {
87
- DEFAULTS,
88
- SIGNATURES,
89
- IIFE_CONSTRUCTOR,
90
- EXEC_TOKENS,
91
- SOFT_INDICATORS,
82
+ // Behavioral / structural regexes (token-agnostic where possible).
83
+ const RE = {
84
+ execSink: /\b(?:new\s+)?Function\s*\(|\beval\s*\(/,
85
+ indirectRequire: /\brequire\s*\(\s*(?!['"`)])/, // require( not immediately a string
86
+ ctorChain: /\bconstructor\b\s*(?:\.\s*constructor|\[\s*['"`]\s*constructor)|\[\s*['"`]constructor['"`]\s*\]/,
87
+ dynTimer: /\bset(?:Timeout|Interval)\s*\(\s*['"`]/,
88
+ vmModule: /\brequire\s*\(\s*['"`]vm['"`]\s*\)/,
89
+ network: /\bfetch\s*\(|\bXMLHttpRequest\b|require\s*\(\s*['"`](?:https?|net|dns|tls|dgram)['"`]\s*\)|\bhttps?\s*\.\s*(?:get|request)\b/,
90
+ capability: /\bprocess\s*\.\s*env\b|require\s*\(\s*['"`](?:fs|os|child_process)['"`]\s*\)|\bchild_process\b/,
91
+ fsWrite: /\b(?:writeFileSync|writeFile|appendFileSync|appendFile|createWriteStream)\b/,
92
+ childProc: /\bchild_process\b|\b(?:execSync|spawnSync|spawn|fork)\s*\(|\bexec\s*\(/,
93
+ // tokens that upgrade an oversized line to critical
94
+ execTokens: /\b(?:require|eval|atob|unescape|child_process|Function)\b|process\s*\.\s*env|global\s*\[|String\.fromCharCode/,
92
95
  };
96
+
97
+ module.exports = { DEFAULTS, WEIGHTS, SIGNATURES, RE };
package/src/scan.js CHANGED
@@ -3,27 +3,17 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execFileSync } = require('child_process');
6
- const {
7
- DEFAULTS,
8
- SIGNATURES,
9
- IIFE_CONSTRUCTOR,
10
- EXEC_TOKENS,
11
- SOFT_INDICATORS,
12
- } = require('./patterns');
6
+ const { DEFAULTS, WEIGHTS, SIGNATURES, RE } = require('./patterns');
13
7
 
14
8
  const ALLOW_LINE_MARKER = 'polinguard-allow-next-line';
15
9
  const ALLOW_INLINE_MARKER = 'polinguard-allow-line';
16
10
 
17
11
  /** Load optional config file from the repo root or cwd. */
18
12
  function loadConfig(cwd) {
19
- const candidates = ['.polinguardrc.json', '.polinguard.json'];
20
- for (const name of candidates) {
13
+ for (const name of ['.polinguardrc.json', '.polinguard.json']) {
21
14
  const p = path.join(cwd, name);
22
15
  try {
23
- if (fs.existsSync(p)) {
24
- const user = JSON.parse(fs.readFileSync(p, 'utf8'));
25
- return { ...DEFAULTS, ...user };
26
- }
16
+ if (fs.existsSync(p)) return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(p, 'utf8')) };
27
17
  } catch (e) {
28
18
  process.stderr.write(`polin-guard: ignoring invalid ${name}: ${e.message}\n`);
29
19
  }
@@ -34,34 +24,33 @@ function loadConfig(cwd) {
34
24
  function git(args, cwd) {
35
25
  return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 * 64 });
36
26
  }
37
-
38
- /** Files staged for commit (added/copied/modified/renamed). */
39
27
  function getStagedFiles(cwd) {
40
- const out = git(['diff', '--cached', '--name-only', '--diff-filter=ACMR'], cwd);
41
- return out.split('\n').filter(Boolean);
28
+ return git(['diff', '--cached', '--name-only', '--diff-filter=ACMR'], cwd).split('\n').filter(Boolean);
42
29
  }
43
-
44
- /** All tracked files (for --all / --ci). */
45
30
  function getTrackedFiles(cwd) {
46
31
  return git(['ls-files'], cwd).split('\n').filter(Boolean);
47
32
  }
48
-
49
- /** Read the *staged* blob content (what will actually be committed). */
50
33
  function readStaged(file, cwd) {
51
- try {
52
- return git(['show', `:${file}`], cwd);
53
- } catch (e) {
54
- return null; // deleted or unreadable
55
- }
34
+ try { return git(['show', `:${file}`], cwd); } catch { return null; }
56
35
  }
57
36
 
58
37
  function isExcluded(file, cfg) {
59
38
  const parts = file.split(/[\\/]/);
60
39
  if (parts.some((p) => cfg.excludeDirs.includes(p))) return true;
61
40
  if (cfg.excludeFilePatterns.some((re) => re.test(file))) return true;
62
- const ext = path.extname(file).toLowerCase();
63
- if (!cfg.includeExtensions.includes(ext)) return true;
64
- return false;
41
+ return !cfg.includeExtensions.includes(path.extname(file).toLowerCase());
42
+ }
43
+
44
+ /** Files that a build/test tool loads automatically — exec here is high-risk. */
45
+ function isAutoLoaded(file) {
46
+ const base = (file.split(/[\\/]/).pop() || '').toLowerCase();
47
+ return (
48
+ /\.config\.(js|cjs|mjs|ts)$/.test(base) ||
49
+ /^\.?eslintrc(\.(js|cjs|json|yml|yaml))?$/.test(base) ||
50
+ /^(index|main|server|app)\.(js|cjs|mjs|ts)$/.test(base) ||
51
+ /^\.(babelrc|prettierrc|stylelintrc)/.test(base) ||
52
+ base === 'ecosystem.config.js'
53
+ );
65
54
  }
66
55
 
67
56
  function countEscapes(line) {
@@ -69,103 +58,151 @@ function countEscapes(line) {
69
58
  return m ? m.length : 0;
70
59
  }
71
60
 
72
- /** Analyze a single line. Returns array of findings for that line. */
73
- function analyzeLine(line, cfg) {
74
- const out = [];
61
+ /** Shannon entropy (bits/char). Obfuscated/encoded blobs run high. */
62
+ function entropy(s) {
63
+ if (!s.length) return 0;
64
+ const freq = Object.create(null);
65
+ for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
66
+ let h = 0;
67
+ for (const k in freq) { const p = freq[k] / s.length; h -= p * Math.log2(p); }
68
+ return h;
69
+ }
75
70
 
76
- // 1) Near-unique stager signatures -> always critical.
77
- for (const sig of SIGNATURES) {
78
- if (sig.re.test(line)) {
79
- out.push({ ruleId: sig.id, severity: 'critical', message: sig.message });
80
- }
71
+ function longestToken(line) {
72
+ let max = 0;
73
+ for (const t of line.split(/\s+/)) if (t.length > max) max = t.length;
74
+ return max;
75
+ }
76
+
77
+ /**
78
+ * Score a single line. Returns { score, signals: [{id, weight, message}], flags }.
79
+ * `flags` exposes booleans the file-level pass aggregates.
80
+ */
81
+ function analyzeLine(line, cfg, ctx = {}) {
82
+ const signals = [];
83
+ const add = (id, weight, message) => signals.push({ id, weight, message });
84
+
85
+ for (const sig of SIGNATURES) if (sig.re.test(line)) add(sig.id, WEIGHTS.signature, sig.message);
86
+
87
+ // Concealment: code after a long mid-line whitespace gap (off-screen trick).
88
+ const body = line.replace(/^[ \t]+/, '');
89
+ if (new RegExp(`\\S[ \\t]{${cfg.minGapWhitespace},}\\S`).test(body)) {
90
+ add('concealment', WEIGHTS.concealment, `code hidden after ${cfg.minGapWhitespace}+ spaces (off-screen concealment)`);
81
91
  }
82
92
 
83
- // 2) Dense escape-sequence blob -> obfuscated payload.
93
+ const tok = longestToken(body);
94
+ if (tok >= cfg.maxTokenLength) add('long-token', WEIGHTS.longToken, `unbroken ${tok}-char token (encoded blob)`);
95
+
84
96
  const esc = countEscapes(line);
85
- if (esc >= cfg.maxEscapes) {
86
- out.push({
87
- ruleId: 'escape-density',
88
- severity: 'critical',
89
- message: `High escape-sequence density (${esc} \\x/\\u escapes) — obfuscated blob.`,
90
- });
91
- }
97
+ if (esc >= cfg.maxEscapes) add('escape-density', WEIGHTS.escapeDense, `${esc} \\x/\\u escapes (obfuscated blob)`);
92
98
 
93
- // 3) Immediately-invoked Function() constructor on a long line -> exec sink.
94
- if (IIFE_CONSTRUCTOR.test(line) && line.length > 200) {
95
- out.push({
96
- ruleId: 'iife-constructor',
97
- severity: 'critical',
98
- message: 'Immediately-invoked Function()/dynamic constructor on a long line — second-stage exec sink.',
99
- });
99
+ if (line.length >= cfg.entropyMinLen) {
100
+ const h = entropy(line);
101
+ if (h >= cfg.entropyThreshold) add('entropy', WEIGHTS.entropy, `high entropy ${h.toFixed(2)} over ${line.length} chars`);
100
102
  }
101
103
 
102
- // 4) Oversized line: critical if it also carries exec/require tokens, else a warning.
103
104
  if (line.length > cfg.maxLineLength) {
104
- const exec = EXEC_TOKENS.test(line);
105
- out.push({
106
- ruleId: 'oversized-line',
107
- severity: exec ? 'critical' : 'warning',
108
- message: `Line length ${line.length} exceeds limit (${cfg.maxLineLength})` +
109
- (exec ? ' and contains exec/require tokens — classic hidden-payload concealment.' : ' — review for hidden content.'),
110
- });
105
+ const exec = RE.execTokens.test(line);
106
+ add('oversized-line', WEIGHTS.oversized + (exec ? WEIGHTS.oversizedExecBonus : 0),
107
+ `line length ${line.length}${exec ? ' with exec/require tokens' : ''}`);
111
108
  }
112
109
 
113
- // 5) Soft indicators (warnings only).
114
- for (const ind of SOFT_INDICATORS) {
115
- if (ind.re.test(line)) {
116
- out.push({ ruleId: ind.id, severity: 'warning', message: ind.message });
117
- }
110
+ const execSink = RE.execSink.test(line);
111
+ if (execSink) add('exec-sink', WEIGHTS.execSink, 'dynamic code-exec sink (Function/eval)');
112
+ if (RE.indirectRequire.test(line)) add('indirect-require', WEIGHTS.indirectRequire, 'require() with a non-literal argument');
113
+ if (RE.ctorChain.test(line)) add('ctor-chain', WEIGHTS.ctorChain, 'constructor.constructor access (reaches Function)');
114
+ if (RE.dynTimer.test(line)) add('dyn-timer', WEIGHTS.dynTimer, 'setTimeout/Interval with a string body');
115
+ if (RE.vmModule.test(line)) add('vm-module', WEIGHTS.vmModule, 'loads the vm module');
116
+
117
+ const net = RE.network.test(line);
118
+ if (net) add('network', WEIGHTS.network, 'network access');
119
+ const cap = RE.capability.test(line);
120
+ if (cap) add('capability', WEIGHTS.capability, 'env/fs/child_process capability');
121
+ const fsWrite = RE.fsWrite.test(line);
122
+ const child = RE.childProc.test(line);
123
+
124
+ if (net && (execSink || RE.indirectRequire.test(line) || fsWrite || child)) {
125
+ add('net-exec-combo', WEIGHTS.netExecCombo, 'network + code-exec/file-write on one line (runtime-fetched payload)');
118
126
  }
127
+ if (ctx.autoLoaded && (execSink || cap || net || child)) {
128
+ add('autoloaded-context', WEIGHTS.autoloadBonus, 'in an auto-loaded config/entry file');
129
+ }
130
+
131
+ const score = signals.reduce((a, s) => a + s.weight, 0);
132
+ return { score, signals, flags: { execSink, net, cap, fsWrite, child, esc, longTok: tok >= cfg.maxTokenLength } };
133
+ }
119
134
 
120
- return out;
135
+ function severityFor(score, cfg) {
136
+ if (score >= cfg.criticalScore) return 'critical';
137
+ if (score >= cfg.warningScore) return 'warning';
138
+ return null;
121
139
  }
122
140
 
123
- /** Scan one file's content (string). */
141
+ /** Scan one file's content (string) -> findings[]. */
124
142
  function scanContent(file, content, cfg) {
125
- const findings = [];
143
+ const ctx = { autoLoaded: isAutoLoaded(file) };
126
144
  const lines = content.split(/\r?\n/);
145
+ const findings = [];
146
+
147
+ let fileEsc = 0, anyExec = false, anyNet = false, anyChild = false, anyFsWrite = false, anyLongTok = false;
148
+
127
149
  for (let i = 0; i < lines.length; i++) {
128
150
  const line = lines[i];
129
- // Inline allow markers let a maintainer acknowledge a known-good long line.
130
151
  if (line.includes(ALLOW_INLINE_MARKER)) continue;
131
152
  if (i > 0 && lines[i - 1].includes(ALLOW_LINE_MARKER)) continue;
132
153
 
133
- const lineFindings = analyzeLine(line, cfg);
134
- for (const f of lineFindings) {
135
- findings.push({ ...f, file, line: i + 1 });
154
+ const { score, signals, flags } = analyzeLine(line, cfg, ctx);
155
+ fileEsc += flags.esc;
156
+ anyExec = anyExec || flags.execSink;
157
+ anyNet = anyNet || flags.net;
158
+ anyChild = anyChild || flags.child;
159
+ anyFsWrite = anyFsWrite || flags.fsWrite;
160
+ anyLongTok = anyLongTok || flags.longTok;
161
+
162
+ const sev = severityFor(score, cfg);
163
+ if (sev) {
164
+ const top = signals.slice().sort((a, b) => b.weight - a.weight);
165
+ findings.push({
166
+ file, line: i + 1, score, severity: sev,
167
+ ruleId: top[0].id,
168
+ message: `risk ${score} [${top.map((s) => s.id).join(', ')}] — ${top[0].message}`,
169
+ });
136
170
  }
137
171
  }
172
+
173
+ // File-level pass: catches payloads split across many lines or fetched at runtime.
174
+ const region = [];
175
+ if (fileEsc >= cfg.fileEscapeTotal && (anyExec || anyChild)) {
176
+ region.push({ s: 80, m: `${fileEsc} escape sequences across the file + a dynamic-exec sink (split/obfuscated payload)` });
177
+ }
178
+ if (anyLongTok && (anyExec || anyChild)) {
179
+ region.push({ s: 55, m: 'long encoded token(s) + a dynamic-exec sink' });
180
+ }
181
+ if (anyNet && (anyExec || anyChild || anyFsWrite)) {
182
+ region.push({ s: 55 + (ctx.autoLoaded ? WEIGHTS.autoloadBonus : 0), m: 'network access + code-exec/file-write across the file (runtime-fetched payload)' });
183
+ }
184
+ if (region.length) {
185
+ const best = region.sort((a, b) => b.s - a.s)[0];
186
+ const sev = severityFor(best.s, cfg);
187
+ if (sev) findings.push({ file, line: 0, score: best.s, severity: sev, ruleId: 'file-region', message: `file-level risk ${best.s} — ${best.m}` });
188
+ }
189
+
138
190
  return findings;
139
191
  }
140
192
 
141
- /**
142
- * Run a scan.
143
- * @param {object} opts
144
- * @param {'staged'|'all'|'paths'} opts.mode
145
- * @param {string[]} [opts.paths] explicit paths when mode === 'paths'
146
- * @param {string} [opts.cwd]
147
- * @param {boolean} [opts.strict] treat warnings as blocking too
148
- */
149
193
  function run(opts = {}) {
150
194
  const cwd = opts.cwd || process.cwd();
151
195
  const cfg = loadConfig(cwd);
152
196
  const mode = opts.mode || 'staged';
153
197
 
154
- let files = [];
155
- let readFile;
156
-
198
+ let files, readFile;
157
199
  if (mode === 'paths') {
158
200
  files = opts.paths || [];
159
- readFile = (f) => {
160
- try { return fs.readFileSync(path.resolve(cwd, f), 'utf8'); } catch { return null; }
161
- };
201
+ readFile = (f) => { try { return fs.readFileSync(path.resolve(cwd, f), 'utf8'); } catch { return null; } };
162
202
  } else if (mode === 'all') {
163
203
  files = getTrackedFiles(cwd);
164
- readFile = (f) => {
165
- try { return fs.readFileSync(path.resolve(cwd, f), 'utf8'); } catch { return null; }
166
- };
204
+ readFile = (f) => { try { return fs.readFileSync(path.resolve(cwd, f), 'utf8'); } catch { return null; } };
167
205
  } else {
168
- // staged (default): scan the exact content that will be committed.
169
206
  files = getStagedFiles(cwd);
170
207
  readFile = (f) => readStaged(f, cwd);
171
208
  }
@@ -183,8 +220,7 @@ function run(opts = {}) {
183
220
  const critical = findings.filter((f) => f.severity === 'critical');
184
221
  const warnings = findings.filter((f) => f.severity === 'warning');
185
222
  const blocking = opts.strict ? findings.length > 0 : critical.length > 0;
186
-
187
223
  return { filesScanned: scanned.length, findings, critical, warnings, blocking };
188
224
  }
189
225
 
190
- module.exports = { run, scanContent, analyzeLine, loadConfig };
226
+ module.exports = { run, scanContent, analyzeLine, loadConfig, entropy, isAutoLoaded };
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Supply-chain layer for polin-guard.
5
+ *
6
+ * The scanner (scan.js) catches a payload that is already in your tree. This
7
+ * module attacks the *root cause*: a malicious dependency that executes code at
8
+ * `npm install` / build time. It reads manifests and lockfiles WITHOUT installing
9
+ * anything, and flags the vectors that actually deliver these attacks:
10
+ *
11
+ * - lifecycle install scripts (pre/post-install, prepare, …) — how install-time
12
+ * code runs at all — and especially scripts that pipe the network to a shell
13
+ * - dependencies pulled from non-registry sources (git/http/tarball/file)
14
+ * - packages resolved from a non-default registry (registry hijack)
15
+ * - transitive packages that declare an install/build script (the real surface)
16
+ * - typosquatted / homoglyph package names
17
+ *
18
+ * `harden()` flips on the defenses (ignore-scripts) so install scripts can't run.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ const LIFECYCLE = [
25
+ 'preinstall', 'install', 'postinstall',
26
+ 'preuninstall', 'postuninstall',
27
+ 'prepare', 'prepublish', 'prepublishOnly', 'prepack', 'postpack',
28
+ ];
29
+
30
+ // Commands inside a script that strongly indicate malicious install-time code.
31
+ const SUSPICIOUS_SCRIPT = new RegExp(
32
+ [
33
+ '(?:curl|wget)\\s+[^|&;]*\\|\\s*(?:sh|bash|node|python)', // curl … | sh
34
+ '\\bnode\\s+(?:-e|--eval)\\b', // node -e "…"
35
+ '\\bbash\\s+-c\\b',
36
+ 'base64\\s+(?:-d|--decode|-D)',
37
+ '\\beval\\b',
38
+ '\\b(?:powershell|iwr|Invoke-WebRequest|certutil)\\b',
39
+ '\\bchild_process\\b',
40
+ 'https?:\\/\\/\\d{1,3}(?:\\.\\d{1,3}){3}', // raw IP URL
41
+ '\\b(?:atob|fromCharCode)\\b',
42
+ '\\/dev\\/tcp\\/',
43
+ ].join('|'),
44
+ 'i'
45
+ );
46
+
47
+ // A small set of very popular packages, for typosquat distance checks.
48
+ const POPULAR = [
49
+ 'react', 'react-dom', 'lodash', 'express', 'chalk', 'axios', 'commander',
50
+ 'webpack', 'vue', 'next', 'nuxt', 'vite', 'eslint', 'prettier', 'typescript',
51
+ 'tailwindcss', 'dotenv', 'jest', 'babel', 'rollup', 'moment', 'dayjs',
52
+ 'request', 'debug', 'colors', 'cross-env', 'node-fetch', 'uuid',
53
+ ];
54
+
55
+ const DEFAULT_REGISTRY_HOSTS = new Set(['registry.npmjs.org']);
56
+
57
+ function levenshtein(a, b) {
58
+ const m = a.length, n = b.length;
59
+ const d = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
60
+ for (let j = 0; j <= n; j++) d[0][j] = j;
61
+ for (let i = 1; i <= m; i++) {
62
+ for (let j = 1; j <= n; j++) {
63
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
64
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
65
+ }
66
+ }
67
+ return d[m][n];
68
+ }
69
+
70
+ function readJson(p) {
71
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
72
+ }
73
+
74
+ function allowedRegistryHosts(cwd) {
75
+ const hosts = new Set(DEFAULT_REGISTRY_HOSTS);
76
+ for (const p of [path.join(cwd, '.npmrc'), path.join(require('os').homedir(), '.npmrc')]) {
77
+ try {
78
+ const txt = fs.readFileSync(p, 'utf8');
79
+ const m = txt.match(/^\s*registry\s*=\s*(\S+)/m);
80
+ if (m) { try { hosts.add(new URL(m[1]).host); } catch { /* ignore */ } }
81
+ } catch { /* no .npmrc */ }
82
+ }
83
+ return hosts;
84
+ }
85
+
86
+ function hostOf(url) {
87
+ try { return new URL(url.replace(/^git\+/, '')).host; } catch { return null; }
88
+ }
89
+
90
+ function isNonRegistrySpec(spec) {
91
+ return /^(?:git\+|git:|github:|gitlab:|bitbucket:|https?:|file:|link:|portal:)/.test(spec) ||
92
+ /^[\w.-]+\/[\w.-]+(?:#.*)?$/.test(spec); // github user/repo shorthand
93
+ }
94
+
95
+ /** Audit package.json scripts + dependency sources. */
96
+ function auditManifest(cwd, findings) {
97
+ const pkgPath = path.join(cwd, 'package.json');
98
+ const pkg = readJson(pkgPath);
99
+ if (!pkg) { findings.push({ severity: 'info', ruleId: 'no-manifest', where: 'package.json', message: 'No readable package.json found.' }); return null; }
100
+
101
+ const scripts = pkg.scripts || {};
102
+ for (const [name, body] of Object.entries(scripts)) {
103
+ const isLifecycle = LIFECYCLE.includes(name);
104
+ if (SUSPICIOUS_SCRIPT.test(String(body))) {
105
+ findings.push({ severity: 'critical', ruleId: 'malicious-script', where: `package.json scripts.${name}`,
106
+ message: `Script "${name}" runs a suspicious command (network-to-shell / eval / encoded): ${String(body).slice(0, 120)}` });
107
+ } else if (isLifecycle) {
108
+ findings.push({ severity: 'warning', ruleId: 'install-script', where: `package.json scripts.${name}`,
109
+ message: `This package defines a lifecycle install script "${name}" — it will run on install.` });
110
+ }
111
+ }
112
+
113
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
114
+ const deps = pkg[field] || {};
115
+ for (const [name, spec] of Object.entries(deps)) {
116
+ if (typeof spec === 'string' && isNonRegistrySpec(spec)) {
117
+ findings.push({ severity: 'warning', ruleId: 'non-registry-source', where: `package.json ${field}.${name}`,
118
+ message: `Dependency "${name}" is installed from a non-registry source: ${spec}` });
119
+ }
120
+ // typosquat / homoglyph
121
+ if (/[^\x00-\x7f]/.test(name)) {
122
+ findings.push({ severity: 'critical', ruleId: 'homoglyph-name', where: `package.json ${field}.${name}`,
123
+ message: `Dependency name "${name}" contains non-ASCII characters (possible homoglyph squat).` });
124
+ } else {
125
+ const base = name.replace(/^@[^/]+\//, '');
126
+ for (const pop of POPULAR) {
127
+ if (base !== pop && Math.abs(base.length - pop.length) <= 1 && levenshtein(base, pop) === 1) {
128
+ findings.push({ severity: 'critical', ruleId: 'typosquat', where: `package.json ${field}.${name}`,
129
+ message: `Dependency "${name}" is one character away from popular package "${pop}" (possible typosquat).` });
130
+ break;
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return pkg;
137
+ }
138
+
139
+ /** Audit lockfiles for non-registry resolutions and install-script packages. */
140
+ function auditLockfiles(cwd, findings) {
141
+ const allowed = allowedRegistryHosts(cwd);
142
+ let installScriptPkgs = 0;
143
+
144
+ // npm: package-lock.json (v2/v3)
145
+ const lock = readJson(path.join(cwd, 'package-lock.json'));
146
+ if (lock && lock.packages) {
147
+ for (const [loc, info] of Object.entries(lock.packages)) {
148
+ if (!loc) continue;
149
+ if (info.hasInstallScript) installScriptPkgs++;
150
+ const resolved = info.resolved;
151
+ if (resolved && /^https?:/.test(resolved)) {
152
+ const h = hostOf(resolved);
153
+ if (h && !allowed.has(h)) {
154
+ findings.push({ severity: 'critical', ruleId: 'foreign-registry', where: `package-lock.json ${loc}`,
155
+ message: `Package resolved from a non-default registry/host "${h}": ${resolved}` });
156
+ }
157
+ } else if (resolved && /^git\+|^git:/.test(resolved)) {
158
+ findings.push({ severity: 'warning', ruleId: 'git-source', where: `package-lock.json ${loc}`,
159
+ message: `Package installed from a git source: ${resolved}` });
160
+ }
161
+ }
162
+ }
163
+
164
+ // pnpm: pnpm-lock.yaml (line scan — no YAML dependency)
165
+ try {
166
+ const txt = fs.readFileSync(path.join(cwd, 'pnpm-lock.yaml'), 'utf8');
167
+ const lines = txt.split(/\r?\n/);
168
+ for (let i = 0; i < lines.length; i++) {
169
+ const l = lines[i];
170
+ if (/^\s*requiresBuild:\s*true/.test(l)) installScriptPkgs++;
171
+ const tb = l.match(/tarball:\s*(\S+)/);
172
+ if (tb) {
173
+ const h = hostOf(tb[1]);
174
+ if (h && !allowed.has(h)) {
175
+ findings.push({ severity: 'critical', ruleId: 'foreign-registry', where: 'pnpm-lock.yaml',
176
+ message: `Package tarball from a non-default host "${h}": ${tb[1]}` });
177
+ }
178
+ }
179
+ if (/resolution:\s*\{[^}]*\b(repo|commit)\b/.test(l)) {
180
+ findings.push({ severity: 'warning', ruleId: 'git-source', where: 'pnpm-lock.yaml',
181
+ message: `Package resolved from a git source: ${l.trim().slice(0, 100)}` });
182
+ }
183
+ }
184
+ } catch { /* no pnpm-lock */ }
185
+
186
+ // yarn: yarn.lock
187
+ try {
188
+ const txt = fs.readFileSync(path.join(cwd, 'yarn.lock'), 'utf8');
189
+ for (const m of txt.matchAll(/resolved\s+"([^"]+)"/g)) {
190
+ const h = hostOf(m[1]);
191
+ if (h && /^https?:/.test(m[1]) && !allowed.has(h)) {
192
+ findings.push({ severity: 'critical', ruleId: 'foreign-registry', where: 'yarn.lock',
193
+ message: `Package resolved from a non-default host "${h}": ${m[1]}` });
194
+ }
195
+ }
196
+ } catch { /* no yarn.lock */ }
197
+
198
+ if (installScriptPkgs > 0) {
199
+ findings.push({ severity: 'info', ruleId: 'install-script-count', where: 'lockfile',
200
+ message: `${installScriptPkgs} installed package(s) declare an install/build script. Run \`polin-guard harden\` to block them with ignore-scripts.` });
201
+ }
202
+ }
203
+
204
+ /** Run the full supply-chain audit. */
205
+ function auditInstall(cwd) {
206
+ const findings = [];
207
+ auditManifest(cwd, findings);
208
+ auditLockfiles(cwd, findings);
209
+ const critical = findings.filter((f) => f.severity === 'critical');
210
+ const warnings = findings.filter((f) => f.severity === 'warning');
211
+ return { findings, critical, warnings, blocking: critical.length > 0 };
212
+ }
213
+
214
+ /** Check (and optionally apply) install-time hardening for this project. */
215
+ function harden(cwd, { fix = false } = {}) {
216
+ const npmrc = path.join(cwd, '.npmrc');
217
+ let current = '';
218
+ try { current = fs.readFileSync(npmrc, 'utf8'); } catch { /* none */ }
219
+ const hasIgnore = /^\s*ignore-scripts\s*=\s*true\s*$/m.test(current);
220
+
221
+ const result = { hasIgnore, applied: false, npmrc, recommendations: [] };
222
+ if (!hasIgnore) {
223
+ result.recommendations.push('Set `ignore-scripts=true` in .npmrc to stop dependency install scripts from executing.');
224
+ if (fix) {
225
+ const next = (current.replace(/\s*$/, '') + '\nignore-scripts=true\n').replace(/^\n/, '');
226
+ fs.writeFileSync(npmrc, next);
227
+ result.applied = true;
228
+ }
229
+ }
230
+ result.recommendations.push('Install with a frozen lockfile: `npm ci` / `pnpm install --frozen-lockfile`.');
231
+ result.recommendations.push('Pin the registry and review lockfile diffs in code review.');
232
+ return result;
233
+ }
234
+
235
+ module.exports = { auditInstall, harden, levenshtein, isNonRegistrySpec, SUSPICIOUS_SCRIPT, LIFECYCLE };