Python is the default language for security tooling, but Rust offers compelling advantages for CLI tools that need to be fast, distributable as a single binary, and safe by default. Here’s how I built a threat intelligence lookup tool in Rust.
The goal
A single binary that:
- Accepts an IP, domain, or hash as input
- Queries VirusTotal, AbuseIPDB, and Shodan in parallel
- Outputs a unified JSON report
Project setup
cargo init threat-lookup
cd threat-lookup
cargo add reqwest --features json,rustls-tls
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json
cargo add clap --features derive
CLI argument parsing with Clap
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "threat-lookup")]
#[command(about = "Query threat intel APIs for IOCs")]
struct Args {
/// The indicator to look up (IP, domain, or hash)
indicator: String,
/// Output format
#[arg(short, long, default_value = "json")]
format: String,
/// Verbose output
#[arg(short, long)]
verbose: bool,
}
Parallel API queries with Tokio
The key advantage of Rust here is safe, zero-cost concurrency. All three API calls happen simultaneously:
use tokio::join;
async fn lookup(indicator: &str) -> Result<Report, Box<dyn std::error::Error>> {
let (vt, abuse, shodan) = join!(
query_virustotal(indicator),
query_abuseipdb(indicator),
query_shodan(indicator),
);
Ok(Report {
indicator: indicator.to_string(),
virustotal: vt.ok(),
abuseipdb: abuse.ok(),
shodan: shodan.ok(),
})
}
Error handling
Rust forces you to handle errors explicitly — no silent None returns or uncaught exceptions:
async fn query_virustotal(indicator: &str) -> Result<VTResponse, LookupError> {
let api_key = std::env::var("VT_API_KEY")
.map_err(|_| LookupError::MissingApiKey("VT_API_KEY"))?;
let resp = reqwest::get(format!(
"https://www.virustotal.com/api/v3/ip_addresses/{}",
indicator
))
.await
.map_err(LookupError::Network)?
.json::<VTResponse>()
.await
.map_err(LookupError::Parse)?;
Ok(resp)
}
Building and distributing
# Build a static binary (works on any Linux)
cargo build --release --target x86_64-unknown-linux-musl
# Result: a single 5MB binary with no runtime dependencies
ls -lh target/x86_64-unknown-linux-musl/release/threat-lookup
This is where Rust really shines for security tooling. Ship a single binary to any endpoint — no Python environment, no dependency conflicts, no pip install on a compromised system.
Performance comparison
Quick benchmark against an equivalent Python implementation:
| Metric | Python | Rust |
|---|---|---|
| Cold start | 340ms | 12ms |
| 3 API lookups (parallel) | 1.8s | 1.2s |
| Binary size | ~45MB (PyInstaller) | 5MB |
| Memory usage | 48MB | 8MB |
The network latency dominates, but the startup time and resource usage make Rust a clear winner for CLI tools that run frequently.
Next steps
I’m planning to add local caching with SQLite (via rusqlite) and a TUI interface using ratatui for interactive investigations.
