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 +31 -0
- package/package.json +4 -4
- package/src/ioc/scraper.js +135 -6
- package/src/ioc/updater.js +11 -8
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.
|
|
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.
|
|
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.
|
|
60
|
+
"eslint": "10.3.0",
|
|
61
61
|
"eslint-plugin-security": "^4.0.0",
|
|
62
|
-
"globals": "17.
|
|
62
|
+
"globals": "17.6.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/ioc/scraper.js
CHANGED
|
@@ -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.
|
|
984
|
-
console.log(' OSV + OSSF + GenSecAI + DataDog +
|
|
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)
|
package/src/ioc/updater.js
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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)) {
|