muaddib-scanner 2.4.3 → 2.4.5
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 +20 -20
- package/README.md +15 -1
- package/iocs/builtin.yaml +131 -131
- package/iocs/hashes.yaml +214 -214
- package/iocs/packages.yaml +276 -276
- package/package.json +2 -3
- package/src/canary-tokens.js +184 -184
- package/src/ioc/bootstrap.js +181 -181
- package/src/ioc/yaml-loader.js +223 -223
- package/src/maintainer-change.js +224 -224
- package/src/output-formatter.js +192 -192
- package/src/publish-anomaly.js +206 -206
- package/src/report.js +230 -230
- package/src/sarif.js +96 -96
- package/src/scanner/ai-config.js +183 -183
- package/src/scanner/ast-detectors.js +40 -17
- package/src/scanner/ast.js +1 -0
- package/src/scanner/dataflow.js +14 -2
- package/src/scanner/dependencies.js +223 -223
- package/src/scanner/entropy.js +7 -0
- package/src/scanner/hash.js +118 -118
- package/src/scanner/npm-registry.js +128 -128
- package/src/scanner/python.js +442 -442
- package/src/scoring.js +3 -1
- package/src/shared/analyze-helper.js +49 -49
- package/src/temporal-analysis.js +260 -260
- package/src/temporal-runner.js +139 -139
- package/src/utils.js +327 -327
- package/src/watch.js +55 -55
package/src/scanner/hash.js
CHANGED
|
@@ -1,118 +1,118 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const nodeCrypto = require('crypto');
|
|
4
|
-
const { loadCachedIOCs } = require('../ioc/updater.js');
|
|
5
|
-
const { findFiles } = require('../utils.js');
|
|
6
|
-
const { MAX_FILE_SIZE } = require('../shared/constants.js');
|
|
7
|
-
|
|
8
|
-
// Hash cache: filePath -> { hash, mtime }
|
|
9
|
-
const hashCache = new Map();
|
|
10
|
-
const MAX_CACHE_SIZE = 10000;
|
|
11
|
-
|
|
12
|
-
async function scanHashes(targetPath) {
|
|
13
|
-
const threats = [];
|
|
14
|
-
hashCache.clear(); // Clear stale cache between scans
|
|
15
|
-
const iocs = loadCachedIOCs();
|
|
16
|
-
|
|
17
|
-
// Use Set for O(1) lookup if available, otherwise create a Set
|
|
18
|
-
const knownHashes = iocs.hashesSet instanceof Set
|
|
19
|
-
? iocs.hashesSet
|
|
20
|
-
: new Set(iocs.hashes || []);
|
|
21
|
-
|
|
22
|
-
if (knownHashes.size === 0) {
|
|
23
|
-
return threats;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const nodeModulesPath = path.join(targetPath, 'node_modules');
|
|
27
|
-
|
|
28
|
-
if (!fs.existsSync(nodeModulesPath)) {
|
|
29
|
-
return threats;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Use shared findFiles utility (with symlink protection and depth limit)
|
|
33
|
-
const jsFiles = findFiles(nodeModulesPath, { extensions: ['.js'], excludedDirs: [], maxDepth: 100 });
|
|
34
|
-
|
|
35
|
-
for (const file of jsFiles) {
|
|
36
|
-
const hash = computeHashCached(file);
|
|
37
|
-
|
|
38
|
-
if (hash && knownHashes.has(hash)) {
|
|
39
|
-
threats.push({
|
|
40
|
-
type: 'known_malicious_hash',
|
|
41
|
-
severity: 'CRITICAL',
|
|
42
|
-
message: `Malicious hash detected: ${hash.substring(0, 16)}...`,
|
|
43
|
-
file: path.relative(targetPath, file)
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return threats;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Computes the SHA256 hash of a file with caching
|
|
53
|
-
* Cache is invalidated if the file mtime changes
|
|
54
|
-
* @param {string} filePath - File path
|
|
55
|
-
* @returns {string|null} SHA256 hash or null on error
|
|
56
|
-
*/
|
|
57
|
-
function computeHashCached(filePath) {
|
|
58
|
-
try {
|
|
59
|
-
const stat = fs.statSync(filePath);
|
|
60
|
-
if (stat.size > MAX_FILE_SIZE) return null;
|
|
61
|
-
const mtime = stat.mtimeMs;
|
|
62
|
-
|
|
63
|
-
// Check the cache
|
|
64
|
-
const cached = hashCache.get(filePath);
|
|
65
|
-
if (cached && cached.mtime === mtime) {
|
|
66
|
-
return cached.hash;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Compute the hash
|
|
70
|
-
const hash = computeHash(filePath);
|
|
71
|
-
|
|
72
|
-
// FIFO eviction if cache is full
|
|
73
|
-
if (hashCache.size >= MAX_CACHE_SIZE) {
|
|
74
|
-
const firstKey = hashCache.keys().next().value;
|
|
75
|
-
hashCache.delete(firstKey);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Store in cache
|
|
79
|
-
hashCache.set(filePath, { hash, mtime });
|
|
80
|
-
|
|
81
|
-
return hash;
|
|
82
|
-
} catch {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Computes the SHA256 hash of a file
|
|
89
|
-
* @param {string} filePath - File path
|
|
90
|
-
* @returns {string} SHA256 hash
|
|
91
|
-
*/
|
|
92
|
-
function computeHash(filePath) {
|
|
93
|
-
const content = fs.readFileSync(filePath);
|
|
94
|
-
return nodeCrypto.createHash('sha256').update(content).digest('hex');
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Clears the hash cache (useful for tests)
|
|
99
|
-
*/
|
|
100
|
-
function clearHashCache() {
|
|
101
|
-
hashCache.clear();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Returns the cache size (useful for debug/monitoring)
|
|
106
|
-
* @returns {number}
|
|
107
|
-
*/
|
|
108
|
-
function getHashCacheSize() {
|
|
109
|
-
return hashCache.size;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
module.exports = {
|
|
113
|
-
scanHashes,
|
|
114
|
-
computeHash,
|
|
115
|
-
computeHashCached,
|
|
116
|
-
clearHashCache,
|
|
117
|
-
getHashCacheSize
|
|
118
|
-
};
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const nodeCrypto = require('crypto');
|
|
4
|
+
const { loadCachedIOCs } = require('../ioc/updater.js');
|
|
5
|
+
const { findFiles } = require('../utils.js');
|
|
6
|
+
const { MAX_FILE_SIZE } = require('../shared/constants.js');
|
|
7
|
+
|
|
8
|
+
// Hash cache: filePath -> { hash, mtime }
|
|
9
|
+
const hashCache = new Map();
|
|
10
|
+
const MAX_CACHE_SIZE = 10000;
|
|
11
|
+
|
|
12
|
+
async function scanHashes(targetPath) {
|
|
13
|
+
const threats = [];
|
|
14
|
+
hashCache.clear(); // Clear stale cache between scans
|
|
15
|
+
const iocs = loadCachedIOCs();
|
|
16
|
+
|
|
17
|
+
// Use Set for O(1) lookup if available, otherwise create a Set
|
|
18
|
+
const knownHashes = iocs.hashesSet instanceof Set
|
|
19
|
+
? iocs.hashesSet
|
|
20
|
+
: new Set(iocs.hashes || []);
|
|
21
|
+
|
|
22
|
+
if (knownHashes.size === 0) {
|
|
23
|
+
return threats;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const nodeModulesPath = path.join(targetPath, 'node_modules');
|
|
27
|
+
|
|
28
|
+
if (!fs.existsSync(nodeModulesPath)) {
|
|
29
|
+
return threats;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Use shared findFiles utility (with symlink protection and depth limit)
|
|
33
|
+
const jsFiles = findFiles(nodeModulesPath, { extensions: ['.js'], excludedDirs: [], maxDepth: 100 });
|
|
34
|
+
|
|
35
|
+
for (const file of jsFiles) {
|
|
36
|
+
const hash = computeHashCached(file);
|
|
37
|
+
|
|
38
|
+
if (hash && knownHashes.has(hash)) {
|
|
39
|
+
threats.push({
|
|
40
|
+
type: 'known_malicious_hash',
|
|
41
|
+
severity: 'CRITICAL',
|
|
42
|
+
message: `Malicious hash detected: ${hash.substring(0, 16)}...`,
|
|
43
|
+
file: path.relative(targetPath, file)
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return threats;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Computes the SHA256 hash of a file with caching
|
|
53
|
+
* Cache is invalidated if the file mtime changes
|
|
54
|
+
* @param {string} filePath - File path
|
|
55
|
+
* @returns {string|null} SHA256 hash or null on error
|
|
56
|
+
*/
|
|
57
|
+
function computeHashCached(filePath) {
|
|
58
|
+
try {
|
|
59
|
+
const stat = fs.statSync(filePath);
|
|
60
|
+
if (stat.size > MAX_FILE_SIZE) return null;
|
|
61
|
+
const mtime = stat.mtimeMs;
|
|
62
|
+
|
|
63
|
+
// Check the cache
|
|
64
|
+
const cached = hashCache.get(filePath);
|
|
65
|
+
if (cached && cached.mtime === mtime) {
|
|
66
|
+
return cached.hash;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Compute the hash
|
|
70
|
+
const hash = computeHash(filePath);
|
|
71
|
+
|
|
72
|
+
// FIFO eviction if cache is full
|
|
73
|
+
if (hashCache.size >= MAX_CACHE_SIZE) {
|
|
74
|
+
const firstKey = hashCache.keys().next().value;
|
|
75
|
+
hashCache.delete(firstKey);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Store in cache
|
|
79
|
+
hashCache.set(filePath, { hash, mtime });
|
|
80
|
+
|
|
81
|
+
return hash;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Computes the SHA256 hash of a file
|
|
89
|
+
* @param {string} filePath - File path
|
|
90
|
+
* @returns {string} SHA256 hash
|
|
91
|
+
*/
|
|
92
|
+
function computeHash(filePath) {
|
|
93
|
+
const content = fs.readFileSync(filePath);
|
|
94
|
+
return nodeCrypto.createHash('sha256').update(content).digest('hex');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clears the hash cache (useful for tests)
|
|
99
|
+
*/
|
|
100
|
+
function clearHashCache() {
|
|
101
|
+
hashCache.clear();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Returns the cache size (useful for debug/monitoring)
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
function getHashCacheSize() {
|
|
109
|
+
return hashCache.size;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = {
|
|
113
|
+
scanHashes,
|
|
114
|
+
computeHash,
|
|
115
|
+
computeHashCached,
|
|
116
|
+
clearHashCache,
|
|
117
|
+
getHashCacheSize
|
|
118
|
+
};
|
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
|
|
2
|
-
const { debugLog } = require('../utils.js');
|
|
3
|
-
|
|
4
|
-
const REGISTRY_URL = 'https://registry.npmjs.org';
|
|
5
|
-
const DOWNLOADS_URL = 'https://api.npmjs.org/downloads/point/last-week';
|
|
6
|
-
const SEARCH_URL = 'https://registry.npmjs.org/-/v1/search';
|
|
7
|
-
|
|
8
|
-
const REQUEST_TIMEOUT = 10000; // 10 seconds
|
|
9
|
-
const MAX_RETRIES = 3;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Create a timeout signal, with fallback for older Node versions.
|
|
13
|
-
* Returns { signal, cleanup } — call cleanup() after fetch to prevent timer leaks.
|
|
14
|
-
*/
|
|
15
|
-
function createTimeoutSignal(ms) {
|
|
16
|
-
if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) {
|
|
17
|
-
return { signal: AbortSignal.timeout(ms), cleanup: () => {} };
|
|
18
|
-
}
|
|
19
|
-
const controller = new AbortController();
|
|
20
|
-
const timer = setTimeout(() => controller.abort(), ms);
|
|
21
|
-
return { signal: controller.signal, cleanup: () => clearTimeout(timer) };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function fetchWithRetry(url) {
|
|
25
|
-
let lastError = null;
|
|
26
|
-
|
|
27
|
-
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
28
|
-
let response;
|
|
29
|
-
const { signal, cleanup } = createTimeoutSignal(REQUEST_TIMEOUT);
|
|
30
|
-
try {
|
|
31
|
-
response = await fetch(url, { signal });
|
|
32
|
-
} catch (err) {
|
|
33
|
-
cleanup();
|
|
34
|
-
// REG-001: Retry on timeout/abort instead of returning null immediately
|
|
35
|
-
lastError = err;
|
|
36
|
-
if (attempt < MAX_RETRIES - 1) {
|
|
37
|
-
const backoff = Math.min(1000 * Math.pow(2, attempt), 8000);
|
|
38
|
-
await new Promise(r => setTimeout(r, backoff));
|
|
39
|
-
}
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
cleanup();
|
|
44
|
-
|
|
45
|
-
// 404 = package doesn't exist
|
|
46
|
-
if (response.status === 404) {
|
|
47
|
-
// Drain response body to free resources
|
|
48
|
-
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// 429 = rate limit, respect Retry-After header (capped at 30s)
|
|
53
|
-
if (response.status === 429) {
|
|
54
|
-
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
55
|
-
const retryAfter = parseInt(response.headers.get('retry-after'), 10);
|
|
56
|
-
const delay = Math.min(retryAfter && retryAfter > 0 ? retryAfter * 1000 : 2000, 30000);
|
|
57
|
-
await new Promise(r => setTimeout(r, delay));
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (!response.ok) {
|
|
62
|
-
// Drain response body on errors
|
|
63
|
-
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
return await response.json();
|
|
69
|
-
} catch {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Don't throw — return null to prevent crashing the scan pipeline (REG-02)
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function getPackageMetadata(packageName) {
|
|
79
|
-
// Validate package name before building URL
|
|
80
|
-
if (!NPM_PACKAGE_REGEX.test(packageName)) return null;
|
|
81
|
-
|
|
82
|
-
// 1. Registry metadata
|
|
83
|
-
const registryUrl = REGISTRY_URL + '/' + encodeURIComponent(packageName);
|
|
84
|
-
const meta = await fetchWithRetry(registryUrl);
|
|
85
|
-
if (!meta) return null;
|
|
86
|
-
|
|
87
|
-
const createdAt = meta.time?.created || null;
|
|
88
|
-
const ageDays = createdAt
|
|
89
|
-
? Math.floor((Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
|
90
|
-
: null;
|
|
91
|
-
|
|
92
|
-
// Extract maintainer name from latest version
|
|
93
|
-
const latestVersion = meta['dist-tags']?.latest;
|
|
94
|
-
const latestMeta = latestVersion ? meta.versions?.[latestVersion] : null;
|
|
95
|
-
const maintainer = latestMeta?.maintainers?.[0]?.name
|
|
96
|
-
|| meta.maintainers?.[0]?.name
|
|
97
|
-
|| null;
|
|
98
|
-
|
|
99
|
-
const readmeText = meta.readme || '';
|
|
100
|
-
const hasReadme = readmeText.length > 100;
|
|
101
|
-
|
|
102
|
-
const hasRepository = !!(latestMeta?.repository || meta.repository);
|
|
103
|
-
|
|
104
|
-
// 2. Weekly downloads + author search (parallel)
|
|
105
|
-
const downloadsUrl = DOWNLOADS_URL + '/' + encodeURIComponent(packageName);
|
|
106
|
-
const authorUrl = maintainer
|
|
107
|
-
? SEARCH_URL + '?text=maintainer:' + encodeURIComponent(maintainer) + '&size=1'
|
|
108
|
-
: null;
|
|
109
|
-
|
|
110
|
-
const [downloadsData, authorData] = await Promise.all([
|
|
111
|
-
fetchWithRetry(downloadsUrl),
|
|
112
|
-
authorUrl ? fetchWithRetry(authorUrl) : Promise.resolve(null)
|
|
113
|
-
]);
|
|
114
|
-
|
|
115
|
-
const weeklyDownloads = downloadsData?.downloads ?? 0;
|
|
116
|
-
const authorPackageCount = authorData?.total ?? 0;
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
created_at: createdAt,
|
|
120
|
-
age_days: ageDays,
|
|
121
|
-
weekly_downloads: weeklyDownloads,
|
|
122
|
-
author_package_count: authorPackageCount,
|
|
123
|
-
has_readme: hasReadme,
|
|
124
|
-
has_repository: hasRepository
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
module.exports = { getPackageMetadata };
|
|
1
|
+
const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
|
|
2
|
+
const { debugLog } = require('../utils.js');
|
|
3
|
+
|
|
4
|
+
const REGISTRY_URL = 'https://registry.npmjs.org';
|
|
5
|
+
const DOWNLOADS_URL = 'https://api.npmjs.org/downloads/point/last-week';
|
|
6
|
+
const SEARCH_URL = 'https://registry.npmjs.org/-/v1/search';
|
|
7
|
+
|
|
8
|
+
const REQUEST_TIMEOUT = 10000; // 10 seconds
|
|
9
|
+
const MAX_RETRIES = 3;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a timeout signal, with fallback for older Node versions.
|
|
13
|
+
* Returns { signal, cleanup } — call cleanup() after fetch to prevent timer leaks.
|
|
14
|
+
*/
|
|
15
|
+
function createTimeoutSignal(ms) {
|
|
16
|
+
if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) {
|
|
17
|
+
return { signal: AbortSignal.timeout(ms), cleanup: () => {} };
|
|
18
|
+
}
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
21
|
+
return { signal: controller.signal, cleanup: () => clearTimeout(timer) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fetchWithRetry(url) {
|
|
25
|
+
let lastError = null;
|
|
26
|
+
|
|
27
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
28
|
+
let response;
|
|
29
|
+
const { signal, cleanup } = createTimeoutSignal(REQUEST_TIMEOUT);
|
|
30
|
+
try {
|
|
31
|
+
response = await fetch(url, { signal });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
cleanup();
|
|
34
|
+
// REG-001: Retry on timeout/abort instead of returning null immediately
|
|
35
|
+
lastError = err;
|
|
36
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
37
|
+
const backoff = Math.min(1000 * Math.pow(2, attempt), 8000);
|
|
38
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
cleanup();
|
|
44
|
+
|
|
45
|
+
// 404 = package doesn't exist
|
|
46
|
+
if (response.status === 404) {
|
|
47
|
+
// Drain response body to free resources
|
|
48
|
+
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 429 = rate limit, respect Retry-After header (capped at 30s)
|
|
53
|
+
if (response.status === 429) {
|
|
54
|
+
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
55
|
+
const retryAfter = parseInt(response.headers.get('retry-after'), 10);
|
|
56
|
+
const delay = Math.min(retryAfter && retryAfter > 0 ? retryAfter * 1000 : 2000, 30000);
|
|
57
|
+
await new Promise(r => setTimeout(r, delay));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
// Drain response body on errors
|
|
63
|
+
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
return await response.json();
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Don't throw — return null to prevent crashing the scan pipeline (REG-02)
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function getPackageMetadata(packageName) {
|
|
79
|
+
// Validate package name before building URL
|
|
80
|
+
if (!NPM_PACKAGE_REGEX.test(packageName)) return null;
|
|
81
|
+
|
|
82
|
+
// 1. Registry metadata
|
|
83
|
+
const registryUrl = REGISTRY_URL + '/' + encodeURIComponent(packageName);
|
|
84
|
+
const meta = await fetchWithRetry(registryUrl);
|
|
85
|
+
if (!meta) return null;
|
|
86
|
+
|
|
87
|
+
const createdAt = meta.time?.created || null;
|
|
88
|
+
const ageDays = createdAt
|
|
89
|
+
? Math.floor((Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
// Extract maintainer name from latest version
|
|
93
|
+
const latestVersion = meta['dist-tags']?.latest;
|
|
94
|
+
const latestMeta = latestVersion ? meta.versions?.[latestVersion] : null;
|
|
95
|
+
const maintainer = latestMeta?.maintainers?.[0]?.name
|
|
96
|
+
|| meta.maintainers?.[0]?.name
|
|
97
|
+
|| null;
|
|
98
|
+
|
|
99
|
+
const readmeText = meta.readme || '';
|
|
100
|
+
const hasReadme = readmeText.length > 100;
|
|
101
|
+
|
|
102
|
+
const hasRepository = !!(latestMeta?.repository || meta.repository);
|
|
103
|
+
|
|
104
|
+
// 2. Weekly downloads + author search (parallel)
|
|
105
|
+
const downloadsUrl = DOWNLOADS_URL + '/' + encodeURIComponent(packageName);
|
|
106
|
+
const authorUrl = maintainer
|
|
107
|
+
? SEARCH_URL + '?text=maintainer:' + encodeURIComponent(maintainer) + '&size=1'
|
|
108
|
+
: null;
|
|
109
|
+
|
|
110
|
+
const [downloadsData, authorData] = await Promise.all([
|
|
111
|
+
fetchWithRetry(downloadsUrl),
|
|
112
|
+
authorUrl ? fetchWithRetry(authorUrl) : Promise.resolve(null)
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const weeklyDownloads = downloadsData?.downloads ?? 0;
|
|
116
|
+
const authorPackageCount = authorData?.total ?? 0;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
created_at: createdAt,
|
|
120
|
+
age_days: ageDays,
|
|
121
|
+
weekly_downloads: weeklyDownloads,
|
|
122
|
+
author_package_count: authorPackageCount,
|
|
123
|
+
has_readme: hasReadme,
|
|
124
|
+
has_repository: hasRepository
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { getPackageMetadata };
|