Threat feeds¶
During enrichment, each attacker IP is checked against external reputation services ("threat feeds"). A feed that flags the IP as malicious adds +20 to the attacker's threat score — awarded once per (IP, feed), so repeat events don't re-score — and the verdict is cached as a ThreatFeedEntry row (default 7-day TTL, purged hourly by Celery beat).
Two feeds ship built in: AbuseIPDB and VirusTotal. Both are optional and self-disable when no API key is configured.
AbuseIPDB¶
AbuseIPDB aggregates community abuse reports and returns a 0–100 abuse confidence score per IP. HoneyDjango treats confidence above 50 as malicious.
Get a free API key:
- Create a free account at abuseipdb.com.
- Go to your account's API tab and create a key. The free tier allows 1,000 checks per day — plenty for a single honeypot, especially since verdicts are cached for
THREAT_FEED_TTL_DAYS.
Configure:
ABUSEIPDB_API_KEY = "your-key-here" # usually from an env var
| Setting | Default | Purpose |
|---|---|---|
ABUSEIPDB_API_KEY | "" (disabled) | API key |
ABUSEIPDB_MAX_AGE_DAYS | 90 | Only consider reports newer than this |
ABUSEIPDB_TIMEOUT | 5.0 | HTTP timeout in seconds |
VirusTotal¶
VirusTotal runs each IP past dozens of security engines. HoneyDjango folds the engine verdicts into a single confidence value — the percentage of engines flagging the IP as malicious — and treats above 50 as malicious. The full engine breakdown is kept in the raw response.
Get a free API key:
- Create a free account at virustotal.com.
- Your API key is under your profile menu → API key. The free tier is rate-limited (4 requests/minute, 500/day) — fine for a honeypot, since the per-IP cache means each attacker is looked up once per TTL, and a 429 is handled gracefully (logged, skipped, never fatal).
Configure:
VIRUSTOTAL_API_KEY = "your-key-here"
| Setting | Default | Purpose |
|---|---|---|
VIRUSTOTAL_API_KEY | "" (disabled) | API key |
VIRUSTOTAL_TIMEOUT | 5.0 | HTTP timeout in seconds |
With no API keys configured¶
Nothing breaks. Each adapter reports itself unconfigured and is skipped by the feed registry — enrichment still runs GeoIP, TTP classification, and JA3 fingerprinting; attacker profiles simply accrue no feed-based score and no ThreatFeedEntry rows are written. The same graceful degradation applies to network failures, rate limits (429), and malformed responses at runtime: the adapter logs a warning and returns nothing, and the event is enriched without that feed's input.
Caching and expiry¶
| Setting | Default | Purpose |
|---|---|---|
THREAT_FEED_TTL_DAYS | 7 | How long a feed verdict is kept |
A malicious verdict is stored as a ThreatFeedEntry (IP, source, confidence, category, expiry). The hourly refresh_threat_feeds beat task purges expired rows, so a since-cleaned IP can be re-scored on its next visit.
Writing a custom adapter¶
Any reputation source can be plugged in with one class. The enrichment pipeline never changes — it always goes through the registry.
1. Write the adapter — subclass ThreatFeedAdapter and implement lookup. The contract: return a ThreatFeedResult, or None when unconfigured or on any failure. Never raise — a flaky feed must not break enrichment.
# honeydj/feeds/adapters/greynoise.py
import logging
import requests
from django.conf import settings
from honeydj.feeds.base import ThreatFeedAdapter, ThreatFeedResult
logger = logging.getLogger(__name__)
class GreyNoiseAdapter(ThreatFeedAdapter):
name = "greynoise" # must match a ThreatFeedEntry.Source value
requires_api_key = True
@property
def api_key(self) -> str | None:
return settings.GREYNOISE_API_KEY or None
def lookup(self, ip: str) -> ThreatFeedResult | None:
if not self.api_key:
return None
try:
response = requests.get(
f"https://api.greynoise.io/v3/community/{ip}",
headers={"key": self.api_key},
timeout=5.0,
)
response.raise_for_status()
data = response.json()
except (requests.RequestException, ValueError, KeyError) as exc:
logger.warning("GreyNoise lookup failed for %s: %s", ip, exc)
return None
malicious = data.get("classification") == "malicious"
return ThreatFeedResult(
ip=ip,
source=self.name,
confidence=100 if malicious else 0,
category=data.get("classification"),
is_malicious=malicious,
raw=data,
)
2. Register the source — ThreatFeedResult.source is stored verbatim on ThreatFeedEntry.source, so add a matching choice to ThreatFeedEntry.Source in honeydj/feeds/models.py (and generate a migration).
3. Add it to the registry in honeydj/feeds/registry.py:
FEED_REGISTRY: list[ThreatFeedAdapter] = [
AbuseIPDBAdapter(),
VirusTotalAdapter(),
GreyNoiseAdapter(),
]
That's it. run_all_feeds will skip it while unconfigured and start querying it as soon as its key appears in settings. A key-less feed (a local blocklist file, say) sets requires_api_key = False and needs no api_key override.