diff --git a/TASKS.md b/TASKS.md index aaa884b..bcaef2d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,17 @@ # derp - Tasks -## Current Sprint -- v1.2.9 InternetDB Plugin (2026-02-19) +## Current Sprint -- v1.3.0 Tier 2 Plugins (2026-02-20) + +| Pri | Status | Task | +|-----|--------|------| +| P0 | [x] | Canary token generator (`plugins/canary.py`) -- gen/list/info/del | +| P0 | [x] | TCP ping (`plugins/tcping.py`) -- latency probe via SOCKS5 | +| P0 | [x] | Wayback archive (`plugins/archive.py`) -- Save Page Now via SOCKS5 | +| P0 | [x] | Bulk DNS resolve (`plugins/resolve.py`) -- concurrent TCP DNS via SOCKS5 | +| P1 | [x] | Tests for all 4 plugins | +| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md) | + +## Previous Sprint -- v1.2.9 InternetDB Plugin (2026-02-19) | Pri | Status | Task | |-----|--------|------| diff --git a/TODO.md b/TODO.md index 55c4fb4..ee086cd 100644 --- a/TODO.md +++ b/TODO.md @@ -68,7 +68,7 @@ is preserved in git history for reference. ## Plugins -- Security/OSINT - [x] `emailcheck` -- SMTP VRFY/RCPT TO verification -- [ ] `canary` -- canary token generator/tracker +- [x] `canary` -- canary token generator/tracker - [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 diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 9e8405f..7793c9d 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -134,6 +134,25 @@ SASL auto-added when sasl_user/sasl_pass configured. !unload # Remove a plugin (admin) ``` +## Archive + +``` +!archive https://example.com/page # Save to Wayback Machine +``` + +URL must have `http://` or `https://` scheme. 30s timeout. SOCKS5-proxied. + +## Bulk DNS + +``` +!resolve example.com github.com # A records (concurrent) +!resolve example.com AAAA # Specific type +!resolve 1.2.3.4 8.8.8.8 # Auto PTR for IPs +``` + +Max 10 hosts. Types: A, AAAA, MX, NS, TXT, CNAME, PTR, SOA. +TCP DNS via SOCKS5, server 1.1.1.1. + ## Recon ``` @@ -224,9 +243,26 @@ Categories: sqli, xss, ssti, lfi, cmdi, xxe !refang hxxps[://]evil[.]com # Refang IOC ``` +## Canary Tokens + +``` +!canary gen db-cred # 40-char hex token (default) +!canary gen aws staging-key # AWS AKIA keypair +!canary gen basic svc-login # user:pass pair +!canary list # List channel canaries +!canary info db-cred # Show full token +!canary del db-cred # Delete canary (admin) +``` + +Types: `token` (hex), `aws` (AKIA+secret), `basic` (user:pass). +Max 50/channel. `gen`/`del` admin only. Persists across restarts. + ## Network ``` +!tcping example.com # TCP latency (port 443, 3 probes) +!tcping example.com 22 # Custom port +!tcping example.com 80 5 # Custom port + count (max 10) !cidr 10.0.0.0/24 # Subnet info !cidr contains 10.0.0.0/8 10.1.2.3 # Membership check !portcheck 10.0.0.1 # Scan common ports diff --git a/docs/USAGE.md b/docs/USAGE.md index 8dac7db..07e45ae 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -134,6 +134,10 @@ format = "text" # Log format: "text" (default) or "json" | `!vt ` | VirusTotal lookup | | `!emailcheck [email2 ...]` | SMTP email verification (admin) | | `!internetdb ` | Shodan InternetDB host recon (ports, CVEs, CPEs) | +| `!canary ` | Canary token generator/tracker | +| `!tcping [port] [count]` | TCP connect latency probe via SOCKS5 | +| `!archive ` | Save URL to Wayback Machine | +| `!resolve [host2 ...] [type]` | Bulk DNS resolution via TCP/SOCKS5 | | `!shorten ` | Shorten a URL via FlaskPaste | | `!pastemoni ` | Paste site keyword monitoring | @@ -959,6 +963,102 @@ Output format: - All requests routed through SOCKS5 proxy - Returns "no data available" for IPs not in the InternetDB index +### `!canary` -- Canary Token Generator + +Generate realistic-looking credentials for planting as canary tokens (tripwires +for detecting unauthorized access). Tokens are persisted per-channel. + +``` +!canary gen db-cred Generate default token (40-char hex) +!canary gen aws staging-key AWS-style keypair +!canary gen basic svc-login Username:password pair +!canary list List canaries in channel +!canary info db-cred Show full token details +!canary del db-cred Delete a canary (admin) +``` + +Token types: + +| Type | Format | Example | +|------|--------|---------| +| `token` | 40-char hex (API key / SHA1) | `a3f8b2c1d4e5...` | +| `aws` | AKIA access key + base64 secret | `AKIA7X9M2PVL5N...` | +| `basic` | user:pass pair | `svcadmin:xK9mP2vL5nR8wQ3z` | + +- `gen` and `del` require admin privileges +- All subcommands must be used in a channel (not PM) +- Labels: 1-32 chars, alphanumeric + hyphens + underscores +- Maximum 50 canaries per channel +- Persisted via `bot.state` (survives restarts) + +### `!tcping` -- TCP Connect Latency Probe + +Measure TCP connect latency to a host:port through the SOCKS5 proxy. Sequential +probes with min/avg/max summary. + +``` +!tcping example.com Port 443, 3 probes +!tcping example.com 22 Port 22, 3 probes +!tcping example.com 80 5 Port 80, 5 probes +``` + +Output format: + +``` +tcping example.com:443 -- 3 probes 1: 45ms 2: 43ms 3: 47ms min/avg/max: 43/45/47 ms +``` + +- Default port: 443, default count: 3 +- Max count: 10, timeout: 10s per probe +- Private/reserved addresses rejected +- Routed through SOCKS5 proxy + +### `!archive` -- Wayback Machine Save + +Save a URL to the Wayback Machine via the Save Page Now API. + +``` +!archive https://example.com/page +``` + +Output format: + +``` +Archiving https://example.com/page... +Archived: https://web.archive.org/web/20260220.../https://example.com/page +``` + +- URL must start with `http://` or `https://` +- Timeout: 30s (archiving can be slow) +- Handles 429 rate limit, 523 origin unreachable +- Sends acknowledgment before archiving +- Routed through SOCKS5 proxy + +### `!resolve` -- Bulk DNS Resolution + +Resolve multiple hosts via TCP DNS through the SOCKS5 proxy. Concurrent +resolution with compact output. + +``` +!resolve example.com github.com A records (default) +!resolve example.com AAAA Specific record type +!resolve 1.2.3.4 8.8.8.8 Auto PTR for IPs +``` + +Output format: + +``` +example.com -> 93.184.216.34 +github.com -> 140.82.121.3 +badhost.invalid -> NXDOMAIN +``` + +- Max 10 hosts per invocation +- Default type: A (auto-detect IP -> PTR) +- DNS server: 1.1.1.1 (Cloudflare) +- Concurrent via `asyncio.gather()` +- Valid types: A, NS, CNAME, SOA, PTR, MX, TXT, AAAA + ### FlaskPaste Configuration ```toml diff --git a/plugins/archive.py b/plugins/archive.py new file mode 100644 index 0000000..d0e09c1 --- /dev/null +++ b/plugins/archive.py @@ -0,0 +1,105 @@ +"""Plugin: Wayback Machine Save Page Now (SOCKS5-proxied).""" + +from __future__ import annotations + +import asyncio +import logging +import urllib.error +import urllib.request + +from derp.http import urlopen as _urlopen +from derp.plugin import command + +log = logging.getLogger(__name__) + +_SAVE_URL = "https://web.archive.org/save/" +_TIMEOUT = 30 +_USER_AGENT = "derp/1.0" + + +def _save_page(url: str) -> dict: + """Blocking POST to Save Page Now. Returns result dict.""" + target = f"{_SAVE_URL}{url}" + req = urllib.request.Request( + target, + headers={"User-Agent": _USER_AGENT}, + ) + + try: + resp = _urlopen(req, timeout=_TIMEOUT) + # The save endpoint returns a redirect to the archived page. + # With urllib3 pooled requests, redirects are followed automatically. + final_url = getattr(resp, "geturl", lambda: None)() + headers = resp.headers if hasattr(resp, "headers") else {} + + # Check for Content-Location or Link header with archived URL + content_location = None + if hasattr(headers, "get"): + content_location = headers.get("Content-Location", "") + link = headers.get("Link", "") + else: + content_location = "" + link = "" + + resp.read() + + # Try Content-Location first (most reliable) + if content_location and "/web/" in content_location: + if content_location.startswith("/"): + return {"url": f"https://web.archive.org{content_location}"} + return {"url": content_location} + + # Try final URL after redirects + if final_url and "/web/" in final_url: + return {"url": final_url} + + # Try Link header + if link and "/web/" in link: + # Extract URL from Link header: ; rel="memento" + for part in link.split(","): + part = part.strip() + if "/web/" in part and "<" in part: + extracted = part.split("<", 1)[1].split(">", 1)[0] + return {"url": extracted} + + # If we got a 200 but no archive URL, report success without link + return {"url": f"https://web.archive.org/web/*/{url}"} + + except urllib.error.HTTPError as exc: + if exc.code == 429: + return {"error": "rate limited -- try again later"} + if exc.code == 523: + return {"error": "origin unreachable"} + return {"error": f"HTTP {exc.code}"} + except (TimeoutError, OSError) as exc: + return {"error": f"timeout: {exc}"} + except Exception as exc: + return {"error": str(exc)[:100]} + + +@command("archive", help="Save to Wayback Machine: !archive ") +async def cmd_archive(bot, message): + """Save a URL to the Wayback Machine via Save Page Now. + + Usage: + !archive https://example.com/page + """ + parts = message.text.split(None, 1) + if len(parts) < 2: + await bot.reply(message, "Usage: !archive ") + return + + url = parts[1].strip() + if not url.startswith(("http://", "https://")): + await bot.reply(message, "URL must start with http:// or https://") + return + + await bot.reply(message, f"Archiving {url}...") + + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(None, _save_page, url) + + if "error" in result: + await bot.reply(message, f"Archive failed: {result['error']}") + else: + await bot.reply(message, f"Archived: {result['url']}") diff --git a/plugins/canary.py b/plugins/canary.py new file mode 100644 index 0000000..c5e3df4 --- /dev/null +++ b/plugins/canary.py @@ -0,0 +1,197 @@ +"""Plugin: canary token generator -- plant realistic fake credentials.""" + +from __future__ import annotations + +import json +import secrets +import string +from datetime import datetime, timezone + +from derp.plugin import command + +_MAX_PER_CHANNEL = 50 + + +def _gen_token() -> str: + """40-char hex string (looks like API key / SHA1).""" + return secrets.token_hex(20) + + +def _gen_aws() -> dict[str, str]: + """AWS-style keypair: AKIA + 16 alnum access key, 40-char base64 secret.""" + chars = string.ascii_uppercase + string.digits + access = "AKIA" + "".join(secrets.choice(chars) for _ in range(16)) + # 30 random bytes -> 40-char base64 + secret = secrets.token_urlsafe(30) + return {"access_key": access, "secret_key": secret} + + +def _gen_basic() -> dict[str, str]: + """Random user:pass pair.""" + alnum = string.ascii_lowercase + string.digits + user = "svc" + "".join(secrets.choice(alnum) for _ in range(5)) + pw = secrets.token_urlsafe(16) + return {"user": user, "pass": pw} + + +_TYPES = { + "token": "API token (40-char hex)", + "aws": "AWS keypair (AKIA access + secret)", + "basic": "Username:password pair", +} + + +def _load(bot, channel: str) -> dict: + """Load canary store for a channel.""" + raw = bot.state.get("canary", channel) + if not raw: + return {} + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return {} + + +def _save(bot, channel: str, store: dict) -> None: + """Persist canary store for a channel.""" + bot.state.set("canary", channel, json.dumps(store)) + + +def _format_token(entry: dict) -> str: + """Format a canary entry for display.""" + ttype = entry["type"] + value = entry["value"] + if ttype == "aws": + return f"Access: {value['access_key']} Secret: {value['secret_key']}" + if ttype == "basic": + return f"{value['user']}:{value['pass']}" + return value + + +@command("canary", help="Canary tokens: !canary gen [type]