muaddib-scanner 2.10.100 → 2.11.0
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/package.json +1 -1
- package/src/ioc/scraper.js +148 -8
- package/src/ioc/updater.js +23 -1
- package/src/ioc/yaml-loader.js +49 -1
- package/src/monitor/daemon.js +21 -1
- package/src/monitor/state.js +366 -59
- package/src/pipeline/executor.js +16 -3
- package/src/pipeline/processor.js +27 -1
- package/src/response/playbooks.js +21 -0
- package/src/rules/index.js +83 -0
- package/src/scanner/anti-forensic.js +225 -0
- package/src/scanner/ioc-strings.js +109 -0
- package/src/scanner/stub-package.js +179 -0
- package/src/scoring.js +316 -29
package/package.json
CHANGED
package/src/ioc/scraper.js
CHANGED
|
@@ -1037,7 +1037,8 @@ async function runScraper() {
|
|
|
1037
1037
|
scrapeDatadogIOCs(),
|
|
1038
1038
|
scrapeOSSFMaliciousPackages(osvResult.knownIds),
|
|
1039
1039
|
scrapeGitHubAdvisory(),
|
|
1040
|
-
scrapeOSVPyPIDataDump()
|
|
1040
|
+
scrapeOSVPyPIDataDump(),
|
|
1041
|
+
scrapeAikidoMalwareFeed()
|
|
1041
1042
|
]);
|
|
1042
1043
|
|
|
1043
1044
|
const shaiHuludResult = results[0];
|
|
@@ -1045,6 +1046,7 @@ async function runScraper() {
|
|
|
1045
1046
|
const ossfPackages = results[2];
|
|
1046
1047
|
const githubPackages = results[3];
|
|
1047
1048
|
const pypiPackages = results[4];
|
|
1049
|
+
const aikidoResult = results[5];
|
|
1048
1050
|
|
|
1049
1051
|
// Log aggregated warnings
|
|
1050
1052
|
if (_noVersionSkipCount > 0) {
|
|
@@ -1057,7 +1059,8 @@ async function runScraper() {
|
|
|
1057
1059
|
...shaiHuludResult.packages,
|
|
1058
1060
|
...datadogResult.packages,
|
|
1059
1061
|
...ossfPackages,
|
|
1060
|
-
...githubPackages
|
|
1062
|
+
...githubPackages,
|
|
1063
|
+
...aikidoResult.packages
|
|
1061
1064
|
];
|
|
1062
1065
|
|
|
1063
1066
|
// Merge all hashes
|
|
@@ -1069,7 +1072,7 @@ async function runScraper() {
|
|
|
1069
1072
|
// Smart deduplication: build map of best entry per key
|
|
1070
1073
|
// For duplicates, keep the one with highest confidence, then most recent date
|
|
1071
1074
|
const dedupSpinner = new Spinner();
|
|
1072
|
-
dedupSpinner.start('Deduplicating ' + allPackages.length + ' npm + ' + pypiPackages.length + ' PyPI entries...');
|
|
1075
|
+
dedupSpinner.start('Deduplicating ' + allPackages.length + ' npm + ' + (pypiPackages.length + (aikidoResult.pypi_packages || []).length) + ' PyPI entries...');
|
|
1073
1076
|
const dedupMap = new Map();
|
|
1074
1077
|
|
|
1075
1078
|
// Seed with existing IOCs (with sanitization of stale comma-in-version entries)
|
|
@@ -1091,11 +1094,34 @@ async function runScraper() {
|
|
|
1091
1094
|
dedupMap.set(key, pkg);
|
|
1092
1095
|
}
|
|
1093
1096
|
|
|
1094
|
-
// Merge new IOCs with smart replacement (with input validation)
|
|
1097
|
+
// Merge new IOCs with smart replacement (with input validation).
|
|
1098
|
+
// Source-aware: each entry accumulates a `sources: [{name, added_at}]` array
|
|
1099
|
+
// tracking every feed that reported this (name, version). A package
|
|
1100
|
+
// reported by >= 3 distinct sources is treated as confidence-max
|
|
1101
|
+
// (used by `getSourceConfidence` for webhook gating).
|
|
1095
1102
|
let addedPackages = 0;
|
|
1096
1103
|
let upgradedPackages = 0;
|
|
1097
1104
|
let skippedInvalid = 0;
|
|
1098
1105
|
let skippedNeverWildcard = 0;
|
|
1106
|
+
function appendSource(target, pkg) {
|
|
1107
|
+
if (!Array.isArray(target.sources)) target.sources = [];
|
|
1108
|
+
const newSrc = pkg.source || (pkg.freshness && pkg.freshness.source) || 'unknown';
|
|
1109
|
+
if (!target.sources.some(s => s.name === newSrc)) {
|
|
1110
|
+
target.sources.push({
|
|
1111
|
+
name: newSrc,
|
|
1112
|
+
added_at: (pkg.freshness && pkg.freshness.added_at) || pkg.published || new Date().toISOString()
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
function seedSources(pkg) {
|
|
1117
|
+
if (!Array.isArray(pkg.sources)) {
|
|
1118
|
+
const src = pkg.source || (pkg.freshness && pkg.freshness.source) || 'unknown';
|
|
1119
|
+
pkg.sources = [{
|
|
1120
|
+
name: src,
|
|
1121
|
+
added_at: (pkg.freshness && pkg.freshness.added_at) || pkg.published || new Date().toISOString()
|
|
1122
|
+
}];
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1099
1125
|
for (const pkg of allPackages) {
|
|
1100
1126
|
if (!validateIOCEntry(pkg.name, pkg.version, 'npm')) {
|
|
1101
1127
|
skippedInvalid++;
|
|
@@ -1108,21 +1134,28 @@ async function runScraper() {
|
|
|
1108
1134
|
}
|
|
1109
1135
|
const key = pkg.name + '@' + pkg.version;
|
|
1110
1136
|
if (!dedupMap.has(key)) {
|
|
1137
|
+
seedSources(pkg);
|
|
1111
1138
|
dedupMap.set(key, pkg);
|
|
1112
1139
|
addedPackages++;
|
|
1113
1140
|
} else {
|
|
1114
1141
|
const existing = dedupMap.get(key);
|
|
1142
|
+
// Always accumulate source attribution before any replacement decision.
|
|
1143
|
+
seedSources(existing);
|
|
1144
|
+
appendSource(existing, pkg);
|
|
1115
1145
|
const existingConf = CONFIDENCE_ORDER[existing.confidence] || 0;
|
|
1116
1146
|
const newConf = CONFIDENCE_ORDER[pkg.confidence] || 0;
|
|
1117
1147
|
if (newConf > existingConf) {
|
|
1118
|
-
|
|
1148
|
+
// Replace with the higher-confidence entry but preserve the merged sources list
|
|
1149
|
+
const mergedSources = existing.sources;
|
|
1150
|
+
dedupMap.set(key, Object.assign({}, pkg, { sources: mergedSources }));
|
|
1119
1151
|
upgradedPackages++;
|
|
1120
1152
|
} else if (newConf === existingConf) {
|
|
1121
1153
|
// Same confidence: keep most recent
|
|
1122
1154
|
const existingDate = existing.published || (existing.freshness && existing.freshness.added_at) || '';
|
|
1123
1155
|
const newDate = pkg.published || (pkg.freshness && pkg.freshness.added_at) || '';
|
|
1124
1156
|
if (newDate > existingDate) {
|
|
1125
|
-
|
|
1157
|
+
const mergedSources = existing.sources;
|
|
1158
|
+
dedupMap.set(key, Object.assign({}, pkg, { sources: mergedSources }));
|
|
1126
1159
|
upgradedPackages++;
|
|
1127
1160
|
}
|
|
1128
1161
|
}
|
|
@@ -1139,21 +1172,27 @@ async function runScraper() {
|
|
|
1139
1172
|
pypiDedupMap.set(key, pkg);
|
|
1140
1173
|
}
|
|
1141
1174
|
let addedPyPIPackages = 0;
|
|
1142
|
-
|
|
1175
|
+
// Merge Aikido PyPI feed into the same loop
|
|
1176
|
+
const allPyPIPackages = pypiPackages.concat(aikidoResult.pypi_packages || []);
|
|
1177
|
+
for (const pkg of allPyPIPackages) {
|
|
1143
1178
|
if (!validateIOCEntry(pkg.name, pkg.version, 'pypi')) {
|
|
1144
1179
|
skippedInvalid++;
|
|
1145
1180
|
continue;
|
|
1146
1181
|
}
|
|
1147
1182
|
const key = pkg.name + '@' + pkg.version;
|
|
1148
1183
|
if (!pypiDedupMap.has(key)) {
|
|
1184
|
+
seedSources(pkg);
|
|
1149
1185
|
pypiDedupMap.set(key, pkg);
|
|
1150
1186
|
addedPyPIPackages++;
|
|
1151
1187
|
} else {
|
|
1152
1188
|
const existing = pypiDedupMap.get(key);
|
|
1189
|
+
seedSources(existing);
|
|
1190
|
+
appendSource(existing, pkg);
|
|
1153
1191
|
const existingConf = CONFIDENCE_ORDER[existing.confidence] || 0;
|
|
1154
1192
|
const newConf = CONFIDENCE_ORDER[pkg.confidence] || 0;
|
|
1155
1193
|
if (newConf > existingConf) {
|
|
1156
|
-
|
|
1194
|
+
const mergedSources = existing.sources;
|
|
1195
|
+
pypiDedupMap.set(key, Object.assign({}, pkg, { sources: mergedSources }));
|
|
1157
1196
|
}
|
|
1158
1197
|
}
|
|
1159
1198
|
}
|
|
@@ -1298,6 +1337,80 @@ async function runScraper() {
|
|
|
1298
1337
|
};
|
|
1299
1338
|
}
|
|
1300
1339
|
|
|
1340
|
+
// ============================================
|
|
1341
|
+
// SOURCE 6: Aikido Open Source Malware Feed (npm + PyPI)
|
|
1342
|
+
// Free flat JSON feed at malware-list.aikido.dev. Each entry:
|
|
1343
|
+
// { package_name, version, reason: 'MALWARE'|'TELEMETRY'|'PROTESTWARE' }
|
|
1344
|
+
// Source: https://github.com/AikidoSec/safe-chain (open-source consumer)
|
|
1345
|
+
// ============================================
|
|
1346
|
+
async function scrapeAikidoMalwareFeed() {
|
|
1347
|
+
console.log('[SCRAPER] Aikido Open Source Malware Feed...');
|
|
1348
|
+
const npmPackages = [];
|
|
1349
|
+
const pypiPackages = [];
|
|
1350
|
+
|
|
1351
|
+
// npm
|
|
1352
|
+
try {
|
|
1353
|
+
const { status, data } = await fetchJSON('https://malware-list.aikido.dev/malware_predictions.json');
|
|
1354
|
+
if (status === 200 && Array.isArray(data)) {
|
|
1355
|
+
for (const entry of data) {
|
|
1356
|
+
if (!entry || typeof entry.package_name !== 'string') continue;
|
|
1357
|
+
// Only keep MALWARE; TELEMETRY/PROTESTWARE are policy decisions, not security
|
|
1358
|
+
if (entry.reason !== 'MALWARE') continue;
|
|
1359
|
+
const ver = (entry.version && entry.version !== '') ? String(entry.version) : '*';
|
|
1360
|
+
npmPackages.push({
|
|
1361
|
+
id: 'AIKIDO-' + entry.package_name + '-' + ver,
|
|
1362
|
+
name: entry.package_name,
|
|
1363
|
+
version: ver,
|
|
1364
|
+
severity: 'critical',
|
|
1365
|
+
confidence: 'high',
|
|
1366
|
+
source: 'aikido',
|
|
1367
|
+
description: 'Flagged by Aikido Open Source Malware Feed',
|
|
1368
|
+
references: ['https://malware-list.aikido.dev/malware_predictions.json',
|
|
1369
|
+
'https://www.aikido.dev/code/malware-detection-in-dependencies'],
|
|
1370
|
+
mitre: 'T1195.002',
|
|
1371
|
+
freshness: createFreshness('aikido', 'high')
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
console.log('[SCRAPER] ' + npmPackages.length + ' npm MALWARE entries from Aikido');
|
|
1375
|
+
} else {
|
|
1376
|
+
console.log('[SCRAPER] Aikido npm feed: HTTP ' + status);
|
|
1377
|
+
}
|
|
1378
|
+
} catch (e) {
|
|
1379
|
+
console.log('[SCRAPER] Aikido npm error: ' + e.message);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// PyPI
|
|
1383
|
+
try {
|
|
1384
|
+
const { status, data } = await fetchJSON('https://malware-list.aikido.dev/malware_pypi.json');
|
|
1385
|
+
if (status === 200 && Array.isArray(data)) {
|
|
1386
|
+
for (const entry of data) {
|
|
1387
|
+
if (!entry || typeof entry.package_name !== 'string') continue;
|
|
1388
|
+
if (entry.reason !== 'MALWARE') continue;
|
|
1389
|
+
const ver = (entry.version && entry.version !== '') ? String(entry.version) : '*';
|
|
1390
|
+
pypiPackages.push({
|
|
1391
|
+
id: 'AIKIDO-PYPI-' + entry.package_name + '-' + ver,
|
|
1392
|
+
name: entry.package_name,
|
|
1393
|
+
version: ver,
|
|
1394
|
+
severity: 'critical',
|
|
1395
|
+
confidence: 'high',
|
|
1396
|
+
source: 'aikido',
|
|
1397
|
+
description: 'Flagged by Aikido Open Source Malware Feed',
|
|
1398
|
+
references: ['https://malware-list.aikido.dev/malware_pypi.json'],
|
|
1399
|
+
mitre: 'T1195.002',
|
|
1400
|
+
freshness: createFreshness('aikido', 'high')
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
console.log('[SCRAPER] ' + pypiPackages.length + ' PyPI MALWARE entries from Aikido');
|
|
1404
|
+
} else {
|
|
1405
|
+
console.log('[SCRAPER] Aikido PyPI feed: HTTP ' + status);
|
|
1406
|
+
}
|
|
1407
|
+
} catch (e) {
|
|
1408
|
+
console.log('[SCRAPER] Aikido PyPI error: ' + e.message);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return { packages: npmPackages, pypi_packages: pypiPackages };
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1301
1414
|
// ============================================
|
|
1302
1415
|
// SOURCE 5: OSV.dev Lightweight API
|
|
1303
1416
|
// Used by `muaddib update` (fast, no zip download)
|
|
@@ -1388,9 +1501,36 @@ async function queryOSVBatch(packageNames) {
|
|
|
1388
1501
|
function getNoVersionSkipCount() { return _noVersionSkipCount; }
|
|
1389
1502
|
function resetNoVersionSkipCount() { _noVersionSkipCount = 0; }
|
|
1390
1503
|
|
|
1504
|
+
/**
|
|
1505
|
+
* Source-aware confidence: a package reported by N distinct feeds is more
|
|
1506
|
+
* confident than one reported by a single source. Used by webhook gating
|
|
1507
|
+
* and the /diff command to prioritize multi-confirmed alerts.
|
|
1508
|
+
*
|
|
1509
|
+
* Tiers:
|
|
1510
|
+
* N >= 3 → 'high' (cross-confirmed, alert immediately)
|
|
1511
|
+
* N === 2 → 'medium' (single-corroboration, alert with sandbox confirm)
|
|
1512
|
+
* N <= 1 → 'low' (single-feed only, log + sandbox before alert)
|
|
1513
|
+
*
|
|
1514
|
+
* @param {object} pkg - IOC package entry (with optional `sources` array)
|
|
1515
|
+
* @returns {{ tier: 'high'|'medium'|'low', count: number, sources: string[] }}
|
|
1516
|
+
*/
|
|
1517
|
+
function getSourceConfidence(pkg) {
|
|
1518
|
+
if (!pkg) return { tier: 'low', count: 0, sources: [] };
|
|
1519
|
+
const sources = Array.isArray(pkg.sources) && pkg.sources.length > 0
|
|
1520
|
+
? pkg.sources.map(s => s.name || 'unknown')
|
|
1521
|
+
: [pkg.source || (pkg.freshness && pkg.freshness.source) || 'unknown'];
|
|
1522
|
+
const unique = Array.from(new Set(sources));
|
|
1523
|
+
let tier = 'low';
|
|
1524
|
+
if (unique.length >= 3) tier = 'high';
|
|
1525
|
+
else if (unique.length === 2) tier = 'medium';
|
|
1526
|
+
return { tier, count: unique.length, sources: unique };
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1391
1529
|
module.exports = {
|
|
1392
1530
|
runScraper, scrapeShaiHuludDetector, scrapeDatadogIOCs,
|
|
1531
|
+
scrapeAikidoMalwareFeed,
|
|
1393
1532
|
scrapeOSVLightweightAPI, queryOSVBatch,
|
|
1533
|
+
getSourceConfidence,
|
|
1394
1534
|
// Pure utility functions (exported for testing)
|
|
1395
1535
|
parseCSVLine, parseCSV, extractVersions, parseOSVEntry,
|
|
1396
1536
|
createFreshness, isAllowedRedirect,
|
package/src/ioc/updater.js
CHANGED
|
@@ -136,7 +136,9 @@ function mergeIOCs(target, source) {
|
|
|
136
136
|
target._hashSet = new Set(target.hashes);
|
|
137
137
|
target._markerSet = new Set(target.markers);
|
|
138
138
|
target._fileSet = new Set(target.files);
|
|
139
|
+
target._stringIocSet = new Set((target.stringIocs || []).map(s => s.string));
|
|
139
140
|
}
|
|
141
|
+
if (!target.stringIocs) target.stringIocs = [];
|
|
140
142
|
|
|
141
143
|
let added = 0;
|
|
142
144
|
|
|
@@ -184,6 +186,16 @@ function mergeIOCs(target, source) {
|
|
|
184
186
|
}
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
// Merge string IOCs (YARA-style)
|
|
190
|
+
for (const sIoc of source.stringIocs || []) {
|
|
191
|
+
const literal = sIoc && typeof sIoc.string === 'string' ? sIoc.string : null;
|
|
192
|
+
if (!literal) continue;
|
|
193
|
+
if (!target._stringIocSet.has(literal)) {
|
|
194
|
+
target.stringIocs.push(sIoc);
|
|
195
|
+
target._stringIocSet.add(literal);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
187
199
|
return added;
|
|
188
200
|
}
|
|
189
201
|
|
|
@@ -207,7 +219,9 @@ function loadCachedIOCs() {
|
|
|
207
219
|
pypi_packages: [],
|
|
208
220
|
hashes: yamlIOCs.hashes.map(function(h) { return h.sha256; }),
|
|
209
221
|
markers: yamlIOCs.markers.map(function(m) { return m.pattern; }),
|
|
210
|
-
files: yamlIOCs.files.map(function(f) { return f.name; })
|
|
222
|
+
files: yamlIOCs.files.map(function(f) { return f.name; }),
|
|
223
|
+
// string-IOCs from string-iocs.yaml (YARA-style high-precision artifacts)
|
|
224
|
+
stringIocs: Array.isArray(yamlIOCs.stringIocs) ? [...yamlIOCs.stringIocs] : []
|
|
211
225
|
};
|
|
212
226
|
|
|
213
227
|
// Priority 2a: Local scraped IOCs (full enriched file)
|
|
@@ -349,6 +363,11 @@ function createOptimizedIOCs(iocs) {
|
|
|
349
363
|
// Set for suspicious files
|
|
350
364
|
const filesSet = new Set(iocs.files);
|
|
351
365
|
|
|
366
|
+
// String IOCs (YARA-style): keep both array (for metadata) and Map keyed by string
|
|
367
|
+
// for O(1) campaign lookup once a substring match has been confirmed.
|
|
368
|
+
const stringIocsArr = Array.isArray(iocs.stringIocs) ? iocs.stringIocs : [];
|
|
369
|
+
const stringIocsMap = new Map(stringIocsArr.map(s => [s.string, s]));
|
|
370
|
+
|
|
352
371
|
return {
|
|
353
372
|
// Optimized structures (npm)
|
|
354
373
|
packagesMap,
|
|
@@ -360,6 +379,9 @@ function createOptimizedIOCs(iocs) {
|
|
|
360
379
|
hashesSet,
|
|
361
380
|
markersSet,
|
|
362
381
|
filesSet,
|
|
382
|
+
// String IOCs (YARA-style)
|
|
383
|
+
stringIocs: stringIocsArr,
|
|
384
|
+
stringIocsMap,
|
|
363
385
|
// Original arrays for compatibility
|
|
364
386
|
packages: iocs.packages,
|
|
365
387
|
pypi_packages: iocs.pypi_packages || [],
|
package/src/ioc/yaml-loader.js
CHANGED
|
@@ -34,7 +34,10 @@ function loadYAMLIOCs() {
|
|
|
34
34
|
packages: [],
|
|
35
35
|
hashes: [],
|
|
36
36
|
markers: [],
|
|
37
|
-
files: []
|
|
37
|
+
files: [],
|
|
38
|
+
// string-IOCs (YARA-style high-precision artifacts) — see iocs/string-iocs.yaml
|
|
39
|
+
// Each entry: { string, campaign, severity, source, description }
|
|
40
|
+
stringIocs: []
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
// Dedup sets for O(1) lookup during loading
|
|
@@ -42,6 +45,7 @@ function loadYAMLIOCs() {
|
|
|
42
45
|
const seenHashes = new Set();
|
|
43
46
|
const seenMarkers = new Set();
|
|
44
47
|
const seenFiles = new Set();
|
|
48
|
+
const seenStrings = new Set();
|
|
45
49
|
|
|
46
50
|
// Charger packages.yaml
|
|
47
51
|
loadPackagesYAML(path.join(IOCS_DIR, 'packages.yaml'), iocs, seenPkgs);
|
|
@@ -52,9 +56,53 @@ function loadYAMLIOCs() {
|
|
|
52
56
|
// Charger hashes.yaml
|
|
53
57
|
loadHashesYAML(path.join(IOCS_DIR, 'hashes.yaml'), iocs, seenHashes, seenMarkers, seenFiles);
|
|
54
58
|
|
|
59
|
+
// Charger string-iocs.yaml (YARA-style)
|
|
60
|
+
loadStringIocsYAML(path.join(IOCS_DIR, 'string-iocs.yaml'), iocs, seenStrings);
|
|
61
|
+
|
|
55
62
|
return iocs;
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Load YARA-style string IOCs from string-iocs.yaml.
|
|
67
|
+
* Each entry must satisfy the inclusion criteria documented in that file:
|
|
68
|
+
* - length >= 6 chars
|
|
69
|
+
* - confirmed in >= 1 sample malware
|
|
70
|
+
* - absent from benign corpus
|
|
71
|
+
* - unique enough that substring match is decisive
|
|
72
|
+
* Length floor enforced here (defense-in-depth) — anything shorter is dropped
|
|
73
|
+
* silently with a single warning to avoid spamming the console.
|
|
74
|
+
*/
|
|
75
|
+
function loadStringIocsYAML(filePath, iocs, seenStrings) {
|
|
76
|
+
if (!fs.existsSync(filePath)) return;
|
|
77
|
+
const MIN_STRING_LEN = 6;
|
|
78
|
+
let dropped = 0;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const data = yaml.load(readVerifiedYAML(filePath), { schema: yaml.JSON_SCHEMA });
|
|
82
|
+
if (!data || !Array.isArray(data.strings)) return;
|
|
83
|
+
|
|
84
|
+
for (const s of data.strings) {
|
|
85
|
+
if (!s || typeof s.string !== 'string') { dropped++; continue; }
|
|
86
|
+
const literal = s.string;
|
|
87
|
+
if (literal.length < MIN_STRING_LEN) { dropped++; continue; }
|
|
88
|
+
if (seenStrings.has(literal)) continue;
|
|
89
|
+
seenStrings.add(literal);
|
|
90
|
+
iocs.stringIocs.push({
|
|
91
|
+
string: literal,
|
|
92
|
+
campaign: typeof s.campaign === 'string' ? s.campaign : 'unknown',
|
|
93
|
+
severity: (s.severity === 'HIGH' || s.severity === 'MEDIUM') ? s.severity : 'CRITICAL',
|
|
94
|
+
source: typeof s.source === 'string' ? s.source : '',
|
|
95
|
+
description: typeof s.description === 'string' ? s.description : ''
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (dropped > 0) {
|
|
99
|
+
console.error(`[WARN] string-iocs.yaml: ${dropped} entries dropped (missing string field or below ${MIN_STRING_LEN} chars)`);
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error('[WARN] Erreur parsing string-iocs.yaml:', e.message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
58
106
|
function loadPackagesYAML(filePath, iocs, seenPkgs) {
|
|
59
107
|
if (!fs.existsSync(filePath)) return;
|
|
60
108
|
|
package/src/monitor/daemon.js
CHANGED
|
@@ -5,7 +5,7 @@ const os = require('os');
|
|
|
5
5
|
const v8 = require('v8');
|
|
6
6
|
const { isDockerAvailable, SANDBOX_CONCURRENCY_MAX } = require('../sandbox/index.js');
|
|
7
7
|
const { setVerboseMode, isSandboxEnabled, isCanaryEnabled, isLlmDetectiveEnabled, getLlmDetectiveMode, DOWNLOADS_CACHE_TTL } = require('./classify.js');
|
|
8
|
-
const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, getParisHour, atomicWriteFileSync, saveNpmSeq, ALERTS_FILE } = require('./state.js');
|
|
8
|
+
const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, getParisHour, atomicWriteFileSync, saveNpmSeq, ALERTS_FILE, runStateMigrations } = require('./state.js');
|
|
9
9
|
const { isTemporalEnabled, isTemporalAstEnabled, isTemporalPublishEnabled, isTemporalMaintainerEnabled } = require('./temporal.js');
|
|
10
10
|
const { pendingGrouped, flushScopeGroup, sendDailyReport, DAILY_REPORT_HOUR, alertedPackageRules } = require('./webhook.js');
|
|
11
11
|
const { poll } = require('./ingestion.js');
|
|
@@ -14,6 +14,12 @@ const { computeTarget, ADJUST_INTERVAL_MS, BASE_CONCURRENCY, resetDeltas } = req
|
|
|
14
14
|
const { startHealthcheck } = require('./healthcheck.js');
|
|
15
15
|
const { startDeferredWorker, stopDeferredWorker, persistDeferredQueue, restoreDeferredQueue, clearDeferredQueue } = require('./deferred-sandbox.js');
|
|
16
16
|
const { clearMetadataCache } = require('../scanner/temporal-analysis.js');
|
|
17
|
+
// Caches not previously cleared by handleMemoryPressure (OOM fix). These live
|
|
18
|
+
// in the main thread and are populated by temporal-ast-diff and the typosquat
|
|
19
|
+
// scanner, neither of which runs in the static-scan worker.
|
|
20
|
+
const { clearMetadataCache: clearTyposquatMetadataCache } = require('../scanner/typosquat.js');
|
|
21
|
+
const { clearFileListCache } = require('../utils.js');
|
|
22
|
+
const { clearASTCache } = require('../shared/constants.js');
|
|
17
23
|
|
|
18
24
|
const POLL_INTERVAL = 60_000;
|
|
19
25
|
const PROCESS_LOOP_INTERVAL = 2_000; // Queue check interval when empty
|
|
@@ -401,6 +407,13 @@ function handleMemoryPressure(level, ratio, recentlyScanned, downloadsCache, sca
|
|
|
401
407
|
console.error(`[MONITOR] MEMORY PRESSURE CRITICAL: heap at ${pct}% — stopping ingestion, clearing scanner caches`);
|
|
402
408
|
// temporal-analysis._metadataCache (200 entries × full npm registry metadata)
|
|
403
409
|
try { clearMetadataCache(); } catch {}
|
|
410
|
+
// typosquat metadataCache (500 entries × npm registry metadata for typosquat scoring)
|
|
411
|
+
try { clearTyposquatMetadataCache(); } catch {}
|
|
412
|
+
// utils._fileListCache, utils._fileContentCache, shared/constants._astCache
|
|
413
|
+
// — populated by temporal-ast-diff (main-thread tarball download + AST parse).
|
|
414
|
+
// Each AST entry can be MB-sized for bundled outputs.
|
|
415
|
+
try { clearFileListCache(); } catch {}
|
|
416
|
+
try { clearASTCache(); } catch {}
|
|
404
417
|
// pendingGrouped webhook buffers
|
|
405
418
|
for (const [scope, group] of pendingGrouped) {
|
|
406
419
|
clearTimeout(group.timer);
|
|
@@ -567,6 +580,13 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
567
580
|
// External healthcheck (Healthchecks.io) — sends /start ping now, heartbeat every 10 min
|
|
568
581
|
const healthcheck = startHealthcheck();
|
|
569
582
|
|
|
583
|
+
// OOM fix: convert legacy detections.json / temporal-detections.json into
|
|
584
|
+
// append-only JSONL on first boot after upgrade. Idempotent and safe to call
|
|
585
|
+
// every boot (skips when JSONL already exists).
|
|
586
|
+
try { runStateMigrations(); } catch (err) {
|
|
587
|
+
console.error(`[MONITOR] runStateMigrations failed: ${err.message}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
570
590
|
const state = loadState(stats);
|
|
571
591
|
loadDailyStats(stats, dailyAlerts); // Restore counters from previous run (survives restarts)
|
|
572
592
|
console.log(`[MONITOR] State loaded — npm last: ${state.npmLastPackage || 'none'}, pypi last: ${state.pypiLastPackage || 'none'}, npm seq: ${state.npmLastSeq || 'none'}`);
|