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.
- package/.gitattributes +18 -0
- package/README.fr.md +819 -825
- package/README.md +822 -822
- package/bin/muaddib.js +868 -849
- package/docker/Dockerfile +19 -18
- package/docker/sandbox-runner.sh +313 -292
- package/package.json +61 -61
- package/src/commands/evaluate.js +484 -484
- package/src/diff.js +411 -411
- package/src/hooks-init.js +264 -258
- package/src/index.js +439 -437
- package/src/ioc/scraper.js +1293 -1293
- package/src/monitor.js +1817 -1764
- package/src/sandbox.js +631 -620
- package/src/scanner/ast-detectors.js +946 -933
- package/src/scanner/ast.js +96 -96
- package/src/scanner/github-actions.js +96 -96
- package/src/scanner/module-graph.js +2 -2
- package/src/scanner/obfuscation.js +128 -128
- package/src/scanner/shell.js +48 -48
- package/src/shared/download.js +181 -171
- package/src/webhook.js +413 -353
package/src/ioc/scraper.js
CHANGED
|
@@ -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(
|
|
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
|
}
|