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.
@@ -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
@@ -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 {
@@ -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.*\|.*sh/m, name: 'curl_pipe_shell', severity: 'HIGH' },
10
- { pattern: /wget.*&&.*chmod.*\+x/m, name: 'wget_chmod_exec', severity: 'HIGH' },
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.*-X\s*POST.*-d/m, name: 'curl_exfiltration', severity: 'HIGH' },
16
- { pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+.*|scp\s+).*\.npmrc/m, name: 'npmrc_access', severity: 'HIGH' },
17
- { pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+.*|scp\s+).*\.ssh/m, name: 'ssh_access', severity: 'HIGH' },
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', 'publish_dormant_spike', 'publish_rapid_succession',
106
- 'maintainer_new_suspicious', 'maintainer_sole_change',
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);
@@ -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.length + '|' + optionsKey + '|' + code.slice(0,128) + code.slice(-64)
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 = 600; // Max entries (one scan 500 files 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: options signature + content fingerprint
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.length + '|' + optKey + '|' + code.slice(0, 128) + code.slice(-64);
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,