ultisuite-backend/internal/filescan/scanner.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

145 lines
3.6 KiB
Go

package filescan
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
"github.com/ultisuite/ulti-backend/internal/virustotal"
)
const memoryBufferLimit = 32 * 1024 * 1024
// Result holds scan outcome for persistence.
type Result struct {
Status string // clean | skipped
}
// PolicyLoader supplies org file policies at runtime.
type PolicyLoader interface {
FilePolicies(ctx context.Context) (orgpolicy.FilePolicies, error)
ScanEnabled(ctx context.Context) (bool, string, error)
}
// Scanner coordinates org policy and VirusTotal scanning.
type Scanner struct {
policies PolicyLoader
logger *slog.Logger
}
func NewScanner(policies PolicyLoader, logger *slog.Logger) *Scanner {
if logger == nil {
logger = slog.Default()
}
return &Scanner{policies: policies, logger: logger}
}
// ScanReader reads all bytes from r (up to maxBytes), optionally scans, returns data for storage.
func (s *Scanner) ScanReader(ctx context.Context, filename string, r io.Reader, size int64) ([]byte, Result, error) {
fp, err := s.policies.FilePolicies(ctx)
if err != nil {
return nil, Result{}, err
}
maxBytes := fp.MaxUploadBytes
if maxBytes <= 0 {
maxBytes = 512 * 1024 * 1024
}
if maxBytes > virustotalMaxBytes() {
maxBytes = virustotalMaxBytes()
}
data, err := readLimited(r, size, maxBytes)
if err != nil {
return nil, Result{}, err
}
enabled := fp.VirusScanEnabled && fp.VirusTotalAPIKey != ""
if !enabled {
return data, Result{Status: "skipped"}, nil
}
vt := virustotal.NewScanner(fp.VirusTotalAPIKey, s.logger)
scanResult, err := vt.ScanBytes(ctx, filename, data, virustotal.SHA256Hex(data))
if err != nil {
if errors.Is(err, virustotal.ErrMalicious) {
return nil, Result{Status: "malicious"}, virustotal.ErrMalicious
}
return nil, Result{}, err
}
return data, Result{Status: scanResult.Status}, nil
}
// ScanBytes scans pre-loaded bytes when policy is already known.
func (s *Scanner) ScanBytes(ctx context.Context, filename string, data []byte) (Result, error) {
enabled, apiKey, err := s.policies.ScanEnabled(ctx)
if err != nil {
return Result{}, err
}
if !enabled {
return Result{Status: "skipped"}, nil
}
vt := virustotal.NewScanner(apiKey, s.logger)
scanResult, err := vt.ScanBytes(ctx, filename, data, virustotal.SHA256Hex(data))
if err != nil {
if errors.Is(err, virustotal.ErrMalicious) {
return Result{Status: "malicious"}, virustotal.ErrMalicious
}
return Result{}, err
}
return Result{Status: scanResult.Status}, nil
}
func readLimited(r io.Reader, size int64, maxBytes int64) ([]byte, error) {
if size >= 0 && size <= memoryBufferLimit {
limited := io.LimitReader(r, maxBytes+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, errors.New("file exceeds max upload size")
}
return data, nil
}
tmp, err := os.CreateTemp("", "ulti-scan-*")
if err != nil {
return nil, err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
written, err := io.Copy(tmp, io.LimitReader(r, maxBytes+1))
if err != nil {
tmp.Close()
return nil, err
}
if err := tmp.Close(); err != nil {
return nil, err
}
if written > maxBytes {
return nil, errors.New("file exceeds max upload size")
}
return os.ReadFile(filepath.Clean(tmpPath))
}
func virustotalMaxBytes() int64 {
return 650 * 1024 * 1024
}
// ErrMalicious re-exports virustotal.ErrMalicious for handlers.
var ErrMalicious = virustotal.ErrMalicious
// NopReader helper for tests.
func NopReader(data []byte) io.Reader {
return bytes.NewReader(data)
}