nikto-mcp
Version:
A secure MCP (Model Context Protocol) server that enables AI agents to interact with Nikto web server scanner
130 lines (121 loc) • 4.06 kB
text/typescript
import { z } from 'zod';
import type { ScanOptions } from '../types/scan.types';
import { config } from '../config/index';
// Helper functions for type coercion
const coerceToNumber = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed === '') {
return undefined;
}
const num = Number(trimmed);
if (!isNaN(num)) {
return num;
}
}
return value as number; // Let Zod handle the validation error
};
const coerceToBoolean = (value: unknown): boolean | undefined => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim().toLowerCase();
if (trimmed === 'true' || trimmed === '1') {
return true;
}
if (trimmed === 'false' || trimmed === '0') {
return false;
}
if (trimmed === '') {
return undefined;
}
}
if (typeof value === 'number') {
if (value === 1) {
return true;
}
if (value === 0) {
return false;
}
}
return value as boolean; // Let Zod handle the validation error
};
const scanOptionsSchema = z
.object({
target: z
.string()
.min(1, 'Target is required')
.refine((value) => {
// Basic validation for URL, IP, hostname, or hostname:port
const urlPattern = /^https?:\/\/.+/;
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/;
const hostnamePattern =
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*$/;
// Check for hostname:port format
const parts = value.split(':');
if (parts.length === 2 && parts[0] && parts[1]) {
const hostname = parts[0];
const portStr = parts[1];
const port = parseInt(portStr, 10);
if (
(hostnamePattern.test(hostname) || ipPattern.test(hostname)) &&
!isNaN(port) &&
port > 0 &&
port <= 65535
) {
return true;
}
}
return urlPattern.test(value) || ipPattern.test(value) || hostnamePattern.test(value);
}, 'Invalid target: must be a valid URL, IP address, or hostname'),
port: z.preprocess(coerceToNumber, z.number().int().min(1).max(65535).optional().default(80)),
ssl: z.preprocess(coerceToBoolean, z.boolean().optional().default(false)),
nossl: z.preprocess(coerceToBoolean, z.boolean().optional().default(false)),
nolookup: z.preprocess(coerceToBoolean, z.boolean().optional().default(false)),
timeout: z.preprocess(
coerceToNumber,
z.number().positive().optional().default(config.defaultTimeout),
),
vhost: z
.string()
.optional()
.refine((value) => {
if (!value) {
return true;
}
// Validate hostname format for vhost
const hostnamePattern =
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*$/;
return hostnamePattern.test(value);
}, 'Invalid vhost: must be a valid hostname'),
outputFormat: z.enum(['json', 'text']).optional().default('json'),
dryRun: z.preprocess(coerceToBoolean, z.boolean().optional().default(false)),
})
.refine((data) => !(data.ssl && data.nossl), {
message: 'Cannot specify both ssl and nossl options',
path: ['ssl', 'nossl'],
});
export function validateScanOptions(options: unknown): ScanOptions {
try {
return scanOptionsSchema.parse(options);
} catch (error) {
if (error instanceof z.ZodError) {
const message = error.issues
.map((e: z.ZodIssue) => `${e.path.join('.')}: ${e.message}`)
.join(', ');
throw new Error(`Validation error: ${message}`);
}
throw error;
}
}
export function sanitizeInput(input: string): string {
// Remove potentially dangerous characters for shell commands
return input
.replace(/[;&|`$<>\\]/g, '')
.replace(/\n|\r/g, '')
.trim();
}