Initial commit

Author: Adrian Helle <adrian@helle.me>
Committer: Jonas Braathen <jonas@ponas.no>
This commit is contained in:
2026-05-27 16:50:15 +02:00
committed by Jonas Braathen
commit 628e09423b
2 changed files with 218 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
Relay parsedmarc aggregate-report webhooks to a Discord webhook.
parsedmarc's [webhook] aggregate_url should point at this server. Each POST
body is a single aggregate report (parsedmarc's JSON schema); we translate
it to Discord embeds and forward.
Env vars:
DISCORD_WEBHOOK_URL required
LISTEN_HOST default 127.0.0.1
LISTEN_PORT default 8080
"""
from __future__ import annotations
import json
import logging
import os
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
import requests
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "")
LISTEN_HOST = os.environ.get("LISTEN_HOST", "127.0.0.1")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8080"))
MAX_EMBEDS_PER_MESSAGE = 10
MAX_FIELD_VALUE_LEN = 1024
COLOR_PASS, COLOR_PARTIAL, COLOR_FAIL, COLOR_INFO = 0x2ECC71, 0xF39C12, 0xE74C3C, 0x3498DB
def truncate(s: str, n: int = MAX_FIELD_VALUE_LEN) -> str:
return s if len(s) <= n else s[: n - 3] + "..."
def result_icon(r): return {"pass": "", "fail": "", "softfail": "⚠️",
"neutral": "", "temperror": "⚠️", "permerror": "⚠️"}.get(r or "", "")
def bool_icon(b): return "" if b else ""
def record_color(record):
if record.get("alignment", {}).get("dmarc"):
return COLOR_PASS
if record.get("policy_evaluated", {}).get("disposition") in ("quarantine", "reject"):
return COLOR_FAIL
return COLOR_PARTIAL
def build_metadata_embed(report):
meta = report.get("report_metadata", {})
policy = report.get("policy_published", {})
records = report.get("records", [])
total = sum(r.get("count", 0) for r in records)
passing = sum(r.get("count", 0) for r in records if r.get("alignment", {}).get("dmarc"))
fields = [
{"name": "Reporter",
"value": f"{meta.get('org_name', 'unknown')}\n{meta.get('org_email', '')}", "inline": True},
{"name": "Report ID", "value": f"`{meta.get('report_id', 'unknown')}`", "inline": True},
{"name": "Timespan (UTC)",
"value": f"{meta.get('begin_date', '?')}\n{meta.get('end_date', '?')}", "inline": False},
{"name": "Published Policy",
"value": (f"**Domain:** `{policy.get('domain', '?')}`\n"
f"**p / sp:** `{policy.get('p', '?')}` / `{policy.get('sp', '?')}`\n"
f"**adkim / aspf:** `{policy.get('adkim', '?')}` / `{policy.get('aspf', '?')}`\n"
f"**pct:** `{policy.get('pct', '?')}` • **fo:** `{policy.get('fo', '?')}`"),
"inline": False},
{"name": "Summary",
"value": f"**Records:** {len(records)}\n**Messages:** {total}\n**DMARC pass:** {passing} / {total}",
"inline": False},
]
if errors := (meta.get("errors") or []):
fields.append({"name": "Errors",
"value": truncate("\n".join(f"{e}" for e in errors)), "inline": False})
return {"title": f"DMARC Aggregate Report — {policy.get('domain', 'unknown')}",
"color": COLOR_INFO, "fields": fields}
def build_record_embed(record, idx, total):
src, align = record.get("source", {}), record.get("alignment", {})
pol, ids, auth = record.get("policy_evaluated", {}), record.get("identifiers", {}), record.get("auth_results", {})
rdns = src.get("reverse_dns") or ""
asn = src.get("asn")
as_str = f"AS{asn} ({src.get('as_name', '?')})" if asn else ""
fields = [
{"name": "Source",
"value": f"**IP:** `{src.get('ip_address', '?')}` ({src.get('country', '??')})\n"
f"**rDNS:** `{rdns}`\n**ASN:** {as_str}", "inline": False},
{"name": "Messages", "value": f"**{record.get('count', 0)}**", "inline": True},
{"name": "Disposition", "value": f"`{pol.get('disposition', '?')}`", "inline": True},
{"name": "Header From", "value": f"`{ids.get('header_from', '?')}`", "inline": True},
{"name": "Alignment",
"value": f"{bool_icon(align.get('dmarc'))} DMARC\n"
f"{bool_icon(align.get('spf'))} SPF\n"
f"{bool_icon(align.get('dkim'))} DKIM", "inline": True},
{"name": "Policy Evaluated",
"value": f"{result_icon(pol.get('spf'))} SPF: `{pol.get('spf', '?')}`\n"
f"{result_icon(pol.get('dkim'))} DKIM: `{pol.get('dkim', '?')}`", "inline": True},
]
for k, label, scope_key in (("dkim", "Auth: DKIM", "selector"), ("spf", "Auth: SPF", "scope")):
results = auth.get(k, [])
if not results:
continue
lines = [f"{result_icon(r.get('result'))} `{r.get('domain', '?')}` "
f"({scope_key}=`{r.get(scope_key, '?')}`) → `{r.get('result', '?')}`"
for r in results]
fields.append({"name": label, "value": truncate("\n".join(lines)), "inline": False})
if overrides := (pol.get("policy_override_reasons") or []):
lines = [f"• `{o.get('type', '?')}`: {o.get('comment', '') or '(no comment)'}"
for o in overrides]
fields.append({"name": "Override Reasons", "value": truncate("\n".join(lines)), "inline": False})
return {"title": f"Record {idx}/{total}{src.get('ip_address', '?')}",
"color": record_color(record), "fields": fields}
def send_to_discord(report):
embeds = [build_metadata_embed(report)]
records = report.get("records", [])
for i, rec in enumerate(records, start=1):
embeds.append(build_record_embed(rec, i, len(records)))
for i in range(0, len(embeds), MAX_EMBEDS_PER_MESSAGE):
payload = {"embeds": embeds[i: i + MAX_EMBEDS_PER_MESSAGE], "username": "parsedmarc"}
while True:
r = requests.post(DISCORD_WEBHOOK_URL, json=payload, timeout=15)
if r.status_code == 429:
try:
delay = float(r.json().get("retry_after", 1))
except Exception:
delay = 1.0
time.sleep(delay)
continue
r.raise_for_status()
break
time.sleep(0.5)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
try:
report = json.loads(body)
except json.JSONDecodeError as e:
self.send_error(400, f"invalid JSON: {e}")
return
try:
send_to_discord(report)
except Exception as e:
logging.exception("forwarding to Discord failed")
self.send_error(502, f"discord forward failed: {e}")
return
self.send_response(204)
self.end_headers()
def do_GET(self):
# cheap health check
if self.path in ("/health", "/healthz", "/"):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok\n")
else:
self.send_error(404)
def log_message(self, fmt, *args):
logging.info("%s - %s", self.address_string(), fmt % args)
def main():
if not DISCORD_WEBHOOK_URL:
sys.exit("ERROR: DISCORD_WEBHOOK_URL required")
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
srv = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
logging.info("listening on %s:%d", LISTEN_HOST, LISTEN_PORT)
srv.serve_forever()
if __name__ == "__main__":
main()