UNPKG

worm-sign

Version:

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

341 lines (340 loc) 15.8 kB
#!/usr/bin/env node "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 }); const path = __importStar(require("path")); const fs = __importStar(require("fs")); const commander_1 = require("commander"); const index_1 = require("../src/index"); const csv_1 = require("../src/utils/csv"); const sarif_1 = require("../src/formatters/sarif"); const package_json_1 = __importDefault(require("../package.json")); const version = package_json_1.default.version; const scriptDir = __dirname; // Default to the bundled list in the package // Handle both ts-node (bin/scan.ts -> root is ..) and dist (dist/bin/scan.js -> root is ../..) let defaultDataDir = path.resolve(scriptDir, '..', '..'); if (fs.existsSync(path.join(scriptDir, '..', 'package.json'))) { // If package.json is in the parent of bin, then that parent is the root. // This happens if we are running from root/bin/scan.ts (ts-node) // BUT if we are in dist/bin, dist/package.json might not exist, so we go up two levels. // However, if dist/package.json DOES exist (e.g. copied for distribution), we might want that. // But here we want the source root where 'sources' dir lives. // Let's check if 'sources' exists in the candidate dir. const candidate = path.resolve(scriptDir, '..'); if (fs.existsSync(path.join(candidate, 'sources'))) { defaultDataDir = candidate; } } function resolveProjectRoot(override) { return override ? path.resolve(override) : process.env.PKG_SCAN_ROOT ? path.resolve(process.env.PKG_SCAN_ROOT) : process.cwd(); } function resolveDataDir() { const override = process.env.PKG_SCAN_DATA_ROOT; const dir = override ? path.resolve(override) : defaultDataDir; return dir; } commander_1.program .name('worm-sign') .description('Scan your project for packages compromised by the Shai Hulud malware (supports name/version and hash detection).') .version(version) .option('-f, --fetch', 'Fetch the latest banned packages from the API') .option('--offline', 'Disable fetching of remote sources') .option('-u, --url <url>', 'Custom API URL to fetch banned packages from') .option('--data-format <format>', 'Data format for custom URL (json, csv)', 'json') .option('-p, --path <path>', 'Path to the project to scan (defaults to current directory)') .option('--format <format>', 'Output format (text, sarif)', 'text') .option('--no-cache', 'Disable caching of API responses') .option('--install-hook', 'Install a pre-commit hook to run worm-sign') .option('--dry-run', 'Run scan but always exit with 0 (useful for CI)') .option('--insecure', 'Disable SSL certificate verification (use with caution)') .option('--debug', 'Enable debug logging') .action(async (options) => { const config = (0, index_1.loadConfig)(); // Apply config defaults if (config.offline && options.offline === undefined) { options.offline = true; } if (options.offline) { options.fetch = false; } // Dynamic imports for ESM libraries const { default: chalk } = await import('chalk'); const { default: ora } = await import('ora'); const { default: boxen } = await import('boxen'); const { default: gradient } = await import('gradient-string'); // Dune-themed gradient const duneGradient = gradient(['#F4A460', '#D2691E', '#8B4513']); // Sandy colors if (options.installHook) { try { const hooksDir = path.join(process.cwd(), '.git', 'hooks'); if (!fs.existsSync(hooksDir)) { console.error(chalk.red('Error: .git/hooks directory not found. Is this a git repository?')); process.exit(1); } const hookPath = path.join(hooksDir, 'pre-commit'); const hookScript = `#!/bin/sh # worm-sign pre-commit hook echo "🪱 Running worm-sign..." npx worm-sign --fetch `; fs.writeFileSync(hookPath, hookScript, { mode: 0o755 }); console.log(chalk.green('✅ Pre-commit hook installed successfully!')); console.log(chalk.dim('worm-sign will now run before every commit.')); process.exit(0); } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(chalk.red(`Error installing hook: ${msg}`)); process.exit(1); } } if (options.format === 'text') { console.log(boxen(duneGradient('WORM SIGN\nShai Hulud Scanner'), { padding: 1, borderStyle: 'round', borderColor: 'yellow', title: 'v' + version, titleAlignment: 'right', })); } try { const projectRoot = resolveProjectRoot(options.path); const dataDir = resolveDataDir(); const sourcesDir = path.join(dataDir, 'sources'); const allCompromised = []; const sourcesToFetch = []; let foundSources = false; // 1. Load from sources directory if (fs.existsSync(sourcesDir) && fs.statSync(sourcesDir).isDirectory()) { const files = fs.readdirSync(sourcesDir); for (const file of files) { const filePath = path.join(sourcesDir, file); try { if (file.endsWith('.csv')) { const packages = (0, csv_1.loadCsv)(filePath); if (packages.length > 0) { allCompromised.push(...packages); foundSources = true; if (options.format === 'text') { console.log(chalk.blue(`Loaded ${packages.length} packages from: ${file}`)); } } } else if (file.endsWith('.json')) { const raw = fs.readFileSync(filePath, 'utf8'); try { const json = JSON.parse(raw); if (Array.isArray(json) || (json.packages && Array.isArray(json.packages))) { const packages = (0, index_1.loadJson)(filePath); if (packages.length > 0) { allCompromised.push(...packages); foundSources = true; if (options.format === 'text') { console.log(chalk.blue(`Loaded ${packages.length} packages from: ${file}`)); } } } else if (json.url && json.type) { // Remote source config if (!options.offline) { sourcesToFetch.push({ ...json, name: file }); } } } catch (e) { console.warn(chalk.yellow(`Warning: Failed to parse JSON ${file}: ${e}`)); } } } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.warn(chalk.yellow(`Warning: Failed to load ${file}: ${msg}`)); } } } // 2. Add custom URL if provided if (options.url) { sourcesToFetch.push({ url: options.url, type: options.dataFormat, name: 'custom-cli', insecure: options.insecure, }); } // 3. Fetch remote sources if (sourcesToFetch.length > 0) { // Filter by allowedSources if configured if (config.allowedSources && config.allowedSources.length > 0) { const allowed = new Set(config.allowedSources); const filtered = sourcesToFetch.filter((s) => s.name && allowed.has(s.name)); if (filtered.length < sourcesToFetch.length) { const blocked = sourcesToFetch.filter((s) => s.name && !allowed.has(s.name)); if (options.format === 'text') { blocked.forEach((s) => console.warn(chalk.yellow(`Warning: Source '${s.name}' blocked by configuration.`))); } } sourcesToFetch.length = 0; sourcesToFetch.push(...filtered); } if (options.insecure) { sourcesToFetch.forEach((s) => (s.insecure = true)); } const spinner = options.format === 'text' ? ora(`Fetching from ${sourcesToFetch.length} remote source(s)...`).start() : null; try { const { packages: fetchedPackages, errors } = await (0, index_1.fetchCompromisedPackages)(sourcesToFetch); allCompromised.push(...fetchedPackages); foundSources = true; if (spinner) { if (errors.length > 0) { spinner.warn(chalk.yellow(`Fetched ${fetchedPackages.length} packages, but some sources failed:\n${errors.map((e) => ' - ' + e).join('\n')}`)); } else { spinner.succeed(chalk.green(`Fetched ${fetchedPackages.length} packages from remote sources.`)); } } else if (errors.length > 0) { errors.forEach((e) => console.warn(chalk.yellow(`Warning: ${e}`))); } } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (spinner) { spinner.fail(chalk.red(`Error: Unexpected failure during fetch: ${msg}`)); } } } // Fallback if (!foundSources && sourcesToFetch.length === 0) { const legacyPath = path.join(dataDir, 'vuls.csv'); if (fs.existsSync(legacyPath)) { const packages = (0, csv_1.loadCsv)(legacyPath); allCompromised.push(...packages); foundSources = true; if (options.format === 'text') { console.log(chalk.blue(`Using local compromised list: ${legacyPath}`)); } } } if (!foundSources && allCompromised.length === 0) { console.warn(chalk.yellow('Warning: No compromised packages loaded. Scan will likely pass.')); } const compromisedListSource = allCompromised; if (options.format === 'text') { console.log(chalk.blue(`Scanning project at: ${projectRoot}`)); } const { matches, warnings, findings } = await (0, index_1.scanProject)(projectRoot, compromisedListSource, { debug: options.debug, config, }); if (options.format === 'sarif') { const sarifReport = (0, sarif_1.generateSarif)(matches, warnings); console.log(JSON.stringify(sarifReport, null, 2)); } else { if (matches.length > 0) { const title = chalk.bold.red(`🚫 FOUND ${matches.length} COMPROMISED PACKAGES`); const list = matches .map((m) => chalk.red(` - ${m.name}@${m.version}`) + chalk.dim(` (found in ${m.section})`)) .join('\n'); console.log(boxen(`${title}\n\n${list}`, { padding: 1, borderStyle: 'double', borderColor: 'red', title: 'CRITICAL SECURITY ALERT', titleAlignment: 'center', })); } } if (matches.length > 0) { if (options.dryRun) { if (options.format === 'text') { console.log(chalk.yellow('\n[DRY RUN] Vulnerabilities found, but exiting with 0.')); } process.exit(0); } process.exit(1); } else { if (warnings.length > 0 && options.format === 'text') { console.log(''); if (findings && findings.length > 0) { findings.forEach((f) => { let color = chalk.yellow; if (f.severity === 'high') color = chalk.red; if (f.severity === 'critical') color = chalk.bgRed.white; console.warn(color(`${f.severity.toUpperCase()}: ${f.message}`)); }); } else { warnings.forEach((w) => console.warn(chalk.yellow(`Warning: ${w}`))); } } if (options.format === 'text') { console.log(chalk.green('\nNo wormsign detected.')); } process.exit(0); } } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(chalk.red(`Error: ${msg}`)); if (msg.includes('package.json not found')) { console.log(chalk.dim('Hint: Are you in the root directory of your Node.js project?')); } else if (msg.includes('no lockfile was found')) { console.log(chalk.dim("Hint: Run your package manager's install command (e.g., `npm install`) to generate a lockfile.")); } else if (msg.includes('Unable to determine which package manager')) { console.log(chalk.dim('Hint: Ensure you have a lockfile (package-lock.json, yarn.lock, pnpm-lock.yaml) or set the "packageManager" field in package.json.')); } else if (msg.includes('API request failed')) { console.log(chalk.dim('Hint: Check your internet connection or run with --offline to skip remote fetches.')); } process.exit(2); } }); commander_1.program.parse();