muaddib-scanner 2.4.4 → 2.4.6

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.4.4",
3
+ "version": "2.4.6",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -221,8 +221,27 @@ function containsDecodePattern(node) {
221
221
  // workflowPathVars, execPathVars, globalThisAliases,
222
222
  // hasFromCharCode, hasEvalInFile (mutable)
223
223
 
224
+ function isStaticValue(node) {
225
+ if (!node) return false;
226
+ if (node.type === 'Literal' && typeof node.value === 'string') return true;
227
+ if (node.type === 'ArrayExpression') {
228
+ return node.elements.every(el => el && el.type === 'Literal' && typeof el.value === 'string');
229
+ }
230
+ if (node.type === 'ObjectExpression') {
231
+ return node.properties.every(p =>
232
+ p.value && p.value.type === 'Literal' && typeof p.value.value === 'string'
233
+ );
234
+ }
235
+ return false;
236
+ }
237
+
224
238
  function handleVariableDeclarator(node, ctx) {
225
239
  if (node.id?.type === 'Identifier') {
240
+ // Track statically-assigned variables for dynamic_require qualification
241
+ if (node.init && isStaticValue(node.init)) {
242
+ ctx.staticAssignments.add(node.id.name);
243
+ }
244
+
226
245
  // Track dynamic require vars
227
246
  if (node.init?.type === 'CallExpression') {
228
247
  const initCallName = getCallName(node.init);
@@ -305,10 +324,15 @@ function handleCallExpression(node, ctx) {
305
324
  });
306
325
  }
307
326
  } else if (arg.type === 'Identifier') {
327
+ // If the variable was assigned from a static value (string literal,
328
+ // array of strings, object with string values), it's a plugin loader pattern
329
+ const severity = ctx.staticAssignments.has(arg.name) ? 'LOW' : 'HIGH';
308
330
  ctx.threats.push({
309
331
  type: 'dynamic_require',
310
- severity: 'HIGH',
311
- message: 'Dynamic require() with variable argument (module name obfuscation).',
332
+ severity,
333
+ message: severity === 'LOW'
334
+ ? `Dynamic require() with statically-assigned variable "${arg.name}" (plugin loader pattern).`
335
+ : 'Dynamic require() with variable argument (module name obfuscation).',
312
336
  file: ctx.relFile
313
337
  });
314
338
  }
@@ -717,14 +741,13 @@ function handleCallExpression(node, ctx) {
717
741
  message: 'Function() with decode argument (atob/Buffer.from base64) — staged payload execution.',
718
742
  file: ctx.relFile
719
743
  });
720
- } else {
721
- const isConstant = hasOnlyStringLiteralArgs(node);
744
+ } else if (!hasOnlyStringLiteralArgs(node)) {
745
+ // Only flag dynamic Function() calls — string literal args (e.g. Function('return this'))
746
+ // are zero-risk globalThis polyfills used by every bundler
722
747
  ctx.threats.push({
723
748
  type: 'dangerous_call_function',
724
- severity: isConstant ? 'LOW' : 'MEDIUM',
725
- message: isConstant
726
- ? 'Function() with constant string literal (low risk, globalThis polyfill pattern).'
727
- : 'Function() with dynamic expression (template/factory pattern).',
749
+ severity: 'MEDIUM',
750
+ message: 'Function() with dynamic expression (template/factory pattern).',
728
751
  file: ctx.relFile
729
752
  });
730
753
  }
@@ -901,15 +924,15 @@ function handleImportExpression(node, ctx) {
901
924
 
902
925
  function handleNewExpression(node, ctx) {
903
926
  if (node.callee.type === 'Identifier' && node.callee.name === 'Function') {
904
- const isConstant = hasOnlyStringLiteralArgs(node);
905
- ctx.threats.push({
906
- type: 'dangerous_call_function',
907
- severity: isConstant ? 'LOW' : 'MEDIUM',
908
- message: isConstant
909
- ? 'new Function() with constant string literal (low risk, globalThis polyfill pattern).'
910
- : 'new Function() with dynamic expression (template/factory pattern).',
911
- file: ctx.relFile
912
- });
927
+ // Skip string literal args — zero-risk globalThis polyfills used by every bundler
928
+ if (!hasOnlyStringLiteralArgs(node)) {
929
+ ctx.threats.push({
930
+ type: 'dangerous_call_function',
931
+ severity: 'MEDIUM',
932
+ message: 'new Function() with dynamic expression (template/factory pattern).',
933
+ file: ctx.relFile
934
+ });
935
+ }
913
936
  }
914
937
 
915
938
  // Detect new Proxy(process.env, handler)
@@ -64,6 +64,7 @@ function analyzeFile(content, filePath, basePath) {
64
64
  threats,
65
65
  relFile: path.relative(basePath, filePath),
66
66
  dynamicRequireVars: new Set(),
67
+ staticAssignments: new Set(),
67
68
  dangerousCmdVars: new Map(),
68
69
  workflowPathVars: new Set(),
69
70
  execPathVars: new Map(),
@@ -114,6 +114,10 @@ async function analyzeDataFlow(targetPath, options = {}) {
114
114
  });
115
115
  }
116
116
 
117
+ /**
118
+ * Check if a VariableDeclarator init expression is a source-generating expression.
119
+ * Used to track which variables hold data from sensitive sources.
120
+ */
117
121
  function analyzeFile(content, filePath, basePath) {
118
122
  const threats = [];
119
123
  let ast;
@@ -208,6 +212,7 @@ function analyzeFile(content, filePath, basePath) {
208
212
  name: callName,
209
213
  line: node.loc?.start?.line
210
214
  });
215
+
211
216
  }
212
217
 
213
218
  if (callName === 'exec' || callName === 'execSync') {
@@ -219,6 +224,7 @@ function analyzeFile(content, filePath, basePath) {
219
224
  name: callName,
220
225
  line: node.loc?.start?.line
221
226
  });
227
+
222
228
  }
223
229
  }
224
230
  }
@@ -268,18 +274,22 @@ function analyzeFile(content, filePath, basePath) {
268
274
  // DNS resolution as exfiltration sink
269
275
  if (obj.name === 'dns' && ['resolve', 'lookup', 'resolve4', 'resolve6', 'resolveTxt'].includes(prop.name)) {
270
276
  sinks.push({ type: 'network_send', name: `dns.${prop.name}`, line: node.loc?.start?.line });
277
+
271
278
  }
272
279
  // HTTP/HTTPS request/get as network sink
273
280
  if ((obj.name === 'http' || obj.name === 'https') && ['request', 'get'].includes(prop.name)) {
274
281
  sinks.push({ type: 'network_send', name: `${obj.name}.${prop.name}`, line: node.loc?.start?.line });
282
+
275
283
  }
276
284
  // net.connect / net.createConnection / tls.connect as network sink
277
285
  if ((obj.name === 'net' || obj.name === 'tls') && ['connect', 'createConnection'].includes(prop.name)) {
278
286
  sinks.push({ type: 'network_send', name: `${obj.name}.${prop.name}`, line: node.loc?.start?.line });
287
+
279
288
  }
280
289
  // Instance socket.connect(port, host) when file imports net/tls
281
290
  if (hasRawSocketModule && prop.name === 'connect' && node.arguments.length >= 2) {
282
291
  sinks.push({ type: 'network_send', name: 'socket.connect', line: node.loc?.start?.line });
292
+
283
293
  }
284
294
  }
285
295
  }
@@ -322,8 +332,9 @@ function analyzeFile(content, filePath, basePath) {
322
332
  // Check sink methods
323
333
  const sinkMethods = MODULE_SINK_METHODS[moduleName];
324
334
  if (sinkMethods && sinkMethods[methodName]) {
335
+ const sinkType = sinkMethods[methodName];
325
336
  sinks.push({
326
- type: sinkMethods[methodName],
337
+ type: sinkType,
327
338
  name: `${moduleName}.${methodName}`,
328
339
  line: node.loc?.start?.line,
329
340
  taint_tracked: true
@@ -341,8 +352,9 @@ function analyzeFile(content, filePath, basePath) {
341
352
  // Check sink methods for destructured calls
342
353
  const sinkMethods = MODULE_SINK_METHODS[moduleName];
343
354
  if (sinkMethods && sinkMethods[methodName]) {
355
+ const sinkType = sinkMethods[methodName];
344
356
  sinks.push({
345
- type: sinkMethods[methodName],
357
+ type: sinkType,
346
358
  name: `${moduleName}.${methodName}`,
347
359
  line: node.loc?.start?.line,
348
360
  taint_tracked: true
@@ -13,6 +13,12 @@ const ENCODING_TABLE_RE = /(?:encoding|tables|unicode|charmap|codepage)/i;
13
13
  // Minimum string length to analyze (short strings naturally have low entropy)
14
14
  const MIN_STRING_LENGTH = 50;
15
15
 
16
+ // Maximum string length to analyze — strings >1000 chars are data blobs
17
+ // (certificates, unicode tables, embedded binary), not malware payloads.
18
+ // Real malware uses 50-500 char encoded payloads; making payloads longer
19
+ // defeats the purpose of obfuscation.
20
+ const MAX_STRING_LENGTH = 1000;
21
+
16
22
  // Thresholds (string-level only — file-level entropy removed, see design notes)
17
23
  const STRING_ENTROPY_MEDIUM = 5.5;
18
24
  const STRING_ENTROPY_HIGH = 6.5;
@@ -222,6 +228,7 @@ function scanEntropy(targetPath, options = {}) {
222
228
  const strings = extractStringLiterals(content);
223
229
  for (const str of strings) {
224
230
  if (str.length < MIN_STRING_LENGTH) continue;
231
+ if (str.length > MAX_STRING_LENGTH) continue;
225
232
 
226
233
  // Skip whitelisted patterns
227
234
  if (isWhitelistedString(str, relativePath)) continue;
package/src/scoring.js CHANGED
@@ -33,7 +33,9 @@ const RISK_THRESHOLDS = {
33
33
  // Maximum score (capped)
34
34
  const MAX_RISK_SCORE = 100;
35
35
 
36
- // Cap MEDIUM prototype_hook contribution (frameworks like Restify have 50+ extensions)
36
+ // Cap MEDIUM prototype_hook contribution MEDIUM hooks are framework class extensions
37
+ // (Request/Response/App/Router) which are not security risks. Capped at 15 points (5 × MEDIUM weight)
38
+ // to limit noise while preserving some signal. CRITICAL and HIGH prototype_hook findings still score normally.
37
39
  const PROTO_HOOK_MEDIUM_CAP = 15;
38
40
 
39
41
  // ============================================