muaddib-scanner 2.11.136 → 2.11.139

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 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 **20 parallel scanners** (266 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring** (17 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 Metrics](#evaluation-metrics) → ML Classifier section).
33
+ MUAD'DIB combines **20 parallel scanners** (274 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring** (17 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 Metrics](#evaluation-metrics) → ML Classifier section).
34
34
 
35
35
  ---
36
36
 
@@ -202,9 +202,9 @@ muaddib replay # Ground truth validation (90/94 TPR@3, v2.11
202
202
  | Python Source (PYSRC) | Import-time / install-time RCE patterns in `__init__.py` / `setup.py` (v2.11.41 — closes TrapDoor PyPI gap) |
203
203
  | Python AST (PYAST) | Tree-sitter-Python AST with taint-aware detectors (v2.11.42+) |
204
204
 
205
- ### 266 detection rules
205
+ ### 274 detection rules
206
206
 
207
- All rules (261 RULES + 5 PARANOID) are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v211117) for the complete rules reference.
207
+ All rules (269 RULES + 5 PARANOID) are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v211117) for the complete rules reference.
208
208
 
209
209
  ### Detected campaigns
210
210
 
@@ -401,7 +401,7 @@ npm test
401
401
  - [Documentation Index](docs/INDEX.md) - All documentation in one place
402
402
  - [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) - Experimental protocol, holdout scores
403
403
  - [Threat Model](docs/threat-model.md) - What MUAD'DIB detects and doesn't detect
404
- - [Security Policy](SECURITY.md) - Detection rules reference (261 rules)
404
+ - [Security Policy](SECURITY.md) - Detection rules reference (269 rules)
405
405
  - [Security Audit](docs/SECURITY_AUDIT.md) - Bypass validation report
406
406
  - [FP Analysis](docs/EVALUATION.md) - Historical false positive analysis
407
407
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.136",
3
+ "version": "2.11.139",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-06-30T19:55:27.666Z",
3
+ "timestamp": "2026-07-01T17:07:59.673Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -635,7 +635,13 @@ function handleMemoryPressure(level, ratio, rssRatio, recentlyScanned, downloads
635
635
  // first (newest survive — most likely to still exist for re-scan), protected only as
636
636
  // a last resort, and LEDGERS every drop. Closes the v2.10.88 gap where the raw
637
637
  // splice(0,n) silently dropped protected scans (CLAUDE.md "ne jamais perdre de scan").
638
- const { dropped, droppedProtected, spilled } = evictFromScanQueueBulk(scanQueue, EMERGENCY_QUEUE_KEEP, 'mem_emergency');
638
+ // IOC-aware anti-spill: never shed a queued name already known-malicious in the IOC DB.
639
+ // loadCachedIOCs() is a 10s-throttled singleton the daemon keeps warm (queue.js calls it
640
+ // per scan), so this is a ref-return — not a fresh alloc — even inside the reclaim path.
641
+ // Best-effort: on any failure the index is null and eviction falls back to prior behavior.
642
+ let _iocIndex = null;
643
+ try { _iocIndex = require('../ioc/updater.js').loadCachedIOCs(); } catch { /* degrade to prior behavior */ }
644
+ const { dropped, droppedProtected, spilled } = evictFromScanQueueBulk(scanQueue, EMERGENCY_QUEUE_KEEP, 'mem_emergency', null, { iocIndex: _iocIndex });
639
645
  summary.queueDropped = dropped;
640
646
  summary.queueDroppedProtected = droppedProtected;
641
647
  summary.queueSpilled = spilled || 0;
@@ -32,6 +32,20 @@ function _isProtected(item) {
32
32
  return !!(item && (item.isIOCMatch || item.isBurst || item.firstPublish || item.atoSignal || item.isATOBurstExtra || item.interrupted));
33
33
  }
34
34
 
35
+ // IOC-aware anti-spill (2026-07, @longzy DPRK campaign post-mortem): a queued package whose
36
+ // NAME is already in the IOC database is known-malicious, and confirming it is a cheap name
37
+ // lookup — so it must never be shed under memory pressure, even when its enqueue-time
38
+ // `isIOCMatch` flag is stale (the IOC DB refreshed while the item sat in the backlog) or was
39
+ // version-gated. Name-level: a wildcard (all-versions-malicious) OR any specific-version entry
40
+ // counts. Best-effort — a missing/malformed index yields false (the exact prior behavior).
41
+ function _isIOCKnownByName(item, iocIndex) {
42
+ if (!item || !item.name || !iocIndex) return false;
43
+ const wild = iocIndex.wildcardPackages;
44
+ const pkgs = iocIndex.packagesMap;
45
+ return !!((wild && typeof wild.has === 'function' && wild.has(item.name)) ||
46
+ (pkgs && typeof pkgs.has === 'function' && pkgs.has(item.name)));
47
+ }
48
+
35
49
  // How far from the head we scan for an unprotected victim. Protected items are a small
36
50
  // fraction of the flood, so a victim is almost always found within a few slots; the bound
37
51
  // keeps eviction O(window) under sustained overflow (CLAUDE.md §2 bounded resources).
@@ -113,18 +127,32 @@ function enqueueScan(scanQueue, item, stats, max = MAX_SCAN_QUEUE) {
113
127
  * the daemon (which holds the same array reference) sees the mutation. Best-effort ledger;
114
128
  * never throws. `ledgerFn` is injectable for tests; defaults to state.appendScanLedger.
115
129
  *
130
+ * `opts.iocIndex` (optional {packagesMap, wildcardPackages}) additionally protects any queued
131
+ * NAME already in the IOC DB from being shed — a known-malicious package must never be lost to
132
+ * memory pressure (IOC-aware anti-spill). Injected by the daemon (the live 10s-singleton), not
133
+ * auto-loaded here, so callers that omit it keep the exact prior behavior.
134
+ *
116
135
  * @returns {{dropped:number, droppedProtected:number}}
117
136
  */
118
- function evictFromScanQueueBulk(scanQueue, targetKeep, source = 'bulk_evict', ledgerFn = null) {
137
+ function evictFromScanQueueBulk(scanQueue, targetKeep, source = 'bulk_evict', ledgerFn = null, opts = {}) {
119
138
  const before = scanQueue.length;
120
139
  const keep = Math.max(0, targetKeep | 0);
121
140
  if (before <= keep) return { dropped: 0, droppedProtected: 0 };
122
141
  const toDrop = before - keep;
123
142
 
124
- // Victim set: oldest unprotected first, then (only if short) oldest protected.
143
+ // IOC-aware anti-spill: on top of the standard _isProtected classes, protect any queued
144
+ // NAME already in the IOC DB. The index is INJECTED (daemon passes the live 10s-singleton;
145
+ // tests pass a fabricated one) — no auto-load here, so callers that omit it keep the exact
146
+ // prior behavior (and unit paths don't drag in the 237MB DB).
147
+ const iocIndex = opts.iocIndex || null;
148
+ const isKept = iocIndex
149
+ ? (item) => _isProtected(item) || _isIOCKnownByName(item, iocIndex)
150
+ : _isProtected;
151
+
152
+ // Victim set: oldest unkept first, then (only if short) oldest kept item.
125
153
  const dropSet = new Set();
126
154
  for (let i = 0; i < before && dropSet.size < toDrop; i++) {
127
- if (!_isProtected(scanQueue[i])) dropSet.add(i);
155
+ if (!isKept(scanQueue[i])) dropSet.add(i);
128
156
  }
129
157
  let droppedProtected = 0;
130
158
  if (dropSet.size < toDrop) {
@@ -172,7 +200,7 @@ function evictFromScanQueueBulk(scanQueue, targetKeep, source = 'bulk_evict', le
172
200
  appendLedger({
173
201
  name: item.name, version: item.version, ecosystem: item.ecosystem,
174
202
  outcome: spilled ? 'spilled' : 'dropped',
175
- source: (_isProtected(item) ? `${source}_protected` : source) + (spilled ? '_spill' : ''),
203
+ source: (isKept(item) ? `${source}_protected` : source) + (spilled ? '_spill' : ''),
176
204
  // AUDIT-A1 observability: record whether a DROPPED item was a first-publish
177
205
  // (real coverage loss) vs a burst-extra (version-spam, expected). Lets us
178
206
  // measure if the memory breaker is evicting genuine new packages.
@@ -1129,6 +1129,32 @@ const RULES = {
1129
1129
  references: ['https://attack.mitre.org/techniques/T1497/001/'],
1130
1130
  mitre: 'T1497.001'
1131
1131
  },
1132
+ anti_analysis_evasion: {
1133
+ id: 'MUADDIB-AST-095',
1134
+ name: 'Anti-Analysis Detonation Gate',
1135
+ severity: 'HIGH',
1136
+ confidence: 'high',
1137
+ domain: 'malware',
1138
+ description: 'Mur de gardes process.exit() silencieuses (>=5) couplees a de la lecture d\'environnement (process.env) et a de l\'obfuscation charcode OU de la reconnaissance hote (hostname/userInfo/networkInterfaces) — le payload refuse de s\'executer sous analyse (sandbox/CI/honeypot). Detection STRUCTURELLE (la forme du mur), agnostique aux marqueurs, donc robuste aux marqueurs inedits. Contre-mesure durable a l\'evasion : evader => detecte en statique ; ne pas evader => detone en sandbox.',
1139
+ references: [
1140
+ 'https://attack.mitre.org/techniques/T1497/',
1141
+ 'https://attack.mitre.org/techniques/T1497/001/'
1142
+ ],
1143
+ mitre: 'T1497'
1144
+ },
1145
+ analyzer_honeytoken_reference: {
1146
+ id: 'MUADDIB-AST-096',
1147
+ name: 'Analyzer Honeytoken Reference',
1148
+ severity: 'CRITICAL',
1149
+ confidence: 'high',
1150
+ domain: 'malware',
1151
+ description: 'Reference DISSIMULEE en charcodes a un marqueur d\'environnement d\'analyse — marqueur de sandbox MUAD\'DIB (MUADDIB_GVISOR), marqueur d\'un analyseur pair, ou nom de poison-token. Seule la forme obfusquee est matchee : le code de sandbox de MUAD\'DIB lui-meme et l\'outillage securite legitime referencent ces marqueurs en clair sans risque. Aucun package legitime n\'encode un check pour ces tripwires. Le marqueur est un honeytoken plante : garde public et stable pour que tout verificateur s\'auto-incrimine.',
1152
+ references: [
1153
+ 'https://attack.mitre.org/techniques/T1497/',
1154
+ 'https://attack.mitre.org/techniques/T1480/'
1155
+ ],
1156
+ mitre: 'T1497'
1157
+ },
1132
1158
  detached_process: {
1133
1159
  id: 'MUADDIB-AST-012',
1134
1160
  name: 'Detached Background Process',
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Anti-analysis / sandbox-evasion detection (2026, born from the @longzy "Contagious
5
+ * Interview" DPRK npm campaign — whose payload charcode-encodes a `MUADDIB_GVISOR` check to
6
+ * refuse detonation inside our sandbox).
7
+ *
8
+ * Design stance: we do NOT try to HIDE the sandbox. That is an unwinnable arms race — gVisor
9
+ * is detectable via /proc, syscall quirks and timing, and an attacker re-reads our public
10
+ * repo. Instead we detect the EVASION ITSELF, statically. That is durable and lose-lose for
11
+ * the attacker: evade → this convicts them (immune to any env-hiding, because it reads code);
12
+ * don't evade → the dynamic sandbox detonates them.
13
+ *
14
+ * Two complementary, marker-AGNOSTIC-first signals (emitted in handlePostWalk):
15
+ *
16
+ * 1. analyzer_honeytoken_reference (CRITICAL, FP~0) — the file hides, behind charcode
17
+ * encoding, a reference to a known analysis-environment tripwire (our own
18
+ * `MUADDIB_GVISOR`, a peer analyzer's marker, a poison-token name). We match ONLY the
19
+ * obfuscated form: legitimate security tooling (incl. MUAD'DIB's own sandbox code)
20
+ * references these markers in the clear, but no benign package charcode-encodes a check
21
+ * for one. This deliberately weaponises our sandbox marker as a HONEYTOKEN — we keep
22
+ * `MUADDIB_GVISOR` public and STABLE precisely so any package that checks for it
23
+ * self-incriminates. Hiding/rotating it would throw the honeytoken away for nothing.
24
+ *
25
+ * 2. the structural "detonation-gate wall" (see countExitGuards / hasHostRecon, combined
26
+ * with env-read + obfuscation in handlePostWalk) — a stack of silent process.exit()
27
+ * guards that read the environment. Keys on the SHAPE, so it catches NOVEL markers a
28
+ * signature list never would. FP-safe by coupling, not breadth.
29
+ *
30
+ * Both are static → immune to sandbox-hiding, and both fire regardless of whether the
31
+ * (secondary, LOG-ONLY) dynamic sandbox ever runs.
32
+ */
33
+
34
+ // Analysis-environment tripwire markers with ZERO legitimate reason to be *charcode-hidden*
35
+ // inside a package. Curated for near-zero FP: excludes AWS doc example keys
36
+ // (AKIAIOSFODNN7EXAMPLE) which legitimately appear in tutorials/tests. Keep this list stable
37
+ // and public — every entry is a planted honeytoken.
38
+ const ANALYZER_MARKERS = [
39
+ 'MUADDIB_GVISOR', // our own sandbox marker — the primary honeytoken
40
+ 'DetonationLogFilePath',
41
+ 'THREAT_ANALYZER_MODEL',
42
+ 'ASPECT_TLOG',
43
+ 'PYPI_POISON',
44
+ 'SANDYCLAW_',
45
+ 'OPENCLAW_',
46
+ 'PERMISO_',
47
+ 'CHAINRADAR_'
48
+ ];
49
+
50
+ // Bound the charcode-array decode so a pathological file can't blow the CPU budget.
51
+ const MAX_DECODE_ARRAYS = 300;
52
+ // A char-code array literal: >=6 small integers, e.g. [77,85,65,68,68,73,66,...].
53
+ const CHARCODE_ARRAY_RE = /\[\s*(\d{1,3}(?:\s*,\s*\d{1,3}){5,})\s*\]/g;
54
+
55
+ /**
56
+ * Return the first analyzer marker that appears CHARCODE-ENCODED in `content` (i.e. hidden
57
+ * inside a numeric array literal that decodes to a string containing the marker), or null.
58
+ * Plaintext references are intentionally ignored (see module doc). Bounded, side-effect-free.
59
+ */
60
+ function detectAnalyzerHoneytoken(content) {
61
+ if (!content || content.indexOf('[') === -1) return null;
62
+ CHARCODE_ARRAY_RE.lastIndex = 0;
63
+ let match, seen = 0;
64
+ while ((match = CHARCODE_ARRAY_RE.exec(content)) !== null) {
65
+ if (++seen > MAX_DECODE_ARRAYS) break;
66
+ const nums = match[1].split(',').map(n => parseInt(n, 10));
67
+ // Only decode arrays that are entirely printable ASCII codes (a real hidden string).
68
+ if (nums.some(n => !(n >= 9 && n <= 126))) continue;
69
+ let decoded;
70
+ try { decoded = String.fromCharCode.apply(null, nums); } catch { continue; }
71
+ for (const m of ANALYZER_MARKERS) {
72
+ if (decoded.indexOf(m) !== -1) return m;
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** Count silent `process.exit(` guards — the magnitude of the "detonation-gate wall". */
79
+ function countExitGuards(content) {
80
+ if (!content) return 0;
81
+ const m = content.match(/process\s*\.\s*exit\s*\(/g);
82
+ return m ? m.length : 0;
83
+ }
84
+
85
+ /** Host/environment reconnaissance calls (note: `os` is often aliased, e.g. `_os.hostname()`). */
86
+ function hasHostRecon(content) {
87
+ return !!content && /\.\s*(?:hostname|userInfo|networkInterfaces)\s*\(/.test(content);
88
+ }
89
+
90
+ module.exports = { ANALYZER_MARKERS, detectAnalyzerHoneytoken, countExitGuards, hasHostRecon };
@@ -74,6 +74,25 @@ function handleMemberExpression(node, ctx) {
74
74
  }
75
75
  }
76
76
  }
77
+
78
+ // AST-018 alias fix (@longzy): `var env = process.env; env[_decode([...])]` reaches env via
79
+ // an alias, so the direct `process.env[x]` match above misses it. Close ONLY the
80
+ // charcode-reconstruction signal for aliases (computed access + String.fromCharCode in the
81
+ // file) — we deliberately do NOT emit the generic env_access MEDIUM for aliases, keeping FPR
82
+ // flat; the HIGH signal fires only on the malicious obfuscated-key shape.
83
+ if (
84
+ node.computed &&
85
+ ctx.hasFromCharCode &&
86
+ node.object?.type === 'Identifier' &&
87
+ ctx.envAliases && ctx.envAliases.has(node.object.name)
88
+ ) {
89
+ ctx.threats.push({
90
+ type: 'env_charcode_reconstruction',
91
+ severity: 'HIGH',
92
+ message: 'process.env (accessed via an alias) reads a dynamically reconstructed key (String.fromCharCode obfuscation).',
93
+ file: ctx.relFile
94
+ });
95
+ }
77
96
  }
78
97
 
79
98
 
@@ -3,6 +3,41 @@
3
3
  const { networkDestinationsAllBenign } = require('../../sdk-destination.js');
4
4
 
5
5
  function handlePostWalk(ctx) {
6
+ // ── Anti-analysis / sandbox evasion (2026, @longzy DPRK "Contagious Interview") ──
7
+ // We detect the EVASION rather than trying to hide the sandbox (an unwinnable arms race).
8
+ // (1) analyzer_honeytoken_reference (CRITICAL, FP~0): the file hides — behind charcode
9
+ // encoding — a check for a known analysis-environment tripwire (our own MUADDIB_GVISOR,
10
+ // a peer analyzer marker, a poison-token name). Only the OBFUSCATED form is matched, so
11
+ // MUAD'DIB's own sandbox code (plaintext) and legit security tooling never trip it. The
12
+ // marker is a planted honeytoken: kept public+stable so any checker self-incriminates.
13
+ // (2) anti_analysis_evasion (HIGH): the structural "detonation-gate wall" — >=5 silent
14
+ // process.exit() guards that read the environment, plus charcode obfuscation OR host
15
+ // reconnaissance. Marker-AGNOSTIC (keys on the shape → catches novel markers). FP-safe
16
+ // by coupling: no benign package stacks 5+ process.exit guards with obfuscated env/host
17
+ // recon. Suppressed in dist/build (minified bundles can inflate the raw counts).
18
+ if (ctx.analyzerHoneytokenHit) {
19
+ ctx.threats.push({
20
+ type: 'analyzer_honeytoken_reference',
21
+ severity: 'CRITICAL',
22
+ message: `Charcode-hidden reference to an analysis-environment marker ("${ctx.analyzerHoneytokenHit}") — sandbox/analyzer evasion. No legitimate package obfuscates a check for this tripwire.`,
23
+ file: ctx.relFile
24
+ });
25
+ } else if (
26
+ ctx.antiAnalysisExitCount >= 5 &&
27
+ ctx.hasProcessEnvRead &&
28
+ (ctx.hasFromCharCode || ctx.hasHostRecon)
29
+ ) {
30
+ const isDistFile = /^(?:dist|build|out|output)[/\\]/i.test(ctx.relFile) || /\.(?:bundle|min)\.js$/i.test(ctx.relFile);
31
+ if (!isDistFile) {
32
+ ctx.threats.push({
33
+ type: 'anti_analysis_evasion',
34
+ severity: 'HIGH',
35
+ message: `Anti-analysis detonation-gate: ${ctx.antiAnalysisExitCount} silent process.exit() guards reading the environment${ctx.hasFromCharCode ? ', charcode-obfuscated' : ''}${ctx.hasHostRecon ? ', with host reconnaissance' : ''} — payload refuses to run under analysis (sandbox/CI/honeypot evasion).`,
36
+ file: ctx.relFile
37
+ });
38
+ }
39
+ }
40
+
6
41
  // SANDWORM_MODE: zlib inflate + base64 decode + eval/Function/Module._compile = obfuscated payload
7
42
  if (ctx.hasZlibInflate && ctx.hasBase64Decode && ctx.hasDynamicExec) {
8
43
  // FIX 4: dist/build files get LOW severity (bundlers legitimately use zlib+base64+eval)
@@ -24,6 +24,14 @@ function handleVariableDeclarator(node, ctx) {
24
24
  ctx.staticAssignments.add(node.id.name);
25
25
  }
26
26
 
27
+ // AST-018 alias capture (@longzy): `var env = process.env` — record the alias so a later
28
+ // `env[String.fromCharCode(...)]` access resolves back to process.env in the member handler.
29
+ if (ctx.envAliases && node.init?.type === 'MemberExpression' && !node.init.computed &&
30
+ node.init.object?.type === 'Identifier' && node.init.object.name === 'process' &&
31
+ node.init.property?.type === 'Identifier' && node.init.property.name === 'env') {
32
+ ctx.envAliases.add(node.id.name);
33
+ }
34
+
27
35
  // v2.10.73 P2: Track WHERE the variable's value originated — used by AST-006
28
36
  // to distinguish plugin loaders (LOW) from real obfuscation (HIGH) from
29
37
  // credential exfil vectors (CRITICAL). See src/scanner/ast-detectors/handle-call-expression.js
@@ -13,6 +13,7 @@ const {
13
13
  handleWithStatement,
14
14
  handlePostWalk
15
15
  } = require('./ast-detectors');
16
+ const { detectAnalyzerHoneytoken, countExitGuards, hasHostRecon } = require('./ast-detectors/anti-evasion.js');
16
17
 
17
18
  // Check if credential keywords appear INSIDE regex literals or new RegExp() patterns.
18
19
  // Only true when the keyword is part of the regex pattern itself, not just a string elsewhere in the file.
@@ -152,11 +153,19 @@ function analyzeFile(content, filePath, basePath) {
152
153
  return names;
153
154
  })(),
154
155
  evalAliases: new Map(), // B1: variable name → 'eval'|'Function'
156
+ envAliases: new Set(), // AST-018 alias fix: names bound to process.env (`var env = process.env`)
155
157
  moduleLoadDirectAliases: new Set(), // B3: destructured _load from require('module')
156
158
  objectPropertyMap: new Map(), // B5: objName → Map<propName, stringValue>
157
159
  concatValues: new Map(), // B2: varName → { value, operands } for concat strings with ≥3 operands
158
160
  stringVarValues: new Map(), // Variable reassignment tracking: varName → string value
159
161
  hasFromCharCode: content.includes('fromCharCode'),
162
+ // Anti-analysis / sandbox evasion (2026, @longzy DPRK). Structural "detonation-gate wall"
163
+ // magnitude + host recon + a charcode-hidden analyzer-honeytoken reference. Aggregated in
164
+ // handlePostWalk. See ast-detectors/anti-evasion.js.
165
+ antiAnalysisExitCount: countExitGuards(content),
166
+ hasHostRecon: hasHostRecon(content),
167
+ hasProcessEnvRead: /process\s*\.\s*env\b/.test(content),
168
+ analyzerHoneytokenHit: detectAnalyzerHoneytoken(content),
160
169
  hasJsReverseShell: /\bnet\.Socket\b/.test(content) &&
161
170
  /\.connect\s*\(/.test(content) &&
162
171
  /\.pipe\b/.test(content) &&
@@ -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