UNPKG

shai-hulud-2-scanner

Version:

Forensic scanner for Shai-Hulud 2.0 malware artifacts and compromised packages.

1,258 lines (1,069 loc) 75.1 kB
#!/usr/bin/env node /** * Shai-Hulud 2.0 Supply Chain Attack Scanner * * A forensic auditing tool for detecting compromised npm packages associated with * the Shai-Hulud 2.0 supply chain attack. Performs deep analysis of local caches, * global installations, and project dependencies against threat intelligence IOCs. * * Key Capabilities: * - Multi-layer Detection: Forensic file scanning, metadata validation, and behavioral analysis * - Cross-Platform Support: Windows, macOS, Linux with native NVM integration * - Zero Dependencies: Self-contained scanner requiring only Node.js runtime * - Threat Intelligence: Auto-syncs with Wiz Research IOC database and Hemachandsai malicious package list * - Enterprise Reporting: Optional centralized report aggregation for organizations * * Detection Methods: * 1. Forensic Analysis: Scans for known malware payloads (setup_bun.js, etc.) * 2. Version Matching: Validates installed packages against IOC registry * 3. Lockfile Inspection: Identifies compromised dependencies in lock files * 4. Ghost Detection: Alerts on suspicious directory structures * 5. Behavioral Signatures: Detects malicious script patterns in package.json */ 'use strict'; const fs = require('fs'); const path = require('path'); const https = require('https'); const os = require('os'); const crypto = require('crypto'); const { execSync } = require('child_process'); // ============================================================================ // CONFIGURATION // ============================================================================ const CONFIG = Object.freeze({ // Report Settings REPORT_FILE: 'shai-hulud-report.csv', // IOC Sources IOC_CSV_URL: 'https://raw.githubusercontent.com/wiz-sec-public/wiz-research-iocs/main/reports/shai-hulud-2-packages.csv', IOC_JSON_URL: 'https://raw.githubusercontent.com/hemachandsai/shai-hulud-malicious-packages/main/malicious_npm_packages.json', // Cache Configuration CACHE_DIR: path.join(__dirname, '.cache'), FALLBACK_DIR: path.join(__dirname, 'fallback'), CACHE_TIMEOUT_MS: 30 * 60 * 1000, // 30 minutes // Security Limits MAX_FILE_SIZE_BYTES: 50 * 1024 * 1024, // 50MB max file read MAX_LOCKFILE_SIZE_BYTES: 100 * 1024 * 1024, // 100MB for lockfiles MAX_SCAN_DEPTH: 10, // Hard limit on recursion DEFAULT_SCAN_DEPTH: 5, NETWORK_TIMEOUT_MS: 15000, // 15 seconds MAX_PATH_LENGTH: 4096, MAX_SYMLINK_DEPTH: 3, // CI/CD Defaults DEFAULT_FAIL_ON: 'critical', // Scan Stats Limits (prevent infinite loops) MAX_DIRECTORIES_SCANNED: 100000, MAX_PACKAGES_SCANNED: 50000, }); // Derived cache paths const CACHE_WIZ_FILE = path.join(CONFIG.CACHE_DIR, 'wiz-iocs.csv'); const CACHE_JSON_FILE = path.join(CONFIG.CACHE_DIR, 'malicious-packages.json'); const FALLBACK_WIZ_FILE = path.join(CONFIG.FALLBACK_DIR, 'wiz-iocs.csv'); const FALLBACK_JSON_FILE = path.join(CONFIG.FALLBACK_DIR, 'malicious-packages.json'); // API Configuration - Use environment variables for sensitive data const UPLOAD_API_URL = process.env.SHAI_HULUD_API_URL || ''; const API_KEY = process.env.SHAI_HULUD_API_KEY || ''; // ============================================================================ // TERMINAL COLORS (with detection) // ============================================================================ const supportsColor = process.stdout.isTTY && (process.env.FORCE_COLOR !== '0') && (process.env.NO_COLOR === undefined); const colors = supportsColor ? { red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m' } : { red: '', green: '', yellow: '', cyan: '', reset: '', dim: '', bold: '' }; // ============================================================================ // FORENSIC FILE LIST // ============================================================================ const FORENSIC_RULES = { // === HIGH CONFIDENCE (Alert immediately if found) === 'setup_bun.js': { type: 'CRITICAL', checkContent: false }, 'bun_environment.js': { type: 'CRITICAL', checkContent: false }, 'truffleSecrets.json': { type: 'CRITICAL', checkContent: false }, 'actionsSecrets.json': { type: 'CRITICAL', checkContent: false }, '.github/workflows/discussion.yaml': { type: 'CRITICAL', checkContent: false }, '.github/workflows/discussion.yml': { type: 'CRITICAL', checkContent: false }, // === LOW CONFIDENCE (Must verify content to avoid False Positives) === // "bundle.js" is common in Webpack/Babel. // Malware version contains the string "setup_bun" or obfuscated shell calls. 'bundle.js': { type: 'HIGH', checkContent: true, indicators: [/setup_bun/i, /bun_environment/i, /child_process/, /socket\.connect/], safePatterns: [/webpack/i, /react/i, /babel/i] // Heuristic for common libs }, // "contents.json" is standard in iOS/Xcode (React Native). // Malware version contains stolen env vars/tokens. // Xcode version contains "images": [] and "info": { "version": 1, "author": "xcode" } 'contents.json': { type: 'HIGH', checkContent: true, isJson: true, requiredKeys: ['aws', 'key', 'token', 'secret', 'password', 'env'], // Malware likely has these safeKeys: ['images', 'info', 'properties'] // Xcode assets have these }, // "cloud.json" and "environment.json" are generic. // Malware version is a dump of env vars. 'cloud.json': { type: 'HIGH', checkContent: true, isJson: true, requiredKeys: ['aws_access_key_id', 'azure_client_id', 'gcp_token'] }, 'environment.json': { type: 'HIGH', checkContent: true, isJson: true, requiredKeys: ['PATH', 'USER', 'SHELL', 'HOME', 'npm_config_'] // Env dump signature } }; // Create sets for fast lookup //const FORENSIC_FILENAMES_LOWER = new Set(Object.keys(FORENSIC_RULES).map(k => k.toLowerCase())); // ============================================================================ // HEURISTIC CONFIGURATION (ReDoS-hardened patterns) // ============================================================================ const SCRIPT_WHITELIST = new Set([ 'husky install', 'husky', 'is-ci || husky install', 'ngcc', 'ngcc --properties es2015 browser module main', 'ivy-ngcc', 'tsc', 'tsc -p tsconfig.json', 'tsc --build', 'rimraf', 'rimraf dist', 'shx', 'prebuild-install', 'node-gyp rebuild', 'node-pre-gyp install --fallback-to-build', 'patch-package', 'esbuild', 'node scripts/postinstall.js', 'node scripts/postinstall', 'lerna bootstrap', 'nx', 'electron-builder install-app-deps', 'exit 0', 'true', 'echo' ]); const SCRIPT_WHITELIST_REGEX = [ /^echo\s/, /^rimraf\s/, /^shx\s/, /^tsc(?:\s|$)/, /^ngcc(?:\s|$)/, /^node-gyp\s/, /^prebuild-install/, /^husky(?:\s|$)/, /^is-ci\s/, /^opencollective(?:-postinstall)?/, /^patch-package/, /^node\s+scripts\/postinstall(?:\.js)?$/, /^electron-builder\s+install-app-deps/, /^lerna\s+bootstrap/, /^(?:nx|turbo)\s+run/, /^esbuild(?:\s|$)/, /^node-pre-gyp\s+install(?:\s|$)/ ]; // ReDoS-hardened critical patterns (using possessive-like constructs and bounded quantifiers) const CRITICAL_PATTERNS = [ // Remote code execution patterns - limited repetition { pattern: /curl\s+[^\s|]{1,500}\s*\|\s*(?:sh|bash|zsh)/i, desc: 'Curl piped to shell', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /wget\s+[^\s|]{1,500}\s*\|\s*(?:sh|bash|zsh)/i, desc: 'Wget piped to shell', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /curl\s+[^\s]{1,500}>\s*[^|&\s]+\s*&&\s*(?:sh|bash|chmod)/i, desc: 'Curl download & exec', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /curl\s+[^\s]{0,200}githubusercontent\.com\/[^\s|]{1,300}\|\s*(?:sh|bash|zsh)/i, desc: 'Pipe raw GitHub content to shell', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /wget\s+[^\s]{0,200}raw\.githubusercontent\.com\/[^\s|]{1,300}\|\s*(?:sh|bash|zsh)/i, desc: 'Pipe raw GitHub content to shell', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /\b(?:b64|base64)\b[^|]{0,100}\|\s*(?:sh|bash)/i, desc: 'Decode then execute via shell', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /base64\s+(?:-d|--decode)/i, desc: 'Base64 decoding', indicator: 'OBFUSCATION' }, { pattern: /\beval\s*\(/, desc: 'Eval statement', indicator: 'CODE_INJECTION' }, { pattern: /setup_bun/i, desc: 'Shai-Hulud Loader', indicator: 'SHAI_HULUD' }, { pattern: /bun_environment/i, desc: 'Shai-Hulud Payload', indicator: 'SHAI_HULUD' }, { pattern: /SHA1HULUD/i, desc: 'Shai-Hulud Signature', indicator: 'SHAI_HULUD' }, { pattern: /node\s+-e\s+["']require\s*\(\s*["']child_process["']\s*\)/, desc: 'Hidden child_process', indicator: 'CODE_INJECTION' }, { pattern: /child_process[^)]{0,50}exec[^)]{0,50}\$\(/, desc: 'Shell command via child_process', indicator: 'CODE_INJECTION' }, { pattern: /\$\(curl/i, desc: 'Subshell curl', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /`curl/i, desc: 'Backtick curl', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /bash\s+-c\s+["'][^"']{0,200}curl/i, desc: 'bash -c curl', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /curl\s+[^\s]{1,300}-o\s+\S+\s*&&\s*(?:sh|bash|chmod)/i, desc: 'curl save & exec', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /wget\s+[^\s]{1,300}-O\s+\S+\s*&&\s*(?:sh|bash|chmod)/i, desc: 'wget save & exec', indicator: 'REMOTE_CODE_EXEC' }, { pattern: /require\s*\(\s*["']child_process["']\s*\)\.\s*(?:exec|execSync|spawn|spawnSync)/i, desc: 'Direct child_process call', indicator: 'CODE_INJECTION' }, { pattern: /\b(?:execSync|spawnSync|execFileSync)\s*\(/, desc: 'Sync process execution', indicator: 'CODE_INJECTION' }, { pattern: /\.github\/workflows\/discussion\.ya?ml/i, desc: 'GitHub workflow backdoor', indicator: 'PERSISTENCE' }, { pattern: /docker\s+run\s+[^\n]{0,200}--privileged/i, desc: 'Privileged Docker run', indicator: 'PRIV_ESC' }, { pattern: /-v\s+\/:\/host\b/i, desc: 'Host mount in container', indicator: 'PRIV_ESC' } ]; const WARNING_PATTERNS = [ { pattern: /http:\/\/[^\s"']{1,200}/, desc: 'Unencrypted HTTP', indicator: 'INSECURE_NETWORK' }, { pattern: /\\x[0-9a-fA-F]{2}/, desc: 'Hex-encoded string', indicator: 'OBFUSCATION' }, { pattern: /String\.fromCharCode/, desc: 'Char code obfuscation', indicator: 'OBFUSCATION' }, { pattern: /atob\s*\(/, desc: 'Base64 atob decode', indicator: 'OBFUSCATION' }, { pattern: /Buffer\.from\s*\([^)]{1,100},\s*['"]base64['"]\)/, desc: 'Buffer base64 decode', indicator: 'OBFUSCATION' }, { pattern: /Buffer\.from\s*\([^)]{1,100},\s*['"]hex['"]\)/, desc: 'Buffer hex decode', indicator: 'OBFUSCATION' }, { pattern: /Function\s*\([^)]{0,200}\)/, desc: 'Dynamic function creation', indicator: 'OBFUSCATION' }, { pattern: /actions\/upload-artifact/i, desc: 'GitHub Actions artifact usage', indicator: 'EXFIL_ATTEMPT' }, { pattern: /https?:\/\/api\.github\.com\/(?:repos|gists|uploads)/i, desc: 'GitHub API interaction', indicator: 'EXFIL_ATTEMPT' }, { pattern: /child_process\.(?:exec|spawn|execSync|spawnSync)\([^)]{0,50}(?:curl|wget|nc|bash|sh)/i, desc: 'Shelling out to network tools', indicator: 'CODE_INJECTION' }, { pattern: /\bnc\b\s+(?:-[a-zA-Z]+\s+){0,5}\S+/i, desc: 'Netcat usage', indicator: 'BACKDOOR_PRIMITIVE' }, { pattern: /\bsocat\b\s+/i, desc: 'socat usage', indicator: 'BACKDOOR_PRIMITIVE' } ]; // ============================================================================ // GLOBAL STATE // ============================================================================ const detectedIssues = []; const scanStats = { directoriesScanned: 0, packagesScanned: 0, filesChecked: 0, lockfilesChecked: 0, startTime: null, symlinksSkipped: 0, errorsEncountered: 0 }; let isShuttingDown = false; // ============================================================================ // SECURITY UTILITIES // ============================================================================ /** * Escapes a string for safe CSV inclusion (prevents CSV injection) * @param {string} str - String to escape * @returns {string} - Escaped string safe for CSV */ function escapeCSV(str) { if (str === null || str === undefined) return ''; const s = String(str); // CSV injection prevention: if starts with dangerous chars, prefix with single quote // Dangerous chars: =, +, -, @, tab, carriage return const dangerousStart = /^[=+\-@\t\r]/; let escaped = s; if (dangerousStart.test(escaped)) { escaped = "'" + escaped; } // Standard CSV escaping: double quotes and wrap if needed if (escaped.includes('"') || escaped.includes(',') || escaped.includes('\n') || escaped.includes('\r')) { escaped = '"' + escaped.replace(/"/g, '""') + '"'; } else if (escaped.includes(' ') || escaped.startsWith("'")) { escaped = '"' + escaped + '"'; } return escaped; } /** * Escapes special regex characters in a string * @param {string} string - String to escape * @returns {string} - Regex-safe string */ function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Validates and normalizes a path, preventing traversal attacks * @param {string} inputPath - Path to validate * @param {string} basePath - Base path that inputPath must be within (optional) * @returns {string|null} - Normalized path or null if invalid */ function validatePath(inputPath, basePath = null) { if (!inputPath || typeof inputPath !== 'string') return null; if (inputPath.length > CONFIG.MAX_PATH_LENGTH) return null; // Normalize the path let normalized; try { normalized = path.resolve(inputPath); } catch (e) { return null; } // Check for null bytes (path traversal attack vector) if (normalized.includes('\0')) return null; // If basePath provided, ensure normalized path is within it if (basePath) { const normalizedBase = path.resolve(basePath); if (!normalized.startsWith(normalizedBase + path.sep) && normalized !== normalizedBase) { return null; } } return normalized; } /** * Safely checks if a path is a symlink and resolves it with depth limiting * @param {string} filePath - Path to check * @param {number} depth - Current symlink resolution depth * @returns {{isSymlink: boolean, realPath: string|null, safe: boolean}} */ function checkSymlink(filePath, depth = 0) { try { const stats = fs.lstatSync(filePath); if (!stats.isSymbolicLink()) { return { isSymlink: false, realPath: filePath, safe: true }; } if (depth >= CONFIG.MAX_SYMLINK_DEPTH) { return { isSymlink: true, realPath: null, safe: false }; } const realPath = fs.realpathSync(filePath); return { isSymlink: true, realPath, safe: true }; } catch (e) { return { isSymlink: false, realPath: null, safe: false }; } } /** * Safely reads a file with size limits * @param {string} filePath - Path to read * @param {number} maxSize - Maximum file size in bytes * @returns {{content: string|null, error: string|null, size: number}} */ function safeReadFile(filePath, maxSize = CONFIG.MAX_FILE_SIZE_BYTES) { try { const stats = fs.statSync(filePath); if (stats.size > maxSize) { return { content: null, error: 'FILE_TOO_LARGE', size: stats.size }; } const content = fs.readFileSync(filePath, 'utf8'); return { content, error: null, size: stats.size }; } catch (e) { return { content: null, error: e.code || 'READ_ERROR', size: 0 }; } } /** * Computes SHA256 hash of content for integrity verification * @param {string} content - Content to hash * @returns {string} - Hex-encoded SHA256 hash */ function computeHash(content) { return crypto.createHash('sha256').update(content, 'utf8').digest('hex'); } /** * Sanitizes a string for safe logging (removes control characters) * @param {string} str - String to sanitize * @param {number} maxLength - Maximum length * @returns {string} - Sanitized string */ function sanitizeForLog(str, maxLength = 200) { if (!str) return ''; // Remove control characters except common whitespace const sanitized = String(str) .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') .slice(0, maxLength); return sanitized; } // ============================================================================ // SIGNAL HANDLING // ============================================================================ function setupSignalHandlers() { const handleShutdown = (signal) => { if (isShuttingDown) return; isShuttingDown = true; console.log(`\n${colors.yellow}[!] Received ${signal}, generating partial report...${colors.reset}`); // Try to generate report with what we have try { const partialReport = generateReport({ gitName: 'INTERRUPTED', gitEmail: 'INTERRUPTED', npmUser: 'INTERRUPTED', hostname: os.hostname(), platform: os.platform() }, true); console.log(`${colors.yellow} > Partial report saved.${colors.reset}`); } catch (e) { console.log(`${colors.red} > Could not save partial report: ${e.message}${colors.reset}`); } process.exit(130); // Standard exit code for SIGINT }; process.on('SIGINT', () => handleShutdown('SIGINT')); process.on('SIGTERM', () => handleShutdown('SIGTERM')); } // ============================================================================ // USER INFO COLLECTION // ============================================================================ function getUserInfo() { console.log(`${colors.cyan}[1/5] Identifying User Environment...${colors.reset}`); const info = { gitName: 'Unknown', gitEmail: 'Unknown', npmUser: 'Not Logged In', hostname: os.hostname(), platform: os.platform(), nodeVersion: process.version, scannerVersion: '2.1.0-hardened' }; try { const result = execSync('git config user.name', { timeout: 5000, encoding: 'utf8' }); info.gitName = sanitizeForLog(result.trim(), 100); } catch (e) { } try { const result = execSync('git config user.email', { timeout: 5000, encoding: 'utf8' }); info.gitEmail = sanitizeForLog(result.trim(), 100); } catch (e) { } try { const npmWhoami = execSync('npm whoami', { stdio: ['pipe', 'pipe', 'ignore'], timeout: 10000, encoding: 'utf8' }).trim(); if (npmWhoami) info.npmUser = sanitizeForLog(npmWhoami, 50); } catch (e) { } console.log(` > User: ${info.gitName} <${info.gitEmail}>`); console.log(` > NPM User: ${info.npmUser}`); console.log(` > Host: ${info.hostname} (${info.platform})`); console.log(` > Node: ${info.nodeVersion}`); return info; } // ============================================================================ // CACHE MANAGEMENT // ============================================================================ function ensureDir(dir) { const validated = validatePath(dir); if (!validated) { throw new Error(`Invalid directory path: ${sanitizeForLog(dir)}`); } if (!fs.existsSync(validated)) { fs.mkdirSync(validated, { recursive: true, mode: 0o755 }); } } function isCacheValid(cacheFile) { try { const validated = validatePath(cacheFile); if (!validated || !fs.existsSync(validated)) return false; const stats = fs.statSync(validated); const age = Date.now() - stats.mtimeMs; return age < CONFIG.CACHE_TIMEOUT_MS; } catch (e) { return false; } } function loadFromCache(cacheFile, type) { try { const validated = validatePath(cacheFile); if (!validated) return null; const { content, error, size } = safeReadFile(validated); if (error) return null; const ageMinutes = Math.round((Date.now() - fs.statSync(validated).mtimeMs) / 1000 / 60); console.log(` > ${type}: Loaded from cache (age: ${ageMinutes}m, size: ${(size / 1024).toFixed(1)}KB)`); return content; } catch (e) { return null; } } function loadFromFallback(fallbackFile, type) { try { const validated = validatePath(fallbackFile); if (!validated || !fs.existsSync(validated)) return null; const { content, error } = safeReadFile(validated); if (error) return null; console.log(` > ${type}: ${colors.yellow}Using offline fallback${colors.reset}`); return content; } catch (e) { return null; } } function saveToCache(cacheFile, content) { try { const validated = validatePath(cacheFile); if (!validated) { console.log(` > Warning: Invalid cache path`); return; } ensureDir(path.dirname(validated)); // Write atomically using temp file const tempFile = validated + '.tmp.' + process.pid; fs.writeFileSync(tempFile, content, { encoding: 'utf8', mode: 0o644 }); fs.renameSync(tempFile, validated); // Store hash for integrity verification const hashFile = validated + '.sha256'; fs.writeFileSync(hashFile, computeHash(content), { encoding: 'utf8', mode: 0o644 }); } catch (e) { console.log(` > Warning: Could not write to cache: ${e.message}`); } } function verifyCacheIntegrity(cacheFile) { try { const hashFile = cacheFile + '.sha256'; if (!fs.existsSync(hashFile)) return true; // No hash file, skip verification const storedHash = fs.readFileSync(hashFile, 'utf8').trim(); const { content } = safeReadFile(cacheFile); if (!content) return false; return computeHash(content) === storedHash; } catch (e) { return false; } } // ============================================================================ // THREAT INTELLIGENCE FETCHING // ============================================================================ async function fetchThreats(forceNoCache = false) { console.log(`\n${colors.cyan}[2/5] Downloading Threat Intelligence (Dual Feed)...${colors.reset}`); if (forceNoCache) console.log(` > ${colors.yellow}Cache bypassed (--no-cache flag)${colors.reset}`); try { // Manual Promise handling for Node.js v12.0-v12.8 compatibility (allSettled added in v12.9.0) let wizData = { status: 'rejected', reason: { message: 'Not fetched' }, value: null }; let jsonData = { status: 'rejected', reason: { message: 'Not fetched' }, value: null }; try { const wizResult = await fetchWithCache(CONFIG.IOC_CSV_URL, CACHE_WIZ_FILE, FALLBACK_WIZ_FILE, 'Wiz.io CSV', forceNoCache); wizData = { status: 'fulfilled', value: wizResult }; } catch (err) { wizData = { status: 'rejected', reason: err }; } try { const jsonResult = await fetchWithCache(CONFIG.IOC_JSON_URL, CACHE_JSON_FILE, FALLBACK_JSON_FILE, 'Malicious JSON', forceNoCache); jsonData = { status: 'fulfilled', value: jsonResult }; } catch (err) { jsonData = { status: 'rejected', reason: err }; } const badPackages = {}; let count = 0; // Process Source 1 (Wiz CSV) if (wizData.status === 'fulfilled' && wizData.value) { const parsed = parseWizCSV(wizData.value); for (const [pkg, vers] of Object.entries(parsed)) { if (!badPackages[pkg]) badPackages[pkg] = []; badPackages[pkg].push(...vers); } console.log(` > [Source 1] Wiz.io: Loaded successfully.`); } else { const wizError = wizData.reason && wizData.reason.message ? wizData.reason.message : 'No data'; console.log(`${colors.red} > [Source 1] Failed: ${wizError}${colors.reset}`); } // Process Source 2 (Hemachandsai JSON) if (jsonData.status === 'fulfilled' && jsonData.value) { const parsed = parseMaliciousJSON(jsonData.value); for (const [pkg, vers] of Object.entries(parsed)) { if (!badPackages[pkg]) badPackages[pkg] = []; if (vers.length === 0) { if (!badPackages[pkg].includes('*')) badPackages[pkg].push('*'); } else { badPackages[pkg].push(...vers); } } console.log(` > [Source 2] Hemachandsai: Loaded successfully.`); } else { const jsonError = jsonData.reason && jsonData.reason.message ? jsonData.reason.message : 'No data'; console.log(`${colors.red} > [Source 2] Failed: ${jsonError}${colors.reset}`); } // Clean duplicates for (const pkg in badPackages) { badPackages[pkg] = [...new Set(badPackages[pkg])]; count++; } console.log(` > Total Threat Database: ${count} unique packages targeted.`); return badPackages; } catch (e) { console.error(`${colors.red}Critical Error fetching feeds: ${e.message}${colors.reset}`); return {}; } } function fetchWithCache(url, cacheFile, fallbackFile, sourceName, forceNoCache = false) { return new Promise((resolve, reject) => { // 1. Check cache validity if (!forceNoCache && isCacheValid(cacheFile)) { if (verifyCacheIntegrity(cacheFile)) { const cached = loadFromCache(cacheFile, sourceName); if (cached) return resolve(cached); } else { console.log(` > ${sourceName}: ${colors.yellow}Cache integrity check failed, re-fetching...${colors.reset}`); } } // 2. Validate URL before fetching let parsedUrl; try { parsedUrl = new URL(url); if (parsedUrl.protocol !== 'https:') { throw new Error('Only HTTPS URLs are allowed'); } } catch (e) { return reject(new Error(`Invalid URL: ${e.message}`)); } // 3. Try to fetch from network with timeout const timeout = setTimeout(() => { console.log(` > ${sourceName}: ${colors.yellow}Network timeout, trying fallback...${colors.reset}`); const fallback = loadFromFallback(fallbackFile, sourceName); if (fallback) resolve(fallback); else reject(new Error('Timeout and no fallback available')); }, CONFIG.NETWORK_TIMEOUT_MS); const req = https.get(url, { timeout: CONFIG.NETWORK_TIMEOUT_MS, headers: { 'User-Agent': 'Shai-Hulud-Scanner/2.1.0' } }, (res) => { clearTimeout(timeout); // Check for redirects (limit to prevent infinite loops) if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { // Don't follow redirects automatically for security console.log(` > ${sourceName}: ${colors.yellow}Redirect detected, using fallback...${colors.reset}`); const fallback = loadFromFallback(fallbackFile, sourceName); if (fallback) return resolve(fallback); return reject(new Error(`Redirect to ${res.headers.location}`)); } let data = ''; let receivedBytes = 0; const maxBytes = 10 * 1024 * 1024; // 10MB max download res.on('data', chunk => { receivedBytes += chunk.length; if (receivedBytes > maxBytes) { res.destroy(); reject(new Error('Response too large')); return; } data += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { console.log(` > ${sourceName}: Downloaded from network (${(receivedBytes / 1024).toFixed(1)}KB).`); saveToCache(cacheFile, data); resolve(data); } else { console.log(` > ${sourceName}: HTTP ${res.statusCode}, trying fallback...`); const fallback = loadFromFallback(fallbackFile, sourceName); if (fallback) resolve(fallback); else reject(new Error(`HTTP ${res.statusCode}`)); } }); }); req.on('error', (e) => { clearTimeout(timeout); console.log(` > ${sourceName}: ${colors.yellow}Network error, trying fallback...${colors.reset}`); const fallback = loadFromFallback(fallbackFile, sourceName); if (fallback) resolve(fallback); else reject(e); }); req.on('timeout', () => { req.destroy(); }); }); } function parseWizCSV(data) { if (!data || typeof data !== 'string') return {}; const lines = data.split('\n').filter(l => l.trim() !== ''); const result = {}; const startIdx = (lines[0] && lines[0].toLowerCase().includes('package')) ? 1 : 0; for (let i = startIdx; i < lines.length && i < 100000; i++) { const parts = lines[i].split(','); if (parts.length >= 2) { const rawName = parts[0].replace(/["']/g, '').trim(); // Validate package name (basic npm naming rules) if (!rawName || rawName.length > 214 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(rawName)) { continue; } const versionField = parts.slice(1).join(',').trim(); const versions = versionField.split('||').map(v => v.replace(/["'=<>v\s]/g, '').trim() ).filter(v => v !== '' && v.length <= 50); if (rawName && versions.length > 0) { if (!result[rawName]) result[rawName] = []; result[rawName].push(...versions); } } } return result; } function parseMaliciousJSON(data) { if (!data || typeof data !== 'string') return {}; try { const json = JSON.parse(data); // Validate structure if (typeof json !== 'object' || json === null || Array.isArray(json)) { console.log(` > Warning: Unexpected JSON structure`); return {}; } const result = {}; let count = 0; const maxPackages = 100000; for (const [pkg, details] of Object.entries(json)) { if (count++ > maxPackages) break; // Validate package name if (!pkg || typeof pkg !== 'string' || pkg.length > 214) continue; // Validate details structure if (details && typeof details === 'object' && Array.isArray(details.versions)) { result[pkg] = details.versions.filter(v => typeof v === 'string' && v.length <= 50 ); } else { result[pkg] = []; } } return result; } catch (e) { console.log(` > Error parsing JSON: ${e.message}`); return {}; } } // ============================================================================ // PATH DISCOVERY // ============================================================================ function getSearchPaths() { console.log(`\n${colors.cyan}[3/5] Locating Cache & Global Directories...${colors.reset}`); const paths = []; const home = os.homedir(); const platform = os.platform(); // A. Active Global (NPM) try { const globalPrefix = execSync('npm root -g', { timeout: 10000, encoding: 'utf8' }).trim(); const validated = validatePath(globalPrefix); if (validated && fs.existsSync(validated)) { paths.push(validated); console.log(` > [NPM] Active Global: ${validated}`); } } catch (e) { } // B. BUN Support const bunBase = path.join(home, '.bun', 'install'); const bunGlobal = path.join(bunBase, 'global', 'node_modules'); if (fs.existsSync(bunGlobal)) { paths.push(bunGlobal); console.log(` > [BUN] Global Modules: ${bunGlobal}`); } const bunCache = path.join(bunBase, 'cache'); if (fs.existsSync(bunCache)) { paths.push(bunCache); console.log(` > [BUN] Global Cache: ${bunCache}`); } // C. NVM Deep Scan let nvmRoot = null; if (platform === 'win32') { if (process.env.NVM_HOME && fs.existsSync(process.env.NVM_HOME)) { nvmRoot = process.env.NVM_HOME; } else { const possible = path.join(process.env.APPDATA || '', 'nvm'); if (fs.existsSync(possible)) nvmRoot = possible; } } else { const possible = path.join(home, '.nvm', 'versions', 'node'); if (fs.existsSync(possible)) nvmRoot = possible; } if (nvmRoot) { console.log(` > [NVM] Root found at: ${nvmRoot}`); try { const versions = fs.readdirSync(nvmRoot, { withFileTypes: true }) .filter(d => d.isDirectory() && d.name.toLowerCase().startsWith('v')) .slice(0, 100); // Limit versions scanned console.log(` > [NVM] Found ${versions.length} installed versions.`); versions.forEach(v => { let vPath; if (platform === 'win32') { vPath = path.join(nvmRoot, v.name, 'node_modules'); } else { vPath = path.join(nvmRoot, v.name, 'lib', 'node_modules'); } if (fs.existsSync(vPath)) { paths.push(vPath); console.log(` -> Added version: ${v.name}`); } }); } catch (e) { console.log(` > [NVM] Error reading versions: ${e.message}`); } } else { console.log(` > [NVM] Not detected.`); } // D. Yarn Specifics const yarnPaths = platform === 'darwin' ? [ path.join(home, 'Library/Caches/Yarn'), path.join(home, '.yarn/berry/cache'), path.join(home, '.config/yarn/global/node_modules') ] : [ path.join(home, '.config/yarn/global/node_modules') ]; yarnPaths.forEach(yPath => { if (fs.existsSync(yPath)) { paths.push(yPath); console.log(` > [YARN] ${yPath}`); } }); // E. Generic Caches const yCache = path.join(home, platform === 'win32' ? 'AppData/Local/Yarn/Cache' : '.cache/yarn'); if (fs.existsSync(yCache)) { paths.push(yCache); console.log(` > [YARN] Standard Cache: ${yCache}`); } const npmCache = path.join(home, platform === 'win32' ? 'AppData/Roaming/npm-cache' : '.npm'); if (fs.existsSync(npmCache)) { paths.push(npmCache); console.log(` > [NPM] Standard Cache: ${npmCache}`); } const pnpmStore = path.join(home, platform === 'win32' ? 'AppData/Local/pnpm/store' : '.local/share/pnpm/store'); if (fs.existsSync(pnpmStore)) { paths.push(pnpmStore); console.log(` > [PNPM] Store: ${pnpmStore}`); } return [...new Set(paths)]; } // ============================================================================ // SCANNING LOGIC (Hardened) // ============================================================================ function scanDir(currentPath, badPackages, depth = 0, maxDepth = CONFIG.DEFAULT_SCAN_DEPTH) { // Check shutdown flag if (isShuttingDown) return; // Check scan limits if (scanStats.directoriesScanned >= CONFIG.MAX_DIRECTORIES_SCANNED) { if (scanStats.directoriesScanned === CONFIG.MAX_DIRECTORIES_SCANNED) { console.log(`${colors.yellow} > Warning: Directory scan limit reached (${CONFIG.MAX_DIRECTORIES_SCANNED})${colors.reset}`); } return; } // Enforce hard depth limit if (depth > Math.min(maxDepth, CONFIG.MAX_SCAN_DEPTH)) return; // Validate path const validated = validatePath(currentPath); if (!validated) return; // Check for symlinks const symlinkCheck = checkSymlink(validated); if (symlinkCheck.isSymlink) { if (!symlinkCheck.safe) { scanStats.symlinksSkipped++; return; } // Use real path for symlinks currentPath = symlinkCheck.realPath; } scanStats.directoriesScanned++; if (path.basename(currentPath) === 'node_modules') { scanNodeModules(currentPath, badPackages); return; } let entries; try { entries = fs.readdirSync(currentPath, { withFileTypes: true }); } catch (e) { scanStats.errorsEncountered++; return; } checkPackageJson(currentPath, path.basename(currentPath), badPackages); for (const entry of entries) { if (isShuttingDown) break; const fullPath = path.join(currentPath, entry.name); if (entry.isFile() && (entry.name === 'package-lock.json' || entry.name === 'yarn.lock' || entry.name === 'npm-shrinkwrap.json')) { checkLockfile(fullPath, badPackages); } else if (entry.isDirectory() && entry.name === 'node_modules') { scanNodeModules(fullPath, badPackages); } else if (entry.isDirectory() && !entry.name.startsWith('.') && !entry.name.startsWith('_')) { scanDir(fullPath, badPackages, depth + 1, maxDepth); } } } function scanNodeModules(modulesPath, badPackages) { if (isShuttingDown) return; try { const packages = fs.readdirSync(modulesPath); for (const pkg of packages) { if (isShuttingDown) break; if (pkg.startsWith('.')) continue; if (scanStats.packagesScanned >= CONFIG.MAX_PACKAGES_SCANNED) { if (scanStats.packagesScanned === CONFIG.MAX_PACKAGES_SCANNED) { console.log(`${colors.yellow} > Warning: Package scan limit reached (${CONFIG.MAX_PACKAGES_SCANNED})${colors.reset}`); } return; } if (pkg.startsWith('@')) { const scopedPath = path.join(modulesPath, pkg); try { const scopedPackages = fs.readdirSync(scopedPath); for (const sp of scopedPackages) { const pkgPath = path.join(scopedPath, sp); checkPackageJson(pkgPath, `${pkg}/${sp}`, badPackages); checkPackageLockfiles(pkgPath, badPackages); scanStats.packagesScanned++; } } catch (e) { scanStats.errorsEncountered++; } } else { const pkgPath = path.join(modulesPath, pkg); checkPackageJson(pkgPath, pkg, badPackages); checkPackageLockfiles(pkgPath, badPackages); scanStats.packagesScanned++; } } } catch (e) { scanStats.errorsEncountered++; } } function checkPackageLockfiles(pkgPath, badPackages) { const lockFiles = ['package-lock.json', 'yarn.lock', 'npm-shrinkwrap.json']; for (const lockFile of lockFiles) { const lockPath = path.join(pkgPath, lockFile); try { if (fs.existsSync(lockPath)) { checkLockfile(lockPath, badPackages); } } catch (e) { } } } // ============================================================================ // CORE PACKAGE CHECKING (Forensic + Metadata + Ghost) // ============================================================================ function checkPackageJson(pkgPath, pkgName, badPackages) { if (isShuttingDown) return; scanStats.filesChecked++; // Validate package path const validatedPkgPath = validatePath(pkgPath); if (!validatedPkgPath) return; // 1. FORENSIC CHECK (Malware files) for (const [forensicPath] of Object.entries(FORENSIC_RULES)) { const fullPath = path.join(validatedPkgPath, forensicPath); const validatedFullPath = validatePath(fullPath, validatedPkgPath); if (!validatedFullPath) continue; // Check if file exists and is actually a file (not directory) let fileExists = false; try { const stats = fs.lstatSync(validatedFullPath); if (!stats.isFile()) continue; fileExists = true; } catch (e) { continue; // File doesn't exist or can't be accessed } // File exists - verify it const verification = verifySuspiciousFile(validatedFullPath, forensicPath); // Track verification errors if (verification.reason === 'Read error') { scanStats.errorsEncountered++; continue; } if (verification.confirmed) { const severity = verification.severity || 'HIGH'; const isCritical = severity === 'CRITICAL'; // Use different colors and labels based on severity const label = isCritical ? 'CRITICAL: MALWARE FILE FOUND' : 'SUSPICIOUS: Artifact Found'; const color = isCritical ? colors.red : colors.yellow; const issueType = isCritical ? 'FORENSIC_MATCH' : 'FORENSIC_ARTIFACT'; const msg = `[${isCritical ? '!!!' : '??'}] ${label}: ${sanitizeForLog(forensicPath, 100)} in ${sanitizeForLog(pkgName)}`; console.log(`${color}${msg}${colors.reset}`); if (forensicPath !== 'setup_bun.js') { // detailed log for contextual files console.log(`${colors.dim} Reason: ${sanitizeForLog(verification.reason, 150)}${colors.reset}`); } detectedIssues.push({ type: issueType, package: pkgName, version: 'UNKNOWN', location: validatedPkgPath, details: `${sanitizeForLog(forensicPath, 100)} (${sanitizeForLog(verification.reason, 150)})` }); } else { // False positive - record as safe match (silent, like version safe matches) detectedIssues.push({ type: 'SAFE_MATCH', package: pkgName, version: 'UNKNOWN', location: validatedPkgPath, details: `Benign ${sanitizeForLog(forensicPath, 50)}: ${sanitizeForLog(verification.reason, 100)}` }); } } const pJsonPath = path.join(validatedPkgPath, 'package.json'); // 2. GHOST CHECK if (!fs.existsSync(pJsonPath)) { if (badPackages[pkgName]) { console.log(`${colors.yellow} [?] WARNING: Ghost folder "${sanitizeForLog(pkgName)}"${colors.reset}`); detectedIssues.push({ type: 'GHOST_PACKAGE', package: pkgName, version: 'UNKNOWN', location: pkgPath, details: 'Targeted package folder exists but package.json is missing' }); } return; } // 3. METADATA CHECK & HEURISTIC CHECK try { const { content, error, size } = safeReadFile(pJsonPath, CONFIG.MAX_FILE_SIZE_BYTES); if (error) { if (badPackages[pkgName]) { detectedIssues.push({ type: 'CORRUPT_PACKAGE', package: pkgName, version: 'UNKNOWN', location: pkgPath, details: `package.json ${error}` }); } return; } const packageJson = JSON.parse(content); // A. HEURISTIC SCRIPT CHECK (Run on everything) checkScripts(packageJson, pkgName, pkgPath); // B. TARGET CHECK if (!badPackages[pkgName]) return; // C. VERSION CHECK const version = packageJson.version; if (!version || typeof version !== 'string') { detectedIssues.push({ type: 'CORRUPT_PACKAGE', package: pkgName, version: 'UNKNOWN', location: pkgPath, details: 'Missing or invalid version field' }); return; } const targetVersions = badPackages[pkgName]; if (targetVersions.includes('*') || targetVersions.includes(version)) { const matchType = targetVersions.includes('*') ? 'WILDCARD_MATCH' : 'VERSION_MATCH'; console.log(`${colors.red} [!] ALERT: ${sanitizeForLog(pkgName)}@${sanitizeForLog(version)} matches denylist (${matchType})${colors.reset}`); detectedIssues.push({ type: matchType, package: pkgName, version: version, location: pkgPath }); } else { detectedIssues.push({ type: 'SAFE_MATCH', package: pkgName, version: version, location: pkgPath }); } } catch (e) { if (badPackages[pkgName]) { detectedIssues.push({ type: 'CORRUPT_PACKAGE', package: pkgName, version: 'UNKNOWN', location: pkgPath, details: `package.json parse error: ${e.message}` }); } } } // ============================================================================ // FORNSIC FILE VERIFICATION (Deep Content Scan) // ============================================================================ function verifySuspiciousFile(filePath, fileName) { // Input validation if (!filePath || typeof filePath !== 'string' || !fileName || typeof fileName !== 'string') { return { confirmed: false, reason: 'Invalid parameters' }; } // Validate path const validatedPath = validatePath(filePath); if (!validatedPath) { return { confirmed: false, reason: 'Invalid file path' }; } // 1. Get rule // Handle case-insensitive match logic const exactRule = FORENSIC_RULES[fileName]; const lowerRuleKey = Object.keys(FORENSIC_RULES).find(k => k.toLowerCase() === fileName.toLowerCase()); const rule = exactRule || FORENSIC_RULES[lowerRuleKey]; if (!rule) return { confirmed: false, reason: 'No rule found' }; // 2. High Confidence files need no verification if (!rule.checkContent) { return { confirmed: true, reason: 'High confidence IOC', severity: rule.type }; } // 3. Read File Content (Limit to 5MB to prevent freezing on huge bundles) const { content, error, size } = safeReadFile(validatedPath, 5 * 1024 * 1024); if (error) return { confirmed: false, reason: 'Read error' }; if (!content || size === 0) return { confirmed: false, reason: 'Empty file' }; // Additional safety: Limit content length for regex operations to prevent ReDoS const MAX_CONTENT_LENGTH_FOR_REGEX = 10 * 1024 * 1024; // 10MB if (content.length > MAX_CONTENT_LENGTH_FOR_REGEX) { return { confirmed: false, reason: 'File too large for content analysis' }; } // 4. JSON Validation (for contents.json, cloud.json) if (rule.isJson) { try { // Prevent JSON bomb attacks - limit JSON depth and size if (content.length > 5 * 1024 * 1024) { // 5MB limit for JSON return { confirmed: false, reason: 'JSON file too large' }; } const json = JSON.parse(content); // Validate JSON structure if (typeof json !== 'object' || json === null) { return { confirmed: false, reason: 'Invalid JSON structure' }; } const keys = Object.keys(json); // Bounds check on keys if (keys.length > 10000) { return { confirmed: false, reason: 'JSON has too many keys' }; } // Check Safe Keys (Allowlist) - If it ha