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 +156 -91
- package/bin/cli.js +92 -70
- package/package.json +1 -1
- package/src/patterns.js +69 -64
- package/src/scan.js +128 -92
- package/src/supplychain.js +235 -0
package/README.md
CHANGED
|
@@ -1,94 +1,169 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
86
|
+
```bash
|
|
87
|
+
npm install --save-dev polin-guard
|
|
88
|
+
```
|
|
15
89
|
|
|
16
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
92
|
+
```bash
|
|
93
|
+
npx polin-guard --all # scan every tracked file in the repo
|
|
94
|
+
```
|
|
24
95
|
|
|
25
|
-
|
|
96
|
+
### Block it on every commit (husky)
|
|
26
97
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
### Add the CI backstop (recommended)
|
|
42
108
|
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
npm install --save-dev polin-guard
|
|
47
|
-
```
|
|
114
|
+
### No Node? Use the standalone script
|
|
48
115
|
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
60
|
-
polin-guard
|
|
61
|
-
polin-guard --
|
|
62
|
-
polin-guard
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
141
|
+
## Supply-chain audit (root-cause defense)
|
|
68
142
|
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
### As a pre-commit hook (pre-commit.com framework)
|
|
151
|
+
It flags, by reading `package.json` and your lockfile:
|
|
81
152
|
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
160
|
+
Then **shut the door** so dependency scripts can't execute at all:
|
|
86
161
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
162
|
+
```bash
|
|
163
|
+
npx polin-guard harden --fix # writes ignore-scripts=true to .npmrc
|
|
164
|
+
```
|
|
90
165
|
|
|
91
|
-
|
|
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
|
-
|
|
181
|
+
**Acknowledging a verified false positive** (e.g. a legitimate inline blob):
|
|
107
182
|
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
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,
|
|
142
|
-
For each candidate file it reads the staged blob (`git show :file`)
|
|
143
|
-
copy,
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
npm test # runs the zero-dependency test suite (clean + inert-malicious fixtures)
|
|
150
|
-
```
|
|
212
|
+
## Links
|
|
151
213
|
|
|
152
|
-
|
|
153
|
-
|
|
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 —
|
|
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
|
-
|
|
12
|
-
--staged Scan files staged for commit (default;
|
|
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
|
|
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.
|
|
29
|
+
Docs & allowlisting: see README. Scan config via .polinguardrc.json.`;
|
|
30
30
|
|
|
31
|
-
function
|
|
32
|
-
const
|
|
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')
|
|
39
|
-
else if (a
|
|
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
|
-
|
|
45
|
-
}
|
|
64
|
+
const c = useColor(argv);
|
|
46
65
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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.
|
|
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
|
|
4
|
+
* Detection model for polin-guard (v0.2).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
//
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
76
|
-
const
|
|
77
|
-
{ id: '
|
|
78
|
-
{ id: '
|
|
79
|
-
{
|
|
80
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
/**
|
|
73
|
-
function
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 =
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
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 };
|