nullvoid
Version:
Detect malicious code
419 lines (369 loc) ⢠17.5 kB
JavaScript
const { program } = require('commander');
const colors = require('../colors');
const ora = require('ora');
const path = require('path');
const { scan } = require('../scan');
const packageJson = require('../package.json');
const { generateSarifOutput, writeSarifFile } = require('../lib/sarif');
// Import secure validation
const { InputValidator, SecurityError, ValidationError } = require('../lib/secureErrorHandler');
const { isNullVoidCode, isTestFile } = require('../lib/nullvoidDetection');
program
.name('nullvoid')
.description('Detect and invalidate malicious npm packages before they reach prod')
.version(packageJson.version);
program
.command('scan')
.description('Scan npm packages for malicious behavior')
.argument('[package]', 'Package name or directory path to scan (default: scan current directory)')
.option('-v, --verbose', 'Enable verbose output')
.option('-o, --output <format>', 'Output format (json, table, sarif)', 'table')
.option('-d, --depth <number>', 'Maximum dependency tree depth to scan', '3')
.option('--tree', 'Show dependency tree structure in output')
.option('--parallel', 'Enable parallel scanning for better performance', true)
.option('--no-parallel', 'Disable parallel scanning')
.option('--workers <number>', 'Number of parallel workers to use', 'auto')
.option('--all', 'Show all threats including low/medium severity')
.option('--sarif-file <path>', 'Write SARIF output to file (requires --output sarif)')
.action(async (packageName, options) => {
const spinner = ora('š Scanning ...').start();
try {
// Validate input parameters securely
let validatedPackageName = packageName;
if (packageName) {
try {
validatedPackageName = InputValidator.validatePackageName(packageName);
} catch (error) {
if (error instanceof SecurityError) {
spinner.fail('šØ Security Error');
console.error(colors.red('Security Error:'), error.message);
console.error(colors.red('Details:'), error.details);
process.exit(1);
} else if (error instanceof ValidationError) {
spinner.fail('ā Validation Error');
console.error(colors.red('Validation Error:'), error.message);
process.exit(1);
}
}
}
// Validate scan options
let validatedOptions;
try {
validatedOptions = InputValidator.validateScanOptions(options);
} catch (error) {
spinner.fail('ā Invalid Options');
console.error(colors.red('Invalid Options:'), error.message);
process.exit(1);
}
// Parse depth option
const scanOptions = {
...validatedOptions,
maxDepth: parseInt(validatedOptions.depth) || 3,
parallel: validatedOptions.parallel !== false, // Default to true unless explicitly disabled
workers: validatedOptions.workers === 'auto' ? undefined : parseInt(validatedOptions.workers) || undefined
};
// Progress callback to show current file with threat detection
let isFirstFile = true;
const progressCallback = (filePath) => {
// Get relative path from the original scan target directory
const originalScanTarget = packageName || process.cwd();
const relativePath = path.relative(originalScanTarget, filePath);
const displayPath = relativePath || path.basename(filePath);
const fs = require('fs');
try {
// Check if this is NullVoid's own code or test files
// Quick threat check for this file
const content = fs.readFileSync(filePath, 'utf8');
const threats = [];
let maxSeverity = 'LOW';
let hasThreats = false;
// Check for obfuscated patterns (HIGH severity)
if (content.includes('_0x') || content.match(/const\s+[a-z]\d+\s*=\s*[A-Z]/)) {
hasThreats = true;
if (isNullVoidCode(filePath)) {
if (!threats.includes('security tools')) threats.push('security tools');
maxSeverity = 'LOW';
} else if (isTestFile(filePath)) {
if (!threats.includes('test file')) threats.push('test file');
maxSeverity = 'LOW';
} else {
threats.push('OBFUSCATED_CODE');
maxSeverity = 'HIGH';
}
}
// Check for suspicious modules (CRITICAL severity)
if (content.includes('require(\'fs\')') || content.includes('require(\'child_process\')') ||
content.includes('require(\'eval\')') || content.includes('require(\'vm\')')) {
hasThreats = true;
if (isNullVoidCode(filePath)) {
if (!threats.includes('security tools')) threats.push('security tools');
maxSeverity = 'LOW';
} else if (isTestFile(filePath)) {
if (!threats.includes('test file')) threats.push('test file');
maxSeverity = 'LOW';
} else {
threats.push('SUSPICIOUS_MODULE');
maxSeverity = 'CRITICAL';
}
}
// Check for malicious code structure (CRITICAL severity)
if (content.match(/const\s+[a-z]\d+\s*=\s*[A-Z]\s*,\s*[a-z]\d+\s*=\s*[A-Z]/) ||
content.split('\n').some(line => line.length > 1000)) {
hasThreats = true;
if (isNullVoidCode(filePath)) {
if (!threats.includes('security tools')) threats.push('security tools');
maxSeverity = 'LOW';
} else if (isTestFile(filePath)) {
if (!threats.includes('test file')) threats.push('test file');
maxSeverity = 'LOW';
} else {
threats.push('MALICIOUS_CODE_STRUCTURE');
maxSeverity = 'CRITICAL';
}
}
// Display filename with threat info using severity-based colors
if (hasThreats) {
// Remove duplicates and join
const uniqueThreats = [...new Set(threats)];
const threatText = uniqueThreats.join(', ');
let colorFunc;
// Color code based on severity (same as results display)
if (maxSeverity === 'CRITICAL') {
colorFunc = colors.red; // Red for CRITICAL
} else if (maxSeverity === 'HIGH') {
colorFunc = colors.red; // Red for HIGH
} else if (maxSeverity === 'MEDIUM') {
colorFunc = colors.yellow; // Yellow for MEDIUM
} else {
colorFunc = colors.blue; // Blue for LOW
}
const prefix = isFirstFile ? '\n' : '';
console.log(`${prefix}š ${displayPath} ${colorFunc(`(detected: ${threatText})`)}`);
isFirstFile = false;
} else {
const prefix = isFirstFile ? '\n' : '';
console.log(`${prefix}š ${displayPath}`);
isFirstFile = false;
}
// Debug: Log progress updates
if (process.env.NULLVOID_DEBUG) {
console.log(`\nDEBUG: Scanning file: ${displayPath}`);
}
} catch (error) {
// If we can't read the file, just show the relative path
const prefix = isFirstFile ? '\n' : '';
console.log(`${prefix}š ${displayPath}`);
isFirstFile = false;
}
};
const results = await scan(validatedPackageName, scanOptions, progressCallback);
spinner.succeed('ā
Scan completed');
if (options.output === 'json') {
console.log(JSON.stringify(results, null, 2));
} else if (options.output === 'sarif') {
const sarifOutput = generateSarifOutput(results, options);
if (options.sarifFile) {
// Write SARIF to file
await writeSarifFile(sarifOutput, options.sarifFile);
console.log(colors.green(`ā
SARIF output written to: ${options.sarifFile}`));
} else {
// Output SARIF to console
console.log(JSON.stringify(sarifOutput, null, 2));
}
} else {
displayResults(results, options);
}
// Properly exit after successful completion
process.exit(0);
} catch (error) {
spinner.fail('ā Scan failed');
console.error(colors.red('Error:'), error.message);
process.exit(1);
}
});
program.parse();
function displayResults(results, options = {}) {
// Use the enhanced output from scan.js instead of custom logic
// The scan.js file already handles severity filtering and sorting
console.log(colors.bold('\nš NullVoid Scan Results\n'));
if (results.threats.length === 0) {
console.log(colors.green('ā
No threats detected'));
} else {
// Sort threats by severity (HIGH first, then MEDIUM, then LOW)
const severityOrder = { 'HIGH': 1, 'MEDIUM': 2, 'LOW': 3, 'CRITICAL': 0 };
const sortedThreats = results.threats.sort((a, b) => {
const aOrder = severityOrder[a.severity] || 4;
const bOrder = severityOrder[b.severity] || 4;
return aOrder - bOrder;
});
// Filter to only show HIGH and above severity (unless --all flag is used)
const showAllThreats = options.all;
const highSeverityThreats = showAllThreats ? sortedThreats : sortedThreats.filter(threat =>
threat.severity === 'HIGH' || threat.severity === 'CRITICAL'
);
if (highSeverityThreats.length === 0) {
console.log(colors.green('ā
No high-severity threats detected'));
if (!showAllThreats) {
console.log(colors.blue(`ā¹ļø ${results.threats.length - highSeverityThreats.length} low/medium severity threats were filtered out`));
console.log(colors.blue('š” Use --all flag to see all threats'));
}
} else {
const threatCount = showAllThreats ? results.threats.length : highSeverityThreats.length;
const severityText = showAllThreats ? 'threat(s)' : 'high-severity threat(s)';
console.log(colors.red(`ā ļø ${threatCount} ${severityText} detected:\n`));
highSeverityThreats.forEach((threat, index) => {
// Color code based on severity
let severityColor = '';
if (threat.severity === 'CRITICAL') {
severityColor = '\x1b[31m'; // Red for CRITICAL
} else if (threat.severity === 'HIGH') {
severityColor = '\x1b[31m'; // Red for HIGH
} else if (threat.severity === 'MEDIUM') {
severityColor = '\x1b[33m'; // Yellow for MEDIUM
} else if (threat.severity === 'LOW') {
severityColor = '\x1b[34m'; // Blue for LOW
}
console.log(`${index + 1}. ${threat.type}: ${threat.message}`);
if (threat.package) {
// Color code package paths
let packageColor = '';
if (threat.package.includes('š')) {
packageColor = '\x1b[32m'; // Green for local packages
} else if (threat.package.includes('š¦')) {
packageColor = '\x1b[33m'; // Yellow for registry packages
}
console.log(` Package: ${packageColor}${threat.package}\x1b[0m`);
}
if (threat.lineNumber) {
console.log(` Line: ${threat.lineNumber}`);
}
if (threat.sampleCode) {
console.log(` Sample: ${threat.sampleCode}`);
}
if (threat.severity) {
console.log(` Severity: ${severityColor}${threat.severity}\x1b[0m`);
}
if (threat.details) {
console.log(` Details: ${threat.details}`);
}
console.log('');
});
}
}
// Display directory structure for directory scans
if (results.directoryStructure && results.packagesScanned === 0) {
console.log(colors.blue(`\nš Directory Structure:`));
console.log(colors.gray(` ${results.directoryStructure.totalDirectories} directories: ${results.directoryStructure.directories.slice(0, 5).join(', ')}${results.directoryStructure.directories.length > 5 ? '...' : ''}`));
console.log(colors.gray(` ${results.directoryStructure.totalFiles} files: ${results.directoryStructure.files.slice(0, 5).join(', ')}${results.directoryStructure.files.length > 5 ? '...' : ''}`));
}
// Display dependency tree structure for package scans
if (results.dependencyTree && options.tree) {
console.log(colors.blue(`\nš³ Dependency Tree Structure:`));
displayDependencyTree(results.dependencyTree, 0, options.verbose);
}
// Show dependency tree summary
if (results.dependencyTree) {
const treeStats = analyzeTreeStats(results.dependencyTree);
console.log(colors.blue(`\nš Dependency Tree Analysis:`));
console.log(colors.gray(` Total packages scanned: ${treeStats.totalPackages}`));
console.log(colors.gray(` Max depth reached: ${treeStats.maxDepth}`));
console.log(colors.gray(` Packages with threats: ${treeStats.packagesWithThreats}`));
console.log(colors.gray(` Deep dependencies (depth ā„2): ${treeStats.deepDependencies}`));
}
// Show performance metrics
if (results.performance && options.verbose) {
console.log(colors.blue(`\nā” Performance Metrics:`));
console.log(colors.gray(` Cache hit rate: ${(results.performance.cacheHitRate * 100).toFixed(1)}%`));
console.log(colors.gray(` Packages per second: ${results.performance.packagesPerSecond.toFixed(1)}`));
console.log(colors.gray(` Network requests: ${results.performance.networkRequests}`));
console.log(colors.gray(` Errors: ${results.performance.errors}`));
if (results.metrics && results.metrics.parallelWorkers > 0) {
console.log(colors.gray(` Parallel workers: ${results.metrics.parallelWorkers}`));
}
console.log(colors.gray(` Duration: ${results.performance.duration}ms`));
}
console.log(colors.gray(`\nš Scanned ${results.packagesScanned > 0 ? results.packagesScanned : 1} ${results.packagesScanned > 0 ? 'package' : 'directory'}(s)${results.filesScanned ? `, ${results.filesScanned} file(s)` : ''} in ${results.duration}ms`));
}
/**
* Display dependency tree structure
* @param {object} tree - Dependency tree
* @param {number} depth - Current depth
* @param {boolean} verbose - Show verbose information
*/
function displayDependencyTree(tree, depth = 0, verbose = false) {
const indent = ' '.repeat(depth);
for (const [packageName, packageInfo] of Object.entries(tree)) {
const threatCount = Array.isArray(packageInfo.threats) ? packageInfo.threats.length : 0;
const depCount = packageInfo.dependencies && typeof packageInfo.dependencies === 'object' ? Object.keys(packageInfo.dependencies).length : 0;
// Color based on threat level
let packageColor = colors.gray;
if (threatCount > 0 && Array.isArray(packageInfo.threats)) {
const maxSeverity = Math.max(...packageInfo.threats.map(t =>
t.severity === 'CRITICAL' ? 4 :
t.severity === 'HIGH' ? 3 :
t.severity === 'MEDIUM' ? 2 : 1
));
packageColor = maxSeverity === 4 ? colors.red.bold :
maxSeverity === 3 ? colors.red :
maxSeverity === 2 ? colors.yellow : colors.gray;
}
// Display package info
let packageDisplay = `${indent}${packageColor(packageName)}@${packageInfo.version}`;
if (threatCount > 0) {
packageDisplay += colors.red(` (${threatCount} threat${threatCount > 1 ? 's' : ''})`);
}
if (depCount > 0) {
packageDisplay += colors.gray(` [${depCount} deps]`);
}
console.log(packageDisplay);
// Show threats in verbose mode
if (verbose && threatCount > 0) {
packageInfo.threats.forEach(threat => {
const severityColor = threat.severity === 'CRITICAL' ? colors.red.bold :
threat.severity === 'HIGH' ? colors.red :
threat.severity === 'MEDIUM' ? colors.yellow : colors.gray;
console.log(`${indent} ${severityColor('ā ')} ${threat.type}: ${threat.message}`);
});
}
// Recursively display dependencies
if (packageInfo.dependencies && typeof packageInfo.dependencies === 'object' && Object.keys(packageInfo.dependencies).length > 0) {
displayDependencyTree(packageInfo.dependencies, depth + 1, verbose);
}
}
}
/**
* Analyze dependency tree statistics
* @param {object} tree - Dependency tree
* @returns {object} Tree statistics
*/
function analyzeTreeStats(tree) {
let totalPackages = 0;
let maxDepth = 0;
let packagesWithThreats = 0;
let deepDependencies = 0;
function analyzeNode(node, depth = 0) {
totalPackages++;
maxDepth = Math.max(maxDepth, depth);
if (node.threats && Array.isArray(node.threats) && node.threats.length > 0) {
packagesWithThreats++;
}
if (depth >= 2) {
deepDependencies++;
}
if (node.dependencies && typeof node.dependencies === 'object') {
for (const dep of Object.values(node.dependencies)) {
analyzeNode(dep, depth + 1);
}
}
}
for (const packageInfo of Object.values(tree)) {
analyzeNode(packageInfo);
}
return {
totalPackages,
maxDepth,
packagesWithThreats,
deepDependencies
};
}