diff --git a/internal/security/claude.go b/internal/security/claude.go index 50639d3..e8f2aed 100644 --- a/internal/security/claude.go +++ b/internal/security/claude.go @@ -15,28 +15,84 @@ func analyzeWithCLI(a *Audit, findings []Finding) error { // Build prompt for Claude prompt := buildClaudePrompt(finding) - // Call claude command - cmd := exec.Command("claude", "ask", prompt) + // Call claude in non-interactive print mode (-p). There is no "ask" + // subcommand; passing one makes the CLI treat "ask" as the prompt. + cmd := exec.Command("claude", "-p", prompt) output, err := cmd.Output() if err != nil { fmt.Printf(" ⚠️ Error analyzing %s %s: %v\n", finding.PackageName, finding.CVE, err) continue } - // Parse Claude's response - mitigation := Mitigation{ - Finding: finding, - Rationale: string(output), - Recommendation: inferRecommendation(string(output)), - NextSteps: inferNextSteps(string(output)), - } - - a.Mitigations = append(a.Mitigations, mitigation) + a.Mitigations = append(a.Mitigations, parseMitigation(finding, string(output))) } return nil } +// claudeResponse is the strict JSON schema we ask Claude to emit. +type claudeResponse struct { + Recommendation string `json:"recommendation"` + Rationale string `json:"rationale"` + NextSteps string `json:"next_steps"` +} + +// parseMitigation builds a Mitigation from Claude's raw stdout. It prefers the +// structured JSON we request, falling back to heuristic extraction if the +// model returned free-form text. +func parseMitigation(finding Finding, output string) Mitigation { + if cr, ok := parseClaudeJSON(output); ok { + rec := strings.ToLower(strings.TrimSpace(cr.Recommendation)) + switch rec { + case "update", "replace", "protect", "monitor": + default: + rec = "unknown" + } + return Mitigation{ + Finding: finding, + Recommendation: rec, + Rationale: cr.Rationale, + NextSteps: cr.NextSteps, + } + } + + // Fallback: heuristic parsing of free-form text. + return Mitigation{ + Finding: finding, + Rationale: output, + Recommendation: inferRecommendation(output), + NextSteps: inferNextSteps(output), + } +} + +// parseClaudeJSON extracts a claudeResponse from output that may be wrapped in +// Markdown code fences or surrounded by prose. +func parseClaudeJSON(output string) (claudeResponse, bool) { + s := strings.TrimSpace(output) + + // Strip a leading ```json / ``` fence and its closing fence if present. + if strings.HasPrefix(s, "```") { + if i := strings.IndexByte(s, '\n'); i != -1 { + s = s[i+1:] + } + s = strings.TrimSuffix(strings.TrimSpace(s), "```") + s = strings.TrimSpace(s) + } + + // Narrow to the outermost JSON object. + start := strings.IndexByte(s, '{') + end := strings.LastIndexByte(s, '}') + if start == -1 || end == -1 || end < start { + return claudeResponse{}, false + } + + var cr claudeResponse + if err := json.Unmarshal([]byte(s[start:end+1]), &cr); err != nil { + return claudeResponse{}, false + } + return cr, true +} + // analyzeWithSDK sends findings to Claude via Anthropic SDK // This requires ANTHROPIC_API_KEY environment variable func analyzeWithSDK(a *Audit, findings []Finding) error { @@ -49,7 +105,7 @@ func analyzeWithSDK(a *Audit, findings []Finding) error { // buildClaudePrompt constructs a prompt for Claude analysis func buildClaudePrompt(f Finding) string { - return fmt.Sprintf(`You are a security expert. Analyze this vulnerability and provide a clear mitigation strategy. + return fmt.Sprintf(`You are a security expert analyzing a single software vulnerability. Package: %s (version %s) CVE: %s @@ -57,18 +113,15 @@ CVSS Score: %.1f Severity: %s Summary: %s -For this vulnerability, provide a concise mitigation strategy. Consider these options: -1. UPDATE - update to a patched version -2. REPLACE - replace with an alternative package -3. PROTECT - add extra protections without updating -4. MONITOR - monitor and plan update +Choose ONE recommended action: +- update: update to a patched version +- replace: replace with an alternative package +- protect: add extra protections without updating +- monitor: monitor and plan an update -Respond with: -- Recommended action (UPDATE/REPLACE/PROTECT/MONITOR) -- Why you recommend this action -- Specific steps to take - -Be concise and practical.`, f.PackageName, f.PackageVersion, f.CVE, f.CVSS, f.Severity, f.Summary) +Respond with ONLY a JSON object (no prose, no Markdown fences) in exactly this shape: +{"recommendation": "", "rationale": "", "next_steps": ""}`, + f.PackageName, f.PackageVersion, f.CVE, f.CVSS, f.Severity, f.Summary) } // inferRecommendation extracts the recommendation from Claude's response diff --git a/internal/security/osv.go b/internal/security/osv.go index 3eafb7b..f90b945 100644 --- a/internal/security/osv.go +++ b/internal/security/osv.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "strings" @@ -26,14 +27,19 @@ type osvResponse struct { ID string `json:"id"` Published string `json:"published"` Summary string `json:"summary"` - Severity []struct { - Type string `json:"type"` - Score float64 `json:"score"` + 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"` } @@ -101,15 +107,18 @@ func queryOSV(pkg inventory.Package) ([]Finding, error) { // Convert to findings for _, vuln := range osvResp.Vulns { cvss := 0.0 - if len(vuln.Severity) > 0 { - // Use CVSS v3.1 score if available - for _, sev := range vuln.Severity { - if strings.HasPrefix(sev.Type, "CVSS_V3") { - cvss = sev.Score - break - } + // 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 := "" @@ -134,6 +143,106 @@ func queryOSV(pkg inventory.Package) ([]Finding, error) { 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 { diff --git a/internal/security/osv_test.go b/internal/security/osv_test.go index 4777413..618a164 100644 --- a/internal/security/osv_test.go +++ b/internal/security/osv_test.go @@ -44,7 +44,7 @@ func TestQueryOSVWithMockedServer(t *testing.T) { "severity": [ { "type": "CVSS_V3", - "score": 7.5 + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" } ], "references": [