muaddib-scanner 2.5.5 → 2.5.6
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/package.json +1 -1
- package/src/index.js +157 -13
- package/src/rules/index.js +2 -2
- package/src/scanner/ast-detectors.js +19 -0
- package/src/shared/download.js +5 -2
- package/src/utils.js +24 -8
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -30,7 +30,8 @@ const { formatOutput } = require('./output-formatter.js');
|
|
|
30
30
|
const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache, debugLog } = require('./utils.js');
|
|
31
31
|
const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat, computeGroupScore, applyFPReductions, calculateRiskScore } = require('./scoring.js');
|
|
32
32
|
|
|
33
|
-
const { MAX_FILE_SIZE } = require('./shared/constants.js');
|
|
33
|
+
const { MAX_FILE_SIZE, safeParse } = require('./shared/constants.js');
|
|
34
|
+
const walk = require('acorn-walk');
|
|
34
35
|
|
|
35
36
|
// Timeout constants for scan safety
|
|
36
37
|
const SCANNER_TIMEOUT = 15000; // 15s per individual scanner
|
|
@@ -40,27 +41,169 @@ const SCAN_TIMEOUT = 60000; // 60s global scan timeout
|
|
|
40
41
|
function scanParanoid(targetPath) {
|
|
41
42
|
const threats = [];
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
// AST-based paranoid detection patterns
|
|
45
|
+
const PARANOID_AST_DETECTORS = {
|
|
46
|
+
network_access: {
|
|
47
|
+
callNames: new Set(['fetch', 'axios']),
|
|
48
|
+
memberPatterns: [
|
|
49
|
+
{ obj: 'http', prop: 'request' }, { obj: 'http', prop: 'get' },
|
|
50
|
+
{ obj: 'https', prop: 'request' }, { obj: 'https', prop: 'get' },
|
|
51
|
+
{ obj: 'net', prop: 'connect' }
|
|
52
|
+
],
|
|
53
|
+
newNames: new Set(['XMLHttpRequest'])
|
|
54
|
+
},
|
|
55
|
+
sensitive_file_access: {
|
|
56
|
+
sensitiveStrings: ['.env', '.npmrc', '.ssh', '.git', 'id_rsa', 'credentials', 'secrets']
|
|
57
|
+
},
|
|
58
|
+
dynamic_execution: {
|
|
59
|
+
callNames: new Set(['eval']),
|
|
60
|
+
newNames: new Set(['Function']),
|
|
61
|
+
memberPatterns: [
|
|
62
|
+
{ obj: 'vm', prop: 'runInContext' }, { obj: 'vm', prop: 'runInNewContext' },
|
|
63
|
+
{ obj: 'vm', prop: 'runInThisContext' }
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
subprocess: {
|
|
67
|
+
callNames: new Set(['exec', 'execSync', 'spawn', 'spawnSync', 'fork', 'execFile', 'execFileSync']),
|
|
68
|
+
memberPatterns: [
|
|
69
|
+
{ obj: 'child_process', prop: 'exec' }, { obj: 'child_process', prop: 'execSync' },
|
|
70
|
+
{ obj: 'child_process', prop: 'spawn' }, { obj: 'child_process', prop: 'fork' }
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
env_access: {
|
|
74
|
+
memberPatterns: [{ obj: 'process', prop: 'env' }]
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function scanFileAST(filePath) {
|
|
44
79
|
try {
|
|
45
80
|
const stat = fs.statSync(filePath);
|
|
46
81
|
if (stat.size > MAX_FILE_SIZE) return;
|
|
47
82
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
83
|
+
const relFile = path.relative(targetPath, filePath);
|
|
48
84
|
|
|
49
|
-
|
|
50
|
-
|
|
85
|
+
const ast = safeParse(content);
|
|
86
|
+
if (!ast) {
|
|
87
|
+
// Parse failed — fall back to content matching for this file
|
|
88
|
+
scanFileContent(filePath, content, relFile);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
51
91
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
92
|
+
const found = new Set(); // deduplicate: one finding per rule per file
|
|
93
|
+
|
|
94
|
+
walk.simple(ast, {
|
|
95
|
+
CallExpression(node) {
|
|
96
|
+
// Direct calls: eval(), exec(), fetch(), etc.
|
|
97
|
+
if (node.callee.type === 'Identifier') {
|
|
98
|
+
const name = node.callee.name;
|
|
99
|
+
for (const [ruleKey, detector] of Object.entries(PARANOID_AST_DETECTORS)) {
|
|
100
|
+
if (detector.callNames && detector.callNames.has(name) && !found.has(ruleKey)) {
|
|
101
|
+
found.add(ruleKey);
|
|
102
|
+
const rule = PARANOID_RULES[ruleKey];
|
|
103
|
+
threats.push({
|
|
104
|
+
type: rule.id, severity: rule.severity.toUpperCase(),
|
|
105
|
+
message: `${rule.message}: "${name}"`, file: relFile, mitre: rule.mitre
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Member calls: http.request(), child_process.exec(), etc.
|
|
111
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
112
|
+
node.callee.object?.type === 'Identifier' &&
|
|
113
|
+
node.callee.property?.type === 'Identifier') {
|
|
114
|
+
const obj = node.callee.object.name;
|
|
115
|
+
const prop = node.callee.property.name;
|
|
116
|
+
for (const [ruleKey, detector] of Object.entries(PARANOID_AST_DETECTORS)) {
|
|
117
|
+
if (detector.memberPatterns &&
|
|
118
|
+
detector.memberPatterns.some(m => m.obj === obj && m.prop === prop) &&
|
|
119
|
+
!found.has(ruleKey)) {
|
|
120
|
+
found.add(ruleKey);
|
|
121
|
+
const rule = PARANOID_RULES[ruleKey];
|
|
122
|
+
threats.push({
|
|
123
|
+
type: rule.id, severity: rule.severity.toUpperCase(),
|
|
124
|
+
message: `${rule.message}: "${obj}.${prop}"`, file: relFile, mitre: rule.mitre
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
NewExpression(node) {
|
|
131
|
+
if (node.callee.type === 'Identifier') {
|
|
132
|
+
const name = node.callee.name;
|
|
133
|
+
for (const [ruleKey, detector] of Object.entries(PARANOID_AST_DETECTORS)) {
|
|
134
|
+
if (detector.newNames && detector.newNames.has(name) && !found.has(ruleKey)) {
|
|
135
|
+
found.add(ruleKey);
|
|
136
|
+
const rule = PARANOID_RULES[ruleKey];
|
|
137
|
+
threats.push({
|
|
138
|
+
type: rule.id, severity: rule.severity.toUpperCase(),
|
|
139
|
+
message: `${rule.message}: "new ${name}"`, file: relFile, mitre: rule.mitre
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
MemberExpression(node) {
|
|
146
|
+
// process.env access
|
|
147
|
+
if (node.object?.type === 'Identifier' && node.object.name === 'process' &&
|
|
148
|
+
node.property?.type === 'Identifier' && node.property.name === 'env' &&
|
|
149
|
+
!found.has('env_access')) {
|
|
150
|
+
found.add('env_access');
|
|
151
|
+
const rule = PARANOID_RULES.env_access;
|
|
55
152
|
threats.push({
|
|
56
|
-
type: rule.id,
|
|
57
|
-
|
|
58
|
-
message: `${rule.message}: "${pattern}"`,
|
|
59
|
-
file: path.relative(targetPath, filePath),
|
|
60
|
-
mitre: rule.mitre
|
|
153
|
+
type: rule.id, severity: rule.severity.toUpperCase(),
|
|
154
|
+
message: `${rule.message}: "process.env"`, file: relFile, mitre: rule.mitre
|
|
61
155
|
});
|
|
62
156
|
}
|
|
157
|
+
},
|
|
158
|
+
Literal(node) {
|
|
159
|
+
// Sensitive file string literals
|
|
160
|
+
if (typeof node.value === 'string' && !found.has('sensitive_file_access')) {
|
|
161
|
+
const detector = PARANOID_AST_DETECTORS.sensitive_file_access;
|
|
162
|
+
for (const s of detector.sensitiveStrings) {
|
|
163
|
+
if (node.value.includes(s)) {
|
|
164
|
+
found.add('sensitive_file_access');
|
|
165
|
+
const rule = PARANOID_RULES.sensitive_file_access;
|
|
166
|
+
threats.push({
|
|
167
|
+
type: rule.id, severity: rule.severity.toUpperCase(),
|
|
168
|
+
message: `${rule.message}: "${s}"`, file: relFile, mitre: rule.mitre
|
|
169
|
+
});
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
63
174
|
}
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
// Ignore read/parse errors
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Content-based fallback for non-JS files or parse failures
|
|
182
|
+
function scanFileContent(filePath, content, relFile) {
|
|
183
|
+
const contentWithoutUrls = content.replace(/https?:\/\/[^\s"']+/g, '');
|
|
184
|
+
for (const [, rule] of Object.entries(PARANOID_RULES)) {
|
|
185
|
+
for (const pattern of rule.patterns) {
|
|
186
|
+
if (contentWithoutUrls.includes(pattern)) {
|
|
187
|
+
threats.push({
|
|
188
|
+
type: rule.id, severity: rule.severity.toUpperCase(),
|
|
189
|
+
message: `${rule.message}: "${pattern}"`, file: relFile, mitre: rule.mitre
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function scanFile(filePath) {
|
|
197
|
+
try {
|
|
198
|
+
const stat = fs.statSync(filePath);
|
|
199
|
+
if (stat.size > MAX_FILE_SIZE) return;
|
|
200
|
+
const ext = path.extname(filePath);
|
|
201
|
+
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
|
202
|
+
scanFileAST(filePath);
|
|
203
|
+
} else {
|
|
204
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
205
|
+
const relFile = path.relative(targetPath, filePath);
|
|
206
|
+
scanFileContent(filePath, content, relFile);
|
|
64
207
|
}
|
|
65
208
|
} catch {
|
|
66
209
|
// Ignore read errors
|
|
@@ -86,7 +229,8 @@ function scanParanoid(targetPath) {
|
|
|
86
229
|
if (!isExcluded) {
|
|
87
230
|
walkDir(fullPath, depth + 1);
|
|
88
231
|
}
|
|
89
|
-
} else if (file.endsWith('.js') || file.endsWith('.
|
|
232
|
+
} else if (file.endsWith('.js') || file.endsWith('.mjs') || file.endsWith('.cjs') ||
|
|
233
|
+
file.endsWith('.json') || file.endsWith('.sh')) {
|
|
90
234
|
scanFile(fullPath);
|
|
91
235
|
}
|
|
92
236
|
}
|
package/src/rules/index.js
CHANGED
|
@@ -424,7 +424,7 @@ const RULES = {
|
|
|
424
424
|
mitre: 'T1105'
|
|
425
425
|
},
|
|
426
426
|
network_require: {
|
|
427
|
-
id: 'MUADDIB-PKG-
|
|
427
|
+
id: 'MUADDIB-PKG-011',
|
|
428
428
|
name: 'Network Module in Lifecycle Script',
|
|
429
429
|
severity: 'HIGH',
|
|
430
430
|
confidence: 'high',
|
|
@@ -433,7 +433,7 @@ const RULES = {
|
|
|
433
433
|
mitre: 'T1105'
|
|
434
434
|
},
|
|
435
435
|
node_inline_exec: {
|
|
436
|
-
id: 'MUADDIB-PKG-
|
|
436
|
+
id: 'MUADDIB-PKG-012',
|
|
437
437
|
name: 'Node Inline Execution in Lifecycle Script',
|
|
438
438
|
severity: 'HIGH',
|
|
439
439
|
confidence: 'high',
|
|
@@ -165,11 +165,30 @@ function resolveStringConcat(node) {
|
|
|
165
165
|
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
|
|
166
166
|
return node.quasis.map(q => q.value.raw).join('');
|
|
167
167
|
}
|
|
168
|
+
// TemplateLiteral with resolvable expressions
|
|
169
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length > 0) {
|
|
170
|
+
const parts = [];
|
|
171
|
+
for (let i = 0; i < node.quasis.length; i++) {
|
|
172
|
+
parts.push(node.quasis[i].value.raw);
|
|
173
|
+
if (i < node.expressions.length) {
|
|
174
|
+
const resolved = resolveStringConcat(node.expressions[i]);
|
|
175
|
+
if (resolved === null) return null;
|
|
176
|
+
parts.push(resolved);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return parts.join('');
|
|
180
|
+
}
|
|
168
181
|
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
169
182
|
const left = resolveStringConcat(node.left);
|
|
170
183
|
const right = resolveStringConcat(node.right);
|
|
171
184
|
if (left !== null && right !== null) return left + right;
|
|
172
185
|
}
|
|
186
|
+
// ConditionalExpression — either branch is enough for detection
|
|
187
|
+
if (node.type === 'ConditionalExpression') {
|
|
188
|
+
const consequent = resolveStringConcat(node.consequent);
|
|
189
|
+
const alternate = resolveStringConcat(node.alternate);
|
|
190
|
+
return consequent !== null ? consequent : alternate;
|
|
191
|
+
}
|
|
173
192
|
return null;
|
|
174
193
|
}
|
|
175
194
|
|
package/src/shared/download.js
CHANGED
|
@@ -229,9 +229,12 @@ function extractTarGz(tgzPath, destDir) {
|
|
|
229
229
|
*/
|
|
230
230
|
function sanitizePackageName(packageName) {
|
|
231
231
|
return packageName
|
|
232
|
+
.normalize('NFC')
|
|
233
|
+
.replace(/[^\x20-\x7E]/g, '') // Strip non-ASCII (Unicode confusables)
|
|
232
234
|
.replace(/\.\./g, '')
|
|
233
|
-
.replace(
|
|
234
|
-
.replace(
|
|
235
|
+
.replace(/[/\\]/g, '_') // Both slash types → _
|
|
236
|
+
.replace(/[@:]/g, '') // @ and : (Windows drive letter)
|
|
237
|
+
.replace(/[\x00-\x1F]/g, ''); // Control chars (safety net)
|
|
235
238
|
}
|
|
236
239
|
|
|
237
240
|
module.exports = {
|
package/src/utils.js
CHANGED
|
@@ -81,6 +81,7 @@ function findFiles(dir, options = {}) {
|
|
|
81
81
|
maxDepth = 100,
|
|
82
82
|
results = [],
|
|
83
83
|
visitedInodes = new Set(),
|
|
84
|
+
visitedPaths = new Set(),
|
|
84
85
|
depth = 0
|
|
85
86
|
} = options;
|
|
86
87
|
|
|
@@ -90,15 +91,15 @@ function findFiles(dir, options = {}) {
|
|
|
90
91
|
[...excludedDirs, ..._extraExcludedDirs].sort().join(',');
|
|
91
92
|
const cached = _fileListCache.get(cacheKey);
|
|
92
93
|
if (cached) return [...cached]; // return copy to prevent mutation
|
|
93
|
-
const result = _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth });
|
|
94
|
+
const result = _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, visitedPaths, depth });
|
|
94
95
|
_fileListCache.set(cacheKey, [...result]);
|
|
95
96
|
return result;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
return _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth });
|
|
99
|
+
return _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, visitedPaths, depth });
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
function _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth }) {
|
|
102
|
+
function _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, visitedPaths, depth }) {
|
|
102
103
|
if (depth > maxDepth) return results;
|
|
103
104
|
if (!fs.existsSync(dir)) return results;
|
|
104
105
|
|
|
@@ -132,10 +133,16 @@ function _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visi
|
|
|
132
133
|
try {
|
|
133
134
|
const realPath = fs.realpathSync(fullPath);
|
|
134
135
|
const realStat = fs.statSync(realPath);
|
|
135
|
-
if (realStat.ino !== 0
|
|
136
|
-
|
|
136
|
+
if (realStat.ino !== 0) {
|
|
137
|
+
if (visitedInodes.has(realStat.ino)) continue;
|
|
138
|
+
visitedInodes.add(realStat.ino);
|
|
139
|
+
} else {
|
|
140
|
+
// Windows ino=0 fallback: use resolved path for cycle detection
|
|
141
|
+
if (visitedPaths.has(realPath)) continue;
|
|
142
|
+
visitedPaths.add(realPath);
|
|
143
|
+
}
|
|
137
144
|
if (realStat.isDirectory()) {
|
|
138
|
-
_findFilesImpl(realPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth: depth + 1 });
|
|
145
|
+
_findFilesImpl(realPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, visitedPaths, depth: depth + 1 });
|
|
139
146
|
} else if (extensions.some(ext => item.endsWith(ext))) {
|
|
140
147
|
results.push(realPath);
|
|
141
148
|
}
|
|
@@ -145,10 +152,19 @@ function _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visi
|
|
|
145
152
|
continue;
|
|
146
153
|
}
|
|
147
154
|
|
|
148
|
-
if (lstat.ino !== 0)
|
|
155
|
+
if (lstat.ino !== 0) {
|
|
156
|
+
visitedInodes.add(lstat.ino);
|
|
157
|
+
} else {
|
|
158
|
+
// Windows ino=0 fallback: use resolved path for cycle detection
|
|
159
|
+
const resolvedPath = path.resolve(fullPath);
|
|
160
|
+
if (lstat.isDirectory()) {
|
|
161
|
+
if (visitedPaths.has(resolvedPath)) continue;
|
|
162
|
+
visitedPaths.add(resolvedPath);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
149
165
|
|
|
150
166
|
if (lstat.isDirectory()) {
|
|
151
|
-
_findFilesImpl(fullPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth: depth + 1 });
|
|
167
|
+
_findFilesImpl(fullPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, visitedPaths, depth: depth + 1 });
|
|
152
168
|
} else if (extensions.some(ext => item.endsWith(ext))) {
|
|
153
169
|
results.push(fullPath);
|
|
154
170
|
}
|