Veruse / cPanel FTP Audit

Last active 1 hour ago

Like 0

bash <(curl -fsSL https://gist.veru.se/Veruse/cpanel-ftp-audit/raw/HEAD/cpanel-ftp-audit.sh)

Revision f7fa80ddb73cf2539034459a55e9858bd6b0002b

cpanel-ftp-audit.sh Raw
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://gist.veru.se/Veruse/cpanel-ftp-audit/raw/HEAD/cpanel-ftp-audit.sh | bash -s -- [-v] cpanel_username
12# bash <(curl -fsSL https://gist.veru.se/Veruse/cpanel-ftp-audit/raw/HEAD/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 and writes no report file (only a
19# temporary working directory, which is removed when the script exits).
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
24usage() {
25 cat <<'EOF'
26Usage: cpanel-ftp-audit.sh [-v] <cpanel_username>
27
28 -v verbose: show all failed attempts and transfers, plus raw FTP log lines
29
30Examples:
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
33EOF
34}
35
36# Escape a literal string for use inside grep -E / sed -E patterns
37re_escape() { printf '%s' "$1" | sed -E 's#[][\.^$*+?(){}|]#\\&#g'; }
38
39# Build "(a|b|c)" from the arguments, regex-escaped
40alt_re() {
41 local out="" x
42 for x in "$@"; do out+="${out:+|}$(re_escape "$x")"; done
43 printf '(%s)' "${out:-__no_match__}"
44}
45
46section() { printf '\n=== %s ===\n' "$*"; }
47
48limit() { if (( VERBOSE )); then cat; else head -n "$1"; fi; }
49
50report() {
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
149main() {
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 report
283}
284
285main "$@"