use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use clap::Parser;
use colored::Colorize as _;
use crate::cli::{GlobalOpts, H_BEHAVIOR, H_OUTPUT, H_TARGET};
use crate::engine::analyzer::{AnalysisResult, AnalyzeConfig, Analyzer};
use crate::proto::auth::{AuthSys, Credential};
use crate::proto::circuit::CircuitBreaker;
use crate::proto::conn::ReconnectStrategy;
use crate::proto::nfs3::Nfs3Client;
use crate::proto::pool::{ConnectionPool, PoolKey};
use crate::proto::portmap::PortmapClient;
use crate::proto::transport::PooledTransport;
use crate::util::stealth::StealthConfig;
#[derive(Parser)]
pub(crate) struct AnalyzeArgs {
#[arg(required_unless_present = "targets_file", help_heading = H_TARGET)]
pub target: Option<String>,
#[arg(short = 'f', long = "file", alias = "targets", value_name = "FILE", help_heading = H_TARGET)]
pub targets_file: Option<String>,
#[arg(long = "test-read", value_name = "PATH", help_heading = H_BEHAVIOR)]
pub test_read_paths: Vec<String>,
#[arg(long = "test-read-gids", value_delimiter = ',', help_heading = H_BEHAVIOR)]
pub test_read_gids: Vec<u32>,
#[arg(long = "test-read-uids", value_delimiter = ',', help_heading = H_BEHAVIOR)]
pub test_read_uids: Vec<u32>,
#[arg(long, default_value = "2", value_name = "N", help_heading = H_BEHAVIOR)]
pub v4_depth: u32,
#[arg(long, value_name = "FILE", num_args = 0..=1, help_heading = H_OUTPUT)]
#[expect(clippy::option_option, reason = "clap optional-value flag: None = absent, Some(None) = --json (stdout), Some(Some(path)) = --json FILE")]
pub json: Option<Option<PathBuf>>,
}
impl AnalyzeArgs {
pub(crate) fn effective_test_gids(&self) -> Vec<u32> {
if self.test_read_gids.is_empty() {
vec![0, 42, 15] } else {
self.test_read_gids.clone()
}
}
pub(crate) fn effective_test_uids(&self) -> Vec<u32> {
if self.test_read_uids.is_empty() { vec![0] } else { self.test_read_uids.clone() }
}
pub(crate) fn effective_test_paths(&self) -> Vec<String> {
if self.test_read_paths.is_empty() { vec!["/etc/shadow".to_owned()] } else { self.test_read_paths.clone() }
}
}
pub(crate) async fn run(args: AnalyzeArgs, globals: &GlobalOpts) -> anyhow::Result<()> {
let targets = collect_targets(&args)?;
let mut all_results: Vec<AnalysisResult> = Vec::with_capacity(targets.len());
for host in &targets {
tracing::info!(%host, "analyzing NFS server");
if !globals.quiet && args.json.is_none() {
eprintln!("{}", crate::output::status_info(&format!("Analyzing {host}...")));
}
let start = std::time::Instant::now();
let result = match run_single(host, &args, globals).await {
Ok(r) => r,
Err(e) => {
if !globals.quiet {
eprintln!("{}", crate::output::status_warn(&format!("Skipping {host}: {e}")));
}
tracing::warn!(%host, err = %e, "analyze: host failed, skipping");
continue;
},
};
if args.json.is_some() {
} else {
print_result(&result);
if !globals.quiet {
eprintln!("{}", crate::output::status_info(&format!("Completed in {} -- {} finding(s)", crate::output::elapsed(start), result.findings.len())));
}
}
all_results.push(result);
}
if let Some(dest) = &args.json {
let json = serde_json::to_string_pretty(&all_results)?;
match dest {
Some(path) => {
std::fs::write(path, &json)?;
if !globals.quiet {
eprintln!("{}", crate::output::status_ok(&format!("JSON written -> {}", path.display())));
}
},
None => println!("{json}"),
}
}
crate::cli::emit_replay(globals);
Ok(())
}
fn collect_targets(args: &AnalyzeArgs) -> anyhow::Result<Vec<String>> {
if let Some(ref file) = args.targets_file {
let content = std::fs::read_to_string(file).map_err(|e| anyhow::anyhow!("read targets file {file}: {e}"))?;
Ok(content.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(strip_export).collect())
} else {
Ok(args.target.iter().map(|t| strip_export(t)).collect())
}
}
fn strip_export(s: &str) -> String {
s.find(":/").map_or_else(|| s.to_owned(), |idx| s[..idx].to_owned())
}
async fn run_single(host: &str, args: &AnalyzeArgs, globals: &GlobalOpts) -> anyhow::Result<AnalysisResult> {
let ip = crate::cli::target::resolve_host(host)?;
let addr = SocketAddr::new(ip, 111);
let host_for_config = if ip.is_ipv6() { format!("[{ip}]") } else { ip.to_string() };
let pool = Arc::new(match &globals.proxy {
Some(p) => ConnectionPool::with_proxy(p.clone()),
None => ConnectionPool::default_config(),
});
let circuit = Arc::new(CircuitBreaker::default_config());
let cred = Credential::Sys(AuthSys::new(globals.uid, globals.gid, &globals.hostname));
let pool_key = PoolKey { host: addr, export: "/".to_owned(), uid: globals.uid, gid: globals.gid };
let stealth = StealthConfig::new(globals.delay, globals.jitter);
let nfs3 = Arc::new(Nfs3Client::new(PooledTransport::new(Arc::clone(&pool), pool_key, Arc::clone(&circuit), stealth, cred, ReconnectStrategy::Persistent)));
let mount_client = crate::cli::probe::make_mount_client(globals);
let portmap_client = match &globals.proxy {
Some(p) => PortmapClient::default_port().with_proxy(p.clone()),
None => PortmapClient::default_port(),
};
let mut analyzer = Analyzer::new(nfs3, mount_client, portmap_client, pool, circuit, globals.hostname.clone(), globals.aux_gids.clone()).with_stealth(StealthConfig::new(globals.delay, globals.jitter));
if let Some(ref p) = globals.proxy {
analyzer = analyzer.with_proxy(p.clone());
}
let config = AnalyzeConfig { host: host_for_config, port: 2049, test_paths: args.effective_test_paths(), test_uids: args.effective_test_uids(), test_gids: args.effective_test_gids() };
analyzer.analyze(&config).await
}
fn print_result(r: &AnalysisResult) {
println!();
crate::output::banner(&format!("NFS Security Analysis: {}", r.host));
println!();
if let Some(os) = &r.os_guess {
let versions = r.nfs_versions.join(", ");
println!(" {} {} | {} {}", "OS:".dimmed(), os, "NFS:".dimmed(), versions);
}
if let Some(fp) = &r.impl_fingerprint {
println!(" {} {}", "Impl:".dimmed(), fp);
}
println!(" {} {}", "Timestamp:".dimmed(), r.timestamp);
println!();
crate::output::section_header("Exports");
let rows: Vec<crate::output::ExportRow> = r
.exports
.iter()
.map(|e| {
let clients = if e.allowed_hosts.is_empty() { "*".to_owned() } else { e.allowed_hosts.join(", ") };
let auth = e.auth_methods.join(", ");
let has_wildcard = e.allowed_hosts.is_empty() || e.allowed_hosts.iter().any(|h| h == "*");
let has_gss = e.auth_methods.iter().any(|a| a.contains("GSS") || a.contains("krb"));
let mut flags = Vec::new();
if has_wildcard {
flags.push("WILDCARD");
}
if !has_gss && !auth.is_empty() {
flags.push("AUTH_SYS_ONLY");
}
crate::output::ExportRow { path: e.path.clone(), clients, auth, flags: flags.join(" "), handle_hex: e.file_handle.clone() }
})
.collect();
crate::output::print_export_table(&rows);
println!();
let handles: Vec<_> = r.exports.iter().filter(|e| !e.file_handle.is_empty()).collect();
if !handles.is_empty() {
crate::output::section_header("File Handles");
for e in &handles {
crate::output::print_handle(&e.path, &e.file_handle);
}
println!();
}
crate::output::section_header("Findings");
crate::output::print_findings_summary(&r.findings);
println!();
crate::output::print_findings(&r.findings);
println!();
}