np-audit 1.5.1 → 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 +67 -9
- package/package.json +1 -1
- package/src/cli.js +18 -3
- package/src/commands/alias.js +30 -11
- package/src/commands/ci.js +5 -2
- package/src/commands/install.js +5 -2
- package/src/commands/scan.js +5 -2
- package/src/core/detector.js +3 -13
- package/src/core/scanner.js +97 -42
- package/src/marshallers/base.js +18 -0
- package/src/marshallers/base64Exec.js +23 -0
- package/src/marshallers/childProcess.js +32 -0
- package/src/marshallers/cve.js +116 -0
- package/src/marshallers/eval.js +30 -0
- package/src/marshallers/filesystemManipulation.js +47 -0
- package/src/marshallers/fromCharCode.js +40 -0
- package/src/marshallers/hexArray.js +21 -0
- package/src/marshallers/hexEscapes.js +27 -0
- package/src/marshallers/highEntropy.js +70 -0
- package/src/marshallers/index.js +25 -0
- package/src/marshallers/networkCalls.js +30 -0
- package/src/marshallers/obfuscatorIo.js +22 -0
- package/src/marshallers/processEnv.js +17 -0
- package/src/utils/config.js +2 -0
- package/src/utils/entropy.js +15 -0
- package/src/utils/fetcher.js +5 -4
- package/src/utils/output.js +39 -9
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
# np-audit — npm package auditor
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
##
|
|
322
|
+
## Marshallers
|
|
277
323
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
package/src/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ function buildMainHelp() {
|
|
|
17
17
|
|
|
18
18
|
return `
|
|
19
19
|
npa — npm package auditor ${VERSION}
|
|
20
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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)
|
package/src/commands/alias.js
CHANGED
|
@@ -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
|
-
|
|
9
|
+
alias npm='npa'`;
|
|
10
10
|
|
|
11
11
|
const POWERSHELL_HOOK = `# npa npm hook
|
|
12
|
-
|
|
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
|
|
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
|
|
25
|
-
npa alias --install Add
|
|
26
|
-
npa alias --uninstall Remove
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
33
|
+
npa alias Print alias for manual installation
|
|
33
34
|
npa alias --install Auto-install to detected shell
|
|
34
|
-
eval "$(npa alias)" Load
|
|
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
|
-
|
|
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;
|
package/src/commands/ci.js
CHANGED
|
@@ -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
|
|
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
|
|
68
|
+
output.success('No issues found.');
|
|
66
69
|
return;
|
|
67
70
|
}
|
|
68
71
|
for (const r of results) {
|
package/src/commands/install.js
CHANGED
|
@@ -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
|
|
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
|
|
87
|
+
output.success('No issues found.');
|
|
85
88
|
return;
|
|
86
89
|
}
|
|
87
90
|
for (const r of results) {
|
package/src/commands/scan.js
CHANGED
|
@@ -10,7 +10,7 @@ module.exports = {
|
|
|
10
10
|
|
|
11
11
|
help() {
|
|
12
12
|
return `
|
|
13
|
-
npa scan — Scan dependencies for
|
|
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
|
|
60
|
+
output.success('No issues found.');
|
|
58
61
|
return;
|
|
59
62
|
}
|
|
60
63
|
for (const r of results) {
|
package/src/core/detector.js
CHANGED
|
@@ -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
|
|
package/src/core/scanner.js
CHANGED
|
@@ -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
|
|
@@ -73,10 +74,7 @@ async function scan(opts) {
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
//
|
|
77
|
-
let skippedCount = 0;
|
|
78
|
-
|
|
79
|
-
// Apply skip filters
|
|
77
|
+
// Apply user skip filters (scopes, packages, dev)
|
|
80
78
|
packages = packages.filter(pkg => {
|
|
81
79
|
if (noDev && pkg.dev) return false;
|
|
82
80
|
if (pkg.inBundle || pkg.link) return false;
|
|
@@ -86,15 +84,15 @@ async function scan(opts) {
|
|
|
86
84
|
if (pkg.name.startsWith(scope + '/') || pkg.name === scope) return false;
|
|
87
85
|
}
|
|
88
86
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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;
|
|
98
96
|
if (lockfileVersion >= 2 && pkg.hasInstallScript === false) return false;
|
|
99
97
|
return true;
|
|
100
98
|
});
|
|
@@ -106,23 +104,48 @@ async function scan(opts) {
|
|
|
106
104
|
return scanPackage(pkg, cwd, config, verbose);
|
|
107
105
|
});
|
|
108
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
|
+
|
|
109
140
|
const scanned = results.filter(Boolean);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// Optionally scan the *current project's own* lifecycle scripts. This is
|
|
114
|
-
// off by default to avoid surprising users — `npa` is a drop-in replacement
|
|
115
|
-
// for `npm install` and most projects' own postinstall scripts are
|
|
116
|
-
// intentionally local. Set `scanSelf: true` in .npmauditor.json (or pass
|
|
117
|
-
// --scan-self) to opt in. Useful for CI on third-party PRs.
|
|
141
|
+
|
|
142
|
+
// Optionally scan the *current project's own* lifecycle scripts.
|
|
118
143
|
if (config.scanSelf) {
|
|
119
144
|
const selfResult = scanCwdProject(cwd, config);
|
|
120
145
|
if (selfResult) scanned.unshift(selfResult);
|
|
121
|
-
else skippedCount++;
|
|
122
146
|
}
|
|
123
147
|
|
|
124
|
-
|
|
125
|
-
scanned.skippedCount = skippedCount;
|
|
148
|
+
scanned.totalPackages = allPackages.length;
|
|
126
149
|
return scanned;
|
|
127
150
|
}
|
|
128
151
|
|
|
@@ -647,24 +670,55 @@ async function resolveSinglePackage(packageSpec, config) {
|
|
|
647
670
|
const packages = [];
|
|
648
671
|
const seen = new Set();
|
|
649
672
|
|
|
650
|
-
function collectDeps(deps) {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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]) => {
|
|
655
679
|
const exactVersion = extractSemver(range);
|
|
656
|
-
if (!exactVersion)
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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);
|
|
668
722
|
}
|
|
669
723
|
}
|
|
670
724
|
|
|
@@ -682,7 +736,8 @@ async function resolveSinglePackage(packageSpec, config) {
|
|
|
682
736
|
link: false,
|
|
683
737
|
});
|
|
684
738
|
|
|
685
|
-
|
|
739
|
+
seen.add(name);
|
|
740
|
+
await collectDeps(versionData.dependencies, !!config.deepResolve);
|
|
686
741
|
|
|
687
742
|
return packages;
|
|
688
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();
|
package/src/utils/config.js
CHANGED
|
@@ -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 };
|
package/src/utils/fetcher.js
CHANGED
|
@@ -9,10 +9,10 @@ const MAX_REDIRECTS = 5;
|
|
|
9
9
|
const DEFAULT_TIMEOUT = 30000;
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Perform an HTTP/HTTPS
|
|
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
|
}
|
package/src/utils/output.js
CHANGED
|
@@ -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(
|
|
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
|
|
90
|
-
const
|
|
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
|
-
|
|
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
|
};
|