#!/bin/bash
# Scan for compromised cPanel/WHM session files.
#
# Each check function inspects a single session file and, if the IOC
# matches, calls report_finding with a severity. report_finding records
# the finding, prints a one-line header, and dumps the session for triage.
# A summary of all findings (grouped by severity) is printed at the end.


# Default paths
SESSIONS_DIR="/var/cpanel/sessions"
ACCESS_LOG="/usr/local/cpanel/logs/access_log"

# Flags
VERBOSE=0
PURGE=0
ASSUME_YES=0

# Parse flags
while [ $# -gt 0 ]; do
    case "$1" in
        --verbose)
            VERBOSE=1
            ;;
        --purge)
            PURGE=1
            ;;
        --yes|-y)
            ASSUME_YES=1
            ;;
        --sessions-dir)
            SESSIONS_DIR="$2"; shift
            ;;
        --access-log)
            ACCESS_LOG="$2"; shift
            ;;
        --help|-h)
            echo "Usage: $0 [--verbose] [--purge [--yes]] [--sessions-dir DIR] [--access-log FILE]"
            exit 0
            ;;
        *)
            echo "Unknown argument: $1" >&2
            exit 1
            ;;
    esac
    shift
done

# Findings accumulator. Each entry: "SEVERITY|session_file|short_message"
FINDINGS=()
# Ordered list of unique session files that produced findings.
FINDING_SESSIONS=()
# Parallel array: token value associated with each entry in FINDING_SESSIONS
# (first non-empty token seen for that session).
FINDING_TOKENS=()
# Parallel array: highest severity reported for each session (by index)
FINDING_SEVERITIES=()
COUNT_CRITICAL=0
COUNT_WARNING=0
COUNT_INFO=0
COUNT_ATTEMPT=0

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

# Extract the value of a key=value line from a session file (first match).
# Use: get_field <file> <key>
get_field() {
    local file="$1" key="$2"
    grep "^${key}=" "$file" | head -1 | cut -d= -f2-
}

hr() {
    echo "    ----------------------------------------------------------------"
}

# Dump full contents of a session file plus related context (matching
# pre-auth file, access_log hits for the injected token, file metadata).
# Use: dump_session <session_file> [token_value]
dump_session() {
    local session_file="$1"
    local token_val="$2"
    local session_name preauth_file
    session_name=$(basename "$session_file")
    preauth_file="$SESSIONS_DIR/preauth/$session_name"

    hr
    echo "    SESSION DUMP: $session_file"
    hr
    echo "    File metadata:"
    ls -la "$session_file" 2>/dev/null | sed 's/^/      /'
    echo
    echo "    Full session contents:"
    sed 's/^/      /' "$session_file"
    echo

    if [ -f "$preauth_file" ]; then
        echo "    Matching pre-auth file: $preauth_file"
        ls -la "$preauth_file" 2>/dev/null | sed 's/^/      /'
        echo "    Pre-auth contents:"
        sed 's/^/      /' "$preauth_file"
        echo
    fi

    if [ -n "$token_val" ] && [ -r "$ACCESS_LOG" ]; then
        echo "    Access log hits for token '$token_val':"
        grep -aF -- "$token_val" "$ACCESS_LOG" | sed 's/^/      /' || echo "      (none)"
        echo
    fi
    hr
}

# Record a finding and print a brief header line. The full session dump is
# deferred to print_summary so that multiple findings for the same session
# are grouped together and the session is only dumped once. When the same
# session matches multiple IOCs at different severities, only the highest
# (CRITICAL > WARNING > ATTEMPT > INFO) is kept.
# Use: report_finding <SEVERITY> <session_file> <token_value> <message>
# SEVERITY is one of: CRITICAL, WARNING, ATTEMPT, INFO
report_finding() {
    local severity="$1"
    local session_file="$2"
    local token_val="$3"
    local message="$4"

    # Severity ranking: CRITICAL=3, WARNING=2, ATTEMPT=1, INFO=0
    local sev_rank=0
    case "$severity" in
        CRITICAL) sev_rank=3 ;;
        WARNING)  sev_rank=2 ;;
        ATTEMPT)  sev_rank=1 ;;
        INFO)     sev_rank=0 ;;
    esac

    local i found=0 prev_sev prev_rank
    for i in "${!FINDING_SESSIONS[@]}"; do
        if [ "${FINDING_SESSIONS[$i]}" = "$session_file" ]; then
            found=1
            prev_sev="${FINDING_SEVERITIES[$i]}"
            case "$prev_sev" in
                CRITICAL) prev_rank=3 ;;
                WARNING)  prev_rank=2 ;;
                ATTEMPT)  prev_rank=1 ;;
                INFO)     prev_rank=0 ;;
            esac
            if [ "$sev_rank" -le "$prev_rank" ]; then
                # Existing finding is at least as severe; ignore.
                return
            fi
            # Upgrade in place: replace severity, token, FINDINGS entry,
            # and roll back the previous severity counter so the new one
            # can be incremented below without double-counting.
            FINDING_SEVERITIES[$i]="$severity"
            [ -n "$token_val" ] && FINDING_TOKENS[$i]="$token_val"
            local j
            for j in "${!FINDINGS[@]}"; do
                local entry="${FINDINGS[$j]}"
                local entry_sev="${entry%%|*}"
                local entry_file="${entry#*|}"; entry_file="${entry_file%%|*}"
                if [ "$entry_file" = "$session_file" ] && [ "$entry_sev" = "$prev_sev" ]; then
                    FINDINGS[$j]="${severity}|${session_file}|${message}"
                    break
                fi
            done
            case "$prev_sev" in
                CRITICAL) COUNT_CRITICAL=$((COUNT_CRITICAL - 1)) ;;
                WARNING)  COUNT_WARNING=$((COUNT_WARNING - 1))   ;;
                ATTEMPT)  COUNT_ATTEMPT=$((COUNT_ATTEMPT - 1))   ;;
                INFO)     COUNT_INFO=$((COUNT_INFO - 1))         ;;
            esac
            break
        fi
    done

    if [ "$found" -eq 0 ]; then
        FINDING_SESSIONS+=("$session_file")
        FINDING_TOKENS+=("$token_val")
        FINDING_SEVERITIES+=("$severity")
        FINDINGS+=("${severity}|${session_file}|${message}")
    fi

    case "$severity" in
        CRITICAL) COUNT_CRITICAL=$((COUNT_CRITICAL + 1)) ;;
        WARNING)  COUNT_WARNING=$((COUNT_WARNING + 1))   ;;
        ATTEMPT)  COUNT_ATTEMPT=$((COUNT_ATTEMPT + 1))   ;;
        INFO)     COUNT_INFO=$((COUNT_INFO + 1))         ;;
    esac

    echo "[${severity}] ${message}: ${session_file}"
}

# ---------------------------------------------------------------------------
# IOC checks
# ---------------------------------------------------------------------------

# IOC 0: token_denied counter alongside cp_security_token, in a session
# whose origin is badpass or otherwise non-benign.
#
# - token_denied is incremented by do_token_denied() (cpsrvd.pl:3821)
#   every time a request supplies the wrong cp_security_token. The
#   session is killed on the third failure.
# - cp_security_token itself is set by newsession() unconditionally
#   while security tokens are enabled (Cpanel/Server.pm:2290), so its
#   presence is NOT by itself an IOC. The pair (token_denied,
#   cp_security_token) tells us only that someone is actively trying
#   tokens against this session.
#
# Auth markers (successful_*_auth_with_timestamp, hasroot=1,
# tfa_verified=1, or an access_log hit on the security token) cannot
# legitimately appear in a badpass session: the badpass call site
# (Cpanel/Server.pm:1244-1252) doesn't pass them, hasroot is not even
# in _SESSION_PARTS (Cpanel/Server.pm:2216-2247), and tfa_verified is
# forced to 0 unless the caller passes a truthy value (line 2295).
#
# Severity tiers:
#   CRITICAL - badpass origin AND auth markers present (post-exploit)
#   INFO     - badpass origin, no auth markers, pass looks like a real
#              encoded password (likely an unrelated failed login that
#              happened to receive bad-token traffic)
#   WARNING  - origin is neither badpass nor a known-benign method
#              (handle_form_login, create_user_session,
#              handle_auth_transfer); the suspicious origin itself is
#              the IOC
#
# Legitimate badpass sessions never carry a pass= line (the badpass
# call site at Cpanel/Server.pm:1244-1252 does not pass `pass` to
# newsession, and saveSession only writes pass= when length is
# non-zero - Cpanel/Session.pm:181). When we see one anyway we defer
# classification to IOC 5 (check_failed_exploit_attempt), which flags
# it as ATTEMPT.
check_token_denied_with_injected_token() {
    local session_file="$1"

    grep -q '^token_denied='      "$session_file" || return
    grep -q '^cp_security_token=' "$session_file" || return

    local token_val external_auth internal_auth hasroot tfa used
    token_val=$(get_field      "$session_file" cp_security_token)
    external_auth=$(get_field  "$session_file" successful_external_auth_with_timestamp)
    internal_auth=$(get_field  "$session_file" successful_internal_auth_with_timestamp)
    hasroot=$(get_field        "$session_file" hasroot)
    tfa=$(get_field            "$session_file" tfa_verified)
    used=""
    if [ -r "$ACCESS_LOG" ]; then
        used=$(grep -aF -- "$token_val" "$ACCESS_LOG" | grep -m1 " 200 ")
    fi

    local has_auth_markers=0
    if [ -n "$external_auth" ] || [ -n "$internal_auth" ] \
       || [ "$hasroot" = "1" ] || [ "$tfa" = "1" ] || [ -n "$used" ]; then
        has_auth_markers=1
    fi

    if grep -q '^origin_as_string=.*method=badpass' "$session_file"; then
        if [ "$has_auth_markers" -eq 1 ]; then
            report_finding CRITICAL "$session_file" "$token_val" \
                "Exploitation artifact - token_denied with injected cp_security_token (badpass origin, token used)"
        else
            # A pass= line on a badpass session is itself anomalous;
            # defer to IOC 5 (ATTEMPT).
            if grep -q '^pass=' "$session_file"; then
                return
            fi
            report_finding INFO "$session_file" "$token_val" \
                "Possible injected session (badpass origin, no usage observed)"
        fi
    elif grep -q '^origin_as_string=.*method=handle_form_login' "$session_file" || \
         grep -q '^origin_as_string=.*method=create_user_session' "$session_file" || \
         grep -q '^origin_as_string=.*method=handle_auth_transfer' "$session_file"; then
        # Known-benign origins where token_denied + cp_security_token
        # genuinely happens during normal use.
        return
    else
        report_finding WARNING "$session_file" "$token_val" \
            "Suspicious session with token_denied + cp_security_token (non-badpass origin)"
    fi
}

# IOC 1: A session that still has its pre-auth marker file but already
# contains an auth-success timestamp (external or internal).
#
# write_session creates $SESSIONS_DIR/preauth/<session_name> when the
# session is written with needs_auth=1, and removes that marker once
# needs_auth is cleared on promotion (Cpanel/Session.pm:225-235). A
# legitimately authenticated session therefore never has both the
# preauth marker and an auth-success timestamp at the same time.
#
# Both successful_external_auth_with_timestamp and
# successful_internal_auth_with_timestamp are checked: the original
# poc.py payload injects the external variant; the watchtowr payload
# (poc/poc_watchtowr.py:35) injects the internal variant.
check_preauth_with_auth_attrs() {
    local session_file="$1"
    local session_name preauth_file
    session_name=$(basename "$session_file")
    preauth_file="$SESSIONS_DIR/preauth/$session_name"

    [ -f "$preauth_file" ] || return

    local marker
    if grep -qE '^successful_external_auth_with_timestamp=' "$session_file"; then
        marker="successful_external_auth_with_timestamp"
    elif grep -qE '^successful_internal_auth_with_timestamp=' "$session_file"; then
        marker="successful_internal_auth_with_timestamp"
    else
        return
    fi

    report_finding CRITICAL "$session_file" \
        "$(get_field "$session_file" cp_security_token)" \
        "Injected session - ${marker} present in pre-auth session"
}

# IOC 2: tfa_verified=1 outside of a legitimate origin method.
#
# tfa_verified=1 is set in only two places:
#   - Cpanel/Security/Authn/TwoFactorAuth/Verify.pm:122, after a real
#     TFA token validation succeeds.
#   - Cpanel/Server.pm:2295, when a caller passes tfa_verified=1 to
#     newsession().
# In both cases the legitimate origin method is one of handle_form_login,
# create_user_session, or handle_auth_transfer. tfa_verified=1 with any
# other origin (notably badpass) cannot occur in a benign flow.
check_tfa_with_bad_origin() {
    local session_file="$1"

    grep -qE '^tfa_verified=1$' "$session_file" || return
    grep -q '^origin_as_string=.*method=handle_form_login'    "$session_file" && return
    grep -q '^origin_as_string=.*method=create_user_session'  "$session_file" && return
    grep -q '^origin_as_string=.*method=handle_auth_transfer' "$session_file" && return

    report_finding WARNING "$session_file" \
        "$(get_field "$session_file" cp_security_token)" \
        "Session with tfa_verified=1 but suspicious origin"
}

# IOC 3: Session file contains a line that is not in `key=value` form.
#
# Three structural invariants together guarantee that every legitimate
# line matches ^[A-Za-z_][A-Za-z0-9_]*=:
#
#   1. write_session serializes via Cpanel::Config::FlushConfig::flushConfig
#      with '=' as the separator (Cpanel/Session.pm:221), so the on-disk
#      format is one key=value pair per line.
#   2. Keys come from a fixed whitelist (_SESSION_PARTS at
#      Cpanel/Server.pm:2216-2247, applied at lines 2268-2270), so they
#      always match the identifier shape above.
#   3. Cpanel::Session::filter_sessiondata strips \r\n from every value
#      (Cpanel/Session.pm:315) and additionally strips \r\n=, from origin
#      sub-values (line 312), so values can never re-introduce line
#      breaks. The `pass` value is additionally encoded by saveSession
#      (Cpanel/Session.pm:181-189) into either lowercase hex (with-secret
#      via Cpanel::Session::Encoder->encode_data) or the literal prefix
#      `no-ob:` followed by lowercase hex (no-secret via
#      Cpanel::Session::Encoder->hex_encode_only), so it cannot
#      reintroduce structural characters either.
#
# Any non-blank line that fails the regex is the footprint of an
# injection that bypassed these invariants - typically raw payload bytes
# that didn't form valid key=value pairs. Note: an injection whose
# smuggled lines DO match key=value (e.g. the watchtowr payload at
# poc/poc_watchtowr.py:35, which fabricates successful_internal_auth_
# with_timestamp/user/tfa_verified/hasroot lines) will not trip this
# check; it is caught by IOC-0 and IOC-4 instead.
check_malformed_session_line() {
    local session_file="$1"

    # Look for any non-blank line that doesn't start with key=...
    grep -nE -v '^[A-Za-z_][A-Za-z0-9_]*=|^[[:space:]]*$' "$session_file" >/dev/null 2>&1 || return

    report_finding CRITICAL "$session_file" \
        "$(get_field "$session_file" cp_security_token)" \
        "Malformed session line(s) detected (not key=value - newline injection footprint)"
}

# IOC 4: badpass origin combined with markers that no legitimate cpsrvd
# code path writes into a badpass session.
#
# The badpass call site (Cpanel/Server.pm:1244-1252) is:
#
#   $randsession = $self->newsession(
#       'needs_auth' => 1,
#       %security_token_options,            # adds cp_security_token
#       'origin' => { 'method' => 'badpass' },
#   );
#
# %security_token_options is why badpass sessions legitimately carry
# cp_security_token, but no auth-related options are ever supplied.
# newsession() filters %OPTS through the _SESSION_PARTS whitelist
# (Cpanel/Server.pm:2216-2247, applied at lines 2268-2270), so any key
# not in that whitelist cannot land in the session via newsession at
# all. Per marker:
#
#   successful_external_auth_with_timestamp - whitelisted, but the
#       badpass caller doesn't pass it
#   successful_internal_auth_with_timestamp - same
#   tfa_verified=1 - newsession unconditionally writes 0 unless the
#       caller passed a truthy value (Cpanel/Server.pm:2295), and the
#       badpass caller doesn't
#   hasroot=1 - NOT in _SESSION_PARTS, so newsession cannot write it
#       for ANY session. A repo-wide grep finds no caller of
#       Cpanel::Session::Modify->set('hasroot', ...) either: hasroot is
#       never written to a session by legitimate code. Its presence in
#       any session file is conclusive evidence of newline injection
#       (the watchtowr payload at poc/poc_watchtowr.py:35 smuggles
#       hasroot=1 via \r\n in a user-controlled field).
check_badpass_with_auth_markers() {
    local session_file="$1"

    grep -q '^origin_as_string=.*method=badpass' "$session_file" || return

    local markers=()
    grep -q '^successful_external_auth_with_timestamp=' "$session_file" \
        && markers+=("successful_external_auth_with_timestamp")
    grep -q '^successful_internal_auth_with_timestamp=' "$session_file" \
        && markers+=("successful_internal_auth_with_timestamp")
    grep -qE '^hasroot=1$'      "$session_file" && markers+=("hasroot=1")
    grep -qE '^tfa_verified=1$' "$session_file" && markers+=("tfa_verified=1")

    [ "${#markers[@]}" -gt 0 ] || return

    local joined
    joined=$(IFS=,; echo "${markers[*]}")
    report_finding CRITICAL "$session_file" \
        "$(get_field "$session_file" cp_security_token)" \
        "badpass origin combined with authenticated markers ($joined) - impossible in benign flow"
}

# IOC 5: Failed exploit attempt - a badpass session that carries a
# pass= line, a token_denied counter, and no auth markers.
#
# A legitimate badpass session is created at Cpanel/Server.pm:1244-1252:
#
#   $randsession = $self->newsession(
#       'needs_auth' => 1,
#       %security_token_options,
#       'origin' => { 'method' => 'badpass' },
#   );
#
# %security_token_options carries only cp_security_token,
# requested_token_at_next_login, and previous_session_user
# (Cpanel/Server.pm:1205-1226) - never `pass`. saveSession only
# writes a pass= line when length($session_ref->{pass}) is non-zero
# (Cpanel/Session.pm:181), so legitimate badpass sessions have no
# pass= line at all.
#
# An exploit that tampers with a user-controlled field on a
# badpass-bound request leaves a pass= line behind (saveSession
# encodes it as `<hex>` or `no-ob:<hex>` per Cpanel/Session.pm:181-189,
# but the format is irrelevant - its presence is the indicator). Combined
# with token_denied (someone was poking at cp_security_token) and the
# absence of auth markers (the injection didn't promote - otherwise
# IOC-0 or IOC-4 fires CRITICAL), this is the signature of a failed
# exploit attempt.
check_failed_exploit_attempt() {
    local session_file="$1"

    grep -q '^origin_as_string=.*method=badpass' "$session_file" || return
    grep -q '^token_denied=' "$session_file" || return

    # If auth markers are present, IOC-4 (CRITICAL) handles it.
    grep -q '^successful_internal_auth_with_timestamp=' "$session_file" && return
    grep -q '^successful_external_auth_with_timestamp=' "$session_file" && return

    # Legitimate badpass sessions never carry pass=.
    grep -q '^pass=' "$session_file" || return

    report_finding ATTEMPT "$session_file" "$(get_field "$session_file" cp_security_token)" \
        "Failed exploit attempt (badpass origin, token_denied, no auth markers, anomalous pass= line)"
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

scan_sessions() {
    local session_file
    while IFS= read -r -d '' session_file; do
        check_token_denied_with_injected_token "$session_file"
        check_preauth_with_auth_attrs          "$session_file"
        check_tfa_with_bad_origin              "$session_file"
        check_malformed_session_line           "$session_file"
        check_badpass_with_auth_markers        "$session_file"
        check_failed_exploit_attempt           "$session_file"
    done < <(find "$SESSIONS_DIR/raw" -type f -print0 2>/dev/null)
}


print_summary() {
    local total=$((COUNT_CRITICAL + COUNT_WARNING + COUNT_INFO + COUNT_ATTEMPT))

    echo
    echo "================================================================="
    echo "                       SCAN SUMMARY"
    echo "================================================================="
    echo "  CRITICAL findings: $COUNT_CRITICAL"
    echo "  WARNING  findings: $COUNT_WARNING"
    echo "  ATTEMPT  findings: $COUNT_ATTEMPT"
    echo "  INFO     findings: $COUNT_INFO"
    echo "  Total            : $total"
    echo "-----------------------------------------------------------------"

    if [ "$total" -eq 0 ]; then
        echo "[+] No indicators of compromise found."
        return
    fi

    # --purge has destructive blast radius (live session files for every
    # logged-in user). Require either --yes for non-interactive use, or
    # an explicit "yes" at an attached TTY.
    if [ "$PURGE" -eq 1 ] && [ "$ASSUME_YES" -ne 1 ]; then
        if [ ! -t 0 ]; then
            echo "[ERROR] --purge requires --yes when stdin is not a TTY (cron, pipes, etc)" >&2
            echo "        Re-run with --yes to confirm deletion." >&2
            exit 64
        fi
        echo
        echo "About to delete ${#FINDING_SESSIONS[@]} session file(s) plus matching preauth markers."
        local confirm=""
        read -r -p "Type 'yes' to confirm: " confirm
        if [ "$confirm" != "yes" ]; then
            echo "[+] Aborted; no files deleted."
            PURGE=0
        fi
    fi


    # For each unique session, print only the highest-severity finding, then dump/purge as needed.
    local i session token severity message found=0
    for i in "${!FINDING_SESSIONS[@]}"; do
        session="${FINDING_SESSIONS[$i]}"
        token="${FINDING_TOKENS[$i]}"
        severity="${FINDING_SEVERITIES[$i]}"
        found=0
        # Find the first matching finding for this session and severity.
        # Use `read` with three names so the last variable (entry_msg)
        # absorbs any remaining `|` characters - the previous `${var##*|}`
        # form took only the suffix after the LAST `|`, which would
        # silently truncate any future message that contained one.
        for entry in "${FINDINGS[@]}"; do
            local entry_sev entry_file entry_msg
            IFS='|' read -r entry_sev entry_file entry_msg <<< "$entry"
            if [ "$entry_file" = "$session" ] && [ "$entry_sev" = "$severity" ]; then
                message="$entry_msg"
                found=1
                break
            fi
        done
        echo
        echo "================================================================="
        echo "  SESSION: $session"
        echo "================================================================="
        echo "  Findings:"
        if [ "$found" -eq 1 ]; then
            printf "    [%-8s] %s\n" "$severity" "$message"
        else
            printf "    [%-8s] %s\n" "$severity" "(no message found)"
        fi
        echo
        if [ "$VERBOSE" -eq 1 ]; then
            dump_session "$session" "$token"
        fi
        if [ "$PURGE" -eq 1 ]; then
            echo "    [ACTION] Deleting session file: $session"
            rm -f -- "$session"
            local preauth_marker="$SESSIONS_DIR/preauth/$(basename "$session")"
            if [ -e "$preauth_marker" ]; then
                echo "    [ACTION] Deleting preauth marker: $preauth_marker"
                rm -f -- "$preauth_marker"
            fi
        fi
    done

    if [ "$COUNT_CRITICAL" -gt 0 ] || [ "$COUNT_WARNING" -gt 0 ]; then
        echo
        echo "[!] INDICATORS OF COMPROMISE DETECTED - IMMEDIATE ACTION REQUIRED"
        echo "    1. Purge all affected sessions"
        echo "    2. Force password reset for root and all WHM users"
        echo "    3. Audit /var/log/wtmp and WHM access logs for unauthorized access"
        echo "    4. Check for persistence mechanisms (cron, SSH keys, backdoors)"
    fi
}

if [ ! -d "$SESSIONS_DIR/raw" ]; then
    echo "[ERROR] Sessions directory not found: $SESSIONS_DIR/raw" >&2
    echo "        Pass --sessions-dir DIR to point at a different location" >&2
    echo "        (the default is /var/cpanel/sessions)." >&2
    exit 64
fi

echo "[*] Scanning session files for injection indicators..."
scan_sessions
print_summary

# Exit codes (for cron / monitoring):
#   2 - at least one CRITICAL or WARNING finding (compromise indicators)
#   1 - only ATTEMPT or INFO findings (probing, no confirmed compromise)
#   0 - clean scan
if [ "$COUNT_CRITICAL" -gt 0 ] || [ "$COUNT_WARNING" -gt 0 ]; then
    exit 2
elif [ "$COUNT_ATTEMPT" -gt 0 ] || [ "$COUNT_INFO" -gt 0 ]; then
    exit 1
fi
exit 0