From 864a846b855084619a6a4f75292367a8ca47bef0 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Fri, 4 Sep 2026 12:25:29 +0200 Subject: [PATCH] Match DNS zones case-insensitively and replace only the zone suffix --- client/anonymize/anonymize.go | 29 +++++++++++------ client/anonymize/anonymize_test.go | 51 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go index a69663d00..089b1924f 100644 --- a/client/anonymize/anonymize.go +++ b/client/anonymize/anonymize.go @@ -349,12 +349,21 @@ func (a *Anonymizer) anonymizeDomain(domain string, newZone bool) string { a.mapDomain(baseForLookup, anonymized) } - result := strings.Replace(baseDomain, baseForLookup, anonymized, 1) - if a.level >= LevelStrict && baseDomain != baseForLookup { - prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup) - result = a.anonymizeLabels(prefix, "host") + "." + anonymized - // The full mapping feeds AnonymizeString so seeded FQDNs are caught - // in log lines as a whole, labels included. + // baseForLookup is the tail of baseDomain up to case, so cut by length: a + // substring match would rewrite an earlier occurrence and leave the real + // zone in place, as with a name whose own label repeats the zone. + prefix := baseDomain[:len(baseDomain)-len(baseForLookup)] + + result := prefix + anonymized + if a.level >= LevelStrict && prefix != "" { + result = a.anonymizeLabels(strings.TrimSuffix(prefix, "."), "host") + "." + anonymized + } + + // A dotless zone is held out of the substring pass, so a name under it + // needs its own mapping to be replaced where a log line mentions it as + // free text rather than as a DNS name. Strict level records every name + // anyway, labels included. + if prefix != "" && (a.level >= LevelStrict || !strings.Contains(baseForLookup, ".")) { a.mapDomain(baseDomain, result) } return withTrailingDot(result, hasDot) @@ -368,7 +377,9 @@ func (a *Anonymizer) mapDomain(key, anonymized string) { } // baseForLookup returns the key under which baseDomain's anonymized base is -// stored, or empty to leave baseDomain alone. +// stored, or empty to leave baseDomain alone. The key is lower case, since DNS +// labels compare case-insensitively, and is always the tail of baseDomain so a +// caller can cut it off by length. // // The key is normally the last two labels, so every name under a domain shares // one anonymized base. A single-label zone is its own key: names under it then @@ -377,7 +388,7 @@ func (a *Anonymizer) mapDomain(key, anonymized string) { // needs newZone to become one, since free text and address forms also reach // here and must not be rewritten on a guess. func (a *Anonymizer) baseForLookup(baseDomain string, newZone bool) string { - parts := strings.Split(baseDomain, ".") + parts := strings.Split(strings.ToLower(baseDomain), ".") last := parts[len(parts)-1] if _, known := a.domainAnonymizer[last]; known { @@ -387,7 +398,7 @@ func (a *Anonymizer) baseForLookup(baseDomain string, newZone bool) string { return parts[len(parts)-2] + "." + last } - if !newZone || last == "" || strings.EqualFold(last, "localhost") { + if !newZone || last == "" || last == "localhost" { return "" } return last diff --git a/client/anonymize/anonymize_test.go b/client/anonymize/anonymize_test.go index b4d75c919..56eab9bf0 100644 --- a/client/anonymize/anonymize_test.go +++ b/client/anonymize/anonymize_test.go @@ -388,6 +388,57 @@ func TestAnonymizeDomain_SingleLabelZone(t *testing.T) { } } +// TestAnonymizeDomainName_ZoneLabelRepeated covers a name whose own label +// repeats its zone. Cutting the key by a substring match would rewrite the +// leading occurrence and leave the real zone in the output. +func TestAnonymizeDomainName_ZoneLabelRepeated(t *testing.T) { + for _, level := range []anonymize.Level{anonymize.LevelDefault, anonymize.LevelStrict} { + t.Run(level.String(), func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(level) + + zone := anonymizer.AnonymizeDomainName("corp.") + record := anonymizer.AnonymizeDomainName("corp.corp.") + + // The zone is the tail, so it is the tail that must be replaced. + // The leading label is a host label, which the default level + // preserves like any other and strict level renumbers. + assert.True(t, strings.HasSuffix(record, "."+zone), "the record should sit under the anonymized zone, got %q for zone %q", record, zone) + assert.NotContains(t, strings.TrimSuffix(record, "."+zone), ".", "only the host label should remain in front of the zone, got %q", record) + if level >= anonymize.LevelStrict { + assert.NotContains(t, record, "corp", "strict level should also replace the host label") + } + }) + } +} + +// TestAnonymizeDomainName_ZoneCaseInsensitive covers a zone and a record that +// disagree on case. DNS labels compare case-insensitively, so both must resolve +// to one anonymized base. +func TestAnonymizeDomainName_ZoneCaseInsensitive(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + zone := anonymizer.AnonymizeDomainName("Corp.") + record := anonymizer.AnonymizeDomainName("host.corp.") + + assert.True(t, strings.HasSuffix(record, "."+zone), "a differently cased record should share the zone's base, got %q for zone %q", record, zone) + assert.NotContains(t, strings.ToLower(record), "corp", "the original zone name should not survive") +} + +// TestAnonymizeString_NameUnderSingleLabelZoneInLogText covers a name under a +// dotless zone appearing as free text. The zone itself is held out of the +// substring pass, so the name needs its own mapping to be replaced. +func TestAnonymizeString_NameUnderSingleLabelZoneInLogText(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + anonymizer.AnonymizeDomainName("corp") + record := anonymizer.AnonymizeDomainName("host.corp") + + result := anonymizer.AnonymizeString("registering handler for [host.corp]") + assert.Equal(t, "registering handler for ["+record+"]", result, + "a name under a dotless zone should be replaced in free log text") +} + // TestAnonymizeString_KnownZoneInLogLine covers the bundle order: the DNS // config establishes the zone, then log lines naming it are anonymized through // the domain= key even though the name carries no dot of its own.