Initial commit
This commit is contained in:
commit
beca2fd7eb
21 changed files with 2698 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-ldap
|
||||
checker-ldap.so
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-ldap .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-ldap /checker-ldap
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/checker-ldap"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CHECKER_NAME := checker-ldap
|
||||
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
|
||||
CHECKER_VERSION ?= custom-build
|
||||
|
||||
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||
|
||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||
|
||||
.PHONY: all plugin docker test clean
|
||||
|
||||
all: $(CHECKER_NAME)
|
||||
|
||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||
|
||||
plugin: $(CHECKER_NAME).so
|
||||
|
||||
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||
|
||||
docker:
|
||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||
|
||||
test:
|
||||
go test -tags standalone ./...
|
||||
|
||||
clean:
|
||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||
31
NOTICE
Normal file
31
NOTICE
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
checker-ldap
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
This product is licensed under the MIT License (see LICENSE).
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Third-party notices
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product includes software developed as part of the checker-sdk-go
|
||||
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||
under the Apache License, Version 2.0:
|
||||
|
||||
checker-sdk-go
|
||||
Copyright 2020-2026 The happyDomain Authors
|
||||
|
||||
This product includes software from the miekg/dns project
|
||||
(https://github.com/miekg/dns), licensed under the BSD 3-Clause License:
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
Copyright (c) 2011 Miek Gieben. All rights reserved.
|
||||
Copyright (c) 2014 CloudFlare. All rights reserved.
|
||||
|
||||
This product includes software from the go-ldap/ldap project
|
||||
(https://github.com/go-ldap/ldap), licensed under the MIT License:
|
||||
|
||||
Copyright (c) 2011-2015 Michael Mitton
|
||||
Copyright (c) 2015-2016 The go-ldap Authors.
|
||||
|
||||
You may obtain a copy of the Apache License 2.0 at:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
99
README.md
Normal file
99
README.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# checker-ldap
|
||||
|
||||
LDAP directory checker for [happyDomain](https://www.happydomain.org/).
|
||||
|
||||
Probes a domain's LDAP deployment end-to-end: SRV discovery
|
||||
(`_ldap._tcp`, `_ldaps._tcp`), transport security (StartTLS per RFC 2830,
|
||||
implicit TLS on port 636), RootDSE introspection (supportedSASLMechanisms,
|
||||
supportedControl, supportedLDAPVersion, namingContexts, vendor
|
||||
fingerprint), anonymous exposure (anonymous bind + baseObject search),
|
||||
plaintext-bind refusal posture, and -- when credentials are supplied --
|
||||
an authenticated bind with an optional `baseObject` read on a base DN.
|
||||
|
||||
TLS certificate chain / SAN / expiry / cipher posture is **out of scope**
|
||||
-- the dedicated TLS checker handles that. This checker only confirms that
|
||||
a TLS session can be established and records the negotiated TLS version
|
||||
and cipher for context.
|
||||
|
||||
We publish each probed endpoint as a `DiscoveryEntry` of type
|
||||
`tls.endpoint.v1` so that `checker-tls` (or any other consumer of that
|
||||
contract) can run TLS posture checks against them without redoing the
|
||||
SRV lookup. For `_ldap._tcp` targets we emit `STARTTLS: "ldap"` with
|
||||
`RequireSTARTTLS: true`, so a misconfigured server that later drops
|
||||
StartTLS shows up as a CRIT, not a WARN. For `_ldaps._tcp` we emit
|
||||
direct-TLS endpoints (`STARTTLS: ""`).
|
||||
|
||||
The TLS checker's resulting observations (under the `tls_probes` key)
|
||||
are folded back into our rule aggregation and HTML report via the SDK's
|
||||
`ObservationGetter.GetRelated` / `ReportContext.Related` path: a bad
|
||||
certificate on an LDAP endpoint shows up on the LDAP service page, not
|
||||
only in a separate TLS view.
|
||||
|
||||
## What it checks
|
||||
|
||||
For each of `_ldap._tcp` (with fallback to port 389) and `_ldaps._tcp`
|
||||
(fallback to port 636):
|
||||
|
||||
- **Reachability**: TCP connect on each resolved A/AAAA address, per
|
||||
IP family, timing captured.
|
||||
- **Transport security**:
|
||||
- On `_ldap._tcp`: whether the server advertises StartTLS in its
|
||||
RootDSE `supportedExtension` (OID 1.3.6.1.4.1.1466.20037), whether
|
||||
the StartTLS upgrade succeeds, and whether cleartext simple binds
|
||||
are refused with `confidentialityRequired` (resultCode 13) per
|
||||
RFC 4513 §5.1.2.
|
||||
- On `_ldaps._tcp`: whether the implicit TLS handshake succeeds.
|
||||
- **RootDSE introspection**:
|
||||
- `supportedLDAPVersion` -- flags a legacy LDAPv2 advertisement.
|
||||
- `supportedSASLMechanisms` -- warns when only PLAIN/LOGIN are offered
|
||||
and when no strong mechanism (SCRAM-*, EXTERNAL, GSSAPI) is present.
|
||||
- `supportedControl`, `supportedExtension`, `namingContexts`,
|
||||
`vendorName`, `vendorVersion` -- captured for the report.
|
||||
- **Anonymous exposure**:
|
||||
- Anonymous bind attempted; result noted.
|
||||
- When anonymous bind succeeds and at least one naming context is
|
||||
advertised, a `baseObject` search is issued on the first naming
|
||||
context. Any returned entry is flagged as
|
||||
`ldap.anon.search_allowed` -- the DIT is enumerable without
|
||||
credentials.
|
||||
- **Credential test (optional)**: when `bind_dn` and `bind_password` are
|
||||
supplied, a simple bind is performed **only on a TLS-protected
|
||||
channel**. When the bind succeeds and `base_dn` is supplied, a
|
||||
`baseObject` search is performed on that DN to confirm the account
|
||||
has read access to the intended subtree.
|
||||
|
||||
## Most common failure scenarios (addressed in the report)
|
||||
|
||||
1. **No encrypted endpoint reachable** → `ldap.no_encrypted_endpoint` /
|
||||
CRIT. Operator must enable either LDAPS or StartTLS.
|
||||
2. **StartTLS not offered on 389** → `ldap.starttls.missing` / CRIT.
|
||||
Server-specific remediation included (OpenLDAP, 389-ds).
|
||||
3. **StartTLS advertised but upgrade fails** →
|
||||
`ldap.starttls.handshake_failed` / CRIT. Hints to run the TLS
|
||||
checker for cipher/cert details.
|
||||
4. **Cleartext bind accepted on 389 without StartTLS** →
|
||||
`ldap.plain_bind.accepted` / CRIT. Remediation via `olcSecurity` on
|
||||
OpenLDAP, `require_tls` on 389-ds.
|
||||
5. **LDAPS handshake fails on 636** → `ldap.ldaps.handshake_failed` /
|
||||
CRIT.
|
||||
6. **Anonymous search exposes DIT** → `ldap.anon.search_allowed` / WARN.
|
||||
7. **Only PLAIN/LOGIN SASL offered** → `ldap.sasl.plain_only` / WARN.
|
||||
8. **LDAPv2 still advertised** → `ldap.legacy_v2` / WARN.
|
||||
9. **RootDSE unreadable on an otherwise working endpoint** →
|
||||
`ldap.rootdse.unreadable` / WARN.
|
||||
10. **Provided bind DN / password fail** → `ldap.bind.failed` / CRIT --
|
||||
surfaces credential / lockout issues immediately.
|
||||
|
||||
## Options
|
||||
|
||||
| Id | Required | Description |
|
||||
|-----------------|----------|-----------------------------------------------------------------------------|
|
||||
| `domain` | yes | Auto-filled from the service scope (domain name). |
|
||||
| `timeout` | no | Per-endpoint timeout in seconds (default: 10). |
|
||||
| `bind_dn` | no | DN to bind as. Used only when `bind_password` is also set. |
|
||||
| `bind_password` | no | Secret. Bound only after TLS is established; never sent over cleartext. |
|
||||
| `base_dn` | no | Base DN to test read access against. Requires a successful authenticated bind. |
|
||||
|
||||
## License
|
||||
|
||||
MIT (see `LICENSE` and `NOTICE`).
|
||||
485
checker/collect.go
Normal file
485
checker/collect.go
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
ldapv3 "github.com/go-ldap/ldap/v3"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// tlsProbeConfig builds a permissive *tls.Config for probing: hostname
|
||||
// verification is skipped because cert validation is the TLS checker's
|
||||
// job. We only care that a TLS session can be established at all.
|
||||
func tlsProbeConfig(serverName string) *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, //nolint:gosec -- cert validation is the TLS checker's job
|
||||
MinVersion: tls.VersionTLS10,
|
||||
}
|
||||
}
|
||||
|
||||
// Collect runs the full LDAP probe for a domain.
|
||||
func (p *ldapProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
domain, _ := sdk.GetOption[string](opts, "domain")
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
if domain == "" {
|
||||
return nil, fmt.Errorf("domain is required")
|
||||
}
|
||||
|
||||
bindDN, _ := sdk.GetOption[string](opts, "bind_dn")
|
||||
bindPassword, _ := sdk.GetOption[string](opts, "bind_password")
|
||||
baseDN, _ := sdk.GetOption[string](opts, "base_dn")
|
||||
|
||||
timeoutSecs := sdk.GetFloatOption(opts, "timeout", 10)
|
||||
if timeoutSecs < 1 {
|
||||
timeoutSecs = 10
|
||||
}
|
||||
perEndpoint := time.Duration(timeoutSecs * float64(time.Second))
|
||||
|
||||
data := &LDAPData{
|
||||
Domain: domain,
|
||||
BaseDN: baseDN,
|
||||
RunAt: time.Now().UTC().Format(time.RFC3339),
|
||||
SRV: SRVLookup{Errors: map[string]string{}},
|
||||
}
|
||||
|
||||
resolver := net.DefaultResolver
|
||||
|
||||
var srvWG sync.WaitGroup
|
||||
var srvErrMu sync.Mutex
|
||||
srvWG.Add(2)
|
||||
lookup := func(prefix string, dst *[]SRVRecord) {
|
||||
defer srvWG.Done()
|
||||
records, err := lookupSRV(ctx, resolver, prefix, domain)
|
||||
if err != nil {
|
||||
srvErrMu.Lock()
|
||||
data.SRV.Errors[prefix] = err.Error()
|
||||
srvErrMu.Unlock()
|
||||
return
|
||||
}
|
||||
*dst = records
|
||||
}
|
||||
go lookup("_ldap._tcp.", &data.SRV.LDAP)
|
||||
go lookup("_ldaps._tcp.", &data.SRV.LDAPS)
|
||||
srvWG.Wait()
|
||||
|
||||
totalSRV := len(data.SRV.LDAP) + len(data.SRV.LDAPS)
|
||||
if totalSRV == 0 {
|
||||
data.SRV.FallbackProbed = true
|
||||
data.SRV.LDAP = []SRVRecord{{Target: domain, Port: 389}}
|
||||
data.SRV.LDAPS = []SRVRecord{{Target: domain, Port: 636}}
|
||||
}
|
||||
|
||||
resolveAll(ctx, resolver, data.SRV.LDAP, data.SRV.LDAPS)
|
||||
|
||||
data.BindTested = bindDN != "" && bindPassword != ""
|
||||
|
||||
var plainEndpoints, ldapsEndpoints []EndpointProbe
|
||||
var probeWG sync.WaitGroup
|
||||
probeWG.Add(2)
|
||||
go func() {
|
||||
defer probeWG.Done()
|
||||
plainEndpoints = probeSet(ctx, domain, ModePlain, "_ldap._tcp", data.SRV.LDAP, perEndpoint, bindDN, bindPassword, baseDN)
|
||||
}()
|
||||
go func() {
|
||||
defer probeWG.Done()
|
||||
ldapsEndpoints = probeSet(ctx, domain, ModeLDAPS, "_ldaps._tcp", data.SRV.LDAPS, perEndpoint, bindDN, bindPassword, baseDN)
|
||||
}()
|
||||
probeWG.Wait()
|
||||
data.Endpoints = append(plainEndpoints, ldapsEndpoints...)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func probeSet(ctx context.Context, domain string, mode LDAPMode, prefix string, records []SRVRecord, timeout time.Duration, bindDN, bindPassword, baseDN string) []EndpointProbe {
|
||||
type task struct {
|
||||
rec SRVRecord
|
||||
addr *probeAddr // nil means the SRV target has no A/AAAA records
|
||||
}
|
||||
var tasks []task
|
||||
for _, rec := range records {
|
||||
addrs := addressesForProbe(rec)
|
||||
if len(addrs) == 0 {
|
||||
tasks = append(tasks, task{rec: rec})
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
tasks = append(tasks, task{rec: rec, addr: &a})
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]EndpointProbe, len(tasks))
|
||||
var wg sync.WaitGroup
|
||||
for i, t := range tasks {
|
||||
wg.Add(1)
|
||||
go func(i int, t task) {
|
||||
defer wg.Done()
|
||||
if t.addr == nil {
|
||||
results[i] = EndpointProbe{
|
||||
Mode: mode,
|
||||
SRVPrefix: prefix,
|
||||
Target: t.rec.Target,
|
||||
Port: t.rec.Port,
|
||||
Error: "no A/AAAA records for target",
|
||||
}
|
||||
return
|
||||
}
|
||||
results[i] = probeEndpoint(ctx, domain, mode, prefix, t.rec, t.addr.ip, t.addr.isV6, timeout, bindDN, bindPassword, baseDN)
|
||||
}(i, t)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
type probeAddr struct {
|
||||
ip string
|
||||
isV6 bool
|
||||
}
|
||||
|
||||
func addressesForProbe(rec SRVRecord) []probeAddr {
|
||||
var out []probeAddr
|
||||
for _, ip := range rec.IPv4 {
|
||||
out = append(out, probeAddr{ip: ip, isV6: false})
|
||||
}
|
||||
for _, ip := range rec.IPv6 {
|
||||
out = append(out, probeAddr{ip: ip, isV6: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// probeEndpoint runs the full probe on a single (host, ip, port) tuple:
|
||||
// TCP connect → optional StartTLS or direct-TLS handshake → RootDSE read →
|
||||
// plaintext-bind posture check (only on LDAP:389 before TLS) → optional
|
||||
// authenticated bind + base-DN read.
|
||||
func probeEndpoint(ctx context.Context, domain string, mode LDAPMode, prefix string, rec SRVRecord, ip string, isV6 bool, timeout time.Duration, bindDN, bindPassword, baseDN string) EndpointProbe {
|
||||
start := time.Now()
|
||||
result := EndpointProbe{
|
||||
Mode: mode,
|
||||
SRVPrefix: prefix,
|
||||
Target: rec.Target,
|
||||
Port: rec.Port,
|
||||
Address: net.JoinHostPort(ip, strconv.Itoa(int(rec.Port))),
|
||||
IsIPv6: isV6,
|
||||
}
|
||||
defer func() { result.ElapsedMS = time.Since(start).Milliseconds() }()
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := &net.Dialer{}
|
||||
rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address)
|
||||
if err != nil {
|
||||
result.Error = "tcp: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.TCPConnected = true
|
||||
defer rawConn.Close()
|
||||
// renewDeadline keeps the underlying TCP deadline rolling per major
|
||||
// step so a slow TLS handshake doesn't starve the RootDSE / bind /
|
||||
// base-read calls that follow.
|
||||
renewDeadline := func() { _ = rawConn.SetDeadline(time.Now().Add(timeout)) }
|
||||
renewDeadline()
|
||||
|
||||
// For the plaintext-bind posture check on Mode=ldap, we first spin up
|
||||
// a separate short-lived connection before upgrading this one to TLS.
|
||||
// A single raw connection can't both "test cleartext bind refusal" and
|
||||
// "then StartTLS" cleanly -- once we've bound we'd skew RootDSE results.
|
||||
if mode == ModePlain {
|
||||
result.PlaintextBindTested, result.PlaintextBindAccepted = probePlaintextBindRefusal(result.Address, timeout)
|
||||
}
|
||||
|
||||
// Establish the LDAP session we'll use for the rest of the probe.
|
||||
var conn *ldapv3.Conn
|
||||
if mode == ModeLDAPS {
|
||||
tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
result.Error = "tls-handshake: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.TLSEstablished = true
|
||||
state := tlsConn.ConnectionState()
|
||||
result.TLSVersion = tls.VersionName(state.Version)
|
||||
result.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
|
||||
renewDeadline()
|
||||
conn = ldapv3.NewConn(tlsConn, true)
|
||||
} else {
|
||||
conn = ldapv3.NewConn(rawConn, false)
|
||||
}
|
||||
conn.Start()
|
||||
defer conn.Close()
|
||||
conn.SetTimeout(timeout)
|
||||
|
||||
// Try RootDSE over the native transport first -- works on LDAPS straight
|
||||
// away, and on LDAP it reveals the supported extensions including
|
||||
// StartTLS capability before we attempt the upgrade.
|
||||
renewDeadline()
|
||||
readRootDSE(conn, &result)
|
||||
|
||||
if mode == ModePlain {
|
||||
// Detect StartTLS advertisement in supportedExtension. RFC 4511
|
||||
// says the RootDSE MAY advertise "1.3.6.1.4.1.1466.20037".
|
||||
offersStartTLS := stringListContainsFold(result.SupportedExtension, "1.3.6.1.4.1.1466.20037")
|
||||
if offersStartTLS {
|
||||
result.StartTLSOffered = true
|
||||
if err := conn.StartTLS(tlsProbeConfig(domain)); err != nil {
|
||||
result.Error = "starttls: " + err.Error()
|
||||
} else {
|
||||
result.StartTLSUpgraded = true
|
||||
result.TLSEstablished = true
|
||||
renewDeadline()
|
||||
// go-ldap doesn't expose the *tls.ConnectionState directly.
|
||||
// Fall back to inspecting the underlying conn via TLSConnectionState.
|
||||
if state, ok := conn.TLSConnectionState(); ok {
|
||||
result.TLSVersion = tls.VersionName(state.Version)
|
||||
result.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
|
||||
}
|
||||
// Post-TLS SASL refresh: some servers only publish the
|
||||
// strong mechanisms once the channel is encrypted. We only
|
||||
// need the mechanisms list -- naming contexts, LDAP
|
||||
// version and vendor strings don't change.
|
||||
refreshSASLMechanisms(conn, &result)
|
||||
}
|
||||
} else if !result.RootDSERead {
|
||||
result.Error = "rootdse-unreadable: RootDSE could not be read"
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous bind + search -- we try unconditionally so we can flag
|
||||
// exposure.
|
||||
renewDeadline()
|
||||
anonBindOK := conn.Bind("", "") == nil
|
||||
result.AnonymousBindAllowed = anonBindOK
|
||||
if anonBindOK && len(result.NamingContexts) > 0 {
|
||||
// baseObject search returns 0 or 1 entries -- we only want to
|
||||
// detect whether an anonymous query can peek at DIT contents.
|
||||
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
||||
result.NamingContexts[0],
|
||||
ldapv3.ScopeBaseObject,
|
||||
ldapv3.NeverDerefAliases,
|
||||
1, int(timeout.Seconds()), false,
|
||||
"(objectClass=*)",
|
||||
[]string{"1.1"}, // request no attributes
|
||||
nil,
|
||||
))
|
||||
if err == nil && sr != nil && len(sr.Entries) > 0 {
|
||||
result.AnonymousSearchAllowed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated bind + base DN read (only when caller provided creds
|
||||
// AND we are on an encrypted channel -- never ship a password over
|
||||
// cleartext).
|
||||
if bindDN != "" && bindPassword != "" && result.TLSEstablished {
|
||||
result.BindAttempted = true
|
||||
renewDeadline()
|
||||
err := conn.Bind(bindDN, bindPassword)
|
||||
if err == nil {
|
||||
result.BindOK = true
|
||||
if baseDN != "" {
|
||||
result.BaseReadAttempted = true
|
||||
renewDeadline()
|
||||
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
||||
baseDN,
|
||||
ldapv3.ScopeBaseObject,
|
||||
ldapv3.NeverDerefAliases,
|
||||
1, int(timeout.Seconds()), false,
|
||||
"(objectClass=*)",
|
||||
[]string{"1.1"},
|
||||
nil,
|
||||
))
|
||||
if err != nil {
|
||||
result.BaseReadError = err.Error()
|
||||
} else if sr != nil {
|
||||
result.BaseReadOK = true
|
||||
result.BaseReadEntries = len(sr.Entries)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.BindError = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// probePlaintextBindRefusal opens a short-lived, fresh TCP connection and
|
||||
// attempts a simple bind with a fixed, syntactically-safe DN over cleartext.
|
||||
// We are not probing credentials -- we want to learn whether the server
|
||||
// refuses authentication on an unprotected link (RFC 4513 §5.1.2 calls this
|
||||
// "confidentialityRequired" / resultCode 13). Any response other than
|
||||
// resultCode 13 means the server will accept cleartext bind attempts.
|
||||
func probePlaintextBindRefusal(address string, timeout time.Duration) (tested, accepted bool) {
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
raw, err := dialer.Dial("tcp", address)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
defer raw.Close()
|
||||
_ = raw.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
conn := ldapv3.NewConn(raw, false)
|
||||
conn.Start()
|
||||
defer conn.Close()
|
||||
conn.SetTimeout(timeout)
|
||||
|
||||
tested = true
|
||||
// Fixed probe DN -- caller-supplied domain is not interpolated to avoid
|
||||
// LDAP DN injection. The server is expected to reject this DN regardless
|
||||
// of value; we only care whether it returns confidentialityRequired (13)
|
||||
// or attempts the bind anyway.
|
||||
err = conn.Bind("cn=checker-probe", "x-not-a-real-password-x")
|
||||
if err == nil {
|
||||
return tested, true
|
||||
}
|
||||
// resultCode 13 (confidentialityRequired) is the only response that
|
||||
// means the server actively refused to authenticate over cleartext.
|
||||
// Anything else (49 invalidCredentials, 32 noSuchObject, …) means the
|
||||
// server was willing to attempt the bind, which is the insecure
|
||||
// posture we want to flag.
|
||||
var lerr *ldapv3.Error
|
||||
if errors.As(err, &lerr) && lerr.ResultCode == ldapv3.LDAPResultConfidentialityRequired {
|
||||
return tested, false
|
||||
}
|
||||
return tested, true
|
||||
}
|
||||
|
||||
// readRootDSE performs a single RootDSE lookup and fills the matching
|
||||
// fields on ep. Failures are not fatal -- many hardened servers refuse
|
||||
// anonymous RootDSE reads; we just note that we couldn't read it.
|
||||
func readRootDSE(conn *ldapv3.Conn, ep *EndpointProbe) {
|
||||
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
||||
"",
|
||||
ldapv3.ScopeBaseObject,
|
||||
ldapv3.NeverDerefAliases,
|
||||
1, 5, false,
|
||||
"(objectClass=*)",
|
||||
[]string{
|
||||
"supportedLDAPVersion",
|
||||
"supportedSASLMechanisms",
|
||||
"supportedControl",
|
||||
"supportedExtension",
|
||||
"namingContexts",
|
||||
"vendorName",
|
||||
"vendorVersion",
|
||||
},
|
||||
nil,
|
||||
))
|
||||
if err != nil || sr == nil || len(sr.Entries) == 0 {
|
||||
return
|
||||
}
|
||||
ep.RootDSERead = true
|
||||
e := sr.Entries[0]
|
||||
ep.SupportedLDAPVersion = unique(append(ep.SupportedLDAPVersion, e.GetAttributeValues("supportedLDAPVersion")...))
|
||||
ep.SupportedSASLMechanisms = unique(append(ep.SupportedSASLMechanisms, e.GetAttributeValues("supportedSASLMechanisms")...))
|
||||
ep.SupportedControl = unique(append(ep.SupportedControl, e.GetAttributeValues("supportedControl")...))
|
||||
ep.SupportedExtension = unique(append(ep.SupportedExtension, e.GetAttributeValues("supportedExtension")...))
|
||||
ep.NamingContexts = unique(append(ep.NamingContexts, e.GetAttributeValues("namingContexts")...))
|
||||
if v := e.GetAttributeValue("vendorName"); v != "" {
|
||||
ep.VendorName = v
|
||||
}
|
||||
if v := e.GetAttributeValue("vendorVersion"); v != "" {
|
||||
ep.VendorVersion = v
|
||||
}
|
||||
}
|
||||
|
||||
// refreshSASLMechanisms re-queries the RootDSE after StartTLS to pick up any
|
||||
// SASL mechanisms the server only advertises over an encrypted channel.
|
||||
func refreshSASLMechanisms(conn *ldapv3.Conn, ep *EndpointProbe) {
|
||||
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
||||
"",
|
||||
ldapv3.ScopeBaseObject,
|
||||
ldapv3.NeverDerefAliases,
|
||||
1, 5, false,
|
||||
"(objectClass=*)",
|
||||
[]string{"supportedSASLMechanisms"},
|
||||
nil,
|
||||
))
|
||||
if err != nil || sr == nil || len(sr.Entries) == 0 {
|
||||
return
|
||||
}
|
||||
ep.SupportedSASLMechanisms = unique(append(ep.SupportedSASLMechanisms, sr.Entries[0].GetAttributeValues("supportedSASLMechanisms")...))
|
||||
}
|
||||
|
||||
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
|
||||
name := prefix + dns.Fqdn(domain)
|
||||
_, records, err := r.LookupSRV(ctx, "", "", name)
|
||||
if err != nil {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// RFC 2782: single record "." with port 0 means "service explicitly not
|
||||
// available at this domain". Treat that as "no records" for probing.
|
||||
if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]SRVRecord, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, SRVRecord{
|
||||
Target: strings.TrimSuffix(r.Target, "."),
|
||||
Port: r.Port,
|
||||
Priority: r.Priority,
|
||||
Weight: r.Weight,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveAll resolves A/AAAA for every record across all sets concurrently.
|
||||
// Each goroutine writes only to its own record, so no lock is needed.
|
||||
func resolveAll(ctx context.Context, r *net.Resolver, sets ...[]SRVRecord) {
|
||||
var wg sync.WaitGroup
|
||||
for _, records := range sets {
|
||||
for i := range records {
|
||||
wg.Add(1)
|
||||
go func(rec *SRVRecord) {
|
||||
defer wg.Done()
|
||||
ips, err := r.LookupIPAddr(ctx, rec.Target)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.IP.To4(); v4 != nil {
|
||||
rec.IPv4 = append(rec.IPv4, v4.String())
|
||||
} else {
|
||||
rec.IPv6 = append(rec.IPv6, ip.IP.String())
|
||||
}
|
||||
}
|
||||
}(&records[i])
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func stringListContainsFold(list []string, want string) bool {
|
||||
return slices.ContainsFunc(list, func(s string) bool { return strings.EqualFold(s, want) })
|
||||
}
|
||||
|
||||
func unique(list []string) []string {
|
||||
if len(list) <= 1 {
|
||||
return list
|
||||
}
|
||||
seen := make(map[string]struct{}, len(list))
|
||||
out := make([]string, 0, len(list))
|
||||
for _, s := range list {
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
69
checker/definition.go
Normal file
69
checker/definition.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is reported in CheckerDefinition.Version. Overridden at build time
|
||||
// by main / plugin.
|
||||
var Version = "built-in"
|
||||
|
||||
func (p *ldapProvider) Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "ldap",
|
||||
Name: "LDAP Directory",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.LDAP"},
|
||||
},
|
||||
HasHTMLReport: true,
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyLDAP},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
RunOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain",
|
||||
Type: "string",
|
||||
Label: "Domain",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "timeout",
|
||||
Type: "number",
|
||||
Label: "Per-endpoint timeout (seconds)",
|
||||
Default: 10,
|
||||
},
|
||||
{
|
||||
Id: "bind_dn",
|
||||
Type: "string",
|
||||
Label: "Bind DN",
|
||||
Placeholder: "cn=reader,dc=example,dc=com",
|
||||
Description: "Optional. When set (with bind_password), the checker performs an authenticated bind over TLS and reports whether the directory accepts the provided credentials.",
|
||||
},
|
||||
{
|
||||
Id: "bind_password",
|
||||
Type: "string",
|
||||
Label: "Bind password",
|
||||
Secret: true,
|
||||
Description: "Optional. Only used when bind_dn is set. The password is not persisted in the observation payload.",
|
||||
},
|
||||
{
|
||||
Id: "base_dn",
|
||||
Type: "string",
|
||||
Label: "Base DN (read test)",
|
||||
Placeholder: "dc=example,dc=com",
|
||||
Description: "Optional. When set, the checker runs a baseObject search on this DN after a successful bind to verify the account has read access. Falls back to an anonymous baseObject search when no bind DN is supplied.",
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: Rules(),
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 5 * time.Minute,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 12 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
47
checker/interactive.go
Normal file
47
checker/interactive.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//go:build standalone
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// RenderForm implements server.Interactive. The option set is the same one
|
||||
// /evaluate documents, so we reuse it directly (AutoFill hints are ignored
|
||||
// by the interactive HTML form, where the human types the value in).
|
||||
func (p *ldapProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
return p.Definition().Options.RunOpts
|
||||
}
|
||||
|
||||
// ParseForm implements server.Interactive. Collect handles its own SRV
|
||||
// and A/AAAA lookups, so the form only needs to forward the user-supplied
|
||||
// values -- no extra host-side resolution is required here.
|
||||
func (p *ldapProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||
if domain == "" {
|
||||
return nil, errors.New("domain is required")
|
||||
}
|
||||
opts := sdk.CheckerOptions{"domain": domain}
|
||||
if v := strings.TrimSpace(r.FormValue("timeout")); v != "" {
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("timeout must be a number")
|
||||
}
|
||||
opts["timeout"] = f
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("bind_dn")); v != "" {
|
||||
opts["bind_dn"] = v
|
||||
}
|
||||
if v := r.FormValue("bind_password"); v != "" {
|
||||
opts["bind_password"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("base_dn")); v != "" {
|
||||
opts["base_dn"] = v
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
63
checker/provider.go
Normal file
63
checker/provider.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &ldapProvider{}
|
||||
}
|
||||
|
||||
type ldapProvider struct{}
|
||||
|
||||
func (p *ldapProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyLDAP
|
||||
}
|
||||
|
||||
// DiscoverEntries implements sdk.DiscoveryPublisher.
|
||||
//
|
||||
// It publishes TLS endpoint contract entries for every SRV target we found,
|
||||
// so a downstream TLS checker can verify the certificate chain / SAN /
|
||||
// expiry on each one without re-doing the SRV lookup. The LDAP checker
|
||||
// itself only confirms a TLS session can be established -- certificate
|
||||
// posture lives in the TLS checker.
|
||||
//
|
||||
// SNI is set to the bare domain, which is usually the hostname clients
|
||||
// connect to. On _ldaps._tcp we emit direct-TLS endpoints (STARTTLS="").
|
||||
// On _ldap._tcp we emit STARTTLS="ldap" so the TLS checker performs an
|
||||
// RFC 2830 extended-op upgrade before the handshake. RequireSTARTTLS is
|
||||
// set to true on 389: a misconfigured server that drops StartTLS must
|
||||
// show up as a CRIT on the TLS side, not a WARN.
|
||||
func (p *ldapProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||
d, ok := data.(*LDAPData)
|
||||
if !ok || d == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var out []sdk.DiscoveryEntry
|
||||
emit := func(recs []SRVRecord, starttls string, require bool) error {
|
||||
for _, r := range recs {
|
||||
ep := tlsct.TLSEndpoint{
|
||||
Host: r.Target,
|
||||
Port: r.Port,
|
||||
SNI: d.Domain,
|
||||
STARTTLS: starttls,
|
||||
RequireSTARTTLS: require,
|
||||
}
|
||||
entry, err := tlsct.NewEntry(ep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := emit(d.SRV.LDAP, "ldap", true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := emit(d.SRV.LDAPS, "", false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
548
checker/report.go
Normal file
548
checker/report.go
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type reportFix struct {
|
||||
Severity string
|
||||
Code string
|
||||
Message string
|
||||
Fix string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type reportEndpoint struct {
|
||||
EndpointProbe
|
||||
ModeLabel string
|
||||
|
||||
// TLS posture (from a related tls_probes observation, when available).
|
||||
TLSPosture *reportTLSPosture
|
||||
|
||||
// Rendering helpers.
|
||||
AnyFail bool
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
}
|
||||
|
||||
type reportTLSPosture struct {
|
||||
CheckedAt time.Time
|
||||
ChainValid *bool
|
||||
HostnameMatch *bool
|
||||
NotAfter time.Time
|
||||
}
|
||||
|
||||
type reportSRVEntry struct {
|
||||
Prefix string
|
||||
Target string
|
||||
Port uint16
|
||||
Priority uint16
|
||||
Weight uint16
|
||||
IPv4 []string
|
||||
IPv6 []string
|
||||
}
|
||||
|
||||
type reportData struct {
|
||||
Domain string
|
||||
BaseDN string
|
||||
RunAt string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
HasIssues bool
|
||||
Fixes []reportFix
|
||||
SRV []reportSRVEntry
|
||||
FallbackProbed bool
|
||||
Endpoints []reportEndpoint
|
||||
HasIPv4 bool
|
||||
HasIPv6 bool
|
||||
EncryptedReachable bool
|
||||
PlainOnlyReachable bool
|
||||
BindTested bool
|
||||
HasTLSPosture bool
|
||||
}
|
||||
|
||||
var reportTpl = template.Must(template.New("ldap").Funcs(template.FuncMap{
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
"deref": func(b *bool) bool { return b != nil && *b },
|
||||
"join": func(sep string, list []string) string { return strings.Join(list, sep) },
|
||||
"upper": strings.ToUpper,
|
||||
}).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LDAP Report -- {{.Domain}}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
body { margin: 0; padding: 1rem; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
|
||||
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
|
||||
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
|
||||
|
||||
.hd, .section, details {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
|
||||
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
|
||||
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
|
||||
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: .2em .65em; border-radius: 9999px;
|
||||
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
|
||||
}
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.fail { background: #fee2e2; color: #991b1b; }
|
||||
.muted { background: #e5e7eb; color: #374151; }
|
||||
.info { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||
|
||||
summary {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
|
||||
details[open] > summary::before { transform: rotate(90deg); }
|
||||
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
|
||||
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
|
||||
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
||||
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
|
||||
.fix {
|
||||
border-left: 3px solid #dc2626;
|
||||
padding: .5rem .75rem; margin-bottom: .5rem;
|
||||
background: #fef2f2; border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
|
||||
.fix.info { border-color: #3b82f6; background: #eff6ff; }
|
||||
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
|
||||
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
|
||||
.fix .how { font-size: .88rem; }
|
||||
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
|
||||
|
||||
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
|
||||
.chip {
|
||||
display: inline-block; padding: .12em .5em;
|
||||
background: #e0e7ff; color: #3730a3;
|
||||
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
|
||||
}
|
||||
.chip.plain { background: #fee2e2; color: #991b1b; }
|
||||
.chip.scram { background: #d1fae5; color: #065f46; }
|
||||
.chip.strong { background: #d1fae5; color: #065f46; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
|
||||
.kv dt { color: #6b7280; }
|
||||
.kv dd { margin: 0; }
|
||||
|
||||
.note { color: #6b7280; font-size: .85rem; }
|
||||
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
|
||||
.check-ok { color: #059669; }
|
||||
.check-fail { color: #dc2626; }
|
||||
.check-warn { color: #b45309; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>LDAP -- <code>{{.Domain}}</code></h1>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
<div class="meta">
|
||||
{{if .EncryptedReachable}}<span class="badge ok" style="margin-right:.25rem">encrypted OK</span>{{else}}<span class="badge fail" style="margin-right:.25rem">no encryption</span>{{end}}
|
||||
{{if .HasIPv4}}<span class="badge muted">IPv4</span>{{end}}
|
||||
{{if .HasIPv6}}<span class="badge muted">IPv6</span>{{end}}
|
||||
{{if .BindTested}}<span class="badge info" style="margin-left:.25rem">bind test</span>{{end}}
|
||||
</div>
|
||||
<div class="meta">Checked {{.RunAt}}{{if .BaseDN}} · base <code>{{.BaseDN}}</code>{{end}}</div>
|
||||
</div>
|
||||
|
||||
{{if .HasIssues}}
|
||||
<div class="section">
|
||||
<h2>What to fix</h2>
|
||||
{{range .Fixes}}
|
||||
<div class="fix {{.Severity}}">
|
||||
<div class="code">{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="section">
|
||||
<h2>DNS / SRV</h2>
|
||||
{{if .FallbackProbed}}
|
||||
<p class="note">No SRV records published -- fell back to probing the bare domain on default ports 389 / 636.</p>
|
||||
{{else if .SRV}}
|
||||
<table>
|
||||
<tr><th>Record</th><th>Target</th><th>Port</th><th>Prio/Weight</th><th>IPv4</th><th>IPv6</th></tr>
|
||||
{{range .SRV}}
|
||||
<tr>
|
||||
<td><code>{{.Prefix}}</code></td>
|
||||
<td><code>{{.Target}}</code></td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{.Priority}}/{{.Weight}}</td>
|
||||
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
|
||||
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="note">No SRV records found.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Endpoints}}
|
||||
<div class="section">
|
||||
<h2>Endpoints ({{len .Endpoints}})</h2>
|
||||
{{range .Endpoints}}
|
||||
<details{{if .AnyFail}} open{{end}}>
|
||||
<summary>
|
||||
<span class="conn-addr">{{.ModeLabel}} · {{.Address}}</span>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
</summary>
|
||||
<div class="details-body">
|
||||
<dl class="kv">
|
||||
{{if .SRVPrefix}}<dt>SRV</dt><dd><code>{{.SRVPrefix}}</code> → <code>{{.Target}}:{{.Port}}</code></dd>{{end}}
|
||||
<dt>Family</dt><dd>{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}</dd>
|
||||
<dt>TCP</dt><dd>{{if .TCPConnected}}<span class="check-ok">✓ connected</span>{{else}}<span class="check-fail">✗ failed</span>{{end}}</dd>
|
||||
|
||||
{{if eq .Mode "ldap"}}
|
||||
<dt>StartTLS</dt><dd>
|
||||
{{if .StartTLSOffered}}<span class="check-ok">✓ offered</span>{{else}}<span class="check-fail">✗ not offered</span>{{end}}
|
||||
{{if .StartTLSUpgraded}} · <span class="check-ok">upgraded</span>{{else if .StartTLSOffered}} · <span class="check-fail">upgrade failed</span>{{end}}
|
||||
</dd>
|
||||
<dt>Cleartext bind</dt><dd>
|
||||
{{if not .PlaintextBindTested}}<span class="note">not tested</span>
|
||||
{{else if .PlaintextBindAccepted}}<span class="check-fail">✗ accepted (insecure)</span>
|
||||
{{else}}<span class="check-ok">✓ refused (confidentiality required)</span>{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
|
||||
<dt>TLS</dt><dd>
|
||||
{{if .TLSEstablished}}<span class="check-ok">✓ {{.TLSVersion}}{{if .TLSCipher}} — {{.TLSCipher}}{{end}}</span>
|
||||
{{else}}<span class="check-fail">✗ plaintext</span>{{end}}
|
||||
</dd>
|
||||
|
||||
<dt>RootDSE</dt><dd>
|
||||
{{if .RootDSERead}}<span class="check-ok">✓ read</span>
|
||||
{{else}}<span class="check-warn">✗ unreadable</span>{{end}}
|
||||
{{if .VendorName}} · <code>{{.VendorName}}{{if .VendorVersion}} {{.VendorVersion}}{{end}}</code>{{end}}
|
||||
{{if .SupportedLDAPVersion}} · LDAPv{{join "," .SupportedLDAPVersion}}{{end}}
|
||||
</dd>
|
||||
|
||||
{{if .NamingContexts}}
|
||||
<dt>Naming contexts</dt><dd>
|
||||
<div class="chiprow">{{range .NamingContexts}}<span class="chip"><code>{{.}}</code></span>{{end}}</div>
|
||||
</dd>
|
||||
{{end}}
|
||||
|
||||
{{if .SupportedSASLMechanisms}}
|
||||
<dt>SASL</dt><dd>
|
||||
<div class="chiprow">
|
||||
{{range .SupportedSASLMechanisms}}
|
||||
{{$u := upper .}}
|
||||
{{if or (eq $u "PLAIN") (eq $u "LOGIN")}}<span class="chip plain">{{.}}</span>
|
||||
{{else if or (hasPrefix $u "SCRAM-") (eq $u "EXTERNAL") (eq $u "GSSAPI") (eq $u "GSS-SPNEGO")}}<span class="chip strong">{{.}}</span>
|
||||
{{else}}<span class="chip">{{.}}</span>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</dd>
|
||||
{{end}}
|
||||
|
||||
<dt>Anonymous</dt><dd>
|
||||
{{if .AnonymousBindAllowed}}<span class="check-warn">bind allowed</span>
|
||||
{{else}}<span class="check-ok">✓ bind refused</span>{{end}}
|
||||
{{if .AnonymousSearchAllowed}} · <span class="check-fail">search allowed (DIT enumerable)</span>{{end}}
|
||||
</dd>
|
||||
|
||||
{{if .BindAttempted}}
|
||||
<dt>Bind as DN</dt><dd>
|
||||
{{if .BindOK}}<span class="check-ok">✓ succeeded</span>
|
||||
{{else}}<span class="check-fail">✗ {{.BindError}}</span>{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
{{if .BaseReadAttempted}}
|
||||
<dt>Base read</dt><dd>
|
||||
{{if .BaseReadOK}}<span class="check-ok">✓ {{.BaseReadEntries}} entry/entries</span>
|
||||
{{else}}<span class="check-fail">✗ {{.BaseReadError}}</span>{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
|
||||
{{with .TLSPosture}}
|
||||
<dt>TLS cert</dt><dd>
|
||||
{{if .ChainValid}}
|
||||
{{if deref .ChainValid}}<span class="check-ok">✓ chain valid</span>{{else}}<span class="check-fail">✗ chain invalid</span>{{end}}
|
||||
{{end}}
|
||||
{{if .HostnameMatch}}
|
||||
· {{if deref .HostnameMatch}}<span class="check-ok">✓ hostname match</span>{{else}}<span class="check-fail">✗ hostname mismatch</span>{{end}}
|
||||
{{end}}
|
||||
{{if not .NotAfter.IsZero}}
|
||||
· expires <code>{{.NotAfter.Format "2006-01-02"}}</code>
|
||||
{{end}}
|
||||
{{if not .CheckedAt.IsZero}}
|
||||
<div class="note">TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}</div>
|
||||
{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
|
||||
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="footer">{{if .HasTLSPosture}}Certificate posture above comes from the TLS checker, which probed the same endpoints after we discovered them.{{else}}For certificate chain, SAN match, expiry, and cipher posture, run the TLS checker on the same ports.{{end}}</p>
|
||||
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// GetHTMLReport implements sdk.CheckerHTMLReporter. It folds in related TLS
|
||||
// observations so the LDAP service page shows cert posture directly, and
|
||||
// uses ReportContext.States() as the sole source of hint/fix/severity text:
|
||||
// when no states are threaded through, the page renders a data-only view of
|
||||
// the raw observation without any derived judgment.
|
||||
func (p *ldapProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
|
||||
var d LDAPData
|
||||
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
|
||||
return "", fmt.Errorf("unmarshal ldap observation: %w", err)
|
||||
}
|
||||
view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States())
|
||||
return renderReport(view)
|
||||
}
|
||||
|
||||
func renderReport(view reportData) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := reportTpl.Execute(&buf, view); err != nil {
|
||||
return "", fmt.Errorf("render ldap report: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func buildReportData(d *LDAPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData {
|
||||
tlsByAddr := foldTLSRelated(related)
|
||||
|
||||
// Coverage is a pure raw-view aggregation over endpoint facts (counts and
|
||||
// booleans, no severity). It feeds the IPv4/IPv6 badges and the
|
||||
// "encrypted OK" vs "no encryption" hint in the page header.
|
||||
cov := coverageView(d)
|
||||
|
||||
view := reportData{
|
||||
Domain: d.Domain,
|
||||
BaseDN: d.BaseDN,
|
||||
RunAt: d.RunAt,
|
||||
FallbackProbed: d.SRV.FallbackProbed,
|
||||
HasIPv4: cov.HasIPv4,
|
||||
HasIPv6: cov.HasIPv6,
|
||||
EncryptedReachable: cov.EncryptedReachable,
|
||||
PlainOnlyReachable: cov.PlainOnlyReachable,
|
||||
BindTested: d.BindTested,
|
||||
HasTLSPosture: len(tlsByAddr) > 0,
|
||||
}
|
||||
|
||||
// Hint / fix / severity text is populated *only* from rule output threaded
|
||||
// through ReportContext.States(). When the host has not piped Evaluate →
|
||||
// Report, the "What to fix" section is omitted entirely and the page
|
||||
// falls back to a raw data-only view of the observation.
|
||||
applyFixes(&view, fixesFromStates(states))
|
||||
|
||||
// SRV rows.
|
||||
addSRV := func(prefix string, records []SRVRecord) {
|
||||
for _, r := range records {
|
||||
view.SRV = append(view.SRV, reportSRVEntry{
|
||||
Prefix: prefix, Target: r.Target, Port: r.Port,
|
||||
Priority: r.Priority, Weight: r.Weight,
|
||||
IPv4: r.IPv4, IPv6: r.IPv6,
|
||||
})
|
||||
}
|
||||
}
|
||||
addSRV("_ldap._tcp", d.SRV.LDAP)
|
||||
addSRV("_ldaps._tcp", d.SRV.LDAPS)
|
||||
|
||||
// Endpoint rows.
|
||||
for _, ep := range d.Endpoints {
|
||||
re := reportEndpoint{
|
||||
EndpointProbe: ep,
|
||||
ModeLabel: modeLabel(ep.Mode),
|
||||
}
|
||||
if meta, hit := tlsByAddr[ep.Address]; hit {
|
||||
re.TLSPosture = meta
|
||||
} else if meta, hit := tlsByAddr[net.JoinHostPort(ep.Target, strconv.FormatUint(uint64(ep.Port), 10))]; hit {
|
||||
re.TLSPosture = meta
|
||||
}
|
||||
ok := ep.TCPConnected && ep.TLSEstablished
|
||||
if ep.Mode == ModePlain {
|
||||
ok = ok && ep.StartTLSUpgraded
|
||||
}
|
||||
re.AnyFail = !ok
|
||||
if ok {
|
||||
re.StatusLabel = "OK"
|
||||
re.StatusClass = "ok"
|
||||
} else if ep.TCPConnected {
|
||||
re.StatusLabel = "partial"
|
||||
re.StatusClass = "warn"
|
||||
} else {
|
||||
re.StatusLabel = "unreachable"
|
||||
re.StatusClass = "fail"
|
||||
}
|
||||
view.Endpoints = append(view.Endpoints, re)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
// fixesFromStates converts rule-evaluator CheckStates into reportFix entries,
|
||||
// dropping StatusOK / StatusUnknown (they are not findings to display in the
|
||||
// "What to fix" list). The fix hint, when present, is read from Meta["fix"]
|
||||
// using the convention set by issueToState in rules.go.
|
||||
func fixesFromStates(states []sdk.CheckState) []reportFix {
|
||||
out := make([]reportFix, 0, len(states))
|
||||
for _, st := range states {
|
||||
if st.Status == sdk.StatusOK || st.Status == sdk.StatusUnknown {
|
||||
continue
|
||||
}
|
||||
fix, _ := st.Meta["fix"].(string)
|
||||
out = append(out, reportFix{
|
||||
Severity: severityFromStatus(st.Status),
|
||||
Code: st.Code,
|
||||
Message: st.Message,
|
||||
Fix: fix,
|
||||
Endpoint: st.Subject,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func severityFromStatus(s sdk.Status) string {
|
||||
switch s {
|
||||
case sdk.StatusCrit, sdk.StatusError:
|
||||
return SeverityCrit
|
||||
case sdk.StatusWarn:
|
||||
return SeverityWarn
|
||||
default:
|
||||
return SeverityInfo
|
||||
}
|
||||
}
|
||||
|
||||
// applyFixes sorts (crit → warn → info) and stamps the page-level status
|
||||
// banner from the worst severity present.
|
||||
func applyFixes(view *reportData, fixes []reportFix) {
|
||||
view.HasIssues = len(fixes) > 0
|
||||
sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) })
|
||||
view.Fixes = fixes
|
||||
|
||||
if len(fixes) == 0 {
|
||||
// No rule output threaded through: render a neutral data-only banner
|
||||
// (no severity text derived from raw facts).
|
||||
view.StatusLabel = "report"
|
||||
view.StatusClass = "muted"
|
||||
return
|
||||
}
|
||||
switch fixes[0].Severity {
|
||||
case SeverityCrit:
|
||||
view.StatusLabel = "FAIL"
|
||||
view.StatusClass = "fail"
|
||||
case SeverityWarn:
|
||||
view.StatusLabel = "WARN"
|
||||
view.StatusClass = "warn"
|
||||
default:
|
||||
view.StatusLabel = "INFO"
|
||||
view.StatusClass = "info"
|
||||
}
|
||||
}
|
||||
|
||||
func sevRank(s string) int {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return 0
|
||||
case SeverityWarn:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func modeLabel(m LDAPMode) string {
|
||||
switch m {
|
||||
case ModePlain:
|
||||
return "ldap"
|
||||
case ModeLDAPS:
|
||||
return "ldaps"
|
||||
default:
|
||||
return string(m)
|
||||
}
|
||||
}
|
||||
|
||||
// foldTLSRelated builds a per-address posture map from downstream TLS
|
||||
// observations. This is pure raw-view data (flags and timestamps) for the
|
||||
// endpoint table; TLS severity text comes in via ReportContext.States()
|
||||
// from the tls_quality rule, not from this helper.
|
||||
func foldTLSRelated(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
|
||||
byAddr := map[string]*reportTLSPosture{}
|
||||
for _, r := range related {
|
||||
v := parseTLSRelated(r)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if addr := v.address(); addr != "" {
|
||||
byAddr[addr] = &reportTLSPosture{
|
||||
CheckedAt: r.CollectedAt,
|
||||
ChainValid: v.ChainValid,
|
||||
HostnameMatch: v.HostnameMatch,
|
||||
NotAfter: v.NotAfter,
|
||||
}
|
||||
}
|
||||
}
|
||||
return byAddr
|
||||
}
|
||||
|
||||
// coverageView aggregates raw per-endpoint booleans into the header-level
|
||||
// reachability summary. It is pure data reshaping, no severity, no fix
|
||||
// strings, and lives here because report.go is its only caller.
|
||||
func coverageView(data *LDAPData) ReachabilitySpan {
|
||||
var cov ReachabilitySpan
|
||||
anyEncrypted := false
|
||||
anyPlain := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.TCPConnected {
|
||||
continue
|
||||
}
|
||||
if ep.IsIPv6 {
|
||||
cov.HasIPv6 = true
|
||||
} else {
|
||||
cov.HasIPv4 = true
|
||||
}
|
||||
if ep.TLSEstablished {
|
||||
anyEncrypted = true
|
||||
} else {
|
||||
anyPlain = true
|
||||
}
|
||||
}
|
||||
cov.EncryptedReachable = anyEncrypted
|
||||
cov.PlainOnlyReachable = anyPlain && !anyEncrypted
|
||||
return cov
|
||||
}
|
||||
107
checker/rules.go
Normal file
107
checker/rules.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules returns the full list of CheckRules exposed by the LDAP checker.
|
||||
// Each rule covers one concern (SRV discovery, StartTLS posture, anonymous
|
||||
// access, …) and produces its CheckStates by scanning raw LDAPData fields
|
||||
// directly -- there is no shared pre-derived Issues slice in between.
|
||||
func Rules() []sdk.CheckRule {
|
||||
return []sdk.CheckRule{
|
||||
&srvDiscoveryRule{},
|
||||
&endpointReachableRule{},
|
||||
&encryptedTransportRule{},
|
||||
&startTLSSupportedRule{},
|
||||
&ldapsHandshakeRule{},
|
||||
&startTLSOnLDAPSRule{},
|
||||
&refusesPlainBindRule{},
|
||||
&anonymousSearchBlockedRule{},
|
||||
&rootDSEReadableRule{},
|
||||
&saslMechanismsRule{},
|
||||
&protocolVersionRule{},
|
||||
&ipv6ReachableRule{},
|
||||
&bindCredentialsRule{},
|
||||
&baseDNReadRule{},
|
||||
&tlsQualityRule{},
|
||||
}
|
||||
}
|
||||
|
||||
// loadLDAPData fetches the LDAP observation. On error, returns a CheckState
|
||||
// the caller should emit to short-circuit its rule.
|
||||
func loadLDAPData(ctx context.Context, obs sdk.ObservationGetter) (*LDAPData, *sdk.CheckState) {
|
||||
var data LDAPData
|
||||
if err := obs.Get(ctx, ObservationKeyLDAP, &data); err != nil {
|
||||
return nil, &sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to load LDAP observation: %v", err),
|
||||
Code: "ldap.observation_error",
|
||||
}
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// critState builds a StatusCrit state with an optional fix hint encoded in
|
||||
// Meta["fix"] so report.go's fixesFromStates can surface it.
|
||||
func critState(code, message, subject, fix string) sdk.CheckState {
|
||||
return stateWithFix(sdk.StatusCrit, code, message, subject, fix)
|
||||
}
|
||||
|
||||
func warnState(code, message, subject, fix string) sdk.CheckState {
|
||||
return stateWithFix(sdk.StatusWarn, code, message, subject, fix)
|
||||
}
|
||||
|
||||
func infoState(code, message, subject, fix string) sdk.CheckState {
|
||||
return stateWithFix(sdk.StatusInfo, code, message, subject, fix)
|
||||
}
|
||||
|
||||
func stateWithFix(status sdk.Status, code, message, subject, fix string) sdk.CheckState {
|
||||
st := sdk.CheckState{
|
||||
Status: status,
|
||||
Message: message,
|
||||
Code: code,
|
||||
Subject: subject,
|
||||
}
|
||||
if fix != "" {
|
||||
st.Meta = map[string]any{"fix": fix}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func passState(code, message string) sdk.CheckState {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Message: message,
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
||||
func notTestedState(code, message string) sdk.CheckState {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusUnknown,
|
||||
Message: message,
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
||||
func optString(opts sdk.CheckerOptions, key string) string {
|
||||
v, _ := opts[key].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// ldapsReachable reports whether at least one LDAPS endpoint accepted TCP.
|
||||
// Used to soften severity of unreachable plain-LDAP endpoints when modern
|
||||
// clients would use LDAPS anyway.
|
||||
func ldapsReachable(data *LDAPData) bool {
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.Mode == ModeLDAPS && ep.TCPConnected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
129
checker/rules_bind.go
Normal file
129
checker/rules_bind.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules in this file cover the optional bind-credentials workflow (driven
|
||||
// by the bind_dn / bind_password / base_dn options) plus the cross-checker
|
||||
// TLS-quality fold. Like the other rules, they read raw EndpointProbe
|
||||
// fields and downstream observations directly.
|
||||
|
||||
// bindCredentialsRule: optional authenticated bind. Skipped when bind_dn
|
||||
// isn't supplied, and reported as "not tested" when no encrypted endpoint
|
||||
// was available to attempt it on.
|
||||
type bindCredentialsRule struct{}
|
||||
|
||||
func (r *bindCredentialsRule) Name() string { return "ldap.bind_credentials" }
|
||||
func (r *bindCredentialsRule) Description() string {
|
||||
return "Verifies the supplied bind credentials are accepted by the directory (only runs when bind_dn is set)."
|
||||
}
|
||||
|
||||
func (r *bindCredentialsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
if optString(opts, "bind_dn") == "" {
|
||||
return []sdk.CheckState{notTestedState("ldap.bind_credentials.skipped", "Authenticated bind not tested (no bind_dn supplied).")}
|
||||
}
|
||||
data, errSt := loadLDAPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
attempted := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.BindAttempted {
|
||||
continue
|
||||
}
|
||||
attempted = true
|
||||
if ep.BindOK {
|
||||
states = append(states, infoState(
|
||||
CodeBindOK,
|
||||
"Bind on "+ep.Address+" succeeded with the provided credentials.",
|
||||
ep.Address,
|
||||
"",
|
||||
))
|
||||
} else {
|
||||
states = append(states, critState(
|
||||
CodeBindFailed,
|
||||
"Bind on "+ep.Address+" failed: "+ep.BindError,
|
||||
ep.Address,
|
||||
"Verify the bind DN exists and the password is current. On AD, check the account is not locked/expired.",
|
||||
))
|
||||
}
|
||||
}
|
||||
if !attempted {
|
||||
return []sdk.CheckState{notTestedState("ldap.bind_credentials.skipped", "Bind not attempted on any endpoint (no encrypted endpoint reachable).")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("ldap.bind_credentials.ok", "Bind succeeded with the provided credentials.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// baseDNReadRule: optional baseObject read on the supplied base_dn.
|
||||
type baseDNReadRule struct{}
|
||||
|
||||
func (r *baseDNReadRule) Name() string { return "ldap.base_dn_read" }
|
||||
func (r *baseDNReadRule) Description() string {
|
||||
return "Verifies the bound account can read the supplied base DN (only runs when base_dn is set and bind succeeded)."
|
||||
}
|
||||
|
||||
func (r *baseDNReadRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
if optString(opts, "base_dn") == "" {
|
||||
return []sdk.CheckState{notTestedState("ldap.base_dn_read.skipped", "Base DN read not tested (no base_dn supplied).")}
|
||||
}
|
||||
data, errSt := loadLDAPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
attempted := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.BaseReadAttempted {
|
||||
continue
|
||||
}
|
||||
attempted = true
|
||||
if ep.BaseReadOK {
|
||||
states = append(states, infoState(
|
||||
CodeBaseReadOK,
|
||||
"Base DN read succeeded on "+ep.Address+" (entries="+strconv.Itoa(ep.BaseReadEntries)+").",
|
||||
ep.Address,
|
||||
"",
|
||||
))
|
||||
} else {
|
||||
states = append(states, critState(
|
||||
CodeBaseReadFailed,
|
||||
"Bind succeeded but baseObject read on "+data.BaseDN+" failed: "+ep.BaseReadError,
|
||||
ep.Address,
|
||||
"Verify the bind DN has read access to the base DN -- typically granted via an ACL entry such as `access to dn.subtree=\"<base>\" by dn.exact=\"<bind>\" read`.",
|
||||
))
|
||||
}
|
||||
}
|
||||
if !attempted {
|
||||
return []sdk.CheckState{notTestedState("ldap.base_dn_read.skipped", "Base DN read not attempted (bind did not succeed on any endpoint).")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// tlsQualityRule: folds downstream TLS checker findings onto the LDAP
|
||||
// service. Consumes a related observation (not LDAPData).
|
||||
type tlsQualityRule struct{}
|
||||
|
||||
func (r *tlsQualityRule) Name() string { return "ldap.tls_quality" }
|
||||
func (r *tlsQualityRule) Description() string {
|
||||
return "Folds the downstream TLS checker findings (certificate chain, hostname match, expiry) onto the LDAP service."
|
||||
}
|
||||
|
||||
func (r *tlsQualityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
|
||||
if len(related) == 0 {
|
||||
return []sdk.CheckState{notTestedState("ldap.tls_quality.skipped", "No related TLS observation available (no TLS checker downstream, or no probe yet).")}
|
||||
}
|
||||
states := tlsStatesFromRelated(related)
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("ldap.tls_quality.ok", "Downstream TLS checker reports no issues on the LDAP endpoints.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
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
|
||||
}
|
||||
279
checker/rules_transport.go
Normal file
279
checker/rules_transport.go
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules in this file cover transport-level concerns: SRV discovery, TCP
|
||||
// reachability, encryption availability, TLS handshakes. They read raw
|
||||
// LDAPData fields directly (no pre-derived Issues slice).
|
||||
|
||||
// srvDiscoveryRule: _ldap._tcp / _ldaps._tcp SRV publishing + resolution.
|
||||
type srvDiscoveryRule struct{}
|
||||
|
||||
func (r *srvDiscoveryRule) Name() string { return "ldap.has_srv" }
|
||||
func (r *srvDiscoveryRule) Description() string {
|
||||
return "Verifies that _ldap._tcp / _ldaps._tcp SRV records are published and resolvable."
|
||||
}
|
||||
|
||||
func (r *srvDiscoveryRule) 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
|
||||
if data.SRV.FallbackProbed {
|
||||
states = append(states, infoState(
|
||||
CodeNoSRV,
|
||||
"No LDAP SRV records published for "+data.Domain+".",
|
||||
"",
|
||||
"Consider publishing _ldap._tcp."+data.Domain+" and _ldaps._tcp."+data.Domain+" SRV records to let clients discover the directory automatically.",
|
||||
))
|
||||
}
|
||||
for prefix, msg := range data.SRV.Errors {
|
||||
states = append(states, warnState(
|
||||
CodeSRVServfail,
|
||||
"DNS lookup failed for "+prefix+data.Domain+": "+msg,
|
||||
"",
|
||||
"Check the authoritative DNS servers for this domain.",
|
||||
))
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("ldap.has_srv.ok", "SRV records are published and resolved cleanly.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// endpointReachableRule: every discovered endpoint accepts a TCP connection.
|
||||
type endpointReachableRule struct{}
|
||||
|
||||
func (r *endpointReachableRule) Name() string { return "ldap.endpoint_reachable" }
|
||||
func (r *endpointReachableRule) Description() string {
|
||||
return "Verifies that every discovered LDAP endpoint accepts a TCP connection."
|
||||
}
|
||||
|
||||
func (r *endpointReachableRule) 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
|
||||
|
||||
// Per-endpoint TCP failures.
|
||||
ldapsUp := ldapsReachable(data)
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected || ep.Error == "" {
|
||||
continue
|
||||
}
|
||||
msg := "Cannot reach " + ep.Address + ": " + ep.Error + "."
|
||||
if ep.Mode == ModePlain && ldapsUp {
|
||||
states = append(states, infoState(
|
||||
CodeTCPUnreachable, msg, ep.Address,
|
||||
"LDAPS (636) is reachable, so modern clients are unaffected. Only relevant if legacy clients still need plain LDAP on 389.",
|
||||
))
|
||||
} else {
|
||||
states = append(states, warnState(
|
||||
CodeTCPUnreachable, msg, ep.Address,
|
||||
"Verify firewall rules and that the LDAP server is listening on this address.",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate: no endpoint reachable at all.
|
||||
if len(data.Endpoints) > 0 {
|
||||
allDown := true
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
allDown = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDown {
|
||||
states = append(states, critState(
|
||||
CodeAllEndpointsDown,
|
||||
"No LDAP endpoint is reachable.",
|
||||
"",
|
||||
"Check firewall rules and that the directory is listening on ports 389/636 (or the ports indicated by SRV).",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("ldap.endpoint_reachable.ok", "All discovered endpoints are reachable.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// encryptedTransportRule: at least one endpoint is reachable AND encrypted.
|
||||
type encryptedTransportRule struct{}
|
||||
|
||||
func (r *encryptedTransportRule) Name() string { return "ldap.has_encrypted_transport" }
|
||||
func (r *encryptedTransportRule) Description() string {
|
||||
return "Verifies that at least one reachable endpoint offers an encrypted channel (LDAPS or StartTLS)."
|
||||
}
|
||||
|
||||
func (r *encryptedTransportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadLDAPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
anyReachable := false
|
||||
anyEncrypted := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
anyReachable = true
|
||||
if ep.TLSEstablished {
|
||||
anyEncrypted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if anyReachable && !anyEncrypted {
|
||||
return []sdk.CheckState{critState(
|
||||
CodeNoEncryptedEndpoint,
|
||||
"None of the reachable endpoints established an encrypted channel (no StartTLS, no LDAPS).",
|
||||
"",
|
||||
"Enable either LDAPS (port 636) or StartTLS on 389 -- a bind DN should never travel in cleartext.",
|
||||
)}
|
||||
}
|
||||
return []sdk.CheckState{passState("ldap.has_encrypted_transport.ok", "At least one endpoint offers encrypted transport.")}
|
||||
}
|
||||
|
||||
// startTLSSupportedRule: StartTLS is advertised and succeeds on every
|
||||
// reachable plain-LDAP endpoint.
|
||||
type startTLSSupportedRule struct{}
|
||||
|
||||
func (r *startTLSSupportedRule) Name() string { return "ldap.starttls_supported" }
|
||||
func (r *startTLSSupportedRule) Description() string {
|
||||
return "Verifies that StartTLS is offered and succeeds on every reachable plain LDAP endpoint."
|
||||
}
|
||||
|
||||
func (r *startTLSSupportedRule) 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.StartTLSOffered {
|
||||
states = append(states, critState(
|
||||
CodeStartTLSMissing,
|
||||
"StartTLS not advertised on "+ep.Address+".",
|
||||
ep.Address,
|
||||
"Configure the LDAP server to accept StartTLS (RFC 2830). On OpenLDAP, set olcTLSCertificateFile / olcTLSCertificateKeyFile and ensure the LDAP listener allows encrypted upgrades.",
|
||||
))
|
||||
} else if !ep.StartTLSUpgraded {
|
||||
states = append(states, critState(
|
||||
CodeStartTLSFailed,
|
||||
"StartTLS handshake failed on "+ep.Address+": "+ep.Error+".",
|
||||
ep.Address,
|
||||
"Run the TLS checker on this endpoint for cert and cipher details.",
|
||||
))
|
||||
}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("ldap.starttls_supported.ok", "StartTLS works on every reachable plain LDAP endpoint.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// ldapsHandshakeRule: the direct TLS handshake succeeds on every reachable
|
||||
// LDAPS endpoint.
|
||||
type ldapsHandshakeRule struct{}
|
||||
|
||||
func (r *ldapsHandshakeRule) Name() string { return "ldap.ldaps_handshake" }
|
||||
func (r *ldapsHandshakeRule) Description() string {
|
||||
return "Verifies that the direct TLS handshake succeeds on every LDAPS endpoint."
|
||||
}
|
||||
|
||||
func (r *ldapsHandshakeRule) 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 == ModeLDAPS && ep.TCPConnected && !ep.TLSEstablished {
|
||||
states = append(states, critState(
|
||||
CodeLDAPSHandshakeFailed,
|
||||
"LDAPS TLS handshake failed on "+ep.Address+": "+ep.Error+".",
|
||||
ep.Address,
|
||||
"Check that the LDAPS listener has a valid certificate/key pair and is speaking TLS directly (not LDAP+StartTLS).",
|
||||
))
|
||||
}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("ldap.ldaps_handshake.ok", "LDAPS handshake succeeds on every reachable LDAPS endpoint.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// startTLSOnLDAPSRule: flags LDAPS endpoints that also advertise StartTLS.
|
||||
type startTLSOnLDAPSRule struct{}
|
||||
|
||||
func (r *startTLSOnLDAPSRule) Name() string { return "ldap.starttls_on_ldaps" }
|
||||
func (r *startTLSOnLDAPSRule) Description() string {
|
||||
return "Flags servers that needlessly advertise StartTLS on the implicit-TLS LDAPS port."
|
||||
}
|
||||
|
||||
func (r *startTLSOnLDAPSRule) 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 == ModeLDAPS && stringListContainsFold(ep.SupportedExtension, "1.3.6.1.4.1.1466.20037") {
|
||||
states = append(states, infoState(
|
||||
CodeStartTLSOnLDAPS,
|
||||
"Server advertises StartTLS on the LDAPS port "+ep.Address+".",
|
||||
ep.Address,
|
||||
"This is not actively harmful, but most directories do not need StartTLS exposed on 636. Consider disabling it to reduce attack surface.",
|
||||
))
|
||||
}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("ldap.starttls_on_ldaps.ok", "LDAPS endpoints do not also advertise StartTLS.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// ipv6ReachableRule: at least one endpoint reachable over IPv6.
|
||||
type ipv6ReachableRule struct{}
|
||||
|
||||
func (r *ipv6ReachableRule) Name() string { return "ldap.ipv6_reachable" }
|
||||
func (r *ipv6ReachableRule) Description() string {
|
||||
return "Verifies at least one endpoint is reachable over IPv6."
|
||||
}
|
||||
|
||||
func (r *ipv6ReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadLDAPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
hasV4 := false
|
||||
hasV6 := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.TCPConnected {
|
||||
continue
|
||||
}
|
||||
if ep.IsIPv6 {
|
||||
hasV6 = true
|
||||
} else {
|
||||
hasV4 = true
|
||||
}
|
||||
}
|
||||
if hasV4 && !hasV6 {
|
||||
return []sdk.CheckState{infoState(
|
||||
CodeNoIPv6,
|
||||
"No IPv6 endpoint reachable.",
|
||||
"",
|
||||
"Publish AAAA records for the SRV targets.",
|
||||
)}
|
||||
}
|
||||
return []sdk.CheckState{passState("ldap.ipv6_reachable.ok", "At least one endpoint is reachable over IPv6.")}
|
||||
}
|
||||
174
checker/tls_related.go
Normal file
174
checker/tls_related.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// TLSRelatedKey is the observation key we expect a TLS checker to publish
|
||||
// for the endpoints we discover. Matches the cross-checker convention
|
||||
// documented in the happyDomain plan.
|
||||
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
|
||||
|
||||
// tlsProbeView is a permissive view of a TLS checker's payload: we read
|
||||
// only the fields we need and tolerate missing ones. The TLS checker owns
|
||||
// the full schema.
|
||||
type tlsProbeView struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Port uint16 `json:"port,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
TLSVersion string `json:"tls_version,omitempty"`
|
||||
CipherSuite string `json:"cipher_suite,omitempty"`
|
||||
HostnameMatch *bool `json:"hostname_match,omitempty"`
|
||||
ChainValid *bool `json:"chain_valid,omitempty"`
|
||||
NotAfter time.Time `json:"not_after,omitempty"`
|
||||
Issues []struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
} `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
func (v *tlsProbeView) address() string {
|
||||
if v.Endpoint != "" {
|
||||
return v.Endpoint
|
||||
}
|
||||
if v.Host != "" && v.Port != 0 {
|
||||
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseTLSRelated decodes a RelatedObservation as a TLS probe, gracefully
|
||||
// returning nil when the payload doesn't look like one. Handles both
|
||||
// {"probes": {"<ref>": <probe>}} and a bare <probe> shape.
|
||||
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
|
||||
var keyed struct {
|
||||
Probes map[string]tlsProbeView `json:"probes"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil {
|
||||
if p, ok := keyed.Probes[r.Ref]; ok {
|
||||
return &p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var v tlsProbeView
|
||||
if err := json.Unmarshal(r.Data, &v); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// tlsStatesFromRelated converts downstream TLS observations into CheckStates
|
||||
// so certificate problems land on the LDAP service page.
|
||||
func tlsStatesFromRelated(related []sdk.RelatedObservation) []sdk.CheckState {
|
||||
var out []sdk.CheckState
|
||||
for _, r := range related {
|
||||
v := parseTLSRelated(r)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
addr := v.address()
|
||||
if len(v.Issues) > 0 {
|
||||
for _, is := range v.Issues {
|
||||
sev := strings.ToLower(is.Severity)
|
||||
switch sev {
|
||||
case SeverityCrit, SeverityWarn, SeverityInfo:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
code := is.Code
|
||||
if code == "" {
|
||||
code = "tls.unknown"
|
||||
}
|
||||
out = append(out, stateWithFix(
|
||||
severityToStatus(sev),
|
||||
"ldap.tls."+code,
|
||||
strings.TrimSpace("TLS on "+addr+": "+is.Message),
|
||||
addr,
|
||||
is.Fix,
|
||||
))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Flag-only payload: synthesize a single summary state.
|
||||
sev := v.worstSeverity()
|
||||
if sev == "" {
|
||||
continue
|
||||
}
|
||||
msg := "TLS issue reported on " + addr
|
||||
switch {
|
||||
case v.ChainValid != nil && !*v.ChainValid:
|
||||
msg = "Invalid certificate chain on " + addr
|
||||
case v.HostnameMatch != nil && !*v.HostnameMatch:
|
||||
msg = "Certificate does not cover the domain on " + addr
|
||||
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0:
|
||||
msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
|
||||
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour:
|
||||
msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
|
||||
}
|
||||
out = append(out, stateWithFix(
|
||||
severityToStatus(sev),
|
||||
"ldap.tls.probe",
|
||||
msg,
|
||||
addr,
|
||||
"See the TLS checker report for details.",
|
||||
))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// severityToStatus bridges a TLS-related severity string to sdk.Status. The
|
||||
// severity strings remain stable so JSON payloads from older TLS checkers
|
||||
// still decode.
|
||||
func severityToStatus(sev string) sdk.Status {
|
||||
switch sev {
|
||||
case SeverityCrit:
|
||||
return sdk.StatusCrit
|
||||
case SeverityWarn:
|
||||
return sdk.StatusWarn
|
||||
case SeverityInfo:
|
||||
return sdk.StatusInfo
|
||||
default:
|
||||
return sdk.StatusOK
|
||||
}
|
||||
}
|
||||
|
||||
func (v *tlsProbeView) worstSeverity() string {
|
||||
worst := ""
|
||||
for _, is := range v.Issues {
|
||||
switch strings.ToLower(is.Severity) {
|
||||
case SeverityCrit:
|
||||
return SeverityCrit
|
||||
case SeverityWarn:
|
||||
if worst != SeverityCrit {
|
||||
worst = SeverityWarn
|
||||
}
|
||||
case SeverityInfo:
|
||||
if worst == "" {
|
||||
worst = SeverityInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.ChainValid != nil && !*v.ChainValid {
|
||||
return SeverityCrit
|
||||
}
|
||||
if v.HostnameMatch != nil && !*v.HostnameMatch {
|
||||
return SeverityCrit
|
||||
}
|
||||
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 {
|
||||
return SeverityCrit
|
||||
}
|
||||
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour {
|
||||
if worst != SeverityCrit {
|
||||
return SeverityWarn
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
182
checker/types.go
Normal file
182
checker/types.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// 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"
|
||||
)
|
||||
22
go.mod
Normal file
22
go.mod
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
module git.happydns.org/checker-ldap
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.4.0
|
||||
git.happydns.org/checker-tls v0.6.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.8
|
||||
github.com/miekg/dns v1.1.72
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
112
go.sum
Normal file
112
go.sum
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs=
|
||||
git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E=
|
||||
git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
28
main.go
Normal file
28
main.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
ldap "git.happydns.org/checker-ldap/checker"
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
)
|
||||
|
||||
// Version is the standalone binary's version. It defaults to "custom-build"
|
||||
// and is meant to be overridden by the CI at link time:
|
||||
//
|
||||
// go build -ldflags "-X main.Version=1.2.3" .
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
ldap.Version = Version
|
||||
|
||||
srv := server.New(ldap.Provider())
|
||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
20
plugin/plugin.go
Normal file
20
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Command plugin is the happyDomain plugin entrypoint for the LDAP checker.
|
||||
//
|
||||
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
|
||||
// runtime by happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
ldap "git.happydns.org/checker-ldap/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
|
||||
// .so file.
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
ldap.Version = Version
|
||||
prvd := ldap.Provider()
|
||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue