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 }}"
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):
- Stop reading
exit 2as "no coverage" when files were actually scanned. IfScanned filesin the summary is nonzero, downgrade the alert from a hard failure to a[WARN] ClamAV weekly scan completed with errorsnotice — still visible, but no longer indistinguishable from mode 2's "this host scans nothing." - 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 thedb/(andobject/,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.
Related¶
- ClamAV Fleet Deployment with Ansible — the role these fixes live in
- ClamAV CPU Spike: Safe Scheduling with nice/ionice — resource throttling for the same weekly scan
- Freshclam Logwatch False "No Updates" — the signature-update side of ClamAV false alarms