clamav

package module
v0.2.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 11 Imported by: 0

README

go-clamav

CI Go Reference

A pure-Go clamd (ClamAV daemon) client for scanning untrusted user uploads before accepting them, designed fail-closed from the type system up.

日本語版は README.ja.md を参照してください。

  • Pure Go, stdlib only. No cgo, no dependencies, CGO_ENABLED=0 builds. The library speaks the clamd socket protocol directly (see ADR-0001 for why clamd instead of linking libclamav).
  • INSTREAM only. File contents are streamed to clamd; clamd never needs filesystem access to the scanned data, and no temporary files are created.
  • Fail-closed by construction. An error can never be mistaken for a clean verdict, oversized inputs are rejected before they are streamed, and replies the client cannot parse are errors — never guesses.
  • Context-aware. Every call honors context.Context cancellation and deadlines, including mid-stream.
  • DoS-resistant. Bounded reply reads, per-operation I/O timeouts, a client-side stream size limit, and an optional concurrency cap.

The fail-closed contract

This library is a security control. Callers must apply exactly one rule set:

Outcome Meaning Caller obligation
err == nil && res.Clean() Scan completed, no detection The file may be accepted
res.Infected() Scan completed, signature match Reject; quarantine and audit
err != nil (any type) Verdict unknown — not clean Reject (or retry, then reject)

When err != nil the returned ScanResult is always the zero value, whose Verdict is VerdictUnknown — so even buggy callers that ignore the error and check res.Clean() do not accept the file. Never treat a scan failure (timeouts, connection failures, ErrSizeLimitExceeded, ...) as "no malware found".

Installation

go get github.com/PyYoshi/go-clamav

Requires Go 1.26+ and a running clamd. CI covers the 1.4 LTS line (supported until 2027-08-15) and the current regular release (1.5); the dockerized test environment defaults to 1.5.

Quick start

client, err := clamav.New("unix:///run/clamav/clamd.sock",
    clamav.WithMaxStreamSize(25<<20),    // keep equal to clamd's StreamMaxLength
    clamav.WithMaxConcurrentScans(4),    // keep below clamd's MaxThreads
)
if err != nil {
    log.Fatal(err)
}

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()

result, err := client.Scan(ctx, uploadedFile)
switch {
case err != nil:
    // Verdict UNKNOWN: reject the upload. See IsRetryable for retry hints.
    reject(err)
case result.Infected():
    quarantine(result.Signature)
default:
    accept()
}

A complete upload endpoint (including HTTP status mapping and health checks) is in examples/httpupload; a CLI scanner is in examples/basicscan. Mocking the client in your own tests (consumer-defined interface, fail-closed mock rules) is shown in examples/mockscan; the library deliberately exports no scanner interface (see ADR-0004).

API overview

func New(addr string, opts ...Option) (*Client, error)

func (c *Client) Scan(ctx context.Context, r io.Reader) (ScanResult, error)
func (c *Client) ScanBytes(ctx context.Context, data []byte) (ScanResult, error)
func (c *Client) ScanFile(ctx context.Context, path string) (ScanResult, error)

func (c *Client) Ping(ctx context.Context) error              // readiness probe
func (c *Client) Version(ctx context.Context) (string, error) // includes DB version/date
func (c *Client) Stats(ctx context.Context) (string, error)   // diagnostic text
func (c *Client) Reload(ctx context.Context) error            // admin; not for scan paths

Client is safe for concurrent use. Each command runs on its own connection, matching clamd's one-command-per-connection session model.

Options
Option Default Notes
WithMaxStreamSize(n) 25 MiB Client-side payload limit. Set equal to clamd's StreamMaxLength. NoSizeLimit disables (discouraged)
WithMaxConcurrentScans(n) 0 (off) Cap concurrent scans; keep below clamd's MaxThreads
WithDialTimeout(d) 10s Connection establishment
WithIOTimeout(d) 30s Per-read/write no-progress limit (total time = context)
WithChunkSize(n) 32 KiB INSTREAM chunk payload size
WithDialFunc(fn) net.Dialer Custom transport (tests, proxies)
Errors

All failures arrive as one of four shapes; classify with errors.Is/As:

Error Meaning IsRetryable
ErrSizeLimitExceeded Client- or clamd-side size limit; not scanned no
*ClamdError clamd replied ... ERROR no
*ProtocolError Unclassifiable reply (fail-closed) no
*ConnectionError Dial/read/write transport failure yes
ctx cancellation Wrapped context.Canceled / DeadlineExceeded no / yes

The library never retries on its own: an io.Reader cannot be replayed and silent retries would double-stream uploads. Use IsRetryable(err) and re-supply the input yourself (e.g. reopen the file) with bounded backoff.

Addresses and deployment security

unix:///run/clamav/clamd.sock   # preferred
tcp://127.0.0.1:3310            # loopback / isolated private networks only

The clamd protocol is unauthenticated cleartext. Treat the address as security-sensitive deployment configuration:

  • Prefer unix sockets; use TCP only to loopback or an isolated network.
  • Never expose clamd's port to untrusted networks (anyone who can reach it can also issue SHUTDOWN).
  • Never derive the address from request or user input (SSRF-style pivots).
  • Scheme-less addresses are rejected by New on purpose.

Operational hardening (clamd.conf limits, freshclam, monitoring signature freshness, overload behavior) is covered in docs/operations.md.

Testing

make test          # unit tests + race detector (no Docker needed)
make integration   # starts dockerized clamd, runs integration suite
make fuzz          # short fuzzing pass over the reply parser
make lint          # golangci-lint (security-heavy config) + govulncheck
make format        # gofumpt + gci formatting (via golangci-lint fmt)

The integration environment (see docker/) starts the official clamav/clamav image in seconds using a minimal EICAR-only signature database, exposes both a unix socket and loopback TCP, and uses a tiny StreamMaxLength so the size limit paths are actually exercised. The EICAR test string never appears assembled in the repository — it is stored as a hex signature and as split string constants, and only ever exists complete in memory during tests.

Architecture fit

The library is the scanning client piece of the pattern used by GoogleCloudPlatform/docker-clamav-malware-scanner: uploads land in an unscanned area, a worker scans them (this library → clamd), and files are then routed to clean storage or quarantine. Run clamd as a sidecar or dedicated service; run freshclam next to clamd, not in your application.

Contributing

main is protected by repository rulesets: changes land only through pull requests with all CI checks green, every commit must carry a verified signature, and pull requests are merged with a merge commit (squash and rebase are disabled so commit signatures survive). Release tags (v*) cannot be deleted or moved.

See CONTRIBUTING.md for the development workflow, and AGENTS.md for the rules every contributor — human and AI — works under. Run make setup once per clone to enable the repository git hooks.

License

MIT — see LICENSE. The library talks to ClamAV over a socket and does not link libclamav; ClamAV itself remains licensed under GPLv2.

Documentation

Overview

Package clamav is a pure-Go client for clamd, the ClamAV daemon, designed for scanning untrusted user uploads before accepting them.

The library talks to clamd over its socket protocol (unix:// or tcp://) and streams file contents with the INSTREAM command, so clamd never needs filesystem access to the scanned data and no temporary files are involved.

Fail-closed contract

This is a security control. Callers MUST apply the following rules:

  • result.Infected() == true: reject (and quarantine/audit) the file.
  • result.Clean() == true: the file may be accepted.
  • err != nil (any error, any type): the verdict is UNKNOWN. The file must NOT be accepted. Reject it or retry; see IsRetryable.

When an error is returned the ScanResult is always the zero value, whose Verdict is VerdictUnknown; a zero ScanResult never reports Clean() true. Never treat a scan failure — including ErrSizeLimitExceeded, timeouts, and connection failures — as "no malware found".

Deployment notes

The clamd protocol is unauthenticated cleartext. Connect over a unix socket, or over TCP only on loopback or an isolated private network. Never expose clamd to untrusted networks and never derive the address passed to New from request data.

The address, like the rest of the configuration, is fixed at New time. Signature freshness, StreamMaxLength, and scan limits are controlled by the clamd deployment (clamd.conf and freshclam); see docs/operations.md in the repository for hardening guidance.

Index

Examples

Constants

View Source
const (
	// DefaultDialTimeout bounds connection establishment.
	DefaultDialTimeout = 10 * time.Second
	// DefaultIOTimeout bounds each individual read/write operation, i.e.
	// the maximum time the connection may make no progress at all. It is
	// not a whole-scan timeout; bound the total scan with the context.
	DefaultIOTimeout = 30 * time.Second
	// DefaultChunkSize is the INSTREAM chunk payload size.
	DefaultChunkSize = 32 << 10 // 32 KiB
	// DefaultMaxStreamSize is the client-side payload limit. It matches
	// clamd's historical StreamMaxLength default (25 MiB). Configure
	// WithMaxStreamSize to the StreamMaxLength of your clamd deployment.
	DefaultMaxStreamSize int64 = 25 << 20 // 25 MiB
)

Defaults applied by New. All of them can be overridden with Options.

View Source
const NoSizeLimit int64 = -1

NoSizeLimit disables the client-side stream size limit when passed to WithMaxStreamSize. clamd's own StreamMaxLength still applies. Disabling the client-side limit is discouraged: it allows a single oversized upload to consume bandwidth and clamd resources before being rejected.

Variables

View Source
var ErrSizeLimitExceeded = errors.New("clamav: stream size limit exceeded")

ErrSizeLimitExceeded reports that a payload exceeded a size limit: either the client-side limit configured with WithMaxStreamSize, or clamd's StreamMaxLength (clamd replies "INSTREAM size limit exceeded. ERROR"). Detect it with errors.Is. The error message states which side enforced the limit.

A size-limited file has NOT been scanned. Under the fail-closed contract it must be rejected, not accepted.

Functions

func IsRetryable

func IsRetryable(err error) bool

IsRetryable reports whether retrying the failed operation with the same input and a fresh context could plausibly succeed.

The library never retries by itself: an io.Reader cannot be replayed, and silent retries would double-stream large uploads. Callers that retry must re-supply the input (e.g. reopen the file) and should apply a bounded backoff.

Retryable: connection failures (ConnectionError) and deadline expiry. Not retryable: context.Canceled, ErrSizeLimitExceeded, ClamdError, ProtocolError, and anything unrecognized (conservative default).

A verdict of VerdictInfected is a scan result, not an error — it is never subject to retry.

Types

type ClamdError

type ClamdError struct {
	// Message is the reply with the trailing " ERROR" token removed.
	Message string
}

ClamdError reports that clamd itself replied with an "... ERROR" response (engine failure, size limit, malformed request, ...). Detect it with errors.As. When the underlying cause is a size limit violation the error also matches errors.Is(err, ErrSizeLimitExceeded).

func (*ClamdError) Error

func (e *ClamdError) Error() string

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a clamd client. It is safe for concurrent use by multiple goroutines: configuration is immutable after New and every command runs on its own connection (clamd closes the connection after each non-session command anyway).

func New

func New(addr string, opts ...Option) (*Client, error)

New creates a Client for the clamd instance at addr and validates the configuration. addr must use an explicit scheme:

unix:///run/clamav/clamd.sock
tcp://127.0.0.1:3310

No connection is made yet; use Ping to probe reachability.

The address is part of the security configuration: it must come from deployment configuration, never from request or user input, and TCP should only point at loopback or an isolated private network (the clamd protocol is unauthenticated cleartext).

func (*Client) Ping

func (c *Client) Ping(ctx context.Context) error

Ping checks that clamd is reachable and answering. It is suitable as a readiness/health probe. Any reply other than PONG is an error.

Example

Ping doubles as a readiness probe for the scanning dependency.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	clamav "github.com/PyYoshi/go-clamav"
)

func main() {
	client, err := clamav.New("tcp://127.0.0.1:3310")
	if err != nil {
		log.Fatal(err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := client.Ping(ctx); err != nil {
		fmt.Println("clamd not ready:", err)
	}
}

func (*Client) Reload

func (c *Client) Reload(ctx context.Context) error

Reload asks clamd to reload its signature databases.

This is an administrative operation: signature updates are normally driven by freshclam on the clamd side, and a reload briefly increases clamd's memory and latency. Do not call it from a scanning code path.

func (*Client) Scan

func (c *Client) Scan(ctx context.Context, r io.Reader) (ScanResult, error)

Scan streams r to clamd with the INSTREAM command and returns the verdict.

Fail-closed contract: when err != nil the returned ScanResult is the zero value (Verdict == VerdictUnknown) and the data MUST NOT be treated as clean. A VerdictInfected result is a successful scan, not an error.

r is consumed exactly once and is not replayed on failure; see IsRetryable for which errors are worth retrying with a fresh reader.

Example

The canonical fail-closed usage: any error means the file must not be accepted — an unreachable or overloaded scanner never lets a file through.

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	clamav "github.com/PyYoshi/go-clamav"
)

func main() {
	client, err := clamav.New("unix:///run/clamav/clamd.sock",
		clamav.WithMaxStreamSize(25<<20), // keep equal to clamd's StreamMaxLength
	)
	if err != nil {
		log.Fatal(err)
	}

	f, err := os.Open("/uploads/pending/document.pdf")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	result, err := client.Scan(ctx, f)
	switch {
	case err != nil:
		// UNKNOWN verdict: reject or retry (see IsRetryable). Never accept.
		fmt.Println("scan failed, rejecting upload:", err)
	case result.Infected():
		fmt.Println("malware detected, quarantining:", result.Signature)
	default:
		fmt.Println("clean, accepting upload")
	}
}

func (*Client) ScanBytes

func (c *Client) ScanBytes(ctx context.Context, data []byte) (ScanResult, error)

ScanBytes scans an in-memory payload. See Scan for the fail-closed contract.

func (*Client) ScanFile

func (c *Client) ScanFile(ctx context.Context, path string) (ScanResult, error)

ScanFile opens path locally and streams its contents to clamd. clamd never sees the path and needs no access to the file (the path-based SCAN command is deliberately not used: it would require a shared filesystem and reintroduce time-of-check/time-of-use concerns).

Files larger than the client-side limit fail with ErrSizeLimitExceeded before any connection is made. Only regular files are accepted; the type is checked before opening (so a FIFO path cannot block the open) and re-checked race-free on the open descriptor. The file is opened only after a concurrency slot is acquired, so queued scans do not accumulate open descriptors. See Scan for the fail-closed contract.

func (*Client) Stats

func (c *Client) Stats(ctx context.Context) (string, error)

Stats returns clamd's multi-line STATS report (thread pool and queue state), for operational monitoring. The format is not stable across clamd versions; treat it as diagnostic text.

func (*Client) Version

func (c *Client) Version(ctx context.Context) (string, error)

Version returns the clamd version line, e.g.

ClamAV 1.4.3/27700/Wed Jul  1 08:32:03 2026

The second and third fields are the signature database version and its publication date — useful for monitoring signature freshness.

type ConnectionError

type ConnectionError struct {
	// Op is the failing operation: "dial", "read", or "write".
	Op string
	// Err is the underlying transport error.
	Err error
}

ConnectionError reports a transport failure (dial, read, or write) while talking to clamd. Detect it with errors.As. These are usually transient (clamd restarting, socket backlog, network hiccup); see IsRetryable.

func (*ConnectionError) Error

func (e *ConnectionError) Error() string

func (*ConnectionError) Unwrap

func (e *ConnectionError) Unwrap() error

type DialFunc

type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)

DialFunc establishes the transport connection. network is "unix" or "tcp" as derived from the address passed to New.

type Option

type Option func(*config)

Option configures a Client at construction time. Configuration is validated and then immutable, which is what makes Client goroutine-safe.

func WithChunkSize

func WithChunkSize(n int) Option

WithChunkSize sets the INSTREAM chunk payload size in bytes.

func WithDialFunc

func WithDialFunc(fn DialFunc) Option

WithDialFunc replaces the transport dialer. Intended for tests and for custom transports (e.g. proxies). The implementation is responsible for honoring ctx.

func WithDialTimeout

func WithDialTimeout(d time.Duration) Option

WithDialTimeout bounds connection establishment. The context passed to each call still applies; the earlier deadline wins.

func WithIOTimeout

func WithIOTimeout(d time.Duration) Option

WithIOTimeout bounds each individual read/write on the connection. The deadline is refreshed before every operation, so it limits the time the scan makes no progress, not the total scan duration — bound that with the context.

func WithMaxConcurrentScans

func WithMaxConcurrentScans(n int) Option

WithMaxConcurrentScans caps the number of Scan/ScanBytes/ScanFile calls executing at once; further calls wait for a slot or fail when their context expires. 0 (the default) means no client-side cap.

Size this below MaxThreads in clamd.conf so a burst of uploads queues in the application instead of overloading clamd. Admin commands (Ping, Version, Stats, Reload) do not count against the cap.

func WithMaxStreamSize

func WithMaxStreamSize(n int64) Option

WithMaxStreamSize sets the client-side payload limit in bytes, or disables it with NoSizeLimit. Inputs that would exceed the limit fail with ErrSizeLimitExceeded before the excess is sent to clamd.

Set this to the same value as StreamMaxLength in your clamd.conf. If the client limit is larger than StreamMaxLength, oversized streams are still rejected — by clamd — but only after the bytes have been transferred.

type ProtocolError

type ProtocolError struct {
	// Command is the clamd command that was being executed, e.g. "INSTREAM".
	Command string
	// Response is the offending reply, bounded by the internal read limit.
	Response string
}

ProtocolError reports a clamd reply that does not match the known reply grammar. Unknown replies are always errors: the library never guesses a verdict from a response it cannot classify (fail-closed).

func (*ProtocolError) Error

func (e *ProtocolError) Error() string

type ScanResult

type ScanResult struct {
	// Verdict is the scan outcome. Never VerdictUnknown on a nil error.
	Verdict Verdict
	// Signature is the matched signature name (e.g. "Win.Test.EICAR_HDB-1").
	// Set only when Verdict is VerdictInfected; it may be empty if clamd
	// reported a detection without a usable name.
	Signature string
	// Raw is the verbatim clamd reply line, for logging and diagnostics.
	// Do not parse it to derive a verdict; use Verdict.
	Raw string
}

ScanResult is the outcome of a successfully completed scan. Methods on Scan-family functions return the zero ScanResult whenever they return a non-nil error, so its Verdict is VerdictUnknown in every error case.

func (ScanResult) Clean

func (r ScanResult) Clean() bool

Clean reports whether the scan completed with no detection.

func (ScanResult) Infected

func (r ScanResult) Infected() bool

Infected reports whether the scan completed with a detection.

type Verdict

type Verdict uint8

Verdict is the outcome of a completed scan.

The zero value is VerdictUnknown, which is never produced by a successful scan: it exists so that a ScanResult obtained alongside a non-nil error (always the zero ScanResult) can never be mistaken for a clean verdict.

const (
	// VerdictUnknown means no verdict was obtained. It is the zero value
	// and must never be treated as clean.
	VerdictUnknown Verdict = iota
	// VerdictClean means the scan completed and clamd found no signature.
	VerdictClean
	// VerdictInfected means the scan completed and clamd matched a signature.
	VerdictInfected
)

func (Verdict) String

func (v Verdict) String() string

String returns "unknown", "clean", or "infected".

Directories

Path Synopsis
examples
basicscan command
Command basicscan scans files with clamd and prints one verdict per file.
Command basicscan scans files with clamd and prints one verdict per file.
httpupload command
Command httpupload is a reference implementation of fail-closed upload scanning: an HTTP endpoint that accepts a file only after clamd has scanned it and returned a clean verdict.
Command httpupload is a reference implementation of fail-closed upload scanning: an HTTP endpoint that accepts a file only after clamd has scanned it and returned a clean verdict.
mockscan
Package mockscan shows how to test code that uses go-clamav without a running clamd: define a minimal interface at your own boundary and hand it a test double (see mockscan_test.go).
Package mockscan shows how to test code that uses go-clamav without a running clamd: define a minimal interface at your own boundary and hand it a test double (see mockscan_test.go).
internal
clamdtest
Package clamdtest provides a scriptable in-process fake clamd server for unit tests.
Package clamdtest provides a scriptable in-process fake clamd server for unit tests.
proto
Package proto implements the clamd wire protocol primitives: z-format command encoding, INSTREAM chunk framing, and response parsing.
Package proto implements the clamd wire protocol primitives: z-format command encoding, INSTREAM chunk framing, and response parsing.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL