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.
@@ -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
+ }
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ // ─── Constants ───────────────────────────────────────────────────────────────
4
+
5
+ const MAX_CODE_SIZE = 500000; // 500KB - chunk larger files
6
+
3
7
  // ─── Individual detection checks ─────────────────────────────────────────────
4
8
 
5
9
  /**
@@ -22,31 +26,57 @@ function checkEval(code) {
22
26
 
23
27
  /**
24
28
  * Detect obfuscator.io signature: _0x variable naming.
29
+ * Score scales with density of obfuscation.
25
30
  * @param {string} code
26
31
  * @returns {Finding|null}
27
32
  */
28
33
  function checkObfuscatorIo(code) {
29
34
  const matches = code.match(/_0x[0-9a-fA-F]+/g) || [];
30
35
  if (matches.length < 3) return null;
31
- return { name: 'obfuscator.io', score: 9, detail: `${matches.length} _0x identifiers found` };
36
+ // Scale score: 3-10 = 9, 11-50 = 15, 51-200 = 30, 201-1000 = 50, 1000+ = 80
37
+ let score = 9;
38
+ if (matches.length > 1000) score = 80;
39
+ else if (matches.length > 200) score = 50;
40
+ else if (matches.length > 50) score = 30;
41
+ else if (matches.length > 10) score = 15;
42
+ return { name: 'obfuscator.io', score, detail: `${matches.length} _0x identifiers found` };
32
43
  }
33
44
 
34
45
  /**
35
46
  * Detect high-entropy strings (likely encoded/encrypted payloads).
47
+ * Uses indexOf-based extraction to avoid regex stack overflow on large files.
36
48
  * @param {string} code
37
49
  * @returns {Finding|null}
38
50
  */
39
51
  function checkHighEntropy(code) {
40
- // Extract string literals (single, double, template)
41
- const stringRe = /(?:"([^"\\]|\\.){50,}"|'([^'\\]|\\.){50,}'|`([^`\\]|\\.){50,}`)/g;
42
- let match;
43
52
  let maxEntropy = 0;
44
53
  let worst = '';
45
- while ((match = stringRe.exec(code)) !== null) {
46
- const s = match[0].slice(1, -1);
47
- const e = shannonEntropy(s);
48
- if (e > maxEntropy) { maxEntropy = e; worst = s.slice(0, 40); }
54
+ const minLen = 50;
55
+
56
+ // Simple string extraction without complex regex
57
+ for (const quote of ['"', "'", '`']) {
58
+ let pos = 0;
59
+ while (pos < code.length) {
60
+ const start = code.indexOf(quote, pos);
61
+ if (start === -1) break;
62
+
63
+ // Find end quote (skip escaped quotes)
64
+ let end = start + 1;
65
+ while (end < code.length) {
66
+ if (code[end] === '\\') { end += 2; continue; }
67
+ if (code[end] === quote) break;
68
+ end++;
69
+ }
70
+
71
+ if (end < code.length && end - start - 1 >= minLen) {
72
+ const s = code.slice(start + 1, end);
73
+ const e = shannonEntropy(s);
74
+ if (e > maxEntropy) { maxEntropy = e; worst = s.slice(0, 40); }
75
+ }
76
+ pos = end + 1;
77
+ }
49
78
  }
79
+
50
80
  if (maxEntropy < 4.5) return null;
51
81
  return {
52
82
  name: 'high-entropy-string',
@@ -57,13 +87,19 @@ function checkHighEntropy(code) {
57
87
 
58
88
  /**
59
89
  * Detect dense hex escape sequences (\x41).
90
+ * Score scales with volume.
60
91
  * @param {string} code
61
92
  * @returns {Finding|null}
62
93
  */
63
94
  function checkHexEscapes(code) {
64
95
  const hexMatches = (code.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
65
96
  if (hexMatches < 10) return null;
66
- return { name: 'hex-escape-density', score: 5, detail: `${hexMatches} \\xNN hex escapes found` };
97
+ // Scale: 10-50 = 5, 51-200 = 15, 201-1000 = 30, 1000+ = 50
98
+ let score = 5;
99
+ if (hexMatches > 1000) score = 50;
100
+ else if (hexMatches > 200) score = 30;
101
+ else if (hexMatches > 50) score = 15;
102
+ return { name: 'hex-escape-density', score, detail: `${hexMatches} \\xNN hex escapes found` };
67
103
  }
68
104
 
69
105
  /**
@@ -119,6 +155,7 @@ function checkChildProcess(code) {
119
155
 
120
156
  /**
121
157
  * Detect large hex literal arrays (common in minified obfuscated code).
158
+ * Score scales with volume.
122
159
  * @param {string} code
123
160
  * @returns {Finding|null}
124
161
  */
@@ -126,7 +163,12 @@ function checkHexArray(code) {
126
163
  // Count 0x1234-style literals
127
164
  const hexLiterals = (code.match(/\b0x[0-9a-fA-F]+\b/g) || []).length;
128
165
  if (hexLiterals < 20) return null;
129
- return { name: 'hex-array', score: 7, detail: `${hexLiterals} hex literal values found` };
166
+ // Scale: 20-100 = 7, 101-500 = 20, 501-2000 = 40, 2000+ = 60
167
+ let score = 7;
168
+ if (hexLiterals > 2000) score = 60;
169
+ else if (hexLiterals > 500) score = 40;
170
+ else if (hexLiterals > 100) score = 20;
171
+ return { name: 'hex-array', score, detail: `${hexLiterals} hex literal values found` };
130
172
  }
131
173
 
132
174
  /**
@@ -190,22 +232,45 @@ const CHECKS = [
190
232
 
191
233
  /**
192
234
  * Run all checks against a code string.
235
+ * For large files, analyzes multiple chunks and aggregates results.
193
236
  * @param {string} code
194
237
  * @param {object} config { blockScore, warnScore }
195
238
  * @returns {{ score: number, findings: Finding[], verdict: 'BLOCK'|'WARN'|'OK' }}
196
239
  */
197
- function detectObfuscation(code, config = { blockScore: 7, warnScore: 4 }) {
240
+ function detectObfuscation(code, config = { blockScore: 50, warnScore: 20 }) {
198
241
  if (!code || typeof code !== 'string') {
199
242
  return { score: 0, findings: [], verdict: 'OK' };
200
243
  }
201
244
 
202
- const findings = [];
203
- for (const check of CHECKS) {
204
- const result = check(code);
205
- if (result) findings.push(result);
245
+ // For large files, analyze chunks and take worst results
246
+ const chunks = [];
247
+ if (code.length > MAX_CODE_SIZE) {
248
+ // Analyze start, middle, and end chunks
249
+ chunks.push(code.slice(0, MAX_CODE_SIZE));
250
+ const mid = Math.floor(code.length / 2) - Math.floor(MAX_CODE_SIZE / 2);
251
+ chunks.push(code.slice(mid, mid + MAX_CODE_SIZE));
252
+ chunks.push(code.slice(-MAX_CODE_SIZE));
253
+ } else {
254
+ chunks.push(code);
206
255
  }
207
256
 
208
- // Score = highest individual finding score (weighted max avoid double-penalizing)
257
+ const allFindings = new Map(); // Dedupe by name, keep highest score
258
+
259
+ for (const chunk of chunks) {
260
+ for (const check of CHECKS) {
261
+ const result = check(chunk);
262
+ if (result) {
263
+ const existing = allFindings.get(result.name);
264
+ if (!existing || result.score > existing.score) {
265
+ allFindings.set(result.name, result);
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ const findings = Array.from(allFindings.values());
272
+
273
+ // Score = highest individual finding score
209
274
  const score = findings.length > 0
210
275
  ? Math.max(...findings.map(f => f.score))
211
276
  : 0;
@@ -2,11 +2,11 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { parseLockfile } = require('./lockfile');
6
- const { fetchTarball, buildTarballUrl, verifyIntegrity } = require('./fetcher');
7
- const { parseTarGz, extractFile, getPackageJson } = require('./tarball');
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('./output');
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
- if (singlePackage) {
27
- packages = await resolveSinglePackage(singlePackage, config);
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
- return results.filter(Boolean);
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
- throw new Error(`package.json not found in ${cwd}`);
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('./fetcher');
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('./fetcher');
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} BLOCK ${RESET}`;
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
- function printScanHeader() {
59
- log('');
60
- log(bold(cyan('npa') + ' — npm package auditor'));
61
- log(dim('Static obfuscation detection for install scripts'));
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
- log(` ${green(String(ok))} clean ${yellow(String(warned))} warnings ${red(String(blocked))} blocked`);
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