#!/usr/bin/env node
/// <reference path="./node-nmap.d.ts" />
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
import type { OAuthTokenVerifier } from "@modelcontextprotocol/sdk/server/auth/provider.js";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { ElicitResultSchema } from "@modelcontextprotocol/sdk/types.js";
import express from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";
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 { createRequire } from 'module';
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';
const require = createRequire(import.meta.url);
function parseCsvEnv(value: string | undefined, fallback: string[] = []): string[] {
if (!value) return fallback;
return value
.split(/[,\s]+/)
.map(v => v.trim())
.filter(Boolean);
}
const transportTypeRaw = (process.argv[2] || process.env.MCP_TRANSPORT || 'stdio').toLowerCase();
const transportType = transportTypeRaw === 'streamable' ? 'http' : transportTypeRaw;
const serverHost = process.env.MCP_SERVER_HOST || '0.0.0.0';
const serverPort = parseInt(process.env.MCP_SERVER_PORT || '8000');
const serverVersion = process.env.npm_package_version || "0.8.0";
// Bearer auth configuration (with legacy OAuth env aliases)
const authEnabled = (process.env.MCP_AUTH_ENABLED ?? process.env.MCP_OAUTH_ENABLED ?? 'false') === 'true';
const authMode = process.env.MCP_AUTH_MODE || 'bearer';
const authIssuer = process.env.MCP_OIDC_ISSUER ?? process.env.MCP_OAUTH_PROVIDER_URL;
const authJwksUrl = process.env.MCP_OIDC_JWKS_URL;
const authIntrospectionUrl = process.env.MCP_OIDC_INTROSPECTION_URL;
const authAudience = process.env.MCP_AUTH_AUDIENCE;
const authScopes = parseCsvEnv(process.env.MCP_AUTH_SCOPES ?? process.env.MCP_OAUTH_SCOPES, []);
const advertisedScopes = authScopes.length > 0 ? authScopes : ['read', 'write'];
const oauthClientId = process.env.MCP_OAUTH_CLIENT_ID;
const oauthClientSecret = process.env.MCP_OAUTH_CLIENT_SECRET;
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;
type WorkflowCategory =
| 'sniffing'
| 'finding'
| 'bruteforce'
| 'cracking'
| 'privilege-escalation'
| 'extraction'
| 'reporting'
| 'other';
interface EngagementRecord {
recordId: string;
createdAt: number;
tool: string;
category: WorkflowCategory;
target?: string;
summary: string;
findings: string[];
rawOutput?: string;
invocation?: string;
}
const engagementRecords: Map<string, EngagementRecord> = new Map();
function saveEngagementRecord(record: Omit<EngagementRecord, 'recordId' | 'createdAt'>): string {
const recordId = `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
engagementRecords.set(recordId, {
...record,
recordId,
createdAt: Date.now(),
});
return recordId;
}
type TokenIntrospectionResponse = {
active?: boolean;
client_id?: string;
sub?: string;
scope?: string;
aud?: string | string[];
exp?: number;
[key: string]: unknown;
};
async function verifyViaIntrospection(token: string): Promise<AuthInfo> {
if (!authIntrospectionUrl) {
throw new Error("MCP_OIDC_INTROSPECTION_URL is required for introspection mode.");
}
const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded" });
if (oauthClientId && oauthClientSecret) {
const basic = Buffer.from(`${oauthClientId}:${oauthClientSecret}`).toString("base64");
headers.set("Authorization", `Basic ${basic}`);
}
const form = new URLSearchParams({ token });
if (authAudience) {
form.set("resource", authAudience);
}
const response = await fetch(authIntrospectionUrl, {
method: "POST",
headers,
body: form.toString(),
});
if (!response.ok) {
const message = await response.text().catch(() => "");
throw new Error(`Introspection failed (${response.status}): ${message || "no response body"}`);
}
const data = (await response.json()) as TokenIntrospectionResponse;
if (!data.active) {
throw new Error("Token is not active.");
}
const scopes = parseCsvEnv(data.scope, []);
const audRaw = data.aud;
const audiences = Array.isArray(audRaw) ? audRaw : audRaw ? [audRaw] : [];
if (authAudience && audiences.length > 0 && !audiences.includes(authAudience)) {
throw new Error(`Token audience mismatch. Expected ${authAudience}, got ${audiences.join(",")}`);
}
return {
token,
clientId: data.client_id || data.sub || "unknown-client",
scopes,
expiresAt: typeof data.exp === "number" ? data.exp : undefined,
extra: data as Record<string, unknown>,
};
}
const remoteJwks = authJwksUrl ? createRemoteJWKSet(new URL(authJwksUrl)) : null;
async function verifyViaJwks(token: string): Promise<AuthInfo> {
if (!remoteJwks) {
throw new Error("MCP_OIDC_JWKS_URL is required for JWKS mode.");
}
const verifyOptions: {
issuer?: string;
audience?: string;
} = {};
if (authIssuer) verifyOptions.issuer = authIssuer;
if (authAudience) verifyOptions.audience = authAudience;
const { payload } = await jwtVerify(token, remoteJwks, verifyOptions);
const scopeValue =
typeof payload.scope === "string"
? payload.scope
: Array.isArray(payload.scp)
? payload.scp.join(" ")
: "";
const scopes = parseCsvEnv(scopeValue, []);
const clientId =
(typeof payload.client_id === "string" && payload.client_id) ||
(typeof payload.azp === "string" && payload.azp) ||
(typeof payload.sub === "string" && payload.sub) ||
"unknown-client";
return {
token,
clientId,
scopes,
expiresAt: typeof payload.exp === "number" ? payload.exp : undefined,
extra: payload as Record<string, unknown>,
};
}
const tokenVerifier: OAuthTokenVerifier = {
async verifyAccessToken(token: string): Promise<AuthInfo> {
if (authIntrospectionUrl) {
return verifyViaIntrospection(token);
}
return verifyViaJwks(token);
},
};
function buildOauthMetadata() {
const issuer = authIssuer || `http://${serverHost}:${serverPort}`;
return {
issuer,
authorization_endpoint: `${issuer.replace(/\/$/, "")}/authorize`,
token_endpoint: `${issuer.replace(/\/$/, "")}/token`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "client_credentials", "refresh_token"],
scopes_supported: advertisedScopes,
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
introspection_endpoint: authIntrospectionUrl,
};
}
function formatInvocation(extra?: { authInfo?: AuthInfo; sessionId?: string; requestId?: unknown }): string {
if (!extra) return "invocation=unknown";
const clientId = extra.authInfo?.clientId || "anonymous";
const scopes = extra.authInfo?.scopes?.length ? extra.authInfo.scopes.join(",") : "none";
const session = extra.sessionId || "stateless";
return `invocation.clientId=${clientId}; invocation.session=${session}; invocation.scopes=${scopes}; invocation.requestId=${String(extra.requestId ?? "unknown")}`;
}
async function resolveInspectorBinPath(): Promise<{ binPath: string; packageDir: string }> {
const packageJsonPath = require.resolve('@modelcontextprotocol/inspector/package.json');
const packageJsonRaw = await fs.readFile(packageJsonPath, 'utf8');
const parsed = JSON.parse(packageJsonRaw) as { bin?: string | Record<string, string> };
let binRelativePath: string | undefined;
if (typeof parsed.bin === 'string') {
binRelativePath = parsed.bin;
} else if (parsed.bin) {
binRelativePath = parsed.bin['mcp-inspector'] || Object.values(parsed.bin)[0];
}
if (!binRelativePath) {
throw new Error('Unable to resolve bundled MCP Inspector binary path.');
}
const packageDir = path.dirname(packageJsonPath);
return {
binPath: path.resolve(packageDir, binRelativePath),
packageDir,
};
}
async function launchBundledInspector(inspectorArgs: string[]): Promise<number> {
const separatorIndex = inspectorArgs.indexOf('--');
const passThroughInspectorArgs = separatorIndex === -1 ? inspectorArgs : inspectorArgs.slice(0, separatorIndex);
const serverArgs = separatorIndex === -1 ? [] : inspectorArgs.slice(separatorIndex + 1);
const { binPath: inspectorBin, packageDir: inspectorPackageDir } = await resolveInspectorBinPath();
const serverLaunchArgs = [process.argv[1], ...(serverArgs.length > 0 ? serverArgs : ['stdio'])];
const cliFlagIndex = passThroughInspectorArgs.indexOf('--cli');
const inspectorLaunchArgs =
cliFlagIndex === -1
? [...passThroughInspectorArgs, process.execPath, ...serverLaunchArgs]
: [
...passThroughInspectorArgs.slice(0, cliFlagIndex + 1),
process.execPath,
...serverLaunchArgs,
...passThroughInspectorArgs.slice(cliFlagIndex + 1),
];
console.error('Launching bundled MCP Inspector...');
console.error(`Inspector command: node ${inspectorBin} ${inspectorLaunchArgs.join(' ')}`.trim());
console.error(`Target MCP server command: ${process.execPath} ${serverLaunchArgs.join(' ')}`);
return await new Promise<number>((resolve, reject) => {
const child = spawn(process.execPath, [
inspectorBin,
...inspectorLaunchArgs,
], {
stdio: 'inherit',
cwd: inspectorPackageDir,
env: {
...process.env,
},
});
child.on('error', reject);
child.on('exit', (code) => resolve(code ?? 1));
});
}
/* // 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;
}
function toArray<T>(value: T | T[] | undefined | null): T[] {
if (value === undefined || value === null) return [];
return Array.isArray(value) ? value : [value];
}
function normalizeString(value: unknown, fallback = ''): string {
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return fallback;
}
function normalizeNullableString(value: unknown): string | null {
const parsed = normalizeString(value);
return parsed || null;
}
function normalizeOptionalString(value: unknown): string | undefined {
const parsed = normalizeString(value);
return parsed || undefined;
}
function parseNmapXmlOutput(xmlOutput: string): ScanData {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@",
allowBooleanAttributes: true,
parseAttributeValue: true,
isArray: (name: string) => ['host', 'port', 'hostname', 'osmatch', 'cpe', 'address'].includes(name),
});
const parsedXml = parser.parse(xmlOutput) as any;
const hosts = toArray(parsedXml?.nmaprun?.host);
const parsedScanData: ScanData = {};
for (let hostIndex = 0; hostIndex < hosts.length; hostIndex++) {
const hostNode = hosts[hostIndex] || {};
const addresses = toArray(hostNode.address);
const ipv4 = addresses.find((addr: any) => normalizeString(addr?.['@addrtype']) === 'ipv4');
const ipv6 = addresses.find((addr: any) => normalizeString(addr?.['@addrtype']) === 'ipv6');
const macAddress = addresses.find((addr: any) => normalizeString(addr?.['@addrtype']) === 'mac');
const selectedAddress = ipv4 || ipv6 || addresses[0];
const ip = normalizeString(selectedAddress?.['@addr'], `host-${hostIndex + 1}`);
const hostnameEntry = toArray(hostNode?.hostnames?.hostname).find((hostname: any) => normalizeString(hostname?.['@name']));
const hostname = normalizeNullableString(hostnameEntry?.['@name']);
const osMatches = toArray(hostNode?.os?.osmatch);
const osNmap = normalizeNullableString(osMatches[0]?.['@name']);
const ports = toArray(hostNode?.ports?.port).map((portNode: any) => {
const stateNode = portNode?.state || {};
const serviceNode = portNode?.service || {};
const cpeRaw = serviceNode?.cpe;
const cpeList = toArray(cpeRaw).map((cpe: any) => normalizeString(cpe)).filter(Boolean);
return {
portId: normalizeString(portNode?.['@portid']),
protocol: normalizeString(portNode?.['@protocol'], 'tcp'),
state: normalizeString(stateNode?.['@state'], 'unknown'),
reason: normalizeString(stateNode?.['@reason'], 'unknown'),
service: {
name: normalizeString(serviceNode?.['@name'], 'unknown'),
product: normalizeOptionalString(serviceNode?.['@product']),
version: normalizeOptionalString(serviceNode?.['@version']),
extrainfo: normalizeOptionalString(serviceNode?.['@extrainfo']),
method: normalizeOptionalString(serviceNode?.['@method']),
conf: normalizeOptionalString(serviceNode?.['@conf']),
cpe: cpeList.length > 0 ? cpeList.join(', ') : undefined,
},
} as Port;
});
parsedScanData[ip] = {
hostname,
ip,
mac: normalizeNullableString(macAddress?.['@addr']),
ports,
osNmap,
};
}
return parsedScanData;
}
// --- 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 {
parsedScanData = parseNmapXmlOutput(stdoutData);
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\(\)\<\>\\]+)$/;
const COMMAND_TIMEOUT_MS = Number(process.env.MCP_COMMAND_TIMEOUT_MS || 15 * 60 * 1000);
const COMMAND_OUTPUT_LIMIT_BYTES = Number(process.env.MCP_COMMAND_OUTPUT_LIMIT_BYTES || 2 * 1024 * 1024);
const commandAvailability = new Map<string, boolean>();
const commandResolutionCache = new Map<string, string | null>();
const COMMAND_CANDIDATES: Record<string, string[]> = {
httpx: ['httpx-toolkit', 'httpx'],
};
type CommandProbeResult = { stdout: string; stderr: string; code: number | null };
type CommandProbe = {
args: string[];
timeoutMs?: number;
validate: (result: CommandProbeResult) => boolean;
};
function looksLikeProjectDiscoveryHttpx(result: CommandProbeResult): boolean {
const combined = `${result.stdout}\n${result.stderr}`.toLowerCase();
if (combined.includes('next generation http client')) {
// This identifies Python HTTPX CLI, not ProjectDiscovery httpx.
return false;
}
return (
combined.includes('projectdiscovery') ||
combined.includes('-status-code') ||
combined.includes('-tech-detect') ||
combined.includes('-silent') ||
combined.includes('httpx version')
);
}
const COMMAND_PROBES: Record<string, CommandProbe[]> = {
httpx: [
{
args: ['-version'],
timeoutMs: 5000,
validate: (result) => result.code === 0 && looksLikeProjectDiscoveryHttpx(result),
},
{
args: ['-h'],
timeoutMs: 5000,
validate: (result) => result.code === 0 && looksLikeProjectDiscoveryHttpx(result),
},
],
};
async function runCommandProbe(command: string, args: string[], timeoutMs = 5000): Promise<CommandProbeResult> {
return new Promise((resolve) => {
const process = spawn(command, args);
let stdout = '';
let stderr = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
process.kill('SIGTERM');
setTimeout(() => process.kill('SIGKILL'), 1000);
}, timeoutMs);
process.stdout.on('data', (data) => {
stdout = cappedAppend(stdout, data.toString());
});
process.stderr.on('data', (data) => {
stderr = cappedAppend(stderr, data.toString());
});
process.on('error', (error) => {
clearTimeout(timer);
resolve({ stdout, stderr: `${stderr}\n${error.message}`, code: null });
});
process.on('close', (code) => {
clearTimeout(timer);
if (timedOut) {
resolve({ stdout, stderr: `${stderr}\nProbe command timed out after ${timeoutMs}ms`, code: null });
} else {
resolve({ stdout, stderr, code });
}
});
});
}
async function validateCommandCandidate(command: string, candidate: string): Promise<boolean> {
const probes = COMMAND_PROBES[command];
if (!probes || probes.length === 0) {
return true;
}
for (const probe of probes) {
const result = await runCommandProbe(candidate, probe.args, probe.timeoutMs);
if (probe.validate(result)) {
return true;
}
}
return false;
}
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;
}
async function checkCommandAvailable(command: string): Promise<boolean> {
if (commandAvailability.has(command)) {
return commandAvailability.get(command)!;
}
const resolved = await resolveCommand(command);
const result = Boolean(resolved);
commandAvailability.set(command, result);
return result;
}
async function resolveCommand(command: string): Promise<string | null> {
if (commandResolutionCache.has(command)) {
return commandResolutionCache.get(command)!;
}
const candidates = COMMAND_CANDIDATES[command] ?? [command];
for (const candidate of candidates) {
const found = await new Promise<boolean>((resolve) => {
const cmd = spawn('which', [candidate]);
cmd.on('close', (code) => resolve(code === 0));
cmd.on('error', () => resolve(false));
});
if (found) {
const valid = await validateCommandCandidate(command, candidate);
if (valid) {
commandResolutionCache.set(command, candidate);
return candidate;
}
console.warn(`Command "${candidate}" is present but did not pass validation for "${command}".`);
}
}
commandResolutionCache.set(command, null);
return null;
}
function cappedAppend(current: string, chunk: string): string {
if (current.length >= COMMAND_OUTPUT_LIMIT_BYTES) {
return current;
}
const remaining = COMMAND_OUTPUT_LIMIT_BYTES - current.length;
return current + chunk.slice(0, Math.max(remaining, 0));
}
// 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 }> {
const resolvedCommand = await resolveCommand(command);
if (!resolvedCommand) {
const candidates = COMMAND_CANDIDATES[command] ?? [command];
throw new Error(`Required command "${command}" is not available in PATH. Checked: ${candidates.join(', ')}`);
}
return new Promise((resolve, reject) => {
console.error(`Attempting to spawn: ${resolvedCommand} ${args.join(' ')}`); // Added for debugging
const process = spawn(resolvedCommand, args);
let stdout = '';
let stderr = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
process.kill('SIGTERM');
setTimeout(() => process.kill('SIGKILL'), 2000);
}, COMMAND_TIMEOUT_MS);
process.stdout.on('data', (data) => { stdout = cappedAppend(stdout, data.toString()); });
process.stderr.on('data', (data) => { stderr = cappedAppend(stderr, data.toString()); });
process.on('error', (error) => {
// Explicitly catch spawn errors (e.g., command not found)
console.error(`Spawn error for command "${resolvedCommand}": ${error.message}`);
clearTimeout(timer);
reject(new Error(`Failed to start command "${resolvedCommand}": ${error.message}`));
});
process.on('close', (code) => {
clearTimeout(timer);
if (timedOut) {
resolve({
stdout,
stderr: `${stderr}\nCommand timed out after ${COMMAND_TIMEOUT_MS}ms.`,
code: null
});
return;
}
console.error(`Command "${resolvedCommand}" 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}`);
}
}
async function runSubfinder(domain: string, rawOptions: string[] = []): Promise<{ fullOutput: string; domains: string[] }> {
if (!domain || /\s/.test(domain)) {
throw new Error("Domain must be a non-empty domain string.");
}
const options = sanitizeOptions(rawOptions);
const args = ['-d', domain, '-silent', ...options];
const result = await runSpawnCommand('subfinder', args);
const domains = result.stdout
.split('\n')
.map(line => line.trim())
.filter(Boolean);
return {
fullOutput: `Executing: subfinder ${args.join(' ')}\nExit: ${result.code}\nStderr:\n${result.stderr}`,
domains
};
}
async function runHttpxProbe(targets: string[], rawOptions: string[] = []): Promise<{ fullOutput: string; results: string[] }> {
if (targets.length === 0) {
throw new Error("At least one target is required.");
}
const options = sanitizeOptions(rawOptions);
const inputPath = path.join('/tmp', `httpx-targets-${Date.now()}.txt`);
try {
await fs.writeFile(inputPath, targets.join('\n'));
const args = ['-silent', '-l', inputPath, ...options];
const result = await runSpawnCommand('httpx', args);
const lines = result.stdout
.split('\n')
.map(line => line.trim())
.filter(Boolean);
return {
fullOutput: `Executing: httpx ${args.join(' ')}\nExit: ${result.code}\nStderr:\n${result.stderr}`,
results: lines
};
} finally {
await fs.unlink(inputPath).catch(() => undefined);
}
}
async function runFfufScan(url: string, wordlist: string, rawOptions: string[] = []): Promise<{ fullOutput: string; findings: string[] }> {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw new Error("URL must start with http:// or https://");
}
const options = sanitizeOptions(rawOptions);
const outputPath = path.join('/tmp', `ffuf-${Date.now()}.json`);
const args = ['-u', url, '-w', wordlist, '-of', 'json', '-o', outputPath, ...options];
const result = await runSpawnCommand('ffuf', args);
let findings: string[] = [];
try {
const raw = await fs.readFile(outputPath, 'utf8');
const parsed = JSON.parse(raw) as { results?: Array<{ url?: string; status?: number; length?: number; words?: number; lines?: number }> };
findings = (parsed.results || []).map(item => `${item.status ?? 'n/a'} ${item.url ?? 'unknown'} len=${item.length ?? 'n/a'} words=${item.words ?? 'n/a'} lines=${item.lines ?? 'n/a'}`);
} catch {
findings = result.stdout
.split('\n')
.map(line => line.trim())
.filter(Boolean);
}
await fs.unlink(outputPath).catch(() => undefined);
return {
fullOutput: `Executing: ffuf ${args.join(' ')}\nExit: ${result.code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}`,
findings
};
}
async function runNucleiScan(targets: string[], rawOptions: string[] = []): Promise<{ fullOutput: string; findings: string[] }> {
if (targets.length === 0) {
throw new Error("At least one target is required.");
}
const options = sanitizeOptions(rawOptions);
const inputPath = path.join('/tmp', `nuclei-targets-${Date.now()}.txt`);
const outputPath = path.join('/tmp', `nuclei-${Date.now()}.jsonl`);
try {
await fs.writeFile(inputPath, targets.join('\n'));
const args = ['-l', inputPath, '-jsonl', '-o', outputPath, ...options];
const result = await runSpawnCommand('nuclei', args);
let findings: string[] = [];
try {
const raw = await fs.readFile(outputPath, 'utf8');
findings = raw
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(line => {
try {
const parsed = JSON.parse(line) as { info?: { severity?: string; name?: string }; matched?: string; templateID?: string };
return `[${parsed.info?.severity || 'info'}] ${parsed.info?.name || parsed.templateID || 'finding'} -> ${parsed.matched || 'n/a'}`;
} catch {
return line;
}
});
} catch {
findings = result.stdout.split('\n').map(line => line.trim()).filter(Boolean);
}
return {
fullOutput: `Executing: nuclei ${args.join(' ')}\nExit: ${result.code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}`,
findings
};
} finally {
await fs.unlink(inputPath).catch(() => undefined);
await fs.unlink(outputPath).catch(() => undefined);
}
}
async function runTrafficCapture(
networkInterface: string,
packetCount: number,
bpfFilter?: string,
outputPcap?: string,
rawOptions: string[] = []
): Promise<{ fullOutput: string; findings: string[] }> {
if (!networkInterface) {
throw new Error("networkInterface is required (example: en0, eth0).");
}
if (!packetCount || packetCount <= 0) {
throw new Error("packetCount must be > 0 to avoid long-running captures.");
}
const options = sanitizeOptions(rawOptions);
const args = ['-i', networkInterface, '-nn', '-c', String(packetCount)];
if (outputPcap) args.push('-w', outputPcap);
args.push(...options);
if (bpfFilter) args.push(bpfFilter);
const result = await runSpawnCommand('tcpdump', args);
const findings = result.stdout
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.slice(0, 100);
return {
fullOutput: `Executing: tcpdump ${args.join(' ')}\nExit: ${result.code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}`,
findings,
};
}
async function runHydra(
target: string,
service: string,
rawOptions: string[] = []
): Promise<{ fullOutput: string; findings: string[] }> {
if (!target) throw new Error("target is required.");
if (!service) throw new Error("service is required (ssh, rdp, ftp, smb, etc.).");
const options = sanitizeOptions(rawOptions);
const args = [...options, target, service];
const result = await runSpawnCommand('hydra', args);
const findings = `${result.stdout}\n${result.stderr}`
.split('\n')
.map(line => line.trim())
.filter(line => /login:|password:/i.test(line));
return {
fullOutput: `Executing: hydra ${args.join(' ')}\nExit: ${result.code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}`,
findings,
};
}
async function runSqlmap(
targetUrl: string,
rawOptions: string[] = []
): Promise<{ fullOutput: string; findings: string[] }> {
if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://')) {
throw new Error("targetUrl must start with http:// or https://");
}
const options = sanitizeOptions(rawOptions);
const args = ['-u', targetUrl, '--batch', ...options];
const result = await runSpawnCommand('sqlmap', args);
const findings = `${result.stdout}\n${result.stderr}`
.split('\n')
.map(line => line.trim())
.filter(line => /\[(CRITICAL|WARNING|INFO)\]/.test(line))
.slice(0, 200);
return {
fullOutput: `Executing: sqlmap ${args.join(' ')}\nExit: ${result.code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}`,
findings,
};
}
async function runPrivEscAudit(rawOptions: string[] = []): Promise<{ fullOutput: string; findings: string[] }> {
const options = sanitizeOptions(rawOptions);
const sections: string[] = [];
const findings: string[] = [];
const baselineCommands: Array<{ cmd: string; args: string[]; label: string }> = [
{ cmd: 'id', args: [], label: 'Identity' },
{ cmd: 'whoami', args: [], label: 'Current User' },
{ cmd: 'uname', args: ['-a'], label: 'Kernel' },
{ cmd: 'sudo', args: ['-n', '-l'], label: 'Sudo Rights' },
];
for (const item of baselineCommands) {
try {
const out = await runSpawnCommand(item.cmd, item.args);
sections.push(`## ${item.label}\n${out.stdout}\n${out.stderr}`);
const flagged = `${out.stdout}\n${out.stderr}`
.split('\n')
.map(line => line.trim())
.filter(line => /NOPASSWD|root|CAP_|SUID|sudoers|ALL=\(ALL\)/i.test(line));
findings.push(...flagged);
} catch (error: any) {
sections.push(`## ${item.label}\nError: ${error.message}`);
}
}
// Try linpeas if available; this is optional but useful when installed.
const linpeasAvailable = await checkCommandAvailable('linpeas');
if (linpeasAvailable) {
try {
const out = await runSpawnCommand('linpeas', options.length > 0 ? options : ['-q']);
sections.push(`## linpeas\n${out.stdout}\n${out.stderr}`);
const flagged = `${out.stdout}\n${out.stderr}`
.split('\n')
.map(line => line.trim())
.filter(line => /(sudo|kernel|exploit|password|credential|SUID|writable)/i.test(line))
.slice(0, 200);
findings.push(...flagged);
} catch (error: any) {
sections.push(`## linpeas\nError: ${error.message}`);
}
}
return {
fullOutput: sections.join('\n\n'),
findings: [...new Set(findings)],
};
}
function defaultScopeTemplate(): string {
return [
'Scope source: default template (client SoW not shared).',
'Authorized targets: Only explicitly approved assets.',
'Prohibited actions: No destructive actions, no denial-of-service, no persistence.',
'Time window: Business-approved testing window only.',
'Data handling: Minimum data access; sensitive data redacted in report.',
'Reporting: Findings include evidence, severity, impact, and remediation.'
].join('\n');
}
// --- 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...`);
const expectedCommands = [
'nmap',
'john',
'hashcat',
'gobuster',
'nikto',
'subfinder',
'httpx',
'ffuf',
'nuclei',
'tcpdump',
'hydra',
'sqlmap',
];
const missingCommands: string[] = [];
for (const command of expectedCommands) {
if (!(await checkCommandAvailable(command))) {
missingCommands.push(command);
}
}
if (missingCommands.length > 0) {
console.warn(`Missing tools in PATH: ${missingCommands.join(', ')}. Related MCP tools will return runtime errors until installed.`);
}
server = new McpServer(
{
name: "pentest-mcp",
version: serverVersion,
},
{
capabilities: {
resources: {},
tools: {},
}
}
);
// --- 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[];
engagementRecords: EngagementRecord[];
workflowSummary: Record<WorkflowCategory, number>;
scopeNotes: string;
invocationContext: string;
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* ") });
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'nmapScan',
category: 'finding',
target,
summary: `Nmap scan completed for ${target}`,
findings: responseContent.map(item => item.text).join('\n').split('\n').slice(0, 50),
rawOutput: responseContent.map(item => item.text).join('\n'),
invocation,
});
responseContent.push({ type: "text", text: `recordId=${recordId}` });
responseContent.push({ type: "text", text: invocation });
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'));
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'generateWordlist',
category: 'other',
summary: `Generated custom wordlist with ${wordlist.size} entries`,
findings: [
`wordCount=${wordlist.size}`,
`path=${filePath}`,
],
rawOutput: Array.from(wordlist).slice(0, 200).join('\n'),
invocation,
});
return {
content: [
{ type: "text", text: `Generated ${wordlist.size} words.` },
{ type: "text", text: `Path: ${filePath}` },
{ type: "text", text: `recordId=${recordId}` },
{ type: "text", text: invocation },
]
};
} 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
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'runJohnTheRipper',
category: 'cracking',
summary: `John the Ripper completed with ${cracked.length} cracked entries.`,
findings: cracked,
rawOutput: fullOutput,
invocation,
});
responseContent.push({ type: "text", text: `recordId=${recordId}` });
responseContent.push({ type: "text", text: invocation });
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: `\n**Tip:** Results are saved in: ${potfileLocation}` });
}
} else {
// Professional mode
responseContent.push({ type: "text", text: `Hashcat completed. Cracked: ${cracked.length} hashes.` });
if (cracked.length > 0) {
responseContent.push({ type: "text", text: "\n**Cracked:**\n" + cracked.join("\n") });
}
if (potfileLocation) {
responseContent.push({ type: "text", text: `Potfile: ${potfileLocation}` });
}
}
responseContent.push({ type: "text", text: "\n--- Full Hashcat Output ---\n" + fullOutput });
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'runHashcat',
category: 'cracking',
summary: `Hashcat completed with ${cracked.length} cracked entries.`,
findings: cracked,
rawOutput: fullOutput,
invocation,
});
responseContent.push({ type: "text", text: `recordId=${recordId}` });
responseContent.push({ type: "text", text: invocation });
return { content: responseContent };
} catch (error: any) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
// Cancel Scan Tool
const cancelScanToolSchema = z.object({
scanId: z.string().describe("The ID of the scan to cancel")
}).describe(
"Stop an Nmap scan that is currently running. Pass the scanId returned when the scan was started."
);
server.tool("cancelScan", cancelScanToolSchema.shape, async ({ scanId } /*, extra */) => {
const activeScan = activeScans.get(scanId);
if (!activeScan) {
return { content: [{ type: "text", text: `Error: No active scan found with ID: ${scanId}` }], isError: true };
}
try {
activeScan.process.kill();
activeScan.progress.status = 'cancelled';
activeScans.delete(scanId);
return { content: [{ type: "text", text: `Successfully cancelled scan ${scanId}` }] };
} catch (error: any) {
return { content: [{ type: "text", text: `Error: Failed to cancel scan: ${error.message}` }], isError: true };
}
});
const listEngagementRecordsToolSchema = z.object({
category: z.enum(['sniffing', 'finding', 'bruteforce', 'cracking', 'privilege-escalation', 'extraction', 'reporting', 'other']).optional().describe("Optional workflow category filter"),
targetContains: z.string().optional().describe("Optional case-insensitive target filter"),
limit: z.number().int().positive().max(500).optional().describe("Maximum records to return")
}).describe("List captured engagement records so reports can be assembled without manual copy/paste.");
server.tool("listEngagementRecords", listEngagementRecordsToolSchema.shape, async ({ category, targetContains, limit }) => {
const normalizedTarget = targetContains?.toLowerCase();
const filtered = Array.from(engagementRecords.values())
.filter(record => !category || record.category === category)
.filter(record => !normalizedTarget || (record.target || '').toLowerCase().includes(normalizedTarget))
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, limit || 100);
return {
content: [
{ type: "text", text: `Returned ${filtered.length} engagement records.` },
{ type: "text", text: JSON.stringify(filtered, null, 2) }
]
};
});
const getEngagementRecordToolSchema = z.object({
recordId: z.string().describe("Engagement record ID (recordId=...)")
}).describe("Fetch a single engagement record by ID.");
server.tool("getEngagementRecord", getEngagementRecordToolSchema.shape, async ({ recordId }) => {
const record = engagementRecords.get(recordId);
if (!record) {
return { content: [{ type: "text", text: `Record ${recordId} not found.` }], isError: true };
}
return {
content: [{ type: "text", text: JSON.stringify(record, null, 2) }]
};
});
// Create Client Report Tool
const createClientReportToolSchema = z.object({
client: z.string().optional().describe("Client name for the report"),
title: z.string().describe("Title of the assessment report"),
assessmentType: z.string().describe("Type of assessment"),
scanIds: z.array(z.string()).optional().describe("IDs of scans to include"),
recordIds: z.array(z.string()).optional().describe("Specific engagement record IDs to include"),
scopeMode: z.enum(['ask', 'provided', 'template']).optional().default('ask').describe("How to include scope-of-work notes"),
scopeOfWork: z.string().optional().describe("Scope-of-work notes when scopeMode=provided"),
summary: z.string().optional().describe("Executive summary"),
recommendations: z.array(z.string()).optional().describe("List of recommendations")
}).describe(
"Create a full client report from collected findings. Scope notes can be asked via MCP elicitation, supplied directly, or generated from a safe default template."
);
server.tool("createClientReport", createClientReportToolSchema.shape, async (args, extra) => {
const { client, title, assessmentType, scanIds, recordIds, scopeMode, scopeOfWork, summary, recommendations } = args;
const reportId = `report-${Date.now()}`;
const scans = (scanIds || []).map(getScanDataById).filter(Boolean);
const findings = analyzeFindings(scans);
const invocationContext = formatInvocation(extra);
const resolvedScopeMode = scopeMode || 'ask';
let scopeNotes = defaultScopeTemplate();
if (resolvedScopeMode === 'provided' && scopeOfWork?.trim()) {
scopeNotes = scopeOfWork.trim();
} else if (resolvedScopeMode === 'ask') {
try {
const elicited = await extra.sendRequest({
method: 'elicitation/create',
params: {
mode: 'form',
message: 'Provide scope-of-work details for this report (or decline to use default template).',
requestedSchema: {
type: 'object',
properties: {
authorizedTargets: { type: 'string', title: 'Authorized Targets' },
prohibitedActions: { type: 'string', title: 'Prohibited Actions' },
objective: { type: 'string', title: 'Assessment Objective' },
timeWindow: { type: 'string', title: 'Testing Window' },
dataHandling: { type: 'string', title: 'Data Handling Requirements' },
},
required: ['authorizedTargets', 'objective'],
}
}
}, ElicitResultSchema);
if (elicited.action === 'accept') {
const c = elicited.content as Record<string, string | undefined>;
scopeNotes = [
'Scope source: elicited via user invocation.',
`Authorized targets: ${c.authorizedTargets || 'Not provided'}`,
`Objective: ${c.objective || 'Not provided'}`,
`Prohibited actions: ${c.prohibitedActions || 'Not provided'}`,
`Testing window: ${c.timeWindow || 'Not provided'}`,
`Data handling: ${c.dataHandling || 'Not provided'}`,
].join('\n');
}
} catch (error: any) {
console.warn(`Scope elicitation failed, using default template: ${error.message}`);
}
}
const selectedRecords = (recordIds && recordIds.length > 0)
? recordIds.map(id => engagementRecords.get(id)).filter(Boolean) as EngagementRecord[]
: Array.from(engagementRecords.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 200);
const workflowSummary: Record<WorkflowCategory, number> = {
'sniffing': 0,
'finding': 0,
'bruteforce': 0,
'cracking': 0,
'privilege-escalation': 0,
'extraction': 0,
'reporting': 0,
'other': 0
};
for (const record of selectedRecords) {
workflowSummary[record.category] = (workflowSummary[record.category] || 0) + 1;
}
const report: ClientReport = {
reportId, title, client: client || "Client withheld", createdAt: Date.now(),
assessmentType, findings, scans,
engagementRecords: selectedRecords,
workflowSummary,
scopeNotes,
invocationContext,
summary: summary || "", recommendations: recommendations || []
};
clientReports.set(reportId, report);
const workflowText = Object.entries(workflowSummary)
.map(([phase, count]) => `${phase}: ${count}`)
.join('\n');
const reportRecordId = saveEngagementRecord({
tool: 'createClientReport',
category: 'reporting',
target: client || 'Client withheld',
summary: `Generated client report ${reportId} for ${assessmentType}`,
findings: [
`reportId=${reportId}`,
`recordsIncluded=${selectedRecords.length}`,
`scopeMode=${resolvedScopeMode}`,
],
rawOutput: JSON.stringify(report, null, 2),
invocation: invocationContext,
});
return {
content: [
{ type: "text", text: `Client report created: ${reportId}` },
{ type: "text", text: `recordId=${reportRecordId}` },
{ type: "text", text: `URI: mcp://pentest/clientReport/${reportId}` },
{ type: "text", text: `Scope Notes:\n${scopeNotes}` },
{ type: "text", text: `Workflow Coverage:\n${workflowText}` },
{ type: "text", text: `Invocation Context:\n${invocationContext}` },
]
};
});
// Gobuster Tool (Reinstated)
const gobusterToolSchema = z.object({
target: z.string().url().describe("Target URL"),
wordlist: z.string().describe("Path to wordlist"),
extensions: z.string().optional().describe("File extensions (comma-separated)"),
threads: z.number().int().positive().optional().describe("Number of threads"),
statusCodes: z.string().optional().describe("Valid status codes (comma-separated)"),
useragent: z.string().optional().describe("User-Agent string"),
timeout: z.string().optional().describe("Timeout for requests"),
basicAuth: z.string().optional().describe("Basic authentication credentials (username:password)"),
cookie: z.string().optional().describe("Cookie to include in requests"),
excludeLength: z.array(z.number()).optional().describe("Exclude paths of specific lengths"),
followRedirect: z.boolean().optional().describe("Follow HTTP redirects"),
noTLSValidation: z.boolean().optional().describe("Skip TLS certificate validation"),
rawOptions: z.array(z.string()).optional().describe("Raw gobuster options")
}).describe(
"Enumerate hidden directories and files on a web server. Run this after confirming the target hosts a web service. " +
"Provide a wordlist and optional extensions. Example: `{\"target\":\"http://example.com\", \"wordlist\":\"/usr/share/wordlists/common.txt\"}`"
);
server.tool("gobuster", gobusterToolSchema.shape, async (args /*: z.infer<typeof gobusterToolSchema> */, extra) => {
const { target, wordlist, extensions, threads, statusCodes, useragent, timeout, basicAuth, cookie, excludeLength, followRedirect, noTLSValidation, rawOptions } = args;
console.error(`Received gobuster request:`, args);
try {
const constructedOptions: string[] = [];
constructedOptions.push('-w', wordlist);
if (extensions) constructedOptions.push('-x', extensions);
if (threads) constructedOptions.push('-t', threads.toString());
if (statusCodes) constructedOptions.push('-s', statusCodes);
if (useragent) constructedOptions.push('-a', useragent);
if (timeout) constructedOptions.push('--timeout', timeout);
if (basicAuth) { const [u,p] = basicAuth.split(':'); constructedOptions.push('-U', u, '-P', p); }
if (cookie) constructedOptions.push('-c', cookie);
if (excludeLength) constructedOptions.push('--exclude-length', excludeLength.join(','));
if (followRedirect) constructedOptions.push('-r');
if (noTLSValidation) constructedOptions.push('-k');
if (rawOptions) constructedOptions.push(...rawOptions);
const { fullOutput, foundPaths } = await runGobuster(target, constructedOptions);
const responseContent: any[] = [];
if (currentUserSession.mode === UserMode.STUDENT) {
responseContent.push({ type: "text", text: `Found ${foundPaths.length} paths/files at ${target}:\n\n${foundPaths.join('\n')}` });
if (foundPaths.length > 0) responseContent.push({ type: "text", text: "\n**Next Steps:** ..." });
else responseContent.push({ type: "text", text: "\n**No paths found...**" });
} else {
responseContent.push({ type: "text", text: `Found ${foundPaths.length} paths at ${target}` });
if (foundPaths.length > 0) responseContent.push({ type: "text", text: "\n**Paths:**\n" + foundPaths.join('\n') });
responseContent.push({ type: "text", text: "\n**Full Output:**\n" + fullOutput });
}
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'gobuster',
category: 'finding',
target,
summary: `Gobuster found ${foundPaths.length} paths on ${target}`,
findings: foundPaths,
rawOutput: fullOutput,
invocation,
});
responseContent.push({ type: "text", text: `recordId=${recordId}` });
responseContent.push({ type: "text", text: invocation });
return { content: responseContent };
} catch (error: any) { return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true }; }
});
// Nikto Tool (Reinstated)
const niktoToolSchema = z.object({
target: z.string().url().describe("Target URL"),
port: z.string().optional().describe("Port(s) to scan"),
ssl: z.boolean().optional().describe("Force SSL mode"),
timeout: z.number().int().optional().describe("Timeout for requests"),
useragent: z.string().optional().describe("User-Agent string"),
tuning: z.string().optional().describe("Tuning mode"),
output: z.string().optional().describe("Output file"),
proxy: z.string().optional().describe("Use proxy"),
basicAuth: z.string().optional().describe("Basic authentication credentials (username:password)"),
root: z.string().optional().describe("Root directory"),
cookies: z.string().optional().describe("Cookies to include"),
rawOptions: z.array(z.string()).optional().describe("Raw nikto options")
}).describe(
"Scan a web server for common vulnerabilities with Nikto. Use after identifying a live site. " +
"Some checks may be noisy. Example: `{\"target\":\"http://192.168.1.10\", \"ssl\":true, \"port\":\"443\"}`"
);
server.tool("nikto", niktoToolSchema.shape, async (args /*: z.infer<typeof niktoToolSchema> */, extra) => {
const { target, port, ssl, timeout, useragent, tuning, output, proxy, basicAuth, root, cookies, rawOptions } = args;
console.error(`Received nikto request:`, args);
try {
const constructedOptions: string[] = [];
constructedOptions.push('-nointeractive');
if (port) constructedOptions.push('-p', port);
if (ssl) constructedOptions.push('-ssl');
if (timeout) constructedOptions.push('-Timeout', timeout.toString());
if (useragent) constructedOptions.push('-useragent', useragent);
if (tuning) constructedOptions.push('-Tuning', tuning);
if (output) constructedOptions.push('-o', output);
if (proxy) constructedOptions.push('-useproxy', proxy);
if (basicAuth) { constructedOptions.push('-id', basicAuth); }
if (root) constructedOptions.push('-root', root);
if (cookies) constructedOptions.push('-Cookies', cookies);
if (rawOptions) constructedOptions.push(...rawOptions);
const { fullOutput, findings } = await runNikto(target, constructedOptions);
const responseContent: any[] = [];
if (currentUserSession.mode === UserMode.STUDENT) {
responseContent.push({ type: "text", text: `Found ${findings.length} issues at ${target}` });
if (findings.length > 0) { /* Categorize and add explanations */ }
else { responseContent.push({ type: "text", text: "\n**No significant issues found...**" }); }
} else {
responseContent.push({ type: "text", text: `Found ${findings.length} issues at ${target}` });
if (findings.length > 0) responseContent.push({ type: "text", text: "\n**Findings:**\n- " + findings.join('\n- ') });
responseContent.push({ type: "text", text: "\n**Full Output:**\n" + fullOutput });
if (findings.some(f => f.toLowerCase().includes('injection'))) { /* Suggest follow-up */ }
}
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'nikto',
category: 'finding',
target,
summary: `Nikto found ${findings.length} findings on ${target}`,
findings,
rawOutput: fullOutput,
invocation,
});
responseContent.push({ type: "text", text: `recordId=${recordId}` });
responseContent.push({ type: "text", text: invocation });
return { content: responseContent };
} catch (error: any) { return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true }; }
});
const subfinderToolSchema = z.object({
domain: z.string().describe("Root domain to enumerate (example.com)"),
recursive: z.boolean().optional().describe("Enable recursive subdomain discovery"),
allSources: z.boolean().optional().describe("Use all available passive sources"),
timeoutSeconds: z.number().int().positive().optional().describe("Global timeout in seconds"),
rawOptions: z.array(z.string()).optional().describe("Additional safe subfinder options")
}).describe("Enumerate subdomains for a target domain using subfinder.");
server.tool("subfinderEnum", subfinderToolSchema.shape, async ({ domain, recursive, allSources, timeoutSeconds, rawOptions }, extra) => {
try {
const options: string[] = [];
if (recursive) options.push('-recursive');
if (allSources) options.push('-all');
if (timeoutSeconds) options.push('-timeout', String(timeoutSeconds));
if (rawOptions) options.push(...rawOptions);
const { fullOutput, domains } = await runSubfinder(domain, options);
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'subfinderEnum',
category: 'finding',
target: domain,
summary: `Subfinder enumerated ${domains.length} domains for ${domain}`,
findings: domains,
rawOutput: fullOutput,
invocation,
});
return {
content: [
{ type: "text", text: `Subfinder discovered ${domains.length} domains for ${domain}.` },
{ type: "text", text: `recordId=${recordId}` },
{ type: "text", text: invocation },
{ type: "text", text: domains.join('\n') || "No subdomains found." },
{ type: "text", text: `\n--- Full Subfinder Output ---\n${fullOutput}` },
],
};
} catch (error: any) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
const httpxToolSchema = z.object({
targets: z.array(z.string()).min(1).describe("List of domains/hosts/URLs to probe"),
includeTitle: z.boolean().optional().describe("Include page title"),
includeStatusCode: z.boolean().optional().describe("Include HTTP status code"),
includeTechDetect: z.boolean().optional().describe("Enable technology detection"),
followRedirects: z.boolean().optional().describe("Follow redirects"),
timeoutSeconds: z.number().int().positive().optional().describe("HTTP timeout in seconds"),
rawOptions: z.array(z.string()).optional().describe("Additional safe httpx options")
}).describe("Probe hosts with httpx and return live web endpoints.");
server.tool("httpxProbe", httpxToolSchema.shape, async ({ targets, includeTitle, includeStatusCode, includeTechDetect, followRedirects, timeoutSeconds, rawOptions }, extra) => {
try {
const options: string[] = [];
if (includeTitle) options.push('-title');
if (includeStatusCode) options.push('-status-code');
if (includeTechDetect) options.push('-tech-detect');
if (followRedirects) options.push('-follow-redirects');
if (timeoutSeconds) options.push('-timeout', String(timeoutSeconds));
if (rawOptions) options.push(...rawOptions);
const { fullOutput, results } = await runHttpxProbe(targets, options);
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'httpxProbe',
category: 'finding',
summary: `httpx probe completed for ${targets.length} targets`,
findings: results,
rawOutput: fullOutput,
invocation,
});
return {
content: [
{ type: "text", text: `httpx found ${results.length} responsive endpoints.` },
{ type: "text", text: `recordId=${recordId}` },
{ type: "text", text: invocation },
{ type: "text", text: results.join('\n') || "No responsive targets found." },
{ type: "text", text: `\n--- Full httpx Output ---\n${fullOutput}` },
],
};
} catch (error: any) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
const ffufToolSchema = z.object({
targetUrl: z.string().url().describe("Target URL with optional FUZZ marker"),
wordlist: z.string().describe("Wordlist path for ffuf"),
threads: z.number().int().positive().optional().describe("Worker threads"),
matchStatusCodes: z.string().optional().describe("Comma-separated status codes to include"),
filterStatusCodes: z.string().optional().describe("Comma-separated status codes to exclude"),
recursion: z.boolean().optional().describe("Enable recursion"),
timeoutSeconds: z.number().int().positive().optional().describe("HTTP timeout in seconds"),
rawOptions: z.array(z.string()).optional().describe("Additional safe ffuf options")
}).describe("Run ffuf for web content discovery and fuzzing.");
server.tool("ffufScan", ffufToolSchema.shape, async ({ targetUrl, wordlist, threads, matchStatusCodes, filterStatusCodes, recursion, timeoutSeconds, rawOptions }, extra) => {
try {
const options: string[] = [];
if (threads) options.push('-t', String(threads));
if (matchStatusCodes) options.push('-mc', matchStatusCodes);
if (filterStatusCodes) options.push('-fc', filterStatusCodes);
if (recursion) options.push('-recursion');
if (timeoutSeconds) options.push('-timeout', String(timeoutSeconds));
if (rawOptions) options.push(...rawOptions);
const fuzzUrl = targetUrl.includes('FUZZ') ? targetUrl : `${targetUrl.replace(/\/$/, '')}/FUZZ`;
const { fullOutput, findings } = await runFfufScan(fuzzUrl, wordlist, options);
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'ffufScan',
category: 'finding',
target: fuzzUrl,
summary: `ffuf produced ${findings.length} findings for ${fuzzUrl}`,
findings,
rawOutput: fullOutput,
invocation,
});
return {
content: [
{ type: "text", text: `ffuf produced ${findings.length} findings.` },
{ type: "text", text: `recordId=${recordId}` },
{ type: "text", text: invocation },
{ type: "text", text: findings.join('\n') || "No matches found." },
{ type: "text", text: `\n--- Full ffuf Output ---\n${fullOutput}` },
],
};
} catch (error: any) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
const nucleiToolSchema = z.object({
targets: z.array(z.string()).min(1).describe("List of targets (URLs/domains/IPs)"),
templates: z.array(z.string()).optional().describe("Template IDs or directories"),
severities: z.array(z.enum(['info', 'low', 'medium', 'high', 'critical', 'unknown'])).optional().describe("Severity filters"),
tags: z.array(z.string()).optional().describe("Nuclei tag filters"),
rateLimit: z.number().int().positive().optional().describe("Requests per second"),
headless: z.boolean().optional().describe("Enable headless templates"),
rawOptions: z.array(z.string()).optional().describe("Additional safe nuclei options")
}).describe("Run nuclei template scanning against target endpoints.");
server.tool("nucleiScan", nucleiToolSchema.shape, async ({ targets, templates, severities, tags, rateLimit, headless, rawOptions }, extra) => {
try {
const options: string[] = [];
if (templates && templates.length > 0) templates.forEach(template => options.push('-t', template));
if (severities && severities.length > 0) options.push('-severity', severities.join(','));
if (tags && tags.length > 0) options.push('-tags', tags.join(','));
if (rateLimit) options.push('-rl', String(rateLimit));
if (headless) options.push('-headless');
if (rawOptions) options.push(...rawOptions);
const { fullOutput, findings } = await runNucleiScan(targets, options);
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'nucleiScan',
category: 'finding',
summary: `Nuclei scan completed for ${targets.length} targets`,
findings,
rawOutput: fullOutput,
invocation,
});
return {
content: [
{ type: "text", text: `Nuclei produced ${findings.length} findings.` },
{ type: "text", text: `recordId=${recordId}` },
{ type: "text", text: invocation },
{ type: "text", text: findings.join('\n') || "No findings produced." },
{ type: "text", text: `\n--- Full Nuclei Output ---\n${fullOutput}` },
],
};
} catch (error: any) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
const trafficCaptureToolSchema = z.object({
networkInterface: z.string().describe("Network interface (en0, eth0, wlan0)"),
packetCount: z.number().int().positive().describe("Number of packets to capture"),
bpfFilter: z.string().optional().describe("Optional BPF filter (simple form, rawOptions for advanced)"),
outputPcap: z.string().optional().describe("Optional output pcap path"),
rawOptions: z.array(z.string()).optional().describe("Additional safe tcpdump options")
}).describe("Capture traffic packets for reconnaissance/evidence collection.");
server.tool("trafficCapture", trafficCaptureToolSchema.shape, async ({ networkInterface, packetCount, bpfFilter, outputPcap, rawOptions }, extra) => {
try {
const { fullOutput, findings } = await runTrafficCapture(networkInterface, packetCount, bpfFilter, outputPcap, rawOptions || []);
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'trafficCapture',
category: 'sniffing',
target: networkInterface,
summary: `Captured ${findings.length} lines on ${networkInterface}`,
findings,
rawOutput: fullOutput,
invocation,
});
return {
content: [
{ type: "text", text: `Traffic capture complete on ${networkInterface}.` },
{ type: "text", text: `recordId=${recordId}` },
{ type: "text", text: invocation },
{ type: "text", text: findings.join('\n') || "No packet lines captured in stdout (check pcap output)." },
{ type: "text", text: `\n--- Full tcpdump Output ---\n${fullOutput}` },
]
};
} catch (error: any) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
const hydraToolSchema = z.object({
target: z.string().describe("Target host/IP"),
service: z.string().describe("Service name (ssh, ftp, rdp, smb, http-get, etc.)"),
username: z.string().optional().describe("Single username"),
password: z.string().optional().describe("Single password"),
usernameList: z.string().optional().describe("Path to username list"),
passwordList: z.string().optional().describe("Path to password list"),
port: z.number().int().positive().optional().describe("Override service port"),
tasks: z.number().int().positive().optional().describe("Parallel tasks"),
rawOptions: z.array(z.string()).optional().describe("Additional safe hydra options")
}).describe("Run brute-force authentication checks with Hydra.");
server.tool("hydraBruteforce", hydraToolSchema.shape, async ({ target, service, username, password, usernameList, passwordList, port, tasks, rawOptions }, extra) => {
try {
const hasRawCredentialMode = (rawOptions || []).some(opt => opt === '-C' || opt.startsWith('-C') || opt === '-x' || opt.startsWith('-x'));
const userSourceCount = Number(Boolean(username)) + Number(Boolean(usernameList));
const passSourceCount = Number(Boolean(password)) + Number(Boolean(passwordList));
if (!hasRawCredentialMode) {
if (userSourceCount !== 1) {
throw new Error("Provide exactly one of username or usernameList unless using raw credential mode (-C/-x).");
}
if (passSourceCount !== 1) {
throw new Error("Provide exactly one of password or passwordList unless using raw credential mode (-C/-x).");
}
}
const options: string[] = [];
if (username) options.push('-l', username);
if (password) options.push('-p', password);
if (usernameList) options.push('-L', usernameList);
if (passwordList) options.push('-P', passwordList);
if (port) options.push('-s', String(port));
if (tasks) options.push('-t', String(tasks));
if (rawOptions) options.push(...rawOptions);
const { fullOutput, findings } = await runHydra(target, service, options);
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'hydraBruteforce',
category: 'bruteforce',
target,
summary: `Hydra brute-force completed against ${service}://${target}.`,
findings,
rawOutput: fullOutput,
invocation,
});
return {
content: [
{ type: "text", text: `Hydra completed. Potential credential hits: ${findings.length}.` },
{ type: "text", text: `recordId=${recordId}` },
{ type: "text", text: invocation },
{ type: "text", text: findings.join('\n') || "No credential hits detected." },
{ type: "text", text: `\n--- Full Hydra Output ---\n${fullOutput}` },
]
};
} catch (error: any) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
const privEscToolSchema = z.object({
rawOptions: z.array(z.string()).optional().describe("Optional linpeas flags when linpeas is available")
}).describe("Run local privilege-escalation audit checks and summarize potential escalation paths.");
server.tool("privEscAudit", privEscToolSchema.shape, async ({ rawOptions }, extra) => {
try {
const { fullOutput, findings } = await runPrivEscAudit(rawOptions || []);
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'privEscAudit',
category: 'privilege-escalation',
summary: `Privilege escalation audit generated ${findings.length} flagged items.`,
findings,
rawOutput: fullOutput,
invocation,
});
return {
content: [
{ type: "text", text: `Privilege escalation audit complete.` },
{ type: "text", text: `recordId=${recordId}` },
{ type: "text", text: invocation },
{ type: "text", text: findings.join('\n') || "No high-signal privilege escalation indicators found." },
{ type: "text", text: `\n--- PrivEsc Audit Output ---\n${fullOutput}` },
]
};
} catch (error: any) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
const extractionToolSchema = z.object({
targetUrl: z.string().url().describe("Target URL with injectable parameter"),
dumpData: z.boolean().optional().describe("Attempt table dump via sqlmap"),
risk: z.number().int().min(1).max(3).optional().describe("sqlmap risk level"),
level: z.number().int().min(1).max(5).optional().describe("sqlmap level"),
rawOptions: z.array(z.string()).optional().describe("Additional safe sqlmap options")
}).describe("Run SQL injection extraction workflow with sqlmap.");
server.tool("extractionSweep", extractionToolSchema.shape, async ({ targetUrl, dumpData, risk, level, rawOptions }, extra) => {
try {
const options: string[] = [];
if (dumpData) options.push('--dump');
if (risk) options.push('--risk', String(risk));
if (level) options.push('--level', String(level));
if (rawOptions) options.push(...rawOptions);
const { fullOutput, findings } = await runSqlmap(targetUrl, options);
const invocation = formatInvocation(extra);
const recordId = saveEngagementRecord({
tool: 'extractionSweep',
category: 'extraction',
target: targetUrl,
summary: `Extraction sweep completed for ${targetUrl}`,
findings,
rawOutput: fullOutput,
invocation,
});
return {
content: [
{ type: "text", text: `Extraction sweep complete.` },
{ type: "text", text: `recordId=${recordId}` },
{ type: "text", text: invocation },
{ type: "text", text: findings.join('\n') || "No extraction findings produced." },
{ type: "text", text: `\n--- Full sqlmap Output ---\n${fullOutput}` },
]
};
} catch (error: any) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
const mcpServerUrl = new URL(`http://${serverHost}:${serverPort}/mcp`);
const authMiddleware = authEnabled
? requireBearerAuth({
verifier: tokenVerifier,
requiredScopes: authScopes,
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
})
: (_req: any, _res: any, next: any) => next();
// --- Transport Selection and Server Start ---
switch (transportType) {
case 'stdio': {
const stdioTransport = new StdioServerTransport();
await server.connect(stdioTransport);
console.error('Pentest MCP Server connected via stdio.');
await new Promise(() => {});
break;
}
case 'http': {
console.error('Starting HTTP Streamable transport...');
if (authEnabled && authMode !== 'bearer') {
throw new Error(`Unsupported MCP_AUTH_MODE=${authMode}. Only "bearer" is supported.`);
}
if (authEnabled && !authIntrospectionUrl && !authJwksUrl) {
throw new Error('Auth enabled but no token verification configured. Set MCP_OIDC_INTROSPECTION_URL or MCP_OIDC_JWKS_URL.');
}
if (process.env.MCP_OAUTH_ENABLED || process.env.MCP_OAUTH_PROVIDER_URL || process.env.MCP_OAUTH_SCOPES) {
console.warn('Legacy MCP_OAUTH_* env vars detected. Prefer MCP_AUTH_* and MCP_OIDC_* vars.');
}
const app = express();
app.use(express.json({ limit: '10mb' }));
if (authEnabled) {
app.use(
mcpAuthMetadataRouter({
oauthMetadata: buildOauthMetadata(),
resourceServerUrl: mcpServerUrl,
scopesSupported: advertisedScopes,
resourceName: 'Pentest MCP',
serviceDocumentationUrl: new URL('https://github.com/dmontgomery40/pentest-mcp'),
})
);
}
const streamableTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(streamableTransport);
app.all('/mcp', authMiddleware, async (req, res) => {
try {
await streamableTransport.handleRequest(req as any, res as any, req.body);
} catch (error: any) {
console.error('Failed to handle HTTP MCP request:', error);
if (!res.headersSent) {
res.status(500).json({ error: error.message || 'internal error' });
}
}
});
app.get('/health', (_req, res) => {
res.json({
status: 'healthy',
transport_mode: 'streamable-http',
server: 'pentest-mcp',
version: serverVersion,
auth_mode: authEnabled ? authMode : 'none',
deprecated: {
sseTransport: true,
legacyOAuthEnvAliases: true,
},
});
});
app.listen(serverPort, serverHost, () => {
console.error(`MCP Streamable HTTP server running at http://${serverHost}:${serverPort}/mcp`);
console.error(`Health check at http://${serverHost}:${serverPort}/health`);
});
break;
}
case 'sse': {
console.error('Starting deprecated SSE transport compatibility mode...');
const app = express();
app.use(express.json({ limit: '10mb' }));
let sseTransport: SSEServerTransport | null = null;
let connected = false;
app.get('/sse', authMiddleware, async (_req, res) => {
res.setHeader('Warning', '299 - "SSE transport is deprecated; use MCP_TRANSPORT=http"');
if (connected) {
res.status(409).json({ error: 'SSE transport already connected.' });
return;
}
sseTransport = new SSEServerTransport('/messages', res);
await server.connect(sseTransport);
sseTransport.onclose = () => {
connected = false;
sseTransport = null;
};
connected = true;
});
app.post('/messages', authMiddleware, async (req, res) => {
res.setHeader('Warning', '299 - "SSE transport is deprecated; use MCP_TRANSPORT=http"');
if (!sseTransport) {
res.status(503).json({ error: "SSE transport not initialized" });
return;
}
// @ts-ignore - method presence is runtime-checked
if (typeof sseTransport.handlePostMessage === 'function') {
// @ts-ignore - express req/res are expected by this API
await sseTransport.handlePostMessage(req, res);
return;
}
res.status(202).json({ success: true });
});
app.get('/health', (_req, res) => {
res.json({
status: 'healthy',
transport_mode: 'sse',
server: 'pentest-mcp',
version: serverVersion,
deprecated: true,
auth_mode: authEnabled ? authMode : 'none',
});
});
app.listen(serverPort, serverHost, () => {
console.error(`MCP SSE compatibility server running at http://${serverHost}:${serverPort}/sse`);
console.error(`Messages endpoint at http://${serverHost}:${serverPort}/messages`);
console.error('SSE is deprecated; migrate to MCP_TRANSPORT=http.');
});
break;
}
default:
console.error(`Unknown transport type: ${transportType}`);
console.error('Valid options are: stdio, http, sse');
process.exit(1);
}
console.error('Pentest MCP Server initialized.');
}
if (process.argv[2]?.toLowerCase() === 'inspector') {
launchBundledInspector(process.argv.slice(3))
.then((exitCode) => process.exit(exitCode))
.catch((error) => {
console.error('Failed to launch bundled MCP Inspector:', error);
process.exit(1);
});
} else {
main().catch(error => {
console.error("Unhandled error in main:", error);
process.exit(1);
});
}