Analiza archivos en busca de virus en Go con ClamAV
Las aplicaciones que aceptan subidas necesitan una regla clara para liberar archivos tras el análisis. Esta guía usa ClamAV y Go para distinguir cuatro resultados: limpio, infectado, error del escáner y límite de tamaño. Solo un resultado explícito de limpio permite que la aplicación continúe con el procesamiento.
Introducción al análisis antivirus en Go
Mantén las nuevas subidas en una cuarentena privada hasta que termine el análisis. Un fallo de conexión, un tiempo de espera agotado, una respuesta vacía o un límite de análisis superado no constituyen evidencia de que un archivo esté limpio. El análisis antivirus es una capa de protección; no puede determinar que todos los archivos sean inofensivos.
Transmitiremos una instantánea de tamaño limitado de un archivo completo a un demonio local. El ejemplo nunca analiza un directorio, elimina un archivo subido ni intenta reparar un archivo. Tu aplicación debe mantener inmutable la instantánea analizada y publicar esos mismos bytes solo tras un veredicto de limpio.
Descripción general de ClamAV y sus capacidades
ClamAV proporciona el comando clamscan, el demonio persistente
clamd y freshclam para actualizar las firmas.
Usar un demonio evita cargar la base de datos de firmas en cada solicitud. Esta guía usa el
cliente de Go para el protocolo del demonio, no los bindings independientes
go-clamav para libclamav.
El protocolo TCP del demonio no tiene autenticación. Usa un socket Unix con permisos restringidos en el mismo host y no expongas clamd a la red pública. Consulta la guía de análisis de ClamAV.
Configura ClamAV en tu entorno de Go
El ejemplo ejecutable que aparece a continuación se probó en Linux con Bash, Go 1.26.8 y ClamAV 1.5.4.
Instala Go con la guía oficial de instalación si
go version no funciona. Usa una versión de ClamAV que reciba mantenimiento y
firmas actualizadas; consulta la política de soporte de ClamAV
para la versión de tu distribución.
En Debian o Ubuntu con systemd, instala el escáner y el demonio mediante los paquetes de la
distribución. Pega cada bloque de Bash completo: && impide que un
paso fallido ejecute el siguiente.
sudo apt-get update &&
sudo apt-get install clamav clamav-daemon &&
sudo systemctl status clamav-freshclam
Deja que el actualizador de firmas termine la descarga inicial de la base de datos. Evita ejecutar
un segundo proceso freshclam mientras su servicio ya gestiona las
actualizaciones. Revisa clamd.conf de tu distribución antes de iniciar o
reiniciar clamav-daemon; usa los límites indicados a continuación y confirma la
ruta del socket y los permisos de grupo. Las rutas de los servicios difieren en otros sistemas
operativos.
sudo systemctl restart clamav-daemon &&
sudo systemctl status clamav-daemon
Implementa go-clamd para el análisis antivirus
Ejecuta esto desde el directorio donde quieras crear un proyecto scan-example.
El bloque crea un módulo de Go independiente y fija la versión del cliente que se usa aquí. Si el
directorio ya existe, se produce un error; elige otro directorio superior en lugar de eliminar tu
proyecto. Si todo sale bien, tu shell entra en el nuevo proyecto. Si falla la creación, el cambio
de directorio o la configuración de dependencias, permanece en el directorio original; revisa el
error 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 mantiene este módulo independiente de un archivo
go.work que lo englobe. Úsalo también para los comandos de compilación y
prueba; consulta la documentación de los espacios de trabajo de Go.
Este cliente es pequeño y antiguo. Su
ScanResult tiene un campo
Path, no Filename.
ScanStream devuelve un canal y un error, y acepta un canal de cancelación.
Cerrar ese canal de cancelación cierra una conexión establecida, pero el paquete no proporciona
un plazo máximo completo que tenga en cuenta el contexto para establecer la conexión y realizar
todas las operaciones de E/S.
Por ello, el ejemplo ejecuta el cliente en un proceso de trabajo de corta duración.
exec.CommandContext de Go termina ese proceso cuando se agota el tiempo de espera y
espera a que finalice, liberando su socket y sus goroutines. Esto requiere un proceso por análisis.
En un servicio, limita la concurrencia de los procesos de trabajo; no inicies un número ilimitado
de goroutines o procesos para cada subida entrante.
Ejemplos prácticos y fragmentos de código
Guarda el programa completo como main.go. Acepta un archivo regular
seleccionado por la aplicación, lee como máximo 10 MiB más un byte y transmite esa instantánea.
CLAMD_SOCKET es una configuración del servidor, no un parámetro de subida.
Ante la ausencia de respuestas, entradas malformadas o una segunda respuesta expuesta por el canal
del cliente, se bloquea la liberación del archivo. Los diagnósticos sin procesar del demonio no se
incluyen en la salida pública del programa.
El cliente cuya versión se ha fijado puede descartar una respuesta final sin terminador y ocultar un fallo de lectura posterior a una respuesta completa. El plazo máximo del proceso de trabajo impide bloqueos indefinidos; no corrige el analizador de respuestas. Un veredicto de limpio significa que el cliente expuso exactamente una respuesta de limpio de tu demonio local de confianza. Usa un cliente que informe explícitamente de los errores de transporte si tu integración necesita garantías más sólidas.
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)
}
}
Compila el ejecutable antes de ejecutarlo; el contenedor que controla el tiempo de espera inicia ese mismo ejecutable en modo de proceso de trabajo. Los códigos de salida son 0 para limpio, 1 para infectado, 2 para error del escáner y 3 para límite de tamaño.
GOWORK=off go build -o scan-example . &&
CLAMD_SOCKET=/var/run/clamav/clamd.ctl ./scan-example "/path/to/quarantine/completed-upload"
Sustituye la ruta del archivo por la de un archivo completo en tu directorio privado de cuarentena,
y la ruta del socket por LocalSocket de tu demonio si es diferente. Un archivo
limpio imprime clean; EICAR imprime infected.
La compilación debe completarse correctamente antes de ejecutar un análisis, de modo que una
recompilación fallida no pueda ejecutar un binario desactualizado.
El tiempo de espera limita la comunicación con el demonio una vez leída la instantánea local. El almacenamiento de cuarentena debe ser local y estar controlado por tu aplicación; esto no establece un plazo máximo para archivos arbitrarios en unidades de red ni comprueba la autorización de las rutas proporcionadas por el usuario.
Configura ClamAV para su uso en producción
Ajusta los límites del demonio al límite de 10 MiB de la aplicación. Por ejemplo, estos son
ajustes de clamd.conf, no 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
Configura LocalSocketGroup con el grupo compartido con tu aplicación y elimina
cualquier escucha TCP que no necesites. Mantén el directorio del socket accesible solo para
procesos de confianza. Los límites de tamaño y de archivos contenedores evitan un trabajo
excesivo, pero también pueden dejar contenido sin analizar. AlertExceedsMax yes
hace que los límites aplicables del motor produzcan hallazgos
Heuristics.Limits.Exceeded…, que este programa comunica como size-limit en lugar de malware.
El mismo estado abarca los límites de recursión y cantidad de archivos.
El rechazo de StreamMaxLength es un error de protocolo independiente. Puede llegar
antes de que el cliente termine de escribir; en ese caso, un fallo de conexión o escritura se
comunica como scanner-error. Ambos estados bloquean la liberación; ninguno significa que se
haya analizado el archivo completo. Las demás restricciones de formato y limitaciones de los
analizadores de ClamAV siguen vigentes incluso cuando el veredicto general es limpio.
Mantén actualizada la base de datos oficial de firmas mediante freshclam.
Supervisa los fallos de actualización, los registros del demonio, la latencia de los análisis y la
acumulación de archivos en cuarentena. Tras cambiar la configuración, reinicia o recarga los
servicios según los paquetes de tu plataforma. Ajusta la concurrencia a la memoria y la CPU
disponibles, incluidos los costos de descompresión, en lugar de basarte solo en la cantidad de
solicitudes HTTP.
Prueba con eicar
EICAR publica una cadena de prueba inofensiva de 68 bytes que los
motores antivirus detectan de forma intencional. Genérala solo en un directorio de pruebas aislado.
Guarda esto como main_test.go; usa los bytes exactos de EICAR del paquete del
cliente y el programa compilado en el paso anterior. Configura CLAMD_SOCKET
con el socket privado de tu demonio de pruebas.
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 .
Un demonio detenido debe hacer que esta prueba falle, no contar como una detección correcta de malware. Prueba también un archivo pequeño y limpio, uno que supere el tamaño permitido, archivos inexistentes, respuestas malformadas o vacías del demonio y un demonio que nunca responda. Los casos de prueba del protocolo permiten comprobar el manejo de errores de forma determinista; no sustituyen una prueba con EICAR contra el escáner real y su base de datos configurada.
Buenas prácticas para el análisis antivirus en aplicaciones de Go
- Pon los archivos en cuarentena primero: almacena de forma privada las subidas completas, analiza una instantánea inmutable y libera solo esos mismos bytes tras un resultado de limpio. No uses un directorio público de subidas.
- Bloquea la liberación ante fallos: rechaza o retén los archivos ante errores del escáner y límites. Reintenta mediante una cola limitada con esperas progresivas; agotar el presupuesto de reintentos no debe liberar el archivo.
- Limita los recursos: establece límites para el tamaño de subida, los procesos de trabajo concurrentes, la profundidad de los archivos contenedores, los bytes descomprimidos, la duración del análisis y la longitud de la cola. Supervisa cada tipo de análisis rechazado.
- Aísla el escáner: usa un socket local restringido y una cuenta de servicio dedicada. La aplicación transmite bytes, por lo que el demonio no necesita acceso directo a los archivos en cuarentena.
- Conserva información de estado útil: expón veredictos estables a quienes realizan las llamadas; mantén los diagnósticos detallados en registros con control de acceso. Nunca interpretes todos los errores devueltos como «virus encontrado».
- Revisa la dependencia: el cliente cuya versión se ha fijado es antiguo y ofrece información limitada sobre errores de E/S. Aquí, el aislamiento de procesos permite la cancelación, pero no lo convierte en una API moderna que tenga en cuenta el contexto.
Resuelve problemas comunes
- Errores del escáner: comprueba los permisos del socket, la disponibilidad de la base de datos, el estado del demonio y los registros. Verifica que el socket configurado coincida con el que usa Go. No hagas público el socket.
- Tiempos de espera agotados: examina la longitud de la cola y los recursos del demonio antes de aumentar el límite de cinco segundos. Agotar el tiempo de espera termina el proceso de trabajo y bloquea la liberación del archivo.
- Límites de tamaño: compara el límite de tamaño de la aplicación,
StreamMaxLength,MaxFileSizey los límites de los archivos contenedores una vez descomprimidos. Aumentar un solo valor puede dejar otro límite en vigor. - EICAR no detectado: verifica el archivo de prueba de 68 bytes, la base de datos de firmas cargada y el veredicto real. Un error de conexión no es una detección positiva.
- Resultados de limpio inesperados: confirma
HeuristicAlertsyAlertExceedsMax, revisa los formatos compatibles y los límites de análisis, y asegúrate de publicar los bytes que se analizaron.
Conclusión y recursos adicionales
ClamAV puede añadir un análisis de malware útil a un pipeline de subidas en Go cuando sus estados de fallo son explícitos. La aplicación, la configuración del demonio y la política de liberación deben coincidir en qué significa un análisis satisfactorio. Usa las referencias principales al adaptar este ejemplo:
- Documentación de ClamAV
- Código fuente de la versión fijada de go-clamd
- Documentación del paquete go-clamd
El Robot 🤖 /file/virusscan
de Transloadit usa ClamAV con actualizaciones diarias de firmas como parte de nuestro
servicio de filtrado de archivos. Su opción
error_on_decline controla si un archivo rechazado detiene la Assembly o se excluye
del procesamiento posterior. Consulta la documentación del Robot al elegir ese comportamiento.
