muaddib-scanner 2.9.3 → 2.9.4
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/src/response/playbooks.js +5 -0
- package/src/rules/index.js +13 -0
- package/src/scanner/ast-detectors.js +128 -11
- package/src/scanner/ast.js +8 -0
- package/src/scanner/dataflow.js +81 -2
- package/src/scanner/package.js +13 -0
- package/src/scoring.js +10 -9
- package/src/shared/analyze-helper.js +17 -2
- package/src/utils.js +3 -1
package/package.json
CHANGED
|
@@ -349,6 +349,11 @@ const PLAYBOOKS = {
|
|
|
349
349
|
'pour eviter la detection statique. Technique de vol de GITHUB_TOKEN, NPM_TOKEN, etc. ' +
|
|
350
350
|
'Verifier quelles variables sont accedees et si elles sont exfiltrees.',
|
|
351
351
|
|
|
352
|
+
lifecycle_hidden_payload:
|
|
353
|
+
'CRITIQUE: Le script lifecycle pointe vers un fichier cache dans node_modules/. ' +
|
|
354
|
+
'Ce pattern est utilise par les attaques DPRK/Lazarus pour cacher le payload dans un repertoire ' +
|
|
355
|
+
'que les scanners excluent par defaut. Examiner le fichier cible immediatement.',
|
|
356
|
+
|
|
352
357
|
lifecycle_shell_pipe:
|
|
353
358
|
'CRITIQUE: Le script lifecycle (preinstall/postinstall) pipe du code distant vers un shell (curl | sh). ' +
|
|
354
359
|
'NE PAS installer. Ceci execute du code arbitraire a l\'installation. ' +
|
package/src/rules/index.js
CHANGED
|
@@ -649,6 +649,19 @@ const RULES = {
|
|
|
649
649
|
mitre: 'T1027'
|
|
650
650
|
},
|
|
651
651
|
|
|
652
|
+
lifecycle_hidden_payload: {
|
|
653
|
+
id: 'MUADDIB-PKG-016',
|
|
654
|
+
name: 'Lifecycle Script Targets Hidden Payload',
|
|
655
|
+
severity: 'CRITICAL',
|
|
656
|
+
confidence: 'high',
|
|
657
|
+
description: 'Script lifecycle pointe vers un fichier dans node_modules/ — technique de dissimulation de payload. Les scanners excluent node_modules/ par defaut, rendant le payload invisible. Pattern DPRK/Lazarus interview attack.',
|
|
658
|
+
references: [
|
|
659
|
+
'https://unit42.paloaltonetworks.com/operation-dream-job/',
|
|
660
|
+
'https://blog.phylum.io/shai-hulud-npm-worm'
|
|
661
|
+
],
|
|
662
|
+
mitre: 'T1027.009'
|
|
663
|
+
},
|
|
664
|
+
|
|
652
665
|
lifecycle_shell_pipe: {
|
|
653
666
|
id: 'MUADDIB-PKG-010',
|
|
654
667
|
name: 'Lifecycle Script Pipes to Shell',
|
|
@@ -475,6 +475,45 @@ function handleVariableDeclarator(node, ctx) {
|
|
|
475
475
|
ctx.stringVarValues.set(node.id.name, strVal);
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
// Track variables assigned from require.cache[...] (module cache references)
|
|
479
|
+
// Used to detect writes to cached module exports (require.cache poisoning)
|
|
480
|
+
if (node.init?.type === 'MemberExpression' && node.init.computed) {
|
|
481
|
+
const obj = node.init.object;
|
|
482
|
+
if (obj?.type === 'MemberExpression' &&
|
|
483
|
+
obj.object?.type === 'Identifier' && obj.object.name === 'require' &&
|
|
484
|
+
obj.property?.type === 'Identifier' && obj.property.name === 'cache') {
|
|
485
|
+
ctx.requireCacheVars.add(node.id.name);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Track variables assigned from BinaryExpression with '+' (string concatenation building)
|
|
490
|
+
// Used to detect setTimeout(concatVar, delay) — eval via timer with built string
|
|
491
|
+
// FP fix: only track when at least one operand is demonstrably a string (literal, template,
|
|
492
|
+
// or known string var). Filters out arithmetic `var e = a + 1` in minified code.
|
|
493
|
+
if (node.init?.type === 'BinaryExpression' && node.init.operator === '+') {
|
|
494
|
+
const left = node.init.left;
|
|
495
|
+
const right = node.init.right;
|
|
496
|
+
const isStringOperand = (n) =>
|
|
497
|
+
(n.type === 'Literal' && typeof n.value === 'string') ||
|
|
498
|
+
n.type === 'TemplateLiteral' ||
|
|
499
|
+
(n.type === 'Identifier' && ctx.stringVarValues?.has(n.name)) ||
|
|
500
|
+
(n.type === 'Identifier' && ctx.stringBuildVars?.has(n.name));
|
|
501
|
+
if (isStringOperand(left) || isStringOperand(right)) {
|
|
502
|
+
ctx.stringBuildVars.add(node.id.name);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Track object variables with Proxy trap properties (set/get/apply/construct)
|
|
507
|
+
// Used to detect new Proxy(target, handlerVar) when handler is not inline
|
|
508
|
+
if (node.init?.type === 'ObjectExpression') {
|
|
509
|
+
const hasTrap = node.init.properties?.some(p =>
|
|
510
|
+
p.key?.type === 'Identifier' && ['set', 'get', 'apply', 'construct'].includes(p.key.name)
|
|
511
|
+
);
|
|
512
|
+
if (hasTrap) {
|
|
513
|
+
ctx.proxyHandlerVars.add(node.id.name);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
478
517
|
// Track variables assigned from path.join containing .github/workflows
|
|
479
518
|
if (node.init?.type === 'CallExpression' && node.init.callee?.type === 'MemberExpression') {
|
|
480
519
|
const obj = node.init.callee.object;
|
|
@@ -1294,6 +1333,29 @@ function handleCallExpression(node, ctx) {
|
|
|
1294
1333
|
file: ctx.relFile
|
|
1295
1334
|
});
|
|
1296
1335
|
}
|
|
1336
|
+
// BinaryExpression with '+' as first arg = string concatenation for eval via timer
|
|
1337
|
+
else if (firstArg.type === 'BinaryExpression' && firstArg.operator === '+') {
|
|
1338
|
+
ctx.hasEvalInFile = true;
|
|
1339
|
+
ctx.hasDynamicExec = true;
|
|
1340
|
+
ctx.threats.push({
|
|
1341
|
+
type: 'dangerous_call_eval',
|
|
1342
|
+
severity: 'HIGH',
|
|
1343
|
+
message: `${callName}() with concatenated string argument — eval equivalent, dynamically built code string.`,
|
|
1344
|
+
file: ctx.relFile
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
// Identifier arg that was tracked as string value or string concatenation result
|
|
1348
|
+
else if (firstArg.type === 'Identifier' &&
|
|
1349
|
+
(ctx.stringVarValues?.has(firstArg.name) || ctx.stringBuildVars?.has(firstArg.name))) {
|
|
1350
|
+
ctx.hasEvalInFile = true;
|
|
1351
|
+
ctx.hasDynamicExec = true;
|
|
1352
|
+
ctx.threats.push({
|
|
1353
|
+
type: 'dangerous_call_eval',
|
|
1354
|
+
severity: 'HIGH',
|
|
1355
|
+
message: `${callName}() with variable "${firstArg.name}" containing built string — eval equivalent, executes the string as code.`,
|
|
1356
|
+
file: ctx.relFile
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1297
1359
|
|
|
1298
1360
|
// Static timer bomb: setTimeout/setInterval with delay > 1 hour (PhantomRaven 48h delay)
|
|
1299
1361
|
if (node.arguments.length >= 2) {
|
|
@@ -1757,8 +1819,17 @@ function handleNewExpression(node, ctx) {
|
|
|
1757
1819
|
);
|
|
1758
1820
|
if (hasTrap) {
|
|
1759
1821
|
ctx.hasProxyTrap = true;
|
|
1822
|
+
const hasSetTrap = handler.properties?.some(p =>
|
|
1823
|
+
p.key?.type === 'Identifier' && p.key.name === 'set'
|
|
1824
|
+
);
|
|
1825
|
+
if (hasSetTrap) ctx.hasProxySetTrap = true;
|
|
1760
1826
|
}
|
|
1761
1827
|
}
|
|
1828
|
+
// Also detect when handler is a variable reference that was tracked as having trap properties
|
|
1829
|
+
if (handler?.type === 'Identifier' && ctx.proxyHandlerVars?.has(handler.name)) {
|
|
1830
|
+
ctx.hasProxyTrap = true;
|
|
1831
|
+
ctx.hasProxySetTrap = true; // proxyHandlerVars tracks objects with any trap including set
|
|
1832
|
+
}
|
|
1762
1833
|
}
|
|
1763
1834
|
}
|
|
1764
1835
|
|
|
@@ -1966,6 +2037,29 @@ function handleAssignmentExpression(node, ctx) {
|
|
|
1966
2037
|
if (node.left?.type === 'MemberExpression') {
|
|
1967
2038
|
const left = node.left;
|
|
1968
2039
|
|
|
2040
|
+
// require.cache[...].exports = ... — module cache poisoning WRITE (not just read)
|
|
2041
|
+
// This is always malicious: replacing a core module's exports to intercept all usage.
|
|
2042
|
+
// Also detects: mod.exports.X = ... where mod is from require.cache[...]
|
|
2043
|
+
if (left.property?.type === 'Identifier' && left.property.name === 'exports') {
|
|
2044
|
+
// Direct pattern: require.cache[...].exports = ...
|
|
2045
|
+
const obj = left.object;
|
|
2046
|
+
if (obj?.type === 'MemberExpression' && obj.computed) {
|
|
2047
|
+
const deep = obj.object;
|
|
2048
|
+
if (deep?.type === 'MemberExpression' &&
|
|
2049
|
+
deep.object?.type === 'Identifier' && deep.object.name === 'require' &&
|
|
2050
|
+
deep.property?.type === 'Identifier' && deep.property.name === 'cache') {
|
|
2051
|
+
ctx.hasRequireCacheWrite = true;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
// Indirect pattern: mod.exports.X = ... where mod = require.cache[...]
|
|
2056
|
+
if (left.object?.type === 'MemberExpression' &&
|
|
2057
|
+
left.object.property?.type === 'Identifier' && left.object.property.name === 'exports' &&
|
|
2058
|
+
left.object.object?.type === 'Identifier' &&
|
|
2059
|
+
ctx.requireCacheVars?.has(left.object.object.name)) {
|
|
2060
|
+
ctx.hasRequireCacheWrite = true;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
1969
2063
|
// globalThis.fetch = ... or globalThis.XMLHttpRequest = ... (B2: include aliases)
|
|
1970
2064
|
if (left.object?.type === 'Identifier' &&
|
|
1971
2065
|
(left.object.name === 'globalThis' || left.object.name === 'global' ||
|
|
@@ -2045,15 +2139,11 @@ function handleAssignmentExpression(node, ctx) {
|
|
|
2045
2139
|
}
|
|
2046
2140
|
|
|
2047
2141
|
function handleMemberExpression(node, ctx) {
|
|
2048
|
-
// Detect require.cache access
|
|
2142
|
+
// Detect require.cache access — set flag, defer threat emission to handlePostWalk
|
|
2143
|
+
// FP fix: distinguish READ (hot-reload, delete, introspection) from WRITE (.exports = ...)
|
|
2049
2144
|
if (node.object?.type === 'Identifier' && node.object.name === 'require' &&
|
|
2050
2145
|
node.property?.type === 'Identifier' && node.property.name === 'cache') {
|
|
2051
|
-
ctx.
|
|
2052
|
-
type: 'require_cache_poison',
|
|
2053
|
-
severity: 'CRITICAL',
|
|
2054
|
-
message: 'require.cache accessed — module cache poisoning to hijack or replace core Node.js modules.',
|
|
2055
|
-
file: ctx.relFile
|
|
2056
|
-
});
|
|
2146
|
+
ctx.hasRequireCacheRead = true;
|
|
2057
2147
|
}
|
|
2058
2148
|
|
|
2059
2149
|
// GlassWorm: track .codePointAt() calls (variation selector decoder pattern)
|
|
@@ -2307,11 +2397,15 @@ function handlePostWalk(ctx) {
|
|
|
2307
2397
|
|
|
2308
2398
|
// Built-in method override + network: console.X = function or Object.defineProperty = function
|
|
2309
2399
|
// combined with network calls. Monkey-patching built-in APIs for data interception.
|
|
2400
|
+
// CRITICAL when Object.defineProperty itself is reassigned (global hook on all property defs).
|
|
2310
2401
|
if (ctx.hasBuiltinOverride && ctx.hasNetworkCallInFile) {
|
|
2402
|
+
const isGlobalHook = ctx.hasBuiltinGlobalHook;
|
|
2311
2403
|
ctx.threats.push({
|
|
2312
2404
|
type: 'builtin_override_exfil',
|
|
2313
|
-
severity: 'HIGH',
|
|
2314
|
-
message:
|
|
2405
|
+
severity: isGlobalHook ? 'CRITICAL' : 'HIGH',
|
|
2406
|
+
message: isGlobalHook
|
|
2407
|
+
? 'Object.defineProperty reassigned + network call — global hook intercepts all property definitions for credential exfiltration.'
|
|
2408
|
+
: 'Built-in method override (console/Object.defineProperty) + network call — runtime API hijacking for data interception and exfiltration.',
|
|
2315
2409
|
file: ctx.relFile
|
|
2316
2410
|
});
|
|
2317
2411
|
}
|
|
@@ -2335,10 +2429,15 @@ function handlePostWalk(ctx) {
|
|
|
2335
2429
|
const hasCredentialSignal = ctx.threats.some(t =>
|
|
2336
2430
|
t.type === 'env_access' || t.type === 'suspicious_dataflow'
|
|
2337
2431
|
);
|
|
2432
|
+
// CRITICAL when: credential signals co-occur, OR set trap (intercepts all property writes)
|
|
2433
|
+
// A set trap with network call = universal data capture + exfiltration
|
|
2434
|
+
const isCritical = hasCredentialSignal || ctx.hasProxySetTrap;
|
|
2338
2435
|
ctx.threats.push({
|
|
2339
2436
|
type: 'proxy_data_intercept',
|
|
2340
|
-
severity:
|
|
2341
|
-
message:
|
|
2437
|
+
severity: isCritical ? 'CRITICAL' : 'HIGH',
|
|
2438
|
+
message: ctx.hasProxySetTrap
|
|
2439
|
+
? 'Proxy set trap with network call — intercepts ALL property writes for exfiltration via Proxy handler.'
|
|
2440
|
+
: 'Proxy trap (set/get/apply) with network call in same file — data interception and exfiltration via Proxy handler.',
|
|
2342
2441
|
file: ctx.relFile
|
|
2343
2442
|
});
|
|
2344
2443
|
}
|
|
@@ -2353,6 +2452,24 @@ function handlePostWalk(ctx) {
|
|
|
2353
2452
|
});
|
|
2354
2453
|
}
|
|
2355
2454
|
|
|
2455
|
+
// require.cache: distinguish WRITE (actual poisoning) from READ-only (hot-reload, introspection)
|
|
2456
|
+
// FP fix: READ-only emits LOW (informational), WRITE emits CRITICAL (malicious module replacement).
|
|
2457
|
+
if (ctx.hasRequireCacheWrite) {
|
|
2458
|
+
ctx.threats.push({
|
|
2459
|
+
type: 'require_cache_poison',
|
|
2460
|
+
severity: 'CRITICAL',
|
|
2461
|
+
message: 'require.cache[...].exports = ... — module cache write: replaces core module exports to intercept all callers.',
|
|
2462
|
+
file: ctx.relFile
|
|
2463
|
+
});
|
|
2464
|
+
} else if (ctx.hasRequireCacheRead) {
|
|
2465
|
+
ctx.threats.push({
|
|
2466
|
+
type: 'require_cache_poison',
|
|
2467
|
+
severity: 'LOW',
|
|
2468
|
+
message: 'require.cache accessed — module cache read (hot-reload/introspection pattern).',
|
|
2469
|
+
file: ctx.relFile
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2356
2473
|
// DPRK/Lazarus compound: detached background process + credential env access + network
|
|
2357
2474
|
// Pattern: spawn({detached:true}) reads secrets then exfils via network.
|
|
2358
2475
|
// This combination is never legitimate — daemons don't read API keys and send them out.
|
package/src/scanner/ast.js
CHANGED
|
@@ -120,6 +120,8 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
120
120
|
hasBuiltinOverride: /\bconsole\s*\.\s*\w+\s*=\s*function/.test(content) ||
|
|
121
121
|
/\bconsole\s*\[\s*\w+\s*\]\s*=\s*function/.test(content) ||
|
|
122
122
|
/\bObject\s*\.\s*defineProperty\s*=\s*function/.test(content),
|
|
123
|
+
// Critical builtin override: Object.defineProperty itself is reassigned (global hook)
|
|
124
|
+
hasBuiltinGlobalHook: /\bObject\s*\.\s*defineProperty\s*=\s*function/.test(content),
|
|
123
125
|
// Stream interceptor: class extending Transform/Duplex/Writable (data wiretap pattern)
|
|
124
126
|
hasStreamInterceptor: /\bextends\s+(Transform|Duplex|Writable)\b/.test(content),
|
|
125
127
|
// SANDWORM_MODE P2: DNS exfiltration co-occurrence
|
|
@@ -157,6 +159,12 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
157
159
|
hasWasmLoad: /\bWebAssembly\s*\.\s*(compile|instantiate|compileStreaming|instantiateStreaming)\b/.test(content),
|
|
158
160
|
hasWasmHostSink: false, // set in handleCallExpression when WASM import object contains network/fs sinks
|
|
159
161
|
hasProxyTrap: false, // set in handleNewExpression when Proxy has set/get/apply trap
|
|
162
|
+
hasProxySetTrap: false, // set when Proxy specifically has a 'set' trap (data interception)
|
|
163
|
+
hasRequireCacheRead: false, // set when require.cache is accessed (read)
|
|
164
|
+
hasRequireCacheWrite: false, // set when require.cache exports are modified
|
|
165
|
+
requireCacheVars: new Set(), // variables assigned from require.cache[...]
|
|
166
|
+
proxyHandlerVars: new Set(), // variables assigned object literals with set/get/apply/construct traps
|
|
167
|
+
stringBuildVars: new Set(), // variables assigned from BinaryExpression with '+' (string concat)
|
|
160
168
|
// C10: Hash verification — legitimate binary installers verify checksums
|
|
161
169
|
// Requires BOTH createHash() call AND .digest() call — false positives from
|
|
162
170
|
// standalone mentions of 'sha256' or 'integrity' in comments/descriptions
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -205,9 +205,17 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
205
205
|
// Fix #23: Function param tainting — track function declarations
|
|
206
206
|
const functionDefs = new Map(); // functionName → { params: [paramNames] }
|
|
207
207
|
|
|
208
|
+
// Fix #24: Callback exposure — track function parameters (potential callbacks)
|
|
209
|
+
// When a callback parameter is invoked with tainted data, it's credential exposure.
|
|
210
|
+
const callbackParams = new Set(); // parameter names of enclosing functions
|
|
211
|
+
const callbackExposures = []; // { callbackName, argName, line }
|
|
212
|
+
|
|
213
|
+
// Pre-scan: collect function declarations and callback params BEFORE the main walk.
|
|
214
|
+
// acorn-walk.simple uses post-order traversal (children before parents), so
|
|
215
|
+
// FunctionDeclaration handlers fire AFTER CallExpressions inside the function body.
|
|
216
|
+
// This pre-scan ensures callbackParams and functionDefs are populated before analysis.
|
|
208
217
|
walk.simple(ast, {
|
|
209
218
|
FunctionDeclaration(node) {
|
|
210
|
-
// Fix #23: Track function declarations for param tainting
|
|
211
219
|
if (node.id && node.id.name && node.params) {
|
|
212
220
|
const paramNames = node.params
|
|
213
221
|
.filter(p => p.type === 'Identifier')
|
|
@@ -215,8 +223,16 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
215
223
|
if (paramNames.length > 0) {
|
|
216
224
|
functionDefs.set(node.id.name, { params: paramNames });
|
|
217
225
|
}
|
|
226
|
+
// FP fix: skip 1-char parameter names (minified code noise: e, t, n, r, a, b, etc.)
|
|
227
|
+
// Real callback exposure attacks use descriptive names (callback, handler, cb, fn, done).
|
|
228
|
+
for (const p of node.params) {
|
|
229
|
+
if (p.type === 'Identifier' && p.name.length > 1) callbackParams.add(p.name);
|
|
230
|
+
}
|
|
218
231
|
}
|
|
219
|
-
}
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
walk.simple(ast, {
|
|
220
236
|
|
|
221
237
|
VariableDeclarator(node) {
|
|
222
238
|
// B9: Array destructuring taint propagation: const [data] = [fs.readFileSync('.npmrc')]
|
|
@@ -268,6 +284,19 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
268
284
|
}
|
|
269
285
|
}
|
|
270
286
|
}
|
|
287
|
+
// Fix #24: Propagate taint through fs.readFileSync/readFile results
|
|
288
|
+
// const data = fs.readFileSync(npmrc) where npmrc is sensitive → data is tainted
|
|
289
|
+
if (initNode.type === 'CallExpression' && initNode.callee?.type === 'MemberExpression') {
|
|
290
|
+
const callProp = initNode.callee.property;
|
|
291
|
+
if (callProp?.type === 'Identifier' &&
|
|
292
|
+
(callProp.name === 'readFileSync' || callProp.name === 'readFile')) {
|
|
293
|
+
const readArg = initNode.arguments[0];
|
|
294
|
+
if (readArg && isCredentialPath(readArg, sensitivePathVars)) {
|
|
295
|
+
sensitivePathVars.add(node.id.name);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
271
300
|
// B7: Taint propagation through data-preserving wrappers
|
|
272
301
|
if (initNode.type === 'CallExpression') {
|
|
273
302
|
const callee = initNode.callee;
|
|
@@ -653,6 +682,22 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
653
682
|
}
|
|
654
683
|
}
|
|
655
684
|
|
|
685
|
+
// Fix #24: Callback exposure — detect callback(taintedData)
|
|
686
|
+
// When a function parameter is called with tainted data, it exposes credentials
|
|
687
|
+
// to the caller (cross-module credential exposure pattern).
|
|
688
|
+
if (node.callee.type === 'Identifier' && callbackParams.has(node.callee.name) &&
|
|
689
|
+
node.arguments.length >= 1) {
|
|
690
|
+
for (const arg of node.arguments) {
|
|
691
|
+
if (arg.type === 'Identifier' && sensitivePathVars.has(arg.name)) {
|
|
692
|
+
callbackExposures.push({
|
|
693
|
+
callbackName: node.callee.name,
|
|
694
|
+
argName: arg.name,
|
|
695
|
+
line: node.loc?.start?.line || 0
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
656
701
|
// Exec callback: exec('cmd', (err, stdout) => {...}) — output will be used
|
|
657
702
|
if (!execResultNodes.has(node) && node.arguments.length >= 2) {
|
|
658
703
|
const lastArg = node.arguments[node.arguments.length - 1];
|
|
@@ -755,6 +800,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
755
800
|
for (const eventName of emitTaintedEvents) {
|
|
756
801
|
const handler = eventHandlers.get(eventName);
|
|
757
802
|
if (handler && handler.hasNetworkSink) {
|
|
803
|
+
// Same-file emit→on with network sink: full suspicious_dataflow
|
|
758
804
|
sources.push({
|
|
759
805
|
type: 'credential_read',
|
|
760
806
|
name: `EventEmitter.emit('${eventName}')`,
|
|
@@ -767,9 +813,31 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
767
813
|
line: 0,
|
|
768
814
|
taint_tracked: true
|
|
769
815
|
});
|
|
816
|
+
} else {
|
|
817
|
+
// Cross-file: tainted data emitted on EventEmitter without same-file listener.
|
|
818
|
+
// The data is broadcasted to other modules — credential exposure pattern.
|
|
819
|
+
sinks.push({
|
|
820
|
+
type: 'network_send',
|
|
821
|
+
name: `EventEmitter.emit('${eventName}') [cross-module broadcast]`,
|
|
822
|
+
line: 0,
|
|
823
|
+
taint_tracked: true
|
|
824
|
+
});
|
|
770
825
|
}
|
|
771
826
|
}
|
|
772
827
|
|
|
828
|
+
// Fix #24: Callback exposure — add sinks for callback invocations with tainted data
|
|
829
|
+
// FP fix: cap at 5 exposures per file. Real attacks have 1-2 targeted callbacks,
|
|
830
|
+
// >5 is minified code noise (jspdf, etc.)
|
|
831
|
+
const cappedExposures = callbackExposures.slice(0, 5);
|
|
832
|
+
for (const exposure of cappedExposures) {
|
|
833
|
+
sinks.push({
|
|
834
|
+
type: 'network_send',
|
|
835
|
+
name: `${exposure.callbackName}(${exposure.argName}) [callback exposure]`,
|
|
836
|
+
line: exposure.line,
|
|
837
|
+
taint_tracked: true
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
773
841
|
// Check if any source or sink was resolved via taint tracking
|
|
774
842
|
const hasTaintTracked = sources.some(s => s.taint_tracked) || sinks.some(s => s.taint_tracked);
|
|
775
843
|
|
|
@@ -804,6 +872,17 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
804
872
|
}
|
|
805
873
|
if (severity === 'CRITICAL') break;
|
|
806
874
|
}
|
|
875
|
+
// Fix #24: EventEmitter broadcast and callback exposure sinks are always CRITICAL
|
|
876
|
+
// when combined with credential sources — the data is being sent to external consumers
|
|
877
|
+
if (severity !== 'CRITICAL') {
|
|
878
|
+
const hasExposureSink = exfilSinks.some(s =>
|
|
879
|
+
s.name.includes('[cross-module broadcast]') || s.name.includes('[callback exposure]')
|
|
880
|
+
);
|
|
881
|
+
const hasCredentialSource = sources.some(s => s.type === 'credential_read');
|
|
882
|
+
if (hasExposureSink && hasCredentialSource) {
|
|
883
|
+
severity = 'CRITICAL';
|
|
884
|
+
}
|
|
885
|
+
}
|
|
807
886
|
|
|
808
887
|
// Downgrade: if ALL sources are pure telemetry (os.platform, os.arch), cap at HIGH
|
|
809
888
|
const allTelemetryOnly = sources.every(s => s.type === 'telemetry_read');
|
package/src/scanner/package.js
CHANGED
|
@@ -103,6 +103,19 @@ async function scanPackageJson(targetPath) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// Escalate: lifecycle script targeting node_modules/ — payload hiding technique.
|
|
107
|
+
// Legitimate postinstall scripts run from the package's own directory, not from node_modules/.
|
|
108
|
+
// Lazarus/DPRK interview attacks hide payloads in node_modules/.cache/ or similar paths.
|
|
109
|
+
if (['preinstall', 'install', 'postinstall'].includes(scriptName) &&
|
|
110
|
+
/\bnode_modules[\/\\]/.test(scriptContent)) {
|
|
111
|
+
threats.push({
|
|
112
|
+
type: 'lifecycle_hidden_payload',
|
|
113
|
+
severity: 'CRITICAL',
|
|
114
|
+
message: `Critical: "${scriptName}" targets file inside node_modules/ — payload hiding technique to evade scanners.`,
|
|
115
|
+
file: 'package.json'
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
// Detect Bun runtime evasion in lifecycle scripts (Shai-Hulud 2.0)
|
|
107
120
|
if (/\bbun\s+(run|exec|install|x)\b/.test(scriptContent) || /\bbunx\s+/.test(scriptContent)) {
|
|
108
121
|
threats.push({
|
package/src/scoring.js
CHANGED
|
@@ -131,7 +131,8 @@ const FP_COUNT_THRESHOLDS = {
|
|
|
131
131
|
// P4: bundled credential_tampering from minified alias resolution (jspdf, lerna)
|
|
132
132
|
credential_tampering: { maxCount: 5, to: 'LOW' },
|
|
133
133
|
// B1 FP reduction: bundled code aliases eval/Function (sinon, storybook, vitest)
|
|
134
|
-
|
|
134
|
+
// FP fix: also cover HIGH severity (setTimeout+stringBuildVar in minified code)
|
|
135
|
+
dangerous_call_eval: { maxCount: 3, to: 'LOW' },
|
|
135
136
|
// P6: HTTP client libraries (undici, aws-sdk, nodemailer, jsdom) parse Authorization/Bearer headers
|
|
136
137
|
// with 3+ credential regexes. Real harvesters use 1-2 targeted regexes.
|
|
137
138
|
credential_regex_harvest: { maxCount: 2, from: 'HIGH', to: 'LOW' },
|
|
@@ -379,13 +380,10 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
379
380
|
}
|
|
380
381
|
}
|
|
381
382
|
|
|
382
|
-
// require_cache_poison: single
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
t.reductions.push({ rule: 'cache_poison_single', from: 'CRITICAL', to: 'HIGH' });
|
|
387
|
-
t.severity = 'HIGH';
|
|
388
|
-
}
|
|
383
|
+
// require_cache_poison: single-hit downgrade removed.
|
|
384
|
+
// The READ/WRITE distinction in ast-detectors already handles the FP case:
|
|
385
|
+
// READ-only → LOW (hot-reload, introspection), WRITE → CRITICAL (malicious replacement).
|
|
386
|
+
// A single cache WRITE is genuinely malicious — no downgrade needed.
|
|
389
387
|
|
|
390
388
|
// Prototype hook: framework class prototypes → MEDIUM
|
|
391
389
|
// Core Node.js prototypes (http.IncomingMessage, net.Socket) stay CRITICAL
|
|
@@ -432,9 +430,12 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
432
430
|
}
|
|
433
431
|
|
|
434
432
|
// Reachability: findings in files not reachable from entry points → LOW
|
|
433
|
+
// Exception: .d.ts files are never require()'d by JS but are executed by ts-node/tsx/bun.
|
|
434
|
+
// Executable code in .d.ts is always malicious — exempt from unreachable downgrade.
|
|
435
|
+
const isDtsFile = t.file && t.file.endsWith('.d.ts');
|
|
435
436
|
if (reachableFiles && reachableFiles.size > 0 && t.file &&
|
|
436
437
|
!REACHABILITY_EXEMPT_TYPES.has(t.type) &&
|
|
437
|
-
!isPackageLevelThreat(t)) {
|
|
438
|
+
!isPackageLevelThreat(t) && !isDtsFile) {
|
|
438
439
|
const normalizedFile = t.file.replace(/\\/g, '/');
|
|
439
440
|
if (!reachableFiles.has(normalizedFile)) {
|
|
440
441
|
t.reductions.push({ rule: 'unreachable', from: t.severity, to: 'LOW' });
|
|
@@ -22,14 +22,29 @@ function analyzeWithDeobfuscation(targetPath, analyzeFileFn, options = {}) {
|
|
|
22
22
|
if (options.excludedFiles && options.excludedFiles.includes(relativePath)) return;
|
|
23
23
|
if (options.skipDevFiles !== false && isDevFile(relativePath)) return;
|
|
24
24
|
|
|
25
|
+
// .d.ts files: strip TypeScript declaration syntax before JS parsing.
|
|
26
|
+
// Legitimate .d.ts files contain only type declarations (no executable code).
|
|
27
|
+
// Any require/exec/network calls in a .d.ts are high-confidence malicious payload hiding.
|
|
28
|
+
let effectiveContent = content;
|
|
29
|
+
if (file.endsWith('.d.ts')) {
|
|
30
|
+
effectiveContent = content.split('\n').map(line => {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
// Strip lines that are pure TypeScript declarations (Acorn can't parse these)
|
|
33
|
+
if (/^export\s+declare\s+/.test(trimmed)) return '// [ts-stripped]';
|
|
34
|
+
if (/^declare\s+(function|class|const|let|var|type|interface|enum|namespace|module|global)\s/.test(trimmed)) return '// [ts-stripped]';
|
|
35
|
+
if (/^(export\s+)?(type|interface)\s/.test(trimmed)) return '// [ts-stripped]';
|
|
36
|
+
return line;
|
|
37
|
+
}).join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
// Analyze original code first (preserves obfuscation-detection rules)
|
|
26
|
-
const fileThreats = analyzeFileFn(
|
|
41
|
+
const fileThreats = analyzeFileFn(effectiveContent, file, targetPath);
|
|
27
42
|
threats.push(...fileThreats);
|
|
28
43
|
|
|
29
44
|
// Also analyze deobfuscated code for additional findings hidden by obfuscation
|
|
30
45
|
if (typeof options.deobfuscate === 'function') {
|
|
31
46
|
try {
|
|
32
|
-
const result = options.deobfuscate(
|
|
47
|
+
const result = options.deobfuscate(effectiveContent);
|
|
33
48
|
if (result.transforms.length > 0) {
|
|
34
49
|
const deobThreats = analyzeFileFn(result.code, file, targetPath);
|
|
35
50
|
const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
|
package/src/utils.js
CHANGED
|
@@ -183,7 +183,9 @@ function _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visi
|
|
|
183
183
|
* @returns {string[]} List of .js file paths
|
|
184
184
|
*/
|
|
185
185
|
function findJsFiles(dir, results = []) {
|
|
186
|
-
|
|
186
|
+
// .d.ts included: legitimate .d.ts files never contain require/exec/network calls,
|
|
187
|
+
// so any executable code in .d.ts is a high-confidence malicious payload hiding technique.
|
|
188
|
+
return findFiles(dir, { extensions: ['.js', '.mjs', '.cjs', '.d.ts'], results });
|
|
187
189
|
}
|
|
188
190
|
|
|
189
191
|
function clearFileListCache() {
|