UNPKG

sha1-hulud-scanner

Version:

Sha1-Hulud 2.0 npm supply chain attack scanner - Real-time detection using Koi.ai data

834 lines (706 loc) 22.5 kB
#!/usr/bin/env node /** * Sha1-Hulud 2.0 NPM Supply Chain Attack Scanner * * Downloads real-time infected package list from Koi.ai and scans your project * * Usage: * node scan.js [project_path] [options] * node scan.js # Scan current directory * node scan.js /path/to/project # Scan specific project * node scan.js --json # JSON output * node scan.js --verbose # Verbose output */ const https = require("https"); const fs = require("fs"); const path = require("path"); const { execSync } = require("child_process"); // Configuration const CONFIG = { KOI_CSV_URL: "https://docs.google.com/spreadsheets/d/16aw6s7mWoGU7vxBciTEZSaR5HaohlBTfVirvI-PypJc/export?format=csv&gid=1289659284", CACHE_DIR: path.join( process.env.HOME || process.env.USERPROFILE, ".cache", "sha1-hulud-scanner" ), CACHE_TTL: 3600 * 1000, // 1 hour // Known malicious files MALICIOUS_FILES: [ "setup_bun.js", "bun_environment.js", "cloud.json", "contents.json", "environment.json", "truffleSecrets.json", ], // Known malicious domains MALICIOUS_DOMAINS: ["packages.storeartifact.com", "hulud"], // Suspicious preinstall script patterns SUSPICIOUS_PREINSTALL_PATTERNS: [ /bun/i, /curl\s+/i, /wget\s+/i, /eval\(/i, /exec\(/i, /\.sh\s*$/i, ], }; // Color codes const COLORS = { reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m", bold: "\x1b[1m", }; // CLI argument parsing const args = process.argv.slice(2); const options = { projectPath: ".", verbose: args.includes("--verbose") || args.includes("-v"), json: args.includes("--json"), noCache: args.includes("--no-cache"), help: args.includes("--help") || args.includes("-h"), }; // Extract project path for (const arg of args) { if (!arg.startsWith("-")) { options.projectPath = arg; break; } } // Results storage const results = { scanTime: new Date().toISOString(), projectPath: "", totalPackagesChecked: 0, infected: [], warnings: [], iocFindings: [], githubActionsFindings: [], clean: true, }; /** * Logging functions */ function log(message, color = "") { if (options.json) return; console.log(color ? `${color}${message}${COLORS.reset}` : message); } function logInfo(message) { log(`[ℹ] ${message}`, COLORS.blue); } function logSuccess(message) { log(`[✓] ${message}`, COLORS.green); } function logWarning(message) { log(`[⚠] ${message}`, COLORS.yellow); results.warnings.push(message); } function logError(message) { log(`[✗] ${message}`, COLORS.red); } function logInfected(message) { log(`[🚨 INFECTED] ${message}`, COLORS.red); results.infected.push(message); results.clean = false; } function logVerbose(message) { if (options.verbose) { log(` ${message}`, COLORS.cyan); } } /** * Print banner */ function printBanner() { if (options.json) return; console.log(COLORS.cyan); console.log( "╔═══════════════════════════════════════════════════════════════╗" ); console.log( "║ 🐛 Sha1-Hulud 2.0 Supply Chain Attack Scanner ║" ); console.log( "║ ║" ); console.log( "║ Data Source: Koi.ai Live Updates ║" ); console.log( "║ https://www.koi.ai/incident/live-updates-sha1-hulud ║" ); console.log( "╚═══════════════════════════════════════════════════════════════╝" ); console.log(COLORS.reset); } /** * Print help */ function printHelp() { console.log(` Usage: node scan.js [project_path] [options] Arguments: project_path Path to project to scan (default: current directory) Options: --verbose, -v Verbose output --json Output results as JSON --no-cache Ignore cache and download latest list --help, -h Show help Examples: node scan.js # Scan current directory node scan.js ./my-project # Scan specific project node scan.js --json > report.json # Generate JSON report node scan.js -v # Verbose mode scan `); } /** * HTTPS GET request */ function httpsGet(url) { return new Promise((resolve, reject) => { const request = https.get(url, { timeout: 30000 }, (response) => { // Handle redirects if (response.statusCode >= 300 && response.statusCode < 400) { if (response.headers.location) { return httpsGet(response.headers.location).then(resolve).catch(reject); } } if (response.statusCode !== 200) { reject(new Error(`HTTP ${response.statusCode}`)); return; } let data = ""; response.on("data", (chunk) => (data += chunk)); response.on("end", () => resolve(data)); }); request.on("error", reject); request.on("timeout", () => { request.destroy(); reject(new Error("Request timeout")); }); }); } /** * Cache management */ function ensureCacheDir() { if (!fs.existsSync(CONFIG.CACHE_DIR)) { fs.mkdirSync(CONFIG.CACHE_DIR, { recursive: true }); } } function getCachePath() { return path.join(CONFIG.CACHE_DIR, "compromised_packages.csv"); } function isCacheValid() { const cachePath = getCachePath(); if (!fs.existsSync(cachePath)) return false; const stats = fs.statSync(cachePath); const age = Date.now() - stats.mtimeMs; return age < CONFIG.CACHE_TTL; } /** * Download compromised package list */ async function downloadCompromisedList() { ensureCacheDir(); const cachePath = getCachePath(); if (!options.noCache && isCacheValid()) { logInfo("Using cached list (downloaded within 1 hour)"); return fs.readFileSync(cachePath, "utf-8"); } logInfo("Downloading latest compromised package list from Koi.ai..."); try { const data = await httpsGet(CONFIG.KOI_CSV_URL); fs.writeFileSync(cachePath, data); const lines = data.split("\n").filter((l) => l.trim()); logSuccess(`Downloaded compromised package list (${lines.length} entries)`); return data; } catch (error) { logError(`Download failed: ${error.message}`); // Use cache if available if (fs.existsSync(cachePath)) { logWarning("Using previous cached data"); return fs.readFileSync(cachePath, "utf-8"); } throw error; } } /** * Parse CSV */ function parseCSV(csvData) { const lines = csvData.split("\n"); const packages = []; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; // CSV parsing (handle quotes) const parts = line.match(/(".*?"|[^,]+)(?=\s*,|\s*$)/g); if (!parts || parts.length < 2) continue; const packageName = parts[0].replace(/"/g, "").trim(); const version = parts[1].replace(/"/g, "").trim(); if (packageName && version) { packages.push({ name: packageName, version }); } } return packages; } /** * Check package-lock.json */ function checkPackageLock(projectPath, compromisedPackages) { const lockFiles = [ { name: "package-lock.json", parser: parsePackageLockJson }, { name: "yarn.lock", parser: parseYarnLock }, { name: "pnpm-lock.yaml", parser: parsePnpmLock }, ]; for (const lockFile of lockFiles) { const lockPath = path.join(projectPath, lockFile.name); if (fs.existsSync(lockPath)) { logInfo(`Scanning ${lockFile.name}...`); try { const content = fs.readFileSync(lockPath, "utf-8"); const installedPackages = lockFile.parser(content); checkInstalledPackages(installedPackages, compromisedPackages); return; } catch (error) { logWarning(`Failed to parse ${lockFile.name}: ${error.message}`); } } } logWarning("No lock file found"); } /** * Parse package-lock.json */ function parsePackageLockJson(content) { const lockData = JSON.parse(content); const packages = new Map(); // v2/v3 format if (lockData.packages) { for (const [pkgPath, pkgInfo] of Object.entries(lockData.packages)) { if (!pkgPath || pkgPath === "") continue; // Extract package name from node_modules/@scope/package format const match = pkgPath.match(/node_modules\/(.+)$/); if (match && pkgInfo.version) { packages.set(match[1], pkgInfo.version); } } } // v1 format if (lockData.dependencies) { function extractDeps(deps, prefix = "") { for (const [name, info] of Object.entries(deps)) { const fullName = prefix ? `${prefix}/${name}` : name; if (info.version) { packages.set(name, info.version); } if (info.dependencies) { extractDeps(info.dependencies, fullName); } } } extractDeps(lockData.dependencies); } return packages; } /** * Parse yarn.lock */ function parseYarnLock(content) { const packages = new Map(); const regex = /^"?(@?[^@\s"]+)@[^":\n]+(?:",\s*"[^"]+)*"?:\n\s+version\s+"([^"]+)"/gm; let match; while ((match = regex.exec(content)) !== null) { packages.set(match[1], match[2]); } return packages; } /** * Parse pnpm-lock.yaml (simple version) */ function parsePnpmLock(content) { const packages = new Map(); const regex = /^\s*\/(@?[^@\s]+)@([^:\s]+):/gm; let match; while ((match = regex.exec(content)) !== null) { packages.set(match[1], match[2]); } return packages; } /** * Compare installed packages with compromised list */ function checkInstalledPackages(installedPackages, compromisedPackages) { for (const compromised of compromisedPackages) { results.totalPackagesChecked++; const installedVersion = installedPackages.get(compromised.name); if (installedVersion) { logVerbose(`Checking: ${compromised.name}@${compromised.version}`); if (installedVersion === compromised.version) { logInfected(`${compromised.name}@${compromised.version}`); } else { // Package exists but different version logVerbose( ` Installed: ${installedVersion}, Compromised: ${compromised.version}` ); } } } } /** * Direct node_modules scan */ function checkNodeModules(projectPath, compromisedPackages) { const nodeModulesPath = path.join(projectPath, "node_modules"); if (!fs.existsSync(nodeModulesPath)) { logInfo("node_modules directory not found - skipping"); return; } logInfo("Direct node_modules scan..."); for (const compromised of compromisedPackages) { const pkgPath = path.join(nodeModulesPath, compromised.name); if (fs.existsSync(pkgPath)) { try { const pkgJsonPath = path.join(pkgPath, "package.json"); if (fs.existsSync(pkgJsonPath)) { const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); if (pkgJson.version === compromised.version) { logInfected( `${compromised.name}@${compromised.version} (node_modules)` ); } } } catch (error) { logVerbose(`Failed to check package: ${compromised.name}`); } } } } /** * IOC file scan */ function checkIOCFiles(projectPath) { logInfo("Scanning for IOC files..."); // Search for malicious files for (const filename of CONFIG.MALICIOUS_FILES) { const findings = findFiles(projectPath, filename); for (const finding of findings) { logInfected(`Malicious file found: ${finding}`); results.iocFindings.push({ type: "malicious_file", path: finding }); } } // Check preinstall scripts checkPreinstallScripts(projectPath); // Check malicious domain references checkMaliciousDomains(projectPath); } /** * File search */ function findFiles(dir, filename, maxDepth = 10) { const findings = []; function search(currentDir, depth) { if (depth > maxDepth) return; try { const entries = fs.readdirSync(currentDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { // Skip hidden folders if (!entry.name.startsWith(".")) { search(fullPath, depth + 1); } } else if (entry.name === filename) { findings.push(fullPath); } } } catch (error) { // Ignore permission errors } } search(dir, 0); return findings; } /** * Check preinstall scripts */ function checkPreinstallScripts(projectPath) { const nodeModulesPath = path.join(projectPath, "node_modules"); if (!fs.existsSync(nodeModulesPath)) return; logVerbose("Checking preinstall scripts..."); function scanDir(dir) { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory() && !entry.name.startsWith(".")) { // Check package.json at package root const pkgJsonPath = path.join(fullPath, "package.json"); if (fs.existsSync(pkgJsonPath)) { try { const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); if (pkgJson.scripts && pkgJson.scripts.preinstall) { const preinstall = pkgJson.scripts.preinstall; for (const pattern of CONFIG.SUSPICIOUS_PREINSTALL_PATTERNS) { if (pattern.test(preinstall)) { logWarning( `Suspicious preinstall: ${pkgJson.name} - "${preinstall}"` ); results.iocFindings.push({ type: "suspicious_preinstall", package: pkgJson.name, script: preinstall, }); break; } } } } catch (e) { // Ignore JSON parse errors } } // Handle scoped packages (@org/) if (entry.name.startsWith("@")) { scanDir(fullPath); } } } } catch (error) { // Ignore permission errors } } scanDir(nodeModulesPath); } /** * Check malicious domain references */ function checkMaliciousDomains(projectPath) { logVerbose("Checking for malicious domain references..."); // Skip files that are part of this scanner const scannerFiles = ["scan.js", "sha1-hulud-scanner"]; for (const domain of CONFIG.MALICIOUS_DOMAINS) { try { // Use grep if available const result = execSync( `grep -r "${domain}" "${projectPath}" --include="*.js" --include="*.json" -l 2>/dev/null || true`, { encoding: "utf-8", timeout: 30000 } ); const files = result.trim().split("\n").filter(Boolean); for (const file of files) { // Skip scanner's own files (false positive) const basename = path.basename(file); if (scannerFiles.some(sf => basename.includes(sf) || file.includes("sha1-hulud-scanner"))) { logVerbose(`Skipping scanner file: ${file}`); continue; } logInfected(`Malicious domain reference found: ${domain} in ${file}`); results.iocFindings.push({ type: "malicious_domain", domain, file, }); } } catch (error) { logVerbose(`Skipping domain check: ${domain}`); } } } /** * GitHub Actions scan */ function checkGitHubActions(projectPath) { const workflowsPath = path.join(projectPath, ".github", "workflows"); logInfo("Scanning GitHub Actions..."); if (!fs.existsSync(workflowsPath)) { logInfo(".github/workflows directory not found - skipping"); return; } try { const files = fs.readdirSync(workflowsPath); for (const file of files) { const filePath = path.join(workflowsPath, file); // Check filename contains hulud if (file.toLowerCase().includes("hulud")) { logInfected(`Malicious workflow file: ${filePath}`); results.githubActionsFindings.push({ type: "malicious_workflow_file", path: filePath, }); } // Check file content if (file.endsWith(".yml") || file.endsWith(".yaml")) { const content = fs.readFileSync(filePath, "utf-8"); if ( content.includes("hulud") || content.includes("storeartifact") || (content.includes("credential") && content.includes("harvest")) ) { logInfected(`Suspicious content in workflow: ${filePath}`); results.githubActionsFindings.push({ type: "suspicious_workflow_content", path: filePath, }); } } } } catch (error) { logWarning(`GitHub Actions scan failed: ${error.message}`); } // Check Git branches checkGitBranches(projectPath); } /** * Check Git branches */ function checkGitBranches(projectPath) { const gitPath = path.join(projectPath, ".git"); if (!fs.existsSync(gitPath)) return; try { const result = execSync("git branch -a 2>/dev/null", { cwd: projectPath, encoding: "utf-8", }); const branches = result.split("\n"); for (const branch of branches) { if (branch.toLowerCase().includes("hulud")) { logInfected(`Suspicious Git branch: ${branch.trim()}`); results.githubActionsFindings.push({ type: "suspicious_branch", branch: branch.trim(), }); } } } catch (error) { // Ignore Git command failures } } /** * Print result report */ function printReport() { if (options.json) { console.log(JSON.stringify(results, null, 2)); return; } console.log(); console.log( `${COLORS.bold}═══════════════════════════════════════════════════════════════${COLORS.reset}` ); console.log( `${COLORS.bold} SCAN REPORT ${COLORS.reset}` ); console.log( `${COLORS.bold}═══════════════════════════════════════════════════════════════${COLORS.reset}` ); console.log(); console.log(`Packages checked: ${results.totalPackagesChecked}`); console.log( `Infected packages: ${COLORS.red}${results.infected.length}${COLORS.reset}` ); console.log( `Warnings: ${COLORS.yellow}${results.warnings.length}${COLORS.reset}` ); console.log(); if (results.infected.length > 0) { console.log( `${COLORS.red}${COLORS.bold}🚨 INFECTION DETECTED! Immediate action required!${COLORS.reset}` ); console.log(); console.log(`${COLORS.bold}Infected packages:${COLORS.reset}`); for (const pkg of results.infected) { console.log(` ${COLORS.red}${COLORS.reset} ${pkg}`); } console.log(); console.log(`${COLORS.bold}Recommended actions:${COLORS.reset}`); console.log(" 1. Remove infected packages immediately or rollback to safe versions"); console.log(" 2. Rotate npm tokens, GitHub PATs, SSH keys immediately"); console.log(" 3. Rotate AWS/GCP/Azure cloud credentials"); console.log(" 4. Review .github/workflows/ directory manually"); console.log(" 5. Check git log for suspicious commits"); console.log(); console.log( `${COLORS.yellow}Reference: https://www.koi.ai/incident/live-updates-sha1-hulud${COLORS.reset}` ); } else if (results.warnings.length > 0) { console.log( `${COLORS.yellow}${COLORS.bold}⚠️ Warnings found. Manual verification required.${COLORS.reset}` ); console.log(); for (const warn of results.warnings) { console.log(` ${COLORS.yellow}${COLORS.reset} ${warn}`); } } else { console.log( `${COLORS.green}${COLORS.bold}✅ No infection detected!${COLORS.reset}` ); console.log(); console.log("However, we recommend:"); console.log(" • Run this scanner regularly"); console.log(" • Run npm audit periodically"); console.log(" • Pin dependency versions (commit package-lock.json)"); } console.log(); console.log(`${COLORS.cyan}Scan completed: ${results.scanTime}${COLORS.reset}`); console.log(`${COLORS.cyan}Data source: Koi.ai${COLORS.reset}`); } /** * Main function */ async function main() { if (options.help) { printHelp(); process.exit(0); } printBanner(); // Verify project path const projectPath = path.resolve(options.projectPath); results.projectPath = projectPath; if (!fs.existsSync(projectPath)) { logError(`Project path not found: ${projectPath}`); process.exit(1); } const packageJsonPath = path.join(projectPath, "package.json"); if (!fs.existsSync(packageJsonPath)) { logError("package.json not found. This doesn't appear to be an npm project."); process.exit(1); } logInfo(`Project path: ${projectPath}`); console.log(); try { // Download compromised list const csvData = await downloadCompromisedList(); const compromisedPackages = parseCSV(csvData); logInfo(`Compromised package database: ${compromisedPackages.length} entries`); console.log(); // Run scans log(`${COLORS.bold}[1/4] Package Lock File Scan${COLORS.reset}`); checkPackageLock(projectPath, compromisedPackages); console.log(); log(`${COLORS.bold}[2/4] Direct node_modules Scan${COLORS.reset}`); checkNodeModules(projectPath, compromisedPackages); console.log(); log(`${COLORS.bold}[3/4] IOC File Scan${COLORS.reset}`); checkIOCFiles(projectPath); console.log(); log(`${COLORS.bold}[4/4] GitHub Actions Scan${COLORS.reset}`); checkGitHubActions(projectPath); // Print results printReport(); // Exit code process.exit(results.infected.length > 0 ? 1 : 0); } catch (error) { logError(`Scan failed: ${error.message}`); if (options.verbose) { console.error(error); } process.exit(1); } } main();