carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package clamd
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/binary"
8 "io"
9 "net"
10 "strings"
11 "testing"
12 "time"
13)
14
15// fakeDeadline bounds the fake clamd's side of every pipe, so a protocol bug
16// surfaces as a failing test rather than a hung one.
17const fakeDeadline = 5 * time.Second
18
19// frameReader decodes the INSTREAM framing off the wire: an optional
20// NUL-terminated command, then 4-byte big-endian length + payload chunks,
21// ending at the zero-length chunk. It exists so the tests assert the bytes we
22// actually put on the socket rather than trusting the writer to agree with
23// itself.
24type frameReader struct {
25 r *bufio.Reader
26 cmdRead bool
27 remain uint32
28 done bool
29}
30
31func newFrameReader(r io.Reader) *frameReader {
32 return &frameReader{r: bufio.NewReader(r)}
33}
34
35func (f *frameReader) Read(p []byte) (int, error) {
36 if !f.cmdRead {
37 if _, err := f.r.ReadString(0); err != nil {
38 return 0, err
39 }
40 f.cmdRead = true
41 }
42 for f.remain == 0 {
43 if f.done {
44 return 0, io.EOF
45 }
46 var hdr [4]byte
47 if _, err := io.ReadFull(f.r, hdr[:]); err != nil {
48 return 0, err
49 }
50 f.remain = binary.BigEndian.Uint32(hdr[:])
51 if f.remain == 0 {
52 f.done = true
53 return 0, io.EOF
54 }
55 }
56 if uint32(len(p)) > f.remain {
57 p = p[:f.remain]
58 }
59 n, err := f.r.Read(p)
60 f.remain -= uint32(n)
61 return n, err
62}
63
64// fakeClamd stands a clamd on one end of a net.Pipe. It reads the command,
65// drains any INSTREAM frames, hands back the payload, then writes reply.
66//
67// NOTE: over net.Pipe (unbuffered, synchronous) the server MUST finish reading
68// before it writes, or both ends deadlock — the client is still blocked in a
69// write nobody is reading while the server blocks in a write nobody is
70// reading. Every helper here keeps that order.
71func fakeClamd(t *testing.T, reply string) (*Client, <-chan []byte) {
72 t.Helper()
73 client, server := net.Pipe()
74 // A framing bug does not fail these tests, it HANGS them: a wrong length
75 // header has the fake waiting on megabytes the client will never send
76 // while the client waits on a reply. Verified by flipping the header to
77 // little-endian, which without this deadline wedges until the package
78 // test timeout ten minutes later. The deadline turns that into a clean
79 // failure in seconds.
80 _ = server.SetDeadline(time.Now().Add(fakeDeadline))
81 got := make(chan []byte, 1)
82 go func() {
83 defer server.Close()
84 br := bufio.NewReader(server)
85 cmd, err := br.ReadString(0)
86 if err != nil {
87 got <- nil
88 return
89 }
90 var payload []byte
91 if strings.Contains(cmd, "INSTREAM") {
92 payload, _ = io.ReadAll(&frameReader{r: br, cmdRead: true})
93 }
94 got <- payload
95 _, _ = server.Write([]byte(reply))
96 }()
97 t.Cleanup(func() { client.Close() })
98 return newWithConn(client), got
99}
100
101func replyFixture(t *testing.T, reply string) Result {
102 t.Helper()
103 c, _ := fakeClamd(t, reply)
104 res, err := c.ScanStream(context.Background(), bytes.NewReader([]byte("payload")))
105 if err != nil {
106 t.Fatalf("ScanStream: %v", err)
107 }
108 return res
109}
110
111func versionFixture(t *testing.T, reply string) string {
112 t.Helper()
113 c, _ := fakeClamd(t, reply)
114 v, err := c.Version(context.Background())
115 if err != nil {
116 t.Fatalf("Version: %v", err)
117 }
118 return v
119}
120
121// INSTREAM framing, asserted on the wire: zINSTREAM\0, then 4-byte
122// big-endian length + payload chunks, then a zero-length chunk.
123func TestScanStreamWireFormat(t *testing.T) {
124 client, server := net.Pipe()
125 _ = server.SetDeadline(time.Now().Add(fakeDeadline))
126 got := make(chan []byte, 1)
127 go func() {
128 defer server.Close()
129 buf, _ := io.ReadAll(newFrameReader(server)) // reads until the zero chunk
130 got <- buf
131 server.Write([]byte("stream: OK\x00"))
132 }()
133
134 c := newWithConn(client)
135 res, err := c.ScanStream(context.Background(), bytes.NewReader([]byte("hello")))
136 if err != nil {
137 t.Fatal(err)
138 }
139 if res.Status != StatusClean {
140 t.Fatalf("status = %v, want clean", res.Status)
141 }
142 if b := <-got; !bytes.Equal(b, []byte("hello")) {
143 t.Fatalf("payload on the wire = %q, want %q", b, "hello")
144 }
145}
146
147func TestScanStreamInfected(t *testing.T) {
148 res := replyFixture(t, "stream: Eicar-Signature FOUND\x00")
149 if res.Status != StatusInfected {
150 t.Fatalf("status = %v, want infected", res.Status)
151 }
152 if res.Signature != "Eicar-Signature" {
153 t.Fatalf("signature = %q", res.Signature)
154 }
155}
156
157// A size refusal is SKIPPED, not FAILED. An over-large object was not judged;
158// calling that a failure would make the evidence lie about what happened.
159func TestScanStreamSizeExceededIsItsOwnStatus(t *testing.T) {
160 res := replyFixture(t, "INSTREAM size limit exceeded. ERROR\x00")
161 if res.Status != StatusSizeExceeded {
162 t.Fatalf("status = %v, want size-exceeded", res.Status)
163 }
164}
165
166// Anything else ending ERROR is the engine reading the object and failing on
167// it — a per-object fact, distinct from the engine being unreachable.
168func TestScanStreamErrorReply(t *testing.T) {
169 res := replyFixture(t, "stream: Can't allocate memory ERROR\x00")
170 if res.Status != StatusError {
171 t.Fatalf("status = %v, want error", res.Status)
172 }
173 if !strings.Contains(res.Raw, "allocate memory") {
174 t.Fatalf("the engine's own words did not survive: %q", res.Raw)
175 }
176}
177
178func TestVersionParse(t *testing.T) {
179 // zVERSION answers engine/sig-db-version/sig-db-date.
180 v := versionFixture(t, "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026\x00")
181 if v != "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026" {
182 t.Fatalf("version = %q", v)
183 }
184}
185
186// A chunked object arrives whole and in order. One Read per chunk means an
187// off-by-one in the framing shows up as a corrupted stream, not as a slightly
188// wrong length — this asserts we never send a frame header we then fail to
189// fill.
190func TestScanStreamChunksLargeObject(t *testing.T) {
191 want := bytes.Repeat([]byte("carlos"), chunkSize) // several chunks' worth
192 c, got := fakeClamd(t, "stream: OK\x00")
193 if _, err := c.ScanStream(context.Background(), bytes.NewReader(want)); err != nil {
194 t.Fatal(err)
195 }
196 if b := <-got; !bytes.Equal(b, want) {
197 t.Fatalf("streamed %d bytes, want %d", len(b), len(want))
198 }
199}
200
201// Every blocking operation carries a deadline. A clamd that accepts the stream
202// and never answers must fail the object, not wedge the pass forever.
203func TestReadReplyHasADeadline(t *testing.T) {
204 client, server := net.Pipe()
205 defer server.Close()
206 go func() {
207 br := bufio.NewReader(server)
208 br.ReadString(0)
209 io.ReadAll(&frameReader{r: br, cmdRead: true})
210 // ... and then never reply.
211 select {}
212 }()
213
214 c := newWithConn(client)
215 c.timeouts.Read = 50 * time.Millisecond
216 done := make(chan error, 1)
217 go func() {
218 _, err := c.ScanStream(context.Background(), bytes.NewReader([]byte("x")))
219 done <- err
220 }()
221 select {
222 case err := <-done:
223 if err == nil {
224 t.Fatal("a silent clamd returned success")
225 }
226 case <-time.After(5 * time.Second):
227 t.Fatal("ScanStream blocked past its read deadline")
228 }
229}
230
231// A zero Timeouts means the defaults, never "wait forever".
232func TestZeroTimeoutsTakeDefaults(t *testing.T) {
233 c := New("/run/clamav/clamd.ctl", Timeouts{})
234 if c.timeouts.Dial <= 0 || c.timeouts.Write <= 0 || c.timeouts.Read <= 0 {
235 t.Fatalf("zero timeouts left an infinite wait: %+v", c.timeouts)
236 }
237}
238
239// The address decides the network, and a leading slash is the whole rule: a
240// unix socket is always an absolute path and a host never is.
241func TestNetworkForAddress(t *testing.T) {
242 for addr, want := range map[string]string{
243 "/run/clamav/clamd.ctl": "unix",
244 "/tmp/clamd.sock": "unix",
245 "scanner.internal:3310": "tcp",
246 "127.0.0.1:3310": "tcp",
247 "[::1]:3310": "tcp",
248 } {
249 if got := networkFor(addr); got != want {
250 t.Errorf("networkFor(%q) = %q, want %q", addr, got, want)
251 }
252 }
253}
254
255// A TCP client dials TCP and speaks the same protocol. The framing, deadlines
256// and reply parsing are transport-independent, so this asserts the dial and
257// then the whole INSTREAM exchange over a real socket.
258func TestScanStreamOverTCP(t *testing.T) {
259 ln, err := net.Listen("tcp", "127.0.0.1:0")
260 if err != nil {
261 t.Fatal(err)
262 }
263 defer ln.Close()
264
265 got := make(chan []byte, 1)
266 go func() {
267 conn, err := ln.Accept()
268 if err != nil {
269 got <- nil
270 return
271 }
272 defer conn.Close()
273 _ = conn.SetDeadline(time.Now().Add(fakeDeadline))
274 br := bufio.NewReader(conn)
275 if _, err := br.ReadString(0); err != nil {
276 got <- nil
277 return
278 }
279 payload, _ := io.ReadAll(&frameReader{r: br, cmdRead: true})
280 got <- payload
281 _, _ = conn.Write([]byte("stream: OK\x00"))
282 }()
283
284 c := New(ln.Addr().String(), Timeouts{})
285 if c.network != "tcp" {
286 t.Fatalf("network = %q, want tcp", c.network)
287 }
288 res, err := c.ScanStream(context.Background(), bytes.NewReader([]byte("hello")))
289 if err != nil {
290 t.Fatal(err)
291 }
292 if res.Status != StatusClean {
293 t.Fatalf("status = %v, want clean", res.Status)
294 }
295 if b := <-got; !bytes.Equal(b, []byte("hello")) {
296 t.Fatalf("payload over tcp = %q", b)
297 }
298}
299
300// An empty address is refused rather than dialled, and the message says
301// address rather than socket now that either shape is legitimate.
302func TestDialRefusesEmptyAddress(t *testing.T) {
303 c := New("", Timeouts{})
304 if _, err := c.Version(context.Background()); err == nil ||
305 !strings.Contains(err.Error(), "no address configured") {
306 t.Fatalf("err = %v", err)
307 }
308}
309
310// With AlertExceedsMax on, clamd reports an archive it could only partly scan
311// as a FOUND whose signature names the limit. That is not an infection and it
312// is not clean: nothing was judged, so it is the same status as an object too
313// large to stream. Recording it clean would sign a verdict over bytes nobody
314// read; recording it infected would make enforce block large honest archives.
315func TestLimitHeuristicIsSizeExceededNotInfected(t *testing.T) {
316 for _, limit := range []string{"MaxScanSize", "MaxFileSize", "MaxFiles", "MaxRecursion"} {
317 reply := "stream: Heuristics.Limits.Exceeded." + limit + " FOUND\x00"
318 res := replyFixture(t, reply)
319 if res.Status != StatusSizeExceeded {
320 t.Errorf("%s: status = %v, want size-exceeded", limit, res.Status)
321 }
322 if res.Signature != "" {
323 t.Errorf("%s: a limit is not a signature, got %q", limit, res.Signature)
324 }
325 if !strings.Contains(res.Raw, limit) {
326 t.Errorf("%s: the engine's own words did not survive: %q", limit, res.Raw)
327 }
328 }
329}
330
331// Older engines name the limit without saying which one.
332func TestBareLimitHeuristicIsSizeExceeded(t *testing.T) {
333 res := replyFixture(t, "stream: Heuristics.Limits.Exceeded FOUND\x00")
334 if res.Status != StatusSizeExceeded {
335 t.Fatalf("status = %v, want size-exceeded", res.Status)
336 }
337}
338
339// A scan that ran out of TIME depends on load, not on the bytes, so it must be
340// retried. StatusError becomes a failed verdict, which every sweep re-issues;
341// size-exceeded becomes skipped, which is carried forward until the object
342// changes. Filing a timeout under skipped would exclude the object for good.
343func TestScanTimeLimitIsAnErrorSoItIsRetried(t *testing.T) {
344 res := replyFixture(t, "stream: Heuristics.Limits.Exceeded.MaxScanTime FOUND\x00")
345 if res.Status != StatusError {
346 t.Fatalf("status = %v, want error", res.Status)
347 }
348 if !strings.Contains(res.Raw, "MaxScanTime") {
349 t.Fatalf("the engine's own words did not survive: %q", res.Raw)
350 }
351}
352
353// The match is on the SIGNATURE, as a prefix. A real signature that happens to
354// carry the phrase elsewhere in its name is still an infection, and so is one
355// that only begins with something similar.
356func TestLookalikeSignaturesAreStillInfected(t *testing.T) {
357 for _, sig := range []string{
358 "Win.Heuristics.Limits.Exceeded.Fake",
359 "Heuristics.Limits.ExceededButNot",
360 "Heuristics.Phishing.Email.SpoofedDomain",
361 "Eicar-Signature",
362 } {
363 res := replyFixture(t, "stream: "+sig+" FOUND\x00")
364 if res.Status != StatusInfected {
365 t.Errorf("%s: status = %v, want infected", sig, res.Status)
366 }
367 if res.Signature != sig {
368 t.Errorf("signature = %q, want %q", res.Signature, sig)
369 }
370 }
371}
372