| 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 |
| 16 | package clamd |
| 17 | |
| 18 | import ( |
| 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. |
| 31 | type Status string |
| 32 | |
| 33 | const ( |
| 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. |
| 49 | type 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". |
| 60 | type 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 | |
| 72 | const ( |
| 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 | |
| 88 | func (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. |
| 104 | type 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. |
| 127 | func 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. |
| 132 | func 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. |
| 141 | func newWithConn(conn net.Conn) *Client { |
| 142 | return &Client{conn: conn, timeouts: Timeouts{}.withDefaults()} |
| 143 | } |
| 144 | |
| 145 | func (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. |
| 164 | func 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 | |
| 172 | func (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. |
| 186 | func (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. |
| 201 | func (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. |
| 251 | func (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. |
| 261 | func (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. |
| 277 | func 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. |
| 286 | func 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". |
| 315 | func 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 | |