muaddib-scanner 2.11.138 → 2.11.140
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 +29 -63
- package/package.json +5 -5
- package/{self-scan-v2.11.138.json → self-scan-v2.11.140.json} +24 -24
- package/src/scanner/python-ast-detectors/handle-assignment.js +13 -1
- package/src/scanner/python-ast-detectors/handle-call-expression.js +29 -1
- package/src/scanner/python-ast-detectors/taint-tracker.js +86 -1
- package/src/scanner/python-ast.js +22 -1
- package/src/scoring.js +6 -1
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 **
|
|
33
|
+
MUAD'DIB combines **21 parallel scanners** (274 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring** (20 compound rules), and a gVisor/Docker sandbox to detect known threats and suspicious behavioral patterns in npm and PyPI packages. An XGBoost classifier exists in the codebase but is **currently inactive** (see [Evaluation](#evaluation)).
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
@@ -45,6 +45,16 @@ MUAD'DIB is an educational tool and a free first line of defense. It detects **k
|
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
|
+
## Scope
|
|
49
|
+
|
|
50
|
+
**Detects** (npm & PyPI): known-malicious packages (name + SHA256 IOC match), typosquats, install-time RCE (lifecycle `preinstall`/`postinstall`, `curl | sh`, Python import-time, `binding.gyp`), credential read then network exfiltration (intra- and cross-file), obfuscated / high-entropy / stub-loader payloads, binary droppers (`chmod +x` + exec/spawn), and anti-analysis evasion markers.
|
|
51
|
+
|
|
52
|
+
**Out of scope**: browser-only attacks (DOM/`window`, no Node.js API), the *contents* of native binaries / WASM (no binary analysis), zero-day unknown packages (the IOC feed is reactive), and non-npm/PyPI ecosystems (RubyGems, Maven, Go). Determined anti-sandbox fingerprinting and multi-stage remote payloads are known false-negative risks. Full detail: [Threat Model](docs/threat-model.md).
|
|
53
|
+
|
|
54
|
+
**No telemetry.** Your code and scan results never leave your machine — MUAD'DIB only *downloads* threat-intel feeds (`muaddib update`) and, for scoring, reads public npm registry metadata. Webhook alerts are opt-in.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
48
58
|
## Installation
|
|
49
59
|
|
|
50
60
|
### npm (recommended)
|
|
@@ -176,7 +186,7 @@ muaddib replay # Ground truth validation (90/94 TPR@3, v2.11
|
|
|
176
186
|
|
|
177
187
|
## Features
|
|
178
188
|
|
|
179
|
-
###
|
|
189
|
+
### 21 parallel scanners
|
|
180
190
|
|
|
181
191
|
| Scanner | Detection |
|
|
182
192
|
|---------|-----------|
|
|
@@ -201,10 +211,11 @@ muaddib replay # Ground truth validation (90/94 TPR@3, v2.11
|
|
|
201
211
|
| Trusted-Dep-Diff (opt-in) | Diff against trusted dep tarballs from registry (v2.10.x) |
|
|
202
212
|
| Python Source (PYSRC) | Import-time / install-time RCE patterns in `__init__.py` / `setup.py` (v2.11.41 — closes TrapDoor PyPI gap) |
|
|
203
213
|
| Python AST (PYAST) | Tree-sitter-Python AST with taint-aware detectors (v2.11.42+) |
|
|
214
|
+
| Anti-Scanner Injection (ASI) | Prompt-injection text in comments/strings that coerces an LLM code reviewer into a clean verdict or into skipping an obfuscated payload (ASI-001..004, Hades campaign 2026-06) |
|
|
204
215
|
|
|
205
216
|
### 274 detection rules
|
|
206
217
|
|
|
207
|
-
All rules (269 RULES + 5 PARANOID) are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-
|
|
218
|
+
All rules (269 RULES + 5 PARANOID) are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v211139) for the complete rules reference.
|
|
208
219
|
|
|
209
220
|
### Detected campaigns
|
|
210
221
|
|
|
@@ -278,75 +289,30 @@ With pre-commit framework:
|
|
|
278
289
|
```yaml
|
|
279
290
|
repos:
|
|
280
291
|
- repo: https://github.com/DNSZLSK/muad-dib
|
|
281
|
-
rev: v2.11.
|
|
292
|
+
rev: v2.11.139
|
|
282
293
|
hooks:
|
|
283
294
|
- id: muaddib-scan
|
|
284
295
|
```
|
|
285
296
|
|
|
286
297
|
---
|
|
287
298
|
|
|
288
|
-
## Evaluation
|
|
289
|
-
|
|
290
|
-
Latest measurement: **v2.11.48** (2026-05-26, Track D + PyPI download fix). Ground truth holds 96 samples (94 in-scope, 2 out-of-scope protestware). This run measures the full 94 in-scope set after the 2026-05-25 enrichment (Track C synthetic for the new PYSRC/PYAST/AST-092/AICONF-004/PKG-022 rules, Track A real-world tarballs recovered from VPS archive, Track B reconstructions from the in-house security-review benchmark).
|
|
291
|
-
|
|
292
|
-
### Operational metrics (what an operator actually gets)
|
|
293
|
-
|
|
294
|
-
These are the numbers a user gets when running `muaddib scan` against npm or PyPI packages. The pipeline executes scanners + FP caps only — no ML filter is applied (see ML Classifier note below).
|
|
295
|
-
|
|
296
|
-
| Metric | Result | Details |
|
|
297
|
-
|--------|--------|---------|
|
|
298
|
-
| **Wild TPR** (Datadog 17K) | **92.8%** (13,538/14,587 in-scope) | 17,922 packages. 3,335 skipped (no JS). By category: compromised_lib 97.8%, malicious_intent 92.1% — last measurement v2.9.4, independent of GT. |
|
|
299
|
-
| **TPR@3** (detection rate, v2.11.48) | **95.74%** (90/94 in-scope) | Full GT re-measurement. Threshold=3: any signal. 13 PyPI samples (was 0). 4 misses incl. 3 browser-only (lottie-player, polyfill-io, trojanized-jquery). |
|
|
300
|
-
| **TPR@20** (alert rate, v2.11.48) | **88.30%** (83/94 in-scope) | Operational alert threshold=20. **+3.1pp vs v2.11.47** — Track D `recon_exfil_direct_ip` compound (MUADDIB-COMPOUND-016) closed the GT-095 gap (risk 3→50) and boosted GT-091 byvendors / GT-092 heloo131313 through `linux_fingerprint_exec`. |
|
|
301
|
-
| **FPR rules** (Benign curated, v2.11.48 measure) | **1.10%** (6/545 scanned, 548 total) | **Unchanged after Track D** — the new compound + types created zero new FPs (sameFile gate + public-IP-only filter). Drop from 15.6% (v2.10.95) is attributable to FP caps F1-F14 (v2.10.97 → v2.11.31). 6 remaining FPs are real (meteor, prisma, @prisma/client, drizzle-orm, scrypt, liquid). |
|
|
302
|
-
| **FPR** (Benign random, v2.11.48) | **2.50%** (5/200) | 200 random npm packages, unchanged. |
|
|
303
|
-
| **FPR PyPI** (v2.11.48, first honest measurement) | **9.68%** (12/124 scanned, 132 total) | **Track D fixed the PyPI downloader** — removed `pip --no-binary :all:` flag (forced compile of wheel-only packages, timed out 38% of the time) + added `.whl` extraction via `extractArchive()`. Brought 42 previously-skipped giants (numpy/pandas/django/matplotlib/scikit-learn/...) into scope. All 12 FPs cluster at score 25-35: this is the cap-PyPI-35 artifact, not new rule misfires. Lifting the cap (Track E) would drop FPR PyPI to ≈0%. 8 residual fails are >500MB packages (torch, tensorflow, scipy, opencv-python, ansible…) hitting the 30s `PACK_TIMEOUT_MS`. |
|
|
304
|
-
| **ADR** (Adversarial + Holdout, v2.11.48) | **96.26%** (103/107) | 67 adversarial + 40 holdout, global threshold=20. Stable vs v2.10.95. |
|
|
305
|
-
|
|
306
|
-
**4414 tests** across 141 files. **266 rules** (261 RULES + 5 PARANOID; v2.11.67/70 Phantom Gyp added PKG-023 + COMPOUND-017).
|
|
307
|
-
|
|
308
|
-
**Known issues (v2.11.48):**
|
|
309
|
-
- *Cap PyPI à 35/100*: Python samples plafonnent à `riskScore=35` even when `globalRiskScore=100`. Confirmed empirically — all 12 PyPI FPs at score 25-35 (flask 32, django 35, tornado 35, bottle 30, pandas 25, matplotlib 25, plotly 25, bokeh 25, pymongo 35, coverage 32, fabric 35, websockets 35). Lifting the cap will simultaneously drop FPR PyPI to ≈0% and unblock PyPI MALWARE detection at higher thresholds. Track E target.
|
|
310
|
-
|
|
311
|
-
### Operational coverage (v2.11.67-76)
|
|
312
|
-
|
|
313
|
-
The static ground-truth TPR above is measured offline. Since v2.11.67 the monitor also tracks **operational** coverage on live npm/PyPI ingestion:
|
|
314
|
-
- A per-scan **ledger** (`data/scan-ledger.jsonl`) records every scanned package's outcome; `computeLedgerRollup()` produces a 24h rollup (`alertRate`, per-ecosystem). Note: `alertRate` is a throughput signal, **not** detection TPR.
|
|
315
|
-
- An active **GHSA poller** (~15 min; npm, pypi, crates) builds an authoritative "what should we have caught" denominator (`data/ghsa-malware.jsonl`), plus a **feed-health** alarm that fires when an IOC feed silently goes dark.
|
|
316
|
-
- The Phase 5 **coverage-audit** (`scripts/coverage-audit.js`, daily 05:00 UTC) joins that denominator against ledger outcomes + the tarball archive to compute an honest GHSA-denominated **operational TPR** (`alerted / total`), and surfaces `scannedClean` misses as human-gated ground-truth candidates.
|
|
317
|
-
|
|
318
|
-
This operational TPR is the real production detection rate, distinct from the static GT TPR (which has not been re-measured since v2.11.48).
|
|
319
|
-
|
|
320
|
-
### ML Classifier (offline only)
|
|
321
|
-
|
|
322
|
-
`src/ml/classifier.js` is **not wired into `muaddib scan`**. The XGBoost model is currently exercised only by `muaddib evaluate` (offline metric replay) and `muaddib monitor` (LOG-ONLY since 2026-04-08, model collapsed pending retrain — see `src/monitor/queue.js:628`). The v2.11.48 evaluate-time replay shows the same 1.10% FPR (no additional FPs filtered) — kept as a reference for retrain validation, but the published operational FPR is the rules-only number above.
|
|
323
|
-
|
|
324
|
-
> **Static evaluation caveats:**
|
|
325
|
-
> - TPR measured on the full 94 in-scope samples from the 96-sample ground truth (2 out-of-scope protestware GT-005/GT-009 with `min_threats=0`)
|
|
326
|
-
> - TPR@3 = detection rate (any signal); TPR@20 = operational alert threshold
|
|
327
|
-
> - FPR rules measured on 548 curated popular npm packages (not a random sample)
|
|
328
|
-
> - FPR PyPI: 124/132 scanned (8 download fails on >500MB giants — torch/tensorflow/ansible/…). Smaller N than npm.
|
|
329
|
-
> - ADR measured with global threshold (score >= 20) as of v2.6.5
|
|
330
|
-
|
|
331
|
-
See [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) for the full experimental protocol, holdout history, and Datadog benchmark details.
|
|
299
|
+
## Evaluation
|
|
332
300
|
|
|
333
|
-
|
|
301
|
+
Last measured **v2.11.48** (2026-05-26), **rules-only** (the ML classifier is inactive — see below). Ground truth: 94 in-scope real-world attacks + 200 random npm + 124 PyPI + 107 adversarial/holdout.
|
|
334
302
|
|
|
335
|
-
|
|
303
|
+
| Metric | Result |
|
|
304
|
+
|--------|--------|
|
|
305
|
+
| Detection rate (TPR@3) | **95.74%** (90/94) |
|
|
306
|
+
| Alert rate (TPR@20) | **88.30%** (83/94) |
|
|
307
|
+
| FPR — curated npm (548) | **1.10%** (6/545) |
|
|
308
|
+
| FPR — random npm (200) | **2.50%** (5/200) |
|
|
309
|
+
| FPR — PyPI (132) | **9.68%** (12/124) |
|
|
310
|
+
| ADR — adversarial + holdout | **96.26%** (103/107) |
|
|
311
|
+
| Wild TPR (Datadog 17K) | **92.8%** (13,538/14,587) |
|
|
336
312
|
|
|
337
|
-
|
|
338
|
-
|--------|--------|---------|
|
|
339
|
-
| **ML FPR** | **2.85%** (239/8,393 holdout) | XGBoost retrained on 56,564 samples, 64 features, threshold=0.710 |
|
|
340
|
-
| **ML TPR** | **99.93%** (2,918/2,920 holdout) | 377 confirmed_malicious via OSSF/GHSA/npm correlation |
|
|
341
|
-
| **FPR after ML T1** (offline replay, v2.11.48) | **1.10%** (6/545 scanned) | Classifier filters 0/6 raw FPs in this run (filtered 1 at v2.11.47). Not applied during real scans — `muaddib scan` never invokes the classifier. |
|
|
313
|
+
**ML classifier: inactive.** An XGBoost model lives in `src/ml/` but is never wired into `muaddib scan`, and runs LOG-ONLY in the monitor since 2026-04-08 (the trained model collapsed, pending retrain). All numbers above are rules-only.
|
|
342
314
|
|
|
343
|
-
|
|
344
|
-
> - Ground truth: 377 confirmed_malicious via auto-labeler (OSSF malicious-packages, GitHub Advisory Database, npm registry takedown correlation)
|
|
345
|
-
> - Dataset: 56,564 samples (14,602 malicious, 41,962 clean). Stratified 80/20 split
|
|
346
|
-
> - Grid search: depth=4, estimators=300, lr=0.05. AUC-ROC=0.999, F1=0.960
|
|
347
|
-
> - Leaky feature filter: 23 dead/leaky features removed (source-identity proxies)
|
|
348
|
-
>
|
|
349
|
-
> The shadow model continues to log predictions in `muaddib monitor` for retraining validation. When the next model passes shadow validation, the LOG-ONLY guard in `src/monitor/queue.js:660` will be flipped and the metrics above will move back into the operational table.
|
|
315
|
+
Full protocol, per-track history, the PyPI cap-35 caveat, operational (GHSA-denominated) coverage, and the ML retrain methodology: [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md).
|
|
350
316
|
|
|
351
317
|
---
|
|
352
318
|
|
|
@@ -380,7 +346,7 @@ npm test
|
|
|
380
346
|
|
|
381
347
|
### Testing
|
|
382
348
|
|
|
383
|
-
- **
|
|
349
|
+
- **4500 tests** across 147 modular test files
|
|
384
350
|
- **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
|
|
385
351
|
- **Datadog 17K benchmark** - 14,587 confirmed malware samples (in-scope)
|
|
386
352
|
- **Ground truth validation** - 96 real-world attacks (95.74% TPR@3, 88.30% TPR@20 — v2.11.48 full measure on 94 in-scope)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.140",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm & PyPI/Python",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -48,16 +48,16 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@inquirer/prompts": "8.5.2",
|
|
51
|
-
"acorn": "8.
|
|
51
|
+
"acorn": "8.17.0",
|
|
52
52
|
"acorn-walk": "8.3.5",
|
|
53
53
|
"adm-zip": "0.5.17",
|
|
54
|
-
"js-yaml": "
|
|
54
|
+
"js-yaml": "5.1.0",
|
|
55
55
|
"web-tree-sitter": "^0.26.9"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@eslint/js": "10.0.1",
|
|
59
|
-
"eslint": "10.
|
|
59
|
+
"eslint": "10.5.0",
|
|
60
60
|
"eslint-plugin-security": "^4.0.0",
|
|
61
|
-
"globals": "17.
|
|
61
|
+
"globals": "17.7.0"
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"target": "node_modules",
|
|
3
|
-
"timestamp": "2026-07-
|
|
3
|
+
"timestamp": "2026-07-01T20:47:08.448Z",
|
|
4
4
|
"threats": [
|
|
5
5
|
{
|
|
6
6
|
"type": "string_mutation_obfuscation",
|
|
@@ -1068,28 +1068,6 @@
|
|
|
1068
1068
|
"playbook": "SharedArrayBuffer + Worker Thread detectes. Canal IPC memoire partagee qui contourne la surveillance. Verifier si les workers manipulent des donnees sensibles. Isoler si combine avec eval/exec.",
|
|
1069
1069
|
"points": 3
|
|
1070
1070
|
},
|
|
1071
|
-
{
|
|
1072
|
-
"type": "dangerous_exec",
|
|
1073
|
-
"severity": "MEDIUM",
|
|
1074
|
-
"message": "[quick-scan] exec/spawn call detected in overflow file.",
|
|
1075
|
-
"file": "eslint/lib/rules/eol-last.js",
|
|
1076
|
-
"degraded": true,
|
|
1077
|
-
"quickScan": true,
|
|
1078
|
-
"count": 1,
|
|
1079
|
-
"reductions": [],
|
|
1080
|
-
"originalSeverity": "MEDIUM",
|
|
1081
|
-
"confidenceTier": "low",
|
|
1082
|
-
"rule_id": "MUADDIB-AST-007",
|
|
1083
|
-
"rule_name": "Dangerous Shell Command Execution",
|
|
1084
|
-
"confidence": "high",
|
|
1085
|
-
"domain": "malware",
|
|
1086
|
-
"references": [
|
|
1087
|
-
"https://owasp.org/www-community/attacks/Command_Injection"
|
|
1088
|
-
],
|
|
1089
|
-
"mitre": "T1059.004",
|
|
1090
|
-
"playbook": "CRITIQUE: Execution de commande shell dangereuse detectee. Isoler la machine. Verifier si la commande a ete executee.",
|
|
1091
|
-
"points": 3
|
|
1092
|
-
},
|
|
1093
1071
|
{
|
|
1094
1072
|
"type": "dangerous_exec",
|
|
1095
1073
|
"severity": "MEDIUM",
|
|
@@ -1468,7 +1446,29 @@
|
|
|
1468
1446
|
"type": "dangerous_exec",
|
|
1469
1447
|
"severity": "MEDIUM",
|
|
1470
1448
|
"message": "[quick-scan] exec/spawn call detected in overflow file.",
|
|
1471
|
-
"file": "js-yaml/
|
|
1449
|
+
"file": "js-yaml/dist/browser/js-yaml.esm.min.mjs",
|
|
1450
|
+
"degraded": true,
|
|
1451
|
+
"quickScan": true,
|
|
1452
|
+
"count": 1,
|
|
1453
|
+
"reductions": [],
|
|
1454
|
+
"originalSeverity": "MEDIUM",
|
|
1455
|
+
"confidenceTier": "low",
|
|
1456
|
+
"rule_id": "MUADDIB-AST-007",
|
|
1457
|
+
"rule_name": "Dangerous Shell Command Execution",
|
|
1458
|
+
"confidence": "high",
|
|
1459
|
+
"domain": "malware",
|
|
1460
|
+
"references": [
|
|
1461
|
+
"https://owasp.org/www-community/attacks/Command_Injection"
|
|
1462
|
+
],
|
|
1463
|
+
"mitre": "T1059.004",
|
|
1464
|
+
"playbook": "CRITIQUE: Execution de commande shell dangereuse detectee. Isoler la machine. Verifier si la commande a ete executee.",
|
|
1465
|
+
"points": 3
|
|
1466
|
+
},
|
|
1467
|
+
{
|
|
1468
|
+
"type": "dangerous_exec",
|
|
1469
|
+
"severity": "MEDIUM",
|
|
1470
|
+
"message": "[quick-scan] exec/spawn call detected in overflow file.",
|
|
1471
|
+
"file": "js-yaml/dist/browser/js-yaml.umd.min.js",
|
|
1472
1472
|
"degraded": true,
|
|
1473
1473
|
"quickScan": true,
|
|
1474
1474
|
"count": 1,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { classifyTaintSource } = require('./taint-tracker.js');
|
|
3
|
+
const { classifyTaintSource, isHarvestEnv } = require('./taint-tracker.js');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Visit `assignment` nodes at module level (scope_depth === 0) and populate
|
|
@@ -13,6 +13,18 @@ const { classifyTaintSource } = require('./taint-tracker.js');
|
|
|
13
13
|
* - reassignment to a non-source value CLEARS the taint
|
|
14
14
|
*/
|
|
15
15
|
function handleAssignment(node, ctx, scopeDepth) {
|
|
16
|
+
// crypto_exfil harvest leg (PyPI, file-level — runs at ANY scope, unlike moduleTaint):
|
|
17
|
+
// `secret = os.environ['AWS_SECRET']` even inside a function sets the harvest flag.
|
|
18
|
+
if (!ctx.hasSensitiveHarvestPy) {
|
|
19
|
+
const rhs = node.childForFieldName('right');
|
|
20
|
+
if (rhs) {
|
|
21
|
+
const t = classifyTaintSource(rhs);
|
|
22
|
+
if (t && t.sourceType === 'env' && isHarvestEnv(t.envVarName)) {
|
|
23
|
+
ctx.hasSensitiveHarvestPy = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
if (scopeDepth !== 0) return;
|
|
17
29
|
if (!ctx.moduleTaint) return; // defensive — should always be initialised per-file
|
|
18
30
|
|
|
@@ -8,7 +8,7 @@ const {
|
|
|
8
8
|
isTruthyLiteral,
|
|
9
9
|
lineOf
|
|
10
10
|
} = require('./helpers.js');
|
|
11
|
-
const { lookupTaint, isEnvSensitive } = require('./taint-tracker.js');
|
|
11
|
+
const { lookupTaint, isEnvSensitive, isCryptoEncryptCall, isSensitiveFileOpen, nodeReadsSensitiveEnv } = require('./taint-tracker.js');
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Visitor for `call` nodes. Emits PYAST-003, PYAST-004, PYAST-005, PYAST-006,
|
|
@@ -121,12 +121,40 @@ function getKwargValue(callNode, kwName) {
|
|
|
121
121
|
return null;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// crypto_exfil harvest helper: true if any argument (positional or kwarg value)
|
|
125
|
+
// reads a sensitive env var, e.g. requests.post(url, data=os.environ['AWS_SECRET']).
|
|
126
|
+
function _callHasSensitiveEnvArg(callNode) {
|
|
127
|
+
const args = callNode.childForFieldName('arguments');
|
|
128
|
+
if (!args) return false;
|
|
129
|
+
for (const child of args.children) {
|
|
130
|
+
if (child.type === 'keyword_argument') {
|
|
131
|
+
const v = child.childForFieldName('value');
|
|
132
|
+
if (v && nodeReadsSensitiveEnv(v)) return true;
|
|
133
|
+
} else if (nodeReadsSensitiveEnv(child)) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
124
140
|
// ---------------------------------------------------------------------------
|
|
125
141
|
// Main visitor
|
|
126
142
|
// ---------------------------------------------------------------------------
|
|
127
143
|
|
|
128
144
|
function handleCallExpression(node, ctx, scopeDepth) {
|
|
129
145
|
const callee = calleeDottedName(node);
|
|
146
|
+
|
|
147
|
+
// crypto_exfil (PyPI mirror of MUADDIB-COMPOUND-019): accumulate file-level flags consumed by
|
|
148
|
+
// the post-walk same-file compound in python-ast.js. Runs for EVERY call (before the !callee
|
|
149
|
+
// early return) and at ANY scope — the compound is file-level, like the JS handle-post-walk one.
|
|
150
|
+
// AST-call based, so a string/comment that merely contains "AES.new(" cannot trip it (JS self-FP lesson).
|
|
151
|
+
if (isCryptoEncryptCall(node)) ctx.hasCryptoEncryptPy = true;
|
|
152
|
+
if (callee && NETWORK_WRITE_CALLEES.has(callee)) ctx.hasNetworkWritePy = true;
|
|
153
|
+
if (!ctx.hasSensitiveHarvestPy &&
|
|
154
|
+
(nodeReadsSensitiveEnv(node) || isSensitiveFileOpen(node) || _callHasSensitiveEnvArg(node))) {
|
|
155
|
+
ctx.hasSensitiveHarvestPy = true;
|
|
156
|
+
}
|
|
157
|
+
|
|
130
158
|
if (!callee) return;
|
|
131
159
|
|
|
132
160
|
// PYAST-003: exec()/eval() at module level — direct RCE on import.
|
|
@@ -168,6 +168,83 @@ function classifyEnvSource(node) {
|
|
|
168
168
|
return null;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// CRYPTO-ENCRYPT detection (crypto_exfil PyPI mirror of MUADDIB-COMPOUND-019)
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
// Construction / encrypt-function callees of the major Python crypto libs. AST
|
|
176
|
+
// dotted-name match (NOT a content regex): a string or comment that merely
|
|
177
|
+
// contains "AES.new(" is not a `call` node, so it cannot trip this (the JS-side
|
|
178
|
+
// self-FP lesson — see scanner/ast.js hasCryptoEncipher). Matching the
|
|
179
|
+
// constructor/load call is sufficient as a file-level "encryption happens here"
|
|
180
|
+
// flag; the generic `.encrypt()` method is deliberately NOT matched (too many
|
|
181
|
+
// non-crypto libs expose .encrypt and it would FP).
|
|
182
|
+
const CRYPTO_ENCRYPT_CALLEES = new Set([
|
|
183
|
+
// pycryptodome / PyCrypto — symmetric .new() + RSA padding .new()
|
|
184
|
+
'AES.new', 'DES.new', 'DES3.new', 'ARC4.new', 'ChaCha20.new', 'Salsa20.new',
|
|
185
|
+
'Blowfish.new', 'ChaCha20_Poly1305.new',
|
|
186
|
+
'Crypto.Cipher.AES.new', 'Cryptodome.Cipher.AES.new',
|
|
187
|
+
'PKCS1_OAEP.new', 'PKCS1_v1_5.new',
|
|
188
|
+
'Crypto.Cipher.PKCS1_OAEP.new', 'Cryptodome.Cipher.PKCS1_OAEP.new',
|
|
189
|
+
// rsa library
|
|
190
|
+
'rsa.encrypt',
|
|
191
|
+
// cryptography — hazmat ciphers + fernet + attacker pubkey load
|
|
192
|
+
'Cipher', 'cryptography.hazmat.primitives.ciphers.Cipher',
|
|
193
|
+
'Fernet', 'MultiFernet', 'cryptography.fernet.Fernet',
|
|
194
|
+
'load_pem_public_key', 'serialization.load_pem_public_key',
|
|
195
|
+
// PyNaCl
|
|
196
|
+
'SealedBox', 'SecretBox', 'nacl.public.SealedBox', 'nacl.secret.SecretBox'
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
function isCryptoEncryptCall(callNode) {
|
|
200
|
+
if (!callNode || callNode.type !== 'call') return false;
|
|
201
|
+
const name = dottedName(callNode.childForFieldName('function'));
|
|
202
|
+
return !!(name && CRYPTO_ENCRYPT_CALLEES.has(name));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Sensitive-file open detection (crypto_exfil harvest leg, beyond env vars)
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
// Mirror of dataflow.js SENSITIVE_PATH_PATTERNS — credential/secret stores.
|
|
210
|
+
const SENSITIVE_FILE_RE = /(\.aws[/\\]credentials|\.ssh[/\\]|id_rsa|id_ed25519|\.npmrc|\.netrc|\.git-credentials|\.config[/\\]gcloud|\.docker[/\\]config|\.kube[/\\]config|\.pgpass|wallet\.dat|\.metamask|\.electrum|\.exodus|\.gnupg|\/etc\/passwd|\/etc\/shadow)/i;
|
|
211
|
+
const FILE_OPEN_CALLEES = new Set(['open', 'io.open', 'codecs.open']);
|
|
212
|
+
|
|
213
|
+
function isSensitiveFileOpen(callNode) {
|
|
214
|
+
if (!callNode || callNode.type !== 'call') return false;
|
|
215
|
+
const name = dottedName(callNode.childForFieldName('function'));
|
|
216
|
+
if (!name || !FILE_OPEN_CALLEES.has(name)) return false;
|
|
217
|
+
const args = callNode.childForFieldName('arguments');
|
|
218
|
+
if (!args) return false;
|
|
219
|
+
for (const child of args.children) {
|
|
220
|
+
if (['(', ')', ',', 'keyword_argument'].includes(child.type)) continue;
|
|
221
|
+
const lit = stringLiteralValue(child); // first positional arg
|
|
222
|
+
return lit !== null && SENSITIVE_FILE_RE.test(lit);
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Env names that denote a CRYPTO KEY read in order to ENCRYPT (transport config),
|
|
228
|
+
// NOT a credential to be stolen. A legit Fernet/cryptography transport wrapper loads
|
|
229
|
+
// its own symmetric key from env (FERNET_KEY, ENCRYPTION_KEY, APP_CIPHER_KEY, ...) then
|
|
230
|
+
// encrypts+sends — without this exclusion that env read would (via the bare "KEY"
|
|
231
|
+
// substring in SENSITIVE_ENV_RE) look like harvesting and FP the crypto_exfil compound.
|
|
232
|
+
// The attacker gains nothing: real stolen creds use names like AWS_SECRET_ACCESS_KEY /
|
|
233
|
+
// *_TOKEN which are NOT crypto-config names, so they still count as harvest.
|
|
234
|
+
const CRYPTO_CONFIG_ENV_RE = /^(FERNET|ENCRYPTION|ENCRYPT|DECRYPT|CIPHER|CRYPTO|AES|MASTER|SIGNING|HMAC)_?KEY$|_(ENCRYPTION|FERNET|CIPHER|AES|CRYPTO)_KEY$/i;
|
|
235
|
+
|
|
236
|
+
// Harvest = a sensitive credential name, EXCLUDING crypto-key config (above).
|
|
237
|
+
function isHarvestEnv(name) {
|
|
238
|
+
return isEnvSensitive(name) && !CRYPTO_CONFIG_ENV_RE.test(name);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// True iff `node` reads a HARVESTABLE credential env var (os.environ['X'] /
|
|
242
|
+
// os.getenv('X') / os.environ.get('X')) — sensitive name, not crypto-key config.
|
|
243
|
+
function nodeReadsSensitiveEnv(node) {
|
|
244
|
+
const name = classifyEnvSource(node);
|
|
245
|
+
return name !== null && isHarvestEnv(name);
|
|
246
|
+
}
|
|
247
|
+
|
|
171
248
|
// ---------------------------------------------------------------------------
|
|
172
249
|
// Public API
|
|
173
250
|
// ---------------------------------------------------------------------------
|
|
@@ -200,11 +277,19 @@ module.exports = {
|
|
|
200
277
|
classifyTaintSource,
|
|
201
278
|
lookupTaint,
|
|
202
279
|
isEnvSensitive,
|
|
280
|
+
// crypto_exfil (PyPI mirror of COMPOUND-019) — file-flag classifiers
|
|
281
|
+
isCryptoEncryptCall,
|
|
282
|
+
isSensitiveFileOpen,
|
|
283
|
+
nodeReadsSensitiveEnv,
|
|
284
|
+
isHarvestEnv,
|
|
203
285
|
// Exposed for unit tests
|
|
204
286
|
_internal: {
|
|
205
287
|
isFetchSource,
|
|
206
288
|
isBase64Source,
|
|
207
289
|
classifyEnvSource,
|
|
208
|
-
SENSITIVE_ENV_RE
|
|
290
|
+
SENSITIVE_ENV_RE,
|
|
291
|
+
CRYPTO_ENCRYPT_CALLEES,
|
|
292
|
+
SENSITIVE_FILE_RE,
|
|
293
|
+
CRYPTO_CONFIG_ENV_RE
|
|
209
294
|
}
|
|
210
295
|
};
|
|
@@ -49,6 +49,7 @@ const WASM_PATH = path.join(__dirname, '..', 'vendor', 'tree-sitter-python.wasm'
|
|
|
49
49
|
// findTargetPythonFiles() from python-source.js below.
|
|
50
50
|
const { _internal: pysrcInternal } = require('./python-source.js');
|
|
51
51
|
const { findTargetPythonFiles } = pysrcInternal;
|
|
52
|
+
const { networkDestinationsAllBenign } = require('../sdk-destination.js');
|
|
52
53
|
|
|
53
54
|
// ---------------------------------------------------------------------------
|
|
54
55
|
// Async tree-sitter init (lazy, cached).
|
|
@@ -151,10 +152,30 @@ function scanPythonAST(targetPath) {
|
|
|
151
152
|
// Per-file taint map populated by handle-assignment.js at scope_depth==0
|
|
152
153
|
// and read by handle-call-expression.js for compound detections
|
|
153
154
|
// (PYAST-005/006/009/010). See python-ast-detectors/taint-tracker.js.
|
|
154
|
-
moduleTaint: new Map()
|
|
155
|
+
moduleTaint: new Map(),
|
|
156
|
+
// crypto_exfil (PyPI mirror of MUADDIB-COMPOUND-019) file-level flags, set during the
|
|
157
|
+
// walk by handle-call-expression.js / handle-assignment.js, read in the finalize below.
|
|
158
|
+
hasCryptoEncryptPy: false,
|
|
159
|
+
hasNetworkWritePy: false,
|
|
160
|
+
hasSensitiveHarvestPy: false
|
|
155
161
|
};
|
|
156
162
|
|
|
157
163
|
walk(tree.rootNode, ctx, visitors);
|
|
164
|
+
|
|
165
|
+
// crypto_exfil finalize (same-file compound): secret harvest + encryption (RSA/AES) +
|
|
166
|
+
// network write, to a destination that is NOT entirely first-party/trusted. Mirror of the
|
|
167
|
+
// JS handle-post-walk compound; emits the SAME 'crypto_exfil' type (MUADDIB-COMPOUND-019),
|
|
168
|
+
// inheriting its rule/playbook/HIGH_CONFIDENCE_MALICE plumbing. destAllBenign reuses the
|
|
169
|
+
// shared host-reputation engine on the .py source (suppresses SDKs posting to their own API).
|
|
170
|
+
if (ctx.hasCryptoEncryptPy && ctx.hasNetworkWritePy && ctx.hasSensitiveHarvestPy &&
|
|
171
|
+
!networkDestinationsAllBenign(source)) {
|
|
172
|
+
threats.push({
|
|
173
|
+
type: 'crypto_exfil',
|
|
174
|
+
severity: 'CRITICAL',
|
|
175
|
+
message: `${ctx.relFile}: hybrid-crypto exfiltration (PyPI) — secret harvesting (env var / credential file) + encryption (RSA/AES via pycryptodome/cryptography/rsa/nacl) + network write in the same file, to a non-first-party destination. Stolen secrets are encrypted before exfil to evade DLP/taint inspection (litellm/Hades pattern).`,
|
|
176
|
+
file: ctx.relFile
|
|
177
|
+
});
|
|
178
|
+
}
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
return threats;
|
package/src/scoring.js
CHANGED
|
@@ -166,7 +166,12 @@ const SINGLE_FIRE_CRITICAL_TYPES = new Set([
|
|
|
166
166
|
// Phantom Gyp compound (Phase 1b): only fires when the invoked configure-time script
|
|
167
167
|
// is INDEPENDENTLY judged malicious, so it carries the same unambiguous-malware weight
|
|
168
168
|
// as the IOC/hash matches above — FP≈0 by construction justifies the single-fire floor.
|
|
169
|
-
'gyp_phantom_exec'
|
|
169
|
+
'gyp_phantom_exec',
|
|
170
|
+
// crypto_exfil (COMPOUND-019): harvest + RSA/AES encryption + network to a non-benign
|
|
171
|
+
// dest in the same file (FP≈0 by construction). Floors a lone CRITICAL to 75 so it is
|
|
172
|
+
// not buried at 25 on PyPI, where a single CRITICAL does not stack co-signals the way
|
|
173
|
+
// the npm side does (env_access + credential_regex_harvest + ... → 100).
|
|
174
|
+
'crypto_exfil'
|
|
170
175
|
]);
|
|
171
176
|
const SINGLE_FIRE_CRITICAL_FLOOR = 75;
|
|
172
177
|
const SINGLE_FIRE_MIN_SEVERITY_RANK = 2; // HIGH
|