checker-ldap/checker/report.go

548 lines
18 KiB
Go

package checker
import (
"encoding/json"
"fmt"
"html/template"
"net"
"sort"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type reportFix struct {
Severity string
Code string
Message string
Fix string
Endpoint string
}
type reportEndpoint struct {
EndpointProbe
ModeLabel string
// TLS posture (from a related tls_probes observation, when available).
TLSPosture *reportTLSPosture
// Rendering helpers.
AnyFail bool
StatusLabel string
StatusClass string
}
type reportTLSPosture struct {
CheckedAt time.Time
ChainValid *bool
HostnameMatch *bool
NotAfter time.Time
}
type reportSRVEntry struct {
Prefix string
Target string
Port uint16
Priority uint16
Weight uint16
IPv4 []string
IPv6 []string
}
type reportData struct {
Domain string
BaseDN string
RunAt string
StatusLabel string
StatusClass string
HasIssues bool
Fixes []reportFix
SRV []reportSRVEntry
FallbackProbed bool
Endpoints []reportEndpoint
HasIPv4 bool
HasIPv6 bool
EncryptedReachable bool
PlainOnlyReachable bool
BindTested bool
HasTLSPosture bool
}
var reportTpl = template.Must(template.New("ldap").Funcs(template.FuncMap{
"hasPrefix": strings.HasPrefix,
"deref": func(b *bool) bool { return b != nil && *b },
"join": func(sep string, list []string) string { return strings.Join(list, sep) },
"upper": strings.ToUpper,
}).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LDAP Report -- {{.Domain}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd, .section, details {
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.warn { background: #fef3c7; color: #92400e; }
.fail { background: #fee2e2; color: #991b1b; }
.muted { background: #e5e7eb; color: #374151; }
.info { background: #dbeafe; color: #1e40af; }
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
details[open] > summary::before { transform: rotate(90deg); }
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
.fix {
border-left: 3px solid #dc2626;
padding: .5rem .75rem; margin-bottom: .5rem;
background: #fef2f2; border-radius: 0 6px 6px 0;
}
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
.fix.info { border-color: #3b82f6; background: #eff6ff; }
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
.fix .how { font-size: .88rem; }
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
.chip {
display: inline-block; padding: .12em .5em;
background: #e0e7ff; color: #3730a3;
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
}
.chip.plain { background: #fee2e2; color: #991b1b; }
.chip.scram { background: #d1fae5; color: #065f46; }
.chip.strong { background: #d1fae5; color: #065f46; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
.kv dt { color: #6b7280; }
.kv dd { margin: 0; }
.note { color: #6b7280; font-size: .85rem; }
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
.check-warn { color: #b45309; }
</style>
</head>
<body>
<div class="hd">
<h1>LDAP -- <code>{{.Domain}}</code></h1>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
<div class="meta">
{{if .EncryptedReachable}}<span class="badge ok" style="margin-right:.25rem">encrypted OK</span>{{else}}<span class="badge fail" style="margin-right:.25rem">no encryption</span>{{end}}
{{if .HasIPv4}}<span class="badge muted">IPv4</span>{{end}}
{{if .HasIPv6}}<span class="badge muted">IPv6</span>{{end}}
{{if .BindTested}}<span class="badge info" style="margin-left:.25rem">bind test</span>{{end}}
</div>
<div class="meta">Checked {{.RunAt}}{{if .BaseDN}} · base <code>{{.BaseDN}}</code>{{end}}</div>
</div>
{{if .HasIssues}}
<div class="section">
<h2>What to fix</h2>
{{range .Fixes}}
<div class="fix {{.Severity}}">
<div class="code">{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
<div class="section">
<h2>DNS / SRV</h2>
{{if .FallbackProbed}}
<p class="note">No SRV records published -- fell back to probing the bare domain on default ports 389 / 636.</p>
{{else if .SRV}}
<table>
<tr><th>Record</th><th>Target</th><th>Port</th><th>Prio/Weight</th><th>IPv4</th><th>IPv6</th></tr>
{{range .SRV}}
<tr>
<td><code>{{.Prefix}}</code></td>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Priority}}/{{.Weight}}</td>
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
</tr>
{{end}}
</table>
{{else}}
<p class="note">No SRV records found.</p>
{{end}}
</div>
{{if .Endpoints}}
<div class="section">
<h2>Endpoints ({{len .Endpoints}})</h2>
{{range .Endpoints}}
<details{{if .AnyFail}} open{{end}}>
<summary>
<span class="conn-addr">{{.ModeLabel}} · {{.Address}}</span>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
</summary>
<div class="details-body">
<dl class="kv">
{{if .SRVPrefix}}<dt>SRV</dt><dd><code>{{.SRVPrefix}}</code> &rarr; <code>{{.Target}}:{{.Port}}</code></dd>{{end}}
<dt>Family</dt><dd>{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}</dd>
<dt>TCP</dt><dd>{{if .TCPConnected}}<span class="check-ok">&#10003; connected</span>{{else}}<span class="check-fail">&#10007; failed</span>{{end}}</dd>
{{if eq .Mode "ldap"}}
<dt>StartTLS</dt><dd>
{{if .StartTLSOffered}}<span class="check-ok">&#10003; offered</span>{{else}}<span class="check-fail">&#10007; not offered</span>{{end}}
{{if .StartTLSUpgraded}} &middot; <span class="check-ok">upgraded</span>{{else if .StartTLSOffered}} &middot; <span class="check-fail">upgrade failed</span>{{end}}
</dd>
<dt>Cleartext bind</dt><dd>
{{if not .PlaintextBindTested}}<span class="note">not tested</span>
{{else if .PlaintextBindAccepted}}<span class="check-fail">&#10007; accepted (insecure)</span>
{{else}}<span class="check-ok">&#10003; refused (confidentiality required)</span>{{end}}
</dd>
{{end}}
<dt>TLS</dt><dd>
{{if .TLSEstablished}}<span class="check-ok">&#10003; {{.TLSVersion}}{{if .TLSCipher}} &mdash; {{.TLSCipher}}{{end}}</span>
{{else}}<span class="check-fail">&#10007; plaintext</span>{{end}}
</dd>
<dt>RootDSE</dt><dd>
{{if .RootDSERead}}<span class="check-ok">&#10003; read</span>
{{else}}<span class="check-warn">&#10007; unreadable</span>{{end}}
{{if .VendorName}} &middot; <code>{{.VendorName}}{{if .VendorVersion}} {{.VendorVersion}}{{end}}</code>{{end}}
{{if .SupportedLDAPVersion}} &middot; LDAPv{{join "," .SupportedLDAPVersion}}{{end}}
</dd>
{{if .NamingContexts}}
<dt>Naming contexts</dt><dd>
<div class="chiprow">{{range .NamingContexts}}<span class="chip"><code>{{.}}</code></span>{{end}}</div>
</dd>
{{end}}
{{if .SupportedSASLMechanisms}}
<dt>SASL</dt><dd>
<div class="chiprow">
{{range .SupportedSASLMechanisms}}
{{$u := upper .}}
{{if or (eq $u "PLAIN") (eq $u "LOGIN")}}<span class="chip plain">{{.}}</span>
{{else if or (hasPrefix $u "SCRAM-") (eq $u "EXTERNAL") (eq $u "GSSAPI") (eq $u "GSS-SPNEGO")}}<span class="chip strong">{{.}}</span>
{{else}}<span class="chip">{{.}}</span>{{end}}
{{end}}
</div>
</dd>
{{end}}
<dt>Anonymous</dt><dd>
{{if .AnonymousBindAllowed}}<span class="check-warn">bind allowed</span>
{{else}}<span class="check-ok">&#10003; bind refused</span>{{end}}
{{if .AnonymousSearchAllowed}} &middot; <span class="check-fail">search allowed (DIT enumerable)</span>{{end}}
</dd>
{{if .BindAttempted}}
<dt>Bind as DN</dt><dd>
{{if .BindOK}}<span class="check-ok">&#10003; succeeded</span>
{{else}}<span class="check-fail">&#10007; {{.BindError}}</span>{{end}}
</dd>
{{end}}
{{if .BaseReadAttempted}}
<dt>Base read</dt><dd>
{{if .BaseReadOK}}<span class="check-ok">&#10003; {{.BaseReadEntries}} entry/entries</span>
{{else}}<span class="check-fail">&#10007; {{.BaseReadError}}</span>{{end}}
</dd>
{{end}}
{{with .TLSPosture}}
<dt>TLS cert</dt><dd>
{{if .ChainValid}}
{{if deref .ChainValid}}<span class="check-ok">&#10003; chain valid</span>{{else}}<span class="check-fail">&#10007; chain invalid</span>{{end}}
{{end}}
{{if .HostnameMatch}}
&middot; {{if deref .HostnameMatch}}<span class="check-ok">&#10003; hostname match</span>{{else}}<span class="check-fail">&#10007; hostname mismatch</span>{{end}}
{{end}}
{{if not .NotAfter.IsZero}}
&middot; expires <code>{{.NotAfter.Format "2006-01-02"}}</code>
{{end}}
{{if not .CheckedAt.IsZero}}
<div class="note">TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}</div>
{{end}}
</dd>
{{end}}
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
</dl>
</div>
</details>
{{end}}
</div>
{{end}}
<p class="footer">{{if .HasTLSPosture}}Certificate posture above comes from the TLS checker, which probed the same endpoints after we discovered them.{{else}}For certificate chain, SAN match, expiry, and cipher posture, run the TLS checker on the same ports.{{end}}</p>
</body>
</html>`))
// GetHTMLReport implements sdk.CheckerHTMLReporter. It folds in related TLS
// observations so the LDAP service page shows cert posture directly, and
// uses ReportContext.States() as the sole source of hint/fix/severity text:
// when no states are threaded through, the page renders a data-only view of
// the raw observation without any derived judgment.
func (p *ldapProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
var d LDAPData
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
return "", fmt.Errorf("unmarshal ldap observation: %w", err)
}
view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States())
return renderReport(view)
}
func renderReport(view reportData) (string, error) {
var buf strings.Builder
if err := reportTpl.Execute(&buf, view); err != nil {
return "", fmt.Errorf("render ldap report: %w", err)
}
return buf.String(), nil
}
func buildReportData(d *LDAPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData {
tlsByAddr := foldTLSRelated(related)
// Coverage is a pure raw-view aggregation over endpoint facts (counts and
// booleans, no severity). It feeds the IPv4/IPv6 badges and the
// "encrypted OK" vs "no encryption" hint in the page header.
cov := coverageView(d)
view := reportData{
Domain: d.Domain,
BaseDN: d.BaseDN,
RunAt: d.RunAt,
FallbackProbed: d.SRV.FallbackProbed,
HasIPv4: cov.HasIPv4,
HasIPv6: cov.HasIPv6,
EncryptedReachable: cov.EncryptedReachable,
PlainOnlyReachable: cov.PlainOnlyReachable,
BindTested: d.BindTested,
HasTLSPosture: len(tlsByAddr) > 0,
}
// Hint / fix / severity text is populated *only* from rule output threaded
// through ReportContext.States(). When the host has not piped Evaluate →
// Report, the "What to fix" section is omitted entirely and the page
// falls back to a raw data-only view of the observation.
applyFixes(&view, fixesFromStates(states))
// SRV rows.
addSRV := func(prefix string, records []SRVRecord) {
for _, r := range records {
view.SRV = append(view.SRV, reportSRVEntry{
Prefix: prefix, Target: r.Target, Port: r.Port,
Priority: r.Priority, Weight: r.Weight,
IPv4: r.IPv4, IPv6: r.IPv6,
})
}
}
addSRV("_ldap._tcp", d.SRV.LDAP)
addSRV("_ldaps._tcp", d.SRV.LDAPS)
// Endpoint rows.
for _, ep := range d.Endpoints {
re := reportEndpoint{
EndpointProbe: ep,
ModeLabel: modeLabel(ep.Mode),
}
if meta, hit := tlsByAddr[ep.Address]; hit {
re.TLSPosture = meta
} else if meta, hit := tlsByAddr[net.JoinHostPort(ep.Target, strconv.FormatUint(uint64(ep.Port), 10))]; hit {
re.TLSPosture = meta
}
ok := ep.TCPConnected && ep.TLSEstablished
if ep.Mode == ModePlain {
ok = ok && ep.StartTLSUpgraded
}
re.AnyFail = !ok
if ok {
re.StatusLabel = "OK"
re.StatusClass = "ok"
} else if ep.TCPConnected {
re.StatusLabel = "partial"
re.StatusClass = "warn"
} else {
re.StatusLabel = "unreachable"
re.StatusClass = "fail"
}
view.Endpoints = append(view.Endpoints, re)
}
return view
}
// fixesFromStates converts rule-evaluator CheckStates into reportFix entries,
// dropping StatusOK / StatusUnknown (they are not findings to display in the
// "What to fix" list). The fix hint, when present, is read from Meta["fix"]
// using the convention set by issueToState in rules.go.
func fixesFromStates(states []sdk.CheckState) []reportFix {
out := make([]reportFix, 0, len(states))
for _, st := range states {
if st.Status == sdk.StatusOK || st.Status == sdk.StatusUnknown {
continue
}
fix, _ := st.Meta["fix"].(string)
out = append(out, reportFix{
Severity: severityFromStatus(st.Status),
Code: st.Code,
Message: st.Message,
Fix: fix,
Endpoint: st.Subject,
})
}
return out
}
func severityFromStatus(s sdk.Status) string {
switch s {
case sdk.StatusCrit, sdk.StatusError:
return SeverityCrit
case sdk.StatusWarn:
return SeverityWarn
default:
return SeverityInfo
}
}
// applyFixes sorts (crit → warn → info) and stamps the page-level status
// banner from the worst severity present.
func applyFixes(view *reportData, fixes []reportFix) {
view.HasIssues = len(fixes) > 0
sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) })
view.Fixes = fixes
if len(fixes) == 0 {
// No rule output threaded through: render a neutral data-only banner
// (no severity text derived from raw facts).
view.StatusLabel = "report"
view.StatusClass = "muted"
return
}
switch fixes[0].Severity {
case SeverityCrit:
view.StatusLabel = "FAIL"
view.StatusClass = "fail"
case SeverityWarn:
view.StatusLabel = "WARN"
view.StatusClass = "warn"
default:
view.StatusLabel = "INFO"
view.StatusClass = "info"
}
}
func sevRank(s string) int {
switch s {
case SeverityCrit:
return 0
case SeverityWarn:
return 1
default:
return 2
}
}
func modeLabel(m LDAPMode) string {
switch m {
case ModePlain:
return "ldap"
case ModeLDAPS:
return "ldaps"
default:
return string(m)
}
}
// foldTLSRelated builds a per-address posture map from downstream TLS
// observations. This is pure raw-view data (flags and timestamps) for the
// endpoint table; TLS severity text comes in via ReportContext.States()
// from the tls_quality rule, not from this helper.
func foldTLSRelated(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
byAddr := map[string]*reportTLSPosture{}
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
if addr := v.address(); addr != "" {
byAddr[addr] = &reportTLSPosture{
CheckedAt: r.CollectedAt,
ChainValid: v.ChainValid,
HostnameMatch: v.HostnameMatch,
NotAfter: v.NotAfter,
}
}
}
return byAddr
}
// coverageView aggregates raw per-endpoint booleans into the header-level
// reachability summary. It is pure data reshaping, no severity, no fix
// strings, and lives here because report.go is its only caller.
func coverageView(data *LDAPData) ReachabilitySpan {
var cov ReachabilitySpan
anyEncrypted := false
anyPlain := false
for _, ep := range data.Endpoints {
if !ep.TCPConnected {
continue
}
if ep.IsIPv6 {
cov.HasIPv6 = true
} else {
cov.HasIPv4 = true
}
if ep.TLSEstablished {
anyEncrypted = true
} else {
anyPlain = true
}
}
cov.EncryptedReachable = anyEncrypted
cov.PlainOnlyReachable = anyPlain && !anyEncrypted
return cov
}