Files
pmg/sandbox/platform/landlock_diagnostics_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

369 lines
11 KiB
Go

//go:build linux
package platform
import (
"context"
"errors"
"fmt"
"net"
"os/exec"
"strings"
"testing"
"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
func denyEvent(syscall, path, access, comm string) capturedAuditEvent {
return capturedAuditEvent{
auditEvent: auditEvent{
Type: auditSeccompDeny,
Syscall: syscall,
Path: path,
Access: access,
Comm: comm,
PID: 123,
},
raw: `{"type":"seccomp_deny"}`,
}
}
func TestExtractLandlockViolations(t *testing.T) {
tests := []struct {
name string
events []capturedAuditEvent
want []sandbox.Violation
}{
{
name: "read denial maps to fs_read",
events: []capturedAuditEvent{denyEvent("openat", "/home/dev/.ssh/id_rsa", "read", "node")},
want: []sandbox.Violation{{
Kind: sandbox.ViolationKindFSRead,
RawKind: "openat",
Target: "/home/dev/.ssh/id_rsa",
Process: "node",
RawLog: `{"type":"seccomp_deny"}`,
RuleLabel: "read access denied: /home/dev/.ssh/id_rsa",
}},
},
{
name: "write denial maps to fs_write",
events: []capturedAuditEvent{denyEvent("openat2", "/home/dev/project/.env", "write", "npm")},
want: []sandbox.Violation{{
Kind: sandbox.ViolationKindFSWrite,
RawKind: "openat2",
Target: "/home/dev/project/.env",
Process: "npm",
RawLog: `{"type":"seccomp_deny"}`,
RuleLabel: "write access denied: /home/dev/project/.env",
}},
},
{
name: "exec denial maps to exec regardless of access",
events: []capturedAuditEvent{denyEvent("execve", "/usr/bin/curl", "", "bash")},
want: []sandbox.Violation{{
Kind: sandbox.ViolationKindExec,
RawKind: "execve",
Target: "/usr/bin/curl",
Process: "bash",
RawLog: `{"type":"seccomp_deny"}`,
RuleLabel: "process execution denied: /usr/bin/curl",
}},
},
{
name: "execveat also maps to exec",
events: []capturedAuditEvent{denyEvent("execveat", "/usr/bin/nc", "", "sh")},
want: []sandbox.Violation{{
Kind: sandbox.ViolationKindExec,
RawKind: "execveat",
Target: "/usr/bin/nc",
Process: "sh",
RawLog: `{"type":"seccomp_deny"}`,
RuleLabel: "process execution denied: /usr/bin/nc",
}},
},
{
name: "operational events are skipped",
events: []capturedAuditEvent{
{auditEvent: auditEvent{Type: auditNamespaceUnavailable, Message: "clone failed"}, raw: "{}"},
{auditEvent: auditEvent{Type: auditMemFdOpenFailed, PID: 1, Error: "EACCES"}, raw: "{}"},
},
want: []sandbox.Violation{},
},
{
name: "identical kind and target deduplicated",
events: []capturedAuditEvent{
denyEvent("openat", "/home/dev/.netrc", "read", "node"),
denyEvent("openat", "/home/dev/.netrc", "read", "node"),
denyEvent("openat2", "/home/dev/.netrc", "read", "node"),
},
want: []sandbox.Violation{{
Kind: sandbox.ViolationKindFSRead,
RawKind: "openat",
Target: "/home/dev/.netrc",
Process: "node",
RawLog: `{"type":"seccomp_deny"}`,
RuleLabel: "read access denied: /home/dev/.netrc",
}},
},
{
name: "rule target populated from rule_path",
events: []capturedAuditEvent{{
auditEvent: auditEvent{
Type: auditSeccompDeny,
Syscall: "openat",
Path: "/home/dev/.ssh/id_rsa",
Access: "read",
RulePath: "/home/dev/.ssh",
Comm: "node",
},
raw: "{}",
}},
want: []sandbox.Violation{{
Kind: sandbox.ViolationKindFSRead,
RawKind: "openat",
Target: "/home/dev/.ssh/id_rsa",
RuleTarget: "/home/dev/.ssh",
Process: "node",
RawLog: "{}",
RuleLabel: "read access denied: /home/dev/.ssh/id_rsa",
}},
},
{
name: "unknown syscall maps to generic_deny",
events: []capturedAuditEvent{denyEvent("syscall_999", "/tmp/x", "", "node")},
want: []sandbox.Violation{{
Kind: sandbox.ViolationKindGenericDeny,
RawKind: "syscall_999",
Target: "/tmp/x",
Process: "node",
RawLog: `{"type":"seccomp_deny"}`,
RuleLabel: "sandbox denied access to /tmp/x",
}},
},
{
name: "same target different kind kept",
events: []capturedAuditEvent{
denyEvent("openat", "/home/dev/.npmrc", "read", "node"),
denyEvent("openat", "/home/dev/.npmrc", "write", "node"),
},
want: []sandbox.Violation{
{
Kind: sandbox.ViolationKindFSRead,
RawKind: "openat",
Target: "/home/dev/.npmrc",
Process: "node",
RawLog: `{"type":"seccomp_deny"}`,
RuleLabel: "read access denied: /home/dev/.npmrc",
},
{
Kind: sandbox.ViolationKindFSWrite,
RawKind: "openat",
Target: "/home/dev/.npmrc",
Process: "node",
RawLog: `{"type":"seccomp_deny"}`,
RuleLabel: "write access denied: /home/dev/.npmrc",
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, extractLandlockViolations(tc.events))
})
}
}
func TestSummarizeLandlockViolation(t *testing.T) {
tests := []struct {
kind sandbox.ViolationKind
target string
want string
}{
{sandbox.ViolationKindFSRead, "/tmp/.env", "read access denied: /tmp/.env"},
{sandbox.ViolationKindFSWrite, "/tmp/out", "write access denied: /tmp/out"},
{sandbox.ViolationKindExec, "/usr/bin/curl", "process execution denied: /usr/bin/curl"},
{sandbox.ViolationKindGenericDeny, "/tmp/x", "sandbox denied access to /tmp/x"},
{sandbox.ViolationKindGenericDeny, "", "sandbox denied an operation"},
}
for _, tc := range tests {
assert.Equal(t, tc.want, summarizeLandlockViolation(tc.kind, tc.target))
}
}
func TestCaptureAuditEvents(t *testing.T) {
input := `{"type":"seccomp_deny","syscall":"openat","path":"/home/dev/.ssh/id_rsa","access":"read","comm":"node","pid":42,"ts":0}
not-json
{"type":"namespace_isolation_unavailable","message":"clone failed","ts":0}
`
s := &landlockSandbox{}
s.captureAuditEvents(strings.NewReader(input))
require.Len(t, s.auditEvents, 2)
assert.Equal(t, auditSeccompDeny, s.auditEvents[0].Type)
assert.Equal(t, "/home/dev/.ssh/id_rsa", s.auditEvents[0].Path)
assert.Equal(t, "read", s.auditEvents[0].Access)
assert.Equal(t, "node", s.auditEvents[0].Comm)
assert.Contains(t, s.auditEvents[0].raw, `"syscall":"openat"`)
assert.Equal(t, auditNamespaceUnavailable, s.auditEvents[1].Type)
}
func TestCaptureAuditEventsBounded(t *testing.T) {
var sb strings.Builder
for i := range landlockAuditEventCap + 10 {
fmt.Fprintf(&sb, `{"type":"seccomp_deny","syscall":"openat","path":"/tmp/f%d","ts":0}`+"\n", i)
}
s := &landlockSandbox{}
s.captureAuditEvents(strings.NewReader(sb.String()))
assert.Len(t, s.auditEvents, landlockAuditEventCap)
}
// A tight retry loop on one denied path must not fill the buffer and evict a
// later distinct denial (dedupe happens before the cap).
func TestCaptureAuditEventsDedupesDenials(t *testing.T) {
var sb strings.Builder
for range landlockAuditEventCap + 10 {
sb.WriteString(`{"type":"seccomp_deny","syscall":"openat","path":"/tmp/.env","access":"write","ts":1}` + "\n")
}
sb.WriteString(`{"type":"seccomp_deny","syscall":"execve","path":"/usr/bin/curl","ts":1}` + "\n")
s := &landlockSandbox{}
s.captureAuditEvents(strings.NewReader(sb.String()))
require.Len(t, s.auditEvents, 2)
assert.Equal(t, "/tmp/.env", s.auditEvents[0].Path)
assert.Equal(t, "/usr/bin/curl", s.auditEvents[1].Path)
}
// An O_RDWR open denied by a read-only rule must be labeled a read denial so
// the suggested override (--sandbox-allow read=...) actually unblocks it;
// allowing write would prune only deny_write entries.
func TestDenyAccessLabelReportsFiredRule(t *testing.T) {
deny := []denyPathEntry{{Path: "/home/dev/.npmrc", Mode: denyRead}}
entry, denied := matchDeniedPath("/home/dev/.npmrc", unix.O_RDWR, deny)
require.True(t, denied)
assert.Equal(t, "read", denyAccessLabel(entry.Mode, unix.O_RDWR))
assert.Equal(t, "write", denyAccessLabel(denyWrite, unix.O_RDWR))
assert.Equal(t, "read", denyAccessLabel(denyBoth, unix.O_RDONLY))
assert.Equal(t, "write", denyAccessLabel(denyBoth, unix.O_RDWR))
}
func TestBestEffortViolationNilOnSuccess(t *testing.T) {
done := make(chan struct{})
close(done)
s := &landlockSandbox{
auditDone: done,
auditEvents: []capturedAuditEvent{denyEvent("openat", "/tmp/.env", "read", "node")},
}
report, err := s.BestEffortViolation(nil)
require.NoError(t, err)
assert.Nil(t, report)
}
func TestBestEffortViolationNilWithoutExecute(t *testing.T) {
s := &landlockSandbox{}
report, err := s.BestEffortViolation(errors.New("exit status 1"))
require.NoError(t, err)
assert.Nil(t, report)
}
func TestBestEffortViolationNilWithoutDenials(t *testing.T) {
done := make(chan struct{})
close(done)
s := &landlockSandbox{
policyName: "test-policy",
auditDone: done,
auditEvents: []capturedAuditEvent{
{auditEvent: auditEvent{Type: auditNamespaceUnavailable, Message: "clone failed"}, raw: "{}"},
},
}
report, err := s.BestEffortViolation(errors.New("exit status 1"))
require.NoError(t, err)
assert.Nil(t, report)
}
// TestBestEffortViolationSocketFlow drives the real Execute socket plumbing:
// a fake helper dials the audit socket, writes deny events through the real
// serializer, and the driver must surface them as a violation report.
func TestBestEffortViolationSocketFlow(t *testing.T) {
s := &landlockSandbox{abi: newLandlockABI(1)}
defer func() {
require.NoError(t, s.Close())
}()
cmd := exec.Command("/bin/true")
policy := &sandbox.SandboxPolicy{Name: "test-policy"}
_, err := s.Execute(context.Background(), cmd, policy, &sandbox.ExecutionContext{})
require.NoError(t, err)
conn, err := net.Dial("unix", s.socketPath)
require.NoError(t, err)
require.NoError(t, landlockWriteAuditEvent(conn, auditEvent{
Type: auditSeccompDeny,
Syscall: "openat",
Path: "/home/dev/.ssh/id_rsa",
Access: "read",
Comm: "node",
PID: 42,
}))
require.NoError(t, landlockWriteAuditEvent(conn, auditEvent{
Type: auditSeccompDeny,
Syscall: "execve",
Path: "/usr/bin/curl",
Comm: "bash",
PID: 43,
}))
require.NoError(t, conn.Close())
report, err := s.BestEffortViolation(errors.New("exit status 2"))
require.NoError(t, err)
require.NotNil(t, report)
assert.Equal(t, sandbox.DriverLandlock, report.SandboxName)
assert.Equal(t, "test-policy", report.PolicyName)
require.Len(t, report.Violations, 2)
assert.Equal(t, sandbox.ViolationKindFSRead, report.Violations[0].Kind)
assert.Equal(t, "/home/dev/.ssh/id_rsa", report.Violations[0].Target)
assert.Equal(t, "node", report.Violations[0].Process)
assert.Equal(t, "read access denied: /home/dev/.ssh/id_rsa", report.Violations[0].RuleLabel)
assert.Equal(t, sandbox.ViolationKindExec, report.Violations[1].Kind)
assert.Equal(t, "/usr/bin/curl", report.Violations[1].Target)
}
// The helper may die before ever dialing the audit socket; BestEffortViolation
// must return within the drain-wait guard instead of hanging on Accept.
func TestBestEffortViolationHelperNeverConnected(t *testing.T) {
s := &landlockSandbox{abi: newLandlockABI(1)}
defer func() {
require.NoError(t, s.Close())
}()
cmd := exec.Command("/bin/true")
policy := &sandbox.SandboxPolicy{Name: "test-policy"}
_, err := s.Execute(context.Background(), cmd, policy, &sandbox.ExecutionContext{})
require.NoError(t, err)
report, err := s.BestEffortViolation(errors.New("exit status 2"))
require.NoError(t, err)
assert.Nil(t, report)
}