mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-04-23 22:20:00 -07:00
feat(cli): create rust package cli for stealth cli interface
This commit is contained in:
@@ -3,6 +3,7 @@ members = [
|
||||
"model",
|
||||
"bitcoincore",
|
||||
"engine",
|
||||
"cli",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
21
cli/Cargo.toml
Normal file
21
cli/Cargo.toml
Normal file
@@ -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"
|
||||
257
cli/src/main.rs
Normal file
257
cli/src/main.rs
Normal file
@@ -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<String> = 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<bool, String> {
|
||||
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<String>,
|
||||
descriptors_file: Option<PathBuf>,
|
||||
utxos_file: Option<PathBuf>,
|
||||
rpc_url: Option<String>,
|
||||
rpc_user: Option<String>,
|
||||
rpc_pass: Option<String>,
|
||||
rpc_cookie: Option<PathBuf>,
|
||||
format: Option<String>,
|
||||
}
|
||||
|
||||
impl ScanOpts {
|
||||
fn build_gateway(&self) -> Result<BitcoinCoreRpc, String> {
|
||||
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<ScanTarget, String> {
|
||||
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<String> = 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<UtxoInput> = 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<ScanOpts, String> {
|
||||
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<String, String> {
|
||||
*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 <DESC> Single output descriptor");
|
||||
eprintln!(" --descriptors <FILE> JSON array of descriptors");
|
||||
eprintln!(" --utxos <FILE> JSON array of {{txid,vout,...}}\n");
|
||||
eprintln!("RPC CONNECTION:");
|
||||
eprintln!(" --rpc-url <URL> bitcoind RPC endpoint");
|
||||
eprintln!(" --rpc-user <USER> RPC username");
|
||||
eprintln!(" --rpc-pass <PASS> RPC password");
|
||||
eprintln!(" --rpc-cookie <PATH> 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 <text|json> Output format (default: text)\n");
|
||||
eprintln!("EXIT CODES:");
|
||||
eprintln!(" 0 scan completed, no findings");
|
||||
eprintln!(" 1 scan completed, findings present");
|
||||
eprintln!(" 2 error");
|
||||
}
|
||||
Reference in New Issue
Block a user