Building a Threat Intel CLI Tool in Rust

Table of Contents

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:

MetricPythonRust
Cold start340ms12ms
3 API lookups (parallel)1.8s1.2s
Binary size~45MB (PyInstaller)5MB
Memory usage48MB8MB

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.