// 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" )