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) <noreply@anthropic.com>
263 lines
7.7 KiB
Go
263 lines
7.7 KiB
Go
package checker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
// Rule returns the rule that cross-references TLS observations against
|
|
// the zone's CAA policy.
|
|
func Rule() sdk.CheckRule {
|
|
return &caaRule{}
|
|
}
|
|
|
|
type caaRule struct{}
|
|
|
|
func (r *caaRule) Name() string { return "caa_compliance" }
|
|
|
|
func (r *caaRule) Description() string {
|
|
return "Cross-references TLS certificates observed on the domain against its CAA policy, using CCADB to map each issuer to its published CAA identifier."
|
|
}
|
|
|
|
// issuerAgg collects, per distinct issuer, the worst observation we
|
|
// made and the set of endpoints it showed up on. A single struct type
|
|
// (rather than an anonymous struct inside Evaluate) keeps it reachable
|
|
// from the helpers below.
|
|
type issuerAgg struct {
|
|
sample *tlsProbeView
|
|
severity string
|
|
code string
|
|
msg string
|
|
fix string
|
|
endpoints map[string]bool
|
|
}
|
|
|
|
// allowList captures the policy in a form the rule can intersect against.
|
|
type allowList struct {
|
|
issueAll map[string]bool // CAA 0 issue "<domain>"
|
|
issueWildAll map[string]bool // CAA 0 issuewild "<domain>"
|
|
disallowIssue bool // CAA 0 issue ";"
|
|
// disallowWildcardIssue only constrains wildcard certs; the rule's
|
|
// inputs are individual probe observations without a "is-wildcard"
|
|
// flag, so it is recorded but not yet enforced. Reserved for a
|
|
// future iteration.
|
|
disallowWildcardIssue bool
|
|
}
|
|
|
|
// buildAllowList walks the CAA records and builds the effective
|
|
// allow/deny sets per RFC 8659 §4.2 "issue" and §4.3 "issuewild".
|
|
// Parameters on the issuer value (after ';') are stripped; v1 of this
|
|
// checker compares base issuer domain names only.
|
|
func buildAllowList(records []CAARecord) allowList {
|
|
al := allowList{
|
|
issueAll: map[string]bool{},
|
|
issueWildAll: map[string]bool{},
|
|
}
|
|
for _, rec := range records {
|
|
tag := strings.ToLower(strings.TrimSpace(rec.Tag))
|
|
value := strings.TrimSpace(rec.Value)
|
|
switch tag {
|
|
case "issue":
|
|
if value == "" || value == ";" {
|
|
al.disallowIssue = true
|
|
} else {
|
|
al.issueAll[issuerFromValue(value)] = true
|
|
}
|
|
case "issuewild":
|
|
if value == "" || value == ";" {
|
|
al.disallowWildcardIssue = true
|
|
} else {
|
|
al.issueWildAll[issuerFromValue(value)] = true
|
|
}
|
|
}
|
|
}
|
|
return al
|
|
}
|
|
|
|
// issuerFromValue extracts the base issuer domain from a CAA value,
|
|
// dropping any ';'-prefixed parameters. Comparison is lowercase.
|
|
func issuerFromValue(v string) string {
|
|
if i := strings.IndexByte(v, ';'); i >= 0 {
|
|
v = v[:i]
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(v))
|
|
}
|
|
|
|
// Evaluate runs the compliance rule.
|
|
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{{
|
|
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{{
|
|
Status: sdk.StatusUnknown,
|
|
Message: "No TLS probes have been observed for this target yet",
|
|
Code: CodeNoTLS,
|
|
}}
|
|
}
|
|
|
|
al := buildAllowList(data.Records)
|
|
hasPolicy := len(al.issueAll) > 0 || al.disallowIssue
|
|
|
|
// Per-issuer bookkeeping: "crit" overrides "info" for the same AKI
|
|
// so a CA that repeatedly shows up as unauthorized isn't demoted to
|
|
// info just because one probe happened to be unresolvable.
|
|
agg := map[string]*issuerAgg{} // keyed by AKI+DN
|
|
|
|
issue := func(p *tlsProbeView, severity, code, msg, fix string) {
|
|
k := p.IssuerAKI + "|" + p.IssuerDN
|
|
cur, ok := agg[k]
|
|
if !ok {
|
|
cur = &issuerAgg{sample: p, endpoints: map[string]bool{}}
|
|
agg[k] = cur
|
|
}
|
|
if severityRank(severity) >= severityRank(cur.severity) {
|
|
cur.severity = severity
|
|
cur.code = code
|
|
cur.msg = msg
|
|
cur.fix = fix
|
|
}
|
|
if addr := p.address(); addr != "" {
|
|
cur.endpoints[addr] = true
|
|
}
|
|
}
|
|
|
|
for _, p := range probes {
|
|
if al.disallowIssue {
|
|
issue(p, SeverityCrit, CodeIssuanceDisallowed,
|
|
fmt.Sprintf("CAA policy forbids issuance (issue \";\") but a certificate was observed on %s", p.address()),
|
|
"Remove the CA 0 issue \";\" record, or stop serving TLS certificates for this domain.")
|
|
continue
|
|
}
|
|
|
|
domains, ok := Lookup(p.IssuerAKI, p.IssuerDN)
|
|
if !ok {
|
|
issue(p, SeverityInfo, CodeIssuerUnknown,
|
|
fmt.Sprintf("Observed issuer not found in CCADB (AKI=%q, DN=%q)", p.IssuerAKI, p.IssuerDN),
|
|
"File a CCADB update for this CA, or verify the cert was issued by a real CA.")
|
|
continue
|
|
}
|
|
|
|
// If the zone has no issue/issuewild records at all, compliance
|
|
// can't be violated (RFC 8659 §2.2: "in the absence of CAA
|
|
// records any CA may issue"). Still surface an informational
|
|
// nudge recommending the user lock issuance down.
|
|
if !hasPolicy {
|
|
issue(p, SeverityInfo, CodeOK,
|
|
fmt.Sprintf("No CAA records published; certificate on %s issued by %s (CAA identifier %s).",
|
|
p.address(), issuerLabel(p), strings.Join(domains, ", ")),
|
|
fmt.Sprintf("Publish %q IN CAA 0 issue %q to lock future issuance to %s.",
|
|
data.Domain, domains[0], domains[0]))
|
|
continue
|
|
}
|
|
|
|
if !intersects(domains, al.issueAll) {
|
|
issue(p, SeverityCrit, CodeNotAuthorized,
|
|
fmt.Sprintf("Certificate on %s issued by %s (CAA identifier %s) is not authorized by the zone's CAA issue records",
|
|
p.address(), issuerLabel(p), strings.Join(domains, ", ")),
|
|
"Either update the CAA record to authorize this CA, or re-issue with an authorized CA.")
|
|
continue
|
|
}
|
|
|
|
issue(p, "", "", "", "")
|
|
}
|
|
|
|
// 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)
|
|
|
|
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:
|
|
out = append(out, sdk.CheckState{
|
|
Status: sdk.StatusCrit, Message: a.msg, Code: a.code,
|
|
Subject: subject, Meta: meta,
|
|
})
|
|
case SeverityWarn:
|
|
out = append(out, sdk.CheckState{
|
|
Status: sdk.StatusWarn, Message: a.msg, Code: a.code,
|
|
Subject: subject, Meta: meta,
|
|
})
|
|
case SeverityInfo:
|
|
out = append(out, sdk.CheckState{
|
|
Status: sdk.StatusInfo, Message: a.msg, Code: a.code,
|
|
Subject: subject, Meta: meta,
|
|
})
|
|
default:
|
|
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
|
|
// the rule can pick the worst per-issuer status.
|
|
func severityRank(s string) int {
|
|
switch s {
|
|
case SeverityCrit:
|
|
return 3
|
|
case SeverityWarn:
|
|
return 2
|
|
case SeverityInfo:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// intersects reports whether any element of lhs is present in the set.
|
|
func intersects(lhs []string, set map[string]bool) bool {
|
|
for _, s := range lhs {
|
|
if set[strings.ToLower(s)] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// issuerLabel picks the best human-readable name for an issuer from a
|
|
// probe: the Issuer CN if the TLS checker populated it, otherwise the
|
|
// full DN.
|
|
func issuerLabel(p *tlsProbeView) string {
|
|
if p.Issuer != "" {
|
|
return p.Issuer
|
|
}
|
|
if p.IssuerDN != "" {
|
|
return p.IssuerDN
|
|
}
|
|
return "unknown issuer"
|
|
}
|