Files
pmg/sandbox/platform/probe_canary.go
T
5131c3f641 feat(sandbox): ExecutionContext plumbing and fail-closed lockdown contract (#371)
* feat(sandbox): ExecutionContext plumbing and fail-closed lockdown contract

Network lockdown needs the PMG proxy's address, which is only known at
spawn time. Thread an ExecutionContext from the proxy flow through the
runner and executor into every sandbox driver, and enforce the
network_via_proxy_only fail-closed contract: lockdown without a running
loopback proxy, or on a driver that cannot enforce it, is a hard error —
never a silent fallback to unrestricted network.

- sandbox.ExecutionContext{ProxyAddr} + 4-arg Sandbox.Execute
- sandbox.ValidateLockdown validates the proxy address (loopback only)
  with usefulerror code SandboxRequiresProxy
- Seatbelt validates lockdown before translation (translation itself
  lands next); bubblewrap and landlock reject lockdown as unsupported
  until Linux enforcement is implemented
- executor.WithExecutionContext, runner.ExecuteOptions.SandboxProxyAddr,
  proxy flow passes the live proxy address
- ApplySandbox also validates centrally before invoking the driver

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS

* fix(sandbox): require numeric in-range proxy port in ValidateLockdown

The validated port string is embedded into generated sandbox profiles,
so service names, zero, and out-of-range ports are refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS

* fix(sandbox): fail closed on Seatbelt lockdown until translation lands

A lockdown policy that passed proxy validation would silently receive
the pre-lockdown network rules from the translator. Reject it until the
lockdown profile translation is implemented, keeping the window between
plumbing and enforcement fail-closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS

* chore: review feedback — drop redundant comment, simplify stub help text

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 20:12:33 +05:30

198 lines
5.5 KiB
Go

package platform
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/safedep/dry/log"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/sandbox"
)
// canaryTimeout bounds the runtime of a single canary smoke test so a misconfigured
// kernel cannot wedge the doctor command.
const canaryTimeout = 15 * time.Second
// canaryTargetPath is the file the canary attempts to read under a deny-all
// policy. It is well-known on every supported host.
const canaryTargetPath = "/etc/hostname"
// canarySandboxFactory builds the sandbox driver under test. Separated from the
// probe body so tests can stub it.
type canarySandboxFactory func() (sandbox.Sandbox, error)
// canaryCommandFactory builds the command the canary attempts to run. Stubbed
// in tests; defaults to `cat /etc/hostname`.
type canaryCommandFactory func(ctx context.Context) *exec.Cmd
func defaultCanaryCommand(ctx context.Context) *exec.Cmd {
return exec.CommandContext(ctx, "cat", canaryTargetPath)
}
// denyAllCanaryPolicy returns a minimal valid policy that denies read access to
// the canary target. ValidateResolved requires at least one rule; the deny on
// canaryTargetPath satisfies that and unambiguously asserts the sandbox is
// blocking the read.
func denyAllCanaryPolicy() *sandbox.SandboxPolicy {
return &sandbox.SandboxPolicy{
Name: "pmg-canary",
Description: "deny-all canary probe",
PackageManagers: []string{"npm", "pip", "uv", "pypi"},
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{canaryTargetPath},
},
AllowGitConfig: utils.PtrTo(false),
AllowPTY: utils.PtrTo(false),
AllowNetworkBind: utils.PtrTo(false),
}
}
// runCanary executes the canary smoke test. On success, the sandbox prevents
// the canary read and the command exits non-zero — that is the OK path.
func runCanary(ctx context.Context, name string, driver sandbox.DriverName, factory canarySandboxFactory, cmdFactory canaryCommandFactory) sandbox.ProbeResult {
ctx, cancel := context.WithTimeout(ctx, canaryTimeout)
defer cancel()
d := string(driver)
sb, err := factory()
if err != nil {
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusFail,
Summary: d + " sandbox could not be constructed",
Detail: err.Error(),
Fixes: []sandbox.ProbeFix{driverInstallFix(driver)},
}
}
defer func() { _ = sb.Close() }()
if !sb.IsAvailable() {
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusSkipped,
Summary: d + " driver not available on this host",
Fixes: []sandbox.ProbeFix{driverInstallFix(driver)},
}
}
cmd := cmdFactory(ctx)
isCanaryRead := isCanaryReadCommand(cmd)
var expected []byte
if isCanaryRead {
var readErr error
expected, readErr = os.ReadFile(canaryTargetPath)
if readErr != nil {
log.Warnf("canary probe: failed to read baseline %s: %v", canaryTargetPath, readErr)
}
}
var stdout, stderr bytes.Buffer
if cmd.Stdout == nil {
cmd.Stdout = &stdout
}
if cmd.Stderr == nil {
cmd.Stderr = &stderr
}
policy := denyAllCanaryPolicy()
result, err := sb.Execute(ctx, cmd, policy, nil)
if err != nil {
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusFail,
Summary: d + " sandbox setup failed",
Detail: err.Error(),
Fixes: []sandbox.ProbeFix{driverInstallFix(driver)},
}
}
defer func() { _ = result.Close() }()
if result.ShouldRun() {
err = cmd.Run()
}
if err == nil {
if isCanaryRead && len(expected) > 0 && !bytes.Equal(stdout.Bytes(), expected) {
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusOK,
Summary: d + " masked canary read",
Detail: "the sandbox hid the contents of " + canaryTargetPath,
}
}
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusFail,
Summary: d + " did not block canary read of " + canaryTargetPath,
Detail: "the sandbox executed `cat " + canaryTargetPath + "` without denial",
Fixes: []sandbox.ProbeFix{{
Description: "Driver appears installed but not enforcing. Re-run with PMG_LOG_LEVEL=debug to inspect the translated policy.",
}},
}
}
if errors.Is(err, context.DeadlineExceeded) {
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusFail,
Summary: d + " canary timed out",
Detail: err.Error(),
}
}
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusOK,
Summary: d + " correctly blocked canary read",
}
}
func isCanaryReadCommand(cmd *exec.Cmd) bool {
if cmd == nil {
return false
}
if len(cmd.Args) == 0 || filepath.Base(cmd.Args[0]) != "cat" {
return false
}
for _, arg := range cmd.Args[1:] {
if arg == canaryTargetPath {
return true
}
}
return false
}
func driverInstallFix(driver sandbox.DriverName) sandbox.ProbeFix {
switch driver {
case sandbox.DriverBubblewrap:
return bubblewrapInstallFix()
case sandbox.DriverLandlock:
return sandbox.ProbeFix{
Description: "Upgrade to Linux 5.13+ for Landlock support.",
Docs: "https://docs.kernel.org/userspace-api/landlock.html",
}
case sandbox.DriverSeatbelt:
return sandbox.ProbeFix{
Description: "sandbox-exec ships with macOS — check SIP and PATH.",
}
}
return sandbox.ProbeFix{Description: "Install the " + string(driver) + " sandbox driver."}
}
func bubblewrapInstallFix() sandbox.ProbeFix {
return sandbox.ProbeFix{
Description: "Install bubblewrap using your distribution package manager and verify unprivileged user namespaces are enabled.",
Docs: "https://github.com/containers/bubblewrap",
}
}