using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text.RegularExpressions; using StellaOps.Determinism; namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture; /// /// macOS runtime capture adapter using dyld interposition or dtrace to trace dylib loads. /// /// /// This adapter can use: /// - DYLD_INSERT_LIBRARIES for interposition (per-process, no root) /// - dtrace for system-wide tracing (requires root/SIP disabled) /// /// Requires: /// - macOS 10.5+ for DYLD_INSERT_LIBRARIES /// - SIP disabled or dtrace entitlement for dtrace /// /// In sandbox mode, uses mock events instead of actual tracing. /// [SupportedOSPlatform("macos")] public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter { private readonly TimeProvider _timeProvider; private readonly IGuidProvider _guidProvider; private readonly ConcurrentBag _events = []; private readonly object _stateLock = new(); private CaptureState _state = CaptureState.Idle; private RuntimeCaptureOptions? _options; private DateTime _startTime; private CancellationTokenSource? _captureCts; private Task? _captureTask; private Process? _dtraceProcess; private long _droppedEvents; private int _redactedPaths; /// /// Creates a new macOS dyld capture adapter. /// /// Optional time provider for deterministic timestamps. /// Optional GUID provider for deterministic session IDs. public MacOsDyldCaptureAdapter(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } /// public string AdapterId => "macos-dyld-interpose"; /// public string DisplayName => "macOS dyld Interpose Tracer"; /// public string Platform => "macos"; /// public string CaptureMethod => "dyld-interpose"; /// public CaptureState State { get { lock (_stateLock) return _state; } } /// public string? SessionId { get; private set; } /// public event EventHandler? LoadEventCaptured; /// public event EventHandler? StateChanged; /// public async Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) { if (!OperatingSystem.IsMacOS()) { return new AdapterAvailability( IsAvailable: false, Reason: "This adapter only works on macOS.", RequiresElevation: false, MissingDependencies: []); } var missingDeps = new List(); var requiresElevation = false; // Check macOS version (dyld interposition available since 10.5) var version = Environment.OSVersion.Version; if (version < new Version(10, 5)) { return new AdapterAvailability( IsAvailable: false, Reason: $"macOS version {version} is too old. dyld interposition requires 10.5+.", RequiresElevation: false, MissingDependencies: missingDeps); } // Check if dtrace is available (for system-wide tracing) var hasDtrace = await CheckCommandExistsAsync("dtrace", cancellationToken); if (!hasDtrace) { missingDeps.Add("dtrace"); } // Check SIP status for system-wide dtrace var sipStatus = await GetSipStatusAsync(cancellationToken); if (sipStatus == SipStatus.Enabled) { // dtrace is restricted, need root for limited functionality requiresElevation = true; } // Check for root for dtrace if (!IsRunningAsRoot()) { requiresElevation = true; } if (missingDeps.Count > 0) { return new AdapterAvailability( IsAvailable: false, Reason: $"Missing dependencies: {string.Join(", ", missingDeps)}", RequiresElevation: requiresElevation, MissingDependencies: missingDeps); } if (requiresElevation) { var reason = sipStatus == SipStatus.Enabled ? "dtrace requires root privileges. Full functionality requires SIP to be disabled." : "dtrace requires root privileges."; return new AdapterAvailability( IsAvailable: true, Reason: reason, RequiresElevation: true, MissingDependencies: []); } return new AdapterAvailability( IsAvailable: true, Reason: null, RequiresElevation: false, MissingDependencies: []); } /// public async Task StartCaptureAsync(RuntimeCaptureOptions options, CancellationToken cancellationToken = default) { var validationErrors = options.Validate(); if (validationErrors.Count > 0) throw new ArgumentException($"Invalid options: {string.Join("; ", validationErrors)}"); lock (_stateLock) { if (_state != CaptureState.Idle && _state != CaptureState.Stopped && _state != CaptureState.Faulted) throw new InvalidOperationException($"Cannot start capture in state {_state}."); SetState(CaptureState.Starting); } _options = options; _events.Clear(); _droppedEvents = 0; _redactedPaths = 0; SessionId = _guidProvider.NewGuid().ToString("N"); _startTime = _timeProvider.GetUtcNow().UtcDateTime; _captureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); try { if (options.Sandbox.Enabled && !options.Sandbox.AllowSystemTracing) { // Sandbox mode - use mock events _captureTask = RunSandboxCaptureAsync(_captureCts.Token); } else { // Real dtrace capture _captureTask = RunDtraceCaptureAsync(_captureCts.Token); } // Start the duration timer _ = Task.Run(async () => { try { await Task.Delay(options.MaxCaptureDuration, _captureCts.Token); await StopCaptureAsync(CancellationToken.None); } catch (OperationCanceledException) { // Expected when capture is stopped manually } }, _captureCts.Token); SetState(CaptureState.Running); return SessionId; } catch (Exception ex) { SetState(CaptureState.Faulted, ex); throw; } } /// public async Task StopCaptureAsync(CancellationToken cancellationToken = default) { lock (_stateLock) { if (_state != CaptureState.Running) throw new InvalidOperationException($"Cannot stop capture in state {_state}."); SetState(CaptureState.Stopping); } try { // Cancel capture _captureCts?.Cancel(); // Kill dtrace process if running if (_dtraceProcess is { HasExited: false }) { try { _dtraceProcess.Kill(true); await _dtraceProcess.WaitForExitAsync(cancellationToken); } catch { // Ignore kill errors } } // Wait for capture task if (_captureTask != null) { try { await _captureTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); } catch (TimeoutException) { // Task didn't complete in time } catch (OperationCanceledException) { // Expected } } var session = new RuntimeCaptureSession( SessionId: SessionId ?? "unknown", StartTime: _startTime, EndTime: _timeProvider.GetUtcNow().UtcDateTime, Platform: Platform, CaptureMethod: CaptureMethod, TargetProcessId: _options?.TargetProcessId, Events: [.. _events], TotalEventsDropped: _droppedEvents, RedactedPaths: _redactedPaths); SetState(CaptureState.Stopped); return session; } catch (Exception ex) { SetState(CaptureState.Faulted, ex); throw; } } /// public (int EventCount, int BufferUsed, int BufferCapacity) GetStatistics() { var count = _events.Count; return (count, count, _options?.BufferSize ?? 1000); } /// public IReadOnlyList GetCurrentEvents() => [.. _events]; /// public async ValueTask DisposeAsync() { if (_state == CaptureState.Running) { try { await StopCaptureAsync(); } catch { // Ignore errors during dispose } } _captureCts?.Dispose(); _dtraceProcess?.Dispose(); } private async Task RunSandboxCaptureAsync(CancellationToken cancellationToken) { // In sandbox mode, inject mock events if (_options?.Sandbox.MockEvents is { Count: > 0 } mockEvents) { foreach (var evt in mockEvents) { cancellationToken.ThrowIfCancellationRequested(); ProcessEvent(evt); await Task.Delay(10, cancellationToken); // Simulate event timing } } // Wait until cancelled try { await Task.Delay(Timeout.Infinite, cancellationToken); } catch (OperationCanceledException) { // Expected } } private async Task RunDtraceCaptureAsync(CancellationToken cancellationToken) { // Build dtrace script for dyld tracing var script = BuildDtraceScript(); var psi = new ProcessStartInfo { FileName = "dtrace", Arguments = $"-n '{script}'", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; if (_options?.TargetProcessId != null) { psi.Arguments = $"-p {_options.TargetProcessId} -n '{script}'"; } _dtraceProcess = new Process { StartInfo = psi }; _dtraceProcess.OutputDataReceived += OnDtraceOutput; _dtraceProcess.ErrorDataReceived += OnDtraceError; try { _dtraceProcess.Start(); _dtraceProcess.BeginOutputReadLine(); _dtraceProcess.BeginErrorReadLine(); await _dtraceProcess.WaitForExitAsync(cancellationToken); } catch (OperationCanceledException) { // Expected when stopping } } private static string BuildDtraceScript() { // This script traces dyld image loads // Using the dyld probes available on macOS return """ pid$target::dlopen:entry { printf("DLOPEN|%d|%d|%s\n", pid, tid, copyinstr(arg0)); } pid$target::dlopen:return { printf("DLOPEN_RET|%d|%d|%p|%d\n", pid, tid, arg1, arg1 == 0 ? errno : 0); } pid$target:libdyld.dylib:dlopen:entry { printf("DYLIB_DLOPEN|%d|%d|%s\n", pid, tid, copyinstr(arg0)); } """; } private void OnDtraceOutput(object sender, DataReceivedEventArgs e) { if (string.IsNullOrEmpty(e.Data)) return; try { var evt = ParseDtraceOutput(e.Data); if (evt != null) ProcessEvent(evt); } catch { Interlocked.Increment(ref _droppedEvents); } } private void OnDtraceError(object sender, DataReceivedEventArgs e) { // Log errors but don't fail capture if (!string.IsNullOrEmpty(e.Data)) { Debug.WriteLine($"dtrace error: {e.Data}"); } } private RuntimeLoadEvent? ParseDtraceOutput(string line) { var parts = line.Split('|'); if (parts.Length < 3) return null; if ((parts[0] == "DLOPEN" || parts[0] == "DYLIB_DLOPEN") && parts.Length >= 4) { var loadType = parts[0] == "DYLIB_DLOPEN" ? RuntimeLoadType.DylibLoad : RuntimeLoadType.MacOsDlopen; return new RuntimeLoadEvent( Timestamp: _timeProvider.GetUtcNow().UtcDateTime, ProcessId: int.Parse(parts[1]), ThreadId: int.Parse(parts[2]), LoadType: loadType, RequestedPath: parts[3], ResolvedPath: null, // Set on return probe LoadAddress: null, Success: true, // Updated on return probe ErrorCode: null, CallerModule: null, CallerAddress: null); } return null; } private void ProcessEvent(RuntimeLoadEvent evt) { // Apply include/exclude filters if (_options != null) { if (_options.IncludePatterns.Count > 0) { var matched = _options.IncludePatterns.Any(p => MatchGlob(evt.RequestedPath, p) || (evt.ResolvedPath != null && MatchGlob(evt.ResolvedPath, p))); if (!matched) return; } if (_options.ExcludePatterns.Any(p => MatchGlob(evt.RequestedPath, p) || (evt.ResolvedPath != null && MatchGlob(evt.ResolvedPath, p)))) { return; } // Apply redaction if (_options.Redaction.Enabled) { var requestedRedacted = _options.Redaction.ApplyRedaction(evt.RequestedPath, out var wasRedacted1); var resolvedRedacted = evt.ResolvedPath != null ? _options.Redaction.ApplyRedaction(evt.ResolvedPath, out var wasRedacted2) : null; if (wasRedacted1 || (evt.ResolvedPath != null && _options.Redaction.ApplyRedaction(evt.ResolvedPath, out _) != evt.ResolvedPath)) { Interlocked.Increment(ref _redactedPaths); evt = evt with { RequestedPath = requestedRedacted, ResolvedPath = resolvedRedacted }; } } // Check failures filter if (!_options.CaptureFailures && !evt.Success) return; } // Check buffer capacity if (_options != null && _events.Count >= _options.BufferSize) { Interlocked.Increment(ref _droppedEvents); return; } _events.Add(evt); LoadEventCaptured?.Invoke(this, new RuntimeLoadEventArgs { Event = evt }); } private void SetState(CaptureState newState, Exception? error = null) { CaptureState previous; lock (_stateLock) { previous = _state; _state = newState; } StateChanged?.Invoke(this, new CaptureStateChangedEventArgs { PreviousState = previous, NewState = newState, Error = error }); } private static async Task CheckCommandExistsAsync(string command, CancellationToken cancellationToken) { try { var psi = new ProcessStartInfo { FileName = "which", Arguments = command, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true, }; using var process = Process.Start(psi); if (process == null) return false; await process.WaitForExitAsync(cancellationToken); return process.ExitCode == 0; } catch { return false; } } private enum SipStatus { Unknown, Enabled, Disabled, } private static async Task GetSipStatusAsync(CancellationToken cancellationToken) { try { var psi = new ProcessStartInfo { FileName = "csrutil", Arguments = "status", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true, }; using var process = Process.Start(psi); if (process == null) return SipStatus.Unknown; var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); await process.WaitForExitAsync(cancellationToken); if (output.Contains("disabled", StringComparison.OrdinalIgnoreCase)) return SipStatus.Disabled; if (output.Contains("enabled", StringComparison.OrdinalIgnoreCase)) return SipStatus.Enabled; return SipStatus.Unknown; } catch { return SipStatus.Unknown; } } private static bool IsRunningAsRoot() { try { return geteuid() == 0; } catch { return false; } } [DllImport("libc", SetLastError = true)] private static extern uint geteuid(); private static bool MatchGlob(string path, string pattern) { var regexPattern = "^" + Regex.Escape(pattern) .Replace(@"\*\*", ".*") .Replace(@"\*", "[^/]*") .Replace(@"\?", ".") + "$"; return Regex.IsMatch(path, regexPattern, RegexOptions.IgnoreCase); } }