Files
pmg/sandbox/platform/landlock_e2e_linux_test.go
T
Sahil BansalandGitHub 47dd859756 feat(sandbox): report landlock seccomp denials via pmg sandbox violations (#389)
* 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.
2026-07-25 12:38:27 +00:00

394 lines
14 KiB
Go

//go:build linux
package platform
import (
"bytes"
"encoding/json"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// These end-to-end tests build the pmg binary and invoke the hidden
// `__landlock_sandbox_exec` entry point directly with a crafted policy file,
// bypassing the rest of pmg (config, proxy, etc.). They verify the helper
// flow on a real kernel + Landlock ABI.
//
// Opt-in: skipped unless PMG_LANDLOCK_E2E=1 is set in the environment. They
// require a kernel that allows installing seccomp without NNP from inside an
// unprivileged user namespace — Ubuntu 24.04 blocks this by default via
// `kernel.apparmor_restrict_unprivileged_userns=1`, so CI must disable
// AppArmor (or the sysctl) before setting the env var. Also skipped when
// kernel Landlock is unavailable or the pmg binary cannot be located/built.
const landlockRuleReadExec = uint64(13) // READ_FILE | READ_DIR | EXECUTE
const landlockRuleReadDir = uint64(12) // READ_FILE | READ_DIR
// landlockE2EEnabled reports whether the user has opted into running these
// e2e tests. Default: skip. The CI landlock job sets PMG_LANDLOCK_E2E=1 after
// disabling AppArmor.
func landlockE2EEnabled() bool {
v := os.Getenv("PMG_LANDLOCK_E2E")
return v == "1" || v == "true" || v == "yes"
}
// buildPmgBinary locates or builds bin/pmg. Returns absolute path.
func buildPmgBinary(t *testing.T) string {
t.Helper()
// Walk upward from CWD to find the repo root (contains go.mod + main.go).
cwd, err := os.Getwd()
require.NoError(t, err)
dir := cwd
for i := 0; i < 10; i++ {
if _, err := os.Stat(filepath.Join(dir, "main.go")); err == nil {
break
}
parent := filepath.Dir(dir)
if parent == dir {
t.Skip("could not locate pmg repo root")
}
dir = parent
}
binPath := filepath.Join(dir, "bin", "pmg")
if _, err := os.Stat(binPath); err == nil {
return binPath
}
// Build fresh.
cmd := exec.Command("go", "build", "-o", binPath, "main.go")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "build failed: %s", out)
return binPath
}
// writePolicyFile serializes a minimal landlockExecPolicy to a temp file.
func writePolicyFile(t *testing.T, p *landlockExecPolicy) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "policy-*.json")
require.NoError(t, err)
require.NoError(t, json.NewEncoder(f).Encode(p))
require.NoError(t, f.Close())
return f.Name()
}
// runHelper invokes the hidden helper subcommand with the given policy and
// returns (stdout, stderr, exit-code). The audit socket points nowhere; use
// runHelperWithAuditSocket to collect audit events.
func runHelper(t *testing.T, policyPath string) (string, string, int) {
t.Helper()
return runHelperWithAuditSocket(t, policyPath, "/tmp/pmg-test-audit.sock.nonexistent")
}
func runHelperWithAuditSocket(t *testing.T, policyPath, auditSocket string) (string, string, int) {
t.Helper()
pmg := buildPmgBinary(t)
cmd := exec.Command(pmg,
"__landlock_sandbox_exec",
"--policy-file", policyPath,
"--audit-socket", auditSocket,
)
// PMG_KEEP_POLICY ensures test state is visible on failure.
cmd.Env = append(os.Environ(), "PMG_KEEP_POLICY=1")
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err := cmd.Run()
exit := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
exit = ee.ExitCode()
} else {
exit = -1
}
}
return outBuf.String(), errBuf.String(), exit
}
// baseRules returns a minimal allow-list sufficient to run common binaries.
func baseRules() []landlockPathRule {
return []landlockPathRule{
{Path: "/", Access: landlockRuleReadExec},
{Path: "/usr", Access: landlockRuleReadExec},
{Path: "/bin", Access: landlockRuleReadExec},
{Path: "/lib", Access: landlockRuleReadExec},
{Path: "/lib64", Access: landlockRuleReadExec},
{Path: "/usr/lib", Access: landlockRuleReadExec},
{Path: "/usr/lib64", Access: landlockRuleReadExec},
{Path: "/proc", Access: landlockRuleReadDir},
{Path: "/dev/null", Access: landlockRuleReadDir},
{Path: "/dev/urandom", Access: landlockRuleReadDir},
}
}
// TestLandlockHelper_EchoRuns is the simplest smoke test: the helper should
// be able to install its filter, fork /bin/echo, collect its output, and
// exit cleanly. Regression for the supervisor.Stop hang and for the
// landlock-before-seccomp ordering deadlock.
func TestLandlockHelper_EchoRuns(t *testing.T) {
if !landlockE2EEnabled() {
t.Skip("PMG_LANDLOCK_E2E not set; skipping landlock e2e (requires AppArmor disabled / unprivileged-userns sysctl)")
}
if _, err := landlockDetectABI(); err != nil {
t.Skipf("Landlock not available: %v", err)
}
policy := &landlockExecPolicy{
FilesystemRules: baseRules(),
SkipPIDNamespace: true,
SkipIPCNamespace: true,
Command: "/bin/echo",
Args: []string{"sandbox-ok"},
}
policyPath := writePolicyFile(t, policy)
stdout, stderr, exit := runHelper(t, policyPath)
assert.Equal(t, 0, exit, "helper exited non-zero: stderr=%s", stderr)
assert.Contains(t, stdout, "sandbox-ok")
}
// TestLandlockHelper_DirectChildDenyBlocksRead is the security-critical
// assertion: when the policy says "allow /" but "deny ~/.ssh", a direct
// target that tries to read ~/.ssh must see EACCES. This works because the
// target is the helper's direct child, so /proc/<pid>/mem is readable and
// seccomp-notify can resolve the openat path argument.
//
// Grandchild processes (e.g. node spawned by an npm shell wrapper) hit a
// dumpable=0 limitation and are covered by a separate TODO — see
// docs/sandbox.md for details. This test intentionally uses /usr/bin/cat
// as a direct target to keep enforcement in scope.
func TestLandlockHelper_DirectChildDenyBlocksRead(t *testing.T) {
if !landlockE2EEnabled() {
t.Skip("PMG_LANDLOCK_E2E not set; skipping landlock e2e (requires AppArmor disabled / unprivileged-userns sysctl)")
}
if _, err := landlockDetectABI(); err != nil {
t.Skipf("Landlock not available: %v", err)
}
if _, err := os.Stat("/usr/bin/cat"); err != nil {
t.Skip("/usr/bin/cat not found")
}
// Build a fake HOME with a decoy secret so we don't need real ~/.ssh.
home := t.TempDir()
secretPath := filepath.Join(home, ".ssh", "id_ed25519")
require.NoError(t, os.Mkdir(filepath.Join(home, ".ssh"), 0o700))
const secret = "SECRET-PRIVATE-KEY-CONTENT"
require.NoError(t, os.WriteFile(secretPath, []byte(secret), 0o600))
policy := &landlockExecPolicy{
FilesystemRules: append(baseRules(),
landlockPathRule{Path: home, Access: landlockRuleReadExec},
),
DenyPaths: []denyPathEntry{
{Path: filepath.Join(home, ".ssh"), Mode: denyBoth},
},
SkipPIDNamespace: true,
SkipIPCNamespace: true,
Command: "/usr/bin/cat",
Args: []string{secretPath},
}
policyPath := writePolicyFile(t, policy)
stdout, stderr, exit := runHelper(t, policyPath)
assert.NotEqual(t, 0, exit, "cat should have failed; stdout=%q", stdout)
assert.NotContains(t, stdout, secret, "secret content must not leak")
combined := stdout + stderr
assert.True(t,
bytesContainsAny(combined, []string{"Permission denied", "EACCES"}),
"expected a permission-denied error; got: %q", combined)
}
// TestLandlockHelper_DenyBothBlocksWrite extends the direct-child test to
// verify that denyBoth blocks write access as well. Uses /usr/bin/tee as
// the *direct* target (no shell wrapper) so seccomp-notify can actually
// read the openat path argument — see grandchild limitation note in
// TestLandlockHelper_DirectChildDenyBlocksRead.
func TestLandlockHelper_DenyBothBlocksWrite(t *testing.T) {
if !landlockE2EEnabled() {
t.Skip("PMG_LANDLOCK_E2E not set; skipping landlock e2e (requires AppArmor disabled / unprivileged-userns sysctl)")
}
if _, err := landlockDetectABI(); err != nil {
t.Skipf("Landlock not available: %v", err)
}
if _, err := os.Stat("/usr/bin/tee"); err != nil {
t.Skip("/usr/bin/tee not found")
}
home := t.TempDir()
denyDir := filepath.Join(home, "secrets")
require.NoError(t, os.Mkdir(denyDir, 0o700))
writeTarget := filepath.Join(denyDir, "token")
// AccessFSWriteFile (0x2) + MakeReg (0x100) so creation under $home is
// permitted by Landlock; the seccomp deny is what should block.
policy := &landlockExecPolicy{
FilesystemRules: append(baseRules(),
landlockPathRule{Path: home, Access: landlockRuleReadExec | 0x2 | 0x100},
),
DenyPaths: []denyPathEntry{
{Path: denyDir, Mode: denyBoth},
},
SkipPIDNamespace: true,
SkipIPCNamespace: true,
Command: "/usr/bin/tee",
Args: []string{writeTarget},
}
policyPath := writePolicyFile(t, policy)
stdout, stderr, exit := runHelper(t, policyPath)
combined := stdout + stderr
if _, err := os.Stat(writeTarget); err == nil {
t.Errorf("write target was created: %s (stdout=%q stderr=%q exit=%d)", writeTarget, stdout, stderr, exit)
}
assert.True(t,
bytesContainsAny(combined, []string{"Permission denied", "EACCES"}),
"expected permission denied; got: %q exit=%d", combined, exit)
}
// TestLandlockHelper_GrandchildDenyBlocksRead is the big one: deny-rule
// enforcement must reach DESCENDANT processes, not just the direct target.
// This is the contract gap we historically had vs bubblewrap. We use a
// nested bash chain so the `cat` that actually opens the secret is a
// grandchild of the helper (bash -> bash -> cat), forcing enforcement to
// route through per-descendant /proc/<pid>/mem reads. Works because the
// shim installs seccomp inside a user namespace WITHOUT NO_NEW_PRIVS,
// keeping dumpable=1 through every execve in the tree.
func TestLandlockHelper_GrandchildDenyBlocksRead(t *testing.T) {
if !landlockE2EEnabled() {
t.Skip("PMG_LANDLOCK_E2E not set; skipping landlock e2e (requires AppArmor disabled / unprivileged-userns sysctl)")
}
if _, err := landlockDetectABI(); err != nil {
t.Skipf("Landlock not available: %v", err)
}
if _, err := os.Stat("/bin/bash"); err != nil {
t.Skip("/bin/bash not found")
}
// Requires unprivileged user namespaces for the shim architecture.
if b, err := os.ReadFile("/proc/sys/kernel/unprivileged_userns_clone"); err == nil {
if len(b) > 0 && b[0] == '0' {
t.Skip("unprivileged user namespaces disabled")
}
}
home := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(home, ".ssh"), 0o700))
secretPath := filepath.Join(home, ".ssh", "id_ed25519")
const secret = "GRANDCHILD-SECRET-CONTENT"
require.NoError(t, os.WriteFile(secretPath, []byte(secret), 0o600))
policy := &landlockExecPolicy{
FilesystemRules: append(baseRules(),
landlockPathRule{Path: home, Access: landlockRuleReadExec},
),
DenyPaths: []denyPathEntry{
{Path: filepath.Join(home, ".ssh"), Mode: denyBoth},
},
SkipPIDNamespace: true,
SkipIPCNamespace: true,
Command: "/bin/bash",
// Two layers of exec-via-bash before cat hits the secret.
Args: []string{"-c",
"exec /bin/bash -c 'exec /bin/cat " + secretPath + "'"},
}
policyPath := writePolicyFile(t, policy)
stdout, stderr, _ := runHelper(t, policyPath)
assert.NotContains(t, stdout, secret, "grandchild must not read the secret")
combined := stdout + stderr
assert.True(t,
bytesContainsAny(combined, []string{"Permission denied", "EACCES"}),
"expected permission-denied from grandchild; got: %q", combined)
}
// TestLandlockHelper_DenyEmitsAuditViolation closes the loop from a real
// in-kernel denial to the violation report surfaced by `pmg sandbox
// violations` / `explain`: the helper's supervisor denies a read, emits the
// audit event over the socket, and the driver's capture + mapping code must
// turn it into a typed fs_read violation.
func TestLandlockHelper_DenyEmitsAuditViolation(t *testing.T) {
if !landlockE2EEnabled() {
t.Skip("PMG_LANDLOCK_E2E not set; skipping landlock e2e (requires AppArmor disabled / unprivileged-userns sysctl)")
}
if _, err := landlockDetectABI(); err != nil {
t.Skipf("Landlock not available: %v", err)
}
if _, err := os.Stat("/usr/bin/cat"); err != nil {
t.Skip("/usr/bin/cat not found")
}
home := t.TempDir()
secretPath := filepath.Join(home, ".ssh", "id_ed25519")
require.NoError(t, os.Mkdir(filepath.Join(home, ".ssh"), 0o700))
require.NoError(t, os.WriteFile(secretPath, []byte("SECRET"), 0o600))
policy := &landlockExecPolicy{
FilesystemRules: append(baseRules(),
landlockPathRule{Path: home, Access: landlockRuleReadExec},
),
DenyPaths: []denyPathEntry{
{Path: filepath.Join(home, ".ssh"), Mode: denyBoth},
},
SkipPIDNamespace: true,
SkipIPCNamespace: true,
Command: "/usr/bin/cat",
Args: []string{secretPath},
}
policyPath := writePolicyFile(t, policy)
socketPath := filepath.Join(t.TempDir(), "audit.sock")
listener, err := net.Listen("unix", socketPath)
require.NoError(t, err)
defer func() {
require.NoError(t, listener.Close())
}()
s := &landlockSandbox{}
done := make(chan struct{})
go func() {
defer close(done)
conn, err := listener.Accept()
if err != nil {
return
}
s.captureAuditEvents(conn)
_ = conn.Close()
}()
stdout, stderr, exit := runHelperWithAuditSocket(t, policyPath, socketPath)
assert.NotEqual(t, 0, exit, "cat should have failed; stdout=%q stderr=%q", stdout, stderr)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("audit reader did not finish after helper exit")
}
violations := extractLandlockViolations(s.auditEvents)
require.NotEmpty(t, violations, "expected a captured violation; stdout=%q stderr=%q", stdout, stderr)
v := violations[0]
assert.Equal(t, sandbox.ViolationKindFSRead, v.Kind)
assert.Equal(t, secretPath, v.Target)
assert.Equal(t, filepath.Join(home, ".ssh"), v.RuleTarget)
assert.Equal(t, "read access denied: "+secretPath, v.RuleLabel)
assert.Equal(t, "cat", v.Process)
assert.NotContains(t, v.RawLog, `"ts":0`)
}
// bytesContainsAny reports whether s contains any of the given substrings.
func bytesContainsAny(s string, subs []string) bool {
for _, sub := range subs {
if bytes.Contains([]byte(s), []byte(sub)) {
return true
}
}
return false
}