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
JavaScript
#!/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