diff --git a/mail-status.sh b/mail-status.sh index 58c861e..0e5f5c0 100644 --- a/mail-status.sh +++ b/mail-status.sh @@ -1,11 +1,16 @@ #!/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), # 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.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 # - arithmetic injection via MAIL_STATS_* (command execution) fixed # - 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 fi -readonly VERSION="5.0.2" +readonly VERSION="5.0.3" die() { local m="$*"; printf 'Error: %s\n' "${m//[[:cntrl:]]/?}" >&2; exit 1; } 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 l_mm = mon[$1]; l_dd = sprintf("%02d", $2); l_time = $3 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]/) { 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 } @@ -527,21 +534,23 @@ function emit(q, to, relay, st, dsn, reason, rec) { out(rec) } -function handle_reject(q, p, r, f, t, ip, ss, re, dsn, st, rec) { - p = index($0, "reject: ") - r = substr($0, p + 8) +function handle_reject(q, rest, p, r, f, t, ip, ss, re, dsn, st, rec) { + # 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" - if (match(r, /^[A-Z-]+ from [^ ]*\[[^]]*\]: /)) { + if (match(r, /^[A-Z-]+ from [^ \[]*\[[^] ]*\]: /)) { 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) - 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" 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) @@ -549,79 +558,78 @@ function handle_reject(q, p, r, f, t, ip, ss, re, dsn, st, 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 tt = l_time; gsub(/:/, "", tt) l_key = l_y l_mm l_dd tt l_ts = l_y "-" l_mm "-" l_dd " " l_time - if (index($0, "NOQUEUE: ") && $0 ~ /NOQUEUE: (milter-)?reject: /) { handle_reject("NOQUEUE"); next } + # Everything after "[pid]: : " 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 = "" - for (i = 2; i <= 8 && i <= NF; i++) if ($i ~ QID_RE) { qid = substr($i, 1, length($i) - 1); break } - if (qid == "") next + if (qf !~ QID_RE) next + qid = substr(qf, 1, length(qf) - 1) track(qid) - if (index($0, qid ": client=")) { - if (match($0, /client=[^ ,]*\[[^]]*\]/)) { - ip = substr($0, RSTART, RLENGTH); sub(/^[^[]*\[/, "", ip); sub(/\]$/, "", ip) + if (rest ~ /^client=/) { + if (match(rest, /^client=[^ ,\[]*\[[^] ,]*\]/)) { + 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_time)) c_time[qid] = l_ts next } - if (index($0, qid ": removed")) { forget(qid); next } + if (rest == "removed") { forget(qid); next } - if (index($0, qid ": from=<")) { - if (match($0, /from=<[^>]*>/)) { s = substr($0, RSTART + 6, RLENGTH - 7); snd[qid] = (s == "") ? "<>" : clean(s, 0) } - if (match($0, /size=[0-9]+/)) sz[qid] = substr($0, RSTART + 5, RLENGTH - 5) + 0 + 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) + tail = substr(rest, RSTART); match(tail, /size=[0-9]+/) + sz[qid] = substr(tail, RSTART + 5, RLENGTH - 5) + 0 + } next } - if (index($0, " header Subject: ")) { - s = substr($0, index($0, " header Subject: ") + 17) - # "from host[ip]; from=<..." (smtpd) or "from local; from=<..." (pickup) + if (rest ~ /^(warning|info): header Subject: /) { + s = rest; sub(/^(warning|info): header Subject: /, "", s) + # "from host[ip]; from=<...>" (smtpd) or "from local; from=<...>" (pickup) sub(/ from [^ ;]*; from=<.*$/, "", s) 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=, (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 - if ($0 ~ /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 (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)) next } - to = "-"; if (match($0, / to=<[^>]*>/)) to = clean(substr($0, RSTART + 5, RLENGTH - 6), 0) - relay = "-"; if (match($0, /relay=[^ ,]+/)) { relay = substr($0, RSTART + 6, RLENGTH - 6); sub(/:[0-9]+$/, "", relay); relay = clean(relay, 255) } - 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) + sub(/:[0-9]+$/, "", relay) + emit(qid, to, clean(relay, 255), st, dsn, reason) } } @@ -666,9 +674,10 @@ NF >= 14 { 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:01 mx postfix/qmgr[2]: 4T3bV22xLnz1t: from=, size=1234, nrcpt=1 (queue active)' \ - 'Sep 1 10:00:02 mx postfix/smtp[3]: 4T3bV22xLnz1t: to=, 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= from a.example[192.0.2.1]; from= to= proto=ESMTP helo=' \ + 'Sep 1 10:00:02 mx postfix/smtp[3]: 4T3bV22xLnz1t: to=, 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 -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=\t250 2.0.0 Ok' || $selftest_out == *fake@example.com$'\t'* ]]; then die "Parser self-test failed with '$AWK' (incompatible awk implementation?). Install gawk." fi unset selftest_out