Update mail-status.sh

This commit is contained in:
robott committed 2026-09-15 18:28:24 +02:00
1 parent b5937cc39b
commit dde33eec1a
1 file changed
+71 -62
+71 -62
View File
@@ -1,11 +1,16 @@
#!/bin/bash #!/bin/bash
# ============================================================================== # ==============================================================================
# mail-stats - Postfix Mail Summary Tool (v5.0.2) # mail-stats - Postfix Mail Summary Tool (v5.0.3)
# #
# 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.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
# - delivery lines validated against the fixed Postfix field structure
# - amavis log lines no longer parsed (linking only via Postfix itself)
# 5.0.2: security fixes # 5.0.2: security fixes
# - arithmetic injection via MAIL_STATS_* (command execution) fixed # - arithmetic injection via MAIL_STATS_* (command execution) fixed
# - env hardening (TMPDIR/XDG_RUNTIME_DIR ignored as root, LESS*/GZIP/XZ/ # - env hardening (TMPDIR/XDG_RUNTIME_DIR ignored as root, LESS*/GZIP/XZ/
@@ -50,7 +55,7 @@ if (( EUID == 0 )); then
unset -v TMPDIR XDG_RUNTIME_DIR unset -v TMPDIR XDG_RUNTIME_DIR
fi fi
readonly VERSION="5.0.2" readonly VERSION="5.0.3"
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; }
@@ -431,11 +436,13 @@ function parse_ts() {
if (NF < 6 || $2 !~ /^[0-9]+$/ || $3 !~ /^[0-9][0-9]:[0-9][0-9]:[0-9][0-9]$/) return 0 if (NF < 6 || $2 !~ /^[0-9]+$/ || $3 !~ /^[0-9][0-9]:[0-9][0-9]:[0-9][0-9]$/) return 0
l_mm = mon[$1]; l_dd = sprintf("%02d", $2); l_time = $3 l_mm = mon[$1]; l_dd = sprintf("%02d", $2); l_time = $3
l_y = (l_mm + 0 > cur_m) ? cur_y - 1 : cur_y l_y = (l_mm + 0 > cur_m) ? cur_y - 1 : cur_y
return 1 pf = 5
return ($5 ~ /^[^ ]+\[[0-9]+\]:$/)
} }
if ($1 ~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]/) { if ($1 ~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]/) {
l_y = substr($1, 1, 4); l_mm = substr($1, 6, 2); l_dd = substr($1, 9, 2); l_time = substr($1, 12, 8) l_y = substr($1, 1, 4); l_mm = substr($1, 6, 2); l_dd = substr($1, 9, 2); l_time = substr($1, 12, 8)
return 1 pf = 3
return (NF >= 4 && $3 ~ /^[^ ]+\[[0-9]+\]:$/)
} }
return 0 return 0
} }
@@ -527,21 +534,23 @@ function emit(q, to, relay, st, dsn, reason, rec) {
out(rec) out(rec)
} }
function handle_reject(q, 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) {
p = index($0, "reject: ") # rest: "reject: RCPT from host[ip]: 554 5.7.1 ...; from=<s> to=<r> proto=... helo=<...>"
r = substr($0, p + 8) 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"
if (match(r, /^[A-Z-]+ from [^ ]*\[[^]]*\]: /)) { if (match(r, /^[A-Z-]+ from [^ \[]*\[[^] ]*\]: /)) {
ss = substr(r, 1, RLENGTH); r = substr(r, RLENGTH + 1) ss = substr(r, 1, RLENGTH); r = substr(r, RLENGTH + 1)
sub(/\]: $/, "", ss); sub(/^.*\[/, "", ss); ip = clean(ss, 64) sub(/\]: $/, "", ss); sub(/^[^\[]*\[/, "", ss); ip = clean(ss, 64)
}
f = "-"; t = "-"
# Postfix appends "; from=<..> to=<..> proto=" after the reply text
if (match(r, /; from=<[^>]*> to=<[^>]*> proto=/)) {
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)
} }
f = "-"
if (match($0, /from=<[^>]*>/)) { f = substr($0, RSTART + 6, RLENGTH - 7); f = (f == "") ? "<>" : clean(f, 0) }
t = "-"
if (match($0, / to=<[^>]*>/)) t = clean(substr($0, RSTART + 5, RLENGTH - 6), 0)
p = index(r, "; from=<"); if (p > 0) r = substr(r, 1, p - 1)
re = clean(r, 512) re = clean(r, 512)
dsn = "-"; if (match(re, /[245]\.[0-9]+\.[0-9]+/)) dsn = substr(re, RSTART, RLENGTH) 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"
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)
@@ -549,79 +558,78 @@ function handle_reject(q, p, r, f, t, ip, ss, re, dsn, st, rec) {
out(rec) out(rec)
} }
function amavis( q1, q2, ip) {
if (!match($0, /Queue-ID: [0-9A-Za-z]+/)) return
q1 = substr($0, RSTART + 10, RLENGTH - 10)
if (!match($0, /queued_as: [0-9A-Za-z]+/)) return
q2 = substr($0, RSTART + 11, RLENGTH - 11)
link(q1, q2)
if (match($0, /\[[0-9A-Fa-f:.]+\]/)) {
ip = substr($0, RSTART + 1, RLENGTH - 2)
if (!is_loop(ip) && (!(q2 in c_ip) || is_loop(c_ip[q2]))) {
c_ip[q2] = ip
if (q2 in pn) flush_pend(q2, 1)
}
}
}
{ {
if (length($0) < 20 || !parse_ts()) next if (length($0) < 20 || !parse_ts()) next
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
if (index($0, "NOQUEUE: ") && $0 ~ /NOQUEUE: (milter-)?reject: /) { handle_reject("NOQUEUE"); next } # Everything after "<program>[pid]: <QUEUE_ID>: " is untrusted text only where
# Postfix embeds foreign data (addresses, Subject, remote replies). All
# matches below are anchored to the start of that remainder.
qf = $(pf + 1)
rest = substr($0, index($0, " " $pf " " qf " ") + length($pf) + length(qf) + 3)
if (index($0, "Queue-ID: ") && index($0, "queued_as: ") && $0 ~ / amavisd?\[[0-9]+\]: /) { amavis(); next } if (qf == "NOQUEUE:") {
if (rest ~ /^(milter-)?reject: /) handle_reject("NOQUEUE", rest)
next
}
qid = "" if (qf !~ QID_RE) next
for (i = 2; i <= 8 && i <= NF; i++) if ($i ~ QID_RE) { qid = substr($i, 1, length($i) - 1); break } qid = substr(qf, 1, length(qf) - 1)
if (qid == "") next
track(qid) track(qid)
if (index($0, qid ": client=")) { if (rest ~ /^client=/) {
if (match($0, /client=[^ ,]*\[[^]]*\]/)) { if (match(rest, /^client=[^ ,\[]*\[[^] ,]*\]/)) {
ip = substr($0, RSTART, RLENGTH); sub(/^[^[]*\[/, "", ip); sub(/\]$/, "", ip) ip = substr(rest, 1, RLENGTH); sub(/^[^\[]*\[/, "", ip); sub(/\]$/, "", ip)
if (!(qid in c_ip) || is_loop(c_ip[qid])) c_ip[qid] = clean(ip, 64) if (!(qid in c_ip) || is_loop(c_ip[qid])) c_ip[qid] = clean(ip, 64)
} }
if (!(qid in c_time)) c_time[qid] = l_ts if (!(qid in c_time)) c_time[qid] = l_ts
next next
} }
if (index($0, qid ": removed")) { forget(qid); next } if (rest == "removed") { forget(qid); next }
if (index($0, qid ": from=<")) { if (rest ~ /^from=</) {
if (match($0, /from=<[^>]*>/)) { s = substr($0, RSTART + 6, RLENGTH - 7); snd[qid] = (s == "") ? "<>" : clean(s, 0) } # qmgr: from=<sender>, size=N, nrcpt=N (queue active) - anchored at the end
if (match($0, /size=[0-9]+/)) sz[qid] = substr($0, RSTART + 5, RLENGTH - 5) + 0 if (match(rest, />, size=[0-9]+, nrcpt=[0-9]+ \(queue active\)$/)) {
s = substr(rest, 7, RSTART - 7); snd[qid] = (s == "") ? "<>" : clean(s, 0)
tail = substr(rest, RSTART); match(tail, /size=[0-9]+/)
sz[qid] = substr(tail, RSTART + 5, RLENGTH - 5) + 0
}
next next
} }
if (index($0, " header Subject: ")) { if (rest ~ /^(warning|info): header Subject: /) {
s = substr($0, index($0, " header Subject: ") + 17) s = rest; sub(/^(warning|info): header Subject: /, "", s)
# "from host[ip]; from=<..." (smtpd) or "from local; from=<..." (pickup) # "from host[ip]; from=<...>" (smtpd) or "from local; from=<...>" (pickup)
sub(/ from [^ ;]*; from=<.*$/, "", s) sub(/ from [^ ;]*; from=<.*$/, "", s)
if (!(qid in subj)) subj[qid] = clean(s, 256) if (!(qid in subj)) subj[qid] = clean(s, 256)
next
} }
if (index($0, qid ": reject: ") || index($0, qid ": milter-reject: ")) { handle_reject(qid); next } if (rest ~ /^(milter-)?reject: /) { handle_reject(qid, rest); next }
if (rest ~ /^to=</) {
# fixed Postfix structure after the (untrusted) recipient address; the
# 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
to = clean(substr(rest, 5, RSTART - 5), 0)
mid = substr(rest, RSTART, RLENGTH)
tail = substr(rest, RSTART + RLENGTH)
match(mid, /relay=[^ ,]+/); relay = substr(mid, RSTART + 6, RLENGTH - 6)
match(mid, /dsn=[0-9.]+/); dsn = substr(mid, RSTART + 4, RLENGTH - 4)
match(mid, /status=[a-z]+$/); st = substr(mid, RSTART + 7, RLENGTH - 7)
reason = "-"
if (tail ~ /^ \(.*\)$/) reason = clean(substr(tail, 3, length(tail) - 3), 512)
if (index($0, " to=<") && index($0, ", status=")) {
# 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 ($0 ~ /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 (match($0, /queued as [0-9A-Za-z]+/)) link(qid, substr($0, RSTART + 10, RLENGTH - 10)) if (st == "sent" && match(tail, /queued as [0-9A-Za-z]+\)$/)) link(qid, substr(tail, RSTART + 10, RLENGTH - 11))
next next
} }
to = "-"; if (match($0, / to=<[^>]*>/)) to = clean(substr($0, RSTART + 5, RLENGTH - 6), 0) sub(/:[0-9]+$/, "", relay)
relay = "-"; if (match($0, /relay=[^ ,]+/)) { relay = substr($0, RSTART + 6, RLENGTH - 6); sub(/:[0-9]+$/, "", relay); relay = clean(relay, 255) } emit(qid, to, clean(relay, 255), st, dsn, reason)
st = "-"; if (match($0, /status=[a-z]+/)) st = substr($0, RSTART + 7, RLENGTH - 7)
dsn = "-"; if (match($0, /dsn=[0-9.]+/)) dsn = substr($0, RSTART + 4, RLENGTH - 4)
reason = "-"
if (match($0, /status=[a-z]+ \(.*\)/)) {
reason = substr($0, RSTART, RLENGTH)
sub(/^status=[a-z]+ \(/, "", reason); sub(/\)$/, "", reason)
reason = clean(reason, 512)
}
emit(qid, to, relay, st, dsn, reason)
} }
} }
@@ -666,9 +674,10 @@ NF >= 14 {
selftest_out=$(printf '%s\n' \ selftest_out=$(printf '%s\n' \
'Sep 1 10:00:00 mx postfix/smtpd[1]: 4T3bV22xLnz1t: client=a.example[192.0.2.1]' \ 'Sep 1 10:00:00 mx postfix/smtpd[1]: 4T3bV22xLnz1t: client=a.example[192.0.2.1]' \
'Sep 1 10:00:01 mx postfix/qmgr[2]: 4T3bV22xLnz1t: from=<a@example.org>, size=1234, nrcpt=1 (queue active)' \ 'Sep 1 10:00:01 mx postfix/qmgr[2]: 4T3bV22xLnz1t: from=<a@example.org>, size=1234, nrcpt=1 (queue active)' \
'Sep 1 10:00:02 mx postfix/smtp[3]: 4T3bV22xLnz1t: to=<b@example.net>, relay=mx.example.net[192.0.2.2]:25, delay=1, dsn=2.0.0, status=sent (250 2.0.0 Ok)' \ 'Sep 1 10:00:01 mx postfix/cleanup[4]: 4T3bV22xLnz1t: warning: header Subject: x, status=sent (1) to=<fake@example.com> from a.example[192.0.2.1]; from=<a@example.org> to=<b@example.net> proto=ESMTP helo=<a>' \
'Sep 1 10:00:02 mx postfix/smtp[3]: 4T3bV22xLnz1t: to=<b@example.net>, relay=mx.example.net[192.0.2.2]:25, delay=1, delays=0/0/0.5/0.5, dsn=2.0.0, status=sent (250 2.0.0 Ok)' \
| MS_CUR_Y=2026 MS_CUR_M=9 "$AWK" "$AWK_PARSE" 2>&1) || true | MS_CUR_Y=2026 MS_CUR_M=9 "$AWK" "$AWK_PARSE" 2>&1) || true
if [[ $selftest_out != *$'\t4T3bV22xLnz1t\t-\t192.0.2.1\tmx.example.net[192.0.2.2]\ta@example.org\tb@example.net\tsent\t1234\t2.0.0\t-\t250 2.0.0 Ok' ]]; then if [[ $selftest_out != *$'\t4T3bV22xLnz1t\t-\t192.0.2.1\tmx.example.net[192.0.2.2]\ta@example.org\tb@example.net\tsent\t1234\t2.0.0\tx, status=sent (1) to=<fake@example.com>\t250 2.0.0 Ok' || $selftest_out == *fake@example.com$'\t'* ]]; then
die "Parser self-test failed with '$AWK' (incompatible awk implementation?). Install gawk." die "Parser self-test failed with '$AWK' (incompatible awk implementation?). Install gawk."
fi fi
unset selftest_out unset selftest_out