#!/bin/bash # 🤖 scanner-selftest: prove, on THIS engine, that clamd never answers OK for # an archive it could only partly scan. # # scanner-selftest.service runs this every time clamd starts, and the TCP # listener is bound to that unit, so a failure here keeps port 3310 shut. It # talks to clamd on its unix socket: the port it guards is not open yet. # # Why it exists: with ClamAV's packaged limits, a zip member that expands past # MaxFileSize is trimmed and the trimmed copy is scanned and answered OK, with # no limit raised. The box is configured so that cannot happen (both limits # equal, AlertExceedsMax on). This checks that the configuration still does # what it did when it was chosen, on whatever version is installed today. # # Fixtures are sized for a 100 MiB scan limit. Members are written from a temp # file so their zip headers are ordinary; streamed members get zip64 headers, # which clamd does not open at all. set -u sock=/run/clamav/clamd.ctl if [ "${SCANNER_SELFTEST_FORCE_FAIL:-}" = 1 ]; then echo "scanner-selftest: FAIL (forced, for the acceptance check)" exit 1 fi d=$(mktemp -d /var/tmp/scanner-selftest.XXXXXX) || exit 1 trap 'rm -rf "$d"' EXIT echo "LocalSocket $sock" > "$d/clamdscan.conf" python3 - "$d" <<'PY' || { echo "scanner-selftest: FAIL (could not build fixtures)"; exit 1; } import io, gzip, os, random, sys, tarfile, zipfile d = sys.argv[1] fx = os.path.join(d, 'fx') os.mkdir(fx) EICAR = rb'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' def block(seed): # 1 MiB: a 16 KiB random block repeated. It compresses about 140 to 1 and # differs per seed, so clamd's clean cache cannot wave a member through. r = random.Random(seed) return bytes(r.getrandbits(8) for _ in range(16384)) * 64 def bigfile(name, mb, seed): p = os.path.join(d, name) blk = block(seed) with open(p, 'wb') as f: for _ in range(mb): f.write(blk) return p def mkzip(name, build): with zipfile.ZipFile(os.path.join(fx, name), 'w', zipfile.ZIP_DEFLATED) as z: build(z) open(os.path.join(fx, '01-clean.txt'), 'wb').write(b'hello world\n') open(os.path.join(fx, '02-eicar.txt'), 'wb').write(EICAR) big = bigfile('big.bin', 500, 1) mid = bigfile('mid.bin', 60, 5) mkzip('03-zip-one-500.zip', lambda z: z.write(big, 'big.bin')) with zipfile.ZipFile(os.path.join(d, 'inner.zip'), 'w', zipfile.ZIP_DEFLATED) as z: z.write(big, 'big.bin') mkzip('04-zip-nested-500.zip', lambda z: z.write(os.path.join(d, 'inner.zip'), 'inner.zip')) def many(z): for i in range(30): z.write(bigfile('m.bin', 20, 100 + i), 'm%02d.bin' % i) mkzip('05-zip-30x20.zip', many) with gzip.open(os.path.join(fx, '06-gzip-500.gz'), 'wb') as g: blk = block(2) for _ in range(500): g.write(blk) with tarfile.open(os.path.join(fx, '07-targz-5x40.tar.gz'), 'w:gz') as t: for i in range(5): data = block(200 + i) * 40 ti = tarfile.TarInfo('t%d.bin' % i) ti.size = len(data) t.addfile(ti, io.BytesIO(data)) def big_then_eicar(z): z.write(big, 'big.bin') z.writestr('eicar.com', EICAR) mkzip('08-zip-500-then-eicar.zip', big_then_eicar) def mid_then_eicar(z): z.write(mid, 'mid.bin') z.writestr('eicar.com', EICAR) mkzip('09-zip-60-then-eicar.zip', mid_then_eicar) mkzip('10-zip-one-60.zip', lambda z: z.write(mid, 'mid.bin')) PY # What each fixture must answer. "limit" is the one that matters: an OK there # is a truncated scan signed clean. want() { case $1 in 01 | 10) echo ok ;; 02 | 09) echo infected ;; *) echo limit ;; esac } # >>> classify # What a reply says: ok, infected, limit, timeout or error. The signature is # read as internal/clamd reads it: drop the trailing " FOUND", keep what follows # the last ": " (clamdscan puts the file path there, not "stream"). A limit is # Heuristics.Limits.Exceeded itself or a name under it, dot included, so a # lookalike such as Win.Heuristics.Limits.Exceeded.Fake is malware. MaxScanTime # is a timeout: it says the engine ran out of time, not that a limit held, so # it never matches what a fixture wants. classify() { local sig case $1 in *" FOUND") sig=${1% FOUND} sig=${sig##*: } case $sig in Heuristics.Limits.Exceeded.MaxScanTime) echo timeout ;; Heuristics.Limits.Exceeded | Heuristics.Limits.Exceeded.*) echo limit ;; *) echo infected ;; esac ;; *": OK") echo ok ;; *) echo error ;; esac } # <<< classify fail=0 for f in "$d"/fx/*; do n=$(basename "$f") exp=$(want "${n%%-*}") reply=$(clamdscan --config-file="$d/clamdscan.conf" --stream --no-summary "$f" 2>&1 | tail -1) got=$(classify "$reply") if [ "$got" = "$exp" ]; then echo "scanner-selftest: pass $n ($got)" elif [ "$got" = timeout ]; then echo "scanner-selftest: FAIL $n timeout, not a verdict (the engine is too slow or overloaded): $reply" fail=1 else echo "scanner-selftest: FAIL $n want $exp, got $got: $reply" fail=1 fi done if [ $fail -eq 0 ]; then echo "scanner-selftest: PASS" else echo "scanner-selftest: FAIL" fi exit $fail