Skip to content

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:

action_setup: Failed to create quarantine lock file in /var/quarantine/clamav: Permission denied

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.