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/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