224 lines
5.6 KiB
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
|
|
}
|