ultisuite-backend/internal/virustotal/scan.go
R3D347HR4Y b90edf317c
Some checks failed
CI / Go tests (push) Has been cancelled
CI / Integration tests (push) Has been cancelled
CI / DB migrations (push) Has been cancelled
feat(scan): add VirusTotal upload antivirus
Admin-stored API key with env fallback; scan drive/mail/IMAP uploads.
Fail-open if VT down, 422 on malware; migration for virus_scan_status.
2026-06-07 22:05:27 +02:00

107 lines
2.6 KiB
Go

package virustotal
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"time"
)
const defaultScanTimeout = 60 * time.Second
// ScanResult is the outcome of a VirusTotal scan attempt.
type ScanResult struct {
Status string // clean | skipped
}
// Scanner wraps Client with fail-open behavior on API errors.
type Scanner struct {
client *Client
logger *slog.Logger
timeout time.Duration
}
func NewScanner(apiKey string, logger *slog.Logger) *Scanner {
if logger == nil {
logger = slog.Default()
}
return &Scanner{
client: NewClient(apiKey),
logger: logger,
timeout: defaultScanTimeout,
}
}
// ScanBytes scans file content. Returns ErrMalicious if detected.
// On VT unavailability, returns skipped (fail-open).
func (s *Scanner) ScanBytes(ctx context.Context, filename string, data []byte, sha256Hex string) (ScanResult, error) {
if len(data) == 0 {
return ScanResult{Status: "skipped"}, nil
}
if len(data) > maxUploadLimit {
s.logger.Warn("virustotal scan skipped: file too large for VT", "filename", filename, "size", len(data))
return ScanResult{Status: "skipped"}, nil
}
if sha256Hex == "" {
sum := sha256.Sum256(data)
sha256Hex = hex.EncodeToString(sum[:])
}
stats, found, err := s.client.lookupFile(ctx, sha256Hex)
if err != nil {
s.logger.Warn("virustotal lookup failed, skipping scan", "filename", filename, "error", err)
return ScanResult{Status: "skipped"}, nil
}
if found {
if statsMalicious(stats) {
return ScanResult{}, ErrMalicious
}
return ScanResult{Status: "clean"}, nil
}
analysisID, err := s.client.uploadFile(ctx, data, safeFilename(filename))
if err != nil {
s.logger.Warn("virustotal upload failed, skipping scan", "filename", filename, "error", err)
return ScanResult{Status: "skipped"}, nil
}
scanCtx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
stats, err = s.client.pollAnalysis(scanCtx, analysisID, s.timeout)
if err != nil {
s.logger.Warn("virustotal analysis failed, skipping scan", "filename", filename, "error", err)
return ScanResult{Status: "skipped"}, nil
}
if statsMalicious(stats) {
return ScanResult{}, ErrMalicious
}
return ScanResult{Status: "clean"}, nil
}
func safeFilename(name string) string {
if name == "" {
return "upload"
}
base := name
if len(base) > 200 {
base = base[:200]
}
return base
}
// SHA256Hex returns hex-encoded SHA-256 of data.
func SHA256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// FormatAnalysisID is a helper for logging.
func FormatAnalysisID(id string) string {
return fmt.Sprintf("vt:%s", id)
}