package checker import ( "context" "crypto/tls" "fmt" "sort" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // tlsVersionRule flags endpoints negotiating a protocol version below the // recommended TLS 1.2 floor. type tlsVersionRule struct{} func (r *tlsVersionRule) Name() string { return "tls.version" } func (r *tlsVersionRule) Description() string { return "Flags endpoints negotiating a TLS version below the recommended TLS 1.2." } func (r *tlsVersionRule) 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.version.no_endpoints")} } var out []sdk.CheckState any := false for _, ref := range sortedRefs(data) { p := data.Probes[ref] if p.TLSVersionNum == 0 { continue } any = true if p.TLSVersionNum >= tls.VersionTLS12 { continue } out = append(out, sdk.CheckState{ Status: sdk.StatusWarn, Code: "tls.version.weak", Subject: subjectOf(p), Message: fmt.Sprintf("Negotiated TLS version %s is below the recommended TLS 1.2.", p.TLSVersion), Meta: metaOf(p), }) } if !any { return []sdk.CheckState{unknownState( "tls.version.skipped", "No endpoint completed a TLS handshake.", )} } if len(out) == 0 { return []sdk.CheckState{passState( "tls.version.ok", "Every endpoint negotiates TLS 1.2 or higher.", )} } return out } // cipherSuiteRule reports the negotiated cipher suite for visibility. // It does not currently classify suites as weak/strong: go's crypto/tls // refuses to negotiate the known-weak suites anyway. The rule exists so the // UI can expose the suite in the passing-list rather than leaving it buried // in the raw observation. type cipherSuiteRule struct{} func (r *cipherSuiteRule) Name() string { return "tls.cipher_suite" } func (r *cipherSuiteRule) Description() string { return "Reports the cipher suite negotiated on each endpoint." } func (r *cipherSuiteRule) 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.cipher_suite.no_endpoints")} } // Collapse per-endpoint cipher suites into a single info state. One // row per endpoint drowns out actionable rules in the UI on domains // with many endpoints; an aggregated list is enough for visibility. suites := map[string]int{} endpoints := map[string][]string{} for _, ref := range sortedRefs(data) { p := data.Probes[ref] if p.CipherSuite == "" { continue } suites[p.CipherSuite]++ endpoints[p.CipherSuite] = append(endpoints[p.CipherSuite], p.Endpoint) } if len(suites) == 0 { return []sdk.CheckState{unknownState( "tls.cipher_suite.skipped", "No endpoint completed a TLS handshake.", )} } names := make([]string, 0, len(suites)) for s := range suites { names = append(names, s) } sort.Strings(names) parts := make([]string, 0, len(names)) for _, n := range names { parts = append(parts, fmt.Sprintf("%s (%d)", n, suites[n])) } return []sdk.CheckState{{ Status: sdk.StatusInfo, Code: "tls.cipher_suite.negotiated", Message: "Negotiated cipher suites: " + strings.Join(parts, ", "), Meta: map[string]any{"suites": endpoints}, }} }