165 lines
7.0 KiB
TypeScript
165 lines
7.0 KiB
TypeScript
// 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);
|
|
},
|
|
});
|
|
}
|