Initial commit
This commit is contained in:
commit
beca2fd7eb
21 changed files with 2698 additions and 0 deletions
237
checker/rules_posture.go
Normal file
237
checker/rules_posture.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue