checker-ldap/checker/types.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"
)