From f36b563e09c3fe76dcb48871d46705881cfd473f Mon Sep 17 00:00:00 2001 From: robott Date: Tue, 15 Sep 2026 18:39:02 +0200 Subject: [PATCH] Update mail-status.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neni povinne cat > /etc/postfix/addr_hygiene.pcre <<'EOF' /[[:space:]<>;,()"]/ REJECT 5.1.3 Bad address syntax EOF postconf -e 'smtpd_sender_restrictions = check_sender_access pcre:/etc/postfix/addr_hygiene.pcre, $smtpd_sender_restrictions' Zmena v produkčnom main.cf. Pred ňou si pozri aktuálnu hodnotu cez postconf -n smtpd_sender_restrictions. Ak je prázdna, $smtpd_sender_restrictions z príkazu vynechaj, inak vznikne rekurzia a postfix check zlyhá. Pravidlo zámerne neblokuje =, + ani /, ktoré používajú SRS, BATV a VERP adresy. --- mail-status.sh | 107 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 89 insertions(+), 18 deletions(-) diff --git a/mail-status.sh b/mail-status.sh index 0e5f5c0..a3a38e1 100644 --- a/mail-status.sh +++ b/mail-status.sh @@ -1,11 +1,15 @@ #!/bin/bash # ============================================================================== -# mail-stats - Postfix Mail Summary Tool (v5.0.3) +# mail-stats - Postfix Mail Summary Tool (v5.0.4) # # Requires: bash >= 4.2, GNU coreutils/findutils, util-linux (flock), # gawk (recommended) or mawk 1.3.4 (verified by a self-test at start) # Optional: gzip/xz/bzip2/zstd (rotated logs), journalctl, less # +# 5.0.4: ambiguous log lines (spoofing via quoted addresses) are detected and +# flagged ("sent?", "REJECT?") instead of silently trusted; +# state table uses age-based eviction (unqueued > 1h, queued > +# maximal_queue_lifetime + 1d) instead of a full reset # 5.0.3: log injection fixes (attacker-controlled Subject/addresses/remote # replies could forge "sent"/"reject" records or hide deliveries): # - parser anchors on the program/queue-id field positions @@ -55,7 +59,7 @@ if (( EUID == 0 )); then unset -v TMPDIR XDG_RUNTIME_DIR fi -readonly VERSION="5.0.3" +readonly VERSION="5.0.4" die() { local m="$*"; printf 'Error: %s\n' "${m//[[:cntrl:]]/?}" >&2; exit 1; } warn() { local m="$*"; printf 'Warning: %s\n' "${m//[[:cntrl:]]/?}" >&2; } @@ -106,6 +110,11 @@ ENVIRONMENT (resource limits for the parser): MAIL_STATS_MEM_KB (default 1048576) ulimit -v MAIL_STATS_CPU_SEC (default 300) ulimit -t (not in watch mode) MAIL_STATS_FSIZE_KB (default 1048576) ulimit -f (not in watch mode) + MAIL_STATS_MAX_STATE (default 200000) max tracked queue IDs + +A status ending with "?" (e.g. "sent?") marks a log line that contains the +Postfix field structure more than once (possible injection via a quoted +address or a remote reply). Verify such records directly in the log. EOF exit 0 } @@ -198,11 +207,25 @@ fi # Resource limits - validated as strings BEFORE any arithmetic use: # bash (( )) evaluates variable contents, e.g. 'x[$(cmd)]' would execute cmd -: "${MAIL_STATS_MEM_KB:=1048576}" "${MAIL_STATS_CPU_SEC:=300}" "${MAIL_STATS_FSIZE_KB:=1048576}" -for lim in MAIL_STATS_MEM_KB MAIL_STATS_CPU_SEC MAIL_STATS_FSIZE_KB; do +: "${MAIL_STATS_MEM_KB:=1048576}" "${MAIL_STATS_CPU_SEC:=300}" "${MAIL_STATS_FSIZE_KB:=1048576}" "${MAIL_STATS_MAX_STATE:=200000}" +for lim in MAIL_STATS_MEM_KB MAIL_STATS_CPU_SEC MAIL_STATS_FSIZE_KB MAIL_STATS_MAX_STATE; do [[ ${!lim} =~ ^[1-9][0-9]{0,11}$ ]] || die "$lim must be a positive integer." done -readonly MAIL_STATS_MEM_KB MAIL_STATS_CPU_SEC MAIL_STATS_FSIZE_KB +readonly MAIL_STATS_MEM_KB MAIL_STATS_CPU_SEC MAIL_STATS_FSIZE_KB MAIL_STATS_MAX_STATE + +# Queue lifetime for state eviction: maximal_queue_lifetime (default unit: days) + 1 day +QLIFE_SEC=518400 +if command -v postconf > /dev/null 2>&1; then + qlife_raw=$(postconf -h maximal_queue_lifetime 2> /dev/null || true) + if [[ $qlife_raw =~ ^([0-9]{1,6})([smhdw]?)$ ]]; then + case ${BASH_REMATCH[2]} in + s) qlife_mul=1 ;; m) qlife_mul=60 ;; h) qlife_mul=3600 ;; w) qlife_mul=604800 ;; *) qlife_mul=86400 ;; + esac + QLIFE_SEC=$(( 10#${BASH_REMATCH[1]} * qlife_mul + 86400 )) + fi + unset qlife_raw qlife_mul +fi +readonly QLIFE_SEC # Run a command with resource limits (parser/sort only, not journalctl/gzip) run_limited() { @@ -407,8 +430,13 @@ BEGIN { # (no {n,m} intervals: broken/unsupported in some mawk builds) hx = "[0-9A-F]"; lg = "[0-9B-DF-HJ-NP-TV-Zb-df-hj-np-tv-z]" QID_RE = "^(" hx hx hx hx hx hx hx "*|" lg lg lg lg lg lg lg lg lg lg lg "*):$" - MAX_STATE = 200000 - nstate = 0; npend = 0; warned = 0 + MAX_STATE = ENVIRON["MS_MAX_STATE"] + 0; if (MAX_STATE < 100) MAX_STATE = 200000 + QLIFE = ENVIRON["MS_QLIFE"] + 0; if (QLIFE < 3600) QLIFE = 518400 + UNQ_AGE = 3600 # queue ids never seen by qmgr (aborted sessions) + nstate = 0; npend = 0; warned = 0; warned_ev = 0; amb_count = 0 + # fixed Postfix structures; more than one occurrence = ambiguous line + DLV_RE = ">, (orig_to=<[^>]*>, )?relay=[^ ,]+, (conn_use=[0-9]+, )?delay=[0-9.]+, (delays=[0-9./]+, )?dsn=[0-9.]+, status=[a-z]+" + REJ_RE = "; from=<[^>]*> to=<[^>]*> proto=" } function chk(val, filt, lfilt, r) { @@ -464,15 +492,40 @@ function out(rec, f) { if (w_mode) fflush() } +function count_re(str, re, n) { + n = 0 + while (match(str, re)) { n++; str = substr(str, RSTART + (RLENGTH > 0 ? RLENGTH : 1)) } + return n +} + function track(q) { - if (q in seen) return - if (nstate >= MAX_STATE) reset_state() - seen[q] = 1; nstate++ + if (!(q in seen)) { + if (nstate >= MAX_STATE) sweep() + seen[q] = 1; nstate++ + } + last[q] = l_sec +} + +# Age-based eviction: aborted sessions first, then messages older than the +# queue lifetime; thresholds are halved only if that is not enough +function sweep( q, ks, k, i, ua, qa, target, forced) { + ua = UNQ_AGE; qa = QLIFE; target = int(MAX_STATE * 0.75); forced = 0 + while (1) { + k = 0; split("", ks) + for (q in seen) if ((l_sec - last[q]) > ((q in queued) ? qa : ua)) ks[++k] = q + for (i = 1; i <= k; i++) forget(ks[i]) + if (nstate <= target) break + if (ua <= 60 && qa <= 60) { reset_state(); return } + if (ua > 60) ua = int(ua / 2) + else { qa = int(qa / 2); forced = 1 } + } + if (forced && !warned_ev++) print "Warning: state table full, evicted queued messages older than " qa "s; some records may lack sender/IP/size (raise MAIL_STATS_MAX_STATE)." > "/dev/stderr" } function forget(q) { if (q in seen) { delete seen[q]; nstate-- } delete c_ip[q]; delete c_time[q]; delete snd[q]; delete subj[q]; delete sz[q]; delete orig[q] + delete last[q]; delete queued[q] } function flush_pend(q, patch, i, f, n, rec) { @@ -501,9 +554,9 @@ function flush_all_pend( q, ks, k, i) { function reset_state() { flush_all_pend() split("", seen); split("", c_ip); split("", c_time); split("", snd) - split("", subj); split("", sz); split("", orig) + split("", subj); split("", sz); split("", orig); split("", last); split("", queued) nstate = 0 - if (!warned++) print "Warning: correlation state limit (" MAX_STATE " queue IDs) reached and reset; some records may lack sender/IP/size." > "/dev/stderr" + if (!warned++) print "Warning: state table full of recent entries (" MAX_STATE " queue IDs), full reset; some records may lack sender/IP/size (raise MAIL_STATS_MAX_STATE)." > "/dev/stderr" } # Content filter re-injection: carry origin data from src to dst queue id @@ -534,7 +587,7 @@ function emit(q, to, relay, st, dsn, reason, rec) { out(rec) } -function handle_reject(q, rest, p, r, f, t, ip, ss, re, dsn, st, rec) { +function handle_reject(q, rest, p, r, f, t, ip, ss, re, dsn, st, rec, amb) { # rest: "reject: RCPT from host[ip]: 554 5.7.1 ...; from= to= proto=... helo=<...>" r = rest; sub(/^(milter-)?reject: /, "", r) ip = (q != "NOQUEUE" && (q in c_ip)) ? c_ip[q] : "unknown" @@ -544,7 +597,8 @@ function handle_reject(q, rest, p, r, f, t, ip, ss, re, dsn, st, rec) { } f = "-"; t = "-" # Postfix appends "; from=<..> to=<..> proto=" after the reply text - if (match(r, /; from=<[^>]*> to=<[^>]*> proto=/)) { + amb = (count_re(r, REJ_RE) > 1) + if (!amb && match(r, REJ_RE)) { ss = substr(r, RSTART, RLENGTH); r = substr(r, 1, RSTART - 1) match(ss, /from=<[^>]*>/); f = substr(ss, RSTART + 6, RLENGTH - 7); f = (f == "") ? "<>" : clean(f, 0) match(ss, /to=<[^>]*>/); t = clean(substr(ss, RSTART + 4, RLENGTH - 5), 0) @@ -552,6 +606,10 @@ function handle_reject(q, rest, p, r, f, t, ip, ss, re, dsn, st, rec) { re = clean(r, 512) dsn = "-"; if (match(re, /^[245][0-9][0-9] [245]\.[0-9]+\.[0-9]+/)) { dsn = substr(re, RSTART + 4, RLENGTH - 4) } st = (re ~ /[Gg]reylist/) ? "GREYLIST" : "REJECT" + if (amb) { + amb_count++; st = st "?"; f = "?"; t = "?" + re = clean("AMBIGUOUS LOG LINE (possible injection), verify in log: " r, 600) + } rec = l_key OFS l_ts OFS ((q in c_time) ? c_time[q] : l_ts) OFS q OFS ((q in orig) ? orig[q] : "-") rec = rec OFS ip OFS "-" OFS f OFS t OFS st OFS ((q in sz) ? sz[q] : 0) rec = rec OFS dsn OFS ((q in subj) ? subj[q] : "-") OFS re @@ -563,6 +621,8 @@ function handle_reject(q, rest, p, r, f, t, ip, ss, re, dsn, st, rec) { tt = l_time; gsub(/:/, "", tt) l_key = l_y l_mm l_dd tt l_ts = l_y "-" l_mm "-" l_dd " " l_time + # monotonic pseudo-epoch (month = 31 days), good enough for age thresholds + l_sec = ((l_y * 12 + l_mm) * 31 + l_dd) * 86400 + substr(tt, 1, 2) * 3600 + substr(tt, 3, 2) * 60 + substr(tt, 5, 2) # Everything after "[pid]: : " is untrusted text only where # Postfix embeds foreign data (addresses, Subject, remote replies). All @@ -593,7 +653,7 @@ function handle_reject(q, rest, p, r, f, t, ip, ss, re, dsn, st, rec) { if (rest ~ /^from=, size=N, nrcpt=N (queue active) - anchored at the end if (match(rest, />, size=[0-9]+, nrcpt=[0-9]+ \(queue active\)$/)) { - s = substr(rest, 7, RSTART - 7); snd[qid] = (s == "") ? "<>" : clean(s, 0) + s = substr(rest, 7, RSTART - 7); snd[qid] = (s == "") ? "<>" : clean(s, 0); queued[qid] = 1 tail = substr(rest, RSTART); match(tail, /size=[0-9]+/) sz[qid] = substr(tail, RSTART + 5, RLENGTH - 5) + 0 } @@ -613,7 +673,8 @@ function handle_reject(q, rest, p, r, f, t, ip, ss, re, dsn, st, rec) { if (rest ~ /^to=, (orig_to=<[^>]*>, )?relay=[^ ,]+, (conn_use=[0-9]+, )?delay=[0-9.]+, (delays=[0-9.\/]+, )?dsn=[0-9.]+, status=[a-z]+/)) next + amb = (count_re(rest, DLV_RE) > 1) + if (!match(rest, DLV_RE)) next to = clean(substr(rest, 5, RSTART - 5), 0) mid = substr(rest, RSTART, RLENGTH) tail = substr(rest, RSTART + RLENGTH) @@ -623,6 +684,13 @@ function handle_reject(q, rest, p, r, f, t, ip, ss, re, dsn, st, rec) { reason = "-" if (tail ~ /^ \(.*\)$/) reason = clean(substr(tail, 3, length(tail) - 3), 512) + if (amb) { + # never hide or link on an ambiguous line - show it flagged with the raw text + amb_count++; st = st "?"; sub(/:[0-9]+$/, "", relay) + reason = clean("AMBIGUOUS LOG LINE (possible injection), verify in log: " rest, 1024) + emit(qid, to, clean(relay, 255), st, dsn, reason) + next + } # content filter hop (amavis & co.) - link to re-injected queue id, do not report if (relay ~ /^[^\[]*\[(127\.0\.0\.1|(ipv6:)?::1)\]:10[0-9][0-9][0-9]$/) { if (st == "sent" && match(tail, /queued as [0-9A-Za-z]+\)$/)) link(qid, substr(tail, RSTART + 10, RLENGTH - 11)) @@ -633,7 +701,10 @@ function handle_reject(q, rest, p, r, f, t, ip, ss, re, dsn, st, rec) { } } -END { flush_all_pend() } +END { + flush_all_pend() + if (amb_count) print "Warning: " amb_count " ambiguous log line(s) flagged with \"?\" status (possible log injection)." > "/dev/stderr" +} ' # ============================================================================== @@ -702,7 +773,7 @@ check_pipe() { export MS_DAY_YMD="$DAY_YMD" MS_TIME="$TIME_FILTER" MS_FROM="$FROM_FILTER" MS_TO="$TO_FILTER" \ MS_ID="$ID_FILTER" MS_IP="$IP_FILTER" MS_STATUS="$STATUS_FILTER" MS_MIN_SIZE="$MIN_SIZE" \ MS_REGEX="$REGEX_MODE" MS_WATCH="$WATCH_MODE" MS_GAWK="$IS_GAWK" MS_CUR_Y="$CUR_Y" \ - MS_CUR_M="$((10#$CUR_M))" MS_COLOR="$USE_COLOR" MS_CSV="$CSV_MODE" MS_DETAIL="$DETAIL_VIEW" + MS_CUR_M="$((10#$CUR_M))" MS_MAX_STATE="$MAIL_STATS_MAX_STATE" MS_QLIFE="$QLIFE_SEC" MS_COLOR="$USE_COLOR" MS_CSV="$CSV_MODE" MS_DETAIL="$DETAIL_VIEW" # ============================================================================== # 8. WATCH MODE