checker-ldap/checker/report.go

577 lines
19 KiB
Go

package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"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 {
Mode string
ModeLabel string
SRVPrefix string
Target string
Port uint16
Address string
IsIPv6 bool
TCPConnected bool
StartTLSOffered bool
StartTLSUpgraded bool
TLSEstablished bool
TLSVersion string
TLSCipher string
RootDSERead bool
SupportedLDAPVersion []string
SupportedSASLMechanisms []string
SupportedControl []string
SupportedExtension []string
NamingContexts []string
VendorName string
VendorVersion string
AnonymousBindAllowed bool
AnonymousSearchAllowed bool
PlaintextBindTested bool
PlaintextBindAccepted bool
BindAttempted bool
BindOK bool
BindError string
BaseReadAttempted bool
BaseReadOK bool
BaseReadEntries int
BaseReadError string
ElapsedMS int64
Error 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
Issues []reportFix
}
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}}
{{range .Issues}}
<div class="fix {{.Severity}}" style="margin-top:.3rem">
<div class="code">{{.Code}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</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.
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))
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) reportData {
tlsIssues := tlsIssuesFromRelated(related)
tlsByAddr := indexTLSByAddress(related)
allIssues := append([]Issue(nil), d.Issues...)
allIssues = append(allIssues, tlsIssues...)
view := reportData{
Domain: d.Domain,
BaseDN: d.BaseDN,
RunAt: d.RunAt,
FallbackProbed: d.SRV.FallbackProbed,
HasIPv4: d.Coverage.HasIPv4,
HasIPv6: d.Coverage.HasIPv6,
EncryptedReachable: d.Coverage.EncryptedReachable,
PlainOnlyReachable: d.Coverage.PlainOnlyReachable,
BindTested: d.BindTested,
HasIssues: len(allIssues) > 0,
HasTLSPosture: len(tlsByAddr) > 0,
}
// Status banner.
worst := ""
for _, is := range allIssues {
if is.Severity == SeverityCrit {
worst = SeverityCrit
break
}
if is.Severity == SeverityWarn {
worst = SeverityWarn
} else if worst == "" && is.Severity == SeverityInfo {
worst = SeverityInfo
}
}
if len(allIssues) == 0 {
view.StatusLabel = "OK"
view.StatusClass = "ok"
} else {
switch worst {
case SeverityCrit:
view.StatusLabel = "FAIL"
view.StatusClass = "fail"
case SeverityWarn:
view.StatusLabel = "WARN"
view.StatusClass = "warn"
default:
view.StatusLabel = "INFO"
view.StatusClass = "info"
}
}
// Fix list: sort crit → warn → info, preserving order within each severity.
sevRank := func(s string) int {
switch s {
case SeverityCrit:
return 0
case SeverityWarn:
return 1
default:
return 2
}
}
sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) })
for _, is := range allIssues {
view.Fixes = append(view.Fixes, reportFix{
Severity: is.Severity,
Code: is.Code,
Message: is.Message,
Fix: is.Fix,
Endpoint: is.Endpoint,
})
}
// 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{
Mode: string(ep.Mode),
ModeLabel: modeLabel(ep.Mode),
SRVPrefix: ep.SRVPrefix,
Target: ep.Target,
Port: ep.Port,
Address: ep.Address,
IsIPv6: ep.IsIPv6,
TCPConnected: ep.TCPConnected,
StartTLSOffered: ep.StartTLSOffered,
StartTLSUpgraded: ep.StartTLSUpgraded,
TLSEstablished: ep.TLSEstablished,
TLSVersion: ep.TLSVersion,
TLSCipher: ep.TLSCipher,
RootDSERead: ep.RootDSERead,
SupportedLDAPVersion: ep.SupportedLDAPVersion,
SupportedSASLMechanisms: ep.SupportedSASLMechanisms,
SupportedControl: ep.SupportedControl,
SupportedExtension: ep.SupportedExtension,
NamingContexts: ep.NamingContexts,
VendorName: ep.VendorName,
VendorVersion: ep.VendorVersion,
AnonymousBindAllowed: ep.AnonymousBindAllowed,
AnonymousSearchAllowed: ep.AnonymousSearchAllowed,
PlaintextBindTested: ep.PlaintextBindTested,
PlaintextBindAccepted: ep.PlaintextBindAccepted,
BindAttempted: ep.BindAttempted,
BindOK: ep.BindOK,
BindError: ep.BindError,
BaseReadAttempted: ep.BaseReadAttempted,
BaseReadOK: ep.BaseReadOK,
BaseReadEntries: ep.BaseReadEntries,
BaseReadError: ep.BaseReadError,
ElapsedMS: ep.ElapsedMS,
Error: ep.Error,
}
if meta, hit := tlsByAddr[ep.Address]; hit {
re.TLSPosture = meta
} else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; 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
}
func modeLabel(m LDAPMode) string {
switch m {
case ModePlain:
return "ldap"
case ModeLDAPS:
return "ldaps"
default:
return string(m)
}
}
// indexTLSByAddress returns a map keyed by "host:port" pointing at a
// reportTLSPosture. This lets the template match a related observation to
// the right endpoint.
func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
out := map[string]*reportTLSPosture{}
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
addr := v.address()
if addr == "" {
continue
}
posture := &reportTLSPosture{
CheckedAt: r.CollectedAt,
ChainValid: v.ChainValid,
HostnameMatch: v.HostnameMatch,
NotAfter: v.NotAfter,
}
for _, is := range v.Issues {
sev := strings.ToLower(is.Severity)
if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo {
continue
}
posture.Issues = append(posture.Issues, reportFix{
Severity: sev,
Code: is.Code,
Message: is.Message,
Fix: is.Fix,
})
}
out[addr] = posture
}
return out
}