notify doctors work, audit work, new product advisory sprints

This commit is contained in:
master
2026-01-13 08:36:29 +02:00
parent b8868a5f13
commit 9ca7cb183e
343 changed files with 24492 additions and 3544 deletions

View File

@@ -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. |

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 />

View File

@@ -514,7 +514,8 @@ public static class PeImportParser
{
IgnoreWhitespace = true,
IgnoreComments = true,
DtdProcessing = DtdProcessing.Ignore,
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
});
while (reader.Read())

View File

@@ -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)
{
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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)

View File

@@ -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(

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);

View File

@@ -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",

View File

@@ -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
});
}

View File

@@ -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

View File

@@ -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))

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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" />

View 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

View File

@@ -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");

View File

@@ -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)

View File

@@ -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))

View File

@@ -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
};

View File

@@ -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++)

View File

@@ -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>

View File

@@ -232,6 +232,7 @@ internal static class DotNetLicenseCache
DtdProcessing = DtdProcessing.Ignore,
IgnoreComments = true,
IgnoreWhitespace = true,
XmlResolver = null,
});
var expressions = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);

View File

@@ -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). |

View File

@@ -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>

View File

@@ -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)

View File

@@ -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)
}
});
}

View File

@@ -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
};

View File

@@ -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. |

View File

@@ -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");
}
}

View File

@@ -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)

View File

@@ -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",

View File

@@ -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
};

View File

@@ -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;
}

View File

@@ -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
};

View File

@@ -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(

View File

@@ -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,

View File

@@ -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" />

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -4,6 +4,8 @@ namespace StellaOps.Scanner.Surface.Secrets;
public interface ISurfaceSecretProvider
{
SurfaceSecretHandle Get(SurfaceSecretRequest request);
ValueTask<SurfaceSecretHandle> GetAsync(
SurfaceSecretRequest request,
CancellationToken cancellationToken = default);

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -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)
};
}

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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++)

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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");

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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))

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()

View File

@@ -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;

View File

@@ -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

View File

@@ -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}'.");
}
}
}

View File

@@ -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