614 lines
19 KiB
C#
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);
|
|
}
|
|
}
|