diff --git a/docs/sandbox.md b/docs/sandbox.md index f188d2c..f54926c 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -99,6 +99,8 @@ rules. A small set of core variables (`PATH`, `HOME`, `LC_*`, `TZ`, ...) is neve - Linux kernel 5.13+ with Landlock enabled (default, no external dependencies) - Bubblewrap on Linux (fallback for kernels < 5.13, or when `PMG_SANDBOX_DRIVER=bubblewrap` is set) - Seatbelt on MacOS +- On Ubuntu 23.10+, an AppArmor profile granting pmg unprivileged user namespaces. See + [AppArmor blocks the Landlock driver](#apparmor-blocks-the-landlock-driver-ubuntu-2310)
Bubblewrap Installation on Linux @@ -210,8 +212,17 @@ pmg sandbox profile show npm-restrictive --resolved ``` `pmg sandbox doctor` runs platform-specific checks for the current host. Cached violation reports -used by `violations list` and `explain --last` are currently produced by macOS Seatbelt diagnostics; -on Linux, Bubblewrap and Landlock denials may only appear as command errors such as `EACCES`. +used by `violations list` and `explain --last` are produced by macOS Seatbelt diagnostics and, on +Linux, by the Landlock driver's seccomp supervisor. + +Coverage differs by platform. Seatbelt logs every denial, including the default-deny allow-list +boundary. The Landlock driver only reports denials made by its seccomp deny-list layer (reads and +writes of `deny_*` paths, blocked `deny_exec` binaries): denials made by the Landlock LSM itself +(operations outside the allow-list, delete/rename, network rules) fail in-kernel with `EACCES` and +produce no report. `deny_write` entries outside writable areas are enforced by Landlock rather than +seccomp, so they are likewise not reported. Operational degradation events on the audit socket +(`namespace_isolation_unavailable`, `memfd_open_failed`) are not included in violation reports +today; they may be added later. Bubblewrap denials only appear as command errors such as `EACCES`. ### Runtime Allow Overrides @@ -614,6 +625,49 @@ bwrap --verbose [arguments...] -- npm install express **Note**: Unlike macOS, Bubblewrap does not provide real-time violation logging. Policy violations typically manifest as `EACCES` (Permission denied) errors. +With the Landlock driver, denials made by the seccomp deny-list layer on a failed run are captured +into the violation cache and can be inspected with `pmg sandbox violations list` and +`pmg sandbox explain --last` (see Sandbox Debug Commands above for coverage limits). + +### AppArmor blocks the Landlock driver (Ubuntu 23.10+) + +Ubuntu restricts unprivileged user namespaces via AppArmor +(`kernel.apparmor_restrict_unprivileged_userns=1`, default since 23.10). The Landlock driver needs +one: it re-executes pmg inside a user namespace to install its seccomp filter. With the restriction +active, sandboxed commands fail with: + +``` +Error: shim: install seccomp: SECCOMP_SET_MODE_FILTER without NNP (user-ns CAP_SYS_ADMIN required): permission denied +``` + +`pmg sandbox doctor` flags this as the "AppArmor user namespaces" check. + +The recommended fix is Ubuntu's own mechanism: an AppArmor profile that grants pmg (and only pmg) +the `userns` permission. Create `/etc/apparmor.d/pmg` with the pmg binary path (`command -v pmg`): + +``` +abi , +include + +profile pmg /usr/local/bin/pmg flags=(unconfined) { + userns, + include if exists +} +``` + +Load it (persists across reboots; no restart needed): + +```bash +sudo apparmor_parser -r /etc/apparmor.d/pmg +``` + +Alternatively, disable the restriction system-wide. This is simpler but weakens the protection for +every binary on the host, so prefer the profile: + +```bash +sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 +``` + ## References - diff --git a/internal/ui/sandbox_violation.go b/internal/ui/sandbox_violation.go index 67bb164..2e3c15d 100644 --- a/internal/ui/sandbox_violation.go +++ b/internal/ui/sandbox_violation.go @@ -61,17 +61,23 @@ func FormatSandboxDetails(report *pmgsandbox.ViolationReport, primary *pmgsandbo lines := []string{ "Sandbox: " + string(report.SandboxName), "Policy: " + report.PolicyName, - "Correlation: " + report.CorrelationID, - "Process: " + process, - "Violation: " + primary.RuleLabel, } + if report.CorrelationID != "" { + lines = append(lines, "Correlation: "+report.CorrelationID) + } + + lines = append(lines, + "Process: "+process, + "Violation: "+primary.RuleLabel, + ) + if primary.RuleTarget != "" && primary.RuleTarget != primary.Target { lines = append(lines, "Matched rule: "+primary.RuleTarget) } if primary.RawLog != "" { - lines = append(lines, "Seatbelt log: "+primary.RawLog) + lines = append(lines, "Raw log: "+primary.RawLog) } if len(report.Violations) > 1 { diff --git a/sandbox/platform/landlock_diagnostics_linux.go b/sandbox/platform/landlock_diagnostics_linux.go new file mode 100644 index 0000000..b2acd8e --- /dev/null +++ b/sandbox/platform/landlock_diagnostics_linux.go @@ -0,0 +1,193 @@ +//go:build linux + +package platform + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/sandbox" +) + +// landlockAuditEventCap bounds the per-run audit event buffer. Deny events +// are deduplicated by (kind, path) at capture time, so this caps DISTINCT +// denials — reaching it means hundreds of different denied targets, itself +// worth the one-time warning below. +const landlockAuditEventCap = 512 + +// landlockAuditDrainWait bounds how long BestEffortViolation waits for the +// audit reader goroutine to finish. The helper exits before cmd.Run() +// returns, so EOF is normally immediate; the timeout covers the case where +// the helper never connected and Accept is still blocked. +const landlockAuditDrainWait = 2 * time.Second + +// capturedAuditEvent pairs a decoded audit event with its original JSON line +// so the violation report can preserve the raw evidence. +type capturedAuditEvent struct { + auditEvent + raw string +} + +// captureAuditEvents reads newline-delimited auditEvent JSON from the audit +// socket connection and buffers events for BestEffortViolation. Malformed +// lines are skipped; both sides are the same pmg binary so they indicate +// corruption, not version skew. +func (s *landlockSandbox) captureAuditEvents(r io.Reader) { + scanner := bufio.NewScanner(r) + seen := make(map[string]bool) + dropped := false + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + + var evt auditEvent + if err := json.Unmarshal(line, &evt); err != nil { + log.Debugf("landlock diagnostics: skipping malformed audit event: %v", err) + continue + } + + // Dedupe deny events before the cap: a tight retry loop on one denied + // path must not fill the buffer and evict a later distinct denial. + // seen is marked only on append so it stays bounded by the cap — the + // keys carry attacker-chosen path bytes, so an unbounded map would + // reintroduce the memory growth the cap exists to prevent. Once the + // buffer is full, new distinct denials hit the drop branch (and its + // one-time warning) instead of growing the map. + key := "" + if evt.Type == auditSeccompDeny { + key = landlockDenyKey(evt) + if seen[key] { + continue + } + } + + s.auditMu.Lock() + if len(s.auditEvents) < landlockAuditEventCap { + if key != "" { + seen[key] = true + } + s.auditEvents = append(s.auditEvents, capturedAuditEvent{auditEvent: evt, raw: string(line)}) + } else if !dropped { + dropped = true + log.Warnf("landlock diagnostics: audit event buffer full (%d), dropping further events", landlockAuditEventCap) + } + s.auditMu.Unlock() + } + + if err := scanner.Err(); err != nil { + log.Debugf("landlock diagnostics: audit socket read: %v", err) + } +} + +// BestEffortViolation reports seccomp-layer denials captured over the audit +// socket during the last Execute. Only the deny-list layer is observable: +// denials made by the Landlock LSM itself (allow-list boundary, delete or +// rename, network) fail in-kernel with no userspace signal and never appear +// here. Operational events (namespace_isolation_unavailable, memfd_open_failed) +// are deliberately excluded from the report; they are degradation warnings, +// not denials. +func (s *landlockSandbox) BestEffortViolation(err error) (*sandbox.ViolationReport, error) { + if err == nil || s.auditDone == nil { + return nil, nil + } + + select { + case <-s.auditDone: + case <-time.After(landlockAuditDrainWait): + } + + s.auditMu.Lock() + events := make([]capturedAuditEvent, len(s.auditEvents)) + copy(events, s.auditEvents) + s.auditMu.Unlock() + + violations := extractLandlockViolations(events) + if len(violations) == 0 { + return nil, nil + } + + return &sandbox.ViolationReport{ + SandboxName: s.Name(), + PolicyName: s.policyName, + Violations: violations, + }, nil +} + +// extractLandlockViolations maps seccomp deny events to violations, +// deduplicating identical (kind, target) pairs — a process commonly retries +// a denied open many times. +func extractLandlockViolations(events []capturedAuditEvent) []sandbox.Violation { + violations := make([]sandbox.Violation, 0, len(events)) + seen := make(map[string]bool, len(events)) + + for _, e := range events { + if e.Type != auditSeccompDeny { + continue + } + + key := landlockDenyKey(e.auditEvent) + if seen[key] { + continue + } + seen[key] = true + + kind := landlockViolationKind(e.auditEvent) + + violations = append(violations, sandbox.Violation{ + Kind: kind, + RawKind: e.Syscall, + Target: e.Path, + RuleTarget: e.RulePath, + Process: e.Comm, + RawLog: e.raw, + RuleLabel: summarizeLandlockViolation(kind, e.Path), + }) + } + + return violations +} + +// landlockDenyKey identifies a denial for deduplication: events with the same +// violation kind and target are the same denial. Capture-time dedupe +// (captureAuditEvents) and extract-time dedupe (extractLandlockViolations) +// must agree on this identity, so both use this function. +func landlockDenyKey(e auditEvent) string { + return string(landlockViolationKind(e)) + "\x00" + e.Path +} + +func landlockViolationKind(e auditEvent) sandbox.ViolationKind { + switch e.Syscall { + case "execve", "execveat": + return sandbox.ViolationKindExec + case "openat", "openat2": + if e.Access == "read" { + return sandbox.ViolationKindFSRead + } + return sandbox.ViolationKindFSWrite + default: + return sandbox.ViolationKindGenericDeny + } +} + +func summarizeLandlockViolation(kind sandbox.ViolationKind, target string) string { + switch kind { + case sandbox.ViolationKindFSRead: + return fmt.Sprintf("read access denied: %s", target) + case sandbox.ViolationKindFSWrite: + return fmt.Sprintf("write access denied: %s", target) + case sandbox.ViolationKindExec: + return fmt.Sprintf("process execution denied: %s", target) + default: + if target == "" { + return "sandbox denied an operation" + } + return fmt.Sprintf("sandbox denied access to %s", target) + } +} diff --git a/sandbox/platform/landlock_diagnostics_linux_test.go b/sandbox/platform/landlock_diagnostics_linux_test.go new file mode 100644 index 0000000..93e1abd --- /dev/null +++ b/sandbox/platform/landlock_diagnostics_linux_test.go @@ -0,0 +1,368 @@ +//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) +} diff --git a/sandbox/platform/landlock_e2e_linux_test.go b/sandbox/platform/landlock_e2e_linux_test.go index 6d22bee..22a1e0f 100644 --- a/sandbox/platform/landlock_e2e_linux_test.go +++ b/sandbox/platform/landlock_e2e_linux_test.go @@ -5,11 +5,14 @@ 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" ) @@ -77,14 +80,20 @@ func writePolicyFile(t *testing.T, p *landlockExecPolicy) string { } // runHelper invokes the hidden helper subcommand with the given policy and -// returns (stdout, stderr, exit-code). +// 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", "/tmp/pmg-test-audit.sock.nonexistent", + "--audit-socket", auditSocket, ) // PMG_KEEP_POLICY ensures test state is visible on failure. cmd.Env = append(os.Environ(), "PMG_KEEP_POLICY=1") @@ -298,6 +307,81 @@ func TestLandlockHelper_GrandchildDenyBlocksRead(t *testing.T) { "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 { diff --git a/sandbox/platform/landlock_linux.go b/sandbox/platform/landlock_linux.go index a72e670..db23d90 100644 --- a/sandbox/platform/landlock_linux.go +++ b/sandbox/platform/landlock_linux.go @@ -6,11 +6,11 @@ import ( "context" "encoding/json" "fmt" - "io" "net" "os" "os/exec" "path/filepath" + "sync" "github.com/safedep/dry/log" "github.com/safedep/dry/usefulerror" @@ -21,11 +21,12 @@ import ( // 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) for future audit event consumption -// - Returns ExecutionResult with executed=false -// - Caller must call cmd.Run() to execute the sandboxed command +// - 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 @@ -33,6 +34,13 @@ type landlockSandbox struct { 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 @@ -129,14 +137,20 @@ func (s *landlockSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sa 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 } - if _, err := io.Copy(io.Discard, conn); err != nil { - log.Warnf("audit socket drain: %v", err) - } + s.captureAuditEvents(conn) if err := conn.Close(); err != nil { log.Warnf("close audit conn: %v", err) } diff --git a/sandbox/platform/landlock_seccomp_linux.go b/sandbox/platform/landlock_seccomp_linux.go index a7f5d95..4df8627 100644 --- a/sandbox/platform/landlock_seccomp_linux.go +++ b/sandbox/platform/landlock_seccomp_linux.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "unsafe" "github.com/safedep/dry/log" @@ -90,13 +91,16 @@ const ( // auditEvent represents a single security audit log entry. type auditEvent struct { - Type auditEventType `json:"type"` - Syscall string `json:"syscall,omitempty"` - Path string `json:"path,omitempty"` - PID int `json:"pid,omitempty"` - Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` - Ts int64 `json:"ts"` + Type auditEventType `json:"type"` + Syscall string `json:"syscall,omitempty"` + Path string `json:"path,omitempty"` + Access string `json:"access,omitempty"` + RulePath string `json:"rule_path,omitempty"` + Comm string `json:"comm,omitempty"` + PID int `json:"pid,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + Ts int64 `json:"ts"` } // writeAuditEvent JSON-encodes an audit event and writes it as a single line to w. @@ -143,15 +147,15 @@ func landlockBuildBPFFilter() (*unix.SockFprog, error) { }, nil } -// isPathDenied checks if a path should be denied based on the deny list and open flags. -// flags uses O_ACCMODE constants (O_RDONLY, O_WRONLY, O_RDWR). -// Matching rules: +// matchDeniedPath returns the deny entry that denies opening path with the +// given flags, if any. flags uses O_ACCMODE constants (O_RDONLY, O_WRONLY, +// O_RDWR). Matching rules: // - Exact match: /home/user/.env matches deny /home/user/.env // - Directory subtree: /home/user/.ssh/id_rsa matches deny /home/user/.ssh // or deny /home/user/.ssh/ (either with or without trailing slash — a // deny entry without slash is treated as "this path OR anything beneath it") // - Must NOT match partial names: /home/.envrc does NOT match deny /home/.env -func isPathDenied(path string, flags int, denyPaths []denyPathEntry) bool { +func matchDeniedPath(path string, flags int, denyPaths []denyPathEntry) (denyPathEntry, bool) { accessMode := flags & unix.O_ACCMODE for _, entry := range denyPaths { @@ -170,36 +174,48 @@ func isPathDenied(path string, flags int, denyPaths []denyPathEntry) bool { switch entry.Mode { case denyRead: if accessMode == unix.O_RDONLY || accessMode == unix.O_RDWR { - return true + return entry, true } case denyWrite: if accessMode == unix.O_WRONLY || accessMode == unix.O_RDWR { - return true + return entry, true } case denyBoth: - return true + return entry, true } } - return false + return denyPathEntry{}, false } -// isExecDenied checks if a path matches the deny exec list. -// Same matching rules as isPathDenied but no flag check. -func isExecDenied(path string, denyExec []string) bool { +// isPathDenied reports whether matchDeniedPath finds a deny entry for path. +func isPathDenied(path string, flags int, denyPaths []denyPathEntry) bool { + _, denied := matchDeniedPath(path, flags, denyPaths) + return denied +} + +// matchDeniedExec returns the deny exec entry matching path, if any. +// Same matching rules as matchDeniedPath but no flag check. +func matchDeniedExec(path string, denyExec []string) (string, bool) { for _, entry := range denyExec { if strings.HasSuffix(entry, "/") { if strings.HasPrefix(path, entry) { - return true + return entry, true } } else { if path == entry || strings.HasPrefix(path, entry+"/") { - return true + return entry, true } } } - return false + return "", false +} + +// isExecDenied reports whether matchDeniedExec finds a deny entry for path. +func isExecDenied(path string, denyExec []string) bool { + _, denied := matchDeniedExec(path, denyExec) + return denied } // readPathFromMem reads a null-terminated path string from a process's memory @@ -306,9 +322,9 @@ type seccompPhase struct { // memFd is the pre-opened /proc//mem fd for the direct child. // Descendants (grandchildren spawned via fork/exec) have their own PIDs; // use memFdFor(pid) to resolve the right fd for any notification. - memFd *os.File - denyPaths []denyPathEntry - denyExec []string + memFd *os.File + denyPaths []denyPathEntry + denyExec []string auditWriter io.Writer // memFdCache maps descendant PID -> /proc//mem fd. Entries live for @@ -328,7 +344,6 @@ type seccompSupervisor struct { loopDone chan struct{} } - // newLandlockSupervisorFromFd wraps an already-created seccomp notify fd // (obtained from the shim over a socketpair) in a supervisor. It does NOT // install a filter — the shim did that inside its user namespace so the @@ -515,13 +530,16 @@ func (s *seccompSupervisor) handleExec(notif *seccompNotification, phase *seccom return } - if isExecDenied(resolved, phase.denyExec) { + if rule, denied := matchDeniedExec(resolved, phase.denyExec); denied { if phase.auditWriter != nil { _ = landlockWriteAuditEvent(phase.auditWriter, auditEvent{ - Type: auditSeccompDeny, - Syscall: syscallName(notif.Data.Nr), - Path: resolved, - PID: int(notif.PID), + Type: auditSeccompDeny, + Syscall: syscallName(notif.Data.Nr), + Path: resolved, + RulePath: rule, + Comm: procComm(notif.PID), + PID: int(notif.PID), + Ts: time.Now().UnixNano(), }) } _ = respondDeny(s.notifyFd, notif.ID) @@ -562,13 +580,17 @@ func (s *seccompSupervisor) handleOpen(notif *seccompNotification, phase *seccom flags := classifyOpenFlags(notif.Data.Nr, notif.Data.Args, memFd) - if isPathDenied(resolved, flags, phase.denyPaths) { + if entry, denied := matchDeniedPath(resolved, flags, phase.denyPaths); denied { if phase.auditWriter != nil { _ = landlockWriteAuditEvent(phase.auditWriter, auditEvent{ - Type: auditSeccompDeny, - Syscall: syscallName(notif.Data.Nr), - Path: resolved, - PID: int(notif.PID), + Type: auditSeccompDeny, + Syscall: syscallName(notif.Data.Nr), + Path: resolved, + Access: denyAccessLabel(entry.Mode, flags), + RulePath: entry.Path, + Comm: procComm(notif.PID), + PID: int(notif.PID), + Ts: time.Now().UnixNano(), }) } _ = respondDeny(s.notifyFd, notif.ID) @@ -594,6 +616,41 @@ func syscallName(nr int32) string { } } +// accessModeString maps an O_ACCMODE value to the audit event access label. +// O_RDWR counts as write: the denial applies to the stronger access. +func accessModeString(flags int) string { + if flags&unix.O_ACCMODE == unix.O_RDONLY { + return "read" + } + return "write" +} + +// denyAccessLabel reports the direction of the deny rule that fired, not the +// requested access. An O_RDWR open denied by a read-only rule must surface as +// a read denial: the write override prunes only deny_write entries, so only a +// read allowance unblocks it. denyBoth falls back to the requested access. +func denyAccessLabel(mode denyMode, flags int) string { + switch mode { + case denyRead: + return "read" + case denyWrite: + return "write" + default: + return accessModeString(flags) + } +} + +// procComm returns the process name from /proc//comm, best-effort. The +// process may already be gone when the denial is recorded, so failures yield +// an empty name rather than an error. +func procComm(pid uint32) string { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + // waitForNotif blocks until notifyFd has a notification to read or stopFd is // signalled. Returns (true, nil) when a notification is ready, (false, nil) // when stop was signalled, and (false, err) on fatal errors. diff --git a/sandbox/platform/probe_apparmor_linux.go b/sandbox/platform/probe_apparmor_linux.go index 532276e..03fa7fa 100644 --- a/sandbox/platform/probe_apparmor_linux.go +++ b/sandbox/platform/probe_apparmor_linux.go @@ -49,11 +49,19 @@ func (p *apparmorProbe) Run(_ context.Context) sandbox.ProbeResult { Name: sandbox.ProbeAppArmorUserns, Status: sandbox.ProbeStatusWarn, Summary: "AppArmor restricts unprivileged user namespaces (value=" + value + ")", - Detail: "bwrap may fail with `setting up uid map: Permission denied` until an AppArmor profile permits it or the sysctl is relaxed.", - Fixes: []sandbox.ProbeFix{{ - Description: "Temporarily relax the restriction (until next reboot).", - Command: "sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0", - Docs: "https://ubuntu.com/blog/ubuntu-23-10-restricted-unprivileged-user-namespaces", - }}, + Detail: "landlock fails with `shim: install seccomp: ... permission denied` and bwrap with " + + "`setting up uid map: Permission denied` until an AppArmor profile permits pmg or the sysctl is relaxed.", + Fixes: []sandbox.ProbeFix{ + { + Description: "Create an AppArmor profile granting pmg the userns permission (recommended), then reload it.", + Command: "sudo apparmor_parser -r /etc/apparmor.d/pmg", + Docs: "https://github.com/safedep/pmg/blob/main/docs/sandbox.md#apparmor-blocks-the-landlock-driver-ubuntu-2310", + }, + { + Description: "Temporarily relax the restriction system-wide (until next reboot).", + Command: "sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0", + Docs: "https://ubuntu.com/blog/ubuntu-23-10-restricted-unprivileged-user-namespaces", + }, + }, } } diff --git a/sandbox/platform/probe_apparmor_linux_test.go b/sandbox/platform/probe_apparmor_linux_test.go index 081be4d..145c292 100644 --- a/sandbox/platform/probe_apparmor_linux_test.go +++ b/sandbox/platform/probe_apparmor_linux_test.go @@ -43,7 +43,9 @@ func TestAppArmorProbe(t *testing.T) { assert.Equal(t, sandbox.ProbeAppArmorUserns, res.Name) assert.Equal(t, tc.want, res.Status) if tc.want == sandbox.ProbeStatusWarn { - assert.NotEmpty(t, res.Fixes) + assert.Len(t, res.Fixes, 2) + assert.Contains(t, res.Fixes[0].Description, "AppArmor profile") + assert.Contains(t, res.Detail, "landlock") } }) }