diff --git a/Cargo.toml b/Cargo.toml index 8d61bfd..7a68c9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "model", "bitcoincore", "engine", + "cli", ] resolver = "2" diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..2ca12f4 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "stealth-cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Detects UTXO privacy vulnerabilities in wallets" +categories = ["cryptography::cryptocurrencies"] +keywords = ["bitcoin", "privacy", "cli"] +exclude = ["tests"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +stealth-bitcoincore = { path = "../bitcoincore" } +stealth-engine = { workspace = true } + +[lints.rust] +missing_debug_implementations = "deny" diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..d9d6e91 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,257 @@ +use std::path::PathBuf; +use std::process::ExitCode; +use std::{env, fs}; + +use stealth_bitcoincore::{read_cookie_file, BitcoinCoreRpc}; +use stealth_engine::engine::{AnalysisEngine, EngineSettings, ScanTarget, UtxoInput}; + +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + + if args.is_empty() || args[0] == "--help" || args[0] == "-h" { + print_usage(); + return ExitCode::SUCCESS; + } + + if args[0] != "scan" { + eprintln!( + "error: unknown command '{}' (try 'stealth-cli --help')", + args[0] + ); + return ExitCode::from(2); + } + + match run_scan(&args[1..]) { + Ok(clean) => { + if clean { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } + } + Err(message) => { + eprintln!("error: {message}"); + ExitCode::from(2) + } + } +} + +fn run_scan(args: &[String]) -> Result { + let opts = parse_scan_args(args)?; + let gateway = opts.build_gateway()?; + let target = opts.scan_target()?; + + let engine = AnalysisEngine::new(&gateway, EngineSettings::default()); + let report = engine.analyze(target).map_err(|e| e.to_string())?; + + match opts.format.as_deref() { + Some("text") | None => print_text_report(&report), + Some("json") => { + let json = serde_json::to_string_pretty(&report) + .map_err(|e| format!("serialization failed: {e}"))?; + println!("{json}"); + } + Some(other) => return Err(format!("unsupported format '{other}' (use json or text)")), + } + + Ok(report.summary.clean) +} + +#[derive(Debug, Default)] +struct ScanOpts { + descriptor: Option, + descriptors_file: Option, + utxos_file: Option, + rpc_url: Option, + rpc_user: Option, + rpc_pass: Option, + rpc_cookie: Option, + format: Option, +} + +impl ScanOpts { + fn build_gateway(&self) -> Result { + let url = self + .rpc_url + .clone() + .or_else(|| env::var("STEALTH_RPC_URL").ok()) + .ok_or("--rpc-url or STEALTH_RPC_URL is required")?; + + let (user, pass) = match ( + self.rpc_user + .clone() + .or_else(|| env::var("STEALTH_RPC_USER").ok()), + self.rpc_pass + .clone() + .or_else(|| env::var("STEALTH_RPC_PASS").ok()), + self.rpc_cookie + .clone() + .or_else(|| env::var("STEALTH_RPC_COOKIE").ok().map(PathBuf::from)), + ) { + (Some(user), Some(pass), _) => (Some(user), Some(pass)), + (_, _, Some(cookie_path)) => { + let (u, p) = read_cookie_file(&cookie_path).map_err(|e| e.to_string())?; + (Some(u), Some(p)) + } + _ => (None, None), + }; + + BitcoinCoreRpc::from_url(&url, user, pass).map_err(|e| e.to_string()) + } + + fn scan_target(&self) -> Result { + let mut sources = 0usize; + if self.descriptor.is_some() { + sources += 1; + } + if self.descriptors_file.is_some() { + sources += 1; + } + if self.utxos_file.is_some() { + sources += 1; + } + + if sources == 0 { + return Err( + "one input is required: --descriptor, --descriptors, or --utxos".to_owned(), + ); + } + if sources > 1 { + return Err( + "--descriptor, --descriptors, and --utxos are mutually exclusive".to_owned(), + ); + } + + if let Some(d) = &self.descriptor { + return Ok(ScanTarget::Descriptor(d.clone())); + } + if let Some(path) = &self.descriptors_file { + let content = fs::read_to_string(path) + .map_err(|e| format!("cannot read {}: {e}", path.display()))?; + let descriptors: Vec = serde_json::from_str(&content) + .map_err(|e| format!("invalid JSON in {}: {e}", path.display()))?; + return Ok(ScanTarget::Descriptors(descriptors)); + } + if let Some(path) = &self.utxos_file { + let content = fs::read_to_string(path) + .map_err(|e| format!("cannot read {}: {e}", path.display()))?; + let utxos: Vec = serde_json::from_str(&content) + .map_err(|e| format!("invalid JSON in {}: {e}", path.display()))?; + return Ok(ScanTarget::Utxos(utxos)); + } + + Err("no scan target specified".to_owned()) + } +} + +fn parse_scan_args(args: &[String]) -> Result { + let mut opts = ScanOpts::default(); + let mut i = 0; + + while i < args.len() { + match args[i].as_str() { + "--descriptor" => { + opts.descriptor = Some(take_value(args, &mut i, "--descriptor")?); + } + "--descriptors" => { + opts.descriptors_file = + Some(PathBuf::from(take_value(args, &mut i, "--descriptors")?)); + } + "--utxos" => { + opts.utxos_file = Some(PathBuf::from(take_value(args, &mut i, "--utxos")?)); + } + "--rpc-url" => { + opts.rpc_url = Some(take_value(args, &mut i, "--rpc-url")?); + } + "--rpc-user" => { + opts.rpc_user = Some(take_value(args, &mut i, "--rpc-user")?); + } + "--rpc-pass" => { + opts.rpc_pass = Some(take_value(args, &mut i, "--rpc-pass")?); + } + "--rpc-cookie" => { + opts.rpc_cookie = Some(PathBuf::from(take_value(args, &mut i, "--rpc-cookie")?)); + } + "--format" => { + opts.format = Some(take_value(args, &mut i, "--format")?); + } + other => return Err(format!("unknown flag '{other}'")), + } + i += 1; + } + + Ok(opts) +} + +fn take_value(args: &[String], i: &mut usize, flag: &str) -> Result { + *i += 1; + let value = args + .get(*i) + .ok_or_else(|| format!("{flag} requires a value"))?; + + if value.starts_with('-') { + return Err(format!("{flag} requires a value")); + } + + Ok(value.clone()) +} + +fn print_text_report(report: &stealth_engine::Report) { + println!( + "Scanned {} transactions, {} addresses, {} current UTXOs\n", + report.stats.transactions_analyzed, report.stats.addresses_seen, report.stats.utxos_current, + ); + + if report.summary.clean { + println!("No privacy issues found."); + return; + } + + if !report.findings.is_empty() { + println!("Findings ({}):", report.findings.len()); + for f in &report.findings { + println!( + " [{severity}] {vtype}: {desc}", + severity = f.severity, + vtype = f.vulnerability_type, + desc = f.description, + ); + } + println!(); + } + + if !report.warnings.is_empty() { + println!("Warnings ({}):", report.warnings.len()); + for w in &report.warnings { + println!( + " [{severity}] {vtype}: {desc}", + severity = w.severity, + vtype = w.vulnerability_type, + desc = w.description, + ); + } + } +} + +fn print_usage() { + eprintln!("stealth-cli – Bitcoin UTXO privacy vulnerability scanner\n"); + eprintln!("USAGE:"); + eprintln!(" stealth-cli scan [OPTIONS]\n"); + eprintln!("SCAN INPUT (one required, mutually exclusive):"); + eprintln!(" --descriptor Single output descriptor"); + eprintln!(" --descriptors JSON array of descriptors"); + eprintln!(" --utxos JSON array of {{txid,vout,...}}\n"); + eprintln!("RPC CONNECTION:"); + eprintln!(" --rpc-url bitcoind RPC endpoint"); + eprintln!(" --rpc-user RPC username"); + eprintln!(" --rpc-pass RPC password"); + eprintln!(" --rpc-cookie Path to .cookie file\n"); + eprintln!(" Env vars: STEALTH_RPC_URL, STEALTH_RPC_USER,"); + eprintln!(" STEALTH_RPC_PASS, STEALTH_RPC_COOKIE\n"); + eprintln!("OUTPUT:"); + eprintln!(" --format Output format (default: text)\n"); + eprintln!("EXIT CODES:"); + eprintln!(" 0 scan completed, no findings"); + eprintln!(" 1 scan completed, findings present"); + eprintln!(" 2 error"); +}