There is a particular category of tool that does not exist until you build it yourself. Not because it cannot be built, but because the off-the-shelf alternatives all make one of three tradeoffs: they are SaaS with a per-query price tag, they are a loose collection of browser tabs, or they are a pipeline that requires four flags and a config file just to ask whether an IP is malicious.
scope-recon is a Rust binary that hits 12 threat intelligence sources in parallel and returns a verdict in a terminal UI. You provide an IP address. Results appear as they arrive. When all sources have responded, an LLM synthesizes them into a threat assessment. The tool completes in approximately three seconds for a fully-keyed configuration. For a detailed walkthrough of the async architecture, verdict logic, and cache design, see the architecture deep-dive.
What It Does
scope-recon 185.220.101.47One argument. The tool determines whether to launch the TUI (single IP, interactive terminal) or the CLI path (bulk, JSON, file output), loads API keys from the environment, fires all queries concurrently, and renders.
It also accepts hostnames and CIDR ranges:
scope-recon dns.google # resolves to 8.8.8.8 via async DNS lookup
scope-recon 192.168.1.0/30 # expands to host IPs, queries each, cap at 256
scope-recon --file targets.txt # newline-delimited list, CIDR lines expand inlineThe 12 Sources
All sources are queried concurrently, with one exception: AI analysis fires sequentially after the other eleven complete, because it requires the full dataset as context.
| Source | Key Required | What It Provides |
|---|---|---|
| ip-api.com | No | Geolocation: country, region, city, ISP, org, ASN |
| Shodan / InternetDB | Optional | Open ports, service banners, hostnames, CVEs, tags |
| AbuseIPDB | Yes | Abuse confidence score (0–100), report history, usage type |
| VirusTotal | Yes | Detection counts across 80+ AV engines |
| AlienVault OTX | Yes | Threat intelligence pulses and pulse names |
| GreyNoise | Optional | Noise/RIOT classification, last seen, actor name |
| ThreatFox | Yes (free) | C2 IOC matches with malware family and confidence |
| RIPE Stat | No | ASN, prefix, RIR, PTR record |
| IPQualityScore | Yes | Fraud score, proxy/VPN/TOR flags, bot status, abuse velocity |
| Pulsedive | Yes | Risk rating, threat feed memberships |
| IPinfo | Optional | Hostname, org, timezone, VPN/proxy/TOR/hosting flags |
| AI Analysis (Grok) | Optional | Synthesized threat assessment, per-malware-family context |
Key requirements are intentionally minimal. Three sources — ip-api, RIPE Stat, and Shodan InternetDB — require no key. Shodan, GreyNoise, and IPinfo degrade gracefully, using a free or limited path when a key is absent rather than failing. ThreatFox is free to register. The tool is functional without any keys configured; it simply has less data to work with.
The TUI
Running scope-recon <ip> in an interactive terminal produces the following interface:
┌─ scope-recon ──────────────────────────────────────────────────────┐
│ IP: 185.220.101.47 VERDICT: ● MALICIOUS │
├──────────────────┬─────────────────────────────────────────────────┤
│ SOURCES │ THREATFOX (abuse.ch) │
│ │ │
│ ▶ Geolocation ✓ │ C2 IOCs: 3 │
│ Shodan ✓ │ │
│ AbuseIPDB ✓ │ IOC: 185.220.101.47:9001 │
│ VirusTotal ✓ │ Threat Type: botnet_cc │
│ OTX ✓ │ Malware: Tor │
│ GreyNoise ✓ │ Confidence: 75% │
│ ThreatFox ✓ │ First Seen: 2024-08-12 │
│ RIPE Stat ✓ │ │
│ IPQS ✓ │ IOC: 185.220.101.47:443 │
│ Pulsedive ✓ │ Threat Type: botnet_cc │
│ IPInfo ✓ │ Malware: Tor │
│ AI Analysis ⠸ │ Confidence: 75% │
├──────────────────┴─────────────────────────────────────────────────┤
│ q quit ↑↓/jk navigate PgUp/PgDn scroll r refresh s save │
└────────────────────────────────────────────────────────────────────┘
Results populate in real time as each API call completes. An animated spinner cycles while a source is loading. Completed sources display a green ✓. Errors display a red ✗. Sources excluded via --only display a dim -.
The verdict in the header is recomputed on every render frame. It uses the same compute_verdict() function as the CLI path, fed a partial report built from whatever sources have finished so far. If ThreatFox returns IOC matches before AbuseIPDB has responded, the header already reflects MALICIOUS.
Navigation
↑↓/jk— move between sources; the detail pane updates immediatelyPgUp/PgDn— scroll the detail pane for sources with long outputr— refresh: drains the channel, creates a new one, respawns all 12 queries with cache bypasseds— save: writes current state as pretty-printed JSON to{ip}-{YYYYMMDD-HHMMSS}.jsonin the current working directory; the footer shows✓ Saved: filename.jsonfor approximately three secondsq/Ctrl-C— quit
AI Analysis in the TUI
The AI Analysis panel uses SSE streaming. The detail pane updates token by token as the response arrives — the assessment renders progressively rather than appearing all at once. The spinner remains active until the first token appears. Once the first token reaches the channel, the source transitions to Done even though text continues to accumulate.
The CLI Path
When scope-recon determines the TUI is not appropriate — due to --json, --output, --file, or multiple targets — it uses tokio::join! to fire all 11 concurrent queries simultaneously, then runs AI analysis sequentially afterward, then renders a single time.
Basic output
IP: 185.220.101.47
══════════════════════════════════
SUMMARY
Verdict: MALICIOUS
Findings: 3 C2 IOC(s) on ThreatFox · 4 malicious VT detections · AbuseIPDB score 87/100 · IPQS: TOR exit node
══════════════════════════════════
GEOLOCATION (ip-api.com)
Country: Germany
Region: Hesse
City: Frankfurt am Main
ISP: Hetzner Online GmbH
Org: Tor network
ASN: AS24940 Hetzner Online GmbH
SHODAN
Hostnames: tor-exit.example.de
Tags: tor
Services:
22/tcp OpenSSH 8.4p1
443/tcp -
9001/tcp -
THREATFOX (abuse.ch)
C2 IOCs: 3
IOC: 185.220.101.47:9001
Threat Type: botnet_cc
Malware: Tor
Confidence: 75%
First Seen: 2024-08-12
...
AI ANALYSIS (Grok via OpenRouter)
185.220.101.47 is a known Tor exit node hosted on Hetzner infrastructure in
Frankfurt, Germany. The IP has been consistently flagged as malicious across
multiple threat intelligence platforms...
JSON output
scope-recon 185.220.101.47 --jsonEvery source is a typed field in a flat struct. Unavailable sources are null and do not break the output shape.
{
"queried_at": "2026-03-09T14:22:08.413+00:00",
"ip": "185.220.101.47",
"ipapi": { "country": "Germany", "city": "Frankfurt am Main" },
"shodan": { "open_ports": [22, 443, 9001], "tags": ["tor"] },
"abuseipdb": { "abuse_confidence": 87, "total_reports": 412 },
"threatfox": { "ioc_count": 3, "iocs": ["..."] },
"ai_analysis": { "analysis": "185.220.101.47 is a known Tor exit node..." }
}Filtering sources
--only excludes everything not named. Excluded sources produce no output and no error.
# Quick IOC check — skip slower sources
scope-recon 1.2.3.4 --only threatfox,virustotal
# BGP context only, no authenticated sources
scope-recon 1.2.3.4 --only ipapi,bgpview,shodan
# AI synthesis of cached data only
scope-recon 1.2.3.4 --only openrouterBulk mode
scope-recon --file targets.txt --json > results.jsonA 500ms courtesy delay between IPs prevents hammering rate-limited APIs. CIDR lines in the file expand inline. Comment lines prefixed with # are skipped.
jq Workflows
JSON output pairs with jq to build composable analysis pipelines.
Triage dashboard — key fields on one line:
scope-recon 1.2.3.4 --json | jq '{
ip,
vt_malicious: .virustotal.malicious,
abuse_score: .abuseipdb.abuse_confidence,
fraud_score: .ipqs.fraud_score,
ioc_count: .threatfox.ioc_count,
tor: .ipqs.tor
}'Extract IOC details:
scope-recon 1.2.3.4 --json | jq '.threatfox.iocs[] | {ioc, threat_type, malware, confidence_level}'Bulk: filter to IPs with VirusTotal detections:
scope-recon --file targets.txt --json | jq '.[] | select(.virustotal.malicious > 0) | {ip, malicious: .virustotal.malicious}'Bulk: identify TOR, VPN, and proxy nodes:
scope-recon --file targets.txt --json | jq '.[] | select(.ipqs.tor == true or .ipqs.vpn == true or .ipqs.proxy == true) | {ip, tor: .ipqs.tor, vpn: .ipqs.vpn, proxy: .ipqs.proxy}'Bulk: ASN pattern analysis:
scope-recon --file targets.txt --json | jq -r '.[] | [.ip, (.bgpview.asn_description // "-"), (.ipapi.isp // "-")] | @tsv'Watch Mode
scope-recon 1.2.3.4 --watch 60 # CLI: reprint every 60s
scope-recon 1.2.3.4 --watch 60 # TUI: auto-refresh every 60sWatch mode always bypasses the cache — the purpose is fresh data. In the TUI, the interval is shown in dim in the footer: watch: 60s. The first tick fires after N seconds rather than immediately, since the TUI already ran queries at startup.
In CLI mode, reruns are separated by a ━━━ divider. This is useful for a monitoring terminal alongside an incident response session.
AI Analysis
The 12th source differs from the other eleven. It does not query a static intelligence feed — it synthesizes all other results into a narrative assessment.
The prompt is built from the full JSON-serialized ThreatReport and requests a 2–3 paragraph threat assessment. If ThreatFox has IOC matches, the prompt appends a request for per-malware-family context paragraphs. An IP with Cobalt Strike and Emotet matches, for example, will receive a dedicated paragraph explaining each family.
AI analysis fires sequentially rather than concurrently because it depends on all other sources. It is spawned in a tokio::spawn only after all_sources_terminal() returns true — every source has reached Done, Error, or Skipped.
In the TUI, the request uses stream: true and consumes the SSE response via bytes_stream(). Each token delta sends a SourceUpdate::AiAnalysis with the accumulated text. The channel is the same one used by all other sources — the event loop does not differentiate between a one-time update and a repeatedly accumulating one.
In the CLI path, the blocking completion endpoint is used. The full response arrives and renders after all other source output.
The model is x-ai/grok-4.1-fast via OpenRouter. Requires OPENROUTER_API_KEY. Optional — exclude it with --only or simply do not set the key.
The Cache
Queries are cached at ~/.cache/scope-recon/{ip}.json with a default one-hour TTL, configurable via --cache-ttl (set to 0 to disable).
Cache entries are the full serialized ThreatReport — the same struct that --json outputs. To inspect an entry:
cat ~/.cache/scope-recon/8.8.8.8.jsonTo clear an entry:
rm ~/.cache/scope-recon/8.8.8.8.jsonFreshness is checked against the queried_at timestamp embedded in the report — no separate metadata table is used. A future timestamp caused by clock skew is treated as expired.
In the TUI, cache hits are applied before any tasks are spawned. Sources already populated from cache skip their tokio::spawn entirely. If all sources are cached, no network traffic occurs and the TUI opens fully populated. The r key forces TTL to zero on a cloned Cli before calling spawn_queries, bypassing the cache entirely.
Architecture
Shared HTTP client
One reqwest::Client is constructed at startup with a 15-second timeout. It is stored in ApiKeys and passed as &client to every fetch function. reqwest::Client is Arc-backed, so client.clone() within tokio::spawn closures is a cheap reference count increment — no socket duplication.
The 15-second timeout is the difference between tolerating a slow API response and hanging indefinitely. Every source either responds or times out; the tool always finishes.
SourceState<T> as a state machine
Every source in App is typed as SourceState<T>:
pub enum SourceState<T> {
Loading,
Done(T),
Error(String),
Skipped,
}State transitions are one-way. The TUI event loop pattern-matches on state to determine what to render: spinner frames for Loading, a green ✓ for Done, a red ✗ for Error, and a dim - for Skipped. The detail pane extracts the inner value from Done(v) and dispatches on the selected source index.
The tokio::select! loop
The TUI event loop selects on four things simultaneously:
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);
maybe_trigger_ai(&mut ai_triggered, &app, &keys, &cli, tx.clone()).await;
}
_ = async {
if let Some(ref mut w) = watch_interval {
w.tick().await;
} else {
std::future::pending::<()>().await
}
} => {
app.refresh_requested = true;
}
}The watch interval arm uses std::future::pending() when watch mode is off — a future that never resolves. The select never picks that arm. No polling, no allocation, no overhead.
After every branch, the UI re-renders. The verdict in the header is rebuilt from scratch on every frame.
Verdict logic
compute_verdict() is a pure function: it accepts a ThreatReport and returns (&'static str, Vec<String>). No state, no side effects. The TUI calls it on every render by constructing a partial report from current App state. The CLI calls it once at the end. The function and its thresholds are identical across both paths.
| 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 TOR, proxy, and VPN flags are always surfaced in findings regardless of fraud score, because they provide meaningful context even when the numeric score is low. GreyNoise RIOT membership always appends "known benign infrastructure (RIOT)" to findings even for CLEAN verdicts.
MITRE ATT&CK Alignment
scope-recon is a defensive tool, but the intelligence it aggregates maps directly to adversary techniques observed across threat intelligence feeds:
- T1071 — Application Layer Protocol: Open port and service banner data from Shodan surfaces non-standard C2 channel configurations (e.g., Tor traffic on port 9001/tcp).
- T1090 — Proxy: IPQS TOR, VPN, and proxy flags, and GreyNoise noise classification, identify IPs used as anonymization infrastructure consistent with this technique.
- T1583 / T1584 — Acquire / Compromise Infrastructure: ASN and geolocation data from ip-api, RIPE Stat, and IPinfo contextualizes whether an IP belongs to known bulletproof hosting, consumer ISP, or compromised cloud infrastructure — directly relevant to infrastructure acquisition analysis.
- T1596 — Search Open Technical Databases: scope-recon itself performs this technique programmatically, querying Shodan, RIPE Stat, and public DNS to enumerate infrastructure context.
- T1102 — Web Service: ThreatFox and OTX pulse matches surface IPs associated with C2 communication over web services.
Setup
API Keys
All keys are read from environment variables. The recommended approach is to add them directly to ~/.zshrc rather than exporting them at the terminal — inline export commands go to shell history and may expose keys.
# 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
export OPENROUTER_API_KEY=your_key_hereAfter saving the file:
chmod 600 ~/.zshrc
source ~/.zshrcThe secure handling of credentials extends to API keys — treat them as secrets equivalent to passwords. Missing keys degrade gracefully: key-required sources show [source unavailable] in CLI output or ✗ in the TUI, and the verdict is still computed from whatever data arrived.
Shell completions
scope-recon --completions zsh > ~/.zfunc/_scope-recon
scope-recon --completions bash > /etc/bash_completion.d/scope-recon
scope-recon --completions fish > ~/.config/fish/completions/scope-recon.fishCompletions are implemented via clap_complete. The --completions flag is hidden from --help.
The source code is available at https://github.com/nethoundsh/scope-recon. Issues and pull requests are open.
