carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package clamd speaks just enough of the clamd protocol to stream one object
2// at a time past a scanning engine and read back a verdict.
3//
4// It is hand-rolled and stdlib-only on purpose. The protocol is four commands
5// wide and we use two of them; a dependency would cost more than it saved, and
6// this way every read and write on the socket carries a deadline we chose
7// rather than one a library chose for us. A scan pass that blocks forever on a
8// wedged clamd is a stuck pass, not a slow one.
9//
10// The wire format, for the record:
11//
12// zINSTREAM\0 the command, NUL-terminated
13// <uint32 be><payload> one chunk, repeated
14// <uint32 be 0> the terminator
15// stream: OK\0 the reply, NUL-terminated
16package clamd
17
18import (
19 "bufio"
20 "context"
21 "encoding/binary"
22 "errors"
23 "fmt"
24 "io"
25 "net"
26 "strings"
27 "time"
28)
29
30// Status is what the engine said about one stream.
31type Status string
32
33const (
34 // StatusClean is a scan that ran and found nothing.
35 StatusClean Status = "clean"
36 // StatusInfected is a scan that ran and matched a signature.
37 StatusInfected Status = "infected"
38 // StatusSizeExceeded is the engine refusing to judge an object at all
39 // because it is larger than StreamMaxLength. This is deliberately NOT
40 // StatusError: nothing failed, the object simply was not judged, and an
41 // audit that cannot tell those apart is not worth keeping.
42 // It also covers clamd's limit heuristics (an archive that expands past MaxScanSize, MaxFileSize, MaxFiles or MaxRecursion), which arrive as FOUND when AlertExceedsMax is on.
43 StatusSizeExceeded Status = "size-exceeded"
44 // StatusError is the engine reading the stream and failing on it.
45 StatusError Status = "error"
46)
47
48// Result is one engine verdict.
49type Result struct {
50 Status Status
51 // Signature is the malware name on an infected result, empty otherwise.
52 Signature string
53 // Raw is the engine's reply with its NUL and whitespace trimmed, kept so
54 // a failure can be logged as the engine actually phrased it.
55 Raw string
56}
57
58// Timeouts bounds every blocking operation. There is no infinite option: a
59// zero field takes the default below, it does not mean "wait forever".
60type Timeouts struct {
61 // Dial bounds connecting to the socket.
62 Dial time.Duration
63 // Write bounds a single write — the command, one chunk, the terminator —
64 // not the whole stream. A large object legitimately takes many writes.
65 Write time.Duration
66 // Read bounds waiting for the reply once the stream is fully sent. clamd
67 // does its scanning work before answering, so this is the one that has to
68 // tolerate a real workload.
69 Read time.Duration
70}
71
72const (
73 defaultDial = 5 * time.Second
74 defaultWrite = 30 * time.Second
75 defaultRead = 5 * time.Minute
76
77 // chunkSize is what we hand the kernel per frame. clamd's own client uses
78 // the same order of magnitude and the frame header is 4 bytes, so this
79 // keeps the framing overhead under a thousandth.
80 chunkSize = 64 * 1024
81
82 // clamd's names for "I stopped before the end". With AlertExceedsMax yes it
83 // reports them as FOUND, which parseReply must not read as an infection.
84 limitHeuristic = "Heuristics.Limits.Exceeded"
85 limitHeuristicScanTime = "Heuristics.Limits.Exceeded.MaxScanTime"
86)
87
88func (t Timeouts) withDefaults() Timeouts {
89 if t.Dial <= 0 {
90 t.Dial = defaultDial
91 }
92 if t.Write <= 0 {
93 t.Write = defaultWrite
94 }
95 if t.Read <= 0 {
96 t.Read = defaultRead
97 }
98 return t
99}
100
101// Client talks to one clamd, over a unix socket or TCP. It holds no connection
102// between calls: clamd's INSTREAM is one command per connection, so each call
103// dials, scans and hangs up. That also means a Client is safe to share.
104type Client struct {
105 network string // "unix" or "tcp"
106 addr string
107 timeouts Timeouts
108
109 // conn, when set, is used for exactly one call and then dropped. Tests set
110 // it through newWithConn to run the protocol over a net.Pipe; production
111 // never does.
112 conn net.Conn
113}
114
115// New returns a Client dialling addr — a unix socket path such as
116// /run/clamav/clamd.ctl, or a host:port such as scanner.internal:3310.
117//
118// The two are told apart by a leading slash, and that is the whole rule: a
119// unix socket is always an absolute path and a host is never one.
120//
121// THE TCP CASE HAS NO AUTHENTICATION. clamd has none at all — anything that
122// can reach the port can have it scan arbitrary bytes and answer back — so a
123// TCP address MUST be on a private interface with the port firewalled to the
124// console, never a public one. The design spec records this as a hard
125// constraint on the infrastructure, not a recommendation, and it is the reason
126// an earlier draft refused TCP outright.
127func New(addr string, timeouts Timeouts) *Client {
128 return &Client{network: networkFor(addr), addr: addr, timeouts: timeouts.withDefaults()}
129}
130
131// networkFor picks the dial network from the shape of the address.
132func networkFor(addr string) string {
133 if strings.HasPrefix(addr, "/") {
134 return "unix"
135 }
136 return "tcp"
137}
138
139// newWithConn returns a Client that will run its next — and only its next —
140// command over conn. Test seam.
141func newWithConn(conn net.Conn) *Client {
142 return &Client{conn: conn, timeouts: Timeouts{}.withDefaults()}
143}
144
145func (c *Client) dial(ctx context.Context) (net.Conn, error) {
146 if c.conn != nil {
147 conn := c.conn
148 c.conn = nil
149 return conn, nil
150 }
151 if c.addr == "" {
152 return nil, errors.New("clamd: no address configured")
153 }
154 d := net.Dialer{Timeout: c.timeouts.Dial}
155 conn, err := d.DialContext(ctx, c.network, c.addr)
156 if err != nil {
157 return nil, fmt.Errorf("clamd: dial %s %s: %w", c.network, c.addr, err)
158 }
159 return conn, nil
160}
161
162// deadline is now+d, pulled in to the context's own deadline when that is
163// sooner, so a cancelled pass does not sit out a full timeout.
164func deadline(ctx context.Context, d time.Duration) time.Time {
165 t := time.Now().Add(d)
166 if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(t) {
167 return ctxDeadline
168 }
169 return t
170}
171
172func (c *Client) write(ctx context.Context, conn net.Conn, b []byte) error {
173 if err := ctx.Err(); err != nil {
174 return err
175 }
176 if err := conn.SetWriteDeadline(deadline(ctx, c.timeouts.Write)); err != nil {
177 return err
178 }
179 _, err := conn.Write(b)
180 return err
181}
182
183// readReply reads up to the NUL that ends every clamd reply. A closed
184// connection with bytes already in hand is a complete reply, not a failure:
185// clamd hangs up promptly after answering.
186func (c *Client) readReply(ctx context.Context, conn net.Conn) (string, error) {
187 if err := conn.SetReadDeadline(deadline(ctx, c.timeouts.Read)); err != nil {
188 return "", err
189 }
190 r := bufio.NewReader(conn)
191 s, err := r.ReadString(0)
192 if err != nil && !(errors.Is(err, io.EOF) && s != "") {
193 return "", fmt.Errorf("clamd: read reply: %w", err)
194 }
195 return strings.TrimSpace(strings.TrimSuffix(s, "\x00")), nil
196}
197
198// ScanStream sends r past the engine and returns its verdict. It never buffers
199// the object: r is read a chunk at a time straight onto the socket, so a
200// gigabyte object costs a chunk of memory, not a gigabyte.
201func (c *Client) ScanStream(ctx context.Context, r io.Reader) (Result, error) {
202 conn, err := c.dial(ctx)
203 if err != nil {
204 return Result{}, err
205 }
206 defer conn.Close()
207
208 if err := c.write(ctx, conn, []byte("zINSTREAM\x00")); err != nil {
209 return Result{}, fmt.Errorf("clamd: send INSTREAM: %w", err)
210 }
211
212 buf := make([]byte, chunkSize)
213 var hdr [4]byte
214 for {
215 n, rerr := r.Read(buf)
216 if n > 0 {
217 binary.BigEndian.PutUint32(hdr[:], uint32(n))
218 if err := c.write(ctx, conn, hdr[:]); err != nil {
219 return c.replyAfterWriteFailure(ctx, conn, err)
220 }
221 if err := c.write(ctx, conn, buf[:n]); err != nil {
222 return c.replyAfterWriteFailure(ctx, conn, err)
223 }
224 }
225 if errors.Is(rerr, io.EOF) {
226 break
227 }
228 if rerr != nil {
229 return Result{}, fmt.Errorf("clamd: read object: %w", rerr)
230 }
231 }
232
233 binary.BigEndian.PutUint32(hdr[:], 0)
234 if err := c.write(ctx, conn, hdr[:]); err != nil {
235 return c.replyAfterWriteFailure(ctx, conn, err)
236 }
237
238 reply, err := c.readReply(ctx, conn)
239 if err != nil {
240 return Result{}, err
241 }
242 return parseReply(reply), nil
243}
244
245// replyAfterWriteFailure covers the one case where a broken pipe is an answer
246// rather than an accident: clamd refuses an over-large stream mid-upload by
247// replying and hanging up, so our next write fails. Reporting that as a
248// transport error would fail the whole pass over an object the engine has
249// already told us it will not judge. If nothing readable is waiting, the
250// original write error stands.
251func (c *Client) replyAfterWriteFailure(ctx context.Context, conn net.Conn, werr error) (Result, error) {
252 reply, rerr := c.readReply(ctx, conn)
253 if rerr != nil || reply == "" {
254 return Result{}, fmt.Errorf("clamd: send stream: %w", werr)
255 }
256 return parseReply(reply), nil
257}
258
259// Version returns the engine and signature-database line, which is what the
260// evidence records as the provenance of a sweep.
261func (c *Client) Version(ctx context.Context) (string, error) {
262 conn, err := c.dial(ctx)
263 if err != nil {
264 return "", err
265 }
266 defer conn.Close()
267
268 if err := c.write(ctx, conn, []byte("zVERSION\x00")); err != nil {
269 return "", fmt.Errorf("clamd: send VERSION: %w", err)
270 }
271 return c.readReply(ctx, conn)
272}
273
274// isLimitHeuristic reports whether sig names a scan limit rather than malware.
275// A prefix on the signature, with the dot, so neither a longer first component
276// nor a real signature carrying the phrase elsewhere can match.
277func isLimitHeuristic(sig string) bool {
278 return sig == limitHeuristic || strings.HasPrefix(sig, limitHeuristic+".")
279}
280
281// parseReply maps clamd's one-line answers onto a Status.
282//
283// The size check comes first and deliberately: the refusal arrives as
284// "INSTREAM size limit exceeded. ERROR", so an ERROR-suffix test that ran
285// first would swallow it and record a failure where nothing failed.
286func parseReply(reply string) Result {
287 res := Result{Raw: reply}
288 switch {
289 case strings.Contains(reply, "size limit exceeded"):
290 res.Status = StatusSizeExceeded
291 case strings.HasSuffix(reply, " OK"):
292 res.Status = StatusClean
293 case strings.HasSuffix(reply, " FOUND"):
294 sig := signatureOf(reply)
295 switch {
296 case sig == limitHeuristicScanTime:
297 // Load-dependent, so retried: an error becomes a failed verdict,
298 // which every sweep re-issues.
299 res.Status = StatusError
300 case isLimitHeuristic(sig):
301 // Deterministic for these bytes and this config, and nothing was
302 // judged: the same fact as an object too large to stream.
303 res.Status = StatusSizeExceeded
304 default:
305 res.Status = StatusInfected
306 res.Signature = sig
307 }
308 default:
309 res.Status = StatusError
310 }
311 return res
312}
313
314// signatureOf pulls the malware name out of "stream: Eicar-Signature FOUND".
315func signatureOf(reply string) string {
316 body := strings.TrimSuffix(reply, " FOUND")
317 if i := strings.LastIndex(body, ": "); i >= 0 {
318 body = body[i+2:]
319 }
320 return strings.TrimSpace(body)
321}
322