2026-05-12 18:11:18 +05:30
//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]*)` )
2026-05-19 14:40:54 +05:30
// 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+)` )
2026-05-12 18:11:18 +05:30
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
}
2026-05-19 14:40:54 +05:30
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
}
}
2026-07-06 23:38:38 +05:30
// 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 )
}
2026-07-10 22:01:10 +05:30
// 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
}
2026-05-12 18:11:18 +05:30
violations = append ( violations , sandbox . Violation {
2026-05-19 14:40:54 +05:30
Kind : kind ,
2026-05-12 18:11:18 +05:30
RawKind : payload . Kind ,
Target : target ,
RuleTarget : payload . Target ,
Process : process ,
RawLog : strings . TrimSpace ( entry . EventMessage ),
2026-07-10 22:01:10 +05:30
RuleLabel : summarizeSeatbeltViolation ( labelKind , labelTarget ),
2026-05-12 18:11:18 +05:30
})
}
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
}
}
2026-05-19 14:40:54 +05:30
// 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
2026-07-06 23:38:38 +05:30
// scoreViolation already understands are recognized; all others fall back to
// (generic_deny, "", false) so the caller keeps the original classification.
2026-05-19 14:40:54 +05:30
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
2026-07-06 23:38:38 +05:30
case verb == "network-bind" :
return sandbox . ViolationKindNetworkBind , "network-bind" , true
2026-07-10 22:01:10 +05:30
case verb == seatbeltKindNetworkOutbound :
return sandbox . ViolationKindNetworkConnect , seatbeltKindNetworkOutbound , true
2026-05-19 14:40:54 +05:30
}
return sandbox . ViolationKindGenericDeny , "" , false
}
2026-05-12 18:11:18 +05:30
func extractSeatbeltDeniedPath ( raw string , payload * seatbeltLogPayload ) string {
2026-07-06 23:38:38 +05:30
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 {
2026-05-12 18:11:18 +05:30
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 ])
2026-07-06 23:38:38 +05:30
return strings . Trim ( last , "\"',;:()[]{}" )
2026-05-12 18:11:18 +05:30
}
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 )
2026-07-06 23:38:38 +05:30
case "network-bind" :
if target == "" {
return "network bind denied"
}
return fmt . Sprintf ( "network bind denied: %s" , target )
2026-07-10 22:01:10 +05:30
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)"
}
2026-07-06 23:38:38 +05:30
if target == "" {
return "network connect denied"
}
return fmt . Sprintf ( "network connect denied: %s" , target )
2026-05-12 18:11:18 +05:30
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
}