sorcery-go/pkg/warding/audit.go

222 lines
8.4 KiB
Go
Executable File

// eBPF violation event watcher.
//
// The Warding monitors security violations from the eBPF Tomb Guard
// program via a perf event buffer. When the eBPF LSM hook detects a
// write attempt on a protected path (Tomb, State DB), it emits a
// violation event that this code receives and converts into Warding
// Alarms.
//
// This replaces the former AppArmor / auditd log watcher which parsed
// /var/log/audit/audit.log for "apparmor=DENIED" strings. The eBPF
// approach is faster (in-kernel, no log parsing), more precise
// (per-syscall, not per-audit-message), and works uniformly across
// LXC, Firecracker, and Podman containers.
//
// Architecture:
//
// Kernel (eBPF LSM) Userspace (Go)
// ───────────────── ───────────────
// tomb_guard.bpf.o audit.go
// ├─ file_permission hook ───────► WatchViolations()
// ├─ inode_permission hook ├─ perf event reader
// └─ trace_execve audit trail ├─ → Alarm{Severity: "taint"}
// └─ → eventbus.Publish("warding")
package warding
import (
"fmt"
"os"
"strings"
"time"
)
// WatchViolations starts watching eBPF violation events from the Tomb Guard
// program. Each violation becomes an Alarm of severity "taint" — the Cockpit
// Threat Map renders them in real time via WebSocket.
//
// This method replaces the former WatchAuditLogs which parsed AppArmor log
// entries. It uses the eBPF enforcer's perf buffer for real-time event
// delivery, eliminating the polling delay and log-rotation edge cases.
//
// Blocks until `stop` is closed.
func (w *Warding) WatchViolations(stop <-chan struct{}) {
if w.ebpf == nil {
// eBPF not available — fall back to no-op with a warning.
w.soundAlarm(Alarm{
Severity: "taint",
Message: "eBPF enforcer not loaded — Tomb Guard violation monitoring unavailable",
})
<-stop
return
}
// Forward eBPF violations to Warding alarms.
handler := func(v EBPFViolation) {
// Only report write-related violations (the eBPF program sends
// both exec audit events and file access violations).
// Skip if NOT a write (bit 1 of AccessMask) AND NOT execve (syscall 59).
if v.AccessMask&2 == 0 && v.SyscallNr != 59 {
return
}
// Filter: only care about sorcery-related paths.
if !strings.Contains(v.Path, "sorcery") && !strings.Contains(v.Path, "tomb") {
return
}
severity := "taint"
if v.SyscallNr == 59 {
severity = "audit" // exec events are audit, not taint
}
w.soundAlarm(Alarm{
Severity: severity,
Message: fmt.Sprintf("eBPF DENIED [%s] pid=%d comm=%s path=%s", syscallName(v.SyscallNr), v.PID, v.Comm, v.Path),
Node: fmt.Sprintf("pid:%d", v.PID),
})
}
if err := w.ebpf.WatchViolations(handler); err != nil {
w.soundAlarm(Alarm{
Severity: "taint",
Message: "eBPF violation watch failed: " + err.Error(),
})
<-stop
return
}
<-stop
w.ebpf.StopWatching()
}
// WatchAuditLogs is preserved for backward compatibility and as a fallback
// when eBPF is not available. It tails the audit log for any remaining
// kernel-level denials that the eBPF program might not catch (e.g., from
// other MAC systems still on the host).
//
// Deprecated: Use WatchViolations for eBPF-based monitoring.
func (w *Warding) WatchAuditLogs(path string, startingPos int64, stop <-chan struct{}) {
if path == "" {
path = "/var/log/audit/audit.log"
}
pos := startingPos
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
newPos, events := tailAuditLog(path, pos)
pos = newPos
for _, line := range events {
if !strings.Contains(line, "DENIED") && !strings.Contains(line, "denied") {
continue
}
if !strings.Contains(line, "/var/lib/sorcery") {
continue
}
source := "kernel"
if strings.Contains(line, "apparmor") {
source = "AppArmor (legacy)"
} else if strings.Contains(line, "selinux") {
source = "SELinux"
} else if strings.Contains(line, "ebpf") {
source = "eBPF"
}
w.soundAlarm(Alarm{
Severity: "taint",
Message: fmt.Sprintf("%s DENIED: %s", source, extractAuditField(line, "name")),
Node: extractAuditField(line, "comm"),
})
}
}
}
}
// tailAuditLog reads new bytes from an audit log file starting at pos.
func tailAuditLog(path string, pos int64) (int64, []string) {
f, err := os.Open(path)
if err != nil {
return pos, nil
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return pos, nil
}
if info.Size() < pos {
// File was rotated — start from the beginning.
pos = 0
}
if info.Size() == pos {
return pos, nil
}
if _, err := f.Seek(pos, 0); err != nil {
return pos, nil
}
buf := make([]byte, info.Size()-pos)
n, err := f.Read(buf)
if err != nil && n == 0 {
return pos, nil
}
newPos := pos + int64(n)
lines := strings.Split(string(buf[:n]), "\n")
// Remove trailing empty string from split.
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return newPos, lines
}
// syscallNameMap maps common syscall numbers to names.
// Package-level to avoid realloc on every call.
var syscallNameMap = map[uint32]string{
2: "open", 3: "close", 59: "execve",
257: "openat", 262: "newfstatat", 281: "execveat",
}
// syscallName returns a human-readable name for common syscall numbers.
func syscallName(nr uint32) string {
if name, ok := syscallNameMap[nr]; ok {
return name
}
return fmt.Sprintf("sys_%d", nr)
}
// EBPFViolation is an alias for the eBPF package's Violation type,
// used to decouple the warding package from the eBPF package at the
// type level while keeping the interface clean.
type EBPFViolation = ebpfViolation
// ebpfViolation mirrors the eBPF Violation struct.
// In production this would reference pkg/warding/ebpf.Violation directly,
// but we define it here to avoid a build dependency on the generated code.
type ebpfViolation struct {
PID uint32
TID uint32
UID uint32
GID uint32
PPID uint32
SyscallNr uint32
Comm string
Path string
AccessMask int32
}
// extractAuditField extracts a key=value field from an audit log line.
// Falls back to "unknown" if the field is not found.
func extractAuditField(line, key string) string {
needle := key + "="
start := strings.Index(line, needle)
if start == -1 {
return "unknown"
}
val := line[start+len(needle):]
if end := strings.Index(val, " "); end != -1 {
val = val[:end]
}
// Strip surrounding quotes.
val = strings.Trim(val, `"'`)
return val
}