| 1 | #!/bin/bash |
| 2 | # đŸ¤– scanner-selftest: prove, on THIS engine, that clamd never answers OK for |
| 3 | # an archive it could only partly scan. |
| 4 | # |
| 5 | # scanner-selftest.service runs this every time clamd starts, and the TCP |
| 6 | # listener is bound to that unit, so a failure here keeps port 3310 shut. It |
| 7 | # talks to clamd on its unix socket: the port it guards is not open yet. |
| 8 | # |
| 9 | # Why it exists: with ClamAV's packaged limits, a zip member that expands past |
| 10 | # MaxFileSize is trimmed and the trimmed copy is scanned and answered OK, with |
| 11 | # no limit raised. The box is configured so that cannot happen (both limits |
| 12 | # equal, AlertExceedsMax on). This checks that the configuration still does |
| 13 | # what it did when it was chosen, on whatever version is installed today. |
| 14 | # |
| 15 | # Fixtures are sized for a 100 MiB scan limit. Members are written from a temp |
| 16 | # file so their zip headers are ordinary; streamed members get zip64 headers, |
| 17 | # which clamd does not open at all. |
| 18 | set -u |
| 19 | sock=/run/clamav/clamd.ctl |
| 20 | |
| 21 | if [ "${SCANNER_SELFTEST_FORCE_FAIL:-}" = 1 ]; then |
| 22 | echo "scanner-selftest: FAIL (forced, for the acceptance check)" |
| 23 | exit 1 |
| 24 | fi |
| 25 | |
| 26 | d=$(mktemp -d /var/tmp/scanner-selftest.XXXXXX) || exit 1 |
| 27 | trap 'rm -rf "$d"' EXIT |
| 28 | echo "LocalSocket $sock" > "$d/clamdscan.conf" |
| 29 | |
| 30 | python3 - "$d" <<'PY' || { echo "scanner-selftest: FAIL (could not build fixtures)"; exit 1; } |
| 31 | import io, gzip, os, random, sys, tarfile, zipfile |
| 32 | d = sys.argv[1] |
| 33 | fx = os.path.join(d, 'fx') |
| 34 | os.mkdir(fx) |
| 35 | EICAR = rb'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' |
| 36 | |
| 37 | def block(seed): |
| 38 | # 1 MiB: a 16 KiB random block repeated. It compresses about 140 to 1 and |
| 39 | # differs per seed, so clamd's clean cache cannot wave a member through. |
| 40 | r = random.Random(seed) |
| 41 | return bytes(r.getrandbits(8) for _ in range(16384)) * 64 |
| 42 | |
| 43 | def bigfile(name, mb, seed): |
| 44 | p = os.path.join(d, name) |
| 45 | blk = block(seed) |
| 46 | with open(p, 'wb') as f: |
| 47 | for _ in range(mb): |
| 48 | f.write(blk) |
| 49 | return p |
| 50 | |
| 51 | def mkzip(name, build): |
| 52 | with zipfile.ZipFile(os.path.join(fx, name), 'w', zipfile.ZIP_DEFLATED) as z: |
| 53 | build(z) |
| 54 | |
| 55 | open(os.path.join(fx, '01-clean.txt'), 'wb').write(b'hello world\n') |
| 56 | open(os.path.join(fx, '02-eicar.txt'), 'wb').write(EICAR) |
| 57 | big = bigfile('big.bin', 500, 1) |
| 58 | mid = bigfile('mid.bin', 60, 5) |
| 59 | mkzip('03-zip-one-500.zip', lambda z: z.write(big, 'big.bin')) |
| 60 | with zipfile.ZipFile(os.path.join(d, 'inner.zip'), 'w', zipfile.ZIP_DEFLATED) as z: |
| 61 | z.write(big, 'big.bin') |
| 62 | mkzip('04-zip-nested-500.zip', lambda z: z.write(os.path.join(d, 'inner.zip'), 'inner.zip')) |
| 63 | def many(z): |
| 64 | for i in range(30): |
| 65 | z.write(bigfile('m.bin', 20, 100 + i), 'm%02d.bin' % i) |
| 66 | mkzip('05-zip-30x20.zip', many) |
| 67 | with gzip.open(os.path.join(fx, '06-gzip-500.gz'), 'wb') as g: |
| 68 | blk = block(2) |
| 69 | for _ in range(500): |
| 70 | g.write(blk) |
| 71 | with tarfile.open(os.path.join(fx, '07-targz-5x40.tar.gz'), 'w:gz') as t: |
| 72 | for i in range(5): |
| 73 | data = block(200 + i) * 40 |
| 74 | ti = tarfile.TarInfo('t%d.bin' % i) |
| 75 | ti.size = len(data) |
| 76 | t.addfile(ti, io.BytesIO(data)) |
| 77 | def big_then_eicar(z): |
| 78 | z.write(big, 'big.bin') |
| 79 | z.writestr('eicar.com', EICAR) |
| 80 | mkzip('08-zip-500-then-eicar.zip', big_then_eicar) |
| 81 | def mid_then_eicar(z): |
| 82 | z.write(mid, 'mid.bin') |
| 83 | z.writestr('eicar.com', EICAR) |
| 84 | mkzip('09-zip-60-then-eicar.zip', mid_then_eicar) |
| 85 | mkzip('10-zip-one-60.zip', lambda z: z.write(mid, 'mid.bin')) |
| 86 | PY |
| 87 | |
| 88 | # What each fixture must answer. "limit" is the one that matters: an OK there |
| 89 | # is a truncated scan signed clean. |
| 90 | want() { |
| 91 | case $1 in |
| 92 | 01 | 10) echo ok ;; |
| 93 | 02 | 09) echo infected ;; |
| 94 | *) echo limit ;; |
| 95 | esac |
| 96 | } |
| 97 | |
| 98 | # >>> classify |
| 99 | # What a reply says: ok, infected, limit, timeout or error. The signature is |
| 100 | # read as internal/clamd reads it: drop the trailing " FOUND", keep what follows |
| 101 | # the last ": " (clamdscan puts the file path there, not "stream"). A limit is |
| 102 | # Heuristics.Limits.Exceeded itself or a name under it, dot included, so a |
| 103 | # lookalike such as Win.Heuristics.Limits.Exceeded.Fake is malware. MaxScanTime |
| 104 | # is a timeout: it says the engine ran out of time, not that a limit held, so |
| 105 | # it never matches what a fixture wants. |
| 106 | classify() { |
| 107 | local sig |
| 108 | case $1 in |
| 109 | *" FOUND") |
| 110 | sig=${1% FOUND} |
| 111 | sig=${sig##*: } |
| 112 | case $sig in |
| 113 | Heuristics.Limits.Exceeded.MaxScanTime) echo timeout ;; |
| 114 | Heuristics.Limits.Exceeded | Heuristics.Limits.Exceeded.*) echo limit ;; |
| 115 | *) echo infected ;; |
| 116 | esac |
| 117 | ;; |
| 118 | *": OK") echo ok ;; |
| 119 | *) echo error ;; |
| 120 | esac |
| 121 | } |
| 122 | # <<< classify |
| 123 | |
| 124 | fail=0 |
| 125 | for f in "$d"/fx/*; do |
| 126 | n=$(basename "$f") |
| 127 | exp=$(want "${n%%-*}") |
| 128 | reply=$(clamdscan --config-file="$d/clamdscan.conf" --stream --no-summary "$f" 2>&1 | tail -1) |
| 129 | got=$(classify "$reply") |
| 130 | if [ "$got" = "$exp" ]; then |
| 131 | echo "scanner-selftest: pass $n ($got)" |
| 132 | elif [ "$got" = timeout ]; then |
| 133 | echo "scanner-selftest: FAIL $n timeout, not a verdict (the engine is too slow or overloaded): $reply" |
| 134 | fail=1 |
| 135 | else |
| 136 | echo "scanner-selftest: FAIL $n want $exp, got $got: $reply" |
| 137 | fail=1 |
| 138 | fi |
| 139 | done |
| 140 | |
| 141 | if [ $fail -eq 0 ]; then |
| 142 | echo "scanner-selftest: PASS" |
| 143 | else |
| 144 | echo "scanner-selftest: FAIL" |
| 145 | fi |
| 146 | exit $fail |
| 147 | |