Files
mailsystem/DOC/DMARC-Report/dmarc-scan-setup.md

12 KiB
Raw Permalink Blame History

DMARC-Auswertungsskript dmarc-scan.sh Installation, Beschreibung & Verwendung

📖 Zweck

dmarc-scan.sh analysiert DMARC-Aggregatberichte (XML, .zip, .gz) und erzeugt:

  • gut lesbare Tabellen im Terminal,
  • eine fortschreibbare Records-CSV (--append),
  • Top-Listen nach IPs (gesamt und je Fail-Kategorie) als CSV.

⚙️ Installation

sudo apt install -y xmlstarlet unzip gzip
sudo tee /usr/local/bin/dmarc-scan.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
#
# dmarc-scan.sh — DMARC-XML-Reports (auch .gz/.zip) einlesen, tabellarisch anzeigen,
#                  Records als CSV exportieren (append optional), Top-IPs ermitteln
#                  und Top-Listen als CSV schreiben.
#
# Nutzung:
#   ./dmarc-scan.sh /pfad/zu/reports \
#       [--domain DOMAIN] \
#       [--csv pfad/zur/records.csv] \
#       [--append] \
#       [--top N] \
#       [--outdir pfad/zum/ordner]
#
# Beispiele:
#   ./dmarc-scan.sh /var/mail/dmarc
#   ./dmarc-scan.sh /var/mail/dmarc --domain fluechtlingsrat-brandenburg.de --csv dmarc.csv --append --top 15 --outdir ./export
#
# Voraussetzungen: xmlstarlet, unzip (für .zip), gzip (für .gz)
#   Debian/Ubuntu: sudo apt-get install xmlstarlet unzip gzip
#
set -euo pipefail

REPORT_DIR="${1:-}"
shift || true

# Defaults
WANT_DOMAIN=""
CSV_PATH="./dmarc-summary.csv"
APPEND=0
TOP_N=10
OUTDIR="."

# Arg-Parsing (einfach)
while [[ $# -gt 0 ]]; do
  case "${1:-}" in
    --domain)
      WANT_DOMAIN="${2:-}"; shift 2 || true ;;
    --csv)
      CSV_PATH="${2:-}"; shift 2 || true ;;
    --append)
      APPEND=1; shift || true ;;
    --top)
      TOP_N="${2:-10}"; shift 2 || true ;;
    --outdir)
      OUTDIR="${2:-.}"; shift 2 || true ;;
    *)
      # Unbekannte Option ignorieren
      shift || true ;;
  esac
done

if [[ -z "$REPORT_DIR" || ! -d "$REPORT_DIR" ]]; then
  echo "Fehler: Bitte ein Verzeichnis mit DMARC-Reports angeben."
  echo "Beispiel: $0 /var/mail/dmarc --domain fluechtlingsrat-brandenburg.de --csv dmarc.csv --append --top 15 --outdir ./export"
  exit 1
fi

if ! command -v xmlstarlet >/dev/null 2>&1; then
  echo "Fehler: xmlstarlet nicht gefunden. Bitte installieren (z.B. 'sudo apt-get install xmlstarlet')."
  exit 1
fi

mkdir -p "$OUTDIR"

# CSV-Header für Records; bei --append nur schreiben, wenn Datei noch nicht existiert
RECORDS_HEADER="report_org,policy_domain,begin_utc,end_utc,source_ip,count,disposition,spf,dkim,header_from"
ensure_records_header() {
  if [[ "$APPEND" -eq 1 ]]; then
    if [[ ! -f "$CSV_PATH" ]]; then
      echo "$RECORDS_HEADER" > "$CSV_PATH"
    fi
  else
    echo "$RECORDS_HEADER" > "$CSV_PATH"
  fi
}
ensure_records_header

# Zähler & Sets
declare -A SEEN_IPS=()
declare -A IP_COUNTS=()         # Summe pro IP
declare -A SPF_ONLY_FAIL_IP=()  # nur SPF fail pro IP
declare -A DKIM_ONLY_FAIL_IP=() # nur DKIM fail pro IP
declare -A BOTH_FAIL_IP=()      # SPF+DKIM fail pro IP

total_msgs=0
pass_msgs=0
fail_msgs=0
spf_only_fail=0
dkim_only_fail=0
both_fail=0

# Hilfsfunktion: Epoch -> Datum (UTC)
epoch2date() {
  local e="$1"
  if [[ -z "$e" ]]; then printf "-"; return; fi
  date -u -d @"$e" +"%Y-%m-%d %H:%M:%S UTC" 2>/dev/null || printf "%s" "$e"
}

# CSV-escape (Felder in Anführungszeichen, doppelte Anführungszeichen verdoppeln)
csv_escape() {
  local s="${1:-}"
  s="${s//\"/\"\"}"
  printf "\"%s\"" "$s"
}

# Eine einzelne XML-Datei parsen
parse_xml() {
  local xml_input="$1"

  # Domain aus policy_published, ggf. für Filter
  local domain
  domain=$(xmlstarlet sel -T -t -v "/feedback/policy_published/domain" "$xml_input" 2>/dev/null || true)
  if [[ -n "$WANT_DOMAIN" && "$domain" != "$WANT_DOMAIN" ]]; then
    return 0
  fi

  local org begin end pol sp aspf adkim
  org=$(xmlstarlet sel -T -t -v "/feedback/report_metadata/org_name" "$xml_input" 2>/dev/null || printf "-")
  begin=$(xmlstarlet sel -T -t -v "/feedback/report_metadata/date_range/begin" "$xml_input" 2>/dev/null || printf "")
  end=$(xmlstarlet sel -T -t -v "/feedback/report_metadata/date_range/end" "$xml_input" 2>/dev/null || printf "")
  pol=$(xmlstarlet sel -T -t -v "/feedback/policy_published/p" "$xml_input" 2>/dev/null || printf "-")
  sp=$(xmlstarlet sel -T -t -v "/feedback/policy_published/sp" "$xml_input" 2>/dev/null || printf "-")
  aspf=$(xmlstarlet sel -T -t -v "/feedback/policy_published/aspf" "$xml_input" 2>/dev/null || printf "-")
  adkim=$(xmlstarlet sel -T -t -v "/feedback/policy_published/adkim" "$xml_input" 2>/dev/null || printf "-")

  # Report-Header
  echo "=============================================================================="
  echo "Report von:       ${org}"
  echo "Domain (Policy):  ${domain}  (p=${pol}, sp=${sp}, aspf=${aspf}, adkim=${adkim})"
  echo "Zeitraum:         $(epoch2date "$begin")    $(epoch2date "$end")"
  echo "------------------------------------------------------------------------------"
  printf "%-16s  %7s  %-10s  %-6s  %-6s  %s\n" "Source IP" "Count" "Disposition" "SPF" "DKIM" "Header-From"
  echo "------------------------------------------------------------------------------"

  # Alle <record>-Einträge tabellarisch ausgeben
  while IFS='|' read -r ip cnt dispo spfres dkimres hfrom; do
    [[ -z "$ip$cnt$dispo$spfres$dkimres$hfrom" ]] && continue

    local n=0
    if [[ -n "${cnt:-}" && "$cnt" =~ ^[0-9]+$ ]]; then n="$cnt"; fi

    total_msgs=$(( total_msgs + n ))
    [[ -n "$ip" ]] && SEEN_IPS["$ip"]=1
    [[ -n "$ip" ]] && IP_COUNTS["$ip"]=$(( ${IP_COUNTS["$ip"]:-0} + n ))

    if [[ "${spfres:-}" == "pass" && "${dkimres:-}" == "pass" ]]; then
      pass_msgs=$(( pass_msgs + n ))
    else
      fail_msgs=$(( fail_msgs + n ))
      if [[ "${spfres:-}" != "pass" && "${dkimres:-}" == "pass" ]]; then
        spf_only_fail=$(( spf_only_fail + n ))
        [[ -n "$ip" ]] && SPF_ONLY_FAIL_IP["$ip"]=$(( ${SPF_ONLY_FAIL_IP["$ip"]:-0} + n ))
      elif [[ "${spfres:-}" == "pass" && "${dkimres:-}" != "pass" ]]; then
        dkim_only_fail=$(( dkim_only_fail + n ))
        [[ -n "$ip" ]] && DKIM_ONLY_FAIL_IP["$ip"]=$(( ${DKIM_ONLY_FAIL_IP["$ip"]:-0} + n ))
      else
        both_fail=$(( both_fail + n ))
        [[ -n "$ip" ]] && BOTH_FAIL_IP["$ip"]=$(( ${BOTH_FAIL_IP["$ip"]:-0} + n ))
      fi
    fi

    printf "%-16s  %7s  %-10s  %-6s  %-6s  %s\n" "${ip:--}" "${n}" "${dispo:--}" "${spfres:--}" "${dkimres:--}" "${hfrom:--}"

    local begin_human end_human
    begin_human="$(epoch2date "$begin")"
    end_human="$(epoch2date "$end")"
    {
      csv_escape "$org"; printf ","
      csv_escape "$domain"; printf ","
      csv_escape "$begin_human"; printf ","
      csv_escape "$end_human"; printf ","
      csv_escape "${ip:-}"; printf ","
      printf "%s," "$n"
      csv_escape "${dispo:-}"; printf ","
      csv_escape "${spfres:-}"; printf ","
      csv_escape "${dkimres:-}"; printf ","
      csv_escape "${hfrom:-}"; printf "\n"
    } >> "$CSV_PATH"

  done < <(xmlstarlet sel -T -t \
      -m "/feedback/record" \
      -v "row/source_ip" -o "|" \
      -v "row/count" -o "|" \
      -v "row/policy_evaluated/disposition" -o "|" \
      -v "row/policy_evaluated/spf" -o "|" \
      -v "row/policy_evaluated/dkim" -o "|" \
      -v "identifiers/header_from" -n \
      "$xml_input" 2>/dev/null)

  echo
}

# Alle Dateien im Verzeichnis verarbeiten
shopt -s nullglob
for f in "$REPORT_DIR"/*; do
  case "$f" in
    *.xml)
      parse_xml "$f"
      ;;
    *.gz)
      if command -v gzip >/dev/null 2>&1; then
        tmp="$(mktemp)"
        if gzip -cd "$f" > "$tmp"; then
          parse_xml "$tmp"
        else
          echo "Warnung: Konnte $f nicht entpacken."
        fi
        rm -f "$tmp"
      else
        echo "Warnung: gzip nicht verfügbar, überspringe $f"
      fi
      ;;
    *.zip)
      if command -v unzip >/dev/null 2>&1; then
        tmpdir="$(mktemp -d)"
        if unzip -qq -j "$f" '*.xml' -d "$tmpdir" >/dev/null 2>&1; then
          for x in "$tmpdir"/*.xml; do
            [[ -e "$x" ]] || continue
            parse_xml "$x"
          done
        else
          echo "Warnung: Konnte $f nicht entpacken (oder keine XML darin)."
        fi
        rm -rf "$tmpdir"
      else
        echo "Warnung: unzip nicht verfügbar, überspringe $f"
      fi
      ;;
    *) : ;;
  esac
done

# Zusammenfassung
unique_ips=${#SEEN_IPS[@]}
echo "==============================================================================" 
echo "GESAMT-ZUSAMMENFASSUNG"
echo "Nachrichten gesamt:         $total_msgs"
echo "Eindeutige Source-IPs:      $unique_ips"
echo "Alle Auth PASS:             $pass_msgs"
echo "SPF/DKIM Fehler gesamt:     $fail_msgs"
echo "  ├─ nur SPF-Fail:          $spf_only_fail"
echo "  ├─ nur DKIM-Fail:         $dkim_only_fail"
echo "  └─ SPF+DKIM-Fail:         $both_fail"
[[ -n "$WANT_DOMAIN" ]] && echo "Gefilterte Domain:          $WANT_DOMAIN"
echo "Records-CSV:                $CSV_PATH"
echo "Hinweis: 'Fail' umfasst Records, bei denen SPF oder DKIM nicht 'pass' war."

# Top-Listen auf STDOUT
echo
echo "TOP $TOP_N IPs nach Anzahl (über alle Reports):"
{
  for ip in "${!IP_COUNTS[@]}"; do
    printf "%10d %s\n" "${IP_COUNTS[$ip]}" "$ip"
  done
} | sort -rn | head -n "$TOP_N" | awk '{printf "  %2d) %-16s  %7s\n", NR, $2, $1}'

if (( fail_msgs > 0 )); then
  echo
  echo "Top-IPs nur SPF-Fail:"
  { for ip in "${!SPF_ONLY_FAIL_IP[@]}"; do printf "%10d %s\n" "${SPF_ONLY_FAIL_IP[$ip]}" "$ip"; done; } \
    | sort -rn | head -n "$TOP_N" | awk '{printf "  %2d) %-16s  %7s\n", NR, $2, $1}'
  echo
  echo "Top-IPs nur DKIM-Fail:"
  { for ip in "${!DKIM_ONLY_FAIL_IP[@]}"; do printf "%10d %s\n" "${DKIM_ONLY_FAIL_IP[$ip]}" "$ip"; done; } \
    | sort -rn | head -n "$TOP_N" | awk '{printf "  %2d) %-16s  %7s\n", NR, $2, $1}'
  echo
  echo "Top-IPs SPF+DKIM-Fail:"
  { for ip in "${!BOTH_FAIL_IP[@]}"; do printf "%10d %s\n" "${BOTH_FAIL_IP[$ip]}" "$ip"; done; } \
    | sort -rn | head -n "$TOP_N" | awk '{printf "  %2d) %-16s  %7s\n", NR, $2, $1}'
fi

# ---- CSV-Exporte der Top-Listen ---------------------------------------------

write_top_csv () {
  local outfile="$1"; shift
  local -n assoc="$1"   # name reference auf assoziatives Array
  echo "ip,count" > "$outfile"
  if [[ "${#assoc[@]}" -eq 0 ]]; then
    : # leer
  else
    for ip in "${!assoc[@]}"; do
      printf "%s,%s\n" "$ip" "${assoc[$ip]}"
    done | sort -t, -k2,2nr > "$outfile"
  fi
}

# Gesamt-Top-IPs
write_top_csv "$OUTDIR/top_ips.csv" IP_COUNTS

# Top-Listen nach Fail-Kategorien
write_top_csv "$OUTDIR/top_spf_fail_ips.csv" SPF_ONLY_FAIL_IP
write_top_csv "$OUTDIR/top_dkim_fail_ips.csv" DKIM_ONLY_FAIL_IP
write_top_csv "$OUTDIR/top_both_fail_ips.csv" BOTH_FAIL_IP

echo
echo "Exportierte Top-CSV-Dateien:"
echo "  $OUTDIR/top_ips.csv"
echo "  $OUTDIR/top_spf_fail_ips.csv"
echo "  $OUTDIR/top_dkim_fail_ips.csv"
echo "  $OUTDIR/top_both_fail_ips.csv"

EOF
sudo chmod 750 /usr/local/bin/dmarc-scan.sh

🧩 Verwendung

Grundbeispiel:

dmarc-scan.sh /var/lib/dmarc/reports/2025/11/12

Mit Optionen:

dmarc-scan.sh /var/lib/dmarc/reports/2025/11/12   --domain fluechtlingsrat-brandenburg.de   --csv /var/lib/dmarc/exports/records.csv   --append   --top 25   --outdir /var/lib/dmarc/exports/

Parameterübersicht

Parameter Bedeutung
--domain <domain> Filtert Berichte auf bestimmte Domain
--csv <pfad> Pfad zur Ausgabedatei (Records-CSV)
--append Bestehende CSV fortschreiben statt überschreiben
--top <N> Anzahl der angezeigten Top-IPs
--outdir <pfad> Zielverzeichnis für Top-Listen (CSV)

📊 Ausgabedateien

Datei Inhalt
records.csv Alle Einzel-Records
top_ips.csv IPs mit den meisten Mails
top_spf_fail_ips.csv IPs mit nur SPF-Fails
top_dkim_fail_ips.csv IPs mit nur DKIM-Fails
top_both_fail_ips.csv IPs mit SPF+DKIM-Fails

🔁 Integration in den Serverbetrieb

Das Skript wird typischerweise durch den täglichen Job /usr/local/lib/dmarc/daily-run.sh aufgerufen.
Manuelle Nutzung ist jederzeit möglich.


🧩 Kontext Verzeichnisstruktur (empfohlen)

/var/lib/dmarc/
├── reports/      # Eingehende Rohdaten (XML, ZIP, GZ)  nach Datum
├── exports/      # CSV- und Top-Dateien
└── logs/         # Logausgaben der täglichen Auswertung

Autor: oopen.de / Systemkonfiguration
Stand: November 2025
Version: 1.2