scanner-host: the self-test reads the signature exactly, a scan timeout fails it, and logging cannot kill init
The self-test now classifies a reply the way internal/clamd does: the signature after the last ': ', a limit only for Heuristics.Limits.Exceeded or a name under it, and MaxScanTime as its own timeout class, which no fixture wants. Lookalikes such as Win.Heuristics.Limits.Exceeded.Fake are malware. say() can no longer fail (stdout, then the console best effort), so a bad console write cannot exit init without the FAILED marker. Tests pin the classifier, the logging, clamd's drop-in Wants= and the self-test unit's Wants=/After=.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+78
−10
infra/modules/scanner-host/files/scanner-selftest+30 −6infra/modules/scanner-host/scanner-init.sh.tftpl+11 −3infra/modules/scanner-host/tests/scanner_host.tftest.hcl+37 −1
diff --git a/infra/modules/scanner-host/files/scanner-selftest b/infra/modules/scanner-host/files/scanner-selftest| index 1773ab86f..f9affe585 100755 |
| --- a/infra/modules/scanner-host/files/scanner-selftest |
| +++ b/infra/modules/scanner-host/files/scanner-selftest |
| @@ -95,19 +95,43 @@ want() { |
| 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) |
| - case $reply in |
| - *"Heuristics.Limits.Exceeded"*" FOUND") got=limit ;; |
| - *" FOUND") got=infected ;; |
| - *": OK") got=ok ;; |
| - *) got=error ;; |
| - esac |
| + 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 |
diff --git a/infra/modules/scanner-host/scanner-init.sh.tftpl b/infra/modules/scanner-host/scanner-init.sh.tftpl| index 4430ffda4..2d8bad14a 100644 |
| --- a/infra/modules/scanner-host/scanner-init.sh.tftpl |
| +++ b/infra/modules/scanner-host/scanner-init.sh.tftpl |
| @@ -12,10 +12,17 @@ |
| # 6. Verify from the outside of the gate, and say READY or FAILED. |
| set -euo pipefail |
| -say() { echo "scanner-init: $*" | tee /dev/console; } |
| +# Logging must never be what stops the script: a failed write inside say() |
| +# would exit under set -e without the ERR trap (functions do not inherit it), |
| +# so without a FAILED marker. stdout goes to cloud-init's log; the serial |
| +# console copy is best effort. |
| +say() { |
| + echo "scanner-init: $*" || true |
| + { echo "scanner-init: $*" > /dev/console; } 2>/dev/null || true |
| +} |
| # A failure in step 2 could leave policy-rc.d behind, and then nobody fixing |
| # the box over SSM could start a service. Remove it on every way out. |
| -fail() { rm -f /usr/sbin/policy-rc.d; say "SCANNER-HOST FAILED: $*"; exit 1; } |
| +fail() { rm -f /usr/sbin/policy-rc.d || true; say "SCANNER-HOST FAILED: $*"; exit 1; } |
| trap 'fail "line $LINENO"' ERR |
| export DEBIAN_FRONTEND=noninteractive |
| @@ -115,7 +122,8 @@ MemoryHigh=${memory_high} |
| Restart=on-failure |
| # The same two lines as Debian's extend.conf drop-in, which the postinst moves |
| -# aside on the first upgrade now that debconf is off. |
| +# aside on its first configure run with debconf off: an upgrade or a |
| +# dpkg-reconfigure, whichever comes first. |
| ExecStartPre=-/bin/mkdir -p /run/clamav |
| ExecStartPre=/bin/chown clamav /run/clamav |
| EOF |
diff --git a/infra/modules/scanner-host/tests/scanner_host.tftest.hcl b/infra/modules/scanner-host/tests/scanner_host.tftest.hcl| index 81d9c6a5c..18047639f 100644 |
| --- a/infra/modules/scanner-host/tests/scanner_host.tftest.hcl |
| +++ b/infra/modules/scanner-host/tests/scanner_host.tftest.hcl |
| @@ -192,12 +192,48 @@ run "the_units" { |
| condition = alltrue([ |
| for line in [ |
| "BindsTo=clamav-daemon.service", |
| + "After=clamav-daemon.service", |
| + "Wants=scanner-tcp.socket", |
| "Type=oneshot", |
| "RemainAfterExit=yes", |
| "PrivateTmp=yes", |
| ] : strcontains(file("${path.module}/files/scanner-selftest.service"), "\n${line}\n") |
| ]) |
| - error_message = "the self-test follows clamd, stays active after a pass, and keeps its fixtures in a private /var/tmp" |
| + error_message = "the self-test follows clamd, asks for the gate, stays active after a pass, and keeps its fixtures in a private /var/tmp" |
| + } |
| + assert { |
| + # Comments may sit between the heredoc line and [Unit]; the Wants= line |
| + # must be the first line of the [Unit] section. |
| + condition = can(regex("10-scanner\\.conf <<EOF\\n(#[^\\n]*\\n)*\\[Unit\\]\\nWants=scanner-tcp\\.socket\\n", local.init_script)) |
| + error_message = "every start of clamd, an automatic restart included, asks for the gate again" |
| + } |
| +} |
| + |
| +run "the_selftest_classifier" { |
| + command = plan |
| + assert { |
| + condition = !strcontains(file("${path.module}/files/scanner-selftest"), "*\"Heuristics.Limits.Exceeded\"*") |
| + error_message = "no substring glob: it accepted MaxScanTime and lookalikes such as Win.Heuristics.Limits.Exceeded.Fake as a limit" |
| + } |
| + assert { |
| + condition = strcontains(file("${path.module}/files/scanner-selftest"), "Heuristics.Limits.Exceeded.MaxScanTime) echo timeout ;;") |
| + error_message = "a scan timeout is its own class, which no fixture wants" |
| + } |
| + assert { |
| + condition = strcontains(file("${path.module}/files/scanner-selftest"), "Heuristics.Limits.Exceeded | Heuristics.Limits.Exceeded.*) echo limit ;;") |
| + error_message = "a limit is the heuristic itself or a name under it, dot included, as internal/clamd reads it" |
| + } |
| +} |
| + |
| +run "logging_cannot_kill_init" { |
| + command = plan |
| + assert { |
| + condition = !strcontains(local.init_script, "tee /dev/console") |
| + error_message = "a failed console write would exit under set -e with no FAILED marker" |
| + } |
| + assert { |
| + condition = strcontains(local.init_script, "{ echo \"scanner-init: $*\" > /dev/console; } 2>/dev/null || true") |
| + error_message = "the console copy of each line is best effort" |
| } |
| } |