595 lines
17 KiB
Go
595 lines
17 KiB
Go
package checker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/miekg/dns"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
// Collect runs the alias testsuite and returns an *AliasData populated with
|
|
// findings, a resolution chain, and optional coexistence / DNSSEC observations.
|
|
func (p *aliasProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
|
owner, err := resolveOwner(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
maxChain := sdk.GetIntOption(opts, "maxChainLength", 8)
|
|
minTTL := uint32(sdk.GetIntOption(opts, "minTargetTTL", 60))
|
|
requireTarget := sdk.GetBoolOption(opts, "requireResolvableTarget", true)
|
|
allowApexCNAME := sdk.GetBoolOption(opts, "allowApexCNAME", false)
|
|
recognizeApex := sdk.GetBoolOption(opts, "recognizeApexFlattening", true)
|
|
|
|
data := &AliasData{Owner: owner}
|
|
|
|
resolver := systemResolver()
|
|
|
|
// 1. Find apex and authoritative servers.
|
|
apex, servers, err := findApex(ctx, owner, resolver)
|
|
if err != nil {
|
|
data.Findings = append(data.Findings, AliasFinding{
|
|
Code: "alias_no_apex",
|
|
Severity: SeverityCrit,
|
|
Message: fmt.Sprintf("could not locate zone apex of %s: %v", owner, err),
|
|
Subject: owner,
|
|
Hint: "Check that the parent delegation exists and that the zone is published.",
|
|
})
|
|
return data, nil
|
|
}
|
|
data.Apex = apex
|
|
data.AuthServers = servers
|
|
data.OwnerIsApex = lowerFQDN(owner) == lowerFQDN(apex)
|
|
|
|
// 2. Detect DNAME substitutions from owner up to apex (exclusive of apex).
|
|
data.DNAMESubstitutions = collectDNAMEs(ctx, servers, owner, apex)
|
|
|
|
// 3. Walk the CNAME/DNAME chain.
|
|
chainCtx := &chainCtx{
|
|
data: data,
|
|
maxLen: maxChain,
|
|
minTTL: minTTL,
|
|
servers: servers,
|
|
apex: apex,
|
|
seenOwners: map[string]bool{},
|
|
recFallback: resolver,
|
|
followTarget: requireTarget,
|
|
}
|
|
chainCtx.walk(ctx, owner)
|
|
|
|
// 4. Apex checks (flattening, CNAME-at-apex coexistence).
|
|
if data.OwnerIsApex {
|
|
checkApex(ctx, data, servers, apex, allowApexCNAME, recognizeApex)
|
|
}
|
|
|
|
// 5. Coexistence at owner (applies at any level, not just apex).
|
|
checkCoexistence(ctx, data, servers, owner, allowApexCNAME, recognizeApex)
|
|
|
|
// 6. DNSSEC checks.
|
|
checkDNSSEC(ctx, data, servers, apex, owner)
|
|
|
|
// 7. Chain-level validations (loops, length, TTL, target resolvability).
|
|
validateChain(data, requireTarget)
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// resolveOwner derives the FQDN to check from the auto-filled options. The
|
|
// "service" option takes precedence (it carries a dns.CNAME whose owner is
|
|
// authoritative); otherwise we fall back to subdomain + domain_name.
|
|
func resolveOwner(opts sdk.CheckerOptions) (string, error) {
|
|
if svcMsg, ok := sdk.GetOption[serviceMessage](opts, "service"); ok && len(svcMsg.Service) > 0 {
|
|
var c cnameService
|
|
if err := json.Unmarshal(svcMsg.Service, &c); err == nil && c.Record != nil && c.Record.Hdr.Name != "" {
|
|
return lowerFQDN(c.Record.Hdr.Name), nil
|
|
}
|
|
}
|
|
|
|
parent, _ := sdk.GetOption[string](opts, "domain_name")
|
|
sub, _ := sdk.GetOption[string](opts, "subdomain")
|
|
if parent == "" {
|
|
return "", fmt.Errorf("missing 'domain_name' option")
|
|
}
|
|
parent = strings.TrimSuffix(parent, ".")
|
|
if sub == "" || sub == "@" {
|
|
return lowerFQDN(parent), nil
|
|
}
|
|
sub = strings.TrimSuffix(sub, ".")
|
|
return lowerFQDN(sub + "." + parent), nil
|
|
}
|
|
|
|
// chainCtx carries the mutable state of a chain walk.
|
|
type chainCtx struct {
|
|
data *AliasData
|
|
maxLen int
|
|
minTTL uint32
|
|
servers []string
|
|
apex string
|
|
seenOwners map[string]bool
|
|
recFallback string
|
|
followTarget bool
|
|
}
|
|
|
|
// walk follows CNAME/DNAME hops starting from name. It writes hops into
|
|
// data.Chain and may add findings.
|
|
func (c *chainCtx) walk(ctx context.Context, name string) {
|
|
current := lowerFQDN(name)
|
|
currentServers := c.servers
|
|
|
|
for i := 0; i <= c.maxLen+1; i++ {
|
|
if c.seenOwners[current] {
|
|
c.data.Findings = append(c.data.Findings, AliasFinding{
|
|
Code: "alias_loop",
|
|
Severity: SeverityCrit,
|
|
Message: fmt.Sprintf("chain loops back to %s", current),
|
|
Subject: current,
|
|
Hint: "Break the loop by pointing the last CNAME at an A/AAAA-bearing name.",
|
|
})
|
|
c.data.FinalTarget = current
|
|
return
|
|
}
|
|
c.seenOwners[current] = true
|
|
|
|
if i > c.maxLen {
|
|
c.data.Findings = append(c.data.Findings, AliasFinding{
|
|
Code: "alias_chain_too_long",
|
|
Severity: SeverityCrit,
|
|
Message: fmt.Sprintf("chain exceeds %d hops at %s; many resolvers will give up", c.maxLen, current),
|
|
Subject: current,
|
|
Hint: "Flatten intermediate CNAMEs so that the chain is at most a few hops long.",
|
|
})
|
|
c.data.FinalTarget = current
|
|
return
|
|
}
|
|
|
|
q := dns.Question{Name: current, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET}
|
|
r, server, err := c.queryFor(ctx, currentServers, q)
|
|
if err != nil {
|
|
c.data.Findings = append(c.data.Findings, AliasFinding{
|
|
Code: "alias_query_failed",
|
|
Severity: SeverityWarn,
|
|
Message: fmt.Sprintf("CNAME query for %s failed: %v", current, err),
|
|
Subject: current,
|
|
})
|
|
c.data.FinalTarget = current
|
|
return
|
|
}
|
|
|
|
if r.Rcode != dns.RcodeSuccess {
|
|
c.data.Rcode = rcodeText(r.Rcode)
|
|
c.data.Findings = append(c.data.Findings, AliasFinding{
|
|
Code: "alias_rcode",
|
|
Severity: SeverityCrit,
|
|
Message: fmt.Sprintf("server answered %s for %s", c.data.Rcode, current),
|
|
Subject: current,
|
|
Hint: "Ensure the zone publishes the expected record; NXDOMAIN/SERVFAIL mid-chain breaks the alias.",
|
|
})
|
|
c.data.FinalTarget = current
|
|
return
|
|
}
|
|
|
|
cname, synthesizedFromDNAME, ttl := extractCNAME(r, current)
|
|
if cname == "" {
|
|
// No CNAME at this name: terminal hop, resolve A/AAAA.
|
|
c.data.Chain = append(c.data.Chain, ChainHop{
|
|
Owner: current,
|
|
Kind: KindTarget,
|
|
Server: server,
|
|
})
|
|
c.data.FinalTarget = current
|
|
c.resolveFinal(ctx, current, currentServers)
|
|
return
|
|
}
|
|
|
|
target := lowerFQDN(cname)
|
|
kind := KindCNAME
|
|
if synthesizedFromDNAME {
|
|
kind = KindDNAME
|
|
}
|
|
c.data.Chain = append(c.data.Chain, ChainHop{
|
|
Owner: current,
|
|
Kind: kind,
|
|
Target: target,
|
|
TTL: ttl,
|
|
Server: server,
|
|
Synthesized: synthesizedFromDNAME,
|
|
})
|
|
if ttl < c.minTTL {
|
|
c.data.Findings = append(c.data.Findings, AliasFinding{
|
|
Code: "alias_low_ttl",
|
|
Severity: SeverityWarn,
|
|
Message: fmt.Sprintf("hop %s → %s has TTL %ds (< %d)", current, target, ttl, c.minTTL),
|
|
Subject: current,
|
|
Hint: "Raise the CNAME TTL to improve cache efficiency (5-15 minutes is a common floor).",
|
|
})
|
|
}
|
|
|
|
// Re-evaluate servers for the next hop: if target leaves the apex,
|
|
// we need its own authoritative servers. Out-of-zone targets are
|
|
// resolved via the system resolver (recursive path).
|
|
if isSubdomain(target, c.apex) {
|
|
currentServers = c.servers
|
|
} else {
|
|
zone, _, _ := findApex(ctx, target, c.recFallback)
|
|
ns, err := resolveZoneNSAddrs(ctx, zone)
|
|
if err != nil || len(ns) == 0 {
|
|
currentServers = []string{c.recFallback}
|
|
} else {
|
|
currentServers = ns
|
|
}
|
|
}
|
|
current = target
|
|
}
|
|
}
|
|
|
|
// queryFor sends q, retrying via the recursive resolver if the authoritative
|
|
// set is empty (useful for foreign targets).
|
|
func (c *chainCtx) queryFor(ctx context.Context, servers []string, q dns.Question) (*dns.Msg, string, error) {
|
|
if len(servers) == 0 {
|
|
r, err := recursiveExchange(ctx, c.recFallback, q)
|
|
return r, c.recFallback, err
|
|
}
|
|
return queryAtAuth(ctx, "", servers, q)
|
|
}
|
|
|
|
// extractCNAME returns the first CNAME target matched for owner, and reports
|
|
// whether it was synthesized from a DNAME present in the same response.
|
|
func extractCNAME(r *dns.Msg, owner string) (target string, fromDNAME bool, ttl uint32) {
|
|
for _, rr := range r.Answer {
|
|
if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(dns.Fqdn(c.Hdr.Name), dns.Fqdn(owner)) {
|
|
target = c.Target
|
|
ttl = c.Hdr.Ttl
|
|
break
|
|
}
|
|
}
|
|
if target == "" {
|
|
return "", false, 0
|
|
}
|
|
for _, rr := range r.Answer {
|
|
if _, ok := rr.(*dns.DNAME); ok {
|
|
fromDNAME = true
|
|
break
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// resolveFinal fetches A/AAAA of the final target and records them.
|
|
func (c *chainCtx) resolveFinal(ctx context.Context, name string, servers []string) {
|
|
var wg sync.WaitGroup
|
|
var finalA, finalAAAA []string
|
|
var rcode string
|
|
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
q := dns.Question{Name: dns.Fqdn(name), Qtype: dns.TypeA, Qclass: dns.ClassINET}
|
|
var r *dns.Msg
|
|
var err error
|
|
if len(servers) > 0 {
|
|
r, _, err = queryAtAuth(ctx, "", servers, q)
|
|
} else {
|
|
r, err = recursiveExchange(ctx, c.recFallback, q)
|
|
}
|
|
if err == nil && r != nil {
|
|
if r.Rcode != dns.RcodeSuccess {
|
|
rcode = rcodeText(r.Rcode)
|
|
}
|
|
for _, rr := range r.Answer {
|
|
if a, ok := rr.(*dns.A); ok {
|
|
finalA = append(finalA, a.A.String())
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
q := dns.Question{Name: dns.Fqdn(name), Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}
|
|
var r *dns.Msg
|
|
var err error
|
|
if len(servers) > 0 {
|
|
r, _, err = queryAtAuth(ctx, "", servers, q)
|
|
} else {
|
|
r, err = recursiveExchange(ctx, c.recFallback, q)
|
|
}
|
|
if err == nil && r != nil {
|
|
for _, rr := range r.Answer {
|
|
if aaaa, ok := rr.(*dns.AAAA); ok {
|
|
finalAAAA = append(finalAAAA, aaaa.AAAA.String())
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
wg.Wait()
|
|
|
|
c.data.FinalA = append(c.data.FinalA, finalA...)
|
|
c.data.FinalAAAA = append(c.data.FinalAAAA, finalAAAA...)
|
|
if rcode != "" {
|
|
c.data.Rcode = rcode
|
|
}
|
|
}
|
|
|
|
// collectDNAMEs queries every label from owner up to (but excluding) apex for
|
|
// a DNAME record, returning any substitutions found.
|
|
func collectDNAMEs(ctx context.Context, servers []string, owner, apex string) []ChainHop {
|
|
labels := dns.SplitDomainName(owner)
|
|
apexLabels := dns.SplitDomainName(apex)
|
|
stop := max(len(labels)-len(apexLabels), 0)
|
|
|
|
results := make([][]ChainHop, stop)
|
|
var wg sync.WaitGroup
|
|
wg.Add(stop)
|
|
for i := range stop {
|
|
go func() {
|
|
defer wg.Done()
|
|
name := dns.Fqdn(strings.Join(labels[i:], "."))
|
|
q := dns.Question{Name: name, Qtype: dns.TypeDNAME, Qclass: dns.ClassINET}
|
|
r, server, err := queryAtAuth(ctx, "", servers, q)
|
|
if err != nil || r == nil || r.Rcode != dns.RcodeSuccess {
|
|
return
|
|
}
|
|
for _, rr := range r.Answer {
|
|
if d, ok := rr.(*dns.DNAME); ok {
|
|
results[i] = append(results[i], ChainHop{
|
|
Owner: lowerFQDN(d.Hdr.Name),
|
|
Kind: KindDNAME,
|
|
Target: lowerFQDN(d.Target),
|
|
TTL: d.Hdr.Ttl,
|
|
Server: server,
|
|
})
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
var out []ChainHop
|
|
for _, hops := range results {
|
|
out = append(out, hops...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// checkApex verifies that a CNAME at apex does not break SOA/NS, and
|
|
// detects ALIAS/ANAME provider-side flattening.
|
|
func checkApex(ctx context.Context, data *AliasData, servers []string, apex string, allowApexCNAME, recognizeApex bool) {
|
|
// Collect A/AAAA at apex.
|
|
var hasA bool
|
|
var hasAMu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
|
go func() {
|
|
defer wg.Done()
|
|
q := dns.Question{Name: apex, Qtype: qt, Qclass: dns.ClassINET}
|
|
r, _, err := queryAtAuth(ctx, "", servers, q)
|
|
if err != nil || r == nil {
|
|
return
|
|
}
|
|
for _, rr := range r.Answer {
|
|
switch rr.(type) {
|
|
case *dns.A, *dns.AAAA:
|
|
hasAMu.Lock()
|
|
hasA = true
|
|
hasAMu.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
// CNAME at apex?
|
|
hasCNAME := false
|
|
for _, h := range data.Chain {
|
|
if h.Kind == KindCNAME && lowerFQDN(h.Owner) == lowerFQDN(apex) {
|
|
hasCNAME = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if hasCNAME {
|
|
sev := SeverityCrit
|
|
if allowApexCNAME {
|
|
sev = SeverityWarn
|
|
}
|
|
data.Findings = append(data.Findings, AliasFinding{
|
|
Code: "alias_cname_at_apex",
|
|
Severity: sev,
|
|
Message: fmt.Sprintf("CNAME at apex %s conflicts with the SOA/NS records a zone apex must carry (RFC 1912 §2.4)", apex),
|
|
Subject: apex,
|
|
Hint: "Use the provider's ALIAS/ANAME flattening, an HTTP redirect, or move content to a sub-label such as www.",
|
|
})
|
|
}
|
|
|
|
if hasA && !hasCNAME {
|
|
// A present at apex alongside SOA/NS, classic ALIAS/ANAME flattening.
|
|
data.ApexFlattening = true
|
|
if recognizeApex {
|
|
data.Findings = append(data.Findings, AliasFinding{
|
|
Code: "alias_apex_flattening",
|
|
Severity: SeverityInfo,
|
|
Message: fmt.Sprintf("apex %s serves A/AAAA directly (provider-side ALIAS/ANAME flattening)", apex),
|
|
Subject: apex,
|
|
Hint: "Keep the upstream target's TTL in mind: apex A/AAAA will only update as fast as the provider re-flattens.",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkCoexistence verifies that a CNAME at owner is the only record type
|
|
// present (RFC 1034 §3.6.2, RFC 2181 §10.1).
|
|
func checkCoexistence(ctx context.Context, data *AliasData, servers []string, owner string, allowApexCNAME, recognizeApex bool) {
|
|
hasCNAME := false
|
|
for _, h := range data.Chain {
|
|
if h.Kind == KindCNAME && lowerFQDN(h.Owner) == lowerFQDN(owner) {
|
|
hasCNAME = true
|
|
break
|
|
}
|
|
}
|
|
if !hasCNAME {
|
|
return
|
|
}
|
|
|
|
// Query a handful of common sibling types at owner.
|
|
siblings := []uint16{
|
|
dns.TypeA, dns.TypeAAAA, dns.TypeMX, dns.TypeTXT,
|
|
dns.TypeNS, dns.TypeSRV, dns.TypeCAA,
|
|
}
|
|
seen := map[string]uint32{}
|
|
var seenMu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
wg.Add(len(siblings))
|
|
for _, qt := range siblings {
|
|
go func() {
|
|
defer wg.Done()
|
|
q := dns.Question{Name: owner, Qtype: qt, Qclass: dns.ClassINET}
|
|
r, _, err := queryAtAuth(ctx, "", servers, q)
|
|
if err != nil || r == nil {
|
|
return
|
|
}
|
|
// A synthesized CNAME from DNAME will be present in Answer for any
|
|
// type; only count answers whose owner matches and whose type is qt.
|
|
for _, rr := range r.Answer {
|
|
if rr.Header().Rrtype != qt {
|
|
continue
|
|
}
|
|
if !strings.EqualFold(dns.Fqdn(rr.Header().Name), dns.Fqdn(owner)) {
|
|
continue
|
|
}
|
|
seenMu.Lock()
|
|
seen[dns.TypeToString[qt]] = rr.Header().Ttl
|
|
seenMu.Unlock()
|
|
break
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
// Apex with ALIAS/ANAME flattening is a known exception when requested.
|
|
isApex := lowerFQDN(owner) == lowerFQDN(data.Apex)
|
|
|
|
for t, ttl := range seen {
|
|
// A/AAAA at apex alongside a CNAME is impossible in a standard zone;
|
|
// a provider may still serve it through flattening. Still report it
|
|
// as critical, two different owners cannot legally exist.
|
|
if isApex && (t == "A" || t == "AAAA") && recognizeApex && data.ApexFlattening {
|
|
continue
|
|
}
|
|
sev := SeverityCrit
|
|
if isApex && allowApexCNAME {
|
|
sev = SeverityWarn
|
|
}
|
|
data.Coexisting = append(data.Coexisting, CoexistingRRset{Type: t, TTL: ttl})
|
|
data.Findings = append(data.Findings, AliasFinding{
|
|
Code: "alias_coexisting_rrset",
|
|
Severity: sev,
|
|
Message: fmt.Sprintf("%s and CNAME both exist at %s (RFC 1034 §3.6.2 / RFC 2181 §10.1)", t, owner),
|
|
Subject: owner,
|
|
Hint: "Remove the sibling record or move it under a different label; a name cannot simultaneously carry a CNAME and other data.",
|
|
})
|
|
}
|
|
}
|
|
|
|
// checkDNSSEC verifies that, if the zone is signed, the CNAME at owner is
|
|
// properly signed (RRSIG covers it).
|
|
func checkDNSSEC(ctx context.Context, data *AliasData, servers []string, apex, owner string) {
|
|
qk := dns.Question{Name: apex, Qtype: dns.TypeDNSKEY, Qclass: dns.ClassINET}
|
|
r, _, err := queryAtAuth(ctx, "tcp", servers, qk)
|
|
if err != nil || r == nil || r.Rcode != dns.RcodeSuccess {
|
|
return
|
|
}
|
|
signed := false
|
|
for _, rr := range r.Answer {
|
|
if _, ok := rr.(*dns.DNSKEY); ok {
|
|
signed = true
|
|
break
|
|
}
|
|
}
|
|
data.ZoneSigned = signed
|
|
if !signed {
|
|
return
|
|
}
|
|
|
|
// Query CNAME with DO; check for an RRSIG covering it.
|
|
q := dns.Question{Name: owner, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET}
|
|
r, _, err = queryAtAuth(ctx, "tcp", servers, q)
|
|
if err != nil || r == nil {
|
|
return
|
|
}
|
|
sawCNAME := false
|
|
sawSig := false
|
|
for _, rr := range r.Answer {
|
|
switch v := rr.(type) {
|
|
case *dns.CNAME:
|
|
sawCNAME = true
|
|
case *dns.RRSIG:
|
|
if v.TypeCovered == dns.TypeCNAME {
|
|
sawSig = true
|
|
}
|
|
}
|
|
}
|
|
if sawCNAME {
|
|
data.CNAMESigned = sawSig
|
|
if !sawSig {
|
|
data.Findings = append(data.Findings, AliasFinding{
|
|
Code: "alias_cname_not_signed",
|
|
Severity: SeverityCrit,
|
|
Message: fmt.Sprintf("zone %s is DNSSEC-signed but CNAME at %s has no RRSIG", apex, owner),
|
|
Subject: owner,
|
|
Hint: "Re-sign the zone or verify your signer covers the alias RRset; unsigned answers in a signed zone SERVFAIL at validating resolvers.",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// validateChain enforces global chain invariants.
|
|
func validateChain(data *AliasData, requireTarget bool) {
|
|
if len(data.Chain) == 0 {
|
|
return
|
|
}
|
|
|
|
// Target resolvability.
|
|
if last := data.Chain[len(data.Chain)-1]; last.Kind == KindTarget {
|
|
if len(data.FinalA) == 0 && len(data.FinalAAAA) == 0 {
|
|
sev := SeverityWarn
|
|
if requireTarget {
|
|
sev = SeverityCrit
|
|
}
|
|
rcode := data.Rcode
|
|
if rcode == "" {
|
|
rcode = "no A/AAAA"
|
|
}
|
|
data.Findings = append(data.Findings, AliasFinding{
|
|
Code: "alias_target_unresolvable",
|
|
Severity: sev,
|
|
Message: fmt.Sprintf("final target %s does not resolve to an address (%s)", last.Owner, rcode),
|
|
Subject: last.Owner,
|
|
Hint: "Point the alias at a name that publishes at least one A or AAAA record, or fix the upstream zone.",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Multiple CNAME/DNAME kinds with same owner (malformed zone).
|
|
seen := map[string]int{}
|
|
for _, h := range data.Chain {
|
|
if h.Kind == KindCNAME || h.Kind == KindDNAME {
|
|
seen[h.Owner]++
|
|
}
|
|
}
|
|
for o, n := range seen {
|
|
if n > 1 {
|
|
data.Findings = append(data.Findings, AliasFinding{
|
|
Code: "alias_multiple_records",
|
|
Severity: SeverityCrit,
|
|
Message: fmt.Sprintf("%s carries %d CNAME/DNAME records in the chain; only one is legal per owner", o, n),
|
|
Subject: o,
|
|
Hint: "Keep a single CNAME per name; remove duplicates at the authoritative zone.",
|
|
})
|
|
}
|
|
}
|
|
}
|