muaddib-scanner 2.2.19 → 2.2.22

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.
@@ -1,96 +1,96 @@
1
- const path = require('path');
2
- const acorn = require('acorn');
3
- const walk = require('acorn-walk');
4
- const { ACORN_OPTIONS } = require('../shared/constants.js');
5
- const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
6
- const {
7
- handleVariableDeclarator,
8
- handleCallExpression,
9
- handleImportExpression,
10
- handleNewExpression,
11
- handleLiteral,
12
- handleAssignmentExpression,
13
- handleMemberExpression,
14
- handlePostWalk
15
- } = require('./ast-detectors.js');
16
-
17
- const EXCLUDED_FILES = [
18
- 'src/scanner/ast.js',
19
- 'src/scanner/shell.js',
20
- 'src/scanner/package.js',
21
- 'src/response/playbooks.js'
22
- ];
23
-
24
- async function analyzeAST(targetPath, options = {}) {
25
- return analyzeWithDeobfuscation(targetPath, analyzeFile, {
26
- deobfuscate: options.deobfuscate,
27
- excludedFiles: EXCLUDED_FILES
28
- });
29
- }
30
-
31
- function analyzeFile(content, filePath, basePath) {
32
- const threats = [];
33
- let ast;
34
-
35
- try {
36
- ast = acorn.parse(content, ACORN_OPTIONS);
37
- } catch {
38
- // AST parse failed — apply regex fallback for known dangerous patterns
39
-
40
- // Workflow manipulation: reads + writes to .github/workflows
41
- if (/\.github/.test(content) && /workflows/.test(content) &&
42
- /writeFileSync|writeFile/.test(content) &&
43
- /readdirSync|readFileSync/.test(content)) {
44
- threats.push({
45
- type: 'workflow_write',
46
- severity: 'CRITICAL',
47
- message: 'File reads and modifies .github/workflows — GitHub Actions injection (regex fallback).',
48
- file: path.relative(basePath, filePath)
49
- });
50
- }
51
-
52
- if (content.length > 1000 && content.split('\n').length < 10) {
53
- threats.push({
54
- type: 'possible_obfuscation',
55
- severity: 'MEDIUM',
56
- message: 'File difficult to parse, possibly obfuscated.',
57
- file: path.relative(basePath, filePath)
58
- });
59
- }
60
- return threats;
61
- }
62
-
63
- // Shared detection context
64
- const ctx = {
65
- threats,
66
- relFile: path.relative(basePath, filePath),
67
- dynamicRequireVars: new Set(),
68
- dangerousCmdVars: new Map(),
69
- workflowPathVars: new Set(),
70
- execPathVars: new Map(),
71
- globalThisAliases: new Set(),
72
- hasFromCharCode: content.includes('fromCharCode'),
73
- hasJsReverseShell: /\bnet\.Socket\b/.test(content) &&
74
- /\.connect\s*\(/.test(content) &&
75
- /\.pipe\b/.test(content) &&
76
- (/\bspawn\b/.test(content) || /\bstdin\b/.test(content) || /\bstdout\b/.test(content)),
77
- hasBinaryFileLiteral: /\.(png|jpg|jpeg|gif|bmp|ico|wasm)\b/i.test(content),
78
- hasEvalInFile: false
79
- };
80
-
81
- walk.simple(ast, {
82
- VariableDeclarator(node) { handleVariableDeclarator(node, ctx); },
83
- CallExpression(node) { handleCallExpression(node, ctx); },
84
- ImportExpression(node) { handleImportExpression(node, ctx); },
85
- NewExpression(node) { handleNewExpression(node, ctx); },
86
- Literal(node) { handleLiteral(node, ctx); },
87
- AssignmentExpression(node) { handleAssignmentExpression(node, ctx); },
88
- MemberExpression(node) { handleMemberExpression(node, ctx); }
89
- });
90
-
91
- handlePostWalk(ctx);
92
-
93
- return threats;
94
- }
95
-
96
- module.exports = { analyzeAST };
1
+ const path = require('path');
2
+ const acorn = require('acorn');
3
+ const walk = require('acorn-walk');
4
+ const { ACORN_OPTIONS } = require('../shared/constants.js');
5
+ const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
6
+ const {
7
+ handleVariableDeclarator,
8
+ handleCallExpression,
9
+ handleImportExpression,
10
+ handleNewExpression,
11
+ handleLiteral,
12
+ handleAssignmentExpression,
13
+ handleMemberExpression,
14
+ handlePostWalk
15
+ } = require('./ast-detectors.js');
16
+
17
+ const EXCLUDED_FILES = [
18
+ 'src/scanner/ast.js',
19
+ 'src/scanner/shell.js',
20
+ 'src/scanner/package.js',
21
+ 'src/response/playbooks.js'
22
+ ];
23
+
24
+ async function analyzeAST(targetPath, options = {}) {
25
+ return analyzeWithDeobfuscation(targetPath, analyzeFile, {
26
+ deobfuscate: options.deobfuscate,
27
+ excludedFiles: EXCLUDED_FILES
28
+ });
29
+ }
30
+
31
+ function analyzeFile(content, filePath, basePath) {
32
+ const threats = [];
33
+ let ast;
34
+
35
+ try {
36
+ ast = acorn.parse(content, ACORN_OPTIONS);
37
+ } catch {
38
+ // AST parse failed — apply regex fallback for known dangerous patterns
39
+
40
+ // Workflow manipulation: reads + writes to .github/workflows
41
+ if (/\.github/.test(content) && /workflows/.test(content) &&
42
+ /writeFileSync|writeFile/.test(content) &&
43
+ /readdirSync|readFileSync/.test(content)) {
44
+ threats.push({
45
+ type: 'workflow_write',
46
+ severity: 'CRITICAL',
47
+ message: 'File reads and modifies .github/workflows — GitHub Actions injection (regex fallback).',
48
+ file: path.relative(basePath, filePath)
49
+ });
50
+ }
51
+
52
+ if (content.length > 1000 && content.split(/\r?\n/).length < 10) {
53
+ threats.push({
54
+ type: 'possible_obfuscation',
55
+ severity: 'MEDIUM',
56
+ message: 'File difficult to parse, possibly obfuscated.',
57
+ file: path.relative(basePath, filePath)
58
+ });
59
+ }
60
+ return threats;
61
+ }
62
+
63
+ // Shared detection context
64
+ const ctx = {
65
+ threats,
66
+ relFile: path.relative(basePath, filePath),
67
+ dynamicRequireVars: new Set(),
68
+ dangerousCmdVars: new Map(),
69
+ workflowPathVars: new Set(),
70
+ execPathVars: new Map(),
71
+ globalThisAliases: new Set(),
72
+ hasFromCharCode: content.includes('fromCharCode'),
73
+ hasJsReverseShell: /\bnet\.Socket\b/.test(content) &&
74
+ /\.connect\s*\(/.test(content) &&
75
+ /\.pipe\b/.test(content) &&
76
+ (/\bspawn\b/.test(content) || /\bstdin\b/.test(content) || /\bstdout\b/.test(content)),
77
+ hasBinaryFileLiteral: /\.(png|jpg|jpeg|gif|bmp|ico|wasm)\b/i.test(content),
78
+ hasEvalInFile: false
79
+ };
80
+
81
+ walk.simple(ast, {
82
+ VariableDeclarator(node) { handleVariableDeclarator(node, ctx); },
83
+ CallExpression(node) { handleCallExpression(node, ctx); },
84
+ ImportExpression(node) { handleImportExpression(node, ctx); },
85
+ NewExpression(node) { handleNewExpression(node, ctx); },
86
+ Literal(node) { handleLiteral(node, ctx); },
87
+ AssignmentExpression(node) { handleAssignmentExpression(node, ctx); },
88
+ MemberExpression(node) { handleMemberExpression(node, ctx); }
89
+ });
90
+
91
+ handlePostWalk(ctx);
92
+
93
+ return threats;
94
+ }
95
+
96
+ module.exports = { analyzeAST };
@@ -1,96 +1,96 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
-
4
- const { MAX_FILE_SIZE } = require('../shared/constants.js');
5
-
6
- const YAML_EXTENSIONS = ['.yml', '.yaml'];
7
- const MAX_DEPTH = 10;
8
-
9
- function scanGitHubActions(targetPath) {
10
- const threats = [];
11
-
12
- // Scan both workflows and custom actions directories
13
- const dirsToScan = [
14
- path.join(targetPath, '.github', 'workflows'),
15
- path.join(targetPath, '.github', 'actions')
16
- ];
17
-
18
- for (const dirPath of dirsToScan) {
19
- if (!fs.existsSync(dirPath)) continue;
20
- scanDirRecursive(dirPath, targetPath, threats);
21
- }
22
-
23
- return threats;
24
- }
25
-
26
- function scanDirRecursive(dirPath, targetPath, threats, depth = 0) {
27
- if (depth > MAX_DEPTH) return;
28
- let files;
29
- try { files = fs.readdirSync(dirPath); } catch { return; }
30
- const relDir = path.relative(targetPath, dirPath).replace(/\\/g, '/');
31
-
32
- for (const file of files) {
33
- const filePath = path.join(dirPath, file);
34
-
35
- try {
36
- const stat = fs.lstatSync(filePath);
37
- if (stat.isSymbolicLink()) continue;
38
- if (stat.isDirectory()) {
39
- scanDirRecursive(filePath, targetPath, threats, depth + 1);
40
- continue;
41
- }
42
- if (!stat.isFile()) continue;
43
- if (stat.size > MAX_FILE_SIZE) continue;
44
- } catch {
45
- continue;
46
- }
47
-
48
- // Only process YAML files
49
- if (!YAML_EXTENSIONS.some(ext => file.endsWith(ext))) continue;
50
-
51
- let content;
52
- try {
53
- content = fs.readFileSync(filePath, 'utf8');
54
- } catch {
55
- continue;
56
- }
57
-
58
- const relFile = `${relDir}/${file}`;
59
-
60
- // GHA-001: Line-by-line YAML-aware parsing (skip comments)
61
- const yamlLines = content.split('\n');
62
- const activeLines = yamlLines.filter(l => !l.trim().startsWith('#'));
63
- const activeContent = activeLines.join('\n');
64
-
65
- // Détection du backdoor Shai-Hulud discussion.yaml
66
- if (file === 'discussion.yaml' || file === 'discussion.yml') {
67
- if (activeContent.includes('github.event.discussion.body')) {
68
- threats.push({
69
- type: 'shai_hulud_backdoor',
70
- severity: 'CRITICAL',
71
- message: 'Backdoor Shai-Hulud détecté: workflow discussion.yaml avec injection via discussion body',
72
- file: relFile
73
- });
74
- }
75
- }
76
-
77
- // GHA-002: Detect attacker-controlled context injection on ALL runners (not just self-hosted)
78
- const injectionPatterns = [
79
- { regex: /\$\{\{\s*github\.event\.(comment\.body|issue\.body|issue\.title|pull_request\.body|pull_request\.title|discussion\.body|discussion\.title)/, msg: 'Attacker-controlled GitHub event context used in workflow' },
80
- { regex: /\$\{\{\s*github\.head_ref/, msg: 'github.head_ref is attacker-controlled in pull_request workflows' }
81
- ];
82
-
83
- for (const { regex, msg } of injectionPatterns) {
84
- if (regex.test(activeContent)) {
85
- threats.push({
86
- type: 'workflow_injection',
87
- severity: 'HIGH',
88
- message: 'Potential injection: ' + msg,
89
- file: relFile
90
- });
91
- }
92
- }
93
- }
94
- }
95
-
96
- module.exports = { scanGitHubActions };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const { MAX_FILE_SIZE } = require('../shared/constants.js');
5
+
6
+ const YAML_EXTENSIONS = ['.yml', '.yaml'];
7
+ const MAX_DEPTH = 10;
8
+
9
+ function scanGitHubActions(targetPath) {
10
+ const threats = [];
11
+
12
+ // Scan both workflows and custom actions directories
13
+ const dirsToScan = [
14
+ path.join(targetPath, '.github', 'workflows'),
15
+ path.join(targetPath, '.github', 'actions')
16
+ ];
17
+
18
+ for (const dirPath of dirsToScan) {
19
+ if (!fs.existsSync(dirPath)) continue;
20
+ scanDirRecursive(dirPath, targetPath, threats);
21
+ }
22
+
23
+ return threats;
24
+ }
25
+
26
+ function scanDirRecursive(dirPath, targetPath, threats, depth = 0) {
27
+ if (depth > MAX_DEPTH) return;
28
+ let files;
29
+ try { files = fs.readdirSync(dirPath); } catch { return; }
30
+ const relDir = path.relative(targetPath, dirPath).replace(/\\/g, '/');
31
+
32
+ for (const file of files) {
33
+ const filePath = path.join(dirPath, file);
34
+
35
+ try {
36
+ const stat = fs.lstatSync(filePath);
37
+ if (stat.isSymbolicLink()) continue;
38
+ if (stat.isDirectory()) {
39
+ scanDirRecursive(filePath, targetPath, threats, depth + 1);
40
+ continue;
41
+ }
42
+ if (!stat.isFile()) continue;
43
+ if (stat.size > MAX_FILE_SIZE) continue;
44
+ } catch {
45
+ continue;
46
+ }
47
+
48
+ // Only process YAML files
49
+ if (!YAML_EXTENSIONS.some(ext => file.endsWith(ext))) continue;
50
+
51
+ let content;
52
+ try {
53
+ content = fs.readFileSync(filePath, 'utf8');
54
+ } catch {
55
+ continue;
56
+ }
57
+
58
+ const relFile = `${relDir}/${file}`;
59
+
60
+ // GHA-001: Line-by-line YAML-aware parsing (skip comments)
61
+ const yamlLines = content.split(/\r?\n/);
62
+ const activeLines = yamlLines.filter(l => !l.trim().startsWith('#'));
63
+ const activeContent = activeLines.join('\n');
64
+
65
+ // Détection du backdoor Shai-Hulud discussion.yaml
66
+ if (file === 'discussion.yaml' || file === 'discussion.yml') {
67
+ if (activeContent.includes('github.event.discussion.body')) {
68
+ threats.push({
69
+ type: 'shai_hulud_backdoor',
70
+ severity: 'CRITICAL',
71
+ message: 'Backdoor Shai-Hulud détecté: workflow discussion.yaml avec injection via discussion body',
72
+ file: relFile
73
+ });
74
+ }
75
+ }
76
+
77
+ // GHA-002: Detect attacker-controlled context injection on ALL runners (not just self-hosted)
78
+ const injectionPatterns = [
79
+ { regex: /\$\{\{\s*github\.event\.(comment\.body|issue\.body|issue\.title|pull_request\.body|pull_request\.title|discussion\.body|discussion\.title)/, msg: 'Attacker-controlled GitHub event context used in workflow' },
80
+ { regex: /\$\{\{\s*github\.head_ref/, msg: 'github.head_ref is attacker-controlled in pull_request workflows' }
81
+ ];
82
+
83
+ for (const { regex, msg } of injectionPatterns) {
84
+ if (regex.test(activeContent)) {
85
+ threats.push({
86
+ type: 'workflow_injection',
87
+ severity: 'HIGH',
88
+ message: 'Potential injection: ' + msg,
89
+ file: relFile
90
+ });
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ module.exports = { scanGitHubActions };
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const acorn = require('acorn');
4
- const { findFiles } = require('../utils');
4
+ const { findFiles, EXCLUDED_DIRS } = require('../utils');
5
5
  const { ACORN_OPTIONS: BASE_ACORN_OPTIONS } = require('../shared/constants.js');
6
6
 
7
7
  // --- Sensitive source patterns ---
@@ -33,7 +33,7 @@ function buildModuleGraph(packagePath) {
33
33
  const graph = {};
34
34
  const files = findFiles(packagePath, {
35
35
  extensions: ['.js', '.mjs', '.cjs'],
36
- excludedDirs: ['node_modules', '.git'],
36
+ excludedDirs: EXCLUDED_DIRS,
37
37
  });
38
38
  for (const absFile of files) {
39
39
  const relFile = toRel(absFile, packagePath);
@@ -1,129 +1,129 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { findFiles, forEachSafeFile } = require('../utils.js');
4
-
5
- // node_modules NOT excluded: detect obfuscated code in dependencies
6
- const OBF_EXCLUDED_DIRS = ['.git', '.muaddib-cache'];
7
-
8
- function detectObfuscation(targetPath) {
9
- const threats = [];
10
- const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: OBF_EXCLUDED_DIRS });
11
-
12
- forEachSafeFile(files, (file, content) => {
13
- const relativePath = path.relative(targetPath, file);
14
-
15
- const signals = [];
16
- let score = 0;
17
- const basename = path.basename(file);
18
- const isMinified = basename.endsWith('.min.js');
19
- const isBundled = basename.endsWith('.bundle.js');
20
- const pathParts = relativePath.split(path.sep);
21
- const isInDistOrBuild = pathParts.some(p => p === 'dist' || p === 'build');
22
- const isPackageOutput = isMinified || isBundled || isInDistOrBuild;
23
-
24
- // 1. Ratio code sur une seule ligne (skip .min.js — minification, not obfuscation)
25
- if (!isMinified) {
26
- const lines = content.split('\n').filter(l => l.trim());
27
- const longLines = lines.filter(l => l.length > 500);
28
- if (lines.length > 0 && longLines.length / lines.length > 0.3) {
29
- score += 25;
30
- signals.push('long_single_lines');
31
- }
32
- }
33
-
34
- // 2. Hex escapes massifs (tracked but only scored with corroborating signals)
35
- let hexScore = 0;
36
- const hexCount = countMatches(content, /\\x[0-9a-fA-F]{2}/g);
37
- if (hexCount > 20) {
38
- hexScore = 25;
39
- signals.push('hex_escapes');
40
- }
41
-
42
- // 3. Unicode escapes massifs (tracked but only scored with corroborating signals)
43
- let unicodeScore = 0;
44
- const unicodeCount = countMatches(content, /\\u[0-9a-fA-F]{4}/g);
45
- if (unicodeCount > 20) {
46
- unicodeScore = 20;
47
- signals.push('unicode_escapes');
48
- }
49
-
50
- // 4. Variables style obfuscateur (_0x, _0xabc)
51
- const obfVarCount = countMatches(content, /\b_0x[a-f0-9]+\b/gi);
52
- if (obfVarCount > 5) {
53
- score += 30;
54
- signals.push('obfuscated_variables');
55
- }
56
-
57
- // 5. String arrays suspects (programmatic check to avoid ReDoS)
58
- if (hasLargeStringArray(content)) {
59
- score += 25;
60
- signals.push('string_array');
61
- }
62
-
63
- // 6. atob/btoa avec eval
64
- if (/atob\s*\(/.test(content) && /(eval|Function)\s*\(/.test(content)) {
65
- score += 30;
66
- signals.push('base64_eval');
67
- }
68
-
69
- // Hex/unicode escapes alone are not obfuscation (e.g. lodash Unicode char tables).
70
- // Only count them when combined with strong obfuscation signals.
71
- const hasStrongSignals = signals.some(s => s !== 'hex_escapes' && s !== 'unicode_escapes');
72
- if (hasStrongSignals) {
73
- score += hexScore + unicodeScore;
74
- }
75
-
76
- if (score >= 40) {
77
- threats.push({
78
- type: 'obfuscation_detected',
79
- severity: isPackageOutput ? 'LOW' : (score >= 70 ? 'CRITICAL' : 'HIGH'),
80
- message: `Code obfusque (score: ${score}). Signaux: ${signals.join(', ')}`,
81
- file: relativePath
82
- });
83
- }
84
- });
85
-
86
- return threats;
87
- }
88
-
89
- /**
90
- * Count regex matches without creating a full match array (avoids memory spikes on large files).
91
- */
92
- function countMatches(str, regex) {
93
- let count = 0;
94
- while (regex.exec(str) !== null) count++;
95
- return count;
96
- }
97
-
98
- /**
99
- * Programmatic check for large string arrays (avoids ReDoS from nested regex quantifiers).
100
- * Detects patterns like: var x = ["a", "b", "c", ...] with 10+ quoted items.
101
- */
102
- function hasLargeStringArray(content) {
103
- const lines = content.split('\n');
104
- for (const line of lines) {
105
- const varIdx = line.indexOf('var ');
106
- if (varIdx === -1) continue;
107
- const bracketIdx = line.indexOf('[', varIdx);
108
- if (bracketIdx === -1) continue;
109
- const closeBracketIdx = line.indexOf(']', bracketIdx);
110
- if (closeBracketIdx === -1) continue;
111
- const segment = line.slice(bracketIdx, closeBracketIdx + 1);
112
- // Count quoted strings in the segment
113
- let count = 0;
114
- for (let i = 0; i < segment.length; i++) {
115
- if (segment[i] === '"' || segment[i] === "'") {
116
- const quote = segment[i];
117
- const end = segment.indexOf(quote, i + 1);
118
- if (end !== -1 && end - i - 1 <= 50) {
119
- count++;
120
- i = end;
121
- }
122
- }
123
- }
124
- if (count >= 10) return true;
125
- }
126
- return false;
127
- }
128
-
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { findFiles, forEachSafeFile } = require('../utils.js');
4
+
5
+ // node_modules NOT excluded: detect obfuscated code in dependencies
6
+ const OBF_EXCLUDED_DIRS = ['.git', '.muaddib-cache'];
7
+
8
+ function detectObfuscation(targetPath) {
9
+ const threats = [];
10
+ const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: OBF_EXCLUDED_DIRS });
11
+
12
+ forEachSafeFile(files, (file, content) => {
13
+ const relativePath = path.relative(targetPath, file);
14
+
15
+ const signals = [];
16
+ let score = 0;
17
+ const basename = path.basename(file);
18
+ const isMinified = basename.endsWith('.min.js');
19
+ const isBundled = basename.endsWith('.bundle.js');
20
+ const pathParts = relativePath.split(path.sep);
21
+ const isInDistOrBuild = pathParts.some(p => p === 'dist' || p === 'build');
22
+ const isPackageOutput = isMinified || isBundled || isInDistOrBuild;
23
+
24
+ // 1. Ratio code sur une seule ligne (skip .min.js — minification, not obfuscation)
25
+ if (!isMinified) {
26
+ const lines = content.split(/\r?\n/).filter(l => l.trim());
27
+ const longLines = lines.filter(l => l.length > 500);
28
+ if (lines.length > 0 && longLines.length / lines.length > 0.3) {
29
+ score += 25;
30
+ signals.push('long_single_lines');
31
+ }
32
+ }
33
+
34
+ // 2. Hex escapes massifs (tracked but only scored with corroborating signals)
35
+ let hexScore = 0;
36
+ const hexCount = countMatches(content, /\\x[0-9a-fA-F]{2}/g);
37
+ if (hexCount > 20) {
38
+ hexScore = 25;
39
+ signals.push('hex_escapes');
40
+ }
41
+
42
+ // 3. Unicode escapes massifs (tracked but only scored with corroborating signals)
43
+ let unicodeScore = 0;
44
+ const unicodeCount = countMatches(content, /\\u[0-9a-fA-F]{4}/g);
45
+ if (unicodeCount > 20) {
46
+ unicodeScore = 20;
47
+ signals.push('unicode_escapes');
48
+ }
49
+
50
+ // 4. Variables style obfuscateur (_0x, _0xabc)
51
+ const obfVarCount = countMatches(content, /\b_0x[a-f0-9]+\b/gi);
52
+ if (obfVarCount > 5) {
53
+ score += 30;
54
+ signals.push('obfuscated_variables');
55
+ }
56
+
57
+ // 5. String arrays suspects (programmatic check to avoid ReDoS)
58
+ if (hasLargeStringArray(content)) {
59
+ score += 25;
60
+ signals.push('string_array');
61
+ }
62
+
63
+ // 6. atob/btoa avec eval
64
+ if (/atob\s*\(/.test(content) && /(eval|Function)\s*\(/.test(content)) {
65
+ score += 30;
66
+ signals.push('base64_eval');
67
+ }
68
+
69
+ // Hex/unicode escapes alone are not obfuscation (e.g. lodash Unicode char tables).
70
+ // Only count them when combined with strong obfuscation signals.
71
+ const hasStrongSignals = signals.some(s => s !== 'hex_escapes' && s !== 'unicode_escapes');
72
+ if (hasStrongSignals) {
73
+ score += hexScore + unicodeScore;
74
+ }
75
+
76
+ if (score >= 40) {
77
+ threats.push({
78
+ type: 'obfuscation_detected',
79
+ severity: isPackageOutput ? 'LOW' : (score >= 70 ? 'CRITICAL' : 'HIGH'),
80
+ message: `Code obfusque (score: ${score}). Signaux: ${signals.join(', ')}`,
81
+ file: relativePath
82
+ });
83
+ }
84
+ });
85
+
86
+ return threats;
87
+ }
88
+
89
+ /**
90
+ * Count regex matches without creating a full match array (avoids memory spikes on large files).
91
+ */
92
+ function countMatches(str, regex) {
93
+ let count = 0;
94
+ while (regex.exec(str) !== null) count++;
95
+ return count;
96
+ }
97
+
98
+ /**
99
+ * Programmatic check for large string arrays (avoids ReDoS from nested regex quantifiers).
100
+ * Detects patterns like: var x = ["a", "b", "c", ...] with 10+ quoted items.
101
+ */
102
+ function hasLargeStringArray(content) {
103
+ const lines = content.split(/\r?\n/);
104
+ for (const line of lines) {
105
+ const varIdx = line.indexOf('var ');
106
+ if (varIdx === -1) continue;
107
+ const bracketIdx = line.indexOf('[', varIdx);
108
+ if (bracketIdx === -1) continue;
109
+ const closeBracketIdx = line.indexOf(']', bracketIdx);
110
+ if (closeBracketIdx === -1) continue;
111
+ const segment = line.slice(bracketIdx, closeBracketIdx + 1);
112
+ // Count quoted strings in the segment
113
+ let count = 0;
114
+ for (let i = 0; i < segment.length; i++) {
115
+ if (segment[i] === '"' || segment[i] === "'") {
116
+ const quote = segment[i];
117
+ const end = segment.indexOf(quote, i + 1);
118
+ if (end !== -1 && end - i - 1 <= 50) {
119
+ count++;
120
+ i = end;
121
+ }
122
+ }
123
+ }
124
+ if (count >= 10) return true;
125
+ }
126
+ return false;
127
+ }
128
+
129
129
  module.exports = { detectObfuscation };