Scan files for viruses in Go with ClamAV
Applications that accept uploads need a clear rule for releasing files after scanning. This guide uses ClamAV and Go to distinguish four outcomes: clean, infected, scanner error, and size limit. Only an explicit clean result permits the application to continue processing.
Introduction to virus scanning in Go
Keep new uploads in private quarantine until scanning finishes. A connection failure, timeout, empty reply, or exceeded scan limit is not evidence that a file is clean. Antivirus scanning is one layer of protection; it cannot establish that every file is harmless.
We’ll stream a bounded snapshot of one completed file to a local daemon. The example never scans a directory, deletes an upload, or tries to repair a file. Your application must keep the scanned snapshot immutable and publish those same bytes only after a clean verdict.
Overview of ClamAV and its capabilities
ClamAV provides the clamscan command, the persistent clamd daemon, and freshclam for signature
updates. Using a daemon avoids loading the signature database for every request. This guide uses
the Go client for the daemon protocol, not the separate go-clamav bindings for libclamav.
The daemon’s TCP protocol has no authentication. Use a permission-restricted Unix socket on the same host and do not expose clamd to the public network. See the ClamAV scanning guide.
Setting up ClamAV in your Go environment
The executable example below was tested on Linux with Bash, Go 1.26.8, and ClamAV 1.5.4.
Install Go using the official installation guide if go version
does not work. Use a maintained ClamAV release with current signatures; consult the
ClamAV support policy for your distribution’s version.
On Debian or Ubuntu with systemd, install the scanner and daemon using the distribution packages.
Paste each Bash block as a whole: && prevents a failed step from running the next one.
sudo apt-get update &&
sudo apt-get install clamav clamav-daemon &&
sudo systemctl status clamav-freshclam
Allow the signature updater to finish its initial database download. Avoid running a second
freshclam process while its service already manages updates. Review your distribution’s
clamd.conf before starting or restarting clamav-daemon; use the limits below and confirm the
socket path and group permissions. Service paths differ on other operating systems.
sudo systemctl restart clamav-daemon &&
sudo systemctl status clamav-daemon
Implementing go-clamd for virus scanning
Run this from the directory where you want a new scan-example project. The block creates a
separate Go module and pins the client version used here. An existing directory is an error;
choose another parent directory instead of deleting your project. On success, your shell enters
the new project. If creation, navigation, or dependency setup fails, it stays in the original
directory; inspect the error before continuing.
(
mkdir scan-example &&
cd scan-example &&
GOWORK=off go mod init example.com/scan-example &&
GOWORK=off go get github.com/dutchcoders/go-clamd@v0.0.0-20170520113014-b970184f4d9e
) && cd scan-example
GOWORK=off keeps this standalone module independent of an enclosing go.work file. Use it
for the build and test commands too; see the Go workspace documentation.
This is an old, small client. Its ScanResult
has a Path field, not Filename. ScanStream returns a channel and an error, and accepts an abort
channel. Closing that abort channel closes an established connection, but the package does not
provide a complete context-aware deadline for connection establishment and all I/O.
The example therefore runs the client in a short-lived worker process. Go’s exec.CommandContext
terminates that process on timeout and waits for it to exit, reclaiming its socket and goroutines.
This costs a process per scan. Use bounded worker concurrency in a service; do not start an
unlimited goroutine or process for every incoming upload.
Practical examples and code snippets
Save the complete program as main.go. It accepts one application-selected regular file, reads at
most 10 MiB plus one byte, and streams that snapshot. CLAMD_SOCKET is server configuration, not
an upload parameter. No replies, malformed entries, or a second reply exposed by the client’s
channel fail closed. Raw daemon diagnostics stay out of the program’s public output.
The pinned client can discard an unterminated trailing reply and hide a read failure after a complete reply. The worker deadline stops hangs; it does not fix the response parser. A clean verdict means the client exposed exactly one clean reply from your trusted local daemon. Use a client with explicit transport-error reporting if your integration needs stronger guarantees.
package main
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/dutchcoders/go-clamd"
)
type Verdict string
const (
Clean Verdict = "clean"
Infected Verdict = "infected"
ScannerError Verdict = "scanner-error"
SizeLimit Verdict = "size-limit"
maxBytes = 10 * 1024 * 1024
)
func scanStream(data []byte, socket string) Verdict {
abort := make(chan bool)
defer close(abort)
replies, err := clamd.NewClamd("unix:" + socket).ScanStream(bytes.NewReader(data), abort)
if err != nil {
return ScannerError
}
verdict := ScannerError
count := 0
for result := range replies {
count++
if result == nil || count != 1 {
return ScannerError
}
if result.Raw == "INSTREAM size limit exceeded. ERROR" ||
(result.Status == clamd.RES_FOUND &&
strings.HasPrefix(result.Description, "Heuristics.Limits.Exceeded")) {
verdict = SizeLimit
continue
}
switch {
case result.Raw == "stream: OK":
verdict = Clean
case result.Status == clamd.RES_FOUND && result.Path == "stream" && result.Description != "":
verdict = Infected
default:
verdict = ScannerError
}
}
return verdict
}
func scanFile(filePath, executable string, timeout time.Duration) Verdict {
// The caller supplies a completed, immutable file in its private quarantine directory.
info, err := os.Lstat(filePath)
if err != nil || !info.Mode().IsRegular() {
return ScannerError
}
if info.Size() > maxBytes {
return SizeLimit
}
file, err := os.Open(filePath)
if err != nil {
return ScannerError
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil {
return ScannerError
}
if len(data) > maxBytes {
return SizeLimit
}
socket := os.Getenv("CLAMD_SOCKET")
if socket == "" {
socket = "/var/run/clamav/clamd.ctl"
}
if !strings.HasPrefix(socket, "/") || timeout <= 0 {
return ScannerError
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
command := exec.CommandContext(ctx, executable, "--worker", "--stdin")
command.Env = []string{"CLAMD_SOCKET=" + socket}
command.Stdin = bytes.NewReader(data)
output, err := command.Output()
if err != nil || ctx.Err() != nil {
return ScannerError
}
switch verdict := Verdict(output); verdict {
case Clean, Infected, ScannerError, SizeLimit:
return verdict
default:
return ScannerError
}
}
func main() {
if len(os.Args) == 3 && os.Args[1] == "--worker" && os.Args[2] == "--stdin" {
data, err := io.ReadAll(io.LimitReader(os.Stdin, maxBytes+1))
verdict := ScannerError
if err == nil && len(data) <= maxBytes {
verdict = scanStream(data, os.Getenv("CLAMD_SOCKET"))
}
fmt.Print(verdict)
return
}
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "Usage: scan-example <quarantined-file>")
os.Exit(2)
}
executable, err := os.Executable()
if err != nil {
fmt.Println(ScannerError)
os.Exit(2)
}
verdict := scanFile(os.Args[1], executable, 5*time.Second)
fmt.Println(verdict)
switch verdict {
case Clean:
return
case Infected:
os.Exit(1)
case SizeLimit:
os.Exit(3)
default:
os.Exit(2)
}
}
Build the executable before running it; the timeout wrapper launches that same executable in worker mode. Exit codes are 0 for clean, 1 for infected, 2 for scanner error, and 3 for size limit.
GOWORK=off go build -o scan-example . &&
CLAMD_SOCKET=/var/run/clamav/clamd.ctl ./scan-example "/path/to/quarantine/completed-upload"
Replace the file path with one completed file in your private quarantine directory, and the socket
path with your daemon’s LocalSocket if it differs. A clean file prints clean; EICAR prints
infected. The build must succeed before a scan runs, so a failed rebuild cannot run a stale binary.
The timeout bounds daemon communication after the local snapshot is read. Quarantine storage must be local and controlled by your application; this is not a deadline for arbitrary network-mounted files or an authorization check on user-supplied paths.
Configuring ClamAV for production use
Align the daemon’s limits with the 10 MiB application limit. For example, these are
clamd.conf settings, not
shell commands:
LocalSocket /var/run/clamav/clamd.ctl
LocalSocketMode 660
StreamMaxLength 10M
MaxFileSize 10M
MaxScanSize 50M
MaxRecursion 16
MaxFiles 1000
MaxThreads 4
HeuristicAlerts yes
AlertExceedsMax yes
Set LocalSocketGroup to the group shared with your application and remove any TCP listener you
do not need. Keep the socket directory private to trusted processes. Size and archive limits
prevent excessive work, but can also leave content unscanned. AlertExceedsMax yes makes applicable
engine limits produce Heuristics.Limits.Exceeded… findings, which this program reports as
size-limit rather than malware. The same state covers recursion and file-count limits.
StreamMaxLength rejection is a separate protocol error. It may arrive before the client finishes
writing, in which case a connection/write failure is reported as scanner-error. Both states
block release; neither means the whole file was scanned. ClamAV’s other format restrictions and
parser limitations still apply even when the overall verdict is clean.
Keep the official signature database updated using freshclam. Monitor update failures, daemon
logs, scan latency, and quarantine backlog. Restart or reload services according to your platform’s
packaging after configuration changes. Tune concurrency to available memory and CPU, including
decompression costs, rather than only the number of HTTP requests.
Testing with eicar
EICAR publishes a harmless 68-byte test
string that antivirus engines intentionally detect. Generate it only in an isolated test directory.
Save this as main_test.go; it uses the client package’s exact EICAR bytes and the compiled program
from the previous step. Set CLAMD_SOCKET to your test daemon’s private socket.
package main
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/dutchcoders/go-clamd"
)
func TestEICAR(t *testing.T) {
if len(clamd.EICAR) != 68 {
t.Fatal("EICAR fixture must contain exactly 68 bytes")
}
executable, err := filepath.Abs("scan-example")
if err != nil {
t.Fatal(err)
}
file := filepath.Join(t.TempDir(), "eicar.txt")
if err := os.WriteFile(file, clamd.EICAR, 0600); err != nil {
t.Fatal(err)
}
if verdict := scanFile(file, executable, 5*time.Second); verdict != Infected {
t.Fatalf("EICAR must be detected as infected; got %s", verdict)
}
}
GOWORK=off go test -run '^TestEICAR$' -count=1 .
A stopped daemon must fail this test, not count as successful malware detection. Also test a small clean fixture, an oversized fixture, missing files, malformed/empty daemon responses, and a daemon that never responds. Protocol fixtures can check error handling deterministically; they do not replace an EICAR check against the actual scanner and its configured database.
Best practices for virus scanning in Go applications
- Quarantine first: store completed uploads privately, scan an immutable snapshot, and release only those same bytes after a clean result. Do not use a public upload directory.
- Fail closed: reject or retain files on scanner errors and limits. Retry through a bounded queue with backoff; a retry budget running out must not release the file.
- Bound resources: cap upload size, concurrent worker processes, archive depth, expanded bytes, scan duration, and queue length. Monitor every type of rejected scan.
- Isolate the scanner: use a restricted local socket and a dedicated service account. The application streams bytes, so the daemon does not need direct access to the quarantine files.
- Retain useful status: expose stable verdicts to callers; keep any detailed diagnostics in access-controlled logs. Never treat every returned error as “virus found.”
- Review the dependency: the pinned client is old and has limited I/O error reporting. Process isolation supplies cancellation here, but does not turn it into a modern context-aware API.
Troubleshooting common issues
- Scanner errors: check socket permissions, database availability, daemon health, and logs. Verify that the configured socket matches the one used by Go. Do not make the socket public.
- Timeouts: examine queue depth and daemon resources before increasing the five-second budget. A timeout kills the worker and blocks file release.
- Size limits: compare application size,
StreamMaxLength,MaxFileSize, and expanded archive limits. Increasing one value alone may leave another limit in effect. - EICAR not detected: verify the 68-byte fixture, the loaded signature database, and the actual verdict. A connection error is not a positive detection.
- Unexpected clean results: confirm
HeuristicAlertsandAlertExceedsMax, inspect supported formats and scan limits, and ensure you are publishing the bytes that were scanned.
Conclusion and additional resources
ClamAV can add a useful malware check to a Go upload pipeline when its failure states are explicit. The application, daemon configuration, and release policy must agree on what a successful scan means. Use the primary references when adapting this example:
Transloadit’s 🤖 /file/virusscan Robot
uses ClamAV with daily signature updates as part of our
file filtering service. Its
error_on_decline option controls whether a rejected file stops the Assembly or is excluded from
subsequent processing. Consult the Robot documentation when choosing that behavior.
