muaddib-scanner 2.11.7 → 2.11.8

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/.env.example ADDED
@@ -0,0 +1,31 @@
1
+ # MUAD'DIB environment variables — template
2
+ # Copy to .env (local dev) or /opt/muaddib/.env (VPS) and fill in real values.
3
+ # .env files are gitignored. NEVER commit a real token.
4
+
5
+ # ----------------------------------------------------------------------------
6
+ # Threat-feed API tokens (all OPTIONAL — scrapers degrade gracefully if absent)
7
+ # ----------------------------------------------------------------------------
8
+
9
+ # OpenSourceMalware.com — community-verified threat intel
10
+ # Free tier: 60 req/min, /query-latest gives 100 most recent threats per ecosystem.
11
+ # Sign up + generate at: https://opensourcemalware.com/auth → profile → API Tokens
12
+ # Format: osm_<random-32+chars>
13
+ # Used by: src/ioc/scraper.js → scrapeOSMQueryLatest()
14
+ OSM_API_TOKEN=
15
+
16
+ # ----------------------------------------------------------------------------
17
+ # Webhook destinations (optional — monitor alerts)
18
+ # ----------------------------------------------------------------------------
19
+
20
+ # Discord webhook for monitor alerts (P1/P2/P3 triage)
21
+ # DISCORD_WEBHOOK_URL=
22
+
23
+ # ----------------------------------------------------------------------------
24
+ # Tuning gates (already documented in deploy/muaddib-monitor.service)
25
+ # ----------------------------------------------------------------------------
26
+
27
+ # MUADDIB_FN_REACHABILITY=1
28
+ # MUADDIB_DECAY=1
29
+ # MUADDIB_MATURE_CAP=1
30
+ # MUADDIB_METADATA_FACTOR=1
31
+ # MUADDIB_DELTA_MODE=1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.7",
3
+ "version": "2.11.8",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -46,7 +46,7 @@
46
46
  "node": ">=18.0.0"
47
47
  },
48
48
  "dependencies": {
49
- "@inquirer/prompts": "8.4.1",
49
+ "@inquirer/prompts": "8.4.2",
50
50
  "acorn": "8.16.0",
51
51
  "acorn-walk": "8.3.5",
52
52
  "adm-zip": "0.5.17",
@@ -57,8 +57,8 @@
57
57
  },
58
58
  "devDependencies": {
59
59
  "@eslint/js": "10.0.1",
60
- "eslint": "10.2.1",
60
+ "eslint": "10.3.0",
61
61
  "eslint-plugin-security": "^4.0.0",
62
- "globals": "17.5.0"
62
+ "globals": "17.6.0"
63
63
  }
64
64
  }
@@ -980,8 +980,8 @@ async function scrapeGitHubAdvisory() {
980
980
  // ============================================
981
981
  async function runScraper() {
982
982
  console.log('\n' + '='.repeat(60));
983
- console.log(' MUAD\'DIB IOC Scraper v4.0');
984
- console.log(' OSV + OSSF + GenSecAI + DataDog + Snyk');
983
+ console.log(' MUAD\'DIB IOC Scraper v4.1');
984
+ console.log(' OSV + OSSF + GenSecAI + DataDog + Aikido + OSM');
985
985
  console.log('='.repeat(60) + '\n');
986
986
 
987
987
  // Reset aggregated warning counters
@@ -1038,7 +1038,8 @@ async function runScraper() {
1038
1038
  scrapeOSSFMaliciousPackages(osvResult.knownIds),
1039
1039
  scrapeGitHubAdvisory(),
1040
1040
  scrapeOSVPyPIDataDump(),
1041
- scrapeAikidoMalwareFeed()
1041
+ scrapeAikidoMalwareFeed(),
1042
+ scrapeOSMQueryLatest()
1042
1043
  ]);
1043
1044
 
1044
1045
  const shaiHuludResult = results[0];
@@ -1047,6 +1048,7 @@ async function runScraper() {
1047
1048
  const githubPackages = results[3];
1048
1049
  const pypiPackages = results[4];
1049
1050
  const aikidoResult = results[5];
1051
+ const osmResult = results[6];
1050
1052
 
1051
1053
  // Log aggregated warnings
1052
1054
  if (_noVersionSkipCount > 0) {
@@ -1060,7 +1062,8 @@ async function runScraper() {
1060
1062
  ...datadogResult.packages,
1061
1063
  ...ossfPackages,
1062
1064
  ...githubPackages,
1063
- ...aikidoResult.packages
1065
+ ...aikidoResult.packages,
1066
+ ...osmResult.packages
1064
1067
  ];
1065
1068
 
1066
1069
  // Merge all hashes
@@ -1072,7 +1075,7 @@ async function runScraper() {
1072
1075
  // Smart deduplication: build map of best entry per key
1073
1076
  // For duplicates, keep the one with highest confidence, then most recent date
1074
1077
  const dedupSpinner = new Spinner();
1075
- dedupSpinner.start('Deduplicating ' + allPackages.length + ' npm + ' + (pypiPackages.length + (aikidoResult.pypi_packages || []).length) + ' PyPI entries...');
1078
+ dedupSpinner.start('Deduplicating ' + allPackages.length + ' npm + ' + (pypiPackages.length + (aikidoResult.pypi_packages || []).length + (osmResult.pypi_packages || []).length) + ' PyPI entries...');
1076
1079
  const dedupMap = new Map();
1077
1080
 
1078
1081
  // Seed with existing IOCs (with sanitization of stale comma-in-version entries)
@@ -1173,7 +1176,7 @@ async function runScraper() {
1173
1176
  }
1174
1177
  let addedPyPIPackages = 0;
1175
1178
  // Merge Aikido PyPI feed into the same loop
1176
- const allPyPIPackages = pypiPackages.concat(aikidoResult.pypi_packages || []);
1179
+ const allPyPIPackages = pypiPackages.concat(aikidoResult.pypi_packages || [], osmResult.pypi_packages || []);
1177
1180
  for (const pkg of allPyPIPackages) {
1178
1181
  if (!validateIOCEntry(pkg.name, pkg.version, 'pypi')) {
1179
1182
  skippedInvalid++;
@@ -1411,6 +1414,131 @@ async function scrapeAikidoMalwareFeed() {
1411
1414
  return { packages: npmPackages, pypi_packages: pypiPackages };
1412
1415
  }
1413
1416
 
1417
+ // ============================================
1418
+ // SOURCE 7: OpenSourceMalware.com (community-verified threat intel)
1419
+ // Free tier: 60 req/min, /query-latest returns 100 most recent verified threats per
1420
+ // ecosystem. Token stored in OSM_API_TOKEN env var (NEVER hardcoded — public repo).
1421
+ // API: https://api.opensourcemalware.com/functions/v1/query-latest?ecosystem={npm|pypi}
1422
+ // Docs: https://docs.opensourcemalware.com/api/query-latest.md
1423
+ // Rate-limit ref: https://docs.opensourcemalware.com/api/rate-limits.md
1424
+ // ============================================
1425
+ async function scrapeOSMQueryLatest() {
1426
+ console.log('[SCRAPER] OpenSourceMalware.com query-latest...');
1427
+ const token = process.env.OSM_API_TOKEN;
1428
+ if (!token) {
1429
+ console.log('[SCRAPER] OSM_API_TOKEN not set — skipping (graceful, no error).');
1430
+ return { packages: [], pypi_packages: [] };
1431
+ }
1432
+ // Defensive token shape check (don't log the value)
1433
+ if (typeof token !== 'string' || !token.startsWith('osm_') || token.length < 16) {
1434
+ console.log('[SCRAPER] OSM_API_TOKEN malformed (expected osm_<chars>) — skipping.');
1435
+ return { packages: [], pypi_packages: [] };
1436
+ }
1437
+
1438
+ const npmPackages = [];
1439
+ const pypiPackages = [];
1440
+ const headers = { Authorization: 'Bearer ' + token };
1441
+
1442
+ // Map OSM severity_level (low/medium/high/critical) to MUAD'DIB severity (lowercase).
1443
+ // OSM doesn't always populate severity; default to 'high' (verified threats are high-confidence by definition).
1444
+ function mapSeverity(s) {
1445
+ if (!s || typeof s !== 'string') return 'high';
1446
+ const v = s.toLowerCase().trim();
1447
+ if (v === 'low' || v === 'medium' || v === 'high' || v === 'critical') return v;
1448
+ return 'high';
1449
+ }
1450
+
1451
+ function buildReferences(threat, ecosystem) {
1452
+ const refs = [];
1453
+ if (threat.osv_advisory_url && typeof threat.osv_advisory_url === 'string') refs.push(threat.osv_advisory_url);
1454
+ if (threat.ghsa_advisory_url && typeof threat.ghsa_advisory_url === 'string') refs.push(threat.ghsa_advisory_url);
1455
+ // Canonical OSM page for this threat. Best-effort URL — if 404 it's harmless metadata.
1456
+ if (threat.package_name) {
1457
+ refs.push('https://opensourcemalware.com/' + ecosystem + '/' + encodeURIComponent(threat.package_name));
1458
+ }
1459
+ return refs;
1460
+ }
1461
+
1462
+ function buildDescription(threat) {
1463
+ const parts = [];
1464
+ if (threat.threat_description) parts.push(String(threat.threat_description));
1465
+ if (Array.isArray(threat.tags) && threat.tags.length > 0) {
1466
+ parts.push('Tags: ' + threat.tags.filter(t => typeof t === 'string').join(', '));
1467
+ }
1468
+ if (threat.researcher) parts.push('Reporter: ' + String(threat.researcher));
1469
+ return parts.length > 0 ? parts.join(' — ') : 'Verified by OpenSourceMalware.com community';
1470
+ }
1471
+
1472
+ async function pull(ecosystem, target) {
1473
+ try {
1474
+ const { status, data } = await fetchJSON(
1475
+ 'https://api.opensourcemalware.com/functions/v1/query-latest?ecosystem=' + encodeURIComponent(ecosystem),
1476
+ { headers }
1477
+ );
1478
+ if (status === 401 || status === 403) {
1479
+ console.log('[SCRAPER] OSM ' + ecosystem + ': HTTP ' + status + ' — token rejected, check OSM_API_TOKEN.');
1480
+ return;
1481
+ }
1482
+ if (status !== 200) {
1483
+ console.log('[SCRAPER] OSM ' + ecosystem + ' feed: HTTP ' + status);
1484
+ return;
1485
+ }
1486
+ if (!data || !Array.isArray(data.threats)) {
1487
+ console.log('[SCRAPER] OSM ' + ecosystem + ' feed: unexpected response shape (no threats[]).');
1488
+ return;
1489
+ }
1490
+ let count = 0;
1491
+ for (const t of data.threats) {
1492
+ if (!t || typeof t.package_name !== 'string' || t.package_name.length === 0) continue;
1493
+ // OSM verifies report_type === 'package' threats. Skip anything else (repository/url/domain).
1494
+ // The query is filtered by ecosystem, but defensive check on registry field.
1495
+ if (t.report_type && t.report_type !== 'package') continue;
1496
+ // Normalize version: OSM uses free-form strings ('all', 'any', 'unknown', null, etc.)
1497
+ // Wildcard placeholders must be MUAD'DIB's canonical '*' so the IOC matcher hits.
1498
+ let ver = '*';
1499
+ if (t.version_info && typeof t.version_info === 'string') {
1500
+ const trimmed = t.version_info.trim();
1501
+ const lc = trimmed.toLowerCase();
1502
+ if (trimmed !== '' && lc !== 'all' && lc !== 'any' && lc !== 'unknown' && lc !== '*' && lc !== 'n/a') {
1503
+ ver = trimmed;
1504
+ }
1505
+ }
1506
+ const severity = mapSeverity(t.severity_level);
1507
+ const addedAt = t.verified_at || t.created_at || t.first_seen || new Date().toISOString();
1508
+ const idPrefix = ecosystem === 'pypi' ? 'OSM-PYPI-' : 'OSM-';
1509
+ target.push({
1510
+ id: idPrefix + t.package_name + '-' + ver,
1511
+ name: t.package_name,
1512
+ version: ver,
1513
+ severity: severity,
1514
+ confidence: 'high',
1515
+ source: 'osm',
1516
+ description: buildDescription(t),
1517
+ references: buildReferences(t, ecosystem),
1518
+ mitre: 'T1195.002',
1519
+ freshness: {
1520
+ added_at: addedAt,
1521
+ source: 'osm',
1522
+ confidence: 'high'
1523
+ }
1524
+ });
1525
+ count++;
1526
+ }
1527
+ console.log('[SCRAPER] ' + count + ' ' + ecosystem + ' verified threats from OSM');
1528
+ } catch (e) {
1529
+ // Defensive: do NOT echo any token-bearing URL or header in the error.
1530
+ console.log('[SCRAPER] OSM ' + ecosystem + ' error: ' + (e && e.message ? e.message : 'unknown'));
1531
+ }
1532
+ }
1533
+
1534
+ // Sequential (not parallel): keeps us well under the 60 req/min rate limit
1535
+ // and gives clearer logs. Two ecosystems = ~200ms total.
1536
+ await pull('npm', npmPackages);
1537
+ await pull('pypi', pypiPackages);
1538
+
1539
+ return { packages: npmPackages, pypi_packages: pypiPackages };
1540
+ }
1541
+
1414
1542
  // ============================================
1415
1543
  // SOURCE 5: OSV.dev Lightweight API
1416
1544
  // Used by `muaddib update` (fast, no zip download)
@@ -1529,6 +1657,7 @@ function getSourceConfidence(pkg) {
1529
1657
  module.exports = {
1530
1658
  runScraper, scrapeShaiHuludDetector, scrapeDatadogIOCs,
1531
1659
  scrapeAikidoMalwareFeed,
1660
+ scrapeOSMQueryLatest,
1532
1661
  scrapeOSVLightweightAPI, queryOSVBatch,
1533
1662
  getSourceConfidence,
1534
1663
  // Pure utility functions (exported for testing)
@@ -39,25 +39,28 @@ 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 + OSV API (GenSecAI + DataDog + OSV lightweight)
43
- const { scrapeShaiHuludDetector, scrapeDatadogIOCs, scrapeOSVLightweightAPI } = require('./scraper.js');
44
- console.log('[3/4] Downloading GitHub + OSV API IOCs...');
42
+ // Step 3: Download additional IOCs from GitHub + OSV API (GenSecAI + DataDog + OSV lightweight + OSM)
43
+ // Light path: JSON/REST only, NO heavy zip dumps. Designed to be safe at 15min cadence.
44
+ // For the deep refresh (OSV zip dumps + OSSF + Aikido + GitHub Advisory), use `muaddib scrape` (~5min).
45
+ const { scrapeShaiHuludDetector, scrapeDatadogIOCs, scrapeOSVLightweightAPI, scrapeOSMQueryLatest } = require('./scraper.js');
46
+ console.log('[3/4] Downloading GitHub + OSV API + OSM IOCs...');
45
47
 
46
- const [shaiHulud, datadog, osvApi] = await Promise.all([
48
+ const [shaiHulud, datadog, osvApi, osmResult] = await Promise.all([
47
49
  scrapeShaiHuludDetector(),
48
50
  scrapeDatadogIOCs(),
49
- scrapeOSVLightweightAPI()
51
+ scrapeOSVLightweightAPI(),
52
+ scrapeOSMQueryLatest()
50
53
  ]);
51
54
 
52
55
  const githubIOCs = {
53
- packages: [].concat(shaiHulud.packages, datadog.packages, osvApi),
54
- pypi_packages: [],
56
+ packages: [].concat(shaiHulud.packages, datadog.packages, osvApi, osmResult.packages),
57
+ pypi_packages: (osmResult.pypi_packages || []).slice(),
55
58
  hashes: [].concat(shaiHulud.hashes || [], datadog.hashes || []),
56
59
  markers: [],
57
60
  files: []
58
61
  };
59
62
  mergeIOCs(baseIOCs, githubIOCs);
60
- console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog, +' + osvApi.length + ' OSV API');
63
+ console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog, +' + osvApi.length + ' OSV API, +' + osmResult.packages.length + ' OSM npm, +' + (osmResult.pypi_packages || []).length + ' OSM PyPI');
61
64
 
62
65
  // Step 3b: Load existing cache IOCs (from bootstrap download or previous update)
63
66
  if (fs.existsSync(CACHE_IOC_FILE)) {