8b9bdc2978
New AuditLogger writes one JSON line per firewall action to <projectRoot>/.gnoma/sessions/<sessionID>/audit.jsonl so a user can grep 'what did the firewall do this session?' after the fact. Records 'block', 'redact', 'warn', and 'unicode_sanitize' events with the matcher name, source (tool_result / message_text / etc.), and token length. Discipline: never the bytes themselves — only the matcher name and the length, matching the README's scope-note promise about audit data. Plumbing: - Firewall gains an audit *AuditLogger field plus SetAudit setter. The firewall is constructed before the session ID exists, so the audit logger is wired post-hoc once main.go has the sessionID. - Honours incognito: Record is a silent no-op when the firewall's IncognitoMode is active, preserving the no-persistence contract. - Tolerant of fs errors: mkdir / open / encode failures log a Warn but never propagate; the scan pipeline must not depend on audit succeeding. - Nil receiver is a valid no-op so callers don't need nil-guards around every Record. Tracks 'Security boundary — per-session audit log' from the v0.3.0 r/SideProject launch thread (u/Secret_Theme3192, 2026-05-24). Per-host egress allowlist remains separately tracked pending the commenter's reply on host-level vs per-tool semantics.
140 lines
3.9 KiB
Go
140 lines
3.9 KiB
Go
package security
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func readAuditLines(t *testing.T, path string) []AuditEvent {
|
|
t.Helper()
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatalf("open audit log: %v", err)
|
|
}
|
|
defer f.Close()
|
|
var events []AuditEvent
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
var ev AuditEvent
|
|
if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
|
|
t.Fatalf("decode line %q: %v", sc.Text(), err)
|
|
}
|
|
events = append(events, ev)
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
t.Fatalf("scan audit log: %v", err)
|
|
}
|
|
return events
|
|
}
|
|
|
|
func TestAuditLogger_NilReceiverIsNoop(t *testing.T) {
|
|
var a *AuditLogger
|
|
// Must not panic.
|
|
a.Record(AuditEvent{Action: "block"})
|
|
}
|
|
|
|
func TestAuditLogger_DisabledWhenPathEmpty(t *testing.T) {
|
|
a := NewAuditLogger(AuditLoggerConfig{})
|
|
if a != nil {
|
|
t.Errorf("expected nil logger for empty path, got %v", a)
|
|
}
|
|
}
|
|
|
|
func TestAuditLogger_AppendsJSONLines(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "audit.jsonl")
|
|
a := NewAuditLogger(AuditLoggerConfig{Path: path})
|
|
if a == nil {
|
|
t.Fatal("expected non-nil logger")
|
|
}
|
|
|
|
a.Record(AuditEvent{Action: "block", Pattern: "anthropic_api_key", Source: "tool_result", TokenLen: 51})
|
|
a.Record(AuditEvent{Action: "redact", Pattern: "high_entropy", Source: "message_text", TokenLen: 42})
|
|
|
|
events := readAuditLines(t, path)
|
|
if len(events) != 2 {
|
|
t.Fatalf("expected 2 events, got %d", len(events))
|
|
}
|
|
if events[0].Action != "block" || events[0].Pattern != "anthropic_api_key" {
|
|
t.Errorf("event 0 = %+v", events[0])
|
|
}
|
|
if events[0].Timestamp.IsZero() {
|
|
t.Error("event 0 missing timestamp")
|
|
}
|
|
if events[1].Action != "redact" || events[1].TokenLen != 42 {
|
|
t.Errorf("event 1 = %+v", events[1])
|
|
}
|
|
}
|
|
|
|
func TestAuditLogger_SkipsUnderIncognito(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "audit.jsonl")
|
|
incog := NewIncognitoMode()
|
|
a := NewAuditLogger(AuditLoggerConfig{Path: path, Incognito: incog})
|
|
|
|
incog.Activate()
|
|
a.Record(AuditEvent{Action: "block", Pattern: "x"})
|
|
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Errorf("expected audit file to not exist under incognito, got err=%v", err)
|
|
}
|
|
|
|
incog.Deactivate()
|
|
a.Record(AuditEvent{Action: "block", Pattern: "y"})
|
|
|
|
events := readAuditLines(t, path)
|
|
if len(events) != 1 {
|
|
t.Fatalf("expected 1 event after deactivate, got %d", len(events))
|
|
}
|
|
if events[0].Pattern != "y" {
|
|
t.Errorf("expected pattern=y (incognito event dropped), got %q", events[0].Pattern)
|
|
}
|
|
}
|
|
|
|
func TestAuditLogger_CreatesParentDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "deeply", "nested", "audit.jsonl")
|
|
a := NewAuditLogger(AuditLoggerConfig{Path: path})
|
|
a.Record(AuditEvent{Action: "block"})
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Errorf("expected audit file at %s, got err=%v", path, err)
|
|
}
|
|
}
|
|
|
|
func TestFirewall_RecordsRedactionToAudit(t *testing.T) {
|
|
dir := t.TempDir()
|
|
auditPath := filepath.Join(dir, "audit.jsonl")
|
|
audit := NewAuditLogger(AuditLoggerConfig{Path: auditPath})
|
|
|
|
fw := NewFirewall(FirewallConfig{
|
|
ScanOutgoing: true,
|
|
ScanToolResults: true,
|
|
Audit: audit,
|
|
})
|
|
|
|
// Anthropic key prefix is a built-in redact pattern; emit it
|
|
// through the tool-result scanning path.
|
|
cleaned := fw.ScanToolResult("here is the key sk-ant-abcdef1234567890abcdef1234567890abcdef")
|
|
if !strings.Contains(cleaned, "[REDACTED]") {
|
|
t.Errorf("expected [REDACTED] in cleaned content, got %q", cleaned)
|
|
}
|
|
|
|
events := readAuditLines(t, auditPath)
|
|
var sawAnthropicRedact bool
|
|
for _, ev := range events {
|
|
if ev.Action == "redact" && ev.Pattern == "anthropic_api_key" && ev.Source == "tool_result" {
|
|
sawAnthropicRedact = true
|
|
if ev.TokenLen == 0 {
|
|
t.Errorf("expected non-zero TokenLen on redact event, got %+v", ev)
|
|
}
|
|
}
|
|
}
|
|
if !sawAnthropicRedact {
|
|
t.Errorf("expected an anthropic_api_key redact event in audit log, got %+v", events)
|
|
}
|
|
}
|