OSV returns severity.score as a CVSS vector string, not a number, so unmarshalling into float64 errored and every vuln was dropped. Read it as a string, compute the CVSS v3 base score from the vector, and fall back to the database_specific severity label otherwise. Invoke the analysis via "claude -p" instead of a nonexistent "ask" subcommand (which made the CLI treat "ask" as the prompt), and request strict JSON so recommendations are parsed reliably rather than by substring matching. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVbEgdgcC1w6qwSq3zC4kg
178 lines
5.0 KiB
Go
178 lines
5.0 KiB
Go
package security
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// analyzeWithCLI sends findings to Claude via the claude command-line tool
|
|
func analyzeWithCLI(a *Audit, findings []Finding) error {
|
|
fmt.Println(" Using Claude Code CLI for analysis...")
|
|
|
|
for _, finding := range findings {
|
|
// Build prompt for Claude
|
|
prompt := buildClaudePrompt(finding)
|
|
|
|
// 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
|
|
}
|
|
|
|
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 {
|
|
fmt.Println(" Using Anthropic SDK for analysis...")
|
|
|
|
// This is a placeholder - full SDK integration would go here
|
|
// For now, fallback to CLI mode
|
|
return analyzeWithCLI(a, findings)
|
|
}
|
|
|
|
// buildClaudePrompt constructs a prompt for Claude analysis
|
|
func buildClaudePrompt(f Finding) string {
|
|
return fmt.Sprintf(`You are a security expert analyzing a single software vulnerability.
|
|
|
|
Package: %s (version %s)
|
|
CVE: %s
|
|
CVSS Score: %.1f
|
|
Severity: %s
|
|
Summary: %s
|
|
|
|
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 ONLY a JSON object (no prose, no Markdown fences) in exactly this shape:
|
|
{"recommendation": "<update|replace|protect|monitor>", "rationale": "<one or two sentences on why>", "next_steps": "<concrete, practical steps>"}`,
|
|
f.PackageName, f.PackageVersion, f.CVE, f.CVSS, f.Severity, f.Summary)
|
|
}
|
|
|
|
// inferRecommendation extracts the recommendation from Claude's response
|
|
func inferRecommendation(response string) string {
|
|
response = strings.ToUpper(response)
|
|
|
|
if strings.Contains(response, "UPDATE") {
|
|
return "update"
|
|
}
|
|
if strings.Contains(response, "REPLACE") {
|
|
return "replace"
|
|
}
|
|
if strings.Contains(response, "PROTECT") {
|
|
return "protect"
|
|
}
|
|
if strings.Contains(response, "MONITOR") {
|
|
return "monitor"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// inferNextSteps extracts action items from Claude's response
|
|
func inferNextSteps(response string) string {
|
|
// Extract the "steps" section if present
|
|
lines := strings.Split(response, "\n")
|
|
var steps []string
|
|
foundSteps := false
|
|
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.Contains(strings.ToLower(line), "step") {
|
|
foundSteps = true
|
|
}
|
|
if foundSteps && strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "•") || strings.HasPrefix(trimmed, "1") {
|
|
steps = append(steps, trimmed)
|
|
}
|
|
}
|
|
|
|
if len(steps) > 0 {
|
|
return strings.Join(steps, "\n")
|
|
}
|
|
return response
|
|
}
|
|
|
|
// MarshalJSON converts Mitigation to JSON, handling the Finding struct
|
|
func (m Mitigation) MarshalJSON() ([]byte, error) {
|
|
type Alias Mitigation
|
|
return json.Marshal(&struct {
|
|
*Alias
|
|
}{
|
|
Alias: (*Alias)(&m),
|
|
})
|
|
}
|