muaddib-scanner 2.10.41 → 2.10.43
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/monitor/classify.js +2 -1
- package/src/monitor/daemon.js +45 -10
- package/src/monitor/ingestion.js +101 -0
- package/src/monitor/queue.js +29 -9
- package/src/response/playbooks.js +9 -0
- package/src/rules/index.js +24 -0
package/package.json
CHANGED
package/src/monitor/classify.js
CHANGED
|
@@ -56,7 +56,8 @@ const HIGH_CONFIDENCE_MALICE_TYPES = new Set([
|
|
|
56
56
|
'systemd_persistence', // writeFile to systemd/ or systemctl enable (CanisterWorm T1543.002)
|
|
57
57
|
'npm_token_steal', // exec("npm config get _authToken") (CanisterWorm findNpmTokens)
|
|
58
58
|
'root_filesystem_wipe', // rm -rf / (CanisterWorm kamikaze.sh wiper T1485)
|
|
59
|
-
'proc_mem_scan'
|
|
59
|
+
'proc_mem_scan', // /proc/mem scanning (TeamPCP Trivy credential stealer)
|
|
60
|
+
'trusted_new_unknown_dependency' // TRUSTED package added unknown/new (<7d) dependency (account takeover)
|
|
60
61
|
]);
|
|
61
62
|
|
|
62
63
|
// Lifecycle compound types that indicate real malicious intent beyond a simple postinstall
|
package/src/monitor/daemon.js
CHANGED
|
@@ -12,6 +12,8 @@ const { processQueue, SCAN_CONCURRENCY } = require('./queue.js');
|
|
|
12
12
|
const { startHealthcheck } = require('./healthcheck.js');
|
|
13
13
|
|
|
14
14
|
const POLL_INTERVAL = 60_000;
|
|
15
|
+
const PROCESS_LOOP_INTERVAL = 2_000; // Queue check interval when empty
|
|
16
|
+
const QUEUE_WARNING_THRESHOLD = 5_000; // Warn if queue depth exceeds this
|
|
15
17
|
|
|
16
18
|
function sleep(ms) {
|
|
17
19
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -171,15 +173,21 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
171
173
|
console.log('[MONITOR] npm changes stream enabled (replicate.npmjs.com) with RSS fallback');
|
|
172
174
|
console.log(`[MONITOR] Scan concurrency: ${SCAN_CONCURRENCY} (MUADDIB_SCAN_CONCURRENCY to override)`);
|
|
173
175
|
console.log(`[MONITOR] Sandbox concurrency: ${SANDBOX_CONCURRENCY_MAX} (MUADDIB_SANDBOX_CONCURRENCY to override)`);
|
|
174
|
-
console.log(`[MONITOR] Polling every ${POLL_INTERVAL / 1000}s. Ctrl+C to stop.\n`);
|
|
176
|
+
console.log(`[MONITOR] Polling every ${POLL_INTERVAL / 1000}s (decoupled from processing). Ctrl+C to stop.\n`);
|
|
175
177
|
|
|
176
178
|
let running = true;
|
|
179
|
+
let pollIntervalHandle = null; // Decoupled poll timer — set after initial poll
|
|
177
180
|
|
|
178
181
|
// Graceful shutdown handler (shared by SIGINT and SIGTERM)
|
|
179
182
|
// Daily report is NEVER sent on shutdown — it only fires at 08:00 Paris time.
|
|
180
183
|
// Counters are persisted to disk so they survive the restart.
|
|
181
184
|
async function gracefulShutdown(signal) {
|
|
182
185
|
console.log(`\n[MONITOR] Received ${signal} — shutting down...`);
|
|
186
|
+
running = false;
|
|
187
|
+
if (pollIntervalHandle) {
|
|
188
|
+
clearInterval(pollIntervalHandle);
|
|
189
|
+
pollIntervalHandle = null;
|
|
190
|
+
}
|
|
183
191
|
healthcheck.stop();
|
|
184
192
|
// Flush all pending scope groups before exit
|
|
185
193
|
for (const [scope, group] of pendingGrouped) {
|
|
@@ -191,25 +199,47 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
191
199
|
saveState(state, stats);
|
|
192
200
|
reportStats(stats);
|
|
193
201
|
console.log('[MONITOR] State saved. Bye!');
|
|
194
|
-
running = false;
|
|
195
202
|
process.exit(0);
|
|
196
203
|
}
|
|
197
204
|
|
|
198
205
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
199
206
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
200
207
|
|
|
201
|
-
// Initial poll + scan
|
|
208
|
+
// Initial poll + scan (sequential for first run)
|
|
202
209
|
await poll(state, scanQueue, stats);
|
|
203
210
|
saveState(state, stats);
|
|
204
211
|
await processQueue(scanQueue, stats, dailyAlerts, recentlyScanned, downloadsCache, sandboxAvailableRef.value);
|
|
205
212
|
|
|
206
|
-
//
|
|
213
|
+
// ─── Decoupled polling ───
|
|
214
|
+
// Poll runs on its own interval, independent of processing.
|
|
215
|
+
// This ensures new packages are ingested even while a large batch is being scanned.
|
|
216
|
+
// Without this, a 2h processing batch blocks all polling — packages published and
|
|
217
|
+
// removed during that window are never seen (e.g. axios/plain-crypto-js 2026-03-30).
|
|
218
|
+
let pollInProgress = false;
|
|
219
|
+
pollIntervalHandle = setInterval(async () => {
|
|
220
|
+
if (!running || pollInProgress) return;
|
|
221
|
+
pollInProgress = true;
|
|
222
|
+
try {
|
|
223
|
+
await poll(state, scanQueue, stats);
|
|
224
|
+
saveState(state, stats);
|
|
225
|
+
if (scanQueue.length > QUEUE_WARNING_THRESHOLD) {
|
|
226
|
+
console.log(`[MONITOR] WARNING: scan queue depth ${scanQueue.length} — processing may be lagging behind ingestion`);
|
|
227
|
+
}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error('[MONITOR] Poll error (interval):', err.message);
|
|
230
|
+
} finally {
|
|
231
|
+
pollInProgress = false;
|
|
232
|
+
}
|
|
233
|
+
}, POLL_INTERVAL);
|
|
234
|
+
|
|
235
|
+
// ─── Continuous processing loop ───
|
|
236
|
+
// Consumes scanQueue independently of polling. Workers inside processQueue
|
|
237
|
+
// check scanQueue.length > 0 after each item, so items added by a concurrent
|
|
238
|
+
// poll are picked up immediately by running workers.
|
|
207
239
|
while (running) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
saveState(state, stats);
|
|
212
|
-
await processQueue(scanQueue, stats, dailyAlerts, recentlyScanned, downloadsCache, sandboxAvailableRef.value);
|
|
240
|
+
if (scanQueue.length > 0) {
|
|
241
|
+
await processQueue(scanQueue, stats, dailyAlerts, recentlyScanned, downloadsCache, sandboxAvailableRef.value);
|
|
242
|
+
}
|
|
213
243
|
|
|
214
244
|
// Hourly stats report + cache purge
|
|
215
245
|
if (Date.now() - stats.lastReportTime >= 3600_000) {
|
|
@@ -221,6 +251,9 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
221
251
|
if (isDailyReportDue(stats)) {
|
|
222
252
|
await sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCache);
|
|
223
253
|
}
|
|
254
|
+
|
|
255
|
+
// Short pause before re-checking queue — yields event loop for poll interval
|
|
256
|
+
await sleep(PROCESS_LOOP_INTERVAL);
|
|
224
257
|
}
|
|
225
258
|
}
|
|
226
259
|
|
|
@@ -231,5 +264,7 @@ module.exports = {
|
|
|
231
264
|
reportStats,
|
|
232
265
|
isDailyReportDue,
|
|
233
266
|
sleep,
|
|
234
|
-
POLL_INTERVAL
|
|
267
|
+
POLL_INTERVAL,
|
|
268
|
+
PROCESS_LOOP_INTERVAL,
|
|
269
|
+
QUEUE_WARNING_THRESHOLD
|
|
235
270
|
};
|
package/src/monitor/ingestion.js
CHANGED
|
@@ -81,6 +81,105 @@ async function getWeeklyDownloads(packageName) {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// --- Trusted dependency diff check ---
|
|
85
|
+
|
|
86
|
+
const TRUSTED_DEP_AGE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check for new dependencies added to a TRUSTED (popular) package.
|
|
90
|
+
* Detects supply-chain attacks where a compromised maintainer account adds a
|
|
91
|
+
* malicious dependency in a patch bump (e.g., axios 1.14.0 → 1.14.1 adding
|
|
92
|
+
* plain-crypto-js, 2026-03-30).
|
|
93
|
+
*
|
|
94
|
+
* @param {string} name - Package name
|
|
95
|
+
* @param {string} newVersion - Newly published version
|
|
96
|
+
* @returns {Array} Array of findings (empty if no new deps or on error)
|
|
97
|
+
*/
|
|
98
|
+
async function checkTrustedDepDiff(name, newVersion) {
|
|
99
|
+
const findings = [];
|
|
100
|
+
try {
|
|
101
|
+
// Fetch packument to get version list and dependencies
|
|
102
|
+
const body = await httpsGet(`https://registry.npmjs.org/${encodeURIComponent(name)}`, 10_000);
|
|
103
|
+
const packument = JSON.parse(body);
|
|
104
|
+
|
|
105
|
+
if (!packument.versions || !packument.time) return findings;
|
|
106
|
+
|
|
107
|
+
// Sort versions by publish time (not semver — handles prereleases correctly)
|
|
108
|
+
const timeMap = packument.time;
|
|
109
|
+
const versionKeys = Object.keys(packument.versions)
|
|
110
|
+
.filter(v => timeMap[v])
|
|
111
|
+
.sort((a, b) => new Date(timeMap[a]) - new Date(timeMap[b]));
|
|
112
|
+
|
|
113
|
+
const newIdx = versionKeys.indexOf(newVersion);
|
|
114
|
+
if (newIdx <= 0) return findings; // First version or not found
|
|
115
|
+
|
|
116
|
+
const prevVersion = versionKeys[newIdx - 1];
|
|
117
|
+
|
|
118
|
+
const prevDeps = (packument.versions[prevVersion] && packument.versions[prevVersion].dependencies) || {};
|
|
119
|
+
const newDeps = (packument.versions[newVersion] && packument.versions[newVersion].dependencies) || {};
|
|
120
|
+
|
|
121
|
+
// Find newly added dependencies (name not present in previous version)
|
|
122
|
+
const addedDeps = Object.keys(newDeps).filter(dep => !(dep in prevDeps));
|
|
123
|
+
if (addedDeps.length === 0) return findings;
|
|
124
|
+
|
|
125
|
+
console.log(`[MONITOR] TRUSTED dep diff: ${name} ${prevVersion} → ${newVersion}: +${addedDeps.length} new dep(s): ${addedDeps.join(', ')}`);
|
|
126
|
+
|
|
127
|
+
for (const dep of addedDeps) {
|
|
128
|
+
let ageMs = null;
|
|
129
|
+
try {
|
|
130
|
+
const depBody = await httpsGet(`https://registry.npmjs.org/${encodeURIComponent(dep)}`, 5_000);
|
|
131
|
+
const depData = JSON.parse(depBody);
|
|
132
|
+
const created = depData.time && depData.time.created;
|
|
133
|
+
if (created) {
|
|
134
|
+
ageMs = Date.now() - new Date(created).getTime();
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.log(`[MONITOR] WARNING: could not check age of dependency ${dep}: ${err.message}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (ageMs === null || ageMs < TRUSTED_DEP_AGE_THRESHOLD_MS) {
|
|
141
|
+
// Unknown or < 7 days old — CRITICAL
|
|
142
|
+
const ageDays = ageMs !== null ? Math.floor(ageMs / 86400000) : 'unknown';
|
|
143
|
+
findings.push({
|
|
144
|
+
type: 'trusted_new_unknown_dependency',
|
|
145
|
+
severity: 'CRITICAL',
|
|
146
|
+
confidence: ageMs === null ? 'medium' : 'high',
|
|
147
|
+
file: 'package.json',
|
|
148
|
+
message: `TRUSTED package ${name} added unknown dependency ${dep} (age: ${ageDays}d) in version ${prevVersion} → ${newVersion}`,
|
|
149
|
+
rule_id: 'MUADDIB-TRUSTED-001',
|
|
150
|
+
mitre: 'T1195.002',
|
|
151
|
+
dep,
|
|
152
|
+
depAgeDays: ageDays,
|
|
153
|
+
prevVersion,
|
|
154
|
+
newVersion
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
// Known dependency (>= 7 days old) — HIGH
|
|
158
|
+
const ageDays = Math.floor(ageMs / 86400000);
|
|
159
|
+
findings.push({
|
|
160
|
+
type: 'trusted_new_dependency',
|
|
161
|
+
severity: 'HIGH',
|
|
162
|
+
confidence: 'medium',
|
|
163
|
+
file: 'package.json',
|
|
164
|
+
message: `TRUSTED package ${name} added new dependency ${dep} (age: ${ageDays}d) in version ${prevVersion} → ${newVersion}`,
|
|
165
|
+
rule_id: 'MUADDIB-TRUSTED-002',
|
|
166
|
+
mitre: 'T1195.002',
|
|
167
|
+
dep,
|
|
168
|
+
depAgeDays: ageDays,
|
|
169
|
+
prevVersion,
|
|
170
|
+
newVersion
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return findings;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// Graceful fallback — log warning, continue as TRUSTED
|
|
178
|
+
console.log(`[MONITOR] WARNING: trusted dep diff check failed for ${name}@${newVersion}: ${err.message}`);
|
|
179
|
+
return findings;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
84
183
|
// --- Tarball URL helpers ---
|
|
85
184
|
|
|
86
185
|
function getNpmTarballUrl(pkgData) {
|
|
@@ -583,6 +682,8 @@ module.exports = {
|
|
|
583
682
|
// HTTP helpers
|
|
584
683
|
httpsGet,
|
|
585
684
|
getWeeklyDownloads,
|
|
685
|
+
checkTrustedDepDiff,
|
|
686
|
+
TRUSTED_DEP_AGE_THRESHOLD_MS,
|
|
586
687
|
|
|
587
688
|
// Tarball URL helpers
|
|
588
689
|
getNpmTarballUrl,
|
package/src/monitor/queue.js
CHANGED
|
@@ -98,7 +98,7 @@ const {
|
|
|
98
98
|
} = require('./temporal.js');
|
|
99
99
|
|
|
100
100
|
// From ./ingestion.js (will be created — currently in monitor.js)
|
|
101
|
-
const { getNpmLatestTarball, getPyPITarballUrl, getWeeklyDownloads } = require('./ingestion.js');
|
|
101
|
+
const { getNpmLatestTarball, getPyPITarballUrl, getWeeklyDownloads, checkTrustedDepDiff } = require('./ingestion.js');
|
|
102
102
|
|
|
103
103
|
// From ./tarball-archive.js
|
|
104
104
|
const { archiveSuspectTarball } = require('./tarball-archive.js');
|
|
@@ -518,14 +518,34 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
518
518
|
if (ecosystem === 'npm' && !hasIOCMatch(result) && !hasTyposquat(result) && !hasHighOrCritical(result)) {
|
|
519
519
|
const downloads = await getWeeklyDownloads(name);
|
|
520
520
|
if (downloads >= POPULAR_THRESHOLD) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
521
|
+
// Dependency diff check: detect supply-chain injection on TRUSTED packages
|
|
522
|
+
// (e.g., axios 1.14.0 → 1.14.1 adding unknown plain-crypto-js, 2026-03-30)
|
|
523
|
+
const trustedFindings = await checkTrustedDepDiff(name, version);
|
|
524
|
+
const hasCriticalDepFinding = trustedFindings.some(f => f.severity === 'CRITICAL');
|
|
525
|
+
|
|
526
|
+
if (hasCriticalDepFinding) {
|
|
527
|
+
// CRITICAL: unknown/new dependency — bypass TRUSTED, route to full scan + sandbox
|
|
528
|
+
console.log(`[MONITOR] TRUSTED BYPASS: ${name}@${version} — new unknown dependency detected, routing to full scan`);
|
|
529
|
+
result.threats.push(...trustedFindings);
|
|
530
|
+
for (const f of trustedFindings) {
|
|
531
|
+
if (f.severity === 'CRITICAL') result.summary.critical = (result.summary.critical || 0) + 1;
|
|
532
|
+
else if (f.severity === 'HIGH') result.summary.high = (result.summary.high || 0) + 1;
|
|
533
|
+
}
|
|
534
|
+
// Fall through to full classification below (do NOT return)
|
|
535
|
+
} else {
|
|
536
|
+
// No CRITICAL dep findings — normal TRUSTED skip (log HIGH findings if any)
|
|
537
|
+
for (const f of trustedFindings) {
|
|
538
|
+
console.log(`[MONITOR] TRUSTED dep change: ${f.message}`);
|
|
539
|
+
}
|
|
540
|
+
stats.scanned++;
|
|
541
|
+
const elapsed = Date.now() - startTime;
|
|
542
|
+
stats.totalTimeMs += elapsed;
|
|
543
|
+
stats.clean++;
|
|
544
|
+
console.log(`[MONITOR] TRUSTED (popular): ${name}@${version} (${Math.round(downloads / 1000)}k downloads/week, ${counts.join(', ')})`);
|
|
545
|
+
updateScanStats('clean');
|
|
546
|
+
recordTrainingSample(result, { name, version, ecosystem, label: 'clean', registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
|
|
547
|
+
return { sandboxResult: null, staticClean: true };
|
|
548
|
+
}
|
|
529
549
|
}
|
|
530
550
|
}
|
|
531
551
|
|
|
@@ -829,6 +829,15 @@ const PLAYBOOKS = {
|
|
|
829
829
|
lifecycle_missing_script:
|
|
830
830
|
'CRITIQUE: Script lifecycle reference un fichier inexistant dans le package. Script fantome. ' +
|
|
831
831
|
'Le payload peut etre injecte dynamiquement ou lors d\'une mise a jour. Installer avec --ignore-scripts. Supprimer le package.',
|
|
832
|
+
|
|
833
|
+
trusted_new_unknown_dependency:
|
|
834
|
+
'CRITIQUE: Package populaire (TRUSTED) a ajoute une dependance inconnue ou tres recente (<7 jours). ' +
|
|
835
|
+
'Indicateur de compromission de compte mainteneur (supply-chain attack). Bloquer la mise a jour. ' +
|
|
836
|
+
'Verifier le changelog, les commits recents, et contacter le mainteneur. Inspecter la nouvelle dependance.',
|
|
837
|
+
|
|
838
|
+
trusted_new_dependency:
|
|
839
|
+
'HAUTE: Package populaire (TRUSTED) a ajoute une nouvelle dependance connue. ' +
|
|
840
|
+
'Verifier le changelog et la legitimite de l\'ajout. Pas de blocage immediat mais surveillance renforcee.',
|
|
832
841
|
};
|
|
833
842
|
|
|
834
843
|
function getPlaybook(threatType) {
|
package/src/rules/index.js
CHANGED
|
@@ -2206,6 +2206,30 @@ const RULES = {
|
|
|
2206
2206
|
],
|
|
2207
2207
|
mitre: 'T1195.002'
|
|
2208
2208
|
},
|
|
2209
|
+
// Trusted dependency diff detections (monitor-only)
|
|
2210
|
+
trusted_new_unknown_dependency: {
|
|
2211
|
+
id: 'MUADDIB-TRUSTED-001',
|
|
2212
|
+
name: 'Trusted Package Added Unknown Dependency',
|
|
2213
|
+
severity: 'CRITICAL',
|
|
2214
|
+
confidence: 'high',
|
|
2215
|
+
description: 'Un package TRUSTED (>50k downloads/semaine) a ajoute une nouvelle dependance inconnue ou tres recente (<7 jours) — indicateur de compromission de compte mainteneur (supply-chain attack type axios/plain-crypto-js).',
|
|
2216
|
+
references: [
|
|
2217
|
+
'https://attack.mitre.org/techniques/T1195.002/',
|
|
2218
|
+
'https://blog.sonatype.com/malicious-npm-packages-targeting-popular-libraries'
|
|
2219
|
+
],
|
|
2220
|
+
mitre: 'T1195.002'
|
|
2221
|
+
},
|
|
2222
|
+
trusted_new_dependency: {
|
|
2223
|
+
id: 'MUADDIB-TRUSTED-002',
|
|
2224
|
+
name: 'Trusted Package Added New Dependency',
|
|
2225
|
+
severity: 'HIGH',
|
|
2226
|
+
confidence: 'medium',
|
|
2227
|
+
description: 'Un package TRUSTED (>50k downloads/semaine) a ajoute une nouvelle dependance connue (>7 jours) dans un bump de version — changement de surface d\'attaque a verifier.',
|
|
2228
|
+
references: [
|
|
2229
|
+
'https://attack.mitre.org/techniques/T1195.002/'
|
|
2230
|
+
],
|
|
2231
|
+
mitre: 'T1195.002'
|
|
2232
|
+
},
|
|
2209
2233
|
};
|
|
2210
2234
|
|
|
2211
2235
|
function getRule(type) {
|