muaddib-scanner 2.10.37 → 2.10.39

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/bin/muaddib.js CHANGED
@@ -260,10 +260,12 @@ if (command === 'version' || command === '--version' || command === '-v') {
260
260
  configPath: configPath,
261
261
  autoSandbox: autoSandbox
262
262
  }).then(exitCode => {
263
- process.exit(exitCode);
263
+ // Use process.exitCode instead of process.exit() to let pending async work
264
+ // (the non-blocking version update check) complete before the process exits.
265
+ process.exitCode = exitCode;
264
266
  }).catch(err => {
265
267
  console.error('[ERROR]', err.message);
266
- process.exit(1);
268
+ process.exitCode = 1;
267
269
  });
268
270
  } else if (command === 'feed') {
269
271
  if (wantHelp) showHelp('feed');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.10.37",
3
+ "version": "2.10.39",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,548 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * MUAD'DIB OpenSSF Benchmark
6
+ *
7
+ * Fetches the OpenSSF malicious-packages dataset (via OSV.dev API),
8
+ * downloads available npm packages, scans them with MUAD'DIB, and
9
+ * produces a benchmark results file consumed by `muaddib evaluate`.
10
+ *
11
+ * Usage:
12
+ * node scripts/ossf-benchmark.js [--sample N] [--seed N] [--refresh]
13
+ *
14
+ * Options:
15
+ * --sample N Number of packages to sample (default: 500)
16
+ * --seed N Random seed for reproducibility (default: 42)
17
+ * --refresh Force re-download of cached tarballs
18
+ *
19
+ * Output:
20
+ * datasets/real-world/ossf-benchmark-results.json
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const zlib = require('zlib');
26
+ const { execSync } = require('child_process');
27
+
28
+ const ROOT = path.join(__dirname, '..');
29
+ const RESULTS_FILE = path.join(ROOT, 'datasets', 'real-world', 'ossf-benchmark-results.json');
30
+ const CACHE_DIR = path.join(ROOT, '.muaddib-cache', 'ossf-tarballs');
31
+ const PACK_TIMEOUT_MS = 30000;
32
+ const SCAN_TIMEOUT_MS = 30000;
33
+ const SAFE_PKG_RE = /^(@[\w._-]+\/)?[\w._-]+$/;
34
+
35
+ // --- CLI args ---
36
+ const SAMPLE_SIZE = parseInt(process.argv.find((a, i) => process.argv[i - 1] === '--sample') || '500', 10);
37
+ const SEED = parseInt(process.argv.find((a, i) => process.argv[i - 1] === '--seed') || '42', 10);
38
+ const REFRESH = process.argv.includes('--refresh');
39
+
40
+ // --- Seeded PRNG (Mulberry32) ---
41
+ function mulberry32(seed) {
42
+ let s = seed | 0;
43
+ return function() {
44
+ s = (s + 0x6D2B79F5) | 0;
45
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
46
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
47
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
48
+ };
49
+ }
50
+
51
+ // --- Native tgz extraction (same as evaluate.js) ---
52
+ function extractTgz(tgzPath, destDir) {
53
+ const compressed = fs.readFileSync(tgzPath);
54
+ const tarData = zlib.gunzipSync(compressed);
55
+
56
+ let offset = 0;
57
+ while (offset + 512 <= tarData.length) {
58
+ const header = tarData.subarray(offset, offset + 512);
59
+ if (header.every(b => b === 0)) break;
60
+
61
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
62
+ const sizeOctal = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
63
+ const size = parseInt(sizeOctal, 8) || 0;
64
+ const typeFlag = String.fromCharCode(header[156]);
65
+
66
+ offset += 512;
67
+
68
+ if (name && (typeFlag === '0' || typeFlag === '\0') && size > 0) {
69
+ const resolved = path.resolve(destDir, name);
70
+ const rel = path.relative(path.resolve(destDir), resolved);
71
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
72
+ offset += Math.ceil(size / 512) * 512;
73
+ continue;
74
+ }
75
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
76
+ fs.writeFileSync(resolved, tarData.subarray(offset, offset + size));
77
+ }
78
+
79
+ offset += Math.ceil(size / 512) * 512;
80
+ }
81
+ }
82
+
83
+ function pkgToCacheName(name, version) {
84
+ return (name + '@' + version).replace(/\//g, '_').replace(/@/g, '_');
85
+ }
86
+
87
+ // --- Step 1: Fetch OSV npm MAL-* index via zip dump ---
88
+ async function fetchOSSFIndex() {
89
+ console.log('\n[1/5] Fetching OSV npm malware index...');
90
+
91
+ // Use the OSV zip dump (same as scrapeOSVDataDump) for the full index
92
+ // This is more complete than the query API which has pagination limits
93
+ const https = require('https');
94
+ const AdmZip = require('adm-zip');
95
+
96
+ const zipUrl = 'https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip';
97
+
98
+ console.log(' Downloading npm OSV zip...');
99
+ const zipBuffer = await new Promise(function(resolve, reject) {
100
+ const chunks = [];
101
+ let totalBytes = 0;
102
+
103
+ https.get(zipUrl, { headers: { 'User-Agent': 'MUADDIB-Scanner/3.0' } }, function(res) {
104
+ if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
105
+ https.get(res.headers.location, { headers: { 'User-Agent': 'MUADDIB-Scanner/3.0' } }, function(res2) {
106
+ res2.on('data', function(chunk) {
107
+ chunks.push(chunk);
108
+ totalBytes += chunk.length;
109
+ if (totalBytes % (10 * 1024 * 1024) < chunk.length) {
110
+ process.stdout.write('\r Downloaded: ' + (totalBytes / 1024 / 1024).toFixed(1) + ' MB');
111
+ }
112
+ });
113
+ res2.on('end', function() {
114
+ process.stdout.write('\r Downloaded: ' + (totalBytes / 1024 / 1024).toFixed(1) + ' MB\n');
115
+ resolve(Buffer.concat(chunks));
116
+ });
117
+ res2.on('error', reject);
118
+ }).on('error', reject);
119
+ return;
120
+ }
121
+
122
+ res.on('data', function(chunk) {
123
+ chunks.push(chunk);
124
+ totalBytes += chunk.length;
125
+ if (totalBytes % (10 * 1024 * 1024) < chunk.length) {
126
+ process.stdout.write('\r Downloaded: ' + (totalBytes / 1024 / 1024).toFixed(1) + ' MB');
127
+ }
128
+ });
129
+ res.on('end', function() {
130
+ process.stdout.write('\r Downloaded: ' + (totalBytes / 1024 / 1024).toFixed(1) + ' MB\n');
131
+ resolve(Buffer.concat(chunks));
132
+ });
133
+ res.on('error', reject);
134
+ }).on('error', reject);
135
+ });
136
+
137
+ console.log(' Parsing MAL-* entries...');
138
+ const zip = new AdmZip(zipBuffer);
139
+ const entries = zip.getEntries();
140
+
141
+ // Deduplicate by name@version, keep first MAL-* ID encountered
142
+ const dedupMap = new Map(); // key: "name@version" -> entry
143
+ let malCount = 0;
144
+
145
+ for (const entry of entries) {
146
+ const entryName = entry.entryName;
147
+ if (!entryName.startsWith('MAL-') || !entryName.endsWith('.json')) continue;
148
+
149
+ // Size guard
150
+ const entrySize = entry.header ? entry.header.size : 0;
151
+ if (entrySize > 10 * 1024 * 1024) continue; // skip >10MB entries
152
+
153
+ try {
154
+ const vuln = JSON.parse(entry.getData().toString('utf8'));
155
+ if (!vuln.affected) continue;
156
+
157
+ for (const affected of vuln.affected) {
158
+ if (!affected.package || affected.package.ecosystem !== 'npm') continue;
159
+
160
+ const pkgName = affected.package.name;
161
+ const versions = [];
162
+
163
+ // Extract versions from ranges or explicit list
164
+ if (affected.versions && affected.versions.length > 0) {
165
+ for (const v of affected.versions) versions.push(v);
166
+ } else if (affected.ranges) {
167
+ for (const range of affected.ranges) {
168
+ if (range.events) {
169
+ for (const evt of range.events) {
170
+ if (evt.introduced && evt.introduced !== '0') versions.push(evt.introduced);
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ // If no specific version, use wildcard marker
177
+ if (versions.length === 0) versions.push('*');
178
+
179
+ // Determine source from database_specific
180
+ let source = 'unknown';
181
+ if (vuln.database_specific && vuln.database_specific['malicious-packages-origins']) {
182
+ const origins = vuln.database_specific['malicious-packages-origins'];
183
+ if (origins.length > 0 && origins[0].source) {
184
+ source = origins[0].source;
185
+ }
186
+ }
187
+
188
+ for (const ver of versions) {
189
+ const key = pkgName + '@' + ver;
190
+ if (!dedupMap.has(key)) {
191
+ dedupMap.set(key, {
192
+ name: pkgName,
193
+ version: ver,
194
+ osv_id: vuln.id,
195
+ source: source,
196
+ summary: (vuln.summary || '').slice(0, 200),
197
+ published: vuln.published || null
198
+ });
199
+ }
200
+ }
201
+ }
202
+
203
+ malCount++;
204
+ } catch { /* skip unparseable */ }
205
+ }
206
+
207
+ const index = Array.from(dedupMap.values());
208
+ console.log(' Parsed ' + malCount + ' MAL-* reports -> ' + index.length + ' unique name@version entries');
209
+
210
+ return index;
211
+ }
212
+
213
+ // --- Step 2: Stratified sampling ---
214
+ function stratifySample(index, sampleSize, seed) {
215
+ console.log('\n[2/5] Stratified sampling (' + sampleSize + ' packages, seed=' + seed + ')...');
216
+
217
+ const rng = mulberry32(seed);
218
+
219
+ // Filter out wildcard versions (can't download *)
220
+ const downloadable = index.filter(e => e.version !== '*' && SAFE_PKG_RE.test(e.name));
221
+ console.log(' Downloadable (non-wildcard, valid name): ' + downloadable.length);
222
+
223
+ // Group by source
224
+ const bySource = {};
225
+ for (const entry of downloadable) {
226
+ const src = entry.source || 'unknown';
227
+ if (!bySource[src]) bySource[src] = [];
228
+ bySource[src].push(entry);
229
+ }
230
+
231
+ console.log(' Sources: ' + Object.entries(bySource).map(([k, v]) => k + '=' + v.length).join(', '));
232
+
233
+ // Shuffle each source group with seeded RNG
234
+ for (const src of Object.keys(bySource)) {
235
+ const arr = bySource[src];
236
+ for (let i = arr.length - 1; i > 0; i--) {
237
+ const j = Math.floor(rng() * (i + 1));
238
+ const tmp = arr[i];
239
+ arr[i] = arr[j];
240
+ arr[j] = tmp;
241
+ }
242
+ }
243
+
244
+ // Proportional allocation per source
245
+ const sources = Object.keys(bySource);
246
+ const totalDownloadable = downloadable.length;
247
+ const sample = [];
248
+
249
+ for (const src of sources) {
250
+ const proportion = bySource[src].length / totalDownloadable;
251
+ const count = Math.max(1, Math.round(proportion * sampleSize));
252
+ const take = bySource[src].slice(0, count);
253
+ sample.push(...take);
254
+ }
255
+
256
+ // Trim to exact sample size
257
+ while (sample.length > sampleSize) sample.pop();
258
+
259
+ console.log(' Sampled: ' + sample.length + ' packages');
260
+ return sample;
261
+ }
262
+
263
+ // --- Step 3: Check npm availability ---
264
+ async function checkAvailability(sample) {
265
+ console.log('\n[3/5] Checking npm availability...');
266
+
267
+ let available = 0;
268
+ let unavailable = 0;
269
+ let errors = 0;
270
+
271
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
272
+
273
+ for (let i = 0; i < sample.length; i++) {
274
+ const entry = sample[i];
275
+
276
+ if (process.stdout.isTTY) {
277
+ process.stdout.write('\r Checking [' + (i + 1) + '/' + sample.length + '] ' + entry.name + '@' + entry.version + ' ');
278
+ }
279
+
280
+ try {
281
+ execSync(npmCmd + ' view ' + entry.name + '@' + entry.version + ' version --json', {
282
+ encoding: 'utf8',
283
+ timeout: 10000,
284
+ stdio: ['pipe', 'pipe', 'pipe']
285
+ });
286
+ entry.status = 'available';
287
+ available++;
288
+ } catch (err) {
289
+ const stderr = (err.stderr || '').toLowerCase();
290
+ if (stderr.includes('404') || stderr.includes('not found') || stderr.includes('not in this registry')) {
291
+ entry.status = 'unavailable';
292
+ unavailable++;
293
+ } else {
294
+ entry.status = 'error';
295
+ entry.error = (err.message || '').slice(0, 100);
296
+ errors++;
297
+ }
298
+ }
299
+ }
300
+
301
+ if (process.stdout.isTTY) {
302
+ process.stdout.write('\r' + ''.padEnd(80) + '\r');
303
+ }
304
+
305
+ console.log(' Available: ' + available + ', Unavailable: ' + unavailable + ', Errors: ' + errors);
306
+ return sample;
307
+ }
308
+
309
+ // --- Step 4: Download and scan ---
310
+ async function downloadAndScan(sample) {
311
+ console.log('\n[4/5] Downloading and scanning available packages...');
312
+
313
+ const { run } = require('../src/index.js');
314
+ const { clearFileListCache } = require('../src/utils.js');
315
+
316
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
317
+
318
+ const scannable = sample.filter(e => e.status === 'available');
319
+ console.log(' Scannable: ' + scannable.length + ' packages');
320
+
321
+ let scanned = 0;
322
+ let detected = 0;
323
+ let scanErrors = 0;
324
+ let scanCount = 0;
325
+
326
+ for (let i = 0; i < scannable.length; i++) {
327
+ const entry = scannable[i];
328
+ const progress = '[' + (i + 1) + '/' + scannable.length + ']';
329
+
330
+ if (process.stdout.isTTY) {
331
+ process.stdout.write('\r Scanning ' + progress + ' ' + entry.name + '@' + entry.version + ' ');
332
+ }
333
+
334
+ // Download
335
+ const cacheName = pkgToCacheName(entry.name, entry.version);
336
+ const pkgCacheDir = path.join(CACHE_DIR, cacheName);
337
+ let extractedDir = null;
338
+
339
+ if (!REFRESH && fs.existsSync(path.join(pkgCacheDir, 'package'))) {
340
+ extractedDir = path.join(pkgCacheDir, 'package');
341
+ } else {
342
+ fs.mkdirSync(pkgCacheDir, { recursive: true });
343
+ try {
344
+ const output = execSync('npm pack ' + entry.name + '@' + entry.version, {
345
+ cwd: pkgCacheDir,
346
+ encoding: 'utf8',
347
+ timeout: PACK_TIMEOUT_MS,
348
+ stdio: ['pipe', 'pipe', 'pipe']
349
+ });
350
+ const tgzFilename = output.trim().split(/\r?\n/).pop().trim();
351
+ const tgzPath = path.join(pkgCacheDir, tgzFilename);
352
+
353
+ if (fs.existsSync(tgzPath)) {
354
+ extractTgz(tgzPath, pkgCacheDir);
355
+ try { fs.unlinkSync(tgzPath); } catch { /* ignore */ }
356
+ if (fs.existsSync(path.join(pkgCacheDir, 'package'))) {
357
+ extractedDir = path.join(pkgCacheDir, 'package');
358
+ }
359
+ }
360
+ } catch {
361
+ // Download failed — mark as unavailable (possibly removed between check and download)
362
+ entry.status = 'unavailable';
363
+ entry.error = 'npm pack failed';
364
+ fs.rmSync(pkgCacheDir, { recursive: true, force: true });
365
+ }
366
+ }
367
+
368
+ if (!extractedDir) {
369
+ if (entry.status === 'available') {
370
+ entry.status = 'error';
371
+ entry.error = 'extraction failed';
372
+ scanErrors++;
373
+ }
374
+ continue;
375
+ }
376
+
377
+ // Scan
378
+ try {
379
+ const result = await Promise.race([
380
+ run(extractedDir, { _capture: true }),
381
+ new Promise(function(_, reject) {
382
+ setTimeout(function() { reject(new Error('scan timeout')); }, SCAN_TIMEOUT_MS);
383
+ })
384
+ ]);
385
+
386
+ const score = result.summary.riskScore || 0;
387
+ entry.score = score;
388
+ entry.detected = score >= 20;
389
+ entry.threat_count = result.summary.total || 0;
390
+ entry.threats = (result.threats || []).slice(0, 20).map(function(t) {
391
+ return { type: t.type, severity: t.severity, file: t.file };
392
+ });
393
+ entry.status = 'scanned';
394
+
395
+ scanned++;
396
+ if (entry.detected) detected++;
397
+ } catch (err) {
398
+ entry.status = 'error';
399
+ entry.error = (err.message || '').slice(0, 100);
400
+ entry.score = 0;
401
+ entry.detected = false;
402
+ scanErrors++;
403
+ }
404
+
405
+ // Memory management
406
+ clearFileListCache();
407
+ scanCount++;
408
+ if (scanCount % 20 === 0 && global.gc) {
409
+ global.gc();
410
+ const used = Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
411
+ console.log('\n [Memory] ' + used + ' MB after ' + scanCount + ' scans');
412
+ }
413
+ }
414
+
415
+ if (process.stdout.isTTY) {
416
+ process.stdout.write('\r' + ''.padEnd(80) + '\r');
417
+ }
418
+
419
+ console.log(' Scanned: ' + scanned + ', Detected: ' + detected + ', Errors: ' + scanErrors);
420
+ return { scanned, detected, scanErrors };
421
+ }
422
+
423
+ // --- Step 5: Save results ---
424
+ function saveResults(sample, index, stats) {
425
+ console.log('\n[5/5] Saving results...');
426
+
427
+ // Compute per-source breakdown
428
+ const bySource = {};
429
+ for (const entry of sample) {
430
+ const src = entry.source || 'unknown';
431
+ if (!bySource[src]) bySource[src] = { total: 0, scanned: 0, detected: 0, unavailable: 0 };
432
+ bySource[src].total++;
433
+ if (entry.status === 'scanned') {
434
+ bySource[src].scanned++;
435
+ if (entry.detected) bySource[src].detected++;
436
+ } else if (entry.status === 'unavailable') {
437
+ bySource[src].unavailable++;
438
+ }
439
+ }
440
+
441
+ // Compute TPR per source
442
+ for (const src of Object.keys(bySource)) {
443
+ const d = bySource[src];
444
+ d.tpr = d.scanned > 0 ? d.detected / d.scanned : 0;
445
+ }
446
+
447
+ // Score distribution
448
+ const scannedEntries = sample.filter(e => e.status === 'scanned');
449
+ const scoreDistribution = { '0': 0, '1-9': 0, '10-19': 0, '20-49': 0, '50-74': 0, '75-100': 0 };
450
+ for (const e of scannedEntries) {
451
+ const s = e.score || 0;
452
+ if (s === 0) scoreDistribution['0']++;
453
+ else if (s <= 9) scoreDistribution['1-9']++;
454
+ else if (s <= 19) scoreDistribution['10-19']++;
455
+ else if (s <= 49) scoreDistribution['20-49']++;
456
+ else if (s <= 74) scoreDistribution['50-74']++;
457
+ else scoreDistribution['75-100']++;
458
+ }
459
+
460
+ const results = {
461
+ metadata: {
462
+ benchmark: 'OpenSSF Malicious Packages',
463
+ version: 'v1',
464
+ repo: 'https://github.com/ossf/malicious-packages',
465
+ scanned_at: new Date().toISOString(),
466
+ seed: SEED,
467
+ total_osv_npm_unique: index.length,
468
+ sampled: sample.length,
469
+ available_on_npm: sample.filter(e => e.status === 'scanned' || e.status === 'available').length,
470
+ scanned: stats.scanned,
471
+ detected: stats.detected,
472
+ missed: stats.scanned - stats.detected,
473
+ errors: stats.scanErrors,
474
+ unavailable: sample.filter(e => e.status === 'unavailable').length,
475
+ threshold: 20,
476
+ tpr: stats.scanned > 0 ? ((stats.detected / stats.scanned * 100).toFixed(1) + '%') : 'N/A',
477
+ coverage: ((stats.scanned / sample.length * 100).toFixed(1) + '%'),
478
+ by_source: bySource,
479
+ score_distribution: scoreDistribution
480
+ },
481
+ results: sample.map(function(e) {
482
+ return {
483
+ name: e.name,
484
+ version: e.version,
485
+ osv_id: e.osv_id,
486
+ source: e.source,
487
+ status: e.status,
488
+ score: e.score || 0,
489
+ detected: e.detected || false,
490
+ threat_count: e.threat_count || 0,
491
+ threats: e.threats || [],
492
+ error: e.error || undefined
493
+ };
494
+ })
495
+ };
496
+
497
+ fs.mkdirSync(path.dirname(RESULTS_FILE), { recursive: true });
498
+ fs.writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2));
499
+ console.log(' Saved to: ' + RESULTS_FILE);
500
+
501
+ return results;
502
+ }
503
+
504
+ // --- Main ---
505
+ async function main() {
506
+ const startTime = Date.now();
507
+
508
+ console.log('='.repeat(60));
509
+ console.log(' MUAD\'DIB OpenSSF Benchmark');
510
+ console.log(' Sample: ' + SAMPLE_SIZE + ', Seed: ' + SEED);
511
+ console.log('='.repeat(60));
512
+
513
+ // 1. Fetch full OSV npm index
514
+ const index = await fetchOSSFIndex();
515
+
516
+ // 2. Stratified sample
517
+ const sample = stratifySample(index, SAMPLE_SIZE, SEED);
518
+
519
+ // 3. Check npm availability
520
+ await checkAvailability(sample);
521
+
522
+ // 4. Download + scan
523
+ const stats = await downloadAndScan(sample);
524
+
525
+ // 5. Save results
526
+ const results = saveResults(sample, index, stats);
527
+
528
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
529
+
530
+ console.log('\n' + '='.repeat(60));
531
+ console.log(' RESULTS');
532
+ console.log('='.repeat(60));
533
+ console.log(' Total OSV npm unique: ' + index.length);
534
+ console.log(' Sampled: ' + sample.length);
535
+ console.log(' Available on npm: ' + results.metadata.available_on_npm);
536
+ console.log(' Scanned: ' + stats.scanned);
537
+ console.log(' Detected (score>=20): ' + stats.detected);
538
+ console.log(' TPR: ' + results.metadata.tpr);
539
+ console.log(' Coverage: ' + results.metadata.coverage);
540
+ console.log(' Elapsed: ' + elapsed + 's');
541
+ console.log('='.repeat(60) + '\n');
542
+ }
543
+
544
+ main().catch(function(err) {
545
+ console.error('[FATAL] ' + err.message);
546
+ console.error(err.stack);
547
+ process.exit(1);
548
+ });
@@ -132,7 +132,7 @@ async function detectPublishAnomaly(packageName) {
132
132
  const spanHours = Math.round(spanMs / MS_PER_HOUR * 10) / 10;
133
133
  findings.push({
134
134
  type: 'publish_burst',
135
- severity: 'HIGH',
135
+ severity: 'LOW',
136
136
  description: `${inWindow.length} versions published in ${spanHours} hours (avg interval: ${stats.avgIntervalDays} days)`,
137
137
  versions: inWindow.map(e => e.version)
138
138
  });
@@ -1283,12 +1283,99 @@ async function runScraper() {
1283
1283
  };
1284
1284
  }
1285
1285
 
1286
+ // ============================================
1287
+ // SOURCE 5: OSV.dev Lightweight API
1288
+ // Used by `muaddib update` (fast, no zip download)
1289
+ // ============================================
1290
+
1291
+ /**
1292
+ * Lightweight OSV.dev query — fetches recent npm MAL-* entries via REST API.
1293
+ * Used by `muaddib update` as a fast complement to the full zip scrape.
1294
+ * @returns {Promise<Array>} Parsed IOC package entries
1295
+ */
1296
+ async function scrapeOSVLightweightAPI() {
1297
+ console.log('[SCRAPER] OSV.dev lightweight API...');
1298
+ const packages = [];
1299
+
1300
+ try {
1301
+ const resp = await fetchJSON('https://api.osv.dev/v1/query', {
1302
+ method: 'POST',
1303
+ headers: { 'Content-Type': 'application/json' },
1304
+ body: { package: { ecosystem: 'npm' } }
1305
+ });
1306
+
1307
+ if (resp.status === 200 && resp.data && resp.data.vulns) {
1308
+ for (const vuln of resp.data.vulns) {
1309
+ if (vuln.id && vuln.id.startsWith('MAL-')) {
1310
+ const parsed = parseOSVEntry(vuln, 'osv-api');
1311
+ for (const p of parsed) packages.push(p);
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
+ console.log('[SCRAPER] ' + packages.length + ' MAL-* packages from OSV API');
1317
+ } catch (e) {
1318
+ console.log('[SCRAPER] OSV API error: ' + e.message);
1319
+ }
1320
+
1321
+ return packages;
1322
+ }
1323
+
1324
+ /**
1325
+ * Batch query OSV.dev for specific package names.
1326
+ * Returns all MAL-* entries matching the given packages.
1327
+ * Used by the OpenSSF benchmark script (W1).
1328
+ * @param {string[]} packageNames - npm package names to query
1329
+ * @returns {Promise<Array>} Parsed IOC entries with osv_id
1330
+ */
1331
+ async function queryOSVBatch(packageNames) {
1332
+ const BATCH_SIZE = 1000;
1333
+ const allResults = [];
1334
+
1335
+ for (let i = 0; i < packageNames.length; i += BATCH_SIZE) {
1336
+ const batch = packageNames.slice(i, i + BATCH_SIZE);
1337
+ const queries = batch.map(function(name) {
1338
+ return { package: { name: name, ecosystem: 'npm' } };
1339
+ });
1340
+
1341
+ try {
1342
+ const resp = await fetchJSON('https://api.osv.dev/v1/querybatch', {
1343
+ method: 'POST',
1344
+ headers: { 'Content-Type': 'application/json' },
1345
+ body: { queries: queries }
1346
+ });
1347
+
1348
+ if (resp.status === 200 && resp.data && resp.data.results) {
1349
+ for (let j = 0; j < resp.data.results.length; j++) {
1350
+ const vulns = resp.data.results[j].vulns || [];
1351
+ for (const vuln of vulns) {
1352
+ if (vuln.id && vuln.id.startsWith('MAL-')) {
1353
+ const parsed = parseOSVEntry(vuln, 'osv-batch');
1354
+ for (const p of parsed) allResults.push(p);
1355
+ }
1356
+ }
1357
+ }
1358
+ }
1359
+ } catch (e) {
1360
+ console.log('[SCRAPER] OSV batch error (offset ' + i + '): ' + e.message);
1361
+ }
1362
+
1363
+ // Courtesy delay between batches
1364
+ if (i + BATCH_SIZE < packageNames.length) {
1365
+ await new Promise(function(r) { setTimeout(r, 200); });
1366
+ }
1367
+ }
1368
+
1369
+ return allResults;
1370
+ }
1371
+
1286
1372
  // Test helpers for aggregated warning counters
1287
1373
  function getNoVersionSkipCount() { return _noVersionSkipCount; }
1288
1374
  function resetNoVersionSkipCount() { _noVersionSkipCount = 0; }
1289
1375
 
1290
1376
  module.exports = {
1291
1377
  runScraper, scrapeShaiHuludDetector, scrapeDatadogIOCs,
1378
+ scrapeOSVLightweightAPI, queryOSVBatch,
1292
1379
  // Pure utility functions (exported for testing)
1293
1380
  parseCSVLine, parseCSV, extractVersions, parseOSVEntry,
1294
1381
  createFreshness, isAllowedRedirect,
@@ -39,24 +39,25 @@ async function updateIOCs() {
39
39
  mergeIOCs(baseIOCs, yamlStandard);
40
40
  console.log('[2/4] YAML IOCs: ' + yamlStandard.packages.length + ' packages, ' + yamlStandard.hashes.length + ' hashes');
41
41
 
42
- // Step 3: Download additional IOCs from GitHub (GenSecAI + DataDog small files, fast)
43
- const { scrapeShaiHuludDetector, scrapeDatadogIOCs } = require('./scraper.js');
44
- console.log('[3/4] Downloading GitHub IOCs...');
42
+ // Step 3: Download additional IOCs from GitHub + OSV API (GenSecAI + DataDog + OSV lightweight)
43
+ const { scrapeShaiHuludDetector, scrapeDatadogIOCs, scrapeOSVLightweightAPI } = require('./scraper.js');
44
+ console.log('[3/4] Downloading GitHub + OSV API IOCs...');
45
45
 
46
- const [shaiHulud, datadog] = await Promise.all([
46
+ const [shaiHulud, datadog, osvApi] = await Promise.all([
47
47
  scrapeShaiHuludDetector(),
48
- scrapeDatadogIOCs()
48
+ scrapeDatadogIOCs(),
49
+ scrapeOSVLightweightAPI()
49
50
  ]);
50
51
 
51
52
  const githubIOCs = {
52
- packages: [].concat(shaiHulud.packages, datadog.packages),
53
+ packages: [].concat(shaiHulud.packages, datadog.packages, osvApi),
53
54
  pypi_packages: [],
54
55
  hashes: [].concat(shaiHulud.hashes || [], datadog.hashes || []),
55
56
  markers: [],
56
57
  files: []
57
58
  };
58
59
  mergeIOCs(baseIOCs, githubIOCs);
59
- console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog');
60
+ console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog, +' + osvApi.length + ' OSV API');
60
61
 
61
62
  // Step 3b: Load existing cache IOCs (from bootstrap download or previous update)
62
63
  if (fs.existsSync(CACHE_IOC_FILE)) {
@@ -90,7 +91,7 @@ async function updateIOCs() {
90
91
  }
91
92
 
92
93
  baseIOCs.updated = new Date().toISOString();
93
- baseIOCs.sources = ['compact', 'yaml', 'shai-hulud-detector', 'datadog', 'cache'];
94
+ baseIOCs.sources = ['compact', 'yaml', 'shai-hulud-detector', 'datadog', 'osv-api', 'cache'];
94
95
 
95
96
  // Clean internal dedup sets before serialization
96
97
  delete baseIOCs._pkgKeys;
@@ -92,9 +92,15 @@ function resetDailyCounter() {
92
92
  _dailyCounter.resetDate = null;
93
93
  }
94
94
 
95
+ // ── Credit exhaustion kill switch (session-level) ──
96
+ // When the Anthropic API returns a 400 with "credit balance is too low",
97
+ // we disable the LLM for the rest of the session to avoid error spam.
98
+ let _creditExhausted = false;
99
+
95
100
  // ── Feature flags ──
96
101
 
97
102
  function isLlmEnabled() {
103
+ if (_creditExhausted) return false;
98
104
  if (!process.env.ANTHROPIC_API_KEY) return false;
99
105
  const env = process.env.MUADDIB_LLM_ENABLED;
100
106
  if (env !== undefined && env.toLowerCase() === 'false') return false;
@@ -441,6 +447,14 @@ async function callAnthropicAPI(system, messages) {
441
447
  }
442
448
 
443
449
  const errorText = await response.text().catch(() => '');
450
+
451
+ // Credit exhaustion: disable LLM for entire session (not just this call)
452
+ if (response.status === 400 && /credit balance is too low/i.test(errorText)) {
453
+ _creditExhausted = true;
454
+ console.warn('[LLM] API credits exhausted — LLM Detective disabled for this session');
455
+ throw new Error('API credits exhausted');
456
+ }
457
+
444
458
  throw new Error(`API ${response.status}: ${errorText.slice(0, 200)}`);
445
459
  } catch (err) {
446
460
  clearTimeout(timeout);
@@ -602,6 +616,14 @@ function resetLlmLimiter() {
602
616
  _semaphore.queue.length = 0;
603
617
  }
604
618
 
619
+ function resetCreditExhausted() {
620
+ _creditExhausted = false;
621
+ }
622
+
623
+ function isCreditExhausted() {
624
+ return _creditExhausted;
625
+ }
626
+
605
627
  module.exports = {
606
628
  investigatePackage,
607
629
  isLlmEnabled,
@@ -614,6 +636,8 @@ module.exports = {
614
636
  resetStats,
615
637
  resetDailyCounter,
616
638
  resetLlmLimiter,
639
+ resetCreditExhausted,
640
+ isCreditExhausted,
617
641
  // Exported for testing
618
642
  collectSourceContext,
619
643
  buildPrompt,
@@ -331,6 +331,35 @@ function evaluateCacheTrigger(name, docMeta, doc) {
331
331
  return { shouldCache: false, reason: '', retentionDays: 0 };
332
332
  }
333
333
 
334
+ /**
335
+ * Determine if a first-publish package is high-risk and should be sandboxed
336
+ * even with a clean static scan (0 findings).
337
+ *
338
+ * First-publish is where malware concentrates: new packages from unknown
339
+ * maintainers with no linked repository are the highest-risk population.
340
+ *
341
+ * @param {Object|null} cacheTrigger - From evaluateCacheTrigger()
342
+ * @param {Object|null} npmRegistryMeta - From getPackageMetadata()
343
+ * @returns {boolean} true if package should be sandboxed regardless of static score
344
+ */
345
+ function isFirstPublishHighRisk(cacheTrigger, npmRegistryMeta) {
346
+ if (!cacheTrigger || cacheTrigger.reason !== 'first_publish') return false;
347
+
348
+ // With registry metadata, require at least one additional risk signal
349
+ if (npmRegistryMeta) {
350
+ // No linked repository — high risk
351
+ if (!npmRegistryMeta.has_repository) return true;
352
+ // New maintainer (only 1 package ever published)
353
+ if (npmRegistryMeta.author_package_count <= 1) return true;
354
+ // Package age < 1 day with registry metadata but no strong signals — skip sandbox
355
+ // (has repo + experienced maintainer = likely legitimate)
356
+ return false;
357
+ }
358
+
359
+ // Without registry metadata, sandbox by default (precautionary)
360
+ return true;
361
+ }
362
+
334
363
  module.exports = {
335
364
  // Constants
336
365
  IOC_MATCH_TYPES,
@@ -367,4 +396,5 @@ module.exports = {
367
396
  setVerboseMode,
368
397
  quickTyposquatCheck,
369
398
  evaluateCacheTrigger,
399
+ isFirstPublishHighRisk,
370
400
  };
@@ -54,6 +54,7 @@ const {
54
54
  classifyError,
55
55
  formatFindings,
56
56
  evaluateCacheTrigger,
57
+ isFirstPublishHighRisk,
57
58
  POPULAR_THRESHOLD,
58
59
  downloadsCache: classifyDownloadsCache,
59
60
  DOWNLOADS_CACHE_TTL,
@@ -109,6 +110,11 @@ const SCAN_TIMEOUT_MS = 180_000; // 3 minutes per package
109
110
  const STATIC_SCAN_TIMEOUT_MS = 45_000; // 45s for static analysis only
110
111
  const LARGE_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB
111
112
 
113
+ // First-publish sandbox: max pending sandbox items before deferring first-publish clean scans
114
+ // Prevents starving T1a sandbox capacity when many first-publish packages arrive at once
115
+ const FIRST_PUBLISH_SANDBOX_MAX_QUEUE = parseInt(process.env.MUADDIB_FIRST_PUBLISH_SANDBOX_MAX_QUEUE, 10) || 10;
116
+ const FIRST_PUBLISH_SANDBOX_ENABLED = process.env.MUADDIB_FIRST_PUBLISH_SANDBOX !== '0';
117
+
112
118
  // --- Bundled tooling false-positive filter ---
113
119
 
114
120
  const KNOWN_BUNDLED_FILES = ['yarn.js', 'webpack.js', 'terser.js', 'esbuild.js', 'polyfills.js'];
@@ -401,10 +407,14 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
401
407
  throw staticErr;
402
408
  }
403
409
 
404
- // ML Phase 2a: Fetch npm registry metadata once for packages with findings.
410
+ // First-publish detection: used for sandbox priority below
411
+ const isFirstPublish = cacheTrigger && cacheTrigger.reason === 'first_publish';
412
+
413
+ // ML Phase 2a: Fetch npm registry metadata once for packages with findings
414
+ // OR for first-publish packages (needed for isFirstPublishHighRisk decision).
405
415
  // Reused for both training records (enriched features) and reputation scoring.
406
416
  let npmRegistryMeta = null;
407
- if (result.summary.total > 0 && ecosystem === 'npm') {
417
+ if ((result.summary.total > 0 || isFirstPublish) && ecosystem === 'npm') {
408
418
  try {
409
419
  const { getPackageMetadata } = require('../scanner/npm-registry.js');
410
420
  npmRegistryMeta = await getPackageMetadata(name);
@@ -413,15 +423,61 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
413
423
  }
414
424
  }
415
425
 
426
+ // First-publish sandbox priority: sandbox even with 0 static findings
427
+ // if the package is from a new/unknown maintainer without a linked repository.
428
+ const firstPublishSandbox = isFirstPublish &&
429
+ FIRST_PUBLISH_SANDBOX_ENABLED &&
430
+ isFirstPublishHighRisk(cacheTrigger, npmRegistryMeta) &&
431
+ isSandboxEnabled() && sandboxAvailable &&
432
+ scanQueue.length < FIRST_PUBLISH_SANDBOX_MAX_QUEUE;
433
+
416
434
  if (result.summary.total === 0) {
417
- stats.scanned++;
418
- const elapsed = Date.now() - startTime;
419
- stats.totalTimeMs += elapsed;
420
- stats.clean++;
421
- console.log(`[MONITOR] CLEAN: ${name}@${version} (0 findings, ${(elapsed / 1000).toFixed(1)}s)`);
422
- updateScanStats('clean');
423
- recordTrainingSample(result, { name, version, ecosystem, label: 'clean', registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
424
- return { sandboxResult: null, staticClean: true };
435
+ if (firstPublishSandbox) {
436
+ // First-publish sandbox priority: run sandbox even with 0 static findings
437
+ console.log(`[MONITOR] FIRST-PUBLISH SANDBOX: ${name}@${version} (0 findings, sandboxing anyway)`);
438
+ stats.firstPublishSandboxed = (stats.firstPublishSandboxed || 0) + 1;
439
+
440
+ let sandboxResult = null;
441
+ try {
442
+ const canary = isCanaryEnabled();
443
+ console.log(`[MONITOR] SANDBOX (first-publish): launching for ${name}@${version}${canary ? ' (canary: on)' : ''}...`);
444
+ sandboxResult = await runSandbox(name, { canary });
445
+ console.log(`[MONITOR] SANDBOX: ${name}@${version} → score: ${sandboxResult.score}, severity: ${sandboxResult.severity}`);
446
+ } catch (err) {
447
+ console.error(`[MONITOR] SANDBOX ERROR: ${name}@${version} — ${err.message}`);
448
+ }
449
+
450
+ const sandboxScore = sandboxResult ? (sandboxResult.score || 0) : 0;
451
+ if (sandboxScore > 0) {
452
+ // Sandbox found something — treat as suspect
453
+ stats.suspect++;
454
+ stats.scanned++;
455
+ const elapsed = Date.now() - startTime;
456
+ stats.totalTimeMs += elapsed;
457
+ updateScanStats('suspect');
458
+ recordTrainingSample(result, { name, version, ecosystem, label: 'suspect', registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
459
+ return { sandboxResult, staticClean: false, firstPublishSandbox: true };
460
+ } else {
461
+ // Sandbox clean — still CLEAN
462
+ stats.scanned++;
463
+ const elapsed = Date.now() - startTime;
464
+ stats.totalTimeMs += elapsed;
465
+ stats.clean++;
466
+ console.log(`[MONITOR] CLEAN (first-publish sandbox OK): ${name}@${version} (${(elapsed / 1000).toFixed(1)}s)`);
467
+ updateScanStats('clean');
468
+ recordTrainingSample(result, { name, version, ecosystem, label: 'clean', registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
469
+ return { sandboxResult, staticClean: true, firstPublishSandbox: true };
470
+ }
471
+ } else {
472
+ stats.scanned++;
473
+ const elapsed = Date.now() - startTime;
474
+ stats.totalTimeMs += elapsed;
475
+ stats.clean++;
476
+ console.log(`[MONITOR] CLEAN: ${name}@${version} (0 findings, ${(elapsed / 1000).toFixed(1)}s)`);
477
+ updateScanStats('clean');
478
+ recordTrainingSample(result, { name, version, ecosystem, label: 'clean', registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
479
+ return { sandboxResult: null, staticClean: true };
480
+ }
425
481
  } else {
426
482
  const counts = [];
427
483
  if (result.summary.critical > 0) counts.push(`${result.summary.critical} CRITICAL`);
@@ -1023,6 +1079,8 @@ module.exports = {
1023
1079
  SCAN_TIMEOUT_MS,
1024
1080
  STATIC_SCAN_TIMEOUT_MS,
1025
1081
  LARGE_PACKAGE_SIZE,
1082
+ FIRST_PUBLISH_SANDBOX_MAX_QUEUE,
1083
+ FIRST_PUBLISH_SANDBOX_ENABLED,
1026
1084
  KNOWN_BUNDLED_FILES,
1027
1085
  KNOWN_BUNDLED_PATHS,
1028
1086
  ML_EXCLUDED_DIRS,
package/src/scoring.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const { getRule } = require('./rules/index.js');
2
+ const { HIGH_CONFIDENCE_MALICE_TYPES } = require('./monitor/classify.js');
2
3
 
3
4
  // ============================================
4
5
  // SCORING CONSTANTS
@@ -873,7 +874,22 @@ function calculateRiskScore(deduped, intentResult) {
873
874
  }
874
875
 
875
876
  // 7. Final score = max file score + cross-file bonus + intent bonus + package-level score + lifecycle boost, capped at 100
876
- const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + crossFileBonus + intentBonus + packageScore + lifecycleBoost);
877
+ let riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + crossFileBonus + intentBonus + packageScore + lifecycleBoost);
878
+
879
+ // 7b. MT-1: Score ceiling for packages without lifecycle scripts.
880
+ // 56% of real malware uses install scripts. Packages without lifecycle that score high
881
+ // (minified bundles, frameworks) are quasi-exclusively false positives.
882
+ // Cap at 35 to prevent webhook triggers (threshold ~20-25 post-reputation).
883
+ // Bypass: HC malice types, compound detections — these are never benign regardless of lifecycle.
884
+ const _hasLifecycle = deduped.some(t =>
885
+ t.type === 'lifecycle_script' || t.type === 'lifecycle_file_exec' ||
886
+ t.type === 'lifecycle_shell_pipe' || t.type === 'lifecycle_remote_fetch'
887
+ );
888
+ const _hasHC = deduped.some(t => HIGH_CONFIDENCE_MALICE_TYPES.has(t.type));
889
+ const _hasCompound = deduped.some(t => t.compound === true);
890
+ if (!_hasLifecycle && !_hasHC && !_hasCompound) {
891
+ riskScore = Math.min(riskScore, 35);
892
+ }
877
893
 
878
894
  // 8. Old global score for comparison (sum of ALL findings)
879
895
  const globalRiskScore = computeGroupScore(deduped);