mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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)
|
||
|
|
}
|
||
|
|
}
|