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.
107 lines
2.6 KiB
Go
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)
|
|
}
|