# clamav-icap (local dev)

This container is the local-dev attachment scanner. It speaks ICAP on
TCP `1344` and is the only thing the worker ever talks to for virus
scanning -- there is no separate `clamd` container, no TCP fan-out, no
shared volumes between services. One process, one port, one signature
database.

It's opt-in. The default `docker compose up` doesn't pull it, because
the upstream signature database is hungry: ~1 GiB of RAM, ~400 MiB on
disk, and a first-boot download from `database.clamav.net` that runs
5–10 minutes over the open internet. Most days you don't want any of
that idling on your laptop.

## Spinning it up

```bash
nox -s "dev(slim)" -- attachment-scanning
# or, raw compose:
docker compose --profile attachment-scanning up -d clamav-icap
```

The first time the container comes up it sits on `(health: starting)`
for several minutes while `freshclam` populates `/var/lib/clamav`. The
DB is persisted in a named volume called `clamav`, so every subsequent
boot is fast (~5 seconds) until you `docker volume rm` it.

## Confirming it's actually working

Once `docker ps` shows `clamav-icap` as `(healthy)`:

```bash
# Same probe the healthcheck runs -- proves c-icap is up.
docker exec clamav-icap c-icap-client -i 127.0.0.1 -p 1344 -s echo -no204

# End-to-end: hand the ICAP server a real file and watch it scan.
# /etc/passwd is harmless, so you'll get an ALLOW back.
docker exec clamav-icap c-icap-client \
    -i 127.0.0.1 -p 1344 \
    -s avscan \
    -f /etc/passwd \
    -req http://example.test/upload
```

To actually trigger a hit, the EICAR test string is the canonical
"fake virus":

```bash
docker exec clamav-icap sh -c 'printf %s "X5O!P%@AP[4\PZX54(P^)7CC)7}\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\$H+H*" > /tmp/eicar && c-icap-client -i 127.0.0.1 -p 1344 -s avscan -f /tmp/eicar -req http://example.test/upload'
```

The response should include `X-Infection-Found:` with
`Threat=Eicar-Test-Signature`.

## What this thing costs you

|         | RAM     | Disk (in the `clamav` volume) |
|---------|---------|--------------------------------|
| idle    | ~1 GiB  | ~400 MiB                       |
| at scan | ~2 GiB  | —                              |

`libclamav` is linked in-process inside the c-icap binary, so the
signature DB is loaded once into the container's memory. On 8 GiB Macs
running the rest of the fides stack alongside this you'll feel it.

## Why it's built the way it is

A few non-obvious choices that a future reader might unpick by
accident:

**Why Ubuntu, not Debian?** The c-icap virus-scan modules
(`virus_scan.so` + `clamav_mod.so`, from `libc-icap-mod-virus-scan`)
are packaged by Ubuntu but not by Debian. Debian ships c-icap itself
without the bundled modules.

**Why in-process libclamav, not a TCP bridge to a separate clamd?**
The original ticket proposed `squidclamav` as the bridge -- that's the
historical ICAP module that delegated scanning over TCP to a separate
clamd. It was removed from both Debian and Ubuntu archives a while
back and isn't maintained upstream either. The only maintained
ICAP-clamav module on modern distros is the in-process one. We had a
two-container topology for a brief moment in this branch's history; it
made the diagram more interesting and bought us nothing concrete, so
we collapsed back to a single container.

**How are signature updates handled?** Three pieces, all inside this
container -- no sidecar, no CronJob, no separate `clamd`:

1. `freshclam --daemon --foreground` is spawned by the entrypoint as a
   background process. It checks `database.clamav.net` every three
   hours (`Checks 8` in `freshclam.conf`) and atomically swaps in
   fresh `.cld`/`.cvd` files when upstream has newer signatures.
2. A `reload-watcher` shell loop in `entrypoint.sh` polls
   `/var/lib/clamav/daily.{cld,cvd}` mtime every 60 seconds. When
   freshclam swaps in a newer file it writes `clamav:dbreload` to
   c-icap's command FIFO (`/var/run/c-icap/c-icap.ctl`, created by
   c-icap via `mkfifo`).
3. `clamav_mod` (which registered the `clamav:dbreload` command at
   startup) re-runs `cl_load` against the fresh DB files. The new
   libclamav engine is live on the next scan; scans in flight stall
   for ~1-2 seconds during the recompile, but with updates happening
   at most ~3 times per day that's acceptable.

The `clamav:dbreload` command is registered by the `clamav_mod` from
Ubuntu's stock `libc-icap-mod-virus-scan` package -- it has been part
of [`c-icap-modules`](https://github.com/c-icap/c-icap-modules) since
well before the 0.5.x line, so we don't need to vendor or rebuild the
module from source.

On first boot (when `/var/lib/clamav/daily.{cvd,cld}` is missing) the
entrypoint runs `freshclam` synchronously first, because libclamav
refuses to initialise without a signature database; the daemon takes
over once that initial pull completes.

This is not zero-downtime reload (which would require switching to a
separate `clamd` process with `ConcurrentDatabaseReload yes`); we
accept the brief stall in exchange for a one-process topology.

**How are the three processes supervised?** The entrypoint runs
c-icap, the freshclam daemon, and the reload-watcher as siblings
(`bash`'s `wait -n -p`) rather than `exec`ing c-icap. Policy:

- If **c-icap** or the **freshclam daemon** dies, the entrypoint
  TERMs the other two and exits non-zero -- the orchestrator (docker
  compose, k8s) then restarts the container. The alternative (c-icap
  kept running with a dead freshclam daemon) would silently serve
  scans against signatures that never get refreshed, and neither the
  echo healthcheck nor an avscan probe against a known-clean fixture
  would notice.
- If the **reload-watcher** dies, the entrypoint respawns it in
  place. Watcher loss only delays the next signature reload (the
  daemon still writes new DBs to disk; clamav_mod loads them on
  container restart), so restarting the whole container would be
  more disruptive than helpful.

**Why does c-icap run as UID 1000 / `clamav`?** Because the `clamav`
package creates `/var/lib/clamav` owned by its `clamav` user, and we
need both freshclam and c-icap to read/write that directory. The
Dockerfile drops Ubuntu's default `ubuntu` user (which occupies UID
1000) so the package's user can claim it.

**Why is the ACL wide open?** Because the security boundary that
matters is the docker network (and `NetworkPolicy` in k8s).
Fine-grained CIDR work at the c-icap layer would just duplicate that
boundary.

**Why do logs go to stderr?** So `docker logs clamav-icap` Just Works
without bind mounts or sidecar log shippers.
