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.
This commit is contained in:
197
plugins/canary.py
Normal file
197
plugins/canary.py
Normal file
@@ -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] <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]")
|
||||
Reference in New Issue
Block a user