carlos / platform Public

spec: scanner host draft 4, closes Astra review 3 (finding 9, tested against clamd 1.4.3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 51985b0d0b4aaf6178bf8c5f542526c923e9849f parent ae1cb88
2 files changed, +105 −22
  • docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md +86 −22
  • docs/superpowers/specs/CODEX-FINDINGS-SCANNER-HOST-R3.md +19 −0
diff --git a/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md b/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md
index 93c103496..054441979 100644
--- a/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md
+++ b/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md
@@ -1,9 +1,10 @@
# Scanner host: getting object-storage scanning running on Carloku and Tito
-**Status:** draft 3. Closes Astra review 1 (`CODEX-FINDINGS-SCANNER-HOST.md`,
-findings 1–5, all accepted) and review 2 (`CODEX-FINDINGS-SCANNER-HOST-R2.md`,
-findings 6–8: 7 and 8 accepted, 6 declined with the reason in §4.3). Accepted
-findings are marked **(codex N)**.
+**Status:** draft 4. Closes Astra review 1 (`CODEX-FINDINGS-SCANNER-HOST.md`,
+findings 1–5, all accepted), review 2 (`CODEX-FINDINGS-SCANNER-HOST-R2.md`,
+findings 6–8: 7 and 8 accepted, 6 declined with the reason in §4.3) and
+review 3 (`CODEX-FINDINGS-SCANNER-HOST-R3.md`, finding 9, accepted and tested
+against a real engine, §3.4). Accepted findings are marked **(codex N)**.
**Builds on:** `2026-09-01-object-storage-virus-scanning-design.md` (the sweep,
verdict tags, enforcement) and `2026-09-02-scan-on-demand-design.md` §9 (clamd
off the console box, reachable over TCP).
@@ -95,13 +96,20 @@ the serial console so `aws ec2 get-console-output` shows how far it got.
- `TCPAddr <private IPv4>` from IMDSv2. **Never `0.0.0.0`.** The security
group is the fence; binding the private address is the second rail.
- `LocalSocket /run/clamav/clamd.ctl` stays, for local checks over SSM.
- - `StreamMaxLength <stream_max_bytes>`, and `MaxFileSize` no lower than it,
- so a stream the console sends whole is never truncated as a single file.
+ - `StreamMaxLength <stream_max_bytes>`.
- `AlertExceedsMax yes`. With the default `no`, an archive that expands past
`MaxScanSize`, `MaxFileSize`, `MaxFiles` or `MaxRecursion` is partly
scanned and answered `OK`. With `yes` clamd answers
`Heuristics.Limits.Exceeded.<limit> FOUND`, which §4.3 records as
unscanned (D8). **(codex 4)**
+ - `MaxFileSize <max_scan_bytes>` and `MaxScanSize <max_scan_bytes>`: **the
+ same value**, default 100 MiB, never below `stream_max_bytes`.
+ `AlertExceedsMax` alone is not enough. ClamAV's zip extractor trims a
+ member that expands past `MaxFileSize` and scans the trimmed copy without
+ raising any limit (`libclamav/unzip.c:222-226`, 1.4.3). With the two
+ limits equal, the trimmed copy is as large as the whole scan budget, so
+ scanning it always trips `MaxScanSize`, which does alert. Tested, §3.4.
+ **(codex 9)**
- `ConcurrentDatabaseReload` from a variable, default `yes`.
4. **Replace the daemon's unit.** Debian's `clamav-daemon.service` carries
`Requires=clamav-daemon.socket` and `Also=clamav-daemon.socket`, and socket
@@ -122,7 +130,51 @@ the serial console so `aws ec2 get-console-output` shows how far it got.
6. **Start clamd** and verify: `zVERSION` answers on `/run/clamav/clamd.ctl`
**and** on `<private ip>:3310`; `ss -ltn` shows 3310 bound to the private IP
only, never `0.0.0.0` or `[::]`. Any failure exits non-zero and logs which
- check failed. Print the `zVERSION` line last.
+ check failed.
+7. **Self-test the limits.** The script installs `/usr/local/sbin/scanner-selftest`
+ (also runnable later over SSM) and runs it. It builds the §3.4 fixtures with
+ `python3` in a temp directory, streams each to clamd with `clamdscan
+ --stream`, and requires the exact class of reply in the table's last
+ column. **An over-limit fixture answering `OK` fails the boot**: the box
+ never reports ready with an engine that signs truncated scans clean. This
+ re-proves the behaviour on whatever ClamAV version the image installs,
+ rather than trusting what 1.4.3 did. Print the `zVERSION` line last.
+ **(codex 9)**
+
+### 3.4 What the limits do, measured
+
+Run 2026-10-05 against Debian trixie's clamd (ClamAV 1.4.3), `StreamMaxLength
+25M`, `AlertExceedsMax yes`, every fixture streamed with `clamdscan --stream`.
+Members are a repeating 16 KiB random block, distinct per member, so the clean
+cache cannot short-circuit them. "Limit" means a
+`Heuristics.Limits.Exceeded.*` reply.
+
+| Fixture | `MaxFileSize 25M`, `MaxScanSize 100M` | Both 100M (this spec) | Self-test requires |
+|---|---|---|---|
+| small clean file | OK | OK | OK |
+| EICAR | infected | infected | infected |
+| zip, one member expanding to 500 MiB | **OK** (trimmed, silent) | Limit | Limit |
+| zip, a zip inside holding that member | **OK** (silent) | Limit | Limit |
+| zip, 30 distinct 20 MiB members | Limit | Limit | Limit |
+| gzip of 500 MiB | Limit | Limit | Limit |
+| tar.gz, 5 × 40 MiB | Limit | Limit | Limit |
+| bzip2, xz, tar.xz of the same | Limit | Limit | not in self-test |
+| zip, 500 MiB member then EICAR | infected | Limit | Limit |
+| zip, 60 MiB member then EICAR | infected | infected | infected |
+| zip, one 60 MiB member | **OK** (trimmed, silent) | OK (fully scanned) | OK |
+
+The first column is finding 9, observed: three fixtures signed clean over
+content nobody read. The second is what this spec configures.
+
+**Known and not fixed: zip64 local headers.** A zip member written with zip64
+local headers is not extracted at all by 1.4.3 (`local header - stream out of
+file`), and EICAR inside one reads `OK` under every config. That is parser
+coverage, not a limit, and no clamd setting changes it. Encrypted archives are
+the same class. So a clean verdict means "clamd found nothing in what it could
+open", never "this object is safe", and the disclosure a member reads before
+opting in (rollout plan step 7) should say so in those words. Recorded in §11
+as a follow-up; it does not block this rollout, because it is true of the
+engine wherever it runs.
### 3.3 Variables and outputs
@@ -133,6 +185,7 @@ the serial console so `aws ec2 get-console-output` shows how far it got.
| `client_security_group_id` | — | The console box's group |
| `instance_type` | `t4g.medium` | `t4g.small` only with `concurrent_database_reload = false` |
| `stream_max_bytes` | `26214400` | Must equal the console's `CARLOS_CLAMD_MAX_BYTES` |
+| `max_scan_bytes` | `104857600` | Sets `MaxFileSize` **and** `MaxScanSize` (§3.2). Validation refuses a value below `stream_max_bytes` |
| `memory_max` | `"3G"` | clamd's cgroup ceiling |
| `concurrent_database_reload` | `true` | |
| `subnet_id` | `null` | |
@@ -145,7 +198,7 @@ spike.
Outputs: `clamd_addr` (`"<private_ip>:3310"`), `instance_id`,
`security_group_id`, `stream_max_bytes`.
-### 3.4 Replacement
+### 3.5 Replacement
A replaced box gets a new private IP, so `CARLOS_CLAMD_ADDR` must be updated by
hand. Accepted: replacements are explicit (`-replace`) and the failure is loud
@@ -195,14 +248,16 @@ actions on `arn:aws:s3:::carlos-store-tito-*/*` and
`skipped` and `unscanned`, never `clean` or `infected`, and like an oversize
object it is carried forward until the bytes change.
-**Declined: codex 6** (a malware match hiding an earlier limit hit). With
-clamd's default `HeuristicScanPrecedence no`, an archive that trips a limit and
-also contains a real match answers with the real match, and the client records
-it infected. That is correct under D8: the object is known to contain malware,
+**Declined: codex 6** (a malware match hiding an earlier limit hit). When
+clamd reports a real match, the client records infected, whatever limit was
+also hit. That is correct under D8: the object is known to contain malware,
whatever else went unread. Setting `HeuristicScanPrecedence yes` to surface the
-limit instead would turn that known infection into `unscanned`, which is worse.
-When nothing matches, clamd still answers with the limit heuristic at the end,
-so the case D8 exists for is covered. §7 step 2 tests both.
+limit instead would turn a known infection into `unscanned`, which is worse.
+When nothing matches, clamd answers with the limit heuristic, so the case D8
+exists for is covered. Which of the two an archive gets depends on what clamd
+reaches before the budget runs out (§3.4: a 500 MiB member ahead of EICAR
+answers the limit, a 60 MiB member ahead of it answers EICAR). Both are honest
+and neither is clean. §7 step 2 tests both.
Tests in `clamd_test.go`: `MaxScanTime` maps to error; each of the other four
limit names maps to size-exceeded;
@@ -296,12 +351,16 @@ On a throwaway app we own, with a plaintext store:
`grant`, `sweep`. **Pass:** `scan status` shows a completed sweep, one clean,
one infected; both objects' tags verify under this deployment's key and carry
the object's current ETag.
-2. Upload two small archives and `sweep`:
- - one that expands past `MaxScanSize` (a zip of a few hundred MB of zeros is
- a few hundred KB). **Pass:** `skipped` and `unscanned`, never `clean` or
- `infected`; its tag carries no clean verdict. **(codex 4)**
- - one holding the same oversized member followed by EICAR. **Pass:**
- `infected` (D8: a real match stands). If it reads `clean`, stop.
+2. Upload three archives from the §3.4 fixture set and `sweep`:
+ - the zip with one member expanding to 500 MiB. **Pass:** `skipped` and
+ `unscanned`, never `clean` or `infected`; its tag carries no clean
+ verdict. **(codex 4, codex 9)**
+ - the zip with that member followed by EICAR. **Pass:** `skipped` and
+ `unscanned` (clamd stops at the limit before reaching EICAR).
+ - the zip with a 60 MiB member followed by EICAR. **Pass:** `infected`
+ (D8: a real match stands).
+
+ If any of the three reads `clean`, stop.
3. Overwrite the clean object, `sweep`. **Pass:** it is re-scanned, not carried
forward. **If it is carried forward, stop the rollout:** a verdict that
outlives its bytes is worse than none.
@@ -323,7 +382,7 @@ in a customer's deployment needs the customer's say-so.
## 9. Docs
- `infra/modules/scanner-host/README.md`: what it is, the three outputs the
- console needs, replacement (§3.4), and the sizing rule.
+ console needs, replacement (§3.5), the limits and why they are equal (§3.4), and the sizing rule.
- `infra/modules/region-host/README.md`: `store_scanning`.
- A dated note at the top of `../plans/2026-09-01-carloku-scanning-rollout.md`:
steps 2 and 4 are superseded by this spec, `CARLOS_STORE_SCANNER` is
@@ -342,6 +401,11 @@ for the pair.
- A member asking for scanning on a client-encrypted store: no change here, the
existing `--client-encrypted` path handles it.
- Store buckets becoming per-region: see §2, out of scope.
+- **zip64 and encrypted archive members are not opened by clamd** (§3.4), so a
+ clean verdict never covers them. Follow-ups, each needing its own decision:
+ say so in the `scan request` disclosure (user-facing copy, so copy review);
+ consider `AlertEncrypted yes` with a mapping to unscanned, the way §4.3 maps
+ limits.
- Raising clamd's limits later: objects already recorded `skipped` under the
old limits are carried forward until their bytes change, as oversize objects
are today. A forced re-sweep of skipped objects is a follow-up if anyone
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-SCANNER-HOST-R3.md b/docs/superpowers/specs/CODEX-FINDINGS-SCANNER-HOST-R3.md
new file mode 100644
index 000000000..5414e2e37
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-SCANNER-HOST-R3.md
@@ -0,0 +1,19 @@
+| Finding | Disposition | Reason |
+|---|---|---|
+| 6 | **Declined-and-sound** | A matched malware signature can take precedence over a limit heuristic with `HeuristicScanPrecedence no`. Recording that result as infected satisfies revised D8. [ClamAV implementation](https://github.com/Cisco-Talos/clamav/blob/clamav-1.4.3/libclamav/others.c#L1271-L1360). |
+| 7 | **Partly closed** | §4.3 maps `MaxScanTime` to `StatusError` → `VerdictFailed`, which subsequent sweeps retry. Previously skipped objects still remain skipped after configuration limits increase; §11 explicitly defers that part. |
+| 8 | **Closed** | §6.2 now requires adoption, restart and verification of the actual running executable against the exact build SHA before wiring, then repeats the executable check after wiring. |
+
+Reviewed draft 3 at `ae1cb880`. No files changed or live scans performed.
+
+9. **P2 — §§3.2, 4.3 and 7: ZIP extraction can silently truncate despite `AlertExceedsMax yes`.**
+
+ ClamAV 1.4.3’s deflate extraction loop stops when expanded output exceeds `MaxFileSize`, sets `Z_STREAM_END`, and subsequently scans the truncated temporary file. That branch does **not** record a limit heuristic. See [`libclamav/unzip.c:209–227`](https://github.com/Cisco-Talos/clamav/blob/clamav-1.4.3/libclamav/unzip.c#L209-L227) and [`:326–335`](https://github.com/Cisco-Talos/clamav/blob/clamav-1.4.3/libclamav/unzip.c#L326-L335).
+
+ For example, the spec permits `MaxFileSize=26214400` with `MaxScanSize=400M`. A small ZIP containing a 500 MiB zero-filled member is truncated to approximately 25 MiB before scanning. The scanned prefix stays below both limits, so the result can be `OK`. The proposed prefix mapping never runs: [internal/clamd/clamd.go:278](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/scanner-host/internal/clamd/clamd.go:278) classifies it as clean, and [internal/objstore/clamav.go:364](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/scanner-host/internal/objstore/clamav.go:364) records and signs it.
+
+ This is a newly identified mechanism that defeats the accepted `AlertExceedsMax` remedy. It violates D8 without involving malware precedence. The mixed archive can also return infected from its separate EICAR member without ever exercising limit-heuristic precedence.
+
+ **Fix:** Require a tested engine/configuration that reports this truncation as unscanned. One configuration to validate is `MaxFileSize` sufficiently above `MaxScanSize`, so the truncated output itself exceeds the aggregate limit. Add separate real-engine fixtures for a single oversized ZIP member and aggregate expansion across smaller members; require their raw limit replies before accepting §7.
+
+**Verdict: accept with fixes.**
\ No newline at end of file