From 3de3f054df4c30d32791548beba542ee99da8b52 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 17:41:51 +0100 Subject: [PATCH] feat: add internetdb plugin (Shodan InternetDB host recon) Free, keyless API returning open ports, hostnames, CPEs, tags, and known CVEs for any public IP. All requests routed through SOCKS5. 21 test cases (927 total). Co-Authored-By: Claude Opus 4.6 --- TASKS.md | 13 +- TODO.md | 69 +++++++++- docs/CHEATSHEET.md | 3 +- docs/USAGE.md | 24 ++++ plugins/internetdb.py | 105 +++++++++++++++ tests/test_internetdb.py | 281 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 487 insertions(+), 8 deletions(-) create mode 100644 plugins/internetdb.py create mode 100644 tests/test_internetdb.py diff --git a/TASKS.md b/TASKS.md index 7fc3a74..aaa884b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,17 @@ # derp - Tasks -## Current Sprint -- v1.2.8 ASN Backend Replacement (2026-02-19) +## Current Sprint -- v1.2.9 InternetDB Plugin (2026-02-19) + +| Pri | Status | Task | +|-----|--------|------| +| P0 | [x] | Shodan InternetDB plugin (`plugins/internetdb.py`) -- free, no API key | +| P0 | [x] | Fetch via SOCKS5 proxy (`derp.http.urlopen`) | +| P1 | [x] | Compact formatting: hostnames, ports, CPEs, tags, CVEs with truncation | +| P1 | [x] | Input validation: IPv4/IPv6, private/loopback rejection | +| P2 | [x] | Tests: fetch, format, command handler (21 cases, 927 total) | +| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md) | + +## Previous Sprint -- v1.2.8 ASN Backend Replacement (2026-02-19) | Pri | Status | Task | |-----|--------|------| diff --git a/TODO.md b/TODO.md index 92247d8..55c4fb4 100644 --- a/TODO.md +++ b/TODO.md @@ -9,15 +9,72 @@ - [ ] Webhook listener (HTTP endpoint for push events to channels) - [ ] Granular ACLs (per-command: trusted, operator, admin) +## LLM Bridge + +Goal: let an LLM agent interact with the bot over IRC in real-time, +with full machine access (bash, file ops, etc.). + +### Architecture + +Owner addresses the bot on IRC. A bridge daemon reads addressed +messages, feeds them to an LLM with tool access, and writes replies +back through the bot. The bot already has `owner` config +(`[bot] owner` hostmask patterns) to gate who can trigger LLM +interactions. + +``` +IRC -> bot stdout (addressed msgs) -> bridge -> LLM API -> bridge -> bot inbox -> IRC +``` + +### Approach options (ranked) + +1. **Claude Code Agent SDK** (clean, non-trivial) + - Custom Python agent using `anthropic` SDK with `tool_use` + - Define tools: bash exec, file read/write, web fetch + - Persistent conversation memory across messages + - Full control over the event loop -- real-time IRC is natural + - Tradeoff: must implement and maintain tool definitions + +2. **Claude Code CLI per-message** (simple, stateless) + - `echo "user said X" | claude --print --allowedTools bash,read,write` + - Each invocation is a cold start with no conversation memory + - Simple to implement but slow startup, no multi-turn context + - Could pass conversation history via system prompt (fragile) + +3. **Persistent Claude Code subprocess** (hack, fragile) + - Long-running `claude` process with stdin/stdout piped + - Keeps context across messages within a session + - Not designed for this -- output parsing is brittle + - Session may drift or hit context limits + +### Bot-side plumbing needed + +- `--llm` CLI flag: route logging to file, stdout for addressed msgs +- `_is_addressed()`: DM or nick-prefixed messages +- `_is_owner()`: only owner hostmasks trigger LLM routing +- Inbox file polling (`/tmp/llm-inbox`): bridge writes ` ` +- `llm-send` script: line splitting (400 char), FlaskPaste overflow +- Stdout format: `HH:MM [#chan] text` / `HH:MM --- status` +- Only LLM-originated replies echoed to stdout (not all bot output) + +### Previous attempt (reverted) + +The `--llm` mode was implemented and tested (commit ea6f079, reverted +in 6f1f4b2). The stdout/stdin plumbing worked but Claude Code CLI +cannot act as a real-time daemon -- each tool call is a blocking +round-trip, making interactive IRC conversation impractical. The code +is preserved in git history for reference. + ## Plugins -- Security/OSINT -- [ ] `emailcheck` -- SMTP VRFY/RCPT TO verification +- [x] `emailcheck` -- SMTP VRFY/RCPT TO verification - [ ] `canary` -- canary token generator/tracker -- [ ] `virustotal` -- hash/URL/IP/domain lookup (free API) -- [ ] `abuseipdb` -- IP abuse confidence scoring (free tier) -- [ ] `jwt` -- decode tokens, show claims/expiry, flag weaknesses -- [ ] `mac` -- OUI vendor lookup (local IEEE database) -- [ ] `pastemoni` -- monitor paste sites for keywords +- [x] `virustotal` -- hash/URL/IP/domain lookup (free API) +- [x] `abuseipdb` -- IP abuse confidence scoring (free tier) +- [x] `jwt` -- decode tokens, show claims/expiry, flag weaknesses +- [x] `mac` -- OUI vendor lookup (local IEEE database) +- [x] `pastemoni` -- monitor paste sites for keywords +- [x] `internetdb` -- Shodan InternetDB host recon (free, no API key) ## Plugins -- Utility diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index dcf597c..9e8405f 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -237,9 +237,10 @@ Categories: sqli, xss, ssti, lfi, cmdi, xxe !blacklist 1.2.3.4 # DNSBL reputation check ``` -## Intelligence (local databases) +## Intelligence (local databases + APIs) ``` +!internetdb 8.8.8.8 # Shodan InternetDB: ports, CVEs, CPEs, tags !geoip 8.8.8.8 # GeoIP: city, country, coords, tz !asn 8.8.8.8 # ASN: number + organization !tor 1.2.3.4 # Check Tor exit node diff --git a/docs/USAGE.md b/docs/USAGE.md index 2656029..8dac7db 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -133,6 +133,7 @@ format = "text" # Log format: "text" (default) or "json" | `!abuse report ` | Report IP to AbuseIPDB (admin) | | `!vt ` | VirusTotal lookup | | `!emailcheck [email2 ...]` | SMTP email verification (admin) | +| `!internetdb ` | Shodan InternetDB host recon (ports, CVEs, CPEs) | | `!shorten ` | Shorten a URL via FlaskPaste | | `!pastemoni ` | Paste site keyword monitoring | @@ -935,6 +936,29 @@ Polling and announcements: - `list` shows keyword and per-backend error counts - `check` forces an immediate poll across all backends +### `!internetdb` -- Shodan InternetDB + +Look up host information from Shodan's free InternetDB API. Returns open ports, +reverse hostnames, CPE software fingerprints, tags, and known CVEs. No API key +required. + +``` +!internetdb 8.8.8.8 +``` + +Output format: + +``` +8.8.8.8 -- dns.google | Ports: 53, 443 | CPEs: cpe:/a:isc:bind | Tags: cloud +``` + +- Single IP per query (IPv4 or IPv6) +- Private/loopback addresses are rejected +- Hostnames truncated to first 5; CVEs truncated to first 10 (with `+N more`) +- CPEs truncated to first 8 +- All requests routed through SOCKS5 proxy +- Returns "no data available" for IPs not in the InternetDB index + ### FlaskPaste Configuration ```toml diff --git a/plugins/internetdb.py b/plugins/internetdb.py new file mode 100644 index 0000000..64cae34 --- /dev/null +++ b/plugins/internetdb.py @@ -0,0 +1,105 @@ +"""Plugin: Shodan InternetDB -- free host reconnaissance (no API key).""" + +from __future__ import annotations + +import asyncio +import ipaddress +import json +import logging + +from derp.http import urlopen as _urlopen +from derp.plugin import command + +log = logging.getLogger(__name__) + +_API_URL = "https://internetdb.shodan.io" +_TIMEOUT = 15 + + +def _fetch(addr: str) -> dict | None: + """Fetch InternetDB data for an IP address. + + Returns parsed JSON dict, or None on 404 (no data). + Raises on network/server errors. + """ + import urllib.error + + try: + resp = _urlopen(f"{_API_URL}/{addr}", timeout=_TIMEOUT) + return json.loads(resp.read()) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise + + +def _format_result(addr: str, data: dict) -> str: + """Format InternetDB response into a compact IRC message.""" + lines = [] + + hostnames = data.get("hostnames", []) + if hostnames: + lines.append(f"{addr} -- {', '.join(hostnames[:5])}") + else: + lines.append(addr) + + ports = data.get("ports", []) + if ports: + lines.append(f"Ports: {', '.join(str(p) for p in sorted(ports))}") + + cpes = data.get("cpes", []) + if cpes: + lines.append(f"CPEs: {', '.join(cpes[:8])}") + + tags = data.get("tags", []) + if tags: + lines.append(f"Tags: {', '.join(tags)}") + + vulns = data.get("vulns", []) + if vulns: + shown = vulns[:10] + suffix = f" (+{len(vulns) - 10} more)" if len(vulns) > 10 else "" + lines.append(f"CVEs: {', '.join(shown)}{suffix}") + + return " | ".join(lines) + + +@command("internetdb", help="Shodan InternetDB: !internetdb ") +async def cmd_internetdb(bot, message): + """Look up host information from Shodan InternetDB. + + Returns open ports, hostnames, CPEs, tags, and known CVEs. + Free API, no key required. + + Usage: + !internetdb 8.8.8.8 + """ + parts = message.text.split(None, 2) + if len(parts) < 2: + await bot.reply(message, "Usage: !internetdb ") + return + + addr = parts[1].strip() + 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 + + loop = asyncio.get_running_loop() + try: + data = await loop.run_in_executor(None, _fetch, str(ip)) + except Exception as exc: + log.error("internetdb: lookup failed for %s: %s", addr, exc) + await bot.reply(message, f"{addr}: lookup failed ({exc})") + return + + if data is None: + await bot.reply(message, f"{addr}: no data available") + return + + await bot.reply(message, _format_result(str(ip), data)) diff --git a/tests/test_internetdb.py b/tests/test_internetdb.py new file mode 100644 index 0000000..d26febe --- /dev/null +++ b/tests/test_internetdb.py @@ -0,0 +1,281 @@ +"""Tests for the InternetDB plugin (Shodan free host recon).""" + +import asyncio +import importlib.util +import json +import sys +import urllib.error +from pathlib import Path +from unittest.mock import patch + +from derp.irc import Message + +# plugins/ is not a Python package -- load the module from file path +_spec = importlib.util.spec_from_file_location( + "plugins.internetdb", + Path(__file__).resolve().parent.parent / "plugins" / "internetdb.py", +) +_mod = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = _mod +_spec.loader.exec_module(_mod) + +from plugins.internetdb import ( # noqa: E402 + _fetch, + _format_result, + cmd_internetdb, +) + +# -- Sample API responses ---------------------------------------------------- + +SAMPLE_FULL = { + "cpes": ["cpe:/a:apache:http_server:2.4.41", "cpe:/a:openssl:openssl:1.1.1"], + "hostnames": ["dns.google"], + "ip": "8.8.8.8", + "ports": [443, 53], + "tags": ["cloud"], + "vulns": ["CVE-2021-23017", "CVE-2021-3449"], +} + +SAMPLE_MINIMAL = { + "cpes": [], + "hostnames": [], + "ip": "203.0.113.1", + "ports": [80], + "tags": [], + "vulns": [], +} + +SAMPLE_EMPTY = { + "cpes": [], + "hostnames": [], + "ip": "198.51.100.1", + "ports": [], + "tags": [], + "vulns": [], +} + +SAMPLE_MANY_VULNS = { + "cpes": [], + "hostnames": ["vuln.example.com"], + "ip": "192.0.2.1", + "ports": [22, 80, 443], + "tags": ["self-signed", "eol-os"], + "vulns": [f"CVE-2021-{i}" for i in range(15)], +} + +SAMPLE_MANY_HOSTNAMES = { + "cpes": [], + "hostnames": [f"host{i}.example.com" for i in range(8)], + "ip": "192.0.2.2", + "ports": [80], + "tags": [], + "vulns": [], +} + + +# -- Helpers ----------------------------------------------------------------- + +class _FakeBot: + def __init__(self): + self.replied: list[str] = [] + + async def reply(self, message, text: str) -> None: + self.replied.append(text) + + +def _msg(text: str, nick: str = "alice", target: str = "#test") -> Message: + return Message( + raw="", prefix=f"{nick}!~{nick}@host", nick=nick, + command="PRIVMSG", params=[target, text], tags={}, + ) + + +class _FakeResp: + """Minimal file-like response for urlopen mocking.""" + + def __init__(self, data: bytes, status: int = 200): + self._data = data + self.status = status + + def read(self): + return self._data + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + +# --------------------------------------------------------------------------- +# TestFetch +# --------------------------------------------------------------------------- + +class TestFetch: + def test_success(self): + body = json.dumps(SAMPLE_FULL).encode() + with patch.object(_mod, "_urlopen", return_value=_FakeResp(body)): + result = _fetch("8.8.8.8") + assert result == SAMPLE_FULL + + def test_not_found(self): + exc = urllib.error.HTTPError( + "https://internetdb.shodan.io/192.0.2.1", 404, "Not Found", {}, None, + ) + with patch.object(_mod, "_urlopen", side_effect=exc): + result = _fetch("192.0.2.1") + assert result is None + + def test_server_error_raises(self): + exc = urllib.error.HTTPError( + "https://internetdb.shodan.io/192.0.2.1", 500, "Server Error", {}, None, + ) + with patch.object(_mod, "_urlopen", side_effect=exc): + try: + _fetch("192.0.2.1") + assert False, "Expected HTTPError" + except urllib.error.HTTPError as e: + assert e.code == 500 + + def test_builds_correct_url(self): + calls = [] + + def _mock_urlopen(url, **kw): + calls.append(url) + return _FakeResp(json.dumps(SAMPLE_MINIMAL).encode()) + + with patch.object(_mod, "_urlopen", side_effect=_mock_urlopen): + _fetch("203.0.113.1") + assert calls[0] == "https://internetdb.shodan.io/203.0.113.1" + + +# --------------------------------------------------------------------------- +# TestFormatResult +# --------------------------------------------------------------------------- + +class TestFormatResult: + def test_full_response(self): + result = _format_result("8.8.8.8", SAMPLE_FULL) + assert "8.8.8.8 -- dns.google" in result + assert "Ports: 53, 443" in result + assert "cpe:/a:apache:http_server:2.4.41" in result + assert "Tags: cloud" in result + assert "CVE-2021-23017" in result + + def test_minimal_response(self): + result = _format_result("203.0.113.1", SAMPLE_MINIMAL) + assert "203.0.113.1" in result + assert "Ports: 80" in result + # No hostnames, CPEs, tags, or vulns sections + assert "--" not in result + assert "CPEs:" not in result + assert "Tags:" not in result + assert "CVEs:" not in result + + def test_empty_response(self): + result = _format_result("198.51.100.1", SAMPLE_EMPTY) + assert result == "198.51.100.1" + + def test_many_vulns_truncated(self): + result = _format_result("192.0.2.1", SAMPLE_MANY_VULNS) + assert "+5 more" in result + # First 10 shown + assert "CVE-2021-0" in result + assert "CVE-2021-9" in result + + def test_many_hostnames_truncated(self): + result = _format_result("192.0.2.2", SAMPLE_MANY_HOSTNAMES) + # Only first 5 hostnames shown + assert "host0.example.com" in result + assert "host4.example.com" in result + assert "host5.example.com" not in result + + def test_ports_sorted(self): + data = {**SAMPLE_FULL, "ports": [8080, 22, 443, 80]} + result = _format_result("8.8.8.8", data) + assert "Ports: 22, 80, 443, 8080" in result + + def test_many_cpes_truncated(self): + data = {**SAMPLE_EMPTY, "cpes": [f"cpe:/a:vendor{i}:prod" for i in range(12)]} + result = _format_result("198.51.100.1", data) + assert "cpe:/a:vendor0:prod" in result + assert "cpe:/a:vendor7:prod" in result + assert "cpe:/a:vendor8:prod" not in result + + +# --------------------------------------------------------------------------- +# TestCommand +# --------------------------------------------------------------------------- + +class TestCommand: + def _run(self, bot, msg, data=None, side_effect=None): + """Run command with mocked _fetch.""" + if side_effect is not None: + with patch.object(_mod, "_fetch", side_effect=side_effect): + asyncio.run(cmd_internetdb(bot, msg)) + else: + with patch.object(_mod, "_fetch", return_value=data): + asyncio.run(cmd_internetdb(bot, msg)) + + def test_valid_ip(self): + bot = _FakeBot() + self._run(bot, _msg("!internetdb 8.8.8.8"), data=SAMPLE_FULL) + assert "dns.google" in bot.replied[0] + assert "Ports:" in bot.replied[0] + + def test_no_data(self): + bot = _FakeBot() + self._run(bot, _msg("!internetdb 4.4.4.4"), data=None) + assert "no data available" in bot.replied[0] + + def test_no_args(self): + bot = _FakeBot() + asyncio.run(cmd_internetdb(bot, _msg("!internetdb"))) + assert "Usage:" in bot.replied[0] + + def test_invalid_ip(self): + bot = _FakeBot() + asyncio.run(cmd_internetdb(bot, _msg("!internetdb notanip"))) + assert "Invalid IP" in bot.replied[0] + + def test_private_ip(self): + bot = _FakeBot() + asyncio.run(cmd_internetdb(bot, _msg("!internetdb 192.168.1.1"))) + assert "private/loopback" in bot.replied[0] + + def test_loopback(self): + bot = _FakeBot() + asyncio.run(cmd_internetdb(bot, _msg("!internetdb 127.0.0.1"))) + assert "private/loopback" in bot.replied[0] + + def test_ipv6(self): + bot = _FakeBot() + self._run(bot, _msg("!internetdb 2606:4700::1"), data=SAMPLE_MINIMAL) + assert "Ports:" in bot.replied[0] + + def test_network_error(self): + bot = _FakeBot() + self._run( + bot, _msg("!internetdb 8.8.8.8"), + side_effect=ConnectionError("timeout"), + ) + assert "lookup failed" in bot.replied[0] + + def test_normalized_ip(self): + """ipaddress normalization (e.g. ::ffff:8.8.8.8 -> mapped).""" + calls = [] + + def _mock_fetch(addr): + calls.append(addr) + return SAMPLE_MINIMAL + + bot = _FakeBot() + with patch.object(_mod, "_fetch", side_effect=_mock_fetch): + asyncio.run(cmd_internetdb(bot, _msg("!internetdb 8.008.8.8"))) + # Leading zeros rejected by Python's strict parser -> "Invalid IP" + assert "Invalid IP" in bot.replied[0] + + def test_empty_result(self): + bot = _FakeBot() + self._run(bot, _msg("!internetdb 198.51.100.1"), data=SAMPLE_EMPTY) + assert "198.51.100.1" in bot.replied[0]