Initial commit

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

203
checker/ccadb.go Normal file
View file

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

104
checker/ccadb_test.go Normal file
View file

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

89
checker/collect.go Normal file
View file

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

49
checker/definition.go Normal file
View file

@ -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,
},
}
}

125
checker/interactive.go Normal file
View file

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

15
checker/provider.go Normal file
View file

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

299
checker/rule.go Normal file
View file

@ -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 "<domain>"
issueWildAll map[string]bool // CAA 0 issuewild "<domain>"
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"
}

559
checker/rule_test.go Normal file
View file

@ -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": {<ref>: …}} shape
// checker-tls actually emits.
func mkTLSObs(t *testing.T, ref string, probe map[string]any) sdk.RelatedObservation {
t.Helper()
payload := map[string]any{
"probes": map[string]any{ref: probe},
}
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal tls payload: %v", err)
}
return sdk.RelatedObservation{
CheckerID: "tls",
Key: TLSRelatedKey,
Data: b,
CollectedAt: time.Now(),
Ref: ref,
}
}
// TestRule_OK: CAA allows letsencrypt.org and the probe is from a
// Let's Encrypt intermediate. Expect StatusOK.
func TestRule_OK(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_dn": "CN=R10,O=Let's Encrypt,C=US",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
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)
}
}

92
checker/tls_related.go Normal file
View file

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

50
checker/types.go Normal file
View file

@ -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"`
}