335 lines
12 KiB
Go
Executable File
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,
|
|
})
|
|
}
|
|
} |