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]