Initial commit
This commit is contained in:
commit
a2a7921cb8
20 changed files with 1868 additions and 0 deletions
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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue