diff --git a/docs/audit-schema.md b/docs/audit-schema.md new file mode 100644 index 0000000..4ef8300 --- /dev/null +++ b/docs/audit-schema.md @@ -0,0 +1,315 @@ +# Kerno Audit Log — Schema Reference + +**Schema version:** `1` +**Format:** NDJSON (Newline-Delimited JSON) — one JSON object per line +**File:** `/var/log/kerno-audit.jsonl` (configurable via `audit.file_path`) +**Encoding:** UTF-8, `SetEscapeHTML(false)` — slashes in paths are not mangled + +--- + +## Why this log exists + +Compliance frameworks (SOC 2, ISO 27001, HIPAA) require an **append-only audit trail** of every privileged action a daemon takes: who triggered it, when, and what changed. Kerno's operational log (`slog`) records health and debug information; this audit log records **security-relevant events** only. + +A compliance reviewer can: +1. Trace a finding from sink emission → AI call → doctor cycle → eBPF signal. +2. Verify that no PII (PIDs, IPs, filesystem paths) appears in any record. +3. Confirm that every config change was source-attributed (file, SIGHUP, env). + +--- + +## Envelope (all records) + +Every line is a JSON object with this envelope: + +```json +{ + "v": "1", + "ts": "2026-05-10T14:32:01.123Z", + "type": "", + "actor": "", + "details": { ... } +} +``` + +| Field | Type | Description | +|---|---|---| +| `v` | string | Schema version. Currently `"1"`. Increment when fields are added or removed. | +| `ts` | RFC 3339 (UTC) | Wall-clock timestamp when the record was written. | +| `type` | string | Event category. See table below. | +| `actor` | string | Kerno subsystem that emitted the record (e.g. `"kerno/start"`, `"ai/analyzer"`). | +| `details` | object | Event-specific payload. Schema per `type` documented below. | + +--- + +## Event types + +| `type` | Trigger | +|---|---| +| `daemon.start` | Daemon process started | +| `daemon.stop` | Daemon process stopped (graceful or signal) | +| `daemon.panic` | Daemon panic recovered; stack trace digest included | +| `config.reload` | Config file reloaded (initial load or SIGHUP) | +| `ai.call` | LLM provider called (success or failure) | +| `finding.emit` | Diagnostic finding emitted to a downstream sink | +| `auth.failure` | `/metrics` bearer-token mismatch or mTLS failure | +| `bpf.load` | eBPF program loaded or failed to load | + +--- + +## `daemon.start` / `daemon.stop` + +```json +{ + "v": "1", + "ts": "2026-05-10T14:30:00.000Z", + "type": "daemon.start", + "actor": "kerno/start", + "details": { + "version": "v0.3.1" + } +} +``` + +| Field | Type | Description | +|---|---|---| +| `version` | string | Kerno binary version (`version.Version`). | + +--- + +## `daemon.panic` + +```json +{ + "v": "1", + "ts": "2026-05-10T15:00:00.000Z", + "type": "daemon.panic", + "actor": "kerno/start", + "details": { + "version": "v0.3.1", + "panic_digest": "a3f1b2c4d5e6f708" + } +} +``` + +| Field | Type | Description | +|---|---|---| +| `version` | string | Kerno binary version. | +| `panic_digest` | string | First 16 hex characters of `SHA-256(stack_trace)`. The full trace goes to the operational log (stderr); this digest lets you correlate without storing the trace here. | + +--- + +## `config.reload` + +```json +{ + "v": "1", + "ts": "2026-05-10T14:32:05.000Z", + "type": "config.reload", + "actor": "config/SIGHUP", + "details": { + "path": "/etc/", + "changed_fields": ["log_level", "doctor.thresholds.fsync_p99_critical_ns"], + "applied": 2, + "restart_required": 0, + "source": "SIGHUP", + "uid": 0 + } +} +``` + +| Field | Type | Description | +|---|---|---| +| `path` | string | Redacted config file path (top-level dir only, e.g. `/etc/`). | +| `changed_fields` | string[] | Dot-notation field names that differed from the previous config. | +| `applied` | int | Number of fields applied without restart. | +| `restart_required` | int | Number of fields that require a daemon restart to take effect. | +| `source` | string | `"file"`, `"SIGHUP"`, or `"env"`. | +| `uid` | int | UID of the process that triggered the reload. | + +--- + +## `ai.call` + +```json +{ + "v": "1", + "ts": "2026-05-10T14:32:01.123Z", + "type": "ai.call", + "actor": "ai/analyzer", + "details": { + "provider": "anthropic", + "model": "claude-sonnet-4-20250514", + "tokens": 412, + "prompt_size": 1024, + "response_size": 380, + "redactions": { + "pid": 3, + "ip": 1, + "path": 0 + }, + "duration_ms": 854 + } +} +``` + +**Privacy guarantee:** Prompt and response bodies are **never** included. Only byte lengths and redaction counts are recorded. The `redactions` object lets a compliance reviewer verify that the privacy mode ran and stripped PII before the prompt was sent. + +| Field | Type | Description | +|---|---|---| +| `provider` | string | `"anthropic"`, `"openai"`, or `"ollama"`. | +| `model` | string | Model identifier returned by the provider. `"cached"` for cache hits; `"unknown"` on error. | +| `tokens` | int | Total tokens used (input + output). 0 for cache hits and errors. | +| `prompt_size` | int | Byte length of the user prompt **before** redaction. | +| `response_size` | int | Byte length of the raw completion text. 0 for cache hits and errors. | +| `redactions.pid` | int | Number of PID patterns stripped from the prompt. | +| `redactions.ip` | int | Number of IPv4 addresses stripped from the prompt. | +| `redactions.path` | int | Number of filesystem paths stripped from the prompt. | +| `duration_ms` | int | Wall-clock milliseconds from provider call start to response. 0 for cache hits. | + +--- + +## `finding.emit` + +```json +{ + "v": "1", + "ts": "2026-05-10T14:32:10.000Z", + "type": "finding.emit", + "actor": "doctor/engine", + "details": { + "rule": "oom_pressure", + "severity": "CRITICAL", + "sink": "slack", + "payload_hash": "a1b2c3d4e5f60708", + "cycle_id": "cycle-20260510-143200" + } +} +``` + +| Field | Type | Description | +|---|---|---| +| `rule` | string | Diagnostic rule that produced the finding. | +| `severity` | string | `"CRITICAL"`, `"WARNING"`, or `"INFO"`. | +| `sink` | string | Downstream sink: `"slack"`, `"pagerduty"`, `"log"`, etc. | +| `payload_hash` | string | FNV-1a (16 hex chars) of the serialised payload. Links this record to the payload without exposing PII. | +| `cycle_id` | string | Identifier for the doctor run that produced this finding. Links to the `ai.call` record for the same cycle. | + +--- + +## `auth.failure` + +```json +{ + "v": "1", + "ts": "2026-05-10T14:33:00.000Z", + "type": "auth.failure", + "actor": "http/server", + "details": { + "endpoint": "/metrics", + "reason": "token_mismatch", + "remote_ip": "" + } +} +``` + +| Field | Type | Description | +|---|---|---| +| `endpoint` | string | HTTP path that was accessed. | +| `reason` | string | `"token_mismatch"` or `"mtls_failure"`. | +| `remote_ip` | string | Always `` — the raw IP is never written to the audit log. | + +--- + +## `bpf.load` + +```json +{ + "v": "1", + "ts": "2026-05-10T14:30:01.000Z", + "type": "bpf.load", + "actor": "bpf/loader", + "details": { + "program": "syscall_latency", + "success": true + } +} +``` + +```json +{ + "v": "1", + "ts": "2026-05-10T14:30:01.100Z", + "type": "bpf.load", + "actor": "bpf/loader", + "details": { + "program": "tcp_monitor", + "success": false, + "error": "operation not permitted" + } +} +``` + +| Field | Type | Description | +|---|---|---| +| `program` | string | eBPF program name (matches `bpf.Loader.Name()`). | +| `success` | bool | `true` if the program loaded successfully. | +| `error` | string | Error message. Present only when `success` is `false`. | + +--- + +## PII redaction + +Kerno **never** writes PIDs, raw IPv4 addresses, or full filesystem paths to the audit log. + +| PII category | Replacement | +|---|---| +| `pid=` / `PID: ` | `pid=` | +| IPv4 address | `` | +| Absolute path under `/etc`, `/home`, `/var`, `/tmp`, `/proc`, `/sys`, `/run`, `/opt`, `/usr` | `//` | + +The `redactions` object in `ai.call` records counts how many items of each category were stripped before the prompt was sent to the provider. A count of `0` means the privacy filter ran and found nothing to strip — not that it was skipped. + +--- + +## Log rotation + +File rotation is handled by [lumberjack](https://github.com/natefinch/lumberjack): + +| Config key | Default | Description | +|---|---|---| +| `audit.file_path` | `/var/log/kerno-audit.jsonl` | Absolute path. Empty string disables the file sink. | +| `audit.max_size_mb` | `100` | Rotate when the file reaches this size in MB. | +| `audit.max_backups` | `0` | Number of rotated files to keep. `0` = unlimited (no auto-deletion). Set a non-zero value only if storage is constrained and your retention policy permits it. | +| `audit.stderr` | `true` | Also emit records to stderr (journald-friendly). | + +Rotated files are named `kerno-audit-.jsonl.gz` by lumberjack. If you use an external log shipper (Fluentd, Vector, Promtail), point it at the directory and enable glob matching for `*.jsonl*`. + +--- + +## Tracing a finding end-to-end + +``` +bpf.load program=syscall_latency success=true + ↓ +ai.call provider=anthropic tokens=412 cycle_id=cycle-abc123 + ↓ +finding.emit rule=syscall_latency_critical sink=slack cycle_id=cycle-abc123 + payload_hash=a1b2c3d4e5f60708 +``` + +1. Filter `bpf.load` records for the relevant program. +2. Find the `ai.call` record whose `ts` is closest after the eBPF programs loaded. +3. Match `finding.emit` records by `cycle_id` to correlate the AI call with the finding. +4. Use `payload_hash` to verify the payload delivered to Slack/PagerDuty matches the audit record. + +--- + +## Schema versioning + +The `"v"` field is bumped when: +- A **required** field is added or removed from any details payload. +- The semantics of an existing field change in a backward-incompatible way. + +Adding **optional** fields (present only sometimes) does **not** bump the version. +Log shippers and parsers should treat unknown fields as forward-compatible extensions. \ No newline at end of file diff --git a/go.mod b/go.mod index a2626e3..faedf8f 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/testcontainers/testcontainers-go v0.39.0 golang.org/x/sys v0.45.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 k8s.io/api v0.31.0 k8s.io/apimachinery v0.31.0 k8s.io/client-go v0.31.0 diff --git a/go.sum b/go.sum index 7132903..4c2b403 100644 --- a/go.sum +++ b/go.sum @@ -312,6 +312,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/ai/analyzer.go b/internal/ai/analyzer.go index 6d520c2..ef1e52d 100644 --- a/internal/ai/analyzer.go +++ b/internal/ai/analyzer.go @@ -9,7 +9,9 @@ import ( "fmt" "io" "log/slog" + "time" + "github.com/optiqor/kerno/internal/audit" "github.com/optiqor/kerno/internal/doctor" ) @@ -20,6 +22,7 @@ type DefaultAnalyzer struct { cache *Cache privacy PrivacyMode logger *slog.Logger + auditLog *audit.Logger // nil-safe: all audit.Logger methods handle nil receiver } // AnalyzerConfig holds configuration for constructing a DefaultAnalyzer. @@ -28,6 +31,7 @@ type AnalyzerConfig struct { Cache *Cache Privacy PrivacyMode Logger *slog.Logger + AuditLog *audit.Logger // optional; pass audit.Noop() to disable } // NewAnalyzer creates a DefaultAnalyzer. @@ -40,41 +44,75 @@ func NewAnalyzer(cfg AnalyzerConfig) *DefaultAnalyzer { if logger == nil { logger = slog.New(slog.NewTextHandler(io.Discard, nil)) } + auditLog := cfg.AuditLog + if auditLog == nil { + auditLog = audit.Noop() + } return &DefaultAnalyzer{ provider: cfg.Provider, cache: cfg.Cache, privacy: privacy, logger: logger, + auditLog: auditLog, } } // Analyze implements doctor.Analyzer. It serializes signals and findings into // a prompt, sends it to the LLM, and parses the structured JSON response. +// Every AI call — cached or live — emits an audit record. func (a *DefaultAnalyzer) Analyze(ctx context.Context, req doctor.AnalysisRequest) (*doctor.AnalysisResponse, error) { + // Build the prompt first so we know its size for the audit record. + userPrompt := BuildUserPrompt(req.Signals, req.Findings, req.History, a.privacy) + + // Redact the prompt BEFORE any logging or audit emission. + // We don't log the prompt body, but we do record how much was stripped + // so compliance reviewers can verify redaction ran. + _, redactions := audit.Redact(userPrompt) + // Check cache first. if a.cache != nil { fingerprint := findingsFingerprint(req.Findings) if cached, ok := a.cache.Get(fingerprint); ok { a.logger.Debug("AI cache hit", "fingerprint", fingerprint) + // Cache hits still produce an audit record so the log is complete. + a.auditLog.RecordAICall( + a.provider.Name(), + "cached", // model unknown for cache hits + cached.TokensUsed, + len(userPrompt), + 0, // response size not re-measured on cache hit + redactions, + 0, // duration 0 — served from cache + ) return cached, nil } } - // Build the prompt. - userPrompt := BuildUserPrompt(req.Signals, req.Findings, req.History, a.privacy) - a.logger.Debug("sending to AI provider", "provider", a.provider.Name(), "privacy", a.privacy, "prompt_len", len(userPrompt), ) - // Call the LLM. + // Call the LLM — measure wall-clock duration for the audit record. + start := time.Now() completion, err := a.provider.Complete(ctx, CompletionRequest{ SystemPrompt: SystemPrompt, UserPrompt: userPrompt, }) + durationMs := time.Since(start).Milliseconds() + if err != nil { + // Emit an audit record even on failure so the log is append-only complete. + a.auditLog.RecordAICall( + a.provider.Name(), + "unknown", // model unknown on error + 0, + len(userPrompt), + 0, + redactions, + durationMs, + ) return nil, fmt.Errorf("AI provider %s: %w", a.provider.Name(), err) } @@ -84,6 +122,17 @@ func (a *DefaultAnalyzer) Analyze(ctx context.Context, req doctor.AnalysisReques "tokens", completion.TokensUsed, ) + // Emit the audit record — no prompt/response bodies, only metadata. + a.auditLog.RecordAICall( + a.provider.Name(), + completion.Model, + completion.TokensUsed, + len(userPrompt), + len(completion.Text), + redactions, + durationMs, + ) + // Parse the structured JSON response. response, err := parseAnalysisResponse(completion.Text) if err != nil { diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..3faeb92 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,285 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +// Package audit provides an append-only structured audit log for SOC 2 / +// ISO 27001 / HIPAA compliance. Every privileged action kerno takes — +// config reloads, AI calls, finding emissions, auth failures, and daemon +// lifecycle events — is written as an NDJSON record to one or more sinks: +// +// - stderr (via slog "audit" group) — picked up by journald / systemd +// - a configurable file path with rotation via lumberjack +// +// No PII (PIDs, IPs, paths) appears in audit records; all fields are +// redacted by [Redact] before being written. +package audit + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "sync" + "time" + + "gopkg.in/natefinch/lumberjack.v2" +) + +// EventType identifies the category of an audit record. +type EventType string + +const ( + EventDaemonStart EventType = "daemon.start" + EventDaemonStop EventType = "daemon.stop" + EventDaemonPanic EventType = "daemon.panic" + EventConfigReload EventType = "config.reload" + EventAICall EventType = "ai.call" + EventFindingEmit EventType = "finding.emit" + EventAuthFailure EventType = "auth.failure" + EventBPFLoad EventType = "bpf.load" +) + +// Record is the top-level NDJSON envelope written for every auditable event. +type Record struct { + SchemaVersion string `json:"v"` + Timestamp time.Time `json:"ts"` + Type EventType `json:"type"` + Actor string `json:"actor"` + Details any `json:"details"` +} + +// DaemonDetails carries version and optional panic information for daemon +// lifecycle events. +type DaemonDetails struct { + Version string `json:"version,omitempty"` + PanicDigest string `json:"panic_digest,omitempty"` +} + +// ConfigReloadDetails describes a configuration reload attempt. +type ConfigReloadDetails struct { + Path string `json:"path"` + ChangedFields []string `json:"changed_fields"` + Applied int `json:"applied"` + RestartRequired int `json:"restart_required"` + Source string `json:"source"` + UID int `json:"uid"` +} + +// AICallDetails records metadata about an outbound AI provider request. +// Prompt and response bodies are never logged; only token counts and +// redaction statistics are retained. +type AICallDetails struct { + Provider string `json:"provider"` + Model string `json:"model"` + Tokens int `json:"tokens"` + PromptSize int `json:"prompt_size"` + ResponseSize int `json:"response_size"` + Redactions RedactSummary `json:"redactions"` + DurationMs int64 `json:"duration_ms"` +} + +// FindingEmitDetails records an outbound finding delivery to a sink. +type FindingEmitDetails struct { + Rule string `json:"rule"` + Severity string `json:"severity"` + Sink string `json:"sink"` + PayloadHash string `json:"payload_hash"` + CycleID string `json:"cycle_id"` +} + +// BPFLoadDetails records the outcome of a BPF program load attempt. +type BPFLoadDetails struct { + Program string `json:"program"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// AuthFailureDetails records a failed authentication attempt on an endpoint. +type AuthFailureDetails struct { + Endpoint string `json:"endpoint"` + Reason string `json:"reason"` + RemoteIP string `json:"remote_ip"` +} + +// Logger writes structured audit records to one or more configured sinks. +// All methods are safe for concurrent use. +type Logger struct { + mu sync.Mutex + enc *json.Encoder + slogger *slog.Logger + noop bool +} + +// Config controls where audit records are written. +type Config struct { + FilePath string + MaxSizeMB int + MaxBackups int + Stderr bool +} + +// New constructs a Logger from cfg. When no FilePath is provided, records +// are written to stderr. Both sinks may be active simultaneously. +func New(cfg Config) (*Logger, error) { + var writers []io.Writer + + if cfg.FilePath != "" { + maxMB := cfg.MaxSizeMB + if maxMB == 0 { + maxMB = 100 + } + lj := &lumberjack.Logger{ + Filename: cfg.FilePath, + MaxSize: maxMB, + MaxBackups: cfg.MaxBackups, + Compress: true, + } + writers = append(writers, lj) + } + + if cfg.Stderr || len(writers) == 0 { + writers = append(writers, os.Stderr) + } + + var w io.Writer + switch len(writers) { + case 1: + w = writers[0] + default: + w = io.MultiWriter(writers...) + } + + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + + return &Logger{ + enc: enc, + slogger: slog.Default().WithGroup("audit"), + }, nil +} + +// NewWithWriter constructs a Logger that writes to w. Intended for testing. +func NewWithWriter(w io.Writer) (*Logger, error) { + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + return &Logger{ + enc: enc, + slogger: slog.Default().WithGroup("audit"), + }, nil +} + +// Noop returns a Logger that discards all records. Useful in tests or +// when audit logging is administratively disabled. +func Noop() *Logger { + enc := json.NewEncoder(io.Discard) + return &Logger{ + enc: enc, + slogger: slog.Default(), + noop: true, + } +} + +// Record emits a single audit record with the given actor, event type, and +// detail payload. It is safe to call concurrently. +func (l *Logger) Record(actor string, evType EventType, details any) { + if l == nil || l.noop { + return + } + + rec := Record{ + SchemaVersion: "1", + Timestamp: time.Now().UTC(), + Type: evType, + Actor: actor, + Details: details, + } + + l.mu.Lock() + _ = l.enc.Encode(rec) + l.mu.Unlock() + + l.slogger.Info("event", + slog.String("type", string(evType)), + slog.String("actor", actor), + ) +} + +// RecordPanic emits a daemon.panic audit record. Only the first 16 hex +// characters of the SHA-256 digest of stackTrace are written; the raw bytes +// are intentionally omitted so sensitive goroutine state stays in +// stderr/journald rather than the structured audit log. +func (l *Logger) RecordPanic(ver string, stackTrace []byte) { + sum := sha256.Sum256(stackTrace) + digest := fmt.Sprintf("%x", sum[:])[:16] + l.Record("kerno/start", EventDaemonPanic, DaemonDetails{ + Version: ver, + PanicDigest: digest, + }) +} + +// RecordAICall emits an ai.call audit record. No prompt or response content +// is logged — only token counts, sizes, redaction statistics, and latency. +func (l *Logger) RecordAICall( + provider, model string, + tokens int, + promptSize, responseSize int, + redactions RedactSummary, + durationMs int64, +) { + l.Record("ai/analyzer", EventAICall, AICallDetails{ + Provider: provider, + Model: model, + Tokens: tokens, + PromptSize: promptSize, + ResponseSize: responseSize, + Redactions: redactions, + DurationMs: durationMs, + }) +} + +// RecordBPFLoad emits a bpf.load audit record. loadErr may be nil on success. +func (l *Logger) RecordBPFLoad(program string, success bool, loadErr error) { + d := BPFLoadDetails{Program: program, Success: success} + if loadErr != nil { + d.Error, _ = Redact(loadErr.Error()) + } + l.Record("bpf/loader", EventBPFLoad, d) +} + +// RecordAuthFailure emits an auth.failure record. remoteIP must already be +// redacted by the caller (e.g. via RedactRemoteAddr) before being passed here. +func (l *Logger) RecordAuthFailure(endpoint, reason, remoteIP string) { + l.Record("http/server", EventAuthFailure, AuthFailureDetails{ + Endpoint: endpoint, + Reason: reason, + RemoteIP: remoteIP, + }) +} + +// RecordFindingEmit emits a finding.emit audit record. payloadHash should be +// produced by HashPayload; the raw finding payload is never written. +func (l *Logger) RecordFindingEmit(rule, severity, sink, payloadHash, cycleID string) { + l.Record("doctor/engine", EventFindingEmit, FindingEmitDetails{ + Rule: rule, + Severity: severity, + Sink: sink, + PayloadHash: payloadHash, + CycleID: cycleID, + }) +} + +// RecordConfigReload emits a config.reload audit record. path is redacted +// automatically. restartRequired is the count of changed fields that require +// a daemon restart to take effect; Applied is derived as +// len(changedFields) - restartRequired. +func (l *Logger) RecordConfigReload(path, source string, changedFields []string, restartRequired int, uid int) { + l.Record(fmt.Sprintf("config/%s", source), EventConfigReload, ConfigReloadDetails{ + Path: RedactPath(path), + ChangedFields: changedFields, + Applied: len(changedFields) - restartRequired, + RestartRequired: restartRequired, + Source: source, + UID: uid, + }) +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go new file mode 100644 index 0000000..072dcb3 --- /dev/null +++ b/internal/audit/audit_test.go @@ -0,0 +1,670 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +package audit_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/optiqor/kerno/internal/audit" +) + +// Redact: PID +func TestRedact_PID(t *testing.T) { + tests := []struct { + name string + input string + wantPIDs int + wantAbsent string + wantPresent string + }{ + { + name: "pid equals", + input: "process pid=1234 crashed", + wantPIDs: 1, + wantAbsent: "1234", + wantPresent: "pid=", + }, + { + name: "pid colon", + input: "PID: 99 killed", + wantPIDs: 1, + wantAbsent: "99", + wantPresent: "pid=", + }, + { + name: "multiple pids", + input: "pid=10 and pid=20 racing", + wantPIDs: 2, + wantAbsent: "pid=10", + wantPresent: "pid=", + }, + { + name: "no pid", + input: "nothing sensitive here", + wantPIDs: 0, + }, + // Regression: "pid count 99" must NOT match — only pid= and pid: forms. + { + name: "pid word boundary no match", + input: "the pid count is 99", + wantPIDs: 0, + }, + // Regression: ensure pid= token does not re-match on second pass. + { + name: "already redacted token is stable", + input: "pid=", + wantPIDs: 0, + wantPresent: "pid=", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, sum := audit.Redact(tc.input) + if sum.PIDs != tc.wantPIDs { + t.Errorf("PIDs: want %d got %d (output: %q)", tc.wantPIDs, sum.PIDs, got) + } + if tc.wantPresent != "" && !strings.Contains(got, tc.wantPresent) { + t.Errorf("want %q in output %q", tc.wantPresent, got) + } + if tc.wantAbsent != "" && strings.Contains(got, tc.wantAbsent) { + t.Errorf("PII %q must NOT appear in output %q", tc.wantAbsent, got) + } + }) + } +} + +// Redact: IP + +func TestRedact_IP(t *testing.T) { + tests := []struct { + name string + input string + wantIPs int + wantAbsent string + }{ + {"single ip", "connected to 192.168.1.100", 1, "192.168.1.100"}, + {"two ips", "src=10.0.0.1 dst=10.0.0.2", 2, "10.0.0.1"}, + {"loopback", "listening on 127.0.0.1:8080", 1, "127.0.0.1"}, + {"no ip", "no addresses here", 0, ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, sum := audit.Redact(tc.input) + if sum.IPs != tc.wantIPs { + t.Errorf("IPs: want %d got %d", tc.wantIPs, sum.IPs) + } + if tc.wantAbsent != "" && strings.Contains(got, tc.wantAbsent) { + t.Errorf("IP %q must not appear in %q", tc.wantAbsent, got) + } + if tc.wantIPs > 0 && !strings.Contains(got, "") { + t.Errorf("want placeholder in %q", got) + } + }) + } +} + +// Redact: path + +func TestRedact_Path(t *testing.T) { + tests := []struct { + name string + input string + wantPaths int + wantAbsent string + wantPresent string + }{ + {"etc path", "reading /etc/kerno/config.yaml", 1, "/etc/kerno/config.yaml", "/etc/"}, + {"var log path", "writing /var/log/kerno-audit.jsonl", 1, "/var/log/kerno-audit.jsonl", ""}, + {"home path", "found /home/ubuntu/.ssh/id_rsa", 1, "/home/ubuntu/.ssh/id_rsa", ""}, + {"no path", "no filesystem paths here", 0, "", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, sum := audit.Redact(tc.input) + if sum.Paths != tc.wantPaths { + t.Errorf("Paths: want %d got %d", tc.wantPaths, sum.Paths) + } + if tc.wantAbsent != "" && strings.Contains(got, tc.wantAbsent) { + t.Errorf("path %q must not appear in %q", tc.wantAbsent, got) + } + if tc.wantPresent != "" && !strings.Contains(got, tc.wantPresent) { + t.Errorf("want prefix %q in %q", tc.wantPresent, got) + } + }) + } +} + +// Redact: idempotency + +func TestRedact_Idempotent(t *testing.T) { + input := "pid=42 connected from 10.0.0.1 reading /etc/passwd" + first, _ := audit.Redact(input) + second, sum2 := audit.Redact(first) + + if first != second { + t.Errorf("redact is not idempotent:\nfirst: %q\nsecond: %q", first, second) + } + if sum2.PIDs != 0 || sum2.IPs != 0 { + t.Errorf("second pass found residual PII: %+v", sum2) + } +} + +func TestRedact_NoPIILeak(t *testing.T) { + sensitive := []string{"192.168.1.1", "10.0.0.50", "/etc/shadow", "/home/alice/.ssh"} + input := "pid=1 ip=192.168.1.1 and 10.0.0.50 file=/etc/shadow key=/home/alice/.ssh/id_rsa" + got, _ := audit.Redact(input) + for _, s := range sensitive { + if strings.Contains(got, s) { + t.Errorf("PII %q leaked into redacted output: %q", s, got) + } + } +} + +// RedactPath + +func TestRedactPath(t *testing.T) { + cases := map[string]string{ + "/etc/kerno/config.yaml": "/etc/", + "/var/log/kerno-audit.jsonl": "/var/", + "/home/alice/.bash_history": "/home/", + "relative/path": "", + "/": "", + } + for in, want := range cases { + got := audit.RedactPath(in) + if got != want { + t.Errorf("RedactPath(%q) = %q, want %q", in, got, want) + } + } +} + +// RedactRemoteAddr + +func TestRedactRemoteAddr(t *testing.T) { + cases := map[string]string{ + // IPv4 TCP + "192.168.1.1:54321": "", + "10.0.0.5:8080": "", + // IPv6 TCP — net.SplitHostPort handles bracket stripping + "[::1]:9090": "", + "[::ffff:1.2.3.4]:443": "", + // Unix domain socket — net.SplitHostPort fails → path-redacted + "/var/run/app.sock": "", + } + for in, want := range cases { + got := audit.RedactRemoteAddr(in) + if got != want { + t.Errorf("RedactRemoteAddr(%q) = %q, want %q", in, got, want) + } + } +} + +// HashPayload + +func TestHashPayload_Deterministic(t *testing.T) { + h1 := audit.HashPayload("rule=oom severity=critical") + h2 := audit.HashPayload("rule=oom severity=critical") + if h1 != h2 { + t.Errorf("hash not deterministic: %q != %q", h1, h2) + } +} + +func TestHashPayload_Different(t *testing.T) { + h1 := audit.HashPayload("rule=oom severity=critical") + h2 := audit.HashPayload("rule=tcp severity=warning") + if h1 == h2 { + t.Errorf("different inputs produced same hash: %q", h1) + } +} + +func TestHashPayload_Length(t *testing.T) { + h := audit.HashPayload("anything") + if len(h) != 16 { + t.Errorf("hash length: want 16, got %d (%q)", len(h), h) + } +} + +// Logger helpers + +func captureLogger(t *testing.T) (*audit.Logger, *bytes.Buffer) { + t.Helper() + var buf bytes.Buffer + l, err := audit.NewWithWriter(&buf) + if err != nil { + t.Fatalf("audit.NewWithWriter: %v", err) + } + return l, &buf +} + +// Logger: schema + +func TestLogger_RecordSchema(t *testing.T) { + l, buf := captureLogger(t) + + l.Record("kerno/start", audit.EventDaemonStart, audit.DaemonDetails{Version: "v0.1.0"}) + + var rec audit.Record + if err := json.NewDecoder(buf).Decode(&rec); err != nil { + t.Fatalf("decode audit record: %v", err) + } + if rec.SchemaVersion != "1" { + t.Errorf("SchemaVersion: want 1, got %q", rec.SchemaVersion) + } + if rec.Type != audit.EventDaemonStart { + t.Errorf("Type: want %q, got %q", audit.EventDaemonStart, rec.Type) + } + if rec.Actor != "kerno/start" { + t.Errorf("Actor: want kerno/start, got %q", rec.Actor) + } + if rec.Timestamp.IsZero() { + t.Error("Timestamp must not be zero") + } + if rec.Timestamp.Location() != time.UTC { + t.Error("Timestamp must be UTC") + } +} + +// Logger: ai.call + +func TestLogger_RecordAICall(t *testing.T) { + l, buf := captureLogger(t) + + l.RecordAICall("anthropic", "claude-sonnet-4-20250514", 412, 800, 200, + audit.RedactSummary{PIDs: 3, IPs: 1, Paths: 0}, 854) + + var rec audit.Record + if err := json.NewDecoder(buf).Decode(&rec); err != nil { + t.Fatalf("decode: %v", err) + } + if rec.Type != audit.EventAICall { + t.Errorf("Type: want ai.call, got %q", rec.Type) + } + + raw, _ := json.Marshal(rec.Details) + var d audit.AICallDetails + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatalf("unmarshal AICallDetails: %v", err) + } + if d.Provider != "anthropic" { + t.Errorf("Provider: want anthropic, got %q", d.Provider) + } + if d.Tokens != 412 { + t.Errorf("Tokens: want 412, got %d", d.Tokens) + } + if d.Redactions.PIDs != 3 { + t.Errorf("Redactions.PIDs: want 3, got %d", d.Redactions.PIDs) + } +} + +// Logger: bpf.load + +func TestLogger_RecordBPFLoad_Failure(t *testing.T) { + l, buf := captureLogger(t) + + l.RecordBPFLoad("syscall_latency", false, testError("permission denied")) + + var rec audit.Record + if err := json.NewDecoder(buf).Decode(&rec); err != nil { + t.Fatalf("decode: %v", err) + } + raw, _ := json.Marshal(rec.Details) + var d audit.BPFLoadDetails + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatalf("unmarshal BPFLoadDetails: %v", err) + } + if d.Success { + t.Error("Success must be false for a failure event") + } + if d.Error == "" { + t.Error("Error must be non-empty for a failure event") + } +} + +func TestLogger_RecordBPFLoad_Success(t *testing.T) { + l, buf := captureLogger(t) + l.RecordBPFLoad("tcp_monitor", true, nil) + + var rec audit.Record + if err := json.NewDecoder(buf).Decode(&rec); err != nil { + t.Fatalf("decode: %v", err) + } + raw, _ := json.Marshal(rec.Details) + var d audit.BPFLoadDetails + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatalf("unmarshal BPFLoadDetails: %v", err) + } + if !d.Success { + t.Error("Success must be true") + } + if d.Error != "" { + t.Errorf("Error must be empty on success, got %q", d.Error) + } +} + +// Logger: auth.failure + +func TestLogger_RecordAuthFailure(t *testing.T) { + l, buf := captureLogger(t) + l.RecordAuthFailure("/metrics", "token_mismatch", "") + + var rec audit.Record + if err := json.NewDecoder(buf).Decode(&rec); err != nil { + t.Fatalf("decode: %v", err) + } + if rec.Type != audit.EventAuthFailure { + t.Errorf("Type: want auth.failure, got %q", rec.Type) + } +} + +// Logger: finding.emit + +func TestLogger_RecordFindingEmit(t *testing.T) { + l, buf := captureLogger(t) + hash := audit.HashPayload(`{"rule":"oom","severity":"critical"}`) + l.RecordFindingEmit("oom_pressure", "CRITICAL", "slack", hash, "cycle-abc123") + + var rec audit.Record + if err := json.NewDecoder(buf).Decode(&rec); err != nil { + t.Fatalf("decode: %v", err) + } + if rec.Type != audit.EventFindingEmit { + t.Errorf("Type: want finding.emit, got %q", rec.Type) + } + raw, _ := json.Marshal(rec.Details) + var d audit.FindingEmitDetails + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatalf("unmarshal FindingEmitDetails: %v", err) + } + if d.PayloadHash == "" { + t.Error("PayloadHash must not be empty") + } + if d.CycleID != "cycle-abc123" { + t.Errorf("CycleID: want cycle-abc123, got %q", d.CycleID) + } +} + +// Logger: daemon.panic + +func TestLogger_RecordPanic(t *testing.T) { + l, buf := captureLogger(t) + + fakeStack := []byte("goroutine 1 [running]:\nmain.main()\n\t/src/main.go:42") + l.RecordPanic("v0.3.1", fakeStack) + + var rec audit.Record + if err := json.NewDecoder(buf).Decode(&rec); err != nil { + t.Fatalf("decode: %v", err) + } + if rec.Type != audit.EventDaemonPanic { + t.Errorf("Type: want daemon.panic, got %q", rec.Type) + } + raw, _ := json.Marshal(rec.Details) + var d audit.DaemonDetails + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatalf("unmarshal DaemonDetails: %v", err) + } + if d.Version != "v0.3.1" { + t.Errorf("Version: want v0.3.1, got %q", d.Version) + } + if len(d.PanicDigest) != 16 { + t.Errorf("PanicDigest length: want 16, got %d (%q)", len(d.PanicDigest), d.PanicDigest) + } + // Raw stack trace must NOT appear in the audit record. + if strings.Contains(buf.String(), "goroutine 1") { + t.Error("raw stack trace must not appear in audit record") + } +} + +// Logger: config.reload + +func TestLogger_RecordConfigReload_RestartRequired(t *testing.T) { + l, buf := captureLogger(t) + + changed := []string{"log_level", "prometheus.addr", "ai.api_key"} + l.RecordConfigReload("/etc/kerno/config.yaml", "SIGHUP", changed, 1, 0) + + var rec audit.Record + if err := json.NewDecoder(buf).Decode(&rec); err != nil { + t.Fatalf("decode: %v", err) + } + raw, _ := json.Marshal(rec.Details) + var d audit.ConfigReloadDetails + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatalf("unmarshal ConfigReloadDetails: %v", err) + } + if d.RestartRequired != 1 { + t.Errorf("RestartRequired: want 1, got %d", d.RestartRequired) + } + if d.Applied != 2 { // 3 changed - 1 restart_required = 2 applied + t.Errorf("Applied: want 2, got %d", d.Applied) + } + // Path must be redacted. + if d.Path != "/etc/" { + t.Errorf("Path: want /etc/, got %q", d.Path) + } +} + +// NEW: Verify that the actor field follows the "config/" pattern +// for a "file" source (initial load), not just SIGHUP. +func TestLogger_RecordConfigReload_FileSource_Actor(t *testing.T) { + l, buf := captureLogger(t) + + l.RecordConfigReload("/etc/kerno/config.yaml", "file", []string{"log_level"}, 0, 0) + + var rec audit.Record + if err := json.NewDecoder(buf).Decode(&rec); err != nil { + t.Fatalf("decode: %v", err) + } + if rec.Actor != "config/file" { + t.Errorf("Actor: want config/file, got %q", rec.Actor) + } + + raw, _ := json.Marshal(rec.Details) + var d audit.ConfigReloadDetails + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if d.Source != "file" { + t.Errorf("Source: want file, got %q", d.Source) + } + if d.Applied != 1 { + t.Errorf("Applied: want 1, got %d", d.Applied) + } +} + +// Logger: NDJSON validity + +func TestLogger_MultipleRecords_ValidNDJSON(t *testing.T) { + l, buf := captureLogger(t) + + l.Record("kerno/start", audit.EventDaemonStart, audit.DaemonDetails{Version: "v0.1.0"}) + l.RecordBPFLoad("disk_io", true, nil) + l.RecordAICall("openai", "gpt-4o-mini", 200, 500, 100, audit.RedactSummary{}, 300) + + dec := json.NewDecoder(buf) + count := 0 + for dec.More() { + var rec audit.Record + if err := dec.Decode(&rec); err != nil { + t.Fatalf("record %d: decode error: %v", count+1, err) + } + if rec.Type == "" { + t.Errorf("record %d: Type must not be empty", count+1) + } + count++ + } + if count != 3 { + t.Errorf("want 3 NDJSON records, got %d", count) + } +} + +// Logger: PII never reaches ai.call record + +func TestLogger_NoPIIInAIRecord(t *testing.T) { + l, buf := captureLogger(t) + + l.RecordAICall("anthropic", "claude-sonnet-4-20250514", 100, 500, 200, + audit.RedactSummary{PIDs: 1, IPs: 2, Paths: 0}, 123) + + raw := buf.String() + piiStrings := []string{"192.168.", "10.0.0.", "/etc/", "/home/", "pid="} + for _, s := range piiStrings { + if strings.Contains(raw, s) { + t.Errorf("PII %q found in audit record: %q", s, raw) + } + } +} + +// Noop logger + +func TestNoop_DoesNotPanic(t *testing.T) { + l := audit.Noop() + l.Record("actor", audit.EventDaemonStart, nil) + l.RecordBPFLoad("prog", true, nil) + l.RecordAICall("p", "m", 0, 0, 0, audit.RedactSummary{}, 0) + l.RecordAuthFailure("/metrics", "token_mismatch", "") + l.RecordFindingEmit("rule", "CRITICAL", "slack", "", "") + l.RecordConfigReload("/etc/kerno/config.yaml", "SIGHUP", []string{"log_level"}, 0, 0) + l.RecordPanic("v0.1.0", []byte("stack")) +} + +// RedactSummary.Total + +func TestRedactSummary_Total(t *testing.T) { + s := audit.RedactSummary{PIDs: 2, IPs: 3, Paths: 1} + if s.Total() != 6 { + t.Errorf("Total: want 6, got %d", s.Total()) + } +} + +// End-to-end cycle linkage +// +// Verifies the full audit trail described in the schema doc: +// +// bpf.load (program loaded) +// ↓ +// ai.call (analysis performed, same cycle window) +// ↓ +// finding.emit (finding dispatched, carrying cycle_id that links back to ai.call) +// +// The test checks that: +// 1. All three record types are emitted in order. +// 2. The finding.emit record carries the correct cycle_id. +// 3. The payload_hash is 16 hex chars (valid FNV-1a output). +// 4. No PII appears anywhere in the output. +func TestCycleLinkage_EndToEnd(t *testing.T) { + l, buf := captureLogger(t) + + const cycleID = "cycle-20260510-143200" + + // Step 1: eBPF program loaded. + l.RecordBPFLoad("syscall_latency", true, nil) + + // Step 2: AI call for this cycle. + l.RecordAICall( + "anthropic", + "claude-sonnet-4-20250514", + 412, + 1024, + 380, + audit.RedactSummary{PIDs: 3, IPs: 1, Paths: 0}, + 854, + ) + + // Step 3: Finding emitted — carries the cycle_id linking back to the AI call. + payloadHash := audit.HashPayload(`{"rule":"syscall_latency_critical","severity":"CRITICAL"}`) + l.RecordFindingEmit("syscall_latency_critical", "CRITICAL", "slack", payloadHash, cycleID) + + // Decode all three NDJSON records. + type rawRecord struct { + Type string `json:"type"` + Details json.RawMessage `json:"details"` + } + + dec := json.NewDecoder(buf) + var records []rawRecord + for dec.More() { + var r rawRecord + if err := dec.Decode(&r); err != nil { + t.Fatalf("decode: %v", err) + } + records = append(records, r) + } + + if len(records) != 3 { + t.Fatalf("want 3 records, got %d", len(records)) + } + + // Record 0: bpf.load + if records[0].Type != string(audit.EventBPFLoad) { + t.Errorf("records[0].type: want bpf.load, got %q", records[0].Type) + } + var bpfD audit.BPFLoadDetails + if err := json.Unmarshal(records[0].Details, &bpfD); err != nil { + t.Fatalf("unmarshal BPFLoadDetails: %v", err) + } + if !bpfD.Success { + t.Error("bpf.load success must be true") + } + if bpfD.Program != "syscall_latency" { + t.Errorf("bpf.load program: want syscall_latency, got %q", bpfD.Program) + } + + // Record 1: ai.call + if records[1].Type != string(audit.EventAICall) { + t.Errorf("records[1].type: want ai.call, got %q", records[1].Type) + } + var aiD audit.AICallDetails + if err := json.Unmarshal(records[1].Details, &aiD); err != nil { + t.Fatalf("unmarshal AICallDetails: %v", err) + } + if aiD.Tokens != 412 { + t.Errorf("ai.call tokens: want 412, got %d", aiD.Tokens) + } + if aiD.Redactions.PIDs != 3 { + t.Errorf("ai.call redactions.pid: want 3, got %d", aiD.Redactions.PIDs) + } + + // Record 2: finding.emit — must carry the cycle_id + if records[2].Type != string(audit.EventFindingEmit) { + t.Errorf("records[2].type: want finding.emit, got %q", records[2].Type) + } + var fD audit.FindingEmitDetails + if err := json.Unmarshal(records[2].Details, &fD); err != nil { + t.Fatalf("unmarshal FindingEmitDetails: %v", err) + } + if fD.CycleID != cycleID { + t.Errorf("finding.emit cycle_id: want %q, got %q", cycleID, fD.CycleID) + } + if len(fD.PayloadHash) != 16 { + t.Errorf("finding.emit payload_hash length: want 16, got %d (%q)", len(fD.PayloadHash), fD.PayloadHash) + } + if fD.Severity != "CRITICAL" { + t.Errorf("finding.emit severity: want CRITICAL, got %q", fD.Severity) + } + if fD.Sink != "slack" { + t.Errorf("finding.emit sink: want slack, got %q", fD.Sink) + } + + // No PII anywhere in the raw NDJSON output. + raw := buf.String() + for _, pii := range []string{"192.168.", "10.0.", "/etc/", "/home/", "pid="} { + if strings.Contains(raw, pii) { + t.Errorf("PII %q leaked into audit output", pii) + } + } +} + +// helpers + +type testError string + +func (e testError) Error() string { return string(e) } diff --git a/internal/audit/redact.go b/internal/audit/redact.go new file mode 100644 index 0000000..3cbd367 --- /dev/null +++ b/internal/audit/redact.go @@ -0,0 +1,136 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +package audit + +import ( + "fmt" + "net" + "regexp" + "strings" +) + +// RedactSummary counts how many items of each category were stripped. +type RedactSummary struct { + PIDs int `json:"pid"` + IPs int `json:"ip"` + Paths int `json:"path"` +} + +// Total returns the sum of all redacted item counts. +func (r RedactSummary) Total() int { + return r.PIDs + r.IPs + r.Paths +} + +var ( + // rePID matches "pid=1234" and "PID: 99" — colon or equals, optional + // whitespace, then digits. The \s+ form was too broad: "pid count 99" + // would match. We want only the canonical key=value / key: value forms. + rePID = regexp.MustCompile(`(?i)\bpid[=:]\s*\d+\b`) + + // reIP matches bare IPv4 addresses (no port). Word-boundary anchors + // prevent partial matches inside longer numbers. + reIP = regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b`) + + // rePath matches absolute paths under the sensitive top-level dirs + // listed in the schema. At least one character must follow the + // top-level dir so bare "/etc" is not matched (nothing to redact). + rePath = regexp.MustCompile(`(?i)(/(?:etc|home|root|var|tmp|proc|sys|run|opt|usr)/[^\s"',;\])}]+)`) +) + +// Redact strips PIDs, IPv4 addresses, and filesystem paths from s. +// Returns the sanitised string and a summary of what was removed. +// Idempotent: running twice on the same input produces the same result +// because the replacement tokens ("pid=", "", +// "//") do not match any of the three regexes. +func Redact(s string) (string, RedactSummary) { + var sum RedactSummary + + // Order matters: PIDs first, then IPs, then paths. + // Each replacement token is designed not to trigger subsequent passes. + + pidMatches := rePID.FindAllString(s, -1) + sum.PIDs = len(pidMatches) + if sum.PIDs > 0 { + s = rePID.ReplaceAllString(s, "pid=") + } + + ipMatches := reIP.FindAllString(s, -1) + sum.IPs = len(ipMatches) + if sum.IPs > 0 { + s = reIP.ReplaceAllString(s, "") + } + + pathMatches := rePath.FindAllString(s, -1) + sum.Paths = len(pathMatches) + if sum.Paths > 0 { + s = rePath.ReplaceAllStringFunc(s, func(match string) string { + // match looks like "/etc/kerno/config.yaml" + // SplitN with n=3 gives ["", "etc", "kerno/config.yaml"] + parts := strings.SplitN(match, "/", 3) + if len(parts) >= 2 && parts[1] != "" { + return "/" + parts[1] + "/" + } + return "" + }) + } + + return s, sum +} + +// RedactPath reduces a full absolute path to its top-level directory only, +// keeping enough context for a compliance reviewer to identify which +// subsystem owns the file without exposing the exact location. +// +// "/etc/kerno/config.yaml" → "/etc/" +// "/var/log/kerno-audit.jsonl" → "/var/" +// "relative/path" → "" +// "/" → "" +func RedactPath(p string) string { + if !strings.HasPrefix(p, "/") { + return "" + } + parts := strings.SplitN(p, "/", 3) + // parts[0] is always "" (empty string before the leading slash). + // parts[1] is the top-level directory name. + if len(parts) < 2 || parts[1] == "" { + return "" + } + return "/" + parts[1] + "/" +} + +// RedactRemoteAddr redacts an HTTP r.RemoteAddr value so no client IP +// ever reaches the audit log. +// +// Handled formats: +// - "host:port" → "" (TCP/IPv4, e.g. "10.0.0.1:54321") +// - "[::1]:port" → "" (TCP/IPv6) +// - anything else → "" (unix socket or unrecognised) +// +// net.SplitHostPort handles the IPv6 bracket stripping, so we do not need +// to special-case it. +func RedactRemoteAddr(addr string) string { + _, _, err := net.SplitHostPort(addr) + if err != nil { + // Not a valid host:port pair — treat as a unix-domain socket path. + return "" + } + return "" +} + +// HashPayload returns a 16-character lowercase hex fingerprint of payload +// using FNV-1a (64-bit). Non-cryptographic — used only as a stable trace +// identifier to link audit records to payload deliveries without exposing +// the payload content itself. +func HashPayload(payload string) string { + const ( + offset64 uint64 = 14695981039346656037 + prime64 uint64 = 1099511628211 + ) + h := offset64 + for i := 0; i < len(payload); i++ { + h ^= uint64(payload[i]) + h *= prime64 + } + return fmt.Sprintf("%016x", h) +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 5076c11..0924481 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -17,6 +17,7 @@ import ( "github.com/optiqor/kerno/internal/adapter" "github.com/optiqor/kerno/internal/ai" + "github.com/optiqor/kerno/internal/audit" "github.com/optiqor/kerno/internal/bpf" "github.com/optiqor/kerno/internal/collector" "github.com/optiqor/kerno/internal/config" @@ -137,19 +138,28 @@ func runDoctor(ctx context.Context, opts doctorOpts) error { // Resolve thresholds from config. thresholds := cfg.Doctor.Thresholds - // Build optional AI analyzer. + auditLog, err := audit.New(audit.Config{ + FilePath: cfg.Audit.FilePath, + MaxSizeMB: cfg.Audit.MaxSizeMB, + MaxBackups: cfg.Audit.MaxBackups, + Stderr: cfg.Audit.Stderr, + }) + if err != nil { + logger.Warn("audit logger init failed in doctor, using noop", "error", err) + auditLog = audit.Noop() + } + var analyzer doctor.Analyzer if opts.aiEnabled { var err error - analyzer, err = buildAnalyzer(cfg, logger) + analyzer, err = buildAnalyzer(cfg, logger, auditLog) if err != nil { // AI setup failure is non-fatal — warn and continue without AI. logger.Warn("AI analysis unavailable, continuing with rule-based diagnostics", "error", err) } } - // Create the diagnostic engine. - engine := doctor.NewEngine(thresholds, analyzer, logger) + engine := doctor.NewEngine(thresholds, analyzer, auditLog, logger) // Select renderer. var renderer doctor.Renderer @@ -230,10 +240,7 @@ func buildCollectors(ctx context.Context, logger *slog.Logger) collectorBuildRes type loaderRegistration struct { name string enabled bool - // build creates the loader, calls Load() on it, and returns a - // Collector ready to be registered. On Load() failure, returns - // (nil, nil, error) so the caller can log + skip. - build func() (collector.Collector, io.Closer, error) + build func() (collector.Collector, io.Closer, error) } // Captured so we can inject a pod enricher after the registration loop. @@ -373,10 +380,7 @@ func buildCollectors(ctx context.Context, logger *slog.Logger) collectorBuildRes // stays empty and LookupByPath returns ("", "") — a no-op. if cgroupColl != nil { kubeAdapter := adapter.NewKubernetesAdapter(logger) - // Start in a goroutine so a slow or absent Kubelet does not block - // the doctor startup. Enrichment is best-effort: early polls may - // have empty namespace; later polls will have it once the index is built. - go func() { _ = kubeAdapter.Start(ctx) }() //nolint:errcheck // Start always returns nil + go func() { _ = kubeAdapter.Start(ctx) }() cgroupColl.SetEnricher(kubeAdapter) closers = append(closers, func() { kubeAdapter.Stop() }) } @@ -414,7 +418,8 @@ func classifyLoadError(err error) string { } // buildAnalyzer constructs the AI analyzer from configuration. -func buildAnalyzer(c *config.Config, logger *slog.Logger) (doctor.Analyzer, error) { +// auditLog is passed through so every AI call emits an audit record. +func buildAnalyzer(c *config.Config, logger *slog.Logger, auditLog *audit.Logger) (doctor.Analyzer, error) { aiCfg := c.AI // Build the LLM provider. @@ -457,6 +462,7 @@ func buildAnalyzer(c *config.Config, logger *slog.Logger) (doctor.Analyzer, erro Cache: cache, Privacy: privacy, Logger: logger, + AuditLog: auditLog, // wire audit log into analyzer }), nil } @@ -498,7 +504,7 @@ func runDiagnosticCycle( n += snap.Sched.TotalCount } if snap.OOM != nil && snap.OOM.Count > 0 { - n += uint64(snap.OOM.Count) //nolint:gosec // Count is a slice len; non-negative + n += uint64(snap.OOM.Count) //nolint:gosec } if snap.DiskIO != nil { n += snap.DiskIO.TotalReads + snap.DiskIO.TotalWrites + snap.DiskIO.TotalSyncs diff --git a/internal/cli/start.go b/internal/cli/start.go index ad1350f..129e463 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -13,6 +13,7 @@ import ( "net/http" "os" "os/signal" + "runtime/debug" "sync/atomic" "syscall" "time" @@ -21,6 +22,7 @@ import ( "github.com/spf13/cobra" "github.com/optiqor/kerno/internal/adapter" + "github.com/optiqor/kerno/internal/audit" "github.com/optiqor/kerno/internal/bpf" "github.com/optiqor/kerno/internal/config" "github.com/optiqor/kerno/internal/metrics" @@ -55,14 +57,8 @@ func newStartCmd() *cobra.Command { Use: "start", Short: "Start Kerno as a long-running daemon with all collectors", Long: `Start Kerno in daemon mode: loads all eBPF programs, starts collectors, -and exposes Prometheus metrics and an optional web dashboard. - -This is the command used in the Kubernetes DaemonSet and for -long-running observability on standalone servers.`, - Example: ` # Start with Prometheus metrics - sudo kerno start - - # Start with custom Prometheus address +and exposes Prometheus metrics and an optional web dashboard.`, + Example: ` sudo kerno start sudo kerno start --prometheus-addr :9091 # Start with web dashboard @@ -111,9 +107,42 @@ func runStart(ctx context.Context, opts startOpts) error { logger := slog.Default() + // Phase 0: Audit logger + auditLog, err := audit.New(audit.Config{ + FilePath: cfg.Audit.FilePath, + MaxSizeMB: cfg.Audit.MaxSizeMB, + MaxBackups: cfg.Audit.MaxBackups, + Stderr: cfg.Audit.Stderr, + }) + if err != nil { + logger.Error("failed to initialise audit logger; audit events will be lost", "error", err) + auditLog = audit.Noop() + } + + // daemon.panic recovery — stack digest audit log mein, full trace stderr mein. + defer func() { + if r := recover(); r != nil { + stack := debug.Stack() + logger.Error("daemon panic recovered", "panic", r, "stack", string(stack)) + auditLog.RecordPanic(version.Version, stack) + panic(r) // re-panic — process must exit non-zero + } + }() + + // daemon.start — pehli auditable event. + auditLog.Record("kerno/start", audit.EventDaemonStart, audit.DaemonDetails{ + Version: version.Version, + }) + + // daemon.stop — hamesha last auditable event hogi. + defer auditLog.Record("kerno/start", audit.EventDaemonStop, audit.DaemonDetails{ + Version: version.Version, + }) + logger.Info("starting kerno daemon", "prometheus", opts.prometheus, "dashboard", opts.dashboard, + "version", version.Version, ) // SIGINT/SIGTERM trigger graceful shutdown via context cancellation. @@ -145,12 +174,11 @@ func runStart(ctx context.Context, opts startOpts) error { for _, l := range loaders { closer, err := l.Load() if err != nil { - logger.Warn("failed to load eBPF program, skipping", - "program", l.Name(), - "error", err, - ) + auditLog.RecordBPFLoad(l.Name(), false, err) + logger.Warn("failed to load eBPF program, skipping", "program", l.Name(), "error", err) continue } + auditLog.RecordBPFLoad(l.Name(), true, nil) closers = append(closers, func() { _ = closer.Close() }) loadedCount++ logger.Info("loaded eBPF program", "program", l.Name()) diff --git a/internal/config/config.go b/internal/config/config.go index 80f9c1a..cf3dbb7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,34 @@ type Config struct { // Kubernetes configures the K8s adapter for pod enrichment. Kubernetes KubernetesConfig `mapstructure:"kubernetes" json:"kubernetes"` + + // Audit configures the compliance audit log. + Audit AuditConfig `mapstructure:"audit" json:"audit"` +} + +// AuditConfig controls the append-only compliance audit log. +// Audit records are always written to stderr (via the slog "audit" group) +// so journald / systemd captures them without any file-system dependency. +// The file sink is optional but recommended for long-running daemon deployments. +type AuditConfig struct { + // FilePath is the absolute path for the NDJSON audit log file. + // Empty string disables the file sink; stderr remains active. + // Example: "/var/log/kerno-audit.jsonl" + FilePath string `mapstructure:"file_path" json:"filePath"` + + // MaxSizeMB is the maximum file size in megabytes before rotation. + // Rotation is handled by lumberjack; the OS log shipper picks up rotated files. + // Default: 100 MB. + MaxSizeMB int `mapstructure:"max_size_mb" json:"maxSizeMB"` + + // MaxBackups is the number of rotated (old) log files to retain on disk. + // 0 means keep all backups. + MaxBackups int `mapstructure:"max_backups" json:"maxBackups"` + + // Stderr controls whether audit records are also emitted to stderr + // via the slog "audit" group. Defaults to true so systemd / journald + // captures them automatically in daemon mode. + Stderr bool `mapstructure:"stderr" json:"stderr"` } // AIConfig controls the optional AI analysis layer. @@ -109,6 +137,10 @@ type DoctorThresholds struct { type PrometheusConfig struct { Enabled bool `mapstructure:"enabled" json:"enabled"` Addr string `mapstructure:"addr" json:"addr"` + // BearerToken, when non-empty, requires callers of /metrics to supply + // "Authorization: Bearer ". Mismatches are recorded as auth.failure + // audit events. Empty = no authentication (rely on network controls). + BearerToken string `mapstructure:"bearer_token" json:"bearerToken"` } // DashboardConfig controls the embedded web dashboard. @@ -175,6 +207,12 @@ func Default() *Config { Enabled: false, Kubeconfig: "", }, + Audit: AuditConfig{ + FilePath: "/var/log/kerno-audit.jsonl", + MaxSizeMB: 100, + MaxBackups: 0, + Stderr: true, + }, } } @@ -232,5 +270,9 @@ func (c *Config) Validate() error { return fmt.Errorf("dashboard.addr must be set when dashboard is enabled") } + if c.Audit.MaxSizeMB < 0 { + return fmt.Errorf("audit.max_size_mb must be >= 0, got %d", c.Audit.MaxSizeMB) + } + return nil } diff --git a/internal/doctor/engine.go b/internal/doctor/engine.go index 5032d30..c8d082c 100644 --- a/internal/doctor/engine.go +++ b/internal/doctor/engine.go @@ -5,21 +5,19 @@ package doctor import ( "context" + "encoding/json" + "fmt" "log/slog" "os" "runtime" "time" + "github.com/optiqor/kerno/internal/audit" "github.com/optiqor/kerno/internal/collector" "github.com/optiqor/kerno/internal/config" ) -// Analyzer is the optional AI analysis interface. When non-nil, the engine -// calls it after rule evaluation to enrich findings with natural language -// diagnosis, cross-signal correlation, and root cause analysis. -// -// This interface lives here (not in the ai package) to avoid import cycles. -// The ai package implements it. +// Analyzer is the optional AI analysis interface. type Analyzer interface { Analyze(ctx context.Context, req AnalysisRequest) (*AnalysisResponse, error) } @@ -33,23 +31,12 @@ type AnalysisRequest struct { // AnalysisResponse contains AI-generated insights. type AnalysisResponse struct { - // Summary is a plain-English diagnosis paragraph. - Summary string `json:"summary"` - - // Correlations are cross-signal patterns detected by AI. + Summary string `json:"summary"` Correlations []Correlation `json:"correlations,omitempty"` - - // RootCauses are prioritized explanations with fix suggestions. - RootCauses []RootCause `json:"rootCauses,omitempty"` - - // Anomalies are deviations from baseline behavior. - Anomalies []Anomaly `json:"anomalies,omitempty"` - - // TrendSummary describes what's changing over time (continuous mode). - TrendSummary string `json:"trendSummary,omitempty"` - - // TokensUsed tracks LLM token consumption for cost monitoring. - TokensUsed int `json:"tokensUsed"` + RootCauses []RootCause `json:"rootCauses,omitempty"` + Anomalies []Anomaly `json:"anomalies,omitempty"` + TrendSummary string `json:"trendSummary,omitempty"` + TokensUsed int `json:"tokensUsed"` } // Correlation describes a cross-signal pattern. @@ -86,17 +73,23 @@ type Engine struct { analyzer Analyzer logger *slog.Logger + auditLog *audit.Logger history []*collector.Signals maxHistory int } // NewEngine creates a new diagnostic engine. // Pass nil for analyzer to run without AI enrichment. -func NewEngine(thresholds config.DoctorThresholds, analyzer Analyzer, logger *slog.Logger) *Engine { +// Pass audit.Noop() for auditLog to disable audit emission. +func NewEngine(thresholds config.DoctorThresholds, analyzer Analyzer, auditLog *audit.Logger, logger *slog.Logger) *Engine { + if auditLog == nil { + auditLog = audit.Noop() + } return &Engine{ thresholds: thresholds, analyzer: analyzer, logger: logger, + auditLog: auditLog, maxHistory: 10, } } @@ -105,7 +98,7 @@ func NewEngine(thresholds config.DoctorThresholds, analyzer Analyzer, logger *sl func (e *Engine) Diagnose(ctx context.Context, signals *collector.Signals) (*Report, error) { start := time.Now() - // Phase 1: Evaluate deterministic rules. + // Phase 1: Deterministic rule evaluation. findings := Evaluate(signals, e.thresholds) e.logger.Debug("rules evaluated", "findings", len(findings), @@ -129,6 +122,10 @@ func (e *Engine) Diagnose(ctx context.Context, signals *collector.Signals) (*Rep // Phase 3: Build the report. hostname, _ := os.Hostname() + + // cycleID links this Diagnose run to all its audit records. + cycleID := fmt.Sprintf("cycle-%s", signals.Timestamp.UTC().Format("20060102-150405")) + report := &Report{ Hostname: hostname, KernelVer: signals.Host.KernelVer, @@ -151,12 +148,41 @@ func (e *Engine) Diagnose(ctx context.Context, signals *collector.Signals) (*Rep report.EventsCollected += signals.Sched.TotalCount } - // Phase 4: Append to the history ring buffer. + // Phase 4: Emit finding.emit audit records for every WARNING/CRITICAL finding. + // INFO findings skipped — too noisy for compliance log. + for i := range findings { + f := &findings[i] + if f.Severity < SeverityWarning { + continue + } + e.auditLog.RecordFindingEmit( + f.Rule, + f.Severity.String(), + "log", + hashFinding(f), + cycleID, + ) + } + + // Phase 5: Append to history ring buffer. e.appendHistory(signals) return report, nil } +// EmitFinding audit-logs a single finding being dispatched to a named sink +// (e.g. "slack", "pagerduty"). Call this from the sink dispatcher after +// Diagnose() so the audit record carries the real sink name. +func (e *Engine) EmitFinding(f *Finding, sink, cycleID string) { + e.auditLog.RecordFindingEmit( + f.Rule, + f.Severity.String(), + sink, + hashFinding(f), + cycleID, + ) +} + func (e *Engine) appendHistory(signals *collector.Signals) { e.history = append(e.history, signals) if len(e.history) > e.maxHistory { @@ -164,6 +190,24 @@ func (e *Engine) appendHistory(signals *collector.Signals) { } } +// hashFinding returns a 16-char FNV-1a fingerprint of the finding's +// non-PII fields for payload traceability. +func hashFinding(f *Finding) string { + payload, err := json.Marshal(struct { + Rule string `json:"rule"` + Severity string `json:"severity"` + Title string `json:"title"` + }{ + Rule: f.Rule, + Severity: f.Severity.String(), + Title: f.Title, + }) + if err != nil { + return "hash-error" + } + return audit.HashPayload(string(payload)) +} + // hasActionableFindings returns true if there is at least one WARNING or // CRITICAL finding — the threshold below which AI enrichment is not worthwhile. func hasActionableFindings(findings []Finding) bool { diff --git a/internal/doctor/engine_ai_failure_test.go b/internal/doctor/engine_ai_failure_test.go index 01f3c8b..f15bff3 100644 --- a/internal/doctor/engine_ai_failure_test.go +++ b/internal/doctor/engine_ai_failure_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/optiqor/kerno/internal/audit" "github.com/optiqor/kerno/internal/collector" "github.com/optiqor/kerno/internal/config" ) @@ -34,7 +35,7 @@ func TestEngineDiagnoseContinuesWhenAnalyzerFails(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - engine := NewEngine(config.Default().Doctor.Thresholds, failingAnalyzer{}, logger) + engine := NewEngine(config.Default().Doctor.Thresholds, failingAnalyzer{}, audit.Noop(), logger) report, err := engine.Diagnose(context.Background(), signals) if err != nil {