muaddib-scanner 2.2.11 → 2.2.14

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.
@@ -328,6 +328,16 @@ const PLAYBOOKS = {
328
328
  'CRITIQUE: eval() ou Function() recoit un argument decode en base64 (atob/Buffer.from). ' +
329
329
  'Technique de staged payload: le code malveillant est encode puis decode et execute dynamiquement. ' +
330
330
  'Isoler la machine. Decoder le payload manuellement pour analyser le code execute. Supprimer le package.',
331
+
332
+ crypto_decipher:
333
+ 'crypto.createDecipher/createDecipheriv detecte. Dechiffrement de payload embarque a runtime. ' +
334
+ 'Pattern canonique de l\'attaque event-stream/flatmap-stream. Extraire et decoder le payload manuellement ' +
335
+ 'pour analyser le code execute. Verifier la source des donnees chiffrees.',
336
+
337
+ module_compile:
338
+ 'CRITIQUE: module._compile() detecte. Cette API Node.js interne execute du code arbitraire ' +
339
+ 'a partir d\'une chaine dans le contexte d\'un module. Utilisee dans flatmap-stream pour executer ' +
340
+ 'un payload dechiffre sans ecrire sur disque. Isoler immediatement. Analyser la source de la chaine compilee.',
331
341
  };
332
342
 
333
343
  function getPlaybook(threatType) {
@@ -649,6 +649,32 @@ const RULES = {
649
649
  mitre: 'T1565.001'
650
650
  },
651
651
 
652
+ crypto_decipher: {
653
+ id: 'MUADDIB-AST-022',
654
+ name: 'Encrypted Payload Decryption',
655
+ severity: 'HIGH',
656
+ confidence: 'high',
657
+ description: 'crypto.createDecipher/createDecipheriv detecte. Dechiffrement runtime de payload embarque. Pattern canonique de flatmap-stream/event-stream.',
658
+ references: [
659
+ 'https://snyk.io/blog/malicious-code-found-in-npm-package-event-stream/',
660
+ 'https://attack.mitre.org/techniques/T1140/'
661
+ ],
662
+ mitre: 'T1140'
663
+ },
664
+
665
+ module_compile: {
666
+ id: 'MUADDIB-AST-023',
667
+ name: 'Module Compile Execution',
668
+ severity: 'CRITICAL',
669
+ confidence: 'high',
670
+ description: 'module._compile() detecte. Execution de code arbitraire a partir d\'une chaine dans le contexte module. Technique cle de flatmap-stream.',
671
+ references: [
672
+ 'https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident',
673
+ 'https://attack.mitre.org/techniques/T1059/007/'
674
+ ],
675
+ mitre: 'T1059'
676
+ },
677
+
652
678
  ai_agent_abuse: {
653
679
  id: 'MUADDIB-AST-013',
654
680
  name: 'AI Agent Weaponization',
@@ -2,9 +2,9 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const acorn = require('acorn');
4
4
  const walk = require('acorn-walk');
5
- const { isDevFile, findJsFiles, getCallName } = require('../utils.js');
6
-
7
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
5
+ const { getCallName } = require('../utils.js');
6
+ const { ACORN_OPTIONS } = require('../shared/constants.js');
7
+ const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
8
8
 
9
9
  const EXCLUDED_FILES = [
10
10
  'src/scanner/ast.js',
@@ -99,55 +99,10 @@ const SANDBOX_INDICATORS = [
99
99
  ];
100
100
 
101
101
  async function analyzeAST(targetPath, options = {}) {
102
- const threats = [];
103
- const files = findJsFiles(targetPath);
104
-
105
- for (const file of files) {
106
- const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
107
-
108
- if (EXCLUDED_FILES.includes(relativePath)) {
109
- continue;
110
- }
111
-
112
- // Ignore files in dev folders
113
- if (isDevFile(relativePath)) {
114
- continue;
115
- }
116
-
117
- try {
118
- const stat = fs.statSync(file);
119
- if (stat.size > MAX_FILE_SIZE) continue;
120
- } catch { continue; }
121
-
122
- let content;
123
- try {
124
- content = fs.readFileSync(file, 'utf8');
125
- } catch {
126
- continue;
127
- }
128
-
129
- // Analyze original code first (preserves obfuscation-detection rules)
130
- const fileThreats = analyzeFile(content, file, targetPath);
131
- threats.push(...fileThreats);
132
-
133
- // Also analyze deobfuscated code for additional findings hidden by obfuscation
134
- if (typeof options.deobfuscate === 'function') {
135
- try {
136
- const result = options.deobfuscate(content);
137
- if (result.transforms.length > 0) {
138
- const deobThreats = analyzeFile(result.code, file, targetPath);
139
- const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
140
- for (const dt of deobThreats) {
141
- if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
142
- threats.push(dt);
143
- }
144
- }
145
- }
146
- } catch { /* deobfuscation failed — skip */ }
147
- }
148
- }
149
-
150
- return threats;
102
+ return analyzeWithDeobfuscation(targetPath, analyzeFile, {
103
+ deobfuscate: options.deobfuscate,
104
+ excludedFiles: EXCLUDED_FILES
105
+ });
151
106
  }
152
107
 
153
108
 
@@ -215,11 +170,7 @@ function analyzeFile(content, filePath, basePath) {
215
170
  let ast;
216
171
 
217
172
  try {
218
- ast = acorn.parse(content, {
219
- ecmaVersion: 2024,
220
- sourceType: 'module',
221
- allowHashBang: true
222
- });
173
+ ast = acorn.parse(content, ACORN_OPTIONS);
223
174
  } catch {
224
175
  // AST parse failed — apply regex fallback for known dangerous patterns
225
176
 
@@ -254,6 +205,8 @@ function analyzeFile(content, filePath, basePath) {
254
205
  const workflowPathVars = new Set();
255
206
  // Track variables assigned temp/executable file paths
256
207
  const execPathVars = new Map();
208
+ // Track variables that alias globalThis/global (e.g. const g = globalThis)
209
+ const globalThisAliases = new Set();
257
210
 
258
211
  /**
259
212
  * Extract string value from a node if it's a Literal or TemplateLiteral with no expressions.
@@ -308,6 +261,13 @@ function analyzeFile(content, filePath, basePath) {
308
261
  execPathVars.set(node.id.name, strVal);
309
262
  }
310
263
 
264
+ // Track variables that alias globalThis or global (e.g. const g = globalThis)
265
+ if (node.init?.type === 'Identifier' &&
266
+ (node.init.name === 'globalThis' || node.init.name === 'global' ||
267
+ node.init.name === 'window' || node.init.name === 'self')) {
268
+ globalThisAliases.add(node.id.name);
269
+ }
270
+
311
271
  // Track variables assigned from path.join containing .github/workflows
312
272
  if (node.init?.type === 'CallExpression' && node.init.callee?.type === 'MemberExpression') {
313
273
  const obj = node.init.callee.object;
@@ -719,6 +679,92 @@ function analyzeFile(content, filePath, basePath) {
719
679
  });
720
680
  }
721
681
  }
682
+
683
+ // Detect indirect eval/Function via computed property: obj['eval'](code), g['Function'](code)
684
+ // Bypasses getCallName() which only reads .property.name (Identifier), not Literal values
685
+ if (node.callee.type === 'MemberExpression' && node.callee.computed) {
686
+ const prop = node.callee.property;
687
+ if (prop.type === 'Literal' && typeof prop.value === 'string') {
688
+ if (prop.value === 'eval') {
689
+ hasEvalInFile = true;
690
+ threats.push({
691
+ type: 'dangerous_call_eval',
692
+ severity: 'HIGH',
693
+ message: 'Indirect eval via computed property access (obj["eval"]) — evasion technique.',
694
+ file: path.relative(basePath, filePath)
695
+ });
696
+ } else if (prop.value === 'Function') {
697
+ threats.push({
698
+ type: 'dangerous_call_function',
699
+ severity: 'MEDIUM',
700
+ message: 'Indirect Function via computed property access (obj["Function"]) — evasion technique.',
701
+ file: path.relative(basePath, filePath)
702
+ });
703
+ }
704
+ }
705
+ // Detect computed call on globalThis/global alias with variable property: g[k]()
706
+ // where g = globalThis and k is a variable (not a literal) — dynamic global dispatch
707
+ const obj = node.callee.object;
708
+ if (prop.type === 'Identifier' && obj?.type === 'Identifier' &&
709
+ (globalThisAliases.has(obj.name) || obj.name === 'globalThis' || obj.name === 'global')) {
710
+ hasEvalInFile = true;
711
+ threats.push({
712
+ type: 'dangerous_call_eval',
713
+ severity: 'HIGH',
714
+ message: `Dynamic global dispatch via computed property (${obj.name}[${prop.name}]) — likely indirect eval evasion.`,
715
+ file: path.relative(basePath, filePath)
716
+ });
717
+ }
718
+ }
719
+
720
+ // Detect indirect eval/Function via sequence expression: (0, eval)(code), (0, Function)(code)
721
+ // Comma operator returns last expression, so (0, eval) === eval but avoids Identifier callee
722
+ if (node.callee.type === 'SequenceExpression') {
723
+ const exprs = node.callee.expressions;
724
+ const last = exprs[exprs.length - 1];
725
+ if (last && last.type === 'Identifier') {
726
+ if (last.name === 'eval') {
727
+ hasEvalInFile = true;
728
+ threats.push({
729
+ type: 'dangerous_call_eval',
730
+ severity: 'HIGH',
731
+ message: 'Indirect eval via sequence expression ((0, eval)) — evasion technique.',
732
+ file: path.relative(basePath, filePath)
733
+ });
734
+ } else if (last.name === 'Function') {
735
+ threats.push({
736
+ type: 'dangerous_call_function',
737
+ severity: 'MEDIUM',
738
+ message: 'Indirect Function via sequence expression ((0, Function)) — evasion technique.',
739
+ file: path.relative(basePath, filePath)
740
+ });
741
+ }
742
+ }
743
+ }
744
+
745
+ // Detect crypto.createDecipher/createDecipheriv — encrypted payload pattern (flatmap-stream)
746
+ // Also detect module._compile — in-memory code execution
747
+ if (node.callee.type === 'MemberExpression') {
748
+ const prop = node.callee.property;
749
+ const propName = prop.type === 'Identifier' ? prop.name :
750
+ (prop.type === 'Literal' ? prop.value : null);
751
+ if (propName === 'createDecipher' || propName === 'createDecipheriv') {
752
+ threats.push({
753
+ type: 'crypto_decipher',
754
+ severity: 'HIGH',
755
+ message: `${propName}() detected — runtime decryption of embedded payload (event-stream/flatmap-stream pattern).`,
756
+ file: path.relative(basePath, filePath)
757
+ });
758
+ }
759
+ if (propName === '_compile') {
760
+ threats.push({
761
+ type: 'module_compile',
762
+ severity: 'CRITICAL',
763
+ message: 'module._compile() detected — executes arbitrary code from string in module context (flatmap-stream pattern).',
764
+ file: path.relative(basePath, filePath)
765
+ });
766
+ }
767
+ }
722
768
  },
723
769
 
724
770
  ImportExpression(node) {
@@ -2,61 +2,14 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const acorn = require('acorn');
4
4
  const walk = require('acorn-walk');
5
- const { isDevFile, findJsFiles, getCallName } = require('../utils.js');
6
-
7
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
5
+ const { getCallName } = require('../utils.js');
6
+ const { ACORN_OPTIONS } = require('../shared/constants.js');
7
+ const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
8
8
 
9
9
  async function analyzeDataFlow(targetPath, options = {}) {
10
- const threats = [];
11
- const files = findJsFiles(targetPath);
12
-
13
- for (const file of files) {
14
- const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
15
-
16
- if (isDevFile(relativePath)) {
17
- continue;
18
- }
19
-
20
- try {
21
- const stat = fs.statSync(file);
22
- if (stat.size > MAX_FILE_SIZE) continue;
23
- } catch { continue; }
24
-
25
- let content;
26
- try {
27
- content = fs.readFileSync(file, 'utf8');
28
- } catch {
29
- continue;
30
- }
31
-
32
- // Respect // muaddib-ignore directive in first 5 lines (like eslint-disable)
33
- const headerLines = content.slice(0, 1024).split('\n').slice(0, 5);
34
- if (headerLines.some(line => line.includes('muaddib-ignore'))) {
35
- continue;
36
- }
37
-
38
- // Analyze original code first (preserves obfuscation-detection rules)
39
- const fileThreats = analyzeFile(content, file, targetPath);
40
- threats.push(...fileThreats);
41
-
42
- // Also analyze deobfuscated code for additional findings hidden by obfuscation
43
- if (typeof options.deobfuscate === 'function') {
44
- try {
45
- const result = options.deobfuscate(content);
46
- if (result.transforms.length > 0) {
47
- const deobThreats = analyzeFile(result.code, file, targetPath);
48
- const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
49
- for (const dt of deobThreats) {
50
- if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
51
- threats.push(dt);
52
- }
53
- }
54
- }
55
- } catch { /* deobfuscation failed — skip */ }
56
- }
57
- }
58
-
59
- return threats;
10
+ return analyzeWithDeobfuscation(targetPath, analyzeFile, {
11
+ deobfuscate: options.deobfuscate
12
+ });
60
13
  }
61
14
 
62
15
  function analyzeFile(content, filePath, basePath) {
@@ -64,12 +17,7 @@ function analyzeFile(content, filePath, basePath) {
64
17
  let ast;
65
18
 
66
19
  try {
67
- ast = acorn.parse(content, {
68
- ecmaVersion: 2024,
69
- sourceType: 'module',
70
- allowHashBang: true,
71
- locations: true
72
- });
20
+ ast = acorn.parse(content, { ...ACORN_OPTIONS, locations: true });
73
21
  } catch {
74
22
  return threats;
75
23
  }
@@ -95,7 +43,10 @@ function analyzeFile(content, filePath, basePath) {
95
43
  const prop = node.init.callee.property;
96
44
  if (obj?.type === 'Identifier' && obj.name === 'path' &&
97
45
  prop?.type === 'Identifier' && (prop.name === 'join' || prop.name === 'resolve')) {
98
- if (node.init.arguments.some(a => a.type === 'Identifier' && sensitivePathVars.has(a.name))) {
46
+ if (node.init.arguments.some(a =>
47
+ (a.type === 'Identifier' && sensitivePathVars.has(a.name)) ||
48
+ (a.type === 'MemberExpression' && a.object?.type === 'Identifier' && sensitivePathVars.has(a.object.name))
49
+ )) {
99
50
  sensitivePathVars.add(node.id.name);
100
51
  }
101
52
  }
@@ -241,6 +192,17 @@ function analyzeFile(content, filePath, basePath) {
241
192
  });
242
193
  }
243
194
  }
195
+
196
+ // Detect property access to secret key material
197
+ const propName = node.property?.type === 'Identifier' ? node.property.name :
198
+ (node.property?.type === 'Literal' ? node.property.value : null);
199
+ if (propName && ['secretKey', '_secretKey', 'privateKey', '_privateKey', 'mnemonic', 'seedPhrase'].includes(propName)) {
200
+ sources.push({
201
+ type: 'credential_read',
202
+ name: propName,
203
+ line: node.loc?.start?.line
204
+ });
205
+ }
244
206
  }
245
207
  });
246
208
 
@@ -300,7 +262,8 @@ const SENSITIVE_PATH_PATTERNS = [
300
262
  '.ethereum', '.electrum', '.config/solana', '.exodus',
301
263
  '.atomic', '.metamask', '.ledger-live', '.trezor',
302
264
  '.bitcoin', '.monero', '.gnupg',
303
- '_cacache', '.cache/yarn', '.cache/pip'
265
+ '_cacache', '.cache/yarn', '.cache/pip',
266
+ 'discord', 'leveldb'
304
267
  ];
305
268
 
306
269
  function isSensitivePath(val) {
@@ -327,6 +290,9 @@ function containsSensitiveLiteral(node) {
327
290
  if (node.type === 'CallExpression' && node.arguments) {
328
291
  return node.arguments.some(a => containsSensitiveLiteral(a));
329
292
  }
293
+ if (node.type === 'ObjectExpression' && node.properties) {
294
+ return node.properties.some(p => p.value && containsSensitiveLiteral(p.value));
295
+ }
330
296
  return false;
331
297
  }
332
298
 
@@ -346,6 +312,11 @@ function isCredentialPath(arg, sensitivePathVars) {
346
312
  if (arg.type === 'Identifier' && sensitivePathVars && sensitivePathVars.has(arg.name)) {
347
313
  return true;
348
314
  }
315
+ // Handle property access on tracked objects: _0x.a where _0x is tracked as sensitive
316
+ if (arg.type === 'MemberExpression' && arg.object?.type === 'Identifier' &&
317
+ sensitivePathVars && sensitivePathVars.has(arg.object.name)) {
318
+ return true;
319
+ }
349
320
  // Handle path.join(dir, '.npmrc') or path.join(sshDir, 'id_rsa') where sshDir is tracked
350
321
  if (arg.type === 'CallExpression' && arg.callee.type === 'MemberExpression') {
351
322
  const obj = arg.callee.object;
@@ -2,6 +2,7 @@
2
2
 
3
3
  const acorn = require('acorn');
4
4
  const walk = require('acorn-walk');
5
+ const { ACORN_OPTIONS } = require('../shared/constants.js');
5
6
 
6
7
  /**
7
8
  * Lightweight static deobfuscation pre-processor.
@@ -16,12 +17,7 @@ function deobfuscate(sourceCode) {
16
17
  // Parse AST — if parsing fails, return source unchanged (fail-safe)
17
18
  let ast;
18
19
  try {
19
- ast = acorn.parse(sourceCode, {
20
- ecmaVersion: 2024,
21
- sourceType: 'module',
22
- allowHashBang: true,
23
- ranges: true
24
- });
20
+ ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
25
21
  } catch {
26
22
  return { code: sourceCode, transforms };
27
23
  }
@@ -97,6 +93,26 @@ function deobfuscate(sourceCode) {
97
93
  return;
98
94
  }
99
95
 
96
+ // Buffer.from('...', 'hex').toString() → decoded string
97
+ if (isBufferHexToString(node)) {
98
+ const hexStr = extractBufferHexArg(node);
99
+ if (hexStr === null) return;
100
+ try {
101
+ const decoded = Buffer.from(hexStr, 'hex').toString();
102
+ if (!isPrintable(decoded)) return;
103
+ const before = sourceCode.slice(node.start, node.end);
104
+ const after = quoteString(decoded);
105
+ replacements.push({
106
+ start: node.start,
107
+ end: node.end,
108
+ value: after,
109
+ type: 'hex',
110
+ before
111
+ });
112
+ } catch { /* decode failure — skip */ }
113
+ return;
114
+ }
115
+
100
116
  // atob('...') → decoded string
101
117
  if (isAtobCall(node)) {
102
118
  const b64str = node.arguments[0]?.value;
@@ -177,12 +193,7 @@ function propagateConsts(sourceCode) {
177
193
  const transforms = [];
178
194
  let ast;
179
195
  try {
180
- ast = acorn.parse(sourceCode, {
181
- ecmaVersion: 2024,
182
- sourceType: 'module',
183
- allowHashBang: true,
184
- ranges: true
185
- });
196
+ ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
186
197
  } catch {
187
198
  return { code: sourceCode, transforms };
188
199
  }
@@ -296,12 +307,7 @@ function foldConcatsOnly(sourceCode) {
296
307
  const transforms = [];
297
308
  let ast;
298
309
  try {
299
- ast = acorn.parse(sourceCode, {
300
- ecmaVersion: 2024,
301
- sourceType: 'module',
302
- allowHashBang: true,
303
- ranges: true
304
- });
310
+ ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
305
311
  } catch {
306
312
  return { code: sourceCode, transforms };
307
313
  }
@@ -430,6 +436,34 @@ function extractBufferBase64Arg(node) {
430
436
  return inner.arguments[0].value;
431
437
  }
432
438
 
439
+ /**
440
+ * Check if node is Buffer.from('...', 'hex').toString()
441
+ */
442
+ function isBufferHexToString(node) {
443
+ if (node.type !== 'CallExpression') return false;
444
+ const callee = node.callee;
445
+ if (callee.type !== 'MemberExpression') return false;
446
+ if (callee.property?.type !== 'Identifier' || callee.property.name !== 'toString') return false;
447
+ const inner = callee.object;
448
+ if (inner?.type !== 'CallExpression') return false;
449
+ const innerCallee = inner.callee;
450
+ if (innerCallee?.type !== 'MemberExpression') return false;
451
+ if (innerCallee.object?.type !== 'Identifier' || innerCallee.object.name !== 'Buffer') return false;
452
+ if (innerCallee.property?.type !== 'Identifier' || innerCallee.property.name !== 'from') return false;
453
+ if (inner.arguments.length < 2) return false;
454
+ if (inner.arguments[1]?.type !== 'Literal' || inner.arguments[1].value !== 'hex') return false;
455
+ if (inner.arguments[0]?.type !== 'Literal' || typeof inner.arguments[0].value !== 'string') return false;
456
+ return true;
457
+ }
458
+
459
+ /**
460
+ * Extract the hex string argument from Buffer.from(str, 'hex').toString()
461
+ */
462
+ function extractBufferHexArg(node) {
463
+ const inner = node.callee.object;
464
+ return inner.arguments[0].value;
465
+ }
466
+
433
467
  /**
434
468
  * Check if node is atob('...')
435
469
  */
@@ -1,9 +1,8 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { findFiles } = require('../utils.js');
3
+ const { findFiles, forEachSafeFile } = require('../utils.js');
4
4
 
5
5
  const ENTROPY_EXCLUDED_DIRS = ['.git', '.muaddib-cache', '__compiled__', '__tests__', '__test__', 'dist', 'build'];
6
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
7
6
 
8
7
  // File patterns to skip (compiled/minified/bundled)
9
8
  const SKIP_FILE_PATTERNS = ['.min.js', '.bundle.js', '.prod.js'];
@@ -203,29 +202,12 @@ function detectObfuscationPatterns(content, relativePath) {
203
202
  function scanEntropy(targetPath, options = {}) {
204
203
  const threats = [];
205
204
  const stringThreshold = options.entropyThreshold || STRING_ENTROPY_MEDIUM;
206
- const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
207
-
208
- for (const file of files) {
209
- // Skip files matching compiled/minified patterns
210
- if (shouldSkipFile(file)) continue;
211
-
212
- // Size guard
213
- try {
214
- const stat = fs.statSync(file);
215
- if (stat.size > MAX_FILE_SIZE) continue;
216
- } catch {
217
- continue;
218
- }
219
-
220
- let content;
221
- try {
222
- content = fs.readFileSync(file, 'utf8');
223
- } catch {
224
- continue;
225
- }
205
+ const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
226
206
 
207
+ const safeFiles = files.filter(f => !shouldSkipFile(f));
208
+ forEachSafeFile(safeFiles, (file, content) => {
227
209
  // Skip files containing source maps (legitimate compiled output)
228
- if (hasSourceMap(content)) continue;
210
+ if (hasSourceMap(content)) return;
229
211
 
230
212
  const relativePath = path.relative(targetPath, file);
231
213
 
@@ -252,7 +234,7 @@ function scanEntropy(targetPath, options = {}) {
252
234
  });
253
235
  }
254
236
  }
255
- }
237
+ });
256
238
 
257
239
  return threats;
258
240
  }
@@ -1,8 +1,9 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
+ const { MAX_FILE_SIZE } = require('../shared/constants.js');
5
+
4
6
  const YAML_EXTENSIONS = ['.yml', '.yaml'];
5
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
6
7
  const MAX_DEPTH = 10;
7
8
 
8
9
  function scanGitHubActions(targetPath) {
@@ -3,11 +3,11 @@ 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
7
 
7
8
  // Hash cache: filePath -> { hash, mtime }
8
9
  const hashCache = new Map();
9
10
  const MAX_CACHE_SIZE = 10000;
10
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
11
11
 
12
12
  async function scanHashes(targetPath) {
13
13
  const threats = [];
@@ -2,13 +2,13 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const acorn = require('acorn');
4
4
  const { findFiles } = require('../utils');
5
+ const { ACORN_OPTIONS: BASE_ACORN_OPTIONS } = require('../shared/constants.js');
5
6
 
6
7
  // --- Sensitive source patterns ---
7
8
  const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os']);
8
9
 
9
10
  const ACORN_OPTIONS = {
10
- ecmaVersion: 'latest',
11
- sourceType: 'module',
11
+ ...BASE_ACORN_OPTIONS,
12
12
  allowReturnOutsideFunction: true,
13
13
  allowImportExportEverywhere: true,
14
14
  };
@@ -32,7 +32,7 @@ const SINK_INSTANCE_METHODS = new Set(['connect', 'write', 'send']);
32
32
  function buildModuleGraph(packagePath) {
33
33
  const graph = {};
34
34
  const files = findFiles(packagePath, {
35
- extensions: ['.js'],
35
+ extensions: ['.js', '.mjs', '.cjs'],
36
36
  excludedDirs: ['node_modules', '.git'],
37
37
  });
38
38
  for (const absFile of files) {
@@ -1,4 +1,5 @@
1
1
  const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
2
+ const { debugLog } = require('../utils.js');
2
3
 
3
4
  const REGISTRY_URL = 'https://registry.npmjs.org';
4
5
  const DOWNLOADS_URL = 'https://api.npmjs.org/downloads/point/last-week';
@@ -44,13 +45,13 @@ async function fetchWithRetry(url) {
44
45
  // 404 = package doesn't exist
45
46
  if (response.status === 404) {
46
47
  // Drain response body to free resources
47
- try { await response.text(); } catch {}
48
+ try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
48
49
  return null;
49
50
  }
50
51
 
51
52
  // 429 = rate limit, respect Retry-After header (capped at 30s)
52
53
  if (response.status === 429) {
53
- try { await response.text(); } catch {}
54
+ try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
54
55
  const retryAfter = parseInt(response.headers.get('retry-after'), 10);
55
56
  const delay = Math.min(retryAfter && retryAfter > 0 ? retryAfter * 1000 : 2000, 30000);
56
57
  await new Promise(r => setTimeout(r, delay));
@@ -59,7 +60,7 @@ async function fetchWithRetry(url) {
59
60
 
60
61
  if (!response.ok) {
61
62
  // Drain response body on errors
62
- try { await response.text(); } catch {}
63
+ try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
63
64
  return null;
64
65
  }
65
66
 
@@ -1,30 +1,15 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { findFiles } = require('../utils.js');
3
+ const { findFiles, forEachSafeFile } = require('../utils.js');
4
4
 
5
5
  // node_modules NOT excluded: detect obfuscated code in dependencies
6
6
  const OBF_EXCLUDED_DIRS = ['.git', '.muaddib-cache'];
7
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
8
7
 
9
8
  function detectObfuscation(targetPath) {
10
9
  const threats = [];
11
- const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: OBF_EXCLUDED_DIRS });
12
-
13
- for (const file of files) {
14
- // Skip files exceeding MAX_FILE_SIZE to avoid memory issues
15
- try {
16
- const stat = fs.statSync(file);
17
- if (stat.size > MAX_FILE_SIZE) continue;
18
- } catch {
19
- continue;
20
- }
10
+ const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: OBF_EXCLUDED_DIRS });
21
11
 
22
- let content;
23
- try {
24
- content = fs.readFileSync(file, 'utf8');
25
- } catch {
26
- continue; // Skip unreadable files
27
- }
12
+ forEachSafeFile(files, (file, content) => {
28
13
  const relativePath = path.relative(targetPath, file);
29
14
 
30
15
  const signals = [];
@@ -96,7 +81,7 @@ function detectObfuscation(targetPath) {
96
81
  file: relativePath
97
82
  });
98
83
  }
99
- }
84
+ });
100
85
 
101
86
  return threats;
102
87
  }