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.
194 lines
5.8 KiB
Go
194 lines
5.8 KiB
Go
//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)
|
|
}
|
|
}
|