Skip to content

Commit d49779a

Browse files
authored
Merge pull request #12 from EdgarPsda/v0.6.0/ai-fix-suggestions
Add AI fix suggestions for HIGH/CRITICAL findings (opt-in)
2 parents 7620d0a + cb69ad8 commit d49779a

8 files changed

Lines changed: 502 additions & 16 deletions

File tree

cli/ai/cache.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package ai
2+
3+
import (
4+
"crypto/sha256"
5+
"fmt"
6+
"sync"
7+
)
8+
9+
// Cache stores AI suggestions keyed by a hash of rule+message.
10+
// Thread-safe for concurrent enrichment.
11+
type Cache struct {
12+
mu sync.RWMutex
13+
store map[string]string
14+
}
15+
16+
func NewCache() *Cache {
17+
return &Cache{store: make(map[string]string)}
18+
}
19+
20+
func (c *Cache) Get(key string) (string, bool) {
21+
c.mu.RLock()
22+
defer c.mu.RUnlock()
23+
v, ok := c.store[key]
24+
return v, ok
25+
}
26+
27+
func (c *Cache) Set(key, value string) {
28+
c.mu.Lock()
29+
defer c.mu.Unlock()
30+
c.store[key] = value
31+
}
32+
33+
// cacheKey returns a stable key for a rule ID + message pair
34+
func cacheKey(ruleID, message string) string {
35+
h := sha256.Sum256([]byte(ruleID + "\x00" + message))
36+
return fmt.Sprintf("%x", h[:8])
37+
}

cli/ai/suggestions.go

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package ai
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strings"
10+
"time"
11+
12+
"github.com/edgarpsda/devsecops-kit/cli/scanners"
13+
)
14+
15+
// Config holds AI provider configuration
16+
type Config struct {
17+
Enabled bool
18+
Provider string // "ollama", "openai", "anthropic"
19+
Model string
20+
Endpoint string // for ollama; defaults to http://localhost:11434
21+
APIKey string // for openai/anthropic; reads from env if empty
22+
}
23+
24+
// Client generates fix suggestions for security findings
25+
type Client struct {
26+
cfg Config
27+
cache *Cache
28+
http *http.Client
29+
}
30+
31+
// NewClient creates an AI client with the given config
32+
func NewClient(cfg Config) *Client {
33+
endpoint := cfg.Endpoint
34+
if endpoint == "" {
35+
endpoint = "http://localhost:11434"
36+
}
37+
cfg.Endpoint = endpoint
38+
39+
model := cfg.Model
40+
if model == "" {
41+
switch cfg.Provider {
42+
case "openai":
43+
model = "gpt-4o-mini"
44+
case "anthropic":
45+
model = "claude-haiku-4-5-20251001"
46+
default:
47+
model = "llama3"
48+
}
49+
}
50+
cfg.Model = model
51+
52+
return &Client{
53+
cfg: cfg,
54+
cache: NewCache(),
55+
http: &http.Client{Timeout: 30 * time.Second},
56+
}
57+
}
58+
59+
// EnrichFindings adds AI fix suggestions to findings in-place.
60+
// Only HIGH and CRITICAL findings are enriched to keep noise low.
61+
// Results are cached so identical rule+message pairs are only sent once.
62+
func (c *Client) EnrichFindings(findings []scanners.Finding) {
63+
for i := range findings {
64+
f := &findings[i]
65+
if f.Severity != "CRITICAL" && f.Severity != "HIGH" {
66+
continue
67+
}
68+
69+
cacheKey := cacheKey(f.RuleID, f.Message)
70+
if suggestion, ok := c.cache.Get(cacheKey); ok {
71+
f.AISuggestion = suggestion
72+
continue
73+
}
74+
75+
suggestion, err := c.getSuggestion(f)
76+
if err != nil {
77+
// Non-fatal: just skip this finding
78+
continue
79+
}
80+
81+
c.cache.Set(cacheKey, suggestion)
82+
f.AISuggestion = suggestion
83+
}
84+
}
85+
86+
func (c *Client) getSuggestion(f *scanners.Finding) (string, error) {
87+
prompt := buildPrompt(f)
88+
89+
switch c.cfg.Provider {
90+
case "openai":
91+
return c.callOpenAI(prompt)
92+
case "anthropic":
93+
return c.callAnthropic(prompt)
94+
default:
95+
return c.callOllama(prompt)
96+
}
97+
}
98+
99+
// buildPrompt constructs a concise, focused prompt for the finding
100+
func buildPrompt(f *scanners.Finding) string {
101+
return fmt.Sprintf(
102+
"You are a security expert. Provide a concise fix suggestion (2-4 sentences max) for this security finding:\n\nTool: %s\nSeverity: %s\nRule: %s\nFile: %s\nIssue: %s\n\nRespond with only the fix suggestion, no preamble.",
103+
f.Tool, f.Severity, f.RuleID, f.File, f.Message,
104+
)
105+
}
106+
107+
// --- Ollama ---
108+
109+
type ollamaRequest struct {
110+
Model string `json:"model"`
111+
Prompt string `json:"prompt"`
112+
Stream bool `json:"stream"`
113+
}
114+
115+
type ollamaResponse struct {
116+
Response string `json:"response"`
117+
Error string `json:"error,omitempty"`
118+
}
119+
120+
func (c *Client) callOllama(prompt string) (string, error) {
121+
body, _ := json.Marshal(ollamaRequest{
122+
Model: c.cfg.Model,
123+
Prompt: prompt,
124+
Stream: false,
125+
})
126+
127+
resp, err := c.http.Post(c.cfg.Endpoint+"/api/generate", "application/json", bytes.NewReader(body))
128+
if err != nil {
129+
return "", fmt.Errorf("ollama request failed: %w", err)
130+
}
131+
defer resp.Body.Close()
132+
133+
var result ollamaResponse
134+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
135+
return "", fmt.Errorf("ollama response decode failed: %w", err)
136+
}
137+
if result.Error != "" {
138+
return "", fmt.Errorf("ollama error: %s", result.Error)
139+
}
140+
141+
return strings.TrimSpace(result.Response), nil
142+
}
143+
144+
// --- OpenAI ---
145+
146+
type openAIRequest struct {
147+
Model string `json:"model"`
148+
Messages []openAIMessage `json:"messages"`
149+
}
150+
151+
type openAIMessage struct {
152+
Role string `json:"role"`
153+
Content string `json:"content"`
154+
}
155+
156+
type openAIResponse struct {
157+
Choices []struct {
158+
Message openAIMessage `json:"message"`
159+
} `json:"choices"`
160+
Error *struct {
161+
Message string `json:"message"`
162+
} `json:"error,omitempty"`
163+
}
164+
165+
func (c *Client) callOpenAI(prompt string) (string, error) {
166+
body, _ := json.Marshal(openAIRequest{
167+
Model: c.cfg.Model,
168+
Messages: []openAIMessage{
169+
{Role: "user", Content: prompt},
170+
},
171+
})
172+
173+
req, _ := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", bytes.NewReader(body))
174+
req.Header.Set("Content-Type", "application/json")
175+
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
176+
177+
resp, err := c.http.Do(req)
178+
if err != nil {
179+
return "", fmt.Errorf("openai request failed: %w", err)
180+
}
181+
defer resp.Body.Close()
182+
183+
rawBody, _ := io.ReadAll(resp.Body)
184+
var result openAIResponse
185+
if err := json.Unmarshal(rawBody, &result); err != nil {
186+
return "", fmt.Errorf("openai response decode failed: %w", err)
187+
}
188+
if result.Error != nil {
189+
return "", fmt.Errorf("openai error: %s", result.Error.Message)
190+
}
191+
if len(result.Choices) == 0 {
192+
return "", fmt.Errorf("openai returned no choices")
193+
}
194+
195+
return strings.TrimSpace(result.Choices[0].Message.Content), nil
196+
}
197+
198+
// --- Anthropic ---
199+
200+
type anthropicRequest struct {
201+
Model string `json:"model"`
202+
MaxTokens int `json:"max_tokens"`
203+
Messages []anthropicMessage `json:"messages"`
204+
}
205+
206+
type anthropicMessage struct {
207+
Role string `json:"role"`
208+
Content string `json:"content"`
209+
}
210+
211+
type anthropicResponse struct {
212+
Content []struct {
213+
Text string `json:"text"`
214+
} `json:"content"`
215+
Error *struct {
216+
Message string `json:"message"`
217+
} `json:"error,omitempty"`
218+
}
219+
220+
func (c *Client) callAnthropic(prompt string) (string, error) {
221+
body, _ := json.Marshal(anthropicRequest{
222+
Model: c.cfg.Model,
223+
MaxTokens: 256,
224+
Messages: []anthropicMessage{
225+
{Role: "user", Content: prompt},
226+
},
227+
})
228+
229+
req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(body))
230+
req.Header.Set("Content-Type", "application/json")
231+
req.Header.Set("x-api-key", c.cfg.APIKey)
232+
req.Header.Set("anthropic-version", "2023-06-01")
233+
234+
resp, err := c.http.Do(req)
235+
if err != nil {
236+
return "", fmt.Errorf("anthropic request failed: %w", err)
237+
}
238+
defer resp.Body.Close()
239+
240+
rawBody, _ := io.ReadAll(resp.Body)
241+
var result anthropicResponse
242+
if err := json.Unmarshal(rawBody, &result); err != nil {
243+
return "", fmt.Errorf("anthropic response decode failed: %w", err)
244+
}
245+
if result.Error != nil {
246+
return "", fmt.Errorf("anthropic error: %s", result.Error.Message)
247+
}
248+
if len(result.Content) == 0 {
249+
return "", fmt.Errorf("anthropic returned empty content")
250+
}
251+
252+
return strings.TrimSpace(result.Content[0].Text), nil
253+
}

0 commit comments

Comments
 (0)