scope-recon is a command-line IP threat intelligence aggregator written in Rust. It queries 11 external sources concurrently, computes a verdict from the aggregate signal, and presents results either as structured terminal output, JSON, or an interactive terminal UI. The entire implementation — including all API integrations, a full ratatui TUI, an on-disk cache, retry logic, CIDR expansion, and bulk processing — was written in approximately six hours.

This document covers the architectural decisions made during that build, the async concurrency model, the verdict logic, the cache design, and API key security practices. For broader context on how tools like this fit into a threat intelligence workflow, and how the sources queried map to established attack frameworks, those topics are covered separately. For the user-facing feature overview and source table, see the feature overview.


Origin and Scope

The project started as scopehound and was renamed to scope-recon before the first working commit. The initial commit landed at 19:36 UTC on March 7, 2026.

Version 1 was intentionally minimal:

  • Two sources: Shodan and AbuseIPDB
  • Both required API keys with no fallback behavior
  • main.rs was 40 lines; model.rs was two structs
  • Output was colored terminal text with no verdict, no cache, and no CIDR support

Concurrent execution was present from the first commit:

// The entirety of v1 query logic
let (shodan_res, abuse_res) = tokio::join!(
    fetch_shodan(&cli.ip, &shodan_key),
    fetch_abuseipdb(&cli.ip, &abuseipdb_key),
);

The decision to use tokio::join! at the outset reflects the core design principle: when querying multiple independent APIs, there is no reason to serialize requests.


Development Timeline

The Expansion Wave (within 90 minutes of v1)

Four sources were added in a single commit: ip-api.com, VirusTotal, AlienVault OTX, and GreyNoise. This commit established the architectural pattern that persisted throughout the build:

  • Each source gets its own file under src/api/
  • Each returns a typed summary struct
  • All sources fire concurrently; results are collected into a ThreatReport
  • compute_verdict() produces a CLEAN, SUSPICIOUS, or MALICIOUS label from the aggregate

Two hours later, ThreatFox was added alongside structured output formatting, the --verbose flag, and expanded Shodan output including service banners.

The Infrastructure Commit (23:20 UTC, 840 lines across 12 files)

Despite being labelled “added tests and updated readme,” this commit introduced the majority of the tool’s operational infrastructure:

  • On-disk cache at ~/.cache/scope-recon/{ip}.json with configurable TTL
  • Retry logic — a generic with_retry() wrapper handling HTTP 429 responses with a 2-second delay and one retry
  • Shodan InternetDB fallback — silent fallback to the free InternetDB endpoint when SHODAN_API_KEY is absent, providing ports, hostnames, and known vulnerabilities without service banners
  • CIDR expansionscope-recon 192.168.1.0/30 expands to host IPs and queries each, capped at 256
  • --file mode — bulk processing from a newline-delimited list
  • --only filter — query a named subset of sources; unselected sources are silent, not errors
  • Tests — cache round-trip, CIDR expansion, and should_run filter tests

Keys were also made optional where the data model permits it. The pattern of returning a descriptive error string rather than panicking when a key is absent was standardized across all key-gated sources.

The TUI (23:59 UTC, 1,474 lines added)

A full ratatui terminal UI was added in a single commit. The rationale is architectural: single-IP interactive use and bulk or JSON use are fundamentally different consumption patterns, and they warrant different output modes.

The detection logic for whether to activate the TUI requires no additional flags:

let use_tui = cli.target.is_some()
    && !cli.json
    && cli.output.is_none()
    && cli.file.is_none()
    && targets.len() == 1;

A single IP passed to an interactive terminal activates the TUI. All other invocations use the original output path unchanged.

Four Additional Sources (00:19 UTC, March 8)

BGPView, IPQualityScore, Pulsedive, and ipinfo.io were added, each contributing new structs, API files, output sections, verdict contributions, and TUI detail panels. This extended coverage to BGP routing data, VPN/proxy/bot scoring, aggregated threat feeds, and hostname and organization resolution — data that previously required separate tools.


Final Source Table

The tool queries 11 sources concurrently:

SourceKey Required
ip-api.comNo
Shodan / InternetDBOptional
AbuseIPDBYes
VirusTotalYes
AlienVault OTXYes
GreyNoiseOptional
ThreatFoxYes (free)
RIPE StatNo
IPQualityScoreYes
PulsediveYes
IPinfoOptional

The compiled binary is approximately 4 MB using rustls, with no OpenSSL dependency. The full codebase — TUI, all 11 integrations, cache, retry logic, tests, and output formatters — is under 3,000 lines of Rust across 18 source files.


Async Architecture

tokio::spawn vs. tokio::join!

The CLI path uses tokio::join!: all 11 futures fire simultaneously, and the process waits for all to complete before rendering output. This is correct for a batch output path where all data must be available before anything can be written.

The TUI path cannot use this model. The primary value of the interactive UI is that results appear as they arrive. With tokio::join!, if VirusTotal takes 3 seconds and ip-api returns in 80ms, the display remains blank for 3 seconds rather than populating progressively.

The TUI uses tokio::spawn per source. Each source runs as an independent task and transmits its result over an mpsc::channel<SourceUpdate> the moment it completes:

let (tx, mut rx) = mpsc::channel::<SourceUpdate>(32);
 
tokio::spawn(async move {
    let result = with_retry("ip-api", RETRY_DELAY, || fetch_ipapi(&ip)).await;
    let _ = tx.send(SourceUpdate::IpApi(result)).await;
});
 
// ... 10 more spawns

The event loop selects on three concurrent sources:

tokio::select! {
    _ = tick.tick() => {
        app.tick = app.tick.wrapping_add(1);
    }
    Some(Ok(ev)) = event_stream.next() => {
        app.handle_key(ev);
    }
    Some(update) = rx.recv() => {
        app.apply_update(update);
    }
}

The UI re-renders after every branch. Completed sources show a green checkmark; in-progress sources show an animated spinner cycling on tick / 2; errors show a red cross.

SourceState<T> as a State Machine

Each of the 11 sources in App is typed as SourceState<T>:

pub enum SourceState<T> {
    Loading,
    Done(T),
    Error(String),
    Skipped,
}

State transitions are one-way and explicit. A source starts as Loading. When its SourceUpdate arrives, apply_update() transitions it to Done(data), Error(message), or Skipped. The Skipped state uses the sentinel string __skipped__ emitted by the --only filter:

SourceUpdate::IpApi(r) => {
    self.ipapi = match r {
        Ok(v) => SourceState::Done(v),
        Err(e) if e.to_string() == "__skipped__" => SourceState::Skipped,
        Err(e) => SourceState::Error(e.to_string()),
    };
}

The renderer matches on state to determine what to display. The detail pane extracts the inner value from Done(v) and shows a placeholder for all other states.

Channel Backpressure

The channel is buffered at 32:

mpsc::channel::<SourceUpdate>(32)

With 11 sources, even simultaneous completion does not fill the buffer. The let _ = tx.send(...).await pattern in each spawn discards the send result intentionally: if the receiver has been dropped because the user pressed q, tasks fail silently rather than panicking.

Refresh drains the old channel and creates a new one:

while let Ok(_) = rx.try_recv() {}
let (new_tx, new_rx) = mpsc::channel::<SourceUpdate>(32);
rx = new_rx;

Tasks still in-flight when r is pressed attempt to send on their now-orphaned tx. Those sends fail silently, and new tasks are already running.

TUI Module Structure

The TUI is organized across three modules:

  • app.rs — state machine. Owns all SourceState<T> fields, the mpsc channel, and the methods handle_key(), apply_update(), and reset_for_refresh().
  • mod.rs — terminal lifecycle and the tokio::select! loop. Spawns one task per source at startup and owns the event loop.
  • ui.rs — rendering. Three regions: header (IP and live verdict), body (source list on the left, detail pane on the right), footer (keybindings). The verdict in the header is recomputed on every frame by building a partial ThreatReport from current App state and calling the same compute_verdict() function used by the CLI path.
┌─ scope-recon ──────────────────────────────────────────────────────┐
│  IP: 8.8.8.8                              VERDICT: ● CLEAN         │
├──────────────────┬─────────────────────────────────────────────────┤
│ SOURCES          │ GEOLOCATION  (ip-api.com)                       │
│                  │                                                  │
│ ▶ Geolocation ✓  │   Country:    United States                      │
│   Shodan      ⠸  │   Region:     Virginia                           │
│   AbuseIPDB   ✓  │   City:       Ashburn                            │
│   VirusTotal  ✓  │   ISP:        Google LLC                         │
│   OTX         ✗  │   Org:        Google Public DNS                  │
│   GreyNoise   ✓  │   ASN:        AS15169 Google LLC                 │
│   ThreatFox   ✓  │                                                  │
│   BGPView     ✓  │                                                  │
│   IPQS        ✗  │                                                  │
│   Pulsedive   ✗  │                                                  │
│   IPInfo      ✓  │                                                  │
├──────────────────┴─────────────────────────────────────────────────┤
│  q quit   ↑↓/jk navigate   PgUp/PgDn scroll detail   r refresh    │
└────────────────────────────────────────────────────────────────────┘

Three APIs That Lied

The final working state of all 11 integrations required resolving four undocumented API issues discovered during development.

BGPView: NXDOMAIN

The BGPView integration was written against api.bgpview.io. The code compiled and was logically correct. The domain no longer resolved. The fix was replacing BGPView entirely with RIPE Stat (stat.ripe.net/data/prefix-overview/data.json), which provides equivalent data — ASN, prefix, holder, RIR — under a different response structure requiring a full parser rewrite. The function signature was unchanged; no callers were affected.

ThreatFox: Silent Authentication Change

ThreatFox’s API began requiring an Auth-Key HTTP header. Requests without it returned HTTP 200 with a JSON body of {"error":"Unauthorized"} — not an authentication error status. The non-standard body structure failed to deserialize against TFResponse, which expected a query_status field.

The fix required three changes: adding THREATFOX_API_KEY as a required environment variable, adding the Auth-Key header to every request, and adding "exact_match": true to the request body, which is necessary for IP searches to return results rather than substring matches. An explicit status check was added after discovering that invalid keys return HTTP 403 rather than a JSON error body:

if resp.status() == 403 {
    anyhow::bail!("ThreatFox: invalid Auth-Key — check THREATFOX_API_KEY");
}

Pulsedive: 404 Is Not an Error

Pulsedive returns HTTP 404 for IPs not present in its database. The original code treated any non-2xx response as an error, causing clean IPs with no Pulsedive history to surface as failures. The fix is a 404 check before the is_success() check:

if resp.status() == 404 {
    return Ok(PulsediveSummary { risk: "unknown".to_string(), ..Default::default() });
}

IPQS: Paid-Tier Parameter on Free Endpoint

The initial IPQS implementation included ?strictness=1 in the request URL. This parameter requires a paid plan. On a free key, the API returns {"success": false, "message": "..."}. Removing the parameter resolves the issue; the free tier functions correctly without it.


Verdict Logic

compute_verdict() is a pure function. It takes a ThreatReport and returns a (&'static str, Vec<String>) — the verdict label and a list of human-readable findings. No side effects, no state. The TUI calls it on every render frame by reconstructing a partial report from current App state; the CLI calls it once at completion. The same function serves both paths.

The implementation uses a severity: u8 accumulator and a findings list that collects every signal regardless of severity:

let mut severity: u8 = 0;
let mut findings: Vec<String> = Vec::new();

Severity only moves upward. A severity < 2 guard prevents a medium-confidence signal from overwriting an already-established malicious verdict:

if a.abuse_confidence >= 75 && severity < 2 {
    severity = 2;
    findings.push(format!("AbuseIPDB score {}/100", a.abuse_confidence));
} else if a.abuse_confidence >= 25 && severity < 1 {
    severity = 1;
    findings.push(format!("AbuseIPDB score {}/100", a.abuse_confidence));
}

The complete threshold table:

SourceMALICIOUSSUSPICIOUS
ThreatFoxAny IOC match
VirusTotalAny malicious detectionAny suspicious detection
AbuseIPDBScore ≥ 75Score 25–74
GreyNoiseclassification == "malicious"
OTXAny pulse
IPQSFraud score ≥ 75Score 30–74
PulsediveRisk = high or criticalRisk = medium

IPQS VPN, proxy, and TOR flags are appended to findings unconditionally regardless of severity, because they represent meaningful context even when the fraud score is below threshold. GreyNoise RIOT membership (g.riot == true) similarly appends "known benign infrastructure (RIOT)" to findings even for CLEAN verdicts.

When severity remains 0 after all checks, the function surfaces positive signals: AbuseIPDB whitelist status, zero VirusTotal detections, and GreyNoise benign classification. These do not alter the verdict; they explain it.


Cache Design

Flat Files, Not a Database

Cache entries live at ~/.cache/scope-recon/{ip}.json. Each file is the full serialized ThreatReport — the same struct emitted by --json output. No schema, no migrations, no query language. A cache entry is inspected with cat and cleared with rm.

SQLite would introduce a dependency and a schema to maintain for data that is naturally keyed by IP, with one record per key, always read and written as a whole unit. A flat file per IP is the correct data structure for this access pattern.

IPv6 addresses contain colons, which are invalid in filenames on some filesystems. The cache path handles this:

fn cache_path(ip: &str) -> Option<PathBuf> {
    cache_dir().map(|d| d.join(format!("{}.json", ip.replace(':', "_"))))
}

TTL Without a Metadata Table

Freshness is evaluated against the queried_at field embedded in every ThreatReport:

let queried_at = chrono::DateTime::parse_from_rfc3339(&report.queried_at).ok()?;
let age_secs = chrono::Utc::now()
    .signed_duration_since(queried_at.with_timezone(&chrono::Utc))
    .num_seconds();
 
if age_secs < 0 || age_secs as u64 > ttl_secs {
    return None;
}

The timestamp travels with the data, requiring no separate metadata record. The age_secs < 0 check catches clock skew — a future timestamp is treated as expired rather than as infinitely fresh.

load() returns Option<ThreatReport>. None is returned on any failure: file not found, parse error, or expired TTL. The caller never needs to distinguish between a missing entry and a stale one. Either way, the result is identical: run the queries.

Cache Hits in the TUI: skip_if_done!

In the TUI path, cached data is applied before any tasks are spawned:

if cli.cache_ttl > 0 {
    if let Some(cached) = cache::load(ip, cli.cache_ttl) {
        apply_cached_report(&mut app, cached);
    }
}
spawn_queries(ip, keys, cli, tx.clone(), &app).await;

apply_cached_report sets each available field directly to SourceState::Done(v). spawn_queries then checks each source before spawning:

macro_rules! skip_if_done {
    ($state:expr) => {
        matches!($state, app::SourceState::Done(_))
    };
}
 
if !skip_if_done!(app.ipapi) {
    // spawn the task
}

Sources populated from cache never spawn a network task. If all 11 sources are in cache, no tasks are spawned and the TUI opens fully populated. If only some are cached — for example, from a previously interrupted run — only the missing sources are queried.

The r key bypasses caching by setting cache_ttl = 0 on a cloned Cli before calling spawn_queries. With TTL zero, cache::load always returns None, apply_cached_report never fires, and all sources start as Loading.


Sample Output

Running scope-recon 8.8.8.8 against Google’s public DNS resolver with key-free sources:

IP: 8.8.8.8
══════════════════════════════════

SUMMARY
  Verdict:         CLEAN
  Findings:        known benign infrastructure (RIOT) · benign per GreyNoise

══════════════════════════════════

GEOLOCATION  (ip-api.com)
  Country:         United States
  Region:          Virginia
  City:            Ashburn
  ISP:             Google LLC
  Org:             Google Public DNS
  ASN:             AS15169 Google LLC

SHODAN
  Hostnames:       dns.google
  Services:
    53/tcp      -
    443/tcp      -

GREYNOISE
  Noise:           No
  RIOT:            Yes
  Class:           benign
  Actor:           Google Public DNS
  Last Seen:       2026-03-07

BGPVIEW
  ASN:             AS15169 GOOGLE
  Description:     Google LLC
  Prefixes:        8.8.8.0/24
  RIR:             ARIN

Sources without API keys configured display [source unavailable]. The tool does not fail; it degrades. The verdict is computed from whatever data was successfully retrieved.

The equivalent JSON output with source filtering:

scope-recon 8.8.8.8 --json --only ipapi,shodan,greynoise,bgpview
{
  "queried_at": "2026-03-08T01:19:15.862568992+00:00",
  "ip": "8.8.8.8",
  "ipapi": {
    "country": "United States",
    "region": "Virginia",
    "city": "Ashburn",
    "isp": "Google LLC",
    "org": "Google Public DNS",
    "asn": "AS15169 Google LLC"
  },
  "shodan": {
    "open_ports": [53, 443],
    "hostnames": ["dns.google"],
    "tags": [],
    "vulns": []
  },
  "greynoise": {
    "noise": false,
    "riot": true,
    "classification": "benign",
    "name": "Google Public DNS",
    "last_seen": "2026-03-07"
  },
  "bgpview": {
    "asn": 15169,
    "asn_name": "GOOGLE",
    "asn_description": "Google LLC",
    "prefixes": ["8.8.8.0/24"],
    "rir": "ARIN"
  }
}

Unavailable sources appear as null in JSON output without breaking the structure.


The jq Workflow

JSON output is a first-class output path. Combined with jq, scope-recon composes with the rest of an analyst’s workflow.

Quick triage — pull the numbers that matter:

scope-recon 1.2.3.4 --json | jq '{
  ip,
  vt_malicious: .virustotal.malicious,
  abuse_score:  .abuseipdb.abuse_confidence,
  fraud_score:  .ipqs.fraud_score,
  threatfox:    .threatfox.ioc_count,
  verdict:      (if .virustotal.malicious > 0 or .threatfox.ioc_count > 0
                 then "MALICIOUS"
                 elif (.abuseipdb.abuse_confidence // 0) >= 25
                 then "SUSPICIOUS"
                 else "CLEAN" end)
}'

Extract IOC details when ThreatFox has matches:

scope-recon 1.2.3.4 --json | jq '.threatfox.iocs[] | {ioc, threat_type, malware, confidence_level}'

List open ports in port/proto format:

scope-recon 1.2.3.4 --json | jq -r '.shodan.services[] | "\(.port)/\(.transport // "tcp")"'

Pull service banners (Shodan full API):

scope-recon 1.2.3.4 --json | jq -r '.shodan.services[] | "\(.port)/\(.transport // "tcp")  \(.product // "-") \(.version // "")"'

Bulk mode — filter to IPs with VirusTotal detections:

scope-recon --file targets.txt --json | jq '.[] | select(.virustotal.malicious > 0) | {ip, malicious: .virustotal.malicious}'

Bulk mode — find everything flagged by ThreatFox:

scope-recon --file targets.txt --json | jq '.[] | select(.threatfox.ioc_count > 0) | {ip, ioc_count: .threatfox.ioc_count, iocs: [.threatfox.iocs[].malware]}'

Extract ASN and org across a bulk scan — useful for identifying hosting provider patterns:

scope-recon --file targets.txt --json | jq -r '.[] | [.ip, (.bgpview.asn_description // "-"), (.ipapi.org // "-")] | @tsv'

Identify VPN, proxy, and TOR nodes across a target list:

scope-recon --file targets.txt --json | jq '.[] | select(.ipqs.vpn == true or .ipqs.tor == true or .ipqs.proxy == true) | {ip, vpn: .ipqs.vpn, tor: .ipqs.tor, proxy: .ipqs.proxy, fraud_score: .ipqs.fraud_score}'

The --only flag pairs with all of the above: --only threatfox,virustotal skips the remaining nine sources and returns results in under a second.


API Key Security

scope-recon reads all keys from environment variables. The security of those keys is entirely a function of how environment variables are managed on the host — not anything the tool itself enforces. This section documents the relevant risks and mitigations. For a broader treatment of operational security practices, see Operational Security (OPSEC): Fundamentals, Countermeasures, and Case Study.

The Habit to Break

export VIRUSTOTAL_API_KEY=abc123xyz

Typed directly at a terminal prompt, this command is recorded in plaintext to the shell history file (~/.zsh_history or ~/.bash_history). Any process or user with read access to the home directory has the key. It persists across reboots and shell upgrades.

Adding Keys via Editor

Add keys in a text editor, not at the prompt:

# Open in your editor — never typed in the terminal as a command
$EDITOR ~/.zshrc
# In ~/.zshrc
export SHODAN_API_KEY=your_key_here
export VIRUSTOTAL_API_KEY=your_key_here
export ABUSEIPDB_API_KEY=your_key_here
export GREYNOISE_API_KEY=your_key_here
export THREATFOX_API_KEY=your_key_here
export IPQS_API_KEY=your_key_here
export PULSEDIVE_API_KEY=your_key_here
export OTX_API_KEY=your_key_here
export IPINFO_TOKEN=your_token_here  # optional

Restrict file permissions so only the owning user can read the file:

chmod 600 ~/.zshrc

Reload without restarting the shell:

source ~/.zshrc

Inline Prefix — Keys Never Touch History

Prefix the command directly. In most shells, a leading space suppresses history recording (HIST_IGNORE_SPACE in zsh):

 VIRUSTOTAL_API_KEY=abc123 scope-recon 1.2.3.4

The key exists only for the duration of that process and is never written to disk.

Secrets Manager Injection

For regular use as part of an automated workflow, secrets managers can inject keys at runtime without them residing in any on-disk configuration file. The 1Password CLI op run command reads an .env-style reference file containing variable names pointing to vault items — not the actual values — and injects the resolved secrets into the child process’s environment:

op run -- scope-recon 1.2.3.4

What to Avoid

  • export KEY=value at the terminal prompt — recorded to shell history
  • .env files inside git repositories — one accidental commit exposes all keys
  • Keys in shell scripts checked into version control
  • Screenshots or log output from printenv or env containing key material

MITRE ATT&CK Alignment

scope-recon is a defensive tool, but understanding the techniques it detects informs how to interpret its output. Several of the signals it surfaces correspond directly to documented adversary behaviors.

The sources queried map to the following technique areas:

  • GreyNoise and AbuseIPDB abuse scoring — identifies infrastructure associated with reconnaissance techniques including active scanning (T1595) and phishing infrastructure staging (T1583.001).
  • ThreatFox IOC matching — surfaces indicators linked to established malware families and C2 frameworks. Matches against this source frequently correspond to T1071 (Application Layer Protocol) and T1041 (Exfiltration Over C2 Channel) depending on the matched threat type.
  • IPQS VPN, proxy, and TOR flags — relevant to T1090 (Proxy) and T1090.003 (Multi-hop Proxy), commonly used to obscure the true origin of traffic during initial access or exfiltration.
  • VirusTotal malicious detections — broad coverage across initial access, execution, and C2 technique families depending on the engine generating the detection.
  • Shodan service banners and open ports — surfaces exposed infrastructure potentially relevant to T1133 (External Remote Services) and T1046 (Network Service Discovery).

The presence of RIOT membership in GreyNoise is a meaningful negative signal: it indicates the IP belongs to a catalogued, verified benign service, substantially reducing the prior probability that traffic from it represents adversarial activity.


The source code is available at https://github.com/nethoundsh/scope-recon.