package security import ( "bytes" "encoding/json" "fmt" "io" "math" "net/http" "strings" "github.com/user/package-review/internal/inventory" ) // osvQuery represents a query to OSV.dev type osvQuery struct { Package struct { Name string `json:"name"` Ecosystem string `json:"ecosystem"` } `json:"package"` Version string `json:"version"` } // osvResponse represents a response from OSV.dev type osvResponse struct { Vulns []struct { ID string `json:"id"` Published string `json:"published"` Summary string `json:"summary"` Severity []struct { // Per the OSV schema, Score is a CVSS vector string // (e.g. "CVSS:3.1/AV:N/AC:L/..."), not a numeric score. Type string `json:"type"` Score string `json:"score"` } `json:"severity"` References []struct { Type string `json:"type"` URL string `json:"url"` } `json:"references"` DatabaseSpecific struct { Severity string `json:"severity"` } `json:"database_specific"` } `json:"vulns"` } // queryOSV queries OSV.dev for vulnerabilities in a package func queryOSV(pkg inventory.Package) ([]Finding, error) { var findings []Finding // Determine ecosystem ecosystem := "" switch pkg.Source { case "homebrew": ecosystem = "GIT" // Homebrew uses Git ecosystem // For Git ecosystem, we'd need the repo URL, which we don't have // Skip for now in MVP return findings, nil case "pip": ecosystem = "PyPI" case "npm": ecosystem = "npm" case "go": ecosystem = "Go" case "application": // Applications are harder to match return findings, nil default: return findings, nil } // Construct query query := osvQuery{} query.Package.Name = pkg.Name query.Package.Ecosystem = ecosystem query.Version = pkg.Version queryJSON, err := json.Marshal(query) if err != nil { return findings, fmt.Errorf("marshal query: %w", err) } // Query OSV.dev resp, err := http.Post( "https://api.osv.dev/v1/query", "application/json", bytes.NewReader(queryJSON), ) if err != nil { return findings, fmt.Errorf("osv request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return findings, fmt.Errorf("read response: %w", err) } if resp.StatusCode != 200 { return findings, fmt.Errorf("osv returned %d: %s", resp.StatusCode, string(body)) } var osvResp osvResponse if err := json.Unmarshal(body, &osvResp); err != nil { return findings, fmt.Errorf("parse response: %w", err) } // Convert to findings for _, vuln := range osvResp.Vulns { cvss := 0.0 // Prefer a CVSS v3.x vector, which we can score numerically. for _, sev := range vuln.Severity { if strings.HasPrefix(sev.Type, "CVSS_V3") { cvss = cvssV3BaseScore(sev.Score) break } } // Fall back to the GitHub advisory severity label (e.g. when only a // CVSS v4 vector or no vector is present). if cvss == 0 { cvss = severityLabelToScore(vuln.DatabaseSpecific.Severity) } severity := cvssToSeverity(cvss) link := "" for _, ref := range vuln.References { if ref.Type == "ADVISORY" || ref.Type == "WEB" { link = ref.URL break } } findings = append(findings, Finding{ PackageName: pkg.Name, PackageVersion: pkg.Version, CVE: vuln.ID, CVSS: cvss, Summary: vuln.Summary, Link: link, Severity: severity, }) } return findings, nil } // cvssV3BaseScore computes the CVSS v3.0/3.1 base score from a vector string // (e.g. "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"). It returns 0 if the // vector is not a parseable v3 vector. Formula per the CVSS v3.1 specification. func cvssV3BaseScore(vector string) float64 { if !strings.HasPrefix(vector, "CVSS:3") { return 0 } m := map[string]string{} for _, part := range strings.Split(vector, "/") { if kv := strings.SplitN(part, ":", 2); len(kv) == 2 { m[kv[0]] = kv[1] } } scopeChanged := m["S"] == "C" av := map[string]float64{"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2}[m["AV"]] ac := map[string]float64{"L": 0.77, "H": 0.44}[m["AC"]] ui := map[string]float64{"N": 0.85, "R": 0.62}[m["UI"]] var pr float64 switch m["PR"] { case "N": pr = 0.85 case "L": if scopeChanged { pr = 0.68 } else { pr = 0.62 } case "H": if scopeChanged { pr = 0.5 } else { pr = 0.27 } } // A required base metric is missing or invalid; can't score reliably. if av == 0 || ac == 0 || ui == 0 || pr == 0 { return 0 } cia := map[string]float64{"H": 0.56, "L": 0.22, "N": 0} c := cia[m["C"]] i := cia[m["I"]] a := cia[m["A"]] iss := 1 - ((1 - c) * (1 - i) * (1 - a)) var impact float64 if scopeChanged { impact = 7.52*(iss-0.029) - 3.25*math.Pow(iss-0.02, 15) } else { impact = 6.42 * iss } if impact <= 0 { return 0 } exploitability := 8.22 * av * ac * pr * ui var base float64 if scopeChanged { base = math.Min(1.08*(impact+exploitability), 10) } else { base = math.Min(impact+exploitability, 10) } return roundUp1(base) } // roundUp1 rounds up to one decimal place, per the CVSS v3.1 Roundup function. func roundUp1(x float64) float64 { i := int(math.Round(x * 100000)) if i%10000 == 0 { return float64(i) / 100000.0 } return (math.Floor(float64(i)/10000) + 1) / 10.0 } // severityLabelToScore maps a qualitative advisory severity label (as used in // GitHub/OSV database_specific.severity) to a representative CVSS score at the // bottom of its band, so it classifies into the matching severity bucket. func severityLabelToScore(label string) float64 { switch strings.ToUpper(strings.TrimSpace(label)) { case "CRITICAL": return 9.0 case "HIGH": return 7.0 case "MODERATE", "MEDIUM": return 4.0 case "LOW": return 0.1 default: return 0 } } // cvssToSeverity converts CVSS score to severity level func cvssToSeverity(cvss float64) string { switch { case cvss >= 9.0: return "CRITICAL" case cvss >= 7.0: return "HIGH" case cvss >= 4.0: return "MEDIUM" case cvss > 0: return "LOW" default: return "UNKNOWN" } }