161 lines
4.4 KiB
Go
161 lines
4.4 KiB
Go
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:])
|
|
}
|