237 lines
8 KiB
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
|
|
}
|