sorcery-go/pkg/warding/warding.go

335 lines
12 KiB
Go
Executable File

// Package warding is the security boundary of the Coven.
//
// "The Eye of the Ward" watches for Taint (unauthorized file modifications)
// via the eBPF Tomb Guard LSM program. The in-kernel eBPF program intercepts
// write syscalls to protected paths (/var/lib/sorcery-go/tomb/** and
// /var/lib/sorcery-go/state/**) and emits violation events to userspace
// via a perf event buffer.
//
// Node admission is controlled at the network firewall layer (OPNsense / IPFire).
// "Warp-Link Blocking" integrates with OpenSnitch or Portmaster to drop
// unsanctioned Essence-sync traffic.
//
// Security architecture (replacing the former AppArmor-based system):
//
// Layer 1: eBPF LSM (Tomb Guard) — in-kernel MAC enforcement
// Layer 2: eBPF cgroup filters — device + network control
// Layer 3: Network firewall — OPNsense / IPFire isolates the Coven
// Layer 4: Network gatekeeping — OpenSnitch / Portmaster
// Layer 5: Integrity verification — Merkle root + per-blob hashing
// Layer 6: Quarantine — cgroup freezer (multi-runtime)
//
// On boot, the Warding runs Inspect() on every Essence a Sanctum tries to
// reanimate. If the Merkle root computed from the on-disk bytes does not
// match the recorded EssenceID, the Reanimation is rejected and an alarm
// is broadcast to all connected nodes.
//
// The Warding also verifies PGP signatures on DETAILS files before the
// Cauldron sources them — see pgp.go.
package warding
import (
"fmt"
"sync"
"time"
"dcos.net/sorcery-go/pkg/eventbus"
)
// TombReader is the read interface warding needs from the Tomb. Kept small
// to avoid an import cycle.
type TombReader interface {
VerifyRoot(essenceID string) error
VerifyBlobs(essenceID string) error
}
// EBPFEnforcer is the interface for the eBPF security enforcement layer.
// This allows the warding package to use the eBPF enforcer without a
// direct dependency on the cilium/ebpf library (which is platform-specific).
type EBPFEnforcer interface {
// Load compiles and loads eBPF programs into the kernel.
Load() error
// AttachCgroup attaches cgroup filter programs.
AttachCgroup(cgroupPath string) error
// SetEnforceMode sets permissive (0) or enforcing (1).
SetEnforceMode(mode uint32) error
// GetEnforceMode returns the current enforcement mode.
GetEnforceMode() (uint32, error)
// SetTombPath configures the protected Tomb path prefix.
SetTombPath(path string) error
// SetStatePath configures the protected State path prefix.
SetStatePath(path string) error
// AddTrustedPID adds a PID to the enforcement bypass allowlist.
AddTrustedPID(pid uint32) error
// SelfTrustedPID adds the current process to the trusted allowlist.
SelfTrustedPID() error
// WatchViolations starts the perf buffer reader for violation events.
WatchViolations(handler func(Violation)) error
// StopWatching stops the violation event reader.
StopWatching()
// Close releases all kernel resources.
Close()
// IsLoaded reports whether eBPF programs are loaded.
IsLoaded() bool
// IsCgroupAttached reports whether cgroup filters are attached.
IsCgroupAttached() bool
// Status returns a human-readable status summary.
Status() string
}
// Violation represents an eBPF-detected security violation event.
type Violation struct {
PID uint32 `json:"pid"`
TID uint32 `json:"tid"`
UID uint32 `json:"uid"`
GID uint32 `json:"gid"`
PPID uint32 `json:"ppid"`
SyscallNr uint32 `json:"syscall_nr"`
Comm string `json:"comm"`
Path string `json:"path"`
AccessMask int32 `json:"access_mask"`
}
// maxAlarms caps the in-memory alarm ring. When exceeded, oldest
// alarms are trimmed to prevent unbounded memory growth in long-running
// deployments.
const maxAlarms = 10000
// Warding is the central defensive service.
type Warding struct {
mu sync.Mutex
Tomb TombReader
Bus *eventbus.Bus // alarms are published here too
Alarms []Alarm
QuarantineList map[string]bool
ebpf EBPFEnforcer // eBPF enforcement layer (nil if unavailable)
}
// Alarm is one event the Warding has flagged.
type Alarm struct {
Time time.Time `json:"time"`
Severity string `json:"severity"` // "taint" | "egress-block" | "license-violation" | "quarantine" | "banish" | "pgp" | "audit"
Node string `json:"node"`
EssenceID string `json:"essence_id"`
Message string `json:"message"`
}
// New returns a Warding backed by the given Tomb.
func New(t TombReader, bus *eventbus.Bus) *Warding {
return &Warding{
Tomb: t,
Bus: bus,
QuarantineList: make(map[string]bool),
}
}
// NewWithEBPF returns a Warding with eBPF enforcement enabled.
func NewWithEBPF(t TombReader, bus *eventbus.Bus, enforcer EBPFEnforcer) *Warding {
return &Warding{
Tomb: t,
Bus: bus,
QuarantineList: make(map[string]bool),
ebpf: enforcer,
}
}
// InitEBPF loads the eBPF enforcement programs and configures them for
// the given Tomb and State paths. This should be called once at startup.
func (w *Warding) InitEBPF(tombPath, statePath string) error {
if w.ebpf == nil {
return fmt.Errorf("warding: no eBPF enforcer configured")
}
// Load eBPF programs into the kernel.
if err := w.ebpf.Load(); err != nil {
return fmt.Errorf("warding: eBPF load failed: %w", err)
}
// Configure protected paths.
if tombPath != "" {
if err := w.ebpf.SetTombPath(tombPath); err != nil {
return fmt.Errorf("warding: set tomb path: %w", err)
}
}
if statePath != "" {
if err := w.ebpf.SetStatePath(statePath); err != nil {
return fmt.Errorf("warding: set state path: %w", err)
}
}
// Trust the current process.
if err := w.ebpf.SelfTrustedPID(); err != nil {
return fmt.Errorf("warding: trust self PID: %w", err)
}
return nil
}
// AttachEBPF attaches the eBPF cgroup filters to the given cgroup path.
// This enables device whitelisting and network filtering for the cgroup.
func (w *Warding) AttachEBPF(cgroupPath string) error {
if w.ebpf == nil {
return fmt.Errorf("warding: no eBPF enforcer configured")
}
return w.ebpf.AttachCgroup(cgroupPath)
}
// SetEnforceMode controls the eBPF enforcement mode:
// 0 = permissive (log violations but don't block)
// 1 = enforcing (block violations and log them)
func (w *Warding) SetEnforceMode(enforcing bool) error {
if w.ebpf == nil {
return nil
}
mode := uint32(0)
if enforcing {
mode = 1
}
return w.ebpf.SetEnforceMode(mode)
}
// EBPFStatus returns the status of the eBPF enforcement layer.
func (w *Warding) EBPFStatus() string {
if w.ebpf == nil {
return "eBPF: not configured"
}
return w.ebpf.Status()
}
// Inspect verifies the Merkle root AND every blob of an Essence. If either
// check fails the Essence is "Tainted" — the Warding sounds an alarm and
// refuses to allow Reanimation.
func (w *Warding) Inspect(essenceID string) error {
if err := w.Tomb.VerifyRoot(essenceID); err != nil {
w.soundAlarm(Alarm{
Severity: "taint",
EssenceID: essenceID,
Message: "merkle root mismatch: " + err.Error(),
})
return fmt.Errorf("warding: rejected Essence %s: %w", essenceID, err)
}
if err := w.Tomb.VerifyBlobs(essenceID); err != nil {
w.soundAlarm(Alarm{
Severity: "taint",
EssenceID: essenceID,
Message: "blob bit-rot: " + err.Error(),
})
return fmt.Errorf("warding: blob verification failed for %s: %w", essenceID, err)
}
return nil
}
// Quarantine freezes a Sanctum via the configured runtime adapter and severs
// its Essence links so memory-based exploits can't spread. Works across
// LXC, Podman, Firecracker, and baremetal deployments.
func (w *Warding) Quarantine(nodeID string) {
w.mu.Lock()
defer w.mu.Unlock()
w.QuarantineList[nodeID] = true
if err := freezeContainer(nodeID); err != nil {
w.soundAlarmLocked(Alarm{
Severity: "quarantine",
Node: nodeID,
Message: "freeze failed: " + err.Error(),
})
} else {
w.soundAlarmLocked(Alarm{
Severity: "quarantine",
Node: nodeID,
Message: "Sanctum quarantined via cgroup/runtime freeze",
})
}
}
// ThawUnfreeze resumes a previously quarantined Sanctum.
func (w *Warding) ThawUnfreeze(nodeID string) error {
w.mu.Lock()
defer w.mu.Unlock()
delete(w.QuarantineList, nodeID)
return thawContainer(nodeID)
}
// IsQuarantined reports whether a node is currently frozen.
func (w *Warding) IsQuarantined(nodeID string) bool {
w.mu.Lock()
defer w.mu.Unlock()
return w.QuarantineList[nodeID]
}
// AlarmsSince returns all alarms emitted after `t`. The Cockpit "Sanctum"
// heatmap renders these in real time via WebSocket.
func (w *Warding) AlarmsSince(t time.Time) []Alarm {
w.mu.Lock()
defer w.mu.Unlock()
var out []Alarm
for _, a := range w.Alarms {
if a.Time.After(t) {
out = append(out, a)
}
}
return out
}
// Banish quarantines a Sanctum by freezing its container runtime and
// recording the banishment in the Warding alarm log.
func (w *Warding) Banish(nodeID string) {
w.mu.Lock()
defer w.mu.Unlock()
w.QuarantineList[nodeID] = true
freezeErr := freezeContainer(nodeID)
w.soundAlarmLocked(Alarm{
Severity: "quarantine",
Node: nodeID,
Message: "Sanctum quarantined via cgroup/runtime freeze",
})
if freezeErr != nil {
w.soundAlarmLocked(Alarm{
Severity: "quarantine",
Node: nodeID,
Message: "freeze failed: " + freezeErr.Error(),
})
}
w.soundAlarmLocked(Alarm{
Severity: "banish",
Node: nodeID,
Message: "node banished from the Coven",
})
}
// Close cleans up all resources, including the eBPF enforcement layer.
func (w *Warding) Close() {
w.mu.Lock()
defer w.mu.Unlock()
if w.ebpf != nil {
w.ebpf.Close()
}
}
// soundAlarm acquires the lock and sounds an alarm. Use this when the
// caller does NOT already hold w.mu.
func (w *Warding) soundAlarm(a Alarm) {
w.mu.Lock()
defer w.mu.Unlock()
w.soundAlarmLocked(a)
}
// soundAlarmLocked appends an alarm and publishes it on the bus.
// The caller MUST already hold w.mu.
func (w *Warding) soundAlarmLocked(a Alarm) {
if a.Time.IsZero() {
a.Time = time.Now()
}
w.Alarms = append(w.Alarms, a)
// Trim oldest alarms when the ring exceeds the cap.
if len(w.Alarms) > maxAlarms {
trimmed := make([]Alarm, maxAlarms)
copy(trimmed, w.Alarms[len(w.Alarms)-maxAlarms:])
w.Alarms = trimmed
}
if w.Bus != nil {
w.Bus.Publish("warding", eventbus.Event{
Type: eventbus.EventAlarm,
Data: a.Severity + ": " + a.Message,
})
}
}