up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
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 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;
|
||||
|
||||
/// <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 = Guid.NewGuid().ToString("N");
|
||||
_startTime = DateTime.UtcNow;
|
||||
_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: DateTime.UtcNow,
|
||||
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: DateTime.UtcNow,
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user