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.
394 lines
14 KiB
Go
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
|
|
}
|