using Microsoft.Extensions.Configuration; using StellaOps.Doctor.Models; using StellaOps.Doctor.Plugins; using System.Globalization; using System.Security.Cryptography.X509Certificates; namespace StellaOps.Doctor.Plugins.Security.Checks; /// /// Validates TLS certificate configuration and expiration. /// public sealed class TlsCertificateCheck : IDoctorCheck { /// public string CheckId => "check.security.tls.certificate"; /// public string Name => "TLS Certificate"; /// public string Description => "Validates TLS certificate validity and expiration"; /// public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail; /// public IReadOnlyList Tags => ["security", "tls", "certificate"]; /// public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5); /// public bool CanRun(DoctorPluginContext context) { var certPath = context.Configuration.GetValue("Tls:CertificatePath") ?? context.Configuration.GetValue("Kestrel:Certificates:Default:Path"); return !string.IsNullOrWhiteSpace(certPath); } /// public Task RunAsync(DoctorPluginContext context, CancellationToken ct) { var result = context.CreateResult(CheckId, "stellaops.doctor.security", DoctorCategory.Security.ToString()); var certPath = context.Configuration.GetValue("Tls:CertificatePath") ?? context.Configuration.GetValue("Kestrel:Certificates:Default:Path"); if (string.IsNullOrWhiteSpace(certPath)) { return Task.FromResult(result .Skip("TLS certificate path not configured") .WithEvidence("Configuration", e => e.Add("CertificatePath", "(not set)")) .Build()); } if (!File.Exists(certPath)) { return Task.FromResult(result .Fail($"TLS certificate file not found: {certPath}") .WithEvidence("TLS configuration", e => { e.Add("CertificatePath", certPath); e.Add("FileExists", "false"); }) .WithCauses("Certificate file path is incorrect", "Certificate file was deleted") .WithRemediation(r => r .AddManualStep(1, "Verify path", "Check Tls:CertificatePath configuration") .AddManualStep(2, "Generate certificate", "Generate or obtain a valid TLS certificate") .WithRunbookUrl("docs/doctor/articles/security/security-tls-certificate.md")) .WithVerification("stella doctor --check check.security.tls.certificate") .Build()); } try { var certPassword = context.Configuration.GetValue("Tls:CertificatePassword") ?? context.Configuration.GetValue("Kestrel:Certificates:Default:Password"); using var cert = string.IsNullOrEmpty(certPassword) ? X509CertificateLoader.LoadCertificateFromFile(certPath) : X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword); var now = context.TimeProvider.GetUtcNow(); var daysUntilExpiry = (cert.NotAfter - now.DateTime).TotalDays; if (now.DateTime < cert.NotBefore) { return Task.FromResult(result .Fail("TLS certificate is not yet valid") .WithEvidence("TLS certificate", e => { e.Add("Subject", cert.Subject); e.Add("Issuer", cert.Issuer); e.Add("NotBefore", cert.NotBefore.ToString("O", CultureInfo.InvariantCulture)); e.Add("NotAfter", cert.NotAfter.ToString("O", CultureInfo.InvariantCulture)); }) .WithCauses("Certificate validity period has not started") .Build()); } if (now.DateTime > cert.NotAfter) { return Task.FromResult(result .Fail("TLS certificate has expired") .WithEvidence("TLS certificate", e => { e.Add("Subject", cert.Subject); e.Add("Issuer", cert.Issuer); e.Add("ExpiredOn", cert.NotAfter.ToString("O", CultureInfo.InvariantCulture)); e.Add("DaysExpired", Math.Abs(daysUntilExpiry).ToString("F0", CultureInfo.InvariantCulture)); }) .WithCauses("Certificate has exceeded its validity period") .WithRemediation(r => r .AddManualStep(1, "Renew certificate", "Obtain a new TLS certificate") .AddManualStep(2, "Update configuration", "Update Tls:CertificatePath with new certificate") .WithRunbookUrl("docs/doctor/articles/security/security-tls-certificate.md")) .WithVerification("stella doctor --check check.security.tls.certificate") .Build()); } if (daysUntilExpiry < 30) { return Task.FromResult(result .Warn($"TLS certificate expires in {daysUntilExpiry:F0} days") .WithEvidence("TLS certificate", e => { e.Add("Subject", cert.Subject); e.Add("Issuer", cert.Issuer); e.Add("NotAfter", cert.NotAfter.ToString("O", CultureInfo.InvariantCulture)); e.Add("DaysUntilExpiry", daysUntilExpiry.ToString("F0", CultureInfo.InvariantCulture)); }) .WithCauses("Certificate is approaching expiration") .WithRemediation(r => r .AddManualStep(1, "Plan renewal", "Schedule certificate renewal before expiration") .WithRunbookUrl("docs/doctor/articles/security/security-tls-certificate.md")) .Build()); } return Task.FromResult(result .Pass($"TLS certificate valid for {daysUntilExpiry:F0} days") .WithEvidence("TLS certificate", e => { e.Add("Subject", cert.Subject); e.Add("Issuer", cert.Issuer); e.Add("NotBefore", cert.NotBefore.ToString("O", CultureInfo.InvariantCulture)); e.Add("NotAfter", cert.NotAfter.ToString("O", CultureInfo.InvariantCulture)); e.Add("DaysUntilExpiry", daysUntilExpiry.ToString("F0", CultureInfo.InvariantCulture)); e.Add("Thumbprint", cert.Thumbprint); }) .Build()); } catch (Exception ex) { return Task.FromResult(result .Fail($"Failed to load TLS certificate: {ex.Message}") .WithEvidence("TLS configuration", e => { e.Add("CertificatePath", certPath); e.Add("ErrorType", ex.GetType().Name); e.Add("Error", ex.Message); }) .WithCauses( "Certificate file is corrupted", "Certificate password is incorrect", "Certificate format not supported") .Build()); } } }