Initial commit

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

222
checker/ccadb.go Normal file
View file

@ -0,0 +1,222 @@
package checker
import (
"bytes"
_ "embed"
"encoding/csv"
"fmt"
"io"
"sort"
"strings"
"sync"
)
//go:generate wget -O AllCAAIdentifiersReport.csv https://ccadb.my.salesforce-sites.com/ccadb/AllCAAIdentifiersReportCSVV2
//go:embed AllCAAIdentifiersReport.csv
var ccadbCSV []byte
// ccadbIndex is the in-memory representation of AllCAAIdentifiersReport.csv.
// Two indexes are maintained because CCADB rows sometimes have an empty
// Subject Key Identifier column (very rare; a handful of legacy entries)
// and we want to still resolve those via Subject DN.
type ccadbIndex struct {
bySKI map[string][]string
byDN map[string][]string
}
var (
ccadbOnce sync.Once
ccadb *ccadbIndex
ccadbErr error
)
// loadCCADB parses the embedded CSV into the two lookup indexes on first
// call. Subsequent calls are no-ops. The CSV is shipped with the binary
// so parse failures indicate a bug or a corrupted build, not a runtime
// condition; tests assert the parse succeeds for the checked-in file.
func loadCCADB() (*ccadbIndex, error) {
ccadbOnce.Do(func() {
ccadb, ccadbErr = parseCCADB(bytes.NewReader(ccadbCSV))
})
return ccadb, ccadbErr
}
// parseCCADB is exposed for testing with alternate CSV inputs.
func parseCCADB(r io.Reader) (*ccadbIndex, error) {
reader := csv.NewReader(r)
reader.FieldsPerRecord = -1 // some rows carry a trailing empty field
header, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("read header: %w", err)
}
idxSubject := -1
idxSKI := -1
idxDomains := -1
for i, h := range header {
switch strings.TrimSpace(h) {
case "Subject":
idxSubject = i
case "Subject Key Identifier (Hex)":
idxSKI = i
case "Recognized CAA Domains":
idxDomains = i
}
}
if idxSubject < 0 || idxSKI < 0 || idxDomains < 0 {
return nil, fmt.Errorf("unexpected CCADB header: %v", header)
}
idx := &ccadbIndex{
bySKI: map[string][]string{},
byDN: map[string][]string{},
}
for {
row, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read row: %w", err)
}
domains := splitCAADomains(row[idxDomains])
if len(domains) == 0 {
continue
}
if ski := strings.ToUpper(strings.TrimSpace(row[idxSKI])); ski != "" {
idx.bySKI[ski] = mergeDomains(idx.bySKI[ski], domains)
}
if dn := normalizeDN(row[idxSubject]); dn != "" {
idx.byDN[dn] = mergeDomains(idx.byDN[dn], domains)
}
}
return idx, nil
}
// Lookup resolves an observed certificate issuer to the CAA identifier
// domains the issuing CA publishes in its CPS. aki is the uppercase hex
// Authority Key Identifier of the leaf (i.e. the issuer's SKI); dn is
// the RFC 2253 subject DN of the issuer (leaf.Issuer.String() in Go).
//
// AKI takes precedence because CCADB keys by it. DN is a fallback for
// the rare rows where the SKI column is empty.
//
// Returns ok=false when neither key resolves. The returned slice is a
// fresh copy; callers may retain or mutate it.
func Lookup(aki, dn string) ([]string, bool) {
idx, err := loadCCADB()
if err != nil || idx == nil {
return nil, false
}
if aki != "" {
if d, ok := idx.bySKI[strings.ToUpper(strings.TrimSpace(aki))]; ok && len(d) > 0 {
return append([]string(nil), d...), true
}
}
if dn != "" {
if d, ok := idx.byDN[normalizeDN(dn)]; ok && len(d) > 0 {
return append([]string(nil), d...), true
}
}
return nil, false
}
// splitCAADomains splits CCADB's "Recognized CAA Domains" cell, which
// can hold a comma-separated list (e.g. DigiCert rows list ~20
// domains). Whitespace is trimmed, empties are dropped, and the result
// is lowercased because CAA identifiers are case-insensitive.
func splitCAADomains(raw string) []string {
var out []string
for d := range strings.SplitSeq(raw, ",") {
d = strings.TrimSpace(strings.ToLower(d))
if d != "" {
out = append(out, d)
}
}
return out
}
// mergeDomains appends new entries to an existing slice, de-duplicating.
// CCADB occasionally lists the same CA twice (cross-signs, re-issues);
// we don't want that to bloat the lookup result.
func mergeDomains(existing, add []string) []string {
if len(existing) == 0 {
return append([]string(nil), add...)
}
seen := map[string]bool{}
for _, d := range existing {
seen[d] = true
}
for _, d := range add {
if !seen[d] {
existing = append(existing, d)
seen[d] = true
}
}
return existing
}
// normalizeDN produces a canonical key from a subject DN so that DNs
// produced by Go's pkix.Name.String (comma-joined) compare equal to
// DNs produced by CCADB (semicolon-joined) when their RDN sets match.
//
// Rules:
// - split on ',' or ';';
// - trim each RDN;
// - uppercase the RDN type (left of '=') because RFC 4514 types are
// case-insensitive; values are left as-is;
// - sort the RDNs alphabetically so reordering does not break
// comparison.
//
// This is intentionally permissive; escaping differences between
// implementations are ignored. Good enough for CCADB fallbacks, and
// the common path is the AKI lookup anyway.
func normalizeDN(dn string) string {
if dn == "" {
return ""
}
fields := splitRDNs(dn)
for i, f := range fields {
f = strings.TrimSpace(f)
if eq := strings.IndexByte(f, '='); eq > 0 {
f = strings.ToUpper(f[:eq]) + "=" + strings.TrimSpace(f[eq+1:])
}
fields[i] = f
}
sort.Strings(fields)
return strings.Join(fields, ",")
}
// splitRDNs splits a DN string on either ',' or ';', respecting
// backslash escapes. Most RDN values in CCADB do not contain escaped
// separators, but a handful (paths in OU values) do.
func splitRDNs(dn string) []string {
var out []string
var cur strings.Builder
escape := false
for i := 0; i < len(dn); i++ {
c := dn[i]
if escape {
cur.WriteByte(c)
escape = false
continue
}
switch c {
case '\\':
cur.WriteByte(c)
escape = true
case ',', ';':
out = append(out, cur.String())
cur.Reset()
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}

104
checker/ccadb_test.go Normal file
View file

@ -0,0 +1,104 @@
package checker
import (
"slices"
"strings"
"testing"
)
// TestCCADBEmbedded asserts the shipped CSV parses cleanly. If this
// fails the build produced a broken binary, so fail loudly.
func TestCCADBEmbedded(t *testing.T) {
idx, err := loadCCADB()
if err != nil {
t.Fatalf("load embedded CCADB: %v", err)
}
if len(idx.bySKI) < 100 {
t.Errorf("expected >=100 SKI entries, got %d", len(idx.bySKI))
}
if len(idx.byDN) < 100 {
t.Errorf("expected >=100 DN entries, got %d", len(idx.byDN))
}
}
// TestLookup_LetsEncryptR10 exercises the AKI path against a well-known,
// currently-active intermediate.
func TestLookup_LetsEncryptR10(t *testing.T) {
domains, ok := Lookup("BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8", "")
if !ok {
t.Fatal("expected Let's Encrypt R10 AKI to resolve")
}
if !slices.Contains(domains, "letsencrypt.org") {
t.Errorf("expected letsencrypt.org in domains, got %v", domains)
}
}
// TestLookup_CaseInsensitiveAKI ensures callers don't need to pre-
// uppercase the AKI.
func TestLookup_CaseInsensitiveAKI(t *testing.T) {
upper, ok := Lookup("BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8", "")
if !ok {
t.Skip("fixture row missing from embedded CCADB")
}
lower, ok := Lookup("bbbcc347a5e4bca9c6c3a4720c108da235e1c8e8", "")
if !ok {
t.Fatal("lowercase AKI should resolve too")
}
if strings.Join(upper, ",") != strings.Join(lower, ",") {
t.Errorf("upper %v != lower %v", upper, lower)
}
}
// TestLookup_DNFallback asserts the DN path works when AKI is empty.
// We use Go's pkix.Name.String-style comma DN and expect it to match
// CCADB's semicolon DN for the same subject.
func TestLookup_DNFallback(t *testing.T) {
// The ISRG Root X2 row uses SKI 7C4296AEDE4B483BFA92F89E8CCF6D8BA9723795
// and Subject "CN=ISRG Root X2; O=Internet Security Research Group; C=US".
// Go would render the same DN with commas, so normalizeDN should
// collapse both to the same key.
domains, ok := Lookup("", "CN=ISRG Root X2,O=Internet Security Research Group,C=US")
if !ok {
t.Fatal("expected ISRG Root X2 DN to resolve via byDN index")
}
if !slices.Contains(domains, "letsencrypt.org") {
t.Errorf("expected letsencrypt.org, got %v", domains)
}
}
// TestLookup_Unknown ensures false is returned cleanly.
func TestLookup_Unknown(t *testing.T) {
if _, ok := Lookup("0000000000000000000000000000000000000000", ""); ok {
t.Error("unknown AKI must not resolve")
}
if _, ok := Lookup("", "CN=This CA Does Not Exist"); ok {
t.Error("unknown DN must not resolve")
}
if _, ok := Lookup("", ""); ok {
t.Error("empty inputs must not resolve")
}
}
// TestNormalizeDN_SortsAndUppercases exercises the canonicalization
// used by the DN fallback. This is the part most likely to miscompare
// across CSV formatting variations.
func TestNormalizeDN_SortsAndUppercases(t *testing.T) {
a := normalizeDN("CN=Foo,O=Bar,C=US")
b := normalizeDN("c=US; cn=Foo; o=Bar")
if a != b {
t.Errorf("expected canonical equality:\n a=%q\n b=%q", a, b)
}
}
// TestSplitCAADomains handles the comma-separated cell format that
// DigiCert and similar CAs use.
func TestSplitCAADomains(t *testing.T) {
got := splitCAADomains("www.digicert.com, digicert.com, amazon.com")
want := []string{"www.digicert.com", "digicert.com", "amazon.com"}
if !slices.Equal(got, want) {
t.Errorf("splitCAADomains got %v want %v", got, want)
}
if splitCAADomains("") != nil {
t.Error("empty input should yield nil")
}
}

95
checker/collect.go Normal file
View file

@ -0,0 +1,95 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// serviceType is the happyDomain service type string this checker binds to.
const serviceType = "svcs.CAAPolicy"
// serviceMessage is a minimal local copy of happydns.ServiceMessage
// matching the JSON wire shape, so this module does not depend on the
// happyDomain core repository. Same pattern as
// checker-ns-restrictions/checker/types.go.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}
// caaPolicyPayload mirrors the JSON shape of svcs.CAAPolicy: a single
// "caa" field holding a list of CAA records. Each record is decoded
// into a trimmed local type with just the fields the rule reads.
type caaPolicyPayload struct {
Records []caaRecordPayload `json:"caa"`
}
// caaRecordPayload matches miekg/dns.CAA's JSON tags
// (Hdr/Flag/Tag/Value) closely enough to round-trip through the
// service body. We only keep Flag/Tag/Value; the Hdr is ignored.
type caaRecordPayload struct {
Flag uint8 `json:"Flag"`
Tag string `json:"Tag"`
Value string `json:"Value"`
}
// Collect reads the auto-filled service body, validates the type, and
// returns the CAA records flattened into CAAData. No network call.
func (p *caaProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
svc, err := serviceFromOptions(opts)
if err != nil {
return nil, err
}
if svc.Type != serviceType {
return nil, fmt.Errorf("service is %q, expected %q", svc.Type, serviceType)
}
var pol caaPolicyPayload
if err := json.Unmarshal(svc.Service, &pol); err != nil {
return nil, fmt.Errorf("decode CAA policy: %w", err)
}
records := make([]CAARecord, 0, len(pol.Records))
for _, r := range pol.Records {
records = append(records, CAARecord{Flag: r.Flag, Tag: r.Tag, Value: r.Value})
}
domain := svc.Domain
if domain == "" {
if v, _ := sdk.GetOption[string](opts, "domain"); v != "" {
domain = v
}
}
return &CAAData{
Domain: domain,
Records: records,
RunAt: time.Now().UTC().Format(time.RFC3339),
}, nil
}
// serviceFromOptions pulls the "service" option out of the options map,
// accepting both the in-process plugin path (native Go value) and the
// HTTP path (JSON-decoded map[string]any). Normalizing via a JSON
// round-trip keeps both paths working without importing the upstream
// type.
func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) {
v, ok := opts["service"]
if !ok {
return nil, fmt.Errorf("service option missing")
}
raw, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("marshal service option: %w", err)
}
var svc serviceMessage
if err := json.Unmarshal(raw, &svc); err != nil {
return nil, fmt.Errorf("decode service option: %w", err)
}
return &svc, nil
}

55
checker/definition.go Normal file
View file

@ -0,0 +1,55 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version defaults to "built-in"; standalone and plugin builds override
// it via -ldflags "-X .../checker.Version=...".
var Version = "built-in"
// Definition is the package-level helper expected by the plugin
// entrypoint and by sdk.NewServer via CheckerDefinitionProvider.
func Definition() *sdk.CheckerDefinition {
return (&caaProvider{}).Definition()
}
// Definition implements sdk.CheckerDefinitionProvider on the provider.
func (p *caaProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "caa",
Name: "CAA Compliance",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{serviceType},
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyCAA},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain",
Type: "string",
Label: "Domain",
AutoFill: sdk.AutoFillDomainName,
Required: true,
},
},
ServiceOpts: []sdk.CheckerOptionDocumentation{
{
Id: "service",
Label: "Service",
AutoFill: sdk.AutoFillService,
},
},
},
Rules: []sdk.CheckRule{Rule()},
Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 12 * time.Hour,
},
}
}

15
checker/provider.go Normal file
View file

@ -0,0 +1,15 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// Provider returns a new CAA observation provider.
func Provider() sdk.ObservationProvider {
return &caaProvider{}
}
type caaProvider struct{}
// Key implements sdk.ObservationProvider.
func (p *caaProvider) Key() sdk.ObservationKey {
return ObservationKeyCAA
}

304
checker/rule.go Normal file
View file

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

230
checker/rule_test.go Normal file
View file

@ -0,0 +1,230 @@
package checker
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// stubObsGetter is a minimal ObservationGetter for tests: it serves a
// canned CAAData under ObservationKeyCAA and a canned list of related
// observations under TLSRelatedKey.
type stubObsGetter struct {
data CAAData
related []sdk.RelatedObservation
}
func (s *stubObsGetter) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
if key != ObservationKeyCAA {
return nil
}
b, _ := json.Marshal(s.data)
return json.Unmarshal(b, dest)
}
func (s *stubObsGetter) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return s.related, nil
}
// mkTLSObs wraps a single probe into the {"probes": {<ref>: …}} shape
// checker-tls actually emits.
func mkTLSObs(t *testing.T, ref string, probe map[string]any) sdk.RelatedObservation {
t.Helper()
payload := map[string]any{
"probes": map[string]any{ref: probe},
}
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal tls payload: %v", err)
}
return sdk.RelatedObservation{
CheckerID: "tls",
Key: TLSRelatedKey,
Data: b,
CollectedAt: time.Now(),
Ref: ref,
}
}
// TestRule_OK: CAA allows letsencrypt.org and the probe is from a
// Let's Encrypt intermediate. Expect StatusOK.
func TestRule_OK(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_dn": "CN=R10,O=Let's Encrypt,C=US",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusOK {
t.Fatalf("expected StatusOK, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeOK {
t.Errorf("expected code %q, got %q", CodeOK, state.Code)
}
}
// TestRule_NotAuthorized: CAA only allows digicert.com but the probe
// shows a Let's Encrypt cert. Expect StatusCrit / caa_not_authorized.
func TestRule_NotAuthorized(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "digicert.com"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusCrit {
t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeNotAuthorized {
t.Errorf("expected code %q, got %q", CodeNotAuthorized, state.Code)
}
if !strings.Contains(state.Message, "letsencrypt.org") {
t.Errorf("expected message to mention letsencrypt.org, got %q", state.Message)
}
}
// TestRule_IssuanceDisallowed: CAA says `issue ";"` but a cert was
// observed. Expect StatusCrit / caa_issuance_disallowed regardless of
// the issuer.
func TestRule_IssuanceDisallowed(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: ";"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusCrit {
t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeIssuanceDisallowed {
t.Errorf("expected code %q, got %q", CodeIssuanceDisallowed, state.Code)
}
}
// TestRule_IssuerUnknown: the observed AKI is not in CCADB. Expect
// StatusInfo / caa_issuer_unknown.
func TestRule_IssuerUnknown(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
"issuer_dn": "CN=Totally Made Up CA,O=Nope,C=XX",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusInfo {
t.Fatalf("expected StatusInfo, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeIssuerUnknown {
t.Errorf("expected code %q, got %q", CodeIssuerUnknown, state.Code)
}
}
// TestRule_NoTLS: no related TLS observations yet. Steady state during
// the eventual-consistency window before checker-tls has produced data.
func TestRule_NoTLS(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: nil,
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusUnknown {
t.Fatalf("expected StatusUnknown, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeNoTLS {
t.Errorf("expected code %q, got %q", CodeNoTLS, state.Code)
}
}
// TestRule_NoCAAPublished: valid TLS cert, but the zone has no CAA
// records. Rule should nudge the user (StatusInfo) with a suggestion
// to publish CAA.
func TestRule_NoCAAPublished(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{Domain: "example.com", Records: nil},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusInfo {
t.Fatalf("expected StatusInfo (no policy), got %s: %s", state.Status, state.Message)
}
if !strings.Contains(state.Message, "letsencrypt.org") {
t.Errorf("expected suggestion to mention letsencrypt.org, got %q", state.Message)
}
}
// TestBuildAllowList is a unit test for the policy parser. The ';'
// sentinel and parameter stripping are the two subtle bits worth
// covering directly.
func TestBuildAllowList(t *testing.T) {
al := buildAllowList([]CAARecord{
{Flag: 0, Tag: "issue", Value: "letsencrypt.org"},
{Flag: 0, Tag: "issue", Value: "sectigo.com; account=12345"},
{Flag: 0, Tag: "issuewild", Value: ";"},
})
if !al.issueAll["letsencrypt.org"] {
t.Error("expected letsencrypt.org in issueAll")
}
if !al.issueAll["sectigo.com"] {
t.Errorf("expected sectigo.com (stripped) in issueAll, got %v", al.issueAll)
}
if al.disallowIssue {
t.Error("disallowIssue should be false; only issuewild was ';'")
}
if !al.disallowWildcardIssue {
t.Error("expected disallowWildcardIssue=true")
}
}

92
checker/tls_related.go Normal file
View file

@ -0,0 +1,92 @@
package checker
import (
"encoding/json"
"net"
"strconv"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// tlsProbeView is the local, permissive view of a single checker-tls
// probe payload. We decode only what the CAA rule needs; unknown fields
// are ignored so the TLS checker can evolve its schema independently.
//
// The IssuerAKI / IssuerDN fields are the cross-checker contract the
// CAA rule depends on. They were added to checker-tls so each probe
// carries the issuer identity in a form that maps directly to the
// CCADB "CAA Identifiers" CSV.
type tlsProbeView struct {
Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Type string `json:"type,omitempty"`
Issuer string `json:"issuer,omitempty"`
IssuerDN string `json:"issuer_dn,omitempty"`
IssuerAKI string `json:"issuer_aki,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"`
}
// address returns "host:port" as a human-readable identifier for
// Issue.Endpoint when the upstream Endpoint field is missing.
func (v *tlsProbeView) address() string {
if v.Endpoint != "" {
return v.Endpoint
}
if v.Host != "" && v.Port != 0 {
return net.JoinHostPort(v.Host, strconv.FormatUint(uint64(v.Port), 10))
}
return v.Host
}
// parseTLSRelated decodes a single RelatedObservation into a list of
// probes. Two payload shapes are supported to match the dual-shape
// contract checker-xmpp already consumes:
//
// 1. {"probes": {"<ref>": <probe>, …}}: the current checker-tls
// format. When r.Ref is set and present in the map, only that
// entry is returned; otherwise all probes are returned so a rule
// operating at domain scope can still see them.
// 2. <probe>: a single top-level probe, kept for back-compat.
//
// Returns nil when the payload is not a recognizable probe shape.
func parseTLSRelated(r sdk.RelatedObservation) []*tlsProbeView {
var keyed struct {
Probes map[string]tlsProbeView `json:"probes"`
}
if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil {
if r.Ref != "" {
if p, ok := keyed.Probes[r.Ref]; ok {
cp := p
return []*tlsProbeView{&cp}
}
}
out := make([]*tlsProbeView, 0, len(keyed.Probes))
for _, p := range keyed.Probes {
cp := p
out = append(out, &cp)
}
return out
}
var v tlsProbeView
if err := json.Unmarshal(r.Data, &v); err != nil {
return nil
}
if v.Host == "" && v.IssuerAKI == "" && v.IssuerDN == "" {
return nil
}
return []*tlsProbeView{&v}
}
// parseAllTLSRelated flattens a slice of RelatedObservations into the
// full set of probe views they carry. This is the input the rule works
// from; one entry per endpoint, not per observation.
func parseAllTLSRelated(related []sdk.RelatedObservation) []*tlsProbeView {
var out []*tlsProbeView
for _, r := range related {
out = append(out, parseTLSRelated(r)...)
}
return out
}

54
checker/types.go Normal file
View file

@ -0,0 +1,54 @@
// Package checker implements the CAA compliance checker for happyDomain.
//
// It consumes observations published by checker-tls (the "tls_probes" key)
// and cross-references each observed certificate issuer against the CAA
// policy declared by the domain's svcs.CAAPolicy service. No network
// probes are performed here.
package checker
// ObservationKeyCAA is the observation key this checker writes. Its
// payload is a pass-through of the zone-side CAA records; the
// checker does not re-query DNS.
const ObservationKeyCAA = "caa_policy"
// TLSRelatedKey is the observation key this checker reads from other
// checkers via ObservationGetter.GetRelated. Matches the key
// published by checker-tls.
const TLSRelatedKey = "tls_probes"
// Severity values used in Issue.Severity (lowercase, ascii). Kept in
// sync with the other happyDomain checkers so aggregators can merge
// severities by string.
const (
SeverityCrit = "crit"
SeverityWarn = "warn"
SeverityInfo = "info"
)
// Rule code values surfaced by CheckState.Code.
const (
CodeOK = "caa_ok"
CodeNoTLS = "caa_no_tls"
CodeNotAuthorized = "caa_not_authorized"
CodeIssuanceDisallowed = "caa_issuance_disallowed"
CodeIssuerUnknown = "caa_issuer_unknown"
CodeObservationError = "caa_observation_error"
)
// CAAData is the payload written under ObservationKeyCAA. It is a thin
// view over the zone's CAA records; consumers (including our own rule)
// can walk Records to rebuild the allow list without re-parsing the
// service body.
type CAAData struct {
Domain string `json:"domain,omitempty"`
Records []CAARecord `json:"records,omitempty"`
RunAt string `json:"run_at,omitempty"`
}
// CAARecord is one entry of the zone's CAA policy, flattened to the
// three fields that matter for compliance evaluation.
type CAARecord struct {
Flag uint8 `json:"flag"`
Tag string `json:"tag"`
Value string `json:"value"`
}