sprints work.
This commit is contained in:
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user