This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/loki"
|
||||
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/store"
|
||||
)
|
||||
|
||||
type Finding struct {
|
||||
Summary string `json:"summary"`
|
||||
RecommendedActions []string `json:"recommended_actions,omitempty"`
|
||||
Confidence string `json:"confidence"`
|
||||
}
|
||||
|
||||
type Analyzer interface {
|
||||
Analyze(ctx context.Context, rule store.Rule, matches []loki.Match) (Finding, error)
|
||||
}
|
||||
|
||||
type NoopAnalyzer struct{}
|
||||
|
||||
func (NoopAnalyzer) Analyze(ctx context.Context, rule store.Rule, matches []loki.Match) (Finding, error) {
|
||||
_ = ctx
|
||||
return Finding{Summary: summarize(rule, matches), Confidence: "rule-only"}, nil
|
||||
}
|
||||
|
||||
type OllamaAnalyzer struct {
|
||||
URL string
|
||||
Model string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewOllamaAnalyzer(url, model string) *OllamaAnalyzer {
|
||||
return &OllamaAnalyzer{URL: strings.TrimRight(url, "/"), Model: model, http: &http.Client{Timeout: 60 * time.Second}}
|
||||
}
|
||||
|
||||
func (a *OllamaAnalyzer) Analyze(ctx context.Context, rule store.Rule, matches []loki.Match) (Finding, error) {
|
||||
if a.URL == "" || a.Model == "" {
|
||||
return NoopAnalyzer{}.Analyze(ctx, rule, matches)
|
||||
}
|
||||
prompt := buildPrompt(rule, matches)
|
||||
body, _ := json.Marshal(map[string]any{"model": a.Model, "prompt": prompt, "stream": false})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.URL+"/api/generate", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Finding{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return Finding{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return Finding{}, fmt.Errorf("ollama analysis failed: status %d", resp.StatusCode)
|
||||
}
|
||||
var raw struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return Finding{}, err
|
||||
}
|
||||
summary := strings.TrimSpace(raw.Response)
|
||||
if summary == "" {
|
||||
return NoopAnalyzer{}.Analyze(ctx, rule, matches)
|
||||
}
|
||||
if len(summary) > 500 {
|
||||
summary = summary[:500] + "..."
|
||||
}
|
||||
return Finding{Summary: summary, Confidence: "ollama-local"}, nil
|
||||
}
|
||||
|
||||
func buildPrompt(rule store.Rule, matches []loki.Match) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are analyzing private application logs locally. Summarize likely cause and operator next steps in 4 concise sentences or fewer. Do not suggest destructive actions.\n")
|
||||
b.WriteString("Rule: ")
|
||||
b.WriteString(rule.Name)
|
||||
b.WriteString("\nSeverity: ")
|
||||
b.WriteString(rule.Severity)
|
||||
b.WriteString("\nSanitized samples:\n")
|
||||
limit := len(matches)
|
||||
if limit > 5 {
|
||||
limit = 5
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
line := sanitizeLine(matches[i].Line)
|
||||
line = redactSecrets(line)
|
||||
if len(line) > 300 {
|
||||
line = line[:300] + "..."
|
||||
}
|
||||
b.WriteString("- ")
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func summarize(rule store.Rule, matches []loki.Match) string {
|
||||
if len(matches) == 0 {
|
||||
return "No matching log entries."
|
||||
}
|
||||
line := redactSecrets(sanitizeLine(matches[0].Line))
|
||||
if len(line) > 180 {
|
||||
line = line[:180] + "..."
|
||||
}
|
||||
return rule.Name + " matched " + plural(len(matches), "log entry", "log entries") + "; first match: " + line
|
||||
}
|
||||
|
||||
func sanitizeLine(line string) string {
|
||||
line = strings.ReplaceAll(line, "\n", " ")
|
||||
line = strings.ReplaceAll(line, "\r", " ")
|
||||
return strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
func redactSecrets(line string) string {
|
||||
fields := strings.Fields(line)
|
||||
for i, field := range fields {
|
||||
lower := strings.ToLower(field)
|
||||
if strings.Contains(lower, "token=") || strings.Contains(lower, "password=") || strings.Contains(lower, "secret=") || strings.Contains(lower, "apikey=") || strings.Contains(lower, "api_key=") || strings.HasPrefix(lower, "bearer") {
|
||||
fields[i] = redactField(field)
|
||||
}
|
||||
}
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
func redactField(field string) string {
|
||||
idx := strings.IndexAny(field, "=:")
|
||||
if idx < 0 {
|
||||
return "[REDACTED]"
|
||||
}
|
||||
prefix := strings.TrimRightFunc(field[:idx], func(r rune) bool { return unicode.IsSpace(r) })
|
||||
return prefix + "=[REDACTED]"
|
||||
}
|
||||
|
||||
func plural(count int, singular, plural string) string {
|
||||
if count == 1 {
|
||||
return "1 " + singular
|
||||
}
|
||||
return strconvItoa(count) + " " + plural
|
||||
}
|
||||
|
||||
func strconvItoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
bp := len(b)
|
||||
for i > 0 {
|
||||
bp--
|
||||
b[bp] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
return string(b[bp:])
|
||||
}
|
||||
Reference in New Issue
Block a user