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=` 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 }