package scanner import ( "context" "fmt" "strings" "github.com/lukaszraczylo/gohoarder/pkg/config" "github.com/lukaszraczylo/gohoarder/pkg/metadata" "github.com/lukaszraczylo/gohoarder/pkg/scanner/ghsa" "github.com/lukaszraczylo/gohoarder/pkg/scanner/govulncheck" "github.com/lukaszraczylo/gohoarder/pkg/scanner/grype" "github.com/lukaszraczylo/gohoarder/pkg/scanner/npmaudit" "github.com/lukaszraczylo/gohoarder/pkg/scanner/osv" "github.com/lukaszraczylo/gohoarder/pkg/scanner/pipaudit" "github.com/lukaszraczylo/gohoarder/pkg/scanner/trivy" "github.com/rs/zerolog/log" ) // Scanner defines the interface for security scanners type Scanner interface { // Name returns the scanner name Name() string // Scan scans a package for vulnerabilities Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) // Health checks scanner health Health(ctx context.Context) error } // DatabaseUpdater is implemented by scanners that need database updates type DatabaseUpdater interface { UpdateDatabase(ctx context.Context) error } // Manager manages multiple security scanners type Manager struct { metadataStore metadata.MetadataStore config config.SecurityConfig scanners []Scanner enabled bool } // New creates a new scanner manager with configured scanners func New(cfg config.SecurityConfig, metadataStore metadata.MetadataStore) (*Manager, error) { manager := &Manager{ scanners: make([]Scanner, 0), enabled: cfg.Enabled, config: cfg, metadataStore: metadataStore, } if !cfg.Enabled { log.Info().Msg("Security scanning disabled") return manager, nil } // Initialize Trivy scanner if cfg.Scanners.Trivy.Enabled { trivyScanner := trivy.New(cfg.Scanners.Trivy) manager.RegisterScanner(trivyScanner) log.Info().Msg("Trivy scanner enabled") // Update database on startup if configured if cfg.UpdateDBOnStartup { if err := trivyScanner.UpdateDatabase(context.Background()); err != nil { log.Warn().Err(err).Msg("Failed to update Trivy database on startup") } } } // Initialize OSV scanner if cfg.Scanners.OSV.Enabled { osvScanner := osv.New(cfg.Scanners.OSV) manager.RegisterScanner(osvScanner) log.Info().Msg("OSV scanner enabled") } // Initialize Grype scanner if cfg.Scanners.Grype.Enabled { grypeScanner := grype.New(cfg.Scanners.Grype) manager.RegisterScanner(grypeScanner) log.Info().Msg("Grype scanner enabled") // Update database on startup if configured if cfg.UpdateDBOnStartup { if err := grypeScanner.UpdateDatabase(context.Background()); err != nil { log.Warn().Err(err).Msg("Failed to update Grype database on startup") } } } // Initialize govulncheck scanner if cfg.Scanners.Govulncheck.Enabled { govulncheckScanner := govulncheck.New(cfg.Scanners.Govulncheck) manager.RegisterScanner(govulncheckScanner) log.Info().Msg("govulncheck scanner enabled") } // Initialize npm-audit scanner if cfg.Scanners.NpmAudit.Enabled { npmAuditScanner := npmaudit.New(cfg.Scanners.NpmAudit) manager.RegisterScanner(npmAuditScanner) log.Info().Msg("npm-audit scanner enabled") } // Initialize pip-audit scanner if cfg.Scanners.PipAudit.Enabled { pipAuditScanner := pipaudit.New(cfg.Scanners.PipAudit) manager.RegisterScanner(pipAuditScanner) log.Info().Msg("pip-audit scanner enabled") } // Initialize GitHub Advisory Database scanner if cfg.Scanners.GHSA.Enabled { ghsaScanner := ghsa.New(cfg.Scanners.GHSA) manager.RegisterScanner(ghsaScanner) log.Info().Msg("GitHub Advisory Database scanner enabled") } if len(manager.scanners) == 0 { log.Warn().Msg("Security scanning enabled but no scanners configured") } return manager, nil } // RegisterScanner registers a scanner func (m *Manager) RegisterScanner(scanner Scanner) { m.scanners = append(m.scanners, scanner) } // ScanPackage scans a package using all registered scanners and saves results func (m *Manager) ScanPackage(ctx context.Context, registry, packageName, version string, filePath string) error { if !m.enabled { return nil } log.Info(). Str("registry", registry). Str("package", packageName). Str("version", version). Msg("Starting security scan") // Collect results from all scanners var scanResults []*metadata.ScanResult scannerNames := make([]string, 0) for _, scanner := range m.scanners { // Skip scanners that don't support this registry if !m.shouldRunScanner(scanner.Name(), registry) { log.Debug(). Str("scanner", scanner.Name()). Str("registry", registry). Msg("Skipping scanner - not compatible with registry") continue } result, err := scanner.Scan(ctx, registry, packageName, version, filePath) if err != nil { log.Error(). Err(err). Str("scanner", scanner.Name()). Str("package", packageName). Msg("Scanner failed") continue } scanResults = append(scanResults, result) scannerNames = append(scannerNames, scanner.Name()) log.Info(). Str("scanner", scanner.Name()). Str("package", packageName). Str("status", string(result.Status)). Int("vulnerabilities", result.VulnerabilityCount). Msg("Scan completed") } // If no scanners succeeded, return if len(scanResults) == 0 { log.Warn(). Str("package", packageName). Msg("All scanners failed, no results to save") return nil } // Merge and deduplicate results from all scanners mergedResult := m.mergeResults(scanResults, scannerNames) // Save consolidated result to metadata store if err := m.metadataStore.SaveScanResult(ctx, mergedResult); err != nil { log.Error(). Err(err). Str("package", packageName). Msg("Failed to save consolidated scan result") return err } log.Info(). Str("package", packageName). Str("status", string(mergedResult.Status)). Int("total_vulnerabilities", mergedResult.VulnerabilityCount). Int("unique_cves", len(mergedResult.Vulnerabilities)). Strs("scanners", scannerNames). Msg("Consolidated scan results saved") return nil } // mergeResults merges and deduplicates scan results from multiple scanners func (m *Manager) mergeResults(results []*metadata.ScanResult, scannerNames []string) *metadata.ScanResult { if len(results) == 0 { return nil } // Use first result as base merged := &metadata.ScanResult{ ID: results[0].ID, Registry: results[0].Registry, PackageName: results[0].PackageName, PackageVersion: results[0].PackageVersion, Scanner: strings.Join(scannerNames, "+"), // Combined scanner name ScannedAt: results[0].ScannedAt, Status: metadata.ScanStatusClean, Vulnerabilities: make([]metadata.Vulnerability, 0), Details: make(map[string]interface{}), } // Use map for deduplication - key is CVE ID in uppercase vulnMap := make(map[string]*metadata.Vulnerability) severityCounts := make(map[string]int) // Merge vulnerabilities from all scanners for i, result := range results { scannerName := scannerNames[i] // Track scanner details merged.Details[scannerName] = result.Details // Add/merge vulnerabilities for _, vuln := range result.Vulnerabilities { cveKey := strings.ToUpper(vuln.ID) // Check if CVE already exists if existing, exists := vulnMap[cveKey]; exists { // CVE found by multiple scanners - merge information log.Debug(). Str("cve", vuln.ID). Strs("existing_scanners", existing.DetectedBy). Str("new_scanner", scannerName). Msg("CVE found by multiple scanners, merging") // Add scanner to DetectedBy list existing.DetectedBy = append(existing.DetectedBy, scannerName) // Prefer higher severity if different if m.compareSeverity(vuln.Severity, existing.Severity) > 0 { existing.Severity = vuln.Severity } // Merge references (deduplicate URLs) refSet := make(map[string]bool) for _, ref := range existing.References { refSet[ref] = true } for _, ref := range vuln.References { if !refSet[ref] { existing.References = append(existing.References, ref) refSet[ref] = true } } // Prefer fixed_in version if not already set if existing.FixedIn == "" && vuln.FixedIn != "" { existing.FixedIn = vuln.FixedIn } } else { // New CVE - add to map vulnCopy := vuln vulnCopy.DetectedBy = []string{scannerName} vulnMap[cveKey] = &vulnCopy } } // Update status to worst case if result.Status == metadata.ScanStatusVulnerable { merged.Status = metadata.ScanStatusVulnerable } else if result.Status == metadata.ScanStatusPending && merged.Status != metadata.ScanStatusVulnerable { merged.Status = metadata.ScanStatusPending } } // Convert map to slice and count severities for _, vuln := range vulnMap { merged.Vulnerabilities = append(merged.Vulnerabilities, *vuln) severityCounts[strings.ToUpper(vuln.Severity)]++ } // Update counts merged.VulnerabilityCount = len(merged.Vulnerabilities) merged.Details["severity_counts"] = severityCounts merged.Details["deduplication_summary"] = fmt.Sprintf( "Merged results from %d scanners (%s)", len(scannerNames), strings.Join(scannerNames, ", "), ) return merged } // compareSeverity returns >0 if s1 is more severe than s2, <0 if less, 0 if equal func (m *Manager) compareSeverity(s1, s2 string) int { severityOrder := map[string]int{ "CRITICAL": 4, "HIGH": 3, "MODERATE": 2, "MEDIUM": 2, // Support both for backwards compatibility "LOW": 1, "UNKNOWN": 0, } v1 := severityOrder[strings.ToUpper(s1)] v2 := severityOrder[strings.ToUpper(s2)] return v1 - v2 } // CheckVulnerabilities checks if a package exceeds vulnerability thresholds func (m *Manager) CheckVulnerabilities(ctx context.Context, registry, packageName, version string) (bool, string, error) { if !m.enabled { return false, "", nil } // Get active CVE bypasses from database bypasses, err := m.metadataStore.GetActiveCVEBypasses(ctx) if err != nil { log.Warn().Err(err).Msg("Failed to get CVE bypasses, continuing without bypasses") bypasses = []*metadata.CVEBypass{} // Continue without bypasses } // Check if entire package is bypassed packageKey := fmt.Sprintf("%s/%s@%s", registry, packageName, version) packageKeyNoVersion := fmt.Sprintf("%s/%s", registry, packageName) for _, bypass := range bypasses { if bypass.Type == metadata.BypassTypePackage && bypass.Active { if bypass.Target == packageKey || bypass.Target == packageKeyNoVersion { log.Info(). Str("package", packageKey). Str("bypass_id", bypass.ID). Str("reason", bypass.Reason). Time("expires_at", bypass.ExpiresAt). Msg("Package bypassed by admin") return false, "", nil } } } // Get latest scan result result, err := m.metadataStore.GetScanResult(ctx, registry, packageName, version) if err != nil { // No scan result found - allow download (will be scanned after) return false, "", nil } // Build set of bypassed CVEs for fast lookup bypassedCVEs := make(map[string]*metadata.CVEBypass) for _, bypass := range bypasses { if bypass.Type == metadata.BypassTypeCVE && bypass.Active { // Check if bypass applies to this package (if AppliesTo is set) if bypass.AppliesTo != "" && bypass.AppliesTo != packageKey && bypass.AppliesTo != packageKeyNoVersion { continue // This bypass doesn't apply to this package } bypassedCVEs[strings.ToUpper(bypass.Target)] = bypass } } // Count vulnerabilities by severity, excluding bypassed CVEs severityCounts := make(map[string]int) for _, vuln := range result.Vulnerabilities { // Check if this CVE is bypassed if bypass, ok := bypassedCVEs[strings.ToUpper(vuln.ID)]; ok { log.Debug(). Str("cve", vuln.ID). Str("package", packageName). Str("bypass_id", bypass.ID). Str("reason", bypass.Reason). Time("expires_at", bypass.ExpiresAt). Msg("CVE bypassed by admin") continue } severityCounts[strings.ToUpper(vuln.Severity)]++ } // Check against thresholds thresholds := m.config.BlockThresholds // Check critical if thresholds.Critical >= 0 && severityCounts["CRITICAL"] > thresholds.Critical { return true, fmt.Sprintf("Package has %d CRITICAL vulnerabilities (threshold: %d)", severityCounts["CRITICAL"], thresholds.Critical), nil } // Check high if thresholds.High >= 0 && severityCounts["HIGH"] > thresholds.High { return true, fmt.Sprintf("Package has %d HIGH vulnerabilities (threshold: %d)", severityCounts["HIGH"], thresholds.High), nil } // Check moderate (medium) moderateCount := severityCounts["MODERATE"] + severityCounts["MEDIUM"] // Support both for backwards compatibility if thresholds.Medium >= 0 && moderateCount > thresholds.Medium { return true, fmt.Sprintf("Package has %d MODERATE vulnerabilities (threshold: %d)", moderateCount, thresholds.Medium), nil } // Check low if thresholds.Low >= 0 && severityCounts["LOW"] > thresholds.Low { return true, fmt.Sprintf("Package has %d LOW vulnerabilities (threshold: %d)", severityCounts["LOW"], thresholds.Low), nil } // Check block on severity if m.config.BlockOnSeverity != "" && m.config.BlockOnSeverity != "none" { severity := strings.ToUpper(m.config.BlockOnSeverity) // Block if any vulnerabilities at or above the specified severity exist switch severity { case "CRITICAL": if severityCounts["CRITICAL"] > 0 { return true, "Package has CRITICAL vulnerabilities", nil } case "HIGH": if severityCounts["CRITICAL"] > 0 || severityCounts["HIGH"] > 0 { return true, "Package has HIGH or CRITICAL vulnerabilities", nil } case "MODERATE", "MEDIUM": moderateCount := severityCounts["MODERATE"] + severityCounts["MEDIUM"] if severityCounts["CRITICAL"] > 0 || severityCounts["HIGH"] > 0 || moderateCount > 0 { return true, "Package has MODERATE, HIGH, or CRITICAL vulnerabilities", nil } case "LOW": if len(result.Vulnerabilities) > 0 { return true, "Package has vulnerabilities", nil } } } return false, "", nil } // UpdateDatabases updates vulnerability databases for all scanners func (m *Manager) UpdateDatabases(ctx context.Context) error { if !m.enabled { return nil } log.Info().Msg("Updating vulnerability databases") for _, scanner := range m.scanners { if updater, ok := scanner.(DatabaseUpdater); ok { if err := updater.UpdateDatabase(ctx); err != nil { log.Error(). Err(err). Str("scanner", scanner.Name()). Msg("Failed to update database") return err } } } log.Info().Msg("Vulnerability databases updated successfully") return nil } // Health checks health of all scanners func (m *Manager) Health(ctx context.Context) error { if !m.enabled { return nil } for _, scanner := range m.scanners { if err := scanner.Health(ctx); err != nil { return fmt.Errorf("scanner %s health check failed: %w", scanner.Name(), err) } } return nil } // shouldRunScanner determines if a scanner should run for a given registry // Language-specific scanners only run for their target ecosystems func (m *Manager) shouldRunScanner(scannerName, registry string) bool { registry = strings.ToLower(registry) // Language-specific scanners - only run for their target registry switch scannerName { case "govulncheck": return registry == "go" case "npm-audit": return registry == "npm" case "pip-audit": return registry == "pypi" // Multi-ecosystem scanners - run for all registries case "trivy", "osv", "grype", "github-advisory-database": return true // Default: allow scanner to run (for future scanners) default: return true } }