From 81e53f87553b84e5b602d93162eb73f50d7ede6a Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Tue, 22 Sep 2026 16:20:47 +0200 Subject: [PATCH] fix panic: cached NXDOMAIN returned an empty CNAME chain as success ResolveCNAME/ResolveIPs cached negative results as an empty Result but the cache-hit path returned (empty, true), so enrichHost did chain[1:] on a zero-length slice and the process panicked (exit 2). Negative cache hits now return false, plus a defensive length guard in enrichHost and a regression test. --- internal/dns/resolver.go | 8 ++++++++ internal/dns/resolver_test.go | 14 ++++++++++++++ internal/pipeline/pipeline.go | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/internal/dns/resolver.go b/internal/dns/resolver.go index a70d393..390a3db 100644 --- a/internal/dns/resolver.go +++ b/internal/dns/resolver.go @@ -100,6 +100,11 @@ func (e *Enricher) ResolveCNAME(ctx context.Context, host string) ([]string, boo key := "c:" + host if e.Cache != nil { if res, ok := e.Cache.Get(key); ok { + if len(res.Chain) == 0 { + // Negatively cached: remember the failure, do not report a + // (empty) successful chain to callers. + return nil, false + } if e.Metrics != nil { e.Metrics.DNSCacheHits.Add(1) } @@ -135,6 +140,9 @@ func (e *Enricher) ResolveIPs(ctx context.Context, host string) ([]net.IP, bool) key := "ip:" + host if e.Cache != nil { if res, ok := e.Cache.Get(key); ok { + if len(res.IPs) == 0 { + return nil, false // negatively cached + } if e.Metrics != nil { e.Metrics.DNSCacheHits.Add(1) } diff --git a/internal/dns/resolver_test.go b/internal/dns/resolver_test.go index 592d9d6..94b8d1a 100644 --- a/internal/dns/resolver_test.go +++ b/internal/dns/resolver_test.go @@ -117,6 +117,20 @@ func (ctxResolver) LookupIP(ctx context.Context, host string) ([]net.IP, error) return nil, ctx.Err() } +func TestResolveCNAMENegativeCacheDoesNotReturnChain(t *testing.T) { + r := &fakeResolver{err: &net.DNSError{Err: "no such host", IsNotFound: true}} + e := &Enricher{Resolver: r, Cache: NewTTLCache[Result](16, 2), PositiveTTL: time.Minute, NegativeTTL: time.Minute, Timeout: time.Second, MaxDepth: 8} + if _, ok := e.ResolveCNAME(context.Background(), "missing.example.com"); ok { + t.Fatal("first NXDOMAIN lookup must fail") + } + // The negative entry must not come back as a successful empty chain: an + // empty chain with ok=true caused a slice panic in pipeline.enrichHost. + chain, ok := e.ResolveCNAME(context.Background(), "missing.example.com") + if ok || len(chain) != 0 { + t.Fatalf("cached NXDOMAIN returned chain=%v ok=%v, want empty/false", chain, ok) + } +} + func TestResolveContextCancellation(t *testing.T) { e := &Enricher{Resolver: ctxResolver{}, Cache: NewTTLCache[Result](16, 2), PositiveTTL: time.Minute, NegativeTTL: time.Minute, MaxDepth: 8} ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index bb1de7c..5ae594f 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -266,7 +266,7 @@ func (p *Pipeline) enrichHost(ctx context.Context, host string) { // CNAME-only during enrichment: this is the high-volume path and A/AAAA is // not needed to classify the target. IPs are resolved only for matches. chain, ok := p.cfg.Enricher.ResolveCNAME(ctx, host) - if !ok { + if !ok || len(chain) < 2 { return } for _, target := range chain[1:] {