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) } }