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 +16 -1
- package/package.json +1 -1
- package/scripts/sample-npm-random.js +339 -0
- package/src/config.js +250 -0
- package/src/index.js +24 -5
- package/src/scanner/github-actions.js +2 -2
- package/src/scanner/hash.js +2 -2
- package/src/scanner/shell.js +2 -2
- package/src/scoring.js +44 -5
- package/src/shared/constants.js +9 -1
- package/src/temporal-ast-diff.js +1 -1
- package/src/utils.js +2 -2
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
|
@@ -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 >
|
|
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 >
|
|
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((
|
|
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 >
|
|
43
|
+
if (stat.size > getMaxFileSize()) continue;
|
|
44
44
|
} catch {
|
|
45
45
|
continue;
|
|
46
46
|
}
|
package/src/scanner/hash.js
CHANGED
|
@@ -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 >
|
|
60
|
+
if (stat.size > getMaxFileSize()) return null;
|
|
61
61
|
const mtime = stat.mtimeMs;
|
|
62
62
|
|
|
63
63
|
// Check the cache
|
package/src/scanner/shell.js
CHANGED
|
@@ -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 <=
|
|
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 =
|
|
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 >=
|
|
589
|
-
: riskScore >=
|
|
590
|
-
: riskScore >=
|
|
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
|
};
|
package/src/shared/constants.js
CHANGED
|
@@ -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 };
|
package/src/temporal-ast-diff.js
CHANGED
|
@@ -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 >
|
|
288
|
+
if (stat.size > getMaxFileSize()) continue;
|
|
289
289
|
} catch { continue; }
|
|
290
290
|
let content;
|
|
291
291
|
try {
|