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 "" }