Initial commit

This commit is contained in:
nemunaire 2026-04-24 12:56:46 +07:00
commit beca2fd7eb
21 changed files with 2698 additions and 0 deletions

485
checker/collect.go Normal file
View file

@ -0,0 +1,485 @@
package checker
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"slices"
"strconv"
"strings"
"sync"
"time"
ldapv3 "github.com/go-ldap/ldap/v3"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// tlsProbeConfig builds a permissive *tls.Config for probing: hostname
// verification is skipped because cert validation is the TLS checker's
// job. We only care that a TLS session can be established at all.
func tlsProbeConfig(serverName string) *tls.Config {
return &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true, //nolint:gosec -- cert validation is the TLS checker's job
MinVersion: tls.VersionTLS10,
}
}
// Collect runs the full LDAP probe for a domain.
func (p *ldapProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain")
domain = strings.TrimSuffix(domain, ".")
if domain == "" {
return nil, fmt.Errorf("domain is required")
}
bindDN, _ := sdk.GetOption[string](opts, "bind_dn")
bindPassword, _ := sdk.GetOption[string](opts, "bind_password")
baseDN, _ := sdk.GetOption[string](opts, "base_dn")
timeoutSecs := sdk.GetFloatOption(opts, "timeout", 10)
if timeoutSecs < 1 {
timeoutSecs = 10
}
perEndpoint := time.Duration(timeoutSecs * float64(time.Second))
data := &LDAPData{
Domain: domain,
BaseDN: baseDN,
RunAt: time.Now().UTC().Format(time.RFC3339),
SRV: SRVLookup{Errors: map[string]string{}},
}
resolver := net.DefaultResolver
var srvWG sync.WaitGroup
var srvErrMu sync.Mutex
srvWG.Add(2)
lookup := func(prefix string, dst *[]SRVRecord) {
defer srvWG.Done()
records, err := lookupSRV(ctx, resolver, prefix, domain)
if err != nil {
srvErrMu.Lock()
data.SRV.Errors[prefix] = err.Error()
srvErrMu.Unlock()
return
}
*dst = records
}
go lookup("_ldap._tcp.", &data.SRV.LDAP)
go lookup("_ldaps._tcp.", &data.SRV.LDAPS)
srvWG.Wait()
totalSRV := len(data.SRV.LDAP) + len(data.SRV.LDAPS)
if totalSRV == 0 {
data.SRV.FallbackProbed = true
data.SRV.LDAP = []SRVRecord{{Target: domain, Port: 389}}
data.SRV.LDAPS = []SRVRecord{{Target: domain, Port: 636}}
}
resolveAll(ctx, resolver, data.SRV.LDAP, data.SRV.LDAPS)
data.BindTested = bindDN != "" && bindPassword != ""
var plainEndpoints, ldapsEndpoints []EndpointProbe
var probeWG sync.WaitGroup
probeWG.Add(2)
go func() {
defer probeWG.Done()
plainEndpoints = probeSet(ctx, domain, ModePlain, "_ldap._tcp", data.SRV.LDAP, perEndpoint, bindDN, bindPassword, baseDN)
}()
go func() {
defer probeWG.Done()
ldapsEndpoints = probeSet(ctx, domain, ModeLDAPS, "_ldaps._tcp", data.SRV.LDAPS, perEndpoint, bindDN, bindPassword, baseDN)
}()
probeWG.Wait()
data.Endpoints = append(plainEndpoints, ldapsEndpoints...)
return data, nil
}
func probeSet(ctx context.Context, domain string, mode LDAPMode, prefix string, records []SRVRecord, timeout time.Duration, bindDN, bindPassword, baseDN string) []EndpointProbe {
type task struct {
rec SRVRecord
addr *probeAddr // nil means the SRV target has no A/AAAA records
}
var tasks []task
for _, rec := range records {
addrs := addressesForProbe(rec)
if len(addrs) == 0 {
tasks = append(tasks, task{rec: rec})
continue
}
for _, a := range addrs {
tasks = append(tasks, task{rec: rec, addr: &a})
}
}
results := make([]EndpointProbe, len(tasks))
var wg sync.WaitGroup
for i, t := range tasks {
wg.Add(1)
go func(i int, t task) {
defer wg.Done()
if t.addr == nil {
results[i] = EndpointProbe{
Mode: mode,
SRVPrefix: prefix,
Target: t.rec.Target,
Port: t.rec.Port,
Error: "no A/AAAA records for target",
}
return
}
results[i] = probeEndpoint(ctx, domain, mode, prefix, t.rec, t.addr.ip, t.addr.isV6, timeout, bindDN, bindPassword, baseDN)
}(i, t)
}
wg.Wait()
return results
}
type probeAddr struct {
ip string
isV6 bool
}
func addressesForProbe(rec SRVRecord) []probeAddr {
var out []probeAddr
for _, ip := range rec.IPv4 {
out = append(out, probeAddr{ip: ip, isV6: false})
}
for _, ip := range rec.IPv6 {
out = append(out, probeAddr{ip: ip, isV6: true})
}
return out
}
// probeEndpoint runs the full probe on a single (host, ip, port) tuple:
// TCP connect → optional StartTLS or direct-TLS handshake → RootDSE read →
// plaintext-bind posture check (only on LDAP:389 before TLS) → optional
// authenticated bind + base-DN read.
func probeEndpoint(ctx context.Context, domain string, mode LDAPMode, prefix string, rec SRVRecord, ip string, isV6 bool, timeout time.Duration, bindDN, bindPassword, baseDN string) EndpointProbe {
start := time.Now()
result := EndpointProbe{
Mode: mode,
SRVPrefix: prefix,
Target: rec.Target,
Port: rec.Port,
Address: net.JoinHostPort(ip, strconv.Itoa(int(rec.Port))),
IsIPv6: isV6,
}
defer func() { result.ElapsedMS = time.Since(start).Milliseconds() }()
dialCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
dialer := &net.Dialer{}
rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address)
if err != nil {
result.Error = "tcp: " + err.Error()
return result
}
result.TCPConnected = true
defer rawConn.Close()
// renewDeadline keeps the underlying TCP deadline rolling per major
// step so a slow TLS handshake doesn't starve the RootDSE / bind /
// base-read calls that follow.
renewDeadline := func() { _ = rawConn.SetDeadline(time.Now().Add(timeout)) }
renewDeadline()
// For the plaintext-bind posture check on Mode=ldap, we first spin up
// a separate short-lived connection before upgrading this one to TLS.
// A single raw connection can't both "test cleartext bind refusal" and
// "then StartTLS" cleanly -- once we've bound we'd skew RootDSE results.
if mode == ModePlain {
result.PlaintextBindTested, result.PlaintextBindAccepted = probePlaintextBindRefusal(result.Address, timeout)
}
// Establish the LDAP session we'll use for the rest of the probe.
var conn *ldapv3.Conn
if mode == ModeLDAPS {
tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
if err := tlsConn.Handshake(); err != nil {
result.Error = "tls-handshake: " + err.Error()
return result
}
result.TLSEstablished = true
state := tlsConn.ConnectionState()
result.TLSVersion = tls.VersionName(state.Version)
result.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
renewDeadline()
conn = ldapv3.NewConn(tlsConn, true)
} else {
conn = ldapv3.NewConn(rawConn, false)
}
conn.Start()
defer conn.Close()
conn.SetTimeout(timeout)
// Try RootDSE over the native transport first -- works on LDAPS straight
// away, and on LDAP it reveals the supported extensions including
// StartTLS capability before we attempt the upgrade.
renewDeadline()
readRootDSE(conn, &result)
if mode == ModePlain {
// Detect StartTLS advertisement in supportedExtension. RFC 4511
// says the RootDSE MAY advertise "1.3.6.1.4.1.1466.20037".
offersStartTLS := stringListContainsFold(result.SupportedExtension, "1.3.6.1.4.1.1466.20037")
if offersStartTLS {
result.StartTLSOffered = true
if err := conn.StartTLS(tlsProbeConfig(domain)); err != nil {
result.Error = "starttls: " + err.Error()
} else {
result.StartTLSUpgraded = true
result.TLSEstablished = true
renewDeadline()
// go-ldap doesn't expose the *tls.ConnectionState directly.
// Fall back to inspecting the underlying conn via TLSConnectionState.
if state, ok := conn.TLSConnectionState(); ok {
result.TLSVersion = tls.VersionName(state.Version)
result.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
}
// Post-TLS SASL refresh: some servers only publish the
// strong mechanisms once the channel is encrypted. We only
// need the mechanisms list -- naming contexts, LDAP
// version and vendor strings don't change.
refreshSASLMechanisms(conn, &result)
}
} else if !result.RootDSERead {
result.Error = "rootdse-unreadable: RootDSE could not be read"
}
}
// Anonymous bind + search -- we try unconditionally so we can flag
// exposure.
renewDeadline()
anonBindOK := conn.Bind("", "") == nil
result.AnonymousBindAllowed = anonBindOK
if anonBindOK && len(result.NamingContexts) > 0 {
// baseObject search returns 0 or 1 entries -- we only want to
// detect whether an anonymous query can peek at DIT contents.
sr, err := conn.Search(ldapv3.NewSearchRequest(
result.NamingContexts[0],
ldapv3.ScopeBaseObject,
ldapv3.NeverDerefAliases,
1, int(timeout.Seconds()), false,
"(objectClass=*)",
[]string{"1.1"}, // request no attributes
nil,
))
if err == nil && sr != nil && len(sr.Entries) > 0 {
result.AnonymousSearchAllowed = true
}
}
// Authenticated bind + base DN read (only when caller provided creds
// AND we are on an encrypted channel -- never ship a password over
// cleartext).
if bindDN != "" && bindPassword != "" && result.TLSEstablished {
result.BindAttempted = true
renewDeadline()
err := conn.Bind(bindDN, bindPassword)
if err == nil {
result.BindOK = true
if baseDN != "" {
result.BaseReadAttempted = true
renewDeadline()
sr, err := conn.Search(ldapv3.NewSearchRequest(
baseDN,
ldapv3.ScopeBaseObject,
ldapv3.NeverDerefAliases,
1, int(timeout.Seconds()), false,
"(objectClass=*)",
[]string{"1.1"},
nil,
))
if err != nil {
result.BaseReadError = err.Error()
} else if sr != nil {
result.BaseReadOK = true
result.BaseReadEntries = len(sr.Entries)
}
}
} else {
result.BindError = err.Error()
}
}
return result
}
// probePlaintextBindRefusal opens a short-lived, fresh TCP connection and
// attempts a simple bind with a fixed, syntactically-safe DN over cleartext.
// We are not probing credentials -- we want to learn whether the server
// refuses authentication on an unprotected link (RFC 4513 §5.1.2 calls this
// "confidentialityRequired" / resultCode 13). Any response other than
// resultCode 13 means the server will accept cleartext bind attempts.
func probePlaintextBindRefusal(address string, timeout time.Duration) (tested, accepted bool) {
dialer := &net.Dialer{Timeout: timeout}
raw, err := dialer.Dial("tcp", address)
if err != nil {
return false, false
}
defer raw.Close()
_ = raw.SetDeadline(time.Now().Add(timeout))
conn := ldapv3.NewConn(raw, false)
conn.Start()
defer conn.Close()
conn.SetTimeout(timeout)
tested = true
// Fixed probe DN -- caller-supplied domain is not interpolated to avoid
// LDAP DN injection. The server is expected to reject this DN regardless
// of value; we only care whether it returns confidentialityRequired (13)
// or attempts the bind anyway.
err = conn.Bind("cn=checker-probe", "x-not-a-real-password-x")
if err == nil {
return tested, true
}
// resultCode 13 (confidentialityRequired) is the only response that
// means the server actively refused to authenticate over cleartext.
// Anything else (49 invalidCredentials, 32 noSuchObject, …) means the
// server was willing to attempt the bind, which is the insecure
// posture we want to flag.
var lerr *ldapv3.Error
if errors.As(err, &lerr) && lerr.ResultCode == ldapv3.LDAPResultConfidentialityRequired {
return tested, false
}
return tested, true
}
// readRootDSE performs a single RootDSE lookup and fills the matching
// fields on ep. Failures are not fatal -- many hardened servers refuse
// anonymous RootDSE reads; we just note that we couldn't read it.
func readRootDSE(conn *ldapv3.Conn, ep *EndpointProbe) {
sr, err := conn.Search(ldapv3.NewSearchRequest(
"",
ldapv3.ScopeBaseObject,
ldapv3.NeverDerefAliases,
1, 5, false,
"(objectClass=*)",
[]string{
"supportedLDAPVersion",
"supportedSASLMechanisms",
"supportedControl",
"supportedExtension",
"namingContexts",
"vendorName",
"vendorVersion",
},
nil,
))
if err != nil || sr == nil || len(sr.Entries) == 0 {
return
}
ep.RootDSERead = true
e := sr.Entries[0]
ep.SupportedLDAPVersion = unique(append(ep.SupportedLDAPVersion, e.GetAttributeValues("supportedLDAPVersion")...))
ep.SupportedSASLMechanisms = unique(append(ep.SupportedSASLMechanisms, e.GetAttributeValues("supportedSASLMechanisms")...))
ep.SupportedControl = unique(append(ep.SupportedControl, e.GetAttributeValues("supportedControl")...))
ep.SupportedExtension = unique(append(ep.SupportedExtension, e.GetAttributeValues("supportedExtension")...))
ep.NamingContexts = unique(append(ep.NamingContexts, e.GetAttributeValues("namingContexts")...))
if v := e.GetAttributeValue("vendorName"); v != "" {
ep.VendorName = v
}
if v := e.GetAttributeValue("vendorVersion"); v != "" {
ep.VendorVersion = v
}
}
// refreshSASLMechanisms re-queries the RootDSE after StartTLS to pick up any
// SASL mechanisms the server only advertises over an encrypted channel.
func refreshSASLMechanisms(conn *ldapv3.Conn, ep *EndpointProbe) {
sr, err := conn.Search(ldapv3.NewSearchRequest(
"",
ldapv3.ScopeBaseObject,
ldapv3.NeverDerefAliases,
1, 5, false,
"(objectClass=*)",
[]string{"supportedSASLMechanisms"},
nil,
))
if err != nil || sr == nil || len(sr.Entries) == 0 {
return
}
ep.SupportedSASLMechanisms = unique(append(ep.SupportedSASLMechanisms, sr.Entries[0].GetAttributeValues("supportedSASLMechanisms")...))
}
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
name := prefix + dns.Fqdn(domain)
_, records, err := r.LookupSRV(ctx, "", "", name)
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return nil, nil
}
return nil, err
}
// RFC 2782: single record "." with port 0 means "service explicitly not
// available at this domain". Treat that as "no records" for probing.
if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 {
return nil, nil
}
out := make([]SRVRecord, 0, len(records))
for _, r := range records {
out = append(out, SRVRecord{
Target: strings.TrimSuffix(r.Target, "."),
Port: r.Port,
Priority: r.Priority,
Weight: r.Weight,
})
}
return out, nil
}
// resolveAll resolves A/AAAA for every record across all sets concurrently.
// Each goroutine writes only to its own record, so no lock is needed.
func resolveAll(ctx context.Context, r *net.Resolver, sets ...[]SRVRecord) {
var wg sync.WaitGroup
for _, records := range sets {
for i := range records {
wg.Add(1)
go func(rec *SRVRecord) {
defer wg.Done()
ips, err := r.LookupIPAddr(ctx, rec.Target)
if err != nil {
return
}
for _, ip := range ips {
if v4 := ip.IP.To4(); v4 != nil {
rec.IPv4 = append(rec.IPv4, v4.String())
} else {
rec.IPv6 = append(rec.IPv6, ip.IP.String())
}
}
}(&records[i])
}
}
wg.Wait()
}
func stringListContainsFold(list []string, want string) bool {
return slices.ContainsFunc(list, func(s string) bool { return strings.EqualFold(s, want) })
}
func unique(list []string) []string {
if len(list) <= 1 {
return list
}
seen := make(map[string]struct{}, len(list))
out := make([]string, 0, len(list))
for _, s := range list {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}

69
checker/definition.go Normal file
View file

@ -0,0 +1,69 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is reported in CheckerDefinition.Version. Overridden at build time
// by main / plugin.
var Version = "built-in"
func (p *ldapProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "ldap",
Name: "LDAP Directory",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.LDAP"},
},
HasHTMLReport: true,
ObservationKeys: []sdk.ObservationKey{ObservationKeyLDAP},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain",
Type: "string",
Label: "Domain",
AutoFill: sdk.AutoFillDomainName,
Required: true,
},
{
Id: "timeout",
Type: "number",
Label: "Per-endpoint timeout (seconds)",
Default: 10,
},
{
Id: "bind_dn",
Type: "string",
Label: "Bind DN",
Placeholder: "cn=reader,dc=example,dc=com",
Description: "Optional. When set (with bind_password), the checker performs an authenticated bind over TLS and reports whether the directory accepts the provided credentials.",
},
{
Id: "bind_password",
Type: "string",
Label: "Bind password",
Secret: true,
Description: "Optional. Only used when bind_dn is set. The password is not persisted in the observation payload.",
},
{
Id: "base_dn",
Type: "string",
Label: "Base DN (read test)",
Placeholder: "dc=example,dc=com",
Description: "Optional. When set, the checker runs a baseObject search on this DN after a successful bind to verify the account has read access. Falls back to an anonymous baseObject search when no bind DN is supplied.",
},
},
},
Rules: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 12 * time.Hour,
},
}
}

47
checker/interactive.go Normal file
View file

@ -0,0 +1,47 @@
//go:build standalone
package checker
import (
"errors"
"net/http"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm implements server.Interactive. The option set is the same one
// /evaluate documents, so we reuse it directly (AutoFill hints are ignored
// by the interactive HTML form, where the human types the value in).
func (p *ldapProvider) RenderForm() []sdk.CheckerOptionField {
return p.Definition().Options.RunOpts
}
// ParseForm implements server.Interactive. Collect handles its own SRV
// and A/AAAA lookups, so the form only needs to forward the user-supplied
// values -- no extra host-side resolution is required here.
func (p *ldapProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain"))
if domain == "" {
return nil, errors.New("domain is required")
}
opts := sdk.CheckerOptions{"domain": domain}
if v := strings.TrimSpace(r.FormValue("timeout")); v != "" {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return nil, errors.New("timeout must be a number")
}
opts["timeout"] = f
}
if v := strings.TrimSpace(r.FormValue("bind_dn")); v != "" {
opts["bind_dn"] = v
}
if v := r.FormValue("bind_password"); v != "" {
opts["bind_password"] = v
}
if v := strings.TrimSpace(r.FormValue("base_dn")); v != "" {
opts["base_dn"] = v
}
return opts, nil
}

63
checker/provider.go Normal file
View file

@ -0,0 +1,63 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
)
func Provider() sdk.ObservationProvider {
return &ldapProvider{}
}
type ldapProvider struct{}
func (p *ldapProvider) Key() sdk.ObservationKey {
return ObservationKeyLDAP
}
// DiscoverEntries implements sdk.DiscoveryPublisher.
//
// It publishes TLS endpoint contract entries for every SRV target we found,
// so a downstream TLS checker can verify the certificate chain / SAN /
// expiry on each one without re-doing the SRV lookup. The LDAP checker
// itself only confirms a TLS session can be established -- certificate
// posture lives in the TLS checker.
//
// SNI is set to the bare domain, which is usually the hostname clients
// connect to. On _ldaps._tcp we emit direct-TLS endpoints (STARTTLS="").
// On _ldap._tcp we emit STARTTLS="ldap" so the TLS checker performs an
// RFC 2830 extended-op upgrade before the handshake. RequireSTARTTLS is
// set to true on 389: a misconfigured server that drops StartTLS must
// show up as a CRIT on the TLS side, not a WARN.
func (p *ldapProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*LDAPData)
if !ok || d == nil {
return nil, nil
}
var out []sdk.DiscoveryEntry
emit := func(recs []SRVRecord, starttls string, require bool) error {
for _, r := range recs {
ep := tlsct.TLSEndpoint{
Host: r.Target,
Port: r.Port,
SNI: d.Domain,
STARTTLS: starttls,
RequireSTARTTLS: require,
}
entry, err := tlsct.NewEntry(ep)
if err != nil {
return err
}
out = append(out, entry)
}
return nil
}
if err := emit(d.SRV.LDAP, "ldap", true); err != nil {
return nil, err
}
if err := emit(d.SRV.LDAPS, "", false); err != nil {
return nil, err
}
return out, nil
}

548
checker/report.go Normal file
View file

@ -0,0 +1,548 @@
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
}

107
checker/rules.go Normal file
View file

@ -0,0 +1,107 @@
package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the full list of CheckRules exposed by the LDAP checker.
// Each rule covers one concern (SRV discovery, StartTLS posture, anonymous
// access, …) and produces its CheckStates by scanning raw LDAPData fields
// directly -- there is no shared pre-derived Issues slice in between.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&srvDiscoveryRule{},
&endpointReachableRule{},
&encryptedTransportRule{},
&startTLSSupportedRule{},
&ldapsHandshakeRule{},
&startTLSOnLDAPSRule{},
&refusesPlainBindRule{},
&anonymousSearchBlockedRule{},
&rootDSEReadableRule{},
&saslMechanismsRule{},
&protocolVersionRule{},
&ipv6ReachableRule{},
&bindCredentialsRule{},
&baseDNReadRule{},
&tlsQualityRule{},
}
}
// loadLDAPData fetches the LDAP observation. On error, returns a CheckState
// the caller should emit to short-circuit its rule.
func loadLDAPData(ctx context.Context, obs sdk.ObservationGetter) (*LDAPData, *sdk.CheckState) {
var data LDAPData
if err := obs.Get(ctx, ObservationKeyLDAP, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load LDAP observation: %v", err),
Code: "ldap.observation_error",
}
}
return &data, nil
}
// critState builds a StatusCrit state with an optional fix hint encoded in
// Meta["fix"] so report.go's fixesFromStates can surface it.
func critState(code, message, subject, fix string) sdk.CheckState {
return stateWithFix(sdk.StatusCrit, code, message, subject, fix)
}
func warnState(code, message, subject, fix string) sdk.CheckState {
return stateWithFix(sdk.StatusWarn, code, message, subject, fix)
}
func infoState(code, message, subject, fix string) sdk.CheckState {
return stateWithFix(sdk.StatusInfo, code, message, subject, fix)
}
func stateWithFix(status sdk.Status, code, message, subject, fix string) sdk.CheckState {
st := sdk.CheckState{
Status: status,
Message: message,
Code: code,
Subject: subject,
}
if fix != "" {
st.Meta = map[string]any{"fix": fix}
}
return st
}
func passState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusOK,
Message: message,
Code: code,
}
}
func notTestedState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusUnknown,
Message: message,
Code: code,
}
}
func optString(opts sdk.CheckerOptions, key string) string {
v, _ := opts[key].(string)
return strings.TrimSpace(v)
}
// ldapsReachable reports whether at least one LDAPS endpoint accepted TCP.
// Used to soften severity of unreachable plain-LDAP endpoints when modern
// clients would use LDAPS anyway.
func ldapsReachable(data *LDAPData) bool {
for _, ep := range data.Endpoints {
if ep.Mode == ModeLDAPS && ep.TCPConnected {
return true
}
}
return false
}

129
checker/rules_bind.go Normal file
View file

@ -0,0 +1,129 @@
package checker
import (
"context"
"strconv"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules in this file cover the optional bind-credentials workflow (driven
// by the bind_dn / bind_password / base_dn options) plus the cross-checker
// TLS-quality fold. Like the other rules, they read raw EndpointProbe
// fields and downstream observations directly.
// bindCredentialsRule: optional authenticated bind. Skipped when bind_dn
// isn't supplied, and reported as "not tested" when no encrypted endpoint
// was available to attempt it on.
type bindCredentialsRule struct{}
func (r *bindCredentialsRule) Name() string { return "ldap.bind_credentials" }
func (r *bindCredentialsRule) Description() string {
return "Verifies the supplied bind credentials are accepted by the directory (only runs when bind_dn is set)."
}
func (r *bindCredentialsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
if optString(opts, "bind_dn") == "" {
return []sdk.CheckState{notTestedState("ldap.bind_credentials.skipped", "Authenticated bind not tested (no bind_dn supplied).")}
}
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
attempted := false
for _, ep := range data.Endpoints {
if !ep.BindAttempted {
continue
}
attempted = true
if ep.BindOK {
states = append(states, infoState(
CodeBindOK,
"Bind on "+ep.Address+" succeeded with the provided credentials.",
ep.Address,
"",
))
} else {
states = append(states, critState(
CodeBindFailed,
"Bind on "+ep.Address+" failed: "+ep.BindError,
ep.Address,
"Verify the bind DN exists and the password is current. On AD, check the account is not locked/expired.",
))
}
}
if !attempted {
return []sdk.CheckState{notTestedState("ldap.bind_credentials.skipped", "Bind not attempted on any endpoint (no encrypted endpoint reachable).")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.bind_credentials.ok", "Bind succeeded with the provided credentials.")}
}
return states
}
// baseDNReadRule: optional baseObject read on the supplied base_dn.
type baseDNReadRule struct{}
func (r *baseDNReadRule) Name() string { return "ldap.base_dn_read" }
func (r *baseDNReadRule) Description() string {
return "Verifies the bound account can read the supplied base DN (only runs when base_dn is set and bind succeeded)."
}
func (r *baseDNReadRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
if optString(opts, "base_dn") == "" {
return []sdk.CheckState{notTestedState("ldap.base_dn_read.skipped", "Base DN read not tested (no base_dn supplied).")}
}
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
attempted := false
for _, ep := range data.Endpoints {
if !ep.BaseReadAttempted {
continue
}
attempted = true
if ep.BaseReadOK {
states = append(states, infoState(
CodeBaseReadOK,
"Base DN read succeeded on "+ep.Address+" (entries="+strconv.Itoa(ep.BaseReadEntries)+").",
ep.Address,
"",
))
} else {
states = append(states, critState(
CodeBaseReadFailed,
"Bind succeeded but baseObject read on "+data.BaseDN+" failed: "+ep.BaseReadError,
ep.Address,
"Verify the bind DN has read access to the base DN -- typically granted via an ACL entry such as `access to dn.subtree=\"<base>\" by dn.exact=\"<bind>\" read`.",
))
}
}
if !attempted {
return []sdk.CheckState{notTestedState("ldap.base_dn_read.skipped", "Base DN read not attempted (bind did not succeed on any endpoint).")}
}
return states
}
// tlsQualityRule: folds downstream TLS checker findings onto the LDAP
// service. Consumes a related observation (not LDAPData).
type tlsQualityRule struct{}
func (r *tlsQualityRule) Name() string { return "ldap.tls_quality" }
func (r *tlsQualityRule) Description() string {
return "Folds the downstream TLS checker findings (certificate chain, hostname match, expiry) onto the LDAP service."
}
func (r *tlsQualityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
if len(related) == 0 {
return []sdk.CheckState{notTestedState("ldap.tls_quality.skipped", "No related TLS observation available (no TLS checker downstream, or no probe yet).")}
}
states := tlsStatesFromRelated(related)
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.tls_quality.ok", "Downstream TLS checker reports no issues on the LDAP endpoints.")}
}
return states
}

237
checker/rules_posture.go Normal file
View file

@ -0,0 +1,237 @@
package checker
import (
"context"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules in this file cover directory-posture concerns that read off the
// RootDSE or probe outcomes: cleartext-bind refusal, anonymous search
// exposure, RootDSE readability, SASL mechanism inventory, legacy
// LDAPv2 advertisement.
// refusesPlainBindRule: server refuses cleartext bind attempts.
type refusesPlainBindRule struct{}
func (r *refusesPlainBindRule) Name() string { return "ldap.refuses_plain_bind" }
func (r *refusesPlainBindRule) Description() string {
return "Verifies the directory refuses authentication attempts over a cleartext channel."
}
func (r *refusesPlainBindRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.Mode != ModePlain || !ep.TCPConnected {
continue
}
if ep.PlaintextBindTested && ep.PlaintextBindAccepted {
states = append(states, critState(
CodePlainBindAccepted,
"Cleartext bind attempts are accepted on "+ep.Address+" (server does not reply confidentialityRequired).",
ep.Address,
"Require TLS before authentication: set `security simple_bind=<n>` on OpenLDAP (olcSecurity) or enable `require_tls` in 389-ds/Samba AD to force StartTLS before bind.",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.refuses_plain_bind.ok", "Server refuses cleartext bind attempts.")}
}
return states
}
// anonymousSearchBlockedRule: directory rejects anonymous search of the
// naming context (information-disclosure signal).
type anonymousSearchBlockedRule struct{}
func (r *anonymousSearchBlockedRule) Name() string { return "ldap.anonymous_search_blocked" }
func (r *anonymousSearchBlockedRule) Description() string {
return "Flags directories that allow anonymous search of the naming context (information disclosure)."
}
func (r *anonymousSearchBlockedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.AnonymousSearchAllowed {
states = append(states, warnState(
CodeAnonymousSearchAllowed,
"Anonymous search against naming context succeeds on "+ep.Address+" -- DIT contents may be enumerable without credentials.",
ep.Address,
"Restrict anonymous access: `access to * by anonymous auth by users read` (OpenLDAP) or set `nsslapd-allow-anonymous-access: off` (389-ds).",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.anonymous_search_blocked.ok", "Anonymous search against the naming context is blocked.")}
}
return states
}
// rootDSEReadableRule: RootDSE is readable over TLS and advertises a
// naming context. The unreadable finding fires once per TLS-established
// endpoint; the naming-context finding fires once per endpoint that did
// read the RootDSE.
type rootDSEReadableRule struct{}
func (r *rootDSEReadableRule) Name() string { return "ldap.rootdse_readable" }
func (r *rootDSEReadableRule) Description() string {
return "Verifies the RootDSE is readable over TLS and advertises naming contexts."
}
func (r *rootDSEReadableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.TCPConnected && ep.TLSEstablished && !ep.RootDSERead {
states = append(states, warnState(
CodeRootDSEUnreadable,
"RootDSE is not readable on "+ep.Address+" -- capability discovery is unavailable.",
ep.Address,
"Grant anonymous read on the empty base DN for schema introspection (`access to dn.base=\"\" by * read` on OpenLDAP), or expect reduced visibility of supported mechanisms.",
))
}
if ep.RootDSERead && len(ep.NamingContexts) == 0 {
states = append(states, infoState(
CodeNoNamingContext,
"RootDSE does not advertise any naming context on "+ep.Address+".",
ep.Address,
"If this server is authoritative, populate the namingContexts attribute so clients can locate the DIT root.",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.rootdse_readable.ok", "RootDSE is readable and advertises naming contexts.")}
}
return states
}
// saslMechanismsRule: supportedSASLMechanisms posture review.
type saslMechanismsRule struct{}
func (r *saslMechanismsRule) Name() string { return "ldap.sasl_mechanisms" }
func (r *saslMechanismsRule) Description() string {
return "Reviews the supportedSASLMechanisms posture (presence of strong mechanisms, absence of password-equivalent ones)."
}
func (r *saslMechanismsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
sawSASL := false
sawStrong := false
sawPlainOnly := false
for _, ep := range data.Endpoints {
if len(ep.SupportedSASLMechanisms) == 0 {
continue
}
sawSASL = true
hasPlain := false
hasStrong := false
for _, m := range ep.SupportedSASLMechanisms {
u := strings.ToUpper(m)
switch u {
case "PLAIN", "LOGIN":
hasPlain = true
case "EXTERNAL", "GSSAPI", "GSS-SPNEGO":
hasStrong = true
default:
if strings.HasPrefix(u, "SCRAM-") {
hasStrong = true
}
}
}
if hasStrong {
sawStrong = true
}
if hasPlain && !hasStrong {
sawPlainOnly = true
}
}
var states []sdk.CheckState
if sawSASL {
if !sawStrong {
states = append(states, warnState(
CodeSASLNoStrongMech,
"No strong SASL mechanism (SCRAM-*, EXTERNAL, GSSAPI) is advertised.",
"",
"Enable SCRAM-SHA-256 or GSSAPI where your identity platform allows it.",
))
}
if sawPlainOnly {
states = append(states, warnState(
CodeSASLPlainOnly,
"Only PLAIN/LOGIN SASL mechanisms are offered.",
"",
"Add SCRAM-SHA-256 so password hashes aren't sent as password-equivalent tokens.",
))
}
} else if len(data.Endpoints) > 0 {
states = append(states, infoState(
CodeNoSASL,
"No supportedSASLMechanisms advertised by the directory.",
"",
"If SASL is in use, ensure the server publishes supportedSASLMechanisms in the RootDSE. Otherwise only simple binds are available.",
))
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.sasl_mechanisms.ok", "SASL posture is sound (a strong mechanism is advertised).")}
}
return states
}
// protocolVersionRule: flags servers that still advertise LDAPv2.
type protocolVersionRule struct{}
func (r *protocolVersionRule) Name() string { return "ldap.protocol_version" }
func (r *protocolVersionRule) Description() string {
return "Flags servers that still advertise the deprecated LDAPv2 protocol."
}
// endpointAdvertisesLDAPv2 reports whether an endpoint's RootDSE still lists
// the deprecated LDAPv2 protocol in supportedLDAPVersion.
func endpointAdvertisesLDAPv2(ep EndpointProbe) bool {
for _, v := range ep.SupportedLDAPVersion {
if strings.TrimSpace(v) == "2" {
return true
}
}
return false
}
func (r *protocolVersionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if endpointAdvertisesLDAPv2(ep) {
states = append(states, warnState(
CodeLegacyLDAPv2,
"Server still advertises supportedLDAPVersion=2 on "+ep.Address+".",
ep.Address,
"Disable LDAPv2 support -- RFC 3494 deprecated it in 2003. On OpenLDAP, remove `allow bind_v2`.",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.protocol_version.ok", "Server does not advertise the deprecated LDAPv2 protocol.")}
}
return states
}

279
checker/rules_transport.go Normal file
View file

@ -0,0 +1,279 @@
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules in this file cover transport-level concerns: SRV discovery, TCP
// reachability, encryption availability, TLS handshakes. They read raw
// LDAPData fields directly (no pre-derived Issues slice).
// srvDiscoveryRule: _ldap._tcp / _ldaps._tcp SRV publishing + resolution.
type srvDiscoveryRule struct{}
func (r *srvDiscoveryRule) Name() string { return "ldap.has_srv" }
func (r *srvDiscoveryRule) Description() string {
return "Verifies that _ldap._tcp / _ldaps._tcp SRV records are published and resolvable."
}
func (r *srvDiscoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
if data.SRV.FallbackProbed {
states = append(states, infoState(
CodeNoSRV,
"No LDAP SRV records published for "+data.Domain+".",
"",
"Consider publishing _ldap._tcp."+data.Domain+" and _ldaps._tcp."+data.Domain+" SRV records to let clients discover the directory automatically.",
))
}
for prefix, msg := range data.SRV.Errors {
states = append(states, warnState(
CodeSRVServfail,
"DNS lookup failed for "+prefix+data.Domain+": "+msg,
"",
"Check the authoritative DNS servers for this domain.",
))
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.has_srv.ok", "SRV records are published and resolved cleanly.")}
}
return states
}
// endpointReachableRule: every discovered endpoint accepts a TCP connection.
type endpointReachableRule struct{}
func (r *endpointReachableRule) Name() string { return "ldap.endpoint_reachable" }
func (r *endpointReachableRule) Description() string {
return "Verifies that every discovered LDAP endpoint accepts a TCP connection."
}
func (r *endpointReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
// Per-endpoint TCP failures.
ldapsUp := ldapsReachable(data)
for _, ep := range data.Endpoints {
if ep.TCPConnected || ep.Error == "" {
continue
}
msg := "Cannot reach " + ep.Address + ": " + ep.Error + "."
if ep.Mode == ModePlain && ldapsUp {
states = append(states, infoState(
CodeTCPUnreachable, msg, ep.Address,
"LDAPS (636) is reachable, so modern clients are unaffected. Only relevant if legacy clients still need plain LDAP on 389.",
))
} else {
states = append(states, warnState(
CodeTCPUnreachable, msg, ep.Address,
"Verify firewall rules and that the LDAP server is listening on this address.",
))
}
}
// Aggregate: no endpoint reachable at all.
if len(data.Endpoints) > 0 {
allDown := true
for _, ep := range data.Endpoints {
if ep.TCPConnected {
allDown = false
break
}
}
if allDown {
states = append(states, critState(
CodeAllEndpointsDown,
"No LDAP endpoint is reachable.",
"",
"Check firewall rules and that the directory is listening on ports 389/636 (or the ports indicated by SRV).",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.endpoint_reachable.ok", "All discovered endpoints are reachable.")}
}
return states
}
// encryptedTransportRule: at least one endpoint is reachable AND encrypted.
type encryptedTransportRule struct{}
func (r *encryptedTransportRule) Name() string { return "ldap.has_encrypted_transport" }
func (r *encryptedTransportRule) Description() string {
return "Verifies that at least one reachable endpoint offers an encrypted channel (LDAPS or StartTLS)."
}
func (r *encryptedTransportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
anyReachable := false
anyEncrypted := false
for _, ep := range data.Endpoints {
if ep.TCPConnected {
anyReachable = true
if ep.TLSEstablished {
anyEncrypted = true
}
}
}
if anyReachable && !anyEncrypted {
return []sdk.CheckState{critState(
CodeNoEncryptedEndpoint,
"None of the reachable endpoints established an encrypted channel (no StartTLS, no LDAPS).",
"",
"Enable either LDAPS (port 636) or StartTLS on 389 -- a bind DN should never travel in cleartext.",
)}
}
return []sdk.CheckState{passState("ldap.has_encrypted_transport.ok", "At least one endpoint offers encrypted transport.")}
}
// startTLSSupportedRule: StartTLS is advertised and succeeds on every
// reachable plain-LDAP endpoint.
type startTLSSupportedRule struct{}
func (r *startTLSSupportedRule) Name() string { return "ldap.starttls_supported" }
func (r *startTLSSupportedRule) Description() string {
return "Verifies that StartTLS is offered and succeeds on every reachable plain LDAP endpoint."
}
func (r *startTLSSupportedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.Mode != ModePlain || !ep.TCPConnected {
continue
}
if !ep.StartTLSOffered {
states = append(states, critState(
CodeStartTLSMissing,
"StartTLS not advertised on "+ep.Address+".",
ep.Address,
"Configure the LDAP server to accept StartTLS (RFC 2830). On OpenLDAP, set olcTLSCertificateFile / olcTLSCertificateKeyFile and ensure the LDAP listener allows encrypted upgrades.",
))
} else if !ep.StartTLSUpgraded {
states = append(states, critState(
CodeStartTLSFailed,
"StartTLS handshake failed on "+ep.Address+": "+ep.Error+".",
ep.Address,
"Run the TLS checker on this endpoint for cert and cipher details.",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.starttls_supported.ok", "StartTLS works on every reachable plain LDAP endpoint.")}
}
return states
}
// ldapsHandshakeRule: the direct TLS handshake succeeds on every reachable
// LDAPS endpoint.
type ldapsHandshakeRule struct{}
func (r *ldapsHandshakeRule) Name() string { return "ldap.ldaps_handshake" }
func (r *ldapsHandshakeRule) Description() string {
return "Verifies that the direct TLS handshake succeeds on every LDAPS endpoint."
}
func (r *ldapsHandshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.Mode == ModeLDAPS && ep.TCPConnected && !ep.TLSEstablished {
states = append(states, critState(
CodeLDAPSHandshakeFailed,
"LDAPS TLS handshake failed on "+ep.Address+": "+ep.Error+".",
ep.Address,
"Check that the LDAPS listener has a valid certificate/key pair and is speaking TLS directly (not LDAP+StartTLS).",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.ldaps_handshake.ok", "LDAPS handshake succeeds on every reachable LDAPS endpoint.")}
}
return states
}
// startTLSOnLDAPSRule: flags LDAPS endpoints that also advertise StartTLS.
type startTLSOnLDAPSRule struct{}
func (r *startTLSOnLDAPSRule) Name() string { return "ldap.starttls_on_ldaps" }
func (r *startTLSOnLDAPSRule) Description() string {
return "Flags servers that needlessly advertise StartTLS on the implicit-TLS LDAPS port."
}
func (r *startTLSOnLDAPSRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.Mode == ModeLDAPS && stringListContainsFold(ep.SupportedExtension, "1.3.6.1.4.1.1466.20037") {
states = append(states, infoState(
CodeStartTLSOnLDAPS,
"Server advertises StartTLS on the LDAPS port "+ep.Address+".",
ep.Address,
"This is not actively harmful, but most directories do not need StartTLS exposed on 636. Consider disabling it to reduce attack surface.",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.starttls_on_ldaps.ok", "LDAPS endpoints do not also advertise StartTLS.")}
}
return states
}
// ipv6ReachableRule: at least one endpoint reachable over IPv6.
type ipv6ReachableRule struct{}
func (r *ipv6ReachableRule) Name() string { return "ldap.ipv6_reachable" }
func (r *ipv6ReachableRule) Description() string {
return "Verifies at least one endpoint is reachable over IPv6."
}
func (r *ipv6ReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
hasV4 := false
hasV6 := false
for _, ep := range data.Endpoints {
if !ep.TCPConnected {
continue
}
if ep.IsIPv6 {
hasV6 = true
} else {
hasV4 = true
}
}
if hasV4 && !hasV6 {
return []sdk.CheckState{infoState(
CodeNoIPv6,
"No IPv6 endpoint reachable.",
"",
"Publish AAAA records for the SRV targets.",
)}
}
return []sdk.CheckState{passState("ldap.ipv6_reachable.ok", "At least one endpoint is reachable over IPv6.")}
}

174
checker/tls_related.go Normal file
View file

@ -0,0 +1,174 @@
package checker
import (
"encoding/json"
"net"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// TLSRelatedKey is the observation key we expect a TLS checker to publish
// for the endpoints we discover. Matches the cross-checker convention
// documented in the happyDomain plan.
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
// tlsProbeView is a permissive view of a TLS checker's payload: we read
// only the fields we need and tolerate missing ones. The TLS checker owns
// the full schema.
type tlsProbeView struct {
Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
TLSVersion string `json:"tls_version,omitempty"`
CipherSuite string `json:"cipher_suite,omitempty"`
HostnameMatch *bool `json:"hostname_match,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
Issues []struct {
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
} `json:"issues,omitempty"`
}
func (v *tlsProbeView) address() string {
if v.Endpoint != "" {
return v.Endpoint
}
if v.Host != "" && v.Port != 0 {
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
}
return ""
}
// parseTLSRelated decodes a RelatedObservation as a TLS probe, gracefully
// returning nil when the payload doesn't look like one. Handles both
// {"probes": {"<ref>": <probe>}} and a bare <probe> shape.
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
var keyed struct {
Probes map[string]tlsProbeView `json:"probes"`
}
if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil {
if p, ok := keyed.Probes[r.Ref]; ok {
return &p
}
return nil
}
var v tlsProbeView
if err := json.Unmarshal(r.Data, &v); err != nil {
return nil
}
return &v
}
// tlsStatesFromRelated converts downstream TLS observations into CheckStates
// so certificate problems land on the LDAP service page.
func tlsStatesFromRelated(related []sdk.RelatedObservation) []sdk.CheckState {
var out []sdk.CheckState
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
addr := v.address()
if len(v.Issues) > 0 {
for _, is := range v.Issues {
sev := strings.ToLower(is.Severity)
switch sev {
case SeverityCrit, SeverityWarn, SeverityInfo:
default:
continue
}
code := is.Code
if code == "" {
code = "tls.unknown"
}
out = append(out, stateWithFix(
severityToStatus(sev),
"ldap.tls."+code,
strings.TrimSpace("TLS on "+addr+": "+is.Message),
addr,
is.Fix,
))
}
continue
}
// Flag-only payload: synthesize a single summary state.
sev := v.worstSeverity()
if sev == "" {
continue
}
msg := "TLS issue reported on " + addr
switch {
case v.ChainValid != nil && !*v.ChainValid:
msg = "Invalid certificate chain on " + addr
case v.HostnameMatch != nil && !*v.HostnameMatch:
msg = "Certificate does not cover the domain on " + addr
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0:
msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour:
msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
}
out = append(out, stateWithFix(
severityToStatus(sev),
"ldap.tls.probe",
msg,
addr,
"See the TLS checker report for details.",
))
}
return out
}
// severityToStatus bridges a TLS-related severity string to sdk.Status. The
// severity strings remain stable so JSON payloads from older TLS checkers
// still decode.
func severityToStatus(sev string) sdk.Status {
switch sev {
case SeverityCrit:
return sdk.StatusCrit
case SeverityWarn:
return sdk.StatusWarn
case SeverityInfo:
return sdk.StatusInfo
default:
return sdk.StatusOK
}
}
func (v *tlsProbeView) worstSeverity() string {
worst := ""
for _, is := range v.Issues {
switch strings.ToLower(is.Severity) {
case SeverityCrit:
return SeverityCrit
case SeverityWarn:
if worst != SeverityCrit {
worst = SeverityWarn
}
case SeverityInfo:
if worst == "" {
worst = SeverityInfo
}
}
}
if v.ChainValid != nil && !*v.ChainValid {
return SeverityCrit
}
if v.HostnameMatch != nil && !*v.HostnameMatch {
return SeverityCrit
}
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 {
return SeverityCrit
}
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour {
if worst != SeverityCrit {
return SeverityWarn
}
}
return worst
}

182
checker/types.go Normal file
View file

@ -0,0 +1,182 @@
// Package checker implements the LDAP server checker for happyDomain.
//
// It probes a domain's LDAP deployment (_ldap._tcp / _ldaps._tcp SRV
// discovery with fallback to default ports 389/636, anonymous bind,
// StartTLS upgrade, RootDSE introspection, plaintext-bind refusal,
// supportedSASLMechanisms) and reports actionable findings.
//
// TLS certificate chain / SAN / expiry / cipher posture is intentionally
// out of scope -- the dedicated TLS checker covers that, fed by the TLS
// endpoints we publish as DiscoveryEntry records.
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
const ObservationKeyLDAP sdk.ObservationKey = "ldap"
// LDAPMode distinguishes plaintext LDAP (with optional StartTLS) from
// implicit-TLS LDAPS endpoints.
type LDAPMode string
const (
ModePlain LDAPMode = "ldap"
ModeLDAPS LDAPMode = "ldaps"
)
// LDAPData is the full observation stored per run.
type LDAPData struct {
Domain string `json:"domain"`
BaseDN string `json:"base_dn,omitempty"`
RunAt string `json:"run_at"`
SRV SRVLookup `json:"srv"`
Endpoints []EndpointProbe `json:"endpoints"`
// BindTested is true when a bind DN was supplied and a bind attempt ran.
BindTested bool `json:"bind_tested,omitempty"`
}
type SRVLookup struct {
LDAP []SRVRecord `json:"ldap,omitempty"`
LDAPS []SRVRecord `json:"ldaps,omitempty"`
// Errors per-set (keyed by record type like "_ldap._tcp").
Errors map[string]string `json:"errors,omitempty"`
// FallbackProbed is true when no SRV was published and we probed the
// bare domain on the default ports.
FallbackProbed bool `json:"fallback_probed,omitempty"`
}
type SRVRecord struct {
Target string `json:"target"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"`
// IPv4 and IPv6 addresses resolved for the target (at probe time).
IPv4 []string `json:"ipv4,omitempty"`
IPv6 []string `json:"ipv6,omitempty"`
}
// EndpointProbe is the result of probing one (mode, host, port, address) tuple.
type EndpointProbe struct {
Mode LDAPMode `json:"mode"`
SRVPrefix string `json:"srv_prefix,omitempty"`
Target string `json:"target"`
Port uint16 `json:"port"`
Address string `json:"address"`
IsIPv6 bool `json:"is_ipv6,omitempty"`
// What happened.
TCPConnected bool `json:"tcp_connected"`
// StartTLSOffered is only meaningful on Mode=ldap: whether the server
// accepted the RFC 2830 ExtendedRequest.
StartTLSOffered bool `json:"starttls_offered"`
StartTLSUpgraded bool `json:"starttls_upgraded"`
// TLSEstablished is true whenever the link was encrypted (direct LDAPS
// handshake succeeded OR StartTLS completed).
TLSEstablished bool `json:"tls_established"`
TLSVersion string `json:"tls_version,omitempty"`
TLSCipher string `json:"tls_cipher,omitempty"`
// RootDSE / capability fingerprint.
RootDSERead bool `json:"rootdse_read"`
SupportedLDAPVersion []string `json:"supported_ldap_version,omitempty"`
SupportedSASLMechanisms []string `json:"supported_sasl_mechanisms,omitempty"`
SupportedControl []string `json:"supported_control,omitempty"`
SupportedExtension []string `json:"supported_extension,omitempty"`
NamingContexts []string `json:"naming_contexts,omitempty"`
VendorName string `json:"vendor_name,omitempty"`
VendorVersion string `json:"vendor_version,omitempty"`
// AnonymousBindAllowed is true when an anonymous simple bind to DN=""
// succeeded. Many directories accept this just to expose the RootDSE;
// we flag it only when paired with anonymous read of naming contexts.
AnonymousBindAllowed bool `json:"anonymous_bind_allowed,omitempty"`
// AnonymousSearchAllowed is true when a subtree search on the first
// naming context with baseObject returned any entry without auth.
// This is the information-disclosure signal.
AnonymousSearchAllowed bool `json:"anonymous_search_allowed,omitempty"`
// Plaintext-bind posture (only for Mode=ldap, run before TLS upgrade).
// PlaintextBindTested is true when we attempted a simple bind with
// dummy credentials over cleartext to see whether the server refused
// it (RFC 4513 requires refusing auth over insecure channels when
// policy demands it, though in practice most deployments don't).
PlaintextBindTested bool `json:"plaintext_bind_tested,omitempty"`
// PlaintextBindAccepted is true when the server did NOT refuse the
// cleartext bind with "confidentiality required" (resultCode 13).
// A bad-credentials response (49) counts as "accepted the attempt",
// which is the insecure-posture signal we want to flag.
PlaintextBindAccepted bool `json:"plaintext_bind_accepted,omitempty"`
// Bind test (run only when bind_dn/bind_password options provided).
BindAttempted bool `json:"bind_attempted,omitempty"`
BindOK bool `json:"bind_ok,omitempty"`
BindError string `json:"bind_error,omitempty"`
// Read access test on base_dn (run only when base_dn provided AND the
// authenticated bind above succeeded, unless base_dn was meant to be
// anonymously readable).
BaseReadAttempted bool `json:"base_read_attempted,omitempty"`
BaseReadOK bool `json:"base_read_ok,omitempty"`
BaseReadEntries int `json:"base_read_entries,omitempty"`
BaseReadError string `json:"base_read_error,omitempty"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
type ReachabilitySpan struct {
HasIPv4 bool `json:"has_ipv4"`
HasIPv6 bool `json:"has_ipv6"`
// EncryptedReachable is true when at least one endpoint offered encrypted
// transport (LDAPS or LDAP+StartTLS).
EncryptedReachable bool `json:"encrypted_reachable"`
// PlainOnlyReachable is true when only cleartext endpoints responded.
PlainOnlyReachable bool `json:"plain_only_reachable"`
}
// Issue is a structured finding attached to the observation so the rule and
// the HTML report can both consume them without re-deriving logic.
type Issue struct {
Code string `json:"code"`
Severity string `json:"severity"` // "info" | "warn" | "crit"
Message string `json:"message"`
Fix string `json:"fix,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
}
// Severities (string for stable JSON, independent of sdk.Status numeric values).
const (
SeverityInfo = "info"
SeverityWarn = "warn"
SeverityCrit = "crit"
)
// Issue codes.
const (
CodeNoSRV = "ldap.no_srv"
CodeSRVServfail = "ldap.srv.servfail"
CodeTCPUnreachable = "ldap.tcp.unreachable"
CodeAllEndpointsDown = "ldap.all_endpoints_down"
CodeNoEncryptedEndpoint = "ldap.no_encrypted_endpoint"
CodeStartTLSMissing = "ldap.starttls.missing"
CodeStartTLSFailed = "ldap.starttls.handshake_failed"
CodeLDAPSHandshakeFailed = "ldap.ldaps.handshake_failed"
CodePlainBindAccepted = "ldap.plain_bind.accepted"
CodeAnonymousSearchAllowed = "ldap.anon.search_allowed"
CodeRootDSEUnreadable = "ldap.rootdse.unreadable"
CodeNoSASL = "ldap.sasl.none"
CodeSASLPlainOnly = "ldap.sasl.plain_only"
CodeSASLNoStrongMech = "ldap.sasl.no_strong_mech"
CodeLegacyLDAPv2 = "ldap.legacy_v2"
CodeStartTLSOnLDAPS = "ldap.starttls.on_ldaps"
CodeNoIPv6 = "ldap.no_ipv6"
CodeBindFailed = "ldap.bind.failed"
CodeBindOK = "ldap.bind.ok"
CodeBaseReadFailed = "ldap.base_read.failed"
CodeBaseReadOK = "ldap.base_read.ok"
CodeNoNamingContext = "ldap.rootdse.no_naming_context"
)