Python · Threat Intelligence

ThreatPulse — CLI threat intelligence aggregator & web dashboard

A Python tool that fans out IOC lookups across four free threat intel feeds simultaneously, consolidates the results, and surfaces a single threat verdict

← Back to projects

ThreatPulse — Threat Intelligence Aggregator

Built because opening four browser tabs every time I had a suspicious IP got old fast.


The Goal

When you're doing triage or poking at a suspicious artifact, you want to know fast whether a given IP, domain, URL, or file hash is already in the wild. The standard workflow is manual: paste something into URLhaus, then VirusTotal, then OTX, then Feodo — tabs everywhere, results never in the same format.

ThreatPulse collapses that into a single CLI call. One IOC in, one verdict out, with a breakdown per source. A local Flask dashboard handles the same thing for when a terminal isn't the right context.


How It Works

The architecture is deliberately simple. threatpulse.py is the entry point with three subcommands: lookup, feed, and serve. The lookup path calls lookup.py, which routes the IOC to the correct set of feed modules based on type, then aggregates the responses.

threatpulse.py
├── cmd_lookup()  →  lookup.lookup(ioc, type)
│                        ├── urlhaus.lookup_host / lookup_url
│                        ├── feodo.lookup_ip
│                        ├── malwarebazaar.lookup_hash
│                        └── otx.lookup_ip / lookup_domain / lookup_hash
├── cmd_feed()    →  feodo.get_blocklist() — refresh / stats
└── cmd_serve()   →  dashboard/app.py (Flask)

Routing is per IOC type. An IP query hits URLhaus, Feodo Tracker, and OTX. A hash query hits MalwareBazaar and OTX. A URL goes to URLhaus only. Domains get URLhaus and OTX. This keeps queries targeted — no point hammering a C2 blocklist with a file hash.

Threat level is determined by a single rule: if any feed returns found: true, the overall verdict is MALICIOUS. Otherwise it's CLEAN. Simple and explicit.

Feodo Tracker data is cached locally as JSON to avoid hammering the feed on every run. The feed --update subcommand blows away the cache and forces a refresh.


Capabilities

  • IP lookups — checks URLhaus (host), Feodo Tracker (C2 blocklist), and AlienVault OTX
  • Domain lookups — checks URLhaus and OTX
  • URL lookups — checks URLhaus
  • File hash lookups — MD5, SHA1, or SHA256 against MalwareBazaar and OTX
  • MITRE ATT&CK mapping — Feodo IOCs map to T1071/T1090 (C2); URLhaus to T1566.002, T1189; MalwareBazaar to T1204.002
  • Feed management — refresh the Feodo cache, view breakdown by malware family
  • JSON output — save any result to disk with --output
  • Web dashboard — dark-themed Flask UI for browser-based lookups with a recent-history table

Tech & Tools

Component Technology
Language Python 3.8+
CLI argparse subcommands
Feed integrations requests against abuse.ch, Feodo, OTX APIs
Feed caching Local JSON file (Feodo blocklist)
Web dashboard Flask
Output Colored terminal (ANSI), JSON export

Feeds used:

Feed IOC Types Auth
abuse.ch URLhaus URL, Host/IP, Domain None
abuse.ch MalwareBazaar File hashes None
Feodo Tracker IP (botnet C2) None
AlienVault OTX IP, Domain, Hash Free API key

Three of the four feeds require no account. OTX is free to sign up for and massively expands coverage, especially for campaign-level context.


What I Learned

On threat intel feeds: Three of the four feeds I chose have clean, consistent JSON APIs with no auth requirement. abuse.ch's URLhaus and MalwareBazaar are especially well-structured — the response schema is stable enough that the feed modules are barely 30 lines each. Feodo is different: it exposes a bulk JSON dump rather than a per-IP query endpoint, which is why local caching matters. Doing a full list download on every lookup would be unreasonably slow and rude.

On the MITRE mapping: Adding the ATT&CK table forced me to think about what each data source actually represents, not just whether something is "bad." A Feodo hit means the IP is known botnet infrastructure, which has specific tactic implications (C2 channels, proxying). A URLhaus hit could mean initial access infrastructure or a drive-by download. These are different threat types and should inform different response actions.

On aggregator design: The routing logic in lookup.py is where most of the interesting decisions live. It would be easy to just throw every IOC at every feed and filter out the irrelevant results afterward, but that wastes API calls and slows down results. Explicit per-type routing keeps the tool fast and the output clean. The tradeoff is that adding a new feed requires updating the routing table, not just registering the module — an acceptable constraint at this scale.

On the threat level decision: I briefly considered a weighted scoring model — something like "two feeds agree = MALICIOUS, one feed = SUSPICIOUS." I kept it binary for now because false confidence in a "SUSPICIOUS" bucket felt worse than a simple any-hit rule. If a feed says an IP is a Emotet C2, that's enough.