| 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # cpanel-ftp-audit.sh |
| 4 | # |
| 5 | # FTP login history, failed logins and file transfers for EVERY FTP account |
| 6 | # under a cPanel user: the main login, the <user>_logs account and all FTP |
| 7 | # sub-accounts (name@domain). Works with Pure-FTPd and ProFTPD. |
| 8 | # |
| 9 | # Usage (as root): |
| 10 | # bash cpanel-ftp-audit.sh [-v] [cpanel_username] |
| 11 | # curl -fsSL https://YOUR-HOST/cpanel-ftp-audit.sh | bash -s -- [-v] cpanel_username |
| 12 | # bash <(curl -fsSL https://YOUR-HOST/cpanel-ftp-audit.sh) # prompts for username |
| 13 | # |
| 14 | # -v verbose: list every failed attempt and every transfer (not just the |
| 15 | # top/latest 50), plus every raw FTP log line for the account |
| 16 | # (uploads, deletes, logouts, timeouts...) |
| 17 | # |
| 18 | # Read-only: changes nothing on the server. A copy of the report is saved to |
| 19 | # /root/ftp-audit-<user>-<timestamp>.txt |
| 20 | # |
| 21 | # Everything is inside functions and only runs on the last line, so it is safe |
| 22 | # to pipe from curl straight into bash. |
| 23 | |
| 24 | usage() { |
| 25 | cat <<'EOF' |
| 26 | Usage: cpanel-ftp-audit.sh [-v] <cpanel_username> |
| 27 | |
| 28 | -v verbose: show all failed attempts and transfers, plus raw FTP log lines |
| 29 | |
| 30 | Examples: |
| 31 | curl -fsSL https://YOUR-HOST/cpanel-ftp-audit.sh | bash -s -- dafaeeaa |
| 32 | bash <(curl -fsSL https://YOUR-HOST/cpanel-ftp-audit.sh) -v dafaeeaa |
| 33 | EOF |
| 34 | } |
| 35 | |
| 36 | # Escape a literal string for use inside grep -E / sed -E patterns |
| 37 | re_escape() { printf '%s' "$1" | sed -E 's#[][\.^$*+?(){}|]#\\&#g'; } |
| 38 | |
| 39 | # Build "(a|b|c)" from the arguments, regex-escaped |
| 40 | alt_re() { |
| 41 | local out="" x |
| 42 | for x in "$@"; do out+="${out:+|}$(re_escape "$x")"; done |
| 43 | printf '(%s)' "${out:-__no_match__}" |
| 44 | } |
| 45 | |
| 46 | section() { printf '\n=== %s ===\n' "$*"; } |
| 47 | |
| 48 | limit() { if (( VERBOSE )); then cat; else head -n "$1"; fi; } |
| 49 | |
| 50 | report() { |
| 51 | local n |
| 52 | |
| 53 | echo "cPanel FTP audit for: $CPUSER" |
| 54 | echo "Generated: $(date '+%F %T %Z') on $(hostname)" |
| 55 | echo "FTP server (config): ${FTPSERVER:-unknown}" |
| 56 | echo "Login log source: ${LOGDESC:-none found}" |
| 57 | echo "Domains: ${DOMAINS[*]:-none found}" |
| 58 | echo "FTP accounts (${#FTP_ACCTS[@]}):" |
| 59 | printf ' %s\n' "${FTP_ACCTS[@]}" |
| 60 | |
| 61 | # ---------------------------------------------------------------- overview |
| 62 | section "Overview per FTP account" |
| 63 | awk -F'\t' -v accts="${FTP_ACCTS[*]}" ' |
| 64 | FILENAME == ARGV[1] { ok[$2]++; last[$2] = $1; lastip[$2] = $3; next } |
| 65 | { bad[$2]++ } |
| 66 | END { |
| 67 | n = split(accts, a, " ") |
| 68 | printf "%-40s %7s %7s %-19s %s\n", "ACCOUNT", "LOGINS", "FAILED", "LAST LOGIN", "LAST IP" |
| 69 | for (i = 1; i <= n; i++) { |
| 70 | u = a[i] |
| 71 | printf "%-40s %7d %7d %-19s %s\n", u, ok[u], bad[u], |
| 72 | (u in last ? last[u] : "-"), (u in lastip ? lastip[u] : "-") |
| 73 | } |
| 74 | }' "$TMP/logins.tsv" "$TMP/failed.tsv" |
| 75 | |
| 76 | # -------------------------------------------------------- successful logins |
| 77 | n=$(wc -l < "$TMP/logins.tsv") |
| 78 | section "Successful logins, oldest first ($n total)" |
| 79 | if (( n )); then |
| 80 | awk -F'\t' '{ printf "%-19s %-40s %s\n", $1, $2, $3 }' "$TMP/logins.tsv" |
| 81 | |
| 82 | section "Successful logins by account and IP" |
| 83 | cut -f2,3 "$TMP/logins.tsv" | sort | uniq -c | sort -k1,1nr \ |
| 84 | | awk '{ printf "%7d %-40s %s\n", $1, $2, $3 }' |
| 85 | else |
| 86 | echo "None found in the available logs." |
| 87 | fi |
| 88 | |
| 89 | # ------------------------------------------------------------ failed logins |
| 90 | n=$(wc -l < "$TMP/failed.tsv") |
| 91 | section "Failed logins by account and IP ($n total)" |
| 92 | echo "(includes attempts on non-existent users @ this account's domains)" |
| 93 | if (( n )); then |
| 94 | cut -f2,3 "$TMP/failed.tsv" | sort | uniq -c | sort -k1,1nr \ |
| 95 | | awk '{ printf "%7d %-40s %s\n", $1, $2, $3 }' | limit 50 |
| 96 | if (( VERBOSE )); then |
| 97 | section "All failed logins, oldest first" |
| 98 | awk -F'\t' '{ printf "%-19s %-40s %s\n", $1, $2, $3 }' "$TMP/failed.tsv" |
| 99 | elif (( $(cut -f2,3 "$TMP/failed.tsv" | sort -u | wc -l) > 50 )); then |
| 100 | echo "(top 50 shown - run with -v for everything)" |
| 101 | fi |
| 102 | else |
| 103 | echo "None found in the available logs." |
| 104 | fi |
| 105 | |
| 106 | # ---------------------------------------------------------- file transfers |
| 107 | section "File transfers" |
| 108 | if (( ${#XFER_FILES[@]} == 0 )); then |
| 109 | echo "No FTP transfer logs found (ftpxferlog / ftp.<domain>-ftp_log)." |
| 110 | else |
| 111 | echo "Transfer logs searched:" |
| 112 | printf ' %s\n' "${XFER_FILES[@]}" |
| 113 | n=$(wc -l < "$TMP/xfer.tsv") |
| 114 | echo |
| 115 | if (( n )); then |
| 116 | echo "Totals:" |
| 117 | awk -F'\t' '{ k = $2 "\t" $4; c[k]++; b[k] += $5 } |
| 118 | END { for (k in c) { split(k, p, "\t") |
| 119 | printf " %-40s %-8s %7d files %16.0f bytes\n", p[1], p[2], c[k], b[k] } }' \ |
| 120 | "$TMP/xfer.tsv" | sort |
| 121 | |
| 122 | if (( VERBOSE )); then |
| 123 | section "All transfers, oldest first ($n)" |
| 124 | else |
| 125 | section "Latest 50 transfers ($n total, run with -v for all)" |
| 126 | fi |
| 127 | tail -n "$( (( VERBOSE )) && echo "+1" || echo 50 )" "$TMP/xfer.tsv" \ |
| 128 | | awk -F'\t' '{ printf "%-20s %-32s %-15s %-8s %12s %s\n", $1, $2, $3, $4, $5, $6 }' |
| 129 | else |
| 130 | echo "No transfers found for these accounts." |
| 131 | fi |
| 132 | fi |
| 133 | |
| 134 | # ------------------------------------------------------------ raw log lines |
| 135 | n=$(wc -l < "$TMP/ftp.log") |
| 136 | if (( VERBOSE )); then |
| 137 | section "Raw FTP daemon log lines for this account ($n)" |
| 138 | cat "$TMP/ftp.log" |
| 139 | else |
| 140 | echo |
| 141 | echo "($n raw FTP log lines matched - run with -v to see them all," |
| 142 | echo " including uploads, deletes, logouts and timeouts)" |
| 143 | fi |
| 144 | |
| 145 | echo |
| 146 | echo "Note: history only goes back as far as log rotation keeps it (${OLDEST:-unknown})." |
| 147 | } |
| 148 | |
| 149 | main() { |
| 150 | export LC_ALL=C |
| 151 | VERBOSE=0 |
| 152 | CPUSER="" |
| 153 | |
| 154 | while [[ $# -gt 0 ]]; do |
| 155 | case $1 in |
| 156 | -v|--verbose) VERBOSE=1 ;; |
| 157 | -h|--help) usage; return 0 ;; |
| 158 | -*) echo "Unknown option: $1" >&2; usage >&2; return 1 ;; |
| 159 | *) CPUSER=$1 ;; |
| 160 | esac |
| 161 | shift |
| 162 | done |
| 163 | |
| 164 | if [[ $EUID -ne 0 ]]; then |
| 165 | echo "Please run this as root." >&2 |
| 166 | return 1 |
| 167 | fi |
| 168 | |
| 169 | if [[ -z $CPUSER ]]; then |
| 170 | read -rp "cPanel username: " CPUSER </dev/tty || true |
| 171 | fi |
| 172 | CPUSER=${CPUSER//[[:space:]]/} |
| 173 | CPUSER=${CPUSER,,} |
| 174 | |
| 175 | if [[ ! $CPUSER =~ ^[a-z0-9][a-z0-9_.-]*$ ]]; then |
| 176 | usage >&2 |
| 177 | return 1 |
| 178 | fi |
| 179 | if [[ ! -f /var/cpanel/users/$CPUSER ]]; then |
| 180 | echo "No cPanel account called '$CPUSER' on this server." >&2 |
| 181 | return 1 |
| 182 | fi |
| 183 | |
| 184 | HOMEDIR=$(getent passwd "$CPUSER" | cut -d: -f6) |
| 185 | HOMEDIR=${HOMEDIR:-/home/$CPUSER} |
| 186 | |
| 187 | # All FTP logins for the account: main, _logs, plus sub-accounts |
| 188 | mapfile -t FTP_ACCTS < <( |
| 189 | { echo "$CPUSER"; echo "${CPUSER}_logs"; cut -d: -f1 "/etc/proftpd/$CPUSER" 2>/dev/null; } \ |
| 190 | | sed 's/[[:space:]]//g' | awk 'NF && !seen[$0]++' |
| 191 | ) |
| 192 | |
| 193 | # Domains owned by the account (used to catch attempts on unknown user@domain) |
| 194 | mapfile -t DOMAINS < <( |
| 195 | awk -F': *' -v u="$CPUSER" '{ sub(/[[:space:]]+$/, "", $2) } $2 == u { print $1 }' \ |
| 196 | /etc/userdomains 2>/dev/null | sort -u |
| 197 | ) |
| 198 | |
| 199 | FTPSERVER=$(awk -F= '$1 == "ftpserver" { print $2 }' /var/cpanel/cpanel.config 2>/dev/null) |
| 200 | |
| 201 | # Syslog files, oldest first (RHEL-family: messages, Ubuntu: syslog) |
| 202 | LOGFILES=() |
| 203 | LOGDESC="" |
| 204 | local base f d |
| 205 | for base in /var/log/messages /var/log/syslog; do |
| 206 | if [[ -e $base ]]; then |
| 207 | mapfile -t LOGFILES < <(ls -1tr -- "$base" "$base"[-.]* 2>/dev/null) |
| 208 | LOGDESC="$base (+$(( ${#LOGFILES[@]} - 1 )) rotated)" |
| 209 | break |
| 210 | fi |
| 211 | done |
| 212 | |
| 213 | TMP=$(mktemp -d) |
| 214 | trap 'rm -rf "$TMP"' EXIT |
| 215 | |
| 216 | ACCT_RE=$(alt_re "${FTP_ACCTS[@]}") |
| 217 | DOM_RE=$(alt_re "${DOMAINS[@]}") |
| 218 | local B='[^[:alnum:]_.-]' |
| 219 | 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})[^ ]*' |
| 220 | local FAILU="(${ACCT_RE}|[^][ :]*@${DOM_RE})" |
| 221 | local FILTER_RE="(pure-ftpd|proftpd).*(${B}${ACCT_RE}${B}|@${DOM_RE}${B})" |
| 222 | |
| 223 | echo "Reading logs for $CPUSER (${#FTP_ACCTS[@]} FTP accounts)..." >&2 |
| 224 | |
| 225 | # 1. Pull every FTP daemon line that mentions one of the accounts/domains |
| 226 | if (( ${#LOGFILES[@]} )); then |
| 227 | OLDEST=$(zcat -f -- "${LOGFILES[0]}" 2>/dev/null | head -n1 | sed -nE "s/${TS}.*/oldest entry: \1/p") |
| 228 | for f in "${LOGFILES[@]}"; do zcat -f -- "$f" 2>/dev/null; done \ |
| 229 | | grep -aE "$FILTER_RE" > "$TMP/ftp.log" |
| 230 | elif command -v journalctl >/dev/null 2>&1; then |
| 231 | LOGDESC="systemd journal" |
| 232 | journalctl --no-pager -o short -t pure-ftpd -t proftpd 2>/dev/null \ |
| 233 | | grep -aE "$FILTER_RE" > "$TMP/ftp.log" |
| 234 | OLDEST=$(head -n1 "$TMP/ftp.log" | sed -nE "s/${TS}.*/oldest match: \1/p") |
| 235 | else |
| 236 | : > "$TMP/ftp.log" |
| 237 | fi |
| 238 | |
| 239 | # 2. Successful logins -> date<TAB>account<TAB>ip |
| 240 | grep -aE "(pure-ftpd.*\] ${ACCT_RE} is now logged in|proftpd.* - USER ${ACCT_RE}: Login successful)" "$TMP/ftp.log" \ |
| 241 | | sed -nE \ |
| 242 | -e "s/${TS}.*\(\?@([^)]*)\) \[[A-Z]+\] ([^ ]+) is now logged in.*/\1\t\3\t\2/p" \ |
| 243 | -e "s/${TS}.*\([^[]*\[([^]]*)\]\) - USER ([^ :]+): Login successful.*/\1\t\3\t\2/p" \ |
| 244 | > "$TMP/logins.tsv" |
| 245 | |
| 246 | # 3. Failed logins -> date<TAB>account<TAB>ip |
| 247 | grep -aE "(pure-ftpd.*Authentication failed for user \[${FAILU}\]|proftpd.* - USER ${FAILU}( \(Login failed\)|: no such user))" "$TMP/ftp.log" \ |
| 248 | | sed -nE \ |
| 249 | -e "s/${TS}.*\(\?@([^)]*)\) \[[A-Z]+\] Authentication failed for user \[([^]]*)\].*/\1\t\3\t\2/p" \ |
| 250 | -e "s/${TS}.*\([^[]*\[([^]]*)\]\) - USER ([^ :]+)( \(Login failed\)|: no such user).*/\1\t\3\t\2/p" \ |
| 251 | > "$TMP/failed.tsv" |
| 252 | |
| 253 | # 4. Transfer logs (xferlog format) for the account's domains |
| 254 | mapfile -t XFER_FILES < <( |
| 255 | { |
| 256 | ls -1 /usr/local/apache/domlogs/ftpxferlog* 2>/dev/null |
| 257 | for d in "${DOMAINS[@]}"; do |
| 258 | ls -1 /usr/local/apache/domlogs/ftp."$d"-ftp_log* \ |
| 259 | /usr/local/apache/domlogs/*/ftp."$d"-ftp_log* \ |
| 260 | "$HOMEDIR/logs/ftp.$d"-ftp_log* 2>/dev/null |
| 261 | done |
| 262 | } | awk '!seen[$0]++' |
| 263 | ) |
| 264 | |
| 265 | # xferlog: 5 date fields, time, host, bytes, filename..., then 9 fixed fields |
| 266 | for f in "${XFER_FILES[@]}"; do zcat -f -- "$f" 2>/dev/null; done \ |
| 267 | | awk -v accts="${FTP_ACCTS[*]}" ' |
| 268 | BEGIN { |
| 269 | n = split(accts, a, " "); for (i = 1; i <= n; i++) want[a[i]] = 1 |
| 270 | split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", m, " ") |
| 271 | for (i = 1; i <= 12; i++) mon[m[i]] = sprintf("%02d", i) |
| 272 | } |
| 273 | NF >= 18 && ($(NF-4) in want) { |
| 274 | fn = $9; for (i = 10; i <= NF - 9; i++) fn = fn " " $i |
| 275 | dir = $(NF-6) |
| 276 | dir = (dir == "i" ? "UPLOAD" : dir == "o" ? "DOWNLOAD" : dir == "d" ? "DELETE" : dir) |
| 277 | printf "%s%s%02d%s\t%s %s %02d %s\t%s\t%s\t%s\t%s\t%s\n", |
| 278 | $5, mon[$2], $3, $4, $5, $2, $3, $4, $(NF-4), $7, dir, $8, fn |
| 279 | }' \ |
| 280 | | sort -u | cut -f2- > "$TMP/xfer.tsv" |
| 281 | |
| 282 | umask 077 |
| 283 | REPORT="/root/ftp-audit-${CPUSER}-$(date +%Y%m%d-%H%M%S).txt" |
| 284 | report | tee "$REPORT" |
| 285 | echo |
| 286 | echo "Report saved to $REPORT" >&2 |
| 287 | } |
| 288 | |
| 289 | main "$@" |
Veruse / cPanel FTP Audit
Last active 1 hour ago
bash <(curl -fsSL https://gist.veru.se/Veruse/cpanel-ftp-audit/raw/HEAD/cpanel-ftp-audit.sh)
Revision 6ebfa2364c3aeac73eaabc43cf973e7d04f34327