WebMail: Difference between revisions

From EAWF Document Repository
Jump to navigation Jump to search
Admin (talk | contribs)
Created page with "* DMARC Processing admin ├── .DMARC │ ├── cur │ ├── new │ └── tmp ├── cur ├── new ├── tmp ... Roundcube Sieve: -rwxr-xr-x 1 root root 4401 Apr 18 22:57 /usr/local/sbin/dmarc_report_scan.py crontab -e 0 8,20 * * * /usr/local/sbin/dmarc_report_scan.py --maildir /var/vmail/eawf.com/admin/.DMARC --trusted-ip 50.28.104.213 --report-file /var/log/dmarc-scan-report.txt --delete-processed >/dev/null 2>&1"
 
Admin (talk | contribs)
 
(2 intermediate revisions by the same user not shown)
Line 1: Line 1:
* DMARC Processing
== DMARC Processing ==
* Purpose: Scan DMARC mails and process accordingly:
** If something needs to be done, sends a mail to admin with a notification and deletes the DMARC mail.
** If nothing critical, deletes the DMARC mail.


<pre>
admin
admin
├── .DMARC
├── .DMARC
Line 10: Line 14:
├── tmp
├── tmp
...
...
</pre>


Roundcube Sieve:
== Roundcube Sieve: ==
<pre>
Filter name: DMARC
Filter enabled
matching any of the following rules
Subject contains Report Domain: eawf.com
From contains DMARC
Actions
Move message to DMARC
Stop evaluating rules
</pre>


: Root crontab:
<pre>
-rwxr-xr-x 1 root root 4401 Apr 18 22:57 /usr/local/sbin/dmarc_report_scan.py
</pre>
:: Root Crontab Configuration
<pre>
0 8,20 * * * /usr/local/sbin/dmarc_report_scan.py\
--maildir /var/vmail/eawf.com/admin/.DMARC\
--trusted-ip 50.28.104.213\
--report-file /var/log/dmarc-scan-report.txt\
--delete-processed >/dev/null 2>&1
</pre>
== Python Code ==
<pre>
#!/usr/bin/env python3
import argparse
import os
import zipfile
import xml.etree.ElementTree as ET
from email import policy
from email.parser import BytesParser
import hashlib
def parse_args():
    p = argparse.ArgumentParser(description="Scan DMARC aggregate reports from a Maildir.")
    p.add_argument("--maildir", required=True)
    p.add_argument("--trusted-ip", action="append", default=[])
    p.add_argument("--state-dir", default="/var/lib/dmarc-report-scan")
    p.add_argument("--report-file")
    p.add_argument("--stdout", action="store_true")
    p.add_argument("--delete-processed", action="store_true")
    p.add_argument("--reset-seen", action="store_true")
    return p.parse_args()
def load_seen(state_dir):
    os.makedirs(state_dir, exist_ok=True)
    seen_file = os.path.join(state_dir, "seen.txt")
    if not os.path.exists(seen_file):
        return set()
    with open(seen_file) as f:
        return set(line.strip() for line in f)
def save_seen(state_dir, seen):
    seen_file = os.path.join(state_dir, "seen.txt")
    with open(seen_file, "w") as f:
        for s in seen:
            f.write(s + "\n")
def hash_msg(path):
    return hashlib.sha256(path.encode()).hexdigest()
def is_trusted(ip, trusted_list):
    return ip in trusted_list
def parse_xml(data):
    root = ET.fromstring(data)
    results = []
    for rec in root.findall(".//record"):
        ip = rec.findtext(".//source_ip")
        count = int(rec.findtext(".//count"))
        spf = rec.findtext(".//policy_evaluated/spf")
        dkim = rec.findtext(".//policy_evaluated/dkim")
        results.append((ip, count, spf, dkim))
    return results
def process_mail(path, trusted_ips):
    alerts = []
    noise = []
    with open(path, "rb") as f:
        msg = BytesParser(policy=policy.default).parse(f)
    found = False
    for part in msg.walk():
        if part.get_content_type() in ("application/zip", "application/gzip", "application/octet-stream"):
            payload = part.get_payload(decode=True)
            if not payload:
                continue
            try:
                with zipfile.ZipFile(io.BytesIO(payload)) as z:
                    for name in z.namelist():
                        xml_data = z.read(name)
                        for ip, count, spf, dkim in parse_xml(xml_data):
                            found = True
                            if is_trusted(ip, trusted_ips):
                                if spf != "pass" or dkim != "pass":
                                    alerts.append(f"ALERT: trusted sender failed {ip} ({count})")
                            else:
                                if spf == "pass" or dkim == "pass":
                                    alerts.append(f"ALERT: untrusted sender passed {ip} ({count})")
                                else:
                                    noise.append(f"Noise: untrusted sender failed {ip} ({count})")
            except:
                pass
    return found, alerts, noise
def main():
    args = parse_args()
    seen = set() if args.reset_seen else load_seen(args.state_dir)


    report_lines = []
    alerts_found = False


    for sub in ("cur", "new"):
        d = os.path.join(args.maildir, sub)
        if not os.path.isdir(d):
            continue


        for fname in os.listdir(d):
            fpath = os.path.join(d, fname)
            h = hash_msg(fpath)


-rwxr-xr-x 1 root root 4401 Apr 18 22:57 /usr/local/sbin/dmarc_report_scan.py
            if h in seen:
                continue
 
            found, alerts, noise = process_mail(fpath, args.trusted_ip)
 
            seen.add(h)
 
            if found and args.delete_processed:
                try:
                    os.remove(fpath)
                except:
                    pass
 
            for a in alerts:
                report_lines.append(a)
                alerts_found = True
 
    save_seen(args.state_dir, seen)
 
    if not report_lines:
        report_lines.append("No issues detected.")
 
    report = "\n".join(report_lines)
 
    if args.report_file:
        with open(args.report_file, "w") as f:
            f.write(report + "\n")
 
    if args.stdout:
        print(report)


    if alerts_found:
        os.system(f'''(
echo "From: dmarc-monitor@eawf.com"
echo "To: admin@eawf.com"
echo "Subject: DMARC ALERT: action needed"
echo "X-EAWF-Notice: DMARC-SCAN"
echo
cat {args.report_file}
) | /usr/sbin/sendmail -t''')


crontab -e
if __name__ == "__main__":
0 8,20 * * * /usr/local/sbin/dmarc_report_scan.py --maildir /var/vmail/eawf.com/admin/.DMARC --trusted-ip 50.28.104.213 --report-file /var/log/dmarc-scan-report.txt --delete-processed >/dev/null 2>&1
    import io
    main()
</pre>

Latest revision as of 16:51, 18 April 2026

DMARC Processing

  • Purpose: Scan DMARC mails and process accordingly:
    • If something needs to be done, sends a mail to admin with a notification and deletes the DMARC mail.
    • If nothing critical, deletes the DMARC mail.
admin
├── .DMARC
│   ├── cur
│   ├── new
│   └── tmp
├── cur
├── new
├── tmp
...

Roundcube Sieve:

Filter name: DMARC
Filter enabled
matching any of the following rules
Subject contains Report Domain: eawf.com
From contains DMARC
Actions
Move message to DMARC
Stop evaluating rules
Root crontab:
-rwxr-xr-x 1 root root 4401 Apr 18 22:57 /usr/local/sbin/dmarc_report_scan.py
Root Crontab Configuration
0 8,20 * * * /usr/local/sbin/dmarc_report_scan.py\
 --maildir /var/vmail/eawf.com/admin/.DMARC\
 --trusted-ip 50.28.104.213\
 --report-file /var/log/dmarc-scan-report.txt\
 --delete-processed >/dev/null 2>&1

Python Code

#!/usr/bin/env python3

import argparse
import os
import zipfile
import xml.etree.ElementTree as ET
from email import policy
from email.parser import BytesParser
import hashlib

def parse_args():
    p = argparse.ArgumentParser(description="Scan DMARC aggregate reports from a Maildir.")
    p.add_argument("--maildir", required=True)
    p.add_argument("--trusted-ip", action="append", default=[])
    p.add_argument("--state-dir", default="/var/lib/dmarc-report-scan")
    p.add_argument("--report-file")
    p.add_argument("--stdout", action="store_true")
    p.add_argument("--delete-processed", action="store_true")
    p.add_argument("--reset-seen", action="store_true")
    return p.parse_args()

def load_seen(state_dir):
    os.makedirs(state_dir, exist_ok=True)
    seen_file = os.path.join(state_dir, "seen.txt")
    if not os.path.exists(seen_file):
        return set()
    with open(seen_file) as f:
        return set(line.strip() for line in f)

def save_seen(state_dir, seen):
    seen_file = os.path.join(state_dir, "seen.txt")
    with open(seen_file, "w") as f:
        for s in seen:
            f.write(s + "\n")

def hash_msg(path):
    return hashlib.sha256(path.encode()).hexdigest()

def is_trusted(ip, trusted_list):
    return ip in trusted_list

def parse_xml(data):
    root = ET.fromstring(data)
    results = []
    for rec in root.findall(".//record"):
        ip = rec.findtext(".//source_ip")
        count = int(rec.findtext(".//count"))
        spf = rec.findtext(".//policy_evaluated/spf")
        dkim = rec.findtext(".//policy_evaluated/dkim")
        results.append((ip, count, spf, dkim))
    return results

def process_mail(path, trusted_ips):
    alerts = []
    noise = []
    with open(path, "rb") as f:
        msg = BytesParser(policy=policy.default).parse(f)

    found = False

    for part in msg.walk():
        if part.get_content_type() in ("application/zip", "application/gzip", "application/octet-stream"):
            payload = part.get_payload(decode=True)
            if not payload:
                continue
            try:
                with zipfile.ZipFile(io.BytesIO(payload)) as z:
                    for name in z.namelist():
                        xml_data = z.read(name)
                        for ip, count, spf, dkim in parse_xml(xml_data):
                            found = True
                            if is_trusted(ip, trusted_ips):
                                if spf != "pass" or dkim != "pass":
                                    alerts.append(f"ALERT: trusted sender failed {ip} ({count})")
                            else:
                                if spf == "pass" or dkim == "pass":
                                    alerts.append(f"ALERT: untrusted sender passed {ip} ({count})")
                                else:
                                    noise.append(f"Noise: untrusted sender failed {ip} ({count})")
            except:
                pass

    return found, alerts, noise

def main():
    args = parse_args()

    seen = set() if args.reset_seen else load_seen(args.state_dir)

    report_lines = []
    alerts_found = False

    for sub in ("cur", "new"):
        d = os.path.join(args.maildir, sub)
        if not os.path.isdir(d):
            continue

        for fname in os.listdir(d):
            fpath = os.path.join(d, fname)
            h = hash_msg(fpath)

            if h in seen:
                continue

            found, alerts, noise = process_mail(fpath, args.trusted_ip)

            seen.add(h)

            if found and args.delete_processed:
                try:
                    os.remove(fpath)
                except:
                    pass

            for a in alerts:
                report_lines.append(a)
                alerts_found = True

    save_seen(args.state_dir, seen)

    if not report_lines:
        report_lines.append("No issues detected.")

    report = "\n".join(report_lines)

    if args.report_file:
        with open(args.report_file, "w") as f:
            f.write(report + "\n")

    if args.stdout:
        print(report)

    if alerts_found:
        os.system(f'''(
echo "From: dmarc-monitor@eawf.com"
echo "To: admin@eawf.com"
echo "Subject: DMARC ALERT: action needed"
echo "X-EAWF-Notice: DMARC-SCAN"
echo
cat {args.report_file}
) | /usr/sbin/sendmail -t''')

if __name__ == "__main__":
    import io
    main()