np-audit 1.3.0 → 1.5.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 +131 -52
- package/package.json +1 -1
- package/src/cli.js +55 -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/core/detector.js +444 -0
- package/src/core/requireWalker.js +192 -0
- package/src/core/scanner.js +700 -0
- package/src/utils/command.js +256 -0
- package/src/{config.js → utils/config.js} +34 -2
- package/src/{output.js → utils/output.js} +22 -7
- package/src/{aware.js → utils/review.js} +56 -16
- package/src/{tarball.js → utils/tarball.js} +7 -1
- package/src/utils/updateChecker.js +72 -0
- package/src/detector.js +0 -300
- package/src/scanner.js +0 -407
- /package/src/{fetcher.js → utils/fetcher.js} +0 -0
- /package/src/{lockfile.js → utils/lockfile.js} +0 -0
package/src/detector.js
DELETED
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
4
|
-
|
|
5
|
-
const MAX_CODE_SIZE = 500000; // 500KB - chunk larger files
|
|
6
|
-
|
|
7
|
-
// ─── Individual detection checks ─────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Detect eval / dynamic code execution.
|
|
11
|
-
* @param {string} code
|
|
12
|
-
* @returns {Finding|null}
|
|
13
|
-
*/
|
|
14
|
-
function checkEval(code) {
|
|
15
|
-
const patterns = [
|
|
16
|
-
/\beval\s*\(/,
|
|
17
|
-
/new\s+Function\s*\(/,
|
|
18
|
-
/vm\.runInThisContext\s*\(/,
|
|
19
|
-
/vm\.runInNewContext\s*\(/,
|
|
20
|
-
/vm\.Script\s*\(/,
|
|
21
|
-
];
|
|
22
|
-
const matched = patterns.filter(p => p.test(code));
|
|
23
|
-
if (matched.length === 0) return null;
|
|
24
|
-
return { name: 'eval/dynamic-exec', score: 8, detail: `eval-like call found (${matched.length} pattern(s))` };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Detect obfuscator.io signature: _0x variable naming.
|
|
29
|
-
* Score scales with density of obfuscation.
|
|
30
|
-
* @param {string} code
|
|
31
|
-
* @returns {Finding|null}
|
|
32
|
-
*/
|
|
33
|
-
function checkObfuscatorIo(code) {
|
|
34
|
-
const matches = code.match(/_0x[0-9a-fA-F]+/g) || [];
|
|
35
|
-
if (matches.length < 3) return null;
|
|
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` };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Detect high-entropy strings (likely encoded/encrypted payloads).
|
|
47
|
-
* Uses indexOf-based extraction to avoid regex stack overflow on large files.
|
|
48
|
-
* @param {string} code
|
|
49
|
-
* @returns {Finding|null}
|
|
50
|
-
*/
|
|
51
|
-
function checkHighEntropy(code) {
|
|
52
|
-
let maxEntropy = 0;
|
|
53
|
-
let worst = '';
|
|
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
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (maxEntropy < 4.5) return null;
|
|
81
|
-
return {
|
|
82
|
-
name: 'high-entropy-string',
|
|
83
|
-
score: 6,
|
|
84
|
-
detail: `Entropy ${maxEntropy.toFixed(2)} in string "${worst}…"`,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Detect dense hex escape sequences (\x41).
|
|
90
|
-
* Score scales with volume.
|
|
91
|
-
* @param {string} code
|
|
92
|
-
* @returns {Finding|null}
|
|
93
|
-
*/
|
|
94
|
-
function checkHexEscapes(code) {
|
|
95
|
-
const hexMatches = (code.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
|
|
96
|
-
if (hexMatches < 10) return null;
|
|
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` };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Detect String.fromCharCode with many numeric arguments.
|
|
107
|
-
* @param {string} code
|
|
108
|
-
* @returns {Finding|null}
|
|
109
|
-
*/
|
|
110
|
-
function checkFromCharCode(code) {
|
|
111
|
-
const re = /String\.fromCharCode\s*\(([^)]+)\)/g;
|
|
112
|
-
let match;
|
|
113
|
-
let maxArgs = 0;
|
|
114
|
-
while ((match = re.exec(code)) !== null) {
|
|
115
|
-
const args = match[1].split(',').filter(a => /^\s*\d+\s*$/.test(a));
|
|
116
|
-
if (args.length > maxArgs) maxArgs = args.length;
|
|
117
|
-
}
|
|
118
|
-
if (maxArgs < 5) return null;
|
|
119
|
-
return { name: 'fromCharCode', score: 7, detail: `String.fromCharCode with ${maxArgs} numeric args` };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Detect base64 decode combined with eval-like execution.
|
|
124
|
-
* @param {string} code
|
|
125
|
-
* @returns {Finding|null}
|
|
126
|
-
*/
|
|
127
|
-
function checkBase64Exec(code) {
|
|
128
|
-
const hasBase64 = /atob\s*\(|Buffer\.from\s*\([^)]*,\s*['"]base64['"]\)/.test(code);
|
|
129
|
-
const hasExec = /eval\s*\(|new\s+Function\s*\(|\.exec\s*\(/.test(code);
|
|
130
|
-
if (!hasBase64) return null;
|
|
131
|
-
if (hasBase64 && !hasExec) {
|
|
132
|
-
return { name: 'base64-decode', score: 3, detail: 'Base64 decode found — verify usage' };
|
|
133
|
-
}
|
|
134
|
-
return { name: 'base64-decode+exec', score: 8, detail: 'Base64 decode with code execution found' };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Detect child_process / shell execution patterns.
|
|
139
|
-
* @param {string} code
|
|
140
|
-
* @returns {Finding|null}
|
|
141
|
-
*/
|
|
142
|
-
function checkChildProcess(code) {
|
|
143
|
-
const patterns = [
|
|
144
|
-
/require\s*\(\s*['"]child_process['"]\s*\)/,
|
|
145
|
-
/\bexec\s*\(/,
|
|
146
|
-
/\bspawn\s*\(/,
|
|
147
|
-
/\bexecSync\s*\(/,
|
|
148
|
-
/\bspawnSync\s*\(/,
|
|
149
|
-
/\bexecFile\s*\(/,
|
|
150
|
-
];
|
|
151
|
-
const matched = patterns.filter(p => p.test(code));
|
|
152
|
-
if (matched.length === 0) return null;
|
|
153
|
-
return { name: 'child-process', score: 5, detail: `Shell execution found (${matched.length} pattern(s))` };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Detect large hex literal arrays (common in minified obfuscated code).
|
|
158
|
-
* Score scales with volume.
|
|
159
|
-
* @param {string} code
|
|
160
|
-
* @returns {Finding|null}
|
|
161
|
-
*/
|
|
162
|
-
function checkHexArray(code) {
|
|
163
|
-
// Count 0x1234-style literals
|
|
164
|
-
const hexLiterals = (code.match(/\b0x[0-9a-fA-F]+\b/g) || []).length;
|
|
165
|
-
if (hexLiterals < 20) return null;
|
|
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` };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Detect process.env access (potential credential exfiltration signal).
|
|
176
|
-
* @param {string} code
|
|
177
|
-
* @returns {Finding|null}
|
|
178
|
-
*/
|
|
179
|
-
function checkProcessEnv(code) {
|
|
180
|
-
const matches = (code.match(/process\.env\b/g) || []).length;
|
|
181
|
-
if (matches === 0) return null;
|
|
182
|
-
return { name: 'process-env', score: 3, detail: `${matches} process.env access(es)` };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Detect suspicious network calls (data exfiltration).
|
|
187
|
-
* @param {string} code
|
|
188
|
-
* @returns {Finding|null}
|
|
189
|
-
*/
|
|
190
|
-
function checkNetworkCalls(code) {
|
|
191
|
-
const patterns = [
|
|
192
|
-
/require\s*\(\s*['"]https?['"]\s*\)/,
|
|
193
|
-
/require\s*\(\s*['"]net['"]\s*\)/,
|
|
194
|
-
/require\s*\(\s*['"]dns['"]\s*\)/,
|
|
195
|
-
/fetch\s*\(/,
|
|
196
|
-
/XMLHttpRequest/,
|
|
197
|
-
/\.request\s*\(/,
|
|
198
|
-
];
|
|
199
|
-
const matched = patterns.filter(p => p.test(code));
|
|
200
|
-
if (matched.length === 0) return null;
|
|
201
|
-
return { name: 'network-call', score: 4, detail: `Network call found (${matched.length} pattern(s))` };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// ─── Entropy helper ──────────────────────────────────────────────────────────
|
|
205
|
-
|
|
206
|
-
function shannonEntropy(str) {
|
|
207
|
-
if (!str || str.length === 0) return 0;
|
|
208
|
-
const freq = {};
|
|
209
|
-
for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
|
|
210
|
-
let entropy = 0;
|
|
211
|
-
for (const count of Object.values(freq)) {
|
|
212
|
-
const p = count / str.length;
|
|
213
|
-
entropy -= p * Math.log2(p);
|
|
214
|
-
}
|
|
215
|
-
return entropy;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ─── Main detection function ─────────────────────────────────────────────────
|
|
219
|
-
|
|
220
|
-
const CHECKS = [
|
|
221
|
-
checkEval,
|
|
222
|
-
checkObfuscatorIo,
|
|
223
|
-
checkHighEntropy,
|
|
224
|
-
checkHexEscapes,
|
|
225
|
-
checkFromCharCode,
|
|
226
|
-
checkBase64Exec,
|
|
227
|
-
checkChildProcess,
|
|
228
|
-
checkHexArray,
|
|
229
|
-
checkProcessEnv,
|
|
230
|
-
checkNetworkCalls,
|
|
231
|
-
];
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Run all checks against a code string.
|
|
235
|
-
* For large files, analyzes multiple chunks and aggregates results.
|
|
236
|
-
* @param {string} code
|
|
237
|
-
* @param {object} config { blockScore, warnScore }
|
|
238
|
-
* @returns {{ score: number, findings: Finding[], verdict: 'BLOCK'|'WARN'|'OK' }}
|
|
239
|
-
*/
|
|
240
|
-
function detectObfuscation(code, config = { blockScore: 50, warnScore: 20 }) {
|
|
241
|
-
if (!code || typeof code !== 'string') {
|
|
242
|
-
return { score: 0, findings: [], verdict: 'OK' };
|
|
243
|
-
}
|
|
244
|
-
|
|
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);
|
|
255
|
-
}
|
|
256
|
-
|
|
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
|
|
274
|
-
const score = findings.length > 0
|
|
275
|
-
? Math.max(...findings.map(f => f.score))
|
|
276
|
-
: 0;
|
|
277
|
-
|
|
278
|
-
let verdict;
|
|
279
|
-
if (score >= config.blockScore) verdict = 'BLOCK';
|
|
280
|
-
else if (score >= config.warnScore) verdict = 'WARN';
|
|
281
|
-
else verdict = 'OK';
|
|
282
|
-
|
|
283
|
-
return { score, findings, verdict };
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
module.exports = {
|
|
287
|
-
detectObfuscation,
|
|
288
|
-
shannonEntropy,
|
|
289
|
-
// Export individual checks for testing
|
|
290
|
-
checkEval,
|
|
291
|
-
checkObfuscatorIo,
|
|
292
|
-
checkHighEntropy,
|
|
293
|
-
checkHexEscapes,
|
|
294
|
-
checkFromCharCode,
|
|
295
|
-
checkBase64Exec,
|
|
296
|
-
checkChildProcess,
|
|
297
|
-
checkHexArray,
|
|
298
|
-
checkProcessEnv,
|
|
299
|
-
checkNetworkCalls,
|
|
300
|
-
};
|
package/src/scanner.js
DELETED
|
@@ -1,407 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const { parseLockfile } = require('./lockfile');
|
|
6
|
-
const { fetchTarball, buildTarballUrl, verifyIntegrity } = require('./fetcher');
|
|
7
|
-
const { parseTarGz, extractFile, getPackageJson } = require('./tarball');
|
|
8
|
-
const { detectObfuscation } = require('./detector');
|
|
9
|
-
const output = require('./output');
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Main scan orchestrator.
|
|
13
|
-
* @param {object} opts
|
|
14
|
-
* @param {string} opts.cwd
|
|
15
|
-
* @param {object} opts.config
|
|
16
|
-
* @param {boolean} opts.noDev
|
|
17
|
-
* @param {boolean} opts.verbose
|
|
18
|
-
* @param {string|null} opts.singlePackage name for single-package mode
|
|
19
|
-
* @returns {Promise<ScanResult[]>}
|
|
20
|
-
*/
|
|
21
|
-
async function scan(opts) {
|
|
22
|
-
const { cwd, config, noDev, verbose, singlePackage } = opts;
|
|
23
|
-
|
|
24
|
-
let packages;
|
|
25
|
-
let lockfileVersion = 1;
|
|
26
|
-
if (singlePackage) {
|
|
27
|
-
packages = await resolveSinglePackage(singlePackage, config);
|
|
28
|
-
} else {
|
|
29
|
-
const lockPath = path.join(cwd, 'package-lock.json');
|
|
30
|
-
if (fs.existsSync(lockPath)) {
|
|
31
|
-
const parsed = parseLockfile(cwd);
|
|
32
|
-
packages = parsed.packages;
|
|
33
|
-
lockfileVersion = parsed.lockfileVersion;
|
|
34
|
-
} else {
|
|
35
|
-
// No lockfile — resolve from package.json
|
|
36
|
-
packages = await resolveFromPackageJson(cwd, config, noDev);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Apply skip filters
|
|
41
|
-
packages = packages.filter(pkg => {
|
|
42
|
-
if (noDev && pkg.dev) return false;
|
|
43
|
-
if (pkg.inBundle || pkg.link) return false;
|
|
44
|
-
if (config.skipPackages && config.skipPackages.includes(pkg.name)) return false;
|
|
45
|
-
if (config.skipScopes) {
|
|
46
|
-
for (const scope of config.skipScopes) {
|
|
47
|
-
if (pkg.name.startsWith(scope + '/') || pkg.name === scope) return false;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
// v2/v3 lockfiles reliably report hasInstallScript — skip definitive negatives
|
|
51
|
-
if (lockfileVersion >= 2 && pkg.hasInstallScript === false) return false;
|
|
52
|
-
return true;
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
if (verbose) output.info(`Scanning ${packages.length} packages...`);
|
|
56
|
-
|
|
57
|
-
// Parallel fetch + scan with concurrency limit
|
|
58
|
-
const results = await mapWithConcurrency(packages, config.parallelFetches, async (pkg) => {
|
|
59
|
-
return scanPackage(pkg, cwd, config, verbose);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
return results.filter(Boolean);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Scan a single package for obfuscated install scripts.
|
|
67
|
-
* @returns {ScanResult|null} null if no install scripts found
|
|
68
|
-
*/
|
|
69
|
-
async function scanPackage(pkg, cwd, config, verbose) {
|
|
70
|
-
let pkgJson = null;
|
|
71
|
-
let source = 'registry';
|
|
72
|
-
|
|
73
|
-
// Try local node_modules first
|
|
74
|
-
const localPkgJson = tryReadLocalPackageJson(cwd, pkg);
|
|
75
|
-
if (localPkgJson) {
|
|
76
|
-
pkgJson = localPkgJson;
|
|
77
|
-
source = 'local';
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// If v2/v3 lockfile says no install script, skip unless we couldn't confirm locally
|
|
81
|
-
if (source === 'local' && !hasInstallScripts(pkgJson)) {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (!pkgJson) {
|
|
86
|
-
// v1 lockfile or package not installed — need to fetch
|
|
87
|
-
if (!pkg.resolved && !pkg.version) return null;
|
|
88
|
-
|
|
89
|
-
const tarballUrl = pkg.resolved || buildTarballUrl(pkg.name, pkg.version, config.registry);
|
|
90
|
-
|
|
91
|
-
let tarBuffer;
|
|
92
|
-
try {
|
|
93
|
-
if (verbose) output.info(`Fetching ${pkg.name}@${pkg.version}...`);
|
|
94
|
-
tarBuffer = await fetchTarball(tarballUrl, { timeout: config.timeout });
|
|
95
|
-
} catch (err) {
|
|
96
|
-
output.warn(`Could not fetch ${pkg.name}@${pkg.version}: ${err.message}`);
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (!verifyIntegrity(tarBuffer, pkg.integrity)) {
|
|
101
|
-
output.warn(`Integrity check failed for ${pkg.name}@${pkg.version} — skipping`);
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
let files;
|
|
106
|
-
try {
|
|
107
|
-
files = parseTarGz(tarBuffer);
|
|
108
|
-
} catch (err) {
|
|
109
|
-
output.warn(`Could not parse tarball for ${pkg.name}@${pkg.version}: ${err.message}`);
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
pkgJson = getPackageJson(files);
|
|
114
|
-
if (!pkgJson) return null;
|
|
115
|
-
|
|
116
|
-
if (!hasInstallScripts(pkgJson)) return null;
|
|
117
|
-
|
|
118
|
-
// Analyze script files from tarball
|
|
119
|
-
return analyzeScripts(pkg, pkgJson, files, config);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Analyze from local node_modules
|
|
123
|
-
return analyzeScriptsLocal(pkg, pkgJson, cwd, config);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Analyze install scripts from a tarball's file map.
|
|
128
|
-
*/
|
|
129
|
-
function analyzeScripts(pkg, pkgJson, files, config) {
|
|
130
|
-
const scripts = getInstallScripts(pkgJson);
|
|
131
|
-
if (scripts.length === 0) return null;
|
|
132
|
-
|
|
133
|
-
const scriptResults = [];
|
|
134
|
-
|
|
135
|
-
for (const { lifecycle, command } of scripts) {
|
|
136
|
-
const scriptFile = extractScriptFileFromCommand(command);
|
|
137
|
-
if (!scriptFile) {
|
|
138
|
-
// Inline shell command — analyze the command string itself
|
|
139
|
-
const result = detectObfuscation(command, config);
|
|
140
|
-
scriptResults.push({ lifecycle, file: '(inline)', code: command, ...result });
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const fileBuf = extractFile(files, scriptFile);
|
|
145
|
-
if (!fileBuf) continue;
|
|
146
|
-
|
|
147
|
-
const code = fileBuf.toString('utf8');
|
|
148
|
-
const result = detectObfuscation(code, config);
|
|
149
|
-
scriptResults.push({ lifecycle, file: scriptFile, code, ...result });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (scriptResults.length === 0) return null;
|
|
153
|
-
|
|
154
|
-
const maxScore = Math.max(...scriptResults.map(r => r.score));
|
|
155
|
-
const allFindings = scriptResults.flatMap(r => r.findings);
|
|
156
|
-
const verdict = verdictFromScore(maxScore, config);
|
|
157
|
-
|
|
158
|
-
return { pkg, scripts: scriptResults, score: maxScore, findings: allFindings, verdict };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Analyze install scripts from local node_modules.
|
|
163
|
-
*/
|
|
164
|
-
function analyzeScriptsLocal(pkg, pkgJson, cwd, config) {
|
|
165
|
-
const scripts = getInstallScripts(pkgJson);
|
|
166
|
-
if (scripts.length === 0) return null;
|
|
167
|
-
|
|
168
|
-
const pkgDir = findLocalPackageDir(cwd, pkg.name);
|
|
169
|
-
const scriptResults = [];
|
|
170
|
-
|
|
171
|
-
for (const { lifecycle, command } of scripts) {
|
|
172
|
-
const scriptFile = extractScriptFileFromCommand(command);
|
|
173
|
-
if (!scriptFile) {
|
|
174
|
-
const result = detectObfuscation(command, config);
|
|
175
|
-
scriptResults.push({ lifecycle, file: '(inline)', code: command, ...result });
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const absolutePath = pkgDir ? path.join(pkgDir, scriptFile) : null;
|
|
180
|
-
if (!absolutePath || !fs.existsSync(absolutePath)) continue;
|
|
181
|
-
|
|
182
|
-
let code;
|
|
183
|
-
try { code = fs.readFileSync(absolutePath, 'utf8'); } catch { continue; }
|
|
184
|
-
|
|
185
|
-
const result = detectObfuscation(code, config);
|
|
186
|
-
scriptResults.push({ lifecycle, file: scriptFile, code, ...result });
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (scriptResults.length === 0) return null;
|
|
190
|
-
|
|
191
|
-
const maxScore = Math.max(...scriptResults.map(r => r.score));
|
|
192
|
-
const allFindings = scriptResults.flatMap(r => r.findings);
|
|
193
|
-
const verdict = verdictFromScore(maxScore, config);
|
|
194
|
-
|
|
195
|
-
return { pkg, scripts: scriptResults, score: maxScore, findings: allFindings, verdict };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
199
|
-
|
|
200
|
-
function hasInstallScripts(pkgJson) {
|
|
201
|
-
if (!pkgJson || !pkgJson.scripts) return false;
|
|
202
|
-
return !!(pkgJson.scripts.preinstall || pkgJson.scripts.postinstall || pkgJson.scripts.install);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function getInstallScripts(pkgJson) {
|
|
206
|
-
const result = [];
|
|
207
|
-
const s = pkgJson && pkgJson.scripts || {};
|
|
208
|
-
for (const lc of ['preinstall', 'install', 'postinstall']) {
|
|
209
|
-
if (s[lc]) result.push({ lifecycle: lc, command: s[lc] });
|
|
210
|
-
}
|
|
211
|
-
return result;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Extract the JS file path from a script command like "node ./install.js" or "node scripts/setup".
|
|
216
|
-
* Returns null if it's a pure shell command.
|
|
217
|
-
*/
|
|
218
|
-
function extractScriptFileFromCommand(command) {
|
|
219
|
-
const m = command.match(/(?:^|\s)node\s+([^\s]+\.(?:js|mjs|cjs))/);
|
|
220
|
-
if (m) return m[1].replace(/^\.\//, '');
|
|
221
|
-
const m2 = command.match(/(?:^|\s)node\s+([^\s]+)(?:\s|$)/);
|
|
222
|
-
if (m2) {
|
|
223
|
-
const f = m2[1].replace(/^\.\//, '');
|
|
224
|
-
if (!f.startsWith('-')) return f + (f.includes('.') ? '' : '.js');
|
|
225
|
-
}
|
|
226
|
-
return null;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function tryReadLocalPackageJson(cwd, pkg) {
|
|
230
|
-
const dir = findLocalPackageDir(cwd, pkg.name);
|
|
231
|
-
if (!dir) return null;
|
|
232
|
-
try {
|
|
233
|
-
return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
234
|
-
} catch {
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function findLocalPackageDir(cwd, name) {
|
|
240
|
-
const candidate = path.join(cwd, 'node_modules', name);
|
|
241
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function verdictFromScore(score, config) {
|
|
246
|
-
if (score >= config.blockScore) return 'BLOCK';
|
|
247
|
-
if (score >= config.warnScore) return 'WARN';
|
|
248
|
-
return 'OK';
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Extract the first clean X.Y.Z (or X.Y or X) semver from a range string.
|
|
253
|
-
* Returns null if no clean version can be found.
|
|
254
|
-
* Examples: "^5.1.0" → "5.1.0", "4.22.1 || ^5" → "4.22.1", "2" → "2", "*" → null
|
|
255
|
-
*/
|
|
256
|
-
function extractSemver(range) {
|
|
257
|
-
const match = range.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?|\d+\.\d+|\d+)(?!\S*-)/);
|
|
258
|
-
if (match) return match[1];
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Resolve dependencies from package.json when no lockfile exists.
|
|
264
|
-
* @param {string} cwd
|
|
265
|
-
* @param {object} config
|
|
266
|
-
* @param {boolean} noDev
|
|
267
|
-
* @returns {Promise<PackageDescriptor[]>}
|
|
268
|
-
*/
|
|
269
|
-
async function resolveFromPackageJson(cwd, config, noDev) {
|
|
270
|
-
const pkgPath = path.join(cwd, 'package.json');
|
|
271
|
-
if (!fs.existsSync(pkgPath)) {
|
|
272
|
-
throw new Error(`package.json not found in ${cwd}`);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
let pkgJson;
|
|
276
|
-
try {
|
|
277
|
-
pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
278
|
-
} catch (err) {
|
|
279
|
-
throw new Error(`Failed to parse package.json: ${err.message}`);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const deps = { ...pkgJson.dependencies };
|
|
283
|
-
if (!noDev && pkgJson.devDependencies) {
|
|
284
|
-
Object.assign(deps, pkgJson.devDependencies);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const packages = [];
|
|
288
|
-
const { fetchJSON } = require('./fetcher');
|
|
289
|
-
|
|
290
|
-
for (const [name, range] of Object.entries(deps)) {
|
|
291
|
-
const version = extractSemver(range);
|
|
292
|
-
if (!version) continue;
|
|
293
|
-
|
|
294
|
-
try {
|
|
295
|
-
const meta = await fetchJSON(`${config.registry}/${encodeURIComponent(name)}`, { timeout: config.timeout });
|
|
296
|
-
const versionData = meta.versions && meta.versions[version];
|
|
297
|
-
if (!versionData) continue;
|
|
298
|
-
|
|
299
|
-
packages.push({
|
|
300
|
-
name,
|
|
301
|
-
version,
|
|
302
|
-
resolved: versionData.dist && versionData.dist.tarball,
|
|
303
|
-
integrity: versionData.dist && versionData.dist.integrity || '',
|
|
304
|
-
hasInstallScript: !!(versionData.scripts &&
|
|
305
|
-
(versionData.scripts.preinstall || versionData.scripts.postinstall || versionData.scripts.install)),
|
|
306
|
-
dev: !!(pkgJson.devDependencies && pkgJson.devDependencies[name]),
|
|
307
|
-
optional: false,
|
|
308
|
-
inBundle: false,
|
|
309
|
-
link: false,
|
|
310
|
-
});
|
|
311
|
-
} catch {
|
|
312
|
-
// Skip packages we can't fetch metadata for
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
return packages;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Resolve a single package's dependency tree via the npm registry.
|
|
321
|
-
* @param {string} packageSpec e.g. "express" or "express@4.18.0"
|
|
322
|
-
* @param {object} config
|
|
323
|
-
* @returns {Promise<PackageDescriptor[]>}
|
|
324
|
-
*/
|
|
325
|
-
async function resolveSinglePackage(packageSpec, config) {
|
|
326
|
-
const [name, version] = packageSpec.includes('@') && !packageSpec.startsWith('@')
|
|
327
|
-
? packageSpec.split('@')
|
|
328
|
-
: [packageSpec, 'latest'];
|
|
329
|
-
|
|
330
|
-
const { fetchJSON } = require('./fetcher');
|
|
331
|
-
let meta;
|
|
332
|
-
try {
|
|
333
|
-
meta = await fetchJSON(`${config.registry}/${encodeURIComponent(name)}`, { timeout: config.timeout });
|
|
334
|
-
} catch (err) {
|
|
335
|
-
throw new Error(`Could not fetch registry metadata for "${name}": ${err.message}`);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const resolvedVersion = version === 'latest'
|
|
339
|
-
? (meta['dist-tags'] && meta['dist-tags'].latest)
|
|
340
|
-
: version;
|
|
341
|
-
|
|
342
|
-
const versionData = meta.versions && meta.versions[resolvedVersion];
|
|
343
|
-
if (!versionData) throw new Error(`Version "${resolvedVersion}" not found for "${name}"`);
|
|
344
|
-
|
|
345
|
-
const packages = [];
|
|
346
|
-
const seen = new Set();
|
|
347
|
-
|
|
348
|
-
function collectDeps(deps) {
|
|
349
|
-
for (const [depName, range] of Object.entries(deps || {})) {
|
|
350
|
-
if (seen.has(depName)) continue;
|
|
351
|
-
seen.add(depName);
|
|
352
|
-
// Extract the first clean semver from the range (e.g. "4.22.1 || ^5" → "4.22.1", "^5.1.0" → "5.1.0")
|
|
353
|
-
const exactVersion = extractSemver(range);
|
|
354
|
-
if (!exactVersion) continue; // skip unresolvable ranges — lockfile scan will cover them
|
|
355
|
-
packages.push({
|
|
356
|
-
name: depName,
|
|
357
|
-
version: exactVersion,
|
|
358
|
-
resolved: buildTarballUrl(depName, exactVersion, config.registry),
|
|
359
|
-
integrity: '',
|
|
360
|
-
hasInstallScript: false,
|
|
361
|
-
dev: false,
|
|
362
|
-
optional: false,
|
|
363
|
-
inBundle: false,
|
|
364
|
-
link: false,
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Include the package itself
|
|
370
|
-
packages.unshift({
|
|
371
|
-
name,
|
|
372
|
-
version: resolvedVersion,
|
|
373
|
-
resolved: versionData.dist && versionData.dist.tarball,
|
|
374
|
-
integrity: versionData.dist && versionData.dist.integrity || '',
|
|
375
|
-
hasInstallScript: !!(versionData.scripts &&
|
|
376
|
-
(versionData.scripts.preinstall || versionData.scripts.postinstall || versionData.scripts.install)),
|
|
377
|
-
dev: false,
|
|
378
|
-
optional: false,
|
|
379
|
-
inBundle: false,
|
|
380
|
-
link: false,
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
collectDeps(versionData.dependencies);
|
|
384
|
-
|
|
385
|
-
return packages;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Async map with concurrency limit.
|
|
390
|
-
*/
|
|
391
|
-
async function mapWithConcurrency(items, limit, fn) {
|
|
392
|
-
const results = new Array(items.length);
|
|
393
|
-
let index = 0;
|
|
394
|
-
|
|
395
|
-
async function worker() {
|
|
396
|
-
while (index < items.length) {
|
|
397
|
-
const i = index++;
|
|
398
|
-
results[i] = await fn(items[i]);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
|
|
403
|
-
await Promise.all(workers);
|
|
404
|
-
return results;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
module.exports = { scan, hasInstallScripts, extractScriptFileFromCommand, verdictFromScore };
|
|
File without changes
|
|
File without changes
|