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 +1 -1
- package/{self-scan-v2.11.138.json → self-scan-v2.11.139.json} +1 -1
- 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/package.json
CHANGED
|
@@ -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
|