np-audit 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -5
- package/package.json +1 -1
- package/src/cli.js +40 -181
- package/src/commands/alias.js +111 -0
- package/src/commands/ci.js +90 -0
- package/src/commands/config.js +71 -0
- package/src/commands/index.js +37 -0
- package/src/commands/install.js +109 -0
- package/src/commands/scan.js +82 -0
- package/src/{detector.js → core/detector.js} +81 -16
- package/src/{scanner.js → core/scanner.js} +47 -12
- package/src/{config.js → utils/config.js} +3 -0
- package/src/{output.js → utils/output.js} +22 -7
- package/src/{aware.js → utils/review.js} +56 -16
- /package/src/{fetcher.js → utils/fetcher.js} +0 -0
- /package/src/{lockfile.js → utils/lockfile.js} +0 -0
- /package/src/{tarball.js → utils/tarball.js} +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const commands = new Map();
|
|
7
|
+
|
|
8
|
+
// Load all command modules from this directory
|
|
9
|
+
const files = fs.readdirSync(__dirname).filter(f => f !== 'index.js' && f.endsWith('.js'));
|
|
10
|
+
|
|
11
|
+
for (const file of files) {
|
|
12
|
+
const cmd = require(path.join(__dirname, file));
|
|
13
|
+
commands.set(cmd.name, cmd);
|
|
14
|
+
for (const alias of cmd.aliases || []) {
|
|
15
|
+
commands.set(alias, cmd);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
commands,
|
|
21
|
+
|
|
22
|
+
get(name) {
|
|
23
|
+
return commands.get(name);
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
list() {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const result = [];
|
|
29
|
+
for (const cmd of commands.values()) {
|
|
30
|
+
if (!seen.has(cmd.name)) {
|
|
31
|
+
seen.add(cmd.name);
|
|
32
|
+
result.push(cmd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scan } = require('../core/scanner');
|
|
4
|
+
const { runAware, runNpm } = require('../utils/review');
|
|
5
|
+
const output = require('../utils/output');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
name: 'install',
|
|
9
|
+
aliases: ['i'],
|
|
10
|
+
description: 'Audit then run npm install',
|
|
11
|
+
|
|
12
|
+
help() {
|
|
13
|
+
return `
|
|
14
|
+
npa install — Audit dependencies then run npm install
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
npa install [package] [options]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--review, -r Interactive mode: review and allow/deny scripts
|
|
21
|
+
--json Output scan results as JSON
|
|
22
|
+
--no-dev Skip devDependencies in scan
|
|
23
|
+
--verbose Show detailed findings
|
|
24
|
+
-h, --help Show this help
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
npa install Install all deps after audit
|
|
28
|
+
npa install lodash Add lodash after auditing it
|
|
29
|
+
npa install --review Review scripts interactively
|
|
30
|
+
`;
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async run({ args, flags, config, cwd }) {
|
|
34
|
+
const packages = args.filter(a => !a.startsWith('-'));
|
|
35
|
+
|
|
36
|
+
const results = await scan({
|
|
37
|
+
cwd,
|
|
38
|
+
config,
|
|
39
|
+
noDev: flags.noDev,
|
|
40
|
+
verbose: flags.verbose,
|
|
41
|
+
packages: packages.length > 0 ? packages : null,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const hasIssues = results.some(r => r.verdict !== 'OK');
|
|
45
|
+
const silent = config.silent && !hasIssues;
|
|
46
|
+
|
|
47
|
+
output.printScanHeader(silent);
|
|
48
|
+
|
|
49
|
+
if (flags.json) {
|
|
50
|
+
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
51
|
+
} else {
|
|
52
|
+
printResults(results, silent);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const blocked = results.filter(r => r.verdict === 'BLOCK');
|
|
56
|
+
|
|
57
|
+
if (blocked.length > 0 && !flags.review) {
|
|
58
|
+
output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
|
|
59
|
+
output.log(output.dim(' Run with --review to interactively decide which scripts to allow.'));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const npmArgs = packages.length > 0 ? packages : [];
|
|
64
|
+
|
|
65
|
+
if (flags.review) {
|
|
66
|
+
const packagesWithScripts = results.filter(r => r.verdict !== 'OK' || r.scripts.length > 0);
|
|
67
|
+
const exit = await runAware({
|
|
68
|
+
results: packagesWithScripts.length > 0 ? packagesWithScripts : results,
|
|
69
|
+
command: 'install',
|
|
70
|
+
npmArgs,
|
|
71
|
+
cwd,
|
|
72
|
+
});
|
|
73
|
+
process.exit(exit);
|
|
74
|
+
} else {
|
|
75
|
+
const exit = runNpm('install', npmArgs, cwd);
|
|
76
|
+
process.exit(exit);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function printResults(results, silent = false) {
|
|
82
|
+
if (silent) return;
|
|
83
|
+
if (results.length === 0) {
|
|
84
|
+
output.success('No packages with install scripts found.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const r of results) {
|
|
88
|
+
output.printPackageResult(r.pkg, r);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toJsonReport(results) {
|
|
93
|
+
return {
|
|
94
|
+
summary: {
|
|
95
|
+
total: results.length,
|
|
96
|
+
blocked: results.filter(r => r.verdict === 'BLOCK').length,
|
|
97
|
+
warned: results.filter(r => r.verdict === 'WARN').length,
|
|
98
|
+
ok: results.filter(r => r.verdict === 'OK').length,
|
|
99
|
+
},
|
|
100
|
+
packages: results.map(r => ({
|
|
101
|
+
name: r.pkg.name,
|
|
102
|
+
version: r.pkg.version,
|
|
103
|
+
verdict: r.verdict,
|
|
104
|
+
score: r.score,
|
|
105
|
+
findings: r.findings,
|
|
106
|
+
scripts: r.scripts.map(s => ({ lifecycle: s.lifecycle, file: s.file, score: s.score })),
|
|
107
|
+
})),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scan } = require('../core/scanner');
|
|
4
|
+
const output = require('../utils/output');
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
name: 'scan',
|
|
8
|
+
aliases: ['s'],
|
|
9
|
+
description: 'Scan only, no npm invocation',
|
|
10
|
+
|
|
11
|
+
help() {
|
|
12
|
+
return `
|
|
13
|
+
npa scan — Scan dependencies for obfuscated install scripts
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
npa scan [package] [options]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--json Output results as JSON
|
|
20
|
+
--no-dev Skip devDependencies
|
|
21
|
+
--verbose Show detailed findings
|
|
22
|
+
-h, --help Show this help
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
npa scan Scan all dependencies
|
|
26
|
+
npa scan lodash Scan a specific package before installing
|
|
27
|
+
npa scan --no-dev Scan production dependencies only
|
|
28
|
+
npa scan --json Output machine-readable JSON
|
|
29
|
+
`;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async run({ args, flags, config, cwd }) {
|
|
33
|
+
const packages = args.filter(a => !a.startsWith('-'));
|
|
34
|
+
const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose, packages: packages.length > 0 ? packages : null });
|
|
35
|
+
const hasIssues = results.some(r => r.verdict !== 'OK');
|
|
36
|
+
const silent = config.silent && !hasIssues;
|
|
37
|
+
|
|
38
|
+
output.printScanHeader(silent);
|
|
39
|
+
|
|
40
|
+
if (flags.json) {
|
|
41
|
+
process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
|
|
42
|
+
const hasBlock = results.some(r => r.verdict === 'BLOCK');
|
|
43
|
+
process.exit(hasBlock ? 1 : 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
printResults(results, silent);
|
|
47
|
+
if (!silent) output.printSummary(results);
|
|
48
|
+
|
|
49
|
+
const hasBlock = results.some(r => r.verdict === 'BLOCK');
|
|
50
|
+
process.exit(hasBlock ? 1 : 0);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function printResults(results, silent = false) {
|
|
55
|
+
if (silent) return;
|
|
56
|
+
if (results.length === 0) {
|
|
57
|
+
output.success('No packages with install scripts found.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const r of results) {
|
|
61
|
+
output.printPackageResult(r.pkg, r);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toJsonReport(results) {
|
|
66
|
+
return {
|
|
67
|
+
summary: {
|
|
68
|
+
total: results.length,
|
|
69
|
+
blocked: results.filter(r => r.verdict === 'BLOCK').length,
|
|
70
|
+
warned: results.filter(r => r.verdict === 'WARN').length,
|
|
71
|
+
ok: results.filter(r => r.verdict === 'OK').length,
|
|
72
|
+
},
|
|
73
|
+
packages: results.map(r => ({
|
|
74
|
+
name: r.pkg.name,
|
|
75
|
+
version: r.pkg.version,
|
|
76
|
+
verdict: r.verdict,
|
|
77
|
+
score: r.score,
|
|
78
|
+
findings: r.findings,
|
|
79
|
+
scripts: r.scripts.map(s => ({ lifecycle: s.lifecycle, file: s.file, score: s.score })),
|
|
80
|
+
})),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const MAX_CODE_SIZE = 500000; // 500KB - chunk larger files
|
|
6
|
+
|
|
3
7
|
// ─── Individual detection checks ─────────────────────────────────────────────
|
|
4
8
|
|
|
5
9
|
/**
|
|
@@ -22,31 +26,57 @@ function checkEval(code) {
|
|
|
22
26
|
|
|
23
27
|
/**
|
|
24
28
|
* Detect obfuscator.io signature: _0x variable naming.
|
|
29
|
+
* Score scales with density of obfuscation.
|
|
25
30
|
* @param {string} code
|
|
26
31
|
* @returns {Finding|null}
|
|
27
32
|
*/
|
|
28
33
|
function checkObfuscatorIo(code) {
|
|
29
34
|
const matches = code.match(/_0x[0-9a-fA-F]+/g) || [];
|
|
30
35
|
if (matches.length < 3) return null;
|
|
31
|
-
|
|
36
|
+
// Scale score: 3-10 = 9, 11-50 = 15, 51-200 = 30, 201-1000 = 50, 1000+ = 80
|
|
37
|
+
let score = 9;
|
|
38
|
+
if (matches.length > 1000) score = 80;
|
|
39
|
+
else if (matches.length > 200) score = 50;
|
|
40
|
+
else if (matches.length > 50) score = 30;
|
|
41
|
+
else if (matches.length > 10) score = 15;
|
|
42
|
+
return { name: 'obfuscator.io', score, detail: `${matches.length} _0x identifiers found` };
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
/**
|
|
35
46
|
* Detect high-entropy strings (likely encoded/encrypted payloads).
|
|
47
|
+
* Uses indexOf-based extraction to avoid regex stack overflow on large files.
|
|
36
48
|
* @param {string} code
|
|
37
49
|
* @returns {Finding|null}
|
|
38
50
|
*/
|
|
39
51
|
function checkHighEntropy(code) {
|
|
40
|
-
// Extract string literals (single, double, template)
|
|
41
|
-
const stringRe = /(?:"([^"\\]|\\.){50,}"|'([^'\\]|\\.){50,}'|`([^`\\]|\\.){50,}`)/g;
|
|
42
|
-
let match;
|
|
43
52
|
let maxEntropy = 0;
|
|
44
53
|
let worst = '';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
const minLen = 50;
|
|
55
|
+
|
|
56
|
+
// Simple string extraction without complex regex
|
|
57
|
+
for (const quote of ['"', "'", '`']) {
|
|
58
|
+
let pos = 0;
|
|
59
|
+
while (pos < code.length) {
|
|
60
|
+
const start = code.indexOf(quote, pos);
|
|
61
|
+
if (start === -1) break;
|
|
62
|
+
|
|
63
|
+
// Find end quote (skip escaped quotes)
|
|
64
|
+
let end = start + 1;
|
|
65
|
+
while (end < code.length) {
|
|
66
|
+
if (code[end] === '\\') { end += 2; continue; }
|
|
67
|
+
if (code[end] === quote) break;
|
|
68
|
+
end++;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (end < code.length && end - start - 1 >= minLen) {
|
|
72
|
+
const s = code.slice(start + 1, end);
|
|
73
|
+
const e = shannonEntropy(s);
|
|
74
|
+
if (e > maxEntropy) { maxEntropy = e; worst = s.slice(0, 40); }
|
|
75
|
+
}
|
|
76
|
+
pos = end + 1;
|
|
77
|
+
}
|
|
49
78
|
}
|
|
79
|
+
|
|
50
80
|
if (maxEntropy < 4.5) return null;
|
|
51
81
|
return {
|
|
52
82
|
name: 'high-entropy-string',
|
|
@@ -57,13 +87,19 @@ function checkHighEntropy(code) {
|
|
|
57
87
|
|
|
58
88
|
/**
|
|
59
89
|
* Detect dense hex escape sequences (\x41).
|
|
90
|
+
* Score scales with volume.
|
|
60
91
|
* @param {string} code
|
|
61
92
|
* @returns {Finding|null}
|
|
62
93
|
*/
|
|
63
94
|
function checkHexEscapes(code) {
|
|
64
95
|
const hexMatches = (code.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
|
|
65
96
|
if (hexMatches < 10) return null;
|
|
66
|
-
|
|
97
|
+
// Scale: 10-50 = 5, 51-200 = 15, 201-1000 = 30, 1000+ = 50
|
|
98
|
+
let score = 5;
|
|
99
|
+
if (hexMatches > 1000) score = 50;
|
|
100
|
+
else if (hexMatches > 200) score = 30;
|
|
101
|
+
else if (hexMatches > 50) score = 15;
|
|
102
|
+
return { name: 'hex-escape-density', score, detail: `${hexMatches} \\xNN hex escapes found` };
|
|
67
103
|
}
|
|
68
104
|
|
|
69
105
|
/**
|
|
@@ -119,6 +155,7 @@ function checkChildProcess(code) {
|
|
|
119
155
|
|
|
120
156
|
/**
|
|
121
157
|
* Detect large hex literal arrays (common in minified obfuscated code).
|
|
158
|
+
* Score scales with volume.
|
|
122
159
|
* @param {string} code
|
|
123
160
|
* @returns {Finding|null}
|
|
124
161
|
*/
|
|
@@ -126,7 +163,12 @@ function checkHexArray(code) {
|
|
|
126
163
|
// Count 0x1234-style literals
|
|
127
164
|
const hexLiterals = (code.match(/\b0x[0-9a-fA-F]+\b/g) || []).length;
|
|
128
165
|
if (hexLiterals < 20) return null;
|
|
129
|
-
|
|
166
|
+
// Scale: 20-100 = 7, 101-500 = 20, 501-2000 = 40, 2000+ = 60
|
|
167
|
+
let score = 7;
|
|
168
|
+
if (hexLiterals > 2000) score = 60;
|
|
169
|
+
else if (hexLiterals > 500) score = 40;
|
|
170
|
+
else if (hexLiterals > 100) score = 20;
|
|
171
|
+
return { name: 'hex-array', score, detail: `${hexLiterals} hex literal values found` };
|
|
130
172
|
}
|
|
131
173
|
|
|
132
174
|
/**
|
|
@@ -190,22 +232,45 @@ const CHECKS = [
|
|
|
190
232
|
|
|
191
233
|
/**
|
|
192
234
|
* Run all checks against a code string.
|
|
235
|
+
* For large files, analyzes multiple chunks and aggregates results.
|
|
193
236
|
* @param {string} code
|
|
194
237
|
* @param {object} config { blockScore, warnScore }
|
|
195
238
|
* @returns {{ score: number, findings: Finding[], verdict: 'BLOCK'|'WARN'|'OK' }}
|
|
196
239
|
*/
|
|
197
|
-
function detectObfuscation(code, config = { blockScore:
|
|
240
|
+
function detectObfuscation(code, config = { blockScore: 50, warnScore: 20 }) {
|
|
198
241
|
if (!code || typeof code !== 'string') {
|
|
199
242
|
return { score: 0, findings: [], verdict: 'OK' };
|
|
200
243
|
}
|
|
201
244
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
245
|
+
// For large files, analyze chunks and take worst results
|
|
246
|
+
const chunks = [];
|
|
247
|
+
if (code.length > MAX_CODE_SIZE) {
|
|
248
|
+
// Analyze start, middle, and end chunks
|
|
249
|
+
chunks.push(code.slice(0, MAX_CODE_SIZE));
|
|
250
|
+
const mid = Math.floor(code.length / 2) - Math.floor(MAX_CODE_SIZE / 2);
|
|
251
|
+
chunks.push(code.slice(mid, mid + MAX_CODE_SIZE));
|
|
252
|
+
chunks.push(code.slice(-MAX_CODE_SIZE));
|
|
253
|
+
} else {
|
|
254
|
+
chunks.push(code);
|
|
206
255
|
}
|
|
207
256
|
|
|
208
|
-
|
|
257
|
+
const allFindings = new Map(); // Dedupe by name, keep highest score
|
|
258
|
+
|
|
259
|
+
for (const chunk of chunks) {
|
|
260
|
+
for (const check of CHECKS) {
|
|
261
|
+
const result = check(chunk);
|
|
262
|
+
if (result) {
|
|
263
|
+
const existing = allFindings.get(result.name);
|
|
264
|
+
if (!existing || result.score > existing.score) {
|
|
265
|
+
allFindings.set(result.name, result);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const findings = Array.from(allFindings.values());
|
|
272
|
+
|
|
273
|
+
// Score = highest individual finding score
|
|
209
274
|
const score = findings.length > 0
|
|
210
275
|
? Math.max(...findings.map(f => f.score))
|
|
211
276
|
: 0;
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { parseLockfile } = require('
|
|
6
|
-
const { fetchTarball, buildTarballUrl, verifyIntegrity } = require('
|
|
7
|
-
const { parseTarGz, extractFile, getPackageJson } = require('
|
|
5
|
+
const { parseLockfile } = require('../utils/lockfile');
|
|
6
|
+
const { fetchTarball, buildTarballUrl, verifyIntegrity } = require('../utils/fetcher');
|
|
7
|
+
const { parseTarGz, extractFile, getPackageJson } = require('../utils/tarball');
|
|
8
8
|
const { detectObfuscation } = require('./detector');
|
|
9
|
-
const output = require('
|
|
9
|
+
const output = require('../utils/output');
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Main scan orchestrator.
|
|
@@ -15,16 +15,33 @@ const output = require('./output');
|
|
|
15
15
|
* @param {object} opts.config
|
|
16
16
|
* @param {boolean} opts.noDev
|
|
17
17
|
* @param {boolean} opts.verbose
|
|
18
|
-
* @param {string|null} opts.singlePackage name for single-package mode
|
|
18
|
+
* @param {string|null} opts.singlePackage name for single-package mode (deprecated, use packages)
|
|
19
|
+
* @param {string[]|null} opts.packages package names to scan
|
|
19
20
|
* @returns {Promise<ScanResult[]>}
|
|
20
21
|
*/
|
|
21
22
|
async function scan(opts) {
|
|
22
|
-
const { cwd, config, noDev, verbose, singlePackage } = opts;
|
|
23
|
+
const { cwd, config, noDev, verbose, singlePackage, packages: packageList } = opts;
|
|
23
24
|
|
|
24
25
|
let packages;
|
|
25
26
|
let lockfileVersion = 1;
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
let explicitPackageNames = new Set();
|
|
28
|
+
|
|
29
|
+
// Support both single package (legacy) and multiple packages
|
|
30
|
+
const targetPackages = packageList || (singlePackage ? [singlePackage] : null);
|
|
31
|
+
|
|
32
|
+
if (targetPackages && targetPackages.length > 0) {
|
|
33
|
+
// Scan specific packages from registry
|
|
34
|
+
const allPackages = [];
|
|
35
|
+
for (const pkg of targetPackages) {
|
|
36
|
+
const resolved = await resolveSinglePackage(pkg, config);
|
|
37
|
+
// Mark the first package (the explicitly requested one) as explicit
|
|
38
|
+
if (resolved.length > 0) {
|
|
39
|
+
const pkgName = pkg.includes('@') && !pkg.startsWith('@') ? pkg.split('@')[0] : pkg;
|
|
40
|
+
explicitPackageNames.add(pkgName);
|
|
41
|
+
}
|
|
42
|
+
allPackages.push(...resolved);
|
|
43
|
+
}
|
|
44
|
+
packages = allPackages;
|
|
28
45
|
} else {
|
|
29
46
|
const lockPath = path.join(cwd, 'package-lock.json');
|
|
30
47
|
if (fs.existsSync(lockPath)) {
|
|
@@ -37,6 +54,9 @@ async function scan(opts) {
|
|
|
37
54
|
}
|
|
38
55
|
}
|
|
39
56
|
|
|
57
|
+
// Track packages without install scripts (for skipped count)
|
|
58
|
+
let skippedCount = 0;
|
|
59
|
+
|
|
40
60
|
// Apply skip filters
|
|
41
61
|
packages = packages.filter(pkg => {
|
|
42
62
|
if (noDev && pkg.dev) return false;
|
|
@@ -47,6 +67,14 @@ async function scan(opts) {
|
|
|
47
67
|
if (pkg.name.startsWith(scope + '/') || pkg.name === scope) return false;
|
|
48
68
|
}
|
|
49
69
|
}
|
|
70
|
+
// For explicit packages, always include them but track if they have no scripts
|
|
71
|
+
if (explicitPackageNames.has(pkg.name)) {
|
|
72
|
+
if (!pkg.hasInstallScript) {
|
|
73
|
+
skippedCount++;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
50
78
|
// v2/v3 lockfiles reliably report hasInstallScript — skip definitive negatives
|
|
51
79
|
if (lockfileVersion >= 2 && pkg.hasInstallScript === false) return false;
|
|
52
80
|
return true;
|
|
@@ -59,7 +87,13 @@ async function scan(opts) {
|
|
|
59
87
|
return scanPackage(pkg, cwd, config, verbose);
|
|
60
88
|
});
|
|
61
89
|
|
|
62
|
-
|
|
90
|
+
const scanned = results.filter(Boolean);
|
|
91
|
+
// Add packages that returned null from scanPackage (no scripts found during scan)
|
|
92
|
+
skippedCount += results.filter(r => r === null).length;
|
|
93
|
+
|
|
94
|
+
// Attach metadata to results array
|
|
95
|
+
scanned.skippedCount = skippedCount;
|
|
96
|
+
return scanned;
|
|
63
97
|
}
|
|
64
98
|
|
|
65
99
|
/**
|
|
@@ -269,7 +303,8 @@ function extractSemver(range) {
|
|
|
269
303
|
async function resolveFromPackageJson(cwd, config, noDev) {
|
|
270
304
|
const pkgPath = path.join(cwd, 'package.json');
|
|
271
305
|
if (!fs.existsSync(pkgPath)) {
|
|
272
|
-
|
|
306
|
+
// No package.json — nothing to scan (e.g. empty directory)
|
|
307
|
+
return [];
|
|
273
308
|
}
|
|
274
309
|
|
|
275
310
|
let pkgJson;
|
|
@@ -285,7 +320,7 @@ async function resolveFromPackageJson(cwd, config, noDev) {
|
|
|
285
320
|
}
|
|
286
321
|
|
|
287
322
|
const packages = [];
|
|
288
|
-
const { fetchJSON } = require('
|
|
323
|
+
const { fetchJSON } = require('../utils/fetcher');
|
|
289
324
|
|
|
290
325
|
for (const [name, range] of Object.entries(deps)) {
|
|
291
326
|
const version = extractSemver(range);
|
|
@@ -327,7 +362,7 @@ async function resolveSinglePackage(packageSpec, config) {
|
|
|
327
362
|
? packageSpec.split('@')
|
|
328
363
|
: [packageSpec, 'latest'];
|
|
329
364
|
|
|
330
|
-
const { fetchJSON } = require('
|
|
365
|
+
const { fetchJSON } = require('../utils/fetcher');
|
|
331
366
|
let meta;
|
|
332
367
|
try {
|
|
333
368
|
meta = await fetchJSON(`${config.registry}/${encodeURIComponent(name)}`, { timeout: config.timeout });
|
|
@@ -14,6 +14,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
14
14
|
parallelFetches: 5,
|
|
15
15
|
skipScopes: [],
|
|
16
16
|
skipPackages: [],
|
|
17
|
+
silent: false,
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
const VALID_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
|
|
@@ -43,6 +44,8 @@ function coerce(obj) {
|
|
|
43
44
|
} else if (typeof def === 'number') {
|
|
44
45
|
const n = Number(val);
|
|
45
46
|
if (!isNaN(n)) result[key] = n;
|
|
47
|
+
} else if (typeof def === 'boolean') {
|
|
48
|
+
result[key] = val === true || val === 'true' || val === '1';
|
|
46
49
|
} else {
|
|
47
50
|
result[key] = val;
|
|
48
51
|
}
|
|
@@ -49,16 +49,26 @@ function log(msg) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function verdictBadge(verdict) {
|
|
52
|
-
if (NO_COLOR) return `[${verdict}]`;
|
|
53
|
-
if (verdict === 'BLOCK') return `${BG_RED}${BOLD}
|
|
52
|
+
if (NO_COLOR) return verdict === 'BLOCK' ? '[DANGER]' : `[${verdict}]`;
|
|
53
|
+
if (verdict === 'BLOCK') return `${BG_RED}${WHITE}${BOLD} DANGER ${RESET}`;
|
|
54
54
|
if (verdict === 'WARN') return `${BG_YELLOW}\x1b[30m WARN ${RESET}`;
|
|
55
55
|
return `${GREEN} OK ${RESET}`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const ASCII_LOGO = `
|
|
59
|
+
|
|
60
|
+
███╗ ██╗██████╗ █████╗
|
|
61
|
+
████╗ ██║██╔══██╗██╔══██╗
|
|
62
|
+
██╔██╗ ██║██████╔╝███████║
|
|
63
|
+
██║╚██╗██║██╔═══╝ ██╔══██║
|
|
64
|
+
██║ ╚████║██║ ██║ ██║
|
|
65
|
+
╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
function printScanHeader(silent = false) {
|
|
69
|
+
if (silent) return;
|
|
70
|
+
log(blue(ASCII_LOGO));
|
|
71
|
+
log(dim(' npm package auditor — static obfuscation detection'));
|
|
62
72
|
log(dim('─'.repeat(60)));
|
|
63
73
|
log('');
|
|
64
74
|
}
|
|
@@ -77,10 +87,15 @@ function printSummary(results) {
|
|
|
77
87
|
const blocked = results.filter(r => r.verdict === 'BLOCK').length;
|
|
78
88
|
const warned = results.filter(r => r.verdict === 'WARN').length;
|
|
79
89
|
const ok = results.filter(r => r.verdict === 'OK').length;
|
|
90
|
+
const skipped = results.skippedCount || 0;
|
|
80
91
|
|
|
81
92
|
log('');
|
|
82
93
|
log(dim('─'.repeat(60)));
|
|
83
|
-
|
|
94
|
+
let summary = ` ${green(String(ok))} clean ${yellow(String(warned))} warnings ${red(String(blocked))} blocked`;
|
|
95
|
+
if (skipped > 0) {
|
|
96
|
+
summary += ` ${dim(String(skipped) + ' skipped (no install scripts)')}`;
|
|
97
|
+
}
|
|
98
|
+
log(summary);
|
|
84
99
|
log('');
|
|
85
100
|
}
|
|
86
101
|
|