UNPKG

worm-sign

Version:

A prescient scanner to detect and banish Shai Hulud malware from your dependencies.

396 lines (395 loc) 17.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SOURCES = exports.loadConfig = exports.parseCsv = exports.loadCsv = exports.analyzeScripts = void 0; exports.loadJson = loadJson; exports.fetchFromApi = fetchFromApi; exports.fetchCompromisedPackages = fetchCompromisedPackages; exports.scanProject = scanProject; const fs = __importStar(require("fs")); const crypto = __importStar(require("crypto")); const path = __importStar(require("path")); const https = __importStar(require("https")); const arborist_1 = __importDefault(require("@npmcli/arborist")); const analysis_1 = require("./analysis"); Object.defineProperty(exports, "analyzeScripts", { enumerable: true, get: function () { return analysis_1.analyzeScripts; } }); const entropy_1 = require("./heuristics/entropy"); const signatures_1 = require("./generated/signatures"); const validators_1 = require("./utils/validators"); const csv_1 = require("./utils/csv"); Object.defineProperty(exports, "loadCsv", { enumerable: true, get: function () { return csv_1.loadCsv; } }); Object.defineProperty(exports, "parseCsv", { enumerable: true, get: function () { return csv_1.parseCsv; } }); const yarn_1 = __importDefault(require("./package-managers/yarn")); const pnpm_1 = __importDefault(require("./package-managers/pnpm")); var config_1 = require("./config"); Object.defineProperty(exports, "loadConfig", { enumerable: true, get: function () { return config_1.loadConfig; } }); function loadJson(filePath) { try { const raw = fs.readFileSync(filePath, 'utf8'); const json = JSON.parse(raw); if (Array.isArray(json)) { // Handle array of packages directly return json; } if (json.packages && Array.isArray(json.packages)) { return json.packages; } console.warn(`Warning: JSON at ${filePath} does not contain a "packages" array or is not an array.`); return []; } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.warn(`Warning: Failed to parse JSON ${filePath}: ${msg}`); return []; } } exports.SOURCES = { datadog: { url: 'https://raw.githubusercontent.com/DataDog/indicators-of-compromise/main/shai-hulud-2.0/consolidated_iocs.csv', type: 'csv', }, koi: { url: 'https://docs.google.com/spreadsheets/d/16aw6s7mWoGU7vxBciTEZSaR5HaohlBTfVirvI-PypJc/export?format=csv&gid=1289659284', type: 'csv', }, // TODO: Update IBM URL as it is currently returning 404 /* ibm: { url: 'https://raw.githubusercontent.com/IBM/security-intelligence/master/threat-intel/shai-hulud.csv', type: 'csv', }, */ }; function fetchFromApi(sourceConfig) { const { url, type, insecure } = sourceConfig; if (!url || !type) { return Promise.reject(new Error('Invalid source configuration: missing url or type')); } const fetchUrl = async (targetUrl, attempt = 1) => { // SSRF Protection: Resolve and validate IP first const resolvedIp = await (0, validators_1.validateUrl)(targetUrl); const urlObj = new URL(targetUrl); return new Promise((resolve, reject) => { if (attempt > 5) { reject(new Error('Too many redirects')); return; } const options = { hostname: resolvedIp, port: urlObj.port || 443, path: urlObj.pathname + urlObj.search, servername: urlObj.hostname, // SNI headers: { Host: urlObj.hostname, // Host header Accept: type === 'json' ? 'application/json' : 'text/csv', 'User-Agent': 'worm-sign', }, rejectUnauthorized: !insecure, }; const req = https.get(options, (res) => { if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { // Follow redirect const redirectUrl = res.headers.location; if (!redirectUrl) { reject(new Error('Redirect without location header')); return; } // Recursive call will validate the new URL fetchUrl(redirectUrl, attempt + 1) .then(resolve) .catch(reject); return; } if (res.statusCode && res.statusCode !== 200) { reject(new Error(`API request failed with status ${res.statusCode}`)); res.resume(); // Consume response return; } let data = ''; res.on('data', (chunk) => (data += chunk)); res.on('end', () => { try { if (type === 'json') { const json = JSON.parse(data); if (!Array.isArray(json.packages)) { reject(new Error('Invalid API response: "packages" field must be an array.')); return; } resolve(json.packages); } else if (type === 'csv') { resolve((0, csv_1.parseCsv)(data)); } } catch (e) { const msg = e instanceof Error ? e.message : String(e); reject(new Error(`Failed to parse API response: ${msg}`)); } }); }); req.on('error', reject); req.setTimeout(5000, () => { req.destroy(); reject(new Error('API request timed out after 5000ms')); }); }); }; return fetchUrl(url); } async function fetchCompromisedPackages(sources) { const allPackages = []; const errors = []; for (const config of sources) { const name = config.name || config.url; try { const pkgs = await fetchFromApi(config); allPackages.push(...pkgs); } catch (error) { const msg = error instanceof Error ? error.message : String(error); errors.push(`Failed to fetch from ${name}: ${msg}`); } } if (allPackages.length === 0 && errors.length > 0) { // If everything failed, we still return the errors, but maybe we should let the caller decide if it's fatal? // The caller (CLI) will see 0 packages and N errors. } // Deduplicate const uniqueMap = new Map(); allPackages.forEach((p) => { const key = `${p.name}@${p.version}`; if (!uniqueMap.has(key)) { uniqueMap.set(key, p); } }); return { packages: Array.from(uniqueMap.values()), errors }; } function buildCompromisedMap(entries) { const map = new Map(); for (const { name, version } of entries) { if (!name) continue; const info = map.get(name) ?? { versions: new Set(), wildcard: false, hashes: new Set() }; const ver = version?.trim(); if (!ver || ver === '*' || ver.toLowerCase() === 'any') { info.wildcard = true; } else { info.versions.add(ver); } const entry = entries.find((e) => e.name === name && e.version === version); if (entry?.integrity) { info.hashes.add(entry.integrity); } map.set(name, info); } return map; } function shouldFlag(compromisedInfo, version, integrity) { if (!compromisedInfo) return false; if (compromisedInfo.wildcard) return true; if (compromisedInfo.versions.has(version)) return true; if (integrity && compromisedInfo.hashes.size > 0) { for (const hash of compromisedInfo.hashes) { if (integrity.includes(hash)) return true; } } return false; } async function scanProject(projectRoot, compromisedListSource, options) { const debug = (msg) => { if (options?.debug) console.log(`[DEBUG] ${msg}`); }; const resolvedRoot = path.resolve(projectRoot); debug(`Scanning project at: ${resolvedRoot}`); if (!fs.existsSync(resolvedRoot)) { throw new Error(`Project root does not exist: ${resolvedRoot}`); } const packageJsonPath = path.join(resolvedRoot, 'package.json'); const allWarnings = []; const allFindings = []; let compromisedEntries; if (Array.isArray(compromisedListSource)) { compromisedEntries = compromisedListSource; } else if (typeof compromisedListSource === 'string') { if (!fs.existsSync(compromisedListSource)) { throw new Error(`Compromised list not found at ${compromisedListSource}`); } compromisedEntries = (0, csv_1.loadCsv)(compromisedListSource); } else { throw new Error('Invalid compromised list source. Must be a file path or an array.'); } if (!fs.existsSync(packageJsonPath)) { throw new Error(`package.json not found at ${packageJsonPath}`); } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); debug('Loaded package.json'); // 1. Analyze Root Scripts const scriptFindings = (0, analysis_1.analyzeScripts)(packageJson, options?.config); allFindings.push(...scriptFindings); // Backwards compatibility: push messages to warnings scriptFindings.forEach((f) => allWarnings.push(f.message)); // 2. Check for known malware files in root const KNOWN_MALWARE_HASHES = new Set([ 'a3894003ad1d293ba96d77881ccd2071446dc3f65f434669b49b3da92421901a', // setup_bun.js '62ee164b9b306250c1172583f138c9614139264f889fa99614903c12755468d0', // bun_environment.js 'cbb9bc5a8496243e02f3cc080efbe3e4a1430ba0671f2e43a202bf45b05479cd', // bun_environment.js 'f099c5d9ec417d4445a0328ac0ada9cde79fc37410914103ae9c609cbc0ee068', // bun_environment.js ]); for (const file of signatures_1.MALWARE_FILENAMES) { const filePath = path.join(resolvedRoot, file); if (fs.existsSync(filePath)) { try { const stats = fs.statSync(filePath); const isLarge = stats.size > 5 * 1024 * 1024; const hash = crypto.createHash('sha256'); // Only calculate entropy if file is large (optimization) const entropyCalc = isLarge ? new entropy_1.EntropyCalculator() : null; const stream = fs.createReadStream(filePath); await new Promise((resolve, reject) => { stream.on('data', (chunk) => { hash.update(chunk); if (entropyCalc) { entropyCalc.update(chunk); } }); stream.on('error', reject); stream.on('end', resolve); }); const finalHash = hash.digest('hex'); if (KNOWN_MALWARE_HASHES.has(finalHash)) { allWarnings.push(`CONFIRMED MALWARE file detected: '${file}' (Hash match: ${finalHash})`); } else if (isLarge && entropyCalc) { const entropy = entropyCalc.digest(); // Threshold 7.0 for packed malware in JS context if (entropy > 7.0) { allWarnings.push(`HIGH RISK file detected: '${file}' (High Entropy: ${entropy.toFixed(2)}, Size: ${stats.size} bytes)`); } allWarnings.push(`Suspicious file detected: '${file}' (associated with Shai Hulud)`); } else { allWarnings.push(`Suspicious file detected: '${file}' (associated with Shai Hulud)`); } } catch (e) { const msg = e instanceof Error ? e.message : String(e); allWarnings.push(`Suspicious file detected: '${file}' (associated with Shai Hulud) - could not read: ${msg}`); } } } // 3. Scan Dependencies const compromisedMap = buildCompromisedMap(compromisedEntries); const matches = []; let treeLoaded = false; // Try npm (Arborist) first try { const arb = new arborist_1.default({ path: resolvedRoot }); const tree = await arb.loadVirtual(); debug(`Loaded dependency tree with ${tree.inventory.size} nodes.`); treeLoaded = true; for (const node of tree.inventory.values()) { const { name, version, integrity } = node; const info = compromisedMap.get(name); if (shouldFlag(info, version, integrity)) { const section = node.dev ? 'devDependencies' : 'dependencies'; matches.push({ name, version, section }); } } } catch (e) { const msg = e instanceof Error ? e.message : String(e); debug(`Arborist failed: ${msg}`); // If it's not a missing lockfile error, rethrow if (!msg.includes('ENOENT') && !msg.includes('lockfile') && !msg.includes('shrinkwrap')) { throw new Error(`Failed to load dependency tree: ${msg}`); } } // Fallback to Yarn or pnpm if npm failed if (!treeLoaded) { debug('Checking for Yarn or pnpm lockfiles...'); const handlers = [yarn_1.default, pnpm_1.default]; let handlerFound = false; for (const handler of handlers) { const lockFile = handler.findLockFile(resolvedRoot); if (lockFile) { debug(`Found ${handler.label} lockfile: ${lockFile}`); handlerFound = true; const result = handler.loadLockPackages(lockFile); if (!result.success) { result.warnings.forEach((w) => allWarnings.push(w)); continue; } // Iterate over loaded packages for (const [name, versions] of result.packages.entries()) { const info = compromisedMap.get(name); if (!info) continue; for (const version of versions) { // Get integrity if available let integrity; if (result.packageIntegrity) { const pkgIntegrity = result.packageIntegrity.get(name); if (pkgIntegrity) { integrity = pkgIntegrity.get(version); } } if (shouldFlag(info, version, integrity)) { matches.push({ name, version, section: 'locked' }); } } } break; // Stop after first successful handler } } if (!handlerFound) { throw new Error('No lockfile found (package-lock.json, yarn.lock, pnpm-lock.yaml). Please install dependencies.'); } } return { matches, warnings: allWarnings, findings: allFindings }; }