33dec722b8
internal/security/ — core security layer baked into gnoma: - Secret scanner: gitleaks-derived regex patterns (Anthropic, OpenAI, AWS, GitHub, GitLab, Slack, Stripe, private keys, DB URLs, generic secrets) + Shannon entropy detection for unknown formats - Redactor: replaces matched secrets with [REDACTED], merges overlapping ranges, preserves surrounding context - Unicode sanitizer: NFKC normalization, strips Cf/Co categories, tag characters (ASCII smuggling), zero-width chars, RTL overrides - Incognito mode: suppresses persistence, learning, content logging - Firewall: wraps engine, scans outgoing messages + system prompt + tool results before they reach the provider Wired into engine and CLI. 21 security tests.
127 lines
3.0 KiB
Go
127 lines
3.0 KiB
Go
package security
|
|
|
|
import (
|
|
"log/slog"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/message"
|
|
)
|
|
|
|
// Firewall scans outgoing LLM requests and incoming tool results
|
|
// for secrets, sensitive data, and dangerous Unicode. Core security
|
|
// layer — not a plugin, everyone benefits by default.
|
|
type Firewall struct {
|
|
scanner *Scanner
|
|
incognito *IncognitoMode
|
|
logger *slog.Logger
|
|
|
|
// Config
|
|
scanOutgoing bool
|
|
scanToolResults bool
|
|
}
|
|
|
|
type FirewallConfig struct {
|
|
ScanOutgoing bool
|
|
ScanToolResults bool
|
|
EntropyThreshold float64
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
func NewFirewall(cfg FirewallConfig) *Firewall {
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Firewall{
|
|
scanner: NewScanner(cfg.EntropyThreshold),
|
|
incognito: NewIncognitoMode(),
|
|
logger: logger,
|
|
scanOutgoing: cfg.ScanOutgoing,
|
|
scanToolResults: cfg.ScanToolResults,
|
|
}
|
|
}
|
|
|
|
// Incognito returns the incognito mode controller.
|
|
func (f *Firewall) Incognito() *IncognitoMode {
|
|
return f.incognito
|
|
}
|
|
|
|
// Scanner returns the secret scanner for adding custom patterns.
|
|
func (f *Firewall) Scanner() *Scanner {
|
|
return f.scanner
|
|
}
|
|
|
|
// ScanOutgoingMessages scans all message content before sending to provider.
|
|
// Returns cleaned messages with secrets redacted.
|
|
func (f *Firewall) ScanOutgoingMessages(msgs []message.Message) []message.Message {
|
|
if !f.scanOutgoing {
|
|
return msgs
|
|
}
|
|
|
|
cleaned := make([]message.Message, len(msgs))
|
|
for i, m := range msgs {
|
|
cleaned[i] = f.scanMessage(m)
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
// ScanToolResult scans a tool execution result for secrets.
|
|
// Returns the cleaned content.
|
|
func (f *Firewall) ScanToolResult(content string) string {
|
|
if !f.scanToolResults {
|
|
return content
|
|
}
|
|
return f.scanAndRedact(content, "tool_result")
|
|
}
|
|
|
|
// ScanSystemPrompt scans the system prompt for accidentally embedded secrets.
|
|
func (f *Firewall) ScanSystemPrompt(prompt string) string {
|
|
return f.scanAndRedact(prompt, "system_prompt")
|
|
}
|
|
|
|
func (f *Firewall) scanMessage(m message.Message) message.Message {
|
|
cleaned := message.Message{Role: m.Role}
|
|
cleaned.Content = make([]message.Content, len(m.Content))
|
|
|
|
for i, c := range m.Content {
|
|
switch c.Type {
|
|
case message.ContentText:
|
|
cleaned.Content[i] = message.NewTextContent(
|
|
f.scanAndRedact(c.Text, "message_text"),
|
|
)
|
|
case message.ContentToolResult:
|
|
if c.ToolResult != nil {
|
|
tr := *c.ToolResult
|
|
tr.Content = f.scanAndRedact(tr.Content, "tool_result")
|
|
cleaned.Content[i] = message.NewToolResultContent(tr)
|
|
} else {
|
|
cleaned.Content[i] = c
|
|
}
|
|
default:
|
|
// Tool calls, thinking blocks — pass through
|
|
cleaned.Content[i] = c
|
|
}
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func (f *Firewall) scanAndRedact(content, source string) string {
|
|
// Unicode sanitization first
|
|
content = SanitizeUnicode(content)
|
|
|
|
// Secret scanning
|
|
matches := f.scanner.Scan(content)
|
|
if len(matches) == 0 {
|
|
return content
|
|
}
|
|
|
|
for _, m := range matches {
|
|
f.logger.Warn("secret detected",
|
|
"pattern", m.Pattern,
|
|
"action", m.Action,
|
|
"source", source,
|
|
)
|
|
}
|
|
|
|
return Redact(content, matches)
|
|
}
|