From 2dda522e2d67746c593f92e8c9e81e746aa1ef84 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Apr 2026 11:28:14 +0700 Subject: [PATCH] checker: migrate Evaluate to per-subject []CheckState Each distinct TLS issuer now produces its own CheckState with Subject set to the issuer label, instead of being folded into a single concatenated status. Aligns with the SDK v2 contract (see checker-sdk-go/migrate-v2.md). Co-Authored-By: Claude Opus 4.7 (1M context) --- checker/rule.go | 115 ++++++++++++++----------------------------- checker/rule_test.go | 36 +++++++++++--- go.sum | 2 - 3 files changed, 67 insertions(+), 86 deletions(-) diff --git a/checker/rule.go b/checker/rule.go index f6ba83e..512795f 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -88,25 +88,25 @@ func issuerFromValue(v string) string { } // Evaluate runs the compliance rule. -func (r *caaRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState { +func (r *caaRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { var data CAAData if err := obs.Get(ctx, ObservationKeyCAA, &data); err != nil { - return sdk.CheckState{ + return []sdk.CheckState{{ Status: sdk.StatusError, Message: fmt.Sprintf("Failed to read caa_policy: %v", err), Code: CodeObservationError, - } + }} } related, _ := obs.GetRelated(ctx, TLSRelatedKey) probes := parseAllTLSRelated(related) if len(probes) == 0 { - return sdk.CheckState{ + return []sdk.CheckState{{ Status: sdk.StatusUnknown, Message: "No TLS probes have been observed for this target yet", Code: CodeNoTLS, - } + }} } al := buildAllowList(data.Records) @@ -175,94 +175,53 @@ func (r *caaRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts issue(p, "", "", "", "") } - // Materialize issues in a deterministic order (keys sorted) so test - // output is stable and the "first" critical/warn message is not - // map-iteration-dependent. + // Emit one CheckState per distinct issuer, keyed deterministically so + // state ordering does not depend on map iteration. keys := make([]string, 0, len(agg)) for k := range agg { keys = append(keys, k) } sort.Strings(keys) - var critCount, warnCount, infoCount, okIssuerCount int - var firstCrit, firstWarn, firstInfo string + out := make([]sdk.CheckState, 0, len(keys)) for _, k := range keys { a := agg[k] + subject := issuerLabel(a.sample) + endpoints := make([]string, 0, len(a.endpoints)) + for ep := range a.endpoints { + endpoints = append(endpoints, ep) + } + sort.Strings(endpoints) + meta := map[string]any{"endpoints": endpoints} + switch a.severity { case SeverityCrit: - critCount++ - if firstCrit == "" { - firstCrit = a.msg - } + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, Message: a.msg, Code: a.code, + Subject: subject, Meta: meta, + }) case SeverityWarn: - warnCount++ - if firstWarn == "" { - firstWarn = a.msg - } + out = append(out, sdk.CheckState{ + Status: sdk.StatusWarn, Message: a.msg, Code: a.code, + Subject: subject, Meta: meta, + }) case SeverityInfo: - infoCount++ - if firstInfo == "" { - firstInfo = a.msg - } + out = append(out, sdk.CheckState{ + Status: sdk.StatusInfo, Message: a.msg, Code: a.code, + Subject: subject, Meta: meta, + }) default: - okIssuerCount++ - } - } - - meta := map[string]any{ - "probes": len(probes), - "distinct_issuers": len(agg), - "authorized": okIssuerCount, - "unauthorized": critCount, - "info": infoCount, - "caa_records": len(data.Records), - } - - switch { - case critCount > 0: - code := CodeNotAuthorized - if al.disallowIssue { - code = CodeIssuanceDisallowed - } - return sdk.CheckState{ - Status: sdk.StatusCrit, - Message: fmt.Sprintf("%d issuer(s) violate the zone's CAA policy: %s", critCount, firstCrit), - Code: code, - Meta: meta, - } - case warnCount > 0: - return sdk.CheckState{ - Status: sdk.StatusWarn, - Message: firstWarn, - Code: CodeNotAuthorized, - Meta: meta, - } - case infoCount > 0 && okIssuerCount == 0: - // Only info-level findings. When a policy exists this is a data - // gap (CCADB didn't know the issuer); without a policy it's the - // "publish CAA" nudge, which is fine; OK code. - code := CodeIssuerUnknown - if !hasPolicy { - code = CodeOK - } - return sdk.CheckState{ - Status: sdk.StatusInfo, - Message: firstInfo, - Code: code, - Meta: meta, - } - default: - msg := fmt.Sprintf("%d TLS issuer(s) authorized by CAA policy", okIssuerCount) - if !hasPolicy { - msg = fmt.Sprintf("%d TLS issuer(s) observed; no CAA records published", okIssuerCount) - } - return sdk.CheckState{ - Status: sdk.StatusOK, - Message: msg, - Code: CodeOK, - Meta: meta, + msg := "Certificate authorized by CAA policy" + if !hasPolicy { + msg = "Certificate observed; no CAA records published" + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusOK, Message: msg, Code: CodeOK, + Subject: subject, Meta: meta, + }) } } + return out } // severityRank turns a severity string into a comparable integer so diff --git a/checker/rule_test.go b/checker/rule_test.go index 235cc03..6b76c95 100644 --- a/checker/rule_test.go +++ b/checker/rule_test.go @@ -69,7 +69,11 @@ func TestRule_OK(t *testing.T) { }), }, } - state := Rule().Evaluate(context.Background(), obs, nil) + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] if state.Status != sdk.StatusOK { t.Fatalf("expected StatusOK, got %s: %s", state.Status, state.Message) } @@ -96,7 +100,11 @@ func TestRule_NotAuthorized(t *testing.T) { }), }, } - state := Rule().Evaluate(context.Background(), obs, nil) + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] if state.Status != sdk.StatusCrit { t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message) } @@ -126,7 +134,11 @@ func TestRule_IssuanceDisallowed(t *testing.T) { }), }, } - state := Rule().Evaluate(context.Background(), obs, nil) + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] if state.Status != sdk.StatusCrit { t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message) } @@ -153,7 +165,11 @@ func TestRule_IssuerUnknown(t *testing.T) { }), }, } - state := Rule().Evaluate(context.Background(), obs, nil) + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] if state.Status != sdk.StatusInfo { t.Fatalf("expected StatusInfo, got %s: %s", state.Status, state.Message) } @@ -172,7 +188,11 @@ func TestRule_NoTLS(t *testing.T) { }, related: nil, } - state := Rule().Evaluate(context.Background(), obs, nil) + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] if state.Status != sdk.StatusUnknown { t.Fatalf("expected StatusUnknown, got %s: %s", state.Status, state.Message) } @@ -197,7 +217,11 @@ func TestRule_NoCAAPublished(t *testing.T) { }), }, } - state := Rule().Evaluate(context.Background(), obs, nil) + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] if state.Status != sdk.StatusInfo { t.Fatalf("expected StatusInfo (no policy), got %s: %s", state.Status, state.Message) } diff --git a/go.sum b/go.sum index f7aa184..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +0,0 @@ -git.happydns.org/checker-sdk-go v1.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y= -git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=