Initial commit
This commit is contained in:
commit
9373d1c967
19 changed files with 1429 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
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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 -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-caa .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-caa /checker-caa
|
||||
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 -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 ./...
|
||||
|
||||
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
|
||||
```
|
||||
222
checker/ccadb.go
Normal file
222
checker/ccadb.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
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 into the two lookup indexes on first
|
||||
// call. Subsequent calls are no-ops. The CSV is shipped with the binary
|
||||
// so parse failures indicate a bug or a corrupted build, not a runtime
|
||||
// condition; tests assert the parse succeeds for the checked-in file.
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 certificate issuer to the CAA identifier
|
||||
// domains the issuing CA publishes in its CPS. aki is the uppercase hex
|
||||
// Authority Key Identifier of the leaf (i.e. the issuer's SKI); dn is
|
||||
// the RFC 2253 subject DN of the issuer (leaf.Issuer.String() in Go).
|
||||
//
|
||||
// AKI takes precedence because CCADB keys by it. DN is a fallback for
|
||||
// the rare rows where the SKI column is empty.
|
||||
//
|
||||
// Returns ok=false when neither key resolves. 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 splits CCADB's "Recognized CAA Domains" cell, which
|
||||
// can hold a comma-separated list (e.g. DigiCert rows list ~20
|
||||
// domains). Whitespace is trimmed, empties are dropped, and the result
|
||||
// is lowercased 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 produces a canonical key from a subject DN so that DNs
|
||||
// produced by Go's pkix.Name.String (comma-joined) compare equal to
|
||||
// DNs produced by CCADB (semicolon-joined) when their RDN sets match.
|
||||
//
|
||||
// Rules:
|
||||
// - split on ',' or ';';
|
||||
// - trim each RDN;
|
||||
// - uppercase the RDN type (left of '=') because RFC 4514 types are
|
||||
// case-insensitive; values are left as-is;
|
||||
// - sort the RDNs alphabetically so reordering does not break
|
||||
// comparison.
|
||||
//
|
||||
// This is intentionally permissive; escaping differences between
|
||||
// implementations are ignored. Good enough for CCADB fallbacks, and
|
||||
// the common path is the AKI lookup 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
|
||||
}
|
||||
105
checker/ccadb_test.go
Normal file
105
checker/ccadb_test.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
||||
95
checker/collect.go
Normal file
95
checker/collect.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
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 minimal local copy of happydns.ServiceMessage
|
||||
// matching the JSON wire shape, so this module does not depend on the
|
||||
// happyDomain core repository. Same pattern as
|
||||
// checker-ns-restrictions/checker/types.go.
|
||||
type serviceMessage struct {
|
||||
Type string `json:"_svctype"`
|
||||
Domain string `json:"_domain"`
|
||||
Service json.RawMessage `json:"Service"`
|
||||
}
|
||||
|
||||
// caaPolicyPayload mirrors the JSON shape of svcs.CAAPolicy: a single
|
||||
// "caa" field holding a list of CAA records. Each record is decoded
|
||||
// into a trimmed local type with just the fields the rule reads.
|
||||
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 pulls the "service" option out of the options map,
|
||||
// accepting both the in-process plugin path (native Go value) and the
|
||||
// HTTP path (JSON-decoded map[string]any). Normalizing via a JSON
|
||||
// round-trip keeps both paths working 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
|
||||
}
|
||||
55
checker/definition.go
Normal file
55
checker/definition.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
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 is the package-level helper expected by the plugin
|
||||
// entrypoint and by sdk.NewServer via CheckerDefinitionProvider.
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
return (&caaProvider{}).Definition()
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
304
checker/rule.go
Normal file
304
checker/rule.go
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
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 we
|
||||
// made and the set of endpoints it showed up on. A single struct type
|
||||
// (rather than an anonymous struct inside Evaluate) keeps it reachable
|
||||
// from the helpers below.
|
||||
type issuerAgg struct {
|
||||
sample *tlsProbeView
|
||||
severity string
|
||||
code string
|
||||
msg string
|
||||
fix string
|
||||
endpoints map[string]bool
|
||||
}
|
||||
|
||||
// allowList captures the policy in a form the rule can intersect against.
|
||||
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 only constrains wildcard certs; the rule's
|
||||
// inputs are individual probe observations without a "is-wildcard"
|
||||
// flag, so it is recorded but not yet enforced. Reserved for a
|
||||
// future iteration.
|
||||
disallowWildcardIssue bool
|
||||
}
|
||||
|
||||
// buildAllowList walks the CAA records and builds the effective
|
||||
// allow/deny sets per RFC 8659 §4.2 "issue" and §4.3 "issuewild".
|
||||
// Parameters on the issuer value (after ';') are stripped; v1 of this
|
||||
// checker compares base issuer domain names only.
|
||||
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":
|
||||
if value == "" || value == ";" {
|
||||
al.disallowWildcardIssue = true
|
||||
} else {
|
||||
al.issueWildAll[issuerFromValue(value)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return al
|
||||
}
|
||||
|
||||
// issuerFromValue extracts the base issuer domain from a CAA value,
|
||||
// dropping any ';'-prefixed parameters. Comparison is lowercase.
|
||||
func issuerFromValue(v string) string {
|
||||
if i := strings.IndexByte(v, ';'); i >= 0 {
|
||||
v = v[:i]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
// Evaluate runs the compliance rule.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
|
||||
probes := parseAllTLSRelated(related)
|
||||
|
||||
if len(probes) == 0 {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusUnknown,
|
||||
Message: "No TLS probes have been observed for this target yet",
|
||||
Code: CodeNoTLS,
|
||||
}
|
||||
}
|
||||
|
||||
al := buildAllowList(data.Records)
|
||||
hasPolicy := len(al.issueAll) > 0 || al.disallowIssue
|
||||
|
||||
// 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, fix 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
|
||||
cur.fix = fix
|
||||
}
|
||||
if addr := p.address(); addr != "" {
|
||||
cur.endpoints[addr] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range probes {
|
||||
if al.disallowIssue {
|
||||
issue(p, SeverityCrit, CodeIssuanceDisallowed,
|
||||
fmt.Sprintf("CAA policy forbids issuance (issue \";\") but a certificate was observed on %s", p.address()),
|
||||
"Remove the CA 0 issue \";\" record, or stop serving TLS certificates for this domain.")
|
||||
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),
|
||||
"File a CCADB update for this CA, or verify the cert was issued by a real CA.")
|
||||
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, ", ")),
|
||||
fmt.Sprintf("Publish %q IN CAA 0 issue %q to lock future issuance to %s.",
|
||||
data.Domain, domains[0], domains[0]))
|
||||
continue
|
||||
}
|
||||
|
||||
if !intersects(domains, al.issueAll) {
|
||||
issue(p, SeverityCrit, CodeNotAuthorized,
|
||||
fmt.Sprintf("Certificate on %s issued by %s (CAA identifier %s) is not authorized by the zone's CAA issue records",
|
||||
p.address(), issuerLabel(p), strings.Join(domains, ", ")),
|
||||
"Either update the CAA record to authorize this CA, or re-issue with an authorized CA.")
|
||||
continue
|
||||
}
|
||||
|
||||
issue(p, "", "", "", "")
|
||||
}
|
||||
|
||||
// Materialize issues in a deterministic order (keys sorted) so test
|
||||
// output is stable and the "first" critical/warn message is not
|
||||
// map-iteration-dependent.
|
||||
keys := make([]string, 0, len(agg))
|
||||
for k := range agg {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var critCount, warnCount, infoCount, okIssuerCount int
|
||||
var firstCrit, firstWarn, firstInfo string
|
||||
for _, k := range keys {
|
||||
a := agg[k]
|
||||
switch a.severity {
|
||||
case SeverityCrit:
|
||||
critCount++
|
||||
if firstCrit == "" {
|
||||
firstCrit = a.msg
|
||||
}
|
||||
case SeverityWarn:
|
||||
warnCount++
|
||||
if firstWarn == "" {
|
||||
firstWarn = a.msg
|
||||
}
|
||||
case SeverityInfo:
|
||||
infoCount++
|
||||
if firstInfo == "" {
|
||||
firstInfo = a.msg
|
||||
}
|
||||
default:
|
||||
okIssuerCount++
|
||||
}
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"probes": len(probes),
|
||||
"distinct_issuers": len(agg),
|
||||
"authorized": okIssuerCount,
|
||||
"unauthorized": critCount,
|
||||
"info": infoCount,
|
||||
"caa_records": len(data.Records),
|
||||
}
|
||||
|
||||
switch {
|
||||
case critCount > 0:
|
||||
code := CodeNotAuthorized
|
||||
if al.disallowIssue {
|
||||
code = CodeIssuanceDisallowed
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Message: fmt.Sprintf("%d issuer(s) violate the zone's CAA policy: %s", critCount, firstCrit),
|
||||
Code: code,
|
||||
Meta: meta,
|
||||
}
|
||||
case warnCount > 0:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Message: firstWarn,
|
||||
Code: CodeNotAuthorized,
|
||||
Meta: meta,
|
||||
}
|
||||
case infoCount > 0 && okIssuerCount == 0:
|
||||
// Only info-level findings. When a policy exists this is a data
|
||||
// gap (CCADB didn't know the issuer); without a policy it's the
|
||||
// "publish CAA" nudge, which is fine; OK code.
|
||||
code := CodeIssuerUnknown
|
||||
if !hasPolicy {
|
||||
code = CodeOK
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Message: firstInfo,
|
||||
Code: code,
|
||||
Meta: meta,
|
||||
}
|
||||
default:
|
||||
msg := fmt.Sprintf("%d TLS issuer(s) authorized by CAA policy", okIssuerCount)
|
||||
if !hasPolicy {
|
||||
msg = fmt.Sprintf("%d TLS issuer(s) observed; no CAA records published", okIssuerCount)
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Message: msg,
|
||||
Code: CodeOK,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// severityRank turns a severity string into a comparable integer so
|
||||
// the rule can pick the worst per-issuer status.
|
||||
func severityRank(s string) int {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return 3
|
||||
case SeverityWarn:
|
||||
return 2
|
||||
case SeverityInfo:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// intersects reports whether any element of lhs is present in the set.
|
||||
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 best human-readable name for an issuer from a
|
||||
// probe: the Issuer CN if the TLS checker populated it, otherwise the
|
||||
// full DN.
|
||||
func issuerLabel(p *tlsProbeView) string {
|
||||
if p.Issuer != "" {
|
||||
return p.Issuer
|
||||
}
|
||||
if p.IssuerDN != "" {
|
||||
return p.IssuerDN
|
||||
}
|
||||
return "unknown issuer"
|
||||
}
|
||||
230
checker/rule_test.go
Normal file
230
checker/rule_test.go
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
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",
|
||||
}),
|
||||
},
|
||||
}
|
||||
state := Rule().Evaluate(context.Background(), obs, nil)
|
||||
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",
|
||||
}),
|
||||
},
|
||||
}
|
||||
state := Rule().Evaluate(context.Background(), obs, nil)
|
||||
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",
|
||||
}),
|
||||
},
|
||||
}
|
||||
state := Rule().Evaluate(context.Background(), obs, nil)
|
||||
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",
|
||||
}),
|
||||
},
|
||||
}
|
||||
state := Rule().Evaluate(context.Background(), obs, nil)
|
||||
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,
|
||||
}
|
||||
state := Rule().Evaluate(context.Background(), obs, nil)
|
||||
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",
|
||||
}),
|
||||
},
|
||||
}
|
||||
state := Rule().Evaluate(context.Background(), obs, nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
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"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// tlsProbeView is the local, permissive view of a single checker-tls
|
||||
// probe payload. We decode only what the CAA rule needs; unknown fields
|
||||
// are ignored so the TLS checker can evolve its schema independently.
|
||||
//
|
||||
// The IssuerAKI / IssuerDN fields are the cross-checker contract the
|
||||
// CAA rule depends on. They were added to checker-tls so each probe
|
||||
// carries the issuer identity in a form that maps directly to the
|
||||
// CCADB "CAA Identifiers" CSV.
|
||||
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"`
|
||||
}
|
||||
|
||||
// address returns "host:port" as a human-readable identifier for
|
||||
// Issue.Endpoint when the upstream Endpoint field is missing.
|
||||
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 single RelatedObservation into a list of
|
||||
// probes. Two payload shapes are supported to match the dual-shape
|
||||
// contract checker-xmpp already consumes:
|
||||
//
|
||||
// 1. {"probes": {"<ref>": <probe>, …}}: the current checker-tls
|
||||
// format. When r.Ref is set and present in the map, only that
|
||||
// entry is returned; otherwise all probes are returned so a rule
|
||||
// operating at domain scope can still see them.
|
||||
// 2. <probe>: a single top-level probe, kept for 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 the
|
||||
// full set of probe views they carry. This is the input the rule works
|
||||
// from; one entry per endpoint, not per observation.
|
||||
func parseAllTLSRelated(related []sdk.RelatedObservation) []*tlsProbeView {
|
||||
var out []*tlsProbeView
|
||||
for _, r := range related {
|
||||
out = append(out, parseTLSRelated(r)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
55
checker/types.go
Normal file
55
checker/types.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// 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"
|
||||
)
|
||||
|
||||
// CAAData is the payload written under ObservationKeyCAA. It is a thin
|
||||
// view over the zone's CAA records; consumers (including our own rule)
|
||||
// can walk Records to rebuild the allow list without re-parsing the
|
||||
// service body.
|
||||
type CAAData struct {
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Records []CAARecord `json:"records,omitempty"`
|
||||
RunAt string `json:"run_at,omitempty"`
|
||||
}
|
||||
|
||||
// CAARecord is one entry of the zone's CAA policy, flattened to the
|
||||
// three fields that matter for compliance evaluation.
|
||||
type CAARecord struct {
|
||||
Flag uint8 `json:"flag"`
|
||||
Tag string `json:"tag"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module git.happydns.org/checker-caa
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.happydns.org/checker-sdk-go v1.1.0
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
git.happydns.org/checker-sdk-go v1.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y=
|
||||
git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
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"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
caa.Version = Version
|
||||
|
||||
server := sdk.NewServer(caa.Provider())
|
||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
19
plugin/plugin.go
Normal file
19
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// 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
|
||||
return caa.Definition(), caa.Provider(), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue