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.rswas 40 lines;model.rswas 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 aCLEAN,SUSPICIOUS, orMALICIOUSlabel 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}.jsonwith 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_KEYis absent, providing ports, hostnames, and known vulnerabilities without service banners - CIDR expansion —
scope-recon 192.168.1.0/30expands to host IPs and queries each, capped at 256 --filemode — bulk processing from a newline-delimited list--onlyfilter — query a named subset of sources; unselected sources are silent, not errors- Tests — cache round-trip, CIDR expansion, and
should_runfilter 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:
| Source | Key Required |
|---|---|
| ip-api.com | No |
| Shodan / InternetDB | Optional |
| AbuseIPDB | Yes |
| VirusTotal | Yes |
| AlienVault OTX | Yes |
| GreyNoise | Optional |
| ThreatFox | Yes (free) |
| RIPE Stat | No |
| IPQualityScore | Yes |
| Pulsedive | Yes |
| IPinfo | Optional |
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 spawnsThe 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 allSourceState<T>fields, thempscchannel, and the methodshandle_key(),apply_update(), andreset_for_refresh().mod.rs— terminal lifecycle and thetokio::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 partialThreatReportfrom currentAppstate and calling the samecompute_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:
| Source | MALICIOUS | SUSPICIOUS |
|---|---|---|
| ThreatFox | Any IOC match | — |
| VirusTotal | Any malicious detection | Any suspicious detection |
| AbuseIPDB | Score ≥ 75 | Score 25–74 |
| GreyNoise | classification == "malicious" | — |
| OTX | — | Any pulse |
| IPQS | Fraud score ≥ 75 | Score 30–74 |
| Pulsedive | Risk = high or critical | Risk = 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=abc123xyzTyped 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 # optionalRestrict file permissions so only the owning user can read the file:
chmod 600 ~/.zshrcReload without restarting the shell:
source ~/.zshrcInline 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.4The 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.4What to Avoid
export KEY=valueat the terminal prompt — recorded to shell history.envfiles inside git repositories — one accidental commit exposes all keys- Keys in shell scripts checked into version control
- Screenshots or log output from
printenvorenvcontaining 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.
