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) }