checker-kerberos/checker/rule.go

224 lines
5.6 KiB
Go

package checker
import (
"context"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns the kerberos_health rule.
func Rule() sdk.CheckRule {
return &kerberosRule{}
}
type kerberosRule struct{}
func (r *kerberosRule) Name() string {
return "kerberos_health"
}
func (r *kerberosRule) Description() string {
return "Checks whether a Kerberos realm answers correctly, advertises strong crypto, and exposes no obvious misconfiguration."
}
func (r *kerberosRule) ValidateOptions(opts sdk.CheckerOptions) error { return nil }
func (r *kerberosRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data KerberosData
if err := obs.Get(ctx, ObservationKeyKerberos, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to get Kerberos data: %v", err),
Code: "kerberos_error",
}}
}
maxSkew := time.Duration(optFloat(opts, "maxClockSkew", 300)) * time.Second
requireStrong := optBool(opts, "requireStrongEnctypes", true)
// Presence of at least one _kerberos._tcp or ._udp record is mandatory.
hasKDCSRV := false
for _, b := range data.SRV {
if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
hasKDCSRV = true
break
}
}
if !hasKDCSRV {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("No _kerberos SRV records found for %s", data.Realm),
Code: "kerberos_no_srv",
}}
}
// KDC reachability: need at least one successful probe among KDC roles.
reachable := 0
kdcProbes := 0
kadminDown, kpasswdDown := false, false
for _, p := range data.Probes {
switch p.Role {
case "kdc":
kdcProbes++
if p.OK {
reachable++
}
case "kadmin":
if !p.OK {
kadminDown = true
}
case "kpasswd":
if !p.OK {
kpasswdDown = true
}
}
}
if reachable == 0 {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "No KDC is reachable on TCP 88 or UDP 88",
Code: "kerberos_kdc_unreachable",
}}
}
// AS-REQ result.
if data.AS.Attempted {
if data.AS.Error != "" && data.AS.ErrorCode == 0 {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "AS-REQ probe failed: " + data.AS.Error,
Code: "kerberos_error",
}}
}
if data.AS.ServerRealm != "" && !strings.EqualFold(data.AS.ServerRealm, data.Realm) {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("KDC replied for realm %q, expected %q", data.AS.ServerRealm, data.Realm),
Code: "kerberos_wrong_realm",
}}
}
if abs(data.AS.ClockSkew) > maxSkew {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("Clock skew with KDC is %s (max %s)", round(data.AS.ClockSkew), maxSkew),
Code: "kerberos_clock_skew",
Meta: map[string]any{
"skew_ns": data.AS.ClockSkew.Nanoseconds(),
},
}}
}
}
// Crypto posture.
hasStrong := false
for _, e := range data.Enctypes {
if !e.Weak {
hasStrong = true
break
}
}
if requireStrong && len(data.Enctypes) > 0 && !hasStrong {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "KDC only advertises weak enctypes (DES/RC4)",
Code: "kerberos_weak_crypto",
}}
}
// Auth probe (if any).
if data.Auth != nil && data.Auth.Attempted {
if !data.Auth.TGTAcquired {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "Authenticated probe: TGT acquisition failed",
Code: "kerberos_auth_fail",
}}
}
if data.Auth.TargetService != "" && !data.Auth.TGSAcquired {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("TGT OK but TGS-REQ for %s failed", data.Auth.TargetService),
Code: "kerberos_tgs_fail",
}}
}
}
// Warnings: partial reachability, no UDP, mixed crypto, no preauth.
var warnings []string
if reachable < kdcProbes {
warnings = append(warnings, fmt.Sprintf("%d/%d KDC endpoints unreachable", kdcProbes-reachable, kdcProbes))
}
if len(data.WeakEnctypes) > 0 && hasStrong {
warnings = append(warnings, "KDC also advertises weak enctypes alongside strong ones")
}
if data.AS.Attempted && data.AS.PrincipalFound && !data.AS.PreauthReq {
warnings = append(warnings, "AS-REP returned without preauth (AS-REP roasting exposure)")
}
if kadminDown {
warnings = append(warnings, "kadmin server unreachable")
}
if kpasswdDown {
warnings = append(warnings, "kpasswd unreachable")
}
if len(warnings) > 0 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("Realm %s reachable: %s", data.Realm, strings.Join(warnings, "; ")),
Code: "kerberos_warn",
Meta: map[string]any{
"reachable_kdcs": reachable,
"warnings": warnings,
},
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("Realm %s healthy (%d KDC reachable, strong crypto)", data.Realm, reachable),
Code: "kerberos_ok",
Meta: map[string]any{
"realm": data.Realm,
"reachable_kdcs": reachable,
"clock_skew_ns": data.AS.ClockSkew.Nanoseconds(),
},
}}
}
func abs(d time.Duration) time.Duration {
if d < 0 {
return -d
}
return d
}
func round(d time.Duration) time.Duration {
return d.Round(time.Millisecond)
}
func optBool(opts sdk.CheckerOptions, key string, def bool) bool {
v, ok := opts[key]
if !ok {
return def
}
switch x := v.(type) {
case bool:
return x
case string:
return x == "true" || x == "1"
}
return def
}