muaddib-scanner 2.10.21 → 2.10.23
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/README.md +11 -11
- package/bin/muaddib.js +365 -39
- package/package.json +1 -1
- package/scripts/benchmark.js +326 -0
- package/src/index.js +34 -3
- package/src/ioc/scraper.js +4 -141
- package/src/ioc/updater.js +26 -7
- package/src/response/playbooks.js +112 -0
- package/src/rules/index.js +324 -1
- package/src/scanner/ast-detectors.js +744 -12
- package/src/scanner/ast.js +62 -1
- package/src/scanner/package.js +70 -0
- package/src/scanner/shell.js +5 -5
- package/src/scoring.js +76 -6
- package/src/shared/constants.js +5 -5
- package/src/utils.js +8 -0
package/src/scanner/ast.js
CHANGED
|
@@ -75,6 +75,32 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
75
75
|
file: path.relative(basePath, filePath)
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
// Blue Team v8b (A6): Detect Proxy + require('child_process') + exec in files that fail to parse
|
|
80
|
+
// This covers 'use strict' + with(Proxy) evasion where acorn can't parse the with statement
|
|
81
|
+
if (/\bnew\s+Proxy\b/.test(content) && /\brequire\s*\(\s*['"]child_process['"]\s*\)/.test(content)) {
|
|
82
|
+
const hasExecInContent = /\bexec\s*\(/.test(content) || /\bexecSync\s*\(/.test(content) || /\bspawn\s*\(/.test(content);
|
|
83
|
+
if (hasExecInContent) {
|
|
84
|
+
threats.push({
|
|
85
|
+
type: 'dangerous_exec',
|
|
86
|
+
severity: 'CRITICAL',
|
|
87
|
+
message: 'Proxy + require(\'child_process\') + exec in unparseable file — scope hijack evasion (regex fallback).',
|
|
88
|
+
file: path.relative(basePath, filePath)
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Content-level: require('child_process') + exec/spawn with shell command patterns
|
|
94
|
+
if (/\brequire\s*\(\s*['"]child_process['"]\s*\)/.test(content) &&
|
|
95
|
+
/\bcurl\b.*\|\s*(sh|bash)\b/.test(content)) {
|
|
96
|
+
threats.push({
|
|
97
|
+
type: 'dangerous_exec',
|
|
98
|
+
severity: 'CRITICAL',
|
|
99
|
+
message: 'require(\'child_process\') + curl pipe to shell in unparseable file — remote code execution (regex fallback).',
|
|
100
|
+
file: path.relative(basePath, filePath)
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
78
104
|
return threats;
|
|
79
105
|
}
|
|
80
106
|
|
|
@@ -180,7 +206,42 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
180
206
|
hasSolanaImport: false,
|
|
181
207
|
hasSolanaC2Method: false,
|
|
182
208
|
// Audit v3: uncaughtException/unhandledRejection handler for error hijacking detection
|
|
183
|
-
hasUncaughtExceptionHandler: false
|
|
209
|
+
hasUncaughtExceptionHandler: false,
|
|
210
|
+
// Audit v3 B2: FinalizationRegistry deferred exec detection
|
|
211
|
+
hasFinalizationRegistry: false,
|
|
212
|
+
// Blue Team v8: SharedArrayBuffer + Worker IPC detection
|
|
213
|
+
hasSharedArrayBuffer: false,
|
|
214
|
+
hasWorkerThread: false, // set when Worker (worker_threads) usage detected
|
|
215
|
+
// Blue Team v8: dgram/UDP exfiltration
|
|
216
|
+
hasDgramImport: /\brequire\s*\(\s*['"](?:node:)?dgram['"]\s*\)/.test(content),
|
|
217
|
+
hasDgramSend: false,
|
|
218
|
+
// Blue Team v8: WebSocket C2
|
|
219
|
+
hasWebSocketNew: false, // set when new WebSocket() detected
|
|
220
|
+
// Blue Team v8: crontab/cron write detection
|
|
221
|
+
hasCrontabWrite: false,
|
|
222
|
+
// Blue Team v8b: Module internals hijack (Module._resolveFilename, _compile, _extensions)
|
|
223
|
+
hasModuleInternalsHijack: false,
|
|
224
|
+
// Blue Team v8b: JSON.parse reviver with __proto__ check
|
|
225
|
+
hasJsonReviverProto: false,
|
|
226
|
+
// Blue Team v8b: vm.runInContext/runInNewContext with dynamic code
|
|
227
|
+
hasVmDynamicExec: false,
|
|
228
|
+
// Blue Team v8b: binary file read + new Function/eval in same file (stego)
|
|
229
|
+
hasBinaryFileRead: false, // set when fs.readFileSync on .png/.jpg/.gif/.bmp/.ico
|
|
230
|
+
// Blue Team v8b: AsyncLocalStorage usage
|
|
231
|
+
hasAsyncLocalStorage: /\bAsyncLocalStorage\b/.test(content),
|
|
232
|
+
// Blue Team v8b: image file reference for stego detection
|
|
233
|
+
hasImageFileRef: /\.(png|jpg|jpeg|gif|bmp|ico)\b/i.test(content),
|
|
234
|
+
// Blue Team v8b: net.Socket creation (for WebSocket C2 detection)
|
|
235
|
+
hasNetSocketCreate: /\bnew\s+net\.Socket\b/.test(content) || /\bnet\.createConnection\b/.test(content),
|
|
236
|
+
// Blue Team v8b: execSync/exec in callback contexts (set when exec inside .on('message'|'data'))
|
|
237
|
+
hasCallbackExec: false,
|
|
238
|
+
// Blue Team v8b (B2): CI environment fingerprinting — count of CI provider env vars referenced
|
|
239
|
+
ciProviderCount: (() => {
|
|
240
|
+
const CI_VARS = ['GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'TRAVIS', 'JENKINS_URL', 'BUILDKITE', 'CONTINUOUS_INTEGRATION', 'TEAMCITY_VERSION', 'CODEBUILD_BUILD_ID', 'BITBUCKET_PIPELINE_UUID'];
|
|
241
|
+
return CI_VARS.filter(v => content.includes(v)).length;
|
|
242
|
+
})(),
|
|
243
|
+
// Audit v3: source code reference for callback body analysis
|
|
244
|
+
_sourceCode: content
|
|
184
245
|
};
|
|
185
246
|
|
|
186
247
|
// Compute fetchOnlySafeDomains: check if ALL URLs in file point to known registries
|
package/src/scanner/package.js
CHANGED
|
@@ -125,6 +125,25 @@ async function scanPackageJson(targetPath) {
|
|
|
125
125
|
file: 'package.json'
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
// Blue Team v8b (B8): Lifecycle script references non-existent file in package
|
|
130
|
+
// Pattern: "node path/to/script.js" where the file does not exist — phantom install script
|
|
131
|
+
// Strong signal: preinstall/install scripts pointing to missing files can't be build artifacts
|
|
132
|
+
if (['preinstall', 'install', 'postinstall'].includes(scriptName)) {
|
|
133
|
+
const nodeFileMatch = scriptContent.match(/^node\s+(\S+)/);
|
|
134
|
+
if (nodeFileMatch) {
|
|
135
|
+
const scriptFile = nodeFileMatch[1];
|
|
136
|
+
const fullScriptPath = path.join(targetPath, scriptFile);
|
|
137
|
+
if (!fs.existsSync(fullScriptPath) && !fs.existsSync(fullScriptPath + '.js')) {
|
|
138
|
+
threats.push({
|
|
139
|
+
type: 'lifecycle_missing_script',
|
|
140
|
+
severity: scriptName === 'postinstall' ? 'HIGH' : 'CRITICAL',
|
|
141
|
+
message: `Lifecycle "${scriptName}" references "${scriptFile}" which does not exist in the package — phantom install script, payload may be injected at publish time.`,
|
|
142
|
+
file: 'package.json'
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
128
147
|
}
|
|
129
148
|
}
|
|
130
149
|
|
|
@@ -181,6 +200,57 @@ async function scanPackageJson(targetPath) {
|
|
|
181
200
|
} catch { /* permission error */ }
|
|
182
201
|
}
|
|
183
202
|
|
|
203
|
+
// Blue Team v8: binding.gyp + lifecycle script = native addon install risk
|
|
204
|
+
// binding.gyp triggers node-gyp compilation during install. Combined with lifecycle scripts
|
|
205
|
+
// that aren't standard node-gyp build tools, this indicates potentially malicious native code.
|
|
206
|
+
const bindingGypPath = path.join(targetPath, 'binding.gyp');
|
|
207
|
+
if (fs.existsSync(bindingGypPath)) {
|
|
208
|
+
const hasInstallLifecycle = ['preinstall', 'install', 'postinstall'].some(s => scripts[s]);
|
|
209
|
+
const installScript = scripts.install || scripts.postinstall || scripts.preinstall || '';
|
|
210
|
+
// node-gyp rebuild / prebuild-install / cmake-js are legitimate native addon builders
|
|
211
|
+
const isStandardBuild = /\b(node-gyp|prebuild|cmake-js|napi|prebuildify|neon)\b/i.test(installScript);
|
|
212
|
+
|
|
213
|
+
// Blue Team v8b (C7): Check binding.gyp content for shell commands in actions
|
|
214
|
+
let gypContent = '';
|
|
215
|
+
try { gypContent = fs.readFileSync(bindingGypPath, 'utf8'); } catch {}
|
|
216
|
+
const hasShellActions = /\baction\b.*\bsh\b/.test(gypContent) || /\bcurl\b/.test(gypContent) ||
|
|
217
|
+
/\bwget\b/.test(gypContent) || /\$\(whoami\)/.test(gypContent) || /\$\(uname/.test(gypContent);
|
|
218
|
+
// Check if binding.gyp references C/C++ source files
|
|
219
|
+
const hasNativeSources = /\.(c|cc|cpp|cxx|h|hpp)\b/.test(gypContent);
|
|
220
|
+
|
|
221
|
+
if (hasShellActions) {
|
|
222
|
+
threats.push({
|
|
223
|
+
type: 'native_addon_install',
|
|
224
|
+
severity: 'CRITICAL',
|
|
225
|
+
message: `binding.gyp contains shell commands in build actions (curl/sh/whoami) — build-time code execution and exfiltration.`,
|
|
226
|
+
file: 'binding.gyp'
|
|
227
|
+
});
|
|
228
|
+
} else if (hasInstallLifecycle && !isStandardBuild) {
|
|
229
|
+
threats.push({
|
|
230
|
+
type: 'native_addon_install',
|
|
231
|
+
severity: 'HIGH',
|
|
232
|
+
message: `binding.gyp present with non-standard lifecycle script: "${installScript.substring(0, 100)}" — potential malicious native compilation.`,
|
|
233
|
+
file: 'package.json'
|
|
234
|
+
});
|
|
235
|
+
} else if (hasInstallLifecycle && hasNativeSources) {
|
|
236
|
+
// Standard build but with native C/C++ sources — HIGH (native code is opaque)
|
|
237
|
+
threats.push({
|
|
238
|
+
type: 'native_addon_install',
|
|
239
|
+
severity: 'HIGH',
|
|
240
|
+
message: `binding.gyp with C/C++ source files + lifecycle script — native addon compilation. Native code is opaque to static analysis.`,
|
|
241
|
+
file: 'package.json'
|
|
242
|
+
});
|
|
243
|
+
} else if (hasInstallLifecycle) {
|
|
244
|
+
// Standard build tool — informational only
|
|
245
|
+
threats.push({
|
|
246
|
+
type: 'native_addon_install',
|
|
247
|
+
severity: 'LOW',
|
|
248
|
+
message: 'binding.gyp with standard build tool (node-gyp/prebuild) in lifecycle script — legitimate native addon.',
|
|
249
|
+
file: 'package.json'
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
184
254
|
// Scan declared dependencies against IOCs
|
|
185
255
|
let iocs;
|
|
186
256
|
try {
|
package/src/scanner/shell.js
CHANGED
|
@@ -6,15 +6,15 @@ const { MAX_FILE_SIZE, getMaxFileSize } = require('../shared/constants.js');
|
|
|
6
6
|
const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
|
|
7
7
|
|
|
8
8
|
const MALICIOUS_PATTERNS = [
|
|
9
|
-
{ pattern: /curl
|
|
10
|
-
{ pattern: /wget
|
|
9
|
+
{ pattern: /curl[^\n]{0,5000}\|[^\n]{0,5000}sh/m, name: 'curl_pipe_shell', severity: 'HIGH' },
|
|
10
|
+
{ pattern: /wget[^\n]{0,5000}&&[^\n]{0,5000}chmod[^\n]{0,5000}\+x/m, name: 'wget_chmod_exec', severity: 'HIGH' },
|
|
11
11
|
{ pattern: /bash\s+-i\s+>&\s+\/dev\/tcp/m, name: 'reverse_shell', severity: 'CRITICAL' },
|
|
12
12
|
{ pattern: /nc\s+-e\s+\/bin\/(ba)?sh/m, name: 'netcat_shell', severity: 'CRITICAL' },
|
|
13
13
|
{ pattern: /rm\s+-rf\s+(~\/|\$HOME|\/home)/m, name: 'home_deletion', severity: 'CRITICAL' },
|
|
14
14
|
{ pattern: /shred.*\$HOME/m, name: 'shred_home', severity: 'CRITICAL' },
|
|
15
|
-
{ pattern: /curl
|
|
16
|
-
{ pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s
|
|
17
|
-
{ pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s
|
|
15
|
+
{ pattern: /curl[^\n]{0,5000}-X\s*POST[^\n]{0,5000}-d/m, name: 'curl_exfiltration', severity: 'HIGH' },
|
|
16
|
+
{ pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+[^\n]{0,5000}|scp\s+)[^\n]{0,5000}\.npmrc/m, name: 'npmrc_access', severity: 'HIGH' },
|
|
17
|
+
{ pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+[^\n]{0,5000}|scp\s+)[^\n]{0,5000}\.ssh/m, name: 'ssh_access', severity: 'HIGH' },
|
|
18
18
|
{ pattern: /python\s+-c.*import\s+socket/m, name: 'python_reverse_shell', severity: 'CRITICAL' },
|
|
19
19
|
{ pattern: /perl\s+-e.*socket/m, name: 'perl_reverse_shell', severity: 'CRITICAL' },
|
|
20
20
|
{ pattern: /mkfifo.*\/dev\/tcp/m, name: 'fifo_reverse_shell', severity: 'CRITICAL' },
|
package/src/scoring.js
CHANGED
|
@@ -102,13 +102,17 @@ const PACKAGE_LEVEL_TYPES = new Set([
|
|
|
102
102
|
'shai_hulud_marker', 'suspicious_file',
|
|
103
103
|
'pypi_malicious_package', 'pypi_typosquat_detected',
|
|
104
104
|
'dangerous_api_added_critical', 'dangerous_api_added_high', 'dangerous_api_added_medium',
|
|
105
|
-
'publish_burst', '
|
|
106
|
-
'
|
|
105
|
+
'publish_burst', 'dormant_spike', 'rapid_succession',
|
|
106
|
+
'suspicious_maintainer', 'sole_maintainer_change',
|
|
107
107
|
'sandbox_network_activity', 'sandbox_file_changes', 'sandbox_process_spawns',
|
|
108
108
|
'sandbox_canary_exfiltration',
|
|
109
109
|
// Compound scoring rules — package-level co-occurrences
|
|
110
110
|
'lifecycle_typosquat', 'lifecycle_inline_exec', 'lifecycle_remote_require',
|
|
111
|
-
'lifecycle_dataflow', 'lifecycle_dangerous_exec', 'obfuscated_lifecycle_env'
|
|
111
|
+
'lifecycle_dataflow', 'lifecycle_dangerous_exec', 'obfuscated_lifecycle_env',
|
|
112
|
+
// Blue Team v8: package-level boost signals
|
|
113
|
+
'isolated_suspicious_file', 'deep_suspicious_file',
|
|
114
|
+
// Blue Team v8b: phantom lifecycle scripts
|
|
115
|
+
'lifecycle_missing_script'
|
|
112
116
|
]);
|
|
113
117
|
|
|
114
118
|
/**
|
|
@@ -299,7 +303,8 @@ const REACHABILITY_EXEMPT_TYPES = new Set([
|
|
|
299
303
|
'typosquat_detected', 'pypi_typosquat_detected',
|
|
300
304
|
'pypi_malicious_package',
|
|
301
305
|
'ai_config_injection', 'ai_config_injection_compound',
|
|
302
|
-
'detached_credential_exfil' // DPRK/Lazarus: invoked via lifecycle, not require/import
|
|
306
|
+
'detached_credential_exfil', // DPRK/Lazarus: invoked via lifecycle, not require/import
|
|
307
|
+
'native_addon_install' // binding.gyp executes during npm install but isn't require()'d
|
|
303
308
|
]);
|
|
304
309
|
|
|
305
310
|
// ============================================
|
|
@@ -728,6 +733,50 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
728
733
|
}
|
|
729
734
|
}
|
|
730
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Blue Team v8: Inject package-level boost threats that detect dissimulation patterns.
|
|
738
|
+
* Called within calculateRiskScore after file scores are computed.
|
|
739
|
+
* @param {Array} deduped - deduplicated threat array (mutated in place)
|
|
740
|
+
* @param {Object} fileScores - map of file → score
|
|
741
|
+
* @param {Map} fileGroups - map of file → threats array
|
|
742
|
+
* @param {Array} packageLevelThreats - package-level threats
|
|
743
|
+
*/
|
|
744
|
+
function applyPackageLevelBoosts(deduped, fileScores, fileGroups, packageLevelThreats) {
|
|
745
|
+
const fileNames = Object.keys(fileScores);
|
|
746
|
+
const totalFiles = fileNames.length;
|
|
747
|
+
|
|
748
|
+
// 1. isolated_suspicious_file: exactly 1 file has score > 0, 10+ files have score 0
|
|
749
|
+
if (totalFiles >= 10) {
|
|
750
|
+
const filesWithScore = fileNames.filter(f => fileScores[f] > 0);
|
|
751
|
+
const filesWithZero = totalFiles - filesWithScore.length;
|
|
752
|
+
if (filesWithScore.length === 1 && filesWithZero >= 10) {
|
|
753
|
+
deduped.push({
|
|
754
|
+
type: 'isolated_suspicious_file',
|
|
755
|
+
severity: 'MEDIUM',
|
|
756
|
+
message: `Single suspicious file among ${totalFiles} files — potential dissimulation pattern (malicious code hidden in clean package).`,
|
|
757
|
+
file: filesWithScore[0],
|
|
758
|
+
boostSignal: true
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// 2. deep_suspicious_file: finding in a file at depth > 3 from package root
|
|
764
|
+
for (const [file, threats] of fileGroups) {
|
|
765
|
+
if (!file || file === '(unknown)') continue;
|
|
766
|
+
const segments = file.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
767
|
+
if (segments.length > 3 && threats.some(t => t.severity !== 'LOW')) {
|
|
768
|
+
deduped.push({
|
|
769
|
+
type: 'deep_suspicious_file',
|
|
770
|
+
severity: 'LOW',
|
|
771
|
+
message: `Suspicious pattern found in deeply nested file (depth ${segments.length}): ${file} — potential hiding technique.`,
|
|
772
|
+
file: file,
|
|
773
|
+
boostSignal: true
|
|
774
|
+
});
|
|
775
|
+
break; // Only emit once per package
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
731
780
|
/**
|
|
732
781
|
* Calculate per-file max risk score from deduplicated threats.
|
|
733
782
|
* Formula: riskScore = min(100, max(file_scores + intent_bonus) + package_level_score)
|
|
@@ -795,6 +844,27 @@ function calculateRiskScore(deduped, intentResult) {
|
|
|
795
844
|
crossFileBonus = Math.min(crossFileBonus, 25);
|
|
796
845
|
}
|
|
797
846
|
|
|
847
|
+
// 5b. Blue Team v8: Package-level boost signals — detect dissimulation patterns
|
|
848
|
+
applyPackageLevelBoosts(deduped, fileScores, fileGroups, packageLevelThreats);
|
|
849
|
+
// Recompute packageScore after boosts may have added new package-level threats
|
|
850
|
+
const boostPackageThreats = deduped.filter(t => isPackageLevelThreat(t) && t.boostSignal);
|
|
851
|
+
if (boostPackageThreats.length > 0) {
|
|
852
|
+
packageScore = computeGroupScore([...packageLevelThreats, ...boostPackageThreats]);
|
|
853
|
+
if (packageScore >= 25 && [...packageLevelThreats, ...boostPackageThreats].some(t => t.severity === 'CRITICAL')) {
|
|
854
|
+
packageScore = Math.max(packageScore, 50);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// 5c. Blue Team v8: lifecycle_plus_finding boost — lifecycle + any finding = +10 package score
|
|
859
|
+
const hasActiveLifecycleForBoost = packageLevelThreats.some(t =>
|
|
860
|
+
t.type === 'lifecycle_script' && t.severity !== 'LOW'
|
|
861
|
+
);
|
|
862
|
+
const hasAnyFileFinding = fileLevelThreats.some(t => t.severity !== 'LOW');
|
|
863
|
+
let lifecycleBoost = 0;
|
|
864
|
+
if (hasActiveLifecycleForBoost && hasAnyFileFinding) {
|
|
865
|
+
lifecycleBoost = 10;
|
|
866
|
+
}
|
|
867
|
+
|
|
798
868
|
// 6. Intent coherence bonus: additive score from source→sink pairs
|
|
799
869
|
let intentBonus = 0;
|
|
800
870
|
if (intentResult && intentResult.intentScore > 0) {
|
|
@@ -802,8 +872,8 @@ function calculateRiskScore(deduped, intentResult) {
|
|
|
802
872
|
intentBonus = Math.min(intentResult.intentScore, 30);
|
|
803
873
|
}
|
|
804
874
|
|
|
805
|
-
// 7. Final score = max file score + cross-file bonus + intent bonus + package-level score, capped at 100
|
|
806
|
-
const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + crossFileBonus + intentBonus + packageScore);
|
|
875
|
+
// 7. Final score = max file score + cross-file bonus + intent bonus + package-level score + lifecycle boost, capped at 100
|
|
876
|
+
const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + crossFileBonus + intentBonus + packageScore + lifecycleBoost);
|
|
807
877
|
|
|
808
878
|
// 8. Old global score for comparison (sum of ALL findings)
|
|
809
879
|
const globalRiskScore = computeGroupScore(deduped);
|
package/src/shared/constants.js
CHANGED
|
@@ -99,16 +99,16 @@ function resetMaxFileSize() { _maxFileSize = MAX_FILE_SIZE; }
|
|
|
99
99
|
const ACORN_OPTIONS = { ecmaVersion: 2024, sourceType: 'module', allowHashBang: true };
|
|
100
100
|
|
|
101
101
|
const acorn = require('acorn');
|
|
102
|
+
const crypto = require('crypto');
|
|
102
103
|
|
|
103
104
|
/**
|
|
104
105
|
* AST parse cache — same content+options returns the same AST.
|
|
105
106
|
* Scanners do not mutate AST nodes (verified: only read comparisons).
|
|
106
107
|
* Cleared between scans via clearASTCache().
|
|
107
|
-
* Key = code
|
|
108
|
-
* (length-prefixed partial key for fast Map lookup; collisions resolved by full WeakRef check)
|
|
108
|
+
* Key = sha256(code) + '|' + optionsKey (collision-free content-addressable key)
|
|
109
109
|
*/
|
|
110
110
|
const _astCache = new Map();
|
|
111
|
-
const _AST_CACHE_MAX =
|
|
111
|
+
const _AST_CACHE_MAX = 200; // Max entries (reduced from 600 to limit memory during evaluate)
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
114
|
* Parse JS source with module-mode fallback to script-mode.
|
|
@@ -117,9 +117,9 @@ const _AST_CACHE_MAX = 600; // Max entries (one scan ≈ 500 files max)
|
|
|
117
117
|
* Returns AST or null if both modes fail.
|
|
118
118
|
*/
|
|
119
119
|
function safeParse(code, extraOptions = {}) {
|
|
120
|
-
// Build cache key:
|
|
120
|
+
// Build cache key: sha256 content hash + options signature
|
|
121
121
|
const optKey = Object.keys(extraOptions).length === 0 ? '' : JSON.stringify(extraOptions);
|
|
122
|
-
const cacheKey = code.
|
|
122
|
+
const cacheKey = crypto.createHash('sha256').update(code).digest('hex') + '|' + optKey;
|
|
123
123
|
|
|
124
124
|
const cached = _astCache.get(cacheKey);
|
|
125
125
|
if (cached !== undefined) return cached;
|
package/src/utils.js
CHANGED
|
@@ -23,6 +23,7 @@ let _scanRoot = '';
|
|
|
23
23
|
*/
|
|
24
24
|
const _fileListCache = new Map();
|
|
25
25
|
let _filesCapped = false;
|
|
26
|
+
let _overflowFiles = [];
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* File content cache — read each file once, reused across all scanners in a single scan.
|
|
@@ -121,6 +122,7 @@ function findFiles(dir, options = {}) {
|
|
|
121
122
|
return depthA - depthB;
|
|
122
123
|
});
|
|
123
124
|
const capped = result.slice(0, maxFiles);
|
|
125
|
+
_overflowFiles = result.slice(maxFiles);
|
|
124
126
|
_fileListCache.set(cacheKey, [...capped]);
|
|
125
127
|
_filesCapped = true;
|
|
126
128
|
return capped;
|
|
@@ -227,12 +229,17 @@ function clearFileListCache() {
|
|
|
227
229
|
_fileContentCache.clear();
|
|
228
230
|
clearASTCache();
|
|
229
231
|
_filesCapped = false;
|
|
232
|
+
_overflowFiles = [];
|
|
230
233
|
}
|
|
231
234
|
|
|
232
235
|
function wasFilesCapped() {
|
|
233
236
|
return _filesCapped;
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
function getOverflowFiles() {
|
|
240
|
+
return _overflowFiles;
|
|
241
|
+
}
|
|
242
|
+
|
|
236
243
|
/**
|
|
237
244
|
* Escapes HTML characters to prevent XSS
|
|
238
245
|
* @param {string} str - String to escape
|
|
@@ -394,6 +401,7 @@ module.exports = {
|
|
|
394
401
|
findJsFiles,
|
|
395
402
|
clearFileListCache,
|
|
396
403
|
wasFilesCapped,
|
|
404
|
+
getOverflowFiles,
|
|
397
405
|
escapeHtml,
|
|
398
406
|
getCallName,
|
|
399
407
|
Spinner,
|