muaddib-scanner 2.6.5 → 2.6.8

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/README.md CHANGED
@@ -270,7 +270,7 @@ With pre-commit framework:
270
270
  ```yaml
271
271
  repos:
272
272
  - repo: https://github.com/DNSZLSK/muad-dib
273
- rev: v2.6.2
273
+ rev: v2.6.6
274
274
  hooks:
275
275
  - id: muaddib-scan
276
276
  ```
@@ -286,7 +286,7 @@ repos:
286
286
  | **FPR** (Benign) | **12.1%** (64/529) | 529 npm packages, real source via `npm pack` |
287
287
  | **ADR** (Adversarial + Holdout) | **92.2%** (71/77) | 53 adversarial + 40 holdout (77 available on disk), global threshold=20 |
288
288
 
289
- **1974 tests** across 44 files, 86% code coverage. **129 rules** (124 RULES + 5 PARANOID).
289
+ **2009 tests** across 46 files, 86% code coverage. **130 rules** (125 RULES + 5 PARANOID).
290
290
 
291
291
  > **Methodology caveats:**
292
292
  > - TPR measured on 49 Node.js attack samples (3 browser-only excluded from 51 total)
@@ -327,7 +327,7 @@ npm test
327
327
 
328
328
  ### Testing
329
329
 
330
- - **1974 tests** across 44 modular test files - 86% code coverage
330
+ - **2009 tests** across 46 modular test files - 86% code coverage
331
331
  - **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
332
332
  - **Datadog 17K benchmark** - 17,922 real malware samples
333
333
  - **Ground truth validation** - 51 real-world attacks (93.9% TPR)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.6.5",
3
+ "version": "2.6.8",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -34,10 +34,6 @@ const { buildIntentPairs } = require('./intent-graph.js');
34
34
  const { MAX_FILE_SIZE, safeParse } = require('./shared/constants.js');
35
35
  const walk = require('acorn-walk');
36
36
 
37
- // Timeout constants for scan safety
38
- const SCANNER_TIMEOUT = 15000; // 15s per individual scanner
39
- const SCAN_TIMEOUT = 60000; // 60s global scan timeout
40
-
41
37
  // Paranoid mode scanner
42
38
  function scanParanoid(targetPath) {
43
39
  const threats = [];
@@ -398,9 +394,10 @@ async function run(targetPath, options = {}) {
398
394
  const emitterFlows = await yieldThen(() => detectEventEmitterFlows(graph, tainted, sinkAnnotations, targetPath));
399
395
  crossFileFlows = crossFileFlows.concat(emitterFlows);
400
396
  };
401
- const timeout = new Promise((_, reject) =>
402
- setTimeout(() => reject(new Error('Module graph timeout')), MODULE_GRAPH_TIMEOUT_MS)
403
- );
397
+ let graphTimerId;
398
+ const timeout = new Promise((_, reject) => {
399
+ graphTimerId = setTimeout(() => reject(new Error('Module graph timeout')), MODULE_GRAPH_TIMEOUT_MS);
400
+ });
404
401
  try {
405
402
  await Promise.race([moduleGraphWork(), timeout]);
406
403
  } catch (e) {
@@ -409,6 +406,8 @@ async function run(targetPath, options = {}) {
409
406
  if (e && e.message === 'Module graph timeout') {
410
407
  warnings.push(`Module graph analysis timed out (${MODULE_GRAPH_TIMEOUT_MS / 1000}s) — cross-file flows may be incomplete`);
411
408
  }
409
+ } finally {
410
+ clearTimeout(graphTimerId);
412
411
  }
413
412
  }
414
413
 
@@ -97,10 +97,6 @@ const COHERENCE_MATRIX = {
97
97
  },
98
98
  };
99
99
 
100
- // Kept for backward compatibility but no longer used in pairing
101
- // Cross-file detection is handled by module-graph.js (cross_file_dataflow)
102
- const CROSS_FILE_MULTIPLIER = 0.5;
103
-
104
100
  /**
105
101
  * Classify a threat as a source type.
106
102
  * Only high-confidence credential access patterns.
@@ -239,6 +235,5 @@ module.exports = {
239
235
  classifySource,
240
236
  classifySink,
241
237
  buildIntentPairs,
242
- COHERENCE_MATRIX,
243
- CROSS_FILE_MULTIPLIER
238
+ COHERENCE_MATRIX
244
239
  };
@@ -180,6 +180,9 @@ const PLAYBOOKS = {
180
180
  workflow_injection:
181
181
  'Injection potentielle dans GitHub Actions via input non sanitise sur self-hosted runner. Supprimer ou corriger le workflow.',
182
182
 
183
+ workflow_pwn_request:
184
+ 'CRITIQUE: Pwn request detecte — pull_request_target avec checkout du head de la PR permet l\'execution de code arbitraire. Remplacer par pull_request ou utiliser une strategie de checkout securisee (base ref uniquement).',
185
+
183
186
  sandbox_sensitive_file_read:
184
187
  'CRITIQUE: Package lit des fichiers sensibles (credentials) lors de l\'installation. Ne pas installer. Supprimer immediatement.',
185
188
  sandbox_sensitive_file_write:
@@ -844,6 +844,18 @@ const RULES = {
844
844
  references: ['https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions'],
845
845
  mitre: 'T1195.002'
846
846
  },
847
+ workflow_pwn_request: {
848
+ id: 'MUADDIB-GHA-003',
849
+ name: 'GitHub Actions Pwn Request',
850
+ severity: 'CRITICAL',
851
+ confidence: 'high',
852
+ description: 'Workflow pull_request_target avec checkout du head ref/sha de la PR — permet execution de code arbitraire (pwn request)',
853
+ references: [
854
+ 'https://securitylab.github.com/research/github-actions-preventing-pwn-requests/',
855
+ 'https://attack.mitre.org/techniques/T1195/002/'
856
+ ],
857
+ mitre: 'T1195.002'
858
+ },
847
859
 
848
860
  // Sandbox detections
849
861
  sandbox_sensitive_file_read: {
@@ -1104,7 +1116,7 @@ const RULES = {
1104
1116
  description: 'Package inactif depuis 6+ mois avec une nouvelle version soudaine. Possible changement de mainteneur ou compromission.',
1105
1117
  references: [
1106
1118
  'https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident',
1107
- 'https://snyk.io/blog/a]]malicious-npm-packages-targeting-developers/'
1119
+ 'https://snyk.io/blog/malicious-npm-packages-targeting-developers/'
1108
1120
  ],
1109
1121
  mitre: 'T1195.002'
1110
1122
  },
@@ -1387,6 +1399,7 @@ const RULES = {
1387
1399
  function getRule(type) {
1388
1400
  if (RULES[type]) return RULES[type];
1389
1401
  if (PARANOID_RULES[type]) return PARANOID_RULES[type];
1402
+ if (PARANOID_RULES_BY_ID[type]) return PARANOID_RULES_BY_ID[type];
1390
1403
  return {
1391
1404
  id: 'MUADDIB-UNK-001',
1392
1405
  name: 'Unknown Threat',
@@ -1437,4 +1450,10 @@ const PARANOID_RULES = {
1437
1450
  }
1438
1451
  };
1439
1452
 
1453
+ // Reverse-map: PARANOID rule ID → rule object (for scanParanoid threats)
1454
+ const PARANOID_RULES_BY_ID = {};
1455
+ for (const [, rule] of Object.entries(PARANOID_RULES)) {
1456
+ PARANOID_RULES_BY_ID[rule.id] = rule;
1457
+ }
1458
+
1440
1459
  module.exports = { RULES, getRule, PARANOID_RULES };
@@ -273,7 +273,7 @@ async function runSingleSandbox(packageName, options = {}) {
273
273
  let report;
274
274
  try {
275
275
  const REPORT_DELIMITER = '---MUADDIB-REPORT-START---';
276
- const delimIdx = stdout.indexOf(REPORT_DELIMITER);
276
+ const delimIdx = stdout.lastIndexOf(REPORT_DELIMITER);
277
277
  let jsonStr;
278
278
  if (delimIdx !== -1) {
279
279
  // Reliable: use delimiter to skip any package output before the report
@@ -232,7 +232,7 @@ function scanEntropy(targetPath, options = {}) {
232
232
  // B12: Windowed analysis for strings > MAX_STRING_LENGTH
233
233
  if (str.length > MAX_STRING_LENGTH) {
234
234
  if (SOURCE_MAP_REGEX.test(str) || SHA256_HEX_REGEX.test(str)) continue;
235
- const WINDOW = 500, WIN_THRESHOLD = 6.0;
235
+ const WINDOW = 500, WIN_THRESHOLD = 5.5;
236
236
  for (let i = 0; i < str.length; i += WINDOW) {
237
237
  const w = str.slice(i, i + WINDOW);
238
238
  if (w.length < 20) continue;
@@ -76,7 +76,7 @@ function scanDirRecursive(dirPath, targetPath, threats, depth = 0) {
76
76
 
77
77
  // GHA-002: Detect attacker-controlled context injection on ALL runners (not just self-hosted)
78
78
  const injectionPatterns = [
79
- { regex: /\$\{\{\s*github\.event\.(comment\.body|issue\.body|issue\.title|pull_request\.body|pull_request\.title|discussion\.body|discussion\.title)/, msg: 'Attacker-controlled GitHub event context used in workflow' },
79
+ { regex: /\$\{\{\s*github\.event\.(comment\.body|issue\.body|issue\.title|pull_request\.body|pull_request\.title|discussion\.body|discussion\.title|pages\[\]\.html_url)/, msg: 'Attacker-controlled GitHub event context used in workflow' },
80
80
  { regex: /\$\{\{\s*github\.head_ref/, msg: 'github.head_ref is attacker-controlled in pull_request workflows' }
81
81
  ];
82
82
 
@@ -90,6 +90,18 @@ function scanDirRecursive(dirPath, targetPath, threats, depth = 0) {
90
90
  });
91
91
  }
92
92
  }
93
+
94
+ // GHA-003: Compound — pull_request_target + checkout of PR head (pwn request)
95
+ const hasPRTarget = /pull_request_target/m.test(activeContent);
96
+ const hasCheckoutPRHead = /actions\/checkout[\s\S]*?ref:\s*\$\{\{\s*github\.event\.pull_request\.head\.(ref|sha)\s*\}\}/m.test(activeContent);
97
+ if (hasPRTarget && hasCheckoutPRHead) {
98
+ threats.push({
99
+ type: 'workflow_pwn_request',
100
+ severity: 'CRITICAL',
101
+ message: 'Pwn request: pull_request_target with checkout of PR head ref/sha allows arbitrary code execution',
102
+ file: relFile
103
+ });
104
+ }
93
105
  }
94
106
  }
95
107
 
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { findFiles, forEachSafeFile } = require('../utils.js');
4
+ const { MAX_FILE_SIZE } = require('../shared/constants.js');
4
5
 
5
6
  const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
6
7
 
@@ -22,31 +23,75 @@ const MALICIOUS_PATTERNS = [
22
23
  { pattern: /wget\s+\S+.*&&.*base64\s+-d/m, name: 'wget_base64_decode', severity: 'HIGH' }
23
24
  ];
24
25
 
26
+ const SHEBANG_RE = /^#!.*\b(?:ba)?sh\b/;
27
+
28
+ function scanFileContent(file, content, targetPath, threats) {
29
+ // Strip comment lines to avoid false positives on documentation
30
+ const activeContent = content.split(/\r?\n/)
31
+ .filter(line => !line.trimStart().startsWith('#'))
32
+ .join('\n');
33
+
34
+ for (const { pattern, name, severity } of MALICIOUS_PATTERNS) {
35
+ if (pattern.test(activeContent)) {
36
+ threats.push({
37
+ type: name,
38
+ severity: severity,
39
+ message: `Pattern malveillant "${name}" detecte.`,
40
+ file: path.relative(targetPath, file)
41
+ });
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Find extensionless files in a directory (non-recursive into excluded dirs).
48
+ * Used for shebang-based shell script detection.
49
+ */
50
+ function findExtensionlessFiles(dir, excludedDirs, results = [], depth = 0) {
51
+ if (depth > 20) return results;
52
+ let items;
53
+ try { items = fs.readdirSync(dir); } catch { return results; }
54
+
55
+ for (const item of items) {
56
+ if (excludedDirs.includes(item)) continue;
57
+ const fullPath = path.join(dir, item);
58
+ try {
59
+ const lstat = fs.lstatSync(fullPath);
60
+ if (lstat.isSymbolicLink()) continue;
61
+ if (lstat.isDirectory()) {
62
+ findExtensionlessFiles(fullPath, excludedDirs, results, depth + 1);
63
+ } else if (lstat.isFile() && !path.extname(item) && lstat.size <= MAX_FILE_SIZE) {
64
+ results.push(fullPath);
65
+ }
66
+ } catch { /* permission error */ }
67
+ }
68
+ return results;
69
+ }
70
+
25
71
  async function scanShellScripts(targetPath) {
26
72
  const threats = [];
27
-
28
- // Cherche les fichiers shell
73
+
74
+ // Pass 1: files with shell extensions
29
75
  const files = findFiles(targetPath, { extensions: ['.sh', '.bash', '.zsh', '.command'], excludedDirs: SHELL_EXCLUDED_DIRS });
30
76
 
31
77
  forEachSafeFile(files, (file, content) => {
32
- // Strip comment lines to avoid false positives on documentation
33
- const activeContent = content.split(/\r?\n/)
34
- .filter(line => !line.trimStart().startsWith('#'))
35
- .join('\n');
36
-
37
- for (const { pattern, name, severity } of MALICIOUS_PATTERNS) {
38
- if (pattern.test(activeContent)) {
39
- threats.push({
40
- type: name,
41
- severity: severity,
42
- message: `Pattern malveillant "${name}" detecte.`,
43
- file: path.relative(targetPath, file)
44
- });
45
- }
46
- }
78
+ scanFileContent(file, content, targetPath, threats);
47
79
  });
48
80
 
81
+ // Pass 2: extensionless files with sh/bash shebang
82
+ const extensionless = findExtensionlessFiles(targetPath, SHELL_EXCLUDED_DIRS);
83
+
84
+ for (const file of extensionless) {
85
+ try {
86
+ const content = fs.readFileSync(file, 'utf8');
87
+ const firstLine = content.split(/\r?\n/, 1)[0];
88
+ if (SHEBANG_RE.test(firstLine)) {
89
+ scanFileContent(file, content, targetPath, threats);
90
+ }
91
+ } catch { /* ignore unreadable files */ }
92
+ }
93
+
49
94
  return threats;
50
95
  }
51
96
 
52
- module.exports = { scanShellScripts };
97
+ module.exports = { scanShellScripts };
package/src/scoring.js CHANGED
@@ -164,7 +164,10 @@ const DIST_BUNDLER_ARTIFACT_TYPES = new Set([
164
164
  'js_obfuscation_pattern', 'vm_code_execution',
165
165
  'module_compile', 'module_compile_dynamic',
166
166
  // P7: env_access in dist/ is bundled SDK config reading, not credential theft
167
- 'env_access'
167
+ 'env_access',
168
+ // P8: Proxy traps in dist/ are state management frameworks (MobX, Vue reactivity, Immer),
169
+ // not malicious data interception. Two-notch downgrade (CRITICAL→MEDIUM, HIGH→LOW).
170
+ 'proxy_data_intercept'
168
171
  ]);
169
172
 
170
173
  // Types exempt from reachability downgrade — IOC matches, lifecycle, and package-level types.