muaddib-scanner 2.10.42 → 2.10.44
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 +6 -6
- package/package.json +1 -1
- package/src/monitor/classify.js +2 -1
- package/src/monitor/daemon.js +108 -3
- 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/README.md
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
npm and PyPI supply-chain attacks are exploding. Shai-Hulud compromised 25K+ repos in 2025. Existing tools detect threats but don't help you respond.
|
|
32
32
|
|
|
33
|
-
MUAD'DIB combines **14 parallel scanners** (
|
|
33
|
+
MUAD'DIB combines **14 parallel scanners** (200 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring**, **ML classifiers** (XGBoost), and gVisor/Docker sandbox to detect known threats and suspicious behavioral patterns in npm and PyPI packages.
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
@@ -195,7 +195,7 @@ muaddib replay # Ground truth validation (46/49 TPR)
|
|
|
195
195
|
| GitHub Actions | Shai-Hulud backdoor detection |
|
|
196
196
|
| Hash Scanner | Known malicious file hashes |
|
|
197
197
|
|
|
198
|
-
###
|
|
198
|
+
### 200 detection rules
|
|
199
199
|
|
|
200
200
|
All rules are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v21021) for the complete rules reference.
|
|
201
201
|
|
|
@@ -271,7 +271,7 @@ With pre-commit framework:
|
|
|
271
271
|
```yaml
|
|
272
272
|
repos:
|
|
273
273
|
- repo: https://github.com/DNSZLSK/muad-dib
|
|
274
|
-
rev: v2.10.
|
|
274
|
+
rev: v2.10.43
|
|
275
275
|
hooks:
|
|
276
276
|
- id: muaddib-scan
|
|
277
277
|
```
|
|
@@ -288,7 +288,7 @@ repos:
|
|
|
288
288
|
| **FPR** (Benign random) | **7.5%** (15/200) | 200 random npm packages, stratified sampling |
|
|
289
289
|
| **ADR** (Adversarial + Holdout) | **94.0%** (101/107) | 67 adversarial + 40 holdout (107 available on disk), global threshold=20 |
|
|
290
290
|
|
|
291
|
-
**
|
|
291
|
+
**3034 tests** across 65 files. **200 rules** (195 RULES + 5 PARANOID).
|
|
292
292
|
|
|
293
293
|
> **Methodology caveats:**
|
|
294
294
|
> - TPR measured on 49 Node.js attack samples (3 browser-only excluded from 51 total)
|
|
@@ -329,7 +329,7 @@ npm test
|
|
|
329
329
|
|
|
330
330
|
### Testing
|
|
331
331
|
|
|
332
|
-
- **
|
|
332
|
+
- **3034 tests** across 65 modular test files
|
|
333
333
|
- **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
|
|
334
334
|
- **Datadog 17K benchmark** - 14,587 confirmed malware samples (in-scope)
|
|
335
335
|
- **Ground truth validation** - 51 real-world attacks (93.9% TPR)
|
|
@@ -351,7 +351,7 @@ npm test
|
|
|
351
351
|
- [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) - Experimental protocol, holdout scores
|
|
352
352
|
- [Threat Model](docs/threat-model.md) - What MUAD'DIB detects and doesn't detect
|
|
353
353
|
- [Adversarial Evaluation](ADVERSARIAL.md) - Red team samples and ADR results
|
|
354
|
-
- [Security Policy](SECURITY.md) - Detection rules reference (
|
|
354
|
+
- [Security Policy](SECURITY.md) - Detection rules reference (200 rules)
|
|
355
355
|
- [Security Audit](docs/SECURITY_AUDIT.md) - Bypass validation report
|
|
356
356
|
- [FP Analysis](docs/EVALUATION.md) - Historical false positive analysis
|
|
357
357
|
|
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
|
@@ -4,7 +4,7 @@ const path = require('path');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const { isDockerAvailable, SANDBOX_CONCURRENCY_MAX } = require('../sandbox/index.js');
|
|
6
6
|
const { setVerboseMode, isSandboxEnabled, isCanaryEnabled, isLlmDetectiveEnabled, getLlmDetectiveMode } = require('./classify.js');
|
|
7
|
-
const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, getParisHour } = require('./state.js');
|
|
7
|
+
const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, getParisHour, atomicWriteFileSync } = require('./state.js');
|
|
8
8
|
const { isTemporalEnabled, isTemporalAstEnabled, isTemporalPublishEnabled, isTemporalMaintainerEnabled } = require('./temporal.js');
|
|
9
9
|
const { pendingGrouped, flushScopeGroup, sendDailyReport, DAILY_REPORT_HOUR } = require('./webhook.js');
|
|
10
10
|
const { poll } = require('./ingestion.js');
|
|
@@ -14,11 +14,88 @@ const { startHealthcheck } = require('./healthcheck.js');
|
|
|
14
14
|
const POLL_INTERVAL = 60_000;
|
|
15
15
|
const PROCESS_LOOP_INTERVAL = 2_000; // Queue check interval when empty
|
|
16
16
|
const QUEUE_WARNING_THRESHOLD = 5_000; // Warn if queue depth exceeds this
|
|
17
|
+
const QUEUE_PERSIST_INTERVAL = 60_000; // Persist queue to disk every 60s
|
|
18
|
+
const QUEUE_STATE_FILE = path.join(__dirname, '..', '..', 'data', 'queue-state.json');
|
|
19
|
+
const QUEUE_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h expiry
|
|
20
|
+
const MAX_QUEUE_PERSIST_SIZE = 100_000; // Don't persist if queue > 100K items
|
|
17
21
|
|
|
18
22
|
function sleep(ms) {
|
|
19
23
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Persist scanQueue to disk so it survives restarts.
|
|
28
|
+
* Uses atomicWriteFileSync (write-to-tmp + rename) for crash safety.
|
|
29
|
+
* Skips if queue is empty or exceeds MAX_QUEUE_PERSIST_SIZE.
|
|
30
|
+
*/
|
|
31
|
+
function persistQueue(scanQueue, state) {
|
|
32
|
+
try {
|
|
33
|
+
if (scanQueue.length === 0) {
|
|
34
|
+
// Empty queue — remove stale file if it exists
|
|
35
|
+
try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (scanQueue.length > MAX_QUEUE_PERSIST_SIZE) {
|
|
39
|
+
console.log(`[MONITOR] WARNING: queue too large to persist (${scanQueue.length} > ${MAX_QUEUE_PERSIST_SIZE})`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const payload = JSON.stringify({
|
|
43
|
+
savedAt: new Date().toISOString(),
|
|
44
|
+
lastSeq: state.npmLastSeq || null,
|
|
45
|
+
count: scanQueue.length,
|
|
46
|
+
items: scanQueue
|
|
47
|
+
});
|
|
48
|
+
atomicWriteFileSync(QUEUE_STATE_FILE, payload);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error('[MONITOR] Failed to persist queue:', err.message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Restore scanQueue from disk on boot. Items are appended to the (empty) scanQueue.
|
|
56
|
+
* File is deleted after successful restore to prevent double-restore.
|
|
57
|
+
* Skips if file is missing, corrupt, or older than 24h.
|
|
58
|
+
*/
|
|
59
|
+
function restoreQueue(scanQueue) {
|
|
60
|
+
try {
|
|
61
|
+
if (!fs.existsSync(QUEUE_STATE_FILE)) return 0;
|
|
62
|
+
const raw = fs.readFileSync(QUEUE_STATE_FILE, 'utf8');
|
|
63
|
+
const data = JSON.parse(raw);
|
|
64
|
+
|
|
65
|
+
// Validate structure
|
|
66
|
+
if (!data || !Array.isArray(data.items) || !data.savedAt) {
|
|
67
|
+
console.log('[MONITOR] Queue state file invalid — ignoring');
|
|
68
|
+
try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check age — discard if > 24h
|
|
73
|
+
const ageMs = Date.now() - new Date(data.savedAt).getTime();
|
|
74
|
+
if (ageMs > QUEUE_STATE_MAX_AGE_MS) {
|
|
75
|
+
console.log(`[MONITOR] Queue state expired (${Math.round(ageMs / 3600000)}h old) — ignoring`);
|
|
76
|
+
try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Restore items
|
|
81
|
+
const count = data.items.length;
|
|
82
|
+
if (count === 0) {
|
|
83
|
+
try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
scanQueue.push(...data.items);
|
|
87
|
+
console.log(`[MONITOR] Restored ${count} packages from queue state (saved at ${data.savedAt})`);
|
|
88
|
+
|
|
89
|
+
// Delete after successful restore
|
|
90
|
+
try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
|
|
91
|
+
return count;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.log(`[MONITOR] WARNING: could not restore queue state: ${err.message}`);
|
|
94
|
+
try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
22
99
|
function cleanupOrphanTmpDirs() {
|
|
23
100
|
const tmpBase = path.join(os.tmpdir(), 'muaddib-monitor');
|
|
24
101
|
try {
|
|
@@ -176,7 +253,14 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
176
253
|
console.log(`[MONITOR] Polling every ${POLL_INTERVAL / 1000}s (decoupled from processing). Ctrl+C to stop.\n`);
|
|
177
254
|
|
|
178
255
|
let running = true;
|
|
179
|
-
let pollIntervalHandle = null;
|
|
256
|
+
let pollIntervalHandle = null; // Decoupled poll timer — set after initial poll
|
|
257
|
+
let queuePersistHandle = null; // Queue persistence timer
|
|
258
|
+
|
|
259
|
+
// Restore queue from previous run (if file exists and is < 24h old)
|
|
260
|
+
const restoredCount = restoreQueue(scanQueue);
|
|
261
|
+
if (restoredCount > 0) {
|
|
262
|
+
console.log(`[MONITOR] ${restoredCount} packages pre-loaded from previous session`);
|
|
263
|
+
}
|
|
180
264
|
|
|
181
265
|
// Graceful shutdown handler (shared by SIGINT and SIGTERM)
|
|
182
266
|
// Daily report is NEVER sent on shutdown — it only fires at 08:00 Paris time.
|
|
@@ -188,6 +272,12 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
188
272
|
clearInterval(pollIntervalHandle);
|
|
189
273
|
pollIntervalHandle = null;
|
|
190
274
|
}
|
|
275
|
+
if (queuePersistHandle) {
|
|
276
|
+
clearInterval(queuePersistHandle);
|
|
277
|
+
queuePersistHandle = null;
|
|
278
|
+
}
|
|
279
|
+
// Persist remaining queue items so they survive the restart
|
|
280
|
+
persistQueue(scanQueue, state);
|
|
191
281
|
healthcheck.stop();
|
|
192
282
|
// Flush all pending scope groups before exit
|
|
193
283
|
for (const [scope, group] of pendingGrouped) {
|
|
@@ -232,6 +322,15 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
232
322
|
}
|
|
233
323
|
}, POLL_INTERVAL);
|
|
234
324
|
|
|
325
|
+
// ─── Queue persistence ───
|
|
326
|
+
// Snapshot queue to disk every 60s so items survive restarts/crashes.
|
|
327
|
+
// Without this, the decoupled poll advances the CouchDB seq but queued
|
|
328
|
+
// items are lost on restart — they won't be re-polled.
|
|
329
|
+
queuePersistHandle = setInterval(() => {
|
|
330
|
+
if (!running) return;
|
|
331
|
+
persistQueue(scanQueue, state);
|
|
332
|
+
}, QUEUE_PERSIST_INTERVAL);
|
|
333
|
+
|
|
235
334
|
// ─── Continuous processing loop ───
|
|
236
335
|
// Consumes scanQueue independently of polling. Workers inside processQueue
|
|
237
336
|
// check scanQueue.length > 0 after each item, so items added by a concurrent
|
|
@@ -264,7 +363,13 @@ module.exports = {
|
|
|
264
363
|
reportStats,
|
|
265
364
|
isDailyReportDue,
|
|
266
365
|
sleep,
|
|
366
|
+
persistQueue,
|
|
367
|
+
restoreQueue,
|
|
267
368
|
POLL_INTERVAL,
|
|
268
369
|
PROCESS_LOOP_INTERVAL,
|
|
269
|
-
QUEUE_WARNING_THRESHOLD
|
|
370
|
+
QUEUE_WARNING_THRESHOLD,
|
|
371
|
+
QUEUE_PERSIST_INTERVAL,
|
|
372
|
+
QUEUE_STATE_FILE,
|
|
373
|
+
QUEUE_STATE_MAX_AGE_MS,
|
|
374
|
+
MAX_QUEUE_PERSIST_SIZE
|
|
270
375
|
};
|
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) {
|