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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.5.5",
3
+ "version": "2.5.6",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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
- function scanFile(filePath) {
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
- // Ignore URLs (they often contain patterns like .git)
50
- const contentWithoutUrls = content.replace(/https?:\/\/[^\s"']+/g, '');
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
- for (const [, rule] of Object.entries(PARANOID_RULES)) {
53
- for (const pattern of rule.patterns) {
54
- if (contentWithoutUrls.includes(pattern)) {
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
- severity: rule.severity.toUpperCase(),
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('.json') || file.endsWith('.sh')) {
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
  }
@@ -424,7 +424,7 @@ const RULES = {
424
424
  mitre: 'T1105'
425
425
  },
426
426
  network_require: {
427
- id: 'MUADDIB-PKG-006',
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-007',
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
 
@@ -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(/\//g, '_')
234
- .replace(/@/g, '');
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 && visitedInodes.has(realStat.ino)) continue;
136
- if (realStat.ino !== 0) visitedInodes.add(realStat.ino);
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) visitedInodes.add(lstat.ino);
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
  }