mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat(sandbox): report landlock seccomp denials via pmg sandbox violations The landlock driver's seccomp supervisor already emitted structured deny events over the audit socket, but the driver drained them to io.Discard, so the violation cache was never populated on Linux and violations list / explain always came up empty. Capture the events at the driver, enrich them with access mode and process name, and implement BestEffortViolation mirroring the seatbelt reporter: failure-only collection, seccomp_deny events only, (kind, target) dedupe. The platform-neutral cache/list/explain pipeline picks it up unchanged. Only the seccomp deny-list layer is observable; denials made by the Landlock LSM itself (allow-list boundary, delete/rename, network) fail in-kernel with no userspace signal and are documented as out of scope. Also make the explain renderer driver-neutral: the raw-log label was hardcoded as "Seatbelt log" and an empty correlation ID printed a blank value. * fix: address review findings on landlock violation reporting Report the deny rule that fired, not the requested access: an O_RDWR open denied by a read-only rule now surfaces as a read denial with an effective override suggestion (allow write= prunes only deny_write). The matched rule path is emitted as rule_path and mapped to RuleTarget, bringing the "Matched rule:" line to parity with seatbelt. Dedupe deny events by (kind, path) at capture time so a retry loop on one denied path cannot fill the buffer and evict a later distinct denial; the cap now bounds distinct denials. Stamp deny events with a timestamp (they rendered "ts":0 in the raw log) and default unknown syscalls to generic_deny instead of fs_write. * refactor: single source for the deny dedupe key Capture-time and extract-time dedupe must agree on what identifies a denial; building the key in two places risks them drifting apart. * fix: bound the capture dedupe map by marking keys only on append seen grew for every distinct deny key even after the buffer was full, and keys carry attacker-chosen path bytes — a hostile process looping over crafted unique denied paths could grow the pmg parent's memory for the run's duration, defeating the cap. Marking keys only when the event is appended bounds the map at the cap and keeps the one-time drop warning reachable for distinct denials past it. * docs(sandbox): AppArmor userns fix for the Landlock driver on Ubuntu 23.10+ The shim fails with "install seccomp: ... permission denied" when kernel.apparmor_restrict_unprivileged_userns=1. Document the per-binary AppArmor profile as the recommended fix and the sysctl as the blunt alternative. * docs(sandbox): drop em dashes from the landlock sections * fix(doctor): cover landlock in the AppArmor userns probe The warn detail only named the bwrap failure and the only suggested fix was the system-wide sysctl. Name the landlock shim error too and suggest the per-binary AppArmor profile first, pointing at the new docs section.
188 lines
5.6 KiB
Go
188 lines
5.6 KiB
Go
//go:build linux
|
|
|
|
package platform
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/safedep/dry/log"
|
|
"github.com/safedep/dry/usefulerror"
|
|
"github.com/safedep/dry/utils"
|
|
"github.com/safedep/pmg/errcodes"
|
|
"github.com/safedep/pmg/sandbox"
|
|
)
|
|
|
|
// landlockSandbox implements the Sandbox interface using Landlock LSM on Linux.
|
|
// This implementation follows the CLI-wrapper pattern (like Bubblewrap):
|
|
// - Modifies the cmd in place by rewiring it to re-exec pmg with __landlock_sandbox_exec
|
|
// - Passes the translated policy via a temp file (--policy-file)
|
|
// - Passes an audit unix socket path (--audit-socket) over which the helper
|
|
// reports audit events, buffered for BestEffortViolation
|
|
// - Returns ExecutionResult with executed=false
|
|
// - Caller must call cmd.Run() to execute the sandboxed command
|
|
type landlockSandbox struct {
|
|
abi *landlockABI
|
|
|
|
// Cleanup state from last Execute()
|
|
policyFile string
|
|
socketPath string
|
|
listener net.Listener
|
|
|
|
// Audit event capture state from last Execute(); consumed by
|
|
// BestEffortViolation (see landlock_diagnostics_linux.go).
|
|
policyName string
|
|
auditMu sync.Mutex
|
|
auditEvents []capturedAuditEvent
|
|
auditDone chan struct{}
|
|
}
|
|
|
|
// newLandlockSandbox creates a new Landlock sandbox instance after verifying
|
|
// that both Landlock and seccomp user notification are available on the system.
|
|
func newLandlockSandbox() (sandbox.Sandbox, error) {
|
|
abi, err := landlockDetectABI()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("landlock not available: %w", err)
|
|
}
|
|
|
|
log.Debugf("Landlock ABI V%d detected (Refer=%v, Truncate=%v, Network=%v, IoctlDev=%v, Scoping=%v)",
|
|
abi.Version, abi.HasRefer, abi.HasTruncate, abi.HasNetwork, abi.HasIoctlDev, abi.HasScoping)
|
|
|
|
return &landlockSandbox{abi: abi}, nil
|
|
}
|
|
|
|
// Name returns the name of this sandbox implementation.
|
|
func (s *landlockSandbox) Name() sandbox.DriverName {
|
|
return sandbox.DriverLandlock
|
|
}
|
|
|
|
// IsAvailable returns true if Landlock is available and functional on this system.
|
|
func (s *landlockSandbox) IsAvailable() bool {
|
|
return s.abi != nil && s.abi.Version > 0
|
|
}
|
|
|
|
// Close cleans up any resources allocated by the sandbox.
|
|
// It closes the audit socket listener and removes temporary files.
|
|
// This method is idempotent and safe to call multiple times.
|
|
func (s *landlockSandbox) Close() error {
|
|
if s.listener != nil {
|
|
_ = s.listener.Close()
|
|
s.listener = nil
|
|
}
|
|
if s.socketPath != "" {
|
|
_ = os.Remove(s.socketPath)
|
|
s.socketPath = ""
|
|
}
|
|
if s.policyFile != "" {
|
|
_ = os.Remove(s.policyFile)
|
|
s.policyFile = ""
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Execute prepares a command to run in the Landlock sandbox with the given policy.
|
|
// It translates the PMG policy to a landlockExecPolicy, serializes it to a pipe,
|
|
// and rewires the command to re-exec pmg with the __landlock_sandbox_exec subcommand.
|
|
//
|
|
// This implementation modifies the cmd in place and does NOT execute it.
|
|
// Returns ExecutionResult with executed=false, indicating the caller must run cmd.Run().
|
|
func (s *landlockSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy, rt *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) {
|
|
if utils.SafelyGetValue(policy.NetworkViaProxyOnly) {
|
|
return nil, usefulerror.NewUsefulError().
|
|
WithCode(errcodes.UnsupportedPlatform).
|
|
WithHumanError("network_via_proxy_only is not yet supported on this platform").
|
|
WithHelp("Disable network_via_proxy_only for this profile.").
|
|
Wrap(fmt.Errorf("network_via_proxy_only is not yet supported on this platform (%s sandbox)", s.Name()))
|
|
}
|
|
|
|
execPolicy, err := landlockTranslatePolicy(policy, s.abi)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to translate policy: %w", err)
|
|
}
|
|
|
|
execPolicy.Command = cmd.Path
|
|
if len(cmd.Args) > 1 {
|
|
execPolicy.Args = cmd.Args[1:]
|
|
}
|
|
execPolicy.Env = cmd.Env
|
|
|
|
policyFile, err := os.CreateTemp("", "pmg-landlock-policy-*.json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create policy temp file: %w", err)
|
|
}
|
|
|
|
if err := json.NewEncoder(policyFile).Encode(execPolicy); err != nil {
|
|
_ = policyFile.Close()
|
|
_ = os.Remove(policyFile.Name())
|
|
return nil, fmt.Errorf("failed to write policy to temp file: %w", err)
|
|
}
|
|
|
|
policyFilePath := policyFile.Name()
|
|
_ = policyFile.Close()
|
|
s.policyFile = policyFilePath
|
|
|
|
socketPath := filepath.Join(os.TempDir(), fmt.Sprintf("pmg-landlock-audit-%d.sock", os.Getpid()))
|
|
listener, err := net.Listen("unix", socketPath)
|
|
if err != nil {
|
|
_ = os.Remove(policyFilePath)
|
|
return nil, fmt.Errorf("failed to create audit unix socket: %w", err)
|
|
}
|
|
|
|
s.socketPath = socketPath
|
|
s.listener = listener
|
|
|
|
s.policyName = policy.Name
|
|
s.auditMu.Lock()
|
|
s.auditEvents = nil
|
|
s.auditMu.Unlock()
|
|
auditDone := make(chan struct{})
|
|
s.auditDone = auditDone
|
|
|
|
go func() {
|
|
defer close(auditDone)
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.captureAuditEvents(conn)
|
|
if err := conn.Close(); err != nil {
|
|
log.Warnf("close audit conn: %v", err)
|
|
}
|
|
}()
|
|
|
|
selfExe, err := os.Executable()
|
|
if err != nil {
|
|
if cerr := s.Close(); cerr != nil {
|
|
log.Warnf("close landlock driver after self-exe lookup failure: %v", cerr)
|
|
}
|
|
return nil, fmt.Errorf("failed to get self executable path: %w", err)
|
|
}
|
|
|
|
originalPath := cmd.Path
|
|
originalArgs := cmd.Args
|
|
|
|
cmd.Path = selfExe
|
|
cmd.Args = []string{
|
|
"pmg", "__landlock_sandbox_exec",
|
|
"--policy-file", policyFilePath,
|
|
"--audit-socket", socketPath,
|
|
"--", originalPath,
|
|
}
|
|
if len(originalArgs) > 1 {
|
|
cmd.Args = append(cmd.Args, originalArgs[1:]...)
|
|
}
|
|
|
|
log.Debugf("Landlock sandboxed command: %s %v", cmd.Path, cmd.Args)
|
|
|
|
return sandbox.NewExecutionResult(
|
|
sandbox.WithExecutionResultExecuted(false),
|
|
sandbox.WithExecutionResultSandbox(s),
|
|
), nil
|
|
}
|