Files
mailsystem/DOC/DMARC-Report/dmarc-scan.sh

302 lines
10 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"