feat: add wave 3 local database plugins

GeoIP and ASN lookup via MaxMind GeoLite2 mmdb, Tor exit node check
against local bulk exit list, IP reputation via Firehol/ET blocklist
feeds, and CVE lookup against local NVD JSON mirror.

Includes cron-friendly update script (scripts/update-data.sh) for all
data sources and make update-data target. GeoLite2 requires a free
MaxMind license key; all other sources are freely downloadable.

Plugins: geoip, asn, torcheck, iprep, cve
Commands: !geoip, !asn, !tor, !iprep, !cve

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 02:38:13 +01:00
parent cf3abbdbae
commit 23b4d6f2a4
13 changed files with 995 additions and 8 deletions

85
plugins/asn.py Normal file
View File

@@ -0,0 +1,85 @@
"""Plugin: ASN lookup using MaxMind GeoLite2-ASN mmdb."""
from __future__ import annotations
import ipaddress
import logging
from pathlib import Path
from derp.plugin import command
log = logging.getLogger(__name__)
_DB_PATHS = [
Path("data/GeoLite2-ASN.mmdb"),
Path("/usr/share/GeoIP/GeoLite2-ASN.mmdb"),
Path.home() / ".local" / "share" / "GeoIP" / "GeoLite2-ASN.mmdb",
]
_reader = None
def _get_reader():
"""Lazy-load the mmdb reader."""
global _reader
if _reader is not None:
return _reader
try:
import maxminddb
except ImportError:
log.error("maxminddb package not installed")
return None
for path in _DB_PATHS:
if path.is_file():
_reader = maxminddb.open_database(str(path))
log.info("asn: loaded %s", path)
return _reader
log.warning("asn: no GeoLite2-ASN.mmdb found")
return None
@command("asn", help="ASN lookup: !asn <ip>")
async def cmd_asn(bot, message):
"""Look up the Autonomous System Number for an IP address.
Usage:
!asn 8.8.8.8
"""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !asn <ip>")
return
addr = parts[1]
try:
ip = ipaddress.ip_address(addr)
except ValueError:
await bot.reply(message, f"Invalid IP address: {addr}")
return
if ip.is_private or ip.is_loopback:
await bot.reply(message, f"{addr}: private/loopback address")
return
reader = _get_reader()
if reader is None:
await bot.reply(message, "ASN database not available (run update-data)")
return
try:
rec = reader.get(str(ip))
except Exception as exc:
await bot.reply(message, f"Lookup error: {exc}")
return
if not rec:
await bot.reply(message, f"{addr}: no ASN data")
return
asn = rec.get("autonomous_system_number", "")
org = rec.get("autonomous_system_organization", "")
if asn:
await bot.reply(message, f"{addr}: AS{asn} ({org})" if org else f"{addr}: AS{asn}")
else:
await bot.reply(message, f"{addr}: no ASN data")

258
plugins/cve.py Normal file
View File

@@ -0,0 +1,258 @@
"""Plugin: CVE lookup against local NVD JSON feed."""
from __future__ import annotations
import json
import logging
import re
import time
from pathlib import Path
from derp.plugin import command
log = logging.getLogger(__name__)
_DATA_DIR = Path("data/nvd")
_MAX_AGE = 86400
_CVE_RE = re.compile(r"^CVE-\d{4}-\d{4,}$", re.IGNORECASE)
_MAX_RESULTS = 5
# In-memory index: cve_id -> {description, severity, score, published}
_index: dict[str, dict] = {}
_loaded_at: float = 0
def _load_index() -> dict[str, dict]:
"""Load NVD JSON files into a searchable index."""
idx: dict[str, dict] = {}
if not _DATA_DIR.is_dir():
return idx
for path in sorted(_DATA_DIR.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
log.warning("cve: skipping %s: %s", path.name, exc)
continue
vulns = data.get("vulnerabilities", [])
for entry in vulns:
cve = entry.get("cve", {})
cve_id = cve.get("id", "")
if not cve_id:
continue
# Extract English description
descs = cve.get("descriptions", [])
desc = ""
for d in descs:
if d.get("lang") == "en":
desc = d.get("value", "")
break
if not desc and descs:
desc = descs[0].get("value", "")
# Extract CVSS score (prefer v3.1, then v3.0, then v2)
metrics = cve.get("metrics", {})
score = ""
severity = ""
for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
metric_list = metrics.get(key, [])
if metric_list:
cvss = metric_list[0].get("cvssData", {})
score = cvss.get("baseScore", "")
severity = cvss.get("baseSeverity", "")
break
published = cve.get("published", "")[:10]
idx[cve_id.upper()] = {
"description": desc,
"severity": severity,
"score": score,
"published": published,
}
log.info("cve: indexed %d CVEs from %s", len(idx), _DATA_DIR)
return idx
def _refresh_if_stale() -> None:
"""Reload the index if stale."""
global _index, _loaded_at
now = time.monotonic()
if _index and (now - _loaded_at) < _MAX_AGE:
return
idx = _load_index()
if idx:
_index = idx
_loaded_at = now
def _format_cve(cve_id: str, rec: dict) -> str:
"""Format a single CVE entry for IRC output."""
parts = [cve_id]
if rec["score"]:
sev = f" {rec['severity']}" if rec["severity"] else ""
parts.append(f"CVSS {rec['score']}{sev}")
if rec["published"]:
parts.append(rec["published"])
desc = rec["description"]
if len(desc) > 200:
desc = desc[:197] + "..."
parts.append(desc)
return " | ".join(parts)
async def _download_nvd() -> tuple[int, str]:
"""Download NVD CVE JSON feed. Returns (count, error)."""
import asyncio
import urllib.request
_DATA_DIR.mkdir(parents=True, exist_ok=True)
loop = asyncio.get_running_loop()
# NVD 2.0 API: paginated, 2000 per request
base_url = "https://services.nvd.nist.gov/rest/json/cves/2.0"
page_size = 2000
start_index = 0
total = 0
file_num = 0
def _fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": "derp-bot"})
with urllib.request.urlopen(req, timeout=120) as resp: # noqa: S310
return resp.read()
try:
while True:
url = f"{base_url}?startIndex={start_index}&resultsPerPage={page_size}"
data = await loop.run_in_executor(None, _fetch, url)
parsed = json.loads(data)
total_results = parsed.get("totalResults", 0)
vulns = parsed.get("vulnerabilities", [])
if not vulns:
break
dest = _DATA_DIR / f"nvd_{file_num:04d}.json"
dest.write_bytes(data)
total += len(vulns)
file_num += 1
start_index += page_size
if start_index >= total_results:
break
# Rate limit: NVD allows ~5 req/30s without API key
await asyncio.sleep(6)
except Exception as exc:
if total > 0:
return total, f"partial ({exc})"
return 0, str(exc)
global _index, _loaded_at
_index = {}
_loaded_at = 0
return total, ""
@command("cve", help="CVE lookup: !cve <id|search term>")
async def cmd_cve(bot, message):
"""Look up CVE details or search by keyword.
Usage:
!cve CVE-2024-1234 Lookup specific CVE
!cve search apache rce Search descriptions
!cve update Download NVD feed (slow)
!cve stats Show index statistics
"""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !cve <CVE-ID|search <term>|update|stats>")
return
arg = parts[1].strip()
if arg == "update":
await bot.reply(message, "Downloading NVD feed (this takes a while)...")
count, err = await _download_nvd()
if err and count == 0:
await bot.reply(message, f"Failed: {err}")
elif err:
await bot.reply(message, f"Downloaded {count} CVEs ({err})")
else:
await bot.reply(message, f"Downloaded {count} CVEs")
return
if arg == "stats":
_refresh_if_stale()
if not _index:
await bot.reply(message, "No CVE data loaded (run !cve update)")
else:
await bot.reply(message, f"CVE index: {len(_index)} entries")
return
if arg.lower() == "search":
term = parts[2].strip() if len(parts) > 2 else ""
if not term:
await bot.reply(message, "Usage: !cve search <term>")
return
_refresh_if_stale()
if not _index:
await bot.reply(message, "No CVE data loaded (run !cve update)")
return
term_lower = term.lower()
matches = []
for cve_id, rec in _index.items():
if term_lower in rec["description"].lower() or term_lower in cve_id.lower():
matches.append((cve_id, rec))
if len(matches) >= _MAX_RESULTS:
break
if not matches:
await bot.reply(message, f"No CVEs matching '{term}'")
else:
for cve_id, rec in matches:
await bot.reply(message, _format_cve(cve_id, rec))
return
# Direct CVE-ID lookup
cve_id = arg.upper()
if not _CVE_RE.match(cve_id):
# Maybe it's a search term without "search" prefix
_refresh_if_stale()
if not _index:
await bot.reply(message, "No CVE data loaded (run !cve update)")
return
term_lower = arg.lower()
rest = parts[2].strip() if len(parts) > 2 else ""
if rest:
term_lower = f"{term_lower} {rest.lower()}"
matches = []
for cid, rec in _index.items():
if term_lower in rec["description"].lower():
matches.append((cid, rec))
if len(matches) >= _MAX_RESULTS:
break
if not matches:
await bot.reply(message, f"No CVEs matching '{arg}'")
else:
for cid, rec in matches:
await bot.reply(message, _format_cve(cid, rec))
return
_refresh_if_stale()
if not _index:
await bot.reply(message, "No CVE data loaded (run !cve update)")
return
rec = _index.get(cve_id)
if not rec:
await bot.reply(message, f"{cve_id}: not found in local index")
return
await bot.reply(message, _format_cve(cve_id, rec))

98
plugins/geoip.py Normal file
View File

@@ -0,0 +1,98 @@
"""Plugin: GeoIP lookup using MaxMind GeoLite2-City mmdb."""
from __future__ import annotations
import ipaddress
import logging
from pathlib import Path
from derp.plugin import command
log = logging.getLogger(__name__)
_DB_PATHS = [
Path("data/GeoLite2-City.mmdb"),
Path("/usr/share/GeoIP/GeoLite2-City.mmdb"),
Path.home() / ".local" / "share" / "GeoIP" / "GeoLite2-City.mmdb",
]
_reader = None
def _get_reader():
"""Lazy-load the mmdb reader."""
global _reader
if _reader is not None:
return _reader
try:
import maxminddb
except ImportError:
log.error("maxminddb package not installed")
return None
for path in _DB_PATHS:
if path.is_file():
_reader = maxminddb.open_database(str(path))
log.info("geoip: loaded %s", path)
return _reader
log.warning("geoip: no GeoLite2-City.mmdb found")
return None
@command("geoip", help="GeoIP lookup: !geoip <ip>")
async def cmd_geoip(bot, message):
"""Look up geographic location for an IP address.
Usage:
!geoip 8.8.8.8
"""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !geoip <ip>")
return
addr = parts[1]
try:
ip = ipaddress.ip_address(addr)
except ValueError:
await bot.reply(message, f"Invalid IP address: {addr}")
return
if ip.is_private or ip.is_loopback:
await bot.reply(message, f"{addr}: private/loopback address")
return
reader = _get_reader()
if reader is None:
await bot.reply(message, "GeoIP database not available (run update-data)")
return
try:
rec = reader.get(str(ip))
except Exception as exc:
await bot.reply(message, f"Lookup error: {exc}")
return
if not rec:
await bot.reply(message, f"{addr}: no GeoIP data")
return
country = rec.get("country", {}).get("names", {}).get("en", "")
iso = rec.get("country", {}).get("iso_code", "")
city = rec.get("city", {}).get("names", {}).get("en", "")
loc = rec.get("location", {})
lat = loc.get("latitude")
lon = loc.get("longitude")
tz = loc.get("time_zone", "")
info = []
if city and country:
info.append(f"{city}, {country} ({iso})")
elif country:
info.append(f"{country} ({iso})")
if lat is not None and lon is not None:
info.append(f"{lat:.4f}, {lon:.4f}")
if tz:
info.append(tz)
result = f"{addr}: {' | '.join(info)}" if info else f"{addr}: no location data"
await bot.reply(message, result)

180
plugins/iprep.py Normal file
View File

@@ -0,0 +1,180 @@
"""Plugin: IP reputation check against Firehol blocklist feeds."""
from __future__ import annotations
import ipaddress
import logging
import time
from pathlib import Path
from derp.plugin import command
log = logging.getLogger(__name__)
_DATA_DIR = Path("data/iprep")
# Firehol feeds: (filename, url, description)
_FEEDS = [
("firehol_level1.netset",
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset",
"Firehol L1"),
("firehol_level2.netset",
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level2.netset",
"Firehol L2"),
("et_compromised.ipset",
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/et_compromised.ipset",
"ET Compromised"),
("bruteforcelogin.ipset",
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/bruteforcelogin.ipset",
"BruteForce"),
("bi_any_2_30d.ipset",
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/bi_any_2_30d.ipset",
"Badips 30d"),
]
_MAX_AGE = 86400 # Refresh cache after 24h
# Cache: feed_name -> (set of IPs/networks, load_time)
_cache: dict[str, tuple[set[str], list, float]] = {}
def _parse_feed(path: Path) -> tuple[set[str], list]:
"""Parse a feed file into sets of IPs and CIDR networks."""
ips: set[str] = set()
nets: list = []
try:
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "/" in line:
try:
nets.append(ipaddress.ip_network(line, strict=False))
except ValueError:
continue
else:
try:
ipaddress.ip_address(line)
ips.add(line)
except ValueError:
continue
except OSError:
pass
return ips, nets
def _load_feed(name: str) -> tuple[set[str], list]:
"""Load a feed from local cache, refreshing if stale."""
now = time.monotonic()
if name in _cache:
ips, nets, loaded = _cache[name]
if (now - loaded) < _MAX_AGE:
return ips, nets
path = _DATA_DIR / name
if not path.is_file():
return set(), []
ips, nets = _parse_feed(path)
_cache[name] = (ips, nets, now)
return ips, nets
def _check_ip(addr: str) -> list[str]:
"""Check an IP against all loaded feeds. Returns list of matching feed names."""
try:
ip_obj = ipaddress.ip_address(addr)
except ValueError:
return []
hits = []
for filename, _url, label in _FEEDS:
ips, nets = _load_feed(filename)
if addr in ips:
hits.append(label)
continue
for net in nets:
if ip_obj in net:
hits.append(label)
break
return hits
async def _download_feeds() -> tuple[int, int]:
"""Download all feeds. Returns (success_count, fail_count)."""
import asyncio
import urllib.request
_DATA_DIR.mkdir(parents=True, exist_ok=True)
loop = asyncio.get_running_loop()
async def _fetch_one(filename: str, url: str) -> bool:
def _do():
req = urllib.request.Request(url, headers={"User-Agent": "derp-bot"})
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
return resp.read()
try:
data = await loop.run_in_executor(None, _do)
(_DATA_DIR / filename).write_bytes(data)
return True
except Exception as exc:
log.error("iprep: failed to fetch %s: %s", filename, exc)
return False
tasks = [_fetch_one(fn, url) for fn, url, _ in _FEEDS]
results = await asyncio.gather(*tasks)
# Clear cache to force reload
_cache.clear()
ok = sum(1 for r in results if r)
return ok, len(results) - ok
@command("iprep", help="IP reputation: !iprep <ip|update>")
async def cmd_iprep(bot, message):
"""Check IP against Firehol/ET blocklist feeds.
Usage:
!iprep 1.2.3.4 Check IP reputation
!iprep update Download latest feeds
"""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !iprep <ip|update>")
return
arg = parts[1].strip()
if arg == "update":
await bot.reply(message, f"Downloading {len(_FEEDS)} feeds...")
ok, fail = await _download_feeds()
msg = f"Updated: {ok}/{len(_FEEDS)} feeds"
if fail:
msg += f" ({fail} failed)"
await bot.reply(message, msg)
return
try:
ip = ipaddress.ip_address(arg)
except ValueError:
await bot.reply(message, f"Invalid IP address: {arg}")
return
if ip.is_private or ip.is_loopback:
await bot.reply(message, f"{arg}: private/loopback address")
return
# Check if any feeds are loaded
has_data = any((_DATA_DIR / fn).is_file() for fn, _, _ in _FEEDS)
if not has_data:
await bot.reply(message, "No feeds loaded (run !iprep update)")
return
hits = _check_ip(str(ip))
if hits:
await bot.reply(message, f"{arg}: LISTED on {', '.join(hits)} "
f"({len(hits)}/{len(_FEEDS)} feeds)")
else:
await bot.reply(message, f"{arg}: clean ({len(_FEEDS)} feeds checked)")

145
plugins/torcheck.py Normal file
View File

@@ -0,0 +1,145 @@
"""Plugin: check IPs against local Tor exit node list."""
from __future__ import annotations
import ipaddress
import logging
import time
from pathlib import Path
from derp.plugin import command
log = logging.getLogger(__name__)
_EXIT_LIST_PATHS = [
Path("data/tor-exit-nodes.txt"),
Path("/var/lib/tor/exit-nodes.txt"),
]
_MAX_AGE = 86400 # Refresh if older than 24h
_TOR_EXIT_URL = "https://check.torproject.org/torbulkexitlist"
_exits: set[str] = set()
_loaded_at: float = 0
def _load_exits() -> set[str]:
"""Load exit node IPs from local file."""
for path in _EXIT_LIST_PATHS:
if path.is_file():
try:
nodes = set()
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
try:
ipaddress.ip_address(line)
nodes.add(line)
except ValueError:
continue
log.info("torcheck: loaded %d exit nodes from %s", len(nodes), path)
return nodes
except OSError:
continue
return set()
def _refresh_if_stale() -> None:
"""Reload the exit list if it hasn't been loaded or is stale."""
global _exits, _loaded_at
now = time.monotonic()
if _exits and (now - _loaded_at) < _MAX_AGE:
return
nodes = _load_exits()
if nodes:
_exits = nodes
_loaded_at = now
async def _download_exits() -> int:
"""Download the Tor bulk exit list. Returns count of nodes fetched."""
import asyncio
import urllib.request
loop = asyncio.get_running_loop()
def _fetch():
req = urllib.request.Request(_TOR_EXIT_URL, headers={"User-Agent": "derp-bot"})
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
return resp.read().decode("utf-8", errors="replace")
try:
text = await loop.run_in_executor(None, _fetch)
except Exception as exc:
log.error("torcheck: download failed: %s", exc)
return -1
nodes = set()
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
try:
ipaddress.ip_address(line)
nodes.add(line)
except ValueError:
continue
if not nodes:
return 0
# Write to first candidate path
dest = _EXIT_LIST_PATHS[0]
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text("\n".join(sorted(nodes)) + "\n")
log.info("torcheck: saved %d exit nodes to %s", len(nodes), dest)
global _exits, _loaded_at
_exits = nodes
_loaded_at = time.monotonic()
return len(nodes)
@command("tor", help="Tor exit check: !tor <ip|update>")
async def cmd_tor(bot, message):
"""Check if an IP is a known Tor exit node.
Usage:
!tor 1.2.3.4 Check IP against exit list
!tor update Download latest exit node list
"""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !tor <ip|update>")
return
arg = parts[1].strip()
if arg == "update":
await bot.reply(message, "Downloading Tor exit list...")
count = await _download_exits()
if count < 0:
await bot.reply(message, "Failed to download exit list")
elif count == 0:
await bot.reply(message, "Downloaded empty list")
else:
await bot.reply(message, f"Updated: {count} exit nodes")
return
try:
ip = ipaddress.ip_address(arg)
except ValueError:
await bot.reply(message, f"Invalid IP address: {arg}")
return
_refresh_if_stale()
if not _exits:
await bot.reply(message, "No exit list loaded (run !tor update)")
return
addr = str(ip)
if addr in _exits:
await bot.reply(message, f"{addr}: Tor exit node ({len(_exits)} nodes in list)")
else:
await bot.reply(message, f"{addr}: not a known Tor exit ({len(_exits)} nodes in list)")