muaddib-scanner 2.9.5 → 2.9.7

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/bin/muaddib.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const { exec } = require('child_process');
2
+ const { execFile } = require('child_process');
3
3
  const { run } = require('../src/index.js');
4
4
  const { updateIOCs } = require('../src/ioc/updater.js');
5
5
  const { watch } = require('../src/watch.js');
@@ -33,6 +33,7 @@ let breakdownMode = false;
33
33
  let noDeobfuscate = false;
34
34
  let noModuleGraph = false;
35
35
  let noReachability = false;
36
+ let configPath = null;
36
37
  let feedLimit = null;
37
38
  let feedSeverity = null;
38
39
  let feedSince = null;
@@ -124,6 +125,18 @@ for (let i = 0; i < options.length; i++) {
124
125
  noModuleGraph = true;
125
126
  } else if (options[i] === '--no-reachability') {
126
127
  noReachability = true;
128
+ } else if (options[i] === '--config') {
129
+ const cfgPath = options[i + 1];
130
+ if (!cfgPath || cfgPath.startsWith('-')) {
131
+ console.error('[ERROR] --config requires a file path argument');
132
+ process.exit(1);
133
+ }
134
+ if (cfgPath.includes('..')) {
135
+ console.error('[ERROR] --config path must not contain path traversal (..)');
136
+ process.exit(1);
137
+ }
138
+ configPath = cfgPath;
139
+ i++;
127
140
  } else if (options[i] === '--temporal') {
128
141
  temporalMode = true;
129
142
  } else if (options[i] === '--limit') {
@@ -160,7 +173,8 @@ for (let i = 0; i < options.length; i++) {
160
173
  if (!jsonOutput && !sarifOutput && command !== 'feed' && command !== 'serve') {
161
174
  try {
162
175
  const currentVersion = require('../package.json').version;
163
- exec('npm view muaddib-scanner version', { timeout: 5000 }, (err, stdout) => {
176
+ const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
177
+ execFile(npmBin, ['view', 'muaddib-scanner', 'version'], { timeout: 5000 }, (err, stdout) => {
164
178
  if (err) return; // No network or npm unavailable
165
179
  const latest = (stdout || '').toString().trim();
166
180
  if (!latest || latest === currentVersion) return;
@@ -425,6 +439,7 @@ const helpText = `
425
439
  --since [date] Filter detections after date (ISO 8601)
426
440
  --port [n] HTTP server port (default: 3000, serve only)
427
441
  --entropy-threshold [n] Custom string-level entropy threshold (default: 5.5)
442
+ --config [file] Custom config file (.muaddibrc.json format)
428
443
  --save-dev, -D Install as dev dependency
429
444
  -g, --global Install globally
430
445
  --force Force install despite threats
@@ -466,7 +481,8 @@ if (command === 'version' || command === '--version' || command === '-v') {
466
481
  breakdown: breakdownMode,
467
482
  noDeobfuscate: noDeobfuscate,
468
483
  noModuleGraph: noModuleGraph,
469
- noReachability: noReachability
484
+ noReachability: noReachability,
485
+ configPath: configPath
470
486
  }).then(exitCode => {
471
487
  process.exit(exitCode);
472
488
  }).catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.9.5",
3
+ "version": "2.9.7",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/config.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * MUAD'DIB Configuration Loader
3
+ *
4
+ * Loads and validates .muaddibrc.json configuration files.
5
+ * All fields are optional — missing values fall back to hardcoded defaults.
6
+ *
7
+ * Configurable: riskThresholds, maxFileSize, severityWeights
8
+ * NOT configurable: ADR_THRESHOLD, BENIGN_THRESHOLD, GT_THRESHOLD (evaluation constants),
9
+ * FP_COUNT_THRESHOLDS, CONFIDENCE_FACTORS (too granular, modifying without expertise breaks the model)
10
+ *
11
+ * Security: parsed into Object.create(null) to prevent prototype pollution.
12
+ * Config files > 10KB are rejected (no legitimate config is that large).
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const MAX_CONFIG_SIZE = 10 * 1024; // 10KB
19
+
20
+ const DEFAULTS = Object.freeze({
21
+ riskThresholds: Object.freeze({ critical: 75, high: 50, medium: 25 }),
22
+ maxFileSize: 10 * 1024 * 1024, // 10MB
23
+ severityWeights: Object.freeze({ critical: 25, high: 10, medium: 3, low: 1 })
24
+ });
25
+
26
+ const VALID_TOP_KEYS = new Set(['riskThresholds', 'maxFileSize', 'severityWeights']);
27
+ const PROTO_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
28
+
29
+ /**
30
+ * Load and parse a JSON config file.
31
+ * Uses JSON.parse (never require) to prevent code execution.
32
+ * @param {string} filePath - absolute path to config file
33
+ * @returns {{ raw: object|null, error: string|null }}
34
+ */
35
+ function loadConfigFile(filePath) {
36
+ try {
37
+ const stat = fs.statSync(filePath);
38
+ if (stat.size > MAX_CONFIG_SIZE) {
39
+ return { raw: null, error: `Config file exceeds 10KB limit (${stat.size} bytes)` };
40
+ }
41
+ const content = fs.readFileSync(filePath, 'utf8');
42
+ // Parse into null-prototype object to prevent prototype pollution
43
+ const parsed = JSON.parse(content);
44
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
45
+ return { raw: null, error: 'Config file must contain a JSON object' };
46
+ }
47
+ // Deep copy into null-prototype objects
48
+ const safe = Object.create(null);
49
+ for (const key of Object.keys(parsed)) {
50
+ if (typeof parsed[key] === 'object' && parsed[key] !== null && !Array.isArray(parsed[key])) {
51
+ const inner = Object.create(null);
52
+ for (const k of Object.keys(parsed[key])) {
53
+ inner[k] = parsed[key][k];
54
+ }
55
+ safe[key] = inner;
56
+ } else {
57
+ safe[key] = parsed[key];
58
+ }
59
+ }
60
+ return { raw: safe, error: null };
61
+ } catch (err) {
62
+ if (err.code === 'ENOENT') {
63
+ return { raw: null, error: null }; // file not found is not an error for auto-detection
64
+ }
65
+ return { raw: null, error: `Failed to parse config: ${err.message}` };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Validate a parsed config object.
71
+ * @param {object} raw - parsed config (null-prototype object)
72
+ * @returns {{ config: object|null, warnings: string[], errors: string[] }}
73
+ */
74
+ function validateConfig(raw) {
75
+ const warnings = [];
76
+ const errors = [];
77
+ const config = Object.create(null);
78
+
79
+ if (!raw) return { config: null, warnings, errors };
80
+
81
+ // Check for prototype pollution keys at all levels
82
+ for (const key of Object.keys(raw)) {
83
+ if (PROTO_KEYS.has(key)) {
84
+ errors.push(`Forbidden key "${key}" detected (prototype pollution attempt)`);
85
+ return { config: null, warnings, errors };
86
+ }
87
+ if (typeof raw[key] === 'object' && raw[key] !== null) {
88
+ for (const k of Object.keys(raw[key])) {
89
+ if (PROTO_KEYS.has(k)) {
90
+ errors.push(`Forbidden key "${key}.${k}" detected (prototype pollution attempt)`);
91
+ return { config: null, warnings, errors };
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ // Check for unknown top-level keys
98
+ for (const key of Object.keys(raw)) {
99
+ if (!VALID_TOP_KEYS.has(key)) {
100
+ warnings.push(`Unknown config key "${key}" — ignored`);
101
+ }
102
+ }
103
+
104
+ // Validate riskThresholds
105
+ if (raw.riskThresholds !== undefined) {
106
+ const rt = raw.riskThresholds;
107
+ if (typeof rt !== 'object' || rt === null || Array.isArray(rt)) {
108
+ errors.push('riskThresholds must be an object');
109
+ } else {
110
+ const validKeys = new Set(['critical', 'high', 'medium']);
111
+ for (const k of Object.keys(rt)) {
112
+ if (!validKeys.has(k)) {
113
+ warnings.push(`Unknown riskThresholds key "${k}" — ignored`);
114
+ }
115
+ }
116
+ const vals = Object.create(null);
117
+ for (const k of ['critical', 'high', 'medium']) {
118
+ if (rt[k] !== undefined) {
119
+ if (typeof rt[k] !== 'number' || !Number.isFinite(rt[k])) {
120
+ errors.push(`riskThresholds.${k} must be a finite number`);
121
+ } else if (rt[k] <= 0) {
122
+ errors.push(`riskThresholds.${k} must be > 0 (got ${rt[k]})`);
123
+ } else {
124
+ vals[k] = rt[k];
125
+ }
126
+ } else {
127
+ vals[k] = DEFAULTS.riskThresholds[k];
128
+ }
129
+ }
130
+ // Ordering: critical > high > medium
131
+ if (!errors.length) {
132
+ const c = vals.critical, h = vals.high, m = vals.medium;
133
+ if (c <= h || h <= m) {
134
+ errors.push(`riskThresholds ordering violation: critical (${c}) > high (${h}) > medium (${m}) required`);
135
+ }
136
+ }
137
+ if (!errors.length) {
138
+ config.riskThresholds = vals;
139
+ // Warn if thresholds are relaxed beyond defaults
140
+ if ((vals.critical > DEFAULTS.riskThresholds.critical) ||
141
+ (vals.high > DEFAULTS.riskThresholds.high) ||
142
+ (vals.medium > DEFAULTS.riskThresholds.medium)) {
143
+ warnings.push('Risk thresholds relaxed — detection sensitivity reduced');
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ // Validate maxFileSize
150
+ if (raw.maxFileSize !== undefined) {
151
+ const mfs = raw.maxFileSize;
152
+ if (typeof mfs !== 'number' || !Number.isFinite(mfs) || !Number.isInteger(mfs)) {
153
+ errors.push('maxFileSize must be a finite integer');
154
+ } else if (mfs < 1024 * 1024) {
155
+ errors.push(`maxFileSize must be >= 1MB (got ${mfs})`);
156
+ } else if (mfs > 100 * 1024 * 1024) {
157
+ errors.push(`maxFileSize must be <= 100MB (got ${mfs})`);
158
+ } else {
159
+ config.maxFileSize = mfs;
160
+ }
161
+ }
162
+
163
+ // Validate severityWeights
164
+ if (raw.severityWeights !== undefined) {
165
+ const sw = raw.severityWeights;
166
+ if (typeof sw !== 'object' || sw === null || Array.isArray(sw)) {
167
+ errors.push('severityWeights must be an object');
168
+ } else {
169
+ const validKeys = new Set(['critical', 'high', 'medium', 'low']);
170
+ for (const k of Object.keys(sw)) {
171
+ if (!validKeys.has(k)) {
172
+ warnings.push(`Unknown severityWeights key "${k}" — ignored`);
173
+ }
174
+ }
175
+ const vals = Object.create(null);
176
+ for (const k of ['critical', 'high', 'medium', 'low']) {
177
+ if (sw[k] !== undefined) {
178
+ if (typeof sw[k] !== 'number' || !Number.isFinite(sw[k])) {
179
+ errors.push(`severityWeights.${k} must be a finite number`);
180
+ } else if (sw[k] < 0) {
181
+ errors.push(`severityWeights.${k} must be >= 0 (got ${sw[k]})`);
182
+ } else {
183
+ vals[k] = sw[k];
184
+ }
185
+ } else {
186
+ vals[k] = DEFAULTS.severityWeights[k];
187
+ }
188
+ }
189
+ // Ordering: critical >= high >= medium >= low
190
+ if (!errors.length) {
191
+ const c = vals.critical, h = vals.high, m = vals.medium, l = vals.low;
192
+ if (c < h || h < m || m < l) {
193
+ errors.push(`severityWeights ordering violation: critical (${c}) >= high (${h}) >= medium (${m}) >= low (${l}) required`);
194
+ }
195
+ }
196
+ if (!errors.length) {
197
+ config.severityWeights = vals;
198
+ }
199
+ }
200
+ }
201
+
202
+ const hasKeys = Object.keys(config).length > 0;
203
+ return { config: hasKeys ? config : null, warnings, errors };
204
+ }
205
+
206
+ /**
207
+ * Resolve which config file to load.
208
+ * Priority: --config <path> > .muaddibrc.json at targetPath root
209
+ * @param {string} targetPath - scan target directory
210
+ * @param {string|null} configPath - explicit --config path (or null)
211
+ * @returns {{ config: object|null, warnings: string[], errors: string[], source: string|null }}
212
+ */
213
+ function resolveConfig(targetPath, configPath) {
214
+ // Explicit --config path
215
+ if (configPath) {
216
+ const absPath = path.isAbsolute(configPath) ? configPath : path.resolve(configPath);
217
+ if (!fs.existsSync(absPath)) {
218
+ return { config: null, warnings: [], errors: [`Config file not found: ${configPath}`], source: null };
219
+ }
220
+ const { raw, error } = loadConfigFile(absPath);
221
+ if (error) {
222
+ return { config: null, warnings: [], errors: [error], source: null };
223
+ }
224
+ const result = validateConfig(raw);
225
+ if (result.config) {
226
+ result.warnings.unshift(`Loaded custom thresholds from ${configPath}`);
227
+ }
228
+ result.source = configPath;
229
+ return result;
230
+ }
231
+
232
+ // Auto-detect .muaddibrc.json at target root
233
+ const rcPath = path.join(targetPath, '.muaddibrc.json');
234
+ if (!fs.existsSync(rcPath)) {
235
+ return { config: null, warnings: [], errors: [], source: null };
236
+ }
237
+ const { raw, error } = loadConfigFile(rcPath);
238
+ if (error) {
239
+ // Auto-detected config with errors is a warning, not a fatal error
240
+ return { config: null, warnings: [`[CONFIG] ${error} — .muaddibrc.json ignored`], errors: [], source: null };
241
+ }
242
+ const result = validateConfig(raw);
243
+ if (result.config) {
244
+ result.warnings.unshift('Loaded custom thresholds from .muaddibrc.json');
245
+ }
246
+ result.source = rcPath;
247
+ return result;
248
+ }
249
+
250
+ module.exports = { DEFAULTS, loadConfigFile, validateConfig, resolveConfig };
package/src/index.js CHANGED
@@ -28,10 +28,11 @@ const { computeReachableFiles } = require('./scanner/reachability.js');
28
28
  const { runTemporalAnalyses } = require('./temporal-runner.js');
29
29
  const { formatOutput } = require('./output-formatter.js');
30
30
  const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache, debugLog } = require('./utils.js');
31
- const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat, computeGroupScore, applyFPReductions, applyCompoundBoosts, calculateRiskScore } = require('./scoring.js');
31
+ const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat, computeGroupScore, applyFPReductions, applyCompoundBoosts, calculateRiskScore, applyConfigOverrides, resetConfigOverrides, getSeverityWeights } = require('./scoring.js');
32
+ const { resolveConfig } = require('./config.js');
32
33
  const { buildIntentPairs } = require('./intent-graph.js');
33
34
 
34
- const { MAX_FILE_SIZE, safeParse } = require('./shared/constants.js');
35
+ const { MAX_FILE_SIZE, getMaxFileSize, setMaxFileSize, resetMaxFileSize, safeParse } = require('./shared/constants.js');
35
36
  const walk = require('acorn-walk');
36
37
 
37
38
  // Paranoid mode scanner
@@ -75,7 +76,7 @@ function scanParanoid(targetPath) {
75
76
  function scanFileAST(filePath) {
76
77
  try {
77
78
  const stat = fs.statSync(filePath);
78
- if (stat.size > MAX_FILE_SIZE) return;
79
+ if (stat.size > getMaxFileSize()) return;
79
80
  const content = fs.readFileSync(filePath, 'utf8');
80
81
  const relFile = path.relative(targetPath, filePath);
81
82
 
@@ -186,8 +187,8 @@ function scanParanoid(targetPath) {
186
187
  }
187
188
  }
188
189
  });
189
- } catch {
190
- // Ignore read/parse errors
190
+ } catch (e) {
191
+ debugLog('[PARANOID] AST parse error:', e?.message);
191
192
  }
192
193
  }
193
194
 
@@ -209,7 +210,7 @@ function scanParanoid(targetPath) {
209
210
  function scanFile(filePath) {
210
211
  try {
211
212
  const stat = fs.statSync(filePath);
212
- if (stat.size > MAX_FILE_SIZE) return;
213
+ if (stat.size > getMaxFileSize()) return;
213
214
  const ext = path.extname(filePath);
214
215
  if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
215
216
  scanFileAST(filePath);
@@ -218,8 +219,8 @@ function scanParanoid(targetPath) {
218
219
  const relFile = path.relative(targetPath, filePath);
219
220
  scanFileContent(filePath, content, relFile);
220
221
  }
221
- } catch {
222
- // Ignore read errors
222
+ } catch (e) {
223
+ debugLog('[PARANOID] file read error:', e?.message);
223
224
  }
224
225
  }
225
226
 
@@ -247,8 +248,8 @@ function scanParanoid(targetPath) {
247
248
  scanFile(fullPath);
248
249
  }
249
250
  }
250
- } catch {
251
- // Ignore walk errors
251
+ } catch (e) {
252
+ debugLog('[PARANOID] walkDir error:', e?.message);
252
253
  }
253
254
  }
254
255
 
@@ -353,6 +354,19 @@ async function run(targetPath, options = {}) {
353
354
  setExtraExcludes(options.exclude, targetPath);
354
355
  }
355
356
 
357
+ // Load custom configuration (.muaddibrc.json or --config)
358
+ let configApplied = false;
359
+ const configResult = resolveConfig(targetPath, options.configPath || null);
360
+ if (configResult.errors.length > 0) {
361
+ for (const err of configResult.errors) console.error(`[CONFIG ERROR] ${err}`);
362
+ throw new Error('Invalid configuration file.');
363
+ }
364
+ if (configResult.config) {
365
+ applyConfigOverrides(configResult.config);
366
+ if (configResult.config.maxFileSize) setMaxFileSize(configResult.config.maxFileSize);
367
+ configApplied = true;
368
+ }
369
+
356
370
  // Detect Python project (synchronous, fast file reads)
357
371
  const pythonDeps = detectPythonProject(targetPath);
358
372
 
@@ -376,6 +390,9 @@ async function run(targetPath, options = {}) {
376
390
  const MODULE_GRAPH_TIMEOUT_MS = 5000;
377
391
  const warnings = [];
378
392
  if (iocStalenessWarning) warnings.push(iocStalenessWarning);
393
+ if (configResult.warnings.length > 0) {
394
+ for (const w of configResult.warnings) warnings.push(`[CONFIG] ${w}`);
395
+ }
379
396
  let crossFileFlows = [];
380
397
  if (!options.noModuleGraph) {
381
398
  const moduleGraphWork = async () => {
@@ -550,7 +567,8 @@ async function run(targetPath, options = {}) {
550
567
  if (!reachability.skipped) {
551
568
  reachableFiles = reachability.reachableFiles;
552
569
  }
553
- } catch {
570
+ } catch (e) {
571
+ debugLog('[REACHABILITY] error:', e?.message);
554
572
  // Graceful fallback — treat all files as reachable
555
573
  }
556
574
  }
@@ -629,7 +647,7 @@ async function run(targetPath, options = {}) {
629
647
  const enrichedThreats = deduped.map(t => {
630
648
  const rule = getRule(t.type);
631
649
  const confFactor = { high: 1.0, medium: 0.85, low: 0.6 }[rule.confidence] || 1.0;
632
- const points = Math.round((SEVERITY_WEIGHTS[t.severity] || 0) * confFactor);
650
+ const points = Math.round((getSeverityWeights()[t.severity] || 0) * confFactor);
633
651
  return {
634
652
  ...t,
635
653
  rule_id: rule.id || t.type,
@@ -694,6 +712,7 @@ async function run(targetPath, options = {}) {
694
712
  if (options._capture) {
695
713
  setExtraExcludes([]);
696
714
  clearFileListCache();
715
+ if (configApplied) { resetConfigOverrides(); resetMaxFileSize(); }
697
716
  return result;
698
717
  }
699
718
 
@@ -728,6 +747,7 @@ async function run(targetPath, options = {}) {
728
747
  // Clear runtime state
729
748
  setExtraExcludes([]);
730
749
  clearFileListCache();
750
+ if (configApplied) { resetConfigOverrides(); resetMaxFileSize(); }
731
751
 
732
752
  return Math.min(failingThreats.length, 125);
733
753
  }
@@ -1495,10 +1495,15 @@ function handleCallExpression(node, ctx) {
1495
1495
  if (prop.type === 'Identifier' && obj?.type === 'Identifier' &&
1496
1496
  (ctx.globalThisAliases.has(obj.name) || obj.name === 'globalThis' || obj.name === 'global')) {
1497
1497
  ctx.hasEvalInFile = true;
1498
+ // Resolve variable value via stringVarValues (e.g., const f = 'eval'; globalThis[f]())
1499
+ const resolvedValue = ctx.stringVarValues.get(prop.name);
1500
+ const isEvalOrFunction = resolvedValue === 'eval' || resolvedValue === 'Function';
1498
1501
  ctx.threats.push({
1499
1502
  type: 'dangerous_call_eval',
1500
- severity: 'HIGH',
1501
- message: `Dynamic global dispatch via computed property (${obj.name}[${prop.name}]) — likely indirect eval evasion.`,
1503
+ severity: isEvalOrFunction ? 'CRITICAL' : 'HIGH',
1504
+ message: isEvalOrFunction
1505
+ ? `Resolved indirect ${resolvedValue}() via computed property (${obj.name}[${prop.name}="${resolvedValue}"]) — confirmed eval evasion.`
1506
+ : `Dynamic global dispatch via computed property (${obj.name}[${prop.name}]) — likely indirect eval evasion.`,
1502
1507
  file: ctx.relFile
1503
1508
  });
1504
1509
  }
@@ -154,6 +154,27 @@ function deobfuscate(sourceCode) {
154
154
  const hexResult = tryResolveHexArrayMap(node, sourceCode);
155
155
  if (hexResult !== null) {
156
156
  replacements.push(hexResult);
157
+ return;
158
+ }
159
+
160
+ // ---- 5. ARRAY JOIN ----
161
+ // ['e','v','a','l'].join('') → "eval"
162
+ if (node.callee?.type === 'MemberExpression' &&
163
+ node.callee.property?.name === 'join' &&
164
+ node.callee.object?.type === 'ArrayExpression' &&
165
+ node.arguments?.length === 1 &&
166
+ node.arguments[0]?.type === 'Literal' &&
167
+ node.arguments[0].value === '') {
168
+ const elements = node.callee.object.elements;
169
+ if (elements.length > 0 && elements.every(el => el?.type === 'Literal' && typeof el.value === 'string')) {
170
+ const joined = elements.map(el => el.value).join('');
171
+ const before = sourceCode.slice(node.start, node.end);
172
+ replacements.push({
173
+ start: node.start, end: node.end,
174
+ value: quoteString(joined),
175
+ type: 'array_join', before
176
+ });
177
+ }
157
178
  }
158
179
  }
159
180
  });
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
- const { MAX_FILE_SIZE } = require('../shared/constants.js');
4
+ const { MAX_FILE_SIZE, getMaxFileSize } = require('../shared/constants.js');
5
5
 
6
6
  const YAML_EXTENSIONS = ['.yml', '.yaml'];
7
7
  const MAX_DEPTH = 10;
@@ -40,7 +40,7 @@ function scanDirRecursive(dirPath, targetPath, threats, depth = 0) {
40
40
  continue;
41
41
  }
42
42
  if (!stat.isFile()) continue;
43
- if (stat.size > MAX_FILE_SIZE) continue;
43
+ if (stat.size > getMaxFileSize()) continue;
44
44
  } catch {
45
45
  continue;
46
46
  }
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const nodeCrypto = require('crypto');
4
4
  const { loadCachedIOCs } = require('../ioc/updater.js');
5
5
  const { findFiles } = require('../utils.js');
6
- const { MAX_FILE_SIZE } = require('../shared/constants.js');
6
+ const { MAX_FILE_SIZE, getMaxFileSize } = require('../shared/constants.js');
7
7
 
8
8
  // Hash cache: filePath -> { hash, mtime }
9
9
  const hashCache = new Map();
@@ -57,7 +57,7 @@ async function scanHashes(targetPath) {
57
57
  function computeHashCached(filePath) {
58
58
  try {
59
59
  const stat = fs.statSync(filePath);
60
- if (stat.size > MAX_FILE_SIZE) return null;
60
+ if (stat.size > getMaxFileSize()) return null;
61
61
  const mtime = stat.mtimeMs;
62
62
 
63
63
  // Check the cache
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { findFiles, forEachSafeFile } = require('../utils.js');
4
- const { MAX_FILE_SIZE } = require('../shared/constants.js');
3
+ const { findFiles, forEachSafeFile, debugLog } = require('../utils.js');
4
+ const { MAX_FILE_SIZE, getMaxFileSize } = require('../shared/constants.js');
5
5
 
6
6
  const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
7
7
 
@@ -56,7 +56,7 @@ function scanFileContent(file, content, targetPath, threats) {
56
56
  function findExtensionlessFiles(dir, excludedDirs, results = [], depth = 0) {
57
57
  if (depth > 20) return results;
58
58
  let items;
59
- try { items = fs.readdirSync(dir); } catch { return results; }
59
+ try { items = fs.readdirSync(dir); } catch (e) { debugLog('[SHELL] readdirSync error:', e?.message); return results; }
60
60
 
61
61
  for (const item of items) {
62
62
  if (excludedDirs.includes(item)) continue;
@@ -66,10 +66,10 @@ function findExtensionlessFiles(dir, excludedDirs, results = [], depth = 0) {
66
66
  if (lstat.isSymbolicLink()) continue;
67
67
  if (lstat.isDirectory()) {
68
68
  findExtensionlessFiles(fullPath, excludedDirs, results, depth + 1);
69
- } else if (lstat.isFile() && !path.extname(item) && lstat.size <= MAX_FILE_SIZE) {
69
+ } else if (lstat.isFile() && !path.extname(item) && lstat.size <= getMaxFileSize()) {
70
70
  results.push(fullPath);
71
71
  }
72
- } catch { /* permission error */ }
72
+ } catch (e) { debugLog('[SHELL] stat error:', e?.message); }
73
73
  }
74
74
  return results;
75
75
  }
@@ -94,7 +94,7 @@ async function scanShellScripts(targetPath) {
94
94
  if (SHEBANG_RE.test(firstLine)) {
95
95
  scanFileContent(file, content, targetPath, threats);
96
96
  }
97
- } catch { /* ignore unreadable files */ }
97
+ } catch (e) { debugLog('[SHELL] readFile error:', e?.message); }
98
98
  }
99
99
 
100
100
  return threats;
package/src/scoring.js CHANGED
@@ -47,6 +47,44 @@ const PROTO_HOOK_MEDIUM_CAP = 15;
47
47
  // Unknown/paranoid rules default to 1.0 (no penalty).
48
48
  const CONFIDENCE_FACTORS = { high: 1.0, medium: 0.85, low: 0.6 };
49
49
 
50
+ // Mutable copies for configurable overrides (reset after each scan)
51
+ let _severityWeights = { ...SEVERITY_WEIGHTS };
52
+ let _riskThresholds = { ...RISK_THRESHOLDS };
53
+
54
+ /**
55
+ * Apply config overrides to scoring parameters.
56
+ * @param {object} config - validated config from config.js
57
+ */
58
+ function applyConfigOverrides(config) {
59
+ if (config.severityWeights) {
60
+ if (config.severityWeights.critical !== undefined) _severityWeights.CRITICAL = config.severityWeights.critical;
61
+ if (config.severityWeights.high !== undefined) _severityWeights.HIGH = config.severityWeights.high;
62
+ if (config.severityWeights.medium !== undefined) _severityWeights.MEDIUM = config.severityWeights.medium;
63
+ if (config.severityWeights.low !== undefined) _severityWeights.LOW = config.severityWeights.low;
64
+ }
65
+ if (config.riskThresholds) {
66
+ if (config.riskThresholds.critical !== undefined) _riskThresholds.CRITICAL = config.riskThresholds.critical;
67
+ if (config.riskThresholds.high !== undefined) _riskThresholds.HIGH = config.riskThresholds.high;
68
+ if (config.riskThresholds.medium !== undefined) _riskThresholds.MEDIUM = config.riskThresholds.medium;
69
+ }
70
+ }
71
+
72
+ /** Reset scoring parameters to defaults (call after each scan to prevent state leak). */
73
+ function resetConfigOverrides() {
74
+ _severityWeights = { ...SEVERITY_WEIGHTS };
75
+ _riskThresholds = { ...RISK_THRESHOLDS };
76
+ }
77
+
78
+ /** Get current severity weights (for enrichment in index.js). */
79
+ function getSeverityWeights() {
80
+ return _severityWeights;
81
+ }
82
+
83
+ /** Get current risk thresholds (for external consumers). */
84
+ function getRiskThresholds() {
85
+ return _riskThresholds;
86
+ }
87
+
50
88
  // ============================================
51
89
  // PER-FILE MAX SCORING (v2.2.11)
52
90
  // ============================================
@@ -91,7 +129,7 @@ function computeGroupScore(threats) {
91
129
  let protoHookMediumPoints = 0;
92
130
 
93
131
  for (const t of threats) {
94
- const weight = SEVERITY_WEIGHTS[t.severity] || 0;
132
+ const weight = _severityWeights[t.severity] || 0;
95
133
  const rule = getRule(t.type);
96
134
  const factor = CONFIDENCE_FACTORS[rule.confidence] || 1.0;
97
135
 
@@ -279,11 +317,11 @@ function applyCompoundBoosts(threats) {
279
317
 
280
318
  // Check all required types are present
281
319
  if (compound.requires.every(req => typeSet.has(req))) {
282
- // Severity gate: at least one component must have severity >= MEDIUM
283
- // after FP reductions. If all components were downgraded to LOW,
284
- // the compound signal is not strong enough to justify a CRITICAL boost.
320
+ // Severity gate: at least one component must have had original severity >= MEDIUM.
321
+ // Uses originalSeverity (pre-FP-reduction) to prevent attackers from
322
+ // manipulating compound gates via count-threshold or dist-file downgrades.
285
323
  const hasSignificantComponent = compound.requires.some(req =>
286
- threats.some(t => t.type === req && t.severity !== 'LOW')
324
+ threats.some(t => t.type === req && (t.originalSeverity || t.severity) !== 'LOW')
287
325
  );
288
326
  if (!hasSignificantComponent) continue;
289
327
 
@@ -323,8 +361,11 @@ const FRAMEWORK_PROTO_RE = new RegExp(
323
361
 
324
362
  function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
325
363
  // Initialize reductions audit trail on each threat
364
+ // Store original severity before any FP reductions, so compound
365
+ // severity gates can check pre-reduction severity (GAP 4b).
326
366
  for (const t of threats) {
327
367
  t.reductions = [];
368
+ t.originalSeverity = t.severity;
328
369
  }
329
370
 
330
371
  // Count occurrences of each threat type (package-level, across all files)
@@ -384,6 +425,29 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
384
425
  // The READ/WRITE distinction in ast-detectors already handles the FP case:
385
426
  // READ-only → LOW (hot-reload, introspection), WRITE → CRITICAL (malicious replacement).
386
427
  // A single cache WRITE is genuinely malicious — no downgrade needed.
428
+ }
429
+
430
+ // Dilution floor: retain at least one instance at original severity per type
431
+ // to prevent complete count-threshold dilution by injected benign patterns.
432
+ // Only applies to types with low maxCount (≤3) and a severity constraint (from field),
433
+ // where injection of benign patterns is feasible. High-count types (dynamic_require,
434
+ // env_access) and unconstrained types (suspicious_dataflow) represent legitimate
435
+ // framework patterns and should allow full downgrade.
436
+ const restoredTypes = new Set();
437
+ for (const t of threats) {
438
+ const lastReduction = t.reductions?.find(r => r.rule === 'count_threshold');
439
+ if (lastReduction && !restoredTypes.has(t.type)) {
440
+ const rule = FP_COUNT_THRESHOLDS[t.type];
441
+ if (rule && rule.from && rule.maxCount <= 3) {
442
+ t.severity = lastReduction.from;
443
+ t.reductions = t.reductions.filter(r => r.rule !== 'count_threshold');
444
+ t.reductions.push({ rule: 'count_threshold_floor', note: 'retained one instance at original severity' });
445
+ restoredTypes.add(t.type);
446
+ }
447
+ }
448
+ }
449
+
450
+ for (const t of threats) {
387
451
 
388
452
  // Prototype hook: framework class prototypes → MEDIUM
389
453
  // Core Node.js prototypes (http.IncomingMessage, net.Socket) stay CRITICAL
@@ -559,9 +623,9 @@ function calculateRiskScore(deduped, intentResult) {
559
623
  const mediumCount = deduped.filter(t => t.severity === 'MEDIUM').length;
560
624
  const lowCount = deduped.filter(t => t.severity === 'LOW').length;
561
625
 
562
- const riskLevel = riskScore >= RISK_THRESHOLDS.CRITICAL ? 'CRITICAL'
563
- : riskScore >= RISK_THRESHOLDS.HIGH ? 'HIGH'
564
- : riskScore >= RISK_THRESHOLDS.MEDIUM ? 'MEDIUM'
626
+ const riskLevel = riskScore >= _riskThresholds.CRITICAL ? 'CRITICAL'
627
+ : riskScore >= _riskThresholds.HIGH ? 'HIGH'
628
+ : riskScore >= _riskThresholds.MEDIUM ? 'MEDIUM'
565
629
  : riskScore > 0 ? 'LOW'
566
630
  : 'SAFE';
567
631
 
@@ -574,5 +638,6 @@ function calculateRiskScore(deduped, intentResult) {
574
638
 
575
639
  module.exports = {
576
640
  SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, CONFIDENCE_FACTORS,
577
- isPackageLevelThreat, computeGroupScore, applyFPReductions, applyCompoundBoosts, calculateRiskScore
641
+ isPackageLevelThreat, computeGroupScore, applyFPReductions, applyCompoundBoosts, calculateRiskScore,
642
+ applyConfigOverrides, resetConfigOverrides, getSeverityWeights, getRiskThresholds
578
643
  };
@@ -88,6 +88,14 @@ const DOWNLOAD_TIMEOUT = 30_000; // 30 seconds
88
88
 
89
89
  // Shared scanner constants
90
90
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB — skip files larger than this to avoid memory issues
91
+ let _maxFileSize = MAX_FILE_SIZE;
92
+
93
+ /** Get current max file size (configurable via .muaddibrc.json). */
94
+ function getMaxFileSize() { return _maxFileSize; }
95
+ /** Set max file size override. */
96
+ function setMaxFileSize(size) { _maxFileSize = size; }
97
+ /** Reset max file size to default. */
98
+ function resetMaxFileSize() { _maxFileSize = MAX_FILE_SIZE; }
91
99
  const ACORN_OPTIONS = { ecmaVersion: 2024, sourceType: 'module', allowHashBang: true };
92
100
 
93
101
  const acorn = require('acorn');
@@ -110,4 +118,4 @@ function safeParse(code, extraOptions = {}) {
110
118
  }
111
119
  }
112
120
 
113
- module.exports = { REHABILITATED_PACKAGES, NPM_PACKAGE_REGEX, MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT, MAX_FILE_SIZE, ACORN_OPTIONS, safeParse };
121
+ module.exports = { REHABILITATED_PACKAGES, NPM_PACKAGE_REGEX, MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT, MAX_FILE_SIZE, ACORN_OPTIONS, safeParse, getMaxFileSize, setMaxFileSize, resetMaxFileSize };
@@ -8,7 +8,7 @@ const { findJsFiles, forEachSafeFile, debugLog } = require('./utils.js');
8
8
  const { fetchPackageMetadata, getLatestVersions } = require('./temporal-analysis.js');
9
9
  const { downloadToFile, extractTarGz, sanitizePackageName } = require('./shared/download.js');
10
10
 
11
- const { MAX_FILE_SIZE, ACORN_OPTIONS, safeParse } = require('./shared/constants.js');
11
+ const { MAX_FILE_SIZE, getMaxFileSize, ACORN_OPTIONS, safeParse } = require('./shared/constants.js');
12
12
 
13
13
  const REGISTRY_URL = 'https://registry.npmjs.org';
14
14
  const METADATA_TIMEOUT = 10_000;
package/src/utils.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { MAX_FILE_SIZE } = require('./shared/constants.js');
3
+ const { MAX_FILE_SIZE, getMaxFileSize } = require('./shared/constants.js');
4
4
 
5
5
  /**
6
6
  * Directories excluded from scanning.
@@ -285,7 +285,7 @@ function forEachSafeFile(files, callback) {
285
285
  for (const file of files) {
286
286
  try {
287
287
  const stat = fs.statSync(file);
288
- if (stat.size > MAX_FILE_SIZE) continue;
288
+ if (stat.size > getMaxFileSize()) continue;
289
289
  } catch { continue; }
290
290
  let content;
291
291
  try {