180 lines
4.8 KiB
Go
180 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] + "..."
|
|
}
|