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 +19 -3
- package/package.json +1 -1
- package/src/config.js +250 -0
- package/src/index.js +32 -12
- package/src/scanner/ast-detectors.js +7 -2
- package/src/scanner/deobfuscate.js +21 -0
- package/src/scanner/github-actions.js +2 -2
- package/src/scanner/hash.js +2 -2
- package/src/scanner/shell.js +6 -6
- package/src/scoring.js +74 -9
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const {
|
|
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
|
-
|
|
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
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
|
|
|
@@ -186,8 +187,8 @@ function scanParanoid(targetPath) {
|
|
|
186
187
|
}
|
|
187
188
|
}
|
|
188
189
|
});
|
|
189
|
-
} catch {
|
|
190
|
-
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
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:
|
|
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 >
|
|
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
|
-
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 <=
|
|
69
|
+
} else if (lstat.isFile() && !path.extname(item) && lstat.size <= getMaxFileSize()) {
|
|
70
70
|
results.push(fullPath);
|
|
71
71
|
}
|
|
72
|
-
} catch {
|
|
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 {
|
|
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 =
|
|
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
|
-
//
|
|
284
|
-
//
|
|
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 >=
|
|
563
|
-
: riskScore >=
|
|
564
|
-
: riskScore >=
|
|
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
|
};
|
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 {
|