// Package clamd speaks just enough of the clamd protocol to stream one object // at a time past a scanning engine and read back a verdict. // // It is hand-rolled and stdlib-only on purpose. The protocol is four commands // wide and we use two of them; a dependency would cost more than it saved, and // this way every read and write on the socket carries a deadline we chose // rather than one a library chose for us. A scan pass that blocks forever on a // wedged clamd is a stuck pass, not a slow one. // // The wire format, for the record: // // zINSTREAM\0 the command, NUL-terminated // one chunk, repeated // the terminator // stream: OK\0 the reply, NUL-terminated package clamd import ( "bufio" "context" "encoding/binary" "errors" "fmt" "io" "net" "strings" "time" ) // Status is what the engine said about one stream. type Status string const ( // StatusClean is a scan that ran and found nothing. StatusClean Status = "clean" // StatusInfected is a scan that ran and matched a signature. StatusInfected Status = "infected" // StatusSizeExceeded is the engine refusing to judge an object at all // because it is larger than StreamMaxLength. This is deliberately NOT // StatusError: nothing failed, the object simply was not judged, and an // audit that cannot tell those apart is not worth keeping. // 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. StatusSizeExceeded Status = "size-exceeded" // StatusError is the engine reading the stream and failing on it. StatusError Status = "error" ) // Result is one engine verdict. type Result struct { Status Status // Signature is the malware name on an infected result, empty otherwise. Signature string // Raw is the engine's reply with its NUL and whitespace trimmed, kept so // a failure can be logged as the engine actually phrased it. Raw string } // Timeouts bounds every blocking operation. There is no infinite option: a // zero field takes the default below, it does not mean "wait forever". type Timeouts struct { // Dial bounds connecting to the socket. Dial time.Duration // Write bounds a single write — the command, one chunk, the terminator — // not the whole stream. A large object legitimately takes many writes. Write time.Duration // Read bounds waiting for the reply once the stream is fully sent. clamd // does its scanning work before answering, so this is the one that has to // tolerate a real workload. Read time.Duration } const ( defaultDial = 5 * time.Second defaultWrite = 30 * time.Second defaultRead = 5 * time.Minute // chunkSize is what we hand the kernel per frame. clamd's own client uses // the same order of magnitude and the frame header is 4 bytes, so this // keeps the framing overhead under a thousandth. chunkSize = 64 * 1024 // clamd's names for "I stopped before the end". With AlertExceedsMax yes it // reports them as FOUND, which parseReply must not read as an infection. limitHeuristic = "Heuristics.Limits.Exceeded" limitHeuristicScanTime = "Heuristics.Limits.Exceeded.MaxScanTime" ) func (t Timeouts) withDefaults() Timeouts { if t.Dial <= 0 { t.Dial = defaultDial } if t.Write <= 0 { t.Write = defaultWrite } if t.Read <= 0 { t.Read = defaultRead } return t } // Client talks to one clamd, over a unix socket or TCP. It holds no connection // between calls: clamd's INSTREAM is one command per connection, so each call // dials, scans and hangs up. That also means a Client is safe to share. type Client struct { network string // "unix" or "tcp" addr string timeouts Timeouts // conn, when set, is used for exactly one call and then dropped. Tests set // it through newWithConn to run the protocol over a net.Pipe; production // never does. conn net.Conn } // New returns a Client dialling addr — a unix socket path such as // /run/clamav/clamd.ctl, or a host:port such as scanner.internal:3310. // // The two are told apart by a leading slash, and that is the whole rule: a // unix socket is always an absolute path and a host is never one. // // THE TCP CASE HAS NO AUTHENTICATION. clamd has none at all — anything that // can reach the port can have it scan arbitrary bytes and answer back — so a // TCP address MUST be on a private interface with the port firewalled to the // console, never a public one. The design spec records this as a hard // constraint on the infrastructure, not a recommendation, and it is the reason // an earlier draft refused TCP outright. func New(addr string, timeouts Timeouts) *Client { return &Client{network: networkFor(addr), addr: addr, timeouts: timeouts.withDefaults()} } // networkFor picks the dial network from the shape of the address. func networkFor(addr string) string { if strings.HasPrefix(addr, "/") { return "unix" } return "tcp" } // newWithConn returns a Client that will run its next — and only its next — // command over conn. Test seam. func newWithConn(conn net.Conn) *Client { return &Client{conn: conn, timeouts: Timeouts{}.withDefaults()} } func (c *Client) dial(ctx context.Context) (net.Conn, error) { if c.conn != nil { conn := c.conn c.conn = nil return conn, nil } if c.addr == "" { return nil, errors.New("clamd: no address configured") } d := net.Dialer{Timeout: c.timeouts.Dial} conn, err := d.DialContext(ctx, c.network, c.addr) if err != nil { return nil, fmt.Errorf("clamd: dial %s %s: %w", c.network, c.addr, err) } return conn, nil } // deadline is now+d, pulled in to the context's own deadline when that is // sooner, so a cancelled pass does not sit out a full timeout. func deadline(ctx context.Context, d time.Duration) time.Time { t := time.Now().Add(d) if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(t) { return ctxDeadline } return t } func (c *Client) write(ctx context.Context, conn net.Conn, b []byte) error { if err := ctx.Err(); err != nil { return err } if err := conn.SetWriteDeadline(deadline(ctx, c.timeouts.Write)); err != nil { return err } _, err := conn.Write(b) return err } // readReply reads up to the NUL that ends every clamd reply. A closed // connection with bytes already in hand is a complete reply, not a failure: // clamd hangs up promptly after answering. func (c *Client) readReply(ctx context.Context, conn net.Conn) (string, error) { if err := conn.SetReadDeadline(deadline(ctx, c.timeouts.Read)); err != nil { return "", err } r := bufio.NewReader(conn) s, err := r.ReadString(0) if err != nil && !(errors.Is(err, io.EOF) && s != "") { return "", fmt.Errorf("clamd: read reply: %w", err) } return strings.TrimSpace(strings.TrimSuffix(s, "\x00")), nil } // ScanStream sends r past the engine and returns its verdict. It never buffers // the object: r is read a chunk at a time straight onto the socket, so a // gigabyte object costs a chunk of memory, not a gigabyte. func (c *Client) ScanStream(ctx context.Context, r io.Reader) (Result, error) { conn, err := c.dial(ctx) if err != nil { return Result{}, err } defer conn.Close() if err := c.write(ctx, conn, []byte("zINSTREAM\x00")); err != nil { return Result{}, fmt.Errorf("clamd: send INSTREAM: %w", err) } buf := make([]byte, chunkSize) var hdr [4]byte for { n, rerr := r.Read(buf) if n > 0 { binary.BigEndian.PutUint32(hdr[:], uint32(n)) if err := c.write(ctx, conn, hdr[:]); err != nil { return c.replyAfterWriteFailure(ctx, conn, err) } if err := c.write(ctx, conn, buf[:n]); err != nil { return c.replyAfterWriteFailure(ctx, conn, err) } } if errors.Is(rerr, io.EOF) { break } if rerr != nil { return Result{}, fmt.Errorf("clamd: read object: %w", rerr) } } binary.BigEndian.PutUint32(hdr[:], 0) if err := c.write(ctx, conn, hdr[:]); err != nil { return c.replyAfterWriteFailure(ctx, conn, err) } reply, err := c.readReply(ctx, conn) if err != nil { return Result{}, err } return parseReply(reply), nil } // replyAfterWriteFailure covers the one case where a broken pipe is an answer // rather than an accident: clamd refuses an over-large stream mid-upload by // replying and hanging up, so our next write fails. Reporting that as a // transport error would fail the whole pass over an object the engine has // already told us it will not judge. If nothing readable is waiting, the // original write error stands. func (c *Client) replyAfterWriteFailure(ctx context.Context, conn net.Conn, werr error) (Result, error) { reply, rerr := c.readReply(ctx, conn) if rerr != nil || reply == "" { return Result{}, fmt.Errorf("clamd: send stream: %w", werr) } return parseReply(reply), nil } // Version returns the engine and signature-database line, which is what the // evidence records as the provenance of a sweep. func (c *Client) Version(ctx context.Context) (string, error) { conn, err := c.dial(ctx) if err != nil { return "", err } defer conn.Close() if err := c.write(ctx, conn, []byte("zVERSION\x00")); err != nil { return "", fmt.Errorf("clamd: send VERSION: %w", err) } return c.readReply(ctx, conn) } // isLimitHeuristic reports whether sig names a scan limit rather than malware. // A prefix on the signature, with the dot, so neither a longer first component // nor a real signature carrying the phrase elsewhere can match. func isLimitHeuristic(sig string) bool { return sig == limitHeuristic || strings.HasPrefix(sig, limitHeuristic+".") } // parseReply maps clamd's one-line answers onto a Status. // // The size check comes first and deliberately: the refusal arrives as // "INSTREAM size limit exceeded. ERROR", so an ERROR-suffix test that ran // first would swallow it and record a failure where nothing failed. func parseReply(reply string) Result { res := Result{Raw: reply} switch { case strings.Contains(reply, "size limit exceeded"): res.Status = StatusSizeExceeded case strings.HasSuffix(reply, " OK"): res.Status = StatusClean case strings.HasSuffix(reply, " FOUND"): sig := signatureOf(reply) switch { case sig == limitHeuristicScanTime: // Load-dependent, so retried: an error becomes a failed verdict, // which every sweep re-issues. res.Status = StatusError case isLimitHeuristic(sig): // Deterministic for these bytes and this config, and nothing was // judged: the same fact as an object too large to stream. res.Status = StatusSizeExceeded default: res.Status = StatusInfected res.Signature = sig } default: res.Status = StatusError } return res } // signatureOf pulls the malware name out of "stream: Eicar-Signature FOUND". func signatureOf(reply string) string { body := strings.TrimSuffix(reply, " FOUND") if i := strings.LastIndex(body, ": "); i >= 0 { body = body[i+2:] } return strings.TrimSpace(body) }