worm-sign
Version:
A prescient scanner to detect and banish Shai Hulud malware from your dependencies.
341 lines (340 loc) • 15.8 kB
JavaScript
;
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();