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).
This commit is contained in:
nemunaire 2026-04-23 11:28:14 +07:00
commit 14ab12a4b0
3 changed files with 67 additions and 86 deletions

View file

@ -87,25 +87,25 @@ func issuerFromValue(v string) string {
} }
// Evaluate runs the compliance rule. // 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 var data CAAData
if err := obs.Get(ctx, ObservationKeyCAA, &data); err != nil { if err := obs.Get(ctx, ObservationKeyCAA, &data); err != nil {
return sdk.CheckState{ return []sdk.CheckState{{
Status: sdk.StatusError, Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to read caa_policy: %v", err), Message: fmt.Sprintf("Failed to read caa_policy: %v", err),
Code: CodeObservationError, Code: CodeObservationError,
} }}
} }
related, _ := obs.GetRelated(ctx, TLSRelatedKey) related, _ := obs.GetRelated(ctx, TLSRelatedKey)
probes := parseAllTLSRelated(related) probes := parseAllTLSRelated(related)
if len(probes) == 0 { if len(probes) == 0 {
return sdk.CheckState{ return []sdk.CheckState{{
Status: sdk.StatusUnknown, Status: sdk.StatusUnknown,
Message: "No TLS probes have been observed for this target yet", Message: "No TLS probes have been observed for this target yet",
Code: CodeNoTLS, Code: CodeNoTLS,
} }}
} }
al := buildAllowList(data.Records) al := buildAllowList(data.Records)
@ -168,94 +168,53 @@ func (r *caaRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
issue(p, "", "", "") issue(p, "", "", "")
} }
// Materialize issues in a deterministic order (keys sorted) so test // Emit one CheckState per distinct issuer, keyed deterministically so
// output is stable and the "first" critical/warn message is not // state ordering does not depend on map iteration.
// map-iteration-dependent.
keys := make([]string, 0, len(agg)) keys := make([]string, 0, len(agg))
for k := range agg { for k := range agg {
keys = append(keys, k) keys = append(keys, k)
} }
sort.Strings(keys) sort.Strings(keys)
var critCount, warnCount, infoCount, okIssuerCount int out := make([]sdk.CheckState, 0, len(keys))
var firstCrit, firstWarn, firstInfo string
for _, k := range keys { for _, k := range keys {
a := agg[k] 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 { switch a.severity {
case SeverityCrit: case SeverityCrit:
critCount++ out = append(out, sdk.CheckState{
if firstCrit == "" { Status: sdk.StatusCrit, Message: a.msg, Code: a.code,
firstCrit = a.msg Subject: subject, Meta: meta,
} })
case SeverityWarn: case SeverityWarn:
warnCount++ out = append(out, sdk.CheckState{
if firstWarn == "" { Status: sdk.StatusWarn, Message: a.msg, Code: a.code,
firstWarn = a.msg Subject: subject, Meta: meta,
} })
case SeverityInfo: case SeverityInfo:
infoCount++ out = append(out, sdk.CheckState{
if firstInfo == "" { Status: sdk.StatusInfo, Message: a.msg, Code: a.code,
firstInfo = a.msg Subject: subject, Meta: meta,
} })
default: default:
okIssuerCount++ msg := "Certificate authorized by CAA policy"
} if !hasPolicy {
} msg = "Certificate observed; no CAA records published"
}
meta := map[string]any{ out = append(out, sdk.CheckState{
"probes": len(probes), Status: sdk.StatusOK, Message: msg, Code: CodeOK,
"distinct_issuers": len(agg), Subject: subject, Meta: meta,
"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,
} }
} }
return out
} }
// severityRank turns a severity string into a comparable integer so // severityRank turns a severity string into a comparable integer so

View file

@ -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 { if state.Status != sdk.StatusOK {
t.Fatalf("expected StatusOK, got %s: %s", state.Status, state.Message) 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 { if state.Status != sdk.StatusCrit {
t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message) 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 { if state.Status != sdk.StatusCrit {
t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message) 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 { if state.Status != sdk.StatusInfo {
t.Fatalf("expected StatusInfo, got %s: %s", state.Status, state.Message) t.Fatalf("expected StatusInfo, got %s: %s", state.Status, state.Message)
} }
@ -172,7 +188,11 @@ func TestRule_NoTLS(t *testing.T) {
}, },
related: nil, 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 { if state.Status != sdk.StatusUnknown {
t.Fatalf("expected StatusUnknown, got %s: %s", state.Status, state.Message) 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 { if state.Status != sdk.StatusInfo {
t.Fatalf("expected StatusInfo (no policy), got %s: %s", state.Status, state.Message) t.Fatalf("expected StatusInfo (no policy), got %s: %s", state.Status, state.Message)
} }

2
go.sum
View file

@ -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=