Skip to content

ClamAV Weekly Scan False Alerts: Exit-Code Pitfalls

A cron-driven clamscan wrapper that mails alerts based on exit codes has a narrow contract: exit 0 = clean, exit 1 = infected files found, exit ≥2 = the scan itself failed. Four distinct failure modes — all hit in production here — can break that contract and produce alerts that are either false or silently absent. This article is the triage guide for each.

First: triage an "infected files" alert

Before assuming malware, verify a scan actually happened:

# 1. Did the scan take real time? A recursive scan of /home /var/www /tmp /root
#    takes minutes. Start == finish in the same second means it never ran.
journalctl | grep -E "cron.weekly.*clamav-scan"

# 2. Is anything actually in quarantine?
ls -la /var/quarantine/clamav/

# 3. Does the scan log exist and contain a SCAN SUMMARY block?
tail -20 /var/log/clamav/weekly-scan.log

An alert email with an empty body, an empty quarantine, and a zero-second scan is not an infection — it is failure mode 1 below.

Failure mode 1: missing log dir → exit 1 misread as "virus found"

Symptom: [ALERT] ClamAV found infected files on <host> with an empty body.

In bash, a failed output redirection makes the command fail with exit 1 without running it:

LOG=/var/log/clamav/weekly-scan.log
clamscan -r --infected /home >> "$LOG" 2>&1   # /var/log/clamav missing
echo $?                                        # → 1, clamscan never executed

Exit 1 is exactly the code the mailer maps to "infections found", and the alert body — cat "$LOG" — is empty because the log doesn't exist either.

Why the dir goes missing: Fedora's clamav package does not ship /var/log/clamav (freshclam/clamd log to the journal by default). On a freshly provisioned host, nothing creates it unless your automation does. Seen live on a mail host 4 days after the ClamAV role was deployed there: the host had zero scan coverage the whole time while appearing configured.

Fix (both layers):

# Ansible role — guarantee the log path, SELinux-aware
- name: Create ClamAV log directory
  ansible.builtin.file:
    path: /var/log/clamav
    state: directory
    owner: root
    group: root
    mode: '0755'
    setype: "{{ 'antivirus_log_t' if ansible_selinux.status | default('disabled') == 'enabled' else omit }}"
# Script backstop — create both dirs the script writes to
mkdir -p "$QUARANTINE" "${LOG%/*}"

On SELinux-Enforcing hosts, a bare mkdir inherits the parent's var_log_t; the correct label is antivirus_log_t (restorecon /var/log/clamav after a manual mkdir).

Failure mode 2: missing target path → exit 2 "scan FAILED" every week

Symptom: weekly scan FAILED (exit 2) alerts from a host that scans fine, log shows WARNING: /var/www: Can't access file and a normal SCAN SUMMARY with 0 infected.

clamscan (and clamdscan) exit 2 if any target path doesn't exist — even when every other path scanned clean. A fleet-standard target list (/home /var/www /tmp /root) breaks on mail-only hosts with no /var/www.

Fix: scan only paths that exist, and log what was skipped so a legitimately-missing path is still visible:

TARGETS=""
for p in /home /var/www /tmp /root; do
  if [ -d "$p" ]; then
    TARGETS="$TARGETS $p"
  else
    echo "Skipping missing path: $p" >> "$LOG"
  fi
done

if [ -z "$TARGETS" ]; then
  echo "ERROR: no scan targets exist" >> "$LOG"
  EXIT=2
else
  clamscan --recursive --infected --move="$QUARANTINE" $TARGETS >> "$LOG" 2>&1
  EXIT=$?
fi

$TARGETS is deliberately unquoted (word-splitting into separate arguments); the empty-list guard keeps "nothing to scan at all" a real, alerting failure.

Failure mode 3: alerting only on exit 1 → dead scans are silent

Symptom: nothing. That's the problem.

A wrapper that mails only on exit 1 treats every exit ≥2 as success-by-silence. Two fleet hosts ran 7 weeks (2026-05-24 → 2026-07-05) with zero scan coverage — failing in under 2 seconds every Sunday — before it was noticed, and only then via an unrelated CPU alert.

Fix: distinct alert branches for exit 1 (infection, mail the full log) and exit ≥2 (scan failure, mail the tail of the log and say explicitly the host has no coverage until fixed).

Failure mode 4: a file vanishes mid-scan → exit 2 misread as "scan FAILED", even though the scan was complete and clean

Symptom: scan FAILED (exit 2) from a host whose log shows a full SCAN SUMMARY block with Infected files: 0 and a normal scan duration — unlike mode 2, every target path exists and was scanned.

clamscan walks a directory in two steps: it lists the files present, then opens each one. If a file is deleted in between — by the application that owns it, not by anything adversarial — clamscan logs Failed open <name> and still exits 2, exactly as it does for a genuinely missing target path. A wrapper that treats "exit ≥2" as one undifferentiated "no coverage" failure (mode 3's fix) cannot tell "a target directory doesn't exist" apart from "every file scanned, one was a moving target."

Root cause here: a WordPress object-cache plugin (W3 Total Cache) writes and deletes files in wp-content/cache/db/options/ continuously — a two-file disk cache rewritten within minutes of every scan pass. The weekly scan caught it mid-rewrite:

traverse_to: Failed open 1e8355afc984189e0ae7dc7fc6108821.php
WARNING: Can't open file /var/www/html/wp-content/cache/db/options/1e8/355/1e8355afc984189e0ae7dc7fc6108821.php for safe quarantine action: Can't open file or directory

48,092 other files scanned clean in the same run — this was not a dead scan, but the exit code alone couldn't say so. The race window exists for as long as the cache drop-ins are live and will recur at an unpredictable cadence; it is not a one-time fluke to retry past.

Fix (two parts — don't stop at the first):

  1. Stop reading exit 2 as "no coverage" when files were actually scanned. If Scanned files in the summary is nonzero, downgrade the alert from a hard failure to a [WARN] ClamAV weekly scan completed with errors notice — still visible, but no longer indistinguishable from mode 2's "this host scans nothing."
  2. Exclude the specific churning path, not the whole web root. The first instinct — exclude all of wp-content/cache — swept in 15,518 files (415 MB) that scan cleanly every week and never churn; only the db/ (and object/, tmp/) subdirectories are the actual moving target:
# clamav_exclude_dirs, scoped to the churning subdirectories only
clamav_exclude_dirs: '/var/www/html/wp-content/cache/(db|object|tmp)$'
clamscan --recursive --infected --exclude-dir="$CLAMAV_EXCLUDE_DIRS" --move="$QUARANTINE" $TARGETS >> "$LOG" 2>&1

A blanket exclude of the whole cache tree would have silently dropped scan coverage over a third of the site's files to fix a two-file race — same shape of mistake as scanning paths that don't exist (mode 2), just inverted: here the fix itself can quietly remove coverage if scoped too wide.

Before trusting an exit-2 alert as a dead scan, check whether this is actually mode 2 or mode 4: a SCAN SUMMARY block with a nonzero Scanned files count and a Failed open/Can't open file warning (not a Can't access file on a directory) is mode 4 — a live scan that lost a race with another process, not a scan that never ran.

The contract, restated

Exit Meaning But only if...
0 Clean scan —
1 Infected files found the log dir existed (mode 1) — verify quarantine + scan duration
≥2 Scan failed, no coverage — or a file vanished mid-scan (mode 4, check Scanned files) you actually alert on it (mode 3), targets were pre-filtered (mode 2), and the alert distinguishes a dead scan from a lost race (mode 4)

The meta-lesson: an exit code is only meaningful if the process that produced it was the one you think ran. Verify with the journal timestamps, the quarantine dir, and the log — in that order — before trusting any single signal.