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 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-20 17:41:51 +01:00
parent 442fea703c
commit 3de3f054df
6 changed files with 487 additions and 8 deletions

View File

@@ -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 |
|-----|--------|------|

69
TODO.md
View File

@@ -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 `<target> <msg>`
- `llm-send` script: line splitting (400 char), FlaskPaste overflow
- Stdout format: `HH:MM [#chan] <nick> 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

View File

@@ -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

View File

@@ -133,6 +133,7 @@ format = "text" # Log format: "text" (default) or "json"
| `!abuse <ip> report <cats> <comment>` | Report IP to AbuseIPDB (admin) |
| `!vt <hash\|ip\|domain\|url>` | VirusTotal lookup |
| `!emailcheck <email> [email2 ...]` | SMTP email verification (admin) |
| `!internetdb <ip>` | Shodan InternetDB host recon (ports, CVEs, CPEs) |
| `!shorten <url>` | Shorten a URL via FlaskPaste |
| `!pastemoni <add\|del\|list\|check>` | 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

105
plugins/internetdb.py Normal file
View File

@@ -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 <ip>")
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 <ip>")
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))

281
tests/test_internetdb.py Normal file
View File

@@ -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]