diff --git a/checker/report.go b/checker/report.go
index aaca430..1afea88 100644
--- a/checker/report.go
+++ b/checker/report.go
@@ -180,7 +180,7 @@ func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error)
// Detect common failures and build the remediation banner. Hints are
// only surfaced when the host supplied rule states for this run.
if hasStates {
- rd.Remediations = buildRemediations(&r, rd)
+ rd.Remediations = buildRemediations(&r, rd, states)
}
var buf strings.Builder
@@ -190,20 +190,21 @@ func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error)
return buf.String(), nil
}
-// buildRemediations inspects the observation and returns actionable hints
-// for the user-visible failures. Only matching hints are appended, so a
-// healthy realm shows an empty list (rendered as nothing).
-func buildRemediations(r *KerberosData, rd reportData) []remediation {
- var out []remediation
-
- hasKDCSRV := false
- for _, b := range r.SRV {
- if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
- hasKDCSRV = true
- break
+// buildRemediations returns actionable hints keyed by rule state codes so the
+// report and the rule output never disagree on which conditions are present.
+// Display-only fields (realm name, clock skew string, unresolved host list)
+// are still read from raw data — they are presentation, not verdict.
+func buildRemediations(r *KerberosData, rd reportData, states []sdk.CheckState) []remediation {
+ byCode := make(map[string]sdk.CheckState, len(states))
+ for _, st := range states {
+ if st.Status == sdk.StatusCrit || st.Status == sdk.StatusWarn || st.Status == sdk.StatusError {
+ byCode[st.Code] = st
}
}
- if !hasKDCSRV {
+
+ var out []remediation
+
+ if _, ok := byCode[CodeNoSRV]; ok {
out = append(out, remediation{
Title: "Publish Kerberos SRV records",
Body: template.HTML(fmt.Sprintf(
@@ -213,41 +214,31 @@ _kerberos._udp.%[1]s. IN SRV 0 0 88 kdc.%[1]s.`, strings.ToLower(r.Realm)
})
}
- // SRV targets that don't resolve.
- var unresolved []string
- for _, h := range rd.Resolution {
- if h.Error != "" || (len(h.IPv4) == 0 && len(h.IPv6) == 0) {
- unresolved = append(unresolved, h.Target)
- }
- }
- if len(unresolved) > 0 {
- out = append(out, remediation{
- Title: "Resolve KDC host names",
- Body: template.HTML(fmt.Sprintf(
- `The following SRV target(s) do not resolve to an IP address: %s. Add A/AAAA records for each host, or correct the SRV target.`,
- template.HTMLEscapeString(strings.Join(unresolved, ", ")))),
- })
- }
-
- // No KDC reachable (port filtered / host down).
- reachable := 0
- totalKDC := 0
- for _, p := range r.Probes {
- if p.Role == "kdc" {
- totalKDC++
- if p.OK {
- reachable++
+ if _, ok := byCode[CodeKDCUnreachable]; ok {
+ // Use raw resolution data to show which hosts are unresolved (display only).
+ var unresolved []string
+ for _, h := range rd.Resolution {
+ if h.Error != "" || (len(h.IPv4) == 0 && len(h.IPv6) == 0) {
+ unresolved = append(unresolved, h.Target)
}
}
- }
- if totalKDC > 0 && reachable == 0 {
- out = append(out, remediation{
- Title: "Open port 88 on KDC hosts",
- Body: template.HTML(`Every KDC endpoint refused or timed out. Ensure your firewall allows inbound TCP 88 and UDP 88, and that the KDC process is listening on the SRV target's IP.`),
- })
+ if len(unresolved) > 0 {
+ out = append(out, remediation{
+ Title: "Resolve KDC host names",
+ Body: template.HTML(fmt.Sprintf(
+ `The following SRV target(s) do not resolve to an IP address: %s. Add A/AAAA records for each host, or correct the SRV target.`,
+ template.HTMLEscapeString(strings.Join(unresolved, ", ")))),
+ })
+ } else {
+ out = append(out, remediation{
+ Title: "Open port 88 on KDC hosts",
+ Body: template.HTML(`Every KDC endpoint refused or timed out. Ensure your firewall allows inbound TCP 88 and UDP 88, and that the KDC process is listening on the SRV target's IP.`),
+ })
+ }
}
- // Clock skew.
+ // Clock skew is already driven by CodeClockSkewBad in the caller; pass
+ // the display-enriched flag through rather than re-checking states here.
if rd.ClockSkewBad {
out = append(out, remediation{
Title: "Synchronize clocks",
@@ -257,8 +248,7 @@ _kerberos._udp.%[1]s. IN SRV 0 0 88 kdc.%[1]s.`, strings.ToLower(r.Realm)
})
}
- // Weak crypto only.
- if rd.HasWeakOnly {
+ if _, ok := byCode[CodeEnctypesWeakOnly]; ok {
out = append(out, remediation{
Title: "Retire DES/RC4 enctypes",
Body: template.HTML(`The KDC only advertises weak encryption types. Configure at least AES:
@@ -270,8 +260,7 @@ then rekey principals with kadmin -q "cpw -randkey principal" or eq
})
}
- // Wrong realm in KDC reply.
- if r.AS.ServerRealm != "" && !strings.EqualFold(r.AS.ServerRealm, r.Realm) {
+ if _, ok := byCode[CodeASWrongRealm]; ok {
out = append(out, remediation{
Title: "Fix realm mismatch",
Body: template.HTML(fmt.Sprintf(
@@ -281,8 +270,7 @@ then rekey principals with kadmin -q "cpw -randkey principal" or eq
})
}
- // AS-REP without preauth, AS-REP roasting.
- if r.AS.Attempted && r.AS.PrincipalFound && !r.AS.PreauthReq {
+ if _, ok := byCode[CodeASRepNoPreauth]; ok {
out = append(out, remediation{
Title: "Enable pre-authentication",
Body: template.HTML(`The KDC returned an AS-REP without demanding pre-authentication. This exposes principals to AS-REP roasting. Enable requires_preauth (MIT) or the equivalent flag on every user principal, e.g. kadmin -q "modprinc +requires_preauth user@REALM".`),