muaddib-scanner 2.9.6 → 2.9.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/bin/muaddib.js CHANGED
@@ -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') {
@@ -426,6 +439,7 @@ const helpText = `
426
439
  --since [date] Filter detections after date (ISO 8601)
427
440
  --port [n] HTTP server port (default: 3000, serve only)
428
441
  --entropy-threshold [n] Custom string-level entropy threshold (default: 5.5)
442
+ --config [file] Custom config file (.muaddibrc.json format)
429
443
  --save-dev, -D Install as dev dependency
430
444
  -g, --global Install globally
431
445
  --force Force install despite threats
@@ -467,7 +481,8 @@ if (command === 'version' || command === '--version' || command === '-v') {
467
481
  breakdown: breakdownMode,
468
482
  noDeobfuscate: noDeobfuscate,
469
483
  noModuleGraph: noModuleGraph,
470
- noReachability: noReachability
484
+ noReachability: noReachability,
485
+ configPath: configPath
471
486
  }).then(exitCode => {
472
487
  process.exit(exitCode);
473
488
  }).catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.9.6",
3
+ "version": "2.9.8",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MUAD'DIB — npm Random Package Sampler
4
+ *
5
+ * Samples 200 packages from the npm registry by stratified random sampling.
6
+ * Used to measure FPR on a representative npm sample (not curated).
7
+ *
8
+ * Strata (by dependency count):
9
+ * small (<10 deps): 80 packages (40%)
10
+ * medium (10-50 deps): 60 packages (30%)
11
+ * large (50-100 deps): 40 packages (20%)
12
+ * vlarge (100+ deps): 20 packages (10%)
13
+ *
14
+ * Exclusions: @types/*, deprecated, already in packages-npm.txt
15
+ *
16
+ * Usage:
17
+ * node scripts/sample-npm-random.js [--seed N] [--output path]
18
+ */
19
+
20
+ const https = require('https');
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ const ROOT = path.join(__dirname, '..');
25
+ const CURATED_FILE = path.join(ROOT, 'datasets', 'benign', 'packages-npm.txt');
26
+ const DEFAULT_OUTPUT = path.join(ROOT, 'datasets', 'benign', 'packages-npm-random.txt');
27
+
28
+ const STRATA = {
29
+ small: { min: 0, max: 9, quota: 80 },
30
+ medium: { min: 10, max: 50, quota: 60 },
31
+ large: { min: 51, max: 100, quota: 40 },
32
+ vlarge: { min: 101, max: Infinity, quota: 20 }
33
+ };
34
+
35
+ // Search keywords — diverse enough to sample across npm
36
+ const SEARCH_KEYWORDS = [
37
+ 'util', 'helper', 'config', 'server', 'client', 'api', 'data',
38
+ 'file', 'string', 'array', 'json', 'http', 'url', 'path', 'stream',
39
+ 'log', 'debug', 'test', 'mock', 'format', 'parse', 'transform',
40
+ 'crypto', 'hash', 'encode', 'decode', 'compress', 'cache', 'queue',
41
+ 'event', 'promise', 'async', 'callback', 'middleware', 'router',
42
+ 'database', 'mongo', 'redis', 'sql', 'orm', 'schema', 'validate',
43
+ 'cli', 'terminal', 'color', 'progress', 'spinner', 'prompt',
44
+ 'image', 'pdf', 'csv', 'xml', 'yaml', 'markdown', 'html',
45
+ 'email', 'auth', 'token', 'session', 'cookie', 'proxy',
46
+ 'date', 'time', 'math', 'random', 'uuid', 'id', 'slug',
47
+ 'webpack', 'babel', 'eslint', 'prettier', 'rollup', 'vite',
48
+ 'react', 'vue', 'angular', 'svelte', 'solid', 'preact',
49
+ 'express', 'koa', 'fastify', 'socket', 'graphql', 'rest',
50
+ 'aws', 'azure', 'gcp', 'docker', 'kubernetes', 'ci',
51
+ 'i18n', 'locale', 'charset', 'buffer', 'binary', 'hex',
52
+ 'retry', 'timeout', 'rate', 'limit', 'throttle', 'debounce',
53
+ 'merge', 'deep', 'clone', 'diff', 'patch', 'compare',
54
+ 'glob', 'pattern', 'regex', 'match', 'search', 'filter',
55
+ 'tree', 'graph', 'list', 'map', 'set', 'stack',
56
+ 'plugin', 'loader', 'adapter', 'wrapper', 'bridge', 'connector'
57
+ ];
58
+
59
+ // Seeded PRNG (mulberry32) for reproducibility
60
+ function mulberry32(seed) {
61
+ return function() {
62
+ seed |= 0; seed = seed + 0x6D2B79F5 | 0;
63
+ let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
64
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
65
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
66
+ };
67
+ }
68
+
69
+ function shuffleArray(arr, rng) {
70
+ for (let i = arr.length - 1; i > 0; i--) {
71
+ const j = Math.floor(rng() * (i + 1));
72
+ [arr[i], arr[j]] = [arr[j], arr[i]];
73
+ }
74
+ return arr;
75
+ }
76
+
77
+ function httpsGet(url) {
78
+ return new Promise((resolve, reject) => {
79
+ const req = https.get(url, { timeout: 15000 }, (res) => {
80
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
81
+ httpsGet(res.headers.location).then(resolve).catch(reject);
82
+ return;
83
+ }
84
+ if (res.statusCode !== 200) {
85
+ res.resume();
86
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
87
+ return;
88
+ }
89
+ let data = '';
90
+ res.on('data', chunk => data += chunk);
91
+ res.on('end', () => {
92
+ try { resolve(JSON.parse(data)); }
93
+ catch (e) { reject(new Error(`JSON parse error: ${e.message}`)); }
94
+ });
95
+ });
96
+ req.on('error', reject);
97
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Search npm registry for packages matching a keyword.
103
+ * Returns array of { name, version } objects.
104
+ */
105
+ async function searchNpm(keyword, from = 0, size = 250) {
106
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(keyword)}&size=${size}&from=${from}`;
107
+ try {
108
+ const data = await httpsGet(url);
109
+ return (data.objects || []).map(o => ({
110
+ name: o.package.name,
111
+ version: o.package.version,
112
+ description: o.package.description || '',
113
+ deprecated: o.package.deprecated || false
114
+ }));
115
+ } catch (err) {
116
+ console.error(` [WARN] npm search "${keyword}" failed: ${err.message}`);
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Get dependency count for a package via npm view.
123
+ * Returns { deps, devDeps } or null on failure.
124
+ */
125
+ async function getDepCount(pkgName) {
126
+ const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}/latest`;
127
+ try {
128
+ const data = await httpsGet(url);
129
+ const deps = data.dependencies ? Object.keys(data.dependencies).length : 0;
130
+ const devDeps = data.devDependencies ? Object.keys(data.devDependencies).length : 0;
131
+ return { deps, devDeps, totalDeps: deps + devDeps };
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function classifyStratum(depCount) {
138
+ for (const [name, { min, max }] of Object.entries(STRATA)) {
139
+ if (depCount >= min && depCount <= max) return name;
140
+ }
141
+ return 'small';
142
+ }
143
+
144
+ function loadCuratedPackages() {
145
+ try {
146
+ return new Set(
147
+ fs.readFileSync(CURATED_FILE, 'utf8')
148
+ .split(/\r?\n/)
149
+ .map(l => l.trim())
150
+ .filter(l => l && !l.startsWith('#'))
151
+ );
152
+ } catch {
153
+ return new Set();
154
+ }
155
+ }
156
+
157
+ async function main() {
158
+ const args = process.argv.slice(2);
159
+ let seed = 42;
160
+ let outputPath = DEFAULT_OUTPUT;
161
+
162
+ for (let i = 0; i < args.length; i++) {
163
+ if (args[i] === '--seed' && args[i + 1]) { seed = parseInt(args[i + 1], 10); i++; }
164
+ if (args[i] === '--output' && args[i + 1]) { outputPath = args[i + 1]; i++; }
165
+ }
166
+
167
+ const rng = mulberry32(seed);
168
+ const curated = loadCuratedPackages();
169
+ console.log(` Loaded ${curated.size} curated packages to exclude`);
170
+ console.log(` Seed: ${seed}`);
171
+
172
+ // Phase 1: Collect candidate packages from npm search
173
+ console.log(`\n [1/3] Collecting candidates from npm search...`);
174
+ const candidates = new Map(); // name -> { name, version, description }
175
+ const shuffledKeywords = shuffleArray([...SEARCH_KEYWORDS], rng);
176
+
177
+ for (let i = 0; i < shuffledKeywords.length; i++) {
178
+ const keyword = shuffledKeywords[i];
179
+ if (process.stdout.isTTY) {
180
+ process.stdout.write(`\r Searching "${keyword}" (${i + 1}/${shuffledKeywords.length})... `);
181
+ }
182
+
183
+ // Search with random offset for diversity
184
+ const offset = Math.floor(rng() * 200);
185
+ const results = await searchNpm(keyword, offset, 250);
186
+
187
+ for (const pkg of results) {
188
+ // Exclusion filters
189
+ if (candidates.has(pkg.name)) continue;
190
+ if (curated.has(pkg.name)) continue;
191
+ if (pkg.name.startsWith('@types/')) continue;
192
+ if (pkg.deprecated) continue;
193
+ if (pkg.name.startsWith('_')) continue;
194
+
195
+ candidates.set(pkg.name, pkg);
196
+ }
197
+
198
+ // Stop early if we have enough candidates
199
+ if (candidates.size >= 2000) break;
200
+
201
+ // Rate limiting: ~100ms between requests
202
+ await new Promise(r => setTimeout(r, 100));
203
+ }
204
+
205
+ if (process.stdout.isTTY) {
206
+ process.stdout.write('\r' + ''.padEnd(80) + '\r');
207
+ }
208
+ console.log(` Collected ${candidates.size} unique candidates`);
209
+
210
+ // Phase 2: Classify by dependency count
211
+ // Over-collect: allow 2x quota per stratum to enable backfill
212
+ console.log(`\n [2/3] Classifying by dependency count...`);
213
+ const buckets = { small: [], medium: [], large: [], vlarge: [] };
214
+ const candidateList = shuffleArray([...candidates.keys()], rng);
215
+
216
+ const totalQuota = Object.values(STRATA).reduce((s, v) => s + v.quota, 0);
217
+ let classified = 0;
218
+ let processed = 0;
219
+ // Over-collect limit: 2x quota per stratum to provide backfill pool
220
+ const OVER_COLLECT = 2;
221
+
222
+ for (const pkgName of candidateList) {
223
+ // Check if all buckets have enough for backfill
224
+ const allOverCollected = Object.entries(STRATA).every(
225
+ ([name, { quota }]) => buckets[name].length >= quota * OVER_COLLECT
226
+ );
227
+ if (allOverCollected) break;
228
+
229
+ processed++;
230
+ if (process.stdout.isTTY && processed % 10 === 0) {
231
+ const bucketStatus = Object.entries(buckets).map(([k, v]) => `${k}:${v.length}/${STRATA[k].quota}`).join(' ');
232
+ process.stdout.write(`\r Classifying [${processed}/${candidateList.length}] ${bucketStatus} `);
233
+ }
234
+
235
+ const info = await getDepCount(pkgName);
236
+ if (!info) continue;
237
+
238
+ const stratum = classifyStratum(info.totalDeps);
239
+ if (buckets[stratum].length < STRATA[stratum].quota * OVER_COLLECT) {
240
+ buckets[stratum].push({ name: pkgName, deps: info.totalDeps, stratum });
241
+ classified++;
242
+ }
243
+
244
+ // Rate limiting
245
+ await new Promise(r => setTimeout(r, 50));
246
+ }
247
+
248
+ if (process.stdout.isTTY) {
249
+ process.stdout.write('\r' + ''.padEnd(80) + '\r');
250
+ }
251
+
252
+ // Phase 3: Output with backfill
253
+ // If large/vlarge strata can't meet quota, redistribute remaining slots
254
+ // to small/medium proportionally (reflects real npm distribution).
255
+ console.log(`\n [3/3] Writing results...`);
256
+ const selected = [];
257
+ let deficit = 0;
258
+ for (const [name, { quota }] of Object.entries(STRATA)) {
259
+ const actual = Math.min(buckets[name].length, quota);
260
+ console.log(` ${name}: ${actual}/${quota} packages`);
261
+ selected.push(...buckets[name].slice(0, actual));
262
+ deficit += quota - actual;
263
+ }
264
+
265
+ // Backfill deficit from small/medium overflow (proportional)
266
+ if (deficit > 0) {
267
+ console.log(` Backfilling ${deficit} slots from small/medium overflow...`);
268
+ const backfillSources = ['small', 'medium']; // priority order
269
+ for (const src of backfillSources) {
270
+ if (deficit <= 0) break;
271
+ const overflow = buckets[src].slice(STRATA[src].quota);
272
+ const take = Math.min(overflow.length, deficit);
273
+ if (take > 0) {
274
+ selected.push(...overflow.slice(0, take));
275
+ deficit -= take;
276
+ console.log(` +${take} from ${src} overflow`);
277
+ }
278
+ }
279
+ }
280
+
281
+ const totalSelected = selected.length;
282
+ console.log(`\n Total: ${totalSelected}/200 packages`);
283
+
284
+ if (totalSelected < 200) {
285
+ console.warn(`\n [WARN] Only ${totalSelected} packages found. Re-run with different --seed or add more search keywords.`);
286
+ }
287
+
288
+ // Write output file
289
+ // Use a Set to track already-written packages (avoid duplication from backfill)
290
+ const writtenNames = new Set();
291
+ const header = [
292
+ '# MUAD\'DIB Benign Random Dataset — npm stratified random sample',
293
+ `# Generated: ${new Date().toISOString()}`,
294
+ `# Seed: ${seed}`,
295
+ `# Total: ${totalSelected} packages`,
296
+ '# Strata: small (<10 deps): 80, medium (10-50): 60, large (51-100): 40, vlarge (100+): 20',
297
+ '# Backfill: unfilled large/vlarge slots redistributed to small/medium',
298
+ '# Used by `muaddib evaluate` to measure FPR on representative npm sample',
299
+ ''
300
+ ];
301
+
302
+ const lines = [];
303
+ for (const [name, { quota }] of Object.entries(STRATA)) {
304
+ const actual = Math.min(buckets[name].length, quota);
305
+ lines.push(`# === ${name} (${actual}/${quota}) ===`);
306
+ for (const pkg of buckets[name].slice(0, actual)) {
307
+ lines.push(pkg.name);
308
+ writtenNames.add(pkg.name);
309
+ }
310
+ lines.push('');
311
+ }
312
+
313
+ // Backfill section (additional packages from overflow)
314
+ const backfillPkgs = selected.filter(p => !writtenNames.has(p.name));
315
+ if (backfillPkgs.length > 0) {
316
+ lines.push(`# === backfill (${backfillPkgs.length}) ===`);
317
+ for (const pkg of backfillPkgs) {
318
+ lines.push(pkg.name);
319
+ }
320
+ lines.push('');
321
+ }
322
+
323
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
324
+ fs.writeFileSync(outputPath, header.join('\n') + lines.join('\n'));
325
+ console.log(` Written to: ${path.relative(ROOT, outputPath)}`);
326
+
327
+ // Verify no overlap with curated
328
+ const overlap = selected.filter(p => curated.has(p.name));
329
+ if (overlap.length > 0) {
330
+ console.error(`\n [ERROR] ${overlap.length} packages overlap with curated corpus: ${overlap.map(p => p.name).join(', ')}`);
331
+ } else {
332
+ console.log(' No overlap with curated corpus');
333
+ }
334
+ }
335
+
336
+ main().catch(err => {
337
+ console.error(`[ERROR] ${err.message}`);
338
+ process.exit(1);
339
+ });
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
 
@@ -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);
@@ -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 () => {
@@ -630,7 +647,7 @@ async function run(targetPath, options = {}) {
630
647
  const enrichedThreats = deduped.map(t => {
631
648
  const rule = getRule(t.type);
632
649
  const confFactor = { high: 1.0, medium: 0.85, low: 0.6 }[rule.confidence] || 1.0;
633
- const points = Math.round((SEVERITY_WEIGHTS[t.severity] || 0) * confFactor);
650
+ const points = Math.round((getSeverityWeights()[t.severity] || 0) * confFactor);
634
651
  return {
635
652
  ...t,
636
653
  rule_id: rule.id || t.type,
@@ -695,6 +712,7 @@ async function run(targetPath, options = {}) {
695
712
  if (options._capture) {
696
713
  setExtraExcludes([]);
697
714
  clearFileListCache();
715
+ if (configApplied) { resetConfigOverrides(); resetMaxFileSize(); }
698
716
  return result;
699
717
  }
700
718
 
@@ -729,6 +747,7 @@ async function run(targetPath, options = {}) {
729
747
  // Clear runtime state
730
748
  setExtraExcludes([]);
731
749
  clearFileListCache();
750
+ if (configApplied) { resetConfigOverrides(); resetMaxFileSize(); }
732
751
 
733
752
  return Math.min(failingThreats.length, 125);
734
753
  }
@@ -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
3
  const { findFiles, forEachSafeFile, debugLog } = require('../utils.js');
4
- const { MAX_FILE_SIZE } = require('../shared/constants.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
 
@@ -66,7 +66,7 @@ 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
72
  } catch (e) { debugLog('[SHELL] stat error:', e?.message); }
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
 
@@ -585,9 +623,9 @@ function calculateRiskScore(deduped, intentResult) {
585
623
  const mediumCount = deduped.filter(t => t.severity === 'MEDIUM').length;
586
624
  const lowCount = deduped.filter(t => t.severity === 'LOW').length;
587
625
 
588
- const riskLevel = riskScore >= RISK_THRESHOLDS.CRITICAL ? 'CRITICAL'
589
- : riskScore >= RISK_THRESHOLDS.HIGH ? 'HIGH'
590
- : riskScore >= RISK_THRESHOLDS.MEDIUM ? 'MEDIUM'
626
+ const riskLevel = riskScore >= _riskThresholds.CRITICAL ? 'CRITICAL'
627
+ : riskScore >= _riskThresholds.HIGH ? 'HIGH'
628
+ : riskScore >= _riskThresholds.MEDIUM ? 'MEDIUM'
591
629
  : riskScore > 0 ? 'LOW'
592
630
  : 'SAFE';
593
631
 
@@ -600,5 +638,6 @@ function calculateRiskScore(deduped, intentResult) {
600
638
 
601
639
  module.exports = {
602
640
  SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, CONFIDENCE_FACTORS,
603
- isPackageLevelThreat, computeGroupScore, applyFPReductions, applyCompoundBoosts, calculateRiskScore
641
+ isPackageLevelThreat, computeGroupScore, applyFPReductions, applyCompoundBoosts, calculateRiskScore,
642
+ applyConfigOverrides, resetConfigOverrides, getSeverityWeights, getRiskThresholds
604
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 {