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
JavaScript
/**
* 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();