np-audit 1.2.1 → 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/{detector.js → core/detector.js} +81 -16
- 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/{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
|
+
};
|