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".`),