feat: add jwt, mac, abuseipdb, virustotal, and emailcheck plugins
v2.0.0 sprint 1 -- five standalone plugins requiring no core changes: - jwt: decode JWT header/payload, flag alg=none/expired/nbf issues - mac: IEEE OUI vendor lookup, random MAC generation, OUI download - abuseipdb: IP reputation check + abuse reporting (admin) via API - virustotal: hash/IP/domain/URL lookup via VT APIv3, 4/min rate limit - emailcheck: SMTP RCPT TO verification via MX + SOCKS proxy (admin) Also adds update_oui() to update-data.sh and documents all five plugins in USAGE.md and CHEATSHEET.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
187
plugins/emailcheck.py
Normal file
187
plugins/emailcheck.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Plugin: SMTP email verification via MX resolution and raw SMTP conversation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
|
||||
from derp.dns import QTYPES, build_query, parse_response
|
||||
from derp.http import create_connection
|
||||
from derp.plugin import command
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_MAX_BATCH = 5
|
||||
_SMTP_TIMEOUT = 15
|
||||
_EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$")
|
||||
|
||||
# Tor DNS resolver for MX lookups
|
||||
_DNS_ADDR = "10.200.1.13"
|
||||
_DNS_PORT = 9053
|
||||
|
||||
|
||||
def _resolve_mx(domain: str) -> list[str]:
|
||||
"""Resolve MX records for a domain via Tor DNS. Returns list of MX hosts."""
|
||||
query = build_query(domain, QTYPES["MX"])
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(5)
|
||||
try:
|
||||
sock.sendto(query, (_DNS_ADDR, _DNS_PORT))
|
||||
data, _ = sock.recvfrom(4096)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
rcode, results = parse_response(data)
|
||||
if rcode != 0 or not results:
|
||||
return []
|
||||
|
||||
# MX results are "priority host" format
|
||||
mx_hosts = []
|
||||
for r in results:
|
||||
parts = r.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
mx_hosts.append(parts[1].rstrip("."))
|
||||
else:
|
||||
mx_hosts.append(r.rstrip("."))
|
||||
|
||||
# Sort by priority (already parsed as "prio host")
|
||||
return mx_hosts
|
||||
|
||||
|
||||
def _resolve_a(domain: str) -> str | None:
|
||||
"""Resolve A record for a domain as fallback. Returns first IP or None."""
|
||||
query = build_query(domain, QTYPES["A"])
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(5)
|
||||
try:
|
||||
sock.sendto(query, (_DNS_ADDR, _DNS_PORT))
|
||||
data, _ = sock.recvfrom(4096)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
rcode, results = parse_response(data)
|
||||
if rcode == 0 and results:
|
||||
return results[0]
|
||||
return None
|
||||
|
||||
|
||||
def _smtp_check(email: str, mx_host: str) -> tuple[int, str]:
|
||||
"""Raw SMTP conversation via SOCKS proxy. Returns (response_code, response_text)."""
|
||||
sock = create_connection((mx_host, 25), timeout=_SMTP_TIMEOUT)
|
||||
try:
|
||||
fp = sock.makefile("rb")
|
||||
|
||||
def _read_reply() -> tuple[int, str]:
|
||||
lines = []
|
||||
while True:
|
||||
line = fp.readline()
|
||||
if not line:
|
||||
break
|
||||
text = line.decode("utf-8", errors="replace").rstrip()
|
||||
lines.append(text)
|
||||
# Multi-line: "250-..." continues, "250 ..." ends
|
||||
if len(text) >= 4 and text[3] == " ":
|
||||
break
|
||||
if not lines:
|
||||
return 0, "no response"
|
||||
try:
|
||||
code = int(lines[-1][:3])
|
||||
except (ValueError, IndexError):
|
||||
code = 0
|
||||
return code, lines[-1][4:] if len(lines[-1]) > 4 else lines[-1]
|
||||
|
||||
# Read banner
|
||||
code, text = _read_reply()
|
||||
if code != 220:
|
||||
return code, f"banner: {text}"
|
||||
|
||||
# EHLO
|
||||
sock.sendall(b"EHLO derp.bot\r\n")
|
||||
code, text = _read_reply()
|
||||
if code != 250:
|
||||
return code, f"EHLO: {text}"
|
||||
|
||||
# MAIL FROM
|
||||
sock.sendall(b"MAIL FROM:<>\r\n")
|
||||
code, text = _read_reply()
|
||||
if code != 250:
|
||||
return code, f"MAIL FROM: {text}"
|
||||
|
||||
# RCPT TO
|
||||
sock.sendall(f"RCPT TO:<{email}>\r\n".encode())
|
||||
code, text = _read_reply()
|
||||
|
||||
# QUIT
|
||||
try:
|
||||
sock.sendall(b"QUIT\r\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return code, text
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
@command("emailcheck", help="SMTP verify: !emailcheck <email> [email2 ...]", admin=True)
|
||||
async def cmd_emailcheck(bot, message):
|
||||
"""Check email deliverability via SMTP RCPT TO verification.
|
||||
|
||||
Usage:
|
||||
!emailcheck user@example.com Single check
|
||||
!emailcheck user@example.com user2@test.org Batch (max 5)
|
||||
"""
|
||||
parts = message.text.split()
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !emailcheck <email> [email2 ...]")
|
||||
return
|
||||
|
||||
emails = parts[1:1 + _MAX_BATCH]
|
||||
|
||||
# Validate format
|
||||
for email in emails:
|
||||
if not _EMAIL_RE.match(email):
|
||||
await bot.reply(message, f"Invalid email format: {email}")
|
||||
return
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async def _check_one(email: str) -> str:
|
||||
domain = email.split("@", 1)[1]
|
||||
|
||||
# Resolve MX
|
||||
try:
|
||||
mx_hosts = await loop.run_in_executor(None, _resolve_mx, domain)
|
||||
except Exception as exc:
|
||||
log.debug("emailcheck: MX lookup failed for %s: %s", domain, exc)
|
||||
mx_hosts = []
|
||||
|
||||
# Fallback to A record
|
||||
if not mx_hosts:
|
||||
try:
|
||||
a_record = await loop.run_in_executor(None, _resolve_a, domain)
|
||||
except Exception:
|
||||
a_record = None
|
||||
if a_record:
|
||||
mx_hosts = [domain]
|
||||
else:
|
||||
return f"{email} -- no MX or A record for {domain}"
|
||||
|
||||
# Try each MX host
|
||||
for mx in mx_hosts:
|
||||
try:
|
||||
code, text = await loop.run_in_executor(None, _smtp_check, email, mx)
|
||||
return f"{email} -- SMTP {code} {text} (mx: {mx})"
|
||||
except Exception as exc:
|
||||
log.debug("emailcheck: SMTP failed for %s via %s: %s", email, mx, exc)
|
||||
continue
|
||||
|
||||
return f"{email} -- all MX hosts unreachable"
|
||||
|
||||
if len(emails) > 1:
|
||||
await bot.reply(message, f"Checking {len(emails)} addresses...")
|
||||
|
||||
results = await asyncio.gather(*[_check_one(e) for e in emails])
|
||||
for line in results:
|
||||
await bot.reply(message, line)
|
||||
Reference in New Issue
Block a user