muaddib-scanner 2.10.101 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.10.101",
3
+ "version": "2.11.0",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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
- dedupMap.set(key, pkg);
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
- dedupMap.set(key, pkg);
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
- for (const pkg of pypiPackages) {
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
- pypiDedupMap.set(key, pkg);
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,
@@ -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 || [],
@@ -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
 
@@ -6,6 +6,9 @@ const { analyzeAST } = require('../scanner/ast.js');
6
6
  const { detectObfuscation } = require('../scanner/obfuscation.js');
7
7
  const { scanDependencies } = require('../scanner/dependencies.js');
8
8
  const { scanHashes } = require('../scanner/hash.js');
9
+ const { scanIocStrings } = require('../scanner/ioc-strings.js');
10
+ const { scanAntiForensic } = require('../scanner/anti-forensic.js');
11
+ const { scanStubPackage } = require('../scanner/stub-package.js');
9
12
  const { analyzeDataFlow } = require('../scanner/dataflow.js');
10
13
  const { scanTyposquatting, findPyPITyposquatMatch } = require('../scanner/typosquat.js');
11
14
  const { scanGitHubActions } = require('../scanner/github-actions.js');
@@ -183,7 +186,8 @@ async function execute(targetPath, options, pythonDeps, warnings) {
183
186
  'scanPackageJson', 'scanShellScripts', 'analyzeAST', 'detectObfuscation',
184
187
  'scanDependencies', 'scanHashes', 'analyzeDataFlow', 'scanTyposquatting',
185
188
  'scanGitHubActions', 'matchPythonIOCs', 'checkPyPITyposquatting',
186
- 'scanEntropy', 'scanAIConfig'
189
+ 'scanEntropy', 'scanAIConfig', 'scanIocStrings', 'scanAntiForensic',
190
+ 'scanStubPackage'
187
191
  ];
188
192
 
189
193
  const settledResults = await Promise.allSettled([
@@ -199,7 +203,10 @@ async function execute(targetPath, options, pythonDeps, warnings) {
199
203
  yieldThen(() => matchPythonIOCs(pythonDeps, targetPath)),
200
204
  yieldThen(() => checkPyPITyposquatting(pythonDeps, targetPath)),
201
205
  withTimeout(() => scanEntropy(targetPath, { entropyThreshold: options.entropyThreshold || undefined }), 'scanEntropy'),
202
- yieldThen(() => scanAIConfig(targetPath))
206
+ yieldThen(() => scanAIConfig(targetPath)),
207
+ yieldThen(() => scanIocStrings(targetPath)),
208
+ withTimeout(() => scanAntiForensic(targetPath), 'scanAntiForensic'),
209
+ yieldThen(() => scanStubPackage(targetPath))
203
210
  ]);
204
211
 
205
212
  // Extract results: use empty array for rejected scanners, log errors
@@ -224,7 +231,10 @@ async function execute(targetPath, options, pythonDeps, warnings) {
224
231
  pythonThreats,
225
232
  pypiTyposquatThreats,
226
233
  entropyThreats,
227
- aiConfigThreats
234
+ aiConfigThreats,
235
+ iocStringThreats,
236
+ antiForensicThreats,
237
+ stubPackageThreats
228
238
  ] = scanResult;
229
239
 
230
240
  // Emit warning if file count cap was hit + quick-scan overflow files
@@ -291,6 +301,9 @@ async function execute(targetPath, options, pythonDeps, warnings) {
291
301
  ...pypiTyposquatThreats,
292
302
  ...entropyThreats,
293
303
  ...aiConfigThreats,
304
+ ...iocStringThreats,
305
+ ...antiForensicThreats,
306
+ ...stubPackageThreats,
294
307
  ...crossFileFlows.filter(f => f && f.sourceFile && f.sinkFile).map(f => ({
295
308
  type: f.type,
296
309
  severity: f.severity,
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const { getRule } = require('../rules/index.js');
4
4
  const { getPlaybook } = require('../response/playbooks.js');
5
5
  const { computeReachableFiles } = require('../scanner/reachability.js');
6
- const { applyFPReductions, applyCompoundBoosts, calculateRiskScore, getSeverityWeights, applyContextualFPCaps } = require('../scoring.js');
6
+ const { applyFPReductions, applyCompoundBoosts, calculateRiskScore, getSeverityWeights, applyContextualFPCaps, applySingleFireCriticalFloor, applyReputationFactor } = require('../scoring.js');
7
7
  const { buildIntentPairs } = require('../intent-graph.js');
8
8
  const { debugLog } = require('../utils.js');
9
9
 
@@ -319,6 +319,32 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
319
319
  ' → score=' + result.summary.riskScore);
320
320
  }
321
321
 
322
+ // Hybrid v3 Phase 1: single-fire critical floor — applied AFTER contextual
323
+ // caps so a deterministic IOC match (known_malicious_hash, lifecycle_shell_pipe…)
324
+ // stays CRITICAL even if the package also matches a benign FP cluster.
325
+ const sfTriggers = applySingleFireCriticalFloor(result);
326
+ if (sfTriggers.length > 0) {
327
+ debugLog('[SF-FLOOR] ' + (packageName || targetPath) + ': ' +
328
+ sfTriggers.map(t => t.type + '/' + t.severity).join(', ') +
329
+ ' → score=' + result.summary.riskScore);
330
+ }
331
+
332
+ // Hybrid v3 Phase 4: metadata-first reputation factor — multiplies the score
333
+ // by a factor in [0.10, 1.5] derived from npm registry signals. Applied LAST
334
+ // so all severity logic completes first; the factor is the final, package-
335
+ // wide context filter. Gated behind MUADDIB_METADATA_FACTOR=1; no-op when
336
+ // metadata is absent (CLI scans, offline) or the gate is off.
337
+ // NOTE: this module's exported function is named `process`, which shadows
338
+ // the global `process` inside its body. Use globalThis.process.env to reach
339
+ // the real environment.
340
+ if (globalThis.process.env.MUADDIB_METADATA_FACTOR === '1') {
341
+ const repAdjust = applyReputationFactor(result, _pkgMeta && _pkgMeta.npmRegistryMeta);
342
+ if (repAdjust) {
343
+ debugLog('[META-FACTOR] ' + (packageName || targetPath) + ': factor=' +
344
+ repAdjust.factor.toFixed(2) + ' (' + repAdjust.oldScore + ' → ' + repAdjust.newScore + ')');
345
+ }
346
+ }
347
+
322
348
  return {
323
349
  result,
324
350
  deduped,
@@ -128,6 +128,27 @@ const PLAYBOOKS = {
128
128
  shai_hulud_marker:
129
129
  'CRITIQUE: Marqueur Shai-Hulud detecte. Package compromis. Supprimer immediatement et regenerer tous les tokens.',
130
130
 
131
+ ioc_string_match:
132
+ 'CRITIQUE: String IOC YARA-style matched (campagne malware connue). Verifier le fichier signale et le champ campaign. Si match dans node_modules, considerer le package compromis. Source du IOC dans `iocs/string-iocs.yaml`.',
133
+
134
+ anti_forensic_xor_autodelete:
135
+ 'CRITIQUE: Pattern complet anti-forensique (XOR + self-delete + decoy write) dans un seul fichier. Style Axios npm 2026-03 / csec autodelete. Considerer le package compromis. Inspecter le fichier signale et regenerer tous les secrets si le code a deja tourne.',
136
+
137
+ anti_forensic_partial:
138
+ 'ATTENTION: 2 patterns anti-forensiques sur 3 dans un meme fichier. Soit malware en cours de developpement, soit faux positif sur lib de crypto/build qui combine XOR + ecriture .bak. Inspection manuelle requise.',
139
+
140
+ stub_package_external_payload:
141
+ 'CRITIQUE: Package stub avec dep URL externe + lifecycle hook = pattern ltidi chain. Le code malveillant est dans la dep externe, pas le tarball npm. Bloquer le package + resoudre la dep externe pour audit.',
142
+
143
+ stub_package_external_dep:
144
+ 'ATTENTION: Package stub avec dep URL externe (sans lifecycle). Lib legitime qui pull un payload via URL devrait re-exporter la dep. Inspecter la dep cible avant install.',
145
+
146
+ axios_family:
147
+ 'CRITIQUE: Famille Axios/csec confirmee (IOC + lifecycle + anti-forensic). Bloquer le package, regenerer tous les secrets, isoler les machines qui ont fait `npm install` recemment.',
148
+
149
+ stub_with_string_ioc:
150
+ 'CRITIQUE: Package stub + IOC string connu = staging chain-attack confirme. Bloquer le package + sa dep externe. Regenerer secrets si install effectue.',
151
+
131
152
  known_malicious_hash:
132
153
  'CRITIQUE: Fichier malveillant confirme par hash. Supprimer immediatement. Considerer la machine compromise.',
133
154
 
@@ -178,6 +178,89 @@ const RULES = {
178
178
  ],
179
179
  mitre: 'T1195.002'
180
180
  },
181
+ ioc_string_match: {
182
+ id: 'MUADDIB-IOC-001',
183
+ name: 'YARA-style String IOC Match',
184
+ severity: 'CRITICAL',
185
+ confidence: 'high',
186
+ description: 'Literal substring uniquement attribuable a une campagne malware connue (XOR key, RAT command name, C2 path, build artifact). Le match en source = signal CRITICAL transverse a toutes les variantes qui reuse le meme stager.',
187
+ references: [
188
+ 'iocs/string-iocs.yaml',
189
+ 'https://gist.github.com/N3mes1s/0c0fc7a0c23cdb5e1c8f66b208053ed6',
190
+ 'https://unit42.paloaltonetworks.com/axios-supply-chain-attack/',
191
+ 'https://blog.gitguardian.com/three-supply-chain-campaigns-hit-npm-pypi-and-docker-hub-in-48-hours/'
192
+ ],
193
+ mitre: 'T1195.002'
194
+ },
195
+ anti_forensic_xor_autodelete: {
196
+ id: 'MUADDIB-AF-001',
197
+ name: 'Anti-Forensic XOR + Self-Delete + Decoy Write',
198
+ severity: 'CRITICAL',
199
+ confidence: 'high',
200
+ description: 'Compound AST pattern: XOR loop with literal-derived operand + fs.unlink/rename of self file + fs.writeFile to a decoy extension (.md/.bak/.tmp/.txt/.log) all in the same source file. Catches the Axios npm 2026-03 setup.js dropper and the csec autodelete family even when the XOR key string is rotated.',
201
+ references: [
202
+ 'https://gist.github.com/N3mes1s/0c0fc7a0c23cdb5e1c8f66b208053ed6',
203
+ 'https://unit42.paloaltonetworks.com/axios-supply-chain-attack/'
204
+ ],
205
+ mitre: 'T1140'
206
+ },
207
+ anti_forensic_partial: {
208
+ id: 'MUADDIB-AF-002',
209
+ name: 'Anti-Forensic Partial (2 of 3 patterns)',
210
+ severity: 'HIGH',
211
+ confidence: 'medium',
212
+ description: '2 of 3 anti-forensic patterns in a single file (XOR loop, self-delete, decoy write). Insufficient for CRITICAL alone but elevates a package that already shows other signals.',
213
+ references: [
214
+ 'https://gist.github.com/N3mes1s/0c0fc7a0c23cdb5e1c8f66b208053ed6'
215
+ ],
216
+ mitre: 'T1140'
217
+ },
218
+ stub_package_external_payload: {
219
+ id: 'MUADDIB-STUB-001',
220
+ name: 'Stub Package + External URL Dep + Lifecycle Hook',
221
+ severity: 'CRITICAL',
222
+ confidence: 'high',
223
+ description: 'Package main file is essentially empty AND declares a non-npm-registry URL dependency AND has an install lifecycle hook. The malicious payload lives in the resolved external dep, not the published tarball. Closes the ltidi chain attack class that bypassed ADR_THRESHOLD=20.',
224
+ references: [
225
+ 'project_detection_gap_ltidi_chain memory entry',
226
+ 'https://blog.gitguardian.com/three-supply-chain-campaigns-hit-npm-pypi-and-docker-hub-in-48-hours/'
227
+ ],
228
+ mitre: 'T1195.002'
229
+ },
230
+ stub_package_external_dep: {
231
+ id: 'MUADDIB-STUB-002',
232
+ name: 'Stub Package + External URL Dep (no lifecycle)',
233
+ severity: 'HIGH',
234
+ confidence: 'medium',
235
+ description: 'Package main file is essentially empty AND declares a non-npm-registry URL dependency. No lifecycle hook so the payload requires an explicit require() — manual review still warranted because legitimate libs that pull a payload via URL would re-export the dep.',
236
+ references: [
237
+ 'project_detection_gap_ltidi_chain memory entry'
238
+ ],
239
+ mitre: 'T1195.002'
240
+ },
241
+ axios_family: {
242
+ id: 'MUADDIB-COMPOUND-AXIOS',
243
+ name: 'Axios / csec Family Compound',
244
+ severity: 'CRITICAL',
245
+ confidence: 'high',
246
+ description: 'Compound: IOC string match + lifecycle hook + anti-forensic partial pattern. Identifies the BlueNoroff/Sapphire Sleet Axios family and the csec autodelete family at a single glance.',
247
+ references: [
248
+ 'https://gist.github.com/N3mes1s/0c0fc7a0c23cdb5e1c8f66b208053ed6',
249
+ 'https://unit42.paloaltonetworks.com/axios-supply-chain-attack/'
250
+ ],
251
+ mitre: 'T1195.002'
252
+ },
253
+ stub_with_string_ioc: {
254
+ id: 'MUADDIB-COMPOUND-STUB-IOC',
255
+ name: 'Stub Package + Known String IOC',
256
+ severity: 'CRITICAL',
257
+ confidence: 'high',
258
+ description: 'Compound: stub package (small main, external URL dep) AND a known string IOC in source. Unambiguous chain-attack staging.',
259
+ references: [
260
+ 'project_detection_gap_ltidi_chain memory entry'
261
+ ],
262
+ mitre: 'T1195.002'
263
+ },
181
264
  lifecycle_script_dependency: {
182
265
  id: 'MUADDIB-DEP-004',
183
266
  name: 'Lifecycle Script in Dependency',