checker-ldap/checker/rules_posture.go

237 lines
8 KiB
Go

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
}