Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.Analyzers.Native/RuntimeCapture/MacOsDyldCaptureAdapter.cs
2026-01-04 22:49:53 +02:00

614 lines
19 KiB
C#

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;
/// <summary>
/// macOS runtime capture adapter using dyld interposition or dtrace to trace dylib loads.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[SupportedOSPlatform("macos")]
public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
{
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ConcurrentBag<RuntimeLoadEvent> _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;
/// <summary>
/// Creates a new macOS dyld capture adapter.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
/// <param name="guidProvider">Optional GUID provider for deterministic session IDs.</param>
public MacOsDyldCaptureAdapter(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
/// <inheritdoc />
public string AdapterId => "macos-dyld-interpose";
/// <inheritdoc />
public string DisplayName => "macOS dyld Interpose Tracer";
/// <inheritdoc />
public string Platform => "macos";
/// <inheritdoc />
public string CaptureMethod => "dyld-interpose";
/// <inheritdoc />
public CaptureState State
{
get { lock (_stateLock) return _state; }
}
/// <inheritdoc />
public string? SessionId { get; private set; }
/// <inheritdoc />
public event EventHandler<RuntimeLoadEventArgs>? LoadEventCaptured;
/// <inheritdoc />
public event EventHandler<CaptureStateChangedEventArgs>? StateChanged;
/// <inheritdoc />
public async Task<AdapterAvailability> 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<string>();
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: []);
}
/// <inheritdoc />
public async Task<string> 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;
}
}
/// <inheritdoc />
public async Task<RuntimeCaptureSession> 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;
}
}
/// <inheritdoc />
public (int EventCount, int BufferUsed, int BufferCapacity) GetStatistics()
{
var count = _events.Count;
return (count, count, _options?.BufferSize ?? 1000);
}
/// <inheritdoc />
public IReadOnlyList<RuntimeLoadEvent> GetCurrentEvents() => [.. _events];
/// <inheritdoc />
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<bool> 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<SipStatus> 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);
}
}