muaddib-scanner 2.11.176 → 2.11.178
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/README.md +1 -1
- package/package.json +1 -1
- package/{self-scan-v2.11.176.json → self-scan-v2.11.178.json} +1 -1
- package/src/ioc/scraper.js +20 -103
- package/src/ioc/updater.js +11 -12
- package/src/scanner/dataflow.js +23 -0
- package/stats.json +2 -2
package/README.md
CHANGED
|
@@ -345,7 +345,7 @@ npm test
|
|
|
345
345
|
|
|
346
346
|
### Testing
|
|
347
347
|
|
|
348
|
-
- **<!--stat:tests-->
|
|
348
|
+
- **<!--stat:tests-->4543<!--/stat:tests--> tests** across <!--stat:testFiles-->152<!--/stat:testFiles--> modular test files
|
|
349
349
|
- **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
|
|
350
350
|
- **Datadog 17K benchmark** - 14,587 confirmed malware samples (in-scope)
|
|
351
351
|
- **Ground truth validation** - 96 real-world attacks (95.74% TPR@3, 88.30% TPR@20 — v2.11.48 full measure on 94 in-scope)
|
package/package.json
CHANGED
package/src/ioc/scraper.js
CHANGED
|
@@ -930,65 +930,14 @@ async function scrapeOSVPyPIDataDump() {
|
|
|
930
930
|
return packages;
|
|
931
931
|
}
|
|
932
932
|
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
const resp = await fetchJSON('https://api.osv.dev/v1/query', {
|
|
942
|
-
method: 'POST',
|
|
943
|
-
headers: { 'Content-Type': 'application/json' },
|
|
944
|
-
body: { package: { ecosystem: 'npm' } }
|
|
945
|
-
});
|
|
946
|
-
|
|
947
|
-
if (resp.status === 200 && resp.data && resp.data.vulns) {
|
|
948
|
-
for (const vuln of resp.data.vulns) {
|
|
949
|
-
// Filter GHSA with malware mention
|
|
950
|
-
if (vuln.id && vuln.id.startsWith('GHSA-')) {
|
|
951
|
-
const summary = (vuln.summary || '').toLowerCase();
|
|
952
|
-
const details = (vuln.details || '').toLowerCase();
|
|
953
|
-
const isMalware = summary.includes('malware') ||
|
|
954
|
-
summary.includes('malicious') ||
|
|
955
|
-
details.includes('malware') ||
|
|
956
|
-
details.includes('malicious') ||
|
|
957
|
-
summary.includes('backdoor') ||
|
|
958
|
-
summary.includes('trojan');
|
|
959
|
-
|
|
960
|
-
if (isMalware) {
|
|
961
|
-
for (const affected of vuln.affected || []) {
|
|
962
|
-
if (affected.package && affected.package.ecosystem === 'npm') {
|
|
963
|
-
const versions = extractVersions(affected);
|
|
964
|
-
for (const ver of versions) {
|
|
965
|
-
packages.push({
|
|
966
|
-
id: vuln.id,
|
|
967
|
-
name: affected.package.name,
|
|
968
|
-
version: ver,
|
|
969
|
-
severity: 'critical',
|
|
970
|
-
confidence: 'high',
|
|
971
|
-
source: 'github-advisory',
|
|
972
|
-
description: (vuln.summary || 'Malicious package').slice(0, 200),
|
|
973
|
-
references: ['https://github.com/advisories/' + vuln.id],
|
|
974
|
-
mitre: 'T1195.002',
|
|
975
|
-
freshness: createFreshness('github-advisory', 'high')
|
|
976
|
-
});
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
console.log(`[SCRAPER] ${packages.length} packages`);
|
|
986
|
-
} catch (e) {
|
|
987
|
-
console.log(`[SCRAPER] Error: ${e.message}`);
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
return packages;
|
|
991
|
-
}
|
|
933
|
+
// SOURCE 4 (GitHub Advisory Database) removed 2026-07 (GAP-2): it POSTed
|
|
934
|
+
// api.osv.dev/v1/query with `{ package: { ecosystem: 'npm' } }` — no package name,
|
|
935
|
+
// which the OSV /v1/query API rejects with HTTP 400 on every call. It therefore
|
|
936
|
+
// returned 0 packages permanently while logging as a normal completion (fictional
|
|
937
|
+
// coverage). Empirically verified that the GHSA-malware it targeted is already
|
|
938
|
+
// 100% covered by the OSV MAL- dump (153/153 pre-snapshot npm GHSA advisories
|
|
939
|
+
// present in the IOC store), so its removal loses no coverage. The GHSA poller
|
|
940
|
+
// (src/ioc/ghsa-poller.js) tracks the coverage denominator independently.
|
|
992
941
|
|
|
993
942
|
// ============================================
|
|
994
943
|
// MAIN SCRAPER
|
|
@@ -1072,7 +1021,6 @@ async function runScraper() {
|
|
|
1072
1021
|
scrapeShaiHuludDetector(),
|
|
1073
1022
|
scrapeDatadogIOCs(),
|
|
1074
1023
|
scrapeOSSFMaliciousPackages(osvResult.knownIds),
|
|
1075
|
-
scrapeGitHubAdvisory(),
|
|
1076
1024
|
scrapeOSVPyPIDataDump(),
|
|
1077
1025
|
scrapeAikidoMalwareFeed(),
|
|
1078
1026
|
scrapeOSMQueryLatest()
|
|
@@ -1081,10 +1029,9 @@ async function runScraper() {
|
|
|
1081
1029
|
const shaiHuludResult = results[0];
|
|
1082
1030
|
const datadogResult = results[1];
|
|
1083
1031
|
const ossfPackages = results[2];
|
|
1084
|
-
const
|
|
1085
|
-
const
|
|
1086
|
-
const
|
|
1087
|
-
const osmResult = results[6];
|
|
1032
|
+
const pypiPackages = results[3];
|
|
1033
|
+
const aikidoResult = results[4];
|
|
1034
|
+
const osmResult = results[5];
|
|
1088
1035
|
|
|
1089
1036
|
// Log aggregated warnings
|
|
1090
1037
|
if (_noVersionSkipCount > 0) {
|
|
@@ -1109,7 +1056,6 @@ async function runScraper() {
|
|
|
1109
1056
|
...shaiHuludResult.packages,
|
|
1110
1057
|
...datadogResult.packages,
|
|
1111
1058
|
...ossfPackages,
|
|
1112
|
-
...githubPackages,
|
|
1113
1059
|
...aikidoResult.packages,
|
|
1114
1060
|
...osmResult.packages
|
|
1115
1061
|
];
|
|
@@ -1597,43 +1543,14 @@ async function scrapeOSMQueryLatest() {
|
|
|
1597
1543
|
return { packages: npmPackages, pypi_packages: pypiPackages };
|
|
1598
1544
|
}
|
|
1599
1545
|
|
|
1600
|
-
//
|
|
1601
|
-
// SOURCE
|
|
1602
|
-
//
|
|
1603
|
-
//
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
* @returns {Promise<Array>} Parsed IOC package entries
|
|
1609
|
-
*/
|
|
1610
|
-
async function scrapeOSVLightweightAPI() {
|
|
1611
|
-
console.log('[SCRAPER] OSV.dev lightweight API...');
|
|
1612
|
-
const packages = [];
|
|
1613
|
-
|
|
1614
|
-
try {
|
|
1615
|
-
const resp = await fetchJSON('https://api.osv.dev/v1/query', {
|
|
1616
|
-
method: 'POST',
|
|
1617
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1618
|
-
body: { package: { ecosystem: 'npm' } }
|
|
1619
|
-
});
|
|
1620
|
-
|
|
1621
|
-
if (resp.status === 200 && resp.data && resp.data.vulns) {
|
|
1622
|
-
for (const vuln of resp.data.vulns) {
|
|
1623
|
-
if (vuln.id && vuln.id.startsWith('MAL-')) {
|
|
1624
|
-
const parsed = parseOSVEntry(vuln, 'osv-api');
|
|
1625
|
-
for (const p of parsed) packages.push(p);
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
console.log('[SCRAPER] ' + packages.length + ' MAL-* packages from OSV API');
|
|
1631
|
-
} catch (e) {
|
|
1632
|
-
console.log('[SCRAPER] OSV API error: ' + e.message);
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
return packages;
|
|
1636
|
-
}
|
|
1546
|
+
// SOURCE 5 (OSV.dev Lightweight API) removed 2026-07 (GAP-2): same defect as
|
|
1547
|
+
// SOURCE 4 — it POSTed api.osv.dev/v1/query with no package name (`{ package:
|
|
1548
|
+
// { ecosystem: 'npm' } }`), which OSV rejects with HTTP 400, so it returned 0
|
|
1549
|
+
// MAL-* packages permanently while `muaddib update` logged "+0 OSV API" as normal.
|
|
1550
|
+
// It was also 100% redundant with the OSV MAL- zip dump (scrapeOSVDataDump), which
|
|
1551
|
+
// pulls the same MAL- npm set. The `muaddib update` light path keeps its recent-
|
|
1552
|
+
// malware coverage via GenSecAI + DataDog + OSM; the authoritative OSV MAL- set
|
|
1553
|
+
// comes from the deep `muaddib scrape`.
|
|
1637
1554
|
|
|
1638
1555
|
/**
|
|
1639
1556
|
* Batch query OSV.dev for specific package names.
|
|
@@ -1718,7 +1635,7 @@ module.exports = {
|
|
|
1718
1635
|
runScraper, scrapeShaiHuludDetector, scrapeDatadogIOCs,
|
|
1719
1636
|
scrapeAikidoMalwareFeed,
|
|
1720
1637
|
scrapeOSMQueryLatest,
|
|
1721
|
-
|
|
1638
|
+
queryOSVBatch,
|
|
1722
1639
|
getSourceConfidence,
|
|
1723
1640
|
// Pure utility functions (exported for testing)
|
|
1724
1641
|
parseCSVLine, parseCSV, extractVersions, parseOSVEntry,
|
package/src/ioc/updater.js
CHANGED
|
@@ -66,28 +66,29 @@ async function updateIOCs() {
|
|
|
66
66
|
mergeIOCs(baseIOCs, yamlStandard);
|
|
67
67
|
console.log('[2/4] YAML IOCs: ' + yamlStandard.packages.length + ' packages, ' + yamlStandard.hashes.length + ' hashes');
|
|
68
68
|
|
|
69
|
-
// Step 3: Download additional IOCs
|
|
69
|
+
// Step 3: Download additional IOCs (GenSecAI + DataDog + OSM)
|
|
70
70
|
// Light path: JSON/REST only, NO heavy zip dumps. Designed to be safe at 15min cadence.
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
// The OSV MAL- set comes from the deep refresh (`muaddib scrape`, OSV zip dumps + OSSF +
|
|
72
|
+
// Aikido). The former OSV lightweight source was removed 2026-07 (GAP-2): it POSTed a
|
|
73
|
+
// nameless OSV /v1/query (HTTP 400 → always 0) and was redundant with the OSV zip dump.
|
|
74
|
+
const { scrapeShaiHuludDetector, scrapeDatadogIOCs, scrapeOSMQueryLatest } = require('./scraper.js');
|
|
75
|
+
console.log('[3/4] Downloading GenSecAI + DataDog + OSM IOCs...');
|
|
74
76
|
|
|
75
|
-
const [shaiHulud, datadog,
|
|
77
|
+
const [shaiHulud, datadog, osmResult] = await Promise.all([
|
|
76
78
|
scrapeShaiHuludDetector(),
|
|
77
79
|
scrapeDatadogIOCs(),
|
|
78
|
-
scrapeOSVLightweightAPI(),
|
|
79
80
|
scrapeOSMQueryLatest()
|
|
80
81
|
]);
|
|
81
82
|
|
|
82
83
|
const githubIOCs = {
|
|
83
|
-
packages: [].concat(shaiHulud.packages, datadog.packages,
|
|
84
|
+
packages: [].concat(shaiHulud.packages, datadog.packages, osmResult.packages),
|
|
84
85
|
pypi_packages: (osmResult.pypi_packages || []).slice(),
|
|
85
86
|
hashes: [].concat(shaiHulud.hashes || [], datadog.hashes || []),
|
|
86
87
|
markers: [],
|
|
87
88
|
files: []
|
|
88
89
|
};
|
|
89
90
|
mergeIOCs(baseIOCs, githubIOCs);
|
|
90
|
-
console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog, +' +
|
|
91
|
+
console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog, +' + osmResult.packages.length + ' OSM npm, +' + (osmResult.pypi_packages || []).length + ' OSM PyPI');
|
|
91
92
|
|
|
92
93
|
// Phase 2c (feed health): a feed that previously returned data but now returns 0 is the
|
|
93
94
|
// silent failure mode that froze the OSM feed and collapsed coverage. Raise a one-shot
|
|
@@ -95,13 +96,11 @@ async function updateIOCs() {
|
|
|
95
96
|
// Only feeds that were actually ATTEMPTED are health-checked: OSM is token-gated, so without
|
|
96
97
|
// OSM_API_TOKEN it is SKIPPED (not down) — counting it would raise a false "OSM went dark"
|
|
97
98
|
// alarm in any no-token context (e.g. an ad-hoc `muaddib update`) against the monitor-seeded
|
|
98
|
-
// baseline.
|
|
99
|
-
// so the engine naturally never establishes an alarm-able baseline for it.
|
|
99
|
+
// baseline.
|
|
100
100
|
try {
|
|
101
101
|
const feedCounts = {
|
|
102
102
|
'GenSecAI': shaiHulud.packages.length,
|
|
103
|
-
'DataDog': datadog.packages.length
|
|
104
|
-
'OSV-API': osvApi.length
|
|
103
|
+
'DataDog': datadog.packages.length
|
|
105
104
|
};
|
|
106
105
|
if (process.env.OSM_API_TOKEN) {
|
|
107
106
|
feedCounts['OSM'] = osmResult.packages.length + (osmResult.pypi_packages || []).length;
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -474,6 +474,29 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
474
474
|
}
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
+
// Stream-based credential read: fs.createReadStream(<sensitive path>) is the
|
|
478
|
+
// stream equivalent of readFileSync. Registering it as a credential_read source
|
|
479
|
+
// closes the .pipe() exfil gap — createReadStream('/.ssh/id_rsa').pipe(net.connect(...))
|
|
480
|
+
// now scores the same as the readFileSync + socket.write form (the co-occurring
|
|
481
|
+
// network sink from the .pipe() argument — net.connect/http.request — is already
|
|
482
|
+
// detected by the MemberExpression sink handler below). Strictly gated on
|
|
483
|
+
// isCredentialPath: FP-measured 0/745 benign packages (createReadStream of a
|
|
484
|
+
// sensitive path is absent from legitimate code; the benign servers'
|
|
485
|
+
// createReadStream('public/index.html').pipe(res) is not sensitive-sourced, and
|
|
486
|
+
// the .pipe(res)/.pipe(createWriteStream) sinks are not network sinks). Deliberately
|
|
487
|
+
// NOT added to MODULE_SOURCE_METHODS to avoid the alias path, which does not
|
|
488
|
+
// re-check the path and would flag a non-sensitive aliased read.
|
|
489
|
+
if (callName === 'createReadStream' || callName === 'fs.createReadStream') {
|
|
490
|
+
const arg = node.arguments[0];
|
|
491
|
+
if (arg && isCredentialPath(arg, sensitivePathVars)) {
|
|
492
|
+
sources.push({
|
|
493
|
+
type: 'credential_read',
|
|
494
|
+
name: callName,
|
|
495
|
+
line: node.loc?.start?.line
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
477
500
|
// fs.promises.readFile(path) — 3-level member chain
|
|
478
501
|
if (node.callee.type === 'MemberExpression' &&
|
|
479
502
|
node.callee.object?.type === 'MemberExpression') {
|
package/stats.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "GENERATED by scripts/collect-stats.js — do not edit by hand. Run `npm run docs:stats`.",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.178",
|
|
4
4
|
"scanners": 22,
|
|
5
5
|
"rulesTotal": 277,
|
|
6
6
|
"rulesCore": 272,
|
|
@@ -12,5 +12,5 @@
|
|
|
12
12
|
"moduleGraph": 9,
|
|
13
13
|
"pythonAstDetectors": 6,
|
|
14
14
|
"testFiles": 152,
|
|
15
|
-
"tests":
|
|
15
|
+
"tests": 4543
|
|
16
16
|
}
|