diff --git a/README.md b/README.md index 5ec0191..7f72fb6 100644 --- a/README.md +++ b/README.md @@ -58,14 +58,22 @@ pass-through view of the zone-side CAA records: - `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) +## 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`. To refresh it, download the CSV from CCADB -and replace the file; no code changes needed. A future `make -update-ccadb` target will automate this. +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: @@ -78,9 +86,9 @@ The lookup key is: ## Options | Id | Type | Default | Description | -| --------- | ------ | ------- | ---------------------------------- | +|-----------|--------|---------|------------------------------------| | `domain` | string | (auto) | Domain being checked (`AutoFill`). | -| `service` | (n/a) | (auto) | `svcs.CAAPolicy` service body. | +| `service` | (n/a) | (auto) | `svcs.CAAPolicy` service body. | ## Running @@ -91,11 +99,3 @@ make plugin # Standalone HTTP server make && ./checker-caa -listen :8080 ``` - -## Out of scope for v1 - -- CAA parameter matching (`;account=…`, `;validationmethods=…`): only - the base issuer domain name is compared. -- `issuemail` / `issuevmc` policies: the consumed TLS observations are - for web-PKI endpoints only. -- HTML report page. diff --git a/checker/ccadb.go b/checker/ccadb.go index d8c608f..a75d4ba 100644 --- a/checker/ccadb.go +++ b/checker/ccadb.go @@ -130,7 +130,7 @@ func Lookup(aki, dn string) ([]string, bool) { // is lowercased because CAA identifiers are case-insensitive. func splitCAADomains(raw string) []string { var out []string - for _, d := range strings.Split(raw, ",") { + for d := range strings.SplitSeq(raw, ",") { d = strings.TrimSpace(strings.ToLower(d)) if d != "" { out = append(out, d) diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..18ff51d --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,118 @@ +package checker + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" + "github.com/miekg/dns" +) + +// RenderForm describes the minimal input the standalone /check route +// accepts: just a domain name to resolve CAA records for. +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 for the submitted domain via direct +// DNS queries and packages them into the CheckerOptions shape Collect +// expects. TLS probes are not gathered here; the rule will report +// StatusUnknown for the TLS 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) + + records, err := lookupCAA(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: strings.TrimSuffix(domain, "."), + Service: svcBody, + } + + return sdk.CheckerOptions{ + "domain": strings.TrimSuffix(domain, "."), + "service": svc, + }, nil +} + +// lookupCAA queries CAA records for fqdn using the system resolver. +// Walks up the label tree per RFC 8659 §3 until a record set is found +// or the zone apex is reached; returns an empty slice when none exist. +func lookupCAA(fqdn string) ([]CAARecord, error) { + resolver, err := systemResolver() + if err != nil { + return nil, err + } + + for name := fqdn; name != "" && name != "."; { + msg := new(dns.Msg) + msg.SetQuestion(name, dns.TypeCAA) + msg.RecursionDesired = true + + c := new(dns.Client) + in, _, err := c.Exchange(msg, resolver) + if err != nil { + return nil, err + } + if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError { + 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. +func systemResolver() (string, error) { + cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil || len(cfg.Servers) == 0 { + return net.JoinHostPort("1.1.1.1", "53"), nil + } + return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil +} diff --git a/checker/rule.go b/checker/rule.go index f6ba83e..512795f 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -88,25 +88,25 @@ func issuerFromValue(v string) string { } // Evaluate runs the compliance rule. -func (r *caaRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState { +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{ + 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{ + return []sdk.CheckState{{ Status: sdk.StatusUnknown, Message: "No TLS probes have been observed for this target yet", Code: CodeNoTLS, - } + }} } al := buildAllowList(data.Records) @@ -175,94 +175,53 @@ func (r *caaRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts 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. + // 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) - var critCount, warnCount, infoCount, okIssuerCount int - var firstCrit, firstWarn, firstInfo string + out := make([]sdk.CheckState, 0, len(keys)) 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: - critCount++ - if firstCrit == "" { - firstCrit = a.msg - } + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, Message: a.msg, Code: a.code, + Subject: subject, Meta: meta, + }) case SeverityWarn: - warnCount++ - if firstWarn == "" { - firstWarn = a.msg - } + out = append(out, sdk.CheckState{ + Status: sdk.StatusWarn, Message: a.msg, Code: a.code, + Subject: subject, Meta: meta, + }) case SeverityInfo: - infoCount++ - if firstInfo == "" { - firstInfo = a.msg - } + out = append(out, sdk.CheckState{ + Status: sdk.StatusInfo, Message: a.msg, Code: a.code, + Subject: subject, Meta: meta, + }) 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, + 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 } // severityRank turns a severity string into a comparable integer so diff --git a/checker/rule_test.go b/checker/rule_test.go index 235cc03..6b76c95 100644 --- a/checker/rule_test.go +++ b/checker/rule_test.go @@ -69,7 +69,11 @@ func TestRule_OK(t *testing.T) { }), }, } - state := Rule().Evaluate(context.Background(), obs, 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.StatusOK { t.Fatalf("expected StatusOK, got %s: %s", state.Status, state.Message) } @@ -96,7 +100,11 @@ func TestRule_NotAuthorized(t *testing.T) { }), }, } - state := Rule().Evaluate(context.Background(), obs, 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.StatusCrit { t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message) } @@ -126,7 +134,11 @@ func TestRule_IssuanceDisallowed(t *testing.T) { }), }, } - state := Rule().Evaluate(context.Background(), obs, 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.StatusCrit { t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message) } @@ -153,7 +165,11 @@ func TestRule_IssuerUnknown(t *testing.T) { }), }, } - state := Rule().Evaluate(context.Background(), obs, 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.StatusInfo { t.Fatalf("expected StatusInfo, got %s: %s", state.Status, state.Message) } @@ -172,7 +188,11 @@ func TestRule_NoTLS(t *testing.T) { }, related: nil, } - state := Rule().Evaluate(context.Background(), obs, 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) } @@ -197,7 +217,11 @@ func TestRule_NoCAAPublished(t *testing.T) { }), }, } - state := Rule().Evaluate(context.Background(), obs, 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.StatusInfo { t.Fatalf("expected StatusInfo (no policy), got %s: %s", state.Status, state.Message) } diff --git a/go.mod b/go.mod index 2150bf5..b207bda 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,12 @@ module git.happydns.org/checker-caa go 1.25.0 require git.happydns.org/checker-sdk-go v1.1.0 + +require ( + github.com/miekg/dns v1.1.72 // indirect + 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 index f7aa184..17bb426 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,13 @@ -git.happydns.org/checker-sdk-go v1.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y= git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +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=