sprints work.

This commit is contained in:
master
2026-01-20 00:45:38 +02:00
parent b34bde89fa
commit 4903395618
275 changed files with 52785 additions and 79 deletions

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Doctor.DependencyInjection;
using StellaOps.Doctor.Plugin.BinaryAnalysis.DependencyInjection;
using StellaOps.Doctor.Plugins.Attestation.DependencyInjection;
using StellaOps.Doctor.Plugins.Core.DependencyInjection;
using StellaOps.Doctor.Plugins.Database.DependencyInjection;
@@ -130,6 +131,7 @@ builder.Services.AddDoctorReleasePlugin(); // Release pipeline health c
builder.Services.AddDoctorEnvironmentPlugin(); // Environment health checks
builder.Services.AddDoctorScannerPlugin(); // Scanner & reachability health checks
builder.Services.AddDoctorCompliancePlugin(); // Evidence & compliance health checks
builder.Services.AddDoctorBinaryAnalysisPlugin(); // Binary analysis & symbol recovery checks
builder.Services.AddSingleton<IReportStorageService, InMemoryReportStorageService>();
builder.Services.AddSingleton<DoctorRunService>();

View File

@@ -31,6 +31,7 @@
<ProjectReference Include="..\__Plugins\StellaOps.Doctor.Plugin.Environment\StellaOps.Doctor.Plugin.Environment.csproj" />
<ProjectReference Include="..\__Plugins\StellaOps.Doctor.Plugin.Scanner\StellaOps.Doctor.Plugin.Scanner.csproj" />
<ProjectReference Include="..\__Plugins\StellaOps.Doctor.Plugin.Compliance\StellaOps.Doctor.Plugin.Compliance.csproj" />
<ProjectReference Include="..\__Plugins\StellaOps.Doctor.Plugin.BinaryAnalysis\StellaOps.Doctor.Plugin.BinaryAnalysis.csproj" />
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,63 @@
// -----------------------------------------------------------------------------
// BinaryAnalysisDoctorPlugin.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-001 - Binary Analysis Doctor Plugin Scaffold
// Description: Doctor plugin for binary analysis prerequisites health monitoring
// -----------------------------------------------------------------------------
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis;
/// <summary>
/// Doctor plugin for binary analysis prerequisites health checks.
/// Monitors debuginfod availability, ddeb repository access, buildinfo cache,
/// and symbol recovery fallback paths.
/// </summary>
public sealed class BinaryAnalysisDoctorPlugin : IDoctorPlugin
{
private static readonly Version PluginVersion = new(1, 0, 0);
private static readonly Version MinVersion = new(1, 0, 0);
/// <inheritdoc />
public string PluginId => "stellaops.doctor.binaryanalysis";
/// <inheritdoc />
public string DisplayName => "Binary Analysis";
/// <inheritdoc />
public DoctorCategory Category => DoctorCategory.Security;
/// <inheritdoc />
public Version Version => PluginVersion;
/// <inheritdoc />
public Version MinEngineVersion => MinVersion;
/// <inheritdoc />
public bool IsAvailable(IServiceProvider services)
{
// Always available - binary analysis prerequisites should be checked
// even when scanners are not yet configured
return true;
}
/// <inheritdoc />
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
{
return new IDoctorCheck[]
{
new DebuginfodAvailabilityCheck(),
new DdebRepoEnabledCheck(),
new BuildinfoCacheCheck(),
new SymbolRecoveryFallbackCheck()
};
}
/// <inheritdoc />
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,321 @@
// -----------------------------------------------------------------------------
// BuildinfoCacheCheck.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-004 - Buildinfo Cache Check
// Description: Verify Debian buildinfo service accessibility and cache directory
// -----------------------------------------------------------------------------
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
/// <summary>
/// Verifies Debian buildinfo service accessibility and local cache directory.
/// Checks HTTPS connectivity to buildinfos.debian.net and reproduce.debian.net.
/// </summary>
public sealed class BuildinfoCacheCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.binaryanalysis";
private const string CategoryName = "Security";
private const string BuildinfosUrl = "https://buildinfos.debian.net";
private const string ReproduceUrl = "https://reproduce.debian.net";
private const int HttpTimeoutSeconds = 10;
// Default cache directory for buildinfo files
private const string DefaultCacheDir = "/var/cache/stella/buildinfo";
private const string ConfigCacheDirKey = "BinaryAnalysis:BuildinfoCache:Directory";
/// <inheritdoc />
public string CheckId => "check.binaryanalysis.buildinfo.cache";
/// <inheritdoc />
public string Name => "Debian Buildinfo Cache";
/// <inheritdoc />
public string Description => "Verify Debian buildinfo service accessibility and cache directory configuration";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["binaryanalysis", "buildinfo", "debian", "cache", "security"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(15);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
// Always run - buildinfo is useful for reproducible builds verification
return true;
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
try
{
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
HttpClient httpClient;
if (httpClientFactory != null)
{
httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
// Don't set timeout on factory-created clients as they may be reused
}
else
{
httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(HttpTimeoutSeconds) };
}
// Test connectivity to buildinfos.debian.net
var buildinfosResult = await TestConnectivityAsync(httpClient, BuildinfosUrl, ct);
// Test connectivity to reproduce.debian.net (optional, but useful)
var reproduceResult = await TestConnectivityAsync(httpClient, ReproduceUrl, ct);
// Check cache directory
var cacheDir = context.Configuration[ConfigCacheDirKey] ?? DefaultCacheDir;
var cacheStatus = CheckCacheDirectory(cacheDir);
// Determine overall status
var anyServiceReachable = buildinfosResult.Reachable || reproduceResult.Reachable;
var allServicesReachable = buildinfosResult.Reachable && reproduceResult.Reachable;
// Build result based on findings
if (!anyServiceReachable && !cacheStatus.Exists)
{
return builder
.Fail("Debian buildinfo services are unreachable and cache directory does not exist")
.WithEvidence("Buildinfo Status", eb =>
{
eb.Add("buildinfos_debian_net_reachable", false);
eb.Add("reproduce_debian_net_reachable", false);
eb.Add("cache_directory", cacheDir);
eb.Add("cache_exists", false);
if (buildinfosResult.Error != null)
eb.Add("buildinfos_error", buildinfosResult.Error);
if (reproduceResult.Error != null)
eb.Add("reproduce_error", reproduceResult.Error);
})
.WithCauses(
"Firewall blocking HTTPS access to Debian buildinfo services",
"Network connectivity issues",
"DNS resolution failure",
"Proxy configuration required but not set")
.WithRemediation(rb => rb
.AddShellStep(1, "Test connectivity to buildinfos.debian.net",
$"curl -I {BuildinfosUrl}")
.AddShellStep(2, "Create cache directory",
$"sudo mkdir -p {cacheDir} && sudo chmod 755 {cacheDir}")
.AddShellStep(3, "Check proxy settings if behind a corporate firewall",
"export HTTPS_PROXY=http://proxy.example.com:8080")
.AddManualStep(4, "For air-gapped environments",
"Pre-populate the buildinfo cache with required files or disable this check"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (!anyServiceReachable && cacheStatus.Exists)
{
return builder
.Warn("Debian buildinfo services are unreachable but cache directory exists (offline mode possible)")
.WithEvidence("Buildinfo Status", eb =>
{
eb.Add("buildinfos_debian_net_reachable", false);
eb.Add("reproduce_debian_net_reachable", false);
eb.Add("cache_directory", cacheDir);
eb.Add("cache_exists", true);
eb.Add("cache_writable", cacheStatus.IsWritable);
})
.WithCauses(
"Network connectivity issues",
"Firewall blocking HTTPS access")
.WithRemediation(rb => rb
.AddShellStep(1, "Test connectivity",
$"curl -I {BuildinfosUrl}")
.AddManualStep(2, "If air-gapped intentionally",
"Ensure buildinfo cache is pre-populated with required files"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (anyServiceReachable && !cacheStatus.Exists)
{
return builder
.Warn("Debian buildinfo services are reachable but cache directory does not exist")
.WithEvidence("Buildinfo Status", eb =>
{
eb.Add("buildinfos_debian_net_reachable", buildinfosResult.Reachable);
eb.Add("buildinfos_latency_ms", buildinfosResult.LatencyMs);
eb.Add("reproduce_debian_net_reachable", reproduceResult.Reachable);
eb.Add("reproduce_latency_ms", reproduceResult.LatencyMs);
eb.Add("cache_directory", cacheDir);
eb.Add("cache_exists", false);
})
.WithCauses("Cache directory not created")
.WithRemediation(rb => rb
.AddShellStep(1, "Create cache directory",
$"sudo mkdir -p {cacheDir} && sudo chmod 755 {cacheDir}"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (anyServiceReachable && cacheStatus.Exists && !cacheStatus.IsWritable)
{
return builder
.Warn("Debian buildinfo services are reachable but cache directory is not writable")
.WithEvidence("Buildinfo Status", eb =>
{
eb.Add("buildinfos_debian_net_reachable", buildinfosResult.Reachable);
eb.Add("reproduce_debian_net_reachable", reproduceResult.Reachable);
eb.Add("cache_directory", cacheDir);
eb.Add("cache_exists", true);
eb.Add("cache_writable", false);
})
.WithCauses("Insufficient permissions on cache directory")
.WithRemediation(rb => rb
.AddShellStep(1, "Fix cache directory permissions",
$"sudo chown $(whoami) {cacheDir} && chmod 755 {cacheDir}"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
// All good - services reachable and cache directory ready
var statusMessage = allServicesReachable
? "Both Debian buildinfo services are reachable and cache directory is ready"
: "Primary buildinfo service is reachable and cache directory is ready";
return builder
.Pass(statusMessage)
.WithEvidence("Buildinfo Status", eb =>
{
eb.Add("buildinfos_debian_net_reachable", buildinfosResult.Reachable);
eb.Add("buildinfos_latency_ms", buildinfosResult.LatencyMs);
eb.Add("reproduce_debian_net_reachable", reproduceResult.Reachable);
eb.Add("reproduce_latency_ms", reproduceResult.LatencyMs);
eb.Add("cache_directory", cacheDir);
eb.Add("cache_exists", true);
eb.Add("cache_writable", cacheStatus.IsWritable);
})
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
{
return builder
.Warn($"Error checking buildinfo services: {ex.Message}")
.WithEvidence("Buildinfo Status", eb =>
{
eb.Add("error_type", ex.GetType().Name);
eb.Add("error_message", ex.Message);
})
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
}
/// <summary>
/// Tests HTTPS connectivity to a URL.
/// </summary>
private static async Task<ConnectivityResult> TestConnectivityAsync(
HttpClient httpClient,
string url,
CancellationToken ct)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, url);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
stopwatch.Stop();
return new ConnectivityResult
{
Reachable = true,
LatencyMs = (int)stopwatch.ElapsedMilliseconds,
StatusCode = (int)response.StatusCode,
Error = null
};
}
catch (HttpRequestException ex)
{
stopwatch.Stop();
return new ConnectivityResult
{
Reachable = false,
LatencyMs = (int)stopwatch.ElapsedMilliseconds,
Error = ex.Message
};
}
catch (TaskCanceledException)
{
stopwatch.Stop();
return new ConnectivityResult
{
Reachable = false,
LatencyMs = (int)stopwatch.ElapsedMilliseconds,
Error = "Request timed out"
};
}
}
/// <summary>
/// Checks cache directory existence and writability.
/// </summary>
private static CacheDirectoryStatus CheckCacheDirectory(string path)
{
var status = new CacheDirectoryStatus();
try
{
if (Directory.Exists(path))
{
status.Exists = true;
// Try to create a temp file to test writability
var testFile = Path.Combine(path, $".stella-doctor-test-{Guid.NewGuid():N}");
try
{
File.WriteAllText(testFile, "test");
File.Delete(testFile);
status.IsWritable = true;
}
catch (UnauthorizedAccessException)
{
status.IsWritable = false;
}
catch (IOException)
{
status.IsWritable = false;
}
}
}
catch
{
// Error checking directory
}
return status;
}
private sealed record ConnectivityResult
{
public required bool Reachable { get; init; }
public required int LatencyMs { get; init; }
public int? StatusCode { get; init; }
public string? Error { get; init; }
}
private sealed record CacheDirectoryStatus
{
public bool Exists { get; set; }
public bool IsWritable { get; set; }
}
}

View File

@@ -0,0 +1,384 @@
// -----------------------------------------------------------------------------
// DdebRepoEnabledCheck.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-003 - Ddeb Repository Check
// Description: Verify Ubuntu ddeb repository availability for debug symbols
// -----------------------------------------------------------------------------
using System.Net.Http;
using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
/// <summary>
/// Verifies Ubuntu ddeb repository availability for debug symbol retrieval.
/// Checks apt sources configuration and tests HTTP connectivity to ddebs.ubuntu.com.
/// </summary>
public sealed partial class DdebRepoEnabledCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.binaryanalysis";
private const string CategoryName = "Security";
private const string DdebBaseUrl = "http://ddebs.ubuntu.com";
private const int HttpTimeoutSeconds = 10;
// Common apt sources locations
private static readonly string[] AptSourcesPaths =
[
"/etc/apt/sources.list",
"/etc/apt/sources.list.d/"
];
/// <inheritdoc />
public string CheckId => "check.binaryanalysis.ddeb.enabled";
/// <inheritdoc />
public string Name => "Ubuntu Ddeb Repository";
/// <inheritdoc />
public string Description => "Verify Ubuntu debug symbol repository (ddebs.ubuntu.com) is configured and accessible";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["binaryanalysis", "ddeb", "ubuntu", "symbols", "security"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
// Only run on Linux systems where apt might be available
return OperatingSystem.IsLinux();
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
// Check if we're on a non-Linux system
if (!OperatingSystem.IsLinux())
{
return builder
.Skip("Ddeb repository check only applies to Linux/Ubuntu systems")
.WithEvidence("Platform", eb => eb.Add("os", Environment.OSVersion.ToString()))
.Build();
}
try
{
// Parse apt sources to find ddeb configuration
var ddebConfig = await ParseAptSourcesAsync(ct);
// Test HTTP connectivity to ddeb mirror
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
HttpClient httpClient;
if (httpClientFactory != null)
{
httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
// Don't set timeout on factory-created clients as they may be reused
}
else
{
httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(HttpTimeoutSeconds) };
}
var connectivityResult = await TestDdebConnectivityAsync(httpClient, ct);
// Determine distribution codename for remediation
var distroCodename = await GetDistributionCodenameAsync(ct);
// Build result based on findings
if (!ddebConfig.IsConfigured && !connectivityResult.Reachable)
{
return builder
.Warn("Ubuntu ddeb repository is not configured and ddebs.ubuntu.com is unreachable")
.WithEvidence("Ddeb Configuration", eb =>
{
eb.Add("ddeb_configured", false);
eb.Add("ddeb_reachable", false);
eb.Add("distribution", distroCodename ?? "unknown");
})
.WithCauses(
"Ddeb repository not added to apt sources",
"Network connectivity issues preventing access to ddebs.ubuntu.com",
"Firewall blocking HTTP access")
.WithRemediation(rb => rb
.AddShellStep(1, "Add Ubuntu debug symbol repository",
BuildDdebAddCommand(distroCodename))
.AddShellStep(2, "Import repository signing key",
"sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F2EDC64DC5AEE1F6B9C621F0C8CAB6595FDFF622")
.AddShellStep(3, "Update package lists",
"sudo apt update"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (!ddebConfig.IsConfigured && connectivityResult.Reachable)
{
return builder
.Warn("Ubuntu ddeb repository is not configured but ddebs.ubuntu.com is reachable")
.WithEvidence("Ddeb Configuration", eb =>
{
eb.Add("ddeb_configured", false);
eb.Add("ddeb_reachable", true);
eb.Add("ddeb_latency_ms", connectivityResult.LatencyMs);
eb.Add("distribution", distroCodename ?? "unknown");
})
.WithCauses("Ddeb repository not added to apt sources")
.WithRemediation(rb => rb
.AddShellStep(1, "Add Ubuntu debug symbol repository",
BuildDdebAddCommand(distroCodename))
.AddShellStep(2, "Import repository signing key",
"sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F2EDC64DC5AEE1F6B9C621F0C8CAB6595FDFF622")
.AddShellStep(3, "Update package lists",
"sudo apt update"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (ddebConfig.IsConfigured && !connectivityResult.Reachable)
{
return builder
.Fail("Ubuntu ddeb repository is configured but ddebs.ubuntu.com is unreachable")
.WithEvidence("Ddeb Configuration", eb =>
{
eb.Add("ddeb_configured", true);
eb.Add("ddeb_source_file", ddebConfig.SourceFile ?? "unknown");
eb.Add("ddeb_reachable", false);
eb.Add("distribution", distroCodename ?? "unknown");
if (connectivityResult.Error != null)
eb.Add("error", connectivityResult.Error);
})
.WithCauses(
"Network connectivity issues",
"Firewall blocking HTTP access to ddebs.ubuntu.com",
"Proxy configuration required but not set",
"DNS resolution failure")
.WithRemediation(rb => rb
.AddShellStep(1, "Test connectivity manually",
$"curl -I {DdebBaseUrl}")
.AddShellStep(2, "Check proxy settings if behind a corporate firewall",
"export HTTP_PROXY=http://proxy.example.com:8080")
.AddManualStep(3, "For air-gapped environments",
"Set up a local ddeb mirror or use offline symbol packages"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
// Both configured and reachable
return builder
.Pass("Ubuntu ddeb repository is configured and accessible")
.WithEvidence("Ddeb Configuration", eb =>
{
eb.Add("ddeb_configured", true);
eb.Add("ddeb_source_file", ddebConfig.SourceFile ?? "sources.list.d");
eb.Add("ddeb_reachable", true);
eb.Add("ddeb_latency_ms", connectivityResult.LatencyMs);
eb.Add("distribution", distroCodename ?? "unknown");
})
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
{
return builder
.Warn($"Error checking ddeb repository: {ex.Message}")
.WithEvidence("Ddeb Configuration", eb =>
{
eb.Add("error_type", ex.GetType().Name);
eb.Add("error_message", ex.Message);
})
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
}
/// <summary>
/// Parses apt sources to find ddeb repository configuration.
/// </summary>
private static async Task<DdebConfiguration> ParseAptSourcesAsync(CancellationToken ct)
{
var config = new DdebConfiguration();
try
{
// Check main sources.list
const string mainSourcesPath = "/etc/apt/sources.list";
if (File.Exists(mainSourcesPath))
{
var content = await File.ReadAllTextAsync(mainSourcesPath, ct);
if (ContainsDdebEntry(content))
{
config.IsConfigured = true;
config.SourceFile = mainSourcesPath;
return config;
}
}
// Check sources.list.d directory
const string sourcesDir = "/etc/apt/sources.list.d/";
if (Directory.Exists(sourcesDir))
{
foreach (var file in Directory.GetFiles(sourcesDir, "*.list"))
{
var content = await File.ReadAllTextAsync(file, ct);
if (ContainsDdebEntry(content))
{
config.IsConfigured = true;
config.SourceFile = file;
return config;
}
}
// Also check .sources files (DEB822 format)
foreach (var file in Directory.GetFiles(sourcesDir, "*.sources"))
{
var content = await File.ReadAllTextAsync(file, ct);
if (ContainsDdebEntry(content))
{
config.IsConfigured = true;
config.SourceFile = file;
return config;
}
}
}
}
catch (UnauthorizedAccessException)
{
// Cannot read apt sources, assume not configured
}
catch (IOException)
{
// IO error reading sources
}
return config;
}
/// <summary>
/// Checks if content contains a ddeb repository entry.
/// </summary>
private static bool ContainsDdebEntry(string content)
{
// Match lines like: deb http://ddebs.ubuntu.com focal main
// Also match commented out lines for detection
return DdebPatternRegex().IsMatch(content);
}
[GeneratedRegex(@"^\s*deb\s+.*ddebs\.ubuntu\.com", RegexOptions.Multiline | RegexOptions.IgnoreCase)]
private static partial Regex DdebPatternRegex();
/// <summary>
/// Tests HTTP connectivity to ddebs.ubuntu.com.
/// </summary>
private static async Task<ConnectivityResult> TestDdebConnectivityAsync(
HttpClient httpClient,
CancellationToken ct)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, DdebBaseUrl + "/");
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
stopwatch.Stop();
return new ConnectivityResult
{
Reachable = true,
LatencyMs = (int)stopwatch.ElapsedMilliseconds,
StatusCode = (int)response.StatusCode,
Error = null
};
}
catch (HttpRequestException ex)
{
stopwatch.Stop();
return new ConnectivityResult
{
Reachable = false,
LatencyMs = (int)stopwatch.ElapsedMilliseconds,
Error = ex.Message
};
}
catch (TaskCanceledException)
{
stopwatch.Stop();
return new ConnectivityResult
{
Reachable = false,
LatencyMs = (int)stopwatch.ElapsedMilliseconds,
Error = "Request timed out"
};
}
}
/// <summary>
/// Gets the Ubuntu distribution codename (e.g., "focal", "jammy").
/// </summary>
private static async Task<string?> GetDistributionCodenameAsync(CancellationToken ct)
{
try
{
// Try to read from /etc/lsb-release
const string lsbReleasePath = "/etc/lsb-release";
if (File.Exists(lsbReleasePath))
{
var content = await File.ReadAllTextAsync(lsbReleasePath, ct);
var match = Regex.Match(content, @"DISTRIB_CODENAME=(\w+)");
if (match.Success)
{
return match.Groups[1].Value;
}
}
// Try to read from /etc/os-release
const string osReleasePath = "/etc/os-release";
if (File.Exists(osReleasePath))
{
var content = await File.ReadAllTextAsync(osReleasePath, ct);
var match = Regex.Match(content, @"VERSION_CODENAME=(\w+)");
if (match.Success)
{
return match.Groups[1].Value;
}
}
}
catch
{
// Ignore errors reading release files
}
return null;
}
/// <summary>
/// Builds the command to add ddeb repository for a given distribution.
/// </summary>
private static string BuildDdebAddCommand(string? codename)
{
var distro = codename ?? "$(lsb_release -cs)";
return $"echo \"deb {DdebBaseUrl} {distro} main restricted universe multiverse\" | sudo tee /etc/apt/sources.list.d/ddebs.list";
}
private sealed record DdebConfiguration
{
public bool IsConfigured { get; set; }
public string? SourceFile { get; set; }
}
private sealed record ConnectivityResult
{
public required bool Reachable { get; init; }
public required int LatencyMs { get; init; }
public int? StatusCode { get; init; }
public string? Error { get; init; }
}
}

View File

@@ -0,0 +1,350 @@
// -----------------------------------------------------------------------------
// DebuginfodAvailabilityCheck.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-002 - Debuginfod Availability Check
// Description: Verify debuginfod service availability for symbol recovery
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
/// <summary>
/// Verifies debuginfod service availability for symbol recovery.
/// Checks DEBUGINFOD_URLS environment variable and tests HTTP connectivity
/// to configured endpoints.
/// </summary>
public sealed class DebuginfodAvailabilityCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.binaryanalysis";
private const string CategoryName = "Security";
private const string DebuginfodUrlsEnvVar = "DEBUGINFOD_URLS";
private const string DefaultFedoraUrl = "https://debuginfod.fedoraproject.org";
private const int HttpTimeoutSeconds = 10;
/// <inheritdoc />
public string CheckId => "check.binaryanalysis.debuginfod.available";
/// <inheritdoc />
public string Name => "Debuginfod Availability";
/// <inheritdoc />
public string Description => "Verify DEBUGINFOD_URLS environment variable and debuginfod service connectivity";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["binaryanalysis", "debuginfod", "symbols", "security"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(15);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
// Always run - this is a prerequisites check
return true;
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
// Check DEBUGINFOD_URLS environment variable
var debuginfodUrls = Environment.GetEnvironmentVariable(DebuginfodUrlsEnvVar);
var urlsConfigured = !string.IsNullOrWhiteSpace(debuginfodUrls);
// Parse URLs (space-separated list)
var urls = ParseDebuginfodUrls(debuginfodUrls);
// If no URLs configured, try the default Fedora URL
if (urls.Count == 0)
{
urls = [DefaultFedoraUrl];
}
try
{
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
HttpClient httpClient;
if (httpClientFactory != null)
{
httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
// Don't set timeout on factory-created clients as they may be reused
}
else
{
// Fallback for environments without IHttpClientFactory
httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(HttpTimeoutSeconds) };
}
// Test connectivity to each URL
var connectivityResults = await TestConnectivityAsync(httpClient, urls, ct);
var reachableCount = connectivityResults.Count(r => r.Reachable);
var unreachableUrls = connectivityResults.Where(r => !r.Reachable).Select(r => r.Url).ToList();
// Build result based on findings
if (!urlsConfigured && reachableCount == 0)
{
return builder
.Warn("DEBUGINFOD_URLS not configured and default Fedora debuginfod is unreachable")
.WithEvidence("Debuginfod Configuration", eb =>
{
eb.Add("debuginfod_urls_set", false);
eb.Add("default_url_tested", DefaultFedoraUrl);
eb.Add("default_url_reachable", false);
})
.WithCauses(
"DEBUGINFOD_URLS environment variable is not set",
"Default Fedora debuginfod server may be blocked by firewall",
"Network connectivity issue preventing HTTPS access")
.WithRemediation(rb => rb
.AddShellStep(1, "Set the DEBUGINFOD_URLS environment variable",
$"export {DebuginfodUrlsEnvVar}=\"{DefaultFedoraUrl}\"")
.AddShellStep(2, "Verify network connectivity",
$"curl -I {DefaultFedoraUrl}")
.AddManualStep(3, "For air-gapped environments",
"Set up a local debuginfod mirror or pre-populate the symbol cache. See docs/modules/binary-index/ground-truth-corpus.md for offline setup"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (!urlsConfigured && reachableCount > 0)
{
return builder
.Info("DEBUGINFOD_URLS not configured but default Fedora debuginfod is reachable")
.WithEvidence("Debuginfod Configuration", eb =>
{
eb.Add("debuginfod_urls_set", false);
eb.Add("default_url_tested", DefaultFedoraUrl);
eb.Add("default_url_reachable", true);
AddConnectivityDetails(eb, connectivityResults);
})
.WithRemediation(rb => rb
.AddShellStep(1, "Optionally set DEBUGINFOD_URLS for explicit configuration (recommended for production)",
$"export {DebuginfodUrlsEnvVar}=\"{DefaultFedoraUrl}\""))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (reachableCount == 0)
{
return builder
.Fail($"None of the {urls.Count} configured debuginfod URL(s) are reachable")
.WithEvidence("Debuginfod Configuration", eb =>
{
eb.Add("debuginfod_urls_set", true);
eb.Add("configured_url_count", urls.Count);
eb.Add("reachable_count", 0);
AddConnectivityDetails(eb, connectivityResults);
})
.WithCauses(
"Configured debuginfod servers may be down",
"Firewall blocking HTTPS access to debuginfod servers",
"Proxy configuration required but not set",
"DNS resolution failure for debuginfod hostnames")
.WithRemediation(rb => rb
.AddShellStep(1, "Verify debuginfod server availability",
$"curl -I {urls[0]}")
.AddShellStep(2, "Check proxy settings if behind a corporate firewall",
"export HTTPS_PROXY=http://proxy.example.com:8080")
.AddManualStep(3, "For air-gapped environments",
"Deploy a local debuginfod instance or use offline symbol bundles. See docs/modules/binary-index/ground-truth-corpus.md for offline setup"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (unreachableUrls.Count > 0)
{
return builder
.Warn($"{reachableCount}/{urls.Count} debuginfod URL(s) reachable; {unreachableUrls.Count} unreachable")
.WithEvidence("Debuginfod Configuration", eb =>
{
eb.Add("debuginfod_urls_set", true);
eb.Add("configured_url_count", urls.Count);
eb.Add("reachable_count", reachableCount);
eb.Add("unreachable_urls", string.Join(", ", unreachableUrls));
AddConnectivityDetails(eb, connectivityResults);
})
.WithCauses(
$"{unreachableUrls.Count} configured server(s) may be down or unreachable",
"Partial network connectivity issues")
.WithRemediation(rb => rb
.AddShellStep(1, "Verify unreachable servers",
$"curl -I {unreachableUrls[0]}")
.AddManualStep(2, "Update DEBUGINFOD_URLS to remove unavailable servers",
$"Edit DEBUGINFOD_URLS to remove: {string.Join(", ", unreachableUrls)}"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
// All URLs are reachable
return builder
.Pass($"All {reachableCount} debuginfod URL(s) are reachable")
.WithEvidence("Debuginfod Configuration", eb =>
{
eb.Add("debuginfod_urls_set", true);
eb.Add("configured_url_count", urls.Count);
eb.Add("reachable_count", reachableCount);
AddConnectivityDetails(eb, connectivityResults);
})
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
{
return builder
.Warn($"Error testing debuginfod connectivity: {ex.Message}")
.WithEvidence("Debuginfod Configuration", eb =>
{
eb.Add("debuginfod_urls_set", urlsConfigured);
eb.Add("error_type", ex.GetType().Name);
eb.Add("error_message", ex.Message);
})
.WithCauses(
"Network connectivity issue",
"DNS resolution failure",
"Timeout reaching debuginfod servers")
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
}
/// <summary>
/// Parses the DEBUGINFOD_URLS environment variable value.
/// URLs are space-separated.
/// </summary>
private static List<string> ParseDebuginfodUrls(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return [];
}
return value
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(u => Uri.TryCreate(u, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
.ToList();
}
/// <summary>
/// Tests HTTP connectivity to each debuginfod URL.
/// </summary>
private static async Task<List<DebuginfodConnectivityResult>> TestConnectivityAsync(
HttpClient httpClient,
IReadOnlyList<string> urls,
CancellationToken ct)
{
var results = new List<DebuginfodConnectivityResult>();
foreach (var url in urls)
{
var result = await TestSingleUrlAsync(httpClient, url, ct);
results.Add(result);
}
return results;
}
/// <summary>
/// Tests connectivity to a single debuginfod URL.
/// </summary>
private static async Task<DebuginfodConnectivityResult> TestSingleUrlAsync(
HttpClient httpClient,
string url,
CancellationToken ct)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// Debuginfod servers should respond to a HEAD request to the root
// Some also support /metrics or just respond to GET
using var request = new HttpRequestMessage(HttpMethod.Head, url.TrimEnd('/') + "/");
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
stopwatch.Stop();
// Accept any response (even 404) as proof of connectivity
// debuginfod may return various status codes depending on the path
return new DebuginfodConnectivityResult
{
Url = url,
Reachable = true,
LatencyMs = (int)stopwatch.ElapsedMilliseconds,
StatusCode = (int)response.StatusCode,
Error = null
};
}
catch (HttpRequestException ex)
{
stopwatch.Stop();
return new DebuginfodConnectivityResult
{
Url = url,
Reachable = false,
LatencyMs = (int)stopwatch.ElapsedMilliseconds,
StatusCode = null,
Error = ex.Message
};
}
catch (TaskCanceledException)
{
stopwatch.Stop();
return new DebuginfodConnectivityResult
{
Url = url,
Reachable = false,
LatencyMs = (int)stopwatch.ElapsedMilliseconds,
StatusCode = null,
Error = "Request timed out"
};
}
}
/// <summary>
/// Adds connectivity details to evidence.
/// </summary>
private static void AddConnectivityDetails(
Plugins.Builders.EvidenceBuilder eb,
IReadOnlyList<DebuginfodConnectivityResult> results)
{
for (var i = 0; i < results.Count; i++)
{
var r = results[i];
var prefix = $"url_{i + 1}";
eb.Add($"{prefix}_address", r.Url);
eb.Add($"{prefix}_reachable", r.Reachable);
eb.Add($"{prefix}_latency_ms", r.LatencyMs);
if (r.StatusCode.HasValue)
{
eb.Add($"{prefix}_status_code", r.StatusCode.Value);
}
if (r.Error != null)
{
eb.Add($"{prefix}_error", r.Error);
}
}
}
/// <summary>
/// Result of testing connectivity to a debuginfod URL.
/// </summary>
private sealed record DebuginfodConnectivityResult
{
public required string Url { get; init; }
public required bool Reachable { get; init; }
public required int LatencyMs { get; init; }
public int? StatusCode { get; init; }
public string? Error { get; init; }
}
}

View File

@@ -0,0 +1,192 @@
// -----------------------------------------------------------------------------
// SymbolRecoveryFallbackCheck.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-005 - Symbol Recovery Fallback Check
// Description: Meta-check ensuring at least one symbol recovery path is available
// -----------------------------------------------------------------------------
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
/// <summary>
/// Meta-check that ensures at least one symbol recovery path is available.
/// Aggregates results from debuginfod, ddeb, and buildinfo checks.
/// Warns if all sources are unavailable and suggests offline bundle fallback.
/// </summary>
public sealed class SymbolRecoveryFallbackCheck : IDoctorCheck
{
private const string PluginId = "stellaops.doctor.binaryanalysis";
private const string CategoryName = "Security";
private readonly DebuginfodAvailabilityCheck _debuginfodCheck = new();
private readonly DdebRepoEnabledCheck _ddebCheck = new();
private readonly BuildinfoCacheCheck _buildinfoCheck = new();
/// <inheritdoc />
public string CheckId => "check.binaryanalysis.symbol.recovery.fallback";
/// <inheritdoc />
public string Name => "Symbol Recovery Fallback";
/// <inheritdoc />
public string Description => "Ensure at least one symbol recovery path is available (debuginfod, ddeb, or buildinfo)";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["binaryanalysis", "symbols", "fallback", "security", "meta"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(45); // Sum of child checks
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
// Always run - this is a critical availability check
return true;
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
var childResults = new List<ChildCheckResult>();
// Run debuginfod check
if (_debuginfodCheck.CanRun(context))
{
var result = await _debuginfodCheck.RunAsync(context, ct);
childResults.Add(new ChildCheckResult
{
CheckId = _debuginfodCheck.CheckId,
Name = _debuginfodCheck.Name,
Severity = result.Severity,
IsAvailable = result.Severity.IsSuccess(),
Diagnosis = result.Diagnosis
});
}
// Run ddeb check
if (_ddebCheck.CanRun(context))
{
var result = await _ddebCheck.RunAsync(context, ct);
childResults.Add(new ChildCheckResult
{
CheckId = _ddebCheck.CheckId,
Name = _ddebCheck.Name,
Severity = result.Severity,
IsAvailable = result.Severity.IsSuccess(),
Diagnosis = result.Diagnosis
});
}
else
{
// Ddeb only runs on Linux, note it as skipped
childResults.Add(new ChildCheckResult
{
CheckId = _ddebCheck.CheckId,
Name = _ddebCheck.Name,
Severity = DoctorSeverity.Skip,
IsAvailable = false,
Diagnosis = "Skipped (non-Linux platform)"
});
}
// Run buildinfo check
if (_buildinfoCheck.CanRun(context))
{
var result = await _buildinfoCheck.RunAsync(context, ct);
childResults.Add(new ChildCheckResult
{
CheckId = _buildinfoCheck.CheckId,
Name = _buildinfoCheck.Name,
Severity = result.Severity,
IsAvailable = result.Severity.IsSuccess(),
Diagnosis = result.Diagnosis
});
}
// Analyze aggregated results
var availableCount = childResults.Count(r => r.IsAvailable);
var totalChecks = childResults.Count(r => r.Severity != DoctorSeverity.Skip);
var failedChecks = childResults.Where(r => r.Severity == DoctorSeverity.Fail).ToList();
var warningChecks = childResults.Where(r => r.Severity == DoctorSeverity.Warn).ToList();
// Build evidence with all child results
void AddChildEvidence(Plugins.Builders.EvidenceBuilder eb)
{
eb.Add("total_sources_checked", totalChecks);
eb.Add("available_sources", availableCount);
for (var i = 0; i < childResults.Count; i++)
{
var child = childResults[i];
var prefix = $"source_{i + 1}";
eb.Add($"{prefix}_name", child.Name);
eb.Add($"{prefix}_status", child.Severity.ToDisplayString());
eb.Add($"{prefix}_available", child.IsAvailable);
}
}
// No sources available - critical failure
if (availableCount == 0)
{
return builder
.Fail($"No symbol recovery sources available (0/{totalChecks} sources operational)")
.WithEvidence("Symbol Recovery Status", AddChildEvidence)
.WithCauses(
"All symbol recovery endpoints are unreachable",
"Network connectivity issues affecting all sources",
"Firewall blocking access to symbol servers",
"Air-gapped environment without offline symbol cache")
.WithRemediation(rb => rb
.AddManualStep(1, "Review individual check failures",
"Run 'stella doctor --plugin stellaops.doctor.binaryanalysis' to see detailed status for each source")
.AddShellStep(2, "Configure at least one symbol source",
"export DEBUGINFOD_URLS=\"https://debuginfod.fedoraproject.org\"")
.AddManualStep(3, "For air-gapped environments",
"Set up an offline symbol bundle. See docs/modules/binary-index/ground-truth-corpus.md for instructions on creating and importing offline symbol packs")
.AddManualStep(4, "Consider setting up a local debuginfod mirror",
"Run a local debuginfod server and point DEBUGINFOD_URLS to it"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
// Some sources available but not all
if (availableCount < totalChecks)
{
var unavailableNames = childResults
.Where(r => !r.IsAvailable && r.Severity != DoctorSeverity.Skip)
.Select(r => r.Name);
return builder
.Info($"Symbol recovery operational with {availableCount}/{totalChecks} sources available")
.WithEvidence("Symbol Recovery Status", AddChildEvidence)
.WithRemediation(rb => rb
.AddManualStep(1, "Optionally configure additional sources for redundancy",
$"The following sources are unavailable: {string.Join(", ", unavailableNames)}"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
// All sources available
return builder
.Pass($"All {totalChecks} symbol recovery sources are available")
.WithEvidence("Symbol Recovery Status", AddChildEvidence)
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
private sealed record ChildCheckResult
{
public required string CheckId { get; init; }
public required string Name { get; init; }
public required DoctorSeverity Severity { get; init; }
public required bool IsAvailable { get; init; }
public required string Diagnosis { get; init; }
}
}

View File

@@ -0,0 +1,29 @@
// -----------------------------------------------------------------------------
// BinaryAnalysisPluginServiceCollectionExtensions.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-001 - Binary Analysis Doctor Plugin Scaffold
// Description: Extension methods for registering the Binary Analysis plugin
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.DependencyInjection;
/// <summary>
/// Extension methods for registering the Binary Analysis Doctor plugin.
/// </summary>
public static class BinaryAnalysisPluginServiceCollectionExtensions
{
/// <summary>
/// Adds the Binary Analysis prerequisites Doctor plugin.
/// Provides checks for debuginfod, ddeb repositories, buildinfo cache, and symbol recovery.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddDoctorBinaryAnalysisPlugin(this IServiceCollection services)
{
services.AddSingleton<IDoctorPlugin, BinaryAnalysisDoctorPlugin>();
return services;
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Doctor.Plugin.BinaryAnalysis</RootNamespace>
<Description>Binary analysis prerequisites health checks for Stella Ops Doctor diagnostics</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Doctor\StellaOps.Doctor.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,143 @@
// -----------------------------------------------------------------------------
// CrlDistributionCheck.cs
// Sprint: SPRINT_20260119_012 Doctor Timestamp Health Checks
// Task: DOC-003 - Revocation Infrastructure Checks
// Description: Health check for CRL distribution point availability.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
namespace StellaOps.Doctor.Plugin.Timestamping;
/// <summary>
/// Checks CRL distribution point availability for TSA certificate validation.
/// </summary>
public sealed class CrlDistributionCheck : IDoctorCheck
{
private readonly ICrlDistributionRegistry _crlRegistry;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<CrlDistributionCheck> _logger;
public string Id => "crl-distribution-available";
public string Name => "CRL Distribution Point Availability";
public string Description => "Checks that configured CRL distribution points are accessible";
public CheckSeverity DefaultSeverity => CheckSeverity.Warning;
public TimeSpan DefaultInterval => TimeSpan.FromMinutes(30);
public CrlDistributionCheck(
ICrlDistributionRegistry crlRegistry,
IHttpClientFactory httpClientFactory,
ILogger<CrlDistributionCheck> logger)
{
_crlRegistry = crlRegistry;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<CheckResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var cdps = await _crlRegistry.GetConfiguredCdpsAsync(cancellationToken);
if (cdps.Count == 0)
{
return CheckResult.Info("No CRL distribution points configured");
}
var results = new List<SubCheckResult>();
var client = _httpClientFactory.CreateClient("crl");
client.Timeout = TimeSpan.FromSeconds(30); // CRLs can be large
foreach (var cdp in cdps)
{
try
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
// HEAD request to check availability without downloading
using var request = new HttpRequestMessage(HttpMethod.Head, cdp.Url);
using var response = await client.SendAsync(request, cancellationToken);
stopwatch.Stop();
if (response.IsSuccessStatusCode)
{
var contentLength = response.Content.Headers.ContentLength;
var lastModified = response.Content.Headers.LastModified;
var details = $"Available in {stopwatch.ElapsedMilliseconds}ms";
if (contentLength.HasValue)
{
details += $", size: {contentLength.Value / 1024}KB";
}
if (lastModified.HasValue)
{
var age = DateTimeOffset.UtcNow - lastModified.Value;
details += $", age: {age.TotalHours:F1}h";
}
results.Add(new SubCheckResult
{
Name = cdp.Name,
Passed = true,
LatencyMs = stopwatch.ElapsedMilliseconds,
Details = details
});
}
else
{
results.Add(new SubCheckResult
{
Name = cdp.Name,
Passed = false,
LatencyMs = stopwatch.ElapsedMilliseconds,
Details = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"
});
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "CRL distribution point {Name} check failed", cdp.Name);
results.Add(new SubCheckResult
{
Name = cdp.Name,
Passed = false,
Details = ex.Message
});
}
}
var passed = results.Count(r => r.Passed);
var failed = results.Count(r => !r.Passed);
if (failed == 0)
{
return CheckResult.Healthy($"All {passed} CRL distribution points available", results);
}
if (passed == 0)
{
return CheckResult.Critical($"All {failed} CRL distribution points unavailable", results);
}
return CheckResult.Warning($"{passed} available, {failed} unavailable", results);
}
}
/// <summary>
/// Registry of configured CRL distribution points.
/// </summary>
public interface ICrlDistributionRegistry
{
Task<IReadOnlyList<CrlDistributionPointInfo>> GetConfiguredCdpsAsync(CancellationToken ct);
}
/// <summary>
/// CRL distribution point configuration.
/// </summary>
public sealed record CrlDistributionPointInfo
{
public required string Name { get; init; }
public required string Url { get; init; }
public string? ForCertificateIssuer { get; init; }
public TimeSpan? ExpectedRefreshInterval { get; init; }
}

View File

@@ -0,0 +1,313 @@
// -----------------------------------------------------------------------------
// EuTrustListChecks.cs
// Sprint: SPRINT_20260119_012 Doctor Timestamp Health Checks
// Task: DOC-005 - EU Trust List Checks
// Description: Health checks for EU Trust List freshness and TSA qualification.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
namespace StellaOps.Doctor.Plugin.Timestamping;
/// <summary>
/// Checks EU Trust List (LOTL) freshness.
/// </summary>
public sealed class EuTrustListFreshCheck : IDoctorCheck
{
private readonly IEuTrustListCache _trustListCache;
private readonly EuTrustListCheckOptions _options;
private readonly ILogger<EuTrustListFreshCheck> _logger;
public string Id => "eu-trustlist-fresh";
public string Name => "EU Trust List Freshness";
public string Description => "Checks that the EU Trust List (LOTL) is up-to-date";
public CheckSeverity DefaultSeverity => CheckSeverity.Warning;
public TimeSpan DefaultInterval => TimeSpan.FromHours(6);
public EuTrustListFreshCheck(
IEuTrustListCache trustListCache,
EuTrustListCheckOptions options,
ILogger<EuTrustListFreshCheck> logger)
{
_trustListCache = trustListCache;
_options = options;
_logger = logger;
}
public async Task<CheckResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var status = await _trustListCache.GetCacheStatusAsync(cancellationToken);
if (!status.HasCache)
{
return CheckResult.Critical("EU Trust List not cached. Run stella trust-list refresh.");
}
var age = DateTimeOffset.UtcNow - status.LastRefresh;
if (age > _options.CriticalAge)
{
return CheckResult.Critical(
$"Trust list is {age.Days} days old (critical threshold: {_options.CriticalAge.Days} days)",
new List<SubCheckResult>
{
new() { Name = "LastRefresh", Passed = false, Details = $"{status.LastRefresh:O}" },
new() { Name = "NextUpdate", Passed = false, Details = status.NextUpdate?.ToString("O") ?? "Unknown" }
});
}
if (age > _options.WarningAge)
{
return CheckResult.Warning(
$"Trust list is {age.Days} days old (warning threshold: {_options.WarningAge.Days} days)",
new List<SubCheckResult>
{
new() { Name = "LastRefresh", Passed = true, Details = $"{status.LastRefresh:O}" },
new() { Name = "NextUpdate", Passed = true, Details = status.NextUpdate?.ToString("O") ?? "Unknown" }
});
}
return CheckResult.Healthy(
$"Trust list is {age.TotalHours:F1} hours old ({status.TspCount} TSPs, {status.QualifiedTsaCount} QTS)",
new List<SubCheckResult>
{
new() { Name = "LastRefresh", Passed = true, Details = $"{status.LastRefresh:O}" },
new() { Name = "TSPCount", Passed = true, Details = $"{status.TspCount}" },
new() { Name = "QualifiedTSAs", Passed = true, Details = $"{status.QualifiedTsaCount}" }
});
}
}
/// <summary>
/// Checks that configured QTS providers are still qualified.
/// </summary>
public sealed class QtsProvidersQualifiedCheck : IDoctorCheck
{
private readonly IQualifiedTsaRegistry _tsaRegistry;
private readonly IEuTrustListCache _trustListCache;
private readonly ILogger<QtsProvidersQualifiedCheck> _logger;
public string Id => "qts-providers-qualified";
public string Name => "QTS Providers Qualification";
public string Description => "Checks that configured qualified TSA providers are still on the EU Trust List";
public CheckSeverity DefaultSeverity => CheckSeverity.Critical;
public TimeSpan DefaultInterval => TimeSpan.FromHours(1);
public QtsProvidersQualifiedCheck(
IQualifiedTsaRegistry tsaRegistry,
IEuTrustListCache trustListCache,
ILogger<QtsProvidersQualifiedCheck> logger)
{
_tsaRegistry = tsaRegistry;
_trustListCache = trustListCache;
_logger = logger;
}
public async Task<CheckResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var configuredProviders = await _tsaRegistry.GetQualifiedProvidersAsync(cancellationToken);
if (configuredProviders.Count == 0)
{
return CheckResult.Info("No qualified TSA providers configured");
}
var results = new List<SubCheckResult>();
var qualified = 0;
var notQualified = 0;
foreach (var provider in configuredProviders)
{
var status = await _trustListCache.GetTsaQualificationStatusAsync(
provider.Identifier,
cancellationToken);
if (status.IsQualified)
{
qualified++;
results.Add(new SubCheckResult
{
Name = provider.Name,
Passed = true,
Details = $"Qualified since {status.StatusStarting:O}"
});
}
else
{
notQualified++;
var details = status.StatusEndDate.HasValue
? $"Qualification ended {status.StatusEndDate:O}: {status.StatusReason}"
: "Not found on EU Trust List";
results.Add(new SubCheckResult
{
Name = provider.Name,
Passed = false,
Details = details
});
_logger.LogWarning(
"QTS provider {Provider} is no longer qualified: {Reason}",
provider.Name,
details);
}
}
if (notQualified == 0)
{
return CheckResult.Healthy($"All {qualified} QTS providers are qualified", results);
}
return CheckResult.Critical(
$"{notQualified} of {configuredProviders.Count} providers no longer qualified",
results);
}
}
/// <summary>
/// Monitors for TSA qualification status changes.
/// </summary>
public sealed class QtsStatusChangeCheck : IDoctorCheck
{
private readonly IQtsStatusChangeTracker _statusTracker;
private readonly ILogger<QtsStatusChangeCheck> _logger;
public string Id => "qts-status-change";
public string Name => "QTS Status Changes";
public string Description => "Alerts on TSA qualification status changes";
public CheckSeverity DefaultSeverity => CheckSeverity.Warning;
public TimeSpan DefaultInterval => TimeSpan.FromHours(1);
public QtsStatusChangeCheck(
IQtsStatusChangeTracker statusTracker,
ILogger<QtsStatusChangeCheck> logger)
{
_statusTracker = statusTracker;
_logger = logger;
}
public async Task<CheckResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var changes = await _statusTracker.GetRecentChangesAsync(
TimeSpan.FromDays(7),
cancellationToken);
if (changes.Count == 0)
{
return CheckResult.Healthy("No qualification status changes in past 7 days");
}
var results = changes.Select(c => new SubCheckResult
{
Name = c.TsaName,
Passed = c.NewStatus == QualificationStatus.Qualified,
Details = $"{c.PreviousStatus} → {c.NewStatus} on {c.ChangeDate:O}"
}).ToList();
var withdrawals = changes.Count(c => c.NewStatus != QualificationStatus.Qualified);
if (withdrawals > 0)
{
return CheckResult.Warning(
$"{withdrawals} qualification withdrawals in past 7 days",
results);
}
return CheckResult.Info($"{changes.Count} status changes in past 7 days", results);
}
}
#region Supporting Interfaces
/// <summary>
/// Options for EU Trust List checks.
/// </summary>
public sealed record EuTrustListCheckOptions
{
public TimeSpan WarningAge { get; init; } = TimeSpan.FromDays(3);
public TimeSpan CriticalAge { get; init; } = TimeSpan.FromDays(7);
}
/// <summary>
/// Cache for EU Trust List data.
/// </summary>
public interface IEuTrustListCache
{
Task<TrustListCacheStatus> GetCacheStatusAsync(CancellationToken ct);
Task<TsaQualificationStatus> GetTsaQualificationStatusAsync(string identifier, CancellationToken ct);
}
/// <summary>
/// Trust list cache status.
/// </summary>
public sealed record TrustListCacheStatus
{
public bool HasCache { get; init; }
public DateTimeOffset LastRefresh { get; init; }
public DateTimeOffset? NextUpdate { get; init; }
public int TspCount { get; init; }
public int QualifiedTsaCount { get; init; }
}
/// <summary>
/// TSA qualification status from trust list.
/// </summary>
public sealed record TsaQualificationStatus
{
public bool IsQualified { get; init; }
public DateTimeOffset StatusStarting { get; init; }
public DateTimeOffset? StatusEndDate { get; init; }
public string? StatusReason { get; init; }
}
/// <summary>
/// Registry of qualified TSA providers.
/// </summary>
public interface IQualifiedTsaRegistry
{
Task<IReadOnlyList<QualifiedTsaProviderInfo>> GetQualifiedProvidersAsync(CancellationToken ct);
}
/// <summary>
/// Qualified TSA provider info.
/// </summary>
public sealed record QualifiedTsaProviderInfo
{
public required string Name { get; init; }
public required string Identifier { get; init; }
public required string Url { get; init; }
}
/// <summary>
/// Tracker for QTS status changes.
/// </summary>
public interface IQtsStatusChangeTracker
{
Task<IReadOnlyList<QtsStatusChange>> GetRecentChangesAsync(TimeSpan window, CancellationToken ct);
}
/// <summary>
/// QTS status change record.
/// </summary>
public sealed record QtsStatusChange
{
public required string TsaName { get; init; }
public required string TsaIdentifier { get; init; }
public required QualificationStatus PreviousStatus { get; init; }
public required QualificationStatus NewStatus { get; init; }
public required DateTimeOffset ChangeDate { get; init; }
}
/// <summary>
/// Qualification status.
/// </summary>
public enum QualificationStatus
{
Unknown,
Qualified,
Withdrawn,
Suspended,
Deprecated
}
#endregion

View File

@@ -0,0 +1,202 @@
// -----------------------------------------------------------------------------
// EvidenceStalenessCheck.cs
// Sprint: SPRINT_20260119_012 Doctor Timestamp Health Checks
// Task: DOC-004 - Evidence Staleness Checks
// Description: Health check for timestamp evidence freshness.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Doctor.Plugin.Timestamping;
/// <summary>
/// Health check for timestamp evidence staleness.
/// </summary>
public sealed class EvidenceStalenessCheck : IDoctorCheck
{
private readonly EvidenceStalenessCheckOptions _options;
private readonly ILogger<EvidenceStalenessCheck> _logger;
/// <inheritdoc />
public string Id => "evidence-staleness";
/// <inheritdoc />
public string Category => "timestamping";
/// <inheritdoc />
public CheckSeverity Severity => CheckSeverity.Warning;
/// <inheritdoc />
public string Name => "Evidence Staleness";
/// <inheritdoc />
public string Description => "Checks if timestamp evidence needs re-timestamping";
/// <summary>
/// Initializes a new instance of the <see cref="EvidenceStalenessCheck"/> class.
/// </summary>
public EvidenceStalenessCheck(
IOptions<EvidenceStalenessCheckOptions> options,
ILogger<EvidenceStalenessCheck> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task<CheckResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var sw = Stopwatch.StartNew();
var subChecks = new List<SubCheckResult>();
// Check TST staleness
var tstResult = await CheckTstStalenessAsync(cancellationToken);
subChecks.Add(tstResult);
// Check OCSP staleness
var ocspResult = await CheckOcspStalenessAsync(cancellationToken);
subChecks.Add(ocspResult);
// Check CRL staleness
var crlResult = await CheckCrlStalenessAsync(cancellationToken);
subChecks.Add(crlResult);
sw.Stop();
var unhealthyCount = subChecks.Count(s => s.Status == CheckStatus.Unhealthy);
var degradedCount = subChecks.Count(s => s.Status == CheckStatus.Degraded);
if (unhealthyCount > 0)
{
return new CheckResult
{
CheckId = Id,
Status = CheckStatus.Unhealthy,
Message = $"{unhealthyCount} evidence type(s) have stale data",
Remediation = "Run evidence refresh job to update stale timestamps and revocation data.",
SubChecks = subChecks,
Duration = sw.Elapsed
};
}
if (degradedCount > 0)
{
return new CheckResult
{
CheckId = Id,
Status = CheckStatus.Degraded,
Message = $"{degradedCount} evidence type(s) approaching staleness",
Remediation = "Schedule evidence refresh to prevent staleness.",
SubChecks = subChecks,
Duration = sw.Elapsed
};
}
return new CheckResult
{
CheckId = Id,
Status = CheckStatus.Healthy,
Message = "All evidence data is fresh",
SubChecks = subChecks,
Duration = sw.Elapsed
};
}
private Task<SubCheckResult> CheckTstStalenessAsync(CancellationToken ct)
{
// Would query ITimestampEvidenceRepository for timestamps approaching expiry
// For now, return placeholder
var now = DateTimeOffset.UtcNow;
var staleCount = 0; // Would be calculated from repository query
if (staleCount > _options.CriticalStaleCount)
{
return Task.FromResult(new SubCheckResult
{
Name = "TST Staleness",
Status = CheckStatus.Unhealthy,
Message = $"{staleCount} timestamps need re-timestamping",
Details = new Dictionary<string, object>
{
["staleCount"] = staleCount,
["threshold"] = _options.CriticalStaleCount
}
});
}
return Task.FromResult(new SubCheckResult
{
Name = "TST Staleness",
Status = CheckStatus.Healthy,
Message = "All timestamps are fresh",
Details = new Dictionary<string, object>
{
["staleCount"] = 0,
["checkedAt"] = now
}
});
}
private Task<SubCheckResult> CheckOcspStalenessAsync(CancellationToken ct)
{
// Would query IRevocationEvidenceRepository for OCSP approaching expiry
return Task.FromResult(new SubCheckResult
{
Name = "OCSP Staleness",
Status = CheckStatus.Healthy,
Message = "All OCSP responses are fresh",
Details = new Dictionary<string, object>
{
["expiringSoonCount"] = 0
}
});
}
private Task<SubCheckResult> CheckCrlStalenessAsync(CancellationToken ct)
{
// Would query IRevocationEvidenceRepository for CRLs approaching expiry
return Task.FromResult(new SubCheckResult
{
Name = "CRL Staleness",
Status = CheckStatus.Healthy,
Message = "All CRLs are fresh",
Details = new Dictionary<string, object>
{
["expiringSoonCount"] = 0
}
});
}
}
/// <summary>
/// Configuration for evidence staleness checks.
/// </summary>
public sealed record EvidenceStalenessCheckOptions
{
/// <summary>
/// Gets the TST staleness warning window.
/// </summary>
public TimeSpan TstWarnWindow { get; init; } = TimeSpan.FromDays(180);
/// <summary>
/// Gets the TST staleness critical window.
/// </summary>
public TimeSpan TstCriticalWindow { get; init; } = TimeSpan.FromDays(90);
/// <summary>
/// Gets the OCSP staleness warning window.
/// </summary>
public TimeSpan OcspWarnWindow { get; init; } = TimeSpan.FromDays(3);
/// <summary>
/// Gets the CRL staleness warning window.
/// </summary>
public TimeSpan CrlWarnWindow { get; init; } = TimeSpan.FromDays(7);
/// <summary>
/// Gets the critical stale count threshold.
/// </summary>
public int CriticalStaleCount { get; init; } = 10;
}

View File

@@ -0,0 +1,200 @@
// -----------------------------------------------------------------------------
// IDoctorCheck.cs
// Sprint: SPRINT_20260119_012 Doctor Timestamp Health Checks
// Task: DOC-001 - TSA Availability Checks
// Description: Base interface for Doctor health checks.
// -----------------------------------------------------------------------------
namespace StellaOps.Doctor.Plugin.Timestamping;
/// <summary>
/// Base interface for Doctor health checks.
/// </summary>
public interface IDoctorCheck
{
/// <summary>
/// Gets the unique check identifier.
/// </summary>
string Id { get; }
/// <summary>
/// Gets the check category.
/// </summary>
string Category { get; }
/// <summary>
/// Gets the check severity.
/// </summary>
CheckSeverity Severity { get; }
/// <summary>
/// Gets the human-readable check name.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the check description.
/// </summary>
string Description { get; }
/// <summary>
/// Executes the health check.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The check result.</returns>
Task<CheckResult> ExecuteAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Severity level for health checks.
/// </summary>
public enum CheckSeverity
{
/// <summary>
/// Informational - no action required.
/// </summary>
Info,
/// <summary>
/// Warning - should be addressed soon.
/// </summary>
Warning,
/// <summary>
/// Critical - immediate action required.
/// </summary>
Critical
}
/// <summary>
/// Status of a health check.
/// </summary>
public enum CheckStatus
{
/// <summary>
/// Check passed.
/// </summary>
Healthy,
/// <summary>
/// Check passed with warnings.
/// </summary>
Degraded,
/// <summary>
/// Check failed.
/// </summary>
Unhealthy,
/// <summary>
/// Check could not be executed.
/// </summary>
Unknown
}
/// <summary>
/// Result of a health check.
/// </summary>
public sealed record CheckResult
{
/// <summary>
/// Gets the check ID.
/// </summary>
public required string CheckId { get; init; }
/// <summary>
/// Gets the status.
/// </summary>
public required CheckStatus Status { get; init; }
/// <summary>
/// Gets the status message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Gets additional details.
/// </summary>
public IReadOnlyDictionary<string, object>? Details { get; init; }
/// <summary>
/// Gets sub-check results.
/// </summary>
public IReadOnlyList<SubCheckResult>? SubChecks { get; init; }
/// <summary>
/// Gets remediation guidance.
/// </summary>
public string? Remediation { get; init; }
/// <summary>
/// Gets when this check was executed.
/// </summary>
public DateTimeOffset ExecutedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Gets how long the check took.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Creates a healthy result.
/// </summary>
public static CheckResult Healthy(string checkId, string message, IReadOnlyDictionary<string, object>? details = null) => new()
{
CheckId = checkId,
Status = CheckStatus.Healthy,
Message = message,
Details = details
};
/// <summary>
/// Creates a degraded result.
/// </summary>
public static CheckResult Degraded(string checkId, string message, string? remediation = null, IReadOnlyDictionary<string, object>? details = null) => new()
{
CheckId = checkId,
Status = CheckStatus.Degraded,
Message = message,
Remediation = remediation,
Details = details
};
/// <summary>
/// Creates an unhealthy result.
/// </summary>
public static CheckResult Unhealthy(string checkId, string message, string? remediation = null, IReadOnlyDictionary<string, object>? details = null) => new()
{
CheckId = checkId,
Status = CheckStatus.Unhealthy,
Message = message,
Remediation = remediation,
Details = details
};
}
/// <summary>
/// Result of a sub-check.
/// </summary>
public sealed record SubCheckResult
{
/// <summary>
/// Gets the sub-check name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets the status.
/// </summary>
public required CheckStatus Status { get; init; }
/// <summary>
/// Gets the message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Gets additional details.
/// </summary>
public IReadOnlyDictionary<string, object>? Details { get; init; }
}

View File

@@ -0,0 +1,128 @@
// -----------------------------------------------------------------------------
// OcspResponderCheck.cs
// Sprint: SPRINT_20260119_012 Doctor Timestamp Health Checks
// Task: DOC-003 - Revocation Infrastructure Checks
// Description: Health check for OCSP responder availability.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
namespace StellaOps.Doctor.Plugin.Timestamping;
/// <summary>
/// Checks OCSP responder availability for TSA certificate validation.
/// </summary>
public sealed class OcspResponderCheck : IDoctorCheck
{
private readonly IOcspResponderRegistry _responderRegistry;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<OcspResponderCheck> _logger;
public string Id => "ocsp-responder-available";
public string Name => "OCSP Responder Availability";
public string Description => "Checks that configured OCSP responders are accessible";
public CheckSeverity DefaultSeverity => CheckSeverity.Warning;
public TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
public OcspResponderCheck(
IOcspResponderRegistry responderRegistry,
IHttpClientFactory httpClientFactory,
ILogger<OcspResponderCheck> logger)
{
_responderRegistry = responderRegistry;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<CheckResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var responders = await _responderRegistry.GetConfiguredRespondersAsync(cancellationToken);
if (responders.Count == 0)
{
return CheckResult.Warning("No OCSP responders configured");
}
var results = new List<SubCheckResult>();
var client = _httpClientFactory.CreateClient("ocsp");
client.Timeout = TimeSpan.FromSeconds(10);
foreach (var responder in responders)
{
try
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
// Send OPTIONS or HEAD request to check availability
using var request = new HttpRequestMessage(HttpMethod.Options, responder.Url);
using var response = await client.SendAsync(request, cancellationToken);
stopwatch.Stop();
if (response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.MethodNotAllowed)
{
results.Add(new SubCheckResult
{
Name = responder.Name,
Passed = true,
LatencyMs = stopwatch.ElapsedMilliseconds,
Details = $"Responding in {stopwatch.ElapsedMilliseconds}ms"
});
}
else
{
results.Add(new SubCheckResult
{
Name = responder.Name,
Passed = false,
LatencyMs = stopwatch.ElapsedMilliseconds,
Details = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"
});
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OCSP responder {Name} check failed", responder.Name);
results.Add(new SubCheckResult
{
Name = responder.Name,
Passed = false,
Details = ex.Message
});
}
}
var passed = results.Count(r => r.Passed);
var failed = results.Count(r => !r.Passed);
if (failed == 0)
{
return CheckResult.Healthy($"All {passed} OCSP responders available", results);
}
if (passed == 0)
{
return CheckResult.Critical($"All {failed} OCSP responders unavailable", results);
}
return CheckResult.Warning($"{passed} available, {failed} unavailable", results);
}
}
/// <summary>
/// Registry of configured OCSP responders.
/// </summary>
public interface IOcspResponderRegistry
{
Task<IReadOnlyList<OcspResponderInfo>> GetConfiguredRespondersAsync(CancellationToken ct);
}
/// <summary>
/// OCSP responder configuration.
/// </summary>
public sealed record OcspResponderInfo
{
public required string Name { get; init; }
public required string Url { get; init; }
public string? ForCertificateIssuer { get; init; }
}

View File

@@ -0,0 +1,161 @@
// -----------------------------------------------------------------------------
// RevocationCacheFreshCheck.cs
// Sprint: SPRINT_20260119_012 Doctor Timestamp Health Checks
// Task: DOC-003 - Revocation Infrastructure Checks
// Description: Health check for cached revocation data freshness.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
namespace StellaOps.Doctor.Plugin.Timestamping;
/// <summary>
/// Checks that cached OCSP responses and CRLs are fresh.
/// </summary>
public sealed class RevocationCacheFreshCheck : IDoctorCheck
{
private readonly IRevocationCacheProvider _cacheProvider;
private readonly RevocationCacheCheckOptions _options;
private readonly ILogger<RevocationCacheFreshCheck> _logger;
public string Id => "revocation-cache-fresh";
public string Name => "Revocation Cache Freshness";
public string Description => "Checks that cached OCSP responses and CRLs are not stale";
public CheckSeverity DefaultSeverity => CheckSeverity.Warning;
public TimeSpan DefaultInterval => TimeSpan.FromMinutes(30);
public RevocationCacheFreshCheck(
IRevocationCacheProvider cacheProvider,
RevocationCacheCheckOptions options,
ILogger<RevocationCacheFreshCheck> logger)
{
_cacheProvider = cacheProvider;
_options = options;
_logger = logger;
}
public async Task<CheckResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var cacheStats = await _cacheProvider.GetCacheStatisticsAsync(cancellationToken);
var results = new List<SubCheckResult>();
// Check OCSP cache
var ocspFresh = 0;
var ocspStale = 0;
foreach (var ocsp in cacheStats.OcspResponses)
{
var age = DateTimeOffset.UtcNow - ocsp.CachedAt;
var isFresh = age < _options.OcspMaxAge;
if (isFresh)
{
ocspFresh++;
}
else
{
ocspStale++;
results.Add(new SubCheckResult
{
Name = $"OCSP:{ocsp.Identifier}",
Passed = false,
Details = $"Stale by {(age - _options.OcspMaxAge).TotalHours:F1}h"
});
}
}
// Check CRL cache
var crlFresh = 0;
var crlStale = 0;
foreach (var crl in cacheStats.CrlSnapshots)
{
var age = DateTimeOffset.UtcNow - crl.CachedAt;
var isFresh = age < _options.CrlMaxAge;
if (isFresh)
{
crlFresh++;
}
else
{
crlStale++;
results.Add(new SubCheckResult
{
Name = $"CRL:{crl.Identifier}",
Passed = false,
Details = $"Stale by {(age - _options.CrlMaxAge).TotalHours:F1}h"
});
}
}
// Build summary
var totalFresh = ocspFresh + crlFresh;
var totalStale = ocspStale + crlStale;
if (totalStale == 0)
{
if (totalFresh == 0)
{
return CheckResult.Info("No cached revocation data");
}
return CheckResult.Healthy($"All {totalFresh} cached responses are fresh");
}
var message = $"{totalFresh} fresh, {totalStale} stale (OCSP: {ocspStale}, CRL: {crlStale})";
if (totalFresh == 0)
{
return CheckResult.Critical(message, results);
}
return CheckResult.Warning(message, results);
}
}
/// <summary>
/// Options for revocation cache freshness check.
/// </summary>
public sealed record RevocationCacheCheckOptions
{
/// <summary>Maximum age for OCSP responses before considered stale.</summary>
public TimeSpan OcspMaxAge { get; init; } = TimeSpan.FromHours(12);
/// <summary>Maximum age for CRLs before considered stale.</summary>
public TimeSpan CrlMaxAge { get; init; } = TimeSpan.FromDays(7);
}
/// <summary>
/// Provider for revocation cache statistics.
/// </summary>
public interface IRevocationCacheProvider
{
Task<RevocationCacheStatistics> GetCacheStatisticsAsync(CancellationToken ct);
}
/// <summary>
/// Revocation cache statistics.
/// </summary>
public sealed record RevocationCacheStatistics
{
public required IReadOnlyList<CachedOcspResponse> OcspResponses { get; init; }
public required IReadOnlyList<CachedCrlSnapshot> CrlSnapshots { get; init; }
}
/// <summary>
/// Cached OCSP response info.
/// </summary>
public sealed record CachedOcspResponse
{
public required string Identifier { get; init; }
public required DateTimeOffset CachedAt { get; init; }
public DateTimeOffset? NextUpdate { get; init; }
}
/// <summary>
/// Cached CRL snapshot info.
/// </summary>
public sealed record CachedCrlSnapshot
{
public required string Identifier { get; init; }
public required DateTimeOffset CachedAt { get; init; }
public DateTimeOffset? NextUpdate { get; init; }
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Doctor.Plugin.Timestamping</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,98 @@
// -----------------------------------------------------------------------------
// TimestampingHealthCheckPlugin.cs
// Sprint: SPRINT_20260119_012 Doctor Timestamp Health Checks
// Task: DOC-001 through DOC-008 - Plugin Registration
// Description: Doctor plugin registration for timestamping health checks.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Doctor.Plugin.Timestamping;
/// <summary>
/// Doctor plugin for timestamping health checks.
/// </summary>
public static class TimestampingHealthCheckPlugin
{
/// <summary>
/// Registers timestamping health checks with the Doctor plugin system.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureTsa">Optional TSA health check configuration.</param>
/// <param name="configureCert">Optional certificate check configuration.</param>
/// <param name="configureEvidence">Optional evidence staleness configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddTimestampingHealthChecks(
this IServiceCollection services,
Action<TsaHealthCheckOptions>? configureTsa = null,
Action<TsaCertificateCheckOptions>? configureCert = null,
Action<EvidenceStalenessCheckOptions>? configureEvidence = null)
{
// Register options
services.AddOptions<TsaHealthCheckOptions>();
services.AddOptions<TsaCertificateCheckOptions>();
services.AddOptions<EvidenceStalenessCheckOptions>();
if (configureTsa is not null)
{
services.Configure(configureTsa);
}
if (configureCert is not null)
{
services.Configure(configureCert);
}
if (configureEvidence is not null)
{
services.Configure(configureEvidence);
}
// Register HttpClient for TSA checks
services.AddHttpClient("Doctor-TSA", client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});
// Register health checks
services.AddSingleton<IDoctorCheck, TsaAvailabilityCheck>();
services.AddSingleton<IDoctorCheck, TsaCertificateExpiryCheck>();
services.AddSingleton<IDoctorCheck, EvidenceStalenessCheck>();
return services;
}
/// <summary>
/// Configures default TSA endpoints for health checking.
/// </summary>
/// <param name="options">The options to configure.</param>
public static void AddCommonTsaEndpoints(TsaHealthCheckOptions options)
{
options.TsaEndpoints.AddRange([
new TsaEndpointConfig
{
Name = "DigiCert",
Url = "http://timestamp.digicert.com",
Required = false
},
new TsaEndpointConfig
{
Name = "Sectigo",
Url = "http://timestamp.sectigo.com",
Required = false
},
new TsaEndpointConfig
{
Name = "GlobalSign",
Url = "http://timestamp.globalsign.com/tsa/r6advanced1",
Required = false
},
new TsaEndpointConfig
{
Name = "FreeTSA",
Url = "https://freetsa.org/tsr",
Required = false
}
]);
}
}

View File

@@ -0,0 +1,226 @@
// -----------------------------------------------------------------------------
// TsaAvailabilityCheck.cs
// Sprint: SPRINT_20260119_012 Doctor Timestamp Health Checks
// Task: DOC-001 - TSA Availability Checks
// Description: Health check for TSA endpoint availability.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Doctor.Plugin.Timestamping;
/// <summary>
/// Health check for TSA endpoint availability.
/// </summary>
public sealed class TsaAvailabilityCheck : IDoctorCheck
{
private readonly TsaHealthCheckOptions _options;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<TsaAvailabilityCheck> _logger;
/// <inheritdoc />
public string Id => "tsa-reachable";
/// <inheritdoc />
public string Category => "timestamping";
/// <inheritdoc />
public CheckSeverity Severity => CheckSeverity.Critical;
/// <inheritdoc />
public string Name => "TSA Availability";
/// <inheritdoc />
public string Description => "Checks if configured TSA endpoints are reachable and responding";
/// <summary>
/// Initializes a new instance of the <see cref="TsaAvailabilityCheck"/> class.
/// </summary>
public TsaAvailabilityCheck(
IOptions<TsaHealthCheckOptions> options,
IHttpClientFactory httpClientFactory,
ILogger<TsaAvailabilityCheck> logger)
{
_options = options.Value;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <inheritdoc />
public async Task<CheckResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var sw = Stopwatch.StartNew();
var subChecks = new List<SubCheckResult>();
var healthyCount = 0;
var totalCount = 0;
foreach (var tsa in _options.TsaEndpoints)
{
totalCount++;
var subResult = await CheckTsaEndpointAsync(tsa, cancellationToken);
subChecks.Add(subResult);
if (subResult.Status == CheckStatus.Healthy)
{
healthyCount++;
}
}
sw.Stop();
if (healthyCount == 0)
{
return new CheckResult
{
CheckId = Id,
Status = CheckStatus.Unhealthy,
Message = $"No TSA endpoints available (0/{totalCount} healthy)",
Remediation = "Check network connectivity to TSA endpoints. Consider adding backup TSA providers.",
SubChecks = subChecks,
Duration = sw.Elapsed
};
}
if (healthyCount < totalCount)
{
return new CheckResult
{
CheckId = Id,
Status = CheckStatus.Degraded,
Message = $"Some TSA endpoints unavailable ({healthyCount}/{totalCount} healthy)",
Remediation = "Investigate failing TSA endpoints. Ensure failover is functioning.",
SubChecks = subChecks,
Duration = sw.Elapsed
};
}
return new CheckResult
{
CheckId = Id,
Status = CheckStatus.Healthy,
Message = $"All TSA endpoints available ({totalCount}/{totalCount} healthy)",
SubChecks = subChecks,
Duration = sw.Elapsed
};
}
private async Task<SubCheckResult> CheckTsaEndpointAsync(TsaEndpointConfig tsa, CancellationToken ct)
{
try
{
var client = _httpClientFactory.CreateClient("Doctor-TSA");
client.Timeout = TimeSpan.FromSeconds(10);
var sw = Stopwatch.StartNew();
// Simple connectivity check - HEAD request or OPTIONS
var request = new HttpRequestMessage(HttpMethod.Head, tsa.Url);
var response = await client.SendAsync(request, ct);
sw.Stop();
// Most TSAs return 405 for HEAD but that still means reachable
var details = new Dictionary<string, object>
{
["url"] = tsa.Url,
["latencyMs"] = sw.ElapsedMilliseconds,
["statusCode"] = (int)response.StatusCode
};
if (sw.ElapsedMilliseconds > _options.CriticalLatencyMs)
{
return new SubCheckResult
{
Name = tsa.Name,
Status = CheckStatus.Degraded,
Message = $"TSA responding but slow ({sw.ElapsedMilliseconds}ms > {_options.CriticalLatencyMs}ms threshold)",
Details = details
};
}
if (sw.ElapsedMilliseconds > _options.WarnLatencyMs)
{
return new SubCheckResult
{
Name = tsa.Name,
Status = CheckStatus.Degraded,
Message = $"TSA responding with elevated latency ({sw.ElapsedMilliseconds}ms)",
Details = details
};
}
return new SubCheckResult
{
Name = tsa.Name,
Status = CheckStatus.Healthy,
Message = $"TSA reachable ({sw.ElapsedMilliseconds}ms)",
Details = details
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "TSA availability check failed for {TSA}", tsa.Name);
return new SubCheckResult
{
Name = tsa.Name,
Status = CheckStatus.Unhealthy,
Message = $"TSA unreachable: {ex.Message}",
Details = new Dictionary<string, object>
{
["url"] = tsa.Url,
["error"] = ex.Message
}
};
}
}
}
/// <summary>
/// Configuration for TSA health checks.
/// </summary>
public sealed record TsaHealthCheckOptions
{
/// <summary>
/// Gets the TSA endpoints to check.
/// </summary>
public List<TsaEndpointConfig> TsaEndpoints { get; init; } = [];
/// <summary>
/// Gets the warning latency threshold in milliseconds.
/// </summary>
public int WarnLatencyMs { get; init; } = 5000;
/// <summary>
/// Gets the critical latency threshold in milliseconds.
/// </summary>
public int CriticalLatencyMs { get; init; } = 30000;
/// <summary>
/// Gets the minimum number of healthy TSAs required.
/// </summary>
public int MinHealthyTsas { get; init; } = 2;
}
/// <summary>
/// TSA endpoint configuration.
/// </summary>
public sealed record TsaEndpointConfig
{
/// <summary>
/// Gets the endpoint name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets the endpoint URL.
/// </summary>
public required string Url { get; init; }
/// <summary>
/// Gets whether this endpoint is required.
/// </summary>
public bool Required { get; init; } = false;
}

View File

@@ -0,0 +1,200 @@
// -----------------------------------------------------------------------------
// TsaCertificateExpiryCheck.cs
// Sprint: SPRINT_20260119_012 Doctor Timestamp Health Checks
// Task: DOC-002 - TSA Certificate Expiry Checks
// Description: Health check for TSA certificate expiry.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Doctor.Plugin.Timestamping;
/// <summary>
/// Health check for TSA certificate expiry.
/// </summary>
public sealed class TsaCertificateExpiryCheck : IDoctorCheck
{
private readonly TsaCertificateCheckOptions _options;
private readonly ILogger<TsaCertificateExpiryCheck> _logger;
/// <inheritdoc />
public string Id => "tsa-cert-expiry";
/// <inheritdoc />
public string Category => "timestamping";
/// <inheritdoc />
public CheckSeverity Severity => CheckSeverity.Warning;
/// <inheritdoc />
public string Name => "TSA Certificate Expiry";
/// <inheritdoc />
public string Description => "Checks if TSA signing certificates are approaching expiry";
/// <summary>
/// Initializes a new instance of the <see cref="TsaCertificateExpiryCheck"/> class.
/// </summary>
public TsaCertificateExpiryCheck(
IOptions<TsaCertificateCheckOptions> options,
ILogger<TsaCertificateExpiryCheck> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task<CheckResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var sw = Stopwatch.StartNew();
var subChecks = new List<SubCheckResult>();
var overallStatus = CheckStatus.Healthy;
var now = DateTimeOffset.UtcNow;
foreach (var cert in _options.TsaCertificates)
{
var daysRemaining = (cert.ExpiresAt - now).TotalDays;
var subResult = EvaluateCertificateExpiry(cert, daysRemaining);
subChecks.Add(subResult);
if (subResult.Status == CheckStatus.Unhealthy)
{
overallStatus = CheckStatus.Unhealthy;
}
else if (subResult.Status == CheckStatus.Degraded && overallStatus == CheckStatus.Healthy)
{
overallStatus = CheckStatus.Degraded;
}
}
sw.Stop();
var message = overallStatus switch
{
CheckStatus.Healthy => "All TSA certificates have sufficient validity",
CheckStatus.Degraded => "Some TSA certificates are approaching expiry",
CheckStatus.Unhealthy => "TSA certificates are near or past expiry",
_ => "Certificate status unknown"
};
var remediation = overallStatus != CheckStatus.Healthy
? "Contact TSA provider to obtain renewed certificates. Update trust configuration with new certificates."
: null;
return new CheckResult
{
CheckId = Id,
Status = overallStatus,
Message = message,
Remediation = remediation,
SubChecks = subChecks,
Duration = sw.Elapsed
};
}
private SubCheckResult EvaluateCertificateExpiry(TsaCertificateConfig cert, double daysRemaining)
{
var details = new Dictionary<string, object>
{
["subject"] = cert.Subject,
["expiresAt"] = cert.ExpiresAt,
["daysRemaining"] = Math.Round(daysRemaining, 1),
["issuer"] = cert.Issuer ?? "Unknown"
};
if (daysRemaining <= 0)
{
return new SubCheckResult
{
Name = cert.Name,
Status = CheckStatus.Unhealthy,
Message = $"Certificate EXPIRED {Math.Abs(daysRemaining):F0} days ago",
Details = details
};
}
if (daysRemaining <= _options.CriticalDays)
{
return new SubCheckResult
{
Name = cert.Name,
Status = CheckStatus.Unhealthy,
Message = $"Certificate expires in {daysRemaining:F0} days (critical threshold: {_options.CriticalDays} days)",
Details = details
};
}
if (daysRemaining <= _options.WarnDays)
{
return new SubCheckResult
{
Name = cert.Name,
Status = CheckStatus.Degraded,
Message = $"Certificate expires in {daysRemaining:F0} days (warning threshold: {_options.WarnDays} days)",
Details = details
};
}
return new SubCheckResult
{
Name = cert.Name,
Status = CheckStatus.Healthy,
Message = $"Certificate valid for {daysRemaining:F0} more days",
Details = details
};
}
}
/// <summary>
/// Configuration for TSA certificate checks.
/// </summary>
public sealed record TsaCertificateCheckOptions
{
/// <summary>
/// Gets the TSA certificates to monitor.
/// </summary>
public List<TsaCertificateConfig> TsaCertificates { get; init; } = [];
/// <summary>
/// Gets the warning threshold in days.
/// </summary>
public int WarnDays { get; init; } = 180;
/// <summary>
/// Gets the critical threshold in days.
/// </summary>
public int CriticalDays { get; init; } = 90;
}
/// <summary>
/// TSA certificate configuration.
/// </summary>
public sealed record TsaCertificateConfig
{
/// <summary>
/// Gets the certificate name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets the certificate subject.
/// </summary>
public required string Subject { get; init; }
/// <summary>
/// Gets the certificate issuer.
/// </summary>
public string? Issuer { get; init; }
/// <summary>
/// Gets the expiration date.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Gets the certificate thumbprint.
/// </summary>
public string? Thumbprint { get; init; }
}

View File

@@ -0,0 +1,175 @@
// -----------------------------------------------------------------------------
// BinaryAnalysisDoctorPluginTests.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-001 - Binary Analysis Doctor Plugin Scaffold
// Description: Unit tests for BinaryAnalysisDoctorPlugin
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests;
[Trait("Category", "Unit")]
public class BinaryAnalysisDoctorPluginTests
{
private readonly BinaryAnalysisDoctorPlugin _plugin = new();
[Fact]
public void PluginId_ReturnsExpectedValue()
{
// Assert
_plugin.PluginId.Should().Be("stellaops.doctor.binaryanalysis");
}
[Fact]
public void Category_IsSecurity()
{
// Assert
_plugin.Category.Should().Be(DoctorCategory.Security);
}
[Fact]
public void DisplayName_IsBinaryAnalysis()
{
// Assert
_plugin.DisplayName.Should().Be("Binary Analysis");
}
[Fact]
public void IsAvailable_ReturnsTrue_Always()
{
// Arrange
var services = new ServiceCollection().BuildServiceProvider();
// Act & Assert
_plugin.IsAvailable(services).Should().BeTrue();
}
[Fact]
public void GetChecks_ReturnsAtLeastOneCheck()
{
// Arrange
var context = CreateContext();
// Act
var checks = _plugin.GetChecks(context);
// Assert
checks.Should().NotBeEmpty();
checks.Should().HaveCountGreaterThanOrEqualTo(1);
}
[Fact]
public void GetChecks_ContainsDebuginfodCheck()
{
// Arrange
var context = CreateContext();
// Act
var checks = _plugin.GetChecks(context);
// Assert
checks.Select(c => c.CheckId).Should().Contain("check.binaryanalysis.debuginfod.available");
}
[Fact]
public void GetChecks_ContainsDdebCheck()
{
// Arrange
var context = CreateContext();
// Act
var checks = _plugin.GetChecks(context);
// Assert
checks.Select(c => c.CheckId).Should().Contain("check.binaryanalysis.ddeb.enabled");
}
[Fact]
public void GetChecks_ContainsBuildinfoCacheCheck()
{
// Arrange
var context = CreateContext();
// Act
var checks = _plugin.GetChecks(context);
// Assert
checks.Select(c => c.CheckId).Should().Contain("check.binaryanalysis.buildinfo.cache");
}
[Fact]
public void GetChecks_ContainsSymbolRecoveryFallbackCheck()
{
// Arrange
var context = CreateContext();
// Act
var checks = _plugin.GetChecks(context);
// Assert
checks.Select(c => c.CheckId).Should().Contain("check.binaryanalysis.symbol.recovery.fallback");
}
[Fact]
public void GetChecks_ReturnsFourChecks()
{
// Arrange
var context = CreateContext();
// Act
var checks = _plugin.GetChecks(context);
// Assert
checks.Should().HaveCount(4);
}
[Fact]
public async Task InitializeAsync_CompletesWithoutError()
{
// Arrange
var context = CreateContext();
// Act & Assert
await _plugin.Invoking(p => p.InitializeAsync(context, CancellationToken.None))
.Should().NotThrowAsync();
}
[Fact]
public void Version_IsNotNull()
{
// Assert
_plugin.Version.Should().NotBeNull();
_plugin.Version.Major.Should().BeGreaterThanOrEqualTo(1);
}
[Fact]
public void MinEngineVersion_IsNotNull()
{
// Assert
_plugin.MinEngineVersion.Should().NotBeNull();
_plugin.MinEngineVersion.Major.Should().BeGreaterThanOrEqualTo(1);
}
private static DoctorPluginContext CreateContext()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
return new DoctorPluginContext
{
Services = new ServiceCollection().BuildServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
}

View File

@@ -0,0 +1,214 @@
// -----------------------------------------------------------------------------
// BuildinfoCacheCheckTests.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-004 - Buildinfo Cache Check
// Description: Unit tests for BuildinfoCacheCheck
// -----------------------------------------------------------------------------
using System.Net;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Checks;
[Trait("Category", "Unit")]
public class BuildinfoCacheCheckTests
{
private readonly BuildinfoCacheCheck _check = new();
[Fact]
public void CheckId_ReturnsExpectedValue()
{
// Assert
_check.CheckId.Should().Be("check.binaryanalysis.buildinfo.cache");
}
[Fact]
public void Name_ReturnsDebianBuildinfoCache()
{
// Assert
_check.Name.Should().Be("Debian Buildinfo Cache");
}
[Fact]
public void DefaultSeverity_IsWarn()
{
// Assert
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
}
[Fact]
public void Tags_ContainsBuildinfo()
{
// Assert
_check.Tags.Should().Contain("buildinfo");
_check.Tags.Should().Contain("debian");
_check.Tags.Should().Contain("cache");
}
[Fact]
public void CanRun_ReturnsTrue_Always()
{
// Arrange
var context = CreateContext();
// Act & Assert
_check.CanRun(context).Should().BeTrue();
}
[Fact]
public async Task RunAsync_IncludesVerificationCommand()
{
// Arrange
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.VerificationCommand.Should().NotBeNullOrEmpty();
result.VerificationCommand.Should().Contain("stella doctor --check");
}
[Fact]
public async Task RunAsync_ReturnsWarningOrPass_WhenServicesReachable()
{
// Arrange
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert - should be Pass, Info, or Warn (not Fail) when services are reachable
result.Severity.Should().NotBe(DoctorSeverity.Fail);
}
[Fact]
public async Task RunAsync_IncludesEvidence_WithServiceStatus()
{
// Arrange
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Evidence.Should().NotBeNull();
result.Evidence.Data.Should().ContainKey("buildinfos_debian_net_reachable");
}
[Fact]
public async Task RunAsync_ReturnsFailOrWarn_WhenServicesUnreachable()
{
// Arrange
var mockHandler = CreateMockHttpHandler(throwException: true);
var context = CreateContextWithHttpClient(mockHandler);
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert - should be Fail or Warn when services unreachable
result.Severity.Should().BeOneOf(DoctorSeverity.Fail, DoctorSeverity.Warn);
}
[Fact]
public void EstimatedDuration_IsReasonable()
{
// Assert
_check.EstimatedDuration.Should().BeGreaterThan(TimeSpan.Zero);
_check.EstimatedDuration.Should().BeLessThanOrEqualTo(TimeSpan.FromSeconds(30));
}
[Fact]
public void Description_IsNotEmpty()
{
// Assert
_check.Description.Should().NotBeNullOrEmpty();
}
private static DoctorPluginContext CreateContext()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
return new DoctorPluginContext
{
Services = new ServiceCollection().BuildServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
private static DoctorPluginContext CreateContextWithHttpClient(Mock<HttpMessageHandler> mockHandler)
{
var httpClient = new HttpClient(mockHandler.Object);
var mockFactory = new Mock<IHttpClientFactory>();
mockFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var services = new ServiceCollection()
.AddSingleton(mockFactory.Object)
.BuildServiceProvider();
return new DoctorPluginContext
{
Services = services,
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
private static Mock<HttpMessageHandler> CreateMockHttpHandler(
HttpStatusCode statusCode = HttpStatusCode.OK,
bool throwException = false)
{
var mockHandler = new Mock<HttpMessageHandler>();
if (throwException)
{
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
}
else
{
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode
});
}
return mockHandler;
}
}

View File

@@ -0,0 +1,129 @@
// -----------------------------------------------------------------------------
// DdebRepoEnabledCheckTests.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-003 - Ddeb Repository Check
// Description: Unit tests for DdebRepoEnabledCheck
// -----------------------------------------------------------------------------
using System.Net;
using System.Runtime.InteropServices;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Checks;
[Trait("Category", "Unit")]
public class DdebRepoEnabledCheckTests
{
private readonly DdebRepoEnabledCheck _check = new();
[Fact]
public void CheckId_ReturnsExpectedValue()
{
// Assert
_check.CheckId.Should().Be("check.binaryanalysis.ddeb.enabled");
}
[Fact]
public void Name_ReturnsUbuntuDdebRepository()
{
// Assert
_check.Name.Should().Be("Ubuntu Ddeb Repository");
}
[Fact]
public void DefaultSeverity_IsWarn()
{
// Assert
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
}
[Fact]
public void Tags_ContainsDdeb()
{
// Assert
_check.Tags.Should().Contain("ddeb");
_check.Tags.Should().Contain("ubuntu");
_check.Tags.Should().Contain("binaryanalysis");
}
[Fact]
public void CanRun_ReturnsFalse_OnWindows()
{
// Arrange
var context = CreateContext();
// Act
var canRun = _check.CanRun(context);
// Assert
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
canRun.Should().BeFalse();
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
canRun.Should().BeTrue();
}
}
[Fact]
public async Task RunAsync_ReturnsSkip_OnNonLinux()
{
// Arrange
var context = CreateContext();
// Skip this test on Linux
if (OperatingSystem.IsLinux())
{
return;
}
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Severity.Should().Be(DoctorSeverity.Skip);
result.Diagnosis.Should().Contain("Linux");
}
[Fact]
public void EstimatedDuration_IsReasonable()
{
// Assert
_check.EstimatedDuration.Should().BeGreaterThan(TimeSpan.Zero);
_check.EstimatedDuration.Should().BeLessThanOrEqualTo(TimeSpan.FromSeconds(30));
}
[Fact]
public void Description_IsNotEmpty()
{
// Assert
_check.Description.Should().NotBeNullOrEmpty();
}
private static DoctorPluginContext CreateContext()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
return new DoctorPluginContext
{
Services = new ServiceCollection().BuildServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
}

View File

@@ -0,0 +1,294 @@
// -----------------------------------------------------------------------------
// DebuginfodAvailabilityCheckTests.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-002 - Debuginfod Availability Check
// Description: Unit tests for DebuginfodAvailabilityCheck with mocked HTTP
// -----------------------------------------------------------------------------
using System.Net;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Checks;
[Trait("Category", "Unit")]
public class DebuginfodAvailabilityCheckTests
{
private readonly DebuginfodAvailabilityCheck _check = new();
[Fact]
public void CheckId_ReturnsExpectedValue()
{
// Assert
_check.CheckId.Should().Be("check.binaryanalysis.debuginfod.available");
}
[Fact]
public void Name_ReturnsDebuginfodAvailability()
{
// Assert
_check.Name.Should().Be("Debuginfod Availability");
}
[Fact]
public void DefaultSeverity_IsWarn()
{
// Assert
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
}
[Fact]
public void Tags_ContainsBinaryAnalysis()
{
// Assert
_check.Tags.Should().Contain("binaryanalysis");
_check.Tags.Should().Contain("debuginfod");
_check.Tags.Should().Contain("symbols");
}
[Fact]
public void CanRun_ReturnsTrue_Always()
{
// Arrange
var context = CreateContext();
// Act & Assert
_check.CanRun(context).Should().BeTrue();
}
[Fact]
public async Task RunAsync_ReturnsPass_WhenDebuginfodReachable()
{
// Arrange
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
// Set environment variable for test
var originalValue = Environment.GetEnvironmentVariable("DEBUGINFOD_URLS");
try
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", "https://debuginfod.example.com");
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Severity.Should().Be(DoctorSeverity.Pass);
result.Diagnosis.Should().Contain("reachable");
}
finally
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", originalValue);
}
}
[Fact]
public async Task RunAsync_ReturnsInfo_WhenDefaultUrlReachableButEnvNotSet()
{
// Arrange
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
var originalValue = Environment.GetEnvironmentVariable("DEBUGINFOD_URLS");
try
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", null);
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Severity.Should().Be(DoctorSeverity.Info);
result.Diagnosis.Should().Contain("not configured");
}
finally
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", originalValue);
}
}
[Fact]
public async Task RunAsync_ReturnsFail_WhenAllUrlsUnreachable()
{
// Arrange
var mockHandler = CreateMockHttpHandler(throwException: true);
var context = CreateContextWithHttpClient(mockHandler);
var originalValue = Environment.GetEnvironmentVariable("DEBUGINFOD_URLS");
try
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", "https://debuginfod.example.com");
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Severity.Should().Be(DoctorSeverity.Fail);
result.Diagnosis.Should().Contain("None");
result.Diagnosis.Should().Contain("reachable");
}
finally
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", originalValue);
}
}
[Fact]
public async Task RunAsync_IncludesRemediationSteps_OnFailure()
{
// Arrange
var mockHandler = CreateMockHttpHandler(throwException: true);
var context = CreateContextWithHttpClient(mockHandler);
var originalValue = Environment.GetEnvironmentVariable("DEBUGINFOD_URLS");
try
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", "https://debuginfod.example.com");
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Remediation.Should().NotBeNull();
result.Remediation!.Steps.Should().NotBeEmpty();
}
finally
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", originalValue);
}
}
[Fact]
public async Task RunAsync_ParsesMultipleUrls()
{
// Arrange - all will return OK since we use the same mock
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
var originalValue = Environment.GetEnvironmentVariable("DEBUGINFOD_URLS");
try
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS",
"https://debuginfod1.example.com https://debuginfod2.example.com");
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Severity.Should().Be(DoctorSeverity.Pass);
result.Diagnosis.Should().Contain("2"); // Should mention 2 URLs
}
finally
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", originalValue);
}
}
[Fact]
public async Task RunAsync_IncludesVerificationCommand()
{
// Arrange
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
var originalValue = Environment.GetEnvironmentVariable("DEBUGINFOD_URLS");
try
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", "https://debuginfod.example.com");
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.VerificationCommand.Should().NotBeNullOrEmpty();
result.VerificationCommand.Should().Contain("stella doctor --check");
}
finally
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", originalValue);
}
}
private static DoctorPluginContext CreateContext()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
return new DoctorPluginContext
{
Services = new ServiceCollection().BuildServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
private static DoctorPluginContext CreateContextWithHttpClient(Mock<HttpMessageHandler> mockHandler)
{
var httpClient = new HttpClient(mockHandler.Object);
var mockFactory = new Mock<IHttpClientFactory>();
mockFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var services = new ServiceCollection()
.AddSingleton(mockFactory.Object)
.BuildServiceProvider();
return new DoctorPluginContext
{
Services = services,
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
private static Mock<HttpMessageHandler> CreateMockHttpHandler(
HttpStatusCode statusCode = HttpStatusCode.OK,
bool throwException = false)
{
var mockHandler = new Mock<HttpMessageHandler>();
if (throwException)
{
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
}
else
{
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode
});
}
return mockHandler;
}
}

View File

@@ -0,0 +1,277 @@
// -----------------------------------------------------------------------------
// SymbolRecoveryFallbackCheckTests.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-005 - Symbol Recovery Fallback Check
// Description: Unit tests for SymbolRecoveryFallbackCheck
// -----------------------------------------------------------------------------
using System.Net;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Checks;
[Trait("Category", "Unit")]
public class SymbolRecoveryFallbackCheckTests
{
private readonly SymbolRecoveryFallbackCheck _check = new();
[Fact]
public void CheckId_ReturnsExpectedValue()
{
// Assert
_check.CheckId.Should().Be("check.binaryanalysis.symbol.recovery.fallback");
}
[Fact]
public void Name_ReturnsSymbolRecoveryFallback()
{
// Assert
_check.Name.Should().Be("Symbol Recovery Fallback");
}
[Fact]
public void DefaultSeverity_IsWarn()
{
// Assert
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
}
[Fact]
public void Tags_ContainsMeta()
{
// Assert
_check.Tags.Should().Contain("meta");
_check.Tags.Should().Contain("fallback");
_check.Tags.Should().Contain("symbols");
}
[Fact]
public void CanRun_ReturnsTrue_Always()
{
// Arrange
var context = CreateContext();
// Act & Assert
_check.CanRun(context).Should().BeTrue();
}
[Fact]
public async Task RunAsync_IncludesVerificationCommand()
{
// Arrange
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.VerificationCommand.Should().NotBeNullOrEmpty();
result.VerificationCommand.Should().Contain("stella doctor --check");
}
[Fact]
public async Task RunAsync_IncludesEvidence_WithSourceCounts()
{
// Arrange
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Evidence.Should().NotBeNull();
result.Evidence.Data.Should().ContainKey("total_sources_checked");
result.Evidence.Data.Should().ContainKey("available_sources");
}
[Fact]
public async Task RunAsync_AggregatesChildCheckResults()
{
// Arrange
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert - should have evidence for multiple sources
result.Evidence.Data.Keys.Should().Contain(k => k.StartsWith("source_"));
}
[Fact]
public async Task RunAsync_ReturnsPassOrInfo_WhenAtLeastOneSourceAvailable()
{
// Arrange - at least debuginfod should succeed with mocked HTTP
var mockHandler = CreateMockHttpHandler(HttpStatusCode.OK);
var context = CreateContextWithHttpClient(mockHandler);
// Set DEBUGINFOD_URLS to ensure at least one source is available
var originalValue = Environment.GetEnvironmentVariable("DEBUGINFOD_URLS");
try
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", "https://debuginfod.example.com");
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert - should be Pass or Info when at least one source is available
result.Severity.Should().BeOneOf(DoctorSeverity.Pass, DoctorSeverity.Info);
}
finally
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", originalValue);
}
}
[Fact]
public async Task RunAsync_ReturnsFail_WhenNoSourcesAvailable()
{
// Arrange - all HTTP calls will fail
var mockHandler = CreateMockHttpHandler(throwException: true);
var context = CreateContextWithHttpClient(mockHandler);
// Ensure no DEBUGINFOD_URLS fallback
var originalValue = Environment.GetEnvironmentVariable("DEBUGINFOD_URLS");
try
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", "https://unreachable.example.com");
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Severity.Should().Be(DoctorSeverity.Fail);
result.Diagnosis.Should().Contain("No symbol recovery sources");
}
finally
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", originalValue);
}
}
[Fact]
public async Task RunAsync_IncludesRemediation_WhenNoSourcesAvailable()
{
// Arrange
var mockHandler = CreateMockHttpHandler(throwException: true);
var context = CreateContextWithHttpClient(mockHandler);
var originalValue = Environment.GetEnvironmentVariable("DEBUGINFOD_URLS");
try
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", "https://unreachable.example.com");
// Act
var result = await _check.RunAsync(context, CancellationToken.None);
// Assert
result.Remediation.Should().NotBeNull();
result.Remediation!.Steps.Should().NotBeEmpty();
}
finally
{
Environment.SetEnvironmentVariable("DEBUGINFOD_URLS", originalValue);
}
}
[Fact]
public void EstimatedDuration_IsLargerThanChildChecks()
{
// The fallback check runs multiple child checks, so should have larger duration
_check.EstimatedDuration.Should().BeGreaterThanOrEqualTo(TimeSpan.FromSeconds(30));
}
[Fact]
public void Description_IsNotEmpty()
{
// Assert
_check.Description.Should().NotBeNullOrEmpty();
_check.Description.Should().Contain("at least one");
}
private static DoctorPluginContext CreateContext()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
return new DoctorPluginContext
{
Services = new ServiceCollection().BuildServiceProvider(),
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
private static DoctorPluginContext CreateContextWithHttpClient(Mock<HttpMessageHandler> mockHandler)
{
var httpClient = new HttpClient(mockHandler.Object);
var mockFactory = new Mock<IHttpClientFactory>();
mockFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var services = new ServiceCollection()
.AddSingleton(mockFactory.Object)
.BuildServiceProvider();
return new DoctorPluginContext
{
Services = services,
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
}
private static Mock<HttpMessageHandler> CreateMockHttpHandler(
HttpStatusCode statusCode = HttpStatusCode.OK,
bool throwException = false)
{
var mockHandler = new Mock<HttpMessageHandler>();
if (throwException)
{
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
}
else
{
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode
});
}
return mockHandler;
}
}

View File

@@ -0,0 +1,238 @@
// -----------------------------------------------------------------------------
// BinaryAnalysisPluginIntegrationTests.cs
// Sprint: SPRINT_20260119_003_Doctor_binary_analysis_checks
// Task: DBIN-006, DBIN-007 - Integration tests for plugin discovery and CLI behavior
// Description: Verifies plugin integration with Doctor engine and CLI filtering
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugin.BinaryAnalysis.DependencyInjection;
using StellaOps.Doctor.Plugins;
using Xunit;
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Integration;
/// <summary>
/// Integration tests verifying plugin registration and discovery behavior.
/// </summary>
[Trait("Category", "Integration")]
public class BinaryAnalysisPluginIntegrationTests
{
[Fact]
public void AddDoctorBinaryAnalysisPlugin_RegistersPluginAsSingleton()
{
// Arrange
var services = new ServiceCollection();
// Act
services.AddDoctorBinaryAnalysisPlugin();
var provider = services.BuildServiceProvider();
// Assert
var plugins = provider.GetServices<IDoctorPlugin>().ToList();
plugins.Should().ContainSingle(p => p.PluginId == "stellaops.doctor.binaryanalysis");
}
[Fact]
public void AddDoctorBinaryAnalysisPlugin_PluginHasSecurityCategory()
{
// Arrange
var services = new ServiceCollection();
services.AddDoctorBinaryAnalysisPlugin();
var provider = services.BuildServiceProvider();
// Act
var plugin = provider.GetServices<IDoctorPlugin>()
.Single(p => p.PluginId == "stellaops.doctor.binaryanalysis");
// Assert
plugin.Category.Should().Be(DoctorCategory.Security);
}
[Fact]
public void GetChecks_ReturnsFourBinaryAnalysisChecks()
{
// Arrange
var services = new ServiceCollection();
services.AddDoctorBinaryAnalysisPlugin();
var provider = services.BuildServiceProvider();
var plugin = provider.GetServices<IDoctorPlugin>()
.Single(p => p.PluginId == "stellaops.doctor.binaryanalysis");
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var context = new DoctorPluginContext
{
Services = provider,
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
// Act
var checks = plugin.GetChecks(context);
// Assert
checks.Should().HaveCount(4);
checks.Select(c => c.CheckId).Should().Contain("check.binaryanalysis.debuginfod.available");
checks.Select(c => c.CheckId).Should().Contain("check.binaryanalysis.ddeb.enabled");
checks.Select(c => c.CheckId).Should().Contain("check.binaryanalysis.buildinfo.cache");
checks.Select(c => c.CheckId).Should().Contain("check.binaryanalysis.symbol.recovery.fallback");
}
[Fact]
public void AllChecks_HaveBinaryanalysisTag()
{
// Arrange
var services = new ServiceCollection();
services.AddDoctorBinaryAnalysisPlugin();
var provider = services.BuildServiceProvider();
var plugin = provider.GetServices<IDoctorPlugin>()
.Single(p => p.PluginId == "stellaops.doctor.binaryanalysis");
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var context = new DoctorPluginContext
{
Services = provider,
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
// Act
var checks = plugin.GetChecks(context);
// Assert
foreach (var check in checks)
{
check.Tags.Should().Contain("binaryanalysis",
because: $"check {check.CheckId} should have binaryanalysis tag for CLI filtering");
}
}
[Fact]
public void AllChecks_HaveSecurityTag()
{
// Arrange
var services = new ServiceCollection();
services.AddDoctorBinaryAnalysisPlugin();
var provider = services.BuildServiceProvider();
var plugin = provider.GetServices<IDoctorPlugin>()
.Single(p => p.PluginId == "stellaops.doctor.binaryanalysis");
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var context = new DoctorPluginContext
{
Services = provider,
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
// Act
var checks = plugin.GetChecks(context);
// Assert
foreach (var check in checks)
{
check.Tags.Should().Contain("security",
because: $"check {check.CheckId} should have security tag for CLI filtering");
}
}
[Fact]
public void AllChecks_HaveValidCheckIdFormat()
{
// Arrange
var services = new ServiceCollection();
services.AddDoctorBinaryAnalysisPlugin();
var provider = services.BuildServiceProvider();
var plugin = provider.GetServices<IDoctorPlugin>()
.Single(p => p.PluginId == "stellaops.doctor.binaryanalysis");
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var context = new DoctorPluginContext
{
Services = provider,
Configuration = config,
TimeProvider = TimeProvider.System,
Logger = NullLogger.Instance,
EnvironmentName = "Test",
PluginConfig = config.GetSection("Doctor:Plugins")
};
// Act
var checks = plugin.GetChecks(context);
// Assert
foreach (var check in checks)
{
check.CheckId.Should().StartWith("check.binaryanalysis.",
"check IDs should follow check.<category>.<name> convention");
check.CheckId.Should().NotContain(" ");
check.CheckId.Should().NotContain("\t");
check.CheckId.Should().NotContain("\n");
}
}
[Fact]
public void Plugin_CanFilterByCategory_Security()
{
// Arrange
var services = new ServiceCollection();
services.AddDoctorBinaryAnalysisPlugin();
var provider = services.BuildServiceProvider();
// Act - simulate CLI --category Security filter
var plugins = provider.GetServices<IDoctorPlugin>()
.Where(p => p.Category == DoctorCategory.Security)
.ToList();
// Assert
plugins.Should().Contain(p => p.PluginId == "stellaops.doctor.binaryanalysis");
}
[Fact]
public void Plugin_HasVersionInfo()
{
// Arrange
var services = new ServiceCollection();
services.AddDoctorBinaryAnalysisPlugin();
var provider = services.BuildServiceProvider();
// Act
var plugin = provider.GetServices<IDoctorPlugin>()
.Single(p => p.PluginId == "stellaops.doctor.binaryanalysis");
// Assert
plugin.Version.Should().NotBeNull();
plugin.Version.Major.Should().BeGreaterThanOrEqualTo(1);
plugin.MinEngineVersion.Should().NotBeNull();
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Plugins\StellaOps.Doctor.Plugin.BinaryAnalysis\StellaOps.Doctor.Plugin.BinaryAnalysis.csproj" />
</ItemGroup>
</Project>