Files
pmg/sandbox/platform/landlock_diagnostics_linux.go
T

194 lines
5.8 KiB
Go
Raw Normal View History

//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)
}
}