Files
derp/plugins/canary.py
user e3bb793574 feat: add canary, tcping, archive, resolve plugins
canary: generate realistic fake credentials (token/aws/basic) for
planting as canary tripwires. Per-channel state persistence.

tcping: TCP connect latency probe through SOCKS5 proxy with
min/avg/max reporting. Proxy-compatible alternative to traceroute.

archive: save URLs to Wayback Machine via Save Page Now API,
routed through SOCKS5 proxy.

resolve: bulk DNS resolution (up to 10 hosts) via TCP DNS through
SOCKS5 proxy with concurrent asyncio.gather.

83 new tests (1010 total), docs updated.
2026-02-20 19:38:10 +01:00

198 lines
6.5 KiB
Python

"""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] <label> | list | info | del")
async def cmd_canary(bot, message):
"""Generate and manage canary tokens (fake credentials for detection).
Usage:
!canary gen [type] <label> Generate token (admin)
!canary list List canaries in channel
!canary info <label> Show full token details
!canary del <label> Delete a canary (admin)
"""
parts = message.text.split()
if len(parts) < 2:
await bot.reply(message, "Usage: !canary <gen|list|info|del> [args]")
return
sub = parts[1].lower()
channel = message.target if message.is_channel else None
# ---- gen -----------------------------------------------------------------
if sub == "gen":
if not bot._is_admin(message):
await bot.reply(message, "Permission denied")
return
if not channel:
await bot.reply(message, "Use this command in a channel")
return
# Parse: !canary gen [type] <label>
rest = parts[2:]
if not rest:
types = ", ".join(_TYPES)
await bot.reply(message, f"Usage: !canary gen [type] <label> (types: {types})")
return
# Check if first arg is a type
ttype = "token"
if rest[0].lower() in _TYPES:
ttype = rest[0].lower()
rest = rest[1:]
if not rest:
await bot.reply(message, "Usage: !canary gen [type] <label>")
return
label = rest[0].lower()
if len(label) > 32 or not all(c.isalnum() or c in "-_" for c in label):
await bot.reply(message, "Label: 1-32 chars, alphanumeric/hyphens/underscores")
return
store = _load(bot, channel)
if label in store:
await bot.reply(message, f"Canary '{label}' already exists")
return
if len(store) >= _MAX_PER_CHANNEL:
await bot.reply(message, f"Limit reached ({_MAX_PER_CHANNEL} per channel)")
return
# Generate
if ttype == "aws":
value = _gen_aws()
elif ttype == "basic":
value = _gen_basic()
else:
value = _gen_token()
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
store[label] = {"type": ttype, "value": value, "created": now}
_save(bot, channel, store)
display = _format_token(store[label])
await bot.reply(message, f"Canary '{label}' ({ttype}): {display}")
return
# ---- list ----------------------------------------------------------------
if sub == "list":
if not channel:
await bot.reply(message, "Use this command in a channel")
return
store = _load(bot, channel)
if not store:
await bot.reply(message, "No canaries in this channel")
return
items = [f"{lbl} ({e['type']})" for lbl, e in sorted(store.items())]
await bot.reply(message, f"Canaries: {', '.join(items)}")
return
# ---- info ----------------------------------------------------------------
if sub == "info":
if not channel:
await bot.reply(message, "Use this command in a channel")
return
if len(parts) < 3:
await bot.reply(message, "Usage: !canary info <label>")
return
label = parts[2].lower()
store = _load(bot, channel)
entry = store.get(label)
if not entry:
await bot.reply(message, f"No canary '{label}'")
return
display = _format_token(entry)
await bot.reply(message, f"{label} ({entry['type']}, {entry['created']}): {display}")
return
# ---- del -----------------------------------------------------------------
if sub == "del":
if not bot._is_admin(message):
await bot.reply(message, "Permission denied")
return
if not channel:
await bot.reply(message, "Use this command in a channel")
return
if len(parts) < 3:
await bot.reply(message, "Usage: !canary del <label>")
return
label = parts[2].lower()
store = _load(bot, channel)
if label not in store:
await bot.reply(message, f"No canary '{label}'")
return
del store[label]
_save(bot, channel, store)
await bot.reply(message, f"Deleted canary '{label}'")
return
# ---- unknown -------------------------------------------------------------
await bot.reply(message, "Usage: !canary <gen|list|info|del> [args]")