Compare commits
No commits in common. "v0.1.0" and "master" have entirely different histories.
20 changed files with 1365 additions and 398 deletions
22
.drone-manifest.yml
Normal file
22
.drone-manifest.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
image: happydomain/checker-kerberos:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
|
||||||
|
{{#if build.tags}}
|
||||||
|
tags:
|
||||||
|
{{#each build.tags}}
|
||||||
|
- {{this}}
|
||||||
|
{{/each}}
|
||||||
|
{{/if}}
|
||||||
|
manifests:
|
||||||
|
- image: happydomain/checker-kerberos:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
|
||||||
|
platform:
|
||||||
|
architecture: amd64
|
||||||
|
os: linux
|
||||||
|
- image: happydomain/checker-kerberos:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
|
||||||
|
platform:
|
||||||
|
architecture: arm64
|
||||||
|
os: linux
|
||||||
|
variant: v8
|
||||||
|
- image: happydomain/checker-kerberos:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
|
||||||
|
platform:
|
||||||
|
architecture: arm
|
||||||
|
os: linux
|
||||||
|
variant: v7
|
||||||
187
.drone.yml
Normal file
187
.drone.yml
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: build-amd64
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: amd64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: checker build
|
||||||
|
image: golang:1-alpine
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache git make
|
||||||
|
- make
|
||||||
|
environment:
|
||||||
|
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
exclude:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
- name: checker build tag
|
||||||
|
image: golang:1-alpine
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache git make
|
||||||
|
- make
|
||||||
|
environment:
|
||||||
|
CHECKER_VERSION: "${DRONE_SEMVER}"
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
- name: publish on Docker Hub
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
repo: happydomain/checker-kerberos
|
||||||
|
auto_tag: true
|
||||||
|
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
build_args:
|
||||||
|
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
exclude:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
- name: publish on Docker Hub (tag)
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
repo: happydomain/checker-kerberos
|
||||||
|
auto_tag: true
|
||||||
|
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
build_args:
|
||||||
|
- CHECKER_VERSION=${DRONE_SEMVER}
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
exclude:
|
||||||
|
- renovate/*
|
||||||
|
event:
|
||||||
|
- cron
|
||||||
|
- push
|
||||||
|
- tag
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: build-arm64
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: checker build
|
||||||
|
image: golang:1-alpine
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache git make
|
||||||
|
- make
|
||||||
|
environment:
|
||||||
|
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
exclude:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
- name: checker build tag
|
||||||
|
image: golang:1-alpine
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache git make
|
||||||
|
- make
|
||||||
|
environment:
|
||||||
|
CHECKER_VERSION: "${DRONE_SEMVER}"
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
- name: publish on Docker Hub
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
repo: happydomain/checker-kerberos
|
||||||
|
auto_tag: true
|
||||||
|
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
build_args:
|
||||||
|
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
exclude:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
- name: publish on Docker Hub (tag)
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
repo: happydomain/checker-kerberos
|
||||||
|
auto_tag: true
|
||||||
|
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
build_args:
|
||||||
|
- CHECKER_VERSION=${DRONE_SEMVER}
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- cron
|
||||||
|
- push
|
||||||
|
- tag
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
name: docker-manifest
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: publish on Docker Hub
|
||||||
|
image: plugins/manifest
|
||||||
|
settings:
|
||||||
|
auto_tag: true
|
||||||
|
ignore_missing: true
|
||||||
|
spec: .drone-manifest.yml
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
exclude:
|
||||||
|
- renovate/*
|
||||||
|
event:
|
||||||
|
- cron
|
||||||
|
- push
|
||||||
|
- tag
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- build-amd64
|
||||||
|
- build-arm64
|
||||||
|
|
@ -6,9 +6,12 @@ WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-kerberos .
|
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-kerberos .
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /checker-kerberos /checker-kerberos
|
COPY --from=builder /checker-kerberos /checker-kerberos
|
||||||
|
USER 65534:65534
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/checker-kerberos", "-healthcheck"]
|
||||||
ENTRYPOINT ["/checker-kerberos"]
|
ENTRYPOINT ["/checker-kerberos"]
|
||||||
|
|
|
||||||
7
Makefile
7
Makefile
|
|
@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||||
|
|
||||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||||
|
|
||||||
.PHONY: all plugin docker clean
|
.PHONY: all plugin docker test clean
|
||||||
|
|
||||||
all: $(CHECKER_NAME)
|
all: $(CHECKER_NAME)
|
||||||
|
|
||||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||||
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
|
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||||
|
|
||||||
plugin: $(CHECKER_NAME).so
|
plugin: $(CHECKER_NAME).so
|
||||||
|
|
||||||
|
|
@ -21,5 +21,8 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||||
docker:
|
docker:
|
||||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -tags standalone ./...
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -4,13 +4,13 @@ happyDomain checker that audits a Kerberos realm from its DNS records.
|
||||||
|
|
||||||
Starting from the realm name (or from the SRV records grouped under the
|
Starting from the realm name (or from the SRV records grouped under the
|
||||||
`abstract.Kerberos` service), the checker performs a series of
|
`abstract.Kerberos` service), the checker performs a series of
|
||||||
**anonymous probes** — and an optional **authenticated round-trip** when
|
**anonymous probes**, and an optional **authenticated round-trip** when
|
||||||
credentials are supplied — to give a complete picture of the realm's
|
credentials are supplied, to give a complete picture of the realm's
|
||||||
availability and security posture.
|
availability and security posture.
|
||||||
|
|
||||||
## What gets checked
|
## What gets checked
|
||||||
|
|
||||||
- SRV layout — `_kerberos._tcp.`, `_kerberos._udp.`,
|
- SRV layout, `_kerberos._tcp.`, `_kerberos._udp.`,
|
||||||
`_kerberos-master._tcp.`, `_kerberos-adm._tcp.`, `_kpasswd._tcp.`,
|
`_kerberos-master._tcp.`, `_kerberos-adm._tcp.`, `_kpasswd._tcp.`,
|
||||||
`_kpasswd._udp.`.
|
`_kpasswd._udp.`.
|
||||||
- Forward resolution of every SRV target (A + AAAA).
|
- Forward resolution of every SRV target (A + AAAA).
|
||||||
|
|
@ -35,6 +35,22 @@ direct remediation hint:
|
||||||
| Wrong realm in reply | fix `default_realm` / realm config |
|
| Wrong realm in reply | fix `default_realm` / realm config |
|
||||||
| AS-REP roasting exposure | enable `requires_preauth` |
|
| AS-REP roasting exposure | enable `requires_preauth` |
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
| Code | Description | Severity |
|
||||||
|
|--------------------------------|---------------------------------------------------------------------------------------------------|---------------------|
|
||||||
|
| `kerberos.srv_present` | Verifies that at least one _kerberos._tcp / _kerberos._udp SRV record is published for the realm. | CRITICAL |
|
||||||
|
| `kerberos.kdc_reachable` | Verifies that at least one KDC endpoint (TCP/UDP 88) accepts a connection. | CRITICAL |
|
||||||
|
| `kerberos.as_probe` | Verifies that the anonymous AS-REQ probe received a sane reply (KRB-ERROR or AS-REP). | CRITICAL |
|
||||||
|
| `kerberos.realm_match` | Verifies the KDC answers for the expected realm name. | CRITICAL |
|
||||||
|
| `kerberos.preauth_required` | Flags KDCs that return an AS-REP without requiring pre-authentication (AS-REP roasting exposure). | WARNING |
|
||||||
|
| `kerberos.clock_skew` | Verifies the KDC clock is within tolerance of the checker's clock. | CRITICAL |
|
||||||
|
| `kerberos.enctypes` | Reviews the encryption types advertised by the KDC, flagging DES/RC4-only configurations. | CRITICAL |
|
||||||
|
| `kerberos.kadmin_reachable` | Flags kadmin endpoints that are published via SRV but not reachable. | WARNING |
|
||||||
|
| `kerberos.kpasswd_reachable` | Flags kpasswd endpoints that are published via SRV but not reachable. | WARNING |
|
||||||
|
| `kerberos.auth_tgt` | Verifies the supplied principal/password can obtain a TGT (only runs when credentials are supplied). | CRITICAL |
|
||||||
|
| `kerberos.auth_tgs` | Verifies a TGS-REQ succeeds for the supplied target service (only runs when credentials and targetService are supplied). | WARNING |
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -48,3 +64,15 @@ make docker # container image
|
||||||
```sh
|
```sh
|
||||||
./checker-kerberos -listen :8080
|
./checker-kerberos -listen :8080
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The HTTP listener has no built-in authentication or rate-limiting, and
|
||||||
|
will issue DNS queries and Kerberos AS-REQ / TGS-REQ exchanges against
|
||||||
|
whatever realm and KDCs the caller asks for. When a `principal` and
|
||||||
|
`password` are supplied, those credentials are forwarded to the target
|
||||||
|
KDC over the network as part of an authenticated round-trip. It is
|
||||||
|
meant to run on a trusted network, reachable only by the happyDomain
|
||||||
|
instance that drives it. Restrict access via a reverse proxy with
|
||||||
|
authentication, a network ACL, or by binding the listener to a private
|
||||||
|
interface; do not expose it directly to the public internet.
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,9 @@ func runAuthProbe(ctx context.Context, realm, principal, password, targetService
|
||||||
res.Latency = time.Since(start)
|
res.Latency = time.Since(start)
|
||||||
if loginErr != nil {
|
if loginErr != nil {
|
||||||
res.Error = loginErr.Error()
|
res.Error = loginErr.Error()
|
||||||
if code, ok := extractKRBErrorCode(loginErr); ok {
|
if code, name, ok := krbErrorInfo(loginErr); ok {
|
||||||
res.ErrorCode = code
|
res.ErrorCode = code
|
||||||
res.ErrorName = errorcodeName(code)
|
res.ErrorName = name
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
@ -63,31 +63,12 @@ func runAuthProbe(ctx context.Context, realm, principal, password, targetService
|
||||||
}
|
}
|
||||||
if _, _, err := cl.GetServiceTicket(spn); err != nil {
|
if _, _, err := cl.GetServiceTicket(spn); err != nil {
|
||||||
res.Error = fmt.Sprintf("TGS failed for %s: %v", spn, err)
|
res.Error = fmt.Sprintf("TGS failed for %s: %v", spn, err)
|
||||||
if code, ok := extractKRBErrorCode(err); ok {
|
if code, name, ok := krbErrorInfo(err); ok {
|
||||||
res.ErrorCode = code
|
res.ErrorCode = code
|
||||||
res.ErrorName = errorcodeName(code)
|
res.ErrorName = name
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
res.TGSAcquired = true
|
res.TGSAcquired = true
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractKRBErrorCode tries to pull a Kerberos error code out of the
|
|
||||||
// wrapped errors returned by gokrb5. It's best-effort: if the code
|
|
||||||
// can't be determined, ok is false.
|
|
||||||
func extractKRBErrorCode(err error) (int32, bool) {
|
|
||||||
if err == nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
// gokrb5 wraps KRBError values: their Error() string begins with "KRB Error: (N)".
|
|
||||||
msg := err.Error()
|
|
||||||
if _, after, ok := strings.Cut(msg, "KRB Error: ("); ok {
|
|
||||||
if code, _, ok := strings.Cut(after, ")"); ok {
|
|
||||||
if n, err := strconv.Atoi(code); err == nil {
|
|
||||||
return int32(n), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -65,16 +66,27 @@ var preferredEnctypes = []int32{
|
||||||
etypeID.RC4_HMAC,
|
etypeID.RC4_HMAC,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// etypeNameByID is a deterministic id->name lookup built once from
|
||||||
|
// etypeID.ETypesByName, ignoring CMS/Env OID aliases.
|
||||||
|
var etypeNameByID = func() map[int32]string {
|
||||||
|
m := make(map[int32]string, len(etypeID.ETypesByName))
|
||||||
|
for name, id := range etypeID.ETypesByName {
|
||||||
|
if strings.Contains(name, "-CmsOID") || strings.HasSuffix(name, "-EnvOID") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing, ok := m[id]; ok && existing < name {
|
||||||
|
continue // keep lexicographically smallest for stability
|
||||||
|
}
|
||||||
|
m[id] = name
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
// etypeName returns a human-friendly name for an enctype ID, falling back
|
// etypeName returns a human-friendly name for an enctype ID, falling back
|
||||||
// to its numeric value when unknown.
|
// to its numeric value when unknown.
|
||||||
func etypeName(id int32) string {
|
func etypeName(id int32) string {
|
||||||
for name, nid := range etypeID.ETypesByName {
|
if name, ok := etypeNameByID[id]; ok {
|
||||||
if nid == id {
|
return name
|
||||||
// Prefer canonical "aes..." / "rc4-hmac" shape
|
|
||||||
if !strings.Contains(name, "-CmsOID") && !strings.HasSuffix(name, "-EnvOID") {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("etype-%d", id)
|
return fmt.Sprintf("etype-%d", id)
|
||||||
}
|
}
|
||||||
|
|
@ -94,11 +106,9 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
|
||||||
}
|
}
|
||||||
timeout := time.Duration(timeoutSec * float64(time.Second))
|
timeout := time.Duration(timeoutSec * float64(time.Second))
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
data := &KerberosData{
|
data := &KerberosData{
|
||||||
Realm: realm,
|
Realm: realm,
|
||||||
CollectedAt: now,
|
CollectedAt: time.Now().UTC(),
|
||||||
LocalTime: now,
|
|
||||||
Resolution: map[string]HostResolution{},
|
Resolution: map[string]HostResolution{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,9 +224,14 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// TCP first, stop after the first parsed reply. UDP endpoints are
|
||||||
|
// always probed (they are the only place we record UDP KDC
|
||||||
|
// reachability), even when a TCP target already answered.
|
||||||
eps := append(tcpEps[:len(tcpEps):len(tcpEps)], udpEps...)
|
eps := append(tcpEps[:len(tcpEps):len(tcpEps)], udpEps...)
|
||||||
|
|
||||||
for _, e := range eps {
|
for _, e := range eps {
|
||||||
|
if e.proto == "tcp" && asProbe.Target != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
var reply []byte
|
var reply []byte
|
||||||
var perr error
|
var perr error
|
||||||
|
|
@ -224,15 +239,14 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
|
||||||
reply, perr = sendASReqTCP(ctx, e.target, e.port, req, timeout)
|
reply, perr = sendASReqTCP(ctx, e.target, e.port, req, timeout)
|
||||||
} else {
|
} else {
|
||||||
reply, perr = sendASReqUDP(ctx, e.target, e.port, req, timeout)
|
reply, perr = sendASReqUDP(ctx, e.target, e.port, req, timeout)
|
||||||
// Track UDP reachability via this attempt.
|
|
||||||
probe := KDCProbe{
|
probe := KDCProbe{
|
||||||
Target: e.target, Port: e.port, Proto: "udp", Role: "kdc",
|
Target: e.target, Port: e.port, Proto: "udp", Role: "kdc",
|
||||||
RTT: time.Since(start),
|
RTT: time.Since(start),
|
||||||
}
|
}
|
||||||
if perr == nil {
|
if perr == nil && len(reply) > 0 {
|
||||||
probe.OK = true
|
probe.OK = true
|
||||||
probe.KrbSeen = true
|
probe.KrbSeen = true
|
||||||
} else {
|
} else if perr != nil {
|
||||||
probe.Error = perr.Error()
|
probe.Error = perr.Error()
|
||||||
}
|
}
|
||||||
data.Probes = append(data.Probes, probe)
|
data.Probes = append(data.Probes, probe)
|
||||||
|
|
@ -240,10 +254,11 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
|
||||||
if perr != nil || len(reply) == 0 {
|
if perr != nil || len(reply) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
asProbe.Target = e.target
|
if asProbe.Target == "" {
|
||||||
asProbe.Proto = e.proto
|
asProbe.Target = e.target
|
||||||
parseASResponse(reply, &asProbe)
|
asProbe.Proto = e.proto
|
||||||
break
|
parseASResponse(reply, &asProbe)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if asProbe.Target == "" && asProbe.Error == "" {
|
if asProbe.Target == "" && asProbe.Error == "" {
|
||||||
asProbe.Error = "no KDC answered our AS-REQ probe"
|
asProbe.Error = "no KDC answered our AS-REQ probe"
|
||||||
|
|
@ -266,7 +281,13 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
|
||||||
// 6. Optional authenticated round-trip ------------------------------------
|
// 6. Optional authenticated round-trip ------------------------------------
|
||||||
principal, _ := opts["principal"].(string)
|
principal, _ := opts["principal"].(string)
|
||||||
password, _ := opts["password"].(string)
|
password, _ := opts["password"].(string)
|
||||||
if principal != "" && password != "" {
|
if principal != "" && password == "" {
|
||||||
|
data.Auth = &AuthProbeResult{
|
||||||
|
Attempted: true,
|
||||||
|
Principal: principal,
|
||||||
|
Error: "password is required when a principal is supplied",
|
||||||
|
}
|
||||||
|
} else if principal != "" {
|
||||||
data.Auth = runAuthProbe(ctx, realm, principal, password,
|
data.Auth = runAuthProbe(ctx, realm, principal, password,
|
||||||
stringOpt(opts, "targetService"), kdcHosts, timeout)
|
stringOpt(opts, "targetService"), kdcHosts, timeout)
|
||||||
}
|
}
|
||||||
|
|
@ -371,13 +392,24 @@ func buildProbeASReq(realm string) (messages.ASReq, error) {
|
||||||
cfg.LibDefaults.NoAddresses = true
|
cfg.LibDefaults.NoAddresses = true
|
||||||
cfg.LibDefaults.TicketLifetime = 10 * time.Minute
|
cfg.LibDefaults.TicketLifetime = 10 * time.Minute
|
||||||
cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes
|
cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes
|
||||||
cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "probe-happydomain")
|
cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, randomProbeCName())
|
||||||
return messages.NewASReqForTGT(realm, cfg, cname)
|
return messages.NewASReqForTGT(realm, cfg, cname)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// randomProbeCName returns a probe-only principal name. The random suffix
|
||||||
|
// avoids creating a recognizable, repeating audit-log entry on the KDC and
|
||||||
|
// keeps two concurrent probes from colliding on the same cname.
|
||||||
|
func randomProbeCName() string {
|
||||||
|
var b [6]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "probe-happydomain"
|
||||||
|
}
|
||||||
|
return "probe-happydomain-" + hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
// parseASResponse inspects the raw KDC reply and fills the ASProbeResult.
|
// parseASResponse inspects the raw KDC reply and fills the ASProbeResult.
|
||||||
// Expected replies: KRB-ERROR (PREAUTH_REQUIRED / C_PRINCIPAL_UNKNOWN) or,
|
// Expected replies: KRB-ERROR (PREAUTH_REQUIRED / C_PRINCIPAL_UNKNOWN) or,
|
||||||
// less commonly, an AS-REP (principal exists and doesn't require preauth —
|
// less commonly, an AS-REP (principal exists and doesn't require preauth .
|
||||||
// AS-REP roasting territory).
|
// AS-REP roasting territory).
|
||||||
func parseASResponse(raw []byte, out *ASProbeResult) {
|
func parseASResponse(raw []byte, out *ASProbeResult) {
|
||||||
// Try KRB-ERROR first.
|
// Try KRB-ERROR first.
|
||||||
|
|
@ -400,7 +432,7 @@ func parseASResponse(raw []byte, out *ASProbeResult) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try AS-REP. If this succeeds, preauth wasn't required — surface it.
|
// Try AS-REP. If this succeeds, preauth wasn't required, surface it.
|
||||||
var asRep messages.ASRep
|
var asRep messages.ASRep
|
||||||
if err := asRep.Unmarshal(raw); err == nil {
|
if err := asRep.Unmarshal(raw); err == nil {
|
||||||
out.PrincipalFound = true
|
out.PrincipalFound = true
|
||||||
|
|
@ -475,17 +507,6 @@ func hasEnctype(list []EnctypeEntry, id int32) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func errorcodeName(code int32) string {
|
|
||||||
s := errorcode.Lookup(code)
|
|
||||||
if _, after, ok := strings.Cut(s, ") "); ok {
|
|
||||||
s = after
|
|
||||||
}
|
|
||||||
if before, _, ok := strings.Cut(s, " "); ok {
|
|
||||||
s = before
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers ----------------------------------------------------------------
|
// ---- helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
func optFloat(opts sdk.CheckerOptions, key string, def float64) float64 {
|
func optFloat(opts sdk.CheckerOptions, key string, def float64) float64 {
|
||||||
|
|
|
||||||
332
checker/collect_test.go
Normal file
332
checker/collect_test.go
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
asn1 "github.com/jcmturner/gofork/encoding/asn1"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/iana/errorcode"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/iana/etypeID"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/iana/nametype"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/iana/patype"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/messages"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildKRBError constructs a marshaled KRB-ERROR with the given code and
|
||||||
|
// optional EData payload.
|
||||||
|
func buildKRBError(t *testing.T, realm string, code int32, edata []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
sname := types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/"+realm)
|
||||||
|
k := messages.NewKRBError(sname, realm, code, "")
|
||||||
|
k.STime = time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
k.Susec = 0
|
||||||
|
k.EData = edata
|
||||||
|
raw, err := k.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal KRBError: %v", err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildETypeInfo2EData marshals a PADataSequence containing one
|
||||||
|
// PA_ETYPE_INFO2 entry per supplied (etype, salt) pair.
|
||||||
|
func buildETypeInfo2EData(t *testing.T, entries []types.ETypeInfo2Entry) []byte {
|
||||||
|
t.Helper()
|
||||||
|
value, err := asn1.Marshal(types.ETypeInfo2(entries))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal ETypeInfo2: %v", err)
|
||||||
|
}
|
||||||
|
pas := types.PADataSequence{
|
||||||
|
{PADataType: patype.PA_ETYPE_INFO2, PADataValue: value},
|
||||||
|
{PADataType: patype.PA_PK_AS_REQ, PADataValue: []byte{0x00}},
|
||||||
|
}
|
||||||
|
raw, err := asn1.Marshal(pas)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal PADataSequence: %v", err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseASResponse_KRBErrorPreauthRequiredWithEData(t *testing.T) {
|
||||||
|
edata := buildETypeInfo2EData(t, []types.ETypeInfo2Entry{
|
||||||
|
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: "EXAMPLE.COMuser"},
|
||||||
|
{EType: etypeID.RC4_HMAC, Salt: ""},
|
||||||
|
})
|
||||||
|
raw := buildKRBError(t, "EXAMPLE.COM", errorcode.KDC_ERR_PREAUTH_REQUIRED, edata)
|
||||||
|
|
||||||
|
var out ASProbeResult
|
||||||
|
parseASResponse(raw, &out)
|
||||||
|
|
||||||
|
if out.Error != "" {
|
||||||
|
t.Fatalf("unexpected parse error: %s", out.Error)
|
||||||
|
}
|
||||||
|
if out.ErrorCode != errorcode.KDC_ERR_PREAUTH_REQUIRED {
|
||||||
|
t.Errorf("ErrorCode = %d, want KDC_ERR_PREAUTH_REQUIRED", out.ErrorCode)
|
||||||
|
}
|
||||||
|
if !out.PreauthReq {
|
||||||
|
t.Error("PreauthReq should be true for KDC_ERR_PREAUTH_REQUIRED")
|
||||||
|
}
|
||||||
|
if out.ServerRealm != "EXAMPLE.COM" {
|
||||||
|
t.Errorf("ServerRealm = %q, want EXAMPLE.COM", out.ServerRealm)
|
||||||
|
}
|
||||||
|
if out.ServerTime.IsZero() {
|
||||||
|
t.Error("ServerTime should be populated from STime")
|
||||||
|
}
|
||||||
|
if !out.PKINITOffered {
|
||||||
|
t.Error("PKINITOffered should be true (PA_PK_AS_REQ present)")
|
||||||
|
}
|
||||||
|
if len(out.Enctypes) != 2 {
|
||||||
|
t.Fatalf("Enctypes len = %d, want 2", len(out.Enctypes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sawAES, sawRC4 bool
|
||||||
|
for _, e := range out.Enctypes {
|
||||||
|
switch e.ID {
|
||||||
|
case etypeID.AES256_CTS_HMAC_SHA1_96:
|
||||||
|
sawAES = true
|
||||||
|
if e.Weak {
|
||||||
|
t.Error("AES256 should not be flagged weak")
|
||||||
|
}
|
||||||
|
if e.Source != "etype-info2" {
|
||||||
|
t.Errorf("AES256 Source = %q, want etype-info2", e.Source)
|
||||||
|
}
|
||||||
|
case etypeID.RC4_HMAC:
|
||||||
|
sawRC4 = true
|
||||||
|
if !e.Weak {
|
||||||
|
t.Error("RC4_HMAC should be flagged weak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawAES || !sawRC4 {
|
||||||
|
t.Errorf("missing expected enctypes (sawAES=%v sawRC4=%v)", sawAES, sawRC4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseASResponse_KRBErrorPrincipalUnknownNoEData(t *testing.T) {
|
||||||
|
raw := buildKRBError(t, "EXAMPLE.COM", errorcode.KDC_ERR_C_PRINCIPAL_UNKNOWN, nil)
|
||||||
|
|
||||||
|
var out ASProbeResult
|
||||||
|
parseASResponse(raw, &out)
|
||||||
|
|
||||||
|
if out.Error != "" {
|
||||||
|
t.Fatalf("unexpected parse error: %s", out.Error)
|
||||||
|
}
|
||||||
|
if out.ErrorCode != errorcode.KDC_ERR_C_PRINCIPAL_UNKNOWN {
|
||||||
|
t.Errorf("ErrorCode = %d, want KDC_ERR_C_PRINCIPAL_UNKNOWN", out.ErrorCode)
|
||||||
|
}
|
||||||
|
if out.PreauthReq {
|
||||||
|
t.Error("PreauthReq should be false")
|
||||||
|
}
|
||||||
|
if len(out.Enctypes) != 0 {
|
||||||
|
t.Errorf("Enctypes should be empty, got %d", len(out.Enctypes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseASResponse_GarbageBytes(t *testing.T) {
|
||||||
|
var out ASProbeResult
|
||||||
|
parseASResponse([]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe}, &out)
|
||||||
|
if out.Error == "" {
|
||||||
|
t.Fatal("expected an Error string for unparsable bytes")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.Error, "deadbeefcafe") {
|
||||||
|
t.Errorf("Error should include hex prefix of payload, got %q", out.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEData_ETypeInfoFallback(t *testing.T) {
|
||||||
|
// PA_ETYPE_INFO (legacy) only. Salt is octet-string here.
|
||||||
|
value, err := asn1.Marshal(types.ETypeInfo{
|
||||||
|
{EType: etypeID.AES128_CTS_HMAC_SHA1_96, Salt: []byte("salty")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal ETypeInfo: %v", err)
|
||||||
|
}
|
||||||
|
edata, err := asn1.Marshal(types.PADataSequence{
|
||||||
|
{PADataType: patype.PA_ETYPE_INFO, PADataValue: value},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal PADataSequence: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enctypes, pkinit := extractEData(edata)
|
||||||
|
if pkinit {
|
||||||
|
t.Error("PKINIT should not be reported when no PA_PK_AS_REQ is present")
|
||||||
|
}
|
||||||
|
if len(enctypes) != 1 {
|
||||||
|
t.Fatalf("got %d enctypes, want 1", len(enctypes))
|
||||||
|
}
|
||||||
|
if enctypes[0].Source != "etype-info" {
|
||||||
|
t.Errorf("Source = %q, want etype-info", enctypes[0].Source)
|
||||||
|
}
|
||||||
|
if enctypes[0].Salt != "salty" {
|
||||||
|
t.Errorf("Salt = %q, want salty", enctypes[0].Salt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEData_ETypeInfo2WinsOverInfo(t *testing.T) {
|
||||||
|
// Both PA_ETYPE_INFO2 and PA_ETYPE_INFO advertise the same enctype.
|
||||||
|
// The legacy info should be skipped (de-duplicated).
|
||||||
|
v2, _ := asn1.Marshal(types.ETypeInfo2{
|
||||||
|
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: "fromInfo2"},
|
||||||
|
})
|
||||||
|
v1, _ := asn1.Marshal(types.ETypeInfo{
|
||||||
|
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: []byte("fromInfo")},
|
||||||
|
})
|
||||||
|
edata, _ := asn1.Marshal(types.PADataSequence{
|
||||||
|
{PADataType: patype.PA_ETYPE_INFO2, PADataValue: v2},
|
||||||
|
{PADataType: patype.PA_ETYPE_INFO, PADataValue: v1},
|
||||||
|
})
|
||||||
|
got, _ := extractEData(edata)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d entries, want 1 (de-duplicated)", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Salt != "fromInfo2" {
|
||||||
|
t.Errorf("salt = %q, want fromInfo2 (etype-info2 must take precedence)", got[0].Salt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEData_BadASN1(t *testing.T) {
|
||||||
|
enctypes, pkinit := extractEData([]byte{0xff, 0x00})
|
||||||
|
if enctypes != nil || pkinit {
|
||||||
|
t.Errorf("expected nil/false on garbage, got %v / %v", enctypes, pkinit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEtypeName(t *testing.T) {
|
||||||
|
if got := etypeName(etypeID.AES256_CTS_HMAC_SHA1_96); !strings.Contains(strings.ToLower(got), "aes256") {
|
||||||
|
t.Errorf("AES256 name = %q, want it to mention aes256", got)
|
||||||
|
}
|
||||||
|
if got := etypeName(99999); got != "etype-99999" {
|
||||||
|
t.Errorf("unknown etype = %q, want etype-99999", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorcodeNameAndKRBErrorInfo(t *testing.T) {
|
||||||
|
name := errorcodeName(errorcode.KDC_ERR_PREAUTH_REQUIRED)
|
||||||
|
if !strings.Contains(name, "PREAUTH") {
|
||||||
|
t.Errorf("errorcodeName = %q, want it to contain PREAUTH", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typed KRBError: errors.As path.
|
||||||
|
sname := types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/EXAMPLE.COM")
|
||||||
|
krb := messages.NewKRBError(sname, "EXAMPLE.COM", errorcode.KDC_ERR_PREAUTH_REQUIRED, "")
|
||||||
|
code, n, ok := krbErrorInfo(krb)
|
||||||
|
if !ok || code != errorcode.KDC_ERR_PREAUTH_REQUIRED || !strings.Contains(n, "PREAUTH") {
|
||||||
|
t.Errorf("krbErrorInfo(typed) = %d %q %v", code, n, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String fallback: gokrb5 sometimes wraps the code only inside the message.
|
||||||
|
wrapped := errors.New("login failed: KRB Error: (24) KDC_ERR_PREAUTH_FAILED - bla")
|
||||||
|
code, n, ok = krbErrorInfo(wrapped)
|
||||||
|
if !ok || code != 24 {
|
||||||
|
t.Errorf("krbErrorInfo(string) code=%d ok=%v", code, ok)
|
||||||
|
}
|
||||||
|
if !strings.Contains(n, "PREAUTH_FAILED") {
|
||||||
|
t.Errorf("krbErrorInfo(string) name = %q", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, ok := krbErrorInfo(nil); ok {
|
||||||
|
t.Error("krbErrorInfo(nil) should return ok=false")
|
||||||
|
}
|
||||||
|
if _, _, ok := krbErrorInfo(errors.New("plain old error")); ok {
|
||||||
|
t.Error("krbErrorInfo on a non-KRB error should return ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleForPrefix(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"_kerberos._tcp.": "kdc",
|
||||||
|
"_kerberos._udp.": "kdc",
|
||||||
|
"_kerberos-master._tcp.": "master",
|
||||||
|
"_kerberos-adm._tcp.": "kadmin",
|
||||||
|
"_kpasswd._tcp.": "kpasswd",
|
||||||
|
"_kpasswd._udp.": "kpasswd",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := roleForPrefix(in); got != want {
|
||||||
|
t.Errorf("roleForPrefix(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptFloat(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in any
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{float64(2.5), 2.5},
|
||||||
|
{float32(1.5), 1.5},
|
||||||
|
{int(7), 7},
|
||||||
|
{int64(8), 8},
|
||||||
|
{"3.14", 3.14},
|
||||||
|
{"nope", 42}, // falls back to default
|
||||||
|
{nil, 42}, // missing key path is exercised below
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
opts := map[string]any{"k": c.in}
|
||||||
|
got := optFloat(opts, "k", 42)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("optFloat(%v) = %v, want %v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := optFloat(map[string]any{}, "missing", 99); got != 99 {
|
||||||
|
t.Errorf("optFloat(missing) = %v, want 99", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptBool(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in any
|
||||||
|
def bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{true, false, true},
|
||||||
|
{false, true, false},
|
||||||
|
{"true", false, true},
|
||||||
|
{"1", false, true},
|
||||||
|
{"false", true, false}, // unrecognized string -> default
|
||||||
|
{nil, true, true},
|
||||||
|
{42, false, false}, // unsupported type -> default
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
opts := map[string]any{}
|
||||||
|
if c.in != nil {
|
||||||
|
opts["k"] = c.in
|
||||||
|
}
|
||||||
|
got := optBool(opts, "k", c.def)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("optBool(%v, def=%v) = %v, want %v", c.in, c.def, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmallHelpers(t *testing.T) {
|
||||||
|
if got := abs(-3 * time.Second); got != 3*time.Second {
|
||||||
|
t.Errorf("abs negative = %v", got)
|
||||||
|
}
|
||||||
|
if got := abs(2 * time.Second); got != 2*time.Second {
|
||||||
|
t.Errorf("abs positive = %v", got)
|
||||||
|
}
|
||||||
|
if got := firstNonEmpty("", "", "x", "y"); got != "x" {
|
||||||
|
t.Errorf("firstNonEmpty = %q", got)
|
||||||
|
}
|
||||||
|
if got := firstNonEmpty("", ""); got != "" {
|
||||||
|
t.Errorf("firstNonEmpty(all empty) = %q", got)
|
||||||
|
}
|
||||||
|
if got := first([]byte{1, 2, 3}, 16); len(got) != 3 {
|
||||||
|
t.Errorf("first(short) len = %d, want 3", len(got))
|
||||||
|
}
|
||||||
|
if got := first([]byte{1, 2, 3, 4, 5}, 2); len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
||||||
|
t.Errorf("first(long) = %v", got)
|
||||||
|
}
|
||||||
|
list := []EnctypeEntry{{ID: 18}, {ID: 17}}
|
||||||
|
if !hasEnctype(list, 17) {
|
||||||
|
t.Error("hasEnctype should find 17")
|
||||||
|
}
|
||||||
|
if hasEnctype(list, 23) {
|
||||||
|
t.Error("hasEnctype should not find 23")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
var Version = "built-in"
|
var Version = "built-in"
|
||||||
|
|
||||||
// Definition returns the CheckerDefinition for the Kerberos checker.
|
// Definition returns the CheckerDefinition for the Kerberos checker.
|
||||||
func Definition() *sdk.CheckerDefinition {
|
func (p *kerberosProvider) Definition() *sdk.CheckerDefinition {
|
||||||
return &sdk.CheckerDefinition{
|
return &sdk.CheckerDefinition{
|
||||||
ID: "kerberos",
|
ID: "kerberos",
|
||||||
Name: "Kerberos Realm Tester",
|
Name: "Kerberos Realm Tester",
|
||||||
|
|
@ -79,9 +79,7 @@ func Definition() *sdk.CheckerDefinition {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Rules: []sdk.CheckRule{
|
Rules: Rules(),
|
||||||
Rule(),
|
|
||||||
},
|
|
||||||
Interval: &sdk.CheckIntervalSpec{
|
Interval: &sdk.CheckIntervalSpec{
|
||||||
Min: 5 * time.Minute,
|
Min: 5 * time.Minute,
|
||||||
Max: 7 * 24 * time.Hour,
|
Max: 7 * 24 * time.Hour,
|
||||||
|
|
|
||||||
48
checker/errors.go
Normal file
48
checker/errors.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jcmturner/gokrb5/v8/iana/errorcode"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// krbErrorInfo extracts a Kerberos error code (and its short name) from an
|
||||||
|
// error returned by gokrb5. Direct KRBError values are matched via
|
||||||
|
// errors.As; otherwise the error string is parsed, since gokrb5 also
|
||||||
|
// returns wrapped krberror.Krberror values that carry the code only inside
|
||||||
|
// their formatted message. ok is false when no code could be extracted.
|
||||||
|
func krbErrorInfo(err error) (code int32, name string, ok bool) {
|
||||||
|
if err == nil {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
var krbErr messages.KRBError
|
||||||
|
if errors.As(err, &krbErr) {
|
||||||
|
return krbErr.ErrorCode, errorcodeName(krbErr.ErrorCode), true
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if _, after, found := strings.Cut(msg, "KRB Error: ("); found {
|
||||||
|
if c, _, found := strings.Cut(after, ")"); found {
|
||||||
|
if n, perr := strconv.Atoi(c); perr == nil {
|
||||||
|
return int32(n), errorcodeName(int32(n)), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorcodeName returns the short symbolic name of a Kerberos error code
|
||||||
|
// (e.g. "KDC_ERR_PREAUTH_REQUIRED"), trimming the numeric/textual padding
|
||||||
|
// gokrb5 wraps around it.
|
||||||
|
func errorcodeName(code int32) string {
|
||||||
|
s := errorcode.Lookup(code)
|
||||||
|
if _, after, ok := strings.Cut(s, ") "); ok {
|
||||||
|
s = after
|
||||||
|
}
|
||||||
|
if before, _, ok := strings.Cut(s, " "); ok {
|
||||||
|
s = before
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
@ -1,92 +1,53 @@
|
||||||
|
//go:build standalone
|
||||||
|
|
||||||
package checker
|
package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderForm exposes the minimal set of fields a human needs to fire a
|
// RenderForm exposes the run + admin options documented in Definition()
|
||||||
// standalone Kerberos probe via GET /check.
|
// so the standalone form stays in sync with the host-side documentation.
|
||||||
func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField {
|
func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField {
|
||||||
return []sdk.CheckerOptionField{
|
docs := p.Definition().Options
|
||||||
{
|
fields := make([]sdk.CheckerOptionField, 0, len(docs.RunOpts)+len(docs.AdminOpts))
|
||||||
Id: "realm",
|
fields = append(fields, docs.RunOpts...)
|
||||||
Type: "string",
|
fields = append(fields, docs.AdminOpts...)
|
||||||
Label: "Kerberos realm",
|
return fields
|
||||||
Placeholder: "EXAMPLE.COM",
|
|
||||||
Required: true,
|
|
||||||
Description: "DNS domain advertising the realm (the realm name itself is derived in uppercase).",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "principal",
|
|
||||||
Type: "string",
|
|
||||||
Label: "Principal (optional)",
|
|
||||||
Placeholder: "user@EXAMPLE.COM",
|
|
||||||
Description: "Supply to run an authenticated round-trip. Leave blank for anonymous probes only.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "password",
|
|
||||||
Type: "string",
|
|
||||||
Label: "Password (optional)",
|
|
||||||
Secret: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "targetService",
|
|
||||||
Type: "string",
|
|
||||||
Label: "Service to request (TGS)",
|
|
||||||
Placeholder: "host/host.example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "timeout",
|
|
||||||
Type: "number",
|
|
||||||
Label: "Per-probe timeout (seconds)",
|
|
||||||
Default: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "requireStrongEnctypes",
|
|
||||||
Type: "bool",
|
|
||||||
Label: "Require strong enctypes",
|
|
||||||
Default: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "maxClockSkew",
|
|
||||||
Type: "number",
|
|
||||||
Label: "Max tolerated clock skew (seconds)",
|
|
||||||
Default: 300,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseForm turns the submitted form into a CheckerOptions. Collect handles
|
// ParseForm turns the submitted form into a CheckerOptions, using the
|
||||||
// the SRV / DNS discovery itself, so there is nothing to auto-fill here
|
// documented field types to coerce values.
|
||||||
// beyond the raw inputs.
|
|
||||||
func (p *kerberosProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
func (p *kerberosProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||||
realm := strings.TrimSpace(r.FormValue("realm"))
|
opts := sdk.CheckerOptions{}
|
||||||
if realm == "" {
|
for _, f := range p.RenderForm() {
|
||||||
return nil, errors.New("realm is required")
|
raw := r.FormValue(f.Id)
|
||||||
|
if f.Type != "bool" {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
if f.Required {
|
||||||
|
return nil, errors.New(f.Id + " is required")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch f.Type {
|
||||||
|
case "bool":
|
||||||
|
opts[f.Id] = raw == "true" || raw == "1" || raw == "on"
|
||||||
|
case "number":
|
||||||
|
if v, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||||
|
opts[f.Id] = v
|
||||||
|
} else {
|
||||||
|
opts[f.Id] = raw
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
opts[f.Id] = raw
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := sdk.CheckerOptions{"realm": realm}
|
|
||||||
|
|
||||||
if v := strings.TrimSpace(r.FormValue("principal")); v != "" {
|
|
||||||
opts["principal"] = v
|
|
||||||
}
|
|
||||||
if v := r.FormValue("password"); v != "" {
|
|
||||||
opts["password"] = v
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(r.FormValue("targetService")); v != "" {
|
|
||||||
opts["targetService"] = v
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(r.FormValue("timeout")); v != "" {
|
|
||||||
opts["timeout"] = v
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(r.FormValue("maxClockSkew")); v != "" {
|
|
||||||
opts["maxClockSkew"] = v
|
|
||||||
}
|
|
||||||
opts["requireStrongEnctypes"] = r.FormValue("requireStrongEnctypes") == "true"
|
|
||||||
|
|
||||||
return opts, nil
|
return opts, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,3 @@ type kerberosProvider struct{}
|
||||||
func (p *kerberosProvider) Key() sdk.ObservationKey {
|
func (p *kerberosProvider) Key() sdk.ObservationKey {
|
||||||
return ObservationKeyKerberos
|
return ObservationKeyKerberos
|
||||||
}
|
}
|
||||||
|
|
||||||
// Definition implements sdk.CheckerDefinitionProvider.
|
|
||||||
func (p *kerberosProvider) Definition() *sdk.CheckerDefinition {
|
|
||||||
return Definition()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── HTML report ───────────────────────────────────────────────────────────────
|
// ── HTML report ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -49,9 +51,9 @@ type srvView struct {
|
||||||
|
|
||||||
type reportData struct {
|
type reportData struct {
|
||||||
Realm string
|
Realm string
|
||||||
|
HasStates bool
|
||||||
OverallOK bool
|
OverallOK bool
|
||||||
CollectedAt string
|
CollectedAt string
|
||||||
LocalTime string
|
|
||||||
ServerTime string
|
ServerTime string
|
||||||
ClockSkew string
|
ClockSkew string
|
||||||
ClockSkewBad bool
|
ClockSkewBad bool
|
||||||
|
|
@ -78,17 +80,30 @@ func fmtDur(d time.Duration) string {
|
||||||
return d.Round(time.Millisecond).String()
|
return d.Round(time.Millisecond).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
|
func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
|
||||||
var r KerberosData
|
var r KerberosData
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
if err := json.Unmarshal(rctx.Data(), &r); err != nil {
|
||||||
return "", fmt.Errorf("failed to unmarshal kerberos report: %w", err)
|
return "", fmt.Errorf("failed to unmarshal kerberos report: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive overall OK exclusively from the states the host produced for
|
||||||
|
// this run. When no states are supplied, render a data-only view with
|
||||||
|
// no status banner and no remediation hints.
|
||||||
|
states := rctx.States()
|
||||||
|
hasStates := len(states) > 0
|
||||||
|
overallOK := hasStates
|
||||||
|
for _, s := range states {
|
||||||
|
if s.Status == sdk.StatusCrit || s.Status == sdk.StatusError || s.Status == sdk.StatusWarn {
|
||||||
|
overallOK = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rd := reportData{
|
rd := reportData{
|
||||||
Realm: r.Realm,
|
Realm: r.Realm,
|
||||||
OverallOK: r.OverallOK,
|
HasStates: hasStates,
|
||||||
|
OverallOK: overallOK,
|
||||||
CollectedAt: r.CollectedAt.Format(time.RFC3339),
|
CollectedAt: r.CollectedAt.Format(time.RFC3339),
|
||||||
LocalTime: r.LocalTime.Format(time.RFC3339),
|
|
||||||
ASProbe: r.AS,
|
ASProbe: r.AS,
|
||||||
ASErrorName: r.AS.ErrorName,
|
ASErrorName: r.AS.ErrorName,
|
||||||
PreauthReq: r.AS.PreauthReq,
|
PreauthReq: r.AS.PreauthReq,
|
||||||
|
|
@ -100,8 +115,14 @@ func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
|
||||||
}
|
}
|
||||||
if r.AS.ClockSkew != 0 {
|
if r.AS.ClockSkew != 0 {
|
||||||
rd.ClockSkew = fmtDur(r.AS.ClockSkew)
|
rd.ClockSkew = fmtDur(r.AS.ClockSkew)
|
||||||
if r.AS.ClockSkew > 5*time.Minute || r.AS.ClockSkew < -5*time.Minute {
|
}
|
||||||
|
// Trust the clock-skew rule's verdict (which honours maxClockSkew)
|
||||||
|
// rather than re-applying a hardcoded threshold here.
|
||||||
|
for _, s := range states {
|
||||||
|
if s.Code == CodeClockSkewBad &&
|
||||||
|
(s.Status == sdk.StatusCrit || s.Status == sdk.StatusWarn || s.Status == sdk.StatusError) {
|
||||||
rd.ClockSkewBad = true
|
rd.ClockSkewBad = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,8 +177,11 @@ func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
|
||||||
rd.HasMixedCrypto = true
|
rd.HasMixedCrypto = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect common failures and build the remediation banner.
|
// Detect common failures and build the remediation banner. Hints are
|
||||||
rd.Remediations = buildRemediations(&r, rd)
|
// only surfaced when the host supplied rule states for this run.
|
||||||
|
if hasStates {
|
||||||
|
rd.Remediations = buildRemediations(&r, rd)
|
||||||
|
}
|
||||||
|
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil {
|
if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil {
|
||||||
|
|
@ -257,7 +281,7 @@ then rekey principals with <code>kadmin -q "cpw -randkey principal"</code> or eq
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AS-REP without preauth — AS-REP roasting.
|
// AS-REP without preauth, AS-REP roasting.
|
||||||
if r.AS.Attempted && r.AS.PrincipalFound && !r.AS.PreauthReq {
|
if r.AS.Attempted && r.AS.PrincipalFound && !r.AS.PreauthReq {
|
||||||
out = append(out, remediation{
|
out = append(out, remediation{
|
||||||
Title: "Enable pre-authentication",
|
Title: "Enable pre-authentication",
|
||||||
|
|
@ -376,9 +400,11 @@ th { font-weight: 600; color: #6b7280; }
|
||||||
|
|
||||||
<div class="hd">
|
<div class="hd">
|
||||||
<h1>Kerberos Realm</h1>
|
<h1>Kerberos Realm</h1>
|
||||||
{{if .OverallOK}}<span class="badge ok">Realm OK</span>
|
{{if .HasStates}}
|
||||||
{{else if .Remediations}}<span class="badge fail">Issues detected</span>
|
{{if .OverallOK}}<span class="badge ok">Realm OK</span>
|
||||||
{{else}}<span class="badge warn">Needs attention</span>{{end}}
|
{{else if .Remediations}}<span class="badge fail">Issues detected</span>
|
||||||
|
{{else}}<span class="badge warn">Needs attention</span>{{end}}
|
||||||
|
{{end}}
|
||||||
<div class="realm">Realm: <code>{{.Realm}}</code>{{if .ASProbe.Target}} · probed via <code>{{.ASProbe.Target}}</code> ({{.ASProbe.Proto}}){{end}}</div>
|
<div class="realm">Realm: <code>{{.Realm}}</code>{{if .ASProbe.Target}} · probed via <code>{{.ASProbe.Target}}</code> ({{.ASProbe.Proto}}){{end}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -472,7 +498,6 @@ th { font-weight: 600; color: #6b7280; }
|
||||||
<dt>Response</dt><dd><code>{{.ASErrorName}}</code> {{if .PreauthReq}}<span class="badge ok">preauth required</span>{{end}}{{if .PrincipalFound}}<span class="badge warn">AS-REP without preauth</span>{{end}}</dd>
|
<dt>Response</dt><dd><code>{{.ASErrorName}}</code> {{if .PreauthReq}}<span class="badge ok">preauth required</span>{{end}}{{if .PrincipalFound}}<span class="badge warn">AS-REP without preauth</span>{{end}}</dd>
|
||||||
<dt>Realm echoed</dt><dd><code>{{.ASProbe.ServerRealm}}</code></dd>
|
<dt>Realm echoed</dt><dd><code>{{.ASProbe.ServerRealm}}</code></dd>
|
||||||
<dt>Server time</dt><dd>{{.ServerTime}}</dd>
|
<dt>Server time</dt><dd>{{.ServerTime}}</dd>
|
||||||
<dt>Local time</dt><dd>{{.LocalTime}}</dd>
|
|
||||||
<dt>Clock skew</dt><dd>{{if .ClockSkewBad}}<span class="check-fail">{{.ClockSkew}}</span>{{else}}{{.ClockSkew}}{{end}}</dd>
|
<dt>Clock skew</dt><dd>{{if .ClockSkewBad}}<span class="check-fail">{{.ClockSkew}}</span>{{else}}{{.ClockSkew}}{{end}}</dd>
|
||||||
<dt>PKINIT offered</dt><dd>{{if .PKINITOffered}}<span class="check-ok">yes</span>{{else}}no{{end}}</dd>
|
<dt>PKINIT offered</dt><dd>{{if .PKINITOffered}}<span class="check-ok">yes</span>{{else}}no{{end}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
|
||||||
224
checker/rule.go
224
checker/rule.go
|
|
@ -1,224 +0,0 @@
|
||||||
package checker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rule returns the kerberos_health rule.
|
|
||||||
func Rule() sdk.CheckRule {
|
|
||||||
return &kerberosRule{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type kerberosRule struct{}
|
|
||||||
|
|
||||||
func (r *kerberosRule) Name() string {
|
|
||||||
return "kerberos_health"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *kerberosRule) Description() string {
|
|
||||||
return "Checks whether a Kerberos realm answers correctly, advertises strong crypto, and exposes no obvious misconfiguration."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *kerberosRule) ValidateOptions(opts sdk.CheckerOptions) error { return nil }
|
|
||||||
|
|
||||||
func (r *kerberosRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
|
||||||
var data KerberosData
|
|
||||||
if err := obs.Get(ctx, ObservationKeyKerberos, &data); err != nil {
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusError,
|
|
||||||
Message: fmt.Sprintf("Failed to get Kerberos data: %v", err),
|
|
||||||
Code: "kerberos_error",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
maxSkew := time.Duration(optFloat(opts, "maxClockSkew", 300)) * time.Second
|
|
||||||
requireStrong := optBool(opts, "requireStrongEnctypes", true)
|
|
||||||
|
|
||||||
// Presence of at least one _kerberos._tcp or ._udp record is mandatory.
|
|
||||||
hasKDCSRV := false
|
|
||||||
for _, b := range data.SRV {
|
|
||||||
if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
|
|
||||||
hasKDCSRV = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasKDCSRV {
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Subject: data.Realm,
|
|
||||||
Message: fmt.Sprintf("No _kerberos SRV records found for %s", data.Realm),
|
|
||||||
Code: "kerberos_no_srv",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// KDC reachability: need at least one successful probe among KDC roles.
|
|
||||||
reachable := 0
|
|
||||||
kdcProbes := 0
|
|
||||||
kadminDown, kpasswdDown := false, false
|
|
||||||
for _, p := range data.Probes {
|
|
||||||
switch p.Role {
|
|
||||||
case "kdc":
|
|
||||||
kdcProbes++
|
|
||||||
if p.OK {
|
|
||||||
reachable++
|
|
||||||
}
|
|
||||||
case "kadmin":
|
|
||||||
if !p.OK {
|
|
||||||
kadminDown = true
|
|
||||||
}
|
|
||||||
case "kpasswd":
|
|
||||||
if !p.OK {
|
|
||||||
kpasswdDown = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if reachable == 0 {
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Subject: data.Realm,
|
|
||||||
Message: "No KDC is reachable on TCP 88 or UDP 88",
|
|
||||||
Code: "kerberos_kdc_unreachable",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AS-REQ result.
|
|
||||||
if data.AS.Attempted {
|
|
||||||
if data.AS.Error != "" && data.AS.ErrorCode == 0 {
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Subject: data.Realm,
|
|
||||||
Message: "AS-REQ probe failed: " + data.AS.Error,
|
|
||||||
Code: "kerberos_error",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
if data.AS.ServerRealm != "" && !strings.EqualFold(data.AS.ServerRealm, data.Realm) {
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Subject: data.Realm,
|
|
||||||
Message: fmt.Sprintf("KDC replied for realm %q, expected %q", data.AS.ServerRealm, data.Realm),
|
|
||||||
Code: "kerberos_wrong_realm",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
if abs(data.AS.ClockSkew) > maxSkew {
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Subject: data.Realm,
|
|
||||||
Message: fmt.Sprintf("Clock skew with KDC is %s (max %s)", round(data.AS.ClockSkew), maxSkew),
|
|
||||||
Code: "kerberos_clock_skew",
|
|
||||||
Meta: map[string]any{
|
|
||||||
"skew_ns": data.AS.ClockSkew.Nanoseconds(),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crypto posture.
|
|
||||||
hasStrong := false
|
|
||||||
for _, e := range data.Enctypes {
|
|
||||||
if !e.Weak {
|
|
||||||
hasStrong = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if requireStrong && len(data.Enctypes) > 0 && !hasStrong {
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Subject: data.Realm,
|
|
||||||
Message: "KDC only advertises weak enctypes (DES/RC4)",
|
|
||||||
Code: "kerberos_weak_crypto",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth probe (if any).
|
|
||||||
if data.Auth != nil && data.Auth.Attempted {
|
|
||||||
if !data.Auth.TGTAcquired {
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Subject: data.Realm,
|
|
||||||
Message: "Authenticated probe: TGT acquisition failed",
|
|
||||||
Code: "kerberos_auth_fail",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
if data.Auth.TargetService != "" && !data.Auth.TGSAcquired {
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusWarn,
|
|
||||||
Subject: data.Realm,
|
|
||||||
Message: fmt.Sprintf("TGT OK but TGS-REQ for %s failed", data.Auth.TargetService),
|
|
||||||
Code: "kerberos_tgs_fail",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warnings: partial reachability, no UDP, mixed crypto, no preauth.
|
|
||||||
var warnings []string
|
|
||||||
if reachable < kdcProbes {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("%d/%d KDC endpoints unreachable", kdcProbes-reachable, kdcProbes))
|
|
||||||
}
|
|
||||||
if len(data.WeakEnctypes) > 0 && hasStrong {
|
|
||||||
warnings = append(warnings, "KDC also advertises weak enctypes alongside strong ones")
|
|
||||||
}
|
|
||||||
if data.AS.Attempted && data.AS.PrincipalFound && !data.AS.PreauthReq {
|
|
||||||
warnings = append(warnings, "AS-REP returned without preauth (AS-REP roasting exposure)")
|
|
||||||
}
|
|
||||||
if kadminDown {
|
|
||||||
warnings = append(warnings, "kadmin server unreachable")
|
|
||||||
}
|
|
||||||
if kpasswdDown {
|
|
||||||
warnings = append(warnings, "kpasswd unreachable")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(warnings) > 0 {
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusWarn,
|
|
||||||
Subject: data.Realm,
|
|
||||||
Message: fmt.Sprintf("Realm %s reachable: %s", data.Realm, strings.Join(warnings, "; ")),
|
|
||||||
Code: "kerberos_warn",
|
|
||||||
Meta: map[string]any{
|
|
||||||
"reachable_kdcs": reachable,
|
|
||||||
"warnings": warnings,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusOK,
|
|
||||||
Subject: data.Realm,
|
|
||||||
Message: fmt.Sprintf("Realm %s healthy (%d KDC reachable, strong crypto)", data.Realm, reachable),
|
|
||||||
Code: "kerberos_ok",
|
|
||||||
Meta: map[string]any{
|
|
||||||
"realm": data.Realm,
|
|
||||||
"reachable_kdcs": reachable,
|
|
||||||
"clock_skew_ns": data.AS.ClockSkew.Nanoseconds(),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func abs(d time.Duration) time.Duration {
|
|
||||||
if d < 0 {
|
|
||||||
return -d
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func round(d time.Duration) time.Duration {
|
|
||||||
return d.Round(time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
func optBool(opts sdk.CheckerOptions, key string, def bool) bool {
|
|
||||||
v, ok := opts[key]
|
|
||||||
if !ok {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
switch x := v.(type) {
|
|
||||||
case bool:
|
|
||||||
return x
|
|
||||||
case string:
|
|
||||||
return x == "true" || x == "1"
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
594
checker/rules.go
Normal file
594
checker/rules.go
Normal file
|
|
@ -0,0 +1,594 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule codes emitted by the kerberos rules. Keep these stable; UI / metrics
|
||||||
|
// may match on them.
|
||||||
|
const (
|
||||||
|
CodeSRVOK = "kerberos.srv.ok"
|
||||||
|
CodeNoSRV = "kerberos.srv.missing"
|
||||||
|
CodeKDCReachableOK = "kerberos.kdc.reachable"
|
||||||
|
CodeKDCUnreachable = "kerberos.kdc.unreachable"
|
||||||
|
CodeKDCPartial = "kerberos.kdc.partial"
|
||||||
|
CodeASProbeOK = "kerberos.as.ok"
|
||||||
|
CodeASProbeFailed = "kerberos.as.failed"
|
||||||
|
CodeASWrongRealm = "kerberos.as.wrong_realm"
|
||||||
|
CodeASRepNoPreauth = "kerberos.as.no_preauth"
|
||||||
|
CodeClockSkewOK = "kerberos.clock_skew.ok"
|
||||||
|
CodeClockSkewBad = "kerberos.clock_skew.bad"
|
||||||
|
CodeEnctypesStrong = "kerberos.enctypes.strong"
|
||||||
|
CodeEnctypesWeakOnly = "kerberos.enctypes.weak_only"
|
||||||
|
CodeEnctypesMixed = "kerberos.enctypes.mixed"
|
||||||
|
CodeEnctypesUnknown = "kerberos.enctypes.unknown"
|
||||||
|
CodeKadminDown = "kerberos.kadmin.unreachable"
|
||||||
|
CodeKadminOK = "kerberos.kadmin.ok"
|
||||||
|
CodeKpasswdDown = "kerberos.kpasswd.unreachable"
|
||||||
|
CodeKpasswdOK = "kerberos.kpasswd.ok"
|
||||||
|
CodeAuthSkipped = "kerberos.auth.skipped"
|
||||||
|
CodeAuthTGTOK = "kerberos.auth.tgt_ok"
|
||||||
|
CodeAuthTGTFail = "kerberos.auth.tgt_fail"
|
||||||
|
CodeAuthTGSOK = "kerberos.auth.tgs_ok"
|
||||||
|
CodeAuthTGSFail = "kerberos.auth.tgs_fail"
|
||||||
|
CodeAuthTGSSkipped = "kerberos.auth.tgs_skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rules returns the full list of CheckRules exposed by the Kerberos checker.
|
||||||
|
func Rules() []sdk.CheckRule {
|
||||||
|
return []sdk.CheckRule{
|
||||||
|
&srvPresenceRule{},
|
||||||
|
&kdcReachabilityRule{},
|
||||||
|
&asProbeRule{},
|
||||||
|
&realmMatchRule{},
|
||||||
|
&preauthRule{},
|
||||||
|
&clockSkewRule{},
|
||||||
|
&enctypesRule{},
|
||||||
|
&kadminRule{},
|
||||||
|
&kpasswdRule{},
|
||||||
|
&authTGTRule{},
|
||||||
|
&authTGSRule{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadData fetches the Kerberos observation. On error, returns a CheckState
|
||||||
|
// the caller should emit to short-circuit its rule.
|
||||||
|
func loadData(ctx context.Context, obs sdk.ObservationGetter) (*KerberosData, *sdk.CheckState) {
|
||||||
|
var data KerberosData
|
||||||
|
if err := obs.Get(ctx, ObservationKeyKerberos, &data); err != nil {
|
||||||
|
return nil, &sdk.CheckState{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Message: fmt.Sprintf("failed to load Kerberos observation: %v", err),
|
||||||
|
Code: "kerberos.observation_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SRV presence ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type srvPresenceRule struct{}
|
||||||
|
|
||||||
|
func (r *srvPresenceRule) Name() string { return "kerberos.srv_present" }
|
||||||
|
func (r *srvPresenceRule) Description() string {
|
||||||
|
return "Verifies that at least one _kerberos._tcp / _kerberos._udp SRV record is published for the realm."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *srvPresenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
for _, b := range data.SRV {
|
||||||
|
if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "Kerberos SRV records are published.",
|
||||||
|
Code: CodeSRVOK,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("No _kerberos SRV records found for %s", data.Realm),
|
||||||
|
Code: CodeNoSRV,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KDC reachability ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type kdcReachabilityRule struct{}
|
||||||
|
|
||||||
|
func (r *kdcReachabilityRule) Name() string { return "kerberos.kdc_reachable" }
|
||||||
|
func (r *kdcReachabilityRule) Description() string {
|
||||||
|
return "Verifies that at least one KDC endpoint (TCP/UDP 88) accepts a connection."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *kdcReachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
total, reachable := 0, 0
|
||||||
|
for _, p := range data.Probes {
|
||||||
|
if p.Role != "kdc" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
if p.OK {
|
||||||
|
reachable++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "No KDC probe was attempted (no SRV target).",
|
||||||
|
Code: CodeKDCUnreachable,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if reachable == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "No KDC is reachable on TCP 88 or UDP 88.",
|
||||||
|
Code: CodeKDCUnreachable,
|
||||||
|
Meta: map[string]any{"total": total},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if reachable < total {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusWarn,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("%d/%d KDC endpoints unreachable.", total-reachable, total),
|
||||||
|
Code: CodeKDCPartial,
|
||||||
|
Meta: map[string]any{"reachable": reachable, "total": total},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("All %d KDC endpoints reachable.", total),
|
||||||
|
Code: CodeKDCReachableOK,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AS-REQ probe sanity ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type asProbeRule struct{}
|
||||||
|
|
||||||
|
func (r *asProbeRule) Name() string { return "kerberos.as_probe" }
|
||||||
|
func (r *asProbeRule) Description() string {
|
||||||
|
return "Verifies that the anonymous AS-REQ probe received a sane reply (KRB-ERROR or AS-REP)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *asProbeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
if !data.AS.Attempted {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "AS-REQ probe not attempted.",
|
||||||
|
Code: CodeASProbeFailed,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if data.AS.Error != "" && data.AS.ErrorCode == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "AS-REQ probe failed: " + data.AS.Error,
|
||||||
|
Code: CodeASProbeFailed,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("KDC replied to AS-REQ (%s).", firstNonEmpty(data.AS.ErrorName, "AS-REP")),
|
||||||
|
Code: CodeASProbeOK,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Realm echoed in KDC reply ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type realmMatchRule struct{}
|
||||||
|
|
||||||
|
func (r *realmMatchRule) Name() string { return "kerberos.realm_match" }
|
||||||
|
func (r *realmMatchRule) Description() string {
|
||||||
|
return "Verifies the KDC answers for the expected realm name."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *realmMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
if data.AS.ServerRealm == "" {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "KDC did not echo a realm (probe may have failed).",
|
||||||
|
Code: CodeASWrongRealm,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(data.AS.ServerRealm, data.Realm) {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("KDC replied for realm %q, expected %q", data.AS.ServerRealm, data.Realm),
|
||||||
|
Code: CodeASWrongRealm,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "KDC echoed the expected realm.",
|
||||||
|
Code: "kerberos.realm_match.ok",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AS-REP without preauth (AS-REP roasting exposure) ───────────────────────
|
||||||
|
|
||||||
|
type preauthRule struct{}
|
||||||
|
|
||||||
|
func (r *preauthRule) Name() string { return "kerberos.preauth_required" }
|
||||||
|
func (r *preauthRule) Description() string {
|
||||||
|
return "Flags KDCs that return an AS-REP without requiring pre-authentication (AS-REP roasting exposure)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *preauthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
if !data.AS.Attempted {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "AS-REQ probe not attempted; preauth posture unknown.",
|
||||||
|
Code: CodeASRepNoPreauth,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if data.AS.PrincipalFound && !data.AS.PreauthReq {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusWarn,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "AS-REP returned without preauth (AS-REP roasting exposure).",
|
||||||
|
Code: CodeASRepNoPreauth,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "Pre-authentication is enforced (or no AS-REP issued).",
|
||||||
|
Code: "kerberos.preauth_required.ok",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clock skew ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type clockSkewRule struct{}
|
||||||
|
|
||||||
|
func (r *clockSkewRule) Name() string { return "kerberos.clock_skew" }
|
||||||
|
func (r *clockSkewRule) Description() string {
|
||||||
|
return "Verifies the KDC clock is within tolerance of the checker's clock."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *clockSkewRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
if data.AS.ServerTime.IsZero() {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "KDC did not return a server time (probe may have failed).",
|
||||||
|
Code: CodeClockSkewBad,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
maxSkew := time.Duration(optFloat(opts, "maxClockSkew", 300) * float64(time.Second))
|
||||||
|
if abs(data.AS.ClockSkew) > maxSkew {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("Clock skew with KDC is %s (max %s).", round(data.AS.ClockSkew), maxSkew),
|
||||||
|
Code: CodeClockSkewBad,
|
||||||
|
Meta: map[string]any{"skew_ns": data.AS.ClockSkew.Nanoseconds()},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("Clock skew within tolerance (%s).", round(data.AS.ClockSkew)),
|
||||||
|
Code: CodeClockSkewOK,
|
||||||
|
Meta: map[string]any{"skew_ns": data.AS.ClockSkew.Nanoseconds()},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enctypes offered ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type enctypesRule struct{}
|
||||||
|
|
||||||
|
func (r *enctypesRule) Name() string { return "kerberos.enctypes" }
|
||||||
|
func (r *enctypesRule) Description() string {
|
||||||
|
return "Reviews the encryption types advertised by the KDC, flagging DES/RC4-only configurations."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *enctypesRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
if len(data.Enctypes) == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "KDC did not advertise enctypes (no ETYPE-INFO2 seen).",
|
||||||
|
Code: CodeEnctypesUnknown,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
hasStrong, hasWeak := false, false
|
||||||
|
var names []string
|
||||||
|
for _, e := range data.Enctypes {
|
||||||
|
names = append(names, e.Name)
|
||||||
|
if e.Weak {
|
||||||
|
hasWeak = true
|
||||||
|
} else {
|
||||||
|
hasStrong = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requireStrong := optBool(opts, "requireStrongEnctypes", true)
|
||||||
|
if !hasStrong {
|
||||||
|
status := sdk.StatusWarn
|
||||||
|
if requireStrong {
|
||||||
|
status = sdk.StatusCrit
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: status,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "KDC only advertises weak enctypes (DES/RC4).",
|
||||||
|
Code: CodeEnctypesWeakOnly,
|
||||||
|
Meta: map[string]any{"enctypes": names},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if hasWeak {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusWarn,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "KDC advertises weak enctypes alongside strong ones.",
|
||||||
|
Code: CodeEnctypesMixed,
|
||||||
|
Meta: map[string]any{"enctypes": names},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "KDC advertises only strong enctypes.",
|
||||||
|
Code: CodeEnctypesStrong,
|
||||||
|
Meta: map[string]any{"enctypes": names},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── kadmin reachability ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type kadminRule struct{}
|
||||||
|
|
||||||
|
func (r *kadminRule) Name() string { return "kerberos.kadmin_reachable" }
|
||||||
|
func (r *kadminRule) Description() string {
|
||||||
|
return "Flags kadmin endpoints that are published via SRV but not reachable."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *kadminRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
return roleReachability(data, "kadmin", "kadmin server", CodeKadminOK, CodeKadminDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── kpasswd reachability ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type kpasswdRule struct{}
|
||||||
|
|
||||||
|
func (r *kpasswdRule) Name() string { return "kerberos.kpasswd_reachable" }
|
||||||
|
func (r *kpasswdRule) Description() string {
|
||||||
|
return "Flags kpasswd endpoints that are published via SRV but not reachable."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *kpasswdRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
return roleReachability(data, "kpasswd", "kpasswd", CodeKpasswdOK, CodeKpasswdDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func roleReachability(data *KerberosData, role, label, okCode, downCode string) []sdk.CheckState {
|
||||||
|
total, reachable := 0, 0
|
||||||
|
for _, p := range data.Probes {
|
||||||
|
if p.Role != role {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
if p.OK {
|
||||||
|
reachable++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("No %s SRV endpoint published.", label),
|
||||||
|
Code: okCode,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if reachable == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusWarn,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("%s unreachable.", label),
|
||||||
|
Code: downCode,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if reachable < total {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusWarn,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("%s: %d/%d endpoints unreachable.", label, total-reachable, total),
|
||||||
|
Code: downCode,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("%s reachable.", label),
|
||||||
|
Code: okCode,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authenticated probe: TGT acquisition ─────────────────────────────────────
|
||||||
|
|
||||||
|
type authTGTRule struct{}
|
||||||
|
|
||||||
|
func (r *authTGTRule) Name() string { return "kerberos.auth_tgt" }
|
||||||
|
func (r *authTGTRule) Description() string {
|
||||||
|
return "Verifies the supplied principal/password can obtain a TGT (only runs when credentials are supplied)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *authTGTRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
if data.Auth == nil || !data.Auth.Attempted {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "Authenticated probe not attempted (no principal/password supplied).",
|
||||||
|
Code: CodeAuthSkipped,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if !data.Auth.TGTAcquired {
|
||||||
|
msg := "Authenticated probe: TGT acquisition failed."
|
||||||
|
if data.Auth.Error != "" {
|
||||||
|
msg = "Authenticated probe: " + data.Auth.Error
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: msg,
|
||||||
|
Code: CodeAuthTGTFail,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "TGT acquired for supplied principal.",
|
||||||
|
Code: CodeAuthTGTOK,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authenticated probe: TGS request ─────────────────────────────────────────
|
||||||
|
|
||||||
|
type authTGSRule struct{}
|
||||||
|
|
||||||
|
func (r *authTGSRule) Name() string { return "kerberos.auth_tgs" }
|
||||||
|
func (r *authTGSRule) Description() string {
|
||||||
|
return "Verifies a TGS-REQ succeeds for the supplied target service (only runs when credentials and targetService are supplied)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *authTGSRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
if data.Auth == nil || !data.Auth.Attempted {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "TGS probe not attempted (no credentials supplied).",
|
||||||
|
Code: CodeAuthTGSSkipped,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if data.Auth.TargetService == "" {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "TGS probe skipped (no targetService supplied).",
|
||||||
|
Code: CodeAuthTGSSkipped,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if !data.Auth.TGTAcquired {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: "TGS probe skipped: TGT not acquired.",
|
||||||
|
Code: CodeAuthTGSSkipped,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if !data.Auth.TGSAcquired {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusWarn,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("TGT OK but TGS-REQ for %s failed.", data.Auth.TargetService),
|
||||||
|
Code: CodeAuthTGSFail,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: data.Realm,
|
||||||
|
Message: fmt.Sprintf("TGS-REQ for %s succeeded.", data.Auth.TargetService),
|
||||||
|
Code: CodeAuthTGSOK,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func abs(d time.Duration) time.Duration {
|
||||||
|
if d < 0 {
|
||||||
|
return -d
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func round(d time.Duration) time.Duration {
|
||||||
|
return d.Round(time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func optBool(opts sdk.CheckerOptions, key string, def bool) bool {
|
||||||
|
v, ok := opts[key]
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
switch x := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return x
|
||||||
|
case string:
|
||||||
|
switch strings.ToLower(strings.TrimSpace(x)) {
|
||||||
|
case "true", "1", "yes", "y", "on":
|
||||||
|
return true
|
||||||
|
case "false", "0", "no", "n", "off":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(ss ...string) string {
|
||||||
|
for _, s := range ss {
|
||||||
|
if s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
@ -100,7 +100,6 @@ type AuthProbeResult struct {
|
||||||
type KerberosData struct {
|
type KerberosData struct {
|
||||||
Realm string `json:"realm"`
|
Realm string `json:"realm"`
|
||||||
CollectedAt time.Time `json:"collectedAt"`
|
CollectedAt time.Time `json:"collectedAt"`
|
||||||
LocalTime time.Time `json:"localTime"`
|
|
||||||
|
|
||||||
SRV []SRVBucket `json:"srv"`
|
SRV []SRVBucket `json:"srv"`
|
||||||
Resolution map[string]HostResolution `json:"resolution,omitempty"`
|
Resolution map[string]HostResolution `json:"resolution,omitempty"`
|
||||||
|
|
@ -110,11 +109,4 @@ type KerberosData struct {
|
||||||
|
|
||||||
Enctypes []EnctypeEntry `json:"enctypes,omitempty"`
|
Enctypes []EnctypeEntry `json:"enctypes,omitempty"`
|
||||||
WeakEnctypes []EnctypeEntry `json:"weakEnctypes,omitempty"`
|
WeakEnctypes []EnctypeEntry `json:"weakEnctypes,omitempty"`
|
||||||
|
|
||||||
// OverallOK is the rule's summary verdict; set by the rule, not the
|
|
||||||
// collector. Stored here for the HTML report which is rendered from
|
|
||||||
// the observation alone.
|
|
||||||
OverallOK bool `json:"overallOK"`
|
|
||||||
Warnings []string `json:"warnings,omitempty"`
|
|
||||||
Errors []string `json:"errors,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -3,7 +3,7 @@ module git.happydns.org/checker-kerberos
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.happydns.org/checker-sdk-go v1.2.0
|
git.happydns.org/checker-sdk-go v1.5.0
|
||||||
github.com/jcmturner/gofork v1.7.6
|
github.com/jcmturner/gofork v1.7.6
|
||||||
github.com/jcmturner/gokrb5/v8 v8.4.4
|
github.com/jcmturner/gokrb5/v8 v8.4.4
|
||||||
)
|
)
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -1,5 +1,5 @@
|
||||||
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
|
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||||
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|
|
||||||
6
main.go
6
main.go
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
kerberos "git.happydns.org/checker-kerberos/checker"
|
kerberos "git.happydns.org/checker-kerberos/checker"
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
"git.happydns.org/checker-sdk-go/checker/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is the standalone binary's version. It defaults to "custom-build"
|
// Version is the standalone binary's version. It defaults to "custom-build"
|
||||||
|
|
@ -21,8 +21,8 @@ func main() {
|
||||||
|
|
||||||
kerberos.Version = Version
|
kerberos.Version = Version
|
||||||
|
|
||||||
server := sdk.NewServer(kerberos.Provider())
|
srv := server.New(kerberos.Provider())
|
||||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||||
log.Fatalf("server error: %v", err)
|
log.Fatalf("server error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@ var Version = "custom-build"
|
||||||
|
|
||||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||||
kerberos.Version = Version
|
kerberos.Version = Version
|
||||||
return kerberos.Definition(), kerberos.Provider(), nil
|
prvd := kerberos.Provider()
|
||||||
|
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue