UNPKG

nikto-mcp

Version:

A secure MCP (Model Context Protocol) server that enables AI agents to interact with Nikto web server scanner

375 lines 15 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.NiktoService = void 0; const child_process_1 = require("child_process"); const crypto_1 = require("crypto"); const logger_1 = require("../utils/logger"); const index_1 = require("../config/index"); const scan_validator_1 = require("../validators/scan.validator"); const scan_types_1 = require("../types/scan.types"); const logger = (0, logger_1.createLogger)('NiktoService'); class NiktoService { activeScans = new Map(); constructor() { // Clean up any zombie processes on exit process.on('exit', () => { this.cleanup(); }); process.on('SIGINT', () => { this.cleanup(); process.exit(0); }); process.on('SIGTERM', () => { this.cleanup(); process.exit(0); }); } async startScan(options) { const scanId = (0, crypto_1.randomUUID)(); // Check concurrent scan limit const runningScans = Array.from(this.activeScans.values()).filter((scan) => scan.status === scan_types_1.ScanStatus.RUNNING).length; if (runningScans >= index_1.config.maxConcurrentScans) { throw new Error(`Maximum concurrent scans (${index_1.config.maxConcurrentScans}) reached`); } const activeScan = { scanId, options, status: scan_types_1.ScanStatus.PENDING, startTime: new Date(), output: [], }; this.activeScans.set(scanId, activeScan); // Handle dry run mode if (options.dryRun) { let command; let args; if (index_1.config.niktoMode === 'docker') { const niktoArgs = this.buildNiktoArgs(options, scanId); if (options.outputFormat === 'json') { const tmpDir = process.env['TMPDIR'] ?? process.cwd(); const hostOutputFile = `${tmpDir}/nikto-scan-${scanId}.json`; command = 'sh'; args = [ '-c', `docker run --rm --network=${index_1.config.dockerNetworkMode} -v ${tmpDir}:/tmp ${index_1.config.dockerImage} ${niktoArgs.join(' ')} && cat ${hostOutputFile} && rm -f ${hostOutputFile}`, ]; } else { command = 'docker'; args = [ 'run', '--rm', `--network=${index_1.config.dockerNetworkMode}`, index_1.config.dockerImage, ...niktoArgs, ]; } } else { command = index_1.config.niktoBinary; args = this.buildNiktoArgs(options, scanId); } const fullCommand = `${command} ${args.join(' ')}`; logger.debug(`Dry run for scan ${scanId}: ${fullCommand}`); activeScan.output.push(`DRY RUN (${index_1.config.niktoMode} mode): ${fullCommand}`); activeScan.status = scan_types_1.ScanStatus.COMPLETED; return { scanId, status: scan_types_1.ScanStatus.COMPLETED, target: options.target, startTime: activeScan.startTime, endTime: new Date(), }; } try { const niktoProcess = this.spawnNiktoProcess(options, scanId); if (niktoProcess.pid) { activeScan.pid = niktoProcess.pid; } activeScan.status = scan_types_1.ScanStatus.RUNNING; // Handle process completion niktoProcess.on('exit', (code) => { const scan = this.activeScans.get(scanId); if (scan) { scan.status = code === 0 ? scan_types_1.ScanStatus.COMPLETED : scan_types_1.ScanStatus.FAILED; logger.info(`Scan ${scanId} completed with code ${code}`); } }); return { scanId, status: scan_types_1.ScanStatus.RUNNING, target: options.target, startTime: activeScan.startTime, }; } catch (error) { activeScan.status = scan_types_1.ScanStatus.FAILED; throw error; } } spawnNiktoProcess(options, scanId) { let command; let args; if (index_1.config.niktoMode === 'docker') { const niktoArgs = this.buildNiktoArgs(options, scanId); if (options.outputFormat === 'json') { // For JSON output in docker mode, use volume mounting and post-process const tmpDir = process.env['TMPDIR'] ?? process.cwd(); const hostOutputFile = `${tmpDir}/nikto-scan-${scanId}.json`; command = 'sh'; args = [ '-c', `docker run --rm --network=${index_1.config.dockerNetworkMode} -v ${tmpDir}:/tmp ${index_1.config.dockerImage} ${niktoArgs.join(' ')} && cat ${hostOutputFile} && rm -f ${hostOutputFile}`, ]; } else { command = 'docker'; args = [ 'run', '--rm', `--network=${index_1.config.dockerNetworkMode}`, index_1.config.dockerImage, ...niktoArgs, ]; } } else { command = index_1.config.niktoBinary; args = this.buildNiktoArgs(options, scanId); } logger.info(`Starting Nikto scan ${scanId} in ${index_1.config.niktoMode} mode with command: ${command} ${args.join(' ')}`); const niktoProcess = (0, child_process_1.spawn)(command, args, { env: { ...process.env, LANG: 'C' }, // Ensure consistent output timeout: options.timeout ? options.timeout * 1000 : undefined, }); const scan = this.activeScans.get(scanId); if (!scan) { throw new Error('Scan not found'); } // Capture stdout niktoProcess.stdout?.on('data', (data) => { const output = data.toString(); scan.output.push(output); logger.debug(`Nikto output for ${scanId}:`, output); }); // Capture stderr niktoProcess.stderr?.on('data', (data) => { const error = data.toString(); scan.output.push(`ERROR: ${error}`); logger.error(`Nikto error for ${scanId}:`, error); }); // Handle errors niktoProcess.on('error', (error) => { scan.status = scan_types_1.ScanStatus.FAILED; scan.error = error.message; logger.error(`Nikto process error for ${scanId}:`, error); }); return niktoProcess; } buildNiktoArgs(options, scanId) { const args = []; // Target (required) args.push('-h', (0, scan_validator_1.sanitizeInput)(options.target)); // Port - only add if target doesn't already contain a port in the URL if (options.port && !this.targetHasPort(options.target)) { args.push('-p', options.port.toString()); } // SSL options (mutually exclusive) if (options.ssl) { args.push('-ssl'); } else if (options.nossl) { args.push('-nossl'); } // DNS lookup option if (options.nolookup) { args.push('-nolookup'); } // Virtual host if (options.vhost) { args.push('-vhost', (0, scan_validator_1.sanitizeInput)(options.vhost)); } // Timeout (use provided timeout or default from config) const timeoutValue = options.timeout ?? index_1.config.defaultTimeout; args.push('-timeout', timeoutValue.toString()); // Output format handling if (options.outputFormat === 'json') { if (index_1.config.niktoMode === 'docker') { // In docker mode, use unique filename based on scanId const outputFile = scanId ? `/tmp/nikto-scan-${scanId}.json` : '/tmp/nikto-scan.json'; args.push('-Format', 'json'); args.push('-output', outputFile); } else { // In native mode, use unique filename based on scanId const outputFile = scanId ? `/tmp/nikto-output-${scanId}.json` : '/tmp/nikto-output.json'; args.push('-Format', 'json'); args.push('-output', outputFile); } } // Additional security options args.push('-nointeractive'); // Don't prompt for input // Log the full command for debugging const command = `${index_1.config.niktoBinary} ${args.join(' ')}`; logger.debug(`Built Nikto command: ${command}`); return args; } getScanStatus(scanId) { const scan = this.activeScans.get(scanId); if (!scan) { throw new Error(`Scan ${scanId} not found`); } const result = { scanId, status: scan.status, target: scan.options.target, startTime: scan.startTime, }; if (scan.status === scan_types_1.ScanStatus.COMPLETED) { result.endTime = new Date(); result.findings = this.parseNiktoOutput(scan.output.join(''), scan.options.outputFormat); } if (scan.error) { result.error = scan.error; } return result; } stopScan(scanId) { const scan = this.activeScans.get(scanId); if (!scan) { throw new Error(`Scan ${scanId} not found`); } if (scan.status !== scan_types_1.ScanStatus.RUNNING) { throw new Error(`Scan ${scanId} is not running`); } if (scan.pid) { try { process.kill(scan.pid, 'SIGTERM'); scan.status = scan_types_1.ScanStatus.CANCELLED; logger.info(`Scan ${scanId} cancelled`); } catch (error) { logger.error(`Failed to stop scan ${scanId}:`, error); throw new Error(`Failed to stop scan: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } getActiveScans() { return Array.from(this.activeScans.values()).map((scan) => ({ scanId: scan.scanId, status: scan.status, target: scan.options.target, })); } parseNiktoOutput(output, format) { const findings = []; if (format === 'json') { try { // Parse JSON output - Nikto JSON format: [{"host":"...","vulnerabilities":[...]}] const jsonData = JSON.parse(output); if (Array.isArray(jsonData)) { for (const hostData of jsonData) { if (hostData.vulnerabilities && Array.isArray(hostData.vulnerabilities)) { for (const vuln of hostData.vulnerabilities) { const finding = { id: vuln.id ?? (0, crypto_1.randomUUID)(), method: vuln.method ?? 'GET', uri: vuln.url ?? '/', description: vuln.msg ?? 'Unknown vulnerability', severity: this.determineSeverityFromVuln(vuln), }; findings.push(finding); } } } } return findings; } catch (error) { logger.error('Failed to parse JSON output:', error); } } // Parse text output const lines = output.split('\n'); for (const line of lines) { if (line.includes('+ ') && !line.includes('Target IP:')) { const finding = { id: (0, crypto_1.randomUUID)(), method: 'GET', // Default, would need parsing uri: '', // Would need parsing description: line.replace(/^\+ /, '').trim(), severity: this.determineSeverity(line), }; findings.push(finding); } } return findings; } determineSeverity(line) { const lowerLine = line.toLowerCase(); if (lowerLine.includes('vulnerability') || lowerLine.includes('exploit')) { return 'high'; } if (lowerLine.includes('outdated') || lowerLine.includes('version')) { return 'medium'; } if (lowerLine.includes('information') || lowerLine.includes('disclosure')) { return 'low'; } return 'info'; } determineSeverityFromVuln(vuln) { const msg = (vuln['msg'] ?? '').toLowerCase(); if (msg.includes('vulnerability') || msg.includes('exploit') || msg.includes('sql injection') || msg.includes('xss')) { return 'high'; } if (msg.includes('outdated') || msg.includes('version') || msg.includes('deprecated')) { return 'medium'; } if (msg.includes('information') || msg.includes('disclosure') || msg.includes('header missing')) { return 'low'; } return 'info'; } targetHasPort(target) { try { // Check if target is a full URL with port if (target.startsWith('http://') || target.startsWith('https://')) { const url = new URL(target); return url.port !== ''; } // Check if target is hostname:port format const parts = target.split(':'); if (parts.length === 2 && parts[1]) { const port = parseInt(parts[1], 10); return !isNaN(port) && port > 0 && port <= 65535; } return false; } catch { // If URL parsing fails, assume no port return false; } } cleanup() { // Kill all running scans for (const [scanId, scan] of this.activeScans.entries()) { if (scan.status === scan_types_1.ScanStatus.RUNNING && scan.pid) { try { process.kill(scan.pid, 'SIGKILL'); logger.info(`Killed scan ${scanId} during cleanup`); } catch (error) { logger.error(`Failed to kill scan ${scanId}:`, error); } } } } } exports.NiktoService = NiktoService; //# sourceMappingURL=nikto.service.js.map