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