Add bounded redbear-nmap scanner
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
[source]
|
||||||
|
path = "source"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
template = "cargo"
|
||||||
|
|
||||||
|
[package.files]
|
||||||
|
"/usr/bin/redbear-nmap" = "redbear-nmap"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "redbear-nmap"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "redbear-nmap"
|
||||||
|
path = "src/main.rs"
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
|
||||||
|
use std::process;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if let Err(err) = run() {
|
||||||
|
eprintln!("redbear-nmap: {err}");
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<(), String> {
|
||||||
|
let args: Vec<String> = env::args().skip(1).collect();
|
||||||
|
let Some(command) = args.first() else {
|
||||||
|
return Err(usage());
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(command.as_str(), "help" | "--help" | "-h") {
|
||||||
|
println!("{}", usage());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = ScanConfig::parse(&args)?;
|
||||||
|
let mut targets = resolve_targets(&config.host)?;
|
||||||
|
if targets.is_empty() {
|
||||||
|
return Err(format!("no addresses resolved for {}", config.host));
|
||||||
|
}
|
||||||
|
|
||||||
|
targets.sort();
|
||||||
|
targets.dedup();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"scan_target={} ports={}",
|
||||||
|
config.host,
|
||||||
|
format_ports(&config.ports)
|
||||||
|
);
|
||||||
|
|
||||||
|
for addr in targets {
|
||||||
|
println!("host {addr}");
|
||||||
|
for port in &config.ports {
|
||||||
|
let socket_addr = SocketAddr::new(addr, *port);
|
||||||
|
let status = scan_port(socket_addr, config.timeout, config.banner_bytes);
|
||||||
|
print_status(*port, &status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() -> String {
|
||||||
|
"Usage: redbear-nmap [--timeout-ms N] [--banner-bytes N] <host> <ports>\n\nBounded scope: TCP connect scanning with optional banner reads only.\nNot implemented: raw/SYN scans, UDP parity, OS detection, packet capture, NSE.\n\nPorts can be a comma-separated list and/or ranges, for example: 22,80,443,8000-8003".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct ScanConfig {
|
||||||
|
host: String,
|
||||||
|
ports: Vec<u16>,
|
||||||
|
timeout: Duration,
|
||||||
|
banner_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScanConfig {
|
||||||
|
fn parse(args: &[String]) -> Result<Self, String> {
|
||||||
|
let mut timeout_ms = 1000_u64;
|
||||||
|
let mut banner_bytes = 64_usize;
|
||||||
|
let mut positionals = Vec::new();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--timeout-ms" => {
|
||||||
|
let value = args.get(i + 1).ok_or_else(usage)?;
|
||||||
|
timeout_ms = value
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|err| format!("invalid --timeout-ms value {value}: {err}"))?;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
"--banner-bytes" => {
|
||||||
|
let value = args.get(i + 1).ok_or_else(usage)?;
|
||||||
|
banner_bytes = value
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|err| format!("invalid --banner-bytes value {value}: {err}"))?;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
positionals.push(other.to_string());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if positionals.len() != 2 {
|
||||||
|
return Err(usage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
host: positionals[0].clone(),
|
||||||
|
ports: parse_ports(&positionals[1])?,
|
||||||
|
timeout: Duration::from_millis(timeout_ms),
|
||||||
|
banner_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
enum PortStatus {
|
||||||
|
Open { banner: Option<String> },
|
||||||
|
Closed,
|
||||||
|
TimedOut,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_port(addr: SocketAddr, timeout: Duration, banner_bytes: usize) -> PortStatus {
|
||||||
|
match TcpStream::connect_timeout(&addr, timeout) {
|
||||||
|
Ok(mut stream) => {
|
||||||
|
let _ = stream.set_read_timeout(Some(timeout));
|
||||||
|
let banner = read_banner(&mut stream, banner_bytes);
|
||||||
|
PortStatus::Open { banner }
|
||||||
|
}
|
||||||
|
Err(err) => match err.kind() {
|
||||||
|
io::ErrorKind::ConnectionRefused => PortStatus::Closed,
|
||||||
|
io::ErrorKind::TimedOut => PortStatus::TimedOut,
|
||||||
|
_ => PortStatus::Error(err.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_banner(stream: &mut TcpStream, banner_bytes: usize) -> Option<String> {
|
||||||
|
if banner_bytes == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf = vec![0_u8; banner_bytes];
|
||||||
|
match stream.read(&mut buf) {
|
||||||
|
Ok(0) => None,
|
||||||
|
Ok(count) => {
|
||||||
|
buf.truncate(count);
|
||||||
|
let text = String::from_utf8_lossy(&buf).trim().to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err)
|
||||||
|
if matches!(
|
||||||
|
err.kind(),
|
||||||
|
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_status(port: u16, status: &PortStatus) {
|
||||||
|
match status {
|
||||||
|
PortStatus::Open {
|
||||||
|
banner: Some(banner),
|
||||||
|
} => {
|
||||||
|
println!(" port={port} state=open banner={:?}", banner);
|
||||||
|
}
|
||||||
|
PortStatus::Open { banner: None } => {
|
||||||
|
println!(" port={port} state=open");
|
||||||
|
}
|
||||||
|
PortStatus::Closed => println!(" port={port} state=closed"),
|
||||||
|
PortStatus::TimedOut => println!(" port={port} state=timed_out"),
|
||||||
|
PortStatus::Error(err) => println!(" port={port} state=error detail={:?}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_targets(host: &str) -> Result<Vec<IpAddr>, String> {
|
||||||
|
(host, 0)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map(|iter| iter.map(|addr| addr.ip()).collect())
|
||||||
|
.map_err(|err| format!("failed to resolve {host}: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ports(spec: &str) -> Result<Vec<u16>, String> {
|
||||||
|
let mut ports = Vec::new();
|
||||||
|
|
||||||
|
for part in spec
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
{
|
||||||
|
if let Some((start, end)) = part.split_once('-') {
|
||||||
|
let start = parse_port_value(start)?;
|
||||||
|
let end = parse_port_value(end)?;
|
||||||
|
if start > end {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid port range {part}: start is greater than end"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for port in start..=end {
|
||||||
|
ports.push(port);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ports.push(parse_port_value(part)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ports.is_empty() {
|
||||||
|
return Err("no ports provided".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
ports.sort_unstable();
|
||||||
|
ports.dedup();
|
||||||
|
Ok(ports)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_port_value(text: &str) -> Result<u16, String> {
|
||||||
|
let port = text
|
||||||
|
.parse::<u16>()
|
||||||
|
.map_err(|err| format!("invalid port {text}: {err}"))?;
|
||||||
|
if port == 0 {
|
||||||
|
return Err("port 0 is not supported".to_string());
|
||||||
|
}
|
||||||
|
Ok(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_ports(ports: &[u16]) -> String {
|
||||||
|
ports
|
||||||
|
.iter()
|
||||||
|
.map(u16::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_port_lists_and_ranges() {
|
||||||
|
assert_eq!(parse_ports("22,80,443").unwrap(), vec![22, 80, 443]);
|
||||||
|
assert_eq!(parse_ports("22,80-82,80").unwrap(), vec![22, 80, 81, 82]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_ranges() {
|
||||||
|
assert!(parse_ports("90-80").is_err());
|
||||||
|
assert!(parse_ports("0").is_err());
|
||||||
|
assert!(parse_ports("abc").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_scan_config_with_flags() {
|
||||||
|
let args = vec![
|
||||||
|
"--timeout-ms".to_string(),
|
||||||
|
"250".to_string(),
|
||||||
|
"--banner-bytes".to_string(),
|
||||||
|
"16".to_string(),
|
||||||
|
"example.com".to_string(),
|
||||||
|
"22,80-81".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let config = ScanConfig::parse(&args).unwrap();
|
||||||
|
assert_eq!(config.host, "example.com");
|
||||||
|
assert_eq!(config.ports, vec![22, 80, 81]);
|
||||||
|
assert_eq!(config.timeout, Duration::from_millis(250));
|
||||||
|
assert_eq!(config.banner_bytes, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user