// ABOUTME: Safe port scan wrapper around nmap with strict local/private scope checks and conservative defaults. // ABOUTME: Refuses public targets, arbitrary flags, and aggressive scanning behavior. import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { Text } from "@mariozechner/pi-tui"; import net from "node:net"; import { execFile } from "node:child_process"; const DEFAULT_PORTS = "22,53,80,123,135,139,443,445,3000,3389,5000,8000,8080,8443"; function execFileAsync(command: string, args: string[], timeout = 15000): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { execFile(command, args, { timeout, encoding: "utf-8", maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { if (error) { reject(new Error(stderr?.trim() || error.message)); return; } resolve({ stdout, stderr }); }); }); } function isPrivateIpv4(ip: string): boolean { const parts = ip.split(".").map((p) => Number(p)); if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false; if (parts[0] === 10) return true; if (parts[0] === 127) return true; if (parts[0] === 192 && parts[1] === 168) return true; if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; return false; } function isPrivateIpv6(ip: string): boolean { const lower = ip.toLowerCase(); return lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd"); } function validateTarget(target: string): { ok: boolean; reason?: string } { if (!target || /\s/.test(target)) return { ok: false, reason: "Target is required and must not contain whitespace." }; if (/[a-z]/i.test(target) && net.isIP(target) === 0) { return { ok: false, reason: "Only literal IP addresses are allowed. Hostnames and domains are refused for safety." }; } const ipVersion = net.isIP(target); if (ipVersion === 4 && isPrivateIpv4(target)) return { ok: true }; if (ipVersion === 6 && isPrivateIpv6(target)) return { ok: true }; return { ok: false, reason: "Target must be loopback or a private local-network IP address." }; } function validatePorts(ports: string): { ok: boolean; reason?: string } { if (!ports) return { ok: true }; if (!/^\d+(,\d+)*$/.test(ports)) { return { ok: false, reason: "Ports must be a comma-separated allowlist like 22,80,443." }; } const values = ports.split(",").map((p) => Number(p)); if (values.length > 25) return { ok: false, reason: "Too many ports requested. Maximum 25 ports per safe scan." }; if (values.some((p) => !Number.isInteger(p) || p < 1 || p > 65535)) { return { ok: false, reason: "Ports must be valid integers between 1 and 65535." }; } return { ok: true }; } function parseGNmap(stdout: string): Array<{ host: string; openPorts: string[] }> { const lines = stdout.split(/\r?\n/); const results: Array<{ host: string; openPorts: string[] }> = []; for (const line of lines) { if (!line.startsWith("Host:")) continue; const hostMatch = line.match(/^Host:\s+(\S+)/); const portsMatch = line.match(/Ports:\s+(.+)$/); const portsField = portsMatch?.[1] || ""; const openPorts = portsField .split(",") .map((entry) => entry.trim()) .filter((entry) => entry.includes("/open/")) .map((entry) => entry.split("/")[0]); results.push({ host: hostMatch?.[1] || "unknown", openPorts }); } return results; } export default function (pi: ExtensionAPI) { pi.registerTool({ name: "safe_port_scan", label: "Safe Port Scan", description: "Safe, low-impact local port analysis using a guarded nmap wrapper. Only loopback and private IP targets are allowed. Aggressive flags, public targets, hostnames, and arbitrary options are refused.", parameters: Type.Object({ target: Type.String({ description: "Literal loopback or private IP address to scan." }), ports: Type.Optional(Type.String({ description: "Comma-separated allowlist of ports. Defaults to a small common set." })), dry_run: Type.Optional(Type.Boolean({ description: "If true, return the bounded command template without executing it." })), }), async execute(_toolCallId, params) { const target = typeof (params as any).target === "string" ? (params as any).target.trim() : ""; const ports = typeof (params as any).ports === "string" ? (params as any).ports.trim() : DEFAULT_PORTS; const dryRun = Boolean((params as any).dry_run); const targetCheck = validateTarget(target); if (!targetCheck.ok) { return { content: [{ type: "text" as const, text: `Refused: ${targetCheck.reason}` }], details: { error: "invalid_target", reason: targetCheck.reason }, }; } const portCheck = validatePorts(ports); if (!portCheck.ok) { return { content: [{ type: "text" as const, text: `Refused: ${portCheck.reason}` }], details: { error: "invalid_ports", reason: portCheck.reason }, }; } const args = [ "-Pn", "-n", "-T2", "--max-rate", "10", "--scan-delay", "1s", "--max-retries", "1", "--host-timeout", "30s", "--reason", "--open", "-p", ports, "-oG", "-", target, ]; const commandPreview = `nmap ${args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg)).join(" ")}`; if (dryRun) { return { content: [{ type: "text" as const, text: `Dry run only. Safe command template:\n\n${commandPreview}` }], details: { dryRun: true, commandPreview, target, ports }, }; } try { const result = await execFileAsync("nmap", args, 35000); const parsed = parseGNmap(result.stdout); const summary = parsed.length === 0 ? "No open ports found within the bounded safe-scan profile." : parsed.map((entry) => `- ${entry.host}: ${entry.openPorts.length ? entry.openPorts.join(", ") : "no open ports reported"}`).join("\n"); return { content: [{ type: "text" as const, text: `Safe port scan complete for ${target}.\n\n${summary}` }], details: { target, ports, commandPreview, parsed }, }; } catch (error: any) { return { content: [{ type: "text" as const, text: `safe_port_scan failed: ${error.message}` }], details: { error: error.message, target, commandPreview }, }; } }, renderCall(args, theme) { const p = args as any; return new Text(theme.fg("toolTitle", theme.bold("safe_port_scan ")) + theme.fg("accent", p.target || ""), 0, 0); }, renderResult(result, _options, theme) { const details = result.details as any; if (details?.error) return new Text(theme.fg("error", `safe_port_scan error: ${details.error}`), 0, 0); if (details?.dryRun) return new Text(theme.fg("accent", "safe_port_scan dry run"), 0, 0); return new Text(theme.fg("success", "safe_port_scan complete"), 0, 0); }, }); }