ClamAV - Hardening version 3

ClamAV - Hardening version 3
Linux Hardening

Well, its that time of year to review all this again.

Linux is not getting any safer. Below is the script after a few teething bugs, put through the AI and further enhanced. It runs on all laptops within this house.

Please note that the script hardens an existing installation and does not do the installation itself. Please refer to standard documentation on how to do that.

Hardening Script

As per below

#!/bin/bash

# ─────────────────────────────────────────────────────────────────────────────
# setup-clamav.sh
# Harden ClamAV configuration for Debian / Linux Mint systems
# ─────────────────────────────────────────────────────────────────────────────
#
# PURPOSE
#   Applies a hardened, best-practice ClamAV configuration focused on
#   real-time workstation protection, home directory defence, and
#   scheduled full-system sweeps. Designed for Linux Mint laptops.
#
# BEHAVIOUR
#   - Self-contained: resets ClamAV config files to a known-good baseline
#     before applying settings — safe for first run and re-runs alike
#   - Idempotent: safely re-runnable; existing config is backed up then
#     replaced with a clean copy before directives are applied
#   - Supports --dry-run mode: validates what would change without touching
#     any files or services  →  ./setup-clamav.sh --dry-run
#   - Must be run as root
#   - Audit-style section comments included for compliance traceability
#
# UPDATE STRATEGY
#   Signature updates are managed entirely by the clamav-freshclam systemd
#   service — NOT by cron. This avoids log-lock conflicts caused by multiple
#   freshclam processes competing for the log file.
#
# PRE-REQUISITES
#   - ClamAV packages installed: clamav-daemon, clamav-freshclam
#   - Kernel with fanotify support (Linux Mint 20+ / kernel 5.4+) for on-access scanning
#   - Local MTA (Postfix) installed and configured for email notifications
#
# PROTECTION COVERAGE
#   Real-time (on-access, BLOCKING):  /home  /tmp  /var/tmp  /root
#   Quarantine destination:           /var/quarantine/clamav  (mode 0700)
#   Signature updates:                Every 4 hours via freshclam systemd service
#
# Version: 3.1.0
# Updated: 25-03-2026
#
# Change Log:
#   v3.1.0 — 25-03-2026
#   - ADDED: systemd failure notification via notify-failure@.service template
#   - OnFailure= drop-ins installed for clamav-daemon and clamav-freshclam
#   - Failure emails sent via local MTA (Postfix) — silent on success
#   - notify-failure@.service is reusable for any other systemd unit
# ─────────────────────────────────────────────────────────────────────────────

set -euo pipefail

# --- Output helpers ---
info()    { echo -e "\033[0;32m[INFO]\033[0m  $*"; }
warn()    { echo -e "\033[0;33m[WARN]\033[0m  $*" >&2; }
error()   { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
section() { echo -e "\n\033[1;34m>>> $* \033[0m"; }

# --- Root privilege check ---
if [[ "${EUID}" -ne 0 ]]; then
    error "This script must be run as root."
    exit 1
fi

DRY_RUN=false
if [[ "${1:-}" == "--dry-run" ]]; then
    DRY_RUN=true
    info "Dry-run mode enabled: no changes will be applied."
fi

# --- Paths ---
CLAMD_CONF="/etc/clamav/clamd.conf"
FRESHCLAM_CONF="/etc/clamav/freshclam.conf"
LOGFILE="/var/log/clamav/harden_clamav.log"
QUARANTINE_DIR="/var/quarantine/clamav"
SCAN_LOG="/var/log/clamav/daily_scan.log"
BACKUP_DIR="/etc/clamav/backups/$(date +%Y%m%d_%H%M%S)"

# --- Ensure log dir and quarantine dir exist ---
if ! $DRY_RUN; then
    mkdir -p "$(dirname "$LOGFILE")" "$QUARANTINE_DIR"
    chmod 0700 "$QUARANTINE_DIR"
    chown root:root "$QUARANTINE_DIR"
    touch "$LOGFILE" "$SCAN_LOG"
    chown clamav:clamav "$LOGFILE" "$SCAN_LOG"
    chmod 0640 "$LOGFILE" "$SCAN_LOG"
fi

# ─────────────────────────────────────────────────────────────────────────────
# ensure_config: idempotently set a directive in a config file.
# Handles the case where the key already exists (replaces) or is absent (appends).
# ─────────────────────────────────────────────────────────────────────────────
ensure_config() {
    local file="$1"
    local key="$2"
    local value="$3"

    if grep -qE "^[[:space:]]*${key}[[:space:]]" "$file"; then
        if $DRY_RUN; then
            info "[DRY] Would update: $key → $value  ($file)"
        else
            sed -i "s|^[[:space:]]*${key}[[:space:]].*|${key} ${value}|" "$file"
            info "Updated: $key = $value"
        fi
    else
        if $DRY_RUN; then
            info "[DRY] Would add: $key $value  ($file)"
        else
            echo "${key} ${value}" >> "$file"
            info "Added: $key = $value"
        fi
    fi
}

# ─────────────────────────────────────────────────────────────────────────────
# ensure_multivalue: idempotently add a directive that may appear multiple times
# (e.g. OnAccessIncludePath, ExcludePath, OnAccessExcludePath).
# Uses exact string matching — will not add a duplicate line.
# ─────────────────────────────────────────────────────────────────────────────
ensure_multivalue() {
    local file="$1"
    local directive="$2"
    local value="$3"

    if ! grep -qF "${directive} ${value}" "$file"; then
        if $DRY_RUN; then
            info "[DRY] Would add: ${directive} ${value}"
        else
            echo "${directive} ${value}" >> "$file"
            info "Added: ${directive} ${value}"
        fi
    else
        info "Already present: ${directive} ${value}"
    fi
}

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 0 — Stop services and back up existing config
#
# We stop both services before touching config files, then reset each config
# to the vendor-supplied default. This guarantees no stale/conflicting
# directives carry over from previous runs or manual edits.
#
# The vendor defaults are at /usr/share/doc/clamav-daemon/examples/ and
# /usr/share/doc/clamav-freshclam/examples/ — but dpkg-reconfigure is the
# cleanest way to regenerate known-good minimal configs.
# ─────────────────────────────────────────────────────────────────────────────
section "Stopping ClamAV services"

if ! $DRY_RUN; then
    info "Stopping clamav-daemon and clamav-freshclam..."
    systemctl stop clamav-daemon.service    2>/dev/null || true
    systemctl stop clamav-freshclam.service 2>/dev/null || true
    sleep 1

    # Kill any stale freshclam processes (prevents log lock on restart)
    if pgrep -x freshclam >/dev/null 2>&1; then
        warn "Stale freshclam process found — terminating..."
        pkill -x freshclam || true
        sleep 1
    fi

    # Remove any stale lock / pid files
    rm -f /var/run/clamav/freshclam.pid
    rm -f /var/run/clamav/clamd.pid
    info "Stale PID files cleared."
else
    info "[DRY] Would stop clamav-daemon and clamav-freshclam services"
    info "[DRY] Would kill any stale freshclam processes"
    info "[DRY] Would remove stale PID files"
fi

section "Backing up existing configuration"

if ! $DRY_RUN; then
    mkdir -p "$BACKUP_DIR"
    [[ -f "$CLAMD_CONF"     ]] && cp "$CLAMD_CONF"     "${BACKUP_DIR}/clamd.conf.bak"     && info "Backed up: $CLAMD_CONF → ${BACKUP_DIR}/clamd.conf.bak"
    [[ -f "$FRESHCLAM_CONF" ]] && cp "$FRESHCLAM_CONF" "${BACKUP_DIR}/freshclam.conf.bak" && info "Backed up: $FRESHCLAM_CONF → ${BACKUP_DIR}/freshclam.conf.bak"
else
    info "[DRY] Would back up $CLAMD_CONF and $FRESHCLAM_CONF to $BACKUP_DIR"
fi

section "Resetting configuration files to vendor baseline"

if ! $DRY_RUN; then
    info "Regenerating clamd.conf and freshclam.conf via dpkg-reconfigure..."
    # Non-interactive reconfigure restores vendor defaults without prompts
    DEBIAN_FRONTEND=noninteractive dpkg-reconfigure clamav-daemon    2>/dev/null || true
    DEBIAN_FRONTEND=noninteractive dpkg-reconfigure clamav-freshclam 2>/dev/null || true
    info "Config files reset to vendor defaults."
else
    info "[DRY] Would run dpkg-reconfigure clamav-daemon and clamav-freshclam"
fi

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 1 — Socket Permissions
# ─────────────────────────────────────────────────────────────────────────────
section "Socket Permissions"
ensure_config "$CLAMD_CONF" "LocalSocketMode"  "660"
ensure_config "$CLAMD_CONF" "LocalSocketGroup" "clamav"
ensure_config "$CLAMD_CONF" "PidFile"          "/var/run/clamav/clamd.pid"

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 2 — Logging
# ─────────────────────────────────────────────────────────────────────────────
section "Logging"
ensure_config "$CLAMD_CONF" "LogFile"        "/var/log/clamav/clamav.log"
ensure_config "$CLAMD_CONF" "LogFileMaxSize" "52428800"   # 50 MB
ensure_config "$CLAMD_CONF" "LogTime"        "yes"
ensure_config "$CLAMD_CONF" "LogVerbose"     "yes"
ensure_config "$CLAMD_CONF" "LogSyslog"      "yes"
ensure_config "$CLAMD_CONF" "LogFacility"    "LOG_LOCAL6"
ensure_config "$CLAMD_CONF" "LogRotate"      "yes"

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 3 — ExcludePath (daemon)
# Excludes virtual/kernel filesystems and container storage to avoid false
# positives and unnecessary CPU load. NOT excluded from on-access scanning.
# ─────────────────────────────────────────────────────────────────────────────
section "ExcludePath Directives"
for path in \
    "/proc/" \
    "/sys/" \
    "/dev/" \
    "/run/" \
    "/var/lib/docker/" \
    "/var/lib/containers/" \
    "/var/lib/mysql/" \
    "/var/lib/postgresql/" \
    "/var/cache/apt/archives/" \
    "$QUARANTINE_DIR/"; do
    ensure_multivalue "$CLAMD_CONF" "ExcludePath" "$path"
done

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 4 — Alert Settings
# ─────────────────────────────────────────────────────────────────────────────
section "Alert Settings"
for key in \
    AlertEncryptedArchive \
    AlertEncryptedDoc \
    AlertOLE2Macros \
    AlertPhishingSSLMismatch \
    AlertPhishingCloak \
    AlertBrokenExecutables \
    AlertBrokenMedia \
    AlertPartitionIntersection; do
    ensure_config "$CLAMD_CONF" "$key" "yes"
done

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 5 — Database Hygiene
# OfficialDatabaseOnly is 'no' to allow third-party signature databases
# (e.g. Sanesecurity, URLhaus). Set to 'yes' if using ONLY official DBs.
# ─────────────────────────────────────────────────────────────────────────────
section "Database Hygiene"
ensure_config "$CLAMD_CONF" "OfficialDatabaseOnly"     "no"
ensure_config "$CLAMD_CONF" "FailIfCvdOlderThan"       "7"
ensure_config "$CLAMD_CONF" "ConcurrentDatabaseReload" "yes"
ensure_config "$CLAMD_CONF" "SelfCheck"                "1800"  # 30-minute self-check interval

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 6 — Detection Refinements
# AlgorithmicDetection was deprecated in ClamAV 0.101 — do not use it.
# HeuristicAlerts is the correct replacement.
# ─────────────────────────────────────────────────────────────────────────────
section "Detection Refinements"
ensure_config "$CLAMD_CONF" "DetectPUA"              "yes"
ensure_config "$CLAMD_CONF" "HeuristicAlerts"         "yes"
ensure_config "$CLAMD_CONF" "HeuristicScanPrecedence" "yes"

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 7 — File & Scan Limits
# Balanced for typical laptop workloads.
# ─────────────────────────────────────────────────────────────────────────────
section "File and Scan Limits"
ensure_config "$CLAMD_CONF" "MaxScanSize"         "209715200"  # 200 MB
ensure_config "$CLAMD_CONF" "MaxFileSize"          "52428800"   # 50 MB
ensure_config "$CLAMD_CONF" "MaxRecursion"         "16"
ensure_config "$CLAMD_CONF" "MaxFiles"             "15000"
ensure_config "$CLAMD_CONF" "MaxEmbeddedPE"        "10485760"   # 10 MB
ensure_config "$CLAMD_CONF" "MaxHTMLNormalize"     "10485760"
ensure_config "$CLAMD_CONF" "MaxHTMLNoTags"        "2097152"
ensure_config "$CLAMD_CONF" "MaxScriptNormalize"   "5242880"
ensure_config "$CLAMD_CONF" "MaxZipTypeRcg"        "1048576"
ensure_config "$CLAMD_CONF" "OnAccessMaxFileSize"  "26214400"   # 25 MB

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 8 — On-Access Scanning (real-time workstation protection)
#
# OnAccessPrevention BLOCKS access to detected files rather than just alerting.
# Requires kernel fanotify support (Linux Mint 20+ / kernel 5.4+).
# clamav-daemon must run as root for fanotify to work.
#
# NOTE: OnAccessExtraScanning was permanently disabled in ClamAV 0.100.2 due
# to a kernel resource cleanup bug — omitted to avoid spurious warnings.
# ─────────────────────────────────────────────────────────────────────────────
section "On-Access Scanning (Home Directory Protection)"

# Clear all existing OnAccessIncludePath entries for a clean rewrite
if ! $DRY_RUN; then
    if grep -qE "^[[:space:]]*OnAccessIncludePath[[:space:]]" "$CLAMD_CONF"; then
        sed -i "/^[[:space:]]*OnAccessIncludePath[[:space:]]/d" "$CLAMD_CONF"
        info "Cleared existing OnAccessIncludePath entries for clean rewrite."
    fi
fi

for oa_path in "/home" "/tmp" "/var/tmp" "/root"; do
    ensure_multivalue "$CLAMD_CONF" "OnAccessIncludePath" "$oa_path"
done

ensure_config "$CLAMD_CONF" "OnAccessPrevention"  "yes"
ensure_config "$CLAMD_CONF" "OnAccessExcludeUID"  "0"   # Prevents clamd scanning its own writes

for path in "/proc" "/sys" "/dev" "/run" "$QUARANTINE_DIR"; do
    ensure_multivalue "$CLAMD_CONF" "OnAccessExcludePath" "$path"
done

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 9 — Freshclam (signature updates via systemd service)
#
# UPDATE STRATEGY: clamav-freshclam.service manages all signature updates.
# No cron job is used. This eliminates the log-lock conflict caused by
# multiple freshclam processes (systemd daemon + cron job) competing for
# /var/log/clamav/freshclam.log simultaneously.
#
# Checks 6 = freshclam checks for new signatures 6 times per day (every 4 hours).
# This matches Sanesecurity's recommended mirror polling frequency and stays within
# URLhaus (abuse.ch) rate limits. Do not increase above 6 in fleet deployments.
# ─────────────────────────────────────────────────────────────────────────────
section "Freshclam Settings"
ensure_config "$FRESHCLAM_CONF" "Checks"         "6"
ensure_config "$FRESHCLAM_CONF" "MaxAttempts"    "5"
ensure_config "$FRESHCLAM_CONF" "ConnectTimeout" "30"
ensure_config "$FRESHCLAM_CONF" "ReceiveTimeout" "30"
ensure_config "$FRESHCLAM_CONF" "LogVerbose"     "yes"
ensure_config "$FRESHCLAM_CONF" "LogSyslog"      "yes"
ensure_config "$FRESHCLAM_CONF" "LogFacility"    "LOG_LOCAL6"
ensure_config "$FRESHCLAM_CONF" "NotifyClamd"    "$CLAMD_CONF"  # Triggers live DB reload after update


# ─────────────────────────────────────────────────────────────────────────────
# SECTION 9b — Third-Party Signatures via DatabaseCustomURL
#
# Freshclam natively supports pulling third-party signature databases via
# DatabaseCustomURL. OfficialDatabaseOnly must be 'no' (set in Section 5).
#
# SOURCES:
#   Sanesecurity  — active since 2006, hourly updates, free. Covers phishing,
#                   malware, macros, spam and zero-day threats.
#   URLhaus       — abuse.ch project, updated frequently. Active malware
#                   distribution URLs. Free, no account required.
#
# FALSE POSITIVE RISK: Only Low FP risk databases are enabled below.
# Medium/High FP databases are commented out — enable with caution.
#
# MEMORY: Each database increases clamd RAM usage ~150-250 MB total for
# the selection below. Monitor with: systemctl status clamav-daemon
# ─────────────────────────────────────────────────────────────────────────────
section "Third-Party Signatures (DatabaseCustomURL)"

SANESEC_MIRROR="https://mirror.rollernet.us/sanesecurity"

# --- Required Sanesecurity support files ---
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/sanesecurity.ftm"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/sigwhitelist.ign2"

# --- Low FP risk: Sanesecurity core databases ---
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/junk.ndb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/jurlbl.ndb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/phish.ndb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/rogue.hdb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/scam.ndb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/spamimg.hdb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/spamattach.hdb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/blurl.ndb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/malwarehash.hsb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/hackingteam.hsb"

# --- Low FP risk: Foxhole (executable/archive content detection) ---
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/foxhole_generic.cdb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/foxhole_filename.cdb"

# --- Low FP risk: OITC winnow databases ---
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/winnow_malware.hdb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/winnow_malware_links.ndb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/winnow_extended_malware.hdb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/winnow.attachments.hdb"
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "${SANESEC_MIRROR}/winnow_bad_cw.hdb"

# --- URLhaus: active malware distribution URLs (abuse.ch) — Low FP risk ---
ensure_multivalue "$FRESHCLAM_CONF" "DatabaseCustomURL" "https://urlhaus.abuse.ch/downloads/urlhaus.ndb"

# --- MEDIUM FP RISK — disabled by default, uncomment to enable ---
# DatabaseCustomURL ${SANESEC_MIRROR}/jurlbla.ndb
# DatabaseCustomURL ${SANESEC_MIRROR}/lott.ndb
# DatabaseCustomURL ${SANESEC_MIRROR}/spam.ldb
# DatabaseCustomURL ${SANESEC_MIRROR}/badmacro.ndb
# DatabaseCustomURL ${SANESEC_MIRROR}/shelter.ldb
# DatabaseCustomURL ${SANESEC_MIRROR}/spear.ndb
# DatabaseCustomURL ${SANESEC_MIRROR}/spearl.ndb
# DatabaseCustomURL ${SANESEC_MIRROR}/foxhole_js.cdb
# DatabaseCustomURL ${SANESEC_MIRROR}/foxhole_js.ndb
# DatabaseCustomURL ${SANESEC_MIRROR}/winnow_spam_complete.ndb
# DatabaseCustomURL ${SANESEC_MIRROR}/winnow_phish_complete_url.ndb
# DatabaseCustomURL ${SANESEC_MIRROR}/winnow.complex.patterns.ldb
# DatabaseCustomURL ${SANESEC_MIRROR}/winnow_extended_malware_links.ndb

# --- HIGH FP RISK — not recommended for workstations ---
# DatabaseCustomURL ${SANESEC_MIRROR}/foxhole_all.cdb
# DatabaseCustomURL ${SANESEC_MIRROR}/foxhole_all.ndb
# DatabaseCustomURL ${SANESEC_MIRROR}/foxhole_mail.cdb
# DatabaseCustomURL ${SANESEC_MIRROR}/winnow_phish_complete.ndb

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 10 — Remove legacy cron job (if present from earlier script versions)
#
# Previous versions of this script installed a cron job at /etc/cron.d/clamav-scheduled
# to manage freshclam updates. This caused log-lock conflicts when both the cron job
# and the systemd daemon attempted to run freshclam simultaneously.
# This section removes that file if it exists.
# ─────────────────────────────────────────────────────────────────────────────
section "Removing legacy cron job (if present)"

LEGACY_CRON="/etc/cron.d/clamav-scheduled"

if [[ -f "$LEGACY_CRON" ]]; then
    if $DRY_RUN; then
        warn "[DRY] Would remove legacy cron file: $LEGACY_CRON"
    else
        rm -f "$LEGACY_CRON"
        warn "Removed legacy cron file: $LEGACY_CRON"
    fi
else
    info "No legacy cron file found — nothing to remove."
fi



# ─────────────────────────────────────────────────────────────────────────────
# SECTION 11 — Enable and start services
#
# clamav-freshclam.service: manages signature updates (Checks 24 = hourly)
# clamav-daemon.service:    provides on-access scanning and clamdscan socket
#
# freshclam runs first to ensure a current signature database exists before
# the daemon starts. If the daemon starts with a missing/outdated DB and
# FailIfCvdOlderThan is set, it will refuse to start.
# ─────────────────────────────────────────────────────────────────────────────
section "Enabling and starting ClamAV services"

if $DRY_RUN; then
    info "[DRY] Would enable and start clamav-freshclam.service"
    info "[DRY] Would run an initial freshclam update"
    info "[DRY] Would enable and start clamav-daemon.service"
else
    info "Enabling clamav-freshclam.service..."
    systemctl enable clamav-freshclam.service

    info "Running initial freshclam update (this may take a moment)..."
    freshclam --quiet || warn "Initial freshclam update failed — daemon may start with existing DB if present."

    info "Starting clamav-freshclam.service..."
    systemctl start clamav-freshclam.service

    sleep 2

    if systemctl is-active --quiet clamav-freshclam.service; then
        info "SUCCESS: clamav-freshclam.service is active."
    else
        warn "clamav-freshclam.service did not start cleanly."
        systemctl status clamav-freshclam.service || true
    fi

    info "Enabling clamav-daemon.service..."
    systemctl enable clamav-daemon.service

    info "Starting clamav-daemon.service..."
    if systemctl start clamav-daemon.service; then
        sleep 2
        if systemctl is-active --quiet clamav-daemon.service; then
            info "SUCCESS: clamav-daemon.service is active."
        else
            error "clamav-daemon failed to start."
            systemctl status clamav-daemon.service
            exit 1
        fi
    else
        error "Failed to start clamav-daemon."
        exit 1
    fi
fi

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 12 — EICAR test (functional validation)
# ─────────────────────────────────────────────────────────────────────────────
section "EICAR Functional Test"

if $DRY_RUN; then
    info "[DRY] Would run EICAR detection test against clamd."
else
    EICAR_FILE="/tmp/eicar_test_$$.txt"
    python3 -c "open('${EICAR_FILE}','w').write('X5O!P%@AP[4\\PZX54(P^)7CC)7}\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\$H+H*')"
    sleep 1
    info "Running EICAR test against clamd..."
    SCAN_RESULT=$(clamdscan --fdpass --no-summary "$EICAR_FILE" 2>&1 || true)
    if echo "$SCAN_RESULT" | grep -q "FOUND"; then
        info "EICAR test: PASSED — clamd detected test signature correctly."
    else
        warn "EICAR test: FAILED or clamd not yet responding."
        warn "Scan output: $SCAN_RESULT"
        warn "Manual test: clamdscan --fdpass /tmp/eicar_test.txt"
    fi
    rm -f "$EICAR_FILE"
fi

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 13 — Systemd Failure Notification
#
# Installs a reusable service template: notify-failure@.service
# When any monitored unit fails, systemd instantiates this template with the
# failed unit name and sends a failure email via the local MTA (Postfix).
#
# The template uses %i (the instance name) to identify which service failed.
# OnFailure= drop-in overrides are written for both ClamAV units — these are
# placed in /etc/systemd/system/<unit>.d/override.conf so they survive
# package updates without modifying vendor unit files.
#
# EMAIL DESTINATION: root  (Postfix alias — redirect in /etc/aliases as needed)
# SILENT ON SUCCESS: no email is sent for normal operation
# REUSABLE: notify-failure@.service can be referenced by any other unit
# ─────────────────────────────────────────────────────────────────────────────
section "Systemd Failure Notification"

NOTIFY_UNIT="/etc/systemd/system/notify-failure@.service"
NOTIFY_CONTENT="[Unit]
Description=Failure notification for %i
After=network.target

[Service]
Type=oneshot
ExecStart=/bin/bash -c '\
    UNIT=%i; \
    HOST=\$(hostname -f); \
    TIMESTAMP=\$(date \"+%%Y-%%m-%%d %%H:%%M:%%S\"); \
    JOURNAL=\$(journalctl -u \"\${UNIT}\" -n 50 --no-pager 2>/dev/null || echo \"Journal unavailable\"); \
    printf \"Subject: [ClamAV FAILURE] \${UNIT} failed on \${HOST}\\nTo: root\\nContent-Type: text/plain\\n\\nService failure alert\\n======================\\nHost:      \${HOST}\\nUnit:      \${UNIT}\\nTime:      \${TIMESTAMP}\\n\\nLast 50 journal lines:\\n\\n\${JOURNAL}\\n\" \
    | /usr/sbin/sendmail -t'
"

if $DRY_RUN; then
    info "[DRY] Would install notify-failure@.service template at $NOTIFY_UNIT"
else
    printf "%s" "$NOTIFY_CONTENT" > "$NOTIFY_UNIT"
    chmod 0644 "$NOTIFY_UNIT"
    info "Installed: $NOTIFY_UNIT"
fi

# Install OnFailure= drop-in overrides for both ClamAV units
for UNIT in clamav-daemon.service clamav-freshclam.service; do
    DROPIN_DIR="/etc/systemd/system/${UNIT}.d"
    DROPIN_FILE="${DROPIN_DIR}/notify-on-failure.conf"
    DROPIN_CONTENT="[Unit]
OnFailure=notify-failure@%n.service
"
    if $DRY_RUN; then
        info "[DRY] Would install OnFailure drop-in for ${UNIT} at ${DROPIN_FILE}"
    else
        mkdir -p "$DROPIN_DIR"
        printf "%s" "$DROPIN_CONTENT" > "$DROPIN_FILE"
        chmod 0644 "$DROPIN_FILE"
        info "Installed drop-in: $DROPIN_FILE"
    fi
done

# Reload systemd to pick up the new unit and drop-ins
if $DRY_RUN; then
    info "[DRY] Would run systemctl daemon-reload"
else
    systemctl daemon-reload
    info "systemctl daemon-reload complete."
fi


# End of script

Warning

If you let the official ClamAV database, get older than 7 days - the daemon won't start

To fix this run the following commands. This will pull a fresh version of the database and restart the daemon.

  1. sudo freshclam
  2. sudo systemctl start clamav-daemon.service && systemctl status clamav-daemon.service

Changes

  1. Refer to readme top of file. Problems were discovered and rectified

Hope this helps

Now back to the LXC Application server. I found another hole. Rather annoyed.

On the plus side - the LXC proxy script worked a treat. About 15 minutes to rebuild and restore from the Zoraxy backup file. Version bumped to latest.

#enoughsaid