mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
370 lines
10 KiB
Go
370 lines
10 KiB
Go
//go:build darwin
|
|
// +build darwin
|
|
|
|
package platform
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/safedep/dry/log"
|
|
"github.com/safedep/pmg/sandbox"
|
|
)
|
|
|
|
const seatbeltLogWindowPadding = 2 * time.Second
|
|
const seatbeltMaxQueryWait = 6 * time.Second
|
|
const seatbeltQueryInterval = 250 * time.Millisecond
|
|
const seatbeltLogCommandTimeout = 5 * time.Second
|
|
const macOSUnifiedLogPath = "/usr/bin/log"
|
|
|
|
var seatbeltMessagePattern = regexp.MustCompile(`PMG_SBX\|run=([^|]+)\|kind=([^|]+)\|target=([^"\s]*)`)
|
|
|
|
// seatbeltDenyVerbPattern captures the sandbox-exec denial verb from a raw log
|
|
// line such as `Sandbox: node(123) deny(1) file-write-data /path`. The verb is
|
|
// used to recover a typed ViolationKind when a denial hit the catch-all
|
|
// `(deny default ...)` rule (which only carries `kind=default` in our marker).
|
|
var seatbeltDenyVerbPattern = regexp.MustCompile(`\bdeny\(\d+\)\s+(\S+)`)
|
|
|
|
type seatbeltLogEntry struct {
|
|
EventMessage string `json:"eventMessage"`
|
|
Process string `json:"process"`
|
|
ProcessImagePath string `json:"processImagePath"`
|
|
}
|
|
|
|
type seatbeltLogPayload struct {
|
|
RunID string
|
|
Kind string
|
|
Target string
|
|
}
|
|
|
|
func parseSeatbeltLogPayload(raw string) (*seatbeltLogPayload, bool) {
|
|
matches := seatbeltMessagePattern.FindStringSubmatch(raw)
|
|
if len(matches) != 4 {
|
|
return nil, false
|
|
}
|
|
|
|
target, err := urlQueryUnescape(matches[3])
|
|
if err != nil {
|
|
target = matches[3]
|
|
}
|
|
|
|
return &seatbeltLogPayload{
|
|
RunID: matches[1],
|
|
Kind: matches[2],
|
|
Target: target,
|
|
}, true
|
|
}
|
|
|
|
func (s *seatbeltSandbox) BestEffortViolation(err error) (*sandbox.ViolationReport, error) {
|
|
if err == nil || s.logTag == "" || s.startedAt.IsZero() {
|
|
return nil, nil
|
|
}
|
|
|
|
deadline := time.Now().Add(seatbeltMaxQueryWait)
|
|
var lastQueryErr error
|
|
|
|
for {
|
|
end := time.Now()
|
|
entries, queryErr := s.queryLogs(s.startedAt.Add(-seatbeltLogWindowPadding), end.Add(seatbeltLogWindowPadding))
|
|
if queryErr != nil {
|
|
lastQueryErr = queryErr
|
|
} else {
|
|
violations := extractSeatbeltViolations(entries, s.logTag)
|
|
if len(violations) > 0 {
|
|
return &sandbox.ViolationReport{
|
|
SandboxName: s.Name(),
|
|
PolicyName: s.policyName,
|
|
CorrelationID: s.logTag,
|
|
Violations: violations,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
break
|
|
}
|
|
|
|
time.Sleep(seatbeltQueryInterval)
|
|
}
|
|
|
|
if lastQueryErr != nil {
|
|
return nil, lastQueryErr
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func extractSeatbeltViolations(entries []seatbeltLogEntry, runID string) []sandbox.Violation {
|
|
violations := make([]sandbox.Violation, 0, len(entries))
|
|
for _, entry := range entries {
|
|
payload, ok := parseSeatbeltLogPayload(entry.EventMessage)
|
|
if !ok || payload.RunID != runID {
|
|
continue
|
|
}
|
|
|
|
process := entry.Process
|
|
if process == "" {
|
|
process = entry.ProcessImagePath
|
|
}
|
|
|
|
target := extractSeatbeltDeniedPath(entry.EventMessage, payload)
|
|
if target == "" {
|
|
target = payload.Target
|
|
}
|
|
|
|
kind := normalizeSeatbeltViolationKind(payload.Kind)
|
|
labelKind := payload.Kind
|
|
|
|
// When the deny hit our catch-all `(deny default ...)` rule, the
|
|
// marker only carries `kind=default`. The sandbox-exec preamble in
|
|
// the same log line names the real verb (file-write-data, etc.) so
|
|
// we recover the typed kind from there. This drives accurate
|
|
// primary-violation ranking and `--sandbox-allow` suggestions.
|
|
if kind == sandbox.ViolationKindGenericDeny {
|
|
if inferredKind, inferredLabelKind, ok := inferSeatbeltKindFromRawLog(entry.EventMessage); ok {
|
|
kind = inferredKind
|
|
labelKind = inferredLabelKind
|
|
}
|
|
}
|
|
|
|
// Network denial operands are addresses, not paths, so the
|
|
// path-shaped extraction above rejected them; recover the raw token.
|
|
if target == "" && (kind == sandbox.ViolationKindNetworkBind || kind == sandbox.ViolationKindNetworkConnect) {
|
|
target = extractSeatbeltDeniedToken(entry.EventMessage, payload)
|
|
}
|
|
|
|
// The lockdown deny marker (target=direct) is authoritative for the
|
|
// human message even when the raw operand is path-shaped (e.g. the
|
|
// mDNSResponder unix socket); Target keeps the denied operand.
|
|
labelTarget := target
|
|
if labelKind == seatbeltKindNetworkOutbound && payload.Target == seatbeltLockdownTargetDirect {
|
|
labelTarget = payload.Target
|
|
}
|
|
|
|
violations = append(violations, sandbox.Violation{
|
|
Kind: kind,
|
|
RawKind: payload.Kind,
|
|
Target: target,
|
|
RuleTarget: payload.Target,
|
|
Process: process,
|
|
RawLog: strings.TrimSpace(entry.EventMessage),
|
|
RuleLabel: summarizeSeatbeltViolation(labelKind, labelTarget),
|
|
})
|
|
}
|
|
|
|
return violations
|
|
}
|
|
|
|
func normalizeSeatbeltViolationKind(kind string) sandbox.ViolationKind {
|
|
switch kind {
|
|
case "file-read":
|
|
return sandbox.ViolationKindFSRead
|
|
case "file-write":
|
|
return sandbox.ViolationKindFSWrite
|
|
case "file-write-unlink":
|
|
return sandbox.ViolationKindFSDeleteOrRename
|
|
case "process-exec":
|
|
return sandbox.ViolationKindExec
|
|
default:
|
|
return sandbox.ViolationKindGenericDeny
|
|
}
|
|
}
|
|
|
|
// inferSeatbeltKindFromRawLog recovers a typed ViolationKind from the
|
|
// sandbox-exec denial verb embedded in raw. It returns the typed kind plus a
|
|
// canonical marker name (the one our own rules would emit for the same kind,
|
|
// suitable for summarizeSeatbeltViolation). Only verbs that map to kinds
|
|
// scoreViolation already understands are recognized; all others fall back to
|
|
// (generic_deny, "", false) so the caller keeps the original classification.
|
|
func inferSeatbeltKindFromRawLog(raw string) (sandbox.ViolationKind, string, bool) {
|
|
m := seatbeltDenyVerbPattern.FindStringSubmatch(raw)
|
|
if len(m) != 2 {
|
|
return sandbox.ViolationKindGenericDeny, "", false
|
|
}
|
|
|
|
verb := m[1]
|
|
switch {
|
|
case verb == "file-write-unlink", verb == "file-write-mount", verb == "file-rename":
|
|
return sandbox.ViolationKindFSDeleteOrRename, "file-write-unlink", true
|
|
case strings.HasPrefix(verb, "file-write"), verb == "file-link", verb == "file-mknod":
|
|
return sandbox.ViolationKindFSWrite, "file-write", true
|
|
case strings.HasPrefix(verb, "file-read"):
|
|
return sandbox.ViolationKindFSRead, "file-read", true
|
|
case strings.HasPrefix(verb, "process-exec"):
|
|
return sandbox.ViolationKindExec, "process-exec", true
|
|
case verb == "network-bind":
|
|
return sandbox.ViolationKindNetworkBind, "network-bind", true
|
|
case verb == seatbeltKindNetworkOutbound:
|
|
return sandbox.ViolationKindNetworkConnect, seatbeltKindNetworkOutbound, true
|
|
}
|
|
|
|
return sandbox.ViolationKindGenericDeny, "", false
|
|
}
|
|
|
|
func extractSeatbeltDeniedPath(raw string, payload *seatbeltLogPayload) string {
|
|
last := extractSeatbeltDeniedToken(raw, payload)
|
|
if last == "" || !looksLikeConcretePath(last) {
|
|
return ""
|
|
}
|
|
|
|
return last
|
|
}
|
|
|
|
// extractSeatbeltDeniedToken returns the last token of the sandbox-exec
|
|
// preamble before our marker — the denial operand. Unlike
|
|
// extractSeatbeltDeniedPath it does not require a path shape, so it also
|
|
// recovers network addresses such as "local:*:0" or "1.2.3.4:443".
|
|
func extractSeatbeltDeniedToken(raw string, payload *seatbeltLogPayload) string {
|
|
if payload == nil {
|
|
return ""
|
|
}
|
|
|
|
marker := seatbeltLogMessage(payload.RunID, payload.Kind, payload.Target)
|
|
idx := strings.Index(raw, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
|
|
prefix := strings.TrimSpace(raw[:idx])
|
|
if prefix == "" {
|
|
return ""
|
|
}
|
|
|
|
fields := strings.Fields(prefix)
|
|
if len(fields) == 0 {
|
|
return ""
|
|
}
|
|
|
|
last := strings.TrimSpace(fields[len(fields)-1])
|
|
return strings.Trim(last, "\"',;:()[]{}")
|
|
}
|
|
|
|
func (s *seatbeltSandbox) queryLogs(start, end time.Time) ([]seatbeltLogEntry, error) {
|
|
info, err := os.Stat(macOSUnifiedLogPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("macOS unified log CLI not available: %w", err)
|
|
}
|
|
if info.IsDir() {
|
|
return nil, fmt.Errorf("macOS unified log CLI not available: %s is a directory", macOSUnifiedLogPath)
|
|
}
|
|
|
|
predicate := fmt.Sprintf(`eventMessage CONTAINS "PMG_SBX|run=%s|"`, s.logTag)
|
|
args := []string{
|
|
"show",
|
|
"--style", "json",
|
|
"--start", start.Format("2006-01-02 15:04:05"),
|
|
"--end", end.Format("2006-01-02 15:04:05"),
|
|
"--predicate", predicate,
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), seatbeltLogCommandTimeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, macOSUnifiedLogPath, args...)
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
var stderr bytes.Buffer
|
|
if ee, ok := err.(*exec.ExitError); ok {
|
|
stderr.Write(ee.Stderr)
|
|
}
|
|
return nil, fmt.Errorf("query seatbelt logs: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
entries, err := decodeSeatbeltLogEntries(output)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode seatbelt logs: %w", err)
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
func decodeSeatbeltLogEntries(data []byte) ([]seatbeltLogEntry, error) {
|
|
trimmed := bytes.TrimSpace(data)
|
|
if len(trimmed) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var list []seatbeltLogEntry
|
|
if err := json.Unmarshal(trimmed, &list); err == nil {
|
|
return list, nil
|
|
}
|
|
|
|
decoder := json.NewDecoder(bytes.NewReader(trimmed))
|
|
entries := []seatbeltLogEntry{}
|
|
for {
|
|
var entry seatbeltLogEntry
|
|
if err := decoder.Decode(&entry); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
return nil, err
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
func summarizeSeatbeltViolation(kind, target string) string {
|
|
switch kind {
|
|
case "file-read":
|
|
return fmt.Sprintf("read access denied: %s", target)
|
|
case "file-write":
|
|
return fmt.Sprintf("write access denied: %s", target)
|
|
case "file-write-unlink":
|
|
return fmt.Sprintf("rename or unlink denied: %s", target)
|
|
case "process-exec":
|
|
return fmt.Sprintf("process execution denied: %s", target)
|
|
case "network-bind":
|
|
if target == "" {
|
|
return "network bind denied"
|
|
}
|
|
return fmt.Sprintf("network bind denied: %s", target)
|
|
case seatbeltKindNetworkOutbound:
|
|
if target == seatbeltLockdownTargetDirect {
|
|
return "direct network access blocked by network_via_proxy_only — traffic must flow through the PMG proxy (a tool may have ignored HTTP_PROXY/HTTPS_PROXY)"
|
|
}
|
|
if target == "" {
|
|
return "network connect denied"
|
|
}
|
|
return fmt.Sprintf("network connect denied: %s", target)
|
|
default:
|
|
if target == "" {
|
|
return "sandbox denied an operation"
|
|
}
|
|
return fmt.Sprintf("sandbox denied access to %s", target)
|
|
}
|
|
}
|
|
|
|
func looksLikeConcretePath(value string) bool {
|
|
if value == "" {
|
|
return false
|
|
}
|
|
|
|
if strings.ContainsAny(value, "*?[]") {
|
|
return false
|
|
}
|
|
|
|
return strings.Contains(value, "/") || strings.HasPrefix(value, ".")
|
|
}
|
|
|
|
func urlQueryUnescape(value string) (string, error) {
|
|
unescaped, err := url.QueryUnescape(value)
|
|
if err != nil {
|
|
log.Debugf("seatbelt diagnostics: failed to decode %q: %v", value, err)
|
|
return "", err
|
|
}
|
|
return unescaped, nil
|
|
}
|