package vulnscan import ( "context" "encoding/json" "fmt" "log" "time" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/events" "github.com/forgeo/forgebucket/internal/models" ) // Scanner watches for SBOM generation events and queries OSV for vulns. type Scanner struct { db *xorm.Engine bus events.EventBus client *Client } func NewScanner(db *xorm.Engine, bus events.EventBus) *Scanner { return &Scanner{ db: db, bus: bus, client: NewClient(), } } // Start subscribes to SBOM-related events and scans for vulnerabilities. func (s *Scanner) Start(ctx context.Context) { // Listen for SBOM Report created events (sync trigger). // In practice this is called on-demand via the API, so Start is minimal. <-ctx.Done() } // ScanByPURL queries OSV for a single package and stores findings. func (s *Scanner) ScanByPURL(repoID int64, purl, version string) ([]models.VulnerabilityFinding, error) { vulns, err := s.client.QueryByPURL(purl, version) if err != nil { return nil, err } return s.persistFindings(repoID, purl, version, vulns), nil } // ScanSBOM reads the latest SBOM report for a repo, queries OSV for every // component, and stores the findings. Returns the new findings. func (s *Scanner) ScanSBOM(repoID int64) ([]models.VulnerabilityFinding, error) { var report models.SBOMReport found, err := s.db.Where("repo_id = ?", repoID). OrderBy("generated_at DESC").Get(&report) if err != nil { return nil, err } if !found { return nil, fmt.Errorf("no SBOM found for repo %d", repoID) } var doc struct { Components []struct { Name string `json:"name"` Version string `json:"version"` PURL string `json:"purl"` } `json:"components"` } if err := json.Unmarshal([]byte(report.BOMDocument), &doc); err != nil { return nil, fmt.Errorf("parse SBOM: %w", err) } var allFindings []models.VulnerabilityFinding for _, comp := range doc.Components { if comp.PURL == "" || comp.Version == "" { continue } vulns, err := s.client.QueryByPURL(comp.PURL, comp.Version) if err != nil { log.Printf("vulnscan: query %s@%s: %v", comp.PURL, comp.Version, err) continue } findings := s.persistFindings(repoID, comp.PURL, comp.Version, vulns) allFindings = append(allFindings, findings...) } return allFindings, nil } // ListFindings returns unfixed vulnerability findings for a repo. func (s *Scanner) ListFindings(repoID int64) ([]models.VulnerabilityFinding, error) { var findings []models.VulnerabilityFinding if err := s.db.Where("repo_id = ? AND dismissed = ?", repoID, false). OrderBy("cvss_score DESC, detected_at DESC").Find(&findings); err != nil { return nil, err } if findings == nil { findings = []models.VulnerabilityFinding{} } return findings, nil } // DismissFindings acknowledges a vulnerability finding. func (s *Scanner) DismissFindings(findingID int64, dismissedBy string) error { now := time.Now().UTC() affected, err := s.db.ID(findingID).Cols("dismissed", "dismissed_by", "dismissed_at"). Update(&models.VulnerabilityFinding{ Dismissed: true, DismissedBy: dismissedBy, DismissedAt: &now, }) if err != nil { return err } if affected == 0 { return fmt.Errorf("finding %d not found", findingID) } return nil } func (s *Scanner) persistFindings(repoID int64, purl, version string, vulns []OSVVuln) []models.VulnerabilityFinding { var findings []models.VulnerabilityFinding for _, v := range vulns { // Check for duplicate before inserting. existing := &models.VulnerabilityFinding{} if has, _ := s.db.Where("vuln_id = ? AND purl = ? AND repo_id = ?", v.ID, purl, repoID).Get(existing); has { continue } cvssScore := parseCVSS(v) finding := &models.VulnerabilityFinding{ RepoID: repoID, VulnID: v.ID, PURL: purl, Version: version, Summary: truncateStr(v.Summary, 300), Details: v.Details, CVSSScore: cvssScore, FixedVersion: extractFixedVersion(v), DetectedAt: time.Now().UTC(), } if _, err := s.db.Insert(finding); err != nil { log.Printf("vulnscan: insert finding %s for %s: %v", v.ID, purl, err) continue } findings = append(findings, *finding) } return findings } // parseCVSS extracts the CVSS score from OSV severity info. func parseCVSS(v OSVVuln) float64 { for _, sev := range v.Severity { if sev.Type == "CVSS_V3" || sev.Type == "CVSS_V2" { var score float64 fmt.Sscanf(sev.Score, "%f", &score) return score } } return 0 } // extractFixedVersion tries to extract the fixed version from affected ranges. func extractFixedVersion(v OSVVuln) string { for _, a := range v.Affected { for _, r := range a.Ranges { for _, e := range r.Events { if e.Fixed != "" { return e.Fixed } } } } return "" } func truncateStr(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }