diff --git a/go.work b/go.work new file mode 100644 index 000000000..8dcaea189 --- /dev/null +++ b/go.work @@ -0,0 +1,3 @@ +go 1.23.8 + +use ./veza-backend-api diff --git a/go.work.sum b/go.work.sum index 78265f6e8..3f7a3b670 100644 --- a/go.work.sum +++ b/go.work.sum @@ -3,6 +3,7 @@ github.com/Microsoft/hcsshim v0.11.5/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnxUFY= github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= github.com/containerd/btrfs/v2 v2.0.0/go.mod h1:swkD/7j9HApWpzl8OHfrHNxppPd9l44DFZdF94BUj9k= @@ -30,6 +31,7 @@ github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a/go.mod h1:sLjdR6uwx3 github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/emicklei/go-restful/v3 v3.10.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -87,6 +89,7 @@ k8s.io/component-base v0.26.2/go.mod h1:DxbuIe9M3IZPRxPIzhch2m1eT7uFrSBJUBuVCQEB k8s.io/cri-api v0.27.1/go.mod h1:+Ts/AVYbIo04S86XbTD73UPp/DkTiYxtsFeOFEu32L0= k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/veza-backend-api/docs/CLAMAV_SETUP.md b/veza-backend-api/docs/CLAMAV_SETUP.md index 144fbf532..a2a0447e0 100644 --- a/veza-backend-api/docs/CLAMAV_SETUP.md +++ b/veza-backend-api/docs/CLAMAV_SETUP.md @@ -6,9 +6,9 @@ Le backend Veza API intègre **ClamAV** pour scanner tous les fichiers uploadés **MOD-P1-001**: Le scan ClamAV est maintenant **obligatoire** et se fait **AVANT toute persistance**. Si ClamAV est indisponible, tous les uploads sont rejetés (fail-secure). -### Limitation connue (go-clamd) +### Implémentation (clamdscan exec) -Le client ClamAV utilisé est **github.com/dutchcoders/go-clamd**, abandonné (dernier commit 2017). En cas de désactivation via `ENABLE_CLAMAV=false`, le scan est entièrement ignoré (aucune connexion à clamd). Pour un environnement sans ClamAV, documenter en interne que le scan antivirus est désactivé et gérer les risques associés. +Le scan utilise **clamdscan** (exécutable système) au lieu de la bibliothèque go-clamd abandonnée. Aucune dépendance Go externe pour ClamAV. Le daemon clamd doit être démarré ; clamdscan s'y connecte via socket Unix ou TCP selon la configuration système. --- @@ -20,9 +20,9 @@ Le client ClamAV utilisé est **github.com/dutchcoders/go-clamd**, abandonné (d # Activer/désactiver ClamAV (par défaut: true) CLAMAV_ENABLED=true -# Adresse du daemon ClamAV (par défaut: localhost:3310) -# Format: host:port (ex: localhost:3310, 192.168.1.100:3310) -CLAMAV_ADDRESS=localhost:3310 +# Chemin vers clamdscan (par défaut: clamdscan) +# Ex: clamdscan, /usr/bin/clamdscan +CLAMAV_CLAMD_PATH=clamdscan ``` ### Exemples de configuration @@ -30,14 +30,11 @@ CLAMAV_ADDRESS=localhost:3310 #### Développement local ```bash CLAMAV_ENABLED=true -CLAMAV_ADDRESS=localhost:3310 +CLAMAV_CLAMD_PATH=clamdscan ``` -#### Production avec ClamAV distant -```bash -CLAMAV_ENABLED=true -CLAMAV_ADDRESS=clamav.internal:3310 -``` +#### Production (clamd sur machine distante) +Configurer clamd.conf pour pointer vers le daemon distant, ou utiliser un wrapper. Par défaut clamdscan utilise le socket Unix local. #### Désactiver ClamAV (non recommandé en production) ```bash @@ -139,10 +136,10 @@ go test -tags=clamav ./internal/services -run TestUploadValidator_ClamAV_CleanFi ### Configuration pour les tests d'intégration -Par défaut, les tests utilisent `localhost:3310`. Pour utiliser une autre adresse : +Par défaut, les tests utilisent `clamdscan`. Pour un chemin personnalisé : ```bash -export CLAMAV_ADDRESS=192.168.1.100:3310 +export CLAMAV_CLAMD_PATH=/usr/bin/clamdscan go test -tags=clamav ./internal/services -run TestUploadValidator_ClamAV -v ``` @@ -206,7 +203,7 @@ go test -tags=clamav ./internal/services -run TestUploadValidator_ClamAV -v **Causes possibles** : 1. ClamAV n'est pas démarré : `sudo systemctl status clamav-daemon` -2. Mauvaise adresse : Vérifier `CLAMAV_ADDRESS` +2. clamdscan introuvable : Vérifier `CLAMAV_CLAMD_PATH` et que clamd est installé 3. Firewall bloque le port 3310 **Solution** : @@ -227,7 +224,7 @@ sudo systemctl restart clamav-daemon **Solution** : 1. Vérifier que ClamAV est démarré -2. Vérifier la variable `CLAMAV_ADDRESS` si ClamAV est sur une autre machine +2. Vérifier que clamd est démarré et que clamdscan peut s'y connecter 3. Vérifier les logs : `sudo journalctl -u clamav-daemon -f` --- @@ -235,5 +232,4 @@ sudo systemctl restart clamav-daemon ## Références - [ClamAV Documentation](https://docs.clamav.net/) -- [go-clamd Library](https://github.com/dutchcoders/go-clamd) - [EICAR Test File](https://en.wikipedia.org/wiki/EICAR_test_file) diff --git a/veza-backend-api/go.mod b/veza-backend-api/go.mod index 1c7d130a1..9fc77125a 100644 --- a/veza-backend-api/go.mod +++ b/veza-backend-api/go.mod @@ -4,7 +4,6 @@ go 1.23.8 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 - github.com/Lyimmi/go-clamd v1.0.0 github.com/aws/aws-sdk-go-v2 v1.41.0 github.com/aws/aws-sdk-go-v2/config v1.32.6 github.com/aws/aws-sdk-go-v2/credentials v1.19.6 diff --git a/veza-backend-api/go.sum b/veza-backend-api/go.sum index 27044b88e..bb1691691 100644 --- a/veza-backend-api/go.sum +++ b/veza-backend-api/go.sum @@ -10,8 +10,6 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/Lyimmi/go-clamd v1.0.0 h1:w+px/HYUTMf/LeBXq+fem6nhd6J+bcbiYQO593sqUh0= -github.com/Lyimmi/go-clamd v1.0.0/go.mod h1:V9dsJa+47pc0C+3AqhEicGiNXhM1uwPiPAF+Ehj0EzQ= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= diff --git a/veza-backend-api/internal/config/config.go b/veza-backend-api/internal/config/config.go index 678de13b7..e395554fa 100644 --- a/veza-backend-api/internal/config/config.go +++ b/veza-backend-api/internal/config/config.go @@ -65,21 +65,21 @@ type Config struct { ConfigWatcher *ConfigWatcher // Configuration - Env string // Environnement: development, test, production (P0-SECURITY) - AppPort int // Port pour le serveur HTTP (T0031) - AppDomain string // Domaine applicatif (APP_DOMAIN) — single source of truth pour URLs & CORS - JWTSecret string - JWTIssuer string // T0204: Issuer claim validation (P1-SECURITY) - JWTAudience string // T0204: Audience claim validation (P1-SECURITY) - ChatJWTSecret string // Secret pour les tokens WebSocket Chat - RedisURL string - RedisEnable bool // Enable/Disable Redis - DatabaseURL string - UploadDir string // Répertoire d'upload + Env string // Environnement: development, test, production (P0-SECURITY) + AppPort int // Port pour le serveur HTTP (T0031) + AppDomain string // Domaine applicatif (APP_DOMAIN) — single source of truth pour URLs & CORS + JWTSecret string + JWTIssuer string // T0204: Issuer claim validation (P1-SECURITY) + JWTAudience string // T0204: Audience claim validation (P1-SECURITY) + ChatJWTSecret string // Secret pour les tokens WebSocket Chat + RedisURL string + RedisEnable bool // Enable/Disable Redis + DatabaseURL string + UploadDir string // Répertoire d'upload StreamServerURL string // URL du serveur de streaming StreamServerInternalAPIKey string // API key for /internal/jobs/transcode (P1.1.2 - same as stream server INTERNAL_API_KEY) ChatServerURL string // URL du serveur de chat - CORSOrigins []string // Liste des origines CORS autorisées + CORSOrigins []string // Liste des origines CORS autorisées // S3 Storage Configuration (BE-SVC-005) S3Bucket string // Nom du bucket S3 @@ -263,12 +263,12 @@ func NewConfig() (*Config, error) { RedisURL: getEnv("REDIS_URL", "redis://"+appDomain+":6379"), RedisEnable: getEnvBool("REDIS_ENABLE", true), // SECURITY: DATABASE_URL est REQUIS - contient des credentials sensibles - DatabaseURL: databaseURL, - UploadDir: getEnv("UPLOAD_DIR", "uploads"), + DatabaseURL: databaseURL, + UploadDir: getEnv("UPLOAD_DIR", "uploads"), StreamServerURL: getEnv("STREAM_SERVER_URL", "http://"+appDomain+":8082"), - StreamServerInternalAPIKey: getEnv("STREAM_SERVER_INTERNAL_API_KEY", ""), + StreamServerInternalAPIKey: getEnv("STREAM_SERVER_INTERNAL_API_KEY", ""), ChatServerURL: getEnv("CHAT_SERVER_URL", "http://"+appDomain+":8081"), - CORSOrigins: corsOrigins, + CORSOrigins: corsOrigins, // S3 Storage Configuration (BE-SVC-005) S3Bucket: getEnv("AWS_S3_BUCKET", ""), @@ -712,6 +712,10 @@ func (c *Config) initServices() error { if !clamAVRequired { c.Logger.Warn("CLAMAV_REQUIRED=false - Uploads will be accepted even if ClamAV is unavailable (degraded mode). This should only be used in development or with alternative security measures.") } + // Chemin vers clamdscan (exec) - remplace go-clamd abandonné + if p := getEnv("CLAMAV_CLAMD_PATH", ""); p != "" { + uploadConfig.ClamAVClamdPath = p + } var err error c.UploadValidator, err = services.NewUploadValidator(uploadConfig, c.Logger) if err != nil { diff --git a/veza-backend-api/internal/services/upload_validator.go b/veza-backend-api/internal/services/upload_validator.go index 159f5a9e7..ea7760b50 100644 --- a/veza-backend-api/internal/services/upload_validator.go +++ b/veza-backend-api/internal/services/upload_validator.go @@ -7,21 +7,20 @@ import ( "fmt" "io" "mime/multipart" - "net" "net/http" + "os" + "os/exec" "path/filepath" - "strconv" "strings" "time" - "github.com/Lyimmi/go-clamd" "go.uber.org/zap" ) // UploadValidator service pour valider les uploads de fichiers type UploadValidator struct { logger *zap.Logger - clamdClient *clamd.Clamd + clamdPath string // Chemin vers clamdscan (ex: clamdscan, /usr/bin/clamdscan) quarantineDir string clamAVRequiredButUnavailable bool // MOD-P1-001-REFINEMENT: Flag pour fail-secure localisé clamAVRequired bool // MOD-P1-002: Si false, accepte uploads même si ClamAV down @@ -39,10 +38,11 @@ type UploadConfig struct { AllowedImageTypes []string AllowedVideoTypes []string - // Configuration ClamAV - ClamAVEnabled bool // Active/désactive ClamAV - ClamAVRequired bool // MOD-P1-002: Si false, accepte uploads même si ClamAV down (mode dégradé) - ClamAVAddress string + // Configuration ClamAV (utilise clamdscan en exec, remplace go-clamd abandonné) + ClamAVEnabled bool // Active/désactive ClamAV + ClamAVRequired bool // MOD-P1-002: Si false, accepte uploads même si ClamAV down (mode dégradé) + ClamAVAddress string // Gardé pour compat config, non utilisé (clamdscan utilise socket par défaut) + ClamAVClamdPath string // Chemin vers clamdscan (ex: clamdscan, /usr/bin/clamdscan) // Dossier de quarantaine QuarantineDir string @@ -79,10 +79,11 @@ func DefaultUploadConfig() *UploadConfig { "video/avi", }, - ClamAVEnabled: true, - ClamAVRequired: true, // MOD-P1-002: Par défaut, ClamAV est requis (fail-secure) - ClamAVAddress: "localhost:3310", - QuarantineDir: "/quarantine", + ClamAVEnabled: true, + ClamAVRequired: true, // MOD-P1-002: Par défaut, ClamAV est requis (fail-secure) + ClamAVAddress: "localhost:3310", + ClamAVClamdPath: "clamdscan", + QuarantineDir: "/quarantine", } } @@ -92,85 +93,55 @@ func DefaultUploadConfig() *UploadConfig { func NewUploadValidator(config *UploadConfig, logger *zap.Logger) (*UploadValidator, error) { fmt.Printf("🔧 [UPLOAD VALIDATOR] Initialisation - ClamAVEnabled=%v, ClamAVRequired=%v\n", config.ClamAVEnabled, config.ClamAVRequired) - // EARLY RETURN: Si ClamAV est désactivé, ne JAMAIS toucher au réseau ClamAV + // EARLY RETURN: Si ClamAV est désactivé, ne JAMAIS toucher à clamdscan if !config.ClamAVEnabled { - fmt.Printf("✅ [UPLOAD VALIDATOR] ClamAV désactivé - Aucune connexion réseau ne sera tentée\n") + fmt.Printf("✅ [UPLOAD VALIDATOR] ClamAV désactivé - Aucun scan antivirus\n") logger.Info("ClamAV is disabled - virus scanning will be skipped", zap.Bool("clamav_enabled", config.ClamAVEnabled), zap.Bool("clamav_required", config.ClamAVRequired), ) return &UploadValidator{ logger: logger, - clamdClient: nil, // Explicitement nil + clamdPath: "", quarantineDir: config.QuarantineDir, clamAVRequiredButUnavailable: false, clamAVRequired: config.ClamAVRequired, }, nil } - // ClamAV est activé - initialiser le client - fmt.Printf("🛡️ [UPLOAD VALIDATOR] ClamAV activé - Initialisation client sur %s\n", config.ClamAVAddress) - var clamdClient *clamd.Clamd + // ClamAV activé - utiliser clamdscan (exec) au lieu de go-clamd abandonné + clamdPath := config.ClamAVClamdPath + if clamdPath == "" { + clamdPath = "clamdscan" + } + fmt.Printf("🛡️ [UPLOAD VALIDATOR] ClamAV activé - Utilisation de %s (exec)\n", clamdPath) + clamAVRequiredButUnavailable := false - - // Parser host:port depuis config.ClamAVAddress - host, portStr, parseErr := net.SplitHostPort(config.ClamAVAddress) - if parseErr != nil { - return nil, fmt.Errorf("invalid ClamAV address %q: %w", config.ClamAVAddress, parseErr) - } - port, parseErr := strconv.Atoi(portStr) - if parseErr != nil { - return nil, fmt.Errorf("invalid ClamAV port %q: %w", portStr, parseErr) - } - - // Créer le client ClamAV (Lyimmi fork - API avec WithTCP) - clamdClient = clamd.NewClamd(clamd.WithTCP(host, port)) - fmt.Printf("🔌 [UPLOAD VALIDATOR] Client ClamAV créé - Test de connexion (Ping avec timeout 2s)...\n") - - // Test connection avec timeout pour éviter blocage - MOD-P1-001-REFINEMENT: Ne pas bloquer le démarrage + // Test disponibilité: clamdscan --version pingCtx, pingCancel := context.WithTimeout(context.Background(), 2*time.Second) defer pingCancel() - - ok, err := clamdClient.Ping(pingCtx) - if !ok || err != nil { - if err == nil { - err = fmt.Errorf("ClamAV ping failed") - } - fmt.Printf("🔌 [UPLOAD VALIDATOR] Ping terminé - erreur: %v\n", err) - } else { - fmt.Printf("🔌 [UPLOAD VALIDATOR] Ping terminé - OK\n") - } - - if err != nil { - fmt.Printf("⚠️ [UPLOAD VALIDATOR] ClamAV Ping échoué: %v\n", err) + if err := exec.CommandContext(pingCtx, clamdPath, "--version").Run(); err != nil { + fmt.Printf("⚠️ [UPLOAD VALIDATOR] clamdscan non disponible: %v\n", err) if config.ClamAVRequired { - // MOD-P1-002: Si ClamAV est requis, rejeter les uploads (fail-secure) - logger.Warn("ClamAV is enabled and required but unavailable - uploads will be rejected until ClamAV is available", + logger.Warn("ClamAV is enabled and required but clamdscan unavailable - uploads will be rejected", zap.Error(err), - zap.String("address", config.ClamAVAddress), + zap.String("clamd_path", clamdPath), ) clamAVRequiredButUnavailable = true } else { - // MOD-P1-002: Si ClamAV n'est pas requis, accepter uploads avec warning (mode dégradé) - logger.Warn("ClamAV is enabled but unavailable and not required - uploads will be accepted without virus scanning (degraded mode)", + logger.Warn("ClamAV enabled but clamdscan unavailable and not required - degraded mode", zap.Error(err), - zap.String("address", config.ClamAVAddress), - zap.String("security_warning", "Virus scanning is disabled. This should only be used in development or with alternative security measures."), + zap.String("clamd_path", clamdPath), ) - clamAVRequiredButUnavailable = false } - // Ne pas retourner d'erreur - le serveur peut démarrer } else { - fmt.Printf("✅ [UPLOAD VALIDATOR] ClamAV Ping réussi - Connexion OK\n") - logger.Info("ClamAV connection successful") + fmt.Printf("✅ [UPLOAD VALIDATOR] clamdscan disponible - OK\n") + logger.Info("ClamAV (clamdscan) available for virus scanning") } - fmt.Printf("✅ [UPLOAD VALIDATOR] Validateur initialisé - clamdClient=%v, requiredButUnavailable=%v\n", - clamdClient != nil, clamAVRequiredButUnavailable) - return &UploadValidator{ logger: logger, - clamdClient: clamdClient, + clamdPath: clamdPath, quarantineDir: config.QuarantineDir, clamAVRequiredButUnavailable: clamAVRequiredButUnavailable, clamAVRequired: config.ClamAVRequired, @@ -192,12 +163,12 @@ type ValidationResult struct { func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipart.FileHeader, fileType string) (*ValidationResult, error) { // DEBUG: Log de début de validation fmt.Printf("🚀 [UPLOAD VALIDATE] Début validation fichier: %s (taille: %d bytes, type: %s)\n", fileHeader.Filename, fileHeader.Size, fileType) - fmt.Printf("🔍 [UPLOAD VALIDATE] État ClamAV - client=%v, required=%v, requiredButUnavailable=%v\n", - uv.clamdClient != nil, uv.clamAVRequired, uv.clamAVRequiredButUnavailable) + fmt.Printf("🔍 [UPLOAD VALIDATE] État ClamAV - enabled=%v, required=%v, requiredButUnavailable=%v\n", + uv.clamdPath != "", uv.clamAVRequired, uv.clamAVRequiredButUnavailable) // EARLY CHECK: Si ClamAV est complètement désactivé, on skip immédiatement - if uv.clamdClient == nil { - fmt.Printf("⏭️ [UPLOAD VALIDATE] ClamAV désactivé (client=nil) - scan antivirus ignoré\n") + if uv.clamdPath == "" { + fmt.Printf("⏭️ [UPLOAD VALIDATE] ClamAV désactivé - scan antivirus ignoré\n") } result := &ValidationResult{ @@ -279,8 +250,8 @@ func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipa // Continuer sans scan ClamAV } - // Scanner avec ClamAV si disponible - if uv.clamdClient != nil { + // Scanner avec ClamAV si disponible (clamdscan exec) + if uv.clamdPath != "" { fmt.Printf("🛡️ [UPLOAD VALIDATE] ClamAV activé - Début scan antivirus...\n") file.Seek(0, 0) infected, err := uv.scanWithClamAV(ctx, file) @@ -312,7 +283,7 @@ func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipa zap.String("status", "clean"), ) } else { - fmt.Printf("⏭️ [UPLOAD VALIDATE] ClamAV désactivé (client=nil) - scan ignoré, validation continue\n") + fmt.Printf("⏭️ [UPLOAD VALIDATE] ClamAV désactivé - scan ignoré, validation continue\n") } // Valider l'extension du fichier @@ -512,27 +483,45 @@ func (uv *UploadValidator) validateVideoMagicBytes(header []byte) error { return fmt.Errorf("invalid video file signature") } -// scanWithClamAV scanne le fichier avec ClamAV avec timeout strict +// scanWithClamAV scanne le fichier avec clamdscan (exec) - remplace go-clamd abandonné // MOD-P1-001: Timeout strict via context pour éviter les blocages -// Lyimmi go-clamd: ScanStream(ctx, r) returns (ok bool, err error) - ok=true means clean, ok=false means virus +// clamdscan exit 0 = clean, exit 1 = infected, exit 2 = error func (uv *UploadValidator) scanWithClamAV(ctx context.Context, file io.Reader) (infected bool, err error) { - // Timeout strict: 30 secondes max pour le scan scanCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - // Lire tout le fichier en mémoire pour le scan - var buf bytes.Buffer - if _, copyErr := io.Copy(&buf, file); copyErr != nil { - return false, fmt.Errorf("failed to read file for scan: %w", copyErr) + tmpFile, err := os.CreateTemp("", "veza-clamav-scan-*") + if err != nil { + return false, fmt.Errorf("failed to create temp file for scan: %w", err) + } + tmpPath := tmpFile.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if _, copyErr := io.Copy(tmpFile, file); copyErr != nil { + _ = tmpFile.Close() + return false, fmt.Errorf("failed to write file for scan: %w", copyErr) + } + if closeErr := tmpFile.Close(); closeErr != nil { + return false, fmt.Errorf("failed to close temp file: %w", closeErr) } - // Lyimmi: ScanStream(ctx, io.Reader) -> (ok bool, err error) - ok, scanErr := uv.clamdClient.ScanStream(scanCtx, &buf) - if scanErr != nil { - return false, fmt.Errorf("clamav scan failed: %w", scanErr) + cmd := exec.CommandContext(scanCtx, uv.clamdPath, "--no-summary", tmpPath) + var stderr bytes.Buffer + cmd.Stderr = &stderr + runErr := cmd.Run() + + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + switch exitErr.ExitCode() { + case 1: + return true, nil // Virus détecté + case 2: + return false, fmt.Errorf("clamdscan error: %s", stderr.String()) + } + } + return false, fmt.Errorf("clamdscan failed: %w", runErr) } - // ok=true: clean, ok=false: virus detected - return !ok, nil + return false, nil // Exit 0 = clean } // QuarantineFile met un fichier en quarantaine diff --git a/veza-backend-api/internal/services/upload_validator_integration_test.go b/veza-backend-api/internal/services/upload_validator_integration_test.go index 1a2c78f84..b5096214c 100644 --- a/veza-backend-api/internal/services/upload_validator_integration_test.go +++ b/veza-backend-api/internal/services/upload_validator_integration_test.go @@ -18,15 +18,15 @@ import ( // Pour exécuter: go test -tags=clamav ./internal/services -run TestUploadValidator_ClamAV_EICAR_Rejected // MOD-P1-001: Test que le fichier EICAR (test virus standard) est rejeté func TestUploadValidator_ClamAV_EICAR_Rejected(t *testing.T) { - // Vérifier que ClamAV est disponible - clamavAddr := os.Getenv("CLAMAV_ADDRESS") - if clamavAddr == "" { - clamavAddr = "localhost:3310" + // Vérifier que clamdscan est disponible (exec) + clamdPath := os.Getenv("CLAMAV_CLAMD_PATH") + if clamdPath == "" { + clamdPath = "clamdscan" } logger, _ := zap.NewDevelopment() - // Configuration avec ClamAV enabled + // Configuration avec ClamAV enabled (utilise clamdscan exec) config := &UploadConfig{ MaxAudioSize: 100 * 1024 * 1024, MaxImageSize: 10 * 1024 * 1024, @@ -35,7 +35,7 @@ func TestUploadValidator_ClamAV_EICAR_Rejected(t *testing.T) { AllowedImageTypes: []string{"image/jpeg"}, AllowedVideoTypes: []string{"video/mp4"}, ClamAVEnabled: true, - ClamAVAddress: clamavAddr, + ClamAVClamdPath: clamdPath, QuarantineDir: "/tmp/quarantine", } @@ -66,15 +66,13 @@ func TestUploadValidator_ClamAV_EICAR_Rejected(t *testing.T) { // TestUploadValidator_ClamAV_CleanFile_Accepted vérifie qu'un fichier propre est accepté // MOD-P1-001: Test qu'un fichier non infecté passe le scan ClamAV func TestUploadValidator_ClamAV_CleanFile_Accepted(t *testing.T) { - // Vérifier que ClamAV est disponible - clamavAddr := os.Getenv("CLAMAV_ADDRESS") - if clamavAddr == "" { - clamavAddr = "localhost:3310" + clamdPath := os.Getenv("CLAMAV_CLAMD_PATH") + if clamdPath == "" { + clamdPath = "clamdscan" } logger, _ := zap.NewDevelopment() - // Configuration avec ClamAV enabled config := &UploadConfig{ MaxAudioSize: 100 * 1024 * 1024, MaxImageSize: 10 * 1024 * 1024, @@ -83,7 +81,7 @@ func TestUploadValidator_ClamAV_CleanFile_Accepted(t *testing.T) { AllowedImageTypes: []string{"image/jpeg"}, AllowedVideoTypes: []string{"video/mp4"}, ClamAVEnabled: true, - ClamAVAddress: clamavAddr, + ClamAVClamdPath: clamdPath, QuarantineDir: "/tmp/quarantine", } diff --git a/veza-backend-api/internal/services/upload_validator_test.go b/veza-backend-api/internal/services/upload_validator_test.go index b3746aff6..777659b84 100644 --- a/veza-backend-api/internal/services/upload_validator_test.go +++ b/veza-backend-api/internal/services/upload_validator_test.go @@ -43,7 +43,7 @@ func createTestFileHeader(t *testing.T, filename string, content []byte) *multip func TestUploadValidator_ClamAVDown_RejectsUploads(t *testing.T) { logger, _ := zap.NewDevelopment() - // Configuration avec ClamAV enabled mais adresse invalide (simule ClamAV down) + // Configuration avec ClamAV enabled mais chemin inexistant (simule ClamAV down) // MOD-P1-002: ClamAVRequired doit être true pour que les uploads soient rejetés config := &UploadConfig{ MaxAudioSize: 100 * 1024 * 1024, @@ -53,8 +53,8 @@ func TestUploadValidator_ClamAVDown_RejectsUploads(t *testing.T) { AllowedImageTypes: []string{"image/jpeg"}, AllowedVideoTypes: []string{"video/mp4"}, ClamAVEnabled: true, - ClamAVRequired: true, // MOD-P1-002: ClamAV requis (fail-secure) - ClamAVAddress: "localhost:99999", // Port invalide pour simuler ClamAV down + ClamAVRequired: true, + ClamAVClamdPath: "/nonexistent/clamdscan", // Simule clamdscan indisponible QuarantineDir: "/tmp/quarantine", } @@ -98,7 +98,6 @@ func TestUploadValidator_ClamAVDisabled_AllowsUploads(t *testing.T) { AllowedImageTypes: []string{"image/jpeg"}, AllowedVideoTypes: []string{"video/mp4"}, ClamAVEnabled: false, // ClamAV désactivé - ClamAVAddress: "localhost:3310", QuarantineDir: "/tmp/quarantine", } @@ -139,8 +138,8 @@ func TestUploadValidator_ClamAVDown_NotRequired_AcceptsUploads(t *testing.T) { AllowedImageTypes: []string{"image/jpeg"}, AllowedVideoTypes: []string{"video/mp4"}, ClamAVEnabled: true, - ClamAVRequired: false, // MOD-P1-002: ClamAV non requis - ClamAVAddress: "localhost:99999", // Port invalide pour simuler ClamAV down + ClamAVRequired: false, // MOD-P1-002: ClamAV non requis + ClamAVClamdPath: "/nonexistent/clamdscan", // Simule clamdscan indisponible QuarantineDir: "/tmp/quarantine", } diff --git a/veza-backend-api/tests/performance/upload_endpoints_performance_test.go b/veza-backend-api/tests/performance/upload_endpoints_performance_test.go index 37b12c817..cdc54b567 100644 --- a/veza-backend-api/tests/performance/upload_endpoints_performance_test.go +++ b/veza-backend-api/tests/performance/upload_endpoints_performance_test.go @@ -102,7 +102,6 @@ func setupUploadTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, uuid.UUID, func AllowedAudioTypes: []string{"audio/mpeg", "audio/mp3"}, ClamAVEnabled: false, // Disable for performance tests ClamAVRequired: false, - ClamAVAddress: "", QuarantineDir: t.TempDir(), } uploadValidator, err := services.NewUploadValidator(uploadConfig, logger)