From a2a7921cb84e5ba9666df952af36cead08eb2062 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Apr 2026 01:52:25 +0700 Subject: [PATCH] Initial commit --- .gitignore | 3 + Dockerfile | 15 ++ LICENSE | 21 ++ Makefile | 28 +++ NOTICE | 40 +++ README.md | 101 ++++++++ checker/ccadb.go | 203 +++++++++++++++ checker/ccadb_test.go | 104 ++++++++ checker/collect.go | 89 +++++++ checker/definition.go | 49 ++++ checker/interactive.go | 125 +++++++++ checker/provider.go | 15 ++ checker/rule.go | 299 ++++++++++++++++++++++ checker/rule_test.go | 559 +++++++++++++++++++++++++++++++++++++++++ checker/tls_related.go | 92 +++++++ checker/types.go | 50 ++++ go.mod | 16 ++ go.sum | 16 ++ main.go | 23 ++ plugin/plugin.go | 20 ++ 20 files changed, 1868 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/ccadb.go create mode 100644 checker/ccadb_test.go create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/interactive.go create mode 100644 checker/provider.go create mode 100644 checker/rule.go create mode 100644 checker/rule_test.go create mode 100644 checker/tls_related.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37c5b9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +checker-caa +checker-caa.so +AllCAAIdentifiersReport.csv diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..916639b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go generate ./... && CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-caa . + +FROM scratch +COPY --from=builder /checker-caa /checker-caa +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["/checker-caa"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..77df544 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +CHECKER_NAME := checker-caa +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) checker/AllCAAIdentifiersReport.csv + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker test clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +test: + go test -tags standalone ./... + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..6383ed8 --- /dev/null +++ b/NOTICE @@ -0,0 +1,40 @@ +checker-caa +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + + This product includes software developed as part of the happyDomain + project (https://happydomain.org). + + Portions of this code were originally written for the happyDomain + server (licensed under AGPL-3.0 and a commercial license) and are + made available there under the Apache License, Version 2.0 to enable + a permissively licensed ecosystem of checker plugins. + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 + +------------------------------------------------------------------------------- +CCADB CAA Identifiers data +------------------------------------------------------------------------------- + +The file checker/AllCAAIdentifiersReport.csv is an unmodified snapshot of +the "CAA Identifiers (V2)" report published by the Common CA Database +(CCADB), used here to map observed certificate issuers to the CAA domain +identifiers the corresponding certificate authorities publish for DNS +CAA "issue" / "issuewild" records. + +CCADB data is maintained by the CCADB participating root programs and +distributed at https://www.ccadb.org/resources. The data is made available +by CCADB without warranty; see https://www.ccadb.org/ for details. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f72fb6 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# checker-caa + +CAA posture checker for happyDomain. + +Validates that certificates observed by `checker-tls` were issued by a +CA actually authorized by the domain's `CAA` records. This checker +runs no network probes of its own: it reads the `svcs.CAAPolicy` +service body (already parsed by happyDomain from the zone's `CAA` +resource records) and the `tls_probes` observations published by +`checker-tls`, and cross-references them via the CCADB "CAA +Identifiers" mapping. + +## How it works + +1. The host runs this checker on a `svcs.CAAPolicy` service. +2. `Collect` unmarshals the service body into a list of + `(flag, tag, value)` entries. No network. +3. The `caa_compliance` rule: + - calls `obs.Get("caa_policy", …)` to load its own payload; + - calls `obs.GetRelated("tls_probes")` to pick up every TLS probe + produced on the target; + - resolves each observed issuer (keyed by `IssuerAKI` with an + `IssuerDN` fallback) against the embedded CCADB CSV to find the + CA's published CAA identifier domain(s); + - compares the observed identifiers against the `issue` / + `issuewild` allow list (or flags a `DisallowIssue` violation). + +## Observation payload + +This checker does not publish endpoints or add a new observation +schema. Under its own observation key `caa_policy` it returns a +pass-through view of the zone-side CAA records: + +```json +{ + "domain": "example.net", + "records": [ + { "flag": 0, "tag": "issue", "value": "letsencrypt.org" }, + { "flag": 0, "tag": "issuewild", "value": ";" } + ], + "run_at": "2026-04-22T12:34:56Z" +} +``` + +## Rule outcomes + +- `caa_ok`: every observed issuer is authorized by the zone's CAA + policy. +- `caa_no_tls`: no TLS probes related to this target have been + published yet. Reported as `UNKNOWN` (the same "eventual + consistency" steady state used by `checker-tls` when it has no + endpoints yet). +- `caa_not_authorized`: CCADB mapped an observed issuer to a domain + the CAA policy does not list. Reported `CRIT`. +- `caa_issuance_disallowed`: the policy contains `CAA 0 issue ";"` + (explicitly disallowing issuance) but a TLS cert was still observed. + Reported `CRIT`. +- `caa_issuer_unknown`: CCADB has no mapping for the observed issuer + (AKI + DN). Reported `INFO`; action is to file a CCADB update. + +## Issuer -> CAA domain mapping (CCADB) + +The file `checker/AllCAAIdentifiersReport.csv` is an unmodified +snapshot of the "CAA Identifiers (V2)" report from the Common CA +Database (https://www.ccadb.org/resources). It is embedded into the +binary via `//go:embed` but is **not committed to the repository**. +To fetch or refresh it, run: + +```bash +go generate ./checker/ +``` + +This downloads the current CSV from CCADB. No code changes are needed +to pick up a new snapshot: only a re-embed (recompile) is required +after the file is refreshed. Note that the download depends on CCADB +being reachable; `go build` itself has no network dependency. + +The lookup key is: + +1. `IssuerAKI` (uppercase hex of `leaf.AuthorityKeyId`), matched + against CCADB's `"Subject Key Identifier (Hex)"` column. +2. `IssuerDN` (Go's `leaf.Issuer.String()`), matched against CCADB's + `"Subject"` column after normalization (RDNs sorted by type, + whitespace trimmed, comma/semicolon separators collapsed). + +## Options + +| Id | Type | Default | Description | +|-----------|--------|---------|------------------------------------| +| `domain` | string | (auto) | Domain being checked (`AutoFill`). | +| `service` | (n/a) | (auto) | `svcs.CAAPolicy` service body. | + +## Running + +```bash +# Plugin (loaded by happyDomain at startup) +make plugin + +# Standalone HTTP server +make && ./checker-caa -listen :8080 +``` diff --git a/checker/ccadb.go b/checker/ccadb.go new file mode 100644 index 0000000..7929c46 --- /dev/null +++ b/checker/ccadb.go @@ -0,0 +1,203 @@ +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 once. Failure means the binary +// itself is broken. +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) + } + minCols := max(idxSubject, idxSKI, idxDomains) + + 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) + } + if len(row) <= minCols { + continue + } + + 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 issuer to its CAA identifier domains. +// AKI takes precedence; DN is the fallback for rows without an SKI. +// 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 lowercases 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 canonicalizes a subject DN so Go's comma-joined form +// compares equal to CCADB's semicolon-joined form for the same RDNs. +// Intentionally permissive: escaping differences are ignored; AKI is +// the common path 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 +} diff --git a/checker/ccadb_test.go b/checker/ccadb_test.go new file mode 100644 index 0000000..4a9944b --- /dev/null +++ b/checker/ccadb_test.go @@ -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") + } +} diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..b378de2 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,89 @@ +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 local copy of happydns.ServiceMessage to avoid +// depending on the happyDomain core repository. +type serviceMessage struct { + Type string `json:"_svctype"` + Domain string `json:"_domain"` + Service json.RawMessage `json:"Service"` +} + +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 normalizes the "service" option via a JSON +// round-trip so the in-process plugin path (native Go value) and the +// HTTP path (decoded map[string]any) both work 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 +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..70e1044 --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,49 @@ +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 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, + }, + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..a9f9cb2 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,125 @@ +//go:build standalone + +package checker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" + "github.com/miekg/dns" +) + +// dnsLookupTimeout caps a single CAA query so the standalone HTTP +// handler can't be hung by a slow or hostile resolver. +const dnsLookupTimeout = 5 * time.Second + +func (p *caaProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "domain", + Type: "string", + Label: "Domain name", + Placeholder: "example.com", + Required: true, + }, + } +} + +// ParseForm resolves CAA records via direct DNS. TLS probes are not +// gathered here; the rule reports StatusUnknown for the cross-check +// when used standalone. +func (p *caaProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + domain := strings.TrimSpace(r.FormValue("domain")) + if domain == "" { + return nil, errors.New("domain is required") + } + domain = dns.Fqdn(domain) + bare := strings.TrimSuffix(domain, ".") + + records, err := lookupCAA(r.Context(), domain) + if err != nil { + return nil, fmt.Errorf("CAA lookup for %s: %w", domain, err) + } + + payload := caaPolicyPayload{Records: make([]caaRecordPayload, 0, len(records))} + for _, rec := range records { + payload.Records = append(payload.Records, caaRecordPayload{ + Flag: rec.Flag, Tag: rec.Tag, Value: rec.Value, + }) + } + svcBody, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal CAA payload: %w", err) + } + + svc := serviceMessage{ + Type: serviceType, + Domain: bare, + Service: svcBody, + } + + return sdk.CheckerOptions{ + "domain": bare, + "service": svc, + }, nil +} + +// lookupCAA queries CAA records for fqdn using the system resolver. +// Per RFC 8659 §3, climbing the label tree only continues on empty +// NOERROR; NXDOMAIN terminates the walk. +func lookupCAA(ctx context.Context, fqdn string) ([]CAARecord, error) { + resolver := systemResolver() + + c := &dns.Client{Timeout: dnsLookupTimeout} + for name := fqdn; name != "" && name != "."; { + msg := new(dns.Msg) + msg.SetQuestion(name, dns.TypeCAA) + msg.RecursionDesired = true + + in, _, err := c.ExchangeContext(ctx, msg, resolver) + if err != nil { + return nil, err + } + if in.Rcode == dns.RcodeNameError { + return nil, nil + } + if in.Rcode != dns.RcodeSuccess { + return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode]) + } + + var out []CAARecord + for _, rr := range in.Answer { + if caa, ok := rr.(*dns.CAA); ok { + out = append(out, CAARecord{Flag: caa.Flag, Tag: caa.Tag, Value: caa.Value}) + } + } + if len(out) > 0 { + return out, nil + } + + i := strings.IndexByte(name, '.') + if i < 0 || i >= len(name)-1 { + break + } + name = name[i+1:] + } + return nil, nil +} + +// systemResolver returns the first nameserver in /etc/resolv.conf as a +// host:port string suitable for dns.Client.Exchange. Falls back to +// 1.1.1.1:53 when resolv.conf is missing, unreadable, or empty. +func systemResolver() string { + cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil || len(cfg.Servers) == 0 { + return net.JoinHostPort("1.1.1.1", "53") + } + return net.JoinHostPort(cfg.Servers[0], cfg.Port) +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..385a76f --- /dev/null +++ b/checker/provider.go @@ -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 +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..6850730 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,299 @@ +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" +} diff --git a/checker/rule_test.go b/checker/rule_test.go new file mode 100644 index 0000000..aa71878 --- /dev/null +++ b/checker/rule_test.go @@ -0,0 +1,559 @@ +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": {: …}} 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", + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] + 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", + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] + 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", + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] + 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", + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] + 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, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] + 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", + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + state := states[0] + 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) + } +} + +// findState returns the first state matching code, or nil. +func findState(states []sdk.CheckState, code string) *sdk.CheckState { + for i := range states { + if states[i].Code == code { + return &states[i] + } + } + return nil +} + +// TestRule_UnknownCriticalTag: an unknown tag with the Issuer Critical +// bit (0x80) must surface a Warn / caa_unknown_critical state. +func TestRule_UnknownCriticalTag(t *testing.T) { + obs := &stubObsGetter{ + data: CAAData{ + Domain: "example.com", + Records: []CAARecord{ + {Flag: 0, Tag: "issue", Value: "letsencrypt.org"}, + {Flag: 128, Tag: "frobnicate", Value: "yes"}, + }, + }, + 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", + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + st := findState(states, CodeUnknownCritical) + if st == nil { + t.Fatalf("expected %s state, got %+v", CodeUnknownCritical, states) + } + if st.Status != sdk.StatusWarn { + t.Errorf("expected StatusWarn, got %s", st.Status) + } + if !strings.Contains(st.Message, "frobnicate") { + t.Errorf("expected unknown tag name in message, got %q", st.Message) + } +} + +// TestRule_UnknownCritical_NoTLS: the policy-level warning must fire +// even when checker-tls has not yet produced any probes (issue #1: the +// warning was previously gated on probe presence). +func TestRule_UnknownCritical_NoTLS(t *testing.T) { + obs := &stubObsGetter{ + data: CAAData{ + Domain: "example.com", + Records: []CAARecord{ + {Flag: 128, Tag: "frobnicate", Value: "yes"}, + }, + }, + related: nil, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if findState(states, CodeUnknownCritical) == nil { + t.Errorf("expected %s state with no TLS probes, got %+v", CodeUnknownCritical, states) + } + if findState(states, CodeNoTLS) == nil { + t.Errorf("expected %s state alongside the warning, got %+v", CodeNoTLS, states) + } +} + +// TestRule_CriticalIodef: iodef is a recognized tag, so the critical +// bit on it must not produce an unknown-critical warning. +func TestRule_CriticalIodef(t *testing.T) { + obs := &stubObsGetter{ + data: CAAData{ + Domain: "example.com", + Records: []CAARecord{ + {Flag: 0, Tag: "issue", Value: "letsencrypt.org"}, + {Flag: 128, Tag: "iodef", Value: "mailto:sec@example.com"}, + }, + }, + related: []sdk.RelatedObservation{ + mkTLSObs(t, "ep-1", map[string]any{ + "host": "www.example.com", + "port": 443, + "endpoint": "www.example.com:443", + "issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8", + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if st := findState(states, CodeUnknownCritical); st != nil { + t.Errorf("did not expect unknown-critical for iodef, got %+v", st) + } +} + +// TestRule_CriticalIssue: critical bit on the well-known "issue" tag +// is normal (CAs always understand it) and must not warn. +func TestRule_CriticalIssue(t *testing.T) { + obs := &stubObsGetter{ + data: CAAData{ + Domain: "example.com", + Records: []CAARecord{ + {Flag: 128, 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": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8", + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if st := findState(states, CodeUnknownCritical); st != nil { + t.Errorf("did not expect unknown-critical for issue, got %+v", st) + } +} + +// TestRule_CriticalEmptyTag: a malformed record with the critical bit +// set and an empty tag is still surfaced (issue #3, previously +// silently dropped). +func TestRule_CriticalEmptyTag(t *testing.T) { + obs := &stubObsGetter{ + data: CAAData{ + Domain: "example.com", + Records: []CAARecord{ + {Flag: 128, Tag: "", Value: "garbage"}, + }, + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if findState(states, CodeUnknownCritical) == nil { + t.Errorf("expected %s for critical empty tag, got %+v", CodeUnknownCritical, states) + } +} + +// TestRule_KnownExtraTagsCritical: tags registered outside the v1 +// vocabulary (contactemail, contactphone, issuemail, issuevmc) should +// not trigger unknown-critical warnings even when marked critical. +func TestRule_KnownExtraTagsCritical(t *testing.T) { + obs := &stubObsGetter{ + data: CAAData{ + Domain: "example.com", + Records: []CAARecord{ + {Flag: 0, Tag: "issue", Value: "letsencrypt.org"}, + {Flag: 128, Tag: "contactemail", Value: "sec@example.com"}, + {Flag: 128, Tag: "contactphone", Value: "+1-555-0100"}, + {Flag: 128, Tag: "issuemail", Value: "letsencrypt.org"}, + {Flag: 128, Tag: "issuevmc", 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": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8", + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if st := findState(states, CodeUnknownCritical); st != nil { + t.Errorf("did not expect unknown-critical for known extra tags, got %+v", st) + } +} + +// 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") + } + if !al.hasIssueWild { + t.Error("expected hasIssueWild=true") + } +} + +// TestRule_WildcardDisallowed: zone allows letsencrypt.org via "issue" +// but explicitly forbids wildcard issuance via `issuewild ";"`. A +// wildcard cert should trip caa_issuance_disallowed even though the +// CA is otherwise authorized. +func TestRule_WildcardDisallowed(t *testing.T) { + obs := &stubObsGetter{ + data: CAAData{ + Domain: "example.com", + Records: []CAARecord{ + {Flag: 0, Tag: "issue", Value: "letsencrypt.org"}, + {Flag: 0, Tag: "issuewild", 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", + "dns_names": []string{"*.example.com", "example.com"}, + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + if states[0].Status != sdk.StatusCrit { + t.Fatalf("expected StatusCrit, got %s: %s", states[0].Status, states[0].Message) + } + if states[0].Code != CodeIssuanceDisallowed { + t.Errorf("expected %q, got %q", CodeIssuanceDisallowed, states[0].Code) + } + if !strings.Contains(states[0].Message, "issuewild") { + t.Errorf("expected message to mention issuewild, got %q", states[0].Message) + } +} + +// TestRule_WildcardOverridesIssue: when "issuewild" is present, it +// fully overrides "issue" for wildcard certs (RFC 8659 §4.3). The +// wildcard probe must be checked against issuewild only, even if the +// CA is allowed by "issue". +func TestRule_WildcardOverridesIssue(t *testing.T) { + obs := &stubObsGetter{ + data: CAAData{ + Domain: "example.com", + Records: []CAARecord{ + {Flag: 0, Tag: "issue", Value: "letsencrypt.org"}, + {Flag: 0, Tag: "issuewild", 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_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8", + "dns_names": []string{"*.example.com"}, + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + if states[0].Status != sdk.StatusCrit { + t.Fatalf("expected StatusCrit (LE not in issuewild), got %s: %s", states[0].Status, states[0].Message) + } + if states[0].Code != CodeNotAuthorized { + t.Errorf("expected %q, got %q", CodeNotAuthorized, states[0].Code) + } + if !strings.Contains(states[0].Message, "issuewild") { + t.Errorf("expected message to mention issuewild, got %q", states[0].Message) + } +} + +// TestRule_WildcardFallsBackToIssue: with no "issuewild" records, a +// wildcard cert is governed by the "issue" allow list as if it were a +// regular cert. +func TestRule_WildcardFallsBackToIssue(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_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8", + "dns_names": []string{"*.example.com"}, + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + if states[0].Status != sdk.StatusOK { + t.Fatalf("expected StatusOK, got %s: %s", states[0].Status, states[0].Message) + } +} + +// TestRule_NonWildcardIgnoresIssueWild: a non-wildcard cert must be +// checked against "issue" even when "issuewild" is present and would +// disallow issuance. +func TestRule_NonWildcardIgnoresIssueWild(t *testing.T) { + obs := &stubObsGetter{ + data: CAAData{ + Domain: "example.com", + Records: []CAARecord{ + {Flag: 0, Tag: "issue", Value: "letsencrypt.org"}, + {Flag: 0, Tag: "issuewild", Value: ";"}, + }, + }, + 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", + "dns_names": []string{"www.example.com"}, + }), + }, + } + states := Rule().Evaluate(context.Background(), obs, nil) + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + if states[0].Status != sdk.StatusOK { + t.Fatalf("expected StatusOK, got %s: %s", states[0].Status, states[0].Message) + } +} diff --git a/checker/tls_related.go b/checker/tls_related.go new file mode 100644 index 0000000..2006cf3 --- /dev/null +++ b/checker/tls_related.go @@ -0,0 +1,92 @@ +package checker + +import ( + "encoding/json" + "net" + "strconv" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// tlsProbeView is a permissive subset of checker-tls's probe payload; +// only fields the CAA rule needs are decoded so the TLS checker can +// evolve its schema independently. +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"` + DNSNames []string `json:"dns_names,omitempty"` + Subject string `json:"subject,omitempty"` +} + +// isWildcard reports whether the observed certificate covers at least +// one wildcard DNS name. Used to pick between the CAA "issue" and +// "issuewild" allow lists per RFC 8659 §4.3. +func (v *tlsProbeView) isWildcard() bool { + for _, n := range v.DNSNames { + if strings.HasPrefix(n, "*.") { + return true + } + } + return false +} + +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 RelatedObservation into probes. Two +// payload shapes are accepted: the current {"probes": {ref: …}} map +// (filtered by r.Ref when set) and a bare top-level probe (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 one +// entry per endpoint. +func parseAllTLSRelated(related []sdk.RelatedObservation) []*tlsProbeView { + var out []*tlsProbeView + for _, r := range related { + out = append(out, parseTLSRelated(r)...) + } + return out +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..b42c67b --- /dev/null +++ b/checker/types.go @@ -0,0 +1,50 @@ +// 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" + CodeUnknownCritical = "caa_unknown_critical" +) + +// CAAData is the payload written under ObservationKeyCAA. +type CAAData struct { + Domain string `json:"domain,omitempty"` + Records []CAARecord `json:"records,omitempty"` + RunAt string `json:"run_at,omitempty"` +} + +type CAARecord struct { + Flag uint8 `json:"flag"` + Tag string `json:"tag"` + Value string `json:"value"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3fdb13c --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module git.happydns.org/checker-caa + +go 1.25.0 + +require ( + git.happydns.org/checker-sdk-go v1.4.0 + github.com/miekg/dns v1.1.72 +) + +require ( + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/tools v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fc3939b --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs= +git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3562b7c --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "flag" + "log" + + caa "git.happydns.org/checker-caa/checker" + "git.happydns.org/checker-sdk-go/checker/server" +) + +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + caa.Version = Version + + srv := server.New(caa.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..d1f1986 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,20 @@ +// Command plugin is the happyDomain plugin entrypoint for the CAA checker. +// +// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at +// runtime by happyDomain. +package main + +import ( + caa "git.happydns.org/checker-caa/checker" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "custom-build" + +// NewCheckerPlugin is the symbol resolved by happyDomain when loading the +// .so file. +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + caa.Version = Version + prvd := caa.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil +}