muaddib-scanner 2.11.16 → 2.11.17

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.16",
3
+ "version": "2.11.17",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -161,6 +161,14 @@ const PLAYBOOKS = {
161
161
  typosquat_detected:
162
162
  'ATTENTION: Ce package a un nom tres similaire a un package populaire. Verifier que c\'est bien le bon package. Si erreur de frappe, corriger immediatement.',
163
163
 
164
+ // RT-C1: dependency boundary-squat (Axios UNC1069 March 2026)
165
+ dependency_typosquat:
166
+ 'Une dependance declaree ressemble a un package populaire avec un prefixe/suffixe suspect. Verifier le nom exact dans package.json et confirmer avec npm view <package>. Si erreur de frappe, corriger immediatement.',
167
+ dependency_typosquat_used:
168
+ 'Le code charge cette dep typosquattee via require/import. Si ce n\'est pas intentionnel, supprimer la dep et la reference, puis reinstaller avec --ignore-scripts.',
169
+ dependency_typosquat_require:
170
+ 'CRITIQUE — pattern Axios UNC1069 detecte: dep typosquattee declaree ET chargee dans le code. Le wrapper apparent est probablement legitime mais sa dep contient le payload. Bloquer l\'install (--ignore-scripts), supprimer la dep, auditer le history de modifications.',
171
+
164
172
  dangerous_call_function:
165
173
  'Appel new Function() detecte. Equivalent a eval(). Verifier la source des donnees.',
166
174
 
@@ -335,6 +335,38 @@ const RULES = {
335
335
  mitre: 'T1195.002'
336
336
  },
337
337
 
338
+ // RT-C1: Dependency boundary-squat (Axios UNC1069 March 2026)
339
+ dependency_typosquat: {
340
+ id: 'MUADDIB-TYPO-002',
341
+ name: 'Dependency Boundary-Squat',
342
+ severity: 'HIGH',
343
+ confidence: 'high',
344
+ description: 'Une dependance declaree porte le nom d\'un package populaire prefixe/suffixe d\'un token suspect (Axios UNC1069, mars 2026). Le wrapper innocent declare un sub-dep malveillant.',
345
+ references: [
346
+ 'https://snyk.io/blog/typosquatting-attacks/',
347
+ 'https://attack.mitre.org/techniques/T1195/002/'
348
+ ],
349
+ mitre: 'T1195.002'
350
+ },
351
+ dependency_typosquat_used: {
352
+ id: 'MUADDIB-TYPO-003',
353
+ name: 'Boundary-Squat Dependency Used in Code',
354
+ severity: 'MEDIUM',
355
+ confidence: 'high',
356
+ description: 'Le code du package require/import un nom de dependance identifie comme boundary-squat. Signal fort que la dep typosquattee est intentionnellement chargee.',
357
+ references: ['https://attack.mitre.org/techniques/T1195/002/'],
358
+ mitre: 'T1195.002'
359
+ },
360
+ dependency_typosquat_require: {
361
+ id: 'MUADDIB-COMPOUND-013',
362
+ name: 'Boundary-Squat Dep Required at Runtime',
363
+ severity: 'CRITICAL',
364
+ confidence: 'high',
365
+ description: 'Dependance boundary-squat declaree ET chargee via require/import dans le code: pattern Axios UNC1069 (sub-dep injection avec wrapper innocent).',
366
+ references: ['https://attack.mitre.org/techniques/T1195/002/'],
367
+ mitre: 'T1195.002'
368
+ },
369
+
338
370
  // Package.json script patterns
339
371
  curl_pipe_sh: {
340
372
  id: 'MUADDIB-PKG-002',
@@ -24,12 +24,13 @@ const MODULE_SOURCE_METHODS = {
24
24
  },
25
25
  child_process: {
26
26
  exec: 'command_output', execSync: 'command_output',
27
- spawn: 'command_output', spawnSync: 'command_output'
27
+ spawn: 'command_output', spawnSync: 'command_output',
28
+ execFile: 'command_output', execFileSync: 'command_output', fork: 'command_output'
28
29
  }
29
30
  };
30
31
 
31
32
  const MODULE_SINK_METHODS = {
32
- child_process: { exec: 'exec_sink', execSync: 'exec_sink', spawn: 'exec_sink' },
33
+ child_process: { exec: 'exec_sink', execSync: 'exec_sink', spawn: 'exec_sink', execFile: 'exec_sink', execFileSync: 'exec_sink', fork: 'exec_sink' },
33
34
  http: { request: 'network_send', get: 'network_send' },
34
35
  https: { request: 'network_send', get: 'network_send' },
35
36
  net: { connect: 'network_send', createConnection: 'network_send' },
@@ -49,7 +50,7 @@ const TRACKED_MODULES = new Set([
49
50
  ]);
50
51
 
51
52
  // Methods that execute commands — used for exec result capture detection
52
- const EXEC_METHODS = new Set(['exec', 'execSync', 'spawn', 'spawnSync']);
53
+ const EXEC_METHODS = new Set(['exec', 'execSync', 'spawn', 'spawnSync', 'execFile', 'execFileSync', 'fork']);
53
54
 
54
55
  /**
55
56
  * Pre-pass: builds a taint map from require() assignments.
@@ -60,6 +61,24 @@ function buildTaintMap(ast) {
60
61
  const taintMap = new Map();
61
62
 
62
63
  walk.simple(ast, {
64
+ // ESM: import fs from 'fs' / import * as fs from 'fs' / import { exec } from 'child_process'
65
+ // Mirrors module-graph/annotate-tainted.js so ESM and CJS produce symmetric taint maps.
66
+ ImportDeclaration(node) {
67
+ if (!node.source || typeof node.source.value !== 'string') return;
68
+ const modName = node.source.value;
69
+ if (!TRACKED_MODULES.has(modName)) return;
70
+ for (const spec of node.specifiers) {
71
+ if (!spec.local || spec.local.type !== 'Identifier') continue;
72
+ if (spec.type === 'ImportDefaultSpecifier' || spec.type === 'ImportNamespaceSpecifier') {
73
+ taintMap.set(spec.local.name, { source: modName, detail: modName });
74
+ } else if (spec.type === 'ImportSpecifier') {
75
+ const imported = spec.imported && spec.imported.type === 'Identifier'
76
+ ? spec.imported.name
77
+ : (spec.imported && spec.imported.value ? spec.imported.value : spec.local.name);
78
+ taintMap.set(spec.local.name, { source: modName, detail: `${modName}.${imported}` });
79
+ }
80
+ }
81
+ },
63
82
  VariableDeclarator(node) {
64
83
  if (!node.init) return;
65
84
  let init = node.init;
@@ -471,7 +490,9 @@ function analyzeFile(content, filePath, basePath) {
471
490
 
472
491
  }
473
492
 
474
- if (callName === 'exec' || callName === 'execSync') {
493
+ // DF-H2: extend exec_network classification to all EXEC_METHODS
494
+ // (execFile/execFileSync/fork were previously missed — trivial evasion vector).
495
+ if (EXEC_METHODS.has(callName)) {
475
496
  const arg = node.arguments[0];
476
497
  if (arg && arg.type === 'Literal' && typeof arg.value === 'string') {
477
498
  if (arg.value.includes('curl') || arg.value.includes('wget')) {
@@ -480,7 +501,6 @@ function analyzeFile(content, filePath, basePath) {
480
501
  name: callName,
481
502
  line: node.loc?.start?.line
482
503
  });
483
-
484
504
  }
485
505
  }
486
506
  }
@@ -214,6 +214,20 @@ function walkForSpawnTargets(node, fileDir, packagePath, targets) {
214
214
  }
215
215
  }
216
216
 
217
+ // RC-C3: new Worker('./worker.js') / new w.Worker(...) — worker_threads spawn.
218
+ // Stable since Node 12 (2019). Resolves only when first arg points to a real .js/.mjs/.cjs.
219
+ if (node.type === 'NewExpression' && node.callee && node.arguments && node.arguments.length >= 1) {
220
+ const ctorName = node.callee.type === 'Identifier'
221
+ ? node.callee.name
222
+ : (node.callee.type === 'MemberExpression' && node.callee.property
223
+ ? (node.callee.property.name || node.callee.property.value || '')
224
+ : '');
225
+ if (ctorName === 'Worker') {
226
+ const target = resolvePathArg(node.arguments[0], fileDir, packagePath);
227
+ if (target) targets.push(target);
228
+ }
229
+ }
230
+
217
231
  for (const key of Object.keys(node)) {
218
232
  if (key === 'type') continue;
219
233
  const child = node[key];
@@ -24,9 +24,30 @@ const POPULAR_PACKAGES = [
24
24
  'mobx', 'redux', 'zustand', 'formik', 'yup', 'ajv', 'validator',
25
25
  'date-fns', 'dayjs', 'luxon', 'numeral', 'accounting', 'currency.js',
26
26
  'lodash-es', 'core-js', 'regenerator-runtime', 'tslib', 'classnames',
27
- 'prop-types', 'cross-env', 'node-fetch', 'got'
27
+ 'prop-types', 'cross-env', 'node-fetch', 'got',
28
+ // RT-C1 (Axios UNC1069 March 2026): crypto-js missing — wrapper packages declared
29
+ // `plain-crypto-js` as sub-dep. Added so dependency boundary-squat catches it.
30
+ 'crypto-js'
28
31
  ];
29
32
 
33
+ // RT-C1: Hyphen tokens that legitimately PREFIX or SUFFIX popular package names.
34
+ // `<token>-<popular>` or `<popular>-<token>` is considered benign when the extra
35
+ // token is in this set (ecosystem qualifiers, framework prefixes, official scopes).
36
+ const LEGIT_BOUNDARY_TOKENS = new Set([
37
+ // Frameworks / build tools (also common official sub-packages)
38
+ 'react', 'vue', 'angular', 'svelte', 'next', 'nuxt', 'gatsby', 'expo',
39
+ 'eslint', 'babel', 'webpack', 'rollup', 'vite', 'parcel', 'esbuild',
40
+ 'jest', 'mocha', 'vitest', 'karma', 'cypress', 'playwright',
41
+ 'typescript', 'ts', 'tsdx', 'koa', 'fastify', 'express', 'nest',
42
+ 'redux', 'mobx', 'apollo', 'graphql', 'rxjs',
43
+ // Build / runtime variants
44
+ 'cli', 'core', 'utils', 'plugin', 'loader', 'preset', 'config',
45
+ 'common', 'browser', 'node', 'native', 'web', 'mobile',
46
+ 'esm', 'cjs', 'umd', 'es', 'types', 'typings',
47
+ // Versions / channels
48
+ 'v2', 'v3', 'v4', 'next', 'latest', 'stable', 'lts', 'legacy', 'beta', 'alpha'
49
+ ]);
50
+
30
51
  // Packages legitimes courts ou qui ressemblent a des populaires
31
52
  const WHITELIST = new Set([
32
53
  // Packages tres courts legitimes
@@ -275,8 +296,6 @@ async function scanTyposquatting(targetPath) {
275
296
  }
276
297
  }
277
298
 
278
- if (candidates.length === 0) return threats;
279
-
280
299
  // Phase 2: API enrichment (batched to avoid socket exhaustion)
281
300
  const BATCH_SIZE = 10;
282
301
  const metadataResults = [];
@@ -333,9 +352,152 @@ async function scanTyposquatting(targetPath) {
333
352
  });
334
353
  }
335
354
 
355
+ // ============================================
356
+ // RT-C1: dependency boundary-squat detection (Axios UNC1069 March 2026)
357
+ // ============================================
358
+ // Runs on deps that did NOT match Levenshtein (length filter excludes them).
359
+ // Catches `<prefix>-<popular>` / `<popular>-<suffix>` injections in package.json
360
+ // deps, plus require/import usage cross-check inside the package source.
361
+ const levenshteinMatches = new Set(candidates.map(c => c.depName));
362
+ const RT_C1_MAX_DEPS = 50;
363
+ let depsEvaluated = 0;
364
+ for (const depName of Object.keys(dependencies)) {
365
+ if (depsEvaluated >= RT_C1_MAX_DEPS) break;
366
+ if (levenshteinMatches.has(depName)) continue; // already flagged by Levenshtein path
367
+ const bMatch = findDependencyBoundarySquat(depName);
368
+ if (!bMatch) continue;
369
+ depsEvaluated++;
370
+
371
+ const declMsg = 'Dependency "' + depName + '" looks like a boundary-squat of "'
372
+ + bMatch.original + '" (extra token: "' + bMatch.extra + '"). Axios UNC1069 pattern.';
373
+ threats.push({
374
+ type: 'dependency_typosquat',
375
+ severity: 'HIGH',
376
+ message: declMsg,
377
+ file: 'package.json',
378
+ details: {
379
+ suspicious: depName,
380
+ legitimate: bMatch.original,
381
+ technique: bMatch.type,
382
+ extra: bMatch.extra,
383
+ distance: bMatch.distance
384
+ }
385
+ });
386
+
387
+ // Cross-check: scan package source for require/import of this dep name
388
+ const usages = findDependencyUsages(targetPath, depName);
389
+ for (const u of usages) {
390
+ threats.push({
391
+ type: 'dependency_typosquat_used',
392
+ severity: 'MEDIUM',
393
+ message: 'Boundary-squat dep "' + depName + '" is require()/import()d in source code',
394
+ file: u.file,
395
+ line: u.line,
396
+ details: {
397
+ suspicious: depName,
398
+ legitimate: bMatch.original
399
+ }
400
+ });
401
+ }
402
+ }
403
+
336
404
  return threats;
337
405
  }
338
406
 
407
+ /**
408
+ * RT-C1: Detects "boundary squat" patterns: <prefix>-<popular> or <popular>-<suffix>
409
+ * where the hyphenated tokens fully contain a popular package name and the extra
410
+ * material is NOT in LEGIT_BOUNDARY_TOKENS. This catches Axios UNC1069-style
411
+ * sub-dependency injection like `plain-crypto-js` (resembles `crypto-js`) that
412
+ * the Levenshtein matcher misses because length-diff is too large.
413
+ */
414
+ function findDependencyBoundarySquat(name) {
415
+ const lower = name.toLowerCase();
416
+ if (!lower || lower.startsWith('@')) return null; // skip scoped
417
+ if (lower.length < MIN_PACKAGE_LENGTH) return null;
418
+ if (WHITELIST.has(lower)) return null;
419
+ if (isLegitimateVariant(lower)) return null;
420
+ if (!lower.includes('-')) return null; // need a boundary
421
+ // If it's an exact match to a popular package, not a squat
422
+ if (POPULAR_PACKAGES_LOWER.indexOf(lower) !== -1) return null;
423
+
424
+ for (let i = 0; i < POPULAR_PACKAGES.length; i++) {
425
+ const popular = POPULAR_PACKAGES_LOWER[i];
426
+ if (popular.length < MIN_PACKAGE_LENGTH) continue;
427
+ if (lower === popular) continue;
428
+
429
+ if (popular.includes('-')) {
430
+ // Multi-token popular (e.g. crypto-js): match prefix or suffix at hyphen boundary
431
+ let extra = null;
432
+ if (lower.endsWith('-' + popular)) {
433
+ extra = lower.slice(0, lower.length - popular.length - 1);
434
+ } else if (lower.startsWith(popular + '-')) {
435
+ extra = lower.slice(popular.length + 1);
436
+ }
437
+ if (extra === null || extra.length === 0) continue;
438
+ // Reject if extra is a legit boundary token (single token only)
439
+ if (!extra.includes('-') && LEGIT_BOUNDARY_TOKENS.has(extra)) continue;
440
+ return { original: POPULAR_PACKAGES[i], type: 'boundary_squat', distance: extra.length, extra };
441
+ } else {
442
+ // Single-token popular: must appear as a full hyphen-bounded token in name
443
+ const tokens = lower.split('-');
444
+ const idx = tokens.indexOf(popular);
445
+ if (idx === -1) continue;
446
+ if (tokens.length === 1) continue;
447
+ const siblings = tokens.filter((_, j) => j !== idx);
448
+ // If all siblings are legit boundary tokens → benign variant (e.g. react-router)
449
+ if (siblings.every(t => LEGIT_BOUNDARY_TOKENS.has(t) || isLegitimateVariant(t))) continue;
450
+ const extra = siblings.join('-');
451
+ return { original: POPULAR_PACKAGES[i], type: 'boundary_squat', distance: extra.length, extra };
452
+ }
453
+ }
454
+ return null;
455
+ }
456
+
457
+ // RT-C1: Bounded scan of the package source for require/import of a given dep name.
458
+ // Returns array of { file, line } usage sites. Bounds: 200 files, 256KB per file.
459
+ const _DEP_USE_MAX_FILES = 200;
460
+ const _DEP_USE_MAX_FILE_BYTES = 256 * 1024;
461
+ function findDependencyUsages(targetPath, depName) {
462
+ const out = [];
463
+ if (!depName) return out;
464
+
465
+ let files;
466
+ try {
467
+ // Use a local require to avoid a circular import surface — utils.js is stable.
468
+ const { findFiles } = require('../utils.js');
469
+ files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], maxFiles: _DEP_USE_MAX_FILES });
470
+ } catch {
471
+ return out;
472
+ }
473
+ if (!files || files.length === 0) return out;
474
+
475
+ // Pre-build matchers — escape regex metacharacters in dep name
476
+ const escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
477
+ const reRequire = new RegExp(`require\\s*\\(\\s*['\"]${escaped}['\"]\\s*\\)`);
478
+ const reFrom = new RegExp(`from\\s+['\"]${escaped}['\"]`);
479
+ const reDynamic = new RegExp(`import\\s*\\(\\s*['\"]${escaped}['\"]\\s*\\)`);
480
+
481
+ for (const abs of files) {
482
+ let stat;
483
+ try { stat = fs.statSync(abs); } catch { continue; }
484
+ if (!stat.isFile() || stat.size > _DEP_USE_MAX_FILE_BYTES) continue;
485
+ let content;
486
+ try { content = fs.readFileSync(abs, 'utf8'); } catch { continue; }
487
+ // Fast-path early bail
488
+ if (!content.includes(depName)) continue;
489
+ const lines = content.split(/\r?\n/);
490
+ for (let i = 0; i < lines.length; i++) {
491
+ const ln = lines[i];
492
+ if (reRequire.test(ln) || reFrom.test(ln) || reDynamic.test(ln)) {
493
+ out.push({ file: path.relative(targetPath, abs), line: i + 1 });
494
+ break; // one match per file is enough — keeps signal density honest
495
+ }
496
+ }
497
+ }
498
+ return out;
499
+ }
500
+
339
501
  function findTyposquatMatch(name) {
340
502
  const nameLower = name.toLowerCase();
341
503
 
package/src/scoring.js CHANGED
@@ -112,6 +112,8 @@ const PACKAGE_LEVEL_TYPES = new Set([
112
112
  // Compound scoring rules — package-level co-occurrences
113
113
  'lifecycle_typosquat', 'lifecycle_inline_exec', 'lifecycle_remote_require',
114
114
  'lifecycle_dataflow', 'lifecycle_dangerous_exec', 'obfuscated_lifecycle_env',
115
+ // RT-C1: dependency boundary-squat family (Axios UNC1069 March 2026)
116
+ 'dependency_typosquat', 'dependency_typosquat_require',
115
117
  // Blue Team v8: package-level boost signals
116
118
  'isolated_suspicious_file', 'deep_suspicious_file',
117
119
  // Blue Team v8b: phantom lifecycle scripts
@@ -380,7 +382,9 @@ const DIST_EXEMPT_TYPES = new Set([
380
382
  'crypto_staged_payload', 'lifecycle_typosquat',
381
383
  'lifecycle_inline_exec', 'lifecycle_remote_require',
382
384
  'lifecycle_file_exec', // B6: lifecycle → malicious file compound
383
- 'lifecycle_dataflow', 'lifecycle_dangerous_exec', 'obfuscated_lifecycle_env'
385
+ 'lifecycle_dataflow', 'lifecycle_dangerous_exec', 'obfuscated_lifecycle_env',
386
+ // RT-C1: Boundary-squat compound is never coincidental (dep declared AND require()d)
387
+ 'dependency_typosquat_require'
384
388
  // P6: remote_code_load and proxy_data_intercept removed — in bundled dist/ files,
385
389
  // fetch + eval co-occurrence is coincidental (bundler combines HTTP client + template compilation).
386
390
  // fetch_decrypt_exec (fetch+decrypt+eval triple) remains exempt — never coincidental.
@@ -488,6 +492,15 @@ const SCORING_COMPOUNDS = [
488
492
  message: 'Lifecycle hook on typosquat package — dependency confusion attack vector (scoring compound).',
489
493
  fileFrom: 'typosquat_detected'
490
494
  },
495
+ {
496
+ // RT-C1: Boundary-squat dep declared AND require()d in code → CRITICAL.
497
+ // Pattern Axios UNC1069 (March 2026): wrapper looks benign, payload is in the dep.
498
+ type: 'dependency_typosquat_require',
499
+ requires: ['dependency_typosquat', 'dependency_typosquat_used'],
500
+ severity: 'CRITICAL',
501
+ message: 'Boundary-squat dependency declared AND require()d in code — Axios UNC1069 pattern (scoring compound).',
502
+ fileFrom: 'dependency_typosquat_used'
503
+ },
491
504
  {
492
505
  type: 'lifecycle_inline_exec',
493
506
  requires: ['lifecycle_script', 'node_inline_exec'],