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.
This commit is contained in:
Sahil Bansal
2026-07-25 12:38:27 +00:00
committed by GitHub
parent 94781d6bda
commit 47dd859756
9 changed files with 845 additions and 59 deletions
+56 -2
View File
@@ -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)
<details>
<summary>Bubblewrap Installation on Linux</summary>
@@ -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 <abi/4.0>,
include <tunables/global>
profile pmg /usr/local/bin/pmg flags=(unconfined) {
userns,
include if exists <local/pmg>
}
```
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
- <https://github.com/anthropic-experimental/sandbox-runtime>
+10 -4
View File
@@ -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 {
@@ -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)
}
}
@@ -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)
}
+86 -2
View File
@@ -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 {
+23 -9
View File
@@ -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)
}
+92 -35
View File
@@ -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/<childPID>/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/<pid>/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/<pid>/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.
+14 -6
View File
@@ -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",
},
},
}
}
@@ -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")
}
})
}