mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 17:59:06 +02:00
Anonymize single-label DNS zones in debug bundles
This commit is contained in:
@@ -75,8 +75,8 @@ var (
|
||||
type Anonymizer struct {
|
||||
ipAnonymizer map[netip.Addr]netip.Addr
|
||||
domainAnonymizer map[string]string
|
||||
// domainOrder caches the keys of domainAnonymizer sorted longest-first
|
||||
// for AnonymizeString; it is rebuilt when the map gains entries.
|
||||
// domainOrder caches the keys of domainAnonymizer that AnonymizeString
|
||||
// replaces, sorted longest-first. mapDomain drops it on every write.
|
||||
domainOrder []string
|
||||
labelAnonymizer map[string]string
|
||||
labelAnonymized map[string]struct{}
|
||||
@@ -294,7 +294,27 @@ func (a *Anonymizer) AnonymizeIPString(ip string) string {
|
||||
return a.AnonymizeIP(addr).String()
|
||||
}
|
||||
|
||||
// AnonymizeDomain replaces the base of domain with a stable anonymized one,
|
||||
// keeping the labels in front of it (default level) or numbering them (strict
|
||||
// level). It accepts free text that may not be a domain at all, so a name with
|
||||
// no dot is anonymized only once AnonymizeDomainName has established it as a
|
||||
// zone; otherwise it is returned unchanged.
|
||||
func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
return a.anonymizeDomain(domain, false)
|
||||
}
|
||||
|
||||
// AnonymizeDomainName anonymizes a value the caller knows to be a DNS name,
|
||||
// such as a configured zone, match domain, or record name. A single-label name,
|
||||
// which a custom zone can legitimately be, is anonymized and remembered as a
|
||||
// zone, so names under it share its anonymized base and later mentions of it
|
||||
// are anonymized as well.
|
||||
func (a *Anonymizer) AnonymizeDomainName(domain string) string {
|
||||
return a.anonymizeDomain(domain, true)
|
||||
}
|
||||
|
||||
// anonymizeDomain implements both entry points. newZone reports whether the
|
||||
// caller may establish a single-label name as a zone.
|
||||
func (a *Anonymizer) anonymizeDomain(domain string, newZone bool) string {
|
||||
baseDomain := domain
|
||||
hasDot := strings.HasSuffix(domain, ".")
|
||||
if hasDot {
|
||||
@@ -318,31 +338,61 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot)
|
||||
}
|
||||
|
||||
parts := strings.Split(baseDomain, ".")
|
||||
if len(parts) < 2 {
|
||||
baseForLookup := a.baseForLookup(baseDomain, newZone)
|
||||
if baseForLookup == "" {
|
||||
return domain
|
||||
}
|
||||
|
||||
baseForLookup := parts[len(parts)-2] + "." + parts[len(parts)-1]
|
||||
|
||||
anonymized, ok := a.domainAnonymizer[baseForLookup]
|
||||
if !ok {
|
||||
anonymizedBase := "anon-" + generateRandomString(5) + anonTLD
|
||||
a.domainAnonymizer[baseForLookup] = anonymizedBase
|
||||
anonymized = anonymizedBase
|
||||
anonymized, mapped := a.domainAnonymizer[baseForLookup]
|
||||
if !mapped {
|
||||
anonymized = "anon-" + generateRandomString(5) + anonTLD
|
||||
a.mapDomain(baseForLookup, anonymized)
|
||||
}
|
||||
|
||||
result := strings.Replace(baseDomain, baseForLookup, anonymized, 1)
|
||||
if a.level >= LevelStrict && len(parts) > 2 {
|
||||
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.
|
||||
a.domainAnonymizer[baseDomain] = result
|
||||
a.mapDomain(baseDomain, result)
|
||||
}
|
||||
return withTrailingDot(result, hasDot)
|
||||
}
|
||||
|
||||
// mapDomain records an anonymized form for a domain key and invalidates the
|
||||
// replacement order AnonymizeString caches.
|
||||
func (a *Anonymizer) mapDomain(key, anonymized string) {
|
||||
a.domainAnonymizer[key] = anonymized
|
||||
a.domainOrder = nil
|
||||
}
|
||||
|
||||
// baseForLookup returns the key under which baseDomain's anonymized base is
|
||||
// stored, or empty to leave baseDomain alone.
|
||||
//
|
||||
// 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
|
||||
// key on the zone rather than on their own last two labels, which keeps the
|
||||
// zone relationship visible. A single-label name that is not yet a known zone
|
||||
// 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, ".")
|
||||
last := parts[len(parts)-1]
|
||||
|
||||
if _, known := a.domainAnonymizer[last]; known {
|
||||
return last
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
return parts[len(parts)-2] + "." + last
|
||||
}
|
||||
|
||||
if !newZone || last == "" || strings.EqualFold(last, "localhost") {
|
||||
return ""
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// anonymizePeerName replaces the labels in front of a protected suffix with
|
||||
// numbered peer placeholders, keeping the suffix, and records the full
|
||||
// mapping for string replacement in logs. The numbering keeps a peer
|
||||
@@ -351,7 +401,7 @@ func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string {
|
||||
prefix := strings.TrimSuffix(baseDomain, "."+suffix)
|
||||
result := a.anonymizeLabels(prefix, "peer") + "." + suffix
|
||||
if result != baseDomain {
|
||||
a.domainAnonymizer[baseDomain] = result
|
||||
a.mapDomain(baseDomain, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -438,25 +488,32 @@ func (a *Anonymizer) AnonymizeString(str string) string {
|
||||
return restoreZones(str)
|
||||
}
|
||||
|
||||
// sortedDomains returns the domain mappings longest-first, so a full-FQDN
|
||||
// mapping (strict level) is applied before the base-domain mapping it
|
||||
// contains. The order is rebuilt only when domainAnonymizer has grown.
|
||||
// sortedDomains returns the domain mappings eligible for plain substring
|
||||
// replacement, longest-first, so a full-FQDN mapping (strict level) is applied
|
||||
// before the base-domain mapping it contains. A single-label name is held out:
|
||||
// it carries no dot to anchor it, so replacing it by substring would also
|
||||
// rewrite any longer word that happens to contain it. Such a name still gets
|
||||
// anonymized wherever it is handled as a domain rather than as free text.
|
||||
// The order is rebuilt only after mapDomain has added an entry.
|
||||
func (a *Anonymizer) sortedDomains() []string {
|
||||
if len(a.domainOrder) == len(a.domainAnonymizer) {
|
||||
if a.domainOrder != nil {
|
||||
return a.domainOrder
|
||||
}
|
||||
|
||||
a.domainOrder = a.domainOrder[:0]
|
||||
order := make([]string, 0, len(a.domainAnonymizer))
|
||||
for domain := range a.domainAnonymizer {
|
||||
a.domainOrder = append(a.domainOrder, domain)
|
||||
if strings.Contains(domain, ".") {
|
||||
order = append(order, domain)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(a.domainOrder, func(x, y string) int {
|
||||
slices.SortFunc(order, func(x, y string) int {
|
||||
if d := len(y) - len(x); d != 0 {
|
||||
return d
|
||||
}
|
||||
return strings.Compare(x, y)
|
||||
})
|
||||
return a.domainOrder
|
||||
a.domainOrder = order
|
||||
return order
|
||||
}
|
||||
|
||||
// anonymizeMACsInString replaces MAC addresses matched by re, skipping
|
||||
|
||||
@@ -313,6 +313,122 @@ func TestAnonymizeDomain_StrictLevel(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestAnonymizeDomain_WildcardSharesBase covers both orders because the base
|
||||
// mapping is created by whichever form is seen first.
|
||||
func TestAnonymizeDomain_WildcardSharesBase(t *testing.T) {
|
||||
for _, level := range []anonymize.Level{anonymize.LevelDefault, anonymize.LevelStrict} {
|
||||
t.Run(level.String(), func(t *testing.T) {
|
||||
t.Run("wildcard first", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(level)
|
||||
|
||||
wildcard := anonymizer.AnonymizeDomain("*.example.org")
|
||||
bare := anonymizer.AnonymizeDomain("example.org")
|
||||
assert.Equal(t, "*."+bare, wildcard, "the wildcard form should be the bare anon domain behind a kept *.")
|
||||
assert.NotContains(t, wildcard, "example", "the original base should not survive")
|
||||
})
|
||||
|
||||
t.Run("bare first", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(level)
|
||||
|
||||
bare := anonymizer.AnonymizeDomain("example.org")
|
||||
wildcard := anonymizer.AnonymizeDomain("*.example.org")
|
||||
assert.Equal(t, "*."+bare, wildcard, "the wildcard form should be the bare anon domain behind a kept *.")
|
||||
})
|
||||
|
||||
t.Run("in a log line", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(level)
|
||||
|
||||
bare := anonymizer.AnonymizeDomain("example.org")
|
||||
line := `{"Domains": ["*.example.org", "example.org"]}`
|
||||
result := anonymizer.AnonymizeString(line)
|
||||
assert.Equal(t, `{"Domains": ["*.`+bare+`", "`+bare+`"]}`, result,
|
||||
"both forms in a log line should share one anon base and keep the wildcard prefix")
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymizeDomain_SingleLabelZone covers a custom zone whose name is a
|
||||
// single label, as a bundle's DNS config can carry. Such a name has no
|
||||
// two-label base to key on, so it used to pass through in the clear.
|
||||
func TestAnonymizeDomain_SingleLabelZone(t *testing.T) {
|
||||
for _, level := range []anonymize.Level{anonymize.LevelDefault, anonymize.LevelStrict} {
|
||||
t.Run(level.String(), func(t *testing.T) {
|
||||
t.Run("zone name is anonymized", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(level)
|
||||
|
||||
zone := anonymizer.AnonymizeDomainName("corp.")
|
||||
assert.Regexp(t, `^anon-[a-zA-Z0-9]+\.domain\.$`, zone, "a single-label zone should be anonymized and keep its trailing dot")
|
||||
})
|
||||
|
||||
t.Run("names under the zone share its base", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(level)
|
||||
|
||||
// The bundle generator anonymizes the zone before its records.
|
||||
zone := anonymizer.AnonymizeDomainName("corp")
|
||||
record := anonymizer.AnonymizeDomainName("host.corp")
|
||||
assert.True(t, strings.HasSuffix(record, "."+zone), "a record under the zone should keep the zone's anon base, got %q for zone %q", record, zone)
|
||||
assert.NotContains(t, record, "corp", "the original zone name should not survive")
|
||||
})
|
||||
|
||||
t.Run("consistent regardless of order", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(level)
|
||||
|
||||
first := anonymizer.AnonymizeDomainName("corp")
|
||||
second := anonymizer.AnonymizeDomainName("corp")
|
||||
assert.Equal(t, first, second, "the same zone should map consistently")
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestAnonymizeString_KnownZoneInLogLine(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
zone := anonymizer.AnonymizeDomainName("corp.")
|
||||
line := "adding handler pattern: domain=corp. original: domain=corp. priority=75"
|
||||
|
||||
result := anonymizer.AnonymizeString(line)
|
||||
assert.Equal(t, "adding handler pattern: domain="+zone+" original: domain="+zone+" priority=75", result,
|
||||
"a log line naming a known zone should carry the zone's anon domain")
|
||||
assert.NotContains(t, result, "corp", "the original zone name should not survive")
|
||||
}
|
||||
|
||||
// TestAnonymizeDomain_UnknownSingleLabelUntouched pins that free text reaching
|
||||
// AnonymizeDomain is left alone. Values that are not domains at all (an address,
|
||||
// an internal placeholder) arrive here, so a bare label is only anonymized once
|
||||
// something that knows it is a DNS name has established it as a zone.
|
||||
func TestAnonymizeDomain_UnknownSingleLabelUntouched(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
assert.Equal(t, "corp", anonymizer.AnonymizeDomain("corp"), "an unknown bare label should pass through")
|
||||
assert.Equal(t, "localhost", anonymizer.AnonymizeDomainName("localhost"), "localhost identifies nothing and should pass through")
|
||||
assert.Equal(t, "2001:db8:ffff::", anonymizer.AnonymizeDomain("2001:db8:ffff::"), "an address form should pass through")
|
||||
}
|
||||
|
||||
// TestAnonymizeString_SingleLabelNotSubstringReplaced pins that a single-label
|
||||
// mapping is never applied as a bare substring: it has no dot to anchor it, so
|
||||
// doing so would corrupt any longer word containing it.
|
||||
func TestAnonymizeString_SingleLabelNotSubstringReplaced(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
anonymizer.AnonymizeDomainName("corp")
|
||||
anonBase := anonymizer.AnonymizeDomainName("corpsite.example")
|
||||
|
||||
result := anonymizer.AnonymizeString("reaching corpsite.example over corporation")
|
||||
assert.Equal(t, "reaching "+anonBase+" over corporation", result,
|
||||
"the single-label mapping should not rewrite longer words that contain it")
|
||||
}
|
||||
|
||||
func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
|
||||
@@ -1551,7 +1551,7 @@ func anonymizeRoute(route *mgmProto.Route, anonymizer *anonymize.Anonymizer) {
|
||||
}
|
||||
|
||||
for i, domain := range route.Domains {
|
||||
route.Domains[i] = anonymizer.AnonymizeDomain(domain)
|
||||
route.Domains[i] = anonymizer.AnonymizeDomainName(domain)
|
||||
}
|
||||
|
||||
route.NetID = anonymizer.AnonymizeString(route.NetID)
|
||||
@@ -1583,20 +1583,21 @@ func anonymizeBundleGenerators(servers []*mgmProto.NameServer, anonymizer *anony
|
||||
|
||||
func anonymizeDomains(domains []string, anonymizer *anonymize.Anonymizer) {
|
||||
for i, domain := range domains {
|
||||
domains[i] = anonymizer.AnonymizeDomain(domain)
|
||||
domains[i] = anonymizer.AnonymizeDomainName(domain)
|
||||
}
|
||||
}
|
||||
|
||||
func anonymizeCustomZones(zones []*mgmProto.CustomZone, anonymizer *anonymize.Anonymizer) {
|
||||
for _, zone := range zones {
|
||||
zone.Domain = anonymizer.AnonymizeDomain(zone.Domain)
|
||||
// The zone goes first: its records are then keyed on it.
|
||||
zone.Domain = anonymizer.AnonymizeDomainName(zone.Domain)
|
||||
anonymizeRecords(zone.Records, anonymizer)
|
||||
}
|
||||
}
|
||||
|
||||
func anonymizeRecords(records []*mgmProto.SimpleRecord, anonymizer *anonymize.Anonymizer) {
|
||||
for _, record := range records {
|
||||
record.Name = anonymizer.AnonymizeDomain(record.Name)
|
||||
record.Name = anonymizer.AnonymizeDomainName(record.Name)
|
||||
anonymizeRData(record, anonymizer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,6 +447,52 @@ func TestAnonymizeNetworkMap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymizeNetworkMap_SingleLabelZone covers the shape a real bundle
|
||||
// carried: a custom zone named by a single label, alongside a nameserver group
|
||||
// whose match domain is an ordinary two-label domain. The single-label zone
|
||||
// used to pass through in the clear while its own records were anonymized.
|
||||
func TestAnonymizeNetworkMap_SingleLabelZone(t *testing.T) {
|
||||
networkMap := &mgmProto.NetworkMap{
|
||||
DNSConfig: &mgmProto.DNSConfig{
|
||||
NameServerGroups: []*mgmProto.NameServerGroup{
|
||||
{
|
||||
NameServers: []*mgmProto.NameServer{{IP: "203.0.113.53"}},
|
||||
Domains: []string{"example.net"},
|
||||
},
|
||||
},
|
||||
CustomZones: []*mgmProto.CustomZone{
|
||||
{
|
||||
Domain: "corp.",
|
||||
Records: []*mgmProto.SimpleRecord{
|
||||
{Name: "test.corp.", Type: 1, RData: "203.0.113.10"},
|
||||
{Name: "app.corp.", Type: 1, RData: "203.0.113.10"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Routes: []*mgmProto.Route{
|
||||
{Network: "203.0.113.0/24", Domains: []string{"corp"}, NetID: "net-1"},
|
||||
},
|
||||
}
|
||||
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
require.NoError(t, anonymizeNetworkMap(networkMap, anonymizer), "anonymize the network map")
|
||||
|
||||
zone := networkMap.DNSConfig.CustomZones[0]
|
||||
assert.NotContains(t, zone.Domain, "corp", "the single-label zone name should not survive")
|
||||
assert.Regexp(t, `^anon-[a-zA-Z0-9]+\.domain\.$`, zone.Domain, "the zone should be replaced by an anon domain, trailing dot kept")
|
||||
|
||||
for _, record := range zone.Records {
|
||||
assert.NotContains(t, record.Name, "corp", "a record name should not carry the original zone")
|
||||
assert.True(t, strings.HasSuffix(record.Name, "."+zone.Domain),
|
||||
"record %q should sit under the anonymized zone %q", record.Name, zone.Domain)
|
||||
}
|
||||
|
||||
assert.NotContains(t, networkMap.DNSConfig.NameServerGroups[0].Domains[0], "example",
|
||||
"the nameserver match domain should not survive")
|
||||
assert.NotContains(t, networkMap.Routes[0].Domains[0], "corp", "a route domain should not survive")
|
||||
}
|
||||
|
||||
func TestIsSensitiveEnvVar(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
|
||||
@@ -356,7 +356,7 @@ func (s *DefaultServer) RegisterHandler(domains domain.List, handler dns.Handler
|
||||
}
|
||||
|
||||
func (s *DefaultServer) registerHandler(domains []string, handler dns.Handler, priority int) {
|
||||
log.Debugf("registering handler %s with priority %d for %v", handler, priority, domains)
|
||||
log.Debugf("registering handler %s with priority %d for %s", handler, priority, joinDomainsForLog(domains))
|
||||
|
||||
for _, domain := range domains {
|
||||
if domain == "" {
|
||||
@@ -417,7 +417,7 @@ func (s *DefaultServer) CancelBatch() {
|
||||
}
|
||||
|
||||
func (s *DefaultServer) deregisterHandler(domains []string, priority int) {
|
||||
log.Debugf("deregistering handler with priority %d for %v", priority, domains)
|
||||
log.Debugf("deregistering handler with priority %d for %s", priority, joinDomainsForLog(domains))
|
||||
|
||||
for _, domain := range domains {
|
||||
if domain == "" {
|
||||
@@ -1362,6 +1362,16 @@ func joinAddrPorts(servers []netip.AddrPort) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// joinDomainsForLog renders domains as domain=<name> tokens, the form the
|
||||
// debug bundle recognizes as a DNS name when it anonymizes a log line.
|
||||
func joinDomainsForLog(domains []string) string {
|
||||
parts := make([]string, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
parts = append(parts, "domain="+d)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// generateGroupKey returns a stable identity for an NS group so health
|
||||
// state (everHealthy / warningActive) survives reorderings in the
|
||||
// configured nameserver or domain lists.
|
||||
|
||||
@@ -1023,7 +1023,7 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
|
||||
for i, nsGroup := range overview.NSServerGroups {
|
||||
for j, domain := range nsGroup.Domains {
|
||||
overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomain(domain)
|
||||
overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomainName(domain)
|
||||
}
|
||||
for j, ns := range nsGroup.Servers {
|
||||
host, port, err := net.SplitHostPort(ns)
|
||||
|
||||
Reference in New Issue
Block a user