182 lines
7.5 KiB
Go
182 lines
7.5 KiB
Go
// Package checker implements the LDAP server checker for happyDomain.
|
|
//
|
|
// It probes a domain's LDAP deployment (_ldap._tcp / _ldaps._tcp SRV
|
|
// discovery with fallback to default ports 389/636, anonymous bind,
|
|
// StartTLS upgrade, RootDSE introspection, plaintext-bind refusal,
|
|
// supportedSASLMechanisms) and reports actionable findings.
|
|
//
|
|
// TLS certificate chain / SAN / expiry / cipher posture is intentionally
|
|
// out of scope -- the dedicated TLS checker covers that, fed by the TLS
|
|
// endpoints we publish as DiscoveryEntry records.
|
|
package checker
|
|
|
|
import (
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
const ObservationKeyLDAP sdk.ObservationKey = "ldap"
|
|
|
|
// LDAPMode distinguishes plaintext LDAP (with optional StartTLS) from
|
|
// implicit-TLS LDAPS endpoints.
|
|
type LDAPMode string
|
|
|
|
const (
|
|
ModePlain LDAPMode = "ldap"
|
|
ModeLDAPS LDAPMode = "ldaps"
|
|
)
|
|
|
|
// LDAPData is the full observation stored per run.
|
|
type LDAPData struct {
|
|
Domain string `json:"domain"`
|
|
BaseDN string `json:"base_dn,omitempty"`
|
|
RunAt string `json:"run_at"`
|
|
SRV SRVLookup `json:"srv"`
|
|
Endpoints []EndpointProbe `json:"endpoints"`
|
|
// BindTested is true when a bind DN was supplied and a bind attempt ran.
|
|
BindTested bool `json:"bind_tested,omitempty"`
|
|
}
|
|
|
|
type SRVLookup struct {
|
|
LDAP []SRVRecord `json:"ldap,omitempty"`
|
|
LDAPS []SRVRecord `json:"ldaps,omitempty"`
|
|
// Errors per-set (keyed by record type like "_ldap._tcp").
|
|
Errors map[string]string `json:"errors,omitempty"`
|
|
// FallbackProbed is true when no SRV was published and we probed the
|
|
// bare domain on the default ports.
|
|
FallbackProbed bool `json:"fallback_probed,omitempty"`
|
|
}
|
|
|
|
type SRVRecord struct {
|
|
Target string `json:"target"`
|
|
Port uint16 `json:"port"`
|
|
Priority uint16 `json:"priority"`
|
|
Weight uint16 `json:"weight"`
|
|
// IPv4 and IPv6 addresses resolved for the target (at probe time).
|
|
IPv4 []string `json:"ipv4,omitempty"`
|
|
IPv6 []string `json:"ipv6,omitempty"`
|
|
}
|
|
|
|
// EndpointProbe is the result of probing one (mode, host, port, address) tuple.
|
|
type EndpointProbe struct {
|
|
Mode LDAPMode `json:"mode"`
|
|
SRVPrefix string `json:"srv_prefix,omitempty"`
|
|
Target string `json:"target"`
|
|
Port uint16 `json:"port"`
|
|
Address string `json:"address"`
|
|
IsIPv6 bool `json:"is_ipv6,omitempty"`
|
|
|
|
// What happened.
|
|
TCPConnected bool `json:"tcp_connected"`
|
|
|
|
// StartTLSOffered is only meaningful on Mode=ldap: whether the server
|
|
// accepted the RFC 2830 ExtendedRequest.
|
|
StartTLSOffered bool `json:"starttls_offered"`
|
|
StartTLSUpgraded bool `json:"starttls_upgraded"`
|
|
// TLSEstablished is true whenever the link was encrypted (direct LDAPS
|
|
// handshake succeeded OR StartTLS completed).
|
|
TLSEstablished bool `json:"tls_established"`
|
|
|
|
TLSVersion string `json:"tls_version,omitempty"`
|
|
TLSCipher string `json:"tls_cipher,omitempty"`
|
|
|
|
// RootDSE / capability fingerprint.
|
|
RootDSERead bool `json:"rootdse_read"`
|
|
SupportedLDAPVersion []string `json:"supported_ldap_version,omitempty"`
|
|
SupportedSASLMechanisms []string `json:"supported_sasl_mechanisms,omitempty"`
|
|
SupportedControl []string `json:"supported_control,omitempty"`
|
|
SupportedExtension []string `json:"supported_extension,omitempty"`
|
|
NamingContexts []string `json:"naming_contexts,omitempty"`
|
|
VendorName string `json:"vendor_name,omitempty"`
|
|
VendorVersion string `json:"vendor_version,omitempty"`
|
|
|
|
// AnonymousBindAllowed is true when an anonymous simple bind to DN=""
|
|
// succeeded. Many directories accept this just to expose the RootDSE;
|
|
// we flag it only when paired with anonymous read of naming contexts.
|
|
AnonymousBindAllowed bool `json:"anonymous_bind_allowed,omitempty"`
|
|
|
|
// AnonymousSearchAllowed is true when a subtree search on the first
|
|
// naming context with baseObject returned any entry without auth.
|
|
// This is the information-disclosure signal.
|
|
AnonymousSearchAllowed bool `json:"anonymous_search_allowed,omitempty"`
|
|
|
|
// Plaintext-bind posture (only for Mode=ldap, run before TLS upgrade).
|
|
// PlaintextBindTested is true when we attempted a simple bind with
|
|
// dummy credentials over cleartext to see whether the server refused
|
|
// it (RFC 4513 requires refusing auth over insecure channels when
|
|
// policy demands it, though in practice most deployments don't).
|
|
PlaintextBindTested bool `json:"plaintext_bind_tested,omitempty"`
|
|
// PlaintextBindAccepted is true when the server did NOT refuse the
|
|
// cleartext bind with "confidentiality required" (resultCode 13).
|
|
// A bad-credentials response (49) counts as "accepted the attempt",
|
|
// which is the insecure-posture signal we want to flag.
|
|
PlaintextBindAccepted bool `json:"plaintext_bind_accepted,omitempty"`
|
|
|
|
// Bind test (run only when bind_dn/bind_password options provided).
|
|
BindAttempted bool `json:"bind_attempted,omitempty"`
|
|
BindOK bool `json:"bind_ok,omitempty"`
|
|
BindError string `json:"bind_error,omitempty"`
|
|
|
|
// Read access test on base_dn (run only when base_dn provided AND the
|
|
// authenticated bind above succeeded, unless base_dn was meant to be
|
|
// anonymously readable).
|
|
BaseReadAttempted bool `json:"base_read_attempted,omitempty"`
|
|
BaseReadOK bool `json:"base_read_ok,omitempty"`
|
|
BaseReadEntries int `json:"base_read_entries,omitempty"`
|
|
BaseReadError string `json:"base_read_error,omitempty"`
|
|
|
|
ElapsedMS int64 `json:"elapsed_ms"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type ReachabilitySpan struct {
|
|
HasIPv4 bool `json:"has_ipv4"`
|
|
HasIPv6 bool `json:"has_ipv6"`
|
|
// EncryptedReachable is true when at least one endpoint offered encrypted
|
|
// transport (LDAPS or LDAP+StartTLS).
|
|
EncryptedReachable bool `json:"encrypted_reachable"`
|
|
// PlainOnlyReachable is true when only cleartext endpoints responded.
|
|
PlainOnlyReachable bool `json:"plain_only_reachable"`
|
|
}
|
|
|
|
// Issue is a structured finding attached to the observation so the rule and
|
|
// the HTML report can both consume them without re-deriving logic.
|
|
type Issue struct {
|
|
Code string `json:"code"`
|
|
Severity string `json:"severity"` // "info" | "warn" | "crit"
|
|
Message string `json:"message"`
|
|
Fix string `json:"fix,omitempty"`
|
|
Endpoint string `json:"endpoint,omitempty"`
|
|
}
|
|
|
|
// Severities (string for stable JSON, independent of sdk.Status numeric values).
|
|
const (
|
|
SeverityInfo = "info"
|
|
SeverityWarn = "warn"
|
|
SeverityCrit = "crit"
|
|
)
|
|
|
|
// Issue codes.
|
|
const (
|
|
CodeNoSRV = "ldap.no_srv"
|
|
CodeSRVServfail = "ldap.srv.servfail"
|
|
CodeTCPUnreachable = "ldap.tcp.unreachable"
|
|
CodeAllEndpointsDown = "ldap.all_endpoints_down"
|
|
CodeNoEncryptedEndpoint = "ldap.no_encrypted_endpoint"
|
|
CodeStartTLSMissing = "ldap.starttls.missing"
|
|
CodeStartTLSFailed = "ldap.starttls.handshake_failed"
|
|
CodeLDAPSHandshakeFailed = "ldap.ldaps.handshake_failed"
|
|
CodePlainBindAccepted = "ldap.plain_bind.accepted"
|
|
CodeAnonymousSearchAllowed = "ldap.anon.search_allowed"
|
|
CodeRootDSEUnreadable = "ldap.rootdse.unreadable"
|
|
CodeNoSASL = "ldap.sasl.none"
|
|
CodeSASLPlainOnly = "ldap.sasl.plain_only"
|
|
CodeSASLNoStrongMech = "ldap.sasl.no_strong_mech"
|
|
CodeLegacyLDAPv2 = "ldap.legacy_v2"
|
|
CodeStartTLSOnLDAPS = "ldap.starttls.on_ldaps"
|
|
CodeNoIPv6 = "ldap.no_ipv6"
|
|
CodeBindFailed = "ldap.bind.failed"
|
|
CodeBindOK = "ldap.bind.ok"
|
|
CodeBaseReadFailed = "ldap.base_read.failed"
|
|
CodeBaseReadOK = "ldap.base_read.ok"
|
|
CodeNoNamingContext = "ldap.rootdse.no_naming_context"
|
|
)
|