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.
@@ -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 };