diff --git a/.drone-manifest.yml b/.drone-manifest.yml deleted file mode 100644 index ad844ab..0000000 --- a/.drone-manifest.yml +++ /dev/null @@ -1,22 +0,0 @@ -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 diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index a00d44b..0000000 --- a/.drone.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -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 diff --git a/Dockerfile b/Dockerfile index 82f5642..99555e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,12 +6,9 @@ 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-kerberos . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-kerberos . FROM scratch COPY --from=builder /checker-kerberos /checker-kerberos -USER 65534:65534 EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD ["/checker-kerberos", "-healthcheck"] ENTRYPOINT ["/checker-kerberos"] diff --git a/Makefile b/Makefile index 1d83007..fb74a58 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go) GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) -.PHONY: all plugin docker test clean +.PHONY: all plugin docker clean all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -21,8 +21,5 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) 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 diff --git a/README.md b/README.md index 8060baa..4c64a87 100644 --- a/README.md +++ b/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 `abstract.Kerberos` service), the checker performs a series of -**anonymous probes**, and an optional **authenticated round-trip** when -credentials are supplied, to give a complete picture of the realm's +**anonymous probes** — and an optional **authenticated round-trip** when +credentials are supplied — to give a complete picture of the realm's availability and security posture. ## What gets checked -- SRV layout, `_kerberos._tcp.`, `_kerberos._udp.`, +- SRV layout — `_kerberos._tcp.`, `_kerberos._udp.`, `_kerberos-master._tcp.`, `_kerberos-adm._tcp.`, `_kpasswd._tcp.`, `_kpasswd._udp.`. - Forward resolution of every SRV target (A + AAAA). @@ -35,22 +35,6 @@ direct remediation hint: | Wrong realm in reply | fix `default_realm` / realm config | | 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 ```sh @@ -64,15 +48,3 @@ make docker # container image ```sh ./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. diff --git a/checker/auth.go b/checker/auth.go index a7dbfdf..6180926 100644 --- a/checker/auth.go +++ b/checker/auth.go @@ -48,9 +48,9 @@ func runAuthProbe(ctx context.Context, realm, principal, password, targetService res.Latency = time.Since(start) if loginErr != nil { res.Error = loginErr.Error() - if code, name, ok := krbErrorInfo(loginErr); ok { + if code, ok := extractKRBErrorCode(loginErr); ok { res.ErrorCode = code - res.ErrorName = name + res.ErrorName = errorcodeName(code) } return res } @@ -63,12 +63,31 @@ func runAuthProbe(ctx context.Context, realm, principal, password, targetService } if _, _, err := cl.GetServiceTicket(spn); err != nil { res.Error = fmt.Sprintf("TGS failed for %s: %v", spn, err) - if code, name, ok := krbErrorInfo(err); ok { + if code, ok := extractKRBErrorCode(err); ok { res.ErrorCode = code - res.ErrorName = name + res.ErrorName = errorcodeName(code) } return res } res.TGSAcquired = true 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 +} diff --git a/checker/collect.go b/checker/collect.go index c5da7b0..e13804b 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -2,7 +2,6 @@ package checker import ( "context" - "crypto/rand" "encoding/binary" "encoding/hex" "errors" @@ -66,27 +65,16 @@ var preferredEnctypes = []int32{ 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 // to its numeric value when unknown. func etypeName(id int32) string { - if name, ok := etypeNameByID[id]; ok { - return name + for name, nid := range etypeID.ETypesByName { + if nid == id { + // Prefer canonical "aes..." / "rc4-hmac" shape + if !strings.Contains(name, "-CmsOID") && !strings.HasSuffix(name, "-EnvOID") { + return name + } + } } return fmt.Sprintf("etype-%d", id) } @@ -106,9 +94,11 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) } timeout := time.Duration(timeoutSec * float64(time.Second)) + now := time.Now().UTC() data := &KerberosData{ Realm: realm, - CollectedAt: time.Now().UTC(), + CollectedAt: now, + LocalTime: now, Resolution: map[string]HostResolution{}, } @@ -224,14 +214,9 @@ 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...) + for _, e := range eps { - if e.proto == "tcp" && asProbe.Target != "" { - continue - } start := time.Now() var reply []byte var perr error @@ -239,14 +224,15 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) reply, perr = sendASReqTCP(ctx, e.target, e.port, req, timeout) } else { reply, perr = sendASReqUDP(ctx, e.target, e.port, req, timeout) + // Track UDP reachability via this attempt. probe := KDCProbe{ Target: e.target, Port: e.port, Proto: "udp", Role: "kdc", RTT: time.Since(start), } - if perr == nil && len(reply) > 0 { + if perr == nil { probe.OK = true probe.KrbSeen = true - } else if perr != nil { + } else { probe.Error = perr.Error() } data.Probes = append(data.Probes, probe) @@ -254,11 +240,10 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) if perr != nil || len(reply) == 0 { continue } - if asProbe.Target == "" { - asProbe.Target = e.target - asProbe.Proto = e.proto - parseASResponse(reply, &asProbe) - } + asProbe.Target = e.target + asProbe.Proto = e.proto + parseASResponse(reply, &asProbe) + break } if asProbe.Target == "" && asProbe.Error == "" { asProbe.Error = "no KDC answered our AS-REQ probe" @@ -281,13 +266,7 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) // 6. Optional authenticated round-trip ------------------------------------ principal, _ := opts["principal"].(string) password, _ := opts["password"].(string) - if principal != "" && password == "" { - data.Auth = &AuthProbeResult{ - Attempted: true, - Principal: principal, - Error: "password is required when a principal is supplied", - } - } else if principal != "" { + if principal != "" && password != "" { data.Auth = runAuthProbe(ctx, realm, principal, password, stringOpt(opts, "targetService"), kdcHosts, timeout) } @@ -392,24 +371,13 @@ func buildProbeASReq(realm string) (messages.ASReq, error) { cfg.LibDefaults.NoAddresses = true cfg.LibDefaults.TicketLifetime = 10 * time.Minute cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes - cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, randomProbeCName()) + cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "probe-happydomain") 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. // 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). func parseASResponse(raw []byte, out *ASProbeResult) { // Try KRB-ERROR first. @@ -432,7 +400,7 @@ func parseASResponse(raw []byte, out *ASProbeResult) { 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 if err := asRep.Unmarshal(raw); err == nil { out.PrincipalFound = true @@ -507,6 +475,17 @@ func hasEnctype(list []EnctypeEntry, id int32) bool { 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 ---------------------------------------------------------------- func optFloat(opts sdk.CheckerOptions, key string, def float64) float64 { diff --git a/checker/collect_test.go b/checker/collect_test.go deleted file mode 100644 index 97136c7..0000000 --- a/checker/collect_test.go +++ /dev/null @@ -1,332 +0,0 @@ -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") - } -} diff --git a/checker/definition.go b/checker/definition.go index c1fc9dd..88e8455 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -11,7 +11,7 @@ import ( var Version = "built-in" // Definition returns the CheckerDefinition for the Kerberos checker. -func (p *kerberosProvider) Definition() *sdk.CheckerDefinition { +func Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "kerberos", Name: "Kerberos Realm Tester", @@ -79,7 +79,9 @@ func (p *kerberosProvider) Definition() *sdk.CheckerDefinition { }, }, }, - Rules: Rules(), + Rules: []sdk.CheckRule{ + Rule(), + }, Interval: &sdk.CheckIntervalSpec{ Min: 5 * time.Minute, Max: 7 * 24 * time.Hour, diff --git a/checker/errors.go b/checker/errors.go deleted file mode 100644 index ba6f67f..0000000 --- a/checker/errors.go +++ /dev/null @@ -1,48 +0,0 @@ -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 -} diff --git a/checker/interactive.go b/checker/interactive.go index 5b3169e..504d9e5 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -1,53 +1,92 @@ -//go:build standalone - package checker import ( "errors" "net/http" - "strconv" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) -// RenderForm exposes the run + admin options documented in Definition() -// so the standalone form stays in sync with the host-side documentation. +// RenderForm exposes the minimal set of fields a human needs to fire a +// standalone Kerberos probe via GET /check. func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField { - docs := p.Definition().Options - fields := make([]sdk.CheckerOptionField, 0, len(docs.RunOpts)+len(docs.AdminOpts)) - fields = append(fields, docs.RunOpts...) - fields = append(fields, docs.AdminOpts...) - return fields + return []sdk.CheckerOptionField{ + { + Id: "realm", + Type: "string", + Label: "Kerberos realm", + 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, using the -// documented field types to coerce values. +// ParseForm turns the submitted form into a CheckerOptions. Collect handles +// the SRV / DNS discovery itself, so there is nothing to auto-fill here +// beyond the raw inputs. func (p *kerberosProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { - opts := sdk.CheckerOptions{} - for _, f := range p.RenderForm() { - 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 - } + realm := strings.TrimSpace(r.FormValue("realm")) + if realm == "" { + return nil, errors.New("realm is required") } + + 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 } diff --git a/checker/provider.go b/checker/provider.go index 2cfc4ff..86d68bd 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -14,3 +14,8 @@ type kerberosProvider struct{} func (p *kerberosProvider) Key() sdk.ObservationKey { return ObservationKeyKerberos } + +// Definition implements sdk.CheckerDefinitionProvider. +func (p *kerberosProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} diff --git a/checker/report.go b/checker/report.go index aaca430..35db28d 100644 --- a/checker/report.go +++ b/checker/report.go @@ -7,8 +7,6 @@ import ( "sort" "strings" "time" - - sdk "git.happydns.org/checker-sdk-go/checker" ) // ── HTML report ─────────────────────────────────────────────────────────────── @@ -51,9 +49,9 @@ type srvView struct { type reportData struct { Realm string - HasStates bool OverallOK bool CollectedAt string + LocalTime string ServerTime string ClockSkew string ClockSkewBad bool @@ -80,30 +78,17 @@ func fmtDur(d time.Duration) string { return d.Round(time.Millisecond).String() } -func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) { +func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) { var r KerberosData - if err := json.Unmarshal(rctx.Data(), &r); err != nil { + if err := json.Unmarshal(raw, &r); err != nil { 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{ Realm: r.Realm, - HasStates: hasStates, - OverallOK: overallOK, + OverallOK: r.OverallOK, CollectedAt: r.CollectedAt.Format(time.RFC3339), + LocalTime: r.LocalTime.Format(time.RFC3339), ASProbe: r.AS, ASErrorName: r.AS.ErrorName, PreauthReq: r.AS.PreauthReq, @@ -115,14 +100,8 @@ func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) } if r.AS.ClockSkew != 0 { rd.ClockSkew = fmtDur(r.AS.ClockSkew) - } - // 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) { + if r.AS.ClockSkew > 5*time.Minute || r.AS.ClockSkew < -5*time.Minute { rd.ClockSkewBad = true - break } } @@ -177,11 +156,8 @@ func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) rd.HasMixedCrypto = true } - // Detect common failures and build the remediation banner. Hints are - // only surfaced when the host supplied rule states for this run. - if hasStates { - rd.Remediations = buildRemediations(&r, rd) - } + // Detect common failures and build the remediation banner. + rd.Remediations = buildRemediations(&r, rd) var buf strings.Builder if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil { @@ -281,7 +257,7 @@ then rekey principals with kadmin -q "cpw -randkey principal" 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 { out = append(out, remediation{ Title: "Enable pre-authentication", @@ -400,11 +376,9 @@ th { font-weight: 600; color: #6b7280; }

Kerberos Realm

- {{if .HasStates}} - {{if .OverallOK}}Realm OK - {{else if .Remediations}}Issues detected - {{else}}Needs attention{{end}} - {{end}} + {{if .OverallOK}}Realm OK + {{else if .Remediations}}Issues detected + {{else}}Needs attention{{end}}
Realm: {{.Realm}}{{if .ASProbe.Target}} · probed via {{.ASProbe.Target}} ({{.ASProbe.Proto}}){{end}}
@@ -498,6 +472,7 @@ th { font-weight: 600; color: #6b7280; }
Response
{{.ASErrorName}} {{if .PreauthReq}}preauth required{{end}}{{if .PrincipalFound}}AS-REP without preauth{{end}}
Realm echoed
{{.ASProbe.ServerRealm}}
Server time
{{.ServerTime}}
+
Local time
{{.LocalTime}}
Clock skew
{{if .ClockSkewBad}}{{.ClockSkew}}{{else}}{{.ClockSkew}}{{end}}
PKINIT offered
{{if .PKINITOffered}}yes{{else}}no{{end}}
diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..f60f299 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,224 @@ +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 +} diff --git a/checker/rules.go b/checker/rules.go deleted file mode 100644 index 5a8d24e..0000000 --- a/checker/rules.go +++ /dev/null @@ -1,594 +0,0 @@ -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 "" -} diff --git a/checker/types.go b/checker/types.go index d0f9151..2ac8cca 100644 --- a/checker/types.go +++ b/checker/types.go @@ -100,6 +100,7 @@ type AuthProbeResult struct { type KerberosData struct { Realm string `json:"realm"` CollectedAt time.Time `json:"collectedAt"` + LocalTime time.Time `json:"localTime"` SRV []SRVBucket `json:"srv"` Resolution map[string]HostResolution `json:"resolution,omitempty"` @@ -109,4 +110,11 @@ type KerberosData struct { Enctypes []EnctypeEntry `json:"enctypes,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"` } diff --git a/go.mod b/go.mod index 0257f14..c2a4640 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.happydns.org/checker-kerberos go 1.25.0 require ( - git.happydns.org/checker-sdk-go v1.5.0 + git.happydns.org/checker-sdk-go v1.2.0 github.com/jcmturner/gofork v1.7.6 github.com/jcmturner/gokrb5/v8 v8.4.4 ) diff --git a/go.sum b/go.sum index 7eadf2b..c652fb7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= -git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= +git.happydns.org/checker-sdk-go v1.2.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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/main.go b/main.go index 6037bfb..846c077 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "log" kerberos "git.happydns.org/checker-kerberos/checker" - "git.happydns.org/checker-sdk-go/checker/server" + sdk "git.happydns.org/checker-sdk-go/checker" ) // Version is the standalone binary's version. It defaults to "custom-build" @@ -21,8 +21,8 @@ func main() { kerberos.Version = Version - srv := server.New(kerberos.Provider()) - if err := srv.ListenAndServe(*listenAddr); err != nil { + server := sdk.NewServer(kerberos.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/plugin/plugin.go b/plugin/plugin.go index fd6776c..3301496 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -13,6 +13,5 @@ var Version = "custom-build" func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { kerberos.Version = Version - prvd := kerberos.Provider() - return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil + return kerberos.Definition(), kerberos.Provider(), nil }