- Python 70.1%
- HTML 25.2%
- Shell 3.6%
- Dockerfile 1.1%
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| nginx | ||
| templates | ||
| tests | ||
| .gitignore | ||
| app.py | ||
| Containerfile | ||
| dmarc_imap.py | ||
| dmarc_parser.py | ||
| drain-imap.sh | ||
| README.md | ||
| requirements.txt | ||
| schema.sql | ||
dmarc-analyzer
DMARC aggregate-report (RUA) analyzer for ps1raf — Flask + SQLite, rootless podman quadlet.
Parses DMARC aggregate reports (.xml, .xml.gz, .zip), stores them in SQLite, and
gives you filterable/searchable statistics, TOP-10 lists, and threshold alarms.
Background on what the fields mean:
https://www.mailreach.co/de/blog/how-to-read-a-dmarc-report.
Access
- URL: https://ps1raf.tn.ps1.at:8470/ (nginx SSL → gunicorn on
127.0.0.1:5095) - No login (LAN/test tool). The webhook is token-protected.
Import paths
-
Web UI — Import page, drag in one or more
.xml/.xml.gz/.zipfiles. -
Webhook —
POST /webhook/dmarcwithAuthorization: Bearer <WEBHOOK_TOKEN>(or?token=…). Raw body (--data-binary @report.xml.gz, setX-Filename) or multipart (-F report=@report.zip). Imports are idempotent on the report id.curl -sk -X POST https://ps1raf.tn.ps1.at:8470/webhook/dmarc \ -H "Authorization: Bearer $WEBHOOK_TOKEN" \ -H "X-Filename: report.xml.gz" --data-binary @report.xml.gz -
IMAP mailbox — pull report attachments straight from a mailbox (DMARC reporters email
.xml.gz/.zip/.xmlattachments). Configure in the container.env:IMAP_HOST=imap.example.com # empty = feature disabled IMAP_PORT=993 # IMAP_SSL=1 IMAP_USER=dmarc@example.com IMAP_PASS=... IMAP_FOLDER=INBOX # IMAP_SEARCH=UNSEEN IMAP_POST_ACTION=seen # seen | delete | move:<folder>Trigger from the Import page ("Fetch now"), via
POST /cron/fetch-imap, or let thedmarc-imap-fetch.timer(every 15 min) poll. Stdlibimaplib; only report-looking attachments are taken (signatures / inline text ignored); imports are idempotent on the report id; processed messages get the post-action (default: mark\Seen).
Statistics
- Dashboard — totals, DMARC aligned-pass / unaligned-fail %, disposition breakdown, and TOP-10 lists (source IPs, failing source IPs, sending domains, reporting orgs, failing from-domains). All respect the filter bar.
- Records — full searchable/filterable table (domain, result pass/fail, disposition, IP search, date range, free-text search), paginated.
- Reports — one row per imported report.
DMARC "pass" = the message is aligned (policy-evaluated DKIM or SPF passed).
Alarms (sensible defaults — override in .env)
| knob | default | meaning |
|---|---|---|
ALARM_WINDOW_DAYS |
7 | analysis window |
ALARM_FAIL_RATE_WARN / _CRIT |
5% / 15% | unaligned-message fraction |
ALARM_MIN_VOLUME |
100 | ignore fail-rate alarm below this many msgs |
ALARM_SOURCE_FAIL_MIN |
50 | one source IP sending ≥N unaligned msgs |
ALARM_DISPOSITION_MIN |
20 | quarantine+reject msgs in window |
Breaches are pushed to ntfy (note: prefix), de-duplicated per kind+subject within
24h. Evaluated on every import and by a daily timer hitting /cron/check-alarms.
Architecture
app.py— Flask app (routes, ingest, filters, stats, alarms).dmarc_parser.py— stdlib decompress + XML parse.schema.sql— SQLite schema (reports,recordswith a generatedaligned_pass,alarm_log).Containerfile— Debian 13 +python3-flask+gunicorn. DB at/data/dmarc.db(mounted).- Quadlet:
~/.config/containers/systemd/dmarc-analyzer.container. Secrets in.env(gitignored).
systemctl --user status dmarc-analyzer.service
podman logs dmarc-analyzer --tail 50
python3 tests/test_parser.py