Three feeds, three verdicts, three different answers. Now what?
threat-tea takes an IP address, asks three reputation providers about it in
parallel-ish, boils the answers down to a single verdict + score, and caches the
result so you never pay for the same lookup twice. It ships a Rich terminal UI, a
Flask dashboard, a JSON API, and Prometheus metrics for Grafana.
It is a defensive tool: it only reads public reputation data about indicators you supply. It never scans, attacks, or touches a target host.
Threat intel providers answer the same question — "is this IP bad?" — in three different dialects:
| Provider | Signal it gives | How threat-tea scores it |
|---|---|---|
| AbuseIPDB | abuse-confidence % + report count | uses the confidence score directly (0–100) |
| AlienVault OTX | number of pulses referencing the IP | min(100, pulses × 20) |
| VirusTotal | last-analysis malicious/suspicious counts | min(100, malicious×15 + suspicious×5) |
An analyst enriching an alert shouldn't have to open three browser tabs, learn
three JSON shapes, and eyeball three scoring scales. threat-tea normalizes all of
it into one Finding:
indicator · type · max_score (0–100) · verdict · [per-source rows with raw evidence]
The headline max_score is the worst thing any single source said (an analyst
wants the loudest alarm), but every source row is kept verbatim — including the raw
provider JSON — so you can always audit why. The UI also shows corroboration
(hits = how many sources independently flagged it), which is what separates "one
twitchy feed" from "everybody agrees."
Verdict thresholds (stable — there's a test pinning these):
| score | verdict | colour |
|---|---|---|
≥ 75 |
high |
red |
40–74 |
medium |
yellow |
1–39 |
low |
cyan |
0 |
informational |
green (clean) |
check-ip 45.83.64.1
│
▼
┌─────────────────────────┐ validate.parse_ip()
│ 1. VALIDATE + NORMALIZE │ • must be a real IP (ipaddress) → blocks URL injection
│ │ • must be public (is_global) → won't leak internal hosts
└─────────────────────────┘ • canonicalized (dedupe key)
│
▼
┌─────────────────────────┐ aggregate.aggregate_ip()
│ 2. FAN OUT TO SOURCES │ AbuseIPDB / OTX / VirusTotal
│ │ • unconfigured key → "skipped", not fatal
│ │ • one source erroring → recorded, others continue
└─────────────────────────┘
│
▼
┌─────────────────────────┐ Finding(indicator, max_score, verdict, sources…)
│ 3. NORMALIZE → FINDING │ max_score = worst source; hits = # that flagged it
└─────────────────────────┘
│
▼
┌─────────────────────────┐ store.save_finding() (SQLite, upsert by indicator)
│ 4. CACHE │ findings(indicator PK, type, max_score, verdict,
└─────────────────────────┘ sources_json, observed_at)
│
▼
┌─────────────────────────┐ Rich panel/table (human) · pure JSON (--json) ·
│ 5. RENDER / SERVE │ Flask dashboard (/) · JSON API · /metrics (Grafana)
└─────────────────────────┘
The network fan-out is lazy about failure: a missing API key or a provider
outage becomes a "skipped" row with a short reason, never a crash. With no keys
configured at all, check-ip does zero network I/O and returns an
informational finding — which is exactly why the whole test suite runs offline.
Global flags (before the subcommand):
| flag | effect |
|---|---|
--db PATH |
SQLite cache location (env THREAT_TEA_DB, default threat_tea.sqlite3) |
--plain / --no-color |
disable colour/decoration (also honours NO_COLOR) |
--version |
print version |
Output rules, applied everywhere:
--json→ pure JSON to stdout, always, with zero decoration.- piped /
--plain/NO_COLOR/ not-a-TTY → clean plain text (the same lines older scripts already parse). - interactive terminal → the Rich banner, panels, tables, and spinners.
threat-tea check-ip 45.83.64.1 --yes
threat-tea check-ip 8.8.8.8 --yes --json # machine-readable
threat-tea check-ip 10.0.0.5 --yes --allow-private # opt in to private ranges| flag | meaning |
|---|---|
-y, --yes |
acknowledge you're authorized to query these indicators (or set THREAT_TEA_ASSUME_YES=1) |
--allow-private |
permit private/reserved/loopback IPs (blocked by default) |
--json |
emit the finding as pure JSON |
Sample interactive output:
╭─ 🍵 threat-tea ─────────────────────────────╮
│ steep three noisy feeds into one calm cup │
│ authorized threat-intel lookups only │
╰─────────────────────────────────────────────╯
╭─ finding ───────────────────────────────────────────────────────╮
│ │
│ 45.83.64.1 high (92) 2 source(s) flagged it │
│ │
│ source score summary │
│ ─────────────────────────────────────────────────────────── │
│ abuseipdb 92 Abuse confidence 92; reports 431 │
│ otx 60 OTX pulse references 3 │
│ virustotal 0 VT malicious=0, suspicious=0 │
│ │
╰──────────────────────────────────────────────────────────────────╯
Plain/piped output stays as the original one line:
45.83.64.1 high score=92
threat-tea collect --input iocs.txt --yes
threat-tea collect --input iocs.txt --yes --delay 1.5 # throttle for rate limits
threat-tea collect --input iocs.txt --yes --json > out.jsonBlank lines and # comments are ignored, duplicates are de-duped (first-seen
order), and invalid or private lines are skipped and reported on stderr — the run
continues. The file is size-capped (5 MB / 100k lines) so a runaway file can't
exhaust memory.
| flag | meaning |
|---|---|
--input PATH |
newline-delimited list of IPs (required) |
--delay SECONDS |
pause between lookups to respect provider rate limits (default 0) |
-y, --yes / --allow-private / --json |
as above |
threat-tea findings # table in a terminal, JSON when piped
threat-tea findings --verdict high # filter
threat-tea findings --limit 20
threat-tea findings --json | jq '.[].indicator'findings prints JSON whenever it isn't drawing to an interactive terminal,
so threat-tea findings > report.json keeps working exactly as before. In a real
terminal you get a sorted table (worst verdict first) with score, corroboration
hits, source count, and freshness.
threat-tea serve # binds 127.0.0.1:5064 by default
threat-tea serve --host 127.0.0.1 --port 8080| route | purpose |
|---|---|
GET / |
HTML dashboard |
GET /api/findings |
cached findings as JSON |
GET /metrics |
threat_tea_risk_score, threat_tea_indicators_total for Prometheus |
Every response carries hardening headers (X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, a strict Content-Security-Policy, Referrer-Policy).
This is a defensive, read-only tool, but it still sends every indicator you give it to third parties. threat-tea makes that explicit:
- Consent gate. Network commands (
check-ip,collect) refuse to run until you acknowledge authorization — pass--yes, setTHREAT_TEA_ASSUME_YES=1, or answer the prompt on an interactive terminal. Piped with no acknowledgement, it refuses (exit2) rather than silently phoning home about someone's IPs. - No internal-topology leaks. Private/reserved/loopback addresses are
blocked by default — the providers have no data on them and submitting them
can leak your internal layout. Override consciously with
--allow-private. - Injection-safe. Every indicator is parsed with the stdlib
ipaddressmodule before it can be spliced into a provider URL, so1.2.3.4/../../xnever leaves the process. SQL uses bound parameters throughout. - No secrets in output. API keys live in request headers, never URLs or logs; source errors are truncated to one short line.
- Safe
servedefaults. Binds to127.0.0.1; warns on any non-loopback bind; and refuses--debugon a non-loopback bind because Werkzeug's interactive debugger is a remote-code-execution surface. - Bounded input. The
collectfile is size- and line-capped.
Only look up indicators you are authorized to investigate.
cd threat-tea
python -m venv .venv && source .venv/bin/activate # optional
pip install -r requirements.txt
cp .env.example .env # then add whatever keys you haveConfigure whichever keys you have (any subset works; missing ones are skipped):
ABUSEIPDB_API_KEY=...
OTX_API_KEY=...
VIRUSTOTAL_API_KEY=...PYTHONPATH=src python -m threat_tea.cli check-ip 8.8.8.8 --yes
PYTHONPATH=src python -m threat_tea.cli findings
PYTHONPATH=src python -m threat_tea.cli serveFast, offline, no network and no sleeps:
PYTHONPATH=src python -m pytest -qdocker compose up --buildPrometheus scrapes /metrics; Grafana is provisioned at http://127.0.0.1:3000
(admin / threat-tea).
Python 3.11+, plus flask, requests, prometheus-client, and rich
(all in requirements.txt). Docker only for the Grafana stack.
Steep the feeds, pour one cup. AbuseIPDB, AlienVault OTX and VirusTotal rarely agree perfectly, and whichever one you happened to query first shouldn't get to decide your verdict. Corroboration across all three beats trusting any single source — so the output tells you not just how bad an IP looks, but how many independent sources actually back that up.
One of ten defensive tools rebuilt to a single standard — understand, reimagine, build the CLI, harden, document, verify. The goal was never to change what these tools are, but to take each one as far as it could honestly go on correctness, safety and the experience of the person actually running it at 2am.
Purpose. A calm IP-reputation aggregator: give it an indicator and it steeps AbuseIPDB, OTX, and VirusTotal into one color-coded verdict with corroboration and raw evidence — safe by default, pure JSON for scripts.
How it works. Each IP is validated and canonically normalized, then queried
against each configured source (keys stay in request headers). Results are cached
in SQLite as findings carrying a max score, a verdict, and a hits count of how
many sources independently flagged the IP. collect batches a file of
indicators; findings filters and renders the store.
Standout CLI. A check-ip finding panel with a verdict chip, corroboration
line, and per-source score/summary table; a sorted findings table (worst first)
with score/hits/source-count/freshness; a collect progress bar and summary
panel; a consistent severity ramp (green clean → cyan whisper → yellow look → red
act-now).
Key improvements. Functional: strict IP validation with IPv6 supported
end-to-end (correct OTX IPv4/IPv6 endpoints, dedupes compressed vs. expanded
forms); the corroboration hits signal; collect de-dupes, skips+reports
invalid/private lines while continuing, caps file size (5 MB / 100k lines), and
supports --delay; parametrized SQL filters. Security: the consent gate on
egress (--yes / THREAT_TEA_ASSUME_YES=1 / interactive; piped-without-ack is
refused, exit 2); private/reserved/loopback IPs blocked by default
(--allow-private to override) so you don't leak internal topology to third
parties; every indicator parsed with stdlib ipaddress before it can enter a
provider URL; API keys never in URLs or logs, error summaries trimmed to 160
chars.
Example.
PYTHONPATH=src python -m threat_tea.cli check-ip 45.83.64.1 --yesThese are defensive tools for systems, files, networks, and people you own or are explicitly authorized to assess. Read this before you run anything:
- Authorization is not optional. Phishing simulations, network scans, IP
reputation lookups, and web-app probing all touch other people's systems or
data. Get written authorization first. Several tools refuse to act until you
assert it (
--yes,--authorized-training,--i-have-authorization,--i-am-authorized, and friends). - Safe by default. Every web UI binds to
127.0.0.1(loopback). Egress, live sends, and active scans are gated behind explicit flags — dry-run, passive, or refuse-and-warn is always the default. - No accidental foot-guns. Flask
--debug(the Werkzeug interactive debugger is remote code execution on any traceback) is warned about loudly and refused entirely off loopback. Untrusted input is size-capped, validated, and rendered inertly so a hostile file or log line can't crash — or take over — your terminal. - No secrets in output. API keys stay in request headers, passwords come from the environment, and nothing sensitive is logged or printed.
These are not offensive tooling. They contain no exploits, no credential harvesters, and no payloads. If a tool could be misused, it is built to resist that misuse by construction.
python -m venv .venv && source .venv/bin/activate
pip install -e .
pytest -q # 24 tests, no network, no sleeps
threat-tea --helpReleased under the MIT License. See LICENSE.