SELinux Confined ClamAV: Your "Passing" Scans May Never Have Read a File¶
On an SELinux Enforcing host, a cron-scheduled clamscan runs confined as antivirus_t — and a manually-run clamscan from your root shell runs unconfined. These are two different programs as far as policy is concerned. Every trap in this article comes from that split: the manual run you used to "verify" the deployment proves nothing about what cron will do at 3 AM.
Discovered on a Fedora mail server where months of exit-0 scan history turned out to be entirely manual verifications — the first genuinely confined run scanned 0 files across every target and got Permission denied on the maildir. The box had never actually been scanned.
Trap 1 — antivirus_can_scan_system defaults off¶
Confined antivirus_t may only read antivirus-owned types (antivirus_db_t, its logs, etc.) until the purpose-built boolean is enabled:
getsebool antivirus_can_scan_system # off = your confined scans read nothing
setsebool -P antivirus_can_scan_system on
With it on, policy grants antivirus_domain read on non_security_file_type — which is what lets it scan /home, /tmp, mail spools (mail_spool_t), and the rest of the system. With it off, the failure is quiet: clamscan reports Scanned files: 0 with a handful of open errors and may still exit 0 if nothing else goes wrong. Check the Scanned files: count in the summary, not just the exit code.
Proof commands:
sesearch -A -s antivirus_t -t non_security_file_type -c file -p read
# allow antivirus_domain non_security_file_type:file { getattr ioctl lock open read }; [ antivirus_can_scan_system ]:True
Trap 2 — the quarantine directory needs antivirus_db_t¶
If the wrapper uses --move=<quarantine dir> and that directory carries a generic label (var_t — the default for anything created under /var without an fcontext rule), confined clamscan can't create its lock file there and dies at startup with exit 2:
Fix with the type antivirus_t already manages, and persist it so a relabel can't revert it:
semanage fcontext -a -t antivirus_db_t "/var/quarantine/clamav(/.*)?"
restorecon -Rv /var/quarantine/clamav
In Ansible, file: setype: alone sets the label but records no policy rule — pair it with community.general.sefcontext (which needs policycoreutils-python-utils on the target).
Trap 3 — you cannot verify with runcon over SSH¶
The obvious verification — ssh host 'runcon system_u:system_r:antivirus_t:s0 /etc/cron.weekly/clamav-scan' — silently fails: a noninteractive SSH command runs as unconfined_service_t, which policy denies { transition } to confined domains. runcon exits 126, the scan never starts, and if you backgrounded it under nohup you'll sit watching a log that never grows.
Verify through the path that actually failed — real crond:
# one-shot cron entry 2 minutes out; crond → system_cronjob_t → auto-transition
# via antivirus_exec_t (prove it: sesearch -T -s system_cronjob_t -t antivirus_exec_t)
printf '%s %s * * * root /etc/cron.weekly/clamav-scan\n' "$(date -d '+2 min' +%M)" "$(date -d '+2 min' +%H)" \
> /etc/cron.d/clamav-verify-once
# after it fires: check the scan log for a real file count, ausearch -m avc -ts recent
# for zero new denials, then delete the cron.d file
The general lesson¶
A confined service's behavior can only be verified from its confined context. Success criteria that survive this class of bug: a plausible Scanned files: count (not 0), exit code per contract, and no new AVC denials — all from a run entered through the real scheduler path.
Related¶
- clamav-weekly-scan-false-alerts — exit-code contract pitfalls in the same wrapper
- selinux-dovecot-vmail-context — the same "generic label breaks the confined daemon" shape for mail storage