Initial commit
This commit is contained in:
commit
9b128d22ed
18 changed files with 2364 additions and 0 deletions
747
checker/collect.go
Normal file
747
checker/collect.go
Normal file
|
|
@ -0,0 +1,747 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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
|
||||
lookupSets := []struct {
|
||||
prefix string
|
||||
dst *[]SRVRecord
|
||||
}{
|
||||
{"_ldap._tcp.", &data.SRV.LDAP},
|
||||
{"_ldaps._tcp.", &data.SRV.LDAPS},
|
||||
}
|
||||
for _, ls := range lookupSets {
|
||||
records, err := lookupSRV(ctx, resolver, ls.prefix, domain)
|
||||
if err != nil {
|
||||
data.SRV.Errors[ls.prefix] = err.Error()
|
||||
continue
|
||||
}
|
||||
*ls.dst = records
|
||||
}
|
||||
|
||||
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}}
|
||||
}
|
||||
|
||||
resolveAllInto(ctx, resolver, data.SRV.LDAP)
|
||||
resolveAllInto(ctx, resolver, data.SRV.LDAPS)
|
||||
|
||||
wantBind := bindDN != "" && bindPassword != ""
|
||||
if wantBind {
|
||||
data.BindTested = true
|
||||
}
|
||||
|
||||
probeSet(ctx, data, domain, ModePlain, "_ldap._tcp", data.SRV.LDAP, perEndpoint, bindDN, bindPassword, baseDN)
|
||||
probeSet(ctx, data, domain, ModeLDAPS, "_ldaps._tcp", data.SRV.LDAPS, perEndpoint, bindDN, bindPassword, baseDN)
|
||||
|
||||
computeCoverage(data)
|
||||
data.Issues = deriveIssues(data, wantBind, baseDN != "")
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func probeSet(ctx context.Context, data *LDAPData, domain string, mode LDAPMode, prefix string, records []SRVRecord, timeout time.Duration, bindDN, bindPassword, baseDN string) {
|
||||
for _, rec := range records {
|
||||
addrs := addressesForProbe(rec)
|
||||
if len(addrs) == 0 {
|
||||
ep := EndpointProbe{
|
||||
Mode: mode,
|
||||
SRVPrefix: prefix,
|
||||
Target: rec.Target,
|
||||
Port: rec.Port,
|
||||
Error: "no A/AAAA records for target",
|
||||
}
|
||||
data.Endpoints = append(data.Endpoints, ep)
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ep := probeEndpoint(ctx, domain, mode, prefix, rec, a.ip, a.isV6, timeout, bindDN, bindPassword, baseDN)
|
||||
data.Endpoints = append(data.Endpoints, ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
_ = rawConn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
// 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(domain, 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)
|
||||
_ = tlsConn.SetDeadline(time.Now().Add(timeout))
|
||||
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.
|
||||
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 := stringListContains(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
|
||||
// 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)
|
||||
}
|
||||
// Refresh RootDSE post-TLS: some servers expose more
|
||||
// supported mechanisms after the secure channel is up.
|
||||
readRootDSE(conn, &result)
|
||||
}
|
||||
} else if !result.RootDSERead {
|
||||
result.Error = "rootdse-unreadable: " + firstNonEmpty(result.Error, "RootDSE could not be read")
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous bind + search -- we try unconditionally so we can flag
|
||||
// exposure.
|
||||
anonBindOK := simpleBindIs(conn, "", "", 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
|
||||
err := conn.Bind(bindDN, bindPassword)
|
||||
if err == nil {
|
||||
result.BindOK = true
|
||||
if baseDN != "" {
|
||||
result.BaseReadAttempted = true
|
||||
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 {
|
||||
result.BaseReadOK = true
|
||||
result.BaseReadEntries = len(sr.Entries)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.BindError = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// simpleBindIs runs a simple bind and reports whether it succeeded. It is a
|
||||
// thin wrapper so we can distinguish "bind accepted" from "bind rejected"
|
||||
// without tracking specific LDAP result codes.
|
||||
func simpleBindIs(conn *ldapv3.Conn, user, pass string, classifier *ldapClassifier) bool {
|
||||
err := conn.Bind(user, pass)
|
||||
if classifier != nil {
|
||||
classifier.lastErr = err
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type ldapClassifier struct {
|
||||
lastErr error
|
||||
}
|
||||
|
||||
// probePlaintextBindRefusal opens a short-lived, fresh TCP connection and
|
||||
// attempts a simple bind with a random 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(domain, 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
|
||||
err = conn.Bind("cn=checker-probe,dc="+domain, "x-not-a-real-password-x")
|
||||
if err == nil {
|
||||
// Unlikely but clear: cleartext bind accepted.
|
||||
return tested, true
|
||||
}
|
||||
// Map LDAP result code 13 (confidentiality required) to "refused".
|
||||
var lerr *ldapv3.Error
|
||||
if errors.As(err, &lerr) && lerr.ResultCode == ldapv3.LDAPResultConfidentialityRequired {
|
||||
return tested, false
|
||||
}
|
||||
// resultCode 49 (invalidCredentials), 32 (noSuchObject), … all mean
|
||||
// the server was willing to *try* the bind over cleartext, which is
|
||||
// the warning-worthy posture.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) {
|
||||
for i := range records {
|
||||
ips, err := r.LookupIPAddr(ctx, records[i].Target)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.IP.To4(); v4 != nil {
|
||||
records[i].IPv4 = append(records[i].IPv4, v4.String())
|
||||
} else {
|
||||
records[i].IPv6 = append(records[i].IPv6, ip.IP.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func computeCoverage(data *LDAPData) {
|
||||
anyEncrypted := false
|
||||
anyPlain := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
if ep.IsIPv6 {
|
||||
data.Coverage.HasIPv6 = true
|
||||
} else {
|
||||
data.Coverage.HasIPv4 = true
|
||||
}
|
||||
if ep.TLSEstablished {
|
||||
anyEncrypted = true
|
||||
} else {
|
||||
anyPlain = true
|
||||
}
|
||||
}
|
||||
}
|
||||
data.Coverage.EncryptedReachable = anyEncrypted
|
||||
data.Coverage.PlainOnlyReachable = anyPlain && !anyEncrypted
|
||||
}
|
||||
|
||||
func deriveIssues(data *LDAPData, wantBind, wantBaseRead bool) []Issue {
|
||||
var issues []Issue
|
||||
|
||||
// 1. No SRV published.
|
||||
if data.SRV.FallbackProbed {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoSRV,
|
||||
Severity: SeverityInfo,
|
||||
Message: "No LDAP SRV records published for " + data.Domain + ".",
|
||||
Fix: "Consider publishing _ldap._tcp." + data.Domain + " and _ldaps._tcp." + data.Domain + " SRV records to let clients discover the directory automatically.",
|
||||
})
|
||||
}
|
||||
|
||||
// 2. SRV lookup errors.
|
||||
for prefix, msg := range data.SRV.Errors {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeSRVServfail,
|
||||
Severity: SeverityWarn,
|
||||
Message: "DNS lookup failed for " + prefix + data.Domain + ": " + msg,
|
||||
Fix: "Check the authoritative DNS servers for this domain.",
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Endpoint-level issues.
|
||||
allDown := len(data.Endpoints) > 0
|
||||
anyEncrypted := false
|
||||
anyLDAPS := false
|
||||
anyLDAPReachable := false
|
||||
sawSASL := false
|
||||
sawStrongSASL := false
|
||||
sawPlainOnly := false
|
||||
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
allDown = false
|
||||
if ep.Mode == ModePlain {
|
||||
anyLDAPReachable = true
|
||||
}
|
||||
if ep.Mode == ModeLDAPS {
|
||||
anyLDAPS = true
|
||||
}
|
||||
if ep.TLSEstablished {
|
||||
anyEncrypted = true
|
||||
}
|
||||
}
|
||||
if !ep.TCPConnected && ep.Error != "" {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeTCPUnreachable,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Cannot reach " + ep.Address + ": " + ep.Error + ".",
|
||||
Fix: "Verify firewall rules and that the LDAP server is listening on this address.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if ep.Mode == ModePlain && ep.TCPConnected {
|
||||
if !ep.StartTLSOffered {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeStartTLSMissing,
|
||||
Severity: SeverityCrit,
|
||||
Message: "StartTLS not advertised on " + ep.Address + ".",
|
||||
Fix: "Configure the LDAP server to accept StartTLS (RFC 2830). On OpenLDAP, set olcTLSCertificateFile / olcTLSCertificateKeyFile and ensure the LDAP listener allows encrypted upgrades.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
} else if !ep.StartTLSUpgraded {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeStartTLSFailed,
|
||||
Severity: SeverityCrit,
|
||||
Message: "StartTLS handshake failed on " + ep.Address + ": " + ep.Error + ".",
|
||||
Fix: "Run the TLS checker on this endpoint for cert and cipher details.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
if ep.PlaintextBindTested && ep.PlaintextBindAccepted {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodePlainBindAccepted,
|
||||
Severity: SeverityCrit,
|
||||
Message: "Cleartext bind attempts are accepted on " + ep.Address + " (server does not reply confidentialityRequired).",
|
||||
Fix: "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.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ep.Mode == ModeLDAPS && ep.TCPConnected && !ep.TLSEstablished {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeLDAPSHandshakeFailed,
|
||||
Severity: SeverityCrit,
|
||||
Message: "LDAPS TLS handshake failed on " + ep.Address + ": " + ep.Error + ".",
|
||||
Fix: "Check that the LDAPS listener has a valid certificate/key pair and is speaking TLS directly (not LDAP+StartTLS).",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
if ep.TCPConnected && ep.TLSEstablished && !ep.RootDSERead {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeRootDSEUnreadable,
|
||||
Severity: SeverityWarn,
|
||||
Message: "RootDSE is not readable on " + ep.Address + " -- capability discovery is unavailable.",
|
||||
Fix: "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.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// Anonymous exposure.
|
||||
if ep.AnonymousSearchAllowed {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeAnonymousSearchAllowed,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Anonymous search against naming context succeeds on " + ep.Address + " -- DIT contents may be enumerable without credentials.",
|
||||
Fix: "Restrict anonymous access: `access to * by anonymous auth by users read` (OpenLDAP) or set `nsslapd-allow-anonymous-access: off` (389-ds).",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// SASL posture (from RootDSE).
|
||||
if len(ep.SupportedSASLMechanisms) > 0 {
|
||||
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 hasPlain && !hasStrong {
|
||||
sawPlainOnly = true
|
||||
}
|
||||
if hasStrong {
|
||||
sawStrongSASL = true
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol version.
|
||||
hasV3 := false
|
||||
hasV2 := false
|
||||
for _, v := range ep.SupportedLDAPVersion {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "3":
|
||||
hasV3 = true
|
||||
case "2":
|
||||
hasV2 = true
|
||||
}
|
||||
}
|
||||
_ = hasV3
|
||||
if hasV2 {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeLegacyLDAPv2,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Server still advertises supportedLDAPVersion=2 on " + ep.Address + ".",
|
||||
Fix: "Disable LDAPv2 support -- RFC 3494 deprecated it in 2003. On OpenLDAP, remove `allow bind_v2`.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// Naming context exposure check -- missing it is usually benign, but
|
||||
// on authoritative directories the absence means you cannot route
|
||||
// queries. Report as info.
|
||||
if ep.RootDSERead && len(ep.NamingContexts) == 0 {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoNamingContext,
|
||||
Severity: SeverityInfo,
|
||||
Message: "RootDSE does not advertise any naming context on " + ep.Address + ".",
|
||||
Fix: "If this server is authoritative, populate the namingContexts attribute so clients can locate the DIT root.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// StartTLS offered on LDAPS port -- not wrong per se (some servers
|
||||
// support both), but usually a configuration smell.
|
||||
if ep.Mode == ModeLDAPS && stringListContains(ep.SupportedExtension, "1.3.6.1.4.1.1466.20037") {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeStartTLSOnLDAPS,
|
||||
Severity: SeverityInfo,
|
||||
Message: "Server advertises StartTLS on the LDAPS port " + ep.Address + ".",
|
||||
Fix: "This is not actively harmful, but most directories do not need StartTLS exposed on 636. Consider disabling it to reduce attack surface.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// Authenticated bind result.
|
||||
if ep.BindAttempted {
|
||||
if ep.BindOK {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeBindOK,
|
||||
Severity: SeverityInfo,
|
||||
Message: "Bind as " + ep.Address + " succeeded with the provided credentials.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
if ep.BaseReadAttempted && !ep.BaseReadOK {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeBaseReadFailed,
|
||||
Severity: SeverityCrit,
|
||||
Message: "Bind succeeded but baseObject read on " + data.BaseDN + " failed: " + ep.BaseReadError,
|
||||
Fix: "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`.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
if ep.BaseReadAttempted && ep.BaseReadOK {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeBaseReadOK,
|
||||
Severity: SeverityInfo,
|
||||
Message: "Base DN read succeeded on " + ep.Address + " (entries=" + strconv.Itoa(ep.BaseReadEntries) + ").",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeBindFailed,
|
||||
Severity: SeverityCrit,
|
||||
Message: "Bind on " + ep.Address + " failed: " + ep.BindError,
|
||||
Fix: "Verify the bind DN exists and the password is current. On AD, check the account is not locked/expired.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate-level derivations.
|
||||
if allDown {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeAllEndpointsDown,
|
||||
Severity: SeverityCrit,
|
||||
Message: "No LDAP endpoint is reachable.",
|
||||
Fix: "Check firewall rules and that the directory is listening on ports 389/636 (or the ports indicated by SRV).",
|
||||
})
|
||||
} else if !anyEncrypted {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoEncryptedEndpoint,
|
||||
Severity: SeverityCrit,
|
||||
Message: "None of the reachable endpoints established an encrypted channel (no StartTLS, no LDAPS).",
|
||||
Fix: "Enable either LDAPS (port 636) or StartTLS on 389 -- a bind DN should never travel in cleartext.",
|
||||
})
|
||||
}
|
||||
if anyLDAPReachable && !anyLDAPS {
|
||||
// Not always a misconfig (some sites run StartTLS-only), info only.
|
||||
// No dedicated issue -- informational.
|
||||
}
|
||||
|
||||
if sawSASL {
|
||||
if !sawStrongSASL {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeSASLNoStrongMech,
|
||||
Severity: SeverityWarn,
|
||||
Message: "No strong SASL mechanism (SCRAM-*, EXTERNAL, GSSAPI) is advertised.",
|
||||
Fix: "Enable SCRAM-SHA-256 or GSSAPI where your identity platform allows it.",
|
||||
})
|
||||
}
|
||||
if sawPlainOnly {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeSASLPlainOnly,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Only PLAIN/LOGIN SASL mechanisms are offered.",
|
||||
Fix: "Add SCRAM-SHA-256 so password hashes aren't sent as password-equivalent tokens.",
|
||||
})
|
||||
}
|
||||
} else if len(data.Endpoints) > 0 {
|
||||
// We didn't see supportedSASLMechanisms at all -- either the server
|
||||
// doesn't advertise them or we couldn't read RootDSE.
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoSASL,
|
||||
Severity: SeverityInfo,
|
||||
Message: "No supportedSASLMechanisms advertised by the directory.",
|
||||
Fix: "If SASL is in use, ensure the server publishes supportedSASLMechanisms in the RootDSE. Otherwise only simple binds are available.",
|
||||
})
|
||||
}
|
||||
|
||||
if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoIPv6,
|
||||
Severity: SeverityInfo,
|
||||
Message: "No IPv6 endpoint reachable.",
|
||||
Fix: "Publish AAAA records for the SRV targets.",
|
||||
})
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func stringListContains(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if strings.EqualFold(s, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unique(list []string) []string {
|
||||
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
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue