checker-tls/checker/rules_certificate.go

233 lines
6.2 KiB
Go

package checker
import (
"context"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// peerCertificateRule flags successful handshakes in which the server sent
// no certificate. This is distinct from chain validity: if no cert was sent,
// hostname/chain/expiry cannot be evaluated.
type peerCertificateRule struct{}
func (r *peerCertificateRule) Name() string { return "tls.peer_certificate_present" }
func (r *peerCertificateRule) Description() string {
return "Verifies the server presented a certificate during the TLS handshake."
}
func (r *peerCertificateRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.peer_certificate_present.no_endpoints")}
}
var out []sdk.CheckState
anyHandshake := false
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if !p.TLSHandshakeOK {
continue
}
anyHandshake = true
if !p.NoPeerCert {
continue
}
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "tls.peer_certificate_present.missing",
Subject: subjectOf(p),
Message: fmt.Sprintf("Server on %s completed the handshake but presented no certificate.", p.Endpoint),
Meta: metaOf(p),
})
}
if !anyHandshake {
return []sdk.CheckState{unknownState(
"tls.peer_certificate_present.skipped",
"No endpoint completed a TLS handshake.",
)}
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.peer_certificate_present.ok",
"Every endpoint presented a certificate.",
)}
}
return out
}
// chainValidityRule flags invalid certificate chains.
type chainValidityRule struct{}
func (r *chainValidityRule) Name() string { return "tls.chain_validity" }
func (r *chainValidityRule) Description() string {
return "Verifies the presented certificate chain validates against the system trust store."
}
func (r *chainValidityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.chain_validity.no_endpoints")}
}
var out []sdk.CheckState
any := false
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.ChainValid == nil {
continue
}
any = true
if *p.ChainValid {
continue
}
msg := "Invalid certificate chain"
if p.ChainVerifyErr != "" {
msg = "Invalid certificate chain: " + p.ChainVerifyErr
}
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "tls.chain_validity.invalid",
Subject: subjectOf(p),
Message: msg,
Meta: metaOf(p),
})
}
if !any {
return []sdk.CheckState{unknownState(
"tls.chain_validity.skipped",
"No endpoint yielded a certificate chain to verify.",
)}
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.chain_validity.ok",
"Every presented chain validates against the system trust store.",
)}
}
return out
}
// hostnameMatchRule flags endpoints whose leaf cert does not cover the SNI
// the probe used.
type hostnameMatchRule struct{}
func (r *hostnameMatchRule) Name() string { return "tls.hostname_match" }
func (r *hostnameMatchRule) Description() string {
return "Verifies the leaf certificate covers the probed hostname (SNI)."
}
func (r *hostnameMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.hostname_match.no_endpoints")}
}
var out []sdk.CheckState
any := false
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.HostnameMatch == nil {
continue
}
any = true
if *p.HostnameMatch {
continue
}
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "tls.hostname_match.mismatch",
Subject: subjectOf(p),
Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", p.SNI, strings.Join(p.DNSNames, ", ")),
Meta: metaOf(p),
})
}
if !any {
return []sdk.CheckState{unknownState(
"tls.hostname_match.skipped",
"No endpoint yielded a certificate to hostname-match.",
)}
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.hostname_match.ok",
"Every certificate covers its probed SNI.",
)}
}
return out
}
// expiryRule flags expired or near-expiry certificates.
type expiryRule struct{}
func (r *expiryRule) Name() string { return "tls.expiry" }
func (r *expiryRule) Description() string {
return "Flags expired or soon-to-expire leaf certificates."
}
func (r *expiryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.expiry.no_endpoints")}
}
now := time.Now()
var out []sdk.CheckState
any := false
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.NotAfter.IsZero() {
continue
}
any = true
meta := metaOf(p)
meta["not_after"] = p.NotAfter
if p.NotAfter.Before(now) {
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "tls.expiry.expired",
Subject: subjectOf(p),
Message: "Certificate expired on " + p.NotAfter.Format(time.RFC3339),
Meta: meta,
})
continue
}
if p.NotAfter.Sub(now) < ExpiringSoonThreshold {
out = append(out, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "tls.expiry.expiring_soon",
Subject: subjectOf(p),
Message: "Certificate expires in less than 14 days (" + p.NotAfter.Format(time.RFC3339) + ")",
Meta: meta,
})
}
}
if !any {
return []sdk.CheckState{unknownState(
"tls.expiry.skipped",
"No endpoint yielded a certificate with an expiry to check.",
)}
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.expiry.ok",
"Every leaf certificate is valid for more than 14 days.",
)}
}
return out
}