muaddib-scanner 2.11.138 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.138",
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-07-01T11:04:30.753Z",
3
+ "timestamp": "2026-07-01T17:07:59.673Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -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