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
text/typescript
#!/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