UNPKG

pentest-mcp

Version:

NOT for educational use: An MCP server for Nmap and John the Ripper, for professional penetration testers. Supports stdio, HTTP, and SSE transports with OAuth 2.1 authentication.

1,022 lines (919 loc) 71.8 kB
#!/usr/bin/env node /// <reference path="./node-nmap.d.ts" /> // Import MCP SDK with standard import patterns import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; // import { PromptTemplate } from "@modelcontextprotocol/sdk/server.js"; // Removed - Cannot find module import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; // Try to import StreamableHTTPServerTransport - it might be in a different location let StreamableHTTPServerTransport: any; try { // @ts-ignore const mod = await import("@modelcontextprotocol/sdk/server/streamableHttp.js"); StreamableHTTPServerTransport = mod.StreamableHTTPServerTransport; } catch (e) { console.error('StreamableHTTPServerTransport not available, using SSE for HTTP mode'); } // Import OAuth support if available let ProxyOAuthServerProvider: any; let mcpAuthRouter: any; try { // @ts-ignore const authMod = await import("@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js"); ProxyOAuthServerProvider = authMod.ProxyOAuthServerProvider; // @ts-ignore const routerMod = await import("@modelcontextprotocol/sdk/server/auth/router.js"); mcpAuthRouter = routerMod.mcpAuthRouter; } catch (e) { console.error('OAuth modules not available, OAuth support disabled'); } // Import express for HTTP/SSE transports import express from "express"; import { z } from "zod"; import { UserMode, UserSession } from './types.js'; import nmap, { ScanData, Host, Port } from 'node-nmap'; import { logScanResult, logMessage, getLatestScanResultForTarget } from './logger.js'; import { spawn } from 'child_process'; import util from 'util'; import fs from 'fs/promises'; import { URL } from 'url'; import path from 'path'; // @ts-ignore - No types available for fast-xml-parser import { XMLParser } from 'fast-xml-parser'; // Parse command line arguments and environment variables for transport selection const transportType = process.env.MCP_TRANSPORT || process.argv[2] || 'stdio'; const serverHost = process.env.MCP_SERVER_HOST || '0.0.0.0'; const serverPort = parseInt(process.env.MCP_SERVER_PORT || '8000'); // OAuth configuration const oauthEnabled = process.env.MCP_OAUTH_ENABLED === 'true'; const oauthProviderUrl = process.env.MCP_OAUTH_PROVIDER_URL; // e.g., https://auth0.com/oauth2 const oauthClientId = process.env.MCP_OAUTH_CLIENT_ID; const oauthClientSecret = process.env.MCP_OAUTH_CLIENT_SECRET; const oauthScopes = process.env.MCP_OAUTH_SCOPES?.split(',') || ['read', 'write']; let currentUserSession: UserSession = { mode: UserMode.UNKNOWN, // Start as unknown, will be determined or asked history: [], }; // Track running scans with their progress states interface ScanProgress { scanId: string; target: string; startTime: number; progress: number; // 0-100 status: 'initializing' | 'scanning' | 'analyzing' | 'complete' | 'failed' | 'cancelled'; currentStage?: string; estimatedTimeRemaining?: number; } // In the runNmapScan function const activeScans: Map<string, {process: any, progress: ScanProgress}> = new Map(); // Server instance - declared globally, initialized in main let server: McpServer; /* // Commenting out trackProgress as sendNotification signature/existence is uncertain in v1.7 function trackProgress(progress: ScanProgress) { if (server) { // server.sendNotification('scan/progress', { progress }); // Likely changed/removed console.error(`[Progress Update - Skipped Sending]: Scan ${progress.scanId} - ${progress.status} - ${progress.progress}%`); } else { // Queue for later if server isn't ready } } */ // 2. Implement getScanDataById function function getScanDataById(scanId: string): any { // First check active scans const activeScan = activeScans.get(scanId); if (activeScan) { return { scanId, target: activeScan.progress.target, options: [], // Would need to store this in the activeScan object timestamp: activeScan.progress.startTime, results: {} // Would need to store partial results }; } // Otherwise, would need to retrieve from log file // This is a placeholder - real implementation would parse logs return null; } // Define the structure of a finding interface Finding { host: string; port: string; service: string; description: string; details: Port; // Assuming Port is the type from 'node-nmap' } // 3. Implement analyzeFindings function function analyzeFindings(scans: any[]): { critical: Finding[]; high: Finding[]; medium: Finding[]; low: Finding[]; info: Finding[]; } { const findings: { critical: Finding[]; high: Finding[]; medium: Finding[]; low: Finding[]; info: Finding[]; } = { critical: [], high: [], medium: [], low: [], info: [] }; for (const scan of scans) { if (!scan || !scan.results) continue; for (const ip in scan.results) { const host = scan.results[ip] as Host; if (!host.ports) continue; for (const port of host.ports) { if (port.state === 'open') { const finding: Finding = { host: ip, port: port.portId, service: port.service?.name || 'unknown', description: `Open port ${port.portId} (${port.service?.name || 'unknown'})`, details: port }; if (['3389', '5432', '1433', '21', '23'].includes(port.portId)) { findings.critical.push(finding); } else if (['22', '445', '139'].includes(port.portId)) { findings.high.push(finding); } else if (['80', '443', '8080'].includes(port.portId)) { findings.medium.push(finding); } else { findings.low.push(finding); } } } } } return findings; } // --- Nmap Execution Logic (with spawn and XML parsing) --- async function runNmapScan(target: string, options: string[] = []): Promise<ScanData | null> { const scanId = `scan-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const progress: ScanProgress = { scanId, target, startTime: Date.now(), progress: 0, status: 'initializing' }; // trackProgress(progress); // Commented out console.error(`Executing nmap scan: target=${target}, options=${options.join(' ')}`); if (!target) { throw new Error("Error: Target is required."); } return new Promise((resolve, reject) => { const args = [...options, '-oX', '-', target]; const nmapProcess = spawn('nmap', args); activeScans.set(scanId, { process: nmapProcess, progress }); // Track active scan let stdoutData = ''; let stderrData = ''; nmapProcess.stdout.on('data', (data) => { stdoutData += data.toString(); }); nmapProcess.stderr.on('data', (data) => { stderrData += data.toString(); // Update progress based on stderr const output = data.toString(); if (output.includes('Initiating')) { progress.status = 'scanning'; progress.progress = 10; } else if (output.includes('Completed SYN Stealth Scan')) { progress.progress = 40; } else if (output.includes('Initiating Service scan')) { progress.currentStage = 'Service detection'; progress.progress = 50; } else if (output.includes('Completed Service scan')) { progress.progress = 70; } else if (output.includes('Initiating OS detection')) { progress.currentStage = 'OS detection'; progress.progress = 80; } // trackProgress(progress); // Commented out }); nmapProcess.on('error', (error) => { activeScans.delete(scanId); progress.status = 'failed'; // trackProgress(progress); // Commented out reject(error); }); let parsedScanData: ScanData | null = null; nmapProcess.on('close', async (code) => { activeScans.delete(scanId); console.error(`Nmap process exited with code ${code}`); if (stderrData) { console.warn(`Nmap stderr: ${stderrData}`); } let resultForLog: ScanData | string | null = null; if (code === 0 && stdoutData) { try { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@", allowBooleanAttributes: true, parseAttributeValue: true, isArray: (name: string) => ['host', 'port', 'hostname', 'osmatch', 'cpe'].includes(name) }); const parsedXml = parser.parse(stdoutData); parsedScanData = {}; // Init // ... (Transformation logic) ... resultForLog = parsedScanData; // Log the parsed data on success } catch (parseError: any) { console.error("Failed to parse Nmap XML output:", parseError); resultForLog = `XML Parse Error: ${parseError.message}\n${stdoutData}`; // Log error + raw data parsedScanData = null; // Don't log twice, the main log call handles this based on code reject(new Error(`Failed to parse Nmap XML output: ${parseError.message}`)); return; } } else if (code !== 0) { resultForLog = stderrData || `Nmap failed: ${code}`; // Log stderr on failure } if (currentUserSession.mode === UserMode.PROFESSIONAL) { // Correct call with 3 arguments await logScanResult(target, options, resultForLog); } if (code !== 0) { const errorMsg = `Nmap scan failed with exit code ${code}.${stderrData ? " Stderr: " + stderrData : ""}`; reject(new Error(errorMsg)); } else { resolve(parsedScanData); // Resolve with parsed data (or null) } }); }); } // --- Input Sanitization Helper --- const SAFE_OPTION_REGEX = /^(?:-[a-zA-Z0-9]+|--[a-zA-Z0-9\-]+(?:=[^;&|`$\s\(\)\<\>\\]+)?|[^;&|`$\s\(\)\<\>\\]+)$/; function sanitizeOptions(options: string[]): string[] { const sanitized: string[] = []; for (const opt of options) { if (SAFE_OPTION_REGEX.test(opt)) { sanitized.push(opt); } else { throw new Error(`Invalid or potentially unsafe option detected: "${opt}". Only standard flags and simple arguments are allowed.`); } } return sanitized; } // Helper function to run a command using spawn and return results async function runSpawnCommand(command: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> { return new Promise((resolve, reject) => { console.error(`Attempting to spawn: ${command} ${args.join(' ')}`); // Added for debugging const process = spawn(command, args); let stdout = ''; let stderr = ''; process.stdout.on('data', (data) => { stdout += data.toString(); }); process.stderr.on('data', (data) => { stderr += data.toString(); }); process.on('error', (error) => { // Explicitly catch spawn errors (e.g., command not found) console.error(`Spawn error for command "${command}": ${error.message}`); reject(new Error(`Failed to start command "${command}": ${error.message}`)); }); process.on('close', (code) => { console.error(`Command "${command}" exited with code ${code}`); // Added for debugging resolve({ stdout, stderr, code }); }); }); } // --- John the Ripper Execution Logic (using spawn) --- async function runJtR(hashData: string, rawOptions: string[] = []): Promise<{ fullOutput: string; cracked: string[] }> { let options: string[]; try { options = sanitizeOptions(rawOptions); } catch (error: any) { throw error; } if (!hashData) { throw new Error("Error: Hash data is required."); } // Use /tmp directory for temp files to avoid read-only directory issues const tempHashFile = path.join('/tmp', `jtr_hashes_${Date.now()}.txt`); let fullOutput = ""; let crackedPasswords: string[] = []; try { await fs.writeFile(tempHashFile, hashData); const crackingArgs = [...options, tempHashFile]; fullOutput += `--- Cracking Attempt ---\nExecuting: john ${crackingArgs.join(' ')}\n`; try { const crackResult = await runSpawnCommand('john', crackingArgs); fullOutput += `Exit Code: ${crackResult.code}\nStdout:\n${crackResult.stdout}\nStderr:\n${crackResult.stderr}\n`; } catch (error: any) { fullOutput += `Cracking command failed to execute: ${error.message}\n`; } const showArgs = ['--show', tempHashFile]; fullOutput += `--- Show Attempt ---\nExecuting: john ${showArgs.join(' ')}\n`; try { const showResult = await runSpawnCommand('john', showArgs); fullOutput += `Exit Code: ${showResult.code}\nStdout:\n${showResult.stdout}\nStderr:\n${showResult.stderr}\n`; crackedPasswords = showResult.stdout.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('0 passwords cracked') && !line.includes('guesses remaining')); } catch (error: any) { fullOutput += `Show command failed to execute: ${error.message}\n`; } await fs.unlink(tempHashFile); if (currentUserSession.mode === UserMode.PROFESSIONAL) { await logMessage(`Ran John the Ripper.\nOptions: ${options.join(' ')}\nCracked: ${crackedPasswords.length}.`); } return { fullOutput, cracked: crackedPasswords }; } catch (error: any) { console.error("Fatal error setting up John the Ripper execution:", error); try { await fs.unlink(tempHashFile); } catch { /* ignore */ } if (currentUserSession.mode === UserMode.PROFESSIONAL) { await logMessage(`John the Ripper FAILED fatally before execution.\nOptions: ${options.join(' ')}\nError: ${error.message}`); } throw new Error(`John the Ripper setup failed fatally: ${error.message}`); } } // --- Hashcat Execution Logic (using spawn) --- async function runHashcat(hashData: string, rawOptions: string[] = []): Promise<{ fullOutput: string; cracked: string[]; potfileLocation?: string }> { let options: string[]; try { options = sanitizeOptions(rawOptions); } catch (error: any) { throw error; } if (!hashData) { throw new Error("Error: Hash data is required."); } // Use /tmp directory for temp files to avoid read-only directory issues const tempHashFile = path.join('/tmp', `hashcat_hashes_${Date.now()}.txt`); const tempPotFile = path.join('/tmp', `hashcat_${Date.now()}.potfile`); let fullOutput = ""; let crackedPasswords: string[] = []; try { await fs.writeFile(tempHashFile, hashData); // Build hashcat arguments const crackingArgs = [...options]; // Add custom potfile to avoid conflicts if (!options.some(opt => opt.includes('--potfile-path'))) { crackingArgs.push('--potfile-path', tempPotFile); } // Add hash file as last argument crackingArgs.push(tempHashFile); fullOutput += `--- Hashcat Cracking Attempt ---\nExecuting: hashcat ${crackingArgs.join(' ')}\n`; try { const crackResult = await runSpawnCommand('hashcat', crackingArgs); fullOutput += `Exit Code: ${crackResult.code}\nStdout:\n${crackResult.stdout}\nStderr:\n${crackResult.stderr}\n`; // Parse cracked passwords from stdout (hashcat shows them during execution) const lines = crackResult.stdout.split('\n'); for (const line of lines) { // Look for lines that contain cracked hashes (usually contain colons) if (line.includes(':') && !line.includes('Session.........') && !line.includes('Status..........')) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('[') && !trimmed.startsWith('hashcat')) { crackedPasswords.push(trimmed); } } } } catch (error: any) { fullOutput += `Hashcat command failed to execute: ${error.message}\n`; } // Try to read from potfile if it exists fullOutput += `--- Potfile Check ---\n`; try { const potfileContent = await fs.readFile(tempPotFile, 'utf8'); fullOutput += `Potfile content:\n${potfileContent}\n`; const potfileLines = potfileContent.split('\n').filter(line => line.trim()); crackedPasswords.push(...potfileLines); } catch (error: any) { fullOutput += `Could not read potfile: ${error.message}\n`; } // Show cracked hashes using --show option const showArgs = [...options.filter(opt => !opt.includes('--potfile-path')), '--potfile-path', tempPotFile, '--show', tempHashFile]; fullOutput += `--- Show Cracked Hashes ---\nExecuting: hashcat ${showArgs.join(' ')}\n`; try { const showResult = await runSpawnCommand('hashcat', showArgs); fullOutput += `Exit Code: ${showResult.code}\nStdout:\n${showResult.stdout}\nStderr:\n${showResult.stderr}\n`; const showLines = showResult.stdout.split('\n').map(line => line.trim()).filter(line => line && line.includes(':')); crackedPasswords.push(...showLines); } catch (error: any) { fullOutput += `Show command failed to execute: ${error.message}\n`; } // Remove duplicates and filter valid entries crackedPasswords = [...new Set(crackedPasswords)].filter(line => line && line.includes(':')); // Clean up temp files try { await fs.unlink(tempHashFile); } catch { /* ignore */ } if (currentUserSession.mode === UserMode.PROFESSIONAL) { await logMessage(`Ran Hashcat.\nOptions: ${options.join(' ')}\nCracked: ${crackedPasswords.length} hashes.`); } return { fullOutput, cracked: crackedPasswords, potfileLocation: tempPotFile }; } catch (error: any) { console.error("Fatal error setting up Hashcat execution:", error); try { await fs.unlink(tempHashFile); } catch { /* ignore */ } try { await fs.unlink(tempPotFile); } catch { /* ignore */ } if (currentUserSession.mode === UserMode.PROFESSIONAL) { await logMessage(`Hashcat FAILED fatally before execution.\nOptions: ${options.join(' ')}\nError: ${error.message}`); } throw new Error(`Hashcat setup failed fatally: ${error.message}`); } } // --- Student Mode Response Formatting --- function formatResultsForStudent(target: string, options: string[], results: ScanData): { explanation: string, suggestions: string[] } { let explanation = `Scan results for **${target}** (options: ${options.join(' ') || 'default'}):\n\n`; const suggestions: string[] = []; let foundOpenPorts = false; for (const ip in results) { const hostData = results[ip] as Host; explanation += `**Host:** ${hostData.hostname ? `${hostData.hostname} (${ip})` : ip}\n`; if (hostData.mac) { explanation += `* MAC Address: ${hostData.mac} (This is the hardware address, useful for identifying devices on a local network).\n`; } if (hostData.osNmap) { explanation += `* Operating System Guess: ${hostData.osNmap} (Nmap tries to guess the OS based on network responses).\n`; if (!options.includes('-O')) { suggestions.push(`Try adding \`-O\` to the options for a more dedicated OS detection scan on ${ip}.`); } } if (hostData.ports && hostData.ports.length > 0) { const openPorts = hostData.ports.filter(port => port.state === 'open'); if (openPorts.length > 0) { foundOpenPorts = true; explanation += `* **Open Ports Found:**\n`; openPorts.forEach(port => { explanation += ` * **Port ${port.portId}/${port.protocol}:** State is **${port.state}**. Service detected: **${port.service?.name || 'unknown'}**. Reason: ${port.reason}\n`; if (port.portId === '80' || port.portId === '443') { suggestions.push(`Port ${port.portId} (${port.service?.name}) is open on ${ip}. Try exploring it with a web browser or tools like \`curl\`.`); suggestions.push(`Consider running \`nmapScan\` with scripts: \`options: ["-sV", "-sC", "-p${port.portId}"]\` on ${ip} to get more service info.`); } if (port.portId === '22') { suggestions.push(`SSH (Port 22) is open on ${ip}. You could try connecting if you have credentials, or check for common vulnerabilities (\`options: ["-sV", "--script=ssh-auth-methods"]\`).`); } if (port.portId === '21' || port.portId === '23') { // FTP/Telnet suggestions.push(`${port.service?.name} (Port ${port.portId}) on ${ip} is often insecure. Check for anonymous login or default credentials (\`options: ["-sV", "--script=ftp-anon"]\` for FTP).`); } if (port.portId === '3389') { // RDP suggestions.push(`RDP (Port 3389) on ${ip} allows remote desktop access. Check for weak passwords or vulnerabilities.`); } }); } else { explanation += `* No *open* ports were detected in the scanned range for ${ip}. Filtered ports might still exist.\n`; } } else { explanation += `* Port scanning was not performed or no ports were reported for ${ip}.\n`; } explanation += `\n`; } if (!foundOpenPorts) { suggestions.push("No open ports found with the current options. Try scanning all ports (\`-p-\` ) or using different scan types like SYN scan (\`-sS\`, requires root/admin) or UDP scan (\`-sU\`)."); } if (!options.includes('-sV') && foundOpenPorts) { suggestions.push("Run with \`-sV\` option to try and determine the version of the services running on the open ports."); } return { explanation, suggestions }; } // Use /tmp directory for wordlists to avoid read-only directory issues const TEMP_WORDLIST_DIR = path.join('/tmp', 'pentest-mcp-wordlists'); async function ensureTempWordlistDirExists(): Promise<void> { try { await fs.mkdir(TEMP_WORDLIST_DIR, { recursive: true }); } catch (error) { console.error('Error creating temp wordlist directory:', error); } } function toLeet(word: string): string { return word.replace(/a/gi, '4').replace(/e/gi, '3').replace(/i/gi, '1').replace(/o/gi, '0').replace(/s/gi, '5').replace(/t/gi, '7'); } // --- Gobuster Execution Logic (Reinstated) --- async function runGobuster(target: string, rawOptions: string[] = []): Promise<{ fullOutput: string; foundPaths: string[] }> { console.error(`Executing Gobuster: target=${target}, raw_options=${rawOptions.join(' ')}`); if (!target.startsWith('http://') && !target.startsWith('https://')) { throw new Error("Target must be a valid URL starting with http:// or https://"); } let options: string[]; try { options = sanitizeOptions(rawOptions); } catch (error: any) { throw error; } let fullOutput = ""; let foundPaths: string[] = []; try { const baseArgs = ['dir', '-u', target, ...options]; fullOutput += `--- Directory Enumeration ---\nExecuting: gobuster ${baseArgs.join(' ')}\n`; try { const result = await runSpawnCommand('gobuster', baseArgs); fullOutput += `Exit Code: ${result.code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}\n`; foundPaths = result.stdout.split('\n').filter(line => line.includes('Status:') && (line.includes('200') || line.includes('301') || line.includes('302'))).map(line => line.match(/^\s*(\/[^\s]*)/)?.[1] || line); } catch (error: any) { fullOutput += `Gobuster command failed to execute: ${error.message}\n`; } if (currentUserSession.mode === UserMode.PROFESSIONAL) { await logMessage(`Ran Gobuster against ${target}.\nOptions: ${options.join(' ')}\nFound: ${foundPaths.length} paths.`); } return { fullOutput, foundPaths }; } catch (error: any) { console.error("Fatal error setting up Gobuster execution:", error); if (currentUserSession.mode === UserMode.PROFESSIONAL) { await logMessage(`Gobuster FAILED fatally before execution.\nTarget: ${target}\nOptions: ${options.join(' ')}\nError: ${error.message}`); } throw new Error(`Gobuster setup failed fatally: ${error.message}`); } } // --- Nikto Execution Logic (Reinstated) --- async function runNikto(target: string, rawOptions: string[] = []): Promise<{ fullOutput: string; findings: string[] }> { console.error(`Executing Nikto: target=${target}, raw_options=${rawOptions.join(' ')}`); if (!target.startsWith('http://') && !target.startsWith('https://')) { throw new Error("Target must be a valid URL starting with http:// or https://"); } let options: string[]; try { options = sanitizeOptions(rawOptions); } catch (error: any) { throw error; } let fullOutput = ""; let findings: string[] = []; try { const baseArgs = ['-h', target, ...options]; fullOutput += `--- Vulnerability Scan ---\nExecuting: nikto ${baseArgs.join(' ')}\n`; try { const result = await runSpawnCommand('nikto', baseArgs); fullOutput += `Exit Code: ${result.code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}\n`; findings = result.stdout.split('\n').filter(line => line.startsWith('+') && !line.includes('+ No web server')).map(line => line.trim()); } catch (error: any) { fullOutput += `Nikto command failed to execute: ${error.message}\n`; } if (currentUserSession.mode === UserMode.PROFESSIONAL) { await logMessage(`Ran Nikto against ${target}.\nOptions: ${options.join(' ')}\nFound: ${findings.length} potential issues.`); } return { fullOutput, findings }; } catch (error: any) { console.error("Fatal error setting up Nikto execution:", error); if (currentUserSession.mode === UserMode.PROFESSIONAL) { await logMessage(`Nikto FAILED fatally before execution.\nTarget: ${target}\nOptions: ${options.join(' ')}\nError: ${error.message}`); } throw new Error(`Nikto setup failed fatally: ${error.message}`); } } // --- MCP Server Setup --- async function main() { // Log the PATH environment variable as seen by Node.js console.error('Node.js process PATH:', process.env.PATH); console.error(`Initializing Pentest MCP Server with ${transportType} transport...`); server = new McpServer({ name: "pentest-mcp", version: "0.5.0", // Explicitly declare capabilities to enable discovery capabilities: { resources: { "mode": {}, "clientReport": {} }, tools: { "setMode": {}, "generateWordlist": {}, "cancelScan": {}, "createClientReport": {}, "nmapScan": {}, "runJohnTheRipper": {}, "runHashcat": {}, "gobuster": {}, "nikto": {} }, // prompts: {} // No prompts defined } }); // --- Resource Definitions --- server.resource( "mode", new ResourceTemplate("mcp://pentest/mode", { list: undefined }), async (uri: URL /*, extra */) => { // Handler receives URI return { contents: [{ uri: uri.href, text: `Current Mode: ${currentUserSession.mode}`, metadata: { currentMode: currentUserSession.mode } }] }; } ); interface ClientReport { reportId: string; title: string; createdAt: number; client: string; assessmentType: string; findings: { critical: Finding[], high: Finding[], medium: Finding[], low: Finding[], info: Finding[] }; scans: any[]; summary: string; recommendations: string[]; } const clientReports: Map<string, ClientReport> = new Map(); server.resource( "clientReport", new ResourceTemplate("mcp://pentest/clientReport/{reportId}", { list: undefined }), async (uri: URL /*, extra */) => { const match = uri.href.match(/mcp:\/\/pentest\/clientReport\/(.+)/); const reportId = match ? match[1] : null; if (!reportId || reportId === "list") { return { contents: Array.from(clientReports.values()).map(report => ({ uri: `mcp://pentest/clientReport/${report.reportId}`, text: `Report: ${report.title}` })) }; } const report = clientReports.get(reportId); if (!report) throw new Error(`Report ${reportId} not found`); return { contents: [{ uri: uri.href, text: JSON.stringify(report, null, 2) }] }; } ); // --- Tool Definitions --- // Set Mode Tool const setModeToolSchema = z.object({ mode: z.enum([UserMode.STUDENT, UserMode.PROFESSIONAL]) }).describe( "Switch between `student` mode (verbose guidance) and `professional` mode " + "(concise output). Call this at the start of a session or whenever you " + "need to adjust the level of explanation. Example: `{\"mode\":\"professional\"}`" ); server.tool("setMode", setModeToolSchema.shape, async ({ mode } /*, extra */) => { currentUserSession.mode = mode; await logMessage(`Mode changed to ${mode}.`); return { content: [{ type: "text", text: `Session mode set to: ${mode}` }] }; }); // Nmap Scan Tool const nmapScanToolSchema = z.object({ target: z.string(), ports: z.string().optional(), fastScan: z.boolean().optional(), topPorts: z.number().int().optional(), scanTechnique: z.enum(['SYN', 'Connect', 'ACK', 'Window', 'Maimon', 'FIN', 'Xmas', 'Null', 'Proto']).optional(), udpScan: z.boolean().optional(), serviceVersionDetection: z.boolean().optional(), versionIntensity: z.number().int().optional(), osDetection: z.boolean().optional(), defaultScripts: z.boolean().optional(), scripts: z.array(z.string()).optional(), scriptArgs: z.string().optional(), timingTemplate: z.enum(['T0', 'T1', 'T2', 'T3', 'T4', 'T5']).optional(), skipHostDiscovery: z.boolean().optional(), verbose: z.boolean().optional(), rawOptions: z.array(z.string()).optional(), userModeHint: z.enum([UserMode.STUDENT, UserMode.PROFESSIONAL]).optional() }).describe( "Run an Nmap scan to discover hosts and services. Use this before other " + "tools to identify attack surface. Options map directly to Nmap flags. " + "Note that SYN scans or OS detection (e.g. `-sS`, `-O`) require elevated " + "privileges. Example: `{\"target\":\"192.168.1.0/24\", \"scanTechnique\":\"SYN\", \"serviceVersionDetection\":true}`" ); server.tool("nmapScan", nmapScanToolSchema.shape, async (args /*: z.infer<typeof nmapScanToolSchema> */ /*, extra */) => { const { target, ports, fastScan, topPorts, scanTechnique, udpScan, serviceVersionDetection, versionIntensity, osDetection, defaultScripts, scripts, scriptArgs, timingTemplate, skipHostDiscovery, verbose, rawOptions, userModeHint } = args; console.error(`Received nmapScan request:`, args); if (currentUserSession.mode === UserMode.UNKNOWN) { if (userModeHint) { currentUserSession.mode = userModeHint; /* log */ } else { currentUserSession.mode = UserMode.STUDENT; /* log */ } } try { const constructedOptions: string[] = []; const validationErrors: string[] = []; // Restore detailed option building logic if (skipHostDiscovery) constructedOptions.push('-Pn'); let portSpecCount = 0; if (ports) portSpecCount++; if (fastScan) portSpecCount++; if (topPorts) portSpecCount++; if (portSpecCount > 1) validationErrors.push("Use only one of ports, fastScan, or topPorts."); else if (ports) constructedOptions.push('-p', ports); else if (fastScan) constructedOptions.push('-F'); else if (topPorts) constructedOptions.push('--top-ports', String(topPorts)); if (scanTechnique) { switch (scanTechnique) { case 'SYN': constructedOptions.push('-sS'); break; case 'Connect': constructedOptions.push('-sT'); break; case 'ACK': constructedOptions.push('-sA'); break; case 'Window': constructedOptions.push('-sW'); break; case 'Maimon': constructedOptions.push('-sM'); break; case 'FIN': constructedOptions.push('-sF'); break; case 'Xmas': constructedOptions.push('-sX'); break; case 'Null': constructedOptions.push('-sN'); break; case 'Proto': constructedOptions.push('-sO'); break; } } if (udpScan) constructedOptions.push('-sU'); if (serviceVersionDetection) { constructedOptions.push('-sV'); if (versionIntensity !== undefined) constructedOptions.push('--version-intensity', String(versionIntensity)); } else if (versionIntensity !== undefined) validationErrors.push("Cannot set intensity without -sV."); if (osDetection) constructedOptions.push('-O'); if (defaultScripts && scripts) validationErrors.push("Cannot use both -sC and --script."); else if (defaultScripts) constructedOptions.push('-sC'); else if (scripts && scripts.length > 0) constructedOptions.push('--script', scripts.join(',')); if (scriptArgs) { if (!defaultScripts && !scripts) validationErrors.push("Cannot use scriptArgs without scripts."); else constructedOptions.push('--script-args', scriptArgs); } if (timingTemplate) constructedOptions.push(`-${timingTemplate}`); if (verbose) constructedOptions.push('-v'); if (rawOptions) constructedOptions.push(...rawOptions); if (validationErrors.length > 0) throw new Error(`Invalid params: ${validationErrors.join('; ')}`); // Privilege Warning (remains valid) const needsPrivileges = constructedOptions.some(opt => opt === '-sS' || opt === '-O'); if (needsPrivileges) console.warn("Nmap options may require elevated privileges."); const results = await runNmapScan(target, constructedOptions); // Restore detailed response formatting let responseContent: any[] = []; let suggestions: string[] = []; if (typeof results === 'string') { responseContent.push({ type: "text", text: results }); } else if (results) { if (currentUserSession.mode === UserMode.STUDENT) { const { explanation, suggestions: studentSuggestions } = formatResultsForStudent(target, constructedOptions, results); responseContent.push({ type: "text", text: explanation }); suggestions.push(...studentSuggestions); } else { // Professional Mode responseContent.push({ type: "text", text: JSON.stringify(results, null, 2) }); const foundPorts: { [key: string]: Set<string> } = {}; Object.entries(results).forEach(([ip, host]) => { const typedHost = host as nmap.Host; if (!foundPorts[ip]) foundPorts[ip] = new Set(); typedHost.ports?.forEach(port => { if (port.state === 'open') foundPorts[ip].add(port.portId); }); }); for (const ip in foundPorts) { /* ... pro suggestions logic ... */ } if (suggestions.length === 0 && Object.keys(foundPorts).length > 0) suggestions.push("Scan complete."); } } else { responseContent.push({ type: "text", text: "Nmap scan returned no data." }); } if (suggestions.length > 0) responseContent.push({ type: "text", text: "\n**Suggestions:**\n* " + suggestions.join("\n* ") }); return { content: responseContent }; } catch (error: any) { return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true }; } }); // Generate Wordlist Tool const generateWordlistSchema = z.object({ baseWords: z.array(z.string()).describe("List of base words (names, pets, places, etc.)."), dates: z.array(z.string()).optional().describe("List of dates (YYYY-MM-DD, MM-DD, YYYY). Parsed for variations."), customPatterns: z.array(z.string()).optional().describe("List of custom patterns/symbols to prepend/append (e.g., '!', '123')."), minYear: z.number().int().optional().describe("Minimum year (YYYY) to include in variations."), maxYear: z.number().int().optional().describe("Maximum year (YYYY) to include in variations (defaults to current year)."), includeLeet: z.boolean().optional().default(false).describe("Apply basic leetspeak substitutions (a=4, e=3, etc.)."), caseVariations: z.boolean().optional().default(true).describe("Include variations like TitleCase, UPPERCASE.") }).describe( "Generate a custom password wordlist from target-related words. " + "Use this before running John the Ripper. Example: `{\"baseWords\":[\"Acme\",\"Smith\"],\"dates\":[\"1984\"],\"customPatterns\":[\"!\"]}`" ); server.tool("generateWordlist", generateWordlistSchema.shape, async ({ baseWords, dates, customPatterns, minYear, maxYear, includeLeet, caseVariations } /*, extra */) => { console.error(`Received generateWordlist:`, { baseWords: `${baseWords.length} words`, dates, customPatterns }); await ensureTempWordlistDirExists(); const wordlist = new Set<string>(); const currentYear = new Date().getFullYear(); const resolvedMinYear = minYear || currentYear - 10; const resolvedMaxYear = maxYear || currentYear; const years: string[] = []; for (let y = resolvedMinYear; y <= resolvedMaxYear; y++) { years.push(String(y)); years.push(String(y).slice(-2)); } const dateVariations: string[] = []; if (dates) { dates.forEach(dateStr => { const parts = dateStr.split(/[-/]/); if (parts.length === 3) { dateVariations.push(parts[0], parts[0].slice(-2), parts[1], parts[2], parts[1]+parts[2], parts[1]+parts[2]+parts[0], parts[1]+parts[2]+parts[0].slice(-2)); } else if (parts.length === 2) { dateVariations.push(parts[0], parts[1], parts[0]+parts[1]); } else if (parts.length === 1 && parts[0].length === 4) { dateVariations.push(parts[0], parts[0].slice(-2)); } }); } const patterns = customPatterns || []; const suffixes = [...patterns, ...years, ...dateVariations]; const prefixes = [...patterns]; baseWords.forEach(base => { const variations = new Set<string>([base]); if (caseVariations) { variations.add(base.toLowerCase()); variations.add(base.toUpperCase()); variations.add(base.charAt(0).toUpperCase() + base.slice(1).toLowerCase()); } if (includeLeet) { const leetBase = toLeet(base); variations.add(leetBase); if (caseVariations) { /* add leet cases */ variations.add(leetBase.toLowerCase()); variations.add(leetBase.toUpperCase()); variations.add(leetBase.charAt(0).toUpperCase() + leetBase.slice(1).toLowerCase()); } } variations.forEach(v => { wordlist.add(v); prefixes.forEach(p => { wordlist.add(p + v); }); suffixes.forEach(s => { wordlist.add(v + s); }); prefixes.forEach(p => { suffixes.forEach(s => { wordlist.add(p + v + s); }); }); }); }); [...years, ...dateVariations].forEach(dv => wordlist.add(dv)); const filename = `wordlist_${Date.now()}.txt`; const filePath = path.join(TEMP_WORDLIST_DIR, filename); try { await fs.writeFile(filePath, Array.from(wordlist).join('\n')); return { content: [ { type: "text", text: `Generated ${wordlist.size} words.` }, { type: "text", text: `Path: ${filePath}` } ] }; } catch (error: any) { return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true }; } }); // John the Ripper Tool const jtrToolSchema = z.object({ hashData: z.string().describe("String containing the password hashes, one per line."), options: z.array(z.string()).optional().describe("Array of command-line options for JtR.") }).describe( "Crack password hashes using John the Ripper. Provide hashes and any JtR options." + " Run this after generating a wordlist. Example: `{\"hashData\":\"user:$1$hash\", \"options\":[\"--wordlist=/tmp/list.txt\"]}`" ); server.tool("runJohnTheRipper", jtrToolSchema.shape, async ({ hashData, options } /*, extra */) => { console.error(`Received JtR:`, { hashData: `len=${hashData.length}`, options }); if (currentUserSession.mode === UserMode.STUDENT) console.warn("[Student Mode] Executing JtR."); try { const { fullOutput, cracked } = await runJtR(hashData, options || []); const responseContent: any[] = [ { type: "text", text: `JtR finished. Found ${cracked.length} cracked.` } ]; if (cracked.length > 0) responseContent.push({ type: "text", text: "\n**Cracked:**\n" + cracked.join("\n") }); responseContent.push({ type: "text", text: "\n--- Full JtR Output ---\n" + fullOutput }); // Keep full output return { content: responseContent }; } catch (error: any) { return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true }; } }); // Hashcat Tool const hashcatToolSchema = z.object({ hashData: z.string().describe("String containing the password hashes, one per line."), attackMode: z.enum(['0', '1', '3', '6', '7']).optional().describe("Attack mode: 0=Straight, 1=Combination, 3=Brute-force, 6=Hybrid Wordlist + Mask, 7=Hybrid Mask + Wordlist"), hashType: z.string().optional().describe("Hash-type, e.g., 0=MD5, 100=SHA1, 1000=NTLM, 1400=SHA2-256, 1800=sha512crypt, 22000=WPA*01/WPA*02"), wordlist: z.string().optional().describe("Path to wordlist file for dictionary attacks"), mask: z.string().optional().describe("Mask for brute-force attacks (e.g., '?a?a?a?a?a?a?a?a' for 8 chars)"), increment: z.boolean().optional().describe("Enable incremental mode (start with shorter passwords)"), incrementMin: z.number().int().optional().describe("Minimum password length for incremental mode"), incrementMax: z.number().int().optional().describe("Maximum password length for incremental mode"), rules: z.string().optional().describe("Rules file to apply to wordlist"), session: z.string().optional().describe("Session name for resuming attacks"), restore: z.boolean().optional().describe("Restore a previous session"), optimizedKernels: z.boolean().optional().describe("Enable optimized kernels (-O)"), workloadProfile: z.enum(['1', '2', '3', '4']).optional().describe("Workload profile: 1=Low, 2=Default, 3=High, 4=Nightmare"), deviceTypes: z.array(z.enum(['1', '2', '3'])).optional().describe("Device types: 1=CPU, 2=GPU, 3=FPGA"), force: z.boolean().optional().describe("Ignore warnings"), potfilePath: z.string().optional().describe("Path to custom potfile"), outfile: z.string().optional().describe("Output file for cracked hashes"), outfileFormat: z.number().int().optional().describe("Output format: 1=hash, 2=plain, 3=hex-plain, etc."), runtime: z.number().int().optional().describe("Abort session after X seconds"), showProgress: z.boolean().optional().describe("Show progress every X seconds"), quiet: z.boolean().optional().describe("Suppress output"), loopback: z.boolean().optional().describe("Add new plains to induct directory"), markovThreshold: z.number().int().optional().describe("Threshold X when to stop accepting new Markov-chains"), customCharset1: z.string().optional().describe("User-defined charset ?1"), customCharset2: z.string().optional().describe("User-defined charset ?2"), customCharset3: z.string().optional().describe("User-defined charset ?3"), customCharset4: z.string().optional().describe("User-defined charset ?4"), options: z.array(z.string()).optional().describe("Additional raw hashcat options") }).describe( "Crack password hashes using Hashcat. More powerful and faster than John the Ripper for many hash types, especially with GPU acceleration. " + "Supports various attack modes and hash types. Example: `{\"hashData\":\"5d41402abc4b2a76b9719d911017c592\", \"hashType\":\"0\", \"attackMode\":\"0\", \"wordlist\":\"/tmp/wordlist.txt\"}`" ); server.tool("runHashcat", hashcatToolSchema.shape, async (args /*, extra */) => { const { hashData, attackMode, hashType, wordlist, mask, increment, incrementMin, incrementMax, rules, session, restore, optimizedKernels, workloadProfile, deviceTypes, force, potfilePath, outfile, outfileFormat, runtime, showProgress, quiet, loopback, markovThreshold, customCharset1, customCharset2, customCharset3, customCharset4, options } = args; console.error(`Received Hashcat:`, { hashData: `len=${hashData.length}`, attackMode, hashType, wordlist }); if (currentUserSession.mode === UserMode.STUDENT) console.warn("[Student Mode] Executing Hashcat."); try { const constructedOptions: string[] = []; const validationErrors: string[] = []; // Attack mode if (attackMode) { constructedOptions.push('-a', attackMode); } else { constructedOptions.push('-a', '0'); // Default to dictionary attack } // Hash type if (hashType) { constructedOptions.push('-m', hashType); } // Wordlist for dictionary/combination attacks if ((attackMode === '0' || attackMode === '1' || !attackMode) && wordlist) { constructedOptions.push(wordlist); } else if ((attackMode === '0' || !attackMode) && !wordlist && !mask) { validationErrors.push("Dictionary attack requires a wordlist"); } // Mask for brute-force attacks if (attackMode === '3' && mask) { constructedOptions.push(mask); } else if (attackMode === '3' && !mask) { validationErrors.push("Brute-force attack requires a mask"); } // Hybrid attacks if (attackMode === '6' && wordlist && mask) { constructedOptions.push(wordlist, mask); } else if (attackMode === '6') { validationErrors.push("Hybrid Wordlist + Mask attack requires both wordlist and mask"); } if (attackMode === '7' && mask && wordlist) { constructedOptions.push(mask, wordlist); } else if (attackMode === '7') { validationErrors.push("Hybrid Mask + Wordlist attack requires both mask and wordlist"); } // Increment mode if (increment) { constructedOptions.push('-i'); if (incrementMin) constructedOptions.push('--increment-min', incrementMin.toString()); if (incrementMax) constructedOptions.push('--increment-max', incrementMax.toString()); } // Rules if (rules) { constructedOptions.push('-r', rules); } // Session management if (session) { constructedOptions.push('--session', session); } if (restore) { constructedOptions.push('--restore'); } // Performance options if (optimizedKernels) { constructedOptions.push('-O'); } if (workloadProfile) { constructedOptions.push('-w', workloadProfile); } if (deviceTypes && deviceTypes.length > 0) { constructedOptions.push('-d', deviceTypes.join(',')); } // General options if (force) { constructedOptions.push('--force'); } if (potfilePath) { constructedOptions.push('--potfile-path', potfilePath); } if (outfile) { constructedOptions.push('-o', outfile); if (outfileFormat) { constructedOptions.push('--outfile-format', outfileFormat.toString()); } } if (runtime) { constructedOptions.push('--runtime', runtime.toString()); } if (showProgress) { constructedOptions.push('--status'); } if (quiet) { constructedOptions.push('--quiet'); } if (loopback) { constructedOptions.push('--loopback'); } if (markovThreshold) { constructedOptions.push('--markov-threshold', markovThreshold.toString()); } // Custom charsets if (customCharset1) constructedOptions.push('-1', customCharset1); if (customCharset2) constructedOptions.push('-2', customCharset2); if (customCharset3) constructedOptions.push('-3', customCharset3); if (customCharset4) constructedOptions.push('-4', customCharset4); // Raw options if (options) { constructedOptions.push(...options); } if (validationErrors.length > 0) { throw new Error(`Invalid parameters: ${validationErrors.join('; ')}`); } const { fullOutput, cracked, potfileLocation } = await runHashcat(hashData, constructedOptions); const responseContent: any[] = []; if (currentUserSession.mode === UserMode.STUDENT) { responseContent.push({ type: "text", text: `Hashcat finished! Found ${cracked.length} cracked passwords.` }); if (cracked.length > 0) { responseContent.push({ type: "text", text: "\n**🎉 Cracked Passwords:**\n" + cracked.join("\n") }); responseContent.push({ type: "text", text: "\n**What this means:** These are the plain-text passwords that correspond to your hashes. You can now use these for further testing or to demonstrate the importance of strong passwords." }); } else { responseContent.push({ type: "text", text: "\n**No passwords cracked.** This could mean:\n- The passwords are very strong\n- You need a better wordlist\n- The hash type might be incorrect\n- Try different attack modes or longer runtime" }); } if (potfileLocation) { responseContent.push({ type: "text", text