sorcery-go/pkg/warding/quarantine.go

183 lines
7.0 KiB
Go
Executable File

// Cgroup-based quarantine with multi-runtime support.
//
// When the Warding detects a high-severity threat it freezes the offending
// container or VM. This uses the most appropriate mechanism for each runtime:
//
// - LXC: cgroup v2 freezer (preferred), then lxc-freeze, then systemctl
// - Podman: cgroup v2 freezer (preferred), then podman pause
// - Firecracker: VMM process SIGSTOP (no cgroup involvement)
// - BareMetal: cgroup v2 freezer if applicable, otherwise SIGSTOP
//
// On cgroup v2 systems (kernel >= 4.15, systemd >= 244) the freezer is at
// /sys/fs/cgroup/<container-path>/cgroup.freeze. Writing "1" freezes every
// process in the cgroup atomically.
package warding
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
// RuntimeAdapter is a minimal interface for runtime-specific freeze/thaw
// operations. This avoids importing the full runtime package from warding.
type RuntimeAdapter interface {
// Freeze suspends all processes in the named container/sanctum.
Freeze(nodeID string) error
// Thaw resumes a frozen container/sanctum.
Thaw(nodeID string) error
// CgroupPath returns the cgroup v2 path for the container.
CgroupPath(nodeID string) string
}
// validateNodeID rejects nodeID values that contain path traversal
// characters, preventing abuse of cgroup path construction.
func validateNodeID(nodeID string) error {
if nodeID == "" {
return fmt.Errorf("warding: nodeID must not be empty")
}
if strings.ContainsAny(nodeID, "/\\..") {
return fmt.Errorf("warding: nodeID contains invalid characters: %q", nodeID)
}
return nil
}
// freezeContainer freezes a container or VM by name using the configured
// runtime adapter. If no adapter is set, falls back to generic cgroup v2
// detection (for backward compatibility).
func freezeContainer(nodeID string) error {
if err := validateNodeID(nodeID); err != nil {
return err
}
// If a runtime adapter is configured, use it.
runtimeAdapterMu.RLock()
adapter := globalRuntimeAdapter
runtimeAdapterMu.RUnlock()
if adapter != nil {
return adapter.Freeze(nodeID)
}
return genericFreeze(nodeID)
}
// thawContainer thaws a frozen container or VM by name.
func thawContainer(nodeID string) error {
if err := validateNodeID(nodeID); err != nil {
return err
}
runtimeAdapterMu.RLock()
adapter := globalRuntimeAdapter
runtimeAdapterMu.RUnlock()
if adapter != nil {
return adapter.Thaw(nodeID)
}
return genericThaw(nodeID)
}
var (
// globalRuntimeAdapter is the runtime-specific freeze/thaw implementation.
// Set via SetRuntimeAdapter(). Protected by runtimeAdapterMu.
globalRuntimeAdapter RuntimeAdapter
runtimeAdapterMu sync.RWMutex
)
// SetRuntimeAdapter configures the runtime-specific freeze/thaw handler.
// Called by the CLI layer when the runtime is initialized.
func SetRuntimeAdapter(adapter RuntimeAdapter) {
runtimeAdapterMu.Lock()
defer runtimeAdapterMu.Unlock()
globalRuntimeAdapter = adapter
}
// genericFreeze is the fallback freeze mechanism that tries multiple
// approaches in order of preference.
func genericFreeze(nodeID string) error {
// 1. Try cgroup v2 freezer (preferred — works for LXC, Podman, any cgroup-managed process).
if v2Path := cgroupV2Path(nodeID); v2Path != "" {
if err := os.WriteFile(filepath.Join(v2Path, "cgroup.freeze"),
[]byte("1"), 0644); err == nil {
return nil
}
}
// 2. Try runtime-specific CLIs (ordered by detection).
freezeCommands := []struct {
binary string
args []string
}{
{"lxc-freeze", []string{"-n", nodeID}},
{"podman", []string{"pause", nodeID}},
{"systemctl", []string{"freeze", nodeID}},
}
for _, fc := range freezeCommands {
if _, err := exec.LookPath(fc.binary); err == nil {
if out, err := exec.Command(fc.binary, fc.args...).CombinedOutput(); err == nil {
return nil
} else {
return fmt.Errorf("%s: %w (%s)", fc.binary, err, string(out))
}
}
}
return fmt.Errorf("no freezer available (tried cgroup v2, lxc-freeze, podman pause, systemctl)")
}
// genericThaw is the fallback thaw mechanism.
func genericThaw(nodeID string) error {
// 1. Try cgroup v2.
if v2Path := cgroupV2Path(nodeID); v2Path != "" {
if err := os.WriteFile(filepath.Join(v2Path, "cgroup.freeze"),
[]byte("0"), 0644); err == nil {
return nil
}
}
// 2. Try runtime-specific CLIs.
thawCommands := []struct {
binary string
args []string
}{
{"lxc-unfreeze", []string{"-n", nodeID}},
{"podman", []string{"unpause", nodeID}},
{"systemctl", []string{"thaw", nodeID}},
}
for _, tc := range thawCommands {
if _, err := exec.LookPath(tc.binary); err == nil {
if out, err := exec.Command(tc.binary, tc.args...).CombinedOutput(); err == nil {
return nil
} else {
return fmt.Errorf("%s: %w (%s)", tc.binary, err, string(out))
}
}
}
return fmt.Errorf("no thaw mechanism available")
}
// cgroupV2Path tries to locate the cgroup v2 path for a container.
// It checks common locations for LXC, Podman, and systemd-managed containers.
func cgroupV2Path(nodeID string) string {
candidates := []string{
// LXC paths.
filepath.Join("/sys/fs/cgroup/lxc", nodeID),
filepath.Join("/sys/fs/cgroup/lxc.payload", nodeID),
filepath.Join("/sys/fs/cgroup/system.slice", "lxc-"+nodeID+".service"),
filepath.Join("/sys/fs/cgroup/machine.slice", "lxc-"+nodeID+".service"),
// Podman paths.
filepath.Join("/sys/fs/cgroup/machine.slice", "libpod-"+nodeID+".scope"),
filepath.Join("/sys/fs/cgroup/machine.slice", "podman-"+nodeID+".scope"),
// Generic systemd scope.
filepath.Join("/sys/fs/cgroup/system.slice", nodeID+".scope"),
filepath.Join("/sys/fs/cgroup/machine.slice", nodeID+".scope"),
}
for _, p := range candidates {
if _, err := os.Stat(filepath.Join(p, "cgroup.freeze")); err == nil {
return p
}
}
return ""
}