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
460 lines
12 KiB
Go
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
|
|
}
|