wraith/src-tauri/src/scanner/mod.rs
Vantz Stockwell 17973fc3dc fix: SEC-1/SEC-2 shell escape utility + MCP bearer token auth
- New shell_escape() utility for safe command interpolation
- Applied across all MCP tools, docker, scanner, network commands
- MCP server generates random bearer token at startup
- Token written to mcp-token file with 0600 permissions
- All MCP HTTP requests require Authorization header
- Bridge binary reads token and sends on every request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:40:13 -04:00

231 lines
7.1 KiB
Rust

//! Network scanner tools — IP discovery, port scanning, and network mapping
//! through SSH exec channels. No agent installation required.
//!
//! All scans run on the REMOTE host through the existing SSH connection,
//! giving visibility into the remote network without direct access.
use std::sync::Arc;
use russh::client::Handle;
use russh::ChannelMsg;
use serde::Serialize;
use tokio::sync::Mutex as TokioMutex;
use crate::ssh::session::SshClient;
use crate::utils::shell_escape;
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredHost {
pub ip: String,
pub mac: Option<String>,
pub hostname: Option<String>,
pub vendor: Option<String>,
pub open_ports: Vec<u16>,
pub services: Vec<String>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PortResult {
pub port: u16,
pub open: bool,
pub service: String,
}
/// Well-known port → service name mapping for common ports.
fn service_name(port: u16) -> &'static str {
match port {
21 => "FTP",
22 => "SSH",
23 => "Telnet",
25 => "SMTP",
53 => "DNS",
80 => "HTTP",
110 => "POP3",
135 => "RPC",
139 => "NetBIOS",
143 => "IMAP",
443 => "HTTPS",
445 => "SMB",
993 => "IMAPS",
995 => "POP3S",
1433 => "MSSQL",
1521 => "Oracle",
3306 => "MySQL",
3389 => "RDP",
5432 => "PostgreSQL",
5900 => "VNC",
6379 => "Redis",
8080 => "HTTP-Alt",
8443 => "HTTPS-Alt",
27017 => "MongoDB",
_ => "unknown",
}
}
/// Discover hosts on the remote network using ARP table and ping sweep.
pub async fn scan_network(
handle: &Arc<TokioMutex<Handle<SshClient>>>,
subnet: &str,
) -> Result<Vec<DiscoveredHost>, String> {
// Script that works on Linux and macOS:
// 1. Ping sweep the subnet to populate ARP cache
// 2. Read ARP table for IP/MAC pairs
// 3. Try reverse DNS for hostnames
let escaped_subnet = shell_escape(subnet);
let script = format!(r#"
OS=$(uname -s 2>/dev/null)
SUBNET={escaped_subnet}
# Ping sweep (background, fast)
if [ "$OS" = "Linux" ]; then
for i in $(seq 1 254); do
ping -c 1 -W 1 "$SUBNET.$i" > /dev/null 2>&1 &
done
wait
elif [ "$OS" = "Darwin" ]; then
for i in $(seq 1 254); do
ping -c 1 -t 1 "$SUBNET.$i" > /dev/null 2>&1 &
done
wait
fi
# Read ARP table
if [ "$OS" = "Linux" ]; then
arp -n 2>/dev/null | grep -v incomplete | awk 'NR>1 {{printf "%s|%s\n", $1, $3}}'
elif [ "$OS" = "Darwin" ]; then
arp -a 2>/dev/null | grep -v incomplete | awk '{{gsub(/[()]/, ""); printf "%s|%s\n", $2, $4}}'
fi
"#);
let output = exec_command(handle, &script).await
.ok_or_else(|| "Failed to execute network scan".to_string())?;
let mut hosts = Vec::new();
for line in output.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 2 && !parts[0].is_empty() {
let ip = parts[0].trim().to_string();
let mac = if parts[1].trim().is_empty() || parts[1].trim() == "(incomplete)" {
None
} else {
Some(parts[1].trim().to_string())
};
hosts.push(DiscoveredHost {
ip,
mac,
hostname: None,
vendor: None,
open_ports: Vec::new(),
services: Vec::new(),
});
}
}
// Try reverse DNS for each host
if !hosts.is_empty() {
let ips: Vec<String> = hosts.iter().map(|h| h.ip.clone()).collect();
let dns_script = ips.iter()
.map(|ip| format!("echo \"{}|$(host {} 2>/dev/null | awk '/domain name pointer/ {{print $NF}}' | sed 's/\\.$//')\"", ip, ip))
.collect::<Vec<_>>()
.join("\n");
if let Some(dns_output) = exec_command(handle, &dns_script).await {
for line in dns_output.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 2 && !parts[1].is_empty() {
if let Some(host) = hosts.iter_mut().find(|h| h.ip == parts[0]) {
host.hostname = Some(parts[1].to_string());
}
}
}
}
}
Ok(hosts)
}
/// Scan specific ports on a target host through the SSH session.
pub async fn scan_ports(
handle: &Arc<TokioMutex<Handle<SshClient>>>,
target: &str,
ports: &[u16],
) -> Result<Vec<PortResult>, String> {
// Validate target — /dev/tcp requires a bare hostname/IP, not a shell-quoted value.
// Only allow alphanumeric, dots, hyphens, and colons (for IPv6).
if !target.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':') {
return Err(format!("Invalid target for port scan: {}", target));
}
// Use bash /dev/tcp for port scanning — no nmap required
let port_checks: Vec<String> = ports.iter()
.map(|p| format!(
"(echo >/dev/tcp/{target}/{p}) 2>/dev/null && echo \"{p}|open\" || echo \"{p}|closed\""
))
.collect();
// Run in parallel batches of 20 for speed
let mut results = Vec::new();
for chunk in port_checks.chunks(20) {
let script = chunk.join(" &\n") + " &\nwait";
let output = exec_command(handle, &script).await
.ok_or_else(|| "Port scan exec failed".to_string())?;
for line in output.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 2 {
if let Ok(port) = parts[0].parse::<u16>() {
results.push(PortResult {
port,
open: parts[1] == "open",
service: service_name(port).to_string(),
});
}
}
}
}
results.sort_by_key(|r| r.port);
Ok(results)
}
/// Quick scan of common ports on a target.
pub async fn quick_port_scan(
handle: &Arc<TokioMutex<Handle<SshClient>>>,
target: &str,
) -> Result<Vec<PortResult>, String> {
let common_ports: Vec<u16> = vec![
21, 22, 23, 25, 53, 80, 110, 135, 139, 143,
443, 445, 993, 995, 1433, 1521, 3306, 3389,
5432, 5900, 6379, 8080, 8443, 27017,
];
scan_ports(handle, target, &common_ports).await
}
async fn exec_command(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
let mut channel = {
let h = handle.lock().await;
h.channel_open_session().await.ok()?
};
channel.exec(true, cmd).await.ok()?;
let mut output = String::new();
loop {
match channel.wait().await {
Some(ChannelMsg::Data { ref data }) => {
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
output.push_str(text);
}
}
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
Some(ChannelMsg::ExitStatus { .. }) => {}
_ => {}
}
}
Some(output)
}