np-audit 1.3.0 → 1.4.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/LICENSE +21 -0
- package/README.md +56 -5
- package/package.json +1 -1
- package/src/cli.js +40 -181
- package/src/commands/alias.js +111 -0
- package/src/commands/ci.js +90 -0
- package/src/commands/config.js +71 -0
- package/src/commands/index.js +37 -0
- package/src/commands/install.js +109 -0
- package/src/commands/scan.js +82 -0
- package/src/{scanner.js → core/scanner.js} +47 -12
- package/src/{config.js → utils/config.js} +3 -0
- package/src/{output.js → utils/output.js} +22 -7
- package/src/{aware.js → utils/review.js} +56 -16
- /package/src/{detector.js → core/detector.js} +0 -0
- /package/src/{fetcher.js → utils/fetcher.js} +0 -0
- /package/src/{lockfile.js → utils/lockfile.js} +0 -0
- /package/src/{tarball.js → utils/tarball.js} +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simon Kobler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/np-audit)
|
|
4
|
+
[](https://www.npmjs.com/package/np-audit)
|
|
5
|
+
[](https://github.com/KoblerS/np-audit/blob/main/LICENSE)
|
|
6
|
+
[](https://github.com/KoblerS/np-audit/actions/workflows/ci.yml)
|
|
7
|
+
|
|
1
8
|
# np-audit — npm package auditor
|
|
2
9
|
|
|
3
10
|
Statically detect obfuscated code in npm `preinstall`/`postinstall` scripts **before** they run. Drop-in replacement for `npm install` and `npm ci`.
|
|
@@ -53,13 +60,16 @@ Real-world examples include:
|
|
|
53
60
|
|
|
54
61
|
## Install
|
|
55
62
|
|
|
63
|
+
**Global install (recommended):**
|
|
64
|
+
|
|
56
65
|
```bash
|
|
57
|
-
# Global install (recommended for daily use)
|
|
58
66
|
npm install -g np-audit
|
|
67
|
+
```
|
|
59
68
|
|
|
60
|
-
|
|
69
|
+
**Or use directly with npx:**
|
|
70
|
+
|
|
71
|
+
```bash
|
|
61
72
|
npx np-audit scan
|
|
62
|
-
npx np-audit install
|
|
63
73
|
```
|
|
64
74
|
|
|
65
75
|
After global install, use the `npa` command:
|
|
@@ -76,11 +86,16 @@ npa --version
|
|
|
76
86
|
|
|
77
87
|
| Command | Alias | Description |
|
|
78
88
|
|---|---|---|
|
|
79
|
-
| `npa install [package]` | `npa i
|
|
89
|
+
| `npa install [package]` | `npa i` | Audit then run `npm install` |
|
|
80
90
|
| `npa ci` | — | Audit then run `npm ci` |
|
|
81
91
|
| `npa scan` | `npa s` | Scan only, no install |
|
|
82
92
|
| `npa config get` | `npa c get` | Show current configuration |
|
|
83
|
-
| `npa config set <key> <value>` | `npa c set
|
|
93
|
+
| `npa config set <key> <value>` | `npa c set` | Update a config value |
|
|
94
|
+
| `npa alias` | — | Print shell hook for auto-scanning |
|
|
95
|
+
| `npa alias --install` | — | Install hook to shell profile |
|
|
96
|
+
| `npa alias --uninstall` | — | Remove hook from shell profile |
|
|
97
|
+
|
|
98
|
+
Use `npa <command> -h` for detailed help on any command.
|
|
84
99
|
|
|
85
100
|
### Flags
|
|
86
101
|
|
|
@@ -159,6 +174,42 @@ npa config set skipScopes '["@types","@babel"]'
|
|
|
159
174
|
|
|
160
175
|
Config is stored in `~/.npmauditor.json` (global) and can be overridden per project with `.npmauditor.json` in your project root.
|
|
161
176
|
|
|
177
|
+
### Shell Hook (npm alias)
|
|
178
|
+
|
|
179
|
+
Automatically run `npa scan` before every `npm install` or `npm ci`:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
# Install the hook to your shell profile (~/.zshrc or ~/.bashrc)
|
|
183
|
+
npa alias --install
|
|
184
|
+
|
|
185
|
+
# Reload your shell
|
|
186
|
+
source ~/.zshrc # or ~/.bashrc
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Now when you run `npm install` or `npm ci`, npa will scan first:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
$ npm install lodash
|
|
193
|
+
[npa] Scanning dependencies before npm install...
|
|
194
|
+
✔ No packages with install scripts found.
|
|
195
|
+
[npa] Scan passed. Running npm install...
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
If issues are found, the install is blocked:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
$ npm install evil-pkg
|
|
202
|
+
[npa] Scanning dependencies before npm install...
|
|
203
|
+
✗ evil-pkg@1.0.0 BLOCK (score: 9)
|
|
204
|
+
[npa] Scan found issues. Run 'npa install --aware' for interactive mode.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
To remove the hook:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
npa alias --uninstall
|
|
211
|
+
```
|
|
212
|
+
|
|
162
213
|
#### All config keys
|
|
163
214
|
|
|
164
215
|
| Key | Default | Description |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,31 +1,33 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const output = require('./output');
|
|
3
|
+
const { loadConfig, DEFAULT_CONFIG } = require('./utils/config');
|
|
4
|
+
const commands = require('./commands');
|
|
5
|
+
const output = require('./utils/output');
|
|
7
6
|
|
|
8
7
|
const VERSION = require('../package.json').version;
|
|
9
8
|
const NAME = require('../package.json').name;
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
function buildMainHelp() {
|
|
11
|
+
const cmdList = commands.list();
|
|
12
|
+
const lines = cmdList.map(cmd => {
|
|
13
|
+
const aliases = cmd.aliases.length ? ` (alias: ${cmd.aliases.join(', ')})` : '';
|
|
14
|
+
return ` npa ${cmd.name.padEnd(20)} ${cmd.description}${aliases}`;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
return `
|
|
12
18
|
npa — npm package auditor ${VERSION}
|
|
13
19
|
Statically detects obfuscated code in npm install scripts.
|
|
14
20
|
|
|
15
21
|
Usage:
|
|
16
|
-
|
|
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
|
|
22
|
+
${lines.join('\n')}
|
|
21
23
|
|
|
22
24
|
Flags:
|
|
23
|
-
--
|
|
25
|
+
--review, -r Interactive mode: choose which scripts to allow
|
|
24
26
|
--json Machine-readable JSON output
|
|
25
27
|
--no-dev Skip devDependencies
|
|
26
28
|
--verbose Show extra detail
|
|
27
29
|
--version Print version
|
|
28
|
-
--help, -h Print this help
|
|
30
|
+
--help, -h Print this help (use <command> -h for command help)
|
|
29
31
|
|
|
30
32
|
Config keys (stored in ~/.npmauditor.json):
|
|
31
33
|
blockScore Score threshold for hard block (default: ${DEFAULT_CONFIG.blockScore})
|
|
@@ -36,11 +38,12 @@ const HELP = `
|
|
|
36
38
|
skipScopes Array of @scopes to skip
|
|
37
39
|
skipPackages Array of package names to skip
|
|
38
40
|
`;
|
|
41
|
+
}
|
|
39
42
|
|
|
40
43
|
function parseArgs(argv) {
|
|
41
44
|
const args = argv.slice(2);
|
|
42
45
|
const flags = {
|
|
43
|
-
|
|
46
|
+
review: false,
|
|
44
47
|
json: false,
|
|
45
48
|
noDev: false,
|
|
46
49
|
verbose: false,
|
|
@@ -48,11 +51,12 @@ function parseArgs(argv) {
|
|
|
48
51
|
help: false,
|
|
49
52
|
};
|
|
50
53
|
const positionals = [];
|
|
54
|
+
const rawArgs = [];
|
|
51
55
|
|
|
52
56
|
for (const arg of args) {
|
|
53
57
|
switch (arg) {
|
|
54
|
-
case '--
|
|
55
|
-
case '-
|
|
58
|
+
case '--review':
|
|
59
|
+
case '-r': flags.review = true; break;
|
|
56
60
|
case '--json': flags.json = true; break;
|
|
57
61
|
case '--no-dev': flags.noDev = true; break;
|
|
58
62
|
case '--verbose': flags.verbose = true; break;
|
|
@@ -67,164 +71,16 @@ function parseArgs(argv) {
|
|
|
67
71
|
|
|
68
72
|
const command = positionals[0] || null;
|
|
69
73
|
const cmdArgs = positionals.slice(1);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
async function runInstall(pkgName, flags, config, cwd) {
|
|
74
|
-
output.printScanHeader();
|
|
75
|
-
|
|
76
|
-
const results = await scan({
|
|
77
|
-
cwd,
|
|
78
|
-
config,
|
|
79
|
-
noDev: flags.noDev,
|
|
80
|
-
verbose: flags.verbose,
|
|
81
|
-
singlePackage: pkgName || null,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
if (flags.json) {
|
|
85
|
-
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
86
|
-
} else {
|
|
87
|
-
printResults(results);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const blocked = results.filter(r => r.verdict === 'BLOCK');
|
|
91
|
-
|
|
92
|
-
if (blocked.length > 0 && !flags.aware) {
|
|
93
|
-
output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
|
|
94
|
-
output.log(output.dim(' Run with --aware to interactively decide which scripts to allow.'));
|
|
95
|
-
process.exit(1);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const npmArgs = pkgName ? [pkgName] : [];
|
|
99
|
-
|
|
100
|
-
if (flags.aware) {
|
|
101
|
-
const packagesWithScripts = results.filter(r => r.verdict !== 'OK' || r.scripts.length > 0);
|
|
102
|
-
const exit = await runAware({
|
|
103
|
-
results: packagesWithScripts.length > 0 ? packagesWithScripts : results,
|
|
104
|
-
command: 'install',
|
|
105
|
-
npmArgs,
|
|
106
|
-
cwd,
|
|
107
|
-
});
|
|
108
|
-
process.exit(exit);
|
|
109
|
-
} else {
|
|
110
|
-
const exit = runNpm('install', npmArgs, cwd);
|
|
111
|
-
process.exit(exit);
|
|
74
|
+
const commandIndex = args.indexOf(command);
|
|
75
|
+
if (commandIndex !== -1) {
|
|
76
|
+
rawArgs.push(...args.slice(commandIndex + 1));
|
|
112
77
|
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function runCi(flags, config, cwd) {
|
|
116
|
-
output.printScanHeader();
|
|
117
|
-
|
|
118
|
-
const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose });
|
|
119
|
-
|
|
120
|
-
if (flags.json) {
|
|
121
|
-
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
122
|
-
} else {
|
|
123
|
-
printResults(results);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const blocked = results.filter(r => r.verdict === 'BLOCK');
|
|
127
|
-
|
|
128
|
-
if (blocked.length > 0 && !flags.aware) {
|
|
129
|
-
output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
|
|
130
|
-
process.exit(1);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (flags.aware) {
|
|
134
|
-
const exit = await runAware({ results, command: 'ci', npmArgs: [], cwd });
|
|
135
|
-
process.exit(exit);
|
|
136
|
-
} else {
|
|
137
|
-
const exit = runNpm('ci', [], cwd);
|
|
138
|
-
process.exit(exit);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function runScan(flags, config, cwd) {
|
|
143
|
-
output.printScanHeader();
|
|
144
|
-
|
|
145
|
-
const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose });
|
|
146
|
-
|
|
147
|
-
if (flags.json) {
|
|
148
|
-
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
149
|
-
const hasBlock = results.some(r => r.verdict === 'BLOCK');
|
|
150
|
-
process.exit(hasBlock ? 1 : 0);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
printResults(results);
|
|
154
|
-
output.printSummary(results.map(r => ({ verdict: r.verdict })));
|
|
155
|
-
|
|
156
|
-
const hasBlock = results.some(r => r.verdict === 'BLOCK');
|
|
157
|
-
process.exit(hasBlock ? 1 : 0);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function runConfig(cmdArgs, config) {
|
|
161
|
-
const subcommand = cmdArgs[0];
|
|
162
|
-
|
|
163
|
-
if (subcommand === 'get') {
|
|
164
|
-
const globalPath = getGlobalConfigPath();
|
|
165
|
-
output.log(output.bold(' Current npa configuration'));
|
|
166
|
-
output.log(output.dim(` (global: ${globalPath})`));
|
|
167
|
-
output.log('');
|
|
168
|
-
for (const [key, val] of Object.entries(config)) {
|
|
169
|
-
output.log(` ${output.cyan(key.padEnd(18))} ${JSON.stringify(val)}`);
|
|
170
|
-
}
|
|
171
|
-
output.log('');
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (subcommand === 'set') {
|
|
176
|
-
const key = cmdArgs[1];
|
|
177
|
-
const value = cmdArgs[2];
|
|
178
|
-
if (!key || value === undefined) {
|
|
179
|
-
output.error('Usage: npa config set <key> <value>');
|
|
180
|
-
process.exit(1);
|
|
181
|
-
}
|
|
182
|
-
try {
|
|
183
|
-
const updated = setGlobalConfig(key, value);
|
|
184
|
-
output.success(`Set ${key} = ${JSON.stringify(updated[key])}`);
|
|
185
|
-
output.log(output.dim(` Written to ${getGlobalConfigPath()}`));
|
|
186
|
-
} catch (err) {
|
|
187
|
-
output.error(err.message);
|
|
188
|
-
process.exit(1);
|
|
189
|
-
}
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
output.error(`Unknown config subcommand: "${subcommand}". Use "get" or "set".`);
|
|
194
|
-
process.exit(1);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function printResults(results) {
|
|
198
|
-
if (results.length === 0) {
|
|
199
|
-
output.success('No packages with install scripts found.');
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
for (const r of results) {
|
|
203
|
-
output.printPackageResult(r.pkg, r);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function toJsonReport(results) {
|
|
208
|
-
return {
|
|
209
|
-
summary: {
|
|
210
|
-
total: results.length,
|
|
211
|
-
blocked: results.filter(r => r.verdict === 'BLOCK').length,
|
|
212
|
-
warned: results.filter(r => r.verdict === 'WARN').length,
|
|
213
|
-
ok: results.filter(r => r.verdict === 'OK').length,
|
|
214
|
-
},
|
|
215
|
-
packages: results.map(r => ({
|
|
216
|
-
name: r.pkg.name,
|
|
217
|
-
version: r.pkg.version,
|
|
218
|
-
verdict: r.verdict,
|
|
219
|
-
score: r.score,
|
|
220
|
-
findings: r.findings,
|
|
221
|
-
scripts: r.scripts.map(s => ({ lifecycle: s.lifecycle, file: s.file, score: s.score })),
|
|
222
|
-
})),
|
|
223
|
-
};
|
|
78
|
+
return { command, args: cmdArgs, rawArgs, flags };
|
|
224
79
|
}
|
|
225
80
|
|
|
226
81
|
async function main() {
|
|
227
|
-
const
|
|
82
|
+
const parsed = parseArgs(process.argv);
|
|
83
|
+
const { command, args, rawArgs, flags } = parsed;
|
|
228
84
|
const cwd = process.cwd();
|
|
229
85
|
const config = loadConfig(cwd);
|
|
230
86
|
|
|
@@ -233,23 +89,26 @@ async function main() {
|
|
|
233
89
|
return;
|
|
234
90
|
}
|
|
235
91
|
|
|
92
|
+
if (flags.help && command) {
|
|
93
|
+
const cmd = commands.get(command);
|
|
94
|
+
if (cmd) {
|
|
95
|
+
process.stdout.write(cmd.help() + '\n');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
236
100
|
if (flags.help || !command) {
|
|
237
|
-
process.stdout.write(
|
|
101
|
+
process.stdout.write(buildMainHelp() + '\n');
|
|
238
102
|
return;
|
|
239
103
|
}
|
|
240
104
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
case 'scan':
|
|
246
|
-
case 's': await runScan(flags, config, cwd); break;
|
|
247
|
-
case 'config':
|
|
248
|
-
case 'c': await runConfig(cmdArgs, config); break;
|
|
249
|
-
default:
|
|
250
|
-
output.error(`Unknown command: "${command}". Run npa --help for usage.`);
|
|
251
|
-
process.exit(1);
|
|
105
|
+
const cmd = commands.get(command);
|
|
106
|
+
if (!cmd) {
|
|
107
|
+
output.error(`Unknown command: "${command}". Run npa --help for usage.`);
|
|
108
|
+
process.exit(1);
|
|
252
109
|
}
|
|
110
|
+
|
|
111
|
+
await cmd.run({ args, rawArgs, flags, config, cwd });
|
|
253
112
|
}
|
|
254
113
|
|
|
255
114
|
main().catch(err => {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const output = require('../utils/output');
|
|
7
|
+
|
|
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 "$@"; }`;
|
|
10
|
+
|
|
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 }`;
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
name: 'alias',
|
|
16
|
+
aliases: [],
|
|
17
|
+
description: 'Shell hook to auto-scan before npm install/ci',
|
|
18
|
+
|
|
19
|
+
help() {
|
|
20
|
+
return `
|
|
21
|
+
npa alias — Shell hook to auto-scan before npm install/ci
|
|
22
|
+
|
|
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
|
|
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.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
npa alias Print hook for manual installation
|
|
33
|
+
npa alias --install Auto-install to detected shell
|
|
34
|
+
eval "$(npa alias)" Load hook in current session only
|
|
35
|
+
`;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
run({ rawArgs }) {
|
|
39
|
+
const install = rawArgs.includes('--install') || rawArgs.includes('-i');
|
|
40
|
+
const uninstall = rawArgs.includes('--uninstall') || rawArgs.includes('-u');
|
|
41
|
+
|
|
42
|
+
const { shell, hook, profilePath } = detectShell();
|
|
43
|
+
|
|
44
|
+
if (uninstall) {
|
|
45
|
+
return doUninstall(profilePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (install) {
|
|
49
|
+
return doInstall(hook, profilePath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Print hook
|
|
53
|
+
process.stdout.write(hook + '\n');
|
|
54
|
+
output.log('');
|
|
55
|
+
output.log(output.dim(` Add to your shell profile, or run: npa alias --install`));
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function detectShell() {
|
|
60
|
+
if (process.platform === 'win32') {
|
|
61
|
+
return { shell: 'powershell', hook: POWERSHELL_HOOK, profilePath: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const userShell = process.env.SHELL || '';
|
|
65
|
+
if (userShell.includes('zsh')) {
|
|
66
|
+
return { shell: 'zsh', hook: BASH_HOOK, profilePath: path.join(os.homedir(), '.zshrc') };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { shell: 'bash', hook: BASH_HOOK, profilePath: path.join(os.homedir(), '.bashrc') };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function doUninstall(profilePath) {
|
|
73
|
+
if (!profilePath) {
|
|
74
|
+
output.error('Auto-uninstall not supported for PowerShell. Remove manually from $PROFILE');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!fs.existsSync(profilePath)) {
|
|
79
|
+
output.warn('No profile found at ' + profilePath);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const content = fs.readFileSync(profilePath, 'utf8');
|
|
84
|
+
if (!content.includes('# npa npm hook')) {
|
|
85
|
+
output.warn('npa hook not found in ' + profilePath);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const cleaned = content.replace(/\n*# npa npm hook\nnpm\(\)[^\n]+\n*/g, '\n');
|
|
90
|
+
fs.writeFileSync(profilePath, cleaned);
|
|
91
|
+
output.success(`Removed npa hook from ${profilePath}`);
|
|
92
|
+
output.log(output.dim(' Run: source ' + profilePath + ' (or restart your terminal)'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function doInstall(hook, profilePath) {
|
|
96
|
+
if (!profilePath) {
|
|
97
|
+
output.error('Auto-install not supported for PowerShell. Copy the output manually to $PROFILE');
|
|
98
|
+
process.stdout.write('\n' + hook + '\n');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const content = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf8') : '';
|
|
103
|
+
if (content.includes('# npa npm hook')) {
|
|
104
|
+
output.warn('npa hook already installed in ' + profilePath);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fs.appendFileSync(profilePath, '\n\n' + hook + '\n');
|
|
109
|
+
output.success(`Installed npa hook to ${profilePath}`);
|
|
110
|
+
output.log(output.dim(' Run: source ' + profilePath + ' (or restart your terminal)'));
|
|
111
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scan } = require('../core/scanner');
|
|
4
|
+
const { runAware, runNpm } = require('../utils/review');
|
|
5
|
+
const output = require('../utils/output');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
name: 'ci',
|
|
9
|
+
aliases: [],
|
|
10
|
+
description: 'Audit then run npm ci',
|
|
11
|
+
|
|
12
|
+
help() {
|
|
13
|
+
return `
|
|
14
|
+
npa ci — Audit dependencies then run npm ci
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
npa ci [options]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--review, -r Interactive mode: review and allow/deny scripts
|
|
21
|
+
--json Output scan results as JSON
|
|
22
|
+
--no-dev Skip devDependencies in scan
|
|
23
|
+
--verbose Show detailed findings
|
|
24
|
+
-h, --help Show this help
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
npa ci Clean install after audit
|
|
28
|
+
npa ci --review Review scripts interactively
|
|
29
|
+
`;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async run({ flags, config, cwd }) {
|
|
33
|
+
const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose });
|
|
34
|
+
const hasIssues = results.some(r => r.verdict !== 'OK');
|
|
35
|
+
const silent = config.silent && !hasIssues;
|
|
36
|
+
|
|
37
|
+
output.printScanHeader(silent);
|
|
38
|
+
|
|
39
|
+
if (flags.json) {
|
|
40
|
+
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
41
|
+
} else {
|
|
42
|
+
printResults(results, silent);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const blocked = results.filter(r => r.verdict === 'BLOCK');
|
|
46
|
+
|
|
47
|
+
if (blocked.length > 0 && !flags.review) {
|
|
48
|
+
output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (flags.review) {
|
|
53
|
+
const exit = await runAware({ results, command: 'ci', npmArgs: [], cwd });
|
|
54
|
+
process.exit(exit);
|
|
55
|
+
} else {
|
|
56
|
+
const exit = runNpm('ci', [], cwd);
|
|
57
|
+
process.exit(exit);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function printResults(results, silent = false) {
|
|
63
|
+
if (silent) return;
|
|
64
|
+
if (results.length === 0) {
|
|
65
|
+
output.success('No packages with install scripts found.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const r of results) {
|
|
69
|
+
output.printPackageResult(r.pkg, r);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function toJsonReport(results) {
|
|
74
|
+
return {
|
|
75
|
+
summary: {
|
|
76
|
+
total: results.length,
|
|
77
|
+
blocked: results.filter(r => r.verdict === 'BLOCK').length,
|
|
78
|
+
warned: results.filter(r => r.verdict === 'WARN').length,
|
|
79
|
+
ok: results.filter(r => r.verdict === 'OK').length,
|
|
80
|
+
},
|
|
81
|
+
packages: results.map(r => ({
|
|
82
|
+
name: r.pkg.name,
|
|
83
|
+
version: r.pkg.version,
|
|
84
|
+
verdict: r.verdict,
|
|
85
|
+
score: r.score,
|
|
86
|
+
findings: r.findings,
|
|
87
|
+
scripts: r.scripts.map(s => ({ lifecycle: s.lifecycle, file: s.file, score: s.score })),
|
|
88
|
+
})),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { setGlobalConfig, getGlobalConfigPath, DEFAULT_CONFIG } = require('../utils/config');
|
|
4
|
+
const output = require('../utils/output');
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
name: 'config',
|
|
8
|
+
aliases: ['c'],
|
|
9
|
+
description: 'View or modify npa configuration',
|
|
10
|
+
|
|
11
|
+
help() {
|
|
12
|
+
return `
|
|
13
|
+
npa config — View or modify npa configuration
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
npa config get Show all config values
|
|
17
|
+
npa config set <key> <val> Set a config value
|
|
18
|
+
|
|
19
|
+
Config keys:
|
|
20
|
+
blockScore Score threshold for hard block (default: ${DEFAULT_CONFIG.blockScore})
|
|
21
|
+
warnScore Score threshold for warning (default: ${DEFAULT_CONFIG.warnScore})
|
|
22
|
+
registry npm registry URL
|
|
23
|
+
timeout HTTP timeout in ms (default: ${DEFAULT_CONFIG.timeout})
|
|
24
|
+
parallelFetches Concurrent downloads (default: ${DEFAULT_CONFIG.parallelFetches})
|
|
25
|
+
skipScopes Array of @scopes to skip (JSON)
|
|
26
|
+
skipPackages Array of package names to skip (JSON)
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
npa config get
|
|
30
|
+
npa config set blockScore 10
|
|
31
|
+
npa config set skipScopes '["@myorg"]'
|
|
32
|
+
`;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async run({ args, config }) {
|
|
36
|
+
const subcommand = args[0];
|
|
37
|
+
|
|
38
|
+
if (subcommand === 'get') {
|
|
39
|
+
const globalPath = getGlobalConfigPath();
|
|
40
|
+
output.log(output.bold(' Current npa configuration'));
|
|
41
|
+
output.log(output.dim(` (global: ${globalPath})`));
|
|
42
|
+
output.log('');
|
|
43
|
+
for (const [key, val] of Object.entries(config)) {
|
|
44
|
+
output.log(` ${output.cyan(key.padEnd(18))} ${JSON.stringify(val)}`);
|
|
45
|
+
}
|
|
46
|
+
output.log('');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (subcommand === 'set') {
|
|
51
|
+
const key = args[1];
|
|
52
|
+
const value = args[2];
|
|
53
|
+
if (!key || value === undefined) {
|
|
54
|
+
output.error('Usage: npa config set <key> <value>');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const updated = setGlobalConfig(key, value);
|
|
59
|
+
output.success(`Set ${key} = ${JSON.stringify(updated[key])}`);
|
|
60
|
+
output.log(output.dim(` Written to ${getGlobalConfigPath()}`));
|
|
61
|
+
} catch (err) {
|
|
62
|
+
output.error(err.message);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
output.error(`Unknown config subcommand: "${subcommand}". Use "get" or "set".`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const commands = new Map();
|
|
7
|
+
|
|
8
|
+
// Load all command modules from this directory
|
|
9
|
+
const files = fs.readdirSync(__dirname).filter(f => f !== 'index.js' && f.endsWith('.js'));
|
|
10
|
+
|
|
11
|
+
for (const file of files) {
|
|
12
|
+
const cmd = require(path.join(__dirname, file));
|
|
13
|
+
commands.set(cmd.name, cmd);
|
|
14
|
+
for (const alias of cmd.aliases || []) {
|
|
15
|
+
commands.set(alias, cmd);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
commands,
|
|
21
|
+
|
|
22
|
+
get(name) {
|
|
23
|
+
return commands.get(name);
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
list() {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const result = [];
|
|
29
|
+
for (const cmd of commands.values()) {
|
|
30
|
+
if (!seen.has(cmd.name)) {
|
|
31
|
+
seen.add(cmd.name);
|
|
32
|
+
result.push(cmd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scan } = require('../core/scanner');
|
|
4
|
+
const { runAware, runNpm } = require('../utils/review');
|
|
5
|
+
const output = require('../utils/output');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
name: 'install',
|
|
9
|
+
aliases: ['i'],
|
|
10
|
+
description: 'Audit then run npm install',
|
|
11
|
+
|
|
12
|
+
help() {
|
|
13
|
+
return `
|
|
14
|
+
npa install — Audit dependencies then run npm install
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
npa install [package] [options]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--review, -r Interactive mode: review and allow/deny scripts
|
|
21
|
+
--json Output scan results as JSON
|
|
22
|
+
--no-dev Skip devDependencies in scan
|
|
23
|
+
--verbose Show detailed findings
|
|
24
|
+
-h, --help Show this help
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
npa install Install all deps after audit
|
|
28
|
+
npa install lodash Add lodash after auditing it
|
|
29
|
+
npa install --review Review scripts interactively
|
|
30
|
+
`;
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async run({ args, flags, config, cwd }) {
|
|
34
|
+
const packages = args.filter(a => !a.startsWith('-'));
|
|
35
|
+
|
|
36
|
+
const results = await scan({
|
|
37
|
+
cwd,
|
|
38
|
+
config,
|
|
39
|
+
noDev: flags.noDev,
|
|
40
|
+
verbose: flags.verbose,
|
|
41
|
+
packages: packages.length > 0 ? packages : null,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const hasIssues = results.some(r => r.verdict !== 'OK');
|
|
45
|
+
const silent = config.silent && !hasIssues;
|
|
46
|
+
|
|
47
|
+
output.printScanHeader(silent);
|
|
48
|
+
|
|
49
|
+
if (flags.json) {
|
|
50
|
+
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
51
|
+
} else {
|
|
52
|
+
printResults(results, silent);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const blocked = results.filter(r => r.verdict === 'BLOCK');
|
|
56
|
+
|
|
57
|
+
if (blocked.length > 0 && !flags.review) {
|
|
58
|
+
output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
|
|
59
|
+
output.log(output.dim(' Run with --review to interactively decide which scripts to allow.'));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const npmArgs = packages.length > 0 ? packages : [];
|
|
64
|
+
|
|
65
|
+
if (flags.review) {
|
|
66
|
+
const packagesWithScripts = results.filter(r => r.verdict !== 'OK' || r.scripts.length > 0);
|
|
67
|
+
const exit = await runAware({
|
|
68
|
+
results: packagesWithScripts.length > 0 ? packagesWithScripts : results,
|
|
69
|
+
command: 'install',
|
|
70
|
+
npmArgs,
|
|
71
|
+
cwd,
|
|
72
|
+
});
|
|
73
|
+
process.exit(exit);
|
|
74
|
+
} else {
|
|
75
|
+
const exit = runNpm('install', npmArgs, cwd);
|
|
76
|
+
process.exit(exit);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function printResults(results, silent = false) {
|
|
82
|
+
if (silent) return;
|
|
83
|
+
if (results.length === 0) {
|
|
84
|
+
output.success('No packages with install scripts found.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const r of results) {
|
|
88
|
+
output.printPackageResult(r.pkg, r);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toJsonReport(results) {
|
|
93
|
+
return {
|
|
94
|
+
summary: {
|
|
95
|
+
total: results.length,
|
|
96
|
+
blocked: results.filter(r => r.verdict === 'BLOCK').length,
|
|
97
|
+
warned: results.filter(r => r.verdict === 'WARN').length,
|
|
98
|
+
ok: results.filter(r => r.verdict === 'OK').length,
|
|
99
|
+
},
|
|
100
|
+
packages: results.map(r => ({
|
|
101
|
+
name: r.pkg.name,
|
|
102
|
+
version: r.pkg.version,
|
|
103
|
+
verdict: r.verdict,
|
|
104
|
+
score: r.score,
|
|
105
|
+
findings: r.findings,
|
|
106
|
+
scripts: r.scripts.map(s => ({ lifecycle: s.lifecycle, file: s.file, score: s.score })),
|
|
107
|
+
})),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scan } = require('../core/scanner');
|
|
4
|
+
const output = require('../utils/output');
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
name: 'scan',
|
|
8
|
+
aliases: ['s'],
|
|
9
|
+
description: 'Scan only, no npm invocation',
|
|
10
|
+
|
|
11
|
+
help() {
|
|
12
|
+
return `
|
|
13
|
+
npa scan — Scan dependencies for obfuscated install scripts
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
npa scan [package] [options]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--json Output results as JSON
|
|
20
|
+
--no-dev Skip devDependencies
|
|
21
|
+
--verbose Show detailed findings
|
|
22
|
+
-h, --help Show this help
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
npa scan Scan all dependencies
|
|
26
|
+
npa scan lodash Scan a specific package before installing
|
|
27
|
+
npa scan --no-dev Scan production dependencies only
|
|
28
|
+
npa scan --json Output machine-readable JSON
|
|
29
|
+
`;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async run({ args, flags, config, cwd }) {
|
|
33
|
+
const packages = args.filter(a => !a.startsWith('-'));
|
|
34
|
+
const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose, packages: packages.length > 0 ? packages : null });
|
|
35
|
+
const hasIssues = results.some(r => r.verdict !== 'OK');
|
|
36
|
+
const silent = config.silent && !hasIssues;
|
|
37
|
+
|
|
38
|
+
output.printScanHeader(silent);
|
|
39
|
+
|
|
40
|
+
if (flags.json) {
|
|
41
|
+
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
42
|
+
const hasBlock = results.some(r => r.verdict === 'BLOCK');
|
|
43
|
+
process.exit(hasBlock ? 1 : 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
printResults(results, silent);
|
|
47
|
+
if (!silent) output.printSummary(results);
|
|
48
|
+
|
|
49
|
+
const hasBlock = results.some(r => r.verdict === 'BLOCK');
|
|
50
|
+
process.exit(hasBlock ? 1 : 0);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function printResults(results, silent = false) {
|
|
55
|
+
if (silent) return;
|
|
56
|
+
if (results.length === 0) {
|
|
57
|
+
output.success('No packages with install scripts found.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const r of results) {
|
|
61
|
+
output.printPackageResult(r.pkg, r);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toJsonReport(results) {
|
|
66
|
+
return {
|
|
67
|
+
summary: {
|
|
68
|
+
total: results.length,
|
|
69
|
+
blocked: results.filter(r => r.verdict === 'BLOCK').length,
|
|
70
|
+
warned: results.filter(r => r.verdict === 'WARN').length,
|
|
71
|
+
ok: results.filter(r => r.verdict === 'OK').length,
|
|
72
|
+
},
|
|
73
|
+
packages: results.map(r => ({
|
|
74
|
+
name: r.pkg.name,
|
|
75
|
+
version: r.pkg.version,
|
|
76
|
+
verdict: r.verdict,
|
|
77
|
+
score: r.score,
|
|
78
|
+
findings: r.findings,
|
|
79
|
+
scripts: r.scripts.map(s => ({ lifecycle: s.lifecycle, file: s.file, score: s.score })),
|
|
80
|
+
})),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { parseLockfile } = require('
|
|
6
|
-
const { fetchTarball, buildTarballUrl, verifyIntegrity } = require('
|
|
7
|
-
const { parseTarGz, extractFile, getPackageJson } = require('
|
|
5
|
+
const { parseLockfile } = require('../utils/lockfile');
|
|
6
|
+
const { fetchTarball, buildTarballUrl, verifyIntegrity } = require('../utils/fetcher');
|
|
7
|
+
const { parseTarGz, extractFile, getPackageJson } = require('../utils/tarball');
|
|
8
8
|
const { detectObfuscation } = require('./detector');
|
|
9
|
-
const output = require('
|
|
9
|
+
const output = require('../utils/output');
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Main scan orchestrator.
|
|
@@ -15,16 +15,33 @@ const output = require('./output');
|
|
|
15
15
|
* @param {object} opts.config
|
|
16
16
|
* @param {boolean} opts.noDev
|
|
17
17
|
* @param {boolean} opts.verbose
|
|
18
|
-
* @param {string|null} opts.singlePackage name for single-package mode
|
|
18
|
+
* @param {string|null} opts.singlePackage name for single-package mode (deprecated, use packages)
|
|
19
|
+
* @param {string[]|null} opts.packages package names to scan
|
|
19
20
|
* @returns {Promise<ScanResult[]>}
|
|
20
21
|
*/
|
|
21
22
|
async function scan(opts) {
|
|
22
|
-
const { cwd, config, noDev, verbose, singlePackage } = opts;
|
|
23
|
+
const { cwd, config, noDev, verbose, singlePackage, packages: packageList } = opts;
|
|
23
24
|
|
|
24
25
|
let packages;
|
|
25
26
|
let lockfileVersion = 1;
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
let explicitPackageNames = new Set();
|
|
28
|
+
|
|
29
|
+
// Support both single package (legacy) and multiple packages
|
|
30
|
+
const targetPackages = packageList || (singlePackage ? [singlePackage] : null);
|
|
31
|
+
|
|
32
|
+
if (targetPackages && targetPackages.length > 0) {
|
|
33
|
+
// Scan specific packages from registry
|
|
34
|
+
const allPackages = [];
|
|
35
|
+
for (const pkg of targetPackages) {
|
|
36
|
+
const resolved = await resolveSinglePackage(pkg, config);
|
|
37
|
+
// Mark the first package (the explicitly requested one) as explicit
|
|
38
|
+
if (resolved.length > 0) {
|
|
39
|
+
const pkgName = pkg.includes('@') && !pkg.startsWith('@') ? pkg.split('@')[0] : pkg;
|
|
40
|
+
explicitPackageNames.add(pkgName);
|
|
41
|
+
}
|
|
42
|
+
allPackages.push(...resolved);
|
|
43
|
+
}
|
|
44
|
+
packages = allPackages;
|
|
28
45
|
} else {
|
|
29
46
|
const lockPath = path.join(cwd, 'package-lock.json');
|
|
30
47
|
if (fs.existsSync(lockPath)) {
|
|
@@ -37,6 +54,9 @@ async function scan(opts) {
|
|
|
37
54
|
}
|
|
38
55
|
}
|
|
39
56
|
|
|
57
|
+
// Track packages without install scripts (for skipped count)
|
|
58
|
+
let skippedCount = 0;
|
|
59
|
+
|
|
40
60
|
// Apply skip filters
|
|
41
61
|
packages = packages.filter(pkg => {
|
|
42
62
|
if (noDev && pkg.dev) return false;
|
|
@@ -47,6 +67,14 @@ async function scan(opts) {
|
|
|
47
67
|
if (pkg.name.startsWith(scope + '/') || pkg.name === scope) return false;
|
|
48
68
|
}
|
|
49
69
|
}
|
|
70
|
+
// For explicit packages, always include them but track if they have no scripts
|
|
71
|
+
if (explicitPackageNames.has(pkg.name)) {
|
|
72
|
+
if (!pkg.hasInstallScript) {
|
|
73
|
+
skippedCount++;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
50
78
|
// v2/v3 lockfiles reliably report hasInstallScript — skip definitive negatives
|
|
51
79
|
if (lockfileVersion >= 2 && pkg.hasInstallScript === false) return false;
|
|
52
80
|
return true;
|
|
@@ -59,7 +87,13 @@ async function scan(opts) {
|
|
|
59
87
|
return scanPackage(pkg, cwd, config, verbose);
|
|
60
88
|
});
|
|
61
89
|
|
|
62
|
-
|
|
90
|
+
const scanned = results.filter(Boolean);
|
|
91
|
+
// Add packages that returned null from scanPackage (no scripts found during scan)
|
|
92
|
+
skippedCount += results.filter(r => r === null).length;
|
|
93
|
+
|
|
94
|
+
// Attach metadata to results array
|
|
95
|
+
scanned.skippedCount = skippedCount;
|
|
96
|
+
return scanned;
|
|
63
97
|
}
|
|
64
98
|
|
|
65
99
|
/**
|
|
@@ -269,7 +303,8 @@ function extractSemver(range) {
|
|
|
269
303
|
async function resolveFromPackageJson(cwd, config, noDev) {
|
|
270
304
|
const pkgPath = path.join(cwd, 'package.json');
|
|
271
305
|
if (!fs.existsSync(pkgPath)) {
|
|
272
|
-
|
|
306
|
+
// No package.json — nothing to scan (e.g. empty directory)
|
|
307
|
+
return [];
|
|
273
308
|
}
|
|
274
309
|
|
|
275
310
|
let pkgJson;
|
|
@@ -285,7 +320,7 @@ async function resolveFromPackageJson(cwd, config, noDev) {
|
|
|
285
320
|
}
|
|
286
321
|
|
|
287
322
|
const packages = [];
|
|
288
|
-
const { fetchJSON } = require('
|
|
323
|
+
const { fetchJSON } = require('../utils/fetcher');
|
|
289
324
|
|
|
290
325
|
for (const [name, range] of Object.entries(deps)) {
|
|
291
326
|
const version = extractSemver(range);
|
|
@@ -327,7 +362,7 @@ async function resolveSinglePackage(packageSpec, config) {
|
|
|
327
362
|
? packageSpec.split('@')
|
|
328
363
|
: [packageSpec, 'latest'];
|
|
329
364
|
|
|
330
|
-
const { fetchJSON } = require('
|
|
365
|
+
const { fetchJSON } = require('../utils/fetcher');
|
|
331
366
|
let meta;
|
|
332
367
|
try {
|
|
333
368
|
meta = await fetchJSON(`${config.registry}/${encodeURIComponent(name)}`, { timeout: config.timeout });
|
|
@@ -14,6 +14,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
14
14
|
parallelFetches: 5,
|
|
15
15
|
skipScopes: [],
|
|
16
16
|
skipPackages: [],
|
|
17
|
+
silent: false,
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
const VALID_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
|
|
@@ -43,6 +44,8 @@ function coerce(obj) {
|
|
|
43
44
|
} else if (typeof def === 'number') {
|
|
44
45
|
const n = Number(val);
|
|
45
46
|
if (!isNaN(n)) result[key] = n;
|
|
47
|
+
} else if (typeof def === 'boolean') {
|
|
48
|
+
result[key] = val === true || val === 'true' || val === '1';
|
|
46
49
|
} else {
|
|
47
50
|
result[key] = val;
|
|
48
51
|
}
|
|
@@ -49,16 +49,26 @@ function log(msg) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function verdictBadge(verdict) {
|
|
52
|
-
if (NO_COLOR) return `[${verdict}]`;
|
|
53
|
-
if (verdict === 'BLOCK') return `${BG_RED}${BOLD}
|
|
52
|
+
if (NO_COLOR) return verdict === 'BLOCK' ? '[DANGER]' : `[${verdict}]`;
|
|
53
|
+
if (verdict === 'BLOCK') return `${BG_RED}${WHITE}${BOLD} DANGER ${RESET}`;
|
|
54
54
|
if (verdict === 'WARN') return `${BG_YELLOW}\x1b[30m WARN ${RESET}`;
|
|
55
55
|
return `${GREEN} OK ${RESET}`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const ASCII_LOGO = `
|
|
59
|
+
|
|
60
|
+
███╗ ██╗██████╗ █████╗
|
|
61
|
+
████╗ ██║██╔══██╗██╔══██╗
|
|
62
|
+
██╔██╗ ██║██████╔╝███████║
|
|
63
|
+
██║╚██╗██║██╔═══╝ ██╔══██║
|
|
64
|
+
██║ ╚████║██║ ██║ ██║
|
|
65
|
+
╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
function printScanHeader(silent = false) {
|
|
69
|
+
if (silent) return;
|
|
70
|
+
log(blue(ASCII_LOGO));
|
|
71
|
+
log(dim(' npm package auditor — static obfuscation detection'));
|
|
62
72
|
log(dim('─'.repeat(60)));
|
|
63
73
|
log('');
|
|
64
74
|
}
|
|
@@ -77,10 +87,15 @@ function printSummary(results) {
|
|
|
77
87
|
const blocked = results.filter(r => r.verdict === 'BLOCK').length;
|
|
78
88
|
const warned = results.filter(r => r.verdict === 'WARN').length;
|
|
79
89
|
const ok = results.filter(r => r.verdict === 'OK').length;
|
|
90
|
+
const skipped = results.skippedCount || 0;
|
|
80
91
|
|
|
81
92
|
log('');
|
|
82
93
|
log(dim('─'.repeat(60)));
|
|
83
|
-
|
|
94
|
+
let summary = ` ${green(String(ok))} clean ${yellow(String(warned))} warnings ${red(String(blocked))} blocked`;
|
|
95
|
+
if (skipped > 0) {
|
|
96
|
+
summary += ` ${dim(String(skipped) + ' skipped (no install scripts)')}`;
|
|
97
|
+
}
|
|
98
|
+
log(summary);
|
|
84
99
|
log('');
|
|
85
100
|
}
|
|
86
101
|
|
|
@@ -40,11 +40,21 @@ async function runAware(opts) {
|
|
|
40
40
|
|
|
41
41
|
let cursor = 0;
|
|
42
42
|
|
|
43
|
+
// Use alternate screen buffer on supported terminals (not legacy Windows console)
|
|
44
|
+
const useAltScreen = process.stdout.isTTY && (
|
|
45
|
+
process.platform !== 'win32' ||
|
|
46
|
+
process.env.WT_SESSION || // Windows Terminal
|
|
47
|
+
process.env.ConEmuPID // ConEmu
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (useAltScreen) {
|
|
51
|
+
process.stdout.write('\x1b[?1049h');
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
function render() {
|
|
44
|
-
|
|
45
|
-
process.stdout.write('\x1b[2J\x1b[H'); // clear screen
|
|
55
|
+
process.stdout.write('\x1b[H\x1b[2J');
|
|
46
56
|
output.log('');
|
|
47
|
-
output.log(output.bold(' npa --
|
|
57
|
+
output.log(output.bold(' npa --review mode'));
|
|
48
58
|
output.log(output.dim(' Use ↑/↓ to navigate, SPACE to toggle, ENTER to confirm, q to quit'));
|
|
49
59
|
output.log('');
|
|
50
60
|
output.log(` Found ${items.length} package(s) with install scripts:\n`);
|
|
@@ -115,8 +125,10 @@ async function runAware(opts) {
|
|
|
115
125
|
process.stdin.on('keypress', onKey);
|
|
116
126
|
});
|
|
117
127
|
|
|
118
|
-
//
|
|
119
|
-
|
|
128
|
+
// Exit alternate screen buffer (restores previous screen)
|
|
129
|
+
if (useAltScreen) {
|
|
130
|
+
process.stdout.write('\x1b[?1049l');
|
|
131
|
+
}
|
|
120
132
|
|
|
121
133
|
const allowedItems = items.filter(i => i.allowed);
|
|
122
134
|
const deniedItems = items.filter(i => !i.allowed);
|
|
@@ -124,30 +136,58 @@ async function runAware(opts) {
|
|
|
124
136
|
output.log('');
|
|
125
137
|
output.info(`Proceeding with ${allowedItems.length} allowed / ${deniedItems.length} denied`);
|
|
126
138
|
|
|
139
|
+
// Get names of denied packages to exclude from install
|
|
140
|
+
const deniedNames = new Set(deniedItems.map(i => i.result.pkg.name));
|
|
141
|
+
|
|
142
|
+
// Filter npmArgs to exclude denied packages
|
|
143
|
+
let filteredNpmArgs = npmArgs.filter(arg => {
|
|
144
|
+
// Extract package name (handle @scope/pkg and pkg@version formats)
|
|
145
|
+
const name = arg.startsWith('@')
|
|
146
|
+
? arg.split('/').slice(0, 2).join('/').split('@').slice(0, 2).join('@').replace(/@[^@]*$/, '') || arg.split('@').slice(0, 2).join('@')
|
|
147
|
+
: arg.split('@')[0];
|
|
148
|
+
return !deniedNames.has(name);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// If all explicitly requested packages are denied, abort
|
|
152
|
+
if (npmArgs.length > 0 && filteredNpmArgs.length === 0) {
|
|
153
|
+
output.error('All requested packages were denied. Aborting install.');
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
127
157
|
if (deniedItems.length > 0) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
158
|
+
if (filteredNpmArgs.length > 0 || npmArgs.length === 0) {
|
|
159
|
+
output.warn('Running npm with --ignore-scripts (will run allowed scripts manually after)');
|
|
160
|
+
const code = runNpm(command, [...filteredNpmArgs, '--ignore-scripts'], cwd);
|
|
161
|
+
if (code !== 0) return code;
|
|
162
|
+
|
|
163
|
+
for (const item of allowedItems) {
|
|
164
|
+
const exitCode = runPackageScripts(item.result, cwd);
|
|
165
|
+
if (exitCode !== 0) {
|
|
166
|
+
output.error(`Script for ${item.result.pkg.name} exited with code ${exitCode}`);
|
|
167
|
+
}
|
|
136
168
|
}
|
|
169
|
+
return 0;
|
|
170
|
+
} else {
|
|
171
|
+
output.warn('No packages to install after excluding denied packages.');
|
|
172
|
+
return 0;
|
|
137
173
|
}
|
|
138
|
-
return 0;
|
|
139
174
|
} else {
|
|
140
|
-
return runNpm(command, npmArgs, cwd);
|
|
175
|
+
return runNpm(command, filteredNpmArgs.length > 0 ? filteredNpmArgs : npmArgs, cwd);
|
|
141
176
|
}
|
|
142
177
|
}
|
|
143
178
|
|
|
144
179
|
/**
|
|
145
180
|
* Spawn npm install/ci and return the exit code.
|
|
181
|
+
* Sets NPA_RUNNING=1 to prevent recursive hooks when npm is aliased to npa.
|
|
146
182
|
*/
|
|
147
183
|
function runNpm(command, args, cwd) {
|
|
148
184
|
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
149
185
|
const npmArgs = command === 'ci' ? ['ci', ...args] : ['install', ...args];
|
|
150
|
-
const result = spawnSync(npmCmd, npmArgs, {
|
|
186
|
+
const result = spawnSync(npmCmd, npmArgs, {
|
|
187
|
+
stdio: 'inherit',
|
|
188
|
+
cwd,
|
|
189
|
+
env: { ...process.env, NPA_RUNNING: '1' },
|
|
190
|
+
});
|
|
151
191
|
return result.status || 0;
|
|
152
192
|
}
|
|
153
193
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|