diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..ad844ab --- /dev/null +++ b/.drone-manifest.yml @@ -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 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..a00d44b --- /dev/null +++ b/.drone.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 99555e6..82f5642 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,12 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download 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 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 fb74a58..1d83007 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 clean +.PHONY: all plugin docker test clean all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -21,5 +21,8 @@ $(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 4c64a87..8060baa 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,6 +35,22 @@ 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 @@ -48,3 +64,15 @@ 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 6180926..a7dbfdf 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, ok := extractKRBErrorCode(loginErr); ok { + if code, name, ok := krbErrorInfo(loginErr); ok { res.ErrorCode = code - res.ErrorName = errorcodeName(code) + res.ErrorName = name } return res } @@ -63,31 +63,12 @@ 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, ok := extractKRBErrorCode(err); ok { + if code, name, ok := krbErrorInfo(err); ok { res.ErrorCode = code - res.ErrorName = errorcodeName(code) + res.ErrorName = name } 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 e13804b..c5da7b0 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -2,6 +2,7 @@ package checker import ( "context" + "crypto/rand" "encoding/binary" "encoding/hex" "errors" @@ -65,16 +66,27 @@ 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 { - 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 - } - } + if name, ok := etypeNameByID[id]; ok { + return name } 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)) - now := time.Now().UTC() data := &KerberosData{ Realm: realm, - CollectedAt: now, - LocalTime: now, + CollectedAt: time.Now().UTC(), 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...) - for _, e := range eps { + if e.proto == "tcp" && asProbe.Target != "" { + continue + } start := time.Now() var reply []byte 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) } 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 { + if perr == nil && len(reply) > 0 { probe.OK = true probe.KrbSeen = true - } else { + } else if perr != nil { probe.Error = perr.Error() } 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 { continue } - asProbe.Target = e.target - asProbe.Proto = e.proto - parseASResponse(reply, &asProbe) - break + if asProbe.Target == "" { + asProbe.Target = e.target + asProbe.Proto = e.proto + parseASResponse(reply, &asProbe) + } } if asProbe.Target == "" && asProbe.Error == "" { 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 ------------------------------------ principal, _ := opts["principal"].(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, stringOpt(opts, "targetService"), kdcHosts, timeout) } @@ -371,13 +392,24 @@ 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, "probe-happydomain") + cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, randomProbeCName()) 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. @@ -400,7 +432,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 @@ -475,17 +507,6 @@ 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 new file mode 100644 index 0000000..97136c7 --- /dev/null +++ b/checker/collect_test.go @@ -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") + } +} diff --git a/checker/definition.go b/checker/definition.go index 88e8455..c1fc9dd 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 Definition() *sdk.CheckerDefinition { +func (p *kerberosProvider) Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "kerberos", Name: "Kerberos Realm Tester", @@ -79,9 +79,7 @@ func Definition() *sdk.CheckerDefinition { }, }, }, - Rules: []sdk.CheckRule{ - Rule(), - }, + Rules: Rules(), Interval: &sdk.CheckIntervalSpec{ Min: 5 * time.Minute, Max: 7 * 24 * time.Hour, diff --git a/checker/errors.go b/checker/errors.go new file mode 100644 index 0000000..ba6f67f --- /dev/null +++ b/checker/errors.go @@ -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 +} diff --git a/checker/interactive.go b/checker/interactive.go index 504d9e5..5b3169e 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -1,92 +1,53 @@ +//go:build standalone + package checker import ( "errors" "net/http" + "strconv" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) -// RenderForm exposes the minimal set of fields a human needs to fire a -// standalone Kerberos probe via GET /check. +// RenderForm exposes the run + admin options documented in Definition() +// so the standalone form stays in sync with the host-side documentation. func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField { - 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, - }, - } + 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 } -// 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. +// ParseForm turns the submitted form into a CheckerOptions, using the +// documented field types to coerce values. func (p *kerberosProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { - realm := strings.TrimSpace(r.FormValue("realm")) - if realm == "" { - return nil, errors.New("realm is required") + 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 + } } - - 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 86d68bd..2cfc4ff 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -14,8 +14,3 @@ 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 35db28d..aaca430 100644 --- a/checker/report.go +++ b/checker/report.go @@ -7,6 +7,8 @@ import ( "sort" "strings" "time" + + sdk "git.happydns.org/checker-sdk-go/checker" ) // ── HTML report ─────────────────────────────────────────────────────────────── @@ -49,9 +51,9 @@ type srvView struct { type reportData struct { Realm string + HasStates bool OverallOK bool CollectedAt string - LocalTime string ServerTime string ClockSkew string ClockSkewBad bool @@ -78,17 +80,30 @@ func fmtDur(d time.Duration) 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 - 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) } + // 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, - OverallOK: r.OverallOK, + HasStates: hasStates, + OverallOK: overallOK, CollectedAt: r.CollectedAt.Format(time.RFC3339), - LocalTime: r.LocalTime.Format(time.RFC3339), ASProbe: r.AS, ASErrorName: r.AS.ErrorName, PreauthReq: r.AS.PreauthReq, @@ -100,8 +115,14 @@ func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) { } if r.AS.ClockSkew != 0 { 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 + break } } @@ -156,8 +177,11 @@ func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) { rd.HasMixedCrypto = true } - // Detect common failures and build the remediation banner. - rd.Remediations = buildRemediations(&r, rd) + // 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) + } var buf strings.Builder if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil { @@ -257,7 +281,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", @@ -376,9 +400,11 @@ th { font-weight: 600; color: #6b7280; }

Kerberos Realm

- {{if .OverallOK}}Realm OK - {{else if .Remediations}}Issues detected - {{else}}Needs attention{{end}} + {{if .HasStates}} + {{if .OverallOK}}Realm OK + {{else if .Remediations}}Issues detected + {{else}}Needs attention{{end}} + {{end}}
Realm: {{.Realm}}{{if .ASProbe.Target}} · probed via {{.ASProbe.Target}} ({{.ASProbe.Proto}}){{end}}
@@ -472,7 +498,6 @@ 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 deleted file mode 100644 index f60f299..0000000 --- a/checker/rule.go +++ /dev/null @@ -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 -} diff --git a/checker/rules.go b/checker/rules.go new file mode 100644 index 0000000..5a8d24e --- /dev/null +++ b/checker/rules.go @@ -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 "" +} diff --git a/checker/types.go b/checker/types.go index 2ac8cca..d0f9151 100644 --- a/checker/types.go +++ b/checker/types.go @@ -100,7 +100,6 @@ 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"` @@ -110,11 +109,4 @@ 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 c2a4640..0257f14 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.2.0 + git.happydns.org/checker-sdk-go v1.5.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 c652fb7..7eadf2b 100644 --- a/go.sum +++ b/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.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +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= 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 846c077..6037bfb 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "log" 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" @@ -21,8 +21,8 @@ func main() { kerberos.Version = Version - server := sdk.NewServer(kerberos.Provider()) - if err := server.ListenAndServe(*listenAddr); err != nil { + srv := server.New(kerberos.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/plugin/plugin.go b/plugin/plugin.go index 3301496..fd6776c 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -13,5 +13,6 @@ var Version = "custom-build" func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { kerberos.Version = Version - return kerberos.Definition(), kerberos.Provider(), nil + prvd := kerberos.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil }