#!/usr/bin/env bash # # cpanel-ftp-audit.sh # # FTP login history, failed logins and file transfers for EVERY FTP account # under a cPanel user: the main login, the _logs account and all FTP # sub-accounts (name@domain). Works with Pure-FTPd and ProFTPD. # # Usage (as root): # bash cpanel-ftp-audit.sh [-v] [cpanel_username] # curl -fsSL https://gist.veru.se/Veruse/cpanel-ftp-audit/raw/HEAD/cpanel-ftp-audit.sh | bash -s -- [-v] cpanel_username # bash <(curl -fsSL https://gist.veru.se/Veruse/cpanel-ftp-audit/raw/HEAD/cpanel-ftp-audit.sh) # prompts for username # # -v verbose: list every failed attempt and every transfer (not just the # top/latest 50), plus every raw FTP log line for the account # (uploads, deletes, logouts, timeouts...) # # Read-only: changes nothing on the server and writes no report file (only a # temporary working directory, which is removed when the script exits). # # Everything is inside functions and only runs on the last line, so it is safe # to pipe from curl straight into bash. usage() { cat <<'EOF' Usage: cpanel-ftp-audit.sh [-v] -v verbose: show all failed attempts and transfers, plus raw FTP log lines Examples: curl -fsSL https://gist.veru.se/Veruse/cpanel-ftp-audit/raw/HEAD/cpanel-ftp-audit.sh | bash -s -- dafaeeaa bash <(curl -fsSL https://gist.veru.se/Veruse/cpanel-ftp-audit/raw/HEAD/cpanel-ftp-audit.sh) -v dafaeeaa EOF } # Escape a literal string for use inside grep -E / sed -E patterns re_escape() { printf '%s' "$1" | sed -E 's#[][\.^$*+?(){}|]#\\&#g'; } # Build "(a|b|c)" from the arguments, regex-escaped alt_re() { local out="" x for x in "$@"; do out+="${out:+|}$(re_escape "$x")"; done printf '(%s)' "${out:-__no_match__}" } section() { printf '\n=== %s ===\n' "$*"; } limit() { if (( VERBOSE )); then cat; else head -n "$1"; fi; } report() { local n echo "cPanel FTP audit for: $CPUSER" echo "Generated: $(date '+%F %T %Z') on $(hostname)" echo "FTP server (config): ${FTPSERVER:-unknown}" echo "Login log source: ${LOGDESC:-none found}" echo "Domains: ${DOMAINS[*]:-none found}" echo "FTP accounts (${#FTP_ACCTS[@]}):" printf ' %s\n' "${FTP_ACCTS[@]}" # ---------------------------------------------------------------- overview section "Overview per FTP account" awk -F'\t' -v accts="${FTP_ACCTS[*]}" ' FILENAME == ARGV[1] { ok[$2]++; last[$2] = $1; lastip[$2] = $3; next } { bad[$2]++ } END { n = split(accts, a, " ") printf "%-40s %7s %7s %-19s %s\n", "ACCOUNT", "LOGINS", "FAILED", "LAST LOGIN", "LAST IP" for (i = 1; i <= n; i++) { u = a[i] printf "%-40s %7d %7d %-19s %s\n", u, ok[u], bad[u], (u in last ? last[u] : "-"), (u in lastip ? lastip[u] : "-") } }' "$TMP/logins.tsv" "$TMP/failed.tsv" # -------------------------------------------------------- successful logins n=$(wc -l < "$TMP/logins.tsv") section "Successful logins, oldest first ($n total)" if (( n )); then awk -F'\t' '{ printf "%-19s %-40s %s\n", $1, $2, $3 }' "$TMP/logins.tsv" section "Successful logins by account and IP" cut -f2,3 "$TMP/logins.tsv" | sort | uniq -c | sort -k1,1nr \ | awk '{ printf "%7d %-40s %s\n", $1, $2, $3 }' else echo "None found in the available logs." fi # ------------------------------------------------------------ failed logins n=$(wc -l < "$TMP/failed.tsv") section "Failed logins by account and IP ($n total)" echo "(includes attempts on non-existent users @ this account's domains)" if (( n )); then cut -f2,3 "$TMP/failed.tsv" | sort | uniq -c | sort -k1,1nr \ | awk '{ printf "%7d %-40s %s\n", $1, $2, $3 }' | limit 50 if (( VERBOSE )); then section "All failed logins, oldest first" awk -F'\t' '{ printf "%-19s %-40s %s\n", $1, $2, $3 }' "$TMP/failed.tsv" elif (( $(cut -f2,3 "$TMP/failed.tsv" | sort -u | wc -l) > 50 )); then echo "(top 50 shown - run with -v for everything)" fi else echo "None found in the available logs." fi # ---------------------------------------------------------- file transfers section "File transfers" if (( ${#XFER_FILES[@]} == 0 )); then echo "No FTP transfer logs found (ftpxferlog / ftp.-ftp_log)." else echo "Transfer logs searched:" printf ' %s\n' "${XFER_FILES[@]}" n=$(wc -l < "$TMP/xfer.tsv") echo if (( n )); then echo "Totals:" awk -F'\t' '{ k = $2 "\t" $4; c[k]++; b[k] += $5 } END { for (k in c) { split(k, p, "\t") printf " %-40s %-8s %7d files %16.0f bytes\n", p[1], p[2], c[k], b[k] } }' \ "$TMP/xfer.tsv" | sort if (( VERBOSE )); then section "All transfers, oldest first ($n)" else section "Latest 50 transfers ($n total, run with -v for all)" fi tail -n "$( (( VERBOSE )) && echo "+1" || echo 50 )" "$TMP/xfer.tsv" \ | awk -F'\t' '{ printf "%-20s %-32s %-15s %-8s %12s %s\n", $1, $2, $3, $4, $5, $6 }' else echo "No transfers found for these accounts." fi fi # ------------------------------------------------------------ raw log lines n=$(wc -l < "$TMP/ftp.log") if (( VERBOSE )); then section "Raw FTP daemon log lines for this account ($n)" cat "$TMP/ftp.log" else echo echo "($n raw FTP log lines matched - run with -v to see them all," echo " including uploads, deletes, logouts and timeouts)" fi echo echo "Note: history only goes back as far as log rotation keeps it (${OLDEST:-unknown})." } main() { export LC_ALL=C VERBOSE=0 CPUSER="" while [[ $# -gt 0 ]]; do case $1 in -v|--verbose) VERBOSE=1 ;; -h|--help) usage; return 0 ;; -*) echo "Unknown option: $1" >&2; usage >&2; return 1 ;; *) CPUSER=$1 ;; esac shift done if [[ $EUID -ne 0 ]]; then echo "Please run this as root." >&2 return 1 fi if [[ -z $CPUSER ]]; then read -rp "cPanel username: " CPUSER &2 return 1 fi if [[ ! -f /var/cpanel/users/$CPUSER ]]; then echo "No cPanel account called '$CPUSER' on this server." >&2 return 1 fi HOMEDIR=$(getent passwd "$CPUSER" | cut -d: -f6) HOMEDIR=${HOMEDIR:-/home/$CPUSER} # All FTP logins for the account: main, _logs, plus sub-accounts mapfile -t FTP_ACCTS < <( { echo "$CPUSER"; echo "${CPUSER}_logs"; cut -d: -f1 "/etc/proftpd/$CPUSER" 2>/dev/null; } \ | sed 's/[[:space:]]//g' | awk 'NF && !seen[$0]++' ) # Domains owned by the account (used to catch attempts on unknown user@domain) mapfile -t DOMAINS < <( awk -F': *' -v u="$CPUSER" '{ sub(/[[:space:]]+$/, "", $2) } $2 == u { print $1 }' \ /etc/userdomains 2>/dev/null | sort -u ) FTPSERVER=$(awk -F= '$1 == "ftpserver" { print $2 }' /var/cpanel/cpanel.config 2>/dev/null) # Syslog files, oldest first (RHEL-family: messages, Ubuntu: syslog) LOGFILES=() LOGDESC="" local base f d for base in /var/log/messages /var/log/syslog; do if [[ -e $base ]]; then mapfile -t LOGFILES < <(ls -1tr -- "$base" "$base"[-.]* 2>/dev/null) LOGDESC="$base (+$(( ${#LOGFILES[@]} - 1 )) rotated)" break fi done TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT ACCT_RE=$(alt_re "${FTP_ACCTS[@]}") DOM_RE=$(alt_re "${DOMAINS[@]}") local B='[^[:alnum:]_.-]' local TS='^([A-Z][a-z]{2} +[0-9]{1,2} [0-9:]{8}|[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8})[^ ]*' local FAILU="(${ACCT_RE}|[^][ :]*@${DOM_RE})" local FILTER_RE="(pure-ftpd|proftpd).*(${B}${ACCT_RE}${B}|@${DOM_RE}${B})" echo "Reading logs for $CPUSER (${#FTP_ACCTS[@]} FTP accounts)..." >&2 # 1. Pull every FTP daemon line that mentions one of the accounts/domains if (( ${#LOGFILES[@]} )); then OLDEST=$(zcat -f -- "${LOGFILES[0]}" 2>/dev/null | head -n1 | sed -nE "s/${TS}.*/oldest entry: \1/p") for f in "${LOGFILES[@]}"; do zcat -f -- "$f" 2>/dev/null; done \ | grep -aE "$FILTER_RE" > "$TMP/ftp.log" elif command -v journalctl >/dev/null 2>&1; then LOGDESC="systemd journal" journalctl --no-pager -o short -t pure-ftpd -t proftpd 2>/dev/null \ | grep -aE "$FILTER_RE" > "$TMP/ftp.log" OLDEST=$(head -n1 "$TMP/ftp.log" | sed -nE "s/${TS}.*/oldest match: \1/p") else : > "$TMP/ftp.log" fi # 2. Successful logins -> dateaccountip grep -aE "(pure-ftpd.*\] ${ACCT_RE} is now logged in|proftpd.* - USER ${ACCT_RE}: Login successful)" "$TMP/ftp.log" \ | sed -nE \ -e "s/${TS}.*\(\?@([^)]*)\) \[[A-Z]+\] ([^ ]+) is now logged in.*/\1\t\3\t\2/p" \ -e "s/${TS}.*\([^[]*\[([^]]*)\]\) - USER ([^ :]+): Login successful.*/\1\t\3\t\2/p" \ > "$TMP/logins.tsv" # 3. Failed logins -> dateaccountip grep -aE "(pure-ftpd.*Authentication failed for user \[${FAILU}\]|proftpd.* - USER ${FAILU}( \(Login failed\)|: no such user))" "$TMP/ftp.log" \ | sed -nE \ -e "s/${TS}.*\(\?@([^)]*)\) \[[A-Z]+\] Authentication failed for user \[([^]]*)\].*/\1\t\3\t\2/p" \ -e "s/${TS}.*\([^[]*\[([^]]*)\]\) - USER ([^ :]+)( \(Login failed\)|: no such user).*/\1\t\3\t\2/p" \ > "$TMP/failed.tsv" # 4. Transfer logs (xferlog format) for the account's domains mapfile -t XFER_FILES < <( { ls -1 /usr/local/apache/domlogs/ftpxferlog* 2>/dev/null for d in "${DOMAINS[@]}"; do ls -1 /usr/local/apache/domlogs/ftp."$d"-ftp_log* \ /usr/local/apache/domlogs/*/ftp."$d"-ftp_log* \ "$HOMEDIR/logs/ftp.$d"-ftp_log* 2>/dev/null done } | awk '!seen[$0]++' ) # xferlog: 5 date fields, time, host, bytes, filename..., then 9 fixed fields for f in "${XFER_FILES[@]}"; do zcat -f -- "$f" 2>/dev/null; done \ | awk -v accts="${FTP_ACCTS[*]}" ' BEGIN { n = split(accts, a, " "); for (i = 1; i <= n; i++) want[a[i]] = 1 split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", m, " ") for (i = 1; i <= 12; i++) mon[m[i]] = sprintf("%02d", i) } NF >= 18 && ($(NF-4) in want) { fn = $9; for (i = 10; i <= NF - 9; i++) fn = fn " " $i dir = $(NF-6) dir = (dir == "i" ? "UPLOAD" : dir == "o" ? "DOWNLOAD" : dir == "d" ? "DELETE" : dir) printf "%s%s%02d%s\t%s %s %02d %s\t%s\t%s\t%s\t%s\t%s\n", $5, mon[$2], $3, $4, $5, $2, $3, $4, $(NF-4), $7, dir, $8, fn }' \ | sort -u | cut -f2- > "$TMP/xfer.tsv" report } main "$@"