#!/usr/bin/env bash
#
# discover_privileged_accounts.sh
#
# Discovers privileged accounts on a Linux server, writes a CSV, and optionally
# uploads it to an SFTP server. Behaviour is driven entirely by config.json so
# the script itself does not need to be edited.
#
# The CSV columns produced here are identical to those produced by the Windows
# companion script (Discover-PrivilegedAccounts.ps1).
#
# Requirements: bash 4+, and either `jq` or `python3` to parse config.json.
# Run as root for complete results (shadow/lastlog/sudoers access).

set -euo pipefail

# Output contains a privileged-account inventory; keep any files we create
# (CSV, log, temp batch) readable only by the owner.
umask 077

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="${1:-$SCRIPT_DIR/config.json}"

# --- Canonical column order (must match the Windows script exactly) ----------
# NOTE: do NOT name this COLUMNS. That is a special bash variable that bash
# overwrites with the terminal width (via checkwinsize, default on RHEL) when a
# command runs without a controlling terminal (e.g. under sudo / redirected),
# which blanked the CSV header row.
CSV_HEADER='CollectionTimestamp,Hostname,IPAddress,OSPlatform,OSVersion,AccountName,AccountDomain,AccountType,AccountID,PrivilegeSource,GroupMemberships,Enabled,IsBuiltIn,PasswordLastSet,PasswordNeverExpires,PasswordRequired,LastLogon,AccountExpires'

if [[ ! -f "$CONFIG_PATH" ]]; then
    echo "Config file not found: $CONFIG_PATH" >&2
    exit 1
fi

# --- Config reader: prefer jq, fall back to python3 --------------------------
if command -v jq >/dev/null 2>&1; then
    CFG_READER="jq"
elif command -v python3 >/dev/null 2>&1; then
    CFG_READER="python3"
else
    echo "Neither 'jq' nor 'python3' is available to parse config.json." >&2
    exit 1
fi

# cfg <jq-path> — returns a scalar string (empty if null/missing)
cfg() {
    local path="$1"
    if [[ "$CFG_READER" == "jq" ]]; then
        jq -r "$path // empty" "$CONFIG_PATH"
    else
        python3 - "$CONFIG_PATH" "$path" <<'PY'
import json, sys
cfg = json.load(open(sys.argv[1]))
# translate a subset of jq dot-paths like .a.b.c
path = sys.argv[2].lstrip('.')
cur = cfg
for part in path.split('.'):
    if part == '':
        continue
    if isinstance(cur, dict) and part in cur:
        cur = cur[part]
    else:
        cur = ''
        break
if isinstance(cur, bool):
    print('true' if cur else 'false')
elif cur is None:
    print('')
else:
    print(cur)
PY
    fi
}

# cfg_array <jq-path> — returns newline-separated list values
cfg_array() {
    local path="$1"
    if [[ "$CFG_READER" == "jq" ]]; then
        jq -r "$path[]? // empty" "$CONFIG_PATH"
    else
        python3 - "$CONFIG_PATH" "$path" <<'PY'
import json, sys
cfg = json.load(open(sys.argv[1]))
path = sys.argv[2].lstrip('.')
cur = cfg
for part in path.split('.'):
    if part == '':
        continue
    cur = cur.get(part, []) if isinstance(cur, dict) else []
if isinstance(cur, list):
    for v in cur:
        print(v)
PY
    fi
}

# --- Load config values ------------------------------------------------------
OUT_DIR="$(cfg '.output.directory')";            OUT_DIR="${OUT_DIR:-./output}"
NAME_PREFIX="$(cfg '.output.filenamePrefix')";   NAME_PREFIX="${NAME_PREFIX:-privileged_accounts}"
INCLUDE_HOST="$(cfg '.output.includeHostnameInFilename')"
INCLUDE_TS="$(cfg '.output.includeTimestampInFilename')"

ROOT_UID_THRESHOLD="$(cfg '.discovery.linux.rootUidThreshold')"; ROOT_UID_THRESHOLD="${ROOT_UID_THRESHOLD:-0}"
INCLUDE_SYSTEM="$(cfg '.discovery.linux.includeSystemAccounts')"
SYSTEM_UID_MAX="$(cfg '.discovery.linux.systemUidMax')";         SYSTEM_UID_MAX="${SYSTEM_UID_MAX:-999}"
CHECK_SUDOERS="$(cfg '.discovery.linux.checkSudoers')"

LOG_FILE="$(cfg '.logging.logFile')"
VERBOSE="$(cfg '.logging.verbose')"

mapfile -t PRIV_GROUPS < <(cfg_array '.discovery.linux.privilegedGroups')

# resolve relative paths against the script directory
[[ "$OUT_DIR" != /* ]] && OUT_DIR="$SCRIPT_DIR/$OUT_DIR"
if [[ -n "$LOG_FILE" && "$LOG_FILE" != /* ]]; then LOG_FILE="$SCRIPT_DIR/$LOG_FILE"; fi

log() {
    local level="${2:-INFO}"
    local line
    line="$(date '+%Y-%m-%d %H:%M:%S') [$level] $1"
    if [[ "$VERBOSE" == "true" || "$level" != "INFO" ]]; then echo "$line"; fi
    [[ -n "$LOG_FILE" ]] && echo "$line" >> "$LOG_FILE" 2>/dev/null || true
}

log "Starting privileged account discovery on Linux."

# --- Config permission check -------------------------------------------------
# config.json may contain a plaintext SFTP password; it must not be readable by
# group/other. Warn loudly (and tighten it if we own it) rather than proceed
# silently with an exposed credential.
if [[ -f "$CONFIG_PATH" ]]; then
    perms="$(stat -c '%a' "$CONFIG_PATH" 2>/dev/null || echo '')"
    if [[ -n "$perms" && "${perms: -2}" != "00" ]]; then
        log "config.json ($CONFIG_PATH) is group/other-accessible (mode $perms); it may hold a plaintext password. Restricting to 600 is required." WARN
        if [[ -O "$CONFIG_PATH" ]]; then
            chmod 600 "$CONFIG_PATH" 2>/dev/null && log "Tightened permissions on $CONFIG_PATH to 600." || true
        fi
    fi
fi

# --- Host metadata -----------------------------------------------------------
HOSTNAME_VAL="$(hostname 2>/dev/null || echo unknown)"
TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"
OS_PLATFORM="Linux"

OS_VERSION=""
if [[ -r /etc/os-release ]]; then
    # Parse (do NOT source) so a tampered/writable os-release cannot execute code.
    OS_VERSION="$(grep -E '^PRETTY_NAME=' /etc/os-release 2>/dev/null \
        | head -n1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//')"
fi
[[ -z "$OS_VERSION" ]] && OS_VERSION="$(uname -sr)"

IP_ADDRESS="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -z "$IP_ADDRESS" ]] && IP_ADDRESS="$(ip -4 addr show scope global 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1 | head -n1)"
IP_ADDRESS="${IP_ADDRESS:-}"

# --- Data structures ---------------------------------------------------------
# We aggregate per-username using parallel associative arrays.
declare -A ACC_SOURCES   # username -> "; "-joined privilege sources
declare -A ACC_GROUPS    # username -> "; "-joined group names
declare -A SEEN          # username -> 1

add_source() {
    local user="$1" source="$2"
    SEEN["$user"]=1
    if [[ -z "${ACC_SOURCES[$user]:-}" ]]; then
        ACC_SOURCES["$user"]="$source"
    elif [[ ";${ACC_SOURCES[$user]};" != *";$source;"* && "${ACC_SOURCES[$user]}" != *"$source"* ]]; then
        ACC_SOURCES["$user"]="${ACC_SOURCES[$user]}; $source"
    fi
}

add_group() {
    local user="$1" group="$2"
    if [[ -z "${ACC_GROUPS[$user]:-}" ]]; then
        ACC_GROUPS["$user"]="$group"
    elif [[ "${ACC_GROUPS[$user]}" != *"$group"* ]]; then
        ACC_GROUPS["$user"]="${ACC_GROUPS[$user]}; $group"
    fi
}

# --- 1. UID 0 (and below threshold) accounts ---------------------------------
while IFS=: read -r uname _ uid _ _ _ _; do
    [[ -z "$uname" ]] && continue
    if [[ "$uid" -le "$ROOT_UID_THRESHOLD" ]]; then
        add_source "$uname" "UID $uid"
    fi
done < /etc/passwd

# --- 2. Members of privileged groups (primary + secondary) -------------------
for grp in "${PRIV_GROUPS[@]}"; do
    [[ -z "$grp" ]] && continue
    grp_line="$(getent group "$grp" 2>/dev/null || true)"
    [[ -z "$grp_line" ]] && { log "Group '$grp' not found." WARN; continue; }

    grp_gid="$(echo "$grp_line" | cut -d: -f3)"
    grp_members="$(echo "$grp_line" | cut -d: -f4)"

    # secondary members (4th field, comma-separated)
    if [[ -n "$grp_members" ]]; then
        IFS=',' read -ra members <<< "$grp_members"
        for m in "${members[@]}"; do
            [[ -n "$m" ]] && { add_source "$m" "Group: $grp"; add_group "$m" "$grp"; }
        done
    fi

    # primary members (users whose passwd GID matches this group)
    while IFS=: read -r uname _ _ pgid _ _ _; do
        if [[ "$pgid" == "$grp_gid" ]]; then
            add_source "$uname" "Group: $grp (primary)"
            add_group "$uname" "$grp"
        fi
    done < /etc/passwd
done

# --- 3. Sudoers entries ------------------------------------------------------
if [[ "$CHECK_SUDOERS" == "true" ]]; then
    scan_sudoers() {
        local file="$1"
        [[ -r "$file" ]] || return 0
        # Match lines granting privileges to a user (skip comments, Defaults,
        # and group specs beginning with % which are covered by group scan).
        #
        # NOTE: the loop is fed via process substitution, NOT a pipe. A piped
        # `while` runs in a subshell, so add_source's updates to SEEN/ACC_*
        # would be discarded and sudoers-only accounts would silently vanish.
        while read -r principal; do
            [[ "$principal" == %* ]] && continue
            [[ -z "$principal" ]] && continue
            add_source "$principal" "sudoers"
        done < <(grep -E '^[[:space:]]*[a-zA-Z0-9._-]+[[:space:]]+.*=' "$file" 2>/dev/null \
                    | grep -vE '^[[:space:]]*#' \
                    | grep -vE '^[[:space:]]*Defaults' \
                    | awk '{print $1}')
    }
    scan_sudoers /etc/sudoers
    if [[ -d /etc/sudoers.d ]]; then
        for f in /etc/sudoers.d/*; do
            [[ -f "$f" ]] && scan_sudoers "$f"
        done
    fi
fi

# --- CSV helpers -------------------------------------------------------------
csv_escape() {
    # Neutralize spreadsheet formula/DDE injection, then wrap in quotes and
    # double any embedded quotes. A field beginning with = + - @ (or a leading
    # tab/CR) is interpreted as a formula by Excel/LibreOffice even inside
    # quotes, so we prefix such values with a single quote to force text.
    local v="${1:-}"
    case "$v" in
        =*|+*|-*|@*|$'\t'*|$'\r'*) v="'$v" ;;
    esac
    v="${v//\"/\"\"}"
    printf '"%s"' "$v"
}

# epoch-days -> yyyy-mm-dd (shadow stores days since 1970-01-01)
days_to_date() {
    local days="$1"
    [[ -z "$days" || "$days" == "0" ]] && { echo ""; return; }
    if ! [[ "$days" =~ ^-?[0-9]+$ ]]; then echo ""; return; fi
    date -u -d "@$((days * 86400))" '+%Y-%m-%d' 2>/dev/null || echo ""
}

# --- Prepare output ----------------------------------------------------------
mkdir -p "$OUT_DIR"
fname="$NAME_PREFIX"
[[ "$INCLUDE_HOST" == "true" ]] && fname="${fname}_${HOSTNAME_VAL}"
[[ "$INCLUDE_TS" == "true" ]]   && fname="${fname}_$(date '+%Y%m%d_%H%M%S')"
CSV_PATH="$OUT_DIR/${fname}.csv"

echo "$CSV_HEADER" > "$CSV_PATH"
chmod 600 "$CSV_PATH" 2>/dev/null || true

row_count=0
for user in "${!SEEN[@]}"; do
    # Look up passwd details
    pw_line="$(getent passwd "$user" 2>/dev/null || true)"
    if [[ -n "$pw_line" ]]; then
        uid="$(echo "$pw_line" | cut -d: -f3)"
        shell="$(echo "$pw_line" | cut -d: -f7)"
    else
        uid=""
        shell=""
    fi

    # Skip system accounts unless configured to include them
    if [[ "$INCLUDE_SYSTEM" != "true" && -n "$uid" ]]; then
        if [[ "$uid" -ne 0 && "$uid" -le "$SYSTEM_UID_MAX" ]]; then
            # keep root(0); drop other system uids
            continue
        fi
    fi

    account_type="LocalUser"
    is_builtin="false"
    [[ "$uid" == "0" ]] && is_builtin="true"

    # Shadow-derived fields (requires root)
    enabled=""
    pw_last_set=""
    pw_never_expires=""
    pw_required=""
    account_expires=""
    sh_line="$(getent shadow "$user" 2>/dev/null || true)"
    if [[ -n "$sh_line" ]]; then
        pw_hash="$(echo "$sh_line" | cut -d: -f2)"
        last_change_days="$(echo "$sh_line" | cut -d: -f3)"
        max_days="$(echo "$sh_line" | cut -d: -f5)"
        expire_days="$(echo "$sh_line" | cut -d: -f8)"

        # Account is "disabled" if password field is locked (! or *) or shell is nologin/false
        if [[ "$pw_hash" == "!"* || "$pw_hash" == "*"* || -z "$pw_hash" ]]; then
            enabled="false"
        else
            enabled="true"
        fi
        pw_required="true"
        [[ -z "$pw_hash" ]] && pw_required="false"

        pw_last_set="$(days_to_date "$last_change_days")"
        if [[ -z "$max_days" || "$max_days" == "99999" ]]; then
            pw_never_expires="true"
        else
            pw_never_expires="false"
        fi
        account_expires="$(days_to_date "$expire_days")"
    fi

    # A nologin/false shell also indicates a non-interactive (effectively disabled) login
    if [[ -n "$shell" && ( "$shell" == *"nologin" || "$shell" == *"/false" ) ]]; then
        [[ -z "$enabled" ]] && enabled="false"
    fi

    # Last logon via lastlog
    last_logon=""
    if command -v lastlog >/dev/null 2>&1; then
        ll="$(lastlog -u "$user" 2>/dev/null | awk 'NR==2')"
        if [[ -n "$ll" && "$ll" != *"Never logged in"* ]]; then
            # everything after the port/host columns is the date
            last_logon="$(echo "$ll" | awk '{for(i=4;i<=NF;i++) printf "%s ", $i; print ""}' | sed 's/[[:space:]]*$//')"
        fi
    fi

    # Build the row in canonical column order
    row=""
    row+="$(csv_escape "$TIMESTAMP"),"
    row+="$(csv_escape "$HOSTNAME_VAL"),"
    row+="$(csv_escape "$IP_ADDRESS"),"
    row+="$(csv_escape "$OS_PLATFORM"),"
    row+="$(csv_escape "$OS_VERSION"),"
    row+="$(csv_escape "$user"),"
    row+="$(csv_escape ""),"                              # AccountDomain (n/a on Linux)
    row+="$(csv_escape "$account_type"),"
    row+="$(csv_escape "$uid"),"                          # AccountID = UID
    row+="$(csv_escape "${ACC_SOURCES[$user]:-}"),"
    row+="$(csv_escape "${ACC_GROUPS[$user]:-}"),"
    row+="$(csv_escape "$enabled"),"
    row+="$(csv_escape "$is_builtin"),"
    row+="$(csv_escape "$pw_last_set"),"
    row+="$(csv_escape "$pw_never_expires"),"
    row+="$(csv_escape "$pw_required"),"
    row+="$(csv_escape "$last_logon"),"
    row+="$(csv_escape "$account_expires")"
    echo "$row" >> "$CSV_PATH"
    row_count=$((row_count + 1))
done

log "Discovered $row_count privileged account entries."
log "CSV written to $CSV_PATH"

# --- SFTP upload -------------------------------------------------------------
SFTP_ENABLED="$(cfg '.sftp.enabled')"
if [[ "$SFTP_ENABLED" == "true" ]]; then
    SFTP_HOST="$(cfg '.sftp.host')"
    SFTP_PORT="$(cfg '.sftp.port')";               SFTP_PORT="${SFTP_PORT:-22}"
    SFTP_USER="$(cfg '.sftp.username')"
    SFTP_AUTH="$(cfg '.sftp.authMethod')"
    SFTP_KEY="$(cfg '.sftp.privateKeyPath')"
    SFTP_PASS="$(cfg '.sftp.password')"
    SFTP_REMOTE="$(cfg '.sftp.remoteDirectory')"
    SFTP_ACCEPT_NEW="$(cfg '.sftp.acceptNewHostKey')"
    SFTP_KNOWN_HOSTS="$(cfg '.sftp.knownHostsPath')"

    [[ "$SFTP_KEY" != /* && -n "$SFTP_KEY" ]] && SFTP_KEY="$SCRIPT_DIR/$SFTP_KEY"

    if ! command -v sftp >/dev/null 2>&1; then
        log "sftp client not found; cannot upload. Install openssh-client." ERROR
    else
        remote_dir="${SFTP_REMOTE%/}"
        remote_name="$(basename "$CSV_PATH")"
        batch_file="$(mktemp)"
        {
            [[ -n "$remote_dir" ]] && echo "cd \"$remote_dir\""
            echo "put \"$CSV_PATH\" \"$remote_name\""
            echo "bye"
        } > "$batch_file"

        ssh_opts=()
        [[ "$SFTP_ACCEPT_NEW" == "true" ]] && ssh_opts+=(-o StrictHostKeyChecking=accept-new)
        [[ -n "$SFTP_KNOWN_HOSTS" ]] && ssh_opts+=(-o "UserKnownHostsFile=$SFTP_KNOWN_HOSTS")

        upload_ok=false
        if [[ "$SFTP_AUTH" == "key" ]]; then
            if [[ ! -r "$SFTP_KEY" ]]; then
                log "Private key not found/readable: $SFTP_KEY" ERROR
            else
                log "Uploading $remote_name to $SFTP_USER@$SFTP_HOST:$remote_dir ..."
                if sftp -P "$SFTP_PORT" -i "$SFTP_KEY" -o BatchMode=yes "${ssh_opts[@]}" \
                        -b "$batch_file" "$SFTP_USER@$SFTP_HOST"; then
                    upload_ok=true
                fi
            fi
        elif [[ "$SFTP_AUTH" == "password" ]]; then
            if command -v sshpass >/dev/null 2>&1; then
                log "Uploading $remote_name via password auth (sshpass) ..."
                # Use -e (password from $SSHPASS env), NOT -p: the -p form exposes
                # the cleartext password in the process list (ps auxww) to any
                # local user for the duration of the transfer.
                if SSHPASS="$SFTP_PASS" sshpass -e sftp -P "$SFTP_PORT" "${ssh_opts[@]}" \
                        -b "$batch_file" "$SFTP_USER@$SFTP_HOST"; then
                    upload_ok=true
                fi
            else
                log "Password auth requested but 'sshpass' is not installed. Use key auth or install sshpass." ERROR
            fi
        else
            log "Unknown sftp.authMethod '$SFTP_AUTH'. Use 'key' or 'password'." ERROR
        fi

        rm -f "$batch_file"
        if [[ "$upload_ok" == true ]]; then
            log "SFTP upload succeeded."
        else
            log "SFTP upload failed." ERROR
        fi
    fi
else
    log "SFTP upload disabled in config."
fi

log "Discovery complete."
