WayfinderAK 6f7549d439
All checks were successful
build-image / docker (push) Successful in 53s
Implement phase 1 foundations
2026-07-03 20:14:06 -08:00

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:])
}