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 "" 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, 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, "", "", "", "") } // Materialize issues in a deterministic order (keys sorted) so test // output is stable and the "first" critical/warn message is not // map-iteration-dependent. 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 for _, k := range keys { a := agg[k] switch a.severity { case SeverityCrit: critCount++ if firstCrit == "" { firstCrit = a.msg } case SeverityWarn: warnCount++ if firstWarn == "" { firstWarn = a.msg } case SeverityInfo: infoCount++ if firstInfo == "" { firstInfo = a.msg } 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, } } } // 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" }