Compare commits
9 Commits
628e09423b
...
18953fde8e
| Author | SHA1 | Date | |
|---|---|---|---|
|
18953fde8e
|
|||
|
9f713fb549
|
|||
|
3e1262b9e4
|
|||
|
59846d9e9b
|
|||
|
dd20d707f8
|
|||
|
1edd0419df
|
|||
|
bd7be8138a
|
|||
|
5848801699
|
|||
|
3aeb00d7b1
|
@@ -0,0 +1,67 @@
|
|||||||
|
# dmarc-to-discord
|
||||||
|
|
||||||
|
A tiny HTTP relay that turns [parsedmarc](https://github.com/domainaware/parsedmarc) aggregate-report webhooks into nicely formatted Discord embeds.
|
||||||
|
|
||||||
|
parsedmarc parses DMARC aggregate reports from your inbox and POSTs each one as JSON to a webhook URL of your choosing. This service is that webhook: it listens for parsedmarc's POSTs, builds one metadata embed plus one embed per record (source IP, alignment, disposition, auth results, override reasons), and forwards them to a Discord channel.
|
||||||
|
|
||||||
|
## How it looks
|
||||||
|
|
||||||
|
Each report produces:
|
||||||
|
|
||||||
|
- **1 metadata embed** — reporter, report ID, timespan, published policy (`p`, `sp`, `adkim`, `aspf`, `pct`, `fo`), and a pass/total summary.
|
||||||
|
- **1 embed per record** — source IP/country/rDNS/ASN, message count, disposition, header-from, DMARC/SPF/DKIM alignment, policy-evaluated SPF/DKIM, raw auth results, and any policy override reasons.
|
||||||
|
|
||||||
|
Embeds are colored green (DMARC aligned), red (quarantine/reject), or orange (anything else). Discord allows max 10 embeds per message, so larger reports are split across multiple messages.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `DISCORD_WEBHOOK_URL` | *(required)* | Discord channel webhook URL |
|
||||||
|
| `LISTEN_HOST` | `127.0.0.1` | bind address |
|
||||||
|
| `LISTEN_PORT` | `8080` | bind port |
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
Requires Python 3.9+ and `requests`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install requests
|
||||||
|
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... ./dmarc_to_discord.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /`, `/health`, and `/healthz` return `200 ok` for liveness checks.
|
||||||
|
|
||||||
|
## Wiring up parsedmarc
|
||||||
|
|
||||||
|
In `parsedmarc.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[webhook]
|
||||||
|
aggregate_url = http://127.0.0.1:8080/
|
||||||
|
```
|
||||||
|
|
||||||
|
(parsedmarc also supports `forensic_url` and `smtp_tls_url`; this relay currently only handles the aggregate-report schema.)
|
||||||
|
|
||||||
|
## Running as a systemd service
|
||||||
|
|
||||||
|
A unit file is included. It expects the script at `/usr/local/bin/dmarc_to_discord.py` and the webhook URL in `/etc/dmarc-to-discord.env`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -m 0755 dmarc_to_discord.py /usr/local/bin/
|
||||||
|
sudo install -m 0644 dmarc-to-discord.service /etc/systemd/system/
|
||||||
|
echo 'DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...' | sudo tee /etc/dmarc-to-discord.env
|
||||||
|
sudo chmod 0600 /etc/dmarc-to-discord.env
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now dmarc-to-discord.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The unit runs under `DynamicUser=` with the filesystem locked down (`ProtectSystem=strict`, `ProtectHome=true`, no kernel/cgroup access, network restricted to `AF_INET`/`AF_INET6`) and is ordered `Before=parsedmarc.service` so parsedmarc's first POSTs aren't refused.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The server speaks plain HTTP. Bind to `127.0.0.1` (the default) and run parsedmarc on the same host, or terminate TLS in front of it.
|
||||||
|
- Discord 429s are honored via `retry_after`; there's a 0.5 s gap between messages to stay friendly to the rate limiter.
|
||||||
|
- No persistence — if Discord is down when a report arrives, the report is dropped (parsedmarc will not retry).
|
||||||
+87
-14
@@ -38,19 +38,23 @@ def truncate(s: str, n: int = MAX_FIELD_VALUE_LEN) -> str:
|
|||||||
return s if len(s) <= n else s[: n - 3] + "..."
|
return s if len(s) <= n else s[: n - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
def result_icon(r): return {"pass": "✅", "fail": "❌", "softfail": "⚠️",
|
def result_icon(r): return {"pass": "✅", "fail": "❌", "softfail": "⚠️", "neutral": "➖",
|
||||||
"neutral": "➖", "temperror": "⚠️", "permerror": "⚠️"}.get(r or "", "❓")
|
"none": "➖", "temperror": "⚠️", "permerror": "⚠️"}.get(r or "", "❓")
|
||||||
|
|
||||||
|
|
||||||
def bool_icon(b): return "✅" if b else "❌"
|
def bool_icon(b): return "✅" if b is True else "❌" if b is False else "❔"
|
||||||
|
|
||||||
|
|
||||||
def record_color(record):
|
def record_color(record):
|
||||||
if record.get("alignment", {}).get("dmarc"):
|
# Drive color by DMARC alignment, not by disposition: on a p=none policy every
|
||||||
|
# failure has disposition=none, and we don't want real spoofing to look the same
|
||||||
|
# as benign forwarder noise.
|
||||||
|
dmarc = (record.get("alignment") or {}).get("dmarc")
|
||||||
|
if dmarc is True:
|
||||||
return COLOR_PASS
|
return COLOR_PASS
|
||||||
if record.get("policy_evaluated", {}).get("disposition") in ("quarantine", "reject"):
|
if dmarc is False:
|
||||||
return COLOR_FAIL
|
return COLOR_FAIL
|
||||||
return COLOR_PARTIAL
|
return COLOR_PARTIAL # alignment missing → unknown
|
||||||
|
|
||||||
|
|
||||||
def build_metadata_embed(report):
|
def build_metadata_embed(report):
|
||||||
@@ -60,9 +64,13 @@ def build_metadata_embed(report):
|
|||||||
total = sum(r.get("count", 0) for r in 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"))
|
passing = sum(r.get("count", 0) for r in records if r.get("alignment", {}).get("dmarc"))
|
||||||
|
|
||||||
|
reporter_lines = [meta.get("org_name", "unknown"), meta.get("org_email", "")]
|
||||||
|
if extra := meta.get("org_extra_contact_info"):
|
||||||
|
reporter_lines.append(extra)
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
{"name": "Reporter",
|
{"name": "Reporter",
|
||||||
"value": f"{meta.get('org_name', 'unknown')}\n{meta.get('org_email', '')}", "inline": True},
|
"value": "\n".join(filter(None, reporter_lines)), "inline": True},
|
||||||
{"name": "Report ID", "value": f"`{meta.get('report_id', 'unknown')}`", "inline": True},
|
{"name": "Report ID", "value": f"`{meta.get('report_id', 'unknown')}`", "inline": True},
|
||||||
{"name": "Timespan (UTC)",
|
{"name": "Timespan (UTC)",
|
||||||
"value": f"{meta.get('begin_date', '?')} →\n{meta.get('end_date', '?')}", "inline": False},
|
"value": f"{meta.get('begin_date', '?')} →\n{meta.get('end_date', '?')}", "inline": False},
|
||||||
@@ -70,33 +78,97 @@ def build_metadata_embed(report):
|
|||||||
"value": (f"**Domain:** `{policy.get('domain', '?')}`\n"
|
"value": (f"**Domain:** `{policy.get('domain', '?')}`\n"
|
||||||
f"**p / sp:** `{policy.get('p', '?')}` / `{policy.get('sp', '?')}`\n"
|
f"**p / sp:** `{policy.get('p', '?')}` / `{policy.get('sp', '?')}`\n"
|
||||||
f"**adkim / aspf:** `{policy.get('adkim', '?')}` / `{policy.get('aspf', '?')}`\n"
|
f"**adkim / aspf:** `{policy.get('adkim', '?')}` / `{policy.get('aspf', '?')}`\n"
|
||||||
f"**pct:** `{policy.get('pct', '?')}` • **fo:** `{policy.get('fo', '?')}`"),
|
f"**pct:** `{policy.get('pct') or '100'}` • **fo:** `{policy.get('fo', '?')}`"),
|
||||||
"inline": False},
|
"inline": False},
|
||||||
{"name": "Summary",
|
{"name": "Summary",
|
||||||
"value": f"**Records:** {len(records)}\n**Messages:** {total}\n**DMARC pass:** {passing} / {total}",
|
"value": f"**Records:** {len(records)}\n**Messages:** {total}\n**DMARC pass:** {passing} / {total}",
|
||||||
"inline": False},
|
"inline": False},
|
||||||
]
|
]
|
||||||
if errors := (meta.get("errors") or []):
|
errors = meta.get("errors") or []
|
||||||
|
if errors:
|
||||||
fields.append({"name": "Errors",
|
fields.append({"name": "Errors",
|
||||||
"value": truncate("\n".join(f"• {e}" for e in errors)), "inline": False})
|
"value": truncate("\n".join(f"• {e}" for e in errors)), "inline": False})
|
||||||
return {"title": f"DMARC Aggregate Report — {policy.get('domain', 'unknown')}",
|
return {"title": f"DMARC Aggregate Report — {policy.get('domain', 'unknown')}",
|
||||||
"color": COLOR_INFO, "fields": fields}
|
"color": COLOR_PARTIAL if errors else COLOR_INFO, "fields": fields}
|
||||||
|
|
||||||
|
|
||||||
def build_record_embed(record, idx, total):
|
def diagnose_record(record):
|
||||||
|
"""One-line explanation of why DMARC passed or failed for this record."""
|
||||||
|
align = record.get("alignment") or {}
|
||||||
|
auth = record.get("auth_results") or {}
|
||||||
|
ids = record.get("identifiers") or {}
|
||||||
|
header_from = ids.get("header_from") or "?"
|
||||||
|
spf_results = auth.get("spf") or []
|
||||||
|
dkim_results = auth.get("dkim") or []
|
||||||
|
|
||||||
|
dmarc = align.get("dmarc")
|
||||||
|
if dmarc is None:
|
||||||
|
return None # parsedmarc didn't supply alignment; don't fabricate a verdict
|
||||||
|
|
||||||
|
if dmarc:
|
||||||
|
via = [name for name, ok in (("SPF", align.get("spf")), ("DKIM", align.get("dkim"))) if ok]
|
||||||
|
return "✅ **DMARC pass** — aligned via " + (" + ".join(via) if via else "?")
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
spf_pass = next((r for r in spf_results if (r.get("result") or "").lower() == "pass"), None)
|
||||||
|
if not spf_results:
|
||||||
|
parts.append("SPF not evaluated")
|
||||||
|
elif spf_pass:
|
||||||
|
parts.append(f"SPF passed on `{spf_pass.get('domain', '?')}` (not aligned with `{header_from}`)")
|
||||||
|
else:
|
||||||
|
worst = spf_results[0]
|
||||||
|
parts.append(f"SPF `{worst.get('result', '?')}` on `{worst.get('domain', '?')}`")
|
||||||
|
|
||||||
|
dkim_pass = next((r for r in dkim_results if (r.get("result") or "").lower() == "pass"), None)
|
||||||
|
if not dkim_results:
|
||||||
|
parts.append("DKIM not signed")
|
||||||
|
elif dkim_pass:
|
||||||
|
parts.append(f"DKIM passed on `{dkim_pass.get('domain', '?')}` (not aligned with `{header_from}`)")
|
||||||
|
else:
|
||||||
|
failed = ", ".join(f"`{r.get('domain', '?')}`/`{r.get('selector', '?')}`→`{r.get('result', '?')}`"
|
||||||
|
for r in dkim_results)
|
||||||
|
parts.append(f"DKIM failed ({failed})")
|
||||||
|
|
||||||
|
return "❌ **DMARC fail** — " + "; ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_record_embed(record, idx, total, policy_domain=None):
|
||||||
src, align = record.get("source", {}), record.get("alignment", {})
|
src, align = record.get("source", {}), record.get("alignment", {})
|
||||||
pol, ids, auth = record.get("policy_evaluated", {}), record.get("identifiers", {}), record.get("auth_results", {})
|
pol, ids, auth = record.get("policy_evaluated", {}), record.get("identifiers", {}), record.get("auth_results", {})
|
||||||
rdns = src.get("reverse_dns") or "—"
|
rdns = src.get("reverse_dns") or "—"
|
||||||
asn = src.get("asn")
|
asn = src.get("asn")
|
||||||
as_str = f"AS{asn} ({src.get('as_name', '?')})" if asn else "—"
|
as_str = f"AS{asn} ({src.get('as_name', '?')})" if asn else "—"
|
||||||
|
if asn and (as_domain := src.get("as_domain")):
|
||||||
|
as_str += f" — `{as_domain}`"
|
||||||
|
|
||||||
|
sender_bits = []
|
||||||
|
if name := src.get("name"):
|
||||||
|
sender_bits.append(f"**{name}**")
|
||||||
|
if base := src.get("base_domain"):
|
||||||
|
sender_bits.append(f"`{base}`")
|
||||||
|
sender_line = " ".join(sender_bits) + "\n" if sender_bits else ""
|
||||||
|
|
||||||
|
header_from = ids.get("header_from") or "?"
|
||||||
|
hf_note = ""
|
||||||
|
if policy_domain and header_from != "?" and header_from.lower() != policy_domain.lower():
|
||||||
|
hf_note = " *(subdomain — `sp` applies)*"
|
||||||
|
from_lines = [f"**Header From:** `{header_from}`{hf_note}",
|
||||||
|
f"**Envelope From:** `{ids.get('envelope_from') or '—'}`"]
|
||||||
|
if env_to := ids.get("envelope_to"):
|
||||||
|
from_lines.append(f"**Envelope To:** `{env_to}`")
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
{"name": "Source",
|
{"name": "Source",
|
||||||
"value": f"**IP:** `{src.get('ip_address', '?')}` ({src.get('country', '??')})\n"
|
"value": sender_line +
|
||||||
|
f"**IP:** `{src.get('ip_address', '?')}` ({src.get('country', '??')})\n"
|
||||||
f"**rDNS:** `{rdns}`\n**ASN:** {as_str}", "inline": False},
|
f"**rDNS:** `{rdns}`\n**ASN:** {as_str}", "inline": False},
|
||||||
|
]
|
||||||
|
if verdict := diagnose_record(record):
|
||||||
|
fields.append({"name": "Verdict", "value": truncate(verdict), "inline": False})
|
||||||
|
fields += [
|
||||||
{"name": "Messages", "value": f"**{record.get('count', 0)}**", "inline": True},
|
{"name": "Messages", "value": f"**{record.get('count', 0)}**", "inline": True},
|
||||||
{"name": "Disposition", "value": f"`{pol.get('disposition', '?')}`", "inline": True},
|
{"name": "Disposition", "value": f"`{pol.get('disposition', '?')}`", "inline": True},
|
||||||
{"name": "Header From", "value": f"`{ids.get('header_from', '?')}`", "inline": True},
|
{"name": "Identifiers", "value": "\n".join(from_lines), "inline": False},
|
||||||
{"name": "Alignment",
|
{"name": "Alignment",
|
||||||
"value": f"{bool_icon(align.get('dmarc'))} DMARC\n"
|
"value": f"{bool_icon(align.get('dmarc'))} DMARC\n"
|
||||||
f"{bool_icon(align.get('spf'))} SPF\n"
|
f"{bool_icon(align.get('spf'))} SPF\n"
|
||||||
@@ -124,8 +196,9 @@ def build_record_embed(record, idx, total):
|
|||||||
def send_to_discord(report):
|
def send_to_discord(report):
|
||||||
embeds = [build_metadata_embed(report)]
|
embeds = [build_metadata_embed(report)]
|
||||||
records = report.get("records", [])
|
records = report.get("records", [])
|
||||||
|
policy_domain = (report.get("policy_published") or {}).get("domain")
|
||||||
for i, rec in enumerate(records, start=1):
|
for i, rec in enumerate(records, start=1):
|
||||||
embeds.append(build_record_embed(rec, i, len(records)))
|
embeds.append(build_record_embed(rec, i, len(records), policy_domain))
|
||||||
|
|
||||||
for i in range(0, len(embeds), MAX_EMBEDS_PER_MESSAGE):
|
for i in range(0, len(embeds), MAX_EMBEDS_PER_MESSAGE):
|
||||||
payload = {"embeds": embeds[i: i + MAX_EMBEDS_PER_MESSAGE], "username": "parsedmarc"}
|
payload = {"embeds": embeds[i: i + MAX_EMBEDS_PER_MESSAGE], "username": "parsedmarc"}
|
||||||
|
|||||||
Reference in New Issue
Block a user