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 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 "" issueWildAll map[string]bool // CAA 0 issuewild "" 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 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 } 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())) 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)) 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, ", "))) 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, ", "))) 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 } } func severityToStatus(s string) sdk.Status { switch s { case SeverityCrit: return sdk.StatusCrit case SeverityWarn: return sdk.StatusWarn case SeverityInfo: return sdk.StatusInfo default: return sdk.StatusOK } } // 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" }