This repository has been archived on 2026-03-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
greywall/internal/sandbox/learning_darwin.go
Mathieu Virbel 9d5d852860 feat: switch macOS learning mode from fs_usage to eslogger
Replace fs_usage (reports Mach thread IDs, requiring process name matching
with false positives) with eslogger (Endpoint Security framework, reports
real Unix PIDs via audit_token.pid plus fork events for process tree tracking).

Key changes:
- Daemon starts eslogger instead of fs_usage, with early-exit detection
  and clear Full Disk Access error messaging
- New two-pass eslogger JSON parser: pass 1 builds PID tree from fork
  events, pass 2 filters filesystem events by PID set
- Remove runtime PID polling (StartPIDTracking, pollDescendantPIDs) —
  process tree is now built post-hoc from the eslogger log
- Platform-specific generateLearnedTemplatePlatform() for darwin/linux/stub
- Refactor TraceResult and GenerateLearnedTemplate to be platform-agnostic
2026-02-26 17:23:43 -06:00

460 lines
12 KiB
Go

//go:build darwin
package sandbox
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"gitea.app.monadical.io/monadical/greywall/internal/daemon"
)
// opClass classifies a filesystem operation.
type opClass int
const (
opSkip opClass = iota
opRead
opWrite
)
// fwriteFlag is the macOS FWRITE flag value (O_WRONLY or O_RDWR includes this).
const fwriteFlag = 0x0002
// eslogger JSON types — mirrors the real Endpoint Security framework output.
// eslogger emits one JSON object per line to stdout.
//
// Key structural details from real eslogger output:
// - event_type is an integer (e.g., 10=open, 11=fork, 13=create, 32=unlink, 33=write, 41=truncate)
// - Event data is nested under event.{event_name} (e.g., event.open, event.fork)
// - write/unlink/truncate use "target" not "file"
// - create uses destination.existing_file
// - fork child has full process info including audit_token
// esloggerEvent is the top-level event from eslogger.
type esloggerEvent struct {
EventType int `json:"event_type"`
Process esloggerProcess `json:"process"`
Event map[string]json.RawMessage `json:"event"`
}
type esloggerProcess struct {
AuditToken esloggerAuditToken `json:"audit_token"`
Executable esloggerExec `json:"executable"`
PPID int `json:"ppid"`
}
type esloggerAuditToken struct {
PID int `json:"pid"`
}
type esloggerExec struct {
Path string `json:"path"`
PathTruncated bool `json:"path_truncated"`
}
// Event-specific types.
type esloggerOpenEvent struct {
File esloggerFile `json:"file"`
Fflag int `json:"fflag"`
}
type esloggerTargetEvent struct {
Target esloggerFile `json:"target"`
}
type esloggerCreateEvent struct {
DestinationType int `json:"destination_type"`
Destination esloggerCreateDest `json:"destination"`
}
type esloggerCreateDest struct {
ExistingFile *esloggerFile `json:"existing_file,omitempty"`
NewPath *esloggerNewPath `json:"new_path,omitempty"`
}
type esloggerNewPath struct {
Dir esloggerFile `json:"dir"`
Filename string `json:"filename"`
}
type esloggerRenameEvent struct {
Source esloggerFile `json:"source"`
Destination esloggerFile `json:"destination_new_path"` // TODO: verify actual field name
}
type esloggerForkEvent struct {
Child esloggerForkChild `json:"child"`
}
type esloggerForkChild struct {
AuditToken esloggerAuditToken `json:"audit_token"`
Executable esloggerExec `json:"executable"`
PPID int `json:"ppid"`
}
type esloggerLinkEvent struct {
Source esloggerFile `json:"source"`
TargetDir esloggerFile `json:"target_dir"`
}
type esloggerFile struct {
Path string `json:"path"`
PathTruncated bool `json:"path_truncated"`
}
// CheckLearningAvailable verifies that eslogger exists and the daemon is running.
func CheckLearningAvailable() error {
if _, err := os.Stat("/usr/bin/eslogger"); err != nil {
return fmt.Errorf("eslogger not found at /usr/bin/eslogger (requires macOS 13+): %w", err)
}
client := daemon.NewClient(daemon.DefaultSocketPath, false)
if !client.IsRunning() {
return fmt.Errorf("greywall daemon is not running (required for macOS learning mode)\n\n" +
" Install and start: sudo greywall daemon install\n" +
" Check status: greywall daemon status")
}
return nil
}
// eventName extracts the event name string from the event map.
// eslogger nests event data under event.{name}, e.g., event.open, event.fork.
func eventName(ev *esloggerEvent) string {
for key := range ev.Event {
return key
}
return ""
}
// ParseEsloggerLog reads an eslogger JSON log, builds the process tree from
// fork events starting at rootPID, then filters filesystem events by the PID set.
// Uses a two-pass approach: pass 1 scans fork events to build the PID tree,
// pass 2 filters filesystem events by the PID set.
func ParseEsloggerLog(logPath string, rootPID int, debug bool) (*TraceResult, error) {
home, _ := os.UserHomeDir()
seenWrite := make(map[string]bool)
seenRead := make(map[string]bool)
result := &TraceResult{}
// Pass 1: Build the PID set from fork events.
pidSet := map[int]bool{rootPID: true}
forkEvents, err := scanForkEvents(logPath)
if err != nil {
return nil, err
}
// BFS: expand PID set using fork parent→child relationships.
// We may need multiple rounds since a child can itself fork.
changed := true
for changed {
changed = false
for _, fe := range forkEvents {
if pidSet[fe.parentPID] && !pidSet[fe.childPID] {
pidSet[fe.childPID] = true
changed = true
}
}
}
if debug {
fmt.Fprintf(os.Stderr, "[greywall] eslogger PID tree from root %d: %d PIDs\n", rootPID, len(pidSet))
}
// Pass 2: Scan filesystem events, filter by PID set.
f, err := os.Open(logPath) //nolint:gosec // daemon-controlled temp file path
if err != nil {
return nil, fmt.Errorf("failed to open eslogger log: %w", err)
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 256*1024), 4*1024*1024)
lineCount := 0
matchedLines := 0
writeCount := 0
readCount := 0
for scanner.Scan() {
line := scanner.Bytes()
lineCount++
var ev esloggerEvent
if err := json.Unmarshal(line, &ev); err != nil {
continue
}
name := eventName(&ev)
// Skip fork events (already processed in pass 1)
if name == "fork" {
continue
}
// Filter by PID set
pid := ev.Process.AuditToken.PID
if !pidSet[pid] {
continue
}
matchedLines++
// Extract path and classify operation
paths, class := classifyEsloggerEvent(&ev, name)
if class == opSkip || len(paths) == 0 {
continue
}
for _, path := range paths {
if shouldFilterPathMacOS(path, home) {
continue
}
switch class {
case opWrite:
writeCount++
if !seenWrite[path] {
seenWrite[path] = true
result.WritePaths = append(result.WritePaths, path)
}
case opRead:
readCount++
if !seenRead[path] {
seenRead[path] = true
result.ReadPaths = append(result.ReadPaths, path)
}
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading eslogger log: %w", err)
}
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Parsed eslogger log: %d lines, %d matched PIDs, %d writes, %d reads, %d unique write paths, %d unique read paths\n",
lineCount, matchedLines, writeCount, readCount, len(result.WritePaths), len(result.ReadPaths))
}
return result, nil
}
// forkRecord stores a parent→child PID relationship from a fork event.
type forkRecord struct {
parentPID int
childPID int
}
// scanForkEvents reads the log and extracts all fork parent→child PID pairs.
func scanForkEvents(logPath string) ([]forkRecord, error) {
f, err := os.Open(logPath) //nolint:gosec // daemon-controlled temp file path
if err != nil {
return nil, fmt.Errorf("failed to open eslogger log: %w", err)
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 256*1024), 4*1024*1024)
var forks []forkRecord
for scanner.Scan() {
line := scanner.Bytes()
// Quick pre-check to avoid parsing non-fork lines.
// Fork events have "fork" as a key in the event object.
if !strings.Contains(string(line), `"fork"`) {
continue
}
var ev esloggerEvent
if err := json.Unmarshal(line, &ev); err != nil {
continue
}
forkRaw, ok := ev.Event["fork"]
if !ok {
continue
}
var fe esloggerForkEvent
if err := json.Unmarshal(forkRaw, &fe); err != nil {
continue
}
forks = append(forks, forkRecord{
parentPID: ev.Process.AuditToken.PID,
childPID: fe.Child.AuditToken.PID,
})
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading eslogger log for fork events: %w", err)
}
return forks, nil
}
// classifyEsloggerEvent extracts paths and classifies the operation from an eslogger event.
// The event name is the key inside the event map (e.g., "open", "fork", "write").
func classifyEsloggerEvent(ev *esloggerEvent, name string) ([]string, opClass) {
eventRaw, ok := ev.Event[name]
if !ok {
return nil, opSkip
}
switch name {
case "open":
var oe esloggerOpenEvent
if err := json.Unmarshal(eventRaw, &oe); err != nil {
return nil, opSkip
}
path := oe.File.Path
if path == "" || oe.File.PathTruncated {
return nil, opSkip
}
if oe.Fflag&fwriteFlag != 0 {
return []string{path}, opWrite
}
return []string{path}, opRead
case "create":
var ce esloggerCreateEvent
if err := json.Unmarshal(eventRaw, &ce); err != nil {
return nil, opSkip
}
// create events use destination.existing_file or destination.new_path
if ce.Destination.ExistingFile != nil {
path := ce.Destination.ExistingFile.Path
if path != "" && !ce.Destination.ExistingFile.PathTruncated {
return []string{path}, opWrite
}
}
if ce.Destination.NewPath != nil {
dir := ce.Destination.NewPath.Dir.Path
filename := ce.Destination.NewPath.Filename
if dir != "" && filename != "" {
return []string{dir + "/" + filename}, opWrite
}
}
return nil, opSkip
case "write", "unlink", "truncate":
// These events use "target" not "file"
var te esloggerTargetEvent
if err := json.Unmarshal(eventRaw, &te); err != nil {
return nil, opSkip
}
path := te.Target.Path
if path == "" || te.Target.PathTruncated {
return nil, opSkip
}
return []string{path}, opWrite
case "rename":
var re esloggerRenameEvent
if err := json.Unmarshal(eventRaw, &re); err != nil {
return nil, opSkip
}
var paths []string
if re.Source.Path != "" && !re.Source.PathTruncated {
paths = append(paths, re.Source.Path)
}
if re.Destination.Path != "" && !re.Destination.PathTruncated {
paths = append(paths, re.Destination.Path)
}
if len(paths) == 0 {
return nil, opSkip
}
return paths, opWrite
case "link":
var le esloggerLinkEvent
if err := json.Unmarshal(eventRaw, &le); err != nil {
return nil, opSkip
}
var paths []string
if le.Source.Path != "" && !le.Source.PathTruncated {
paths = append(paths, le.Source.Path)
}
if le.TargetDir.Path != "" && !le.TargetDir.PathTruncated {
paths = append(paths, le.TargetDir.Path)
}
if len(paths) == 0 {
return nil, opSkip
}
return paths, opWrite
default:
return nil, opSkip
}
}
// shouldFilterPathMacOS returns true if a path should be excluded from macOS learning results.
func shouldFilterPathMacOS(path, home string) bool {
if path == "" || !strings.HasPrefix(path, "/") {
return true
}
// macOS system path prefixes to filter
systemPrefixes := []string{
"/dev/",
"/private/var/run/",
"/private/var/db/",
"/private/var/folders/",
"/System/",
"/Library/",
"/usr/lib/",
"/usr/share/",
"/private/etc/",
"/tmp/",
"/private/tmp/",
}
for _, prefix := range systemPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
// Filter .dylib files (macOS shared libraries)
if strings.HasSuffix(path, ".dylib") {
return true
}
// Filter greywall infrastructure files
if strings.Contains(path, "greywall-") {
return true
}
// Filter paths outside home directory
if home != "" && !strings.HasPrefix(path, home+"/") {
return true
}
// Filter exact home directory match
if path == home {
return true
}
// Filter shell infrastructure directories (PATH lookups, plugin dirs)
if home != "" {
shellInfraPrefixes := []string{
home + "/.antigen/",
home + "/.oh-my-zsh/",
home + "/.pyenv/shims/",
home + "/.bun/bin/",
home + "/.local/bin/",
}
for _, prefix := range shellInfraPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
}
return false
}