#!/usr/bin/env bash
# clamav-regular-scan - Weekly ClamAV scan with structured (JSON) syslog output.
# VERSION is substituted at build time from the top-level VERSION file.

set -u
set -o pipefail

VERSION="0.1.1"
PROG="clamav-regular-scan"
CONF="/etc/${PROG}/scan.conf"

usage() {
    cat <<EOF
Usage: ${PROG} [--help] [--version]

Runs a ClamAV scan according to ${CONF} and writes structured JSON
messages to syslog (facility configurable, default: auth).

Options:
  --help       Show this help
  --version    Print version and exit

Config file: ${CONF}
Systemd:     systemctl status ${PROG}.timer
Logs:        journalctl -t ${PROG}
EOF
}

case "${1-}" in
    --help|-h) usage; exit 0 ;;
    --version|-V) echo "${PROG} ${VERSION}"; exit 0 ;;
    "") ;;
    *) echo "unknown argument: $1" >&2; usage >&2; exit 64 ;;
esac

# ---- Defaults (overridden by scan.conf) --------------------------------------
SCAN_TARGETS="/"
EXTRA_SCAN_TARGETS=""
HIGH_RISK_TARGETS="/tmp /var/tmp /dev/shm"
CROSS_FS="no"
EXCLUDE_DIRS="^/proc ^/sys ^/dev ^/run ^/var/lib/clamav ^/var/lib/docker ^/var/lib/containers ^/snap ^/var/lib/lxcfs"
NICE_LEVEL=19
IONICE_CLASS=3
IONICE_LEVEL=0
MAX_FILESIZE="2000M"
MAX_SCANSIZE="2000M"
RUN_FRESHCLAM="yes"
SYSLOG_FACILITY="daemon"
CLAMSCAN_EXTRA_ARGS=""

# shellcheck source=/dev/null
[[ -r "$CONF" ]] && . "$CONF"

# ---- Runtime identity --------------------------------------------------------
# Prefer systemd-provided INVOCATION_ID (a UUID); fall back to the kernel's
# random UUID source. No extra package needed either way.
RUN_ID="${INVOCATION_ID:-$(cat /proc/sys/kernel/random/uuid)}"
HOST="$(hostname)"

# ---- Helpers -----------------------------------------------------------------
have() { command -v "$1" >/dev/null 2>&1; }

# same_fs_as_any <path> <other-paths...>
#   Returns 0 if <path> shares a filesystem (same st_dev) with ANY of the
#   other paths. Non-existent paths are skipped. Used to decide whether a
#   high-risk target is already covered by the primary scan.
same_fs_as_any() {
    local target="$1"
    shift
    [[ ! -e "$target" ]] && return 1
    local target_dev
    target_dev=$(stat -c %d "$target" 2>/dev/null) || return 1
    local other other_dev
    for other in "$@"; do
        [[ ! -e "$other" ]] && continue
        other_dev=$(stat -c %d "$other" 2>/dev/null) || continue
        [[ "$target_dev" == "$other_dev" ]] && return 0
    done
    return 1
}

# emit_json <severity> <event> [extra_json_object]
#   extra_json_object: optional JSON object string of additional fields
#                      (default "{}"). Built by callers with `jq -cn`.
emit_json() {
    local severity="$1" event="$2" extra_json="${3:-{\}}"
    local msg
    msg=$(jq -cn \
        --arg     event   "$event" \
        --arg     run_id  "$RUN_ID" \
        --arg     version "$VERSION" \
        --arg     host    "$HOST" \
        --arg     ts      "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
        --argjson extra   "$extra_json" \
        '{event:$event, run_id:$run_id, version:$version, host:$host, ts:$ts} + $extra')
    if [[ -z "$msg" ]]; then
        # jq failed for some reason; emit a minimal fallback so at least the
        # event fires and monitoring can catch it.
        msg="{\"event\":\"${event}\",\"run_id\":\"${RUN_ID}\",\"error\":\"jq_failed\"}"
    fi
    logger -t "$PROG" -p "${SYSLOG_FACILITY}.${severity}" -- "$msg"
}

# ---- freshclam ---------------------------------------------------------------
run_freshclam() {
    [[ "$RUN_FRESHCLAM" != "yes" ]] && return 0
    if ! have freshclam; then
        emit_json notice scan.freshclam_failed \
            "$(jq -cn '{rc:-1, stderr_tail:"freshclam not installed"}')"
        return 0
    fi
    local err_file
    err_file=$(mktemp)
    if ! freshclam --quiet >/dev/null 2>"$err_file"; then
        local rc=$?
        local tail_msg
        tail_msg=$(tail -c 500 "$err_file" | tr '\n' ' ')
        emit_json notice scan.freshclam_failed \
            "$(jq -cn --arg tail "$tail_msg" --argjson rc "$rc" \
                '{rc:$rc, stderr_tail:$tail}')"
    fi
    rm -f "$err_file"
}

# ---- Build clamscan argv ------------------------------------------------------
build_clamscan_args() {
    local -n _out=$1
    _out=(--recursive --infected --stdout)
    [[ "$CROSS_FS" == "no" ]] && _out+=("--cross-fs=no")
    local d
    for d in $EXCLUDE_DIRS; do
        _out+=("--exclude-dir=$d")
    done
    [[ -n "$MAX_FILESIZE" ]] && _out+=("--max-filesize=$MAX_FILESIZE")
    [[ -n "$MAX_SCANSIZE" ]] && _out+=("--max-scansize=$MAX_SCANSIZE")
    # shellcheck disable=SC2206
    local extra=($CLAMSCAN_EXTRA_ARGS)
    _out+=("${extra[@]}")
}

# ---- One scan pass -----------------------------------------------------------
THREATS_TOTAL=0
FILES_TOTAL=0
DATA_TOTAL_MB=0
LAST_RC=0

run_one_pass() {
    local target_root="$1"
    shift
    local -a targets=("$@")

    local args
    build_clamscan_args args

    local out_file
    out_file=$(mktemp)
    local start_ts end_ts duration
    start_ts=$(date +%s)

    nice -n "$NICE_LEVEL" \
        ionice -c "$IONICE_CLASS" -n "$IONICE_LEVEL" \
        clamscan "${args[@]}" "${targets[@]}" >"$out_file" 2>&1
    local rc=$?
    LAST_RC=$rc

    end_ts=$(date +%s)
    duration=$((end_ts - start_ts))

    # Emit one virus.detected per "FOUND" line.
    local infected_count=0
    while IFS= read -r line; do
        [[ "$line" != *": "*" FOUND" ]] && continue
        local body file signature
        body="${line% FOUND}"          # strip trailing " FOUND"
        signature="${body##*: }"       # after last ": "
        file="${body%: *}"             # everything before last ": "
        emit_json warning virus.detected \
            "$(jq -cn --arg file "$file" --arg sig "$signature" --arg tr "$target_root" \
                '{file:$file, signature:$sig, target_root:$tr}')"
        infected_count=$((infected_count + 1))
    done <"$out_file"

    # Parse SCAN SUMMARY footer.
    local files_scanned data_scanned_mb
    files_scanned=$(awk -F': *' '/^Scanned files/ {print $2; exit}' "$out_file")
    files_scanned=${files_scanned:-0}
    data_scanned_mb=$(awk -F'[: ]+' '/^Data scanned/ {print $3; exit}' "$out_file")
    data_scanned_mb=${data_scanned_mb:-0}

    THREATS_TOTAL=$((THREATS_TOTAL + infected_count))
    FILES_TOTAL=$((FILES_TOTAL + files_scanned))
    DATA_TOTAL_MB=$((DATA_TOTAL_MB + ${data_scanned_mb%.*}))

    # rc==2 = clamscan reported an error; surface it so a "clean" summary
    # doesn't hide it.
    if [[ $rc -eq 2 ]]; then
        local tail_msg
        tail_msg=$(tail -c 500 "$out_file" | tr '\n' ' ')
        emit_json err scan.error \
            "$(jq -cn --arg msg "$tail_msg" --arg tr "$target_root" --argjson rc "$rc" \
                '{rc:$rc, message:$msg, target_root:$tr}')"
    fi

    rm -f "$out_file"
}

# ---- Main --------------------------------------------------------------------
main() {
    local tool
    for tool in clamscan logger jq nice ionice; do
        if ! have "$tool"; then
            logger -t "$PROG" -p "${SYSLOG_FACILITY}.err" -- \
                "{\"event\":\"scan.error\",\"run_id\":\"${RUN_ID}\",\"message\":\"required tool missing: ${tool}\"}"
            exit 2
        fi
    done

    local overall_start overall_end duration
    overall_start=$(date +%s)

    local targets_arr extra_arr cross_fs_bool
    # shellcheck disable=SC2086
    targets_arr=$(printf '%s\n' $SCAN_TARGETS | jq -Rn '[inputs]')
    if [[ -n "$EXTRA_SCAN_TARGETS" ]]; then
        # shellcheck disable=SC2086
        extra_arr=$(printf '%s\n' $EXTRA_SCAN_TARGETS | jq -Rn '[inputs]')
    else
        extra_arr='[]'
    fi
    cross_fs_bool=$([[ "$CROSS_FS" == "yes" ]] && echo true || echo false)

    local high_risk_arr
    if [[ -n "$HIGH_RISK_TARGETS" ]]; then
        # shellcheck disable=SC2086
        high_risk_arr=$(printf '%s\n' $HIGH_RISK_TARGETS | jq -Rn '[inputs]')
    else
        high_risk_arr='[]'
    fi

    emit_json info scan.started \
        "$(jq -cn --argjson t "$targets_arr" --argjson e "$extra_arr" \
                  --argjson hr "$high_risk_arr" \
                  --argjson cf "$cross_fs_bool" --argjson pid "$$" \
            '{targets:$t, extra_targets:$e, high_risk_targets:$hr, cross_fs:$cf, pid:$pid}')"

    run_freshclam

    # Primary pass: SCAN_TARGETS as a single clamscan invocation.
    # shellcheck disable=SC2206
    local primary_targets=($SCAN_TARGETS)
    if [[ ${#primary_targets[@]} -gt 0 ]]; then
        run_one_pass "primary" "${primary_targets[@]}"
    fi

    # High-risk pass: commonly-targeted dirs (default /tmp /var/tmp /dev/shm)
    # that may live on a separate filesystem (e.g. tmpfs) and thus be skipped
    # by the primary pass when CROSS_FS=no. Each is scanned only if it isn't
    # already on the same filesystem as any primary target.
    if [[ -n "$HIGH_RISK_TARGETS" ]]; then
        local hr
        # shellcheck disable=SC2086
        for hr in $HIGH_RISK_TARGETS; do
            [[ ! -e "$hr" ]] && continue
            if [[ "$CROSS_FS" != "yes" ]] && ! same_fs_as_any "$hr" "${primary_targets[@]}"; then
                run_one_pass "high_risk:$hr" "$hr"
            elif [[ "$CROSS_FS" == "yes" ]]; then
                : # primary pass already crossed FS boundaries
            fi
        done
    fi

    # Extra passes: each target gets its own invocation so --cross-fs=no on
    # the primary root doesn't skip removable media the user opted into.
    if [[ -n "$EXTRA_SCAN_TARGETS" ]]; then
        local t
        # shellcheck disable=SC2086
        for t in $EXTRA_SCAN_TARGETS; do
            run_one_pass "extra:$t" "$t"
        done
    fi

    overall_end=$(date +%s)
    duration=$((overall_end - overall_start))

    if [[ $THREATS_TOTAL -gt 0 ]]; then
        emit_json warning scan.completed_with_threats \
            "$(jq -cn --argjson th "$THREATS_TOTAL" --argjson fs "$FILES_TOTAL" \
                      --argjson dm "$DATA_TOTAL_MB" --argjson du "$duration" \
                      --argjson rc "$LAST_RC" \
                '{threats:$th, files_scanned:$fs, data_scanned_mb:$dm, duration_sec:$du, clamscan_rc:$rc}')"
        exit 1
    fi

    emit_json info scan.completed_clean \
        "$(jq -cn --argjson fs "$FILES_TOTAL" --argjson dm "$DATA_TOTAL_MB" \
                  --argjson du "$duration" --argjson rc "$LAST_RC" \
            '{files_scanned:$fs, data_scanned_mb:$dm, duration_sec:$du, clamscan_rc:$rc}')"
}

main "$@"
