checker-kerberos/checker/rules.go

594 lines
18 KiB
Go

package checker
import (
"context"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule codes emitted by the kerberos rules. Keep these stable; UI / metrics
// may match on them.
const (
CodeSRVOK = "kerberos.srv.ok"
CodeNoSRV = "kerberos.srv.missing"
CodeKDCReachableOK = "kerberos.kdc.reachable"
CodeKDCUnreachable = "kerberos.kdc.unreachable"
CodeKDCPartial = "kerberos.kdc.partial"
CodeASProbeOK = "kerberos.as.ok"
CodeASProbeFailed = "kerberos.as.failed"
CodeASWrongRealm = "kerberos.as.wrong_realm"
CodeASRepNoPreauth = "kerberos.as.no_preauth"
CodeClockSkewOK = "kerberos.clock_skew.ok"
CodeClockSkewBad = "kerberos.clock_skew.bad"
CodeEnctypesStrong = "kerberos.enctypes.strong"
CodeEnctypesWeakOnly = "kerberos.enctypes.weak_only"
CodeEnctypesMixed = "kerberos.enctypes.mixed"
CodeEnctypesUnknown = "kerberos.enctypes.unknown"
CodeKadminDown = "kerberos.kadmin.unreachable"
CodeKadminOK = "kerberos.kadmin.ok"
CodeKpasswdDown = "kerberos.kpasswd.unreachable"
CodeKpasswdOK = "kerberos.kpasswd.ok"
CodeAuthSkipped = "kerberos.auth.skipped"
CodeAuthTGTOK = "kerberos.auth.tgt_ok"
CodeAuthTGTFail = "kerberos.auth.tgt_fail"
CodeAuthTGSOK = "kerberos.auth.tgs_ok"
CodeAuthTGSFail = "kerberos.auth.tgs_fail"
CodeAuthTGSSkipped = "kerberos.auth.tgs_skipped"
)
// Rules returns the full list of CheckRules exposed by the Kerberos checker.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&srvPresenceRule{},
&kdcReachabilityRule{},
&asProbeRule{},
&realmMatchRule{},
&preauthRule{},
&clockSkewRule{},
&enctypesRule{},
&kadminRule{},
&kpasswdRule{},
&authTGTRule{},
&authTGSRule{},
}
}
// loadData fetches the Kerberos observation. On error, returns a CheckState
// the caller should emit to short-circuit its rule.
func loadData(ctx context.Context, obs sdk.ObservationGetter) (*KerberosData, *sdk.CheckState) {
var data KerberosData
if err := obs.Get(ctx, ObservationKeyKerberos, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load Kerberos observation: %v", err),
Code: "kerberos.observation_error",
}
}
return &data, nil
}
// ── SRV presence ─────────────────────────────────────────────────────────────
type srvPresenceRule struct{}
func (r *srvPresenceRule) Name() string { return "kerberos.srv_present" }
func (r *srvPresenceRule) Description() string {
return "Verifies that at least one _kerberos._tcp / _kerberos._udp SRV record is published for the realm."
}
func (r *srvPresenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
for _, b := range data.SRV {
if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: "Kerberos SRV records are published.",
Code: CodeSRVOK,
}}
}
}
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("No _kerberos SRV records found for %s", data.Realm),
Code: CodeNoSRV,
}}
}
// ── KDC reachability ─────────────────────────────────────────────────────────
type kdcReachabilityRule struct{}
func (r *kdcReachabilityRule) Name() string { return "kerberos.kdc_reachable" }
func (r *kdcReachabilityRule) Description() string {
return "Verifies that at least one KDC endpoint (TCP/UDP 88) accepts a connection."
}
func (r *kdcReachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
total, reachable := 0, 0
for _, p := range data.Probes {
if p.Role != "kdc" {
continue
}
total++
if p.OK {
reachable++
}
}
if total == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "No KDC probe was attempted (no SRV target).",
Code: CodeKDCUnreachable,
}}
}
if reachable == 0 {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "No KDC is reachable on TCP 88 or UDP 88.",
Code: CodeKDCUnreachable,
Meta: map[string]any{"total": total},
}}
}
if reachable < total {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("%d/%d KDC endpoints unreachable.", total-reachable, total),
Code: CodeKDCPartial,
Meta: map[string]any{"reachable": reachable, "total": total},
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("All %d KDC endpoints reachable.", total),
Code: CodeKDCReachableOK,
}}
}
// ── AS-REQ probe sanity ──────────────────────────────────────────────────────
type asProbeRule struct{}
func (r *asProbeRule) Name() string { return "kerberos.as_probe" }
func (r *asProbeRule) Description() string {
return "Verifies that the anonymous AS-REQ probe received a sane reply (KRB-ERROR or AS-REP)."
}
func (r *asProbeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.AS.Attempted {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "AS-REQ probe not attempted.",
Code: CodeASProbeFailed,
}}
}
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: CodeASProbeFailed,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("KDC replied to AS-REQ (%s).", firstNonEmpty(data.AS.ErrorName, "AS-REP")),
Code: CodeASProbeOK,
}}
}
// ── Realm echoed in KDC reply ────────────────────────────────────────────────
type realmMatchRule struct{}
func (r *realmMatchRule) Name() string { return "kerberos.realm_match" }
func (r *realmMatchRule) Description() string {
return "Verifies the KDC answers for the expected realm name."
}
func (r *realmMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.AS.ServerRealm == "" {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "KDC did not echo a realm (probe may have failed).",
Code: CodeASWrongRealm,
}}
}
if !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: CodeASWrongRealm,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: "KDC echoed the expected realm.",
Code: "kerberos.realm_match.ok",
}}
}
// ── AS-REP without preauth (AS-REP roasting exposure) ───────────────────────
type preauthRule struct{}
func (r *preauthRule) Name() string { return "kerberos.preauth_required" }
func (r *preauthRule) Description() string {
return "Flags KDCs that return an AS-REP without requiring pre-authentication (AS-REP roasting exposure)."
}
func (r *preauthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.AS.Attempted {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "AS-REQ probe not attempted; preauth posture unknown.",
Code: CodeASRepNoPreauth,
}}
}
if data.AS.PrincipalFound && !data.AS.PreauthReq {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: "AS-REP returned without preauth (AS-REP roasting exposure).",
Code: CodeASRepNoPreauth,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: "Pre-authentication is enforced (or no AS-REP issued).",
Code: "kerberos.preauth_required.ok",
}}
}
// ── Clock skew ───────────────────────────────────────────────────────────────
type clockSkewRule struct{}
func (r *clockSkewRule) Name() string { return "kerberos.clock_skew" }
func (r *clockSkewRule) Description() string {
return "Verifies the KDC clock is within tolerance of the checker's clock."
}
func (r *clockSkewRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.AS.ServerTime.IsZero() {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "KDC did not return a server time (probe may have failed).",
Code: CodeClockSkewBad,
}}
}
maxSkew := time.Duration(optFloat(opts, "maxClockSkew", 300) * float64(time.Second))
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: CodeClockSkewBad,
Meta: map[string]any{"skew_ns": data.AS.ClockSkew.Nanoseconds()},
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("Clock skew within tolerance (%s).", round(data.AS.ClockSkew)),
Code: CodeClockSkewOK,
Meta: map[string]any{"skew_ns": data.AS.ClockSkew.Nanoseconds()},
}}
}
// ── Enctypes offered ─────────────────────────────────────────────────────────
type enctypesRule struct{}
func (r *enctypesRule) Name() string { return "kerberos.enctypes" }
func (r *enctypesRule) Description() string {
return "Reviews the encryption types advertised by the KDC, flagging DES/RC4-only configurations."
}
func (r *enctypesRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Enctypes) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "KDC did not advertise enctypes (no ETYPE-INFO2 seen).",
Code: CodeEnctypesUnknown,
}}
}
hasStrong, hasWeak := false, false
var names []string
for _, e := range data.Enctypes {
names = append(names, e.Name)
if e.Weak {
hasWeak = true
} else {
hasStrong = true
}
}
requireStrong := optBool(opts, "requireStrongEnctypes", true)
if !hasStrong {
status := sdk.StatusWarn
if requireStrong {
status = sdk.StatusCrit
}
return []sdk.CheckState{{
Status: status,
Subject: data.Realm,
Message: "KDC only advertises weak enctypes (DES/RC4).",
Code: CodeEnctypesWeakOnly,
Meta: map[string]any{"enctypes": names},
}}
}
if hasWeak {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: "KDC advertises weak enctypes alongside strong ones.",
Code: CodeEnctypesMixed,
Meta: map[string]any{"enctypes": names},
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: "KDC advertises only strong enctypes.",
Code: CodeEnctypesStrong,
Meta: map[string]any{"enctypes": names},
}}
}
// ── kadmin reachability ──────────────────────────────────────────────────────
type kadminRule struct{}
func (r *kadminRule) Name() string { return "kerberos.kadmin_reachable" }
func (r *kadminRule) Description() string {
return "Flags kadmin endpoints that are published via SRV but not reachable."
}
func (r *kadminRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
return roleReachability(data, "kadmin", "kadmin server", CodeKadminOK, CodeKadminDown)
}
// ── kpasswd reachability ─────────────────────────────────────────────────────
type kpasswdRule struct{}
func (r *kpasswdRule) Name() string { return "kerberos.kpasswd_reachable" }
func (r *kpasswdRule) Description() string {
return "Flags kpasswd endpoints that are published via SRV but not reachable."
}
func (r *kpasswdRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
return roleReachability(data, "kpasswd", "kpasswd", CodeKpasswdOK, CodeKpasswdDown)
}
func roleReachability(data *KerberosData, role, label, okCode, downCode string) []sdk.CheckState {
total, reachable := 0, 0
for _, p := range data.Probes {
if p.Role != role {
continue
}
total++
if p.OK {
reachable++
}
}
if total == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: fmt.Sprintf("No %s SRV endpoint published.", label),
Code: okCode,
}}
}
if reachable == 0 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("%s unreachable.", label),
Code: downCode,
}}
}
if reachable < total {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("%s: %d/%d endpoints unreachable.", label, total-reachable, total),
Code: downCode,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("%s reachable.", label),
Code: okCode,
}}
}
// ── Authenticated probe: TGT acquisition ─────────────────────────────────────
type authTGTRule struct{}
func (r *authTGTRule) Name() string { return "kerberos.auth_tgt" }
func (r *authTGTRule) Description() string {
return "Verifies the supplied principal/password can obtain a TGT (only runs when credentials are supplied)."
}
func (r *authTGTRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.Auth == nil || !data.Auth.Attempted {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "Authenticated probe not attempted (no principal/password supplied).",
Code: CodeAuthSkipped,
}}
}
if !data.Auth.TGTAcquired {
msg := "Authenticated probe: TGT acquisition failed."
if data.Auth.Error != "" {
msg = "Authenticated probe: " + data.Auth.Error
}
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: msg,
Code: CodeAuthTGTFail,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: "TGT acquired for supplied principal.",
Code: CodeAuthTGTOK,
}}
}
// ── Authenticated probe: TGS request ─────────────────────────────────────────
type authTGSRule struct{}
func (r *authTGSRule) Name() string { return "kerberos.auth_tgs" }
func (r *authTGSRule) Description() string {
return "Verifies a TGS-REQ succeeds for the supplied target service (only runs when credentials and targetService are supplied)."
}
func (r *authTGSRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.Auth == nil || !data.Auth.Attempted {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "TGS probe not attempted (no credentials supplied).",
Code: CodeAuthTGSSkipped,
}}
}
if data.Auth.TargetService == "" {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "TGS probe skipped (no targetService supplied).",
Code: CodeAuthTGSSkipped,
}}
}
if !data.Auth.TGTAcquired {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "TGS probe skipped: TGT not acquired.",
Code: CodeAuthTGSSkipped,
}}
}
if !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: CodeAuthTGSFail,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("TGS-REQ for %s succeeded.", data.Auth.TargetService),
Code: CodeAuthTGSOK,
}}
}
// ── helpers ──────────────────────────────────────────────────────────────────
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:
switch strings.ToLower(strings.TrimSpace(x)) {
case "true", "1", "yes", "y", "on":
return true
case "false", "0", "no", "n", "off":
return false
}
}
return def
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}