Initial commit
This commit is contained in:
commit
a2a7921cb8
20 changed files with 1868 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
checker-caa
|
||||
checker-caa.so
|
||||
AllCAAIdentifiersReport.csv
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -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"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -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
|
||||
40
NOTICE
Normal file
40
NOTICE
Normal file
|
|
@ -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.
|
||||
101
README.md
Normal file
101
README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
203
checker/ccadb.go
Normal file
203
checker/ccadb.go
Normal 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
104
checker/ccadb_test.go
Normal 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
89
checker/collect.go
Normal 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
49
checker/definition.go
Normal 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
125
checker/interactive.go
Normal 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
15
checker/provider.go
Normal 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
299
checker/rule.go
Normal 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
559
checker/rule_test.go
Normal 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
92
checker/tls_related.go
Normal 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
50
checker/types.go
Normal 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"`
|
||||
}
|
||||
16
go.mod
Normal file
16
go.mod
Normal file
|
|
@ -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
|
||||
)
|
||||
16
go.sum
Normal file
16
go.sum
Normal file
|
|
@ -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=
|
||||
23
main.go
Normal file
23
main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
20
plugin/plugin.go
Normal file
20
plugin/plugin.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue