Update mail-status.sh

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.
This commit is contained in:
robott committed 2026-09-15 18:39:02 +02:00
1 parent dde33eec1a
commit f36b563e09
1 file changed
+88 -17
+88 -17
View File
@@ -1,11 +1,15 @@
#!/bin/bash #!/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), # Requires: bash >= 4.2, GNU coreutils/findutils, util-linux (flock),
# gawk (recommended) or mawk 1.3.4 (verified by a self-test at start) # gawk (recommended) or mawk 1.3.4 (verified by a self-test at start)
# Optional: gzip/xz/bzip2/zstd (rotated logs), journalctl, less # 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 # 5.0.3: log injection fixes (attacker-controlled Subject/addresses/remote
# replies could forge "sent"/"reject" records or hide deliveries): # replies could forge "sent"/"reject" records or hide deliveries):
# - parser anchors on the program/queue-id field positions # - parser anchors on the program/queue-id field positions
@@ -55,7 +59,7 @@ if (( EUID == 0 )); then
unset -v TMPDIR XDG_RUNTIME_DIR unset -v TMPDIR XDG_RUNTIME_DIR
fi fi
readonly VERSION="5.0.3" readonly VERSION="5.0.4"
die() { local m="$*"; printf 'Error: %s\n' "${m//[[:cntrl:]]/?}" >&2; exit 1; } die() { local m="$*"; printf 'Error: %s\n' "${m//[[:cntrl:]]/?}" >&2; exit 1; }
warn() { local m="$*"; printf 'Warning: %s\n' "${m//[[:cntrl:]]/?}" >&2; } 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_MEM_KB (default 1048576) ulimit -v
MAIL_STATS_CPU_SEC (default 300) ulimit -t (not in watch mode) 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_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 EOF
exit 0 exit 0
} }
@@ -198,11 +207,25 @@ fi
# Resource limits - validated as strings BEFORE any arithmetic use: # Resource limits - validated as strings BEFORE any arithmetic use:
# bash (( )) evaluates variable contents, e.g. 'x[$(cmd)]' would execute cmd # 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}" : "${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; do 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." [[ ${!lim} =~ ^[1-9][0-9]{0,11}$ ]] || die "$lim must be a positive integer."
done 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 a command with resource limits (parser/sort only, not journalctl/gzip)
run_limited() { run_limited() {
@@ -407,8 +430,13 @@ BEGIN {
# (no {n,m} intervals: broken/unsupported in some mawk builds) # (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]" 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 "*):$" QID_RE = "^(" hx hx hx hx hx hx hx "*|" lg lg lg lg lg lg lg lg lg lg lg "*):$"
MAX_STATE = 200000 MAX_STATE = ENVIRON["MS_MAX_STATE"] + 0; if (MAX_STATE < 100) MAX_STATE = 200000
nstate = 0; npend = 0; warned = 0 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) { function chk(val, filt, lfilt, r) {
@@ -464,15 +492,40 @@ function out(rec, f) {
if (w_mode) fflush() 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) { function track(q) {
if (q in seen) return if (!(q in seen)) {
if (nstate >= MAX_STATE) reset_state() if (nstate >= MAX_STATE) sweep()
seen[q] = 1; nstate++ 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) { function forget(q) {
if (q in seen) { delete seen[q]; nstate-- } 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 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) { function flush_pend(q, patch, i, f, n, rec) {
@@ -501,9 +554,9 @@ function flush_all_pend( q, ks, k, i) {
function reset_state() { function reset_state() {
flush_all_pend() flush_all_pend()
split("", seen); split("", c_ip); split("", c_time); split("", snd) 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 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 # 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) 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=<s> to=<r> proto=... helo=<...>" # rest: "reject: RCPT from host[ip]: 554 5.7.1 ...; from=<s> to=<r> proto=... helo=<...>"
r = rest; sub(/^(milter-)?reject: /, "", r) r = rest; sub(/^(milter-)?reject: /, "", r)
ip = (q != "NOQUEUE" && (q in c_ip)) ? c_ip[q] : "unknown" 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 = "-" f = "-"; t = "-"
# Postfix appends "; from=<..> to=<..> proto=" after the reply text # 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) 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, /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) 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) 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) } 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" 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 = 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 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 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) tt = l_time; gsub(/:/, "", tt)
l_key = l_y l_mm l_dd tt l_key = l_y l_mm l_dd tt
l_ts = l_y "-" l_mm "-" l_dd " " l_time 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 "<program>[pid]: <QUEUE_ID>: " is untrusted text only where # Everything after "<program>[pid]: <QUEUE_ID>: " is untrusted text only where
# Postfix embeds foreign data (addresses, Subject, remote replies). All # 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=</) { if (rest ~ /^from=</) {
# qmgr: from=<sender>, size=N, nrcpt=N (queue active) - anchored at the end # qmgr: from=<sender>, size=N, nrcpt=N (queue active) - anchored at the end
if (match(rest, />, size=[0-9]+, nrcpt=[0-9]+ \(queue active\)$/)) { 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]+/) tail = substr(rest, RSTART); match(tail, /size=[0-9]+/)
sz[qid] = substr(tail, RSTART + 5, RLENGTH - 5) + 0 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=</) { if (rest ~ /^to=</) {
# fixed Postfix structure after the (untrusted) recipient address; the # fixed Postfix structure after the (untrusted) recipient address; the
# first match wins so that text in the remote reply cannot spoof it # first match wins so that text in the remote reply cannot spoof it
if (!match(rest, />, (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) to = clean(substr(rest, 5, RSTART - 5), 0)
mid = substr(rest, RSTART, RLENGTH) mid = substr(rest, RSTART, RLENGTH)
tail = 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 = "-" reason = "-"
if (tail ~ /^ \(.*\)$/) reason = clean(substr(tail, 3, length(tail) - 3), 512) 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 # 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 (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)) 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" \ 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_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_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 # 8. WATCH MODE