muaddib-scanner 2.2.19 → 2.2.22

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,1294 +1,1294 @@
1
- const https = require('https');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const os = require('os');
5
- const AdmZip = require('adm-zip');
6
-
7
- const IOC_FILE = path.join(__dirname, 'data/iocs.json');
8
- const COMPACT_IOC_FILE = path.join(__dirname, 'data/iocs-compact.json');
9
- const HOME_IOC_FILE = path.join(os.homedir(), '.muaddib', 'data', 'iocs.json');
10
- const STATIC_IOCS_FILE = path.join(__dirname, '../../data/static-iocs.json');
11
- const { generateCompactIOCs } = require('./updater.js');
12
- const { Spinner } = require('../utils.js');
13
-
14
- // Allowed domains for redirections (SSRF security)
15
- const ALLOWED_REDIRECT_DOMAINS = [
16
- 'raw.githubusercontent.com',
17
- 'github.com',
18
- 'api.github.com',
19
- 'api.osv.dev',
20
- 'osv.dev',
21
- 'objects.githubusercontent.com',
22
- 'osv-vulnerabilities.storage.googleapis.com',
23
- 'storage.googleapis.com'
24
- ];
25
-
26
- /**
27
- * Checks if a redirect URL is allowed
28
- * @param {string} redirectUrl - Redirect URL
29
- * @returns {boolean} true if allowed
30
- */
31
- function isAllowedRedirect(redirectUrl) {
32
- try {
33
- const urlObj = new URL(redirectUrl);
34
- if (urlObj.protocol !== 'https:') return false;
35
- return ALLOWED_REDIRECT_DOMAINS.includes(urlObj.hostname);
36
- } catch {
37
- return false;
38
- }
39
- }
40
-
41
- // ============================================
42
- // UTILITY FUNCTIONS
43
- // ============================================
44
-
45
- /**
46
- * Parse a CSV line correctly handling commas within quoted fields
47
- * Supports: "field1","field, with comma","field3"
48
- * @param {string} line - CSV line to parse
49
- * @returns {string[]} Array of fields
50
- */
51
- function parseCSVLine(line) {
52
- const fields = [];
53
- let current = '';
54
- let inQuotes = false;
55
- let i = 0;
56
-
57
- while (i < line.length) {
58
- const char = line[i];
59
-
60
- if (char === '"') {
61
- // Handle escaped double quotes ("") within a field
62
- if (inQuotes && line[i + 1] === '"') {
63
- current += '"';
64
- i += 2;
65
- continue;
66
- }
67
- // Toggle quote mode
68
- inQuotes = !inQuotes;
69
- i++;
70
- continue;
71
- }
72
-
73
- if (char === ',' && !inQuotes) {
74
- // End of field
75
- fields.push(current.trim());
76
- current = '';
77
- i++;
78
- continue;
79
- }
80
-
81
- current += char;
82
- i++;
83
- }
84
-
85
- // Add the last field
86
- fields.push(current.trim());
87
-
88
- return fields;
89
- }
90
-
91
- /**
92
- * Parse a complete CSV file
93
- * @param {string} csvContent - CSV content
94
- * @param {boolean} [hasHeader=true] - If true, skip the first line
95
- * @returns {string[][]} Array of parsed lines
96
- */
97
- function parseCSV(csvContent, hasHeader = true) {
98
- const lines = csvContent.split('\n').filter(l => l.trim());
99
- const startIndex = hasHeader ? 1 : 0;
100
- const results = [];
101
-
102
- for (let i = startIndex; i < lines.length; i++) {
103
- const parsed = parseCSVLine(lines[i]);
104
- if (parsed.length > 0 && parsed[0]) {
105
- results.push(parsed);
106
- }
107
- }
108
-
109
- return results;
110
- }
111
-
112
- function loadStaticIOCs() {
113
- try {
114
- if (fs.existsSync(STATIC_IOCS_FILE)) {
115
- return JSON.parse(fs.readFileSync(STATIC_IOCS_FILE, 'utf8'));
116
- }
117
- } catch (e) {
118
- console.log(`[WARN] Error loading static-iocs.json: ${e.message}`);
119
- }
120
- return { socket: [], phylum: [], npmRemoved: [] };
121
- }
122
-
123
- const MAX_REDIRECTS = 5;
124
- const MAX_RESPONSE_SIZE = 200 * 1024 * 1024; // 200MB
125
-
126
- function fetchJSON(url, options = {}, redirectCount = 0) {
127
- return new Promise((resolve, reject) => {
128
- const urlObj = new URL(url);
129
- const reqOptions = {
130
- hostname: urlObj.hostname,
131
- path: urlObj.pathname + urlObj.search,
132
- method: options.method || 'GET',
133
- headers: {
134
- 'User-Agent': 'MUADDIB-Scanner/3.0',
135
- 'Accept': 'application/json',
136
- ...options.headers
137
- }
138
- };
139
-
140
- const req = https.request(reqOptions, (res) => {
141
- // Handle redirects (with security validation and limit)
142
- if ([301, 302, 307, 308].includes(res.statusCode)) {
143
- res.resume(); // Drain old response before following redirect
144
- if (redirectCount >= MAX_REDIRECTS) {
145
- reject(new Error('Too many redirects'));
146
- return;
147
- }
148
- const redirectUrl = res.headers.location;
149
- if (!isAllowedRedirect(redirectUrl)) {
150
- reject(new Error(`Unauthorized redirect to: ${redirectUrl}`));
151
- return;
152
- }
153
- fetchJSON(redirectUrl, options, redirectCount + 1).then(resolve).catch(reject);
154
- return;
155
- }
156
-
157
- let data = '';
158
- let dataSize = 0;
159
- let destroyed = false;
160
- res.on('data', chunk => {
161
- if (destroyed) return;
162
- dataSize += chunk.length;
163
- if (dataSize > MAX_RESPONSE_SIZE) {
164
- destroyed = true;
165
- res.destroy();
166
- reject(new Error('Response exceeded maximum size'));
167
- return;
168
- }
169
- data += chunk;
170
- });
171
- res.on('end', () => {
172
- if (destroyed) return;
173
- try {
174
- resolve({ status: res.statusCode, data: JSON.parse(data) });
175
- } catch (e) {
176
- resolve({ status: res.statusCode, data: null, raw: data, error: e.message });
177
- }
178
- });
179
- });
180
-
181
- req.on('error', reject);
182
- req.setTimeout(30000, () => {
183
- req.destroy();
184
- reject(new Error('Timeout'));
185
- });
186
-
187
- if (options.body) {
188
- req.write(JSON.stringify(options.body));
189
- }
190
-
191
- req.end();
192
- });
193
- }
194
-
195
- function fetchText(url, redirectCount = 0) {
196
- return new Promise((resolve, reject) => {
197
- const urlObj = new URL(url);
198
- const reqOptions = {
199
- hostname: urlObj.hostname,
200
- path: urlObj.pathname + urlObj.search,
201
- method: 'GET',
202
- headers: {
203
- 'User-Agent': 'MUADDIB-Scanner/3.0'
204
- }
205
- };
206
-
207
- const req = https.request(reqOptions, (res) => {
208
- // Handle redirects (with security validation and limit)
209
- if ([301, 302, 307, 308].includes(res.statusCode)) {
210
- res.resume(); // Drain old response before following redirect
211
- if (redirectCount >= MAX_REDIRECTS) {
212
- reject(new Error('Too many redirects'));
213
- return;
214
- }
215
- const redirectUrl = res.headers.location;
216
- if (!isAllowedRedirect(redirectUrl)) {
217
- reject(new Error(`Unauthorized redirect to: ${redirectUrl}`));
218
- return;
219
- }
220
- fetchText(redirectUrl, redirectCount + 1).then(resolve).catch(reject);
221
- return;
222
- }
223
-
224
- let data = '';
225
- let dataSize = 0;
226
- res.on('data', chunk => {
227
- dataSize += chunk.length;
228
- if (dataSize > MAX_RESPONSE_SIZE) {
229
- req.destroy();
230
- reject(new Error('Response exceeded maximum size'));
231
- return;
232
- }
233
- data += chunk;
234
- });
235
- res.on('end', () => {
236
- resolve({ status: res.statusCode, data: data });
237
- });
238
- });
239
-
240
- req.on('error', reject);
241
- req.setTimeout(30000, () => {
242
- req.destroy();
243
- reject(new Error('Timeout'));
244
- });
245
-
246
- req.end();
247
- });
248
- }
249
-
250
- function fetchBuffer(url, redirectCount = 0) {
251
- return new Promise((resolve, reject) => {
252
- const urlObj = new URL(url);
253
- const reqOptions = {
254
- hostname: urlObj.hostname,
255
- path: urlObj.pathname + urlObj.search,
256
- method: 'GET',
257
- headers: {
258
- 'User-Agent': 'MUADDIB-Scanner/3.0'
259
- }
260
- };
261
-
262
- const req = https.request(reqOptions, (res) => {
263
- if ([301, 302, 307, 308].includes(res.statusCode)) {
264
- res.resume(); // Drain response body before following redirect
265
- if (redirectCount >= MAX_REDIRECTS) {
266
- reject(new Error('Too many redirects'));
267
- return;
268
- }
269
- const redirectUrl = res.headers.location;
270
- if (!isAllowedRedirect(redirectUrl)) {
271
- reject(new Error('Unauthorized redirect to: ' + redirectUrl));
272
- return;
273
- }
274
- fetchBuffer(redirectUrl, redirectCount + 1).then(resolve).catch(reject);
275
- return;
276
- }
277
-
278
- if (res.statusCode !== 200) {
279
- res.resume(); // Drain response body on error
280
- reject(new Error('HTTP ' + res.statusCode));
281
- return;
282
- }
283
-
284
- const chunks = [];
285
- let received = 0;
286
- res.on('data', chunk => {
287
- received += chunk.length;
288
- if (received > MAX_RESPONSE_SIZE) {
289
- req.destroy();
290
- reject(new Error('Response exceeded maximum size'));
291
- return;
292
- }
293
- chunks.push(chunk);
294
- });
295
- res.on('end', () => resolve(Buffer.concat(chunks)));
296
- });
297
-
298
- req.on('error', reject);
299
- req.setTimeout(120000, () => {
300
- req.destroy();
301
- reject(new Error('Timeout'));
302
- });
303
-
304
- req.end();
305
- });
306
- }
307
-
308
- /**
309
- * Download a large file with spinner progress (npm/ora style).
310
- * Used for bulk zip downloads (OSV npm/PyPI ~50-100MB each).
311
- */
312
- function fetchBufferWithProgress(url, label, redirectCount = 0) {
313
- return new Promise((resolve, reject) => {
314
- const urlObj = new URL(url);
315
- const reqOptions = {
316
- hostname: urlObj.hostname,
317
- path: urlObj.pathname + urlObj.search,
318
- method: 'GET',
319
- headers: {
320
- 'User-Agent': 'MUADDIB-Scanner/3.0'
321
- }
322
- };
323
-
324
- const req = https.request(reqOptions, (res) => {
325
- if ([301, 302, 307, 308].includes(res.statusCode)) {
326
- res.resume(); // Drain response body before following redirect
327
- if (redirectCount >= MAX_REDIRECTS) {
328
- reject(new Error('Too many redirects'));
329
- return;
330
- }
331
- const redirectUrl = res.headers.location;
332
- if (!isAllowedRedirect(redirectUrl)) {
333
- reject(new Error('Unauthorized redirect to: ' + redirectUrl));
334
- return;
335
- }
336
- fetchBufferWithProgress(redirectUrl, label, redirectCount + 1).then(resolve).catch(reject);
337
- return;
338
- }
339
-
340
- if (res.statusCode !== 200) {
341
- res.resume(); // Drain response body on error
342
- reject(new Error('HTTP ' + res.statusCode));
343
- return;
344
- }
345
-
346
- const totalSize = parseInt(res.headers['content-length'], 10) || 0;
347
- const totalMb = totalSize > 0 ? Math.round(totalSize / 1024 / 1024) : null;
348
- const chunks = [];
349
- let received = 0;
350
-
351
- const spinner = new Spinner();
352
- spinner.start('Downloading ' + label + '...');
353
-
354
- res.on('data', (chunk) => {
355
- chunks.push(chunk);
356
- received += chunk.length;
357
- if (received > MAX_RESPONSE_SIZE) {
358
- req.destroy();
359
- spinner.fail('Download exceeded maximum size');
360
- reject(new Error('Response exceeded maximum size'));
361
- return;
362
- }
363
- const mb = Math.round(received / 1024 / 1024);
364
- if (totalMb) {
365
- spinner.update('Downloading ' + label + '... ' + mb + 'MB/' + totalMb + 'MB');
366
- } else {
367
- spinner.update('Downloading ' + label + '... ' + mb + 'MB');
368
- }
369
- });
370
-
371
- res.on('end', () => {
372
- const mb = Math.round(received / 1024 / 1024);
373
- spinner.succeed('Downloaded ' + label + ' (' + mb + 'MB)');
374
- resolve(Buffer.concat(chunks));
375
- });
376
- });
377
-
378
- req.on('error', (err) => {
379
- spinner.fail('Download failed: ' + err.message);
380
- reject(err);
381
- });
382
- req.setTimeout(300000, () => {
383
- req.destroy();
384
- spinner.fail('Download timed out');
385
- reject(new Error('Timeout downloading ' + label));
386
- });
387
-
388
- req.end();
389
- });
390
- }
391
-
392
- // ============================================
393
- // SHARED HELPERS
394
- // ============================================
395
-
396
- const CONFIDENCE_ORDER = { 'high': 3, 'medium': 2, 'low': 1 };
397
-
398
- function createFreshness(source, confidence) {
399
- return {
400
- added_at: new Date().toISOString(),
401
- source: source,
402
- confidence: confidence || 'high'
403
- };
404
- }
405
-
406
- /**
407
- * Extract version list from an OSV affected entry.
408
- * Returns explicit versions if available, otherwise ['*'].
409
- */
410
- function extractVersions(affected) {
411
- const versions = new Set();
412
-
413
- if (affected.versions && affected.versions.length > 0) {
414
- for (const v of affected.versions) {
415
- versions.add(v);
416
- }
417
- }
418
-
419
- if (affected.ranges) {
420
- for (const range of affected.ranges) {
421
- if (range.events) {
422
- for (const event of range.events) {
423
- if (event.introduced && event.introduced !== '0') {
424
- versions.add(event.introduced);
425
- }
426
- }
427
- }
428
- }
429
- }
430
-
431
- return versions.size > 0 ? [...versions] : ['*'];
432
- }
433
-
434
- /**
435
- * Parse an OSV-format vulnerability entry into IOC packages.
436
- * Shared by OSSF and OSV sources.
437
- * @param {Object} vuln - OSV vulnerability object
438
- * @param {string} source - Source identifier
439
- * @param {string} [ecosystem='npm'] - Target ecosystem ('npm' or 'PyPI')
440
- */
441
- function parseOSVEntry(vuln, source, ecosystem) {
442
- if (!ecosystem) ecosystem = 'npm';
443
- const packages = [];
444
- if (!vuln || !vuln.affected) return packages;
445
-
446
- for (const affected of vuln.affected) {
447
- if (!affected.package || affected.package.ecosystem !== ecosystem) continue;
448
-
449
- const pkgVersions = extractVersions(affected);
450
-
451
- for (const ver of pkgVersions) {
452
- packages.push({
453
- id: vuln.id || source + '-' + affected.package.name,
454
- name: affected.package.name,
455
- version: ver,
456
- severity: 'critical',
457
- confidence: 'high',
458
- source: source,
459
- description: (vuln.summary || vuln.details || 'Malicious package').slice(0, 200),
460
- references: (vuln.references || []).map(r => r.url).slice(0, 3),
461
- mitre: 'T1195.002',
462
- published: vuln.published || vuln.modified || null,
463
- freshness: createFreshness(source, 'high')
464
- });
465
- }
466
- }
467
-
468
- return packages;
469
- }
470
-
471
- // ============================================
472
- // SOURCE 1: GenSecAI Shai-Hulud 2.0 Detector
473
- // Consolidated list (700+ packages)
474
- // ============================================
475
- async function scrapeShaiHuludDetector() {
476
- console.log('[SCRAPER] GenSecAI Shai-Hulud 2.0 Detector...');
477
- const packages = [];
478
- const hashes = [];
479
-
480
- try {
481
- const url = 'https://raw.githubusercontent.com/gensecaihq/Shai-Hulud-2.0-Detector/main/compromised-packages.json';
482
- const { status, data } = await fetchJSON(url);
483
-
484
- if (status === 200 && data) {
485
- // Extract packages — one IOC per version for correct matching
486
- const pkgList = data.packages || [];
487
- for (const pkg of pkgList) {
488
- const versions = pkg.affectedVersions || ['*'];
489
- for (const ver of versions) {
490
- packages.push({
491
- id: `SHAI-HULUD-${pkg.name}-${ver}`,
492
- name: pkg.name,
493
- version: ver,
494
- severity: pkg.severity || 'critical',
495
- confidence: 'high',
496
- source: 'shai-hulud-detector',
497
- description: 'Compromised by Shai-Hulud 2.0 supply chain attack',
498
- references: ['https://github.com/gensecaihq/Shai-Hulud-2.0-Detector'],
499
- mitre: 'T1195.002',
500
- freshness: createFreshness('gensecai', 'high')
501
- });
502
- }
503
- }
504
-
505
- // Extract hashes
506
- if (data.indicators && data.indicators.fileHashes) {
507
- const fileHashes = data.indicators.fileHashes;
508
- for (const hashData of Object.values(fileHashes)) {
509
- if (hashData.sha256) {
510
- const sha256List = Array.isArray(hashData.sha256) ? hashData.sha256 : [hashData.sha256];
511
- for (const hash of sha256List) {
512
- if (hash && hash.length === 64) {
513
- hashes.push(hash.toLowerCase());
514
- }
515
- }
516
- }
517
- }
518
- }
519
-
520
- console.log(`[SCRAPER] ${packages.length} packages, ${hashes.length} hashes`);
521
- }
522
- } catch (e) {
523
- console.log(`[SCRAPER] Error: ${e.message}`);
524
- }
525
-
526
- return { packages, hashes };
527
- }
528
-
529
- // ============================================
530
- // SOURCE 2: DataDog Consolidated IOCs
531
- // Fixed URLs - consolidated_iocs.csv
532
- // ============================================
533
- async function scrapeDatadogIOCs() {
534
- console.log('[SCRAPER] DataDog Security Labs IOCs...');
535
- const packages = [];
536
-
537
- try {
538
- // Consolidated file (multiple vendors)
539
- const consolidatedUrl = 'https://raw.githubusercontent.com/DataDog/indicators-of-compromise/main/shai-hulud-2.0/consolidated_iocs.csv';
540
- const consolidatedResp = await fetchText(consolidatedUrl);
541
-
542
- if (consolidatedResp.status === 200 && consolidatedResp.data) {
543
- // Format: package_name,versions,vendors (with comma handling in fields)
544
- const rows = parseCSV(consolidatedResp.data, true);
545
-
546
- for (const parts of rows) {
547
- const name = parts[0] || '';
548
- const versions = parts[1] || '*';
549
- const vendors = parts[2] || 'datadog';
550
-
551
- if (name && name !== 'package_name' && name !== 'name') {
552
- packages.push({
553
- id: `DATADOG-${name}`,
554
- name: name,
555
- version: versions || '*',
556
- severity: 'critical',
557
- confidence: 'high',
558
- source: 'datadog-consolidated',
559
- description: `Compromised package (sources: ${vendors})`,
560
- references: ['https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/'],
561
- mitre: 'T1195.002',
562
- freshness: createFreshness('datadog', 'high')
563
- });
564
- }
565
- }
566
- console.log(`[SCRAPER] ${packages.length} packages (consolidated)`);
567
- }
568
-
569
- // DataDog specific file
570
- const ddUrl = 'https://raw.githubusercontent.com/DataDog/indicators-of-compromise/main/shai-hulud-2.0/shai-hulud-2.0.csv';
571
- const ddResp = await fetchText(ddUrl);
572
-
573
- if (ddResp.status === 200 && ddResp.data) {
574
- // Parse with comma handling in quoted fields
575
- const rows = parseCSV(ddResp.data, true);
576
- let ddCount = 0;
577
-
578
- for (const parts of rows) {
579
- if (parts.length >= 2) {
580
- const name = parts[0] || '';
581
- const version = parts[1] || '*';
582
-
583
- if (name && name !== 'package_name') {
584
- // Check if not already added
585
- if (!packages.find(p => p.name === name && p.version === version)) {
586
- packages.push({
587
- id: `DATADOG-DD-${name}-${version}`.replace(/[^a-zA-Z0-9-]/g, '-'),
588
- name: name,
589
- version: version,
590
- severity: 'critical',
591
- confidence: 'high',
592
- source: 'datadog-direct',
593
- description: 'Manually confirmed by DataDog Security Labs',
594
- references: ['https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/'],
595
- mitre: 'T1195.002',
596
- freshness: createFreshness('datadog', 'high')
597
- });
598
- ddCount++;
599
- }
600
- }
601
- }
602
- }
603
- console.log(`[SCRAPER] +${ddCount} packages (datadog direct)`);
604
- }
605
-
606
- } catch (e) {
607
- console.log(`[SCRAPER] Error: ${e.message}`);
608
- }
609
-
610
- return { packages, hashes: [] };
611
- }
612
-
613
- // ============================================
614
- // SOURCE 3: OSSF Malicious Packages
615
- // GitHub tree API + batch fetch with incremental SHA
616
- // ============================================
617
- async function scrapeOSSFMaliciousPackages(knownIds) {
618
- console.log('[SCRAPER] OSSF Malicious Packages (GitHub tree)...');
619
- const packages = [];
620
- const knownIdSet = knownIds || new Set();
621
-
622
- try {
623
- // Step 1: Get recursive tree
624
- const treeUrl = 'https://api.github.com/repos/ossf/malicious-packages/git/trees/main?recursive=1';
625
- const { status, data } = await fetchJSON(treeUrl);
626
-
627
- if (status !== 200 || !data || !data.tree) {
628
- console.log('[SCRAPER] Failed to get tree (HTTP ' + status + ')');
629
- return packages;
630
- }
631
-
632
- // Incremental: compare tree SHA (stored in ~/.muaddib/data/ to persist across npm updates)
633
- const treeSha = data.sha;
634
- const homeDir = path.dirname(HOME_IOC_FILE);
635
- if (!fs.existsSync(homeDir)) fs.mkdirSync(homeDir, { recursive: true });
636
- const shaFile = path.join(homeDir, '.ossf-tree-sha');
637
- let lastSha = null;
638
- try { lastSha = fs.readFileSync(shaFile, 'utf8').trim(); } catch {}
639
-
640
- if (lastSha === treeSha) {
641
- console.log('[SCRAPER] Tree unchanged (SHA: ' + treeSha.slice(0, 8) + '...), skipping fetch');
642
- return packages;
643
- }
644
-
645
- // Step 2: Filter npm MAL-* entries
646
- const npmMalFiles = data.tree.filter(function(entry) {
647
- return entry.path.startsWith('osv/malicious/npm/')
648
- && entry.path.endsWith('.json')
649
- && entry.path.includes('/MAL-');
650
- });
651
-
652
- console.log('[SCRAPER] Found ' + npmMalFiles.length + ' npm malware entries in tree');
653
-
654
- // Step 3: Skip entries already known from OSV dump
655
- const toFetch = npmMalFiles.filter(function(entry) {
656
- // Extract MAL-XXXX-XXXX from filename
657
- const filename = path.basename(entry.path, '.json');
658
- return !knownIdSet.has(filename);
659
- });
660
-
661
- console.log('[SCRAPER] ' + (npmMalFiles.length - toFetch.length) + ' already known from OSV, fetching ' + toFetch.length + ' new entries');
662
-
663
- // Step 4: Batch fetch (50 concurrent, with small delay between batches for rate limit)
664
- const BATCH_SIZE = 50;
665
- let fetchSpinner = null;
666
- if (toFetch.length > 0) {
667
- fetchSpinner = new Spinner();
668
- fetchSpinner.start('Fetching OSSF entries... 0/' + toFetch.length);
669
- }
670
-
671
- for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
672
- const batch = toFetch.slice(i, i + BATCH_SIZE);
673
- const results = await Promise.all(batch.map(function(entry) {
674
- const rawUrl = 'https://raw.githubusercontent.com/ossf/malicious-packages/main/' + entry.path;
675
- return fetchJSON(rawUrl).catch(function() { return null; });
676
- }));
677
-
678
- for (const result of results) {
679
- if (!result || result.status !== 200 || !result.data) continue;
680
- const parsed = parseOSVEntry(result.data, 'ossf-malicious');
681
- for (const p of parsed) packages.push(p);
682
- }
683
-
684
- // Progress
685
- const progress = Math.min(i + BATCH_SIZE, toFetch.length);
686
- fetchSpinner.update('Fetching OSSF entries... ' + progress + '/' + toFetch.length);
687
-
688
- // Small delay between batches to respect rate limits
689
- if (i + BATCH_SIZE < toFetch.length) {
690
- await new Promise(function(r) { setTimeout(r, 100); });
691
- }
692
- }
693
-
694
- if (fetchSpinner) {
695
- fetchSpinner.succeed('Fetched OSSF entries: ' + packages.length + ' packages');
696
- }
697
-
698
- // Save tree SHA for next incremental run
699
- try { fs.writeFileSync(shaFile, treeSha); } catch {}
700
- } catch (e) {
701
- console.log('[SCRAPER] Error: ' + e.message);
702
- }
703
-
704
- return packages;
705
- }
706
-
707
- // ============================================
708
- // SOURCE 3b: OSV.dev npm data dump
709
- // Bulk zip download — primary volume source
710
- // ============================================
711
- async function scrapeOSVDataDump() {
712
- const packages = [];
713
- const knownIds = new Set();
714
-
715
- try {
716
- // Download the full npm zip (~50-100MB)
717
- const zipUrl = 'https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip';
718
- const zipBuffer = await fetchBufferWithProgress(zipUrl, 'npm all.zip');
719
-
720
- // Extract using adm-zip
721
- const zip = new AdmZip(zipBuffer);
722
- const entries = zip.getEntries();
723
- const total = entries.length;
724
-
725
- let malCount = 0;
726
- let skippedCount = 0;
727
-
728
- const spinner = new Spinner();
729
- try {
730
- spinner.start('Parsing npm entries... 0/' + total);
731
-
732
- for (let i = 0; i < entries.length; i++) {
733
- const entry = entries[i];
734
- const name = entry.entryName;
735
-
736
- // Only process MAL-*.json files (malware), skip GHSA-*, CVE-*, PYSEC-* etc.
737
- if (!name.startsWith('MAL-') || !name.endsWith('.json')) {
738
- skippedCount++;
739
- } else {
740
- try {
741
- const content = entry.getData().toString('utf8');
742
- const vuln = JSON.parse(content);
743
- const parsed = parseOSVEntry(vuln, 'osv-malicious');
744
- for (const p of parsed) packages.push(p);
745
-
746
- // Track known IDs so OSSF can skip them
747
- knownIds.add(vuln.id || path.basename(name, '.json'));
748
- malCount++;
749
- } catch {
750
- // Skip unparseable entries
751
- }
752
- }
753
-
754
- if ((i + 1) % 1000 === 0 || i === entries.length - 1) {
755
- spinner.update('Parsing npm entries... ' + (i + 1) + '/' + total);
756
- }
757
- }
758
-
759
- spinner.succeed('Parsed npm entries: ' + malCount + ' MAL-* (' + skippedCount + ' skipped) \u2192 ' + packages.length + ' packages');
760
- } catch (innerErr) {
761
- spinner.fail('Parsing npm entries failed');
762
- throw innerErr;
763
- }
764
- } catch (e) {
765
- console.log('[SCRAPER] Error: ' + e.message);
766
- }
767
-
768
- return { packages, knownIds };
769
- }
770
-
771
- // ============================================
772
- // SOURCE 3c: OSV.dev PyPI data dump
773
- // Bulk zip download — PyPI malicious packages
774
- // ============================================
775
- async function scrapeOSVPyPIDataDump() {
776
- const packages = [];
777
-
778
- try {
779
- const zipUrl = 'https://osv-vulnerabilities.storage.googleapis.com/PyPI/all.zip';
780
- const zipBuffer = await fetchBufferWithProgress(zipUrl, 'PyPI all.zip');
781
-
782
- const zip = new AdmZip(zipBuffer);
783
- const entries = zip.getEntries();
784
- const total = entries.length;
785
-
786
- let malCount = 0;
787
- let skippedCount = 0;
788
-
789
- const spinner = new Spinner();
790
- try {
791
- spinner.start('Parsing PyPI entries... 0/' + total);
792
-
793
- for (let i = 0; i < entries.length; i++) {
794
- const entry = entries[i];
795
- const name = entry.entryName;
796
-
797
- // Only process MAL-*.json files (malware)
798
- if (!name.startsWith('MAL-') || !name.endsWith('.json')) {
799
- skippedCount++;
800
- } else {
801
- try {
802
- const content = entry.getData().toString('utf8');
803
- const vuln = JSON.parse(content);
804
- const parsed = parseOSVEntry(vuln, 'osv-malicious-pypi', 'PyPI');
805
- for (const p of parsed) packages.push(p);
806
- malCount++;
807
- } catch {
808
- // Skip unparseable entries
809
- }
810
- }
811
-
812
- if ((i + 1) % 1000 === 0 || i === entries.length - 1) {
813
- spinner.update('Parsing PyPI entries... ' + (i + 1) + '/' + total);
814
- }
815
- }
816
-
817
- spinner.succeed('Parsed PyPI entries: ' + malCount + ' MAL-* (' + skippedCount + ' skipped) \u2192 ' + packages.length + ' packages');
818
- } catch (innerErr) {
819
- spinner.fail('Parsing PyPI entries failed');
820
- throw innerErr;
821
- }
822
- } catch (e) {
823
- console.log('[SCRAPER] Error: ' + e.message);
824
- }
825
-
826
- return packages;
827
- }
828
-
829
- // ============================================
830
- // SOURCE 4: GitHub Advisory Database (Malware)
831
- // ============================================
832
- async function scrapeGitHubAdvisory() {
833
- console.log('[SCRAPER] GitHub Advisory Database (malware)...');
834
- const packages = [];
835
-
836
- try {
837
- const resp = await fetchJSON('https://api.osv.dev/v1/query', {
838
- method: 'POST',
839
- headers: { 'Content-Type': 'application/json' },
840
- body: { package: { ecosystem: 'npm' } }
841
- });
842
-
843
- if (resp.status === 200 && resp.data && resp.data.vulns) {
844
- for (const vuln of resp.data.vulns) {
845
- // Filter GHSA with malware mention
846
- if (vuln.id && vuln.id.startsWith('GHSA-')) {
847
- const summary = (vuln.summary || '').toLowerCase();
848
- const details = (vuln.details || '').toLowerCase();
849
- const isMalware = summary.includes('malware') ||
850
- summary.includes('malicious') ||
851
- details.includes('malware') ||
852
- details.includes('malicious') ||
853
- summary.includes('backdoor') ||
854
- summary.includes('trojan');
855
-
856
- if (isMalware) {
857
- for (const affected of vuln.affected || []) {
858
- if (affected.package && affected.package.ecosystem === 'npm') {
859
- packages.push({
860
- id: vuln.id,
861
- name: affected.package.name,
862
- version: '*',
863
- severity: 'critical',
864
- confidence: 'high',
865
- source: 'github-advisory',
866
- description: (vuln.summary || 'Malicious package').slice(0, 200),
867
- references: ['https://github.com/advisories/' + vuln.id],
868
- mitre: 'T1195.002',
869
- freshness: createFreshness('github-advisory', 'high')
870
- });
871
- }
872
- }
873
- }
874
- }
875
- }
876
- }
877
-
878
- console.log(`[SCRAPER] ${packages.length} packages`);
879
- } catch (e) {
880
- console.log(`[SCRAPER] Error: ${e.message}`);
881
- }
882
-
883
- return packages;
884
- }
885
-
886
- // ============================================
887
- // SOURCE 5: Static IOCs (Socket, Phylum, npm removed)
888
- // Local file maintained manually
889
- // ============================================
890
- async function scrapeStaticIOCs() {
891
- console.log('[SCRAPER] Static IOCs (local file)...');
892
- const packages = [];
893
- const staticIOCs = loadStaticIOCs();
894
-
895
- // Socket.dev reports
896
- for (const pkg of staticIOCs.socket || []) {
897
- packages.push({
898
- id: `SOCKET-${pkg.name}`,
899
- name: pkg.name,
900
- version: pkg.version || '*',
901
- severity: pkg.severity || 'critical',
902
- confidence: 'high',
903
- source: 'socket-dev',
904
- description: pkg.description || 'Malicious package reported by Socket.dev',
905
- references: ['https://socket.dev/npm/package/' + pkg.name],
906
- mitre: 'T1195.002',
907
- freshness: createFreshness('socket', 'high')
908
- });
909
- }
910
-
911
- // Phylum Research
912
- for (const pkg of staticIOCs.phylum || []) {
913
- packages.push({
914
- id: `PHYLUM-${pkg.name}`,
915
- name: pkg.name,
916
- version: pkg.version || '*',
917
- severity: pkg.severity || 'critical',
918
- confidence: 'high',
919
- source: 'phylum',
920
- description: pkg.description || 'Malicious package reported by Phylum Research',
921
- references: ['https://blog.phylum.io'],
922
- mitre: 'T1195.002',
923
- freshness: createFreshness('phylum', 'high')
924
- });
925
- }
926
-
927
- // npm removed packages
928
- for (const pkg of staticIOCs.npmRemoved || []) {
929
- packages.push({
930
- id: `NPM-REMOVED-${pkg.name}`,
931
- name: pkg.name,
932
- version: pkg.version || '*',
933
- severity: 'critical',
934
- confidence: 'high',
935
- source: 'npm-removed',
936
- description: 'Removed from npm: ' + (pkg.reason || 'security violation'),
937
- references: ['https://www.npmjs.com/policies/security'],
938
- mitre: 'T1195.002',
939
- freshness: createFreshness('npm-removed', 'medium')
940
- });
941
- }
942
-
943
- console.log(`[SCRAPER] ${packages.length} packages`);
944
- return packages;
945
- }
946
-
947
- // ============================================
948
- // SOURCE 6: Snyk Known Malware
949
- // Historical attacks database
950
- // ============================================
951
- async function scrapeSnykMalware() {
952
- console.log('[SCRAPER] Snyk Malware DB...');
953
- const packages = [];
954
-
955
- const knownSnykMalware = [
956
- { name: 'event-stream', version: '3.3.6', description: 'Flatmap-stream backdoor (2018)' },
957
- { name: 'flatmap-stream', version: '*', description: 'Malicious dependency of event-stream' },
958
- { name: 'eslint-scope', version: '3.7.2', description: 'Credential theft (2018)' },
959
- { name: 'eslint-config-eslint', version: '*', description: 'Credential theft (2018)' },
960
- { name: 'getcookies', version: '*', description: 'Backdoor malware' },
961
- { name: 'mailparser', version: '2.3.0', description: 'Compromised version' },
962
- { name: 'node-ipc', version: '10.1.1', description: 'Protestware - file deletion' },
963
- { name: 'node-ipc', version: '10.1.2', description: 'Protestware - file deletion' },
964
- { name: 'node-ipc', version: '10.1.3', description: 'Protestware - file deletion' },
965
- { name: 'colors', version: '1.4.1', description: 'Protestware - infinite loop' },
966
- { name: 'colors', version: '1.4.2', description: 'Protestware - infinite loop' },
967
- { name: 'faker', version: '6.6.6', description: 'Protestware - breaking change' },
968
- { name: 'ua-parser-js', version: '0.7.29', description: 'Cryptominer injection' },
969
- { name: 'ua-parser-js', version: '0.8.0', description: 'Cryptominer injection' },
970
- { name: 'ua-parser-js', version: '1.0.0', description: 'Cryptominer injection' },
971
- { name: 'coa', version: '2.0.3', description: 'Malicious version' },
972
- { name: 'coa', version: '2.0.4', description: 'Malicious version' },
973
- { name: 'coa', version: '2.1.1', description: 'Malicious version' },
974
- { name: 'coa', version: '2.1.3', description: 'Malicious version' },
975
- { name: 'coa', version: '3.0.1', description: 'Malicious version' },
976
- { name: 'coa', version: '3.1.3', description: 'Malicious version' },
977
- { name: 'rc', version: '1.2.9', description: 'Malicious version' },
978
- { name: 'rc', version: '1.3.9', description: 'Malicious version' },
979
- { name: 'rc', version: '2.3.9', description: 'Malicious version' },
980
- ];
981
-
982
- for (const pkg of knownSnykMalware) {
983
- packages.push({
984
- id: ('SNYK-' + pkg.name + '-' + pkg.version).replace(/[^a-zA-Z0-9-]/g, '-'),
985
- name: pkg.name,
986
- version: pkg.version,
987
- severity: 'critical',
988
- confidence: 'high',
989
- source: 'snyk-known',
990
- description: pkg.description,
991
- references: ['https://snyk.io/advisor'],
992
- mitre: 'T1195.002',
993
- freshness: createFreshness('snyk', 'high')
994
- });
995
- }
996
-
997
- console.log(`[SCRAPER] ${packages.length} packages`);
998
- return packages;
999
- }
1000
-
1001
- // ============================================
1002
- // MAIN SCRAPER
1003
- // ============================================
1004
- async function runScraper() {
1005
- console.log('\n' + '='.repeat(60));
1006
- console.log(' MUAD\'DIB IOC Scraper v4.0');
1007
- console.log(' OSV + OSSF + GenSecAI + DataDog + Snyk');
1008
- console.log('='.repeat(60) + '\n');
1009
-
1010
- // Create data directory if needed
1011
- const dataDir = path.dirname(IOC_FILE);
1012
- if (!fs.existsSync(dataDir)) {
1013
- fs.mkdirSync(dataDir, { recursive: true });
1014
- }
1015
-
1016
- // Verify write permission (CROSS-001)
1017
- try {
1018
- fs.accessSync(dataDir, fs.constants.W_OK);
1019
- } catch {
1020
- throw new Error(`Data directory is not writable: ${dataDir}`);
1021
- }
1022
-
1023
- // Load existing IOCs (check ~/.muaddib/data/ first, then local)
1024
- let existingIOCs = { packages: [], pypi_packages: [], hashes: [], markers: [], files: [] };
1025
- if (fs.existsSync(HOME_IOC_FILE)) {
1026
- try {
1027
- existingIOCs = JSON.parse(fs.readFileSync(HOME_IOC_FILE, 'utf8'));
1028
- if (!existingIOCs.pypi_packages) existingIOCs.pypi_packages = [];
1029
- console.log('[INFO] Loaded existing IOCs from ' + HOME_IOC_FILE);
1030
- } catch {
1031
- console.log('[WARN] Home IOCs file corrupted, trying local...');
1032
- }
1033
- }
1034
- if (existingIOCs.packages.length === 0 && fs.existsSync(IOC_FILE)) {
1035
- try {
1036
- existingIOCs = JSON.parse(fs.readFileSync(IOC_FILE, 'utf8'));
1037
- if (!existingIOCs.pypi_packages) existingIOCs.pypi_packages = [];
1038
- } catch {
1039
- console.log('[WARN] IOCs file corrupted, resetting...');
1040
- }
1041
- }
1042
-
1043
- const initialCount = existingIOCs.packages.length;
1044
- const initialPyPICount = existingIOCs.pypi_packages.length;
1045
- const initialHashCount = existingIOCs.hashes ? existingIOCs.hashes.length : 0;
1046
-
1047
- console.log('[INFO] Existing IOCs: ' + initialCount + ' packages, ' + initialHashCount + ' hashes\n');
1048
-
1049
- // Phase 1: OSV data dump first (bulk, primary source)
1050
- // This returns knownIds so OSSF can skip already-known entries
1051
- const osvResult = await scrapeOSVDataDump();
1052
-
1053
- // Phase 2: All other sources in parallel (including PyPI dump)
1054
- // OSSF receives knownIds from OSV to avoid redundant fetches
1055
- const results = await Promise.all([
1056
- scrapeShaiHuludDetector(),
1057
- scrapeDatadogIOCs(),
1058
- scrapeOSSFMaliciousPackages(osvResult.knownIds),
1059
- scrapeGitHubAdvisory(),
1060
- scrapeStaticIOCs(),
1061
- scrapeSnykMalware(),
1062
- scrapeOSVPyPIDataDump()
1063
- ]);
1064
-
1065
- const shaiHuludResult = results[0];
1066
- const datadogResult = results[1];
1067
- const ossfPackages = results[2];
1068
- const githubPackages = results[3];
1069
- const staticPackages = results[4];
1070
- const snykPackages = results[5];
1071
- const pypiPackages = results[6];
1072
-
1073
- // Merge all scraped packages
1074
- const allPackages = [
1075
- ...osvResult.packages,
1076
- ...shaiHuludResult.packages,
1077
- ...datadogResult.packages,
1078
- ...ossfPackages,
1079
- ...githubPackages,
1080
- ...staticPackages,
1081
- ...snykPackages
1082
- ];
1083
-
1084
- // Merge all hashes
1085
- const allHashes = [
1086
- ...(shaiHuludResult.hashes || []),
1087
- ...(datadogResult.hashes || [])
1088
- ];
1089
-
1090
- // Smart deduplication: build map of best entry per key
1091
- // For duplicates, keep the one with highest confidence, then most recent date
1092
- const dedupMap = new Map();
1093
-
1094
- // Seed with existing IOCs
1095
- for (const pkg of existingIOCs.packages) {
1096
- const key = pkg.name + '@' + pkg.version;
1097
- dedupMap.set(key, pkg);
1098
- }
1099
-
1100
- // Merge new IOCs with smart replacement
1101
- let addedPackages = 0;
1102
- let upgradedPackages = 0;
1103
- for (const pkg of allPackages) {
1104
- const key = pkg.name + '@' + pkg.version;
1105
- if (!dedupMap.has(key)) {
1106
- dedupMap.set(key, pkg);
1107
- addedPackages++;
1108
- } else {
1109
- const existing = dedupMap.get(key);
1110
- const existingConf = CONFIDENCE_ORDER[existing.confidence] || 0;
1111
- const newConf = CONFIDENCE_ORDER[pkg.confidence] || 0;
1112
- if (newConf > existingConf) {
1113
- dedupMap.set(key, pkg);
1114
- upgradedPackages++;
1115
- } else if (newConf === existingConf) {
1116
- // Same confidence: keep most recent
1117
- const existingDate = existing.published || (existing.freshness && existing.freshness.added_at) || '';
1118
- const newDate = pkg.published || (pkg.freshness && pkg.freshness.added_at) || '';
1119
- if (newDate > existingDate) {
1120
- dedupMap.set(key, pkg);
1121
- upgradedPackages++;
1122
- }
1123
- }
1124
- }
1125
- }
1126
-
1127
- // Rebuild packages array from dedup map
1128
- existingIOCs.packages = [...dedupMap.values()];
1129
-
1130
- // PyPI deduplication (same logic, separate array)
1131
- const pypiDedupMap = new Map();
1132
- for (const pkg of existingIOCs.pypi_packages) {
1133
- const key = pkg.name + '@' + pkg.version;
1134
- pypiDedupMap.set(key, pkg);
1135
- }
1136
- let addedPyPIPackages = 0;
1137
- for (const pkg of pypiPackages) {
1138
- const key = pkg.name + '@' + pkg.version;
1139
- if (!pypiDedupMap.has(key)) {
1140
- pypiDedupMap.set(key, pkg);
1141
- addedPyPIPackages++;
1142
- } else {
1143
- const existing = pypiDedupMap.get(key);
1144
- const existingConf = CONFIDENCE_ORDER[existing.confidence] || 0;
1145
- const newConf = CONFIDENCE_ORDER[pkg.confidence] || 0;
1146
- if (newConf > existingConf) {
1147
- pypiDedupMap.set(key, pkg);
1148
- }
1149
- }
1150
- }
1151
- existingIOCs.pypi_packages = [...pypiDedupMap.values()];
1152
-
1153
- // Deduplicate and add new hashes
1154
- const existingHashes = new Set(existingIOCs.hashes || []);
1155
- let addedHashes = 0;
1156
- for (const hash of allHashes) {
1157
- if (!existingHashes.has(hash)) {
1158
- existingIOCs.hashes = existingIOCs.hashes || [];
1159
- existingIOCs.hashes.push(hash);
1160
- existingHashes.add(hash);
1161
- addedHashes++;
1162
- }
1163
- }
1164
-
1165
- // Add Shai-Hulud markers if not present
1166
- if (!existingIOCs.markers || existingIOCs.markers.length === 0) {
1167
- existingIOCs.markers = [
1168
- 'setup_bun.js',
1169
- 'bun_environment.js',
1170
- 'bun_installer.js',
1171
- 'environment_source.js',
1172
- 'cloud.json',
1173
- 'contents.json',
1174
- 'environment.json',
1175
- 'truffleSecrets.json',
1176
- 'actionsSecrets.json',
1177
- 'trufflehog_output.json',
1178
- '3nvir0nm3nt.json',
1179
- 'cl0vd.json',
1180
- 'c9nt3nts.json',
1181
- 'pigS3cr3ts.json'
1182
- ];
1183
- }
1184
-
1185
- // Update metadata
1186
- existingIOCs.updated = new Date().toISOString();
1187
- existingIOCs.sources = [
1188
- 'osv-malicious',
1189
- 'osv-malicious-pypi',
1190
- 'ossf-malicious',
1191
- 'shai-hulud-detector',
1192
- 'datadog-consolidated',
1193
- 'datadog-direct',
1194
- 'github-advisory',
1195
- 'socket-dev',
1196
- 'phylum',
1197
- 'npm-removed',
1198
- 'snyk-known'
1199
- ];
1200
-
1201
- // Save enriched (full) IOCs — atomic write via .tmp + rename
1202
- const saveSpinner = new Spinner();
1203
- saveSpinner.start('Saving IOCs...');
1204
- const tmpIOCFile = IOC_FILE + '.tmp';
1205
- fs.writeFileSync(tmpIOCFile, JSON.stringify(existingIOCs, null, 2));
1206
- fs.renameSync(tmpIOCFile, IOC_FILE);
1207
-
1208
- // Save compact IOCs (lightweight, shipped in npm) — atomic write
1209
- saveSpinner.update('Generating compact IOCs...');
1210
- const compactIOCs = generateCompactIOCs(existingIOCs);
1211
- const tmpCompactFile = COMPACT_IOC_FILE + '.tmp';
1212
- fs.writeFileSync(tmpCompactFile, JSON.stringify(compactIOCs));
1213
- fs.renameSync(tmpCompactFile, COMPACT_IOC_FILE);
1214
-
1215
- // Persist to ~/.muaddib/data/ (survives npm update)
1216
- saveSpinner.update('Persisting to home directory...');
1217
- const homeDir = path.dirname(HOME_IOC_FILE);
1218
- if (!fs.existsSync(homeDir)) {
1219
- fs.mkdirSync(homeDir, { recursive: true });
1220
- }
1221
- try {
1222
- const tmpHomeFile = HOME_IOC_FILE + '.tmp';
1223
- fs.writeFileSync(tmpHomeFile, JSON.stringify(existingIOCs, null, 2));
1224
- fs.renameSync(tmpHomeFile, HOME_IOC_FILE);
1225
- saveSpinner.succeed('Saved IOCs + compact format + home directory');
1226
- } catch (e) {
1227
- saveSpinner.succeed('Saved IOCs + compact format (home dir write failed: ' + e.message + ')');
1228
- }
1229
-
1230
- // Display summary
1231
- console.log('\n' + '='.repeat(60));
1232
- console.log(' RESULTS');
1233
- console.log('='.repeat(60));
1234
- console.log(' npm packages before: ' + initialCount);
1235
- console.log(' npm packages after: ' + existingIOCs.packages.length);
1236
- console.log(' New npm: +' + addedPackages);
1237
- console.log(' Upgraded: ' + upgradedPackages);
1238
- console.log(' PyPI packages before: ' + initialPyPICount);
1239
- console.log(' PyPI packages after: ' + existingIOCs.pypi_packages.length);
1240
- console.log(' New PyPI: +' + addedPyPIPackages);
1241
- console.log(' Hashes before: ' + initialHashCount);
1242
- console.log(' Hashes after: ' + (existingIOCs.hashes ? existingIOCs.hashes.length : 0));
1243
- console.log(' New hashes: +' + addedHashes);
1244
- console.log(' File: ' + IOC_FILE);
1245
-
1246
- // Stats by source (npm + PyPI combined)
1247
- console.log('\n Distribution by source:');
1248
- const sourceCounts = {};
1249
- for (const pkg of existingIOCs.packages) {
1250
- sourceCounts[pkg.source] = (sourceCounts[pkg.source] || 0) + 1;
1251
- }
1252
- for (const pkg of existingIOCs.pypi_packages) {
1253
- sourceCounts[pkg.source] = (sourceCounts[pkg.source] || 0) + 1;
1254
- }
1255
- const sortedSources = Object.entries(sourceCounts).sort(function(a, b) { return b[1] - a[1]; });
1256
- for (const [source, count] of sortedSources) {
1257
- console.log(' - ' + source + ': ' + count);
1258
- }
1259
-
1260
- // Target check
1261
- const total = existingIOCs.packages.length;
1262
- if (total >= 5000) {
1263
- console.log('\n [OK] Target reached: ' + total + ' IOCs (>= 5000)');
1264
- } else {
1265
- console.log('\n [WARN] Target NOT reached: ' + total + ' IOCs (< 5000)');
1266
- }
1267
-
1268
- console.log('\n');
1269
-
1270
- return {
1271
- added: addedPackages,
1272
- total: existingIOCs.packages.length,
1273
- upgraded: upgradedPackages,
1274
- addedHashes: addedHashes,
1275
- totalHashes: existingIOCs.hashes ? existingIOCs.hashes.length : 0,
1276
- addedPyPI: addedPyPIPackages,
1277
- totalPyPI: existingIOCs.pypi_packages.length
1278
- };
1279
- }
1280
-
1281
- module.exports = { runScraper, scrapeShaiHuludDetector, scrapeDatadogIOCs };
1282
-
1283
- // Direct execution if called as CLI
1284
- if (require.main === module) {
1285
- runScraper()
1286
- .then(function(result) {
1287
- console.log('[OK] ' + result.added + ' new IOCs (total: ' + result.total + ')');
1288
- process.exit(0);
1289
- })
1290
- .catch(function(err) {
1291
- console.error('[ERROR] ' + err.message);
1292
- process.exit(1);
1293
- });
1
+ const https = require('https');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const AdmZip = require('adm-zip');
6
+
7
+ const IOC_FILE = path.join(__dirname, 'data/iocs.json');
8
+ const COMPACT_IOC_FILE = path.join(__dirname, 'data/iocs-compact.json');
9
+ const HOME_IOC_FILE = path.join(os.homedir(), '.muaddib', 'data', 'iocs.json');
10
+ const STATIC_IOCS_FILE = path.join(__dirname, '../../data/static-iocs.json');
11
+ const { generateCompactIOCs } = require('./updater.js');
12
+ const { Spinner } = require('../utils.js');
13
+
14
+ // Allowed domains for redirections (SSRF security)
15
+ const ALLOWED_REDIRECT_DOMAINS = [
16
+ 'raw.githubusercontent.com',
17
+ 'github.com',
18
+ 'api.github.com',
19
+ 'api.osv.dev',
20
+ 'osv.dev',
21
+ 'objects.githubusercontent.com',
22
+ 'osv-vulnerabilities.storage.googleapis.com',
23
+ 'storage.googleapis.com'
24
+ ];
25
+
26
+ /**
27
+ * Checks if a redirect URL is allowed
28
+ * @param {string} redirectUrl - Redirect URL
29
+ * @returns {boolean} true if allowed
30
+ */
31
+ function isAllowedRedirect(redirectUrl) {
32
+ try {
33
+ const urlObj = new URL(redirectUrl);
34
+ if (urlObj.protocol !== 'https:') return false;
35
+ return ALLOWED_REDIRECT_DOMAINS.includes(urlObj.hostname);
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ // ============================================
42
+ // UTILITY FUNCTIONS
43
+ // ============================================
44
+
45
+ /**
46
+ * Parse a CSV line correctly handling commas within quoted fields
47
+ * Supports: "field1","field, with comma","field3"
48
+ * @param {string} line - CSV line to parse
49
+ * @returns {string[]} Array of fields
50
+ */
51
+ function parseCSVLine(line) {
52
+ const fields = [];
53
+ let current = '';
54
+ let inQuotes = false;
55
+ let i = 0;
56
+
57
+ while (i < line.length) {
58
+ const char = line[i];
59
+
60
+ if (char === '"') {
61
+ // Handle escaped double quotes ("") within a field
62
+ if (inQuotes && line[i + 1] === '"') {
63
+ current += '"';
64
+ i += 2;
65
+ continue;
66
+ }
67
+ // Toggle quote mode
68
+ inQuotes = !inQuotes;
69
+ i++;
70
+ continue;
71
+ }
72
+
73
+ if (char === ',' && !inQuotes) {
74
+ // End of field
75
+ fields.push(current.trim());
76
+ current = '';
77
+ i++;
78
+ continue;
79
+ }
80
+
81
+ current += char;
82
+ i++;
83
+ }
84
+
85
+ // Add the last field
86
+ fields.push(current.trim());
87
+
88
+ return fields;
89
+ }
90
+
91
+ /**
92
+ * Parse a complete CSV file
93
+ * @param {string} csvContent - CSV content
94
+ * @param {boolean} [hasHeader=true] - If true, skip the first line
95
+ * @returns {string[][]} Array of parsed lines
96
+ */
97
+ function parseCSV(csvContent, hasHeader = true) {
98
+ const lines = csvContent.split(/\r?\n/).filter(l => l.trim());
99
+ const startIndex = hasHeader ? 1 : 0;
100
+ const results = [];
101
+
102
+ for (let i = startIndex; i < lines.length; i++) {
103
+ const parsed = parseCSVLine(lines[i]);
104
+ if (parsed.length > 0 && parsed[0]) {
105
+ results.push(parsed);
106
+ }
107
+ }
108
+
109
+ return results;
110
+ }
111
+
112
+ function loadStaticIOCs() {
113
+ try {
114
+ if (fs.existsSync(STATIC_IOCS_FILE)) {
115
+ return JSON.parse(fs.readFileSync(STATIC_IOCS_FILE, 'utf8'));
116
+ }
117
+ } catch (e) {
118
+ console.log(`[WARN] Error loading static-iocs.json: ${e.message}`);
119
+ }
120
+ return { socket: [], phylum: [], npmRemoved: [] };
121
+ }
122
+
123
+ const MAX_REDIRECTS = 5;
124
+ const MAX_RESPONSE_SIZE = 200 * 1024 * 1024; // 200MB
125
+
126
+ function fetchJSON(url, options = {}, redirectCount = 0) {
127
+ return new Promise((resolve, reject) => {
128
+ const urlObj = new URL(url);
129
+ const reqOptions = {
130
+ hostname: urlObj.hostname,
131
+ path: urlObj.pathname + urlObj.search,
132
+ method: options.method || 'GET',
133
+ headers: {
134
+ 'User-Agent': 'MUADDIB-Scanner/3.0',
135
+ 'Accept': 'application/json',
136
+ ...options.headers
137
+ }
138
+ };
139
+
140
+ const req = https.request(reqOptions, (res) => {
141
+ // Handle redirects (with security validation and limit)
142
+ if ([301, 302, 307, 308].includes(res.statusCode)) {
143
+ res.resume(); // Drain old response before following redirect
144
+ if (redirectCount >= MAX_REDIRECTS) {
145
+ reject(new Error('Too many redirects'));
146
+ return;
147
+ }
148
+ const redirectUrl = res.headers.location;
149
+ if (!isAllowedRedirect(redirectUrl)) {
150
+ reject(new Error(`Unauthorized redirect to: ${redirectUrl}`));
151
+ return;
152
+ }
153
+ fetchJSON(redirectUrl, options, redirectCount + 1).then(resolve).catch(reject);
154
+ return;
155
+ }
156
+
157
+ let data = '';
158
+ let dataSize = 0;
159
+ let destroyed = false;
160
+ res.on('data', chunk => {
161
+ if (destroyed) return;
162
+ dataSize += chunk.length;
163
+ if (dataSize > MAX_RESPONSE_SIZE) {
164
+ destroyed = true;
165
+ res.destroy();
166
+ reject(new Error('Response exceeded maximum size'));
167
+ return;
168
+ }
169
+ data += chunk;
170
+ });
171
+ res.on('end', () => {
172
+ if (destroyed) return;
173
+ try {
174
+ resolve({ status: res.statusCode, data: JSON.parse(data) });
175
+ } catch (e) {
176
+ resolve({ status: res.statusCode, data: null, raw: data, error: e.message });
177
+ }
178
+ });
179
+ });
180
+
181
+ req.on('error', reject);
182
+ req.setTimeout(30000, () => {
183
+ req.destroy();
184
+ reject(new Error('Timeout'));
185
+ });
186
+
187
+ if (options.body) {
188
+ req.write(JSON.stringify(options.body));
189
+ }
190
+
191
+ req.end();
192
+ });
193
+ }
194
+
195
+ function fetchText(url, redirectCount = 0) {
196
+ return new Promise((resolve, reject) => {
197
+ const urlObj = new URL(url);
198
+ const reqOptions = {
199
+ hostname: urlObj.hostname,
200
+ path: urlObj.pathname + urlObj.search,
201
+ method: 'GET',
202
+ headers: {
203
+ 'User-Agent': 'MUADDIB-Scanner/3.0'
204
+ }
205
+ };
206
+
207
+ const req = https.request(reqOptions, (res) => {
208
+ // Handle redirects (with security validation and limit)
209
+ if ([301, 302, 307, 308].includes(res.statusCode)) {
210
+ res.resume(); // Drain old response before following redirect
211
+ if (redirectCount >= MAX_REDIRECTS) {
212
+ reject(new Error('Too many redirects'));
213
+ return;
214
+ }
215
+ const redirectUrl = res.headers.location;
216
+ if (!isAllowedRedirect(redirectUrl)) {
217
+ reject(new Error(`Unauthorized redirect to: ${redirectUrl}`));
218
+ return;
219
+ }
220
+ fetchText(redirectUrl, redirectCount + 1).then(resolve).catch(reject);
221
+ return;
222
+ }
223
+
224
+ let data = '';
225
+ let dataSize = 0;
226
+ res.on('data', chunk => {
227
+ dataSize += chunk.length;
228
+ if (dataSize > MAX_RESPONSE_SIZE) {
229
+ req.destroy();
230
+ reject(new Error('Response exceeded maximum size'));
231
+ return;
232
+ }
233
+ data += chunk;
234
+ });
235
+ res.on('end', () => {
236
+ resolve({ status: res.statusCode, data: data });
237
+ });
238
+ });
239
+
240
+ req.on('error', reject);
241
+ req.setTimeout(30000, () => {
242
+ req.destroy();
243
+ reject(new Error('Timeout'));
244
+ });
245
+
246
+ req.end();
247
+ });
248
+ }
249
+
250
+ function fetchBuffer(url, redirectCount = 0) {
251
+ return new Promise((resolve, reject) => {
252
+ const urlObj = new URL(url);
253
+ const reqOptions = {
254
+ hostname: urlObj.hostname,
255
+ path: urlObj.pathname + urlObj.search,
256
+ method: 'GET',
257
+ headers: {
258
+ 'User-Agent': 'MUADDIB-Scanner/3.0'
259
+ }
260
+ };
261
+
262
+ const req = https.request(reqOptions, (res) => {
263
+ if ([301, 302, 307, 308].includes(res.statusCode)) {
264
+ res.resume(); // Drain response body before following redirect
265
+ if (redirectCount >= MAX_REDIRECTS) {
266
+ reject(new Error('Too many redirects'));
267
+ return;
268
+ }
269
+ const redirectUrl = res.headers.location;
270
+ if (!isAllowedRedirect(redirectUrl)) {
271
+ reject(new Error('Unauthorized redirect to: ' + redirectUrl));
272
+ return;
273
+ }
274
+ fetchBuffer(redirectUrl, redirectCount + 1).then(resolve).catch(reject);
275
+ return;
276
+ }
277
+
278
+ if (res.statusCode !== 200) {
279
+ res.resume(); // Drain response body on error
280
+ reject(new Error('HTTP ' + res.statusCode));
281
+ return;
282
+ }
283
+
284
+ const chunks = [];
285
+ let received = 0;
286
+ res.on('data', chunk => {
287
+ received += chunk.length;
288
+ if (received > MAX_RESPONSE_SIZE) {
289
+ req.destroy();
290
+ reject(new Error('Response exceeded maximum size'));
291
+ return;
292
+ }
293
+ chunks.push(chunk);
294
+ });
295
+ res.on('end', () => resolve(Buffer.concat(chunks)));
296
+ });
297
+
298
+ req.on('error', reject);
299
+ req.setTimeout(120000, () => {
300
+ req.destroy();
301
+ reject(new Error('Timeout'));
302
+ });
303
+
304
+ req.end();
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Download a large file with spinner progress (npm/ora style).
310
+ * Used for bulk zip downloads (OSV npm/PyPI ~50-100MB each).
311
+ */
312
+ function fetchBufferWithProgress(url, label, redirectCount = 0) {
313
+ return new Promise((resolve, reject) => {
314
+ const urlObj = new URL(url);
315
+ const reqOptions = {
316
+ hostname: urlObj.hostname,
317
+ path: urlObj.pathname + urlObj.search,
318
+ method: 'GET',
319
+ headers: {
320
+ 'User-Agent': 'MUADDIB-Scanner/3.0'
321
+ }
322
+ };
323
+
324
+ const req = https.request(reqOptions, (res) => {
325
+ if ([301, 302, 307, 308].includes(res.statusCode)) {
326
+ res.resume(); // Drain response body before following redirect
327
+ if (redirectCount >= MAX_REDIRECTS) {
328
+ reject(new Error('Too many redirects'));
329
+ return;
330
+ }
331
+ const redirectUrl = res.headers.location;
332
+ if (!isAllowedRedirect(redirectUrl)) {
333
+ reject(new Error('Unauthorized redirect to: ' + redirectUrl));
334
+ return;
335
+ }
336
+ fetchBufferWithProgress(redirectUrl, label, redirectCount + 1).then(resolve).catch(reject);
337
+ return;
338
+ }
339
+
340
+ if (res.statusCode !== 200) {
341
+ res.resume(); // Drain response body on error
342
+ reject(new Error('HTTP ' + res.statusCode));
343
+ return;
344
+ }
345
+
346
+ const totalSize = parseInt(res.headers['content-length'], 10) || 0;
347
+ const totalMb = totalSize > 0 ? Math.round(totalSize / 1024 / 1024) : null;
348
+ const chunks = [];
349
+ let received = 0;
350
+
351
+ const spinner = new Spinner();
352
+ spinner.start('Downloading ' + label + '...');
353
+
354
+ res.on('data', (chunk) => {
355
+ chunks.push(chunk);
356
+ received += chunk.length;
357
+ if (received > MAX_RESPONSE_SIZE) {
358
+ req.destroy();
359
+ spinner.fail('Download exceeded maximum size');
360
+ reject(new Error('Response exceeded maximum size'));
361
+ return;
362
+ }
363
+ const mb = Math.round(received / 1024 / 1024);
364
+ if (totalMb) {
365
+ spinner.update('Downloading ' + label + '... ' + mb + 'MB/' + totalMb + 'MB');
366
+ } else {
367
+ spinner.update('Downloading ' + label + '... ' + mb + 'MB');
368
+ }
369
+ });
370
+
371
+ res.on('end', () => {
372
+ const mb = Math.round(received / 1024 / 1024);
373
+ spinner.succeed('Downloaded ' + label + ' (' + mb + 'MB)');
374
+ resolve(Buffer.concat(chunks));
375
+ });
376
+ });
377
+
378
+ req.on('error', (err) => {
379
+ spinner.fail('Download failed: ' + err.message);
380
+ reject(err);
381
+ });
382
+ req.setTimeout(300000, () => {
383
+ req.destroy();
384
+ spinner.fail('Download timed out');
385
+ reject(new Error('Timeout downloading ' + label));
386
+ });
387
+
388
+ req.end();
389
+ });
390
+ }
391
+
392
+ // ============================================
393
+ // SHARED HELPERS
394
+ // ============================================
395
+
396
+ const CONFIDENCE_ORDER = { 'high': 3, 'medium': 2, 'low': 1 };
397
+
398
+ function createFreshness(source, confidence) {
399
+ return {
400
+ added_at: new Date().toISOString(),
401
+ source: source,
402
+ confidence: confidence || 'high'
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Extract version list from an OSV affected entry.
408
+ * Returns explicit versions if available, otherwise ['*'].
409
+ */
410
+ function extractVersions(affected) {
411
+ const versions = new Set();
412
+
413
+ if (affected.versions && affected.versions.length > 0) {
414
+ for (const v of affected.versions) {
415
+ versions.add(v);
416
+ }
417
+ }
418
+
419
+ if (affected.ranges) {
420
+ for (const range of affected.ranges) {
421
+ if (range.events) {
422
+ for (const event of range.events) {
423
+ if (event.introduced && event.introduced !== '0') {
424
+ versions.add(event.introduced);
425
+ }
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ return versions.size > 0 ? [...versions] : ['*'];
432
+ }
433
+
434
+ /**
435
+ * Parse an OSV-format vulnerability entry into IOC packages.
436
+ * Shared by OSSF and OSV sources.
437
+ * @param {Object} vuln - OSV vulnerability object
438
+ * @param {string} source - Source identifier
439
+ * @param {string} [ecosystem='npm'] - Target ecosystem ('npm' or 'PyPI')
440
+ */
441
+ function parseOSVEntry(vuln, source, ecosystem) {
442
+ if (!ecosystem) ecosystem = 'npm';
443
+ const packages = [];
444
+ if (!vuln || !vuln.affected) return packages;
445
+
446
+ for (const affected of vuln.affected) {
447
+ if (!affected.package || affected.package.ecosystem !== ecosystem) continue;
448
+
449
+ const pkgVersions = extractVersions(affected);
450
+
451
+ for (const ver of pkgVersions) {
452
+ packages.push({
453
+ id: vuln.id || source + '-' + affected.package.name,
454
+ name: affected.package.name,
455
+ version: ver,
456
+ severity: 'critical',
457
+ confidence: 'high',
458
+ source: source,
459
+ description: (vuln.summary || vuln.details || 'Malicious package').slice(0, 200),
460
+ references: (vuln.references || []).map(r => r.url).slice(0, 3),
461
+ mitre: 'T1195.002',
462
+ published: vuln.published || vuln.modified || null,
463
+ freshness: createFreshness(source, 'high')
464
+ });
465
+ }
466
+ }
467
+
468
+ return packages;
469
+ }
470
+
471
+ // ============================================
472
+ // SOURCE 1: GenSecAI Shai-Hulud 2.0 Detector
473
+ // Consolidated list (700+ packages)
474
+ // ============================================
475
+ async function scrapeShaiHuludDetector() {
476
+ console.log('[SCRAPER] GenSecAI Shai-Hulud 2.0 Detector...');
477
+ const packages = [];
478
+ const hashes = [];
479
+
480
+ try {
481
+ const url = 'https://raw.githubusercontent.com/gensecaihq/Shai-Hulud-2.0-Detector/main/compromised-packages.json';
482
+ const { status, data } = await fetchJSON(url);
483
+
484
+ if (status === 200 && data) {
485
+ // Extract packages — one IOC per version for correct matching
486
+ const pkgList = data.packages || [];
487
+ for (const pkg of pkgList) {
488
+ const versions = pkg.affectedVersions || ['*'];
489
+ for (const ver of versions) {
490
+ packages.push({
491
+ id: `SHAI-HULUD-${pkg.name}-${ver}`,
492
+ name: pkg.name,
493
+ version: ver,
494
+ severity: pkg.severity || 'critical',
495
+ confidence: 'high',
496
+ source: 'shai-hulud-detector',
497
+ description: 'Compromised by Shai-Hulud 2.0 supply chain attack',
498
+ references: ['https://github.com/gensecaihq/Shai-Hulud-2.0-Detector'],
499
+ mitre: 'T1195.002',
500
+ freshness: createFreshness('gensecai', 'high')
501
+ });
502
+ }
503
+ }
504
+
505
+ // Extract hashes
506
+ if (data.indicators && data.indicators.fileHashes) {
507
+ const fileHashes = data.indicators.fileHashes;
508
+ for (const hashData of Object.values(fileHashes)) {
509
+ if (hashData.sha256) {
510
+ const sha256List = Array.isArray(hashData.sha256) ? hashData.sha256 : [hashData.sha256];
511
+ for (const hash of sha256List) {
512
+ if (hash && hash.length === 64) {
513
+ hashes.push(hash.toLowerCase());
514
+ }
515
+ }
516
+ }
517
+ }
518
+ }
519
+
520
+ console.log(`[SCRAPER] ${packages.length} packages, ${hashes.length} hashes`);
521
+ }
522
+ } catch (e) {
523
+ console.log(`[SCRAPER] Error: ${e.message}`);
524
+ }
525
+
526
+ return { packages, hashes };
527
+ }
528
+
529
+ // ============================================
530
+ // SOURCE 2: DataDog Consolidated IOCs
531
+ // Fixed URLs - consolidated_iocs.csv
532
+ // ============================================
533
+ async function scrapeDatadogIOCs() {
534
+ console.log('[SCRAPER] DataDog Security Labs IOCs...');
535
+ const packages = [];
536
+
537
+ try {
538
+ // Consolidated file (multiple vendors)
539
+ const consolidatedUrl = 'https://raw.githubusercontent.com/DataDog/indicators-of-compromise/main/shai-hulud-2.0/consolidated_iocs.csv';
540
+ const consolidatedResp = await fetchText(consolidatedUrl);
541
+
542
+ if (consolidatedResp.status === 200 && consolidatedResp.data) {
543
+ // Format: package_name,versions,vendors (with comma handling in fields)
544
+ const rows = parseCSV(consolidatedResp.data, true);
545
+
546
+ for (const parts of rows) {
547
+ const name = parts[0] || '';
548
+ const versions = parts[1] || '*';
549
+ const vendors = parts[2] || 'datadog';
550
+
551
+ if (name && name !== 'package_name' && name !== 'name') {
552
+ packages.push({
553
+ id: `DATADOG-${name}`,
554
+ name: name,
555
+ version: versions || '*',
556
+ severity: 'critical',
557
+ confidence: 'high',
558
+ source: 'datadog-consolidated',
559
+ description: `Compromised package (sources: ${vendors})`,
560
+ references: ['https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/'],
561
+ mitre: 'T1195.002',
562
+ freshness: createFreshness('datadog', 'high')
563
+ });
564
+ }
565
+ }
566
+ console.log(`[SCRAPER] ${packages.length} packages (consolidated)`);
567
+ }
568
+
569
+ // DataDog specific file
570
+ const ddUrl = 'https://raw.githubusercontent.com/DataDog/indicators-of-compromise/main/shai-hulud-2.0/shai-hulud-2.0.csv';
571
+ const ddResp = await fetchText(ddUrl);
572
+
573
+ if (ddResp.status === 200 && ddResp.data) {
574
+ // Parse with comma handling in quoted fields
575
+ const rows = parseCSV(ddResp.data, true);
576
+ let ddCount = 0;
577
+
578
+ for (const parts of rows) {
579
+ if (parts.length >= 2) {
580
+ const name = parts[0] || '';
581
+ const version = parts[1] || '*';
582
+
583
+ if (name && name !== 'package_name') {
584
+ // Check if not already added
585
+ if (!packages.find(p => p.name === name && p.version === version)) {
586
+ packages.push({
587
+ id: `DATADOG-DD-${name}-${version}`.replace(/[^a-zA-Z0-9-]/g, '-'),
588
+ name: name,
589
+ version: version,
590
+ severity: 'critical',
591
+ confidence: 'high',
592
+ source: 'datadog-direct',
593
+ description: 'Manually confirmed by DataDog Security Labs',
594
+ references: ['https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/'],
595
+ mitre: 'T1195.002',
596
+ freshness: createFreshness('datadog', 'high')
597
+ });
598
+ ddCount++;
599
+ }
600
+ }
601
+ }
602
+ }
603
+ console.log(`[SCRAPER] +${ddCount} packages (datadog direct)`);
604
+ }
605
+
606
+ } catch (e) {
607
+ console.log(`[SCRAPER] Error: ${e.message}`);
608
+ }
609
+
610
+ return { packages, hashes: [] };
611
+ }
612
+
613
+ // ============================================
614
+ // SOURCE 3: OSSF Malicious Packages
615
+ // GitHub tree API + batch fetch with incremental SHA
616
+ // ============================================
617
+ async function scrapeOSSFMaliciousPackages(knownIds) {
618
+ console.log('[SCRAPER] OSSF Malicious Packages (GitHub tree)...');
619
+ const packages = [];
620
+ const knownIdSet = knownIds || new Set();
621
+
622
+ try {
623
+ // Step 1: Get recursive tree
624
+ const treeUrl = 'https://api.github.com/repos/ossf/malicious-packages/git/trees/main?recursive=1';
625
+ const { status, data } = await fetchJSON(treeUrl);
626
+
627
+ if (status !== 200 || !data || !data.tree) {
628
+ console.log('[SCRAPER] Failed to get tree (HTTP ' + status + ')');
629
+ return packages;
630
+ }
631
+
632
+ // Incremental: compare tree SHA (stored in ~/.muaddib/data/ to persist across npm updates)
633
+ const treeSha = data.sha;
634
+ const homeDir = path.dirname(HOME_IOC_FILE);
635
+ if (!fs.existsSync(homeDir)) fs.mkdirSync(homeDir, { recursive: true });
636
+ const shaFile = path.join(homeDir, '.ossf-tree-sha');
637
+ let lastSha = null;
638
+ try { lastSha = fs.readFileSync(shaFile, 'utf8').trim(); } catch {}
639
+
640
+ if (lastSha === treeSha) {
641
+ console.log('[SCRAPER] Tree unchanged (SHA: ' + treeSha.slice(0, 8) + '...), skipping fetch');
642
+ return packages;
643
+ }
644
+
645
+ // Step 2: Filter npm MAL-* entries
646
+ const npmMalFiles = data.tree.filter(function(entry) {
647
+ return entry.path.startsWith('osv/malicious/npm/')
648
+ && entry.path.endsWith('.json')
649
+ && entry.path.includes('/MAL-');
650
+ });
651
+
652
+ console.log('[SCRAPER] Found ' + npmMalFiles.length + ' npm malware entries in tree');
653
+
654
+ // Step 3: Skip entries already known from OSV dump
655
+ const toFetch = npmMalFiles.filter(function(entry) {
656
+ // Extract MAL-XXXX-XXXX from filename
657
+ const filename = path.basename(entry.path, '.json');
658
+ return !knownIdSet.has(filename);
659
+ });
660
+
661
+ console.log('[SCRAPER] ' + (npmMalFiles.length - toFetch.length) + ' already known from OSV, fetching ' + toFetch.length + ' new entries');
662
+
663
+ // Step 4: Batch fetch (50 concurrent, with small delay between batches for rate limit)
664
+ const BATCH_SIZE = 50;
665
+ let fetchSpinner = null;
666
+ if (toFetch.length > 0) {
667
+ fetchSpinner = new Spinner();
668
+ fetchSpinner.start('Fetching OSSF entries... 0/' + toFetch.length);
669
+ }
670
+
671
+ for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
672
+ const batch = toFetch.slice(i, i + BATCH_SIZE);
673
+ const results = await Promise.all(batch.map(function(entry) {
674
+ const rawUrl = 'https://raw.githubusercontent.com/ossf/malicious-packages/main/' + entry.path;
675
+ return fetchJSON(rawUrl).catch(function() { return null; });
676
+ }));
677
+
678
+ for (const result of results) {
679
+ if (!result || result.status !== 200 || !result.data) continue;
680
+ const parsed = parseOSVEntry(result.data, 'ossf-malicious');
681
+ for (const p of parsed) packages.push(p);
682
+ }
683
+
684
+ // Progress
685
+ const progress = Math.min(i + BATCH_SIZE, toFetch.length);
686
+ fetchSpinner.update('Fetching OSSF entries... ' + progress + '/' + toFetch.length);
687
+
688
+ // Small delay between batches to respect rate limits
689
+ if (i + BATCH_SIZE < toFetch.length) {
690
+ await new Promise(function(r) { setTimeout(r, 100); });
691
+ }
692
+ }
693
+
694
+ if (fetchSpinner) {
695
+ fetchSpinner.succeed('Fetched OSSF entries: ' + packages.length + ' packages');
696
+ }
697
+
698
+ // Save tree SHA for next incremental run
699
+ try { fs.writeFileSync(shaFile, treeSha); } catch {}
700
+ } catch (e) {
701
+ console.log('[SCRAPER] Error: ' + e.message);
702
+ }
703
+
704
+ return packages;
705
+ }
706
+
707
+ // ============================================
708
+ // SOURCE 3b: OSV.dev npm data dump
709
+ // Bulk zip download — primary volume source
710
+ // ============================================
711
+ async function scrapeOSVDataDump() {
712
+ const packages = [];
713
+ const knownIds = new Set();
714
+
715
+ try {
716
+ // Download the full npm zip (~50-100MB)
717
+ const zipUrl = 'https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip';
718
+ const zipBuffer = await fetchBufferWithProgress(zipUrl, 'npm all.zip');
719
+
720
+ // Extract using adm-zip
721
+ const zip = new AdmZip(zipBuffer);
722
+ const entries = zip.getEntries();
723
+ const total = entries.length;
724
+
725
+ let malCount = 0;
726
+ let skippedCount = 0;
727
+
728
+ const spinner = new Spinner();
729
+ try {
730
+ spinner.start('Parsing npm entries... 0/' + total);
731
+
732
+ for (let i = 0; i < entries.length; i++) {
733
+ const entry = entries[i];
734
+ const name = entry.entryName;
735
+
736
+ // Only process MAL-*.json files (malware), skip GHSA-*, CVE-*, PYSEC-* etc.
737
+ if (!name.startsWith('MAL-') || !name.endsWith('.json')) {
738
+ skippedCount++;
739
+ } else {
740
+ try {
741
+ const content = entry.getData().toString('utf8');
742
+ const vuln = JSON.parse(content);
743
+ const parsed = parseOSVEntry(vuln, 'osv-malicious');
744
+ for (const p of parsed) packages.push(p);
745
+
746
+ // Track known IDs so OSSF can skip them
747
+ knownIds.add(vuln.id || path.basename(name, '.json'));
748
+ malCount++;
749
+ } catch {
750
+ // Skip unparseable entries
751
+ }
752
+ }
753
+
754
+ if ((i + 1) % 1000 === 0 || i === entries.length - 1) {
755
+ spinner.update('Parsing npm entries... ' + (i + 1) + '/' + total);
756
+ }
757
+ }
758
+
759
+ spinner.succeed('Parsed npm entries: ' + malCount + ' MAL-* (' + skippedCount + ' skipped) \u2192 ' + packages.length + ' packages');
760
+ } catch (innerErr) {
761
+ spinner.fail('Parsing npm entries failed');
762
+ throw innerErr;
763
+ }
764
+ } catch (e) {
765
+ console.log('[SCRAPER] Error: ' + e.message);
766
+ }
767
+
768
+ return { packages, knownIds };
769
+ }
770
+
771
+ // ============================================
772
+ // SOURCE 3c: OSV.dev PyPI data dump
773
+ // Bulk zip download — PyPI malicious packages
774
+ // ============================================
775
+ async function scrapeOSVPyPIDataDump() {
776
+ const packages = [];
777
+
778
+ try {
779
+ const zipUrl = 'https://osv-vulnerabilities.storage.googleapis.com/PyPI/all.zip';
780
+ const zipBuffer = await fetchBufferWithProgress(zipUrl, 'PyPI all.zip');
781
+
782
+ const zip = new AdmZip(zipBuffer);
783
+ const entries = zip.getEntries();
784
+ const total = entries.length;
785
+
786
+ let malCount = 0;
787
+ let skippedCount = 0;
788
+
789
+ const spinner = new Spinner();
790
+ try {
791
+ spinner.start('Parsing PyPI entries... 0/' + total);
792
+
793
+ for (let i = 0; i < entries.length; i++) {
794
+ const entry = entries[i];
795
+ const name = entry.entryName;
796
+
797
+ // Only process MAL-*.json files (malware)
798
+ if (!name.startsWith('MAL-') || !name.endsWith('.json')) {
799
+ skippedCount++;
800
+ } else {
801
+ try {
802
+ const content = entry.getData().toString('utf8');
803
+ const vuln = JSON.parse(content);
804
+ const parsed = parseOSVEntry(vuln, 'osv-malicious-pypi', 'PyPI');
805
+ for (const p of parsed) packages.push(p);
806
+ malCount++;
807
+ } catch {
808
+ // Skip unparseable entries
809
+ }
810
+ }
811
+
812
+ if ((i + 1) % 1000 === 0 || i === entries.length - 1) {
813
+ spinner.update('Parsing PyPI entries... ' + (i + 1) + '/' + total);
814
+ }
815
+ }
816
+
817
+ spinner.succeed('Parsed PyPI entries: ' + malCount + ' MAL-* (' + skippedCount + ' skipped) \u2192 ' + packages.length + ' packages');
818
+ } catch (innerErr) {
819
+ spinner.fail('Parsing PyPI entries failed');
820
+ throw innerErr;
821
+ }
822
+ } catch (e) {
823
+ console.log('[SCRAPER] Error: ' + e.message);
824
+ }
825
+
826
+ return packages;
827
+ }
828
+
829
+ // ============================================
830
+ // SOURCE 4: GitHub Advisory Database (Malware)
831
+ // ============================================
832
+ async function scrapeGitHubAdvisory() {
833
+ console.log('[SCRAPER] GitHub Advisory Database (malware)...');
834
+ const packages = [];
835
+
836
+ try {
837
+ const resp = await fetchJSON('https://api.osv.dev/v1/query', {
838
+ method: 'POST',
839
+ headers: { 'Content-Type': 'application/json' },
840
+ body: { package: { ecosystem: 'npm' } }
841
+ });
842
+
843
+ if (resp.status === 200 && resp.data && resp.data.vulns) {
844
+ for (const vuln of resp.data.vulns) {
845
+ // Filter GHSA with malware mention
846
+ if (vuln.id && vuln.id.startsWith('GHSA-')) {
847
+ const summary = (vuln.summary || '').toLowerCase();
848
+ const details = (vuln.details || '').toLowerCase();
849
+ const isMalware = summary.includes('malware') ||
850
+ summary.includes('malicious') ||
851
+ details.includes('malware') ||
852
+ details.includes('malicious') ||
853
+ summary.includes('backdoor') ||
854
+ summary.includes('trojan');
855
+
856
+ if (isMalware) {
857
+ for (const affected of vuln.affected || []) {
858
+ if (affected.package && affected.package.ecosystem === 'npm') {
859
+ packages.push({
860
+ id: vuln.id,
861
+ name: affected.package.name,
862
+ version: '*',
863
+ severity: 'critical',
864
+ confidence: 'high',
865
+ source: 'github-advisory',
866
+ description: (vuln.summary || 'Malicious package').slice(0, 200),
867
+ references: ['https://github.com/advisories/' + vuln.id],
868
+ mitre: 'T1195.002',
869
+ freshness: createFreshness('github-advisory', 'high')
870
+ });
871
+ }
872
+ }
873
+ }
874
+ }
875
+ }
876
+ }
877
+
878
+ console.log(`[SCRAPER] ${packages.length} packages`);
879
+ } catch (e) {
880
+ console.log(`[SCRAPER] Error: ${e.message}`);
881
+ }
882
+
883
+ return packages;
884
+ }
885
+
886
+ // ============================================
887
+ // SOURCE 5: Static IOCs (Socket, Phylum, npm removed)
888
+ // Local file maintained manually
889
+ // ============================================
890
+ async function scrapeStaticIOCs() {
891
+ console.log('[SCRAPER] Static IOCs (local file)...');
892
+ const packages = [];
893
+ const staticIOCs = loadStaticIOCs();
894
+
895
+ // Socket.dev reports
896
+ for (const pkg of staticIOCs.socket || []) {
897
+ packages.push({
898
+ id: `SOCKET-${pkg.name}`,
899
+ name: pkg.name,
900
+ version: pkg.version || '*',
901
+ severity: pkg.severity || 'critical',
902
+ confidence: 'high',
903
+ source: 'socket-dev',
904
+ description: pkg.description || 'Malicious package reported by Socket.dev',
905
+ references: ['https://socket.dev/npm/package/' + pkg.name],
906
+ mitre: 'T1195.002',
907
+ freshness: createFreshness('socket', 'high')
908
+ });
909
+ }
910
+
911
+ // Phylum Research
912
+ for (const pkg of staticIOCs.phylum || []) {
913
+ packages.push({
914
+ id: `PHYLUM-${pkg.name}`,
915
+ name: pkg.name,
916
+ version: pkg.version || '*',
917
+ severity: pkg.severity || 'critical',
918
+ confidence: 'high',
919
+ source: 'phylum',
920
+ description: pkg.description || 'Malicious package reported by Phylum Research',
921
+ references: ['https://blog.phylum.io'],
922
+ mitre: 'T1195.002',
923
+ freshness: createFreshness('phylum', 'high')
924
+ });
925
+ }
926
+
927
+ // npm removed packages
928
+ for (const pkg of staticIOCs.npmRemoved || []) {
929
+ packages.push({
930
+ id: `NPM-REMOVED-${pkg.name}`,
931
+ name: pkg.name,
932
+ version: pkg.version || '*',
933
+ severity: 'critical',
934
+ confidence: 'high',
935
+ source: 'npm-removed',
936
+ description: 'Removed from npm: ' + (pkg.reason || 'security violation'),
937
+ references: ['https://www.npmjs.com/policies/security'],
938
+ mitre: 'T1195.002',
939
+ freshness: createFreshness('npm-removed', 'medium')
940
+ });
941
+ }
942
+
943
+ console.log(`[SCRAPER] ${packages.length} packages`);
944
+ return packages;
945
+ }
946
+
947
+ // ============================================
948
+ // SOURCE 6: Snyk Known Malware
949
+ // Historical attacks database
950
+ // ============================================
951
+ async function scrapeSnykMalware() {
952
+ console.log('[SCRAPER] Snyk Malware DB...');
953
+ const packages = [];
954
+
955
+ const knownSnykMalware = [
956
+ { name: 'event-stream', version: '3.3.6', description: 'Flatmap-stream backdoor (2018)' },
957
+ { name: 'flatmap-stream', version: '*', description: 'Malicious dependency of event-stream' },
958
+ { name: 'eslint-scope', version: '3.7.2', description: 'Credential theft (2018)' },
959
+ { name: 'eslint-config-eslint', version: '*', description: 'Credential theft (2018)' },
960
+ { name: 'getcookies', version: '*', description: 'Backdoor malware' },
961
+ { name: 'mailparser', version: '2.3.0', description: 'Compromised version' },
962
+ { name: 'node-ipc', version: '10.1.1', description: 'Protestware - file deletion' },
963
+ { name: 'node-ipc', version: '10.1.2', description: 'Protestware - file deletion' },
964
+ { name: 'node-ipc', version: '10.1.3', description: 'Protestware - file deletion' },
965
+ { name: 'colors', version: '1.4.1', description: 'Protestware - infinite loop' },
966
+ { name: 'colors', version: '1.4.2', description: 'Protestware - infinite loop' },
967
+ { name: 'faker', version: '6.6.6', description: 'Protestware - breaking change' },
968
+ { name: 'ua-parser-js', version: '0.7.29', description: 'Cryptominer injection' },
969
+ { name: 'ua-parser-js', version: '0.8.0', description: 'Cryptominer injection' },
970
+ { name: 'ua-parser-js', version: '1.0.0', description: 'Cryptominer injection' },
971
+ { name: 'coa', version: '2.0.3', description: 'Malicious version' },
972
+ { name: 'coa', version: '2.0.4', description: 'Malicious version' },
973
+ { name: 'coa', version: '2.1.1', description: 'Malicious version' },
974
+ { name: 'coa', version: '2.1.3', description: 'Malicious version' },
975
+ { name: 'coa', version: '3.0.1', description: 'Malicious version' },
976
+ { name: 'coa', version: '3.1.3', description: 'Malicious version' },
977
+ { name: 'rc', version: '1.2.9', description: 'Malicious version' },
978
+ { name: 'rc', version: '1.3.9', description: 'Malicious version' },
979
+ { name: 'rc', version: '2.3.9', description: 'Malicious version' },
980
+ ];
981
+
982
+ for (const pkg of knownSnykMalware) {
983
+ packages.push({
984
+ id: ('SNYK-' + pkg.name + '-' + pkg.version).replace(/[^a-zA-Z0-9-]/g, '-'),
985
+ name: pkg.name,
986
+ version: pkg.version,
987
+ severity: 'critical',
988
+ confidence: 'high',
989
+ source: 'snyk-known',
990
+ description: pkg.description,
991
+ references: ['https://snyk.io/advisor'],
992
+ mitre: 'T1195.002',
993
+ freshness: createFreshness('snyk', 'high')
994
+ });
995
+ }
996
+
997
+ console.log(`[SCRAPER] ${packages.length} packages`);
998
+ return packages;
999
+ }
1000
+
1001
+ // ============================================
1002
+ // MAIN SCRAPER
1003
+ // ============================================
1004
+ async function runScraper() {
1005
+ console.log('\n' + '='.repeat(60));
1006
+ console.log(' MUAD\'DIB IOC Scraper v4.0');
1007
+ console.log(' OSV + OSSF + GenSecAI + DataDog + Snyk');
1008
+ console.log('='.repeat(60) + '\n');
1009
+
1010
+ // Create data directory if needed
1011
+ const dataDir = path.dirname(IOC_FILE);
1012
+ if (!fs.existsSync(dataDir)) {
1013
+ fs.mkdirSync(dataDir, { recursive: true });
1014
+ }
1015
+
1016
+ // Verify write permission (CROSS-001)
1017
+ try {
1018
+ fs.accessSync(dataDir, fs.constants.W_OK);
1019
+ } catch {
1020
+ throw new Error(`Data directory is not writable: ${dataDir}`);
1021
+ }
1022
+
1023
+ // Load existing IOCs (check ~/.muaddib/data/ first, then local)
1024
+ let existingIOCs = { packages: [], pypi_packages: [], hashes: [], markers: [], files: [] };
1025
+ if (fs.existsSync(HOME_IOC_FILE)) {
1026
+ try {
1027
+ existingIOCs = JSON.parse(fs.readFileSync(HOME_IOC_FILE, 'utf8'));
1028
+ if (!existingIOCs.pypi_packages) existingIOCs.pypi_packages = [];
1029
+ console.log('[INFO] Loaded existing IOCs from ' + HOME_IOC_FILE);
1030
+ } catch {
1031
+ console.log('[WARN] Home IOCs file corrupted, trying local...');
1032
+ }
1033
+ }
1034
+ if (existingIOCs.packages.length === 0 && fs.existsSync(IOC_FILE)) {
1035
+ try {
1036
+ existingIOCs = JSON.parse(fs.readFileSync(IOC_FILE, 'utf8'));
1037
+ if (!existingIOCs.pypi_packages) existingIOCs.pypi_packages = [];
1038
+ } catch {
1039
+ console.log('[WARN] IOCs file corrupted, resetting...');
1040
+ }
1041
+ }
1042
+
1043
+ const initialCount = existingIOCs.packages.length;
1044
+ const initialPyPICount = existingIOCs.pypi_packages.length;
1045
+ const initialHashCount = existingIOCs.hashes ? existingIOCs.hashes.length : 0;
1046
+
1047
+ console.log('[INFO] Existing IOCs: ' + initialCount + ' packages, ' + initialHashCount + ' hashes\n');
1048
+
1049
+ // Phase 1: OSV data dump first (bulk, primary source)
1050
+ // This returns knownIds so OSSF can skip already-known entries
1051
+ const osvResult = await scrapeOSVDataDump();
1052
+
1053
+ // Phase 2: All other sources in parallel (including PyPI dump)
1054
+ // OSSF receives knownIds from OSV to avoid redundant fetches
1055
+ const results = await Promise.all([
1056
+ scrapeShaiHuludDetector(),
1057
+ scrapeDatadogIOCs(),
1058
+ scrapeOSSFMaliciousPackages(osvResult.knownIds),
1059
+ scrapeGitHubAdvisory(),
1060
+ scrapeStaticIOCs(),
1061
+ scrapeSnykMalware(),
1062
+ scrapeOSVPyPIDataDump()
1063
+ ]);
1064
+
1065
+ const shaiHuludResult = results[0];
1066
+ const datadogResult = results[1];
1067
+ const ossfPackages = results[2];
1068
+ const githubPackages = results[3];
1069
+ const staticPackages = results[4];
1070
+ const snykPackages = results[5];
1071
+ const pypiPackages = results[6];
1072
+
1073
+ // Merge all scraped packages
1074
+ const allPackages = [
1075
+ ...osvResult.packages,
1076
+ ...shaiHuludResult.packages,
1077
+ ...datadogResult.packages,
1078
+ ...ossfPackages,
1079
+ ...githubPackages,
1080
+ ...staticPackages,
1081
+ ...snykPackages
1082
+ ];
1083
+
1084
+ // Merge all hashes
1085
+ const allHashes = [
1086
+ ...(shaiHuludResult.hashes || []),
1087
+ ...(datadogResult.hashes || [])
1088
+ ];
1089
+
1090
+ // Smart deduplication: build map of best entry per key
1091
+ // For duplicates, keep the one with highest confidence, then most recent date
1092
+ const dedupMap = new Map();
1093
+
1094
+ // Seed with existing IOCs
1095
+ for (const pkg of existingIOCs.packages) {
1096
+ const key = pkg.name + '@' + pkg.version;
1097
+ dedupMap.set(key, pkg);
1098
+ }
1099
+
1100
+ // Merge new IOCs with smart replacement
1101
+ let addedPackages = 0;
1102
+ let upgradedPackages = 0;
1103
+ for (const pkg of allPackages) {
1104
+ const key = pkg.name + '@' + pkg.version;
1105
+ if (!dedupMap.has(key)) {
1106
+ dedupMap.set(key, pkg);
1107
+ addedPackages++;
1108
+ } else {
1109
+ const existing = dedupMap.get(key);
1110
+ const existingConf = CONFIDENCE_ORDER[existing.confidence] || 0;
1111
+ const newConf = CONFIDENCE_ORDER[pkg.confidence] || 0;
1112
+ if (newConf > existingConf) {
1113
+ dedupMap.set(key, pkg);
1114
+ upgradedPackages++;
1115
+ } else if (newConf === existingConf) {
1116
+ // Same confidence: keep most recent
1117
+ const existingDate = existing.published || (existing.freshness && existing.freshness.added_at) || '';
1118
+ const newDate = pkg.published || (pkg.freshness && pkg.freshness.added_at) || '';
1119
+ if (newDate > existingDate) {
1120
+ dedupMap.set(key, pkg);
1121
+ upgradedPackages++;
1122
+ }
1123
+ }
1124
+ }
1125
+ }
1126
+
1127
+ // Rebuild packages array from dedup map
1128
+ existingIOCs.packages = [...dedupMap.values()];
1129
+
1130
+ // PyPI deduplication (same logic, separate array)
1131
+ const pypiDedupMap = new Map();
1132
+ for (const pkg of existingIOCs.pypi_packages) {
1133
+ const key = pkg.name + '@' + pkg.version;
1134
+ pypiDedupMap.set(key, pkg);
1135
+ }
1136
+ let addedPyPIPackages = 0;
1137
+ for (const pkg of pypiPackages) {
1138
+ const key = pkg.name + '@' + pkg.version;
1139
+ if (!pypiDedupMap.has(key)) {
1140
+ pypiDedupMap.set(key, pkg);
1141
+ addedPyPIPackages++;
1142
+ } else {
1143
+ const existing = pypiDedupMap.get(key);
1144
+ const existingConf = CONFIDENCE_ORDER[existing.confidence] || 0;
1145
+ const newConf = CONFIDENCE_ORDER[pkg.confidence] || 0;
1146
+ if (newConf > existingConf) {
1147
+ pypiDedupMap.set(key, pkg);
1148
+ }
1149
+ }
1150
+ }
1151
+ existingIOCs.pypi_packages = [...pypiDedupMap.values()];
1152
+
1153
+ // Deduplicate and add new hashes
1154
+ const existingHashes = new Set(existingIOCs.hashes || []);
1155
+ let addedHashes = 0;
1156
+ for (const hash of allHashes) {
1157
+ if (!existingHashes.has(hash)) {
1158
+ existingIOCs.hashes = existingIOCs.hashes || [];
1159
+ existingIOCs.hashes.push(hash);
1160
+ existingHashes.add(hash);
1161
+ addedHashes++;
1162
+ }
1163
+ }
1164
+
1165
+ // Add Shai-Hulud markers if not present
1166
+ if (!existingIOCs.markers || existingIOCs.markers.length === 0) {
1167
+ existingIOCs.markers = [
1168
+ 'setup_bun.js',
1169
+ 'bun_environment.js',
1170
+ 'bun_installer.js',
1171
+ 'environment_source.js',
1172
+ 'cloud.json',
1173
+ 'contents.json',
1174
+ 'environment.json',
1175
+ 'truffleSecrets.json',
1176
+ 'actionsSecrets.json',
1177
+ 'trufflehog_output.json',
1178
+ '3nvir0nm3nt.json',
1179
+ 'cl0vd.json',
1180
+ 'c9nt3nts.json',
1181
+ 'pigS3cr3ts.json'
1182
+ ];
1183
+ }
1184
+
1185
+ // Update metadata
1186
+ existingIOCs.updated = new Date().toISOString();
1187
+ existingIOCs.sources = [
1188
+ 'osv-malicious',
1189
+ 'osv-malicious-pypi',
1190
+ 'ossf-malicious',
1191
+ 'shai-hulud-detector',
1192
+ 'datadog-consolidated',
1193
+ 'datadog-direct',
1194
+ 'github-advisory',
1195
+ 'socket-dev',
1196
+ 'phylum',
1197
+ 'npm-removed',
1198
+ 'snyk-known'
1199
+ ];
1200
+
1201
+ // Save enriched (full) IOCs — atomic write via .tmp + rename
1202
+ const saveSpinner = new Spinner();
1203
+ saveSpinner.start('Saving IOCs...');
1204
+ const tmpIOCFile = IOC_FILE + '.tmp';
1205
+ fs.writeFileSync(tmpIOCFile, JSON.stringify(existingIOCs, null, 2));
1206
+ fs.renameSync(tmpIOCFile, IOC_FILE);
1207
+
1208
+ // Save compact IOCs (lightweight, shipped in npm) — atomic write
1209
+ saveSpinner.update('Generating compact IOCs...');
1210
+ const compactIOCs = generateCompactIOCs(existingIOCs);
1211
+ const tmpCompactFile = COMPACT_IOC_FILE + '.tmp';
1212
+ fs.writeFileSync(tmpCompactFile, JSON.stringify(compactIOCs));
1213
+ fs.renameSync(tmpCompactFile, COMPACT_IOC_FILE);
1214
+
1215
+ // Persist to ~/.muaddib/data/ (survives npm update)
1216
+ saveSpinner.update('Persisting to home directory...');
1217
+ const homeDir = path.dirname(HOME_IOC_FILE);
1218
+ if (!fs.existsSync(homeDir)) {
1219
+ fs.mkdirSync(homeDir, { recursive: true });
1220
+ }
1221
+ try {
1222
+ const tmpHomeFile = HOME_IOC_FILE + '.tmp';
1223
+ fs.writeFileSync(tmpHomeFile, JSON.stringify(existingIOCs, null, 2));
1224
+ fs.renameSync(tmpHomeFile, HOME_IOC_FILE);
1225
+ saveSpinner.succeed('Saved IOCs + compact format + home directory');
1226
+ } catch (e) {
1227
+ saveSpinner.succeed('Saved IOCs + compact format (home dir write failed: ' + e.message + ')');
1228
+ }
1229
+
1230
+ // Display summary
1231
+ console.log('\n' + '='.repeat(60));
1232
+ console.log(' RESULTS');
1233
+ console.log('='.repeat(60));
1234
+ console.log(' npm packages before: ' + initialCount);
1235
+ console.log(' npm packages after: ' + existingIOCs.packages.length);
1236
+ console.log(' New npm: +' + addedPackages);
1237
+ console.log(' Upgraded: ' + upgradedPackages);
1238
+ console.log(' PyPI packages before: ' + initialPyPICount);
1239
+ console.log(' PyPI packages after: ' + existingIOCs.pypi_packages.length);
1240
+ console.log(' New PyPI: +' + addedPyPIPackages);
1241
+ console.log(' Hashes before: ' + initialHashCount);
1242
+ console.log(' Hashes after: ' + (existingIOCs.hashes ? existingIOCs.hashes.length : 0));
1243
+ console.log(' New hashes: +' + addedHashes);
1244
+ console.log(' File: ' + IOC_FILE);
1245
+
1246
+ // Stats by source (npm + PyPI combined)
1247
+ console.log('\n Distribution by source:');
1248
+ const sourceCounts = {};
1249
+ for (const pkg of existingIOCs.packages) {
1250
+ sourceCounts[pkg.source] = (sourceCounts[pkg.source] || 0) + 1;
1251
+ }
1252
+ for (const pkg of existingIOCs.pypi_packages) {
1253
+ sourceCounts[pkg.source] = (sourceCounts[pkg.source] || 0) + 1;
1254
+ }
1255
+ const sortedSources = Object.entries(sourceCounts).sort(function(a, b) { return b[1] - a[1]; });
1256
+ for (const [source, count] of sortedSources) {
1257
+ console.log(' - ' + source + ': ' + count);
1258
+ }
1259
+
1260
+ // Target check
1261
+ const total = existingIOCs.packages.length;
1262
+ if (total >= 5000) {
1263
+ console.log('\n [OK] Target reached: ' + total + ' IOCs (>= 5000)');
1264
+ } else {
1265
+ console.log('\n [WARN] Target NOT reached: ' + total + ' IOCs (< 5000)');
1266
+ }
1267
+
1268
+ console.log('\n');
1269
+
1270
+ return {
1271
+ added: addedPackages,
1272
+ total: existingIOCs.packages.length,
1273
+ upgraded: upgradedPackages,
1274
+ addedHashes: addedHashes,
1275
+ totalHashes: existingIOCs.hashes ? existingIOCs.hashes.length : 0,
1276
+ addedPyPI: addedPyPIPackages,
1277
+ totalPyPI: existingIOCs.pypi_packages.length
1278
+ };
1279
+ }
1280
+
1281
+ module.exports = { runScraper, scrapeShaiHuludDetector, scrapeDatadogIOCs };
1282
+
1283
+ // Direct execution if called as CLI
1284
+ if (require.main === module) {
1285
+ runScraper()
1286
+ .then(function(result) {
1287
+ console.log('[OK] ' + result.added + ' new IOCs (total: ' + result.total + ')');
1288
+ process.exit(0);
1289
+ })
1290
+ .catch(function(err) {
1291
+ console.error('[ERROR] ' + err.message);
1292
+ process.exit(1);
1293
+ });
1294
1294
  }