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 and // the endpoints it appeared on. type issuerAgg struct { sample *tlsProbeView severity string code string msg string endpoints map[string]bool } type allowList struct { issueAll map[string]bool // CAA 0 issue "" issueWildAll map[string]bool // CAA 0 issuewild "" disallowIssue bool // CAA 0 issue ";" disallowWildcardIssue bool // CAA 0 issuewild ";" // Per RFC 8659 §4.3, presence of any "issuewild" record makes it // fully override "issue" for wildcard certs. hasIssueWild bool // Unknown tags with the Issuer Critical bit set: RFC 8659 §4.1 // requires a conformant CA to refuse issuance, so we surface them. unknownCritical []string } // caaFlagCritical is the Issuer Critical bit (RFC 8659 §4.1). const caaFlagCritical = 0x80 // buildAllowList builds the effective allow/deny sets per RFC 8659 // §4.2 "issue" and §4.3 "issuewild". Parameters after ';' on the // issuer value are stripped. 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": al.hasIssueWild = true if value == "" || value == ";" { al.disallowWildcardIssue = true } else { al.issueWildAll[issuerFromValue(value)] = true } case "iodef", "contactemail", "contactphone", "issuemail", "issuevmc": // Recognized property tags (RFC 8659, RFC 9495, CA/B BR); // listed only to suppress the unknown-critical warning. default: if rec.Flag&caaFlagCritical != 0 { name := tag if name == "" { name = "(empty)" } al.unknownCritical = append(al.unknownCritical, name) } } } return al } func issuerFromValue(v string) string { if i := strings.IndexByte(v, ';'); i >= 0 { v = v[:i] } return strings.ToLower(strings.TrimSpace(v)) } 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, }} } al := buildAllowList(data.Records) hasPolicy := len(al.issueAll) > 0 || al.disallowIssue || len(al.issueWildAll) > 0 || al.disallowWildcardIssue // Policy-level findings (e.g. an unknown tag with the Issuer Critical // bit set) are intrinsic to the published CAA records and must be // reported regardless of whether checker-tls has produced probes yet. var policyStates []sdk.CheckState if len(al.unknownCritical) > 0 { tags := append([]string(nil), al.unknownCritical...) sort.Strings(tags) policyStates = append(policyStates, sdk.CheckState{ Status: sdk.StatusWarn, Code: CodeUnknownCritical, Subject: "policy", Message: fmt.Sprintf("CAA policy contains unknown tag(s) marked critical: %s; conformant CAs must refuse issuance", strings.Join(tags, ", ")), }) } related, _ := obs.GetRelated(ctx, TLSRelatedKey) probes := parseAllTLSRelated(related) if len(probes) == 0 { return append(policyStates, sdk.CheckState{ Status: sdk.StatusUnknown, Message: "No TLS probes have been observed for this target yet", Code: CodeNoTLS, }) } // 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 { // Per RFC 8659 §4.3, if any "issuewild" record is present, it // fully overrides "issue" for wildcard certificates. Otherwise // "issue" applies to both wildcard and non-wildcard. wildcard := p.isWildcard() useWild := wildcard && al.hasIssueWild denied := al.disallowIssue allow := al.issueAll tag := "issue" if useWild { denied = al.disallowWildcardIssue allow = al.issueWildAll tag = "issuewild" } if denied { issue(p, SeverityCrit, CodeIssuanceDisallowed, fmt.Sprintf("CAA policy forbids issuance (%s \";\") but a certificate was observed on %s", tag, 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, allow) { kind := "Certificate" if wildcard { kind = "Wildcard certificate" } issue(p, SeverityCrit, CodeNotAuthorized, fmt.Sprintf("%s on %s issued by %s (CAA identifier %s) is not authorized by the zone's CAA %s records", kind, p.address(), issuerLabel(p), strings.Join(domains, ", "), tag)) 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)+len(policyStates)) out = append(out, policyStates...) 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 } func severityRank(s string) int { switch s { case SeverityCrit: return 3 case SeverityWarn: return 2 case SeverityInfo: return 1 default: return 0 } } 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 most readable issuer name available on a probe. func issuerLabel(p *tlsProbeView) string { if p.Issuer != "" { return p.Issuer } if p.IssuerDN != "" { return p.IssuerDN } return "unknown issuer" }