np-audit 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  # np-audit — npm package auditor
10
10
 
11
- Statically detect obfuscated code in npm `preinstall`/`postinstall` scripts **before** they run. Drop-in replacement for `npm install` and `npm ci`.
11
+ Static security analysis for npm packages — detects obfuscated lifecycle scripts, known vulnerabilities, and malicious patterns **before** they run. Drop-in replacement for `npm install` and `npm ci`.
12
12
 
13
13
  **Zero dependencies.** Pure Node.js built-ins only.
14
14
 
@@ -246,6 +246,52 @@ npa alias --uninstall
246
246
  | `skipPackages` | `[]` | Specific package names to skip |
247
247
  | `silent` | `false` | Suppress output when no issues found |
248
248
  | `scanSelf` | `true` | Also scan the current project's own `package.json` lifecycle scripts |
249
+ | `maxTarballSize` | `50MB` | Max unpacked tarball size (zip bomb protection) |
250
+ | `checkVulnerabilities` | `true` | Check packages against known vulnerability databases |
251
+ | `deepResolve` | `false` | Recursively resolve full transitive dependency tree |
252
+
253
+ ---
254
+
255
+ ### Vulnerability Scanning (CVE)
256
+
257
+ np-audit checks every scanned package against known vulnerability databases. This runs alongside the obfuscation detection and flags packages with known CVEs.
258
+
259
+ **Default: OSV.dev (no setup required)**
260
+
261
+ Works out of the box — queries the free [OSV.dev](https://osv.dev/) API for known vulnerabilities and malicious package advisories.
262
+
263
+ **Optional: Snyk API (richer data)**
264
+
265
+ For more detailed vulnerability information, configure a Snyk API token:
266
+
267
+ ```bash
268
+ # Option 1: Environment variable
269
+ export SNYK_API_TOKEN=your-token-here
270
+
271
+ # Option 2: Snyk CLI config (if you have Snyk CLI installed)
272
+ snyk auth
273
+ ```
274
+
275
+ np-audit checks for the token in this order:
276
+ 1. `SNYK_API_TOKEN` environment variable
277
+ 2. `SNYK_TOKEN` environment variable
278
+ 3. `~/.config/configstore/snyk.json` (created by `snyk auth`)
279
+
280
+ **Scoring:**
281
+ | Severity | Score | Verdict |
282
+ | -------- | ----- | ------- |
283
+ | Malicious package | 80 | DANGER |
284
+ | 10+ vulnerabilities | 6 | WARN |
285
+ | 5-9 vulnerabilities | 5 | WARN |
286
+ | 1-4 vulnerabilities | 4 | WARN |
287
+
288
+ Non-malicious CVEs produce warnings but never block installation. Only confirmed malicious packages trigger DANGER.
289
+
290
+ **Disable vulnerability checks:**
291
+
292
+ ```bash
293
+ npa config set checkVulnerabilities false
294
+ ```
249
295
 
250
296
  ---
251
297
 
@@ -273,16 +319,28 @@ npa alias --uninstall
273
319
 
274
320
  ---
275
321
 
276
- ## Development
322
+ ## Marshallers
277
323
 
278
- ```bash
279
- git clone https://github.com/KoblerS/np-audit.git
280
- cd np-audit
281
- npm test # run all unit + E2E tests
282
- npm link # install npa globally from source
283
- ```
324
+ Detection is split into modular marshallers — each one detects a single attack signal:
325
+
326
+ | Marshaller | What it detects | Score |
327
+ | ---------- | --------------- | ----- |
328
+ | `eval/dynamic-exec` | `eval()`, `new Function()`, indirect eval, `vm.*`, `setTimeout` with string | 8 |
329
+ | `obfuscator.io` | `_0x` variable naming patterns (obfuscator.io output) | 9–80 |
330
+ | `high-entropy-string` | Long strings or concatenation chains with high Shannon entropy | 6 |
331
+ | `hex-escape-density` | Dense `\xNN` and `\uXXXX` escape sequences | 5–50 |
332
+ | `fromCharCode` | `String.fromCharCode` with many args, large decimal char-code arrays | 7 |
333
+ | `encoded-decode` | Base64/hex decode (`atob`, `Buffer.from`) optionally combined with `eval` | 3–8 |
334
+ | `child-process` | `require('child_process')`, `exec`, `spawn`, `fork`, worker_threads | 5 |
335
+ | `hex-array` | Large numbers of `0x` hex literal values | 7–60 |
336
+ | `process-env` | `process.env` access (credential exfiltration signal) | 3 |
337
+ | `network-call` | `require('https')`, `fetch()`, `dns`, `net`, `tls` | 4 |
338
+ | `filesystem-manipulation` | `fs.writeFile`, `chmod`, `symlink` (backdoor persistence) | 3–4 |
339
+ | `known-vulnerability` | Known CVEs via Snyk API or OSV.dev | 4–6 (WARN), 80 (malicious) |
340
+
341
+ Scores scale with severity — higher counts of obfuscation indicators produce higher scores. The final verdict is based on the highest individual score across all marshallers.
284
342
 
285
- No build step, no transpilation plain Node.js 18.
343
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the marshaller architecture and how to write custom ones.
286
344
 
287
345
  ---
288
346
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "np-audit",
3
- "version": "1.5.0",
3
+ "version": "2.0.0",
4
4
  "description": "Static obfuscation detector for npm lifecycle scripts — supply chain attack prevention",
5
5
  "bin": {
6
6
  "npa": "bin/npa.js",
package/src/cli.js CHANGED
@@ -17,7 +17,7 @@ function buildMainHelp() {
17
17
 
18
18
  return `
19
19
  npa — npm package auditor ${VERSION}
20
- Statically detects obfuscated code in npm install scripts.
20
+ Static security analysis for npm packages.
21
21
 
22
22
  Usage:
23
23
  ${lines.join('\n')}
@@ -100,14 +100,29 @@ async function main() {
100
100
  }
101
101
 
102
102
  if (flags.help || !command) {
103
+ output.printLogo(VERSION);
103
104
  process.stdout.write(buildMainHelp() + '\n');
104
105
  return;
105
106
  }
106
107
 
107
108
  const cmd = commands.get(command);
108
109
  if (!cmd) {
109
- output.error(`Unknown command: "${command}". Run npa --help for usage.`);
110
- process.exit(1);
110
+ // Forward unknown commands to npm (allows `alias npm='npa'`)
111
+ const { spawnSync } = require('child_process');
112
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
113
+ const result = spawnSync(npmCmd, process.argv.slice(2), {
114
+ stdio: 'inherit',
115
+ cwd,
116
+ });
117
+ process.exit(result.status || 0);
118
+ return;
119
+ }
120
+
121
+ // Deprecation notice for old shell hook
122
+ if (process.env.NPA_RUNNING && !flags.json && !config.silent) {
123
+ output.warn('Deprecated: The old npa shell hook is no longer needed.');
124
+ output.log(output.dim(' Run: npa alias --install to upgrade to the simpler alias.'));
125
+ output.log('');
111
126
  }
112
127
 
113
128
  // Fire update check only for scan/install/ci commands (non-blocking)
@@ -6,10 +6,10 @@ const os = require('os');
6
6
  const output = require('../utils/output');
7
7
 
8
8
  const BASH_HOOK = `# npa npm hook
9
- npm() { [[ -n "$NPA_RUNNING" ]] && { command npm "$@"; return; }; case "$1" in scan) npa scan "\${@:2}"; return;; install|i|add) command -v npa >/dev/null && { local pkgs=(); for a in "\${@:2}"; do [[ "$a" != -* ]] && pkgs+=("$a"); done; if [[ \${#pkgs[@]} -gt 0 ]]; then npa scan "\${pkgs[@]}" || { echo "[npa] Blocked. Use 'npa install --review'"; return 1; }; else npa scan || { echo "[npa] Blocked. Use 'npa install --review'"; return 1; }; fi; };; ci) command -v npa >/dev/null && { npa scan || { echo "[npa] Blocked. Use 'npa ci --review'"; return 1; }; };; esac; command npm "$@"; }`;
9
+ alias npm='npa'`;
10
10
 
11
11
  const POWERSHELL_HOOK = `# npa npm hook
12
- function npm { if($env:NPA_RUNNING){& npm.cmd @args;return}; if($args[0] -eq 'scan'){& npa scan @($args|Select-Object -Skip 1);return}; if($args[0] -in @('install','i','add')){$pkgs=@($args|Where-Object{$_ -notmatch '^-'}|Select-Object -Skip 1); if($pkgs.Count -gt 0){& npa scan @pkgs; if($LASTEXITCODE -ne 0){Write-Host "[npa] Blocked.";return 1}}else{& npa scan; if($LASTEXITCODE -ne 0){Write-Host "[npa] Blocked.";return 1}}}; if($args[0] -eq 'ci'){& npa scan; if($LASTEXITCODE -ne 0){Write-Host "[npa] Blocked.";return 1}}; & npm.cmd @args }`;
12
+ Set-Alias -Name npm -Value npa`;
13
13
 
14
14
  module.exports = {
15
15
  name: 'alias',
@@ -18,20 +18,21 @@ module.exports = {
18
18
 
19
19
  help() {
20
20
  return `
21
- npa alias — Shell hook to auto-scan before npm install/ci
21
+ npa alias — Shell alias to use npa as an npm drop-in replacement
22
22
 
23
23
  Usage:
24
- npa alias Print the shell hook
25
- npa alias --install Add hook to shell profile (~/.zshrc or ~/.bashrc)
26
- npa alias --uninstall Remove hook from shell profile
24
+ npa alias Print the shell alias
25
+ npa alias --install Add alias to shell profile (~/.zshrc or ~/.bashrc)
26
+ npa alias --uninstall Remove alias from shell profile
27
27
 
28
- The hook intercepts npm install/ci/add commands and runs npa scan first.
29
- If issues are found, the install is blocked until resolved.
28
+ With the alias active, all npm commands pass through npa.
29
+ Install/ci/add commands are scanned before execution.
30
+ All other commands (run, test, publish, etc.) forward directly to npm.
30
31
 
31
32
  Examples:
32
- npa alias Print hook for manual installation
33
+ npa alias Print alias for manual installation
33
34
  npa alias --install Auto-install to detected shell
34
- eval "$(npa alias)" Load hook in current session only
35
+ eval "$(npa alias)" Load alias in current session only
35
36
  `;
36
37
  },
37
38
 
@@ -86,7 +87,9 @@ function doUninstall(profilePath) {
86
87
  return;
87
88
  }
88
89
 
89
- const cleaned = content.replace(/\n*# npa npm hook\nnpm\(\)[^\n]+\n*/g, '\n');
90
+ // Remove all known hook formats (old function, new alias)
91
+ const cleaned = content
92
+ .replace(/\n*# npa npm hook\n(?:npm\(\) \{[\s\S]*?\n\}|npm\(\)[^\n]+|alias npm='npa'|Set-Alias[^\n]*)\n*/g, '\n');
90
93
  fs.writeFileSync(profilePath, cleaned);
91
94
  output.success(`Removed npa hook from ${profilePath}`);
92
95
  output.log(output.dim(' Run: source ' + profilePath + ' (or restart your terminal)'));
@@ -100,6 +103,22 @@ function doInstall(hook, profilePath) {
100
103
  }
101
104
 
102
105
  const content = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf8') : '';
106
+
107
+ // Migrate from old format (function or multi-line) to new alias
108
+ const hasOldHook = content.includes('# npa npm hook') && !content.includes("alias npm='npa'");
109
+ if (hasOldHook) {
110
+ output.warn('Deprecated: The old npm() shell function hook is no longer needed.');
111
+ output.log(output.dim(' npa now forwards unknown commands to npm directly.'));
112
+ output.log(output.dim(' Replacing with: alias npm=\'npa\''));
113
+ output.log('');
114
+ const migrated = content
115
+ .replace(/\n*# npa npm hook\n(?:npm\(\) \{[\s\S]*?\n\}|npm\(\)[^\n]+)\n*/g, '\n\n' + hook + '\n');
116
+ fs.writeFileSync(profilePath, migrated);
117
+ output.success(`Migrated npa hook to new format in ${profilePath}`);
118
+ output.log(output.dim(' Run: source ' + profilePath + ' (or restart your terminal)'));
119
+ return;
120
+ }
121
+
103
122
  if (content.includes('# npa npm hook')) {
104
123
  output.warn('npa hook already installed in ' + profilePath);
105
124
  return;
@@ -30,7 +30,10 @@ module.exports = {
30
30
  },
31
31
 
32
32
  async run({ flags, config, cwd }) {
33
+ const spinner = !flags.json && !config.silent ? output.createSpinner('Auditing packages...') : null;
34
+ if (spinner) spinner.start();
33
35
  const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose });
36
+ if (spinner) spinner.stop();
34
37
  const hasIssues = results.some(r => r.verdict !== 'OK');
35
38
  const silent = config.silent && !hasIssues;
36
39
 
@@ -45,7 +48,7 @@ module.exports = {
45
48
  const blocked = results.filter(r => r.verdict === 'BLOCK');
46
49
 
47
50
  if (blocked.length > 0 && !flags.review) {
48
- output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
51
+ output.error(`${blocked.length} package(s) blocked suspicious or malicious packages detected.`);
49
52
  process.exit(1);
50
53
  }
51
54
 
@@ -62,7 +65,7 @@ module.exports = {
62
65
  function printResults(results, silent = false) {
63
66
  if (silent) return;
64
67
  if (results.length === 0) {
65
- output.success('No packages with install scripts found.');
68
+ output.success('No issues found.');
66
69
  return;
67
70
  }
68
71
  for (const r of results) {
@@ -33,6 +33,8 @@ module.exports = {
33
33
  async run({ args, flags, config, cwd }) {
34
34
  const packages = args.filter(a => !a.startsWith('-'));
35
35
 
36
+ const spinner = !flags.json && !config.silent ? output.createSpinner('Auditing packages...') : null;
37
+ if (spinner) spinner.start();
36
38
  const results = await scan({
37
39
  cwd,
38
40
  config,
@@ -40,6 +42,7 @@ module.exports = {
40
42
  verbose: flags.verbose,
41
43
  packages: packages.length > 0 ? packages : null,
42
44
  });
45
+ if (spinner) spinner.stop();
43
46
 
44
47
  const hasIssues = results.some(r => r.verdict !== 'OK');
45
48
  const silent = config.silent && !hasIssues;
@@ -55,7 +58,7 @@ module.exports = {
55
58
  const blocked = results.filter(r => r.verdict === 'BLOCK');
56
59
 
57
60
  if (blocked.length > 0 && !flags.review) {
58
- output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
61
+ output.error(`${blocked.length} package(s) blocked suspicious or malicious packages detected.`);
59
62
  output.log(output.dim(' Run with --review to interactively decide which scripts to allow.'));
60
63
  process.exit(1);
61
64
  }
@@ -81,7 +84,7 @@ module.exports = {
81
84
  function printResults(results, silent = false) {
82
85
  if (silent) return;
83
86
  if (results.length === 0) {
84
- output.success('No packages with install scripts found.');
87
+ output.success('No issues found.');
85
88
  return;
86
89
  }
87
90
  for (const r of results) {
@@ -10,7 +10,7 @@ module.exports = {
10
10
 
11
11
  help() {
12
12
  return `
13
- npa scan — Scan dependencies for obfuscated install scripts
13
+ npa scan — Scan dependencies for security issues
14
14
 
15
15
  Usage:
16
16
  npa scan [package] [options]
@@ -31,7 +31,10 @@ module.exports = {
31
31
 
32
32
  async run({ args, flags, config, cwd }) {
33
33
  const packages = args.filter(a => !a.startsWith('-'));
34
+ const spinner = !flags.json && !config.silent ? output.createSpinner('Auditing packages...') : null;
35
+ if (spinner) spinner.start();
34
36
  const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose, packages: packages.length > 0 ? packages : null });
37
+ if (spinner) spinner.stop();
35
38
  const hasIssues = results.some(r => r.verdict !== 'OK');
36
39
  const silent = config.silent && !hasIssues;
37
40
 
@@ -54,7 +57,7 @@ module.exports = {
54
57
  function printResults(results, silent = false) {
55
58
  if (silent) return;
56
59
  if (results.length === 0) {
57
- output.success('No packages with install scripts found.');
60
+ output.success('No issues found.');
58
61
  return;
59
62
  }
60
63
  for (const r of results) {
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { shannonEntropy } = require('../utils/entropy');
4
+
3
5
  // ─── Constants ───────────────────────────────────────────────────────────────
4
6
 
5
7
  const MAX_CODE_SIZE = 500000; // 500KB - chunk larger files
@@ -338,19 +340,7 @@ function checkFilesystemManipulation(code) {
338
340
  };
339
341
  }
340
342
 
341
- // ─── Entropy helper ──────────────────────────────────────────────────────────
342
-
343
- function shannonEntropy(str) {
344
- if (!str || str.length === 0) return 0;
345
- const freq = {};
346
- for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
347
- let entropy = 0;
348
- for (const count of Object.values(freq)) {
349
- const p = count / str.length;
350
- entropy -= p * Math.log2(p);
351
- }
352
- return entropy;
353
- }
343
+ // ─── Entropy helper (re-exported from utils/entropy.js) ─────────────────────
354
344
 
355
345
  // ─── Main detection function ─────────────────────────────────────────────────
356
346
 
@@ -8,6 +8,7 @@ const { parseTarGz, extractFile, getPackageJson } = require('../utils/tar
8
8
  const { detectObfuscation } = require('./detector');
9
9
  const { walkRequires, MAX_FILES_PER_PACKAGE, MAX_TOTAL_BYTES } = require('./requireWalker');
10
10
  const { parseCommand } = require('../utils/command');
11
+ const { getPackageMarshallers } = require('../marshallers');
11
12
  const output = require('../utils/output');
12
13
 
13
14
  // Lifecycle scripts that npm executes during install. The original tool only
@@ -54,7 +55,8 @@ async function scan(opts) {
54
55
  const resolved = await resolveSinglePackage(pkg, config);
55
56
  // Mark the first package (the explicitly requested one) as explicit
56
57
  if (resolved.length > 0) {
57
- const pkgName = pkg.includes('@') && !pkg.startsWith('@') ? pkg.split('@')[0] : pkg;
58
+ const lastAt = pkg.lastIndexOf('@');
59
+ const pkgName = lastAt > 0 ? pkg.slice(0, lastAt) : pkg;
58
60
  explicitPackageNames.add(pkgName);
59
61
  }
60
62
  allPackages.push(...resolved);
@@ -72,10 +74,7 @@ async function scan(opts) {
72
74
  }
73
75
  }
74
76
 
75
- // Track packages without install scripts (for skipped count)
76
- let skippedCount = 0;
77
-
78
- // Apply skip filters
77
+ // Apply user skip filters (scopes, packages, dev)
79
78
  packages = packages.filter(pkg => {
80
79
  if (noDev && pkg.dev) return false;
81
80
  if (pkg.inBundle || pkg.link) return false;
@@ -85,15 +84,15 @@ async function scan(opts) {
85
84
  if (pkg.name.startsWith(scope + '/') || pkg.name === scope) return false;
86
85
  }
87
86
  }
88
- // For explicit packages, always include them but track if they have no scripts
89
- if (explicitPackageNames.has(pkg.name)) {
90
- if (!pkg.hasInstallScript) {
91
- skippedCount++;
92
- return false;
93
- }
94
- return true;
95
- }
96
- // v2/v3 lockfiles reliably report hasInstallScript — skip definitive negatives
87
+ return true;
88
+ });
89
+
90
+ // All non-skipped packages are eligible for package-level marshallers (CVE)
91
+ const allPackages = packages;
92
+
93
+ // Filter to only packages with lifecycle scripts for code analysis
94
+ packages = packages.filter(pkg => {
95
+ if (explicitPackageNames.has(pkg.name)) return true;
97
96
  if (lockfileVersion >= 2 && pkg.hasInstallScript === false) return false;
98
97
  return true;
99
98
  });
@@ -105,23 +104,48 @@ async function scan(opts) {
105
104
  return scanPackage(pkg, cwd, config, verbose);
106
105
  });
107
106
 
107
+ // Run package-level marshallers (CVE checks) on ALL packages, not just those with scripts
108
+ if (config.checkVulnerabilities) {
109
+ const packageMarshallers = getPackageMarshallers();
110
+ const cveResults = await mapWithConcurrency(allPackages, config.parallelFetches, async (pkg) => {
111
+ for (const marshaller of packageMarshallers) {
112
+ const finding = await marshaller.checkPackage(pkg, config);
113
+ if (finding) return { pkg, finding };
114
+ }
115
+ return null;
116
+ });
117
+
118
+ for (const cveResult of cveResults) {
119
+ if (!cveResult) continue;
120
+ const { pkg, finding } = cveResult;
121
+ const existing = results.find(r => r && r.pkg && r.pkg.name === pkg.name);
122
+ if (existing) {
123
+ existing.findings.push(finding);
124
+ if (finding.score > existing.score) {
125
+ existing.score = finding.score;
126
+ existing.verdict = verdictFromScore(finding.score, config);
127
+ }
128
+ } else {
129
+ results.push({
130
+ pkg,
131
+ scripts: [],
132
+ score: finding.score,
133
+ findings: [finding],
134
+ verdict: verdictFromScore(finding.score, config),
135
+ });
136
+ }
137
+ }
138
+ }
139
+
108
140
  const scanned = results.filter(Boolean);
109
- // Add packages that returned null from scanPackage (no scripts found during scan)
110
- skippedCount += results.filter(r => r === null).length;
111
-
112
- // Optionally scan the *current project's own* lifecycle scripts. This is
113
- // off by default to avoid surprising users — `npa` is a drop-in replacement
114
- // for `npm install` and most projects' own postinstall scripts are
115
- // intentionally local. Set `scanSelf: true` in .npmauditor.json (or pass
116
- // --scan-self) to opt in. Useful for CI on third-party PRs.
141
+
142
+ // Optionally scan the *current project's own* lifecycle scripts.
117
143
  if (config.scanSelf) {
118
144
  const selfResult = scanCwdProject(cwd, config);
119
145
  if (selfResult) scanned.unshift(selfResult);
120
- else skippedCount++;
121
146
  }
122
147
 
123
- // Attach metadata to results array
124
- scanned.skippedCount = skippedCount;
148
+ scanned.totalPackages = allPackages.length;
125
149
  return scanned;
126
150
  }
127
151
 
@@ -585,7 +609,8 @@ async function resolveFromPackageJson(cwd, config, noDev) {
585
609
  if (!version) continue;
586
610
 
587
611
  try {
588
- const meta = await fetchJSON(`${config.registry}/${encodeURIComponent(name)}`, { timeout: config.timeout });
612
+ const encodedName = name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name);
613
+ const meta = await fetchJSON(`${config.registry}/${encodedName}`, { timeout: config.timeout });
589
614
  const versionData = meta.versions && meta.versions[version];
590
615
  if (!versionData) continue;
591
616
 
@@ -616,14 +641,21 @@ async function resolveFromPackageJson(cwd, config, noDev) {
616
641
  * @returns {Promise<PackageDescriptor[]>}
617
642
  */
618
643
  async function resolveSinglePackage(packageSpec, config) {
619
- const [name, version] = packageSpec.includes('@') && !packageSpec.startsWith('@')
620
- ? packageSpec.split('@')
621
- : [packageSpec, 'latest'];
644
+ let name, version;
645
+ const lastAt = packageSpec.lastIndexOf('@');
646
+ if (lastAt > 0) {
647
+ name = packageSpec.slice(0, lastAt);
648
+ version = packageSpec.slice(lastAt + 1);
649
+ } else {
650
+ name = packageSpec;
651
+ version = 'latest';
652
+ }
622
653
 
623
654
  const { fetchJSON } = require('../utils/fetcher');
624
655
  let meta;
625
656
  try {
626
- meta = await fetchJSON(`${config.registry}/${encodeURIComponent(name)}`, { timeout: config.timeout });
657
+ const encodedName = name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name);
658
+ meta = await fetchJSON(`${config.registry}/${encodedName}`, { timeout: config.timeout });
627
659
  } catch (err) {
628
660
  throw new Error(`Could not fetch registry metadata for "${name}": ${err.message}`);
629
661
  }
@@ -638,24 +670,55 @@ async function resolveSinglePackage(packageSpec, config) {
638
670
  const packages = [];
639
671
  const seen = new Set();
640
672
 
641
- function collectDeps(deps) {
642
- for (const [depName, range] of Object.entries(deps || {})) {
643
- if (seen.has(depName)) continue;
644
- seen.add(depName);
645
- // Extract the first clean semver from the range (e.g. "4.22.1 || ^5" → "4.22.1", "^5.1.0" → "5.1.0")
673
+ async function collectDeps(deps, recurse) {
674
+ const queue = Object.entries(deps || {}).filter(([depName]) => !seen.has(depName));
675
+ // Mark all as seen first to avoid duplicate fetches
676
+ for (const [depName] of queue) seen.add(depName);
677
+
678
+ const resolutions = await mapWithConcurrency(queue, config.parallelFetches, async ([depName, range]) => {
646
679
  const exactVersion = extractSemver(range);
647
- if (!exactVersion) continue; // skip unresolvable ranges — lockfile scan will cover them
648
- packages.push({
649
- name: depName,
650
- version: exactVersion,
651
- resolved: buildTarballUrl(depName, exactVersion, config.registry),
652
- integrity: '',
653
- hasInstallScript: false,
654
- dev: false,
655
- optional: false,
656
- inBundle: false,
657
- link: false,
658
- });
680
+ if (!exactVersion) return null;
681
+
682
+ let depScripts = false;
683
+ let depDeps = null;
684
+ let depTarball = buildTarballUrl(depName, exactVersion, config.registry);
685
+ let depIntegrity = '';
686
+
687
+ try {
688
+ const encodedDep = depName.startsWith('@') ? `@${encodeURIComponent(depName.slice(1))}` : encodeURIComponent(depName);
689
+ const depMeta = await fetchJSON(`${config.registry}/${encodedDep}`, { timeout: config.timeout });
690
+ const depData = depMeta.versions && depMeta.versions[exactVersion];
691
+ if (depData) {
692
+ depScripts = !!(depData.scripts &&
693
+ (depData.scripts.preinstall || depData.scripts.postinstall || depData.scripts.install));
694
+ depTarball = depData.dist && depData.dist.tarball || depTarball;
695
+ depIntegrity = depData.dist && depData.dist.integrity || '';
696
+ depDeps = depData.dependencies;
697
+ }
698
+ } catch {
699
+ // Failed to fetch dep metadata — add with what we have
700
+ }
701
+
702
+ return {
703
+ pkg: {
704
+ name: depName,
705
+ version: exactVersion,
706
+ resolved: depTarball,
707
+ integrity: depIntegrity,
708
+ hasInstallScript: depScripts,
709
+ dev: false,
710
+ optional: false,
711
+ inBundle: false,
712
+ link: false,
713
+ },
714
+ depDeps,
715
+ };
716
+ });
717
+
718
+ for (const r of resolutions) {
719
+ if (!r) continue;
720
+ packages.push(r.pkg);
721
+ if (recurse && r.depDeps) await collectDeps(r.depDeps, true);
659
722
  }
660
723
  }
661
724
 
@@ -673,7 +736,8 @@ async function resolveSinglePackage(packageSpec, config) {
673
736
  link: false,
674
737
  });
675
738
 
676
- collectDeps(versionData.dependencies);
739
+ seen.add(name);
740
+ await collectDeps(versionData.dependencies, !!config.deepResolve);
677
741
 
678
742
  return packages;
679
743
  }
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ class Marshaller {
4
+ constructor(name, title) {
5
+ this.name = name;
6
+ this.title = title;
7
+ }
8
+
9
+ check(code) {
10
+ return null;
11
+ }
12
+
13
+ async checkPackage(pkg, config) {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ module.exports = { Marshaller };
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+
5
+ class Base64ExecMarshaller extends Marshaller {
6
+ constructor() {
7
+ super('encoded-decode', 'Encoded decode + execution detection');
8
+ }
9
+
10
+ check(code) {
11
+ const hasBase64 = /atob\s*\(|Buffer\.from\s*\([^)]*,\s*['"]base64['"]\)/.test(code);
12
+ const hasHexDecode = /Buffer\.from\s*\([^)]*,\s*['"]hex['"]\)/.test(code);
13
+ const hasExec = /\beval\s*\(|new\s+Function\s*\(|\.exec\s*\(|\(\s*0\s*,\s*eval\s*\)\s*\(/.test(code);
14
+ if (!hasBase64 && !hasHexDecode) return null;
15
+ const kind = hasBase64 ? 'Base64' : 'Hex';
16
+ if (!hasExec) {
17
+ return { name: 'encoded-decode', score: 3, detail: `${kind} decode found — verify usage` };
18
+ }
19
+ return { name: 'encoded-decode+exec', score: 8, detail: `${kind} decode with code execution found` };
20
+ }
21
+ }
22
+
23
+ module.exports = new Base64ExecMarshaller();
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+
5
+ class ChildProcessMarshaller extends Marshaller {
6
+ constructor() {
7
+ super('child-process', 'Shell / process execution detection');
8
+ }
9
+
10
+ check(code) {
11
+ const patterns = [
12
+ /require\s*\(\s*['"]child_process['"]\s*\)/,
13
+ /require\s*\(\s*['"]node:child_process['"]\s*\)/,
14
+ /require\s*\(\s*['"`][^'"`]*['"`](?:\s*\+\s*['"`][^'"`]*['"`])+\s*\)/,
15
+ /require\s*\(\s*[a-zA-Z_$][\w$]*\s*\[/,
16
+ /\bexec\s*\(/,
17
+ /\bspawn\s*\(/,
18
+ /\bexecSync\s*\(/,
19
+ /\bspawnSync\s*\(/,
20
+ /\bexecFile\s*\(/,
21
+ /\bexecFileSync\s*\(/,
22
+ /\bfork\s*\(/,
23
+ /require\s*\(\s*['"]worker_threads['"]\s*\)/,
24
+ /new\s+Worker\s*\(/,
25
+ ];
26
+ const matched = patterns.filter(p => p.test(code));
27
+ if (matched.length === 0) return null;
28
+ return { name: this.name, score: 5, detail: `Shell/process execution found (${matched.length} pattern(s))` };
29
+ }
30
+ }
31
+
32
+ module.exports = new ChildProcessMarshaller();
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { Marshaller } = require('./base');
7
+
8
+ const SNYK_CONFIG_FILE = '.config/configstore/snyk.json';
9
+
10
+ class CveMarshaller extends Marshaller {
11
+ constructor() {
12
+ super('known-vulnerability', 'Known vulnerability check (Snyk / OSV.dev)');
13
+ }
14
+
15
+ async checkPackage(pkg, config) {
16
+ if (!pkg.name || !pkg.version) return null;
17
+
18
+ try {
19
+ const token = this.getSnykToken();
20
+ const timeout = Math.min(config.timeout || 10000, 10000);
21
+ const result = token
22
+ ? await this.querySnyk(pkg.name, pkg.version, token, timeout)
23
+ : await this.queryOsv(pkg.name, pkg.version, timeout);
24
+
25
+ if (result.issuesCount === 0) return null;
26
+
27
+ if (result.isMalicious) {
28
+ return {
29
+ name: this.name,
30
+ score: 80,
31
+ detail: `Malicious package detected — ${result.issuesCount} advisory(ies) found`,
32
+ };
33
+ }
34
+
35
+ // Non-malicious CVEs are informational (WARN, never BLOCK)
36
+ let score = 4;
37
+ if (result.issuesCount >= 10) score = 6;
38
+ else if (result.issuesCount >= 5) score = 5;
39
+
40
+ const source = token ? 'Snyk' : 'OSV.dev';
41
+ return {
42
+ name: this.name,
43
+ score,
44
+ detail: `${result.issuesCount} known vulnerability(ies) via ${source}`,
45
+ };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ getSnykToken() {
52
+ const token = process.env.SNYK_API_TOKEN || process.env.SNYK_TOKEN;
53
+ if (token) return token;
54
+
55
+ try {
56
+ const configPath = path.join(os.homedir(), SNYK_CONFIG_FILE);
57
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
58
+ if (cfg && cfg.api) return cfg.api;
59
+ } catch {
60
+ // No Snyk config
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ async querySnyk(packageName, packageVersion, token, timeout) {
67
+ const { fetchJSON } = require('../utils/fetcher');
68
+ const apiUrl = process.env.SNYK_API_URL || process.env.SNYK_API || 'https://snyk.io/api/v1/vuln/npm';
69
+ const url = `${apiUrl}/${encodeURIComponent(packageName + '@' + packageVersion)}`;
70
+
71
+ const data = await fetchJSON(url, {
72
+ timeout,
73
+ headers: { Authorization: `token ${token}` },
74
+ });
75
+
76
+ if (data && data.vulnerabilities) {
77
+ const isMalicious = data.vulnerabilities.some(v => v.title === 'Malicious Package');
78
+ return { issuesCount: data.vulnerabilities.length, isMalicious };
79
+ }
80
+ return { issuesCount: 0, isMalicious: false };
81
+ }
82
+
83
+ async queryOsv(packageName, packageVersion, timeout) {
84
+ const { fetchJSON } = require('../utils/fetcher');
85
+ const url = 'https://api.osv.dev/v1/query';
86
+
87
+ const body = JSON.stringify({
88
+ version: packageVersion,
89
+ package: { name: packageName, ecosystem: 'npm' },
90
+ });
91
+
92
+ const data = await fetchJSON(url, {
93
+ timeout,
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body,
97
+ });
98
+
99
+ if (data && data.vulns && data.vulns.length > 0) {
100
+ const isMalicious = data.vulns.some(vuln => {
101
+ const ds = vuln.database_specific;
102
+ if (ds && ds['malicious-packages-origins'] && Array.isArray(ds['malicious-packages-origins'])) {
103
+ return true;
104
+ }
105
+ if (typeof vuln.summary === 'string' && vuln.summary.toLowerCase().startsWith('malicious')) {
106
+ return true;
107
+ }
108
+ return false;
109
+ });
110
+ return { issuesCount: data.vulns.length, isMalicious };
111
+ }
112
+ return { issuesCount: 0, isMalicious: false };
113
+ }
114
+ }
115
+
116
+ module.exports = new CveMarshaller();
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+
5
+ class EvalMarshaller extends Marshaller {
6
+ constructor() {
7
+ super('eval/dynamic-exec', 'Eval / dynamic code execution');
8
+ }
9
+
10
+ check(code) {
11
+ const patterns = [
12
+ /\beval\s*\(/,
13
+ /new\s+Function\s*\(/,
14
+ /vm\.runInThisContext\s*\(/,
15
+ /vm\.runInNewContext\s*\(/,
16
+ /vm\.Script\s*\(/,
17
+ /\(\s*0\s*,\s*eval\s*\)\s*\(/,
18
+ /(?:global|globalThis|window|self|this)\s*\[\s*['"`](?:eval|Function)['"`]\s*\]\s*\(/,
19
+ /(?:global|globalThis|window|self|this)\s*\[\s*['"`][^'"`]*['"`](?:\s*\+\s*['"`][^'"`]*['"`]){1,}\s*\]\s*\(/,
20
+ /\.constructor\s*\.\s*constructor\s*\(/,
21
+ /\b(?:setTimeout|setInterval)\s*\(\s*['"`]/,
22
+ /require\s*\(\s*['"]vm['"]\s*\)/,
23
+ ];
24
+ const matched = patterns.filter(p => p.test(code));
25
+ if (matched.length === 0) return null;
26
+ return { name: this.name, score: 8, detail: `eval-like call found (${matched.length} pattern(s))` };
27
+ }
28
+ }
29
+
30
+ module.exports = new EvalMarshaller();
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+
5
+ class FilesystemManipulationMarshaller extends Marshaller {
6
+ constructor() {
7
+ super('filesystem-manipulation', 'Filesystem manipulation detection');
8
+ }
9
+
10
+ check(code) {
11
+ const writePatterns = [
12
+ /fs\.write(?:File)?(?:Sync)?\s*\(/,
13
+ /fs\.append(?:File)?(?:Sync)?\s*\(/,
14
+ /fs\.create(?:WriteStream)?\s*\(/,
15
+ /\.pipe\s*\(/,
16
+ ];
17
+ const permissionPatterns = [
18
+ /fs\.chmod(?:Sync)?\s*\(/,
19
+ /fs\.chown(?:Sync)?\s*\(/,
20
+ /fs\.access(?:Sync)?\s*\(/,
21
+ ];
22
+ const linkPatterns = [
23
+ /fs\.symlink(?:Sync)?\s*\(/,
24
+ /fs\.link(?:Sync)?\s*\(/,
25
+ ];
26
+
27
+ const writeMatches = writePatterns.filter(p => p.test(code)).length;
28
+ const permMatches = permissionPatterns.filter(p => p.test(code)).length;
29
+ const linkMatches = linkPatterns.filter(p => p.test(code)).length;
30
+
31
+ if (writeMatches === 0 && permMatches === 0 && linkMatches === 0) return null;
32
+
33
+ const details = [];
34
+ if (writeMatches > 0) details.push(`${writeMatches} write operation(s)`);
35
+ if (permMatches > 0) details.push(`${permMatches} permission change(s)`);
36
+ if (linkMatches > 0) details.push(`${linkMatches} symlink operation(s)`);
37
+
38
+ let score = 3;
39
+ if ((writeMatches > 0 ? 1 : 0) + (permMatches > 0 ? 1 : 0) + (linkMatches > 0 ? 1 : 0) >= 2) {
40
+ score = 4;
41
+ }
42
+
43
+ return { name: this.name, score, detail: details.join(', ') };
44
+ }
45
+ }
46
+
47
+ module.exports = new FilesystemManipulationMarshaller();
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+
5
+ class FromCharCodeMarshaller extends Marshaller {
6
+ constructor() {
7
+ super('fromCharCode', 'String.fromCharCode obfuscation detection');
8
+ }
9
+
10
+ check(code) {
11
+ let maxArgs = 0;
12
+ const direct = /(?:String|[\w$]+)\.fromCharCode\s*\(([^)]+)\)/g;
13
+ let match;
14
+ while ((match = direct.exec(code)) !== null) {
15
+ const args = match[1].split(',').filter(a => /^\s*\d+\s*$/.test(a));
16
+ if (args.length > maxArgs) maxArgs = args.length;
17
+ }
18
+
19
+ const arrRe = /\[\s*((?:\d{1,3}\s*,\s*){7,}\d{1,3})\s*\]/g;
20
+ let arrMatch;
21
+ let maxArr = 0;
22
+ while ((arrMatch = arrRe.exec(code)) !== null) {
23
+ const nums = arrMatch[1].split(',')
24
+ .map(s => parseInt(s.trim(), 10))
25
+ .filter(n => !Number.isNaN(n));
26
+ const printable = nums.filter(n => n >= 32 && n <= 126).length;
27
+ if (printable / nums.length >= 0.9 && nums.length > maxArr) maxArr = nums.length;
28
+ }
29
+
30
+ if (maxArgs >= 5) {
31
+ return { name: this.name, score: 7, detail: `fromCharCode with ${maxArgs} numeric args` };
32
+ }
33
+ if (maxArr >= 16) {
34
+ return { name: this.name, score: 7, detail: `decimal char-code array of length ${maxArr}` };
35
+ }
36
+ return null;
37
+ }
38
+ }
39
+
40
+ module.exports = new FromCharCodeMarshaller();
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+
5
+ class HexArrayMarshaller extends Marshaller {
6
+ constructor() {
7
+ super('hex-array', 'Hex literal array detection');
8
+ }
9
+
10
+ check(code) {
11
+ const hexLiterals = (code.match(/\b0x[0-9a-fA-F]+\b/g) || []).length;
12
+ if (hexLiterals < 20) return null;
13
+ let score = 7;
14
+ if (hexLiterals > 2000) score = 60;
15
+ else if (hexLiterals > 500) score = 40;
16
+ else if (hexLiterals > 100) score = 20;
17
+ return { name: this.name, score, detail: `${hexLiterals} hex literal values found` };
18
+ }
19
+ }
20
+
21
+ module.exports = new HexArrayMarshaller();
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+
5
+ class HexEscapesMarshaller extends Marshaller {
6
+ constructor() {
7
+ super('hex-escape-density', 'Dense hex/unicode escape detection');
8
+ }
9
+
10
+ check(code) {
11
+ const hexMatches = (code.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
12
+ const unicodeMatches = (code.match(/\\u[0-9a-fA-F]{4}/g) || []).length
13
+ + (code.match(/\\u\{[0-9a-fA-F]+\}/g) || []).length;
14
+ const total = hexMatches + unicodeMatches;
15
+ if (total < 10) return null;
16
+ let score = 5;
17
+ if (total > 1000) score = 50;
18
+ else if (total > 200) score = 30;
19
+ else if (total > 50) score = 15;
20
+ const detail = unicodeMatches > 0
21
+ ? `${hexMatches} \\xNN + ${unicodeMatches} \\uXXXX escapes found`
22
+ : `${hexMatches} \\xNN hex escapes found`;
23
+ return { name: this.name, score, detail };
24
+ }
25
+ }
26
+
27
+ module.exports = new HexEscapesMarshaller();
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+ const { shannonEntropy } = require('../utils/entropy');
5
+
6
+ class HighEntropyMarshaller extends Marshaller {
7
+ constructor() {
8
+ super('high-entropy-string', 'High-entropy string detection');
9
+ }
10
+
11
+ check(code) {
12
+ let maxEntropy = 0;
13
+ let worst = '';
14
+ const minLen = 50;
15
+
16
+ for (const quote of ['"', "'", '`']) {
17
+ let pos = 0;
18
+ while (pos < code.length) {
19
+ const start = code.indexOf(quote, pos);
20
+ if (start === -1) break;
21
+
22
+ let end = start + 1;
23
+ while (end < code.length) {
24
+ if (code[end] === '\\') { end += 2; continue; }
25
+ if (code[end] === quote) break;
26
+ end++;
27
+ }
28
+
29
+ if (end < code.length && end - start - 1 >= minLen) {
30
+ const s = code.slice(start + 1, end);
31
+ const e = shannonEntropy(s);
32
+ if (e > maxEntropy) { maxEntropy = e; worst = s.slice(0, 40); }
33
+ }
34
+ pos = end + 1;
35
+ }
36
+ }
37
+
38
+ if (maxEntropy >= 4.5) {
39
+ return {
40
+ name: this.name,
41
+ score: 6,
42
+ detail: `Entropy ${maxEntropy.toFixed(2)} in string "${worst}…"`,
43
+ };
44
+ }
45
+
46
+ const concatChainRe = /(?:['"`][^'"`\n]{0,40}['"`]\s*\+\s*){5,}['"`][^'"`\n]{0,40}['"`]/g;
47
+ let m;
48
+ let bestChain = '';
49
+ while ((m = concatChainRe.exec(code)) !== null) {
50
+ const literals = m[0].match(/['"`]([^'"`\n]{0,40})['"`]/g) || [];
51
+ const joined = literals.map(l => l.slice(1, -1)).join('');
52
+ if (joined.length >= 40) {
53
+ const e = shannonEntropy(joined);
54
+ if (e > maxEntropy) { maxEntropy = e; bestChain = joined.slice(0, 40); }
55
+ }
56
+ }
57
+
58
+ if (maxEntropy >= 4.5) {
59
+ return {
60
+ name: this.name,
61
+ score: 6,
62
+ detail: `Entropy ${maxEntropy.toFixed(2)} in concatenated literals "${bestChain}…"`,
63
+ };
64
+ }
65
+
66
+ return null;
67
+ }
68
+ }
69
+
70
+ module.exports = new HighEntropyMarshaller();
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const MARSHALLERS_DIR = __dirname;
7
+ const EXCLUDE = new Set(['index.js', 'base.js', 'cve.js']);
8
+
9
+ let _staticMarshallers = null;
10
+
11
+ function getStaticMarshallers() {
12
+ if (_staticMarshallers) return _staticMarshallers;
13
+
14
+ _staticMarshallers = fs.readdirSync(MARSHALLERS_DIR)
15
+ .filter(f => f.endsWith('.js') && !EXCLUDE.has(f))
16
+ .map(f => require(path.join(MARSHALLERS_DIR, f)));
17
+
18
+ return _staticMarshallers;
19
+ }
20
+
21
+ function getPackageMarshallers() {
22
+ return [require('./cve')];
23
+ }
24
+
25
+ module.exports = { getStaticMarshallers, getPackageMarshallers };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+
5
+ class NetworkCallsMarshaller extends Marshaller {
6
+ constructor() {
7
+ super('network-call', 'Suspicious network call detection');
8
+ }
9
+
10
+ check(code) {
11
+ const patterns = [
12
+ /require\s*\(\s*['"]https?['"]\s*\)/,
13
+ /require\s*\(\s*['"]net['"]\s*\)/,
14
+ /require\s*\(\s*['"]dns['"]\s*\)/,
15
+ /require\s*\(\s*['"]tls['"]\s*\)/,
16
+ /require\s*\(\s*['"]dgram['"]\s*\)/,
17
+ /require\s*\(\s*['"]http2['"]\s*\)/,
18
+ /\bfetch\s*\(/,
19
+ /XMLHttpRequest/,
20
+ /\.request\s*\(/,
21
+ /require\s*\(\s*['"]node:(?:https?|net|dns|tls|dgram|http2)['"]\s*\)/,
22
+ /import\s*\(\s*['"](?:node:)?(?:https?|net|dns|tls|dgram|http2)['"]\s*\)/,
23
+ ];
24
+ const matched = patterns.filter(p => p.test(code));
25
+ if (matched.length === 0) return null;
26
+ return { name: this.name, score: 4, detail: `Network call found (${matched.length} pattern(s))` };
27
+ }
28
+ }
29
+
30
+ module.exports = new NetworkCallsMarshaller();
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+
5
+ class ObfuscatorIoMarshaller extends Marshaller {
6
+ constructor() {
7
+ super('obfuscator.io', 'Obfuscator.io signature detection');
8
+ }
9
+
10
+ check(code) {
11
+ const matches = code.match(/_0x[0-9a-fA-F]+/g) || [];
12
+ if (matches.length < 3) return null;
13
+ let score = 9;
14
+ if (matches.length > 1000) score = 80;
15
+ else if (matches.length > 200) score = 50;
16
+ else if (matches.length > 50) score = 30;
17
+ else if (matches.length > 10) score = 15;
18
+ return { name: this.name, score, detail: `${matches.length} _0x identifiers found` };
19
+ }
20
+ }
21
+
22
+ module.exports = new ObfuscatorIoMarshaller();
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ const { Marshaller } = require('./base');
4
+
5
+ class ProcessEnvMarshaller extends Marshaller {
6
+ constructor() {
7
+ super('process-env', 'Process environment access detection');
8
+ }
9
+
10
+ check(code) {
11
+ const matches = (code.match(/process\.env\b/g) || []).length;
12
+ if (matches === 0) return null;
13
+ return { name: this.name, score: 3, detail: `${matches} process.env access(es)` };
14
+ }
15
+ }
16
+
17
+ module.exports = new ProcessEnvMarshaller();
@@ -17,6 +17,8 @@ const DEFAULT_CONFIG = Object.freeze({
17
17
  silent: false,
18
18
  scanSelf: true,
19
19
  maxTarballSize: '50MB', // Max unpacked tarball size (e.g. '5MB', '1GB', or bytes as number)
20
+ checkVulnerabilities: true,
21
+ deepResolve: false,
20
22
  });
21
23
 
22
24
  const VALID_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ function shannonEntropy(str) {
4
+ if (!str || str.length === 0) return 0;
5
+ const freq = {};
6
+ for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
7
+ let entropy = 0;
8
+ for (const count of Object.values(freq)) {
9
+ const p = count / str.length;
10
+ entropy -= p * Math.log2(p);
11
+ }
12
+ return entropy;
13
+ }
14
+
15
+ module.exports = { shannonEntropy };
@@ -9,10 +9,10 @@ const MAX_REDIRECTS = 5;
9
9
  const DEFAULT_TIMEOUT = 30000;
10
10
 
11
11
  /**
12
- * Perform an HTTP/HTTPS GET and collect the response body as a Buffer.
12
+ * Perform an HTTP/HTTPS request and collect the response body as a Buffer.
13
13
  * Follows redirects up to MAX_REDIRECTS.
14
14
  * @param {string} rawUrl
15
- * @param {object} opts { timeout?, headers? }
15
+ * @param {object} opts { timeout?, headers?, method?, body? }
16
16
  * @returns {Promise<Buffer>}
17
17
  */
18
18
  function fetch(rawUrl, opts = {}) {
@@ -28,7 +28,7 @@ function fetch(rawUrl, opts = {}) {
28
28
  hostname: parsed.hostname,
29
29
  port: parsed.port || (isHttps ? 443 : 80),
30
30
  path: parsed.pathname + parsed.search,
31
- method: 'GET',
31
+ method: opts.method || 'GET',
32
32
  headers: Object.assign({
33
33
  'User-Agent': 'npa/1.0.0 (npm-auditor)',
34
34
  'Accept': '*/*',
@@ -61,6 +61,7 @@ function fetch(rawUrl, opts = {}) {
61
61
  });
62
62
 
63
63
  req.on('error', reject);
64
+ if (opts.body) req.write(opts.body);
64
65
  req.end();
65
66
  }
66
67
 
@@ -87,7 +88,7 @@ function fetchTarball(tarballUrl, opts = {}) {
87
88
  async function fetchJSON(jsonUrl, opts = {}) {
88
89
  const buf = await fetch(jsonUrl, {
89
90
  ...opts,
90
- headers: { 'Accept': 'application/json' },
91
+ headers: { 'Accept': 'application/json', ...opts.headers },
91
92
  });
92
93
  return JSON.parse(buf.toString('utf8'));
93
94
  }
@@ -29,10 +29,12 @@ function cyan(text) { return c(CYAN, text); }
29
29
  function white(text) { return c(WHITE, text); }
30
30
 
31
31
  function error(msg) {
32
+ if (process.stderr.isTTY) process.stderr.write('\r\x1b[2K');
32
33
  process.stderr.write(red(`✖ ${msg}`) + '\n');
33
34
  }
34
35
 
35
36
  function warn(msg) {
37
+ if (process.stderr.isTTY) process.stderr.write('\r\x1b[2K');
36
38
  process.stderr.write(yellow(`⚠ ${msg}`) + '\n');
37
39
  }
38
40
 
@@ -67,12 +69,17 @@ const ASCII_LOGO = `
67
69
 
68
70
  function printScanHeader(silent = false) {
69
71
  if (silent) return;
70
- log(blue(ASCII_LOGO));
71
- log(dim(' npm package auditor — static obfuscation detection'));
72
+ log('');
72
73
  log(dim('─'.repeat(60)));
73
74
  log('');
74
75
  }
75
76
 
77
+ function printLogo(version) {
78
+ log(blue(ASCII_LOGO));
79
+ log(dim(` npm package auditor v${version}`));
80
+ log('');
81
+ }
82
+
76
83
  function printPackageResult(pkg, result) {
77
84
  const badge = verdictBadge(result.verdict);
78
85
  const name = bold(`${pkg.name}@${pkg.version}`);
@@ -86,22 +93,45 @@ function printPackageResult(pkg, result) {
86
93
  function printSummary(results) {
87
94
  const blocked = results.filter(r => r.verdict === 'BLOCK').length;
88
95
  const warned = results.filter(r => r.verdict === 'WARN').length;
89
- const ok = results.filter(r => r.verdict === 'OK').length;
90
- const skipped = results.skippedCount || 0;
96
+ const total = results.totalPackages || results.length;
97
+ const ok = total - blocked - warned;
91
98
 
92
99
  log('');
93
100
  log(dim('─'.repeat(60)));
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);
101
+ log(` ${green(String(ok))} clean ${yellow(String(warned))} warnings ${red(String(blocked))} blocked`);
99
102
  log('');
100
103
  }
101
104
 
105
+ const SPINNER_FRAMES = ['▉', '▊', '▋', '▌', '▍', '▎', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
106
+
107
+ function createSpinner(message) {
108
+ if (NO_COLOR || !process.stderr.isTTY) {
109
+ return { start() {}, stop() {} };
110
+ }
111
+ let i = 0;
112
+ let timer = null;
113
+ const clear = () => process.stderr.write('\r\x1b[2K');
114
+ return {
115
+ start() {
116
+ process.stderr.write(`\x1b[?25l`);
117
+ process.stderr.write(` ${cyan(SPINNER_FRAMES[0])} ${white(message)}`);
118
+ timer = setInterval(() => {
119
+ i = (i + 1) % SPINNER_FRAMES.length;
120
+ process.stderr.write(`\r\x1b[2K ${cyan(SPINNER_FRAMES[i])} ${white(message)}`);
121
+ }, 60);
122
+ },
123
+ stop() {
124
+ if (timer) clearInterval(timer);
125
+ clear();
126
+ process.stderr.write(`\x1b[?25h`);
127
+ },
128
+ };
129
+ }
130
+
102
131
  module.exports = {
103
132
  bold, dim, red, green, yellow, blue, cyan, white,
104
133
  error, warn, info, success, log,
105
134
  verdictBadge, printScanHeader, printPackageResult, printSummary,
135
+ printLogo, createSpinner,
106
136
  RESET, BOLD, DIM, RED, GREEN, YELLOW, BLUE, CYAN,
107
137
  };