Files

181 lines
4.8 KiB
Go

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] + "..."
}