Initial commit

This commit is contained in:
nemunaire 2026-04-23 01:52:25 +07:00
commit b9175054bc
19 changed files with 1433 additions and 0 deletions

310
checker/rule.go Normal file
View file

@ -0,0 +1,310 @@
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 "<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 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, "", "", "")
}
// 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
}
}
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"
}