Escaneie arquivos em busca de vírus em Go com ClamAV
Aplicações que aceitam uploads precisam de uma regra clara para liberar arquivos após a varredura. Este guia usa ClamAV e Go para distinguir quatro resultados: limpo, infectado, erro do scanner e limite de tamanho. Somente um resultado limpo explícito permite que a aplicação continue o processamento.
Introdução à varredura de vírus em Go
Mantenha novos uploads em quarentena privada até que a varredura termine. Uma falha de conexão, um timeout, uma resposta vazia ou um limite de varredura excedido não são evidência de que um arquivo está limpo. A varredura antivírus é uma camada de proteção; ela não consegue comprovar que todo arquivo é inofensivo.
Vamos transmitir via stream um snapshot limitado de um arquivo concluído para um daemon local. O exemplo nunca varre um diretório, nunca exclui um upload nem tenta reparar um arquivo. Sua aplicação deve manter o snapshot escaneado imutável e publicar esses mesmos bytes somente após um veredito limpo.
Visão geral do ClamAV e de seus recursos
O ClamAV fornece o comando clamscan, o daemon persistente clamd e o freshclam para atualizações
de assinaturas. Usar um daemon evita carregar o banco de dados de assinaturas a cada requisição.
Este guia usa o cliente Go para o protocolo do daemon, e não os bindings go-clamav separados para a
libclamav.
O protocolo TCP do daemon não tem autenticação. Use um socket Unix com permissões restritas no mesmo host e não exponha o clamd à rede pública. Consulte o guia de varredura do ClamAV.
Configurando o ClamAV no seu ambiente Go
O exemplo executável abaixo foi testado no Linux com Bash, Go 1.26.8 e ClamAV 1.5.4.
Instale o Go usando o guia oficial de instalação se go version
não funcionar. Use uma versão mantida do ClamAV com assinaturas atuais; consulte a
política de suporte do ClamAV para a versão da sua distribuição.
No Debian ou Ubuntu com systemd, instale o scanner e o daemon usando os pacotes da distribuição.
Cole cada bloco Bash por inteiro: && impede que a próxima etapa seja executada quando uma etapa falha.
sudo apt-get update &&
sudo apt-get install clamav clamav-daemon &&
sudo systemctl status clamav-freshclam
Permita que o atualizador de assinaturas conclua o download inicial do banco de dados. Evite executar
um segundo processo freshclam enquanto o serviço dele já gerencia as atualizações. Revise o
clamd.conf da sua distribuição antes de iniciar ou reiniciar o clamav-daemon; use os limites abaixo e confirme o
caminho do socket e as permissões de grupo. Os caminhos dos serviços variam em outros sistemas
operacionais.
sudo systemctl restart clamav-daemon &&
sudo systemctl status clamav-daemon
Implementando o go-clamd para varredura de vírus
Execute isto a partir do diretório onde você quer criar um novo projeto scan-example. O bloco cria um
módulo Go separado e fixa a versão do cliente usada aqui. Um diretório já existente é tratado como
erro; escolha outro diretório pai em vez de excluir seu projeto. Em caso de sucesso, seu shell entra
no novo projeto. Se a criação, a navegação ou a configuração das dependências falhar, ele permanece
no diretório original; inspecione o erro antes de continuar.
(
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 mantém este módulo independente de um arquivo go.work que o englobe. Use-o também
nos comandos de build e de teste; consulte a documentação de workspaces do Go.
Este é um cliente antigo e pequeno. Seu ScanResult
tem um campo Path, não Filename. ScanStream retorna um canal e um erro, e aceita um canal
de cancelamento (abort). Fechar esse canal de cancelamento fecha uma conexão estabelecida, mas o
pacote não oferece um prazo completo, ciente de contexto, para o estabelecimento da conexão e para
todo o I/O.
Por isso, o exemplo executa o cliente em um processo worker de curta duração. O exec.CommandContext do Go
encerra esse processo no timeout e espera que ele termine, recuperando seu socket e suas goroutines.
Isso custa um processo por varredura. Use concorrência limitada de workers em um serviço; não inicie
goroutines ou processos sem limite, um para cada upload recebido.
Exemplos práticos e trechos de código
Salve o programa completo como main.go. Ele aceita um arquivo regular selecionado pela aplicação,
lê no máximo 10 MiB mais um byte e transmite esse snapshot via stream. CLAMD_SOCKET é configuração do
servidor, não um parâmetro de upload. Ausência de respostas, entradas malformadas ou uma segunda
resposta exposta pelo canal do cliente resultam em falha fechada (fail closed). Os diagnósticos
brutos do daemon ficam fora da saída pública do programa.
O cliente fixado pode descartar uma resposta final sem terminador e ocultar uma falha de leitura após uma resposta completa. O prazo do worker impede travamentos; ele não corrige o parser de respostas. Um veredito limpo significa que o cliente expôs exatamente uma resposta limpa do seu daemon local confiável. Use um cliente com relato explícito de erros de transporte se a sua integração precisar de garantias mais fortes.
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)
}
}
Compile o executável antes de executá-lo; o wrapper de timeout inicia esse mesmo executável em modo worker. Os códigos de saída são 0 para limpo, 1 para infectado, 2 para erro do scanner e 3 para limite de tamanho.
GOWORK=off go build -o scan-example . &&
CLAMD_SOCKET=/var/run/clamav/clamd.ctl ./scan-example "/path/to/quarantine/completed-upload"
Substitua o caminho do arquivo por um arquivo concluído no seu diretório de quarentena privado, e o
caminho do socket pelo LocalSocket do seu daemon, se for diferente. Um arquivo limpo imprime clean; o
EICAR imprime infected. O build precisa ser bem-sucedido antes que uma varredura seja executada,
então um rebuild com falha não pode executar um binário desatualizado.
O timeout limita a comunicação com o daemon depois que o snapshot local é lido. O armazenamento da quarentena deve ser local e controlado pela sua aplicação; isto não é um prazo para arquivos arbitrários montados via rede nem uma verificação de autorização para caminhos fornecidos pelo usuário.
Configurando o ClamAV para uso em produção
Alinhe os limites do daemon com o limite de 10 MiB da aplicação. Por exemplo, estas são
configurações do clamd.conf, não
comandos de shell:
LocalSocket /var/run/clamav/clamd.ctl
LocalSocketMode 660
StreamMaxLength 10M
MaxFileSize 10M
MaxScanSize 50M
MaxRecursion 16
MaxFiles 1000
MaxThreads 4
HeuristicAlerts yes
AlertExceedsMax yes
Defina LocalSocketGroup como o grupo compartilhado com a sua aplicação e remova qualquer listener TCP de
que você não precise. Mantenha o diretório do socket restrito a processos confiáveis. Os limites de
tamanho e de contêineres de arquivos evitam trabalho excessivo, mas também podem deixar conteúdo sem
varredura.
AlertExceedsMax yes faz com que os limites aplicáveis do engine produzam ocorrências Heuristics.Limits.Exceeded…, que este programa
reporta como size-limit em vez de malware. O mesmo estado abrange limites de recursão e de
quantidade de arquivos.
A rejeição por StreamMaxLength é um erro de protocolo separado. Ela pode chegar antes que o cliente termine
a escrita e, nesse caso, uma falha de conexão/escrita é reportada como scanner-error. Ambos os
estados bloqueiam a liberação; nenhum deles significa que o arquivo inteiro foi escaneado. As demais
restrições de formato e limitações de parser do ClamAV continuam valendo mesmo quando o veredito
geral é limpo.
Mantenha o banco de dados oficial de assinaturas atualizado usando freshclam. Monitore falhas de
atualização, logs do daemon, latência de varredura e o backlog da quarentena. Reinicie ou recarregue
os serviços de acordo com o empacotamento da sua plataforma após alterações de configuração. Ajuste
a concorrência à memória e à CPU disponíveis, incluindo os custos de descompressão, e não apenas ao
número de requisições HTTP.
Testes com o EICAR
O EICAR publica uma string de teste inofensiva de 68 bytes
que engines antivírus detectam intencionalmente. Gere-a somente em um diretório de teste isolado.
Salve isto como main_test.go; o teste usa os bytes exatos do EICAR do pacote cliente e o programa
compilado na etapa anterior. Defina CLAMD_SOCKET como o socket privado do seu daemon de teste.
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 .
Um daemon parado deve fazer este teste falhar, e não contar como detecção bem-sucedida de malware. Teste também um fixture limpo pequeno, um fixture grande demais, arquivos ausentes, respostas malformadas/vazias do daemon e um daemon que nunca responde. Os fixtures de protocolo podem verificar o tratamento de erros de forma determinística; eles não substituem uma verificação com o EICAR contra o scanner real e seu banco de dados configurado.
Boas práticas para varredura de vírus em aplicações Go
- Quarentena primeiro: armazene uploads concluídos de forma privada, escaneie um snapshot imutável e libere somente esses mesmos bytes após um resultado limpo. Não use um diretório público de uploads.
- Falhe de forma fechada: rejeite ou retenha arquivos em caso de erros e limites do scanner. Tente novamente por meio de uma fila limitada com backoff; o esgotamento do orçamento de novas tentativas não deve liberar o arquivo.
- Limite os recursos: restrinja o tamanho do upload, os processos worker simultâneos, a profundidade de contêineres de arquivos, os bytes expandidos, a duração da varredura e o tamanho da fila. Monitore cada tipo de varredura rejeitada.
- Isole o scanner: use um socket local restrito e uma conta de serviço dedicada. A aplicação transmite os bytes via stream, então o daemon não precisa de acesso direto aos arquivos em quarentena.
- Mantenha status úteis: exponha vereditos estáveis a quem faz a chamada; mantenha diagnósticos detalhados em logs com controle de acesso. Nunca trate todo erro retornado como “vírus encontrado”.
- Revise a dependência: o cliente fixado é antigo e tem relato limitado de erros de I/O. O isolamento de processos fornece o cancelamento aqui, mas não o transforma em uma API moderna ciente de contexto.
Solução de problemas comuns
- Erros do scanner: verifique as permissões do socket, a disponibilidade do banco de dados, a saúde do daemon e os logs. Confirme que o socket configurado é o mesmo usado pelo Go. Não torne o socket público.
- Timeouts: examine a profundidade da fila e os recursos do daemon antes de aumentar o orçamento de cinco segundos. Um timeout encerra o worker e bloqueia a liberação do arquivo.
- Limites de tamanho: compare o limite de tamanho de arquivo da aplicação,
StreamMaxLength,MaxFileSizee os limites de expansão de contêineres de arquivos. Aumentar apenas um valor pode deixar outro limite em vigor. - EICAR não detectado: verifique o fixture de 68 bytes, o banco de dados de assinaturas carregado e o veredito real. Um erro de conexão não é uma detecção positiva.
- Resultados limpos inesperados: confirme
HeuristicAlertseAlertExceedsMax, inspecione os formatos suportados e os limites de varredura, e garanta que você está publicando os bytes que foram escaneados.
Conclusão e recursos adicionais
O ClamAV pode adicionar uma verificação útil de malware a um pipeline de upload em Go quando seus estados de falha são explícitos. A aplicação, a configuração do daemon e a política de liberação precisam concordar sobre o que significa uma varredura bem-sucedida. Use as referências primárias ao adaptar este exemplo:
O 🤖 /file/virusscan Robot da Transloadit
usa o ClamAV com atualizações diárias de assinaturas como parte do nosso
serviço de filtragem de arquivos. Sua opção
error_on_decline controla se um arquivo rejeitado interrompe a Assembly ou é excluído do processamento
subsequente. Consulte a documentação do Robot ao escolher esse comportamento.
