notify doctors work, audit work, new product advisory sprints
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Scanner Deno Analyzer Task Board
|
||||
|
||||
This board tracks audit follow-ups for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_apply.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-DENO-0001 | DONE | Remediated Deno runtime hardening, determinism fixes, and tests for the audit hotlist. |
|
||||
@@ -13,9 +13,10 @@ public sealed class ElfHardeningExtractor : IHardeningExtractor
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ElfHardeningExtractor(TimeProvider? timeProvider = null)
|
||||
public ElfHardeningExtractor(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
// ELF magic bytes
|
||||
|
||||
@@ -19,9 +19,10 @@ public sealed class MachoHardeningExtractor : IHardeningExtractor
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public MachoHardeningExtractor(TimeProvider? timeProvider = null)
|
||||
public MachoHardeningExtractor(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
// Mach-O magic numbers
|
||||
@@ -105,7 +106,7 @@ public sealed class MachoHardeningExtractor : IHardeningExtractor
|
||||
// Handle universal binaries - just extract first architecture for now
|
||||
if (magic is FAT_MAGIC or FAT_CIGAM)
|
||||
{
|
||||
var fatResult = ExtractFromFat(data, path, digest);
|
||||
var fatResult = await ExtractFromFatAsync(data, path, digest, ct);
|
||||
if (fatResult is not null)
|
||||
return fatResult;
|
||||
return CreateResult(path, digest, [], ["Universal binary: no supported architectures"]);
|
||||
@@ -233,7 +234,7 @@ public sealed class MachoHardeningExtractor : IHardeningExtractor
|
||||
/// <summary>
|
||||
/// Extract hardening info from the first slice of a universal (fat) binary.
|
||||
/// </summary>
|
||||
private BinaryHardeningFlags? ExtractFromFat(byte[] data, string path, string digest)
|
||||
private async Task<BinaryHardeningFlags?> ExtractFromFatAsync(byte[] data, string path, string digest, CancellationToken ct)
|
||||
{
|
||||
if (data.Length < 8) return null;
|
||||
|
||||
@@ -254,7 +255,7 @@ public sealed class MachoHardeningExtractor : IHardeningExtractor
|
||||
// Extract first architecture and re-parse
|
||||
var sliceData = data.AsSpan((int)archOffset, (int)archSize).ToArray();
|
||||
using var sliceStream = new MemoryStream(sliceData);
|
||||
return ExtractAsync(sliceStream, path, digest).GetAwaiter().GetResult();
|
||||
return await ExtractAsync(sliceStream, path, digest, ct);
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(byte[] data, int offset, bool littleEndian)
|
||||
|
||||
@@ -21,9 +21,10 @@ public sealed class PeHardeningExtractor : IHardeningExtractor
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PeHardeningExtractor(TimeProvider? timeProvider = null)
|
||||
public PeHardeningExtractor(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
// PE magic bytes: MZ (DOS header)
|
||||
|
||||
@@ -32,16 +32,17 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
|
||||
public OfflineBuildIdIndex(
|
||||
IOptions<BuildIdIndexOptions> options,
|
||||
ILogger<OfflineBuildIdIndex> logger,
|
||||
IDsseSigningService? dsseSigningService = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider timeProvider,
|
||||
IDsseSigningService? dsseSigningService = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_dsseSigningService = dsseSigningService;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -514,7 +514,8 @@ public static class PeImportParser
|
||||
{
|
||||
IgnoreWhitespace = true,
|
||||
IgnoreComments = true,
|
||||
DtdProcessing = DtdProcessing.Ignore,
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
XmlResolver = null,
|
||||
});
|
||||
|
||||
while (reader.Read())
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
|
||||
|
||||
internal static class CaptureDurationTimer
|
||||
{
|
||||
internal static Task RunAsync(
|
||||
TimeSpan duration,
|
||||
Func<CancellationToken, Task> stopAsync,
|
||||
CancellationToken captureToken,
|
||||
CancellationToken stopToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stopAsync);
|
||||
return RunCoreAsync(duration, stopAsync, captureToken, stopToken);
|
||||
}
|
||||
|
||||
private static async Task RunCoreAsync(
|
||||
TimeSpan duration,
|
||||
Func<CancellationToken, Task> stopAsync,
|
||||
CancellationToken captureToken,
|
||||
CancellationToken stopToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(duration, captureToken).ConfigureAwait(false);
|
||||
await stopAsync(stopToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
|
||||
|
||||
/// <summary>
|
||||
@@ -159,16 +161,19 @@ public static class RuntimeCaptureAdapterFactory
|
||||
/// Creates the appropriate capture adapter for the current platform.
|
||||
/// </summary>
|
||||
/// <returns>Platform-specific adapter or null if no adapter available.</returns>
|
||||
public static IRuntimeCaptureAdapter? CreateForCurrentPlatform()
|
||||
public static IRuntimeCaptureAdapter? CreateForCurrentPlatform(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(guidProvider);
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
return new LinuxEbpfCaptureAdapter();
|
||||
return new LinuxEbpfCaptureAdapter(timeProvider, guidProvider);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
return new WindowsEtwCaptureAdapter();
|
||||
return new WindowsEtwCaptureAdapter(timeProvider, guidProvider);
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return new MacOsDyldCaptureAdapter();
|
||||
return new MacOsDyldCaptureAdapter(timeProvider, guidProvider);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -177,18 +182,21 @@ public static class RuntimeCaptureAdapterFactory
|
||||
/// Gets all available adapters for the current platform.
|
||||
/// </summary>
|
||||
/// <returns>List of available adapters.</returns>
|
||||
public static IReadOnlyList<IRuntimeCaptureAdapter> GetAvailableAdapters()
|
||||
public static IReadOnlyList<IRuntimeCaptureAdapter> GetAvailableAdapters(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(guidProvider);
|
||||
|
||||
var adapters = new List<IRuntimeCaptureAdapter>();
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
adapters.Add(new LinuxEbpfCaptureAdapter());
|
||||
adapters.Add(new LinuxEbpfCaptureAdapter(timeProvider, guidProvider));
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
adapters.Add(new WindowsEtwCaptureAdapter());
|
||||
adapters.Add(new WindowsEtwCaptureAdapter(timeProvider, guidProvider));
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
adapters.Add(new MacOsDyldCaptureAdapter());
|
||||
adapters.Add(new MacOsDyldCaptureAdapter(timeProvider, guidProvider));
|
||||
|
||||
return adapters;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
@@ -32,6 +33,7 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
|
||||
private DateTime _startTime;
|
||||
private CancellationTokenSource? _captureCts;
|
||||
private Task? _captureTask;
|
||||
private Task? _durationTask;
|
||||
private Process? _bpftraceProcess;
|
||||
private long _droppedEvents;
|
||||
private int _redactedPaths;
|
||||
@@ -39,11 +41,12 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
|
||||
/// <summary>
|
||||
/// Creates a new Linux eBPF capture adapter.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
|
||||
/// <param name="guidProvider">Optional GUID provider for deterministic session IDs.</param>
|
||||
public LinuxEbpfCaptureAdapter(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
|
||||
public LinuxEbpfCaptureAdapter(TimeProvider timeProvider, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
@@ -184,18 +187,11 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
|
||||
}
|
||||
|
||||
// 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);
|
||||
_durationTask = CaptureDurationTimer.RunAsync(
|
||||
options.MaxCaptureDuration,
|
||||
ct => StopCaptureAsync(ct),
|
||||
_captureCts.Token,
|
||||
cancellationToken);
|
||||
|
||||
SetState(CaptureState.Running);
|
||||
return SessionId;
|
||||
@@ -254,6 +250,22 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
|
||||
}
|
||||
}
|
||||
|
||||
if (_durationTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _durationTask.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Timer did not complete in time
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when canceling
|
||||
}
|
||||
}
|
||||
|
||||
var session = new RuntimeCaptureSession(
|
||||
SessionId: SessionId ?? "unknown",
|
||||
StartTime: _startTime,
|
||||
@@ -333,20 +345,7 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
|
||||
// Build bpftrace script for dlopen tracing
|
||||
var script = BuildBpftraceScript();
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "bpftrace",
|
||||
Arguments = $"-e '{script}'",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
if (_options?.TargetProcessId != null)
|
||||
{
|
||||
psi.Arguments = $"-p {_options.TargetProcessId} -e '{script}'";
|
||||
}
|
||||
var psi = CreateBpftraceStartInfo(script, _options?.TargetProcessId);
|
||||
|
||||
_bpftraceProcess = new Process { StartInfo = psi };
|
||||
_bpftraceProcess.OutputDataReceived += OnBpftraceOutput;
|
||||
@@ -420,8 +419,8 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
|
||||
{
|
||||
return new RuntimeLoadEvent(
|
||||
Timestamp: _timeProvider.GetUtcNow().UtcDateTime,
|
||||
ProcessId: int.Parse(parts[1]),
|
||||
ThreadId: int.Parse(parts[2]),
|
||||
ProcessId: int.Parse(parts[1], CultureInfo.InvariantCulture),
|
||||
ThreadId: int.Parse(parts[2], CultureInfo.InvariantCulture),
|
||||
LoadType: RuntimeLoadType.Dlopen,
|
||||
RequestedPath: parts[3],
|
||||
ResolvedPath: null, // Set on return probe
|
||||
@@ -512,14 +511,17 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IsSafeToken(command))
|
||||
return false;
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "which",
|
||||
Arguments = command,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add(command);
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null)
|
||||
@@ -545,8 +547,8 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
|
||||
if (match.Success)
|
||||
{
|
||||
return new Version(
|
||||
int.Parse(match.Groups[1].Value),
|
||||
int.Parse(match.Groups[2].Value));
|
||||
int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture),
|
||||
int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -577,14 +579,17 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IsSafeToken(capability))
|
||||
return false;
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "capsh",
|
||||
Arguments = $"--print",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--print");
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null)
|
||||
@@ -609,4 +614,43 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
|
||||
|
||||
return Regex.IsMatch(path, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateBpftraceStartInfo(string script, int? targetProcessId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(script))
|
||||
throw new ArgumentException("Script cannot be empty.", nameof(script));
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "bpftrace",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
if (targetProcessId is int pid && pid > 0)
|
||||
{
|
||||
psi.ArgumentList.Add("-p");
|
||||
psi.ArgumentList.Add(pid.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
psi.ArgumentList.Add("-e");
|
||||
psi.ArgumentList.Add(script);
|
||||
return psi;
|
||||
}
|
||||
|
||||
private static bool IsSafeToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (!char.IsAsciiLetterOrDigit(ch) && ch != '-' && ch != '_' && ch != '.')
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -33,6 +34,7 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
|
||||
private DateTime _startTime;
|
||||
private CancellationTokenSource? _captureCts;
|
||||
private Task? _captureTask;
|
||||
private Task? _durationTask;
|
||||
private Process? _dtraceProcess;
|
||||
private long _droppedEvents;
|
||||
private int _redactedPaths;
|
||||
@@ -40,11 +42,12 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
|
||||
/// <summary>
|
||||
/// Creates a new macOS dyld capture adapter.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
/// <param name="timeProvider">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)
|
||||
public MacOsDyldCaptureAdapter(TimeProvider timeProvider, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
@@ -188,18 +191,11 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
|
||||
}
|
||||
|
||||
// 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);
|
||||
_durationTask = CaptureDurationTimer.RunAsync(
|
||||
options.MaxCaptureDuration,
|
||||
ct => StopCaptureAsync(ct),
|
||||
_captureCts.Token,
|
||||
cancellationToken);
|
||||
|
||||
SetState(CaptureState.Running);
|
||||
return SessionId;
|
||||
@@ -258,6 +254,22 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
|
||||
}
|
||||
}
|
||||
|
||||
if (_durationTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _durationTask.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Timer did not complete in time
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when canceling
|
||||
}
|
||||
}
|
||||
|
||||
var session = new RuntimeCaptureSession(
|
||||
SessionId: SessionId ?? "unknown",
|
||||
StartTime: _startTime,
|
||||
@@ -337,20 +349,7 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
|
||||
// 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}'";
|
||||
}
|
||||
var psi = CreateDtraceStartInfo(script, _options?.TargetProcessId);
|
||||
|
||||
_dtraceProcess = new Process { StartInfo = psi };
|
||||
_dtraceProcess.OutputDataReceived += OnDtraceOutput;
|
||||
@@ -432,8 +431,8 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
|
||||
|
||||
return new RuntimeLoadEvent(
|
||||
Timestamp: _timeProvider.GetUtcNow().UtcDateTime,
|
||||
ProcessId: int.Parse(parts[1]),
|
||||
ThreadId: int.Parse(parts[2]),
|
||||
ProcessId: int.Parse(parts[1], CultureInfo.InvariantCulture),
|
||||
ThreadId: int.Parse(parts[2], CultureInfo.InvariantCulture),
|
||||
LoadType: loadType,
|
||||
RequestedPath: parts[3],
|
||||
ResolvedPath: null, // Set on return probe
|
||||
@@ -524,14 +523,17 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IsSafeToken(command))
|
||||
return false;
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "which",
|
||||
Arguments = command,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add(command);
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null)
|
||||
@@ -557,14 +559,17 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IsSafeToken("csrutil"))
|
||||
return SipStatus.Unknown;
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "csrutil",
|
||||
Arguments = "status",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add("status");
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null)
|
||||
@@ -610,4 +615,43 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
|
||||
|
||||
return Regex.IsMatch(path, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateDtraceStartInfo(string script, int? targetProcessId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(script))
|
||||
throw new ArgumentException("Script cannot be empty.", nameof(script));
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dtrace",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
if (targetProcessId is int pid && pid > 0)
|
||||
{
|
||||
psi.ArgumentList.Add("-p");
|
||||
psi.ArgumentList.Add(pid.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
psi.ArgumentList.Add("-n");
|
||||
psi.ArgumentList.Add(script);
|
||||
return psi;
|
||||
}
|
||||
|
||||
private static bool IsSafeToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (!char.IsAsciiLetterOrDigit(ch) && ch != '-' && ch != '_' && ch != '.')
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,14 +48,15 @@ public static class RuntimeEvidenceAggregator
|
||||
/// <param name="runtimeEvidence">Runtime capture evidence.</param>
|
||||
/// <param name="staticEdges">Static analysis dependency edges.</param>
|
||||
/// <param name="heuristicEdges">Heuristic analysis edges.</param>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
|
||||
/// <returns>Merged evidence document.</returns>
|
||||
public static MergedEvidence MergeWithStaticAnalysis(
|
||||
RuntimeEvidence runtimeEvidence,
|
||||
IEnumerable<Observations.NativeObservationDeclaredEdge> staticEdges,
|
||||
IEnumerable<Observations.NativeObservationHeuristicEdge> heuristicEdges,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
var staticList = staticEdges.ToList();
|
||||
var heuristicList = heuristicEdges.ToList();
|
||||
|
||||
@@ -142,7 +143,6 @@ public static class RuntimeEvidenceAggregator
|
||||
}
|
||||
}
|
||||
|
||||
var tp = timeProvider ?? TimeProvider.System;
|
||||
return new MergedEvidence(
|
||||
ConfirmedEdges: confirmedEdges,
|
||||
StaticOnlyEdges: staticOnlyEdges,
|
||||
@@ -151,7 +151,7 @@ public static class RuntimeEvidenceAggregator
|
||||
TotalRuntimeEvents: runtimeEvidence.Sessions.Sum(s => s.Events.Count),
|
||||
TotalDroppedEvents: runtimeEvidence.Sessions.Sum(s => s.TotalEventsDropped),
|
||||
CaptureStartTime: runtimeEvidence.Sessions.Min(s => s.StartTime),
|
||||
CaptureEndTime: runtimeEvidence.Sessions.Max(s => s.EndTime ?? tp.GetUtcNow().UtcDateTime));
|
||||
CaptureEndTime: runtimeEvidence.Sessions.Max(s => s.EndTime ?? timeProvider.GetUtcNow().UtcDateTime));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
|
||||
@@ -274,12 +275,14 @@ public sealed record CollapsedStack
|
||||
/// Format: "container@digest;buildid=xxx;func;... count"
|
||||
/// </summary>
|
||||
/// <param name="line">The collapsed stack line to parse.</param>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
public static CollapsedStack? Parse(string line, TimeProvider? timeProvider = null)
|
||||
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
|
||||
public static CollapsedStack? Parse(string line, TimeProvider timeProvider)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
return null;
|
||||
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
var lastSpace = line.LastIndexOf(' ');
|
||||
if (lastSpace < 0)
|
||||
return null;
|
||||
@@ -287,7 +290,7 @@ public sealed record CollapsedStack
|
||||
var stackPart = line[..lastSpace];
|
||||
var countPart = line[(lastSpace + 1)..];
|
||||
|
||||
if (!int.TryParse(countPart, out var count))
|
||||
if (!int.TryParse(countPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count))
|
||||
return null;
|
||||
|
||||
var firstSemi = stackPart.IndexOf(';');
|
||||
@@ -307,8 +310,7 @@ public sealed record CollapsedStack
|
||||
}
|
||||
}
|
||||
|
||||
var tp = timeProvider ?? TimeProvider.System;
|
||||
var now = tp.GetUtcNow().UtcDateTime;
|
||||
var now = timeProvider.GetUtcNow().UtcDateTime;
|
||||
return new CollapsedStack
|
||||
{
|
||||
ContainerIdentifier = container,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Principal;
|
||||
@@ -31,20 +32,19 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
private DateTime _startTime;
|
||||
private CancellationTokenSource? _captureCts;
|
||||
private Task? _captureTask;
|
||||
#pragma warning disable CS0649 // Field is never assigned (assigned via Start/Stop ETW)
|
||||
private Process? _logmanProcess;
|
||||
#pragma warning restore CS0649
|
||||
private Task? _durationTask;
|
||||
private long _droppedEvents;
|
||||
private int _redactedPaths;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Windows ETW capture adapter.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
|
||||
/// <param name="guidProvider">Optional GUID provider for deterministic session IDs.</param>
|
||||
public WindowsEtwCaptureAdapter(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
|
||||
public WindowsEtwCaptureAdapter(TimeProvider timeProvider, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
@@ -106,16 +106,21 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
requiresElevation = true;
|
||||
}
|
||||
|
||||
// Check for logman.exe (built-in ETW management tool)
|
||||
var logmanPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.System),
|
||||
"logman.exe");
|
||||
// Check for logman.exe and tracerpt.exe (built-in ETW tools)
|
||||
var systemDir = Environment.GetFolderPath(Environment.SpecialFolder.System);
|
||||
var logmanPath = Path.Combine(systemDir, "logman.exe");
|
||||
var tracerptPath = Path.Combine(systemDir, "tracerpt.exe");
|
||||
|
||||
if (!File.Exists(logmanPath))
|
||||
{
|
||||
missingDeps.Add("logman.exe");
|
||||
}
|
||||
|
||||
if (!File.Exists(tracerptPath))
|
||||
{
|
||||
missingDeps.Add("tracerpt.exe");
|
||||
}
|
||||
|
||||
if (missingDeps.Count > 0)
|
||||
{
|
||||
return Task.FromResult(new AdapterAvailability(
|
||||
@@ -178,18 +183,11 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
}
|
||||
|
||||
// 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);
|
||||
_durationTask = CaptureDurationTimer.RunAsync(
|
||||
options.MaxCaptureDuration,
|
||||
ct => StopCaptureAsync(ct),
|
||||
_captureCts.Token,
|
||||
cancellationToken);
|
||||
|
||||
SetState(CaptureState.Running);
|
||||
return SessionId;
|
||||
@@ -220,20 +218,6 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
// Stop ETW session
|
||||
await StopEtwSessionAsync(cancellationToken);
|
||||
|
||||
// Kill logman process if running
|
||||
if (_logmanProcess is { HasExited: false })
|
||||
{
|
||||
try
|
||||
{
|
||||
_logmanProcess.Kill(true);
|
||||
await _logmanProcess.WaitForExitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore kill errors
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for capture task
|
||||
if (_captureTask != null)
|
||||
{
|
||||
@@ -251,6 +235,22 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
}
|
||||
}
|
||||
|
||||
if (_durationTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _durationTask.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Timer did not complete in time
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when canceling
|
||||
}
|
||||
}
|
||||
|
||||
var session = new RuntimeCaptureSession(
|
||||
SessionId: SessionId ?? "unknown",
|
||||
StartTime: _startTime,
|
||||
@@ -298,7 +298,6 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
}
|
||||
|
||||
_captureCts?.Dispose();
|
||||
_logmanProcess?.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunSandboxCaptureAsync(CancellationToken cancellationToken)
|
||||
@@ -367,17 +366,17 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
{
|
||||
// Create ETW session for kernel image load events
|
||||
// Provider GUID for Microsoft-Windows-Kernel-Process: {22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}
|
||||
var args = $"create trace \"{sessionName}\" -o \"{etlPath}\" -p \"Microsoft-Windows-Kernel-Process\" 0x10 -ets";
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "logman.exe",
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
var psi = CreateLogmanStartInfo(
|
||||
sessionName,
|
||||
"create",
|
||||
"trace",
|
||||
sessionName,
|
||||
"-o",
|
||||
etlPath,
|
||||
"-p",
|
||||
"Microsoft-Windows-Kernel-Process",
|
||||
"0x10",
|
||||
"-ets");
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null)
|
||||
@@ -399,15 +398,11 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
|
||||
var sessionName = $"StellaOps_ImageLoad_{SessionId}";
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "logman.exe",
|
||||
Arguments = $"stop \"{sessionName}\" -ets",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
var psi = CreateLogmanStartInfo(
|
||||
sessionName,
|
||||
"stop",
|
||||
sessionName,
|
||||
"-ets");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -433,15 +428,7 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "tracerpt.exe",
|
||||
Arguments = $"\"{etlPath}\" -o \"{xmlPath}\" -of XML -summary -report",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
var psi = CreateTracerptStartInfo(etlPath, xmlPath);
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process != null)
|
||||
@@ -482,8 +469,13 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
foreach (Match match in imageLoadPattern.Matches(content))
|
||||
{
|
||||
var imagePath = match.Groups[1].Value;
|
||||
var processId = int.Parse(match.Groups[2].Value);
|
||||
var imageBase = Convert.ToUInt64(match.Groups[3].Value, 16);
|
||||
var processId = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||
var imageBaseText = match.Groups[3].Value;
|
||||
if (imageBaseText.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
imageBaseText = imageBaseText[2..];
|
||||
}
|
||||
var imageBase = ulong.Parse(imageBaseText, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
|
||||
// Skip if filtering by process and doesn't match
|
||||
if (_options?.TargetProcessId != null && processId != _options.TargetProcessId)
|
||||
@@ -597,6 +589,81 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateLogmanStartInfo(string sessionName, params string[] args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionName))
|
||||
throw new ArgumentException("Session name cannot be empty.", nameof(sessionName));
|
||||
|
||||
if (!IsSafeToken(sessionName))
|
||||
throw new InvalidOperationException("Session name contains unsupported characters.");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = GetSystemToolPath("logman.exe"),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
foreach (var arg in args)
|
||||
{
|
||||
psi.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
return psi;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateTracerptStartInfo(string etlPath, string xmlPath)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = GetSystemToolPath("tracerpt.exe"),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
psi.ArgumentList.Add(etlPath);
|
||||
psi.ArgumentList.Add("-o");
|
||||
psi.ArgumentList.Add(xmlPath);
|
||||
psi.ArgumentList.Add("-of");
|
||||
psi.ArgumentList.Add("XML");
|
||||
psi.ArgumentList.Add("-summary");
|
||||
psi.ArgumentList.Add("-report");
|
||||
|
||||
return psi;
|
||||
}
|
||||
|
||||
private static string GetSystemToolPath(string toolName)
|
||||
{
|
||||
if (!IsSafeToken(toolName))
|
||||
throw new InvalidOperationException("Tool name contains unsupported characters.");
|
||||
|
||||
var systemDir = Environment.GetFolderPath(Environment.SpecialFolder.System);
|
||||
var path = Path.Combine(systemDir, toolName);
|
||||
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException($"Required tool not found: {toolName}", path);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static bool IsSafeToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (!char.IsAsciiLetterOrDigit(ch) && ch != '-' && ch != '_' && ch != '.')
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool MatchGlob(string path, string pattern)
|
||||
{
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Analyzers.Native.Plugin;
|
||||
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
|
||||
|
||||
@@ -84,6 +85,7 @@ public static class ServiceCollectionExtensions
|
||||
this IServiceCollection services,
|
||||
Action<RuntimeCaptureOptions>? configure = null)
|
||||
{
|
||||
services.AddDeterminismDefaults();
|
||||
var optionsBuilder = services.AddOptions<RuntimeCaptureOptions>();
|
||||
|
||||
if (configure != null)
|
||||
@@ -94,7 +96,9 @@ public static class ServiceCollectionExtensions
|
||||
// Register platform-appropriate capture adapter
|
||||
services.TryAddSingleton<IRuntimeCaptureAdapter>(sp =>
|
||||
{
|
||||
var adapter = RuntimeCaptureAdapterFactory.CreateForCurrentPlatform();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
var guidProvider = sp.GetRequiredService<IGuidProvider>();
|
||||
var adapter = RuntimeCaptureAdapterFactory.CreateForCurrentPlatform(timeProvider, guidProvider);
|
||||
if (adapter == null)
|
||||
{
|
||||
throw new PlatformNotSupportedException(
|
||||
|
||||
@@ -93,7 +93,7 @@ internal static class Program
|
||||
|
||||
var result = await casClient.VerifyWriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"handshake ok: {manifest.Id}@{manifest.Version} → {result.Algorithm}:{result.Digest}");
|
||||
Console.WriteLine($"handshake ok: {manifest.Id}@{manifest.Version} -> {result.Algorithm}:{result.Digest}");
|
||||
Console.WriteLine(result.Path);
|
||||
return 0;
|
||||
}
|
||||
@@ -260,8 +260,9 @@ internal static class Program
|
||||
|
||||
if (attestorUri is not null)
|
||||
{
|
||||
using var httpClient = CreateAttestorHttpClient(attestorUri, attestorToken, attestorInsecure);
|
||||
var attestorClient = new AttestorClient(httpClient);
|
||||
var allowInsecure = attestorInsecure && ShouldAllowInsecureAttestor(attestorUri);
|
||||
using var attestorScope = CreateAttestorHttpClientScope(attestorUri, attestorToken, allowInsecure);
|
||||
var attestorClient = new AttestorClient(attestorScope.Client);
|
||||
await attestorClient.SendPlaceholderAsync(attestorUri, document, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -340,7 +341,7 @@ internal static class Program
|
||||
?? generatorVersion;
|
||||
var workerInstance = GetOption(args, "--surface-worker-instance")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_WORKER_INSTANCE")
|
||||
?? Environment.MachineName;
|
||||
?? component;
|
||||
var attemptValue = GetOption(args, "--surface-attempt")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ATTEMPT");
|
||||
var attempt = 1;
|
||||
@@ -445,7 +446,7 @@ internal static class Program
|
||||
Component: "Scanner.BuildXPlugin",
|
||||
SecretType: "attestation");
|
||||
|
||||
using var handle = secretProvider.GetAsync(request).AsTask().GetAwaiter().GetResult();
|
||||
using var handle = secretProvider.Get(request);
|
||||
var secret = SurfaceSecretParser.ParseAttestationSecret(handle);
|
||||
|
||||
// Return the API key or token for attestor authentication
|
||||
@@ -498,7 +499,7 @@ internal static class Program
|
||||
Component: "Scanner.BuildXPlugin",
|
||||
SecretType: "cas-access");
|
||||
|
||||
using var handle = secretProvider.GetAsync(request).AsTask().GetAwaiter().GetResult();
|
||||
using var handle = secretProvider.Get(request);
|
||||
return SurfaceSecretParser.ParseCasAccessSecret(handle);
|
||||
}
|
||||
catch
|
||||
@@ -556,31 +557,71 @@ internal static class Program
|
||||
return value;
|
||||
}
|
||||
|
||||
private static HttpClient CreateAttestorHttpClient(Uri attestorUri, string? bearerToken, bool insecure)
|
||||
private static bool ShouldAllowInsecureAttestor(Uri attestorUri)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
if (!string.Equals(attestorUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
CheckCertificateRevocationList = true,
|
||||
};
|
||||
Console.Error.WriteLine("Attestor insecure flag ignored for non-HTTPS endpoint.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (insecure && string.Equals(attestorUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.Error.WriteLine("WARNING: Attestor TLS verification disabled; use only for dev/test.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static AttestorHttpClientScope CreateAttestorHttpClientScope(Uri attestorUri, string? bearerToken, bool insecure)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient("attestor", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bearerToken))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
}
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(() =>
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
CheckCertificateRevocationList = true
|
||||
};
|
||||
|
||||
if (insecure && string.Equals(attestorUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
#pragma warning disable S4830 // Explicitly gated by --attestor-insecure flag/env for dev/test usage.
|
||||
handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
|
||||
handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
|
||||
#pragma warning restore S4830
|
||||
}
|
||||
|
||||
return handler;
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var factory = provider.GetRequiredService<IHttpClientFactory>();
|
||||
var client = factory.CreateClient("attestor");
|
||||
return new AttestorHttpClientScope(provider, client);
|
||||
}
|
||||
|
||||
private sealed class AttestorHttpClientScope : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public AttestorHttpClientScope(ServiceProvider provider, HttpClient client)
|
||||
{
|
||||
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
|
||||
Client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
var client = new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
public HttpClient Client { get; }
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bearerToken))
|
||||
public void Dispose()
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
Client.Dispose();
|
||||
_provider.Dispose();
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Canonical.Json\\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.FS\\StellaOps.Scanner.Surface.FS.csproj" />
|
||||
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.Secrets\\StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.Env\\StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -2,10 +2,12 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
|
||||
@@ -16,7 +18,8 @@ internal sealed class SurfaceManifestWriter
|
||||
private static readonly JsonSerializerOptions ManifestSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
WriteIndented = false,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
@@ -54,7 +57,7 @@ internal sealed class SurfaceManifestWriter
|
||||
? null
|
||||
: options.ComponentVersion.Trim();
|
||||
var workerInstance = string.IsNullOrWhiteSpace(options.WorkerInstance)
|
||||
? Environment.MachineName
|
||||
? component
|
||||
: options.WorkerInstance.Trim();
|
||||
var attempt = options.Attempt <= 0 ? 1 : options.Attempt;
|
||||
var scanId = string.IsNullOrWhiteSpace(options.ScanId)
|
||||
@@ -129,7 +132,7 @@ internal sealed class SurfaceManifestWriter
|
||||
Artifacts = orderedArtifacts
|
||||
};
|
||||
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifestDocument, ManifestSerializerOptions);
|
||||
var manifestBytes = CanonJson.Canonicalize(manifestDocument, ManifestSerializerOptions);
|
||||
var manifestDigest = SurfaceCasLayout.ComputeDigest(_hash, manifestBytes);
|
||||
var manifestKey = SurfaceCasLayout.BuildObjectKey(rootPrefix, SurfaceCasKind.Manifest, manifestDigest);
|
||||
var manifestPath = await SurfaceCasLayout.WriteBytesAsync(cacheRoot, manifestKey, manifestBytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
public readonly record struct ScanId(string Value)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new ScanId with a random GUID value.
|
||||
/// Creates a new ScanId with a provided GUID generator.
|
||||
/// </summary>
|
||||
public static ScanId New() => new(Guid.NewGuid().ToString("D"));
|
||||
public static ScanId New(IGuidProvider guidProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(guidProvider);
|
||||
return new ScanId(guidProvider.NewGuid().ToString("D"));
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
@@ -26,12 +27,12 @@ internal static class HealthEndpoints
|
||||
group.MapGet("/healthz", HandleHealth)
|
||||
.WithName("scanner.health")
|
||||
.Produces<HealthDocument>(StatusCodes.Status200OK)
|
||||
.AllowAnonymous();
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
group.MapGet("/readyz", HandleReady)
|
||||
.WithName("scanner.ready")
|
||||
.Produces<ReadyDocument>(StatusCodes.Status200OK)
|
||||
.AllowAnonymous();
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static IResult HandleHealth(
|
||||
|
||||
@@ -20,6 +20,7 @@ using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
// Suppress ASPDEPR002 for current minimal API route usage; revisit during endpoint modernization.
|
||||
#pragma warning disable ASPDEPR002
|
||||
|
||||
internal static class PolicyEndpoints
|
||||
|
||||
@@ -16,6 +16,7 @@ using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
// Suppress ASPDEPR002 for current minimal API route usage; revisit during endpoint modernization.
|
||||
#pragma warning disable ASPDEPR002
|
||||
|
||||
internal static class ReportEndpoints
|
||||
|
||||
@@ -134,45 +134,51 @@ internal static class WebhookEndpoints
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source.WebhookSecretRef))
|
||||
{
|
||||
logger.LogWarning("Webhook secret not configured for source {SourceId}", sourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Webhook secret is not configured",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
// Determine signature to use
|
||||
var signature = signatureSha256 ?? signatureSha1 ?? gitlabToken ?? ExtractBearerToken(authorization);
|
||||
|
||||
// Verify signature if source has a webhook secret reference
|
||||
if (!string.IsNullOrEmpty(source.WebhookSecretRef))
|
||||
if (string.IsNullOrEmpty(signature))
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature))
|
||||
{
|
||||
logger.LogWarning("Webhook received without signature for source {SourceId}", sourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Missing webhook signature",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
logger.LogWarning("Webhook received without signature for source {SourceId}", sourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Missing webhook signature",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
// Resolve the webhook secret from the credential store
|
||||
var secretCredential = await credentialResolver.ResolveAsync(source.WebhookSecretRef, ct);
|
||||
var webhookSecret = secretCredential?.Token ?? secretCredential?.Password;
|
||||
// Resolve the webhook secret from the credential store
|
||||
var secretCredential = await credentialResolver.ResolveAsync(source.WebhookSecretRef, ct);
|
||||
var webhookSecret = secretCredential?.Token ?? secretCredential?.Password;
|
||||
|
||||
if (string.IsNullOrEmpty(webhookSecret))
|
||||
{
|
||||
logger.LogWarning("Failed to resolve webhook secret for source {SourceId}", sourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.InternalError,
|
||||
"Failed to resolve webhook secret",
|
||||
StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
if (string.IsNullOrEmpty(webhookSecret))
|
||||
{
|
||||
logger.LogWarning("Failed to resolve webhook secret for source {SourceId}", sourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.InternalError,
|
||||
"Failed to resolve webhook secret",
|
||||
StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
if (!webhookHandler.VerifyWebhookSignature(payloadBytes, signature, webhookSecret))
|
||||
{
|
||||
logger.LogWarning("Invalid webhook signature for source {SourceId}", sourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Invalid webhook signature",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
if (!webhookHandler.VerifyWebhookSignature(payloadBytes, signature, webhookSecret))
|
||||
{
|
||||
logger.LogWarning("Invalid webhook signature for source {SourceId}", sourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Invalid webhook signature",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
// Parse the payload
|
||||
@@ -446,6 +452,16 @@ internal static class WebhookEndpoints
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source.WebhookSecretRef))
|
||||
{
|
||||
logger.LogWarning("Webhook secret not configured for source {SourceId}", source.SourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Webhook secret is not configured",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
// Get signature from header
|
||||
string? signature = signatureHeader switch
|
||||
{
|
||||
@@ -456,42 +472,38 @@ internal static class WebhookEndpoints
|
||||
_ => null
|
||||
};
|
||||
|
||||
// Verify signature if source has a webhook secret reference
|
||||
if (!string.IsNullOrEmpty(source.WebhookSecretRef))
|
||||
if (string.IsNullOrEmpty(signature))
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature))
|
||||
{
|
||||
logger.LogWarning("Webhook received without signature for source {SourceId}", source.SourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Missing webhook signature",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
logger.LogWarning("Webhook received without signature for source {SourceId}", source.SourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Missing webhook signature",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
// Resolve the webhook secret from the credential store
|
||||
var secretCredential = await credentialResolver.ResolveAsync(source.WebhookSecretRef, ct);
|
||||
var webhookSecret = secretCredential?.Token ?? secretCredential?.Password;
|
||||
// Resolve the webhook secret from the credential store
|
||||
var secretCredential = await credentialResolver.ResolveAsync(source.WebhookSecretRef, ct);
|
||||
var webhookSecret = secretCredential?.Token ?? secretCredential?.Password;
|
||||
|
||||
if (string.IsNullOrEmpty(webhookSecret))
|
||||
{
|
||||
logger.LogWarning("Failed to resolve webhook secret for source {SourceId}", source.SourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.InternalError,
|
||||
"Failed to resolve webhook secret",
|
||||
StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
if (string.IsNullOrEmpty(webhookSecret))
|
||||
{
|
||||
logger.LogWarning("Failed to resolve webhook secret for source {SourceId}", source.SourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.InternalError,
|
||||
"Failed to resolve webhook secret",
|
||||
StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
if (!webhookHandler.VerifyWebhookSignature(payloadBytes, signature, webhookSecret))
|
||||
{
|
||||
logger.LogWarning("Invalid webhook signature for source {SourceId}", source.SourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Invalid webhook signature",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
if (!webhookHandler.VerifyWebhookSignature(payloadBytes, signature, webhookSecret))
|
||||
{
|
||||
logger.LogWarning("Invalid webhook signature for source {SourceId}", source.SourceId);
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Invalid webhook signature",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
// Parse the payload
|
||||
|
||||
@@ -44,7 +44,7 @@ internal sealed class ScannerSurfaceSecretConfigurator : IConfigureOptions<Scann
|
||||
CasAccessSecret? secret = null;
|
||||
try
|
||||
{
|
||||
using var handle = _secretProvider.GetAsync(request).AsTask().GetAwaiter().GetResult();
|
||||
using var handle = _secretProvider.Get(request);
|
||||
secret = SurfaceSecretParser.ParseCasAccessSecret(handle);
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
@@ -74,7 +74,7 @@ internal sealed class ScannerSurfaceSecretConfigurator : IConfigureOptions<Scann
|
||||
RegistryAccessSecret? secret = null;
|
||||
try
|
||||
{
|
||||
using var handle = _secretProvider.GetAsync(request).AsTask().GetAwaiter().GetResult();
|
||||
using var handle = _secretProvider.Get(request);
|
||||
secret = SurfaceSecretParser.ParseRegistryAccessSecret(handle);
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
@@ -143,7 +143,7 @@ internal sealed class ScannerSurfaceSecretConfigurator : IConfigureOptions<Scann
|
||||
AttestationSecret? secret = null;
|
||||
try
|
||||
{
|
||||
using var handle = _secretProvider.GetAsync(request).AsTask().GetAwaiter().GetResult();
|
||||
using var handle = _secretProvider.Get(request);
|
||||
secret = SurfaceSecretParser.ParseAttestationSecret(handle);
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
@@ -120,6 +121,7 @@ else
|
||||
{
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
}
|
||||
builder.Services.AddDeterminismDefaults();
|
||||
builder.Services.AddScannerCache(builder.Configuration);
|
||||
builder.Services.AddSingleton<ServiceStatus>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Serialization;
|
||||
|
||||
internal static class OrchestratorEventSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
|
||||
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = CreateOptions();
|
||||
private static readonly JsonSerializerOptions PrettyOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
public static string Serialize(OrchestratorEvent @event)
|
||||
=> JsonSerializer.Serialize(@event, CompactOptions);
|
||||
=> Encoding.UTF8.GetString(CanonJson.Canonicalize(@event, CanonicalOptions));
|
||||
|
||||
public static string SerializeIndented(OrchestratorEvent @event)
|
||||
=> JsonSerializer.Serialize(@event, PrettyOptions);
|
||||
{
|
||||
var canonicalBytes = CanonJson.Canonicalize(@event, CanonicalOptions);
|
||||
using var document = JsonDocument.Parse(canonicalBytes);
|
||||
return JsonSerializer.Serialize(document.RootElement, PrettyOptions);
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateOptions(bool writeIndented)
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = writeIndented,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
|
||||
|
||||
@@ -15,6 +15,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
@@ -28,6 +29,7 @@ public sealed class HumanApprovalAttestationService : IHumanApprovalAttestationS
|
||||
private readonly ILogger<HumanApprovalAttestationService> _logger;
|
||||
private readonly HumanApprovalAttestationOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory attestation store. In production, this would be backed by a database.
|
||||
@@ -41,10 +43,12 @@ public sealed class HumanApprovalAttestationService : IHumanApprovalAttestationS
|
||||
public HumanApprovalAttestationService(
|
||||
ILogger<HumanApprovalAttestationService> logger,
|
||||
IOptions<HumanApprovalAttestationOptions> options,
|
||||
IGuidProvider guidProvider,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
@@ -79,7 +83,7 @@ public sealed class HumanApprovalAttestationService : IHumanApprovalAttestationS
|
||||
var ttl = input.ApprovalTtl ?? TimeSpan.FromDays(_options.DefaultApprovalTtlDays);
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var approvalId = $"approval-{Guid.NewGuid():N}";
|
||||
var approvalId = $"approval-{_guidProvider.NewGuid():N}";
|
||||
|
||||
var statement = BuildStatement(input, approvalId, now, expiresAt);
|
||||
var attestationId = ComputeAttestationId(statement);
|
||||
|
||||
@@ -15,12 +15,14 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
public sealed class LayerSbomService : ILayerSbomService
|
||||
{
|
||||
private readonly ICompositionRecipeService _recipeService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// In-memory cache for layer SBOMs (would be replaced with CAS in production)
|
||||
private static readonly ConcurrentDictionary<string, LayerSbomStore> LayerSbomCache = new(StringComparer.Ordinal);
|
||||
|
||||
public LayerSbomService(ICompositionRecipeService? recipeService = null)
|
||||
public LayerSbomService(TimeProvider timeProvider, ICompositionRecipeService? recipeService = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_recipeService = recipeService ?? new CompositionRecipeService();
|
||||
}
|
||||
|
||||
@@ -166,7 +168,7 @@ public sealed class LayerSbomService : ILayerSbomService
|
||||
{
|
||||
ScanId = scanId,
|
||||
ImageDigest = imageDigest,
|
||||
CreatedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
CreatedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
|
||||
Recipe = new CompositionRecipe
|
||||
{
|
||||
Version = "1.0.0",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
@@ -13,6 +14,13 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
/// </summary>
|
||||
internal sealed class NullGitHubCodeScanningService : IGitHubCodeScanningService
|
||||
{
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public NullGitHubCodeScanningService(IGuidProvider guidProvider)
|
||||
{
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
public Task<GitHubUploadResult> UploadSarifAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
@@ -24,7 +32,7 @@ internal sealed class NullGitHubCodeScanningService : IGitHubCodeScanningService
|
||||
// Return a mock result for development/testing
|
||||
return Task.FromResult(new GitHubUploadResult
|
||||
{
|
||||
SarifId = $"mock-sarif-{Guid.NewGuid():N}",
|
||||
SarifId = $"mock-sarif-{_guidProvider.NewGuid():N}",
|
||||
Url = null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestation;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
@@ -186,7 +187,7 @@ public sealed class OfflineAttestationVerifier : IOfflineAttestationVerifier
|
||||
}
|
||||
|
||||
// Compute PAE (Pre-Authentication Encoding) per DSSE spec
|
||||
var pae = ComputePae(envelope.PayloadType, payloadBytes);
|
||||
var pae = DsseHelper.PreAuthenticationEncoding(envelope.PayloadType, payloadBytes);
|
||||
|
||||
// Try to verify at least one signature
|
||||
foreach (var sig in envelope.Signatures)
|
||||
@@ -587,29 +588,6 @@ public sealed class OfflineAttestationVerifier : IOfflineAttestationVerifier
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputePae(string payloadType, byte[] payload)
|
||||
{
|
||||
// Pre-Authentication Encoding per DSSE spec:
|
||||
// PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
|
||||
const string DssePrefix = "DSSEv1";
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms);
|
||||
|
||||
writer.Write(Encoding.UTF8.GetBytes(DssePrefix));
|
||||
writer.Write((byte)' ');
|
||||
writer.Write(BitConverter.GetBytes((long)typeBytes.Length));
|
||||
writer.Write((byte)' ');
|
||||
writer.Write(typeBytes);
|
||||
writer.Write((byte)' ');
|
||||
writer.Write(BitConverter.GetBytes((long)payload.Length));
|
||||
writer.Write((byte)' ');
|
||||
writer.Write(payload);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static string? ExtractSignerIdentity(X509Certificate2 cert)
|
||||
{
|
||||
// Try to get SAN (Subject Alternative Name) email
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestation;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
@@ -257,7 +258,8 @@ internal sealed class OfflineKitImportService
|
||||
}
|
||||
|
||||
var trustRoots = BuildTrustRoots(resolution, options.TrustRootDirectory ?? string.Empty);
|
||||
var pae = BuildPreAuthEncoding(envelope.PayloadType, envelope.Payload);
|
||||
var payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
var pae = DsseHelper.PreAuthenticationEncoding(envelope.PayloadType, payloadBytes);
|
||||
|
||||
var verified = 0;
|
||||
foreach (var signature in envelope.Signatures)
|
||||
@@ -310,26 +312,6 @@ internal sealed class OfflineKitImportService
|
||||
PublicKeys: publicKeys);
|
||||
}
|
||||
|
||||
private static byte[] BuildPreAuthEncoding(string payloadType, string payloadBase64)
|
||||
{
|
||||
const string paePrefix = "DSSEv1";
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64);
|
||||
var parts = new[] { paePrefix, payloadType, Encoding.UTF8.GetString(payloadBytes) };
|
||||
|
||||
var paeBuilder = new StringBuilder();
|
||||
paeBuilder.Append("PAE:");
|
||||
paeBuilder.Append(parts.Length);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part.Length);
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetBytes(paeBuilder.ToString());
|
||||
}
|
||||
|
||||
private static bool TryVerifySignature(TrustRootConfig trustRoots, DsseSignature signature, byte[] pae)
|
||||
{
|
||||
if (!trustRoots.PublicKeys.TryGetValue(signature.KeyId, out var keyBytes))
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
@@ -29,6 +30,7 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
|
||||
private readonly IPlatformEventPublisher _publisher;
|
||||
private readonly IClassificationChangeTracker _classificationChangeTracker;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ReportEventDispatcher> _logger;
|
||||
private readonly string[] _apiBaseSegments;
|
||||
@@ -43,11 +45,13 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
IPlatformEventPublisher publisher,
|
||||
IClassificationChangeTracker classificationChangeTracker,
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
IGuidProvider guidProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ReportEventDispatcher> logger)
|
||||
{
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_classificationChangeTracker = classificationChangeTracker ?? throw new ArgumentNullException(nameof(classificationChangeTracker));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
@@ -102,7 +106,7 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
|
||||
var reportEvent = new OrchestratorEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventId = _guidProvider.NewGuid(),
|
||||
Kind = OrchestratorEventKinds.ScannerReportReady,
|
||||
Version = 1,
|
||||
Tenant = tenant,
|
||||
@@ -124,7 +128,7 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
|
||||
var scanCompletedEvent = new OrchestratorEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventId = _guidProvider.NewGuid(),
|
||||
Kind = OrchestratorEventKinds.ScannerScanCompleted,
|
||||
Version = 1,
|
||||
Tenant = tenant,
|
||||
|
||||
@@ -3,10 +3,12 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
@@ -29,7 +31,8 @@ internal sealed class SurfacePointerService : ISurfacePointerService
|
||||
private static readonly JsonSerializerOptions ManifestSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
WriteIndented = false,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
private readonly LinkRepository _linkRepository;
|
||||
@@ -152,7 +155,7 @@ internal sealed class SurfacePointerService : ISurfacePointerService
|
||||
Artifacts = orderedArtifacts
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.SerializeToUtf8Bytes(manifest, ManifestSerializerOptions);
|
||||
var manifestJson = CanonJson.Canonicalize(manifest, ManifestSerializerOptions);
|
||||
var manifestDigest = ComputeDigest(manifestJson);
|
||||
var manifestUri = BuildManifestUri(bucket, rootPrefix, tenant, manifestDigest);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Policy.Counterfactuals;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
@@ -21,15 +22,18 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
private readonly ITriageQueryService _queryService;
|
||||
private readonly ICounterfactualEngine? _counterfactualEngine;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public TriageStatusService(
|
||||
ILogger<TriageStatusService> logger,
|
||||
ITriageQueryService queryService,
|
||||
IGuidProvider guidProvider,
|
||||
TimeProvider timeProvider,
|
||||
ICounterfactualEngine? counterfactualEngine = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_queryService = queryService ?? throw new ArgumentNullException(nameof(queryService));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_counterfactualEngine = counterfactualEngine;
|
||||
}
|
||||
@@ -85,7 +89,7 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
NewLane = newLane,
|
||||
PreviousVerdict = previousVerdict,
|
||||
NewVerdict = newVerdict,
|
||||
SnapshotId = $"snap-{Guid.NewGuid():N}",
|
||||
SnapshotId = $"snap-{_guidProvider.NewGuid():N}",
|
||||
AppliedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
@@ -105,7 +109,7 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
}
|
||||
|
||||
var previousVerdict = GetCurrentVerdict(finding);
|
||||
var vexStatementId = $"vex-{Guid.NewGuid():N}";
|
||||
var vexStatementId = $"vex-{_guidProvider.NewGuid():N}";
|
||||
|
||||
// Determine if verdict changes based on VEX status
|
||||
var verdictChanged = false;
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj" />
|
||||
|
||||
11
src/Scanner/StellaOps.Scanner.WebService/TASKS.md
Normal file
11
src/Scanner/StellaOps.Scanner.WebService/TASKS.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Scanner WebService Task Board
|
||||
|
||||
This board tracks TODO/FIXME/HACK markers and audit follow-ups for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_apply.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| TODO-WEB-001 | TODO | Load tenant-specific policy configuration in `src/Scanner/StellaOps.Scanner.WebService/Services/VexGateQueryService.cs`. |
|
||||
| TODO-WEB-002 | TODO | Implement CAS retrieval for slices in `src/Scanner/StellaOps.Scanner.WebService/Services/SliceQueryService.cs`. |
|
||||
| TODO-WEB-003 | TODO | Add VEX expiry once integrated in `src/Scanner/StellaOps.Scanner.WebService/Services/EvidenceCompositionService.cs`. |
|
||||
| PRAGMA-WEB-001 | DONE | Documented ASPDEPR002 suppressions in `src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReportEndpoints.cs`, `src/Scanner/StellaOps.Scanner.WebService/Endpoints/PolicyEndpoints.cs`, and `src/Scanner/StellaOps.Scanner.WebService/Endpoints/EpssEndpoints.cs`. |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -426,8 +426,13 @@ function flush() {
|
||||
const sorted = events.sort((a, b) => {
|
||||
const at = String(a.ts);
|
||||
const bt = String(b.ts);
|
||||
if (at === bt) return String(a.type).localeCompare(String(b.type));
|
||||
return at.localeCompare(bt);
|
||||
if (at === bt) {
|
||||
const atype = String(a.type);
|
||||
const btype = String(b.type);
|
||||
if (atype === btype) return 0;
|
||||
return atype < btype ? -1 : 1;
|
||||
}
|
||||
return at < bt ? -1 : 1;
|
||||
});
|
||||
|
||||
const data = sorted.map((e) => JSON.stringify(e)).join("\\n");
|
||||
|
||||
@@ -11,11 +11,12 @@ internal sealed class DenoRuntimeTraceRecorder
|
||||
private readonly string _rootPath;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DenoRuntimeTraceRecorder(string rootPath, TimeProvider? timeProvider = null)
|
||||
public DenoRuntimeTraceRecorder(string rootPath, TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_rootPath = Path.GetFullPath(rootPath);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public void AddModuleLoad(string absoluteModulePath, string reason, IEnumerable<string> permissions, string? origin = null, DateTimeOffset? timestamp = null)
|
||||
|
||||
@@ -13,6 +13,12 @@ internal static class DenoRuntimeTraceRunner
|
||||
private const string EntrypointEnvVar = "STELLA_DENO_ENTRYPOINT";
|
||||
private const string BinaryEnvVar = "STELLA_DENO_BINARY";
|
||||
private const string RuntimeFileName = "deno-runtime.ndjson";
|
||||
private static readonly HashSet<string> AllowedBinaryNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"deno",
|
||||
"deno.exe",
|
||||
"deno.cmd"
|
||||
};
|
||||
|
||||
public static async Task<bool> TryExecuteAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
@@ -28,29 +34,28 @@ internal static class DenoRuntimeTraceRunner
|
||||
return false;
|
||||
}
|
||||
|
||||
var entrypointPath = Path.GetFullPath(Path.Combine(context.RootPath, entrypoint));
|
||||
if (!File.Exists(entrypointPath))
|
||||
var rootPath = Path.GetFullPath(context.RootPath);
|
||||
if (!TryResolveEntrypointPath(rootPath, entrypoint, logger, out var entrypointPath))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: entrypoint '{Entrypoint}' missing", entrypointPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
var shimPath = Path.Combine(context.RootPath, DenoRuntimeShim.FileName);
|
||||
if (!File.Exists(shimPath))
|
||||
{
|
||||
await DenoRuntimeShim.WriteAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var binary = Environment.GetEnvironmentVariable(BinaryEnvVar);
|
||||
var binary = ResolveBinary(rootPath, Environment.GetEnvironmentVariable(BinaryEnvVar), logger);
|
||||
if (string.IsNullOrWhiteSpace(binary))
|
||||
{
|
||||
binary = "deno";
|
||||
return false;
|
||||
}
|
||||
|
||||
var shimPath = Path.Combine(rootPath, DenoRuntimeShim.FileName);
|
||||
if (!File.Exists(shimPath))
|
||||
{
|
||||
await DenoRuntimeShim.WriteAsync(rootPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = binary,
|
||||
WorkingDirectory = context.RootPath,
|
||||
WorkingDirectory = rootPath,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
@@ -58,7 +63,7 @@ internal static class DenoRuntimeTraceRunner
|
||||
|
||||
startInfo.ArgumentList.Add("run");
|
||||
startInfo.ArgumentList.Add("--cached-only");
|
||||
startInfo.ArgumentList.Add("--allow-read");
|
||||
startInfo.ArgumentList.Add(BuildAllowReadArgument(rootPath, logger));
|
||||
startInfo.ArgumentList.Add("--allow-env");
|
||||
startInfo.ArgumentList.Add("--quiet");
|
||||
startInfo.ArgumentList.Add(shimPath);
|
||||
@@ -96,7 +101,7 @@ internal static class DenoRuntimeTraceRunner
|
||||
return false;
|
||||
}
|
||||
|
||||
var runtimePath = Path.Combine(context.RootPath, RuntimeFileName);
|
||||
var runtimePath = Path.Combine(rootPath, RuntimeFileName);
|
||||
if (!File.Exists(runtimePath))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
@@ -108,6 +113,122 @@ internal static class DenoRuntimeTraceRunner
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryResolveEntrypointPath(
|
||||
string rootPath,
|
||||
string entrypoint,
|
||||
ILogger? logger,
|
||||
out string entrypointPath)
|
||||
{
|
||||
entrypointPath = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(entrypoint))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: entrypoint was empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var candidate = Path.GetFullPath(Path.Combine(rootPath, entrypoint));
|
||||
if (!IsWithinRoot(rootPath, candidate))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: entrypoint '{Entrypoint}' not under root", entrypoint);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: entrypoint '{Entrypoint}' missing", candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
entrypointPath = candidate;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException or UnauthorizedAccessException)
|
||||
{
|
||||
logger?.LogWarning(ex, "Deno runtime trace skipped: entrypoint '{Entrypoint}' invalid", entrypoint);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveBinary(string rootPath, string? binary, ILogger? logger)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(binary))
|
||||
{
|
||||
return "deno";
|
||||
}
|
||||
|
||||
var trimmed = binary.Trim();
|
||||
var fileName = Path.GetFileName(trimmed);
|
||||
if (string.IsNullOrWhiteSpace(fileName) || !AllowedBinaryNames.Contains(fileName))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: binary '{Binary}' not allowlisted", trimmed);
|
||||
return null;
|
||||
}
|
||||
|
||||
var isPath = trimmed.Contains(Path.DirectorySeparatorChar) ||
|
||||
trimmed.Contains(Path.AltDirectorySeparatorChar) ||
|
||||
Path.IsPathRooted(trimmed);
|
||||
if (!isPath)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var candidate = Path.GetFullPath(Path.IsPathRooted(trimmed)
|
||||
? trimmed
|
||||
: Path.Combine(rootPath, trimmed));
|
||||
if (!IsWithinRoot(rootPath, candidate))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: binary '{Binary}' not under root", trimmed);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: binary '{Binary}' missing", candidate);
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException or UnauthorizedAccessException)
|
||||
{
|
||||
logger?.LogWarning(ex, "Deno runtime trace skipped: binary '{Binary}' invalid", trimmed);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAllowReadArgument(string rootPath, ILogger? logger)
|
||||
{
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
var allowed = new HashSet<string>(comparison) { rootPath };
|
||||
var denoDir = Environment.GetEnvironmentVariable("DENO_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(denoDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
var denoDirPath = Path.GetFullPath(denoDir, rootPath);
|
||||
allowed.Add(denoDirPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException or UnauthorizedAccessException)
|
||||
{
|
||||
logger?.LogWarning(ex, "Deno runtime trace: invalid DENO_DIR '{DenoDir}'", denoDir);
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = allowed.OrderBy(path => path, comparison);
|
||||
return $"--allow-read={string.Join(",", ordered)}";
|
||||
}
|
||||
|
||||
private static bool IsWithinRoot(string rootPath, string candidatePath)
|
||||
{
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
var normalizedRoot = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return candidatePath.StartsWith(normalizedRoot, comparison);
|
||||
}
|
||||
|
||||
private static string Truncate(string? value, int maxLength = 400)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
@@ -8,7 +9,7 @@ internal static class DenoRuntimeTraceSerializer
|
||||
{
|
||||
private static readonly JsonWriterOptions WriterOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Encoder = JavaScriptEncoder.Default,
|
||||
Indented = false
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Bundling;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
@@ -278,7 +279,7 @@ internal sealed record BundlingSignal(
|
||||
yield return new("bundle.detected", "true");
|
||||
yield return new("bundle.filePath", FilePath);
|
||||
yield return new("bundle.kind", Kind.ToString().ToLowerInvariant());
|
||||
yield return new("bundle.sizeBytes", SizeBytes.ToString());
|
||||
yield return new("bundle.sizeBytes", SizeBytes.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (IsSkipped)
|
||||
{
|
||||
@@ -292,7 +293,7 @@ internal sealed record BundlingSignal(
|
||||
{
|
||||
if (EstimatedBundledAssemblies > 0)
|
||||
{
|
||||
yield return new("bundle.estimatedAssemblies", EstimatedBundledAssemblies.ToString());
|
||||
yield return new("bundle.estimatedAssemblies", EstimatedBundledAssemblies.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
for (var i = 0; i < Indicators.Length; i++)
|
||||
|
||||
@@ -23,10 +23,10 @@ internal sealed class DotNetCallgraphBuilder
|
||||
private int _assemblyCount;
|
||||
private int _typeCount;
|
||||
|
||||
public DotNetCallgraphBuilder(string contextDigest, TimeProvider? timeProvider = null)
|
||||
public DotNetCallgraphBuilder(string contextDigest, TimeProvider timeProvider)
|
||||
{
|
||||
_contextDigest = contextDigest;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -232,6 +232,7 @@ internal static class DotNetLicenseCache
|
||||
DtdProcessing = DtdProcessing.Ignore,
|
||||
IgnoreComments = true,
|
||||
IgnoreWhitespace = true,
|
||||
XmlResolver = null,
|
||||
});
|
||||
|
||||
var expressions = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Scanner .NET Analyzer Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_apply.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-HOTLIST-SCANNER-LANG-DOTNET-0001 | DONE | Applied hotlist fixes and tests. |
|
||||
| AUDIT-0644-A | DONE | Audit tracker updated for DotNet analyzer apply. |
|
||||
| AUDIT-0698-A | DONE | Test project apply completed (warnings, deterministic fixtures). |
|
||||
@@ -18,10 +18,11 @@ internal sealed class NativeCallgraphBuilder
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private int _binaryCount;
|
||||
|
||||
public NativeCallgraphBuilder(string layerDigest, TimeProvider? timeProvider = null)
|
||||
public NativeCallgraphBuilder(string layerDigest, TimeProvider timeProvider)
|
||||
{
|
||||
_layerDigest = layerDigest;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,6 +16,14 @@ namespace StellaOps.Scanner.Analyzers.Native;
|
||||
/// </summary>
|
||||
public sealed class NativeReachabilityAnalyzer
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public NativeReachabilityAnalyzer(TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes a directory of ELF binaries and produces a reachability graph.
|
||||
/// </summary>
|
||||
@@ -31,7 +39,7 @@ public sealed class NativeReachabilityAnalyzer
|
||||
ArgumentException.ThrowIfNullOrEmpty(layerPath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(layerDigest);
|
||||
|
||||
var builder = new NativeCallgraphBuilder(layerDigest);
|
||||
var builder = new NativeCallgraphBuilder(layerDigest, _timeProvider);
|
||||
|
||||
// Find all potential ELF files in the layer
|
||||
await foreach (var filePath in FindElfFilesAsync(layerPath, cancellationToken))
|
||||
@@ -73,7 +81,7 @@ public sealed class NativeReachabilityAnalyzer
|
||||
ArgumentException.ThrowIfNullOrEmpty(filePath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(layerDigest);
|
||||
|
||||
var builder = new NativeCallgraphBuilder(layerDigest);
|
||||
var builder = new NativeCallgraphBuilder(layerDigest, _timeProvider);
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var elf = ElfReader.Parse(stream, filePath, layerDigest);
|
||||
@@ -98,7 +106,7 @@ public sealed class NativeReachabilityAnalyzer
|
||||
ArgumentException.ThrowIfNullOrEmpty(filePath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(layerDigest);
|
||||
|
||||
var builder = new NativeCallgraphBuilder(layerDigest);
|
||||
var builder = new NativeCallgraphBuilder(layerDigest, _timeProvider);
|
||||
var elf = ElfReader.Parse(stream, filePath, layerDigest);
|
||||
|
||||
if (elf is not null)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture.Timeline;
|
||||
|
||||
public interface ITimelineBuilder
|
||||
@@ -108,7 +110,7 @@ public sealed class TimelineBuilder : ITimelineBuilder
|
||||
Details = new Dictionary<string, string>
|
||||
{
|
||||
["path"] = obs.Path ?? "",
|
||||
["process_id"] = obs.ProcessId.ToString()
|
||||
["process_id"] = obs.ProcessId.ToString(CultureInfo.InvariantCulture)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ public static class CallGraphDigests
|
||||
{
|
||||
private static readonly JsonWriterOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Default,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Scanner Contracts Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_apply.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-HOTLIST-SCANNER-CONTRACTS-0001 | DONE | Applied safe JSON encoder and test coverage update. |
|
||||
| AUDIT-0946-A | DONE | Audit tracker updated for Scanner.Contracts apply. |
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -414,23 +415,23 @@ public sealed class PrReachabilityGate : IPrReachabilityGate
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine(passed
|
||||
? "## ✅ Reachability Gate Passed"
|
||||
: "## ❌ Reachability Gate Blocked");
|
||||
? "## [OK] Reachability Gate Passed"
|
||||
: "## [BLOCKED] Reachability Gate Blocked");
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Metric | Value |");
|
||||
sb.AppendLine("|--------|-------|");
|
||||
sb.AppendLine($"| New reachable paths | {decision.NewReachableCount} |");
|
||||
sb.AppendLine($"| New reachable paths | {decision.NewReachableCount.ToString(CultureInfo.InvariantCulture)} |");
|
||||
|
||||
if (options.IncludeMitigatedInSummary)
|
||||
{
|
||||
sb.AppendLine($"| Mitigated paths | {decision.MitigatedCount} |");
|
||||
sb.AppendLine($"| Net change | {decision.NetChange:+#;-#;0} |");
|
||||
sb.AppendLine($"| Mitigated paths | {decision.MitigatedCount.ToString(CultureInfo.InvariantCulture)} |");
|
||||
sb.AppendLine($"| Net change | {decision.NetChange.ToString("+#;-#;0", CultureInfo.InvariantCulture)} |");
|
||||
}
|
||||
|
||||
sb.AppendLine($"| Analysis type | {(decision.WasIncremental ? "Incremental" : "Full")} |");
|
||||
sb.AppendLine($"| Cache savings | {decision.SavingsRatio:P0} |");
|
||||
sb.AppendLine($"| Duration | {decision.Duration.TotalMilliseconds:F0}ms |");
|
||||
sb.AppendLine($"| Cache savings | {decision.SavingsRatio.ToString("P0", CultureInfo.InvariantCulture)} |");
|
||||
sb.AppendLine($"| Duration | {decision.Duration.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture)}ms |");
|
||||
|
||||
if (!passed && decision.BlockingFlips.Count > 0)
|
||||
{
|
||||
@@ -440,12 +441,13 @@ public sealed class PrReachabilityGate : IPrReachabilityGate
|
||||
|
||||
foreach (var flip in decision.BlockingFlips.Take(10))
|
||||
{
|
||||
sb.AppendLine($"- `{flip.EntryMethodKey}` -> `{flip.SinkMethodKey}` (confidence: {flip.Confidence:P0})");
|
||||
sb.AppendLine($"- `{flip.EntryMethodKey}` -> `{flip.SinkMethodKey}` (confidence: {flip.Confidence.ToString("P0", CultureInfo.InvariantCulture)})");
|
||||
}
|
||||
|
||||
if (decision.BlockingFlips.Count > 10)
|
||||
{
|
||||
sb.AppendLine($"- ... and {decision.BlockingFlips.Count - 10} more");
|
||||
var remaining = decision.BlockingFlips.Count - 10;
|
||||
sb.AppendLine($"- ... and {remaining.ToString(CultureInfo.InvariantCulture)} more");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -124,7 +125,7 @@ public sealed class PathExplanationService : IPathExplanationService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<ExplainedPath?> ExplainPathAsync(
|
||||
public async Task<ExplainedPath?> ExplainPathAsync(
|
||||
RichGraph graph,
|
||||
string pathId,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -145,20 +146,22 @@ public sealed class PathExplanationService : IPathExplanationService
|
||||
MaxPaths = 100
|
||||
};
|
||||
|
||||
var resultTask = ExplainAsync(graph, query, cancellationToken);
|
||||
return resultTask.ContinueWith(t =>
|
||||
var result = await ExplainAsync(graph, query, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Paths.Count == 0)
|
||||
{
|
||||
if (t.Result.Paths.Count == 0)
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// If path index specified, return that specific one
|
||||
if (parts.Length >= 3 && int.TryParse(parts[2], out var idx) && idx < t.Result.Paths.Count)
|
||||
{
|
||||
return t.Result.Paths[idx];
|
||||
}
|
||||
// If path index specified, return that specific one
|
||||
if (parts.Length >= 3 &&
|
||||
int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var idx) &&
|
||||
idx >= 0 &&
|
||||
idx < result.Paths.Count)
|
||||
{
|
||||
return result.Paths[idx];
|
||||
}
|
||||
|
||||
return t.Result.Paths[0];
|
||||
}, cancellationToken);
|
||||
return result.Paths[0];
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<RichGraphEdge>> BuildEdgeLookup(RichGraph graph)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
@@ -11,6 +12,7 @@ using StellaOps.Scanner.Reachability.Binary;
|
||||
using StellaOps.Scanner.Reachability.Runtime;
|
||||
using StellaOps.Scanner.Reachability.Services;
|
||||
using StellaOps.Scanner.Reachability.Stack;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
// Aliases to disambiguate types with same name in different namespaces
|
||||
using StackEntrypointType = StellaOps.Scanner.Reachability.Stack.EntrypointType;
|
||||
@@ -33,6 +35,7 @@ public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobEx
|
||||
private readonly IRuntimeReachabilityCollector? _runtimeCollector;
|
||||
private readonly ILogger<ReachabilityEvidenceJobExecutor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public ReachabilityEvidenceJobExecutor(
|
||||
ICveSymbolMappingService cveSymbolService,
|
||||
@@ -42,6 +45,7 @@ public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobEx
|
||||
ILogger<ReachabilityEvidenceJobExecutor> logger,
|
||||
IBinaryPatchVerifier? binaryPatchVerifier = null,
|
||||
IRuntimeReachabilityCollector? runtimeCollector = null,
|
||||
IGuidProvider? guidProvider = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_cveSymbolService = cveSymbolService ?? throw new ArgumentNullException(nameof(cveSymbolService));
|
||||
@@ -51,6 +55,7 @@ public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobEx
|
||||
_binaryPatchVerifier = binaryPatchVerifier;
|
||||
_runtimeCollector = runtimeCollector;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
@@ -409,7 +414,7 @@ public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobEx
|
||||
// Create a minimal stack with Unknown verdict
|
||||
var unknownStack = new ReachabilityStack
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Id = _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
FindingId = $"{job.CveId}:{job.Purl}",
|
||||
Symbol = new StackVulnerableSymbol(
|
||||
Name: "unknown",
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -22,7 +23,7 @@ public sealed class ReachabilityUnionWriter
|
||||
|
||||
private static readonly JsonWriterOptions JsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Encoder = JavaScriptEncoder.Default,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -94,7 +95,7 @@ public static class RichGraphSemanticExtensions
|
||||
return null;
|
||||
}
|
||||
|
||||
return double.TryParse(value, out var score) ? score : null;
|
||||
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var score) ? score : null;
|
||||
}
|
||||
|
||||
/// <summary>Gets the confidence score.</summary>
|
||||
@@ -106,7 +107,7 @@ public static class RichGraphSemanticExtensions
|
||||
return null;
|
||||
}
|
||||
|
||||
return double.TryParse(value, out var score) ? score : null;
|
||||
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var score) ? score : null;
|
||||
}
|
||||
|
||||
/// <summary>Checks if this node is an entrypoint.</summary>
|
||||
@@ -190,13 +191,13 @@ public sealed class RichGraphNodeSemanticBuilder
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithRiskScore(double score)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.RiskScore] = score.ToString("F3");
|
||||
_attributes[RichGraphSemanticAttributes.RiskScore] = score.ToString("F3", CultureInfo.InvariantCulture);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithConfidence(double score, string tier)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.Confidence] = score.ToString("F3");
|
||||
_attributes[RichGraphSemanticAttributes.Confidence] = score.ToString("F3", CultureInfo.InvariantCulture);
|
||||
_attributes[RichGraphSemanticAttributes.ConfidenceTier] = tier;
|
||||
return this;
|
||||
}
|
||||
@@ -225,7 +226,7 @@ public sealed class RichGraphNodeSemanticBuilder
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithCweId(int cweId)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.CweId] = cweId.ToString();
|
||||
_attributes[RichGraphSemanticAttributes.CweId] = cweId.ToString(CultureInfo.InvariantCulture);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -22,7 +23,7 @@ public sealed class RichGraphWriter
|
||||
|
||||
private static readonly JsonWriterOptions JsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Encoder = JavaScriptEncoder.Default,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ public sealed class InMemorySliceCache : ISliceCache, IDisposable
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
private readonly ILogger<InMemorySliceCache> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CancellationTokenSource _evictionCts = new();
|
||||
private readonly Timer _evictionTimer;
|
||||
private readonly SemaphoreSlim _evictionLock = new(1, 1);
|
||||
|
||||
@@ -26,7 +27,7 @@ public sealed class InMemorySliceCache : ISliceCache, IDisposable
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_evictionTimer = new Timer(
|
||||
_ => _ = EvictExpiredEntriesAsync(CancellationToken.None),
|
||||
_ => _ = EvictExpiredEntriesAsync(_evictionCts.Token),
|
||||
null,
|
||||
TimeSpan.FromSeconds(EvictionIntervalSeconds),
|
||||
TimeSpan.FromSeconds(EvictionIntervalSeconds));
|
||||
@@ -116,7 +117,19 @@ public sealed class InMemorySliceCache : ISliceCache, IDisposable
|
||||
|
||||
private async Task EvictExpiredEntriesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _evictionLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!await _evictionLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -199,8 +212,14 @@ public sealed class InMemorySliceCache : ISliceCache, IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_evictionCts.IsCancellationRequested)
|
||||
{
|
||||
_evictionCts.Cancel();
|
||||
}
|
||||
|
||||
_evictionTimer?.Dispose();
|
||||
_evictionLock?.Dispose();
|
||||
_evictionCts.Dispose();
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Stack;
|
||||
@@ -48,6 +50,18 @@ public interface IReachabilityStackEvaluator
|
||||
/// </remarks>
|
||||
public sealed class ReachabilityStackEvaluator : IReachabilityStackEvaluator
|
||||
{
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public ReachabilityStackEvaluator()
|
||||
: this(SystemGuidProvider.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public ReachabilityStackEvaluator(IGuidProvider guidProvider)
|
||||
{
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ReachabilityStack Evaluate(
|
||||
string findingId,
|
||||
@@ -63,7 +77,7 @@ public sealed class ReachabilityStackEvaluator : IReachabilityStackEvaluator
|
||||
|
||||
return new ReachabilityStack
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Id = _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
FindingId = findingId,
|
||||
Symbol = symbol,
|
||||
StaticCallGraph = layer1,
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\Signals\__Libraries\StellaOps.Signals.Ebpf\StellaOps.Signals.Ebpf.csproj" />
|
||||
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Ghidra\StellaOps.BinaryIndex.Ghidra.csproj" />
|
||||
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Decompiler\StellaOps.BinaryIndex.Decompiler.csproj" />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
@@ -16,17 +18,29 @@ public class SubgraphExtractor : IReachabilityResolver
|
||||
private readonly IEntryPointResolver _entryPointResolver;
|
||||
private readonly IVulnSurfaceService _vulnSurfaceService;
|
||||
private readonly ILogger<SubgraphExtractor> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public SubgraphExtractor(
|
||||
IRichGraphStore graphStore,
|
||||
IEntryPointResolver entryPointResolver,
|
||||
IVulnSurfaceService vulnSurfaceService,
|
||||
ILogger<SubgraphExtractor> logger)
|
||||
: this(graphStore, entryPointResolver, vulnSurfaceService, logger, SystemGuidProvider.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public SubgraphExtractor(
|
||||
IRichGraphStore graphStore,
|
||||
IEntryPointResolver entryPointResolver,
|
||||
IVulnSurfaceService vulnSurfaceService,
|
||||
ILogger<SubgraphExtractor> logger,
|
||||
IGuidProvider guidProvider)
|
||||
{
|
||||
_graphStore = graphStore ?? throw new ArgumentNullException(nameof(graphStore));
|
||||
_entryPointResolver = entryPointResolver ?? throw new ArgumentNullException(nameof(entryPointResolver));
|
||||
_vulnSurfaceService = vulnSurfaceService ?? throw new ArgumentNullException(nameof(vulnSurfaceService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
public async Task<PoESubgraph?> ResolveAsync(
|
||||
@@ -206,7 +220,7 @@ public class SubgraphExtractor : IReachabilityResolver
|
||||
if (sinkSet.Contains(current))
|
||||
{
|
||||
paths.Add(new CallPath(
|
||||
PathId: Guid.NewGuid().ToString(),
|
||||
PathId: _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
Nodes: path.ToList(),
|
||||
Edges: ExtractEdgesFromPath(path, graph),
|
||||
Length: path.Count - 1,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
@@ -11,11 +13,12 @@ namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
|
||||
{
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -44,13 +47,10 @@ public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
|
||||
try
|
||||
{
|
||||
// Serialize witness to canonical JSON bytes
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(witness, CanonicalJsonOptions);
|
||||
var payloadBytes = CanonJson.Canonicalize(witness, CanonicalJsonOptions);
|
||||
|
||||
// Build the PAE (Pre-Authentication Encoding) for DSSE
|
||||
var pae = BuildPae(SuppressionWitnessSchema.DssePayloadType, payloadBytes);
|
||||
|
||||
// Sign the PAE
|
||||
var signResult = _signatureService.Sign(pae, signingKey, cancellationToken);
|
||||
// Sign DSSE payload using the shared PAE helper
|
||||
var signResult = _signatureService.SignDsse(SuppressionWitnessSchema.DssePayloadType, payloadBytes, signingKey, cancellationToken);
|
||||
if (!signResult.IsSuccess)
|
||||
{
|
||||
return SuppressionDsseResult.Failure($"Signing failed: {signResult.Error?.Message}");
|
||||
@@ -114,12 +114,10 @@ public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
|
||||
return SuppressionVerifyResult.Failure($"No signature found for key ID: {publicKey.KeyId}");
|
||||
}
|
||||
|
||||
// Build PAE and verify signature
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload.ToArray());
|
||||
var signatureBytes = Convert.FromBase64String(matchingSignature.Signature);
|
||||
var envelopeSignature = new EnvelopeSignature(publicKey.KeyId, publicKey.AlgorithmId, signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.Verify(pae, envelopeSignature, publicKey, cancellationToken);
|
||||
var verifyResult = _signatureService.VerifyDsse(envelope.PayloadType, envelope.Payload.Span, envelopeSignature, publicKey, cancellationToken);
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
return SuppressionVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}");
|
||||
@@ -133,43 +131,6 @@ public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the DSSE Pre-Authentication Encoding (PAE) for a payload.
|
||||
/// PAE = "DSSEv1" SP len(type) SP type SP len(payload) SP payload
|
||||
/// </summary>
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
// Write "DSSEv1 "
|
||||
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
|
||||
|
||||
// Write len(type) as ASCII decimal string followed by space
|
||||
WriteLengthAndSpace(writer, typeBytes.Length);
|
||||
|
||||
// Write type followed by space
|
||||
writer.Write(typeBytes);
|
||||
writer.Write((byte)' ');
|
||||
|
||||
// Write len(payload) as ASCII decimal string followed by space
|
||||
WriteLengthAndSpace(writer, payload.Length);
|
||||
|
||||
// Write payload
|
||||
writer.Write(payload);
|
||||
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteLengthAndSpace(BinaryWriter writer, int length)
|
||||
{
|
||||
// Write length as ASCII decimal string
|
||||
writer.Write(Encoding.UTF8.GetBytes(length.ToString()));
|
||||
writer.Write((byte)' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
@@ -11,11 +13,12 @@ namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
{
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -44,13 +47,10 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
try
|
||||
{
|
||||
// Serialize witness to canonical JSON bytes
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(witness, CanonicalJsonOptions);
|
||||
var payloadBytes = CanonJson.Canonicalize(witness, CanonicalJsonOptions);
|
||||
|
||||
// Build the PAE (Pre-Authentication Encoding) for DSSE
|
||||
var pae = BuildPae(WitnessSchema.DssePayloadType, payloadBytes);
|
||||
|
||||
// Sign the PAE
|
||||
var signResult = _signatureService.Sign(pae, signingKey, cancellationToken);
|
||||
// Sign DSSE payload using the shared PAE helper
|
||||
var signResult = _signatureService.SignDsse(WitnessSchema.DssePayloadType, payloadBytes, signingKey, cancellationToken);
|
||||
if (!signResult.IsSuccess)
|
||||
{
|
||||
return WitnessDsseResult.Failure($"Signing failed: {signResult.Error?.Message}");
|
||||
@@ -114,12 +114,10 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
return WitnessVerifyResult.Failure($"No signature found for key ID: {publicKey.KeyId}");
|
||||
}
|
||||
|
||||
// Build PAE and verify signature
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload.ToArray());
|
||||
var signatureBytes = Convert.FromBase64String(matchingSignature.Signature);
|
||||
var envelopeSignature = new EnvelopeSignature(publicKey.KeyId, publicKey.AlgorithmId, signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.Verify(pae, envelopeSignature, publicKey, cancellationToken);
|
||||
var verifyResult = _signatureService.VerifyDsse(envelope.PayloadType, envelope.Payload.Span, envelopeSignature, publicKey, cancellationToken);
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
return WitnessVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}");
|
||||
@@ -133,43 +131,6 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the DSSE Pre-Authentication Encoding (PAE) for a payload.
|
||||
/// PAE = "DSSEv1" SP len(type) SP type SP len(payload) SP payload
|
||||
/// </summary>
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
// Write "DSSEv1 "
|
||||
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
|
||||
|
||||
// Write len(type) as little-endian 8-byte integer followed by space
|
||||
WriteLengthAndSpace(writer, typeBytes.Length);
|
||||
|
||||
// Write type followed by space
|
||||
writer.Write(typeBytes);
|
||||
writer.Write((byte)' ');
|
||||
|
||||
// Write len(payload) as little-endian 8-byte integer followed by space
|
||||
WriteLengthAndSpace(writer, payload.Length);
|
||||
|
||||
// Write payload
|
||||
writer.Write(payload);
|
||||
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteLengthAndSpace(BinaryWriter writer, int length)
|
||||
{
|
||||
// Write length as ASCII decimal string
|
||||
writer.Write(Encoding.UTF8.GetBytes(length.ToString()));
|
||||
writer.Write((byte)' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public interface ISurfaceSecretProvider
|
||||
{
|
||||
SurfaceSecretHandle Get(SurfaceSecretRequest request);
|
||||
|
||||
ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -29,6 +29,50 @@ internal sealed class AuditingSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
_componentName = componentName ?? throw new ArgumentNullException(nameof(componentName));
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var handle = _inner.Get(request);
|
||||
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
handle.Metadata,
|
||||
success: true,
|
||||
elapsed,
|
||||
error: null);
|
||||
|
||||
return handle;
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
metadata: null,
|
||||
success: false,
|
||||
elapsed,
|
||||
error: "NotFound");
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
metadata: null,
|
||||
success: false,
|
||||
elapsed,
|
||||
error: ex.GetType().Name);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -35,6 +35,25 @@ internal sealed class CachingSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
|
||||
public TimeSpan CacheTtl => _ttl;
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
var key = BuildCacheKey(request);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
|
||||
{
|
||||
_logger.LogDebug("Surface secret cache hit for {Key}.", key);
|
||||
return entry.Handle;
|
||||
}
|
||||
|
||||
var handle = _inner.Get(request);
|
||||
var newEntry = new CacheEntry(handle, now.Add(_ttl));
|
||||
_cache[key] = newEntry;
|
||||
|
||||
_logger.LogDebug("Surface secret cached for {Key}, expires at {ExpiresAt}.", key, newEntry.ExpiresAt);
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -18,6 +18,23 @@ internal sealed class CompositeSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
}
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
foreach (var provider in _providers)
|
||||
{
|
||||
try
|
||||
{
|
||||
return provider.Get(request);
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
{
|
||||
// try next provider
|
||||
}
|
||||
}
|
||||
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -20,6 +20,35 @@ internal sealed class FileSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
_root = root;
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var path = ResolvePath(request);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
var descriptor = JsonSerializer.Deserialize<FileSecretDescriptor>(json);
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(descriptor.Payload))
|
||||
{
|
||||
return SurfaceSecretHandle.Empty;
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(descriptor.Payload);
|
||||
return SurfaceSecretHandle.FromBytes(bytes, descriptor.Metadata);
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -21,6 +21,21 @@ public sealed class InMemorySurfaceSecretProvider : ISurfaceSecretProvider
|
||||
_secrets[request.CacheKey] = handle;
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (_secrets.TryGetValue(request.CacheKey, out var handle))
|
||||
{
|
||||
return handle;
|
||||
}
|
||||
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
|
||||
@@ -14,6 +14,30 @@ internal sealed class InlineSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
if (!_configuration.AllowInline)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var envKey = BuildEnvironmentKey(request);
|
||||
var value = Environment.GetEnvironmentVariable(envKey);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(value);
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["source"] = "inline-env",
|
||||
["key"] = envKey
|
||||
};
|
||||
|
||||
return SurfaceSecretHandle.FromBytes(bytes, metadata);
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -23,6 +23,30 @@ internal sealed class KubernetesSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
}
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
var directory = Path.Combine(_configuration.Root!, request.Tenant, request.Component, request.SecretType);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
_logger.LogDebug("Kubernetes secret directory {Directory} not found.", directory);
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var name = request.Name ?? "default";
|
||||
var payloadPath = Path.Combine(directory, name);
|
||||
if (!File.Exists(payloadPath))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var bytes = File.ReadAllBytes(payloadPath);
|
||||
return SurfaceSecretHandle.FromBytes(bytes, new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "kubernetes",
|
||||
["path"] = payloadPath
|
||||
});
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -42,6 +42,73 @@ internal sealed class OfflineSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
}
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var directory = Path.Combine(_root, request.Tenant, request.Component, request.SecretType);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
_logger.LogDebug("Offline secret directory {Directory} not found.", directory);
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
// Deterministic selection: if name is specified use it, otherwise pick lexicographically smallest
|
||||
var targetName = request.Name ?? SelectDeterministicName(directory);
|
||||
if (targetName is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var path = Path.Combine(directory, targetName + ".json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
var descriptor = JsonSerializer.Deserialize<OfflineSecretDescriptor>(json);
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(descriptor.Payload))
|
||||
{
|
||||
return SurfaceSecretHandle.Empty;
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(descriptor.Payload);
|
||||
|
||||
// Verify integrity if manifest entry exists
|
||||
var manifestKey = BuildManifestKey(request.Tenant, request.Component, request.SecretType, targetName);
|
||||
if (_manifest?.TryGetValue(manifestKey, out var entry) == true)
|
||||
{
|
||||
var actualHash = ComputeSha256(bytes);
|
||||
if (!string.Equals(actualHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Offline secret integrity check failed for {Key}. Expected {Expected}, got {Actual}.",
|
||||
manifestKey,
|
||||
entry.Sha256,
|
||||
actualHash);
|
||||
throw new InvalidOperationException($"Offline secret integrity check failed for {manifestKey}.");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Offline secret integrity verified for {Key}.", manifestKey);
|
||||
}
|
||||
|
||||
var metadata = descriptor.Metadata ?? new Dictionary<string, string>();
|
||||
metadata["source"] = "offline";
|
||||
metadata["path"] = path;
|
||||
metadata["name"] = targetName;
|
||||
|
||||
return SurfaceSecretHandle.FromBytes(bytes, metadata);
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -10,7 +10,9 @@ public sealed class DenoRuntimeTraceRecorderTests
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
try
|
||||
{
|
||||
var recorder = new DenoRuntimeTraceRecorder(root);
|
||||
var recorder = new DenoRuntimeTraceRecorder(
|
||||
root,
|
||||
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-17T12:00:00Z")));
|
||||
|
||||
recorder.AddPermissionUse(
|
||||
absoluteModulePath: Path.Combine(root, "c.ts"),
|
||||
@@ -58,4 +60,16 @@ public sealed class DenoRuntimeTraceRecorderTests
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime)
|
||||
{
|
||||
_fixedTime = fixedTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,13 +61,65 @@ public sealed class DenoRuntimeTraceRunnerTests
|
||||
await File.WriteAllTextAsync(entrypoint, "console.log('hi')", TestContext.Current.CancellationToken);
|
||||
|
||||
using var entryEnv = new EnvironmentVariableScope("STELLA_DENO_ENTRYPOINT", entrypoint);
|
||||
using var binaryEnv = new EnvironmentVariableScope("STELLA_DENO_BINARY", Guid.NewGuid().ToString("N"));
|
||||
using var binaryEnv = new EnvironmentVariableScope("STELLA_DENO_BINARY", Path.Combine("tools", "deno"));
|
||||
|
||||
var context = new LanguageAnalyzerContext(root, TimeProvider.System);
|
||||
var result = await DenoRuntimeTraceRunner.TryExecuteAsync(context, logger: null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.True(File.Exists(Path.Combine(root, DenoRuntimeShim.FileName)));
|
||||
Assert.False(File.Exists(Path.Combine(root, DenoRuntimeShim.FileName)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsFalse_WhenEntrypointOutsideRoot()
|
||||
{
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
var externalRoot = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var externalEntrypoint = Path.Combine(externalRoot, "main.ts");
|
||||
await File.WriteAllTextAsync(externalEntrypoint, "console.log('hi')", TestContext.Current.CancellationToken);
|
||||
|
||||
using var entryEnv = new EnvironmentVariableScope("STELLA_DENO_ENTRYPOINT", externalEntrypoint);
|
||||
var context = new LanguageAnalyzerContext(root, TimeProvider.System);
|
||||
|
||||
var result = await DenoRuntimeTraceRunner.TryExecuteAsync(context, logger: null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.False(File.Exists(Path.Combine(root, DenoRuntimeShim.FileName)));
|
||||
Assert.False(File.Exists(Path.Combine(root, "deno-runtime.ndjson")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(externalRoot);
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsFalse_WhenDenoBinaryDisallowed()
|
||||
{
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var entrypoint = Path.Combine(root, "main.ts");
|
||||
await File.WriteAllTextAsync(entrypoint, "console.log('hi')", TestContext.Current.CancellationToken);
|
||||
|
||||
using var entryEnv = new EnvironmentVariableScope("STELLA_DENO_ENTRYPOINT", entrypoint);
|
||||
using var binaryEnv = new EnvironmentVariableScope("STELLA_DENO_BINARY", "not-deno");
|
||||
|
||||
var context = new LanguageAnalyzerContext(root, TimeProvider.System);
|
||||
var result = await DenoRuntimeTraceRunner.TryExecuteAsync(context, logger: null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.False(File.Exists(Path.Combine(root, DenoRuntimeShim.FileName)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -137,14 +189,7 @@ public sealed class DenoRuntimeTraceRunnerTests
|
||||
EOF
|
||||
""";
|
||||
File.WriteAllText(path, script);
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start("chmod", $"+x {path}")?.WaitForExit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort; on Windows this branch won't execute
|
||||
}
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
}
|
||||
|
||||
return path;
|
||||
|
||||
@@ -92,11 +92,11 @@ public sealed class DenoWorkspaceNormalizerTests
|
||||
if (vendorCacheEdges.Length == 0)
|
||||
{
|
||||
var sample = string.Join(
|
||||
Environment.NewLine,
|
||||
"\n",
|
||||
graph.Edges
|
||||
.Select(edge => $"{edge.ImportKind}:{edge.Specifier}:{edge.Provenance}")
|
||||
.Take(10));
|
||||
Assert.Fail($"Expected vendor cache edges but none were found. Sample edges:{Environment.NewLine}{sample}");
|
||||
Assert.Fail($"Expected vendor cache edges but none were found. Sample edges:\n{sample}");
|
||||
}
|
||||
|
||||
var vendorEdge = vendorCacheEdges.FirstOrDefault(
|
||||
@@ -104,9 +104,9 @@ public sealed class DenoWorkspaceNormalizerTests
|
||||
if (vendorEdge is null)
|
||||
{
|
||||
var details = string.Join(
|
||||
Environment.NewLine,
|
||||
"\n",
|
||||
vendorCacheEdges.Select(edge => $"{edge.Specifier} [{edge.Provenance}] -> {edge.Resolution}"));
|
||||
Assert.Fail($"Unable to locate vendor cache edge for std server.ts. Observed edges:{Environment.NewLine}{details}");
|
||||
Assert.Fail($"Unable to locate vendor cache edge for std server.ts. Observed edges:\n{details}");
|
||||
}
|
||||
|
||||
var npmBridgeEdges = graph.Edges
|
||||
@@ -115,11 +115,11 @@ public sealed class DenoWorkspaceNormalizerTests
|
||||
if (npmBridgeEdges.Length == 0)
|
||||
{
|
||||
var bridgeSample = string.Join(
|
||||
Environment.NewLine,
|
||||
"\n",
|
||||
graph.Edges
|
||||
.Select(edge => $"{edge.ImportKind}:{edge.Specifier}:{edge.Resolution}")
|
||||
.Take(10));
|
||||
Assert.Fail($"No npm bridge edges discovered. Sample:{Environment.NewLine}{bridgeSample}");
|
||||
Assert.Fail($"No npm bridge edges discovered. Sample:\n{bridgeSample}");
|
||||
}
|
||||
|
||||
Assert.Contains(
|
||||
|
||||
@@ -3,6 +3,7 @@ using Xunit;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Unicode;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Tests.TestFixtures;
|
||||
@@ -270,6 +271,6 @@ public sealed class DenoAnalyzerGoldenTests
|
||||
private static readonly JsonSerializerOptions JsonSerializerOptionsProvider = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Tests.TestUtilities;
|
||||
|
||||
internal static class TestPaths
|
||||
{
|
||||
private static long _tempCounter;
|
||||
|
||||
public static string ResolveFixture(params string[] segments)
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
@@ -17,7 +22,8 @@ internal static class TestPaths
|
||||
|
||||
public static string CreateTemporaryDirectory()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "tmp", Guid.NewGuid().ToString("N"));
|
||||
var suffix = Interlocked.Increment(ref _tempCounter).ToString("D4", CultureInfo.InvariantCulture);
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "tmp", suffix);
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Tests.DotNet.Bundling;
|
||||
|
||||
public sealed class BundlingSignalTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToMetadata_UsesInvariantCultureForNumericValues()
|
||||
{
|
||||
var originalCulture = CultureInfo.CurrentCulture;
|
||||
var originalUiCulture = CultureInfo.CurrentUICulture;
|
||||
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("ar-SA");
|
||||
CultureInfo.CurrentUICulture = new CultureInfo("ar-SA");
|
||||
|
||||
var signal = new BundlingSignal(
|
||||
FilePath: "app.exe",
|
||||
Kind: BundlingKind.ILMerge,
|
||||
IsSkipped: false,
|
||||
SkipReason: null,
|
||||
Indicators: ImmutableArray<string>.Empty,
|
||||
SizeBytes: 123456,
|
||||
EstimatedBundledAssemblies: 42);
|
||||
|
||||
var metadata = signal.ToMetadata()
|
||||
.ToDictionary(item => item.Key, item => item.Value, StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal("123456", metadata["bundle.sizeBytes"]);
|
||||
Assert.Equal("42", metadata["bundle.estimatedAssemblies"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = originalCulture;
|
||||
CultureInfo.CurrentUICulture = originalUiCulture;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Callgraph;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Tests.DotNet.Callgraph;
|
||||
|
||||
public sealed class DotNetCallgraphBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_UsesProvidedTimeProvider()
|
||||
{
|
||||
var fixedTime = new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero);
|
||||
var builder = new DotNetCallgraphBuilder("context-digest", new FixedTimeProvider(fixedTime));
|
||||
|
||||
var graph = builder.Build();
|
||||
|
||||
Assert.Equal(fixedTime, graph.Metadata.GeneratedAt);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- Disable Concelier test infrastructure - not needed for scanner tests -->
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Bundling;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Tests.TestUtilities;
|
||||
@@ -8,6 +10,7 @@ namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Tests.TestUtilities;
|
||||
/// </summary>
|
||||
internal static class DotNetFixtureBuilder
|
||||
{
|
||||
private static int _tempCounter;
|
||||
/// <summary>
|
||||
/// Creates a minimal SDK-style project file.
|
||||
/// </summary>
|
||||
@@ -373,7 +376,9 @@ internal static class DotNetFixtureBuilder
|
||||
/// </summary>
|
||||
public static string CreateTemporaryDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString("N"));
|
||||
var counter = Interlocked.Increment(ref _tempCounter)
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
var path = Path.Combine(Path.GetTempPath(), "stellaops-tests", $"run-{counter}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture.Timeline;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Tests.RuntimeCapture.Timeline;
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Library.Tests.RuntimeCapture.Timeline;
|
||||
|
||||
public class TimelineBuilderTests
|
||||
{
|
||||
private readonly TimelineBuilder _builder = new();
|
||||
private static readonly DateTimeOffset BaseTime = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Build_WithNoObservations_ReturnsUnknownPosture()
|
||||
@@ -78,15 +79,15 @@ public class TimelineBuilderTests
|
||||
{
|
||||
return new RuntimeEvidence
|
||||
{
|
||||
FirstObservation = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
LastObservation = DateTimeOffset.UtcNow,
|
||||
FirstObservation = BaseTime.AddHours(-1),
|
||||
LastObservation = BaseTime,
|
||||
Observations = Array.Empty<RuntimeObservation>(),
|
||||
Sessions = new[]
|
||||
{
|
||||
new RuntimeSession
|
||||
{
|
||||
StartTime = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
EndTime = DateTimeOffset.UtcNow,
|
||||
StartTime = BaseTime.AddHours(-1),
|
||||
EndTime = BaseTime,
|
||||
Platform = "linux-x64"
|
||||
}
|
||||
},
|
||||
@@ -96,7 +97,7 @@ public class TimelineBuilderTests
|
||||
|
||||
private static RuntimeEvidence CreateEvidenceWithoutComponent()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = BaseTime;
|
||||
return new RuntimeEvidence
|
||||
{
|
||||
FirstObservation = now.AddHours(-1),
|
||||
@@ -127,7 +128,7 @@ public class TimelineBuilderTests
|
||||
|
||||
private static RuntimeEvidence CreateEvidenceWithNetworkExposure()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = BaseTime;
|
||||
return new RuntimeEvidence
|
||||
{
|
||||
FirstObservation = now.AddHours(-1),
|
||||
@@ -166,7 +167,7 @@ public class TimelineBuilderTests
|
||||
|
||||
private static RuntimeEvidence CreateEvidenceOver24Hours()
|
||||
{
|
||||
var start = DateTimeOffset.UtcNow.AddHours(-24);
|
||||
var start = BaseTime.AddHours(-24);
|
||||
var observations = new List<RuntimeObservation>();
|
||||
|
||||
for (int i = 0; i < 24; i++)
|
||||
@@ -200,7 +201,7 @@ public class TimelineBuilderTests
|
||||
|
||||
private static RuntimeEvidence CreateEvidenceWithComponentLoad()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = BaseTime;
|
||||
return new RuntimeEvidence
|
||||
{
|
||||
FirstObservation = now.AddHours(-1),
|
||||
@@ -231,7 +232,7 @@ public class TimelineBuilderTests
|
||||
|
||||
private static RuntimeEvidence CreateEvidenceWith10Observations()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = BaseTime;
|
||||
var observations = new List<RuntimeObservation>();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Tests;
|
||||
|
||||
internal sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime)
|
||||
{
|
||||
_fixedTime = fixedTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
using System.Buffers.Binary;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Native.Hardening;
|
||||
using StellaOps.Scanner.Analyzers.Native.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Tests.Hardening;
|
||||
@@ -18,7 +19,10 @@ namespace StellaOps.Scanner.Analyzers.Native.Tests.Hardening;
|
||||
/// </summary>
|
||||
public class ElfHardeningExtractorTests
|
||||
{
|
||||
private readonly ElfHardeningExtractor _extractor = new();
|
||||
private static readonly TimeProvider FixedTimeProvider =
|
||||
new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
private readonly ElfHardeningExtractor _extractor = new(FixedTimeProvider);
|
||||
|
||||
#region Magic Detection Tests
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ namespace StellaOps.Scanner.Analyzers.Native.Tests.Hardening;
|
||||
/// </summary>
|
||||
public class HardeningScoreCalculatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
#region Score Range Tests
|
||||
|
||||
[Fact]
|
||||
@@ -38,7 +40,7 @@ public class HardeningScoreCalculatorTests
|
||||
Flags: flags,
|
||||
HardeningScore: CalculateScore(flags, BinaryFormat.Elf),
|
||||
MissingFlags: [],
|
||||
ExtractedAt: DateTimeOffset.UtcNow);
|
||||
ExtractedAt: FixedTime);
|
||||
|
||||
// Assert
|
||||
result.HardeningScore.Should().BeGreaterThanOrEqualTo(0.8);
|
||||
@@ -63,7 +65,7 @@ public class HardeningScoreCalculatorTests
|
||||
Flags: flags,
|
||||
HardeningScore: CalculateScore(flags, BinaryFormat.Elf),
|
||||
MissingFlags: ["PIE", "RELRO", "NX", "STACK_CANARY", "FORTIFY"],
|
||||
ExtractedAt: DateTimeOffset.UtcNow);
|
||||
ExtractedAt: FixedTime);
|
||||
|
||||
// Assert
|
||||
result.HardeningScore.Should().Be(0);
|
||||
@@ -82,7 +84,7 @@ public class HardeningScoreCalculatorTests
|
||||
Flags: flags,
|
||||
HardeningScore: CalculateScore(flags, BinaryFormat.Elf),
|
||||
MissingFlags: [],
|
||||
ExtractedAt: DateTimeOffset.UtcNow);
|
||||
ExtractedAt: FixedTime);
|
||||
|
||||
// Assert
|
||||
result.HardeningScore.Should().Be(0);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
using System.Buffers.Binary;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Native.Hardening;
|
||||
using StellaOps.Scanner.Analyzers.Native.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Tests.Hardening;
|
||||
@@ -18,7 +19,10 @@ namespace StellaOps.Scanner.Analyzers.Native.Tests.Hardening;
|
||||
/// </summary>
|
||||
public class PeHardeningExtractorTests
|
||||
{
|
||||
private readonly PeHardeningExtractor _extractor = new();
|
||||
private static readonly TimeProvider FixedTimeProvider =
|
||||
new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
private readonly PeHardeningExtractor _extractor = new(FixedTimeProvider);
|
||||
|
||||
#region Magic Detection Tests
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Analyzers.Native.Tests;
|
||||
using StellaOps.Scanner.Analyzers.Native.Index;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
using StellaOps.Scanner.ProofSpine.Options;
|
||||
@@ -10,33 +11,21 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Index.Tests;
|
||||
|
||||
public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
|
||||
public sealed class OfflineBuildIdIndexSignatureTests
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public OfflineBuildIdIndexSignatureTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"buildid-sig-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
private static readonly TimeProvider FixedTimeProvider =
|
||||
new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_RequiresTrustedDsseSignature_WhenEnabled()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"}
|
||||
""");
|
||||
|
||||
var signaturePath = Path.Combine(_tempDir, "index.ndjson.dsse.json");
|
||||
var signaturePath = Path.Combine(temp.Path, "index.ndjson.dsse.json");
|
||||
await File.WriteAllTextAsync(signaturePath, CreateDsseSignature(indexPath, expectedSha256: ComputeSha256Hex(indexPath)));
|
||||
|
||||
var dsseService = CreateTrustedDsseService(keyId: "buildid-index-test-key", secretBase64: Convert.ToBase64String("supersecret-supersecret-supersecret"u8.ToArray()));
|
||||
@@ -48,7 +37,7 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
|
||||
RequireSignature = true,
|
||||
});
|
||||
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, dsseService);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider, dsseService);
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
@@ -62,12 +51,13 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
|
||||
[Fact]
|
||||
public async Task LoadAsync_RefusesToLoadIndex_WhenDigestDoesNotMatchSignaturePayload()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
|
||||
""");
|
||||
|
||||
var signaturePath = Path.Combine(_tempDir, "index.ndjson.dsse.json");
|
||||
var signaturePath = Path.Combine(temp.Path, "index.ndjson.dsse.json");
|
||||
await File.WriteAllTextAsync(signaturePath, CreateDsseSignature(indexPath, expectedSha256: "deadbeef"));
|
||||
|
||||
var dsseService = CreateTrustedDsseService(keyId: "buildid-index-test-key", secretBase64: Convert.ToBase64String("supersecret-supersecret-supersecret"u8.ToArray()));
|
||||
@@ -79,7 +69,7 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
|
||||
RequireSignature = true,
|
||||
});
|
||||
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, dsseService);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider, dsseService);
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
@@ -89,12 +79,13 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
|
||||
[Fact]
|
||||
public async Task LoadAsync_RefusesToLoadIndex_WhenSignatureFileMissing()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
|
||||
""");
|
||||
|
||||
var signaturePath = Path.Combine(_tempDir, "missing.dsse.json");
|
||||
var signaturePath = Path.Combine(temp.Path, "missing.dsse.json");
|
||||
var dsseService = CreateTrustedDsseService(keyId: "buildid-index-test-key", secretBase64: Convert.ToBase64String("supersecret-supersecret-supersecret"u8.ToArray()));
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions
|
||||
@@ -104,7 +95,7 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
|
||||
RequireSignature = true,
|
||||
});
|
||||
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, dsseService);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider, dsseService);
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Analyzers.Native.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Index.Tests;
|
||||
@@ -8,23 +9,10 @@ namespace StellaOps.Scanner.Analyzers.Native.Index.Tests;
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="OfflineBuildIdIndex"/>.
|
||||
/// </summary>
|
||||
public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
public sealed class OfflineBuildIdIndexTests
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public OfflineBuildIdIndexTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"buildid-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
private static readonly TimeProvider FixedTimeProvider =
|
||||
new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
#region Loading Tests
|
||||
|
||||
@@ -32,7 +20,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
public async Task LoadAsync_EmptyIndex_WhenNoPathConfigured()
|
||||
{
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = null });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
|
||||
await index.LoadAsync();
|
||||
|
||||
@@ -44,7 +32,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
public async Task LoadAsync_EmptyIndex_WhenFileNotFound()
|
||||
{
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = "/nonexistent/file.ndjson" });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
|
||||
await index.LoadAsync();
|
||||
|
||||
@@ -55,7 +43,8 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesNdjsonEntries()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"}
|
||||
{"build_id":"pe-cv:12345678-1234-1234-1234-123456789012-1","purl":"pkg:nuget/System.Text.Json@8.0.0","confidence":"inferred"}
|
||||
@@ -63,7 +52,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
""");
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
|
||||
await index.LoadAsync();
|
||||
|
||||
@@ -74,7 +63,8 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsEmptyLines()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index-empty-lines.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index-empty-lines.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
|
||||
|
||||
@@ -83,7 +73,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
""");
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
|
||||
await index.LoadAsync();
|
||||
|
||||
@@ -93,7 +83,8 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsCommentLines()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index-comments.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index-comments.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
# This is a comment
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
|
||||
@@ -102,7 +93,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
""");
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
|
||||
await index.LoadAsync();
|
||||
|
||||
@@ -112,7 +103,8 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsInvalidJsonLines()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index-invalid.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index-invalid.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
|
||||
not valid json at all
|
||||
@@ -120,7 +112,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
""");
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
|
||||
await index.LoadAsync();
|
||||
|
||||
@@ -134,13 +126,14 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[Fact]
|
||||
public async Task LookupAsync_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
|
||||
""");
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
await index.LoadAsync();
|
||||
|
||||
var result = await index.LookupAsync("gnu-build-id:notfound");
|
||||
@@ -151,13 +144,14 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[Fact]
|
||||
public async Task LookupAsync_ReturnsNull_ForNullOrEmpty()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
|
||||
""");
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.Null(await index.LookupAsync(null!));
|
||||
@@ -168,13 +162,14 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[Fact]
|
||||
public async Task LookupAsync_FindsExactMatch()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123def456","purl":"pkg:deb/debian/libc6@2.31","version":"2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"}
|
||||
""");
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
await index.LoadAsync();
|
||||
|
||||
var result = await index.LookupAsync("gnu-build-id:abc123def456");
|
||||
@@ -190,13 +185,14 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[Fact]
|
||||
public async Task LookupAsync_CaseInsensitive()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:ABC123DEF456","purl":"pkg:deb/debian/libc6@2.31"}
|
||||
""");
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
await index.LoadAsync();
|
||||
|
||||
// Query with lowercase
|
||||
@@ -213,7 +209,8 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[Fact]
|
||||
public async Task BatchLookupAsync_ReturnsFoundEntries()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:aaa","purl":"pkg:deb/debian/liba@1.0"}
|
||||
{"build_id":"gnu-build-id:bbb","purl":"pkg:deb/debian/libb@1.0"}
|
||||
@@ -221,7 +218,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
""");
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
await index.LoadAsync();
|
||||
|
||||
var results = await index.BatchLookupAsync(["gnu-build-id:aaa", "gnu-build-id:notfound", "gnu-build-id:ccc"]);
|
||||
@@ -234,13 +231,14 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[Fact]
|
||||
public async Task BatchLookupAsync_SkipsNullAndEmpty()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:aaa","purl":"pkg:deb/debian/liba@1.0"}
|
||||
""");
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
await index.LoadAsync();
|
||||
|
||||
var results = await index.BatchLookupAsync([null!, "", " ", "gnu-build-id:aaa"]);
|
||||
@@ -263,12 +261,13 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
[InlineData("", BuildIdConfidence.Heuristic)]
|
||||
public async Task LoadAsync_ParsesConfidenceLevels(string confidenceValue, BuildIdConfidence expected)
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
using var temp = TempDirectory.Create();
|
||||
var indexPath = Path.Combine(temp.Path, "index.ndjson");
|
||||
var entry = new { build_id = "gnu-build-id:test", purl = "pkg:test/test@1.0", confidence = confidenceValue };
|
||||
await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(entry));
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
|
||||
await index.LoadAsync();
|
||||
|
||||
var result = await index.LookupAsync("gnu-build-id:test");
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Index.Tests;
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
private TempDirectory(string path)
|
||||
{
|
||||
Path = path;
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
|
||||
public static TempDirectory Create([CallerMemberName] string? testName = null)
|
||||
{
|
||||
var safeName = Sanitize(testName ?? "unknown");
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"stellaops-tests",
|
||||
"buildid-index",
|
||||
safeName);
|
||||
return new TempDirectory(path);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
var buffer = new char[value.Length];
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var ch = value[i];
|
||||
buffer[i] = char.IsLetterOrDigit(ch) ? ch : '_';
|
||||
}
|
||||
return new string(buffer);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ namespace StellaOps.Scanner.Analyzers.Native.Tests.Reachability;
|
||||
/// </summary>
|
||||
public class RichgraphV1AlignmentTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// §8.2: SymbolID Construction uses sym: prefix with 16 hex chars.
|
||||
/// </summary>
|
||||
@@ -400,7 +402,7 @@ public class RichgraphV1AlignmentTests
|
||||
{
|
||||
// Arrange & Act
|
||||
var metadata = new TestGraphMetadata(
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
GeneratedAt: FixedTime,
|
||||
GeneratorVersion: "1.0.0",
|
||||
LayerDigest: "sha256:layer123",
|
||||
BinaryCount: 5,
|
||||
@@ -410,7 +412,7 @@ public class RichgraphV1AlignmentTests
|
||||
SyntheticRootCount: 8);
|
||||
|
||||
// Assert
|
||||
metadata.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1));
|
||||
metadata.GeneratedAt.Should().Be(FixedTime);
|
||||
metadata.GeneratorVersion.Should().Be("1.0.0");
|
||||
metadata.LayerDigest.Should().StartWith("sha256:");
|
||||
metadata.BinaryCount.Should().BeGreaterThan(0);
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Tests;
|
||||
|
||||
internal static class RuntimeCaptureTestClock
|
||||
{
|
||||
internal static readonly DateTime BaseTime = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
internal static readonly DateTimeOffset BaseTimeOffset = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
internal static class RuntimeCaptureTestFactory
|
||||
{
|
||||
internal static TimeProvider CreateTimeProvider() => new FixedTimeProvider(RuntimeCaptureTestClock.BaseTimeOffset);
|
||||
|
||||
internal static IGuidProvider CreateGuidProvider() => new SequentialGuidProvider();
|
||||
|
||||
internal static IRuntimeCaptureAdapter? CreatePlatformAdapter(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
return new LinuxEbpfCaptureAdapter(timeProvider, guidProvider);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
return new WindowsEtwCaptureAdapter(timeProvider, guidProvider);
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return new MacOsDyldCaptureAdapter(timeProvider, guidProvider);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public class RuntimeCaptureOptionsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -261,7 +289,7 @@ public class RuntimeEvidenceAggregatorTests
|
||||
var events = new[]
|
||||
{
|
||||
new RuntimeLoadEvent(
|
||||
DateTime.UtcNow.AddMinutes(-5),
|
||||
RuntimeCaptureTestClock.BaseTime.AddMinutes(-5),
|
||||
ProcessId: 1234,
|
||||
ThreadId: 1,
|
||||
LoadType: RuntimeLoadType.Dlopen,
|
||||
@@ -273,7 +301,7 @@ public class RuntimeEvidenceAggregatorTests
|
||||
CallerModule: "myapp",
|
||||
CallerAddress: 0x400000),
|
||||
new RuntimeLoadEvent(
|
||||
DateTime.UtcNow.AddMinutes(-4),
|
||||
RuntimeCaptureTestClock.BaseTime.AddMinutes(-4),
|
||||
ProcessId: 1234,
|
||||
ThreadId: 1,
|
||||
LoadType: RuntimeLoadType.Dlopen,
|
||||
@@ -288,8 +316,8 @@ public class RuntimeEvidenceAggregatorTests
|
||||
|
||||
var session = new RuntimeCaptureSession(
|
||||
SessionId: "test-session",
|
||||
StartTime: DateTime.UtcNow.AddMinutes(-10),
|
||||
EndTime: DateTime.UtcNow,
|
||||
StartTime: RuntimeCaptureTestClock.BaseTime.AddMinutes(-10),
|
||||
EndTime: RuntimeCaptureTestClock.BaseTime,
|
||||
Platform: "linux",
|
||||
CaptureMethod: "ebpf",
|
||||
TargetProcessId: 1234,
|
||||
@@ -315,7 +343,7 @@ public class RuntimeEvidenceAggregatorTests
|
||||
public void Aggregate_DuplicateLoads_AggregatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var baseTime = DateTime.UtcNow.AddMinutes(-10);
|
||||
var baseTime = RuntimeCaptureTestClock.BaseTime.AddMinutes(-10);
|
||||
var events = new[]
|
||||
{
|
||||
new RuntimeLoadEvent(baseTime, 1, 1, RuntimeLoadType.Dlopen, "libc.so.6", "/lib/libc.so.6", null, true, null, null, null),
|
||||
@@ -323,7 +351,7 @@ public class RuntimeEvidenceAggregatorTests
|
||||
new RuntimeLoadEvent(baseTime.AddMinutes(2), 1, 1, RuntimeLoadType.Dlopen, "libc.so.6", "/lib/libc.so.6", null, true, null, null, null),
|
||||
};
|
||||
|
||||
var session = new RuntimeCaptureSession("test", baseTime, DateTime.UtcNow, "linux", "ebpf", 1, events, 0, 0);
|
||||
var session = new RuntimeCaptureSession("test", baseTime, RuntimeCaptureTestClock.BaseTime, "linux", "ebpf", 1, events, 0, 0);
|
||||
|
||||
// Act
|
||||
var evidence = RuntimeEvidenceAggregator.Aggregate([session]);
|
||||
@@ -341,10 +369,19 @@ public class RuntimeEvidenceAggregatorTests
|
||||
// Arrange
|
||||
var events = new[]
|
||||
{
|
||||
new RuntimeLoadEvent(DateTime.UtcNow, 1, 1, RuntimeLoadType.Dlopen, "missing.so", null, null, false, -1, null, null),
|
||||
new RuntimeLoadEvent(RuntimeCaptureTestClock.BaseTime, 1, 1, RuntimeLoadType.Dlopen, "missing.so", null, null, false, -1, null, null),
|
||||
};
|
||||
|
||||
var session = new RuntimeCaptureSession("test", DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow, "linux", "ebpf", 1, events, 0, 0);
|
||||
var session = new RuntimeCaptureSession(
|
||||
"test",
|
||||
RuntimeCaptureTestClock.BaseTime.AddMinutes(-1),
|
||||
RuntimeCaptureTestClock.BaseTime,
|
||||
"linux",
|
||||
"ebpf",
|
||||
1,
|
||||
events,
|
||||
0,
|
||||
0);
|
||||
|
||||
// Act
|
||||
var evidence = RuntimeEvidenceAggregator.Aggregate([session]);
|
||||
@@ -359,8 +396,8 @@ public class RuntimeEvidenceAggregatorTests
|
||||
public void Aggregate_MultipleSessions_MergesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var time1 = DateTime.UtcNow.AddHours(-2);
|
||||
var time2 = DateTime.UtcNow.AddHours(-1);
|
||||
var time1 = RuntimeCaptureTestClock.BaseTime.AddHours(-2);
|
||||
var time2 = RuntimeCaptureTestClock.BaseTime.AddHours(-1);
|
||||
|
||||
var session1 = new RuntimeCaptureSession(
|
||||
"s1", time1, time1.AddMinutes(30), "linux", "ebpf", 1,
|
||||
@@ -387,8 +424,11 @@ public class RuntimeCaptureAdapterFactoryTests
|
||||
[Fact]
|
||||
public void CreateForCurrentPlatform_ReturnsAdapter()
|
||||
{
|
||||
var timeProvider = RuntimeCaptureTestFactory.CreateTimeProvider();
|
||||
var guidProvider = RuntimeCaptureTestFactory.CreateGuidProvider();
|
||||
|
||||
// Act
|
||||
var adapter = RuntimeCaptureAdapterFactory.CreateForCurrentPlatform();
|
||||
var adapter = RuntimeCaptureAdapterFactory.CreateForCurrentPlatform(timeProvider, guidProvider);
|
||||
|
||||
// Assert
|
||||
// Should return an adapter on Linux/Windows/macOS, null on other platforms
|
||||
@@ -406,8 +446,11 @@ public class RuntimeCaptureAdapterFactoryTests
|
||||
[Fact]
|
||||
public void GetAvailableAdapters_ReturnsAdaptersForCurrentPlatform()
|
||||
{
|
||||
var timeProvider = RuntimeCaptureTestFactory.CreateTimeProvider();
|
||||
var guidProvider = RuntimeCaptureTestFactory.CreateGuidProvider();
|
||||
|
||||
// Act
|
||||
var adapters = RuntimeCaptureAdapterFactory.GetAvailableAdapters();
|
||||
var adapters = RuntimeCaptureAdapterFactory.GetAvailableAdapters(timeProvider, guidProvider);
|
||||
|
||||
// Assert
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsWindows() || OperatingSystem.IsMacOS())
|
||||
@@ -431,8 +474,8 @@ public class SandboxCaptureTests
|
||||
// Arrange
|
||||
var mockEvents = new[]
|
||||
{
|
||||
new RuntimeLoadEvent(DateTime.UtcNow, 1, 1, RuntimeLoadType.Dlopen, "libtest.so", "/lib/libtest.so", null, true, null, null, null),
|
||||
new RuntimeLoadEvent(DateTime.UtcNow, 1, 1, RuntimeLoadType.Dlopen, "libother.so", "/lib/libother.so", null, true, null, null, null),
|
||||
new RuntimeLoadEvent(RuntimeCaptureTestClock.BaseTime, 1, 1, RuntimeLoadType.Dlopen, "libtest.so", "/lib/libtest.so", null, true, null, null, null),
|
||||
new RuntimeLoadEvent(RuntimeCaptureTestClock.BaseTime, 1, 1, RuntimeLoadType.Dlopen, "libother.so", "/lib/libother.so", null, true, null, null, null),
|
||||
};
|
||||
|
||||
var options = new RuntimeCaptureOptions
|
||||
@@ -446,13 +489,9 @@ public class SandboxCaptureTests
|
||||
}
|
||||
};
|
||||
|
||||
IRuntimeCaptureAdapter? adapter = null;
|
||||
if (OperatingSystem.IsLinux())
|
||||
adapter = new LinuxEbpfCaptureAdapter();
|
||||
else if (OperatingSystem.IsWindows())
|
||||
adapter = new WindowsEtwCaptureAdapter();
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
adapter = new MacOsDyldCaptureAdapter();
|
||||
var timeProvider = RuntimeCaptureTestFactory.CreateTimeProvider();
|
||||
var guidProvider = RuntimeCaptureTestFactory.CreateGuidProvider();
|
||||
var adapter = RuntimeCaptureTestFactory.CreatePlatformAdapter(timeProvider, guidProvider);
|
||||
|
||||
if (adapter == null)
|
||||
return; // Skip on unsupported platforms
|
||||
@@ -486,13 +525,9 @@ public class SandboxCaptureTests
|
||||
}
|
||||
};
|
||||
|
||||
IRuntimeCaptureAdapter? adapter = null;
|
||||
if (OperatingSystem.IsLinux())
|
||||
adapter = new LinuxEbpfCaptureAdapter();
|
||||
else if (OperatingSystem.IsWindows())
|
||||
adapter = new WindowsEtwCaptureAdapter();
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
adapter = new MacOsDyldCaptureAdapter();
|
||||
var timeProvider = RuntimeCaptureTestFactory.CreateTimeProvider();
|
||||
var guidProvider = RuntimeCaptureTestFactory.CreateGuidProvider();
|
||||
var adapter = RuntimeCaptureTestFactory.CreatePlatformAdapter(timeProvider, guidProvider);
|
||||
|
||||
if (adapter == null)
|
||||
return; // Skip on unsupported platforms
|
||||
@@ -527,13 +562,9 @@ public class SandboxCaptureTests
|
||||
}
|
||||
};
|
||||
|
||||
IRuntimeCaptureAdapter? adapter = null;
|
||||
if (OperatingSystem.IsLinux())
|
||||
adapter = new LinuxEbpfCaptureAdapter();
|
||||
else if (OperatingSystem.IsWindows())
|
||||
adapter = new WindowsEtwCaptureAdapter();
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
adapter = new MacOsDyldCaptureAdapter();
|
||||
var timeProvider = RuntimeCaptureTestFactory.CreateTimeProvider();
|
||||
var guidProvider = RuntimeCaptureTestFactory.CreateGuidProvider();
|
||||
var adapter = RuntimeCaptureTestFactory.CreatePlatformAdapter(timeProvider, guidProvider);
|
||||
|
||||
if (adapter == null)
|
||||
return; // Skip on unsupported platforms
|
||||
@@ -558,7 +589,7 @@ public class RuntimeEvidenceModelTests
|
||||
public void RuntimeLoadEvent_RecordEquality_Works()
|
||||
{
|
||||
// Arrange
|
||||
var time = DateTime.UtcNow;
|
||||
var time = RuntimeCaptureTestClock.BaseTime;
|
||||
var event1 = new RuntimeLoadEvent(time, 1, 1, RuntimeLoadType.Dlopen, "lib.so", "/lib.so", null, true, null, null, null);
|
||||
var event2 = new RuntimeLoadEvent(time, 1, 1, RuntimeLoadType.Dlopen, "lib.so", "/lib.so", null, true, null, null, null);
|
||||
var event3 = new RuntimeLoadEvent(time, 2, 1, RuntimeLoadType.Dlopen, "lib.so", "/lib.so", null, true, null, null, null);
|
||||
@@ -580,10 +611,54 @@ public class RuntimeEvidenceModelTests
|
||||
{
|
||||
// Verify each type can be used to create an event
|
||||
var evt = new RuntimeLoadEvent(
|
||||
DateTime.UtcNow, 1, 1, loadType,
|
||||
RuntimeCaptureTestClock.BaseTime, 1, 1, loadType,
|
||||
"test.so", "/test.so", null, true, null, null, null);
|
||||
|
||||
evt.LoadType.Should().Be(loadType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class CaptureDurationTimerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_CallsStopWithProvidedToken()
|
||||
{
|
||||
using var captureCts = new CancellationTokenSource();
|
||||
using var stopCts = new CancellationTokenSource();
|
||||
var tcs = new TaskCompletionSource<CancellationToken>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Task StopAsync(CancellationToken token)
|
||||
{
|
||||
tcs.TrySetResult(token);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var timerTask = CaptureDurationTimer.RunAsync(TimeSpan.Zero, StopAsync, captureCts.Token, stopCts.Token);
|
||||
var observedToken = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
|
||||
observedToken.Should().Be(stopCts.Token);
|
||||
await timerTask;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_CanceledCapture_DoesNotInvokeStop()
|
||||
{
|
||||
using var captureCts = new CancellationTokenSource();
|
||||
using var stopCts = new CancellationTokenSource();
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Task StopAsync(CancellationToken token)
|
||||
{
|
||||
tcs.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
captureCts.Cancel();
|
||||
await CaptureDurationTimer.RunAsync(TimeSpan.FromMinutes(1), StopAsync, captureCts.Token, stopCts.Token);
|
||||
|
||||
tcs.Task.IsCompleted.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -13,11 +13,6 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Exclude TimelineBuilderTests.cs as the Timeline namespace is in a different project -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="RuntimeCapture\Timeline\TimelineBuilderTests.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
@@ -32,4 +27,4 @@
|
||||
<PropertyGroup>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -426,6 +426,20 @@ public class CallGraphDigestsTests
|
||||
Assert.Equal("native:SSL_read", stableId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeResultDigest_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var result = CreateMinimalResult();
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeResultDigest(result);
|
||||
var digest2 = CallGraphDigests.ComputeResultDigest(result);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateMinimalSnapshot()
|
||||
{
|
||||
return new CallGraphSnapshot(
|
||||
@@ -453,6 +467,24 @@ public class CallGraphDigestsTests
|
||||
);
|
||||
}
|
||||
|
||||
private static ReachabilityAnalysisResult CreateMinimalResult()
|
||||
{
|
||||
return new ReachabilityAnalysisResult(
|
||||
ScanId: "test-scan-001",
|
||||
GraphDigest: "sha256:graph",
|
||||
Language: "native",
|
||||
ComputedAt: DateTimeOffset.UtcNow,
|
||||
ReachableNodeIds: ImmutableArray.Create("entry", "node-a"),
|
||||
ReachableSinkIds: ImmutableArray.Create("sink-a"),
|
||||
Paths: ImmutableArray.Create(
|
||||
new ReachabilityPath(
|
||||
EntrypointId: "entry",
|
||||
SinkId: "sink-a",
|
||||
NodeIds: ImmutableArray.Create("entry", "node-a", "sink-a"))),
|
||||
ResultDigest: string.Empty
|
||||
);
|
||||
}
|
||||
|
||||
private static bool IsValidHex(string hex)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hex))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using System.Globalization;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
using StellaOps.Scanner.Reachability.Stack;
|
||||
|
||||
@@ -10,7 +12,7 @@ namespace StellaOps.Scanner.Reachability.Stack.Tests;
|
||||
|
||||
public class ReachabilityStackEvaluatorTests
|
||||
{
|
||||
private readonly ReachabilityStackEvaluator _evaluator = new();
|
||||
private readonly ReachabilityStackEvaluator _evaluator = new(new SequentialGuidProvider());
|
||||
|
||||
private static VulnerableSymbol CreateTestSymbol() => new(
|
||||
Name: "EVP_DecryptUpdate",
|
||||
@@ -172,17 +174,19 @@ public class ReachabilityStackEvaluatorTests
|
||||
var layer1 = CreateLayer1(isReachable: true, ConfidenceLevel.High);
|
||||
var layer2 = CreateLayer2(isResolved: true, ConfidenceLevel.High);
|
||||
var layer3 = CreateLayer3(isGated: false, GatingOutcome.NotGated, ConfidenceLevel.High);
|
||||
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 1, 12, 9, 0, 0, TimeSpan.Zero));
|
||||
var expectedId = new SequentialGuidProvider().NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
var stack = _evaluator.Evaluate("finding-123", symbol, layer1, layer2, layer3);
|
||||
var stack = _evaluator.Evaluate("finding-123", symbol, layer1, layer2, layer3, timeProvider);
|
||||
|
||||
stack.Id.Should().NotBeNullOrEmpty();
|
||||
stack.Id.Should().Be(expectedId);
|
||||
stack.FindingId.Should().Be("finding-123");
|
||||
stack.Symbol.Should().Be(symbol);
|
||||
stack.StaticCallGraph.Should().Be(layer1);
|
||||
stack.BinaryResolution.Should().Be(layer2);
|
||||
stack.RuntimeGating.Should().Be(layer3);
|
||||
stack.Verdict.Should().Be(ReachabilityVerdict.Exploitable);
|
||||
stack.AnalyzedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
stack.AnalyzedAt.Should().Be(timeProvider.GetUtcNow());
|
||||
stack.Explanation.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
@@ -422,4 +426,16 @@ public class ReachabilityStackEvaluatorTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime)
|
||||
{
|
||||
_fixedTime = fixedTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Decompiler/StellaOps.BinaryIndex.Decompiler.csproj" />
|
||||
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Ghidra/StellaOps.BinaryIndex.Ghidra.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Security;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using Xunit;
|
||||
|
||||
@@ -137,6 +141,49 @@ public class WitnessDsseSignerTests
|
||||
Assert.Equal(result1.PayloadBytes, result2.PayloadBytes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignWitness_UsesCanonicalPayloadAndDssePae()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new WitnessDsseSigner(new EnvelopeSignatureService());
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
// Act
|
||||
var signResult = signer.SignWitness(witness, signingKey, TestCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(signResult.IsSuccess, signResult.Error);
|
||||
Assert.NotNull(signResult.Envelope);
|
||||
Assert.NotNull(signResult.PayloadBytes);
|
||||
|
||||
var payloadBytes = signResult.PayloadBytes!;
|
||||
var expectedPayload = CanonJson.Canonicalize(witness, options);
|
||||
Assert.Equal(expectedPayload, payloadBytes);
|
||||
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
var signatureBytes = Convert.FromBase64String(signResult.Envelope!.Signatures[0].Signature);
|
||||
var envelopeSignature = new EnvelopeSignature(signingKey.KeyId, signingKey.AlgorithmId, signatureBytes);
|
||||
var verifyResult = new EnvelopeSignatureService().VerifyDsse(
|
||||
WitnessSchema.DssePayloadType,
|
||||
payloadBytes,
|
||||
envelopeSignature,
|
||||
verifyKey,
|
||||
TestCancellationToken);
|
||||
|
||||
Assert.True(verifyResult.IsSuccess);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VerifyWitness_WithInvalidPayloadType_ReturnsFails()
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Security;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
@@ -286,6 +290,49 @@ public sealed class SuppressionDsseSignerTests
|
||||
verifyResult.Witness.Evidence.Unreachability?.UnreachableSymbol);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignWitness_UsesCanonicalPayloadAndDssePae()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new SuppressionDsseSigner(new EnvelopeSignatureService());
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
// Act
|
||||
var signResult = signer.SignWitness(witness, signingKey, TestCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(signResult.IsSuccess, signResult.Error);
|
||||
Assert.NotNull(signResult.Envelope);
|
||||
Assert.NotNull(signResult.PayloadBytes);
|
||||
|
||||
var payloadBytes = signResult.PayloadBytes!;
|
||||
var expectedPayload = CanonJson.Canonicalize(witness, options);
|
||||
Assert.Equal(expectedPayload, payloadBytes);
|
||||
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
var signatureBytes = Convert.FromBase64String(signResult.Envelope!.Signatures[0].Signature);
|
||||
var envelopeSignature = new EnvelopeSignature(signingKey.KeyId, signingKey.AlgorithmId, signatureBytes);
|
||||
var verifyResult = new EnvelopeSignatureService().VerifyDsse(
|
||||
SuppressionWitnessSchema.DssePayloadType,
|
||||
payloadBytes,
|
||||
envelopeSignature,
|
||||
verifyKey,
|
||||
TestCancellationToken);
|
||||
|
||||
Assert.True(verifyResult.IsSuccess);
|
||||
}
|
||||
|
||||
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
|
||||
{
|
||||
private byte _value;
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
@@ -17,7 +18,9 @@ public sealed class AttestorClientTests
|
||||
public async Task SendPlaceholderAsync_PostsJsonPayload()
|
||||
{
|
||||
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.Accepted));
|
||||
using var httpClient = new HttpClient(handler);
|
||||
using var provider = BuildHttpClientProvider(handler);
|
||||
var factory = provider.GetRequiredService<IHttpClientFactory>();
|
||||
using var httpClient = factory.CreateClient("attestor");
|
||||
var client = new AttestorClient(httpClient);
|
||||
|
||||
var document = BuildDescriptorDocument();
|
||||
@@ -43,7 +46,9 @@ public sealed class AttestorClientTests
|
||||
{
|
||||
Content = new StringContent("invalid")
|
||||
});
|
||||
using var httpClient = new HttpClient(handler);
|
||||
using var provider = BuildHttpClientProvider(handler);
|
||||
var factory = provider.GetRequiredService<IHttpClientFactory>();
|
||||
using var httpClient = factory.CreateClient("attestor");
|
||||
var client = new AttestorClient(httpClient);
|
||||
|
||||
var document = BuildDescriptorDocument();
|
||||
@@ -54,12 +59,21 @@ public sealed class AttestorClientTests
|
||||
|
||||
private static DescriptorDocument BuildDescriptorDocument()
|
||||
{
|
||||
var createdAt = DateTimeOffset.Parse("2026-01-13T00:00:00Z");
|
||||
var subject = new DescriptorSubject("application/vnd.oci.image.manifest.v1+json", "sha256:img");
|
||||
var artifact = new DescriptorArtifact("application/vnd.cyclonedx+json; version=1.7", "sha256:sbom", 42, new System.Collections.Generic.Dictionary<string, string>());
|
||||
var provenance = new DescriptorProvenance("pending", "sha256:dsse", "nonce", "https://attestor.example.com/api/v1/provenance", "https://slsa.dev/provenance/v1");
|
||||
var generatorMetadata = new DescriptorGeneratorMetadata("generator", "1.0.0");
|
||||
var metadata = new System.Collections.Generic.Dictionary<string, string>();
|
||||
return new DescriptorDocument("schema", DateTimeOffset.UtcNow, generatorMetadata, subject, artifact, provenance, metadata);
|
||||
return new DescriptorDocument("schema", createdAt, generatorMetadata, subject, artifact, provenance, metadata);
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildHttpClientProvider(HttpMessageHandler handler)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient("attestor")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => handler);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
|
||||
@@ -74,6 +74,15 @@ public sealed class DescriptorCommandSurfaceTests
|
||||
throw new FileNotFoundException($"BuildX plug-in assembly not found at '{pluginAssembly}'.");
|
||||
}
|
||||
|
||||
EnsurePathWithinRoot(actualRepoRoot, pluginAssembly, "Plug-in assembly");
|
||||
EnsurePathWithinRoot(actualRepoRoot, manifestDirectory, "Manifest directory");
|
||||
EnsurePathWithinRoot(temp.Path, casRoot, "CAS root");
|
||||
EnsurePathWithinRoot(temp.Path, sbomPath, "SBOM path");
|
||||
EnsurePathWithinRoot(temp.Path, layerFragmentsPath, "Layer fragments path");
|
||||
EnsurePathWithinRoot(temp.Path, entryTraceGraphPath, "Entry trace graph path");
|
||||
EnsurePathWithinRoot(temp.Path, entryTraceNdjsonPath, "Entry trace NDJSON path");
|
||||
EnsurePathWithinRoot(temp.Path, manifestOutputPath, "Manifest output path");
|
||||
|
||||
var psi = new ProcessStartInfo("dotnet")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
@@ -107,10 +116,11 @@ public sealed class DescriptorCommandSurfaceTests
|
||||
psi.ArgumentList.Add("--surface-manifest-output");
|
||||
psi.ArgumentList.Add(manifestOutputPath);
|
||||
|
||||
ValidateProcessStartInfo(psi);
|
||||
var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start BuildX plug-in process.");
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
await process.WaitForExitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(process.ExitCode == 0, $"Descriptor command failed.\nSTDOUT: {stdout}\nSTDERR: {stderr}");
|
||||
|
||||
@@ -163,4 +173,33 @@ public sealed class DescriptorCommandSurfaceTests
|
||||
var digest = hash.ComputeHash(bytes, HashAlgorithms.Sha256);
|
||||
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static void ValidateProcessStartInfo(ProcessStartInfo psi)
|
||||
{
|
||||
if (!string.Equals(System.IO.Path.GetFileName(psi.FileName), "dotnet", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Only dotnet execution is permitted for plug-in tests.");
|
||||
}
|
||||
|
||||
foreach (var argument in psi.ArgumentList)
|
||||
{
|
||||
if (argument.Contains('\n') || argument.Contains('\r'))
|
||||
{
|
||||
throw new InvalidOperationException("Process arguments must not contain newline characters.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsurePathWithinRoot(string root, string candidatePath, string label)
|
||||
{
|
||||
var normalizedRoot = System.IO.Path.GetFullPath(root)
|
||||
.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar)
|
||||
+ System.IO.Path.DirectorySeparatorChar;
|
||||
var normalizedPath = System.IO.Path.GetFullPath(candidatePath);
|
||||
|
||||
if (!normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"{label} must stay under '{normalizedRoot}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Surface;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
|
||||
@@ -43,7 +47,8 @@ public sealed class SurfaceManifestWriterTests
|
||||
EntryTraceNdjsonPath: ndjsonPath,
|
||||
ManifestOutputPath: manifestOutputPath);
|
||||
|
||||
var writer = new SurfaceManifestWriter(TimeProvider.System, CryptoHashFactory.CreateDefault());
|
||||
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero));
|
||||
var writer = new SurfaceManifestWriter(timeProvider, CryptoHashFactory.CreateDefault());
|
||||
var result = await writer.WriteAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
@@ -67,6 +72,10 @@ public sealed class SurfaceManifestWriterTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(artifact.ManifestArtifact.Uri));
|
||||
Assert.StartsWith("cas://scanner-artifacts/", artifact.ManifestArtifact.Uri, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var canonicalBytes = CanonJson.Canonicalize(result.Document, CreateManifestJsonOptions());
|
||||
var manifestBytes = await File.ReadAllBytesAsync(result.ManifestPath, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(canonicalBytes, manifestBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -89,8 +98,60 @@ public sealed class SurfaceManifestWriterTests
|
||||
EntryTraceNdjsonPath: null,
|
||||
ManifestOutputPath: null);
|
||||
|
||||
var writer = new SurfaceManifestWriter(TimeProvider.System, CryptoHashFactory.CreateDefault());
|
||||
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero));
|
||||
var writer = new SurfaceManifestWriter(timeProvider, CryptoHashFactory.CreateDefault());
|
||||
var result = await writer.WriteAsync(options, TestContext.Current.CancellationToken);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_DefaultsWorkerInstanceToComponent()
|
||||
{
|
||||
await using var temp = new TempDirectory();
|
||||
var fragmentsPath = Path.Combine(temp.Path, "layer-fragments.json");
|
||||
await File.WriteAllTextAsync(fragmentsPath, "[]");
|
||||
|
||||
var options = new SurfaceOptions(
|
||||
CacheRoot: temp.Path,
|
||||
CacheBucket: "scanner-artifacts",
|
||||
RootPrefix: "scanner",
|
||||
Tenant: "tenant-a",
|
||||
Component: "scanner.buildx",
|
||||
ComponentVersion: "1.2.3",
|
||||
WorkerInstance: "",
|
||||
Attempt: 1,
|
||||
ImageDigest: "sha256:feedface",
|
||||
ScanId: "scan-123",
|
||||
LayerFragmentsPath: fragmentsPath,
|
||||
EntryTraceGraphPath: null,
|
||||
EntryTraceNdjsonPath: null,
|
||||
ManifestOutputPath: null);
|
||||
|
||||
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero));
|
||||
var writer = new SurfaceManifestWriter(timeProvider, CryptoHashFactory.CreateDefault());
|
||||
var result = await writer.WriteAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("scanner.buildx", result!.Document.Source?.WorkerInstance);
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateManifestJsonOptions()
|
||||
=> new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user