[client] Add strict anonymization level and MAC anonymization to debug bundles (#7102)

This commit is contained in:
Viktor Liu
2026-08-10 11:27:20 +02:00
committed by GitHub
parent f65f7b347e
commit 5584f8ef0a
20 changed files with 2068 additions and 1160 deletions
+380 -30
View File
@@ -2,6 +2,7 @@ package anonymize
import (
"crypto/rand"
"encoding/base64"
"fmt"
"math/big"
"net"
@@ -15,13 +16,88 @@ import (
const anonTLD = ".domain"
// Level selects how much the anonymizer redacts. Levels are ordered: a higher
// level redacts strictly more. On the wire (protos, flags) levels travel as
// their string form.
type Level int
const (
// LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC
// addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are
// preserved so support can reason about the real topology.
LevelDefault Level = iota
// LevelStrict additionally anonymizes internal IP ranges, peer names, and
// WireGuard public keys.
LevelStrict
)
// LevelDefaultString and LevelStrictString are the wire forms of the levels,
// for boundaries that pass levels as strings (flags, protos, mobile bindings).
const (
LevelDefaultString = "default"
LevelStrictString = "strict"
)
// ParseLevel maps s to a Level. Empty means LevelDefault; anything
// unrecognized maps to LevelStrict so an unknown request never yields less
// anonymization than intended.
func ParseLevel(s string) Level {
switch strings.ToLower(s) {
case "", LevelDefaultString:
return LevelDefault
default:
return LevelStrict
}
}
// String returns the wire form of the level: "default" or "strict".
func (l Level) String() string {
if l >= LevelStrict {
return LevelStrictString
}
return LevelDefaultString
}
// protectedDomains are NetBird-operated suffixes that stay recognizable in an
// anonymized bundle. At LevelStrict the labels in front of them (the peer
// name) are still replaced, except under netbird.io, which only hosts
// NetBird infrastructure (api, signal, flow), never peer names.
var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"}
const infraDomain = "netbird.io"
var (
macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`)
macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`)
wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`)
)
type Anonymizer struct {
ipAnonymizer map[netip.Addr]netip.Addr
domainAnonymizer map[string]string
currentAnonIPv4 netip.Addr
currentAnonIPv6 netip.Addr
startAnonIPv4 netip.Addr
startAnonIPv6 netip.Addr
// domainOrder caches the keys of domainAnonymizer sorted longest-first
// for AnonymizeString; it is rebuilt when the map gains entries.
domainOrder []string
labelAnonymizer map[string]string
labelAnonymized map[string]struct{}
labelCounter uint32
macAnonymizer map[string]string
macCounter uint32
wgKeyAnonymizer map[string]string
wgKeyAnonymized map[string]struct{}
currentAnonIPv4 netip.Addr
currentAnonIPv6 netip.Addr
startAnonIPv4 netip.Addr
startAnonIPv6 netip.Addr
// LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT,
// link-local), replacing them from the dedicated internal pools below so
// a reader can still tell an internal address from a public one.
level Level
currentAnonInternalIPv4 netip.Addr
currentAnonInternalIPv6 netip.Addr
startAnonInternalIPv4 netip.Addr
startAnonInternalIPv6 netip.Addr
domainKeyRegex *regexp.Regexp
}
@@ -32,25 +108,50 @@ func DefaultAddresses() (netip.Addr, netip.Addr) {
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::")
}
// InternalAddresses returns the pool starts used in strict mode for internal
// ranges. Both are reserved ranges that cannot collide with real addressing:
// 198.18.0.0 (RFC 2544 benchmarking), 2001:db8:1:: (RFC 3849 documentation).
func InternalAddresses() (netip.Addr, netip.Addr) {
return netip.AddrFrom4([4]byte{198, 18, 0, 0}), netip.MustParseAddr("2001:db8:1::")
}
func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
internalIPv4, internalIPv6 := InternalAddresses()
return &Anonymizer{
ipAnonymizer: map[netip.Addr]netip.Addr{},
domainAnonymizer: map[string]string{},
labelAnonymizer: map[string]string{},
labelAnonymized: map[string]struct{}{},
macAnonymizer: map[string]string{},
wgKeyAnonymizer: map[string]string{},
wgKeyAnonymized: map[string]struct{}{},
currentAnonIPv4: startIPv4,
currentAnonIPv6: startIPv6,
startAnonIPv4: startIPv4,
startAnonIPv6: startIPv6,
level: LevelDefault,
currentAnonInternalIPv4: internalIPv4,
currentAnonInternalIPv6: internalIPv6,
startAnonInternalIPv4: internalIPv4,
startAnonInternalIPv6: internalIPv6,
domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`),
}
}
// SetLevel selects the anonymization level. The zero value of a new
// Anonymizer is LevelDefault.
func (a *Anonymizer) SetLevel(level Level) {
a.level = level
}
func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
// Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps
// like 192.168.1.1.
ip = ip.Unmap()
if ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
(ip.Is4() && ip.IsPrivate()) ||
ip.IsUnspecified() ||
ip.IsMulticast() ||
isWellKnown(ip) ||
@@ -59,18 +160,100 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
return ip
}
if isInternal(ip) && a.level < LevelStrict {
return ip
}
if _, ok := a.ipAnonymizer[ip]; !ok {
if ip.Is4() {
a.ipAnonymizer[ip] = a.currentAnonIPv4
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
} else {
a.ipAnonymizer[ip] = a.currentAnonIPv6
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
}
a.ipAnonymizer[ip] = a.nextAnonIP(ip)
}
return a.ipAnonymizer[ip]
}
func (a *Anonymizer) nextAnonIP(ip netip.Addr) netip.Addr {
// At the strict level, internal addresses (including IPv6 ULA, matched
// by IsPrivate) come from the internal pools so they remain recognizable
// as internal without disclosing the real values.
if a.level >= LevelStrict && (isInternal(ip) || ip.IsPrivate()) {
if ip.Is4() {
anon := a.currentAnonInternalIPv4
a.currentAnonInternalIPv4 = a.currentAnonInternalIPv4.Next()
return anon
}
anon := a.currentAnonInternalIPv6
a.currentAnonInternalIPv6 = a.currentAnonInternalIPv6.Next()
return anon
}
if ip.Is4() {
anon := a.currentAnonIPv4
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
return anon
}
anon := a.currentAnonIPv6
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
return anon
}
// AnonymizeMAC replaces a MAC address with a consistent placeholder from the
// locally administered range starting at 02:00:00:00:00:01, at every
// anonymization level. Broadcast, multicast, all-zero, and already assigned
// placeholder addresses are preserved. The colon and dash spellings of the
// same address share one placeholder; the output keeps the input's separator.
func (a *Anonymizer) AnonymizeMAC(mac string) string {
hw, err := net.ParseMAC(mac)
if err != nil || len(hw) != 6 {
return mac
}
if isWellKnownMAC(hw) || a.isAnonymizedMAC(hw) {
return mac
}
key := hw.String()
anon, ok := a.macAnonymizer[key]
if !ok {
a.macCounter++
anon = fmt.Sprintf("02:00:00:%02x:%02x:%02x", byte(a.macCounter>>16), byte(a.macCounter>>8), byte(a.macCounter))
a.macAnonymizer[key] = anon
}
if strings.Contains(mac, "-") {
anon = strings.ReplaceAll(anon, ":", "-")
}
return anon
}
// isAnonymizedMAC reports whether hw is a placeholder this anonymizer already
// handed out, so a second pass over anonymized output leaves it unchanged.
func (a *Anonymizer) isAnonymizedMAC(hw net.HardwareAddr) bool {
if hw[0] != 0x02 || hw[1] != 0 || hw[2] != 0 {
return false
}
value := uint32(hw[3])<<16 | uint32(hw[4])<<8 | uint32(hw[5])
return value <= a.macCounter
}
// AnonymizeWGKey replaces a WireGuard public key with a consistent random
// placeholder of the same shape. Keys are only anonymized at LevelStrict;
// placeholders already handed out pass through unchanged.
func (a *Anonymizer) AnonymizeWGKey(key string) string {
if a.level < LevelStrict || !looksLikeWGKey(key) {
return key
}
if _, ok := a.wgKeyAnonymized[key]; ok {
return key
}
anon, ok := a.wgKeyAnonymizer[key]
if !ok {
anon = generateAnonymousKey()
a.wgKeyAnonymizer[key] = anon
a.wgKeyAnonymized[anon] = struct{}{}
}
return anon
}
func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
// Convert IP to netip.Addr
ip, ok := netip.AddrFromSlice(addr.IP)
@@ -89,12 +272,12 @@ func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
// isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs
func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 {
return true
} else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 {
return true
if ip.Is4() {
return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) ||
inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4)
}
return false
return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) ||
inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6)
}
func (a *Anonymizer) AnonymizeIPString(ip string) string {
@@ -118,14 +301,17 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
baseDomain = domain[:len(domain)-1]
}
if strings.HasSuffix(baseDomain, "netbird.io") ||
strings.HasSuffix(baseDomain, "netbird.selfhosted") ||
strings.HasSuffix(baseDomain, "netbird.cloud") ||
strings.HasSuffix(baseDomain, "netbird.stage") ||
strings.HasSuffix(baseDomain, anonTLD) {
if strings.HasSuffix(baseDomain, anonTLD) {
return domain
}
if suffix := protectedSuffix(baseDomain); suffix != "" {
if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
return domain
}
return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot)
}
parts := strings.Split(baseDomain, ".")
if len(parts) < 2 {
return domain
@@ -141,12 +327,53 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
}
result := strings.Replace(baseDomain, baseForLookup, anonymized, 1)
if hasDot {
result += "."
if a.level >= LevelStrict && len(parts) > 2 {
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
}
return withTrailingDot(result, hasDot)
}
// 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
// recognizable across the whole bundle without disclosing its name.
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
}
return result
}
// anonymizeLabels replaces each dot-separated label with a consistent
// numbered placeholder ("<placeholder>-<n>"). Wildcard labels and
// placeholders already handed out pass through unchanged.
func (a *Anonymizer) anonymizeLabels(prefix, placeholder string) string {
labels := strings.Split(prefix, ".")
for i, label := range labels {
if label == "*" {
continue
}
if _, ok := a.labelAnonymized[label]; ok {
continue
}
anon, ok := a.labelAnonymizer[label]
if !ok {
a.labelCounter++
anon = fmt.Sprintf("%s-%d", placeholder, a.labelCounter)
a.labelAnonymizer[label] = anon
a.labelAnonymized[anon] = struct{}{}
}
labels[i] = anon
}
return strings.Join(labels, ".")
}
func (a *Anonymizer) AnonymizeURI(uri string) string {
u, err := url.Parse(uri)
if err != nil {
@@ -181,16 +408,70 @@ func (a *Anonymizer) AnonymizeString(str string) string {
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
for domain, anonDomain := range a.domainAnonymizer {
str = strings.ReplaceAll(str, domain, anonDomain)
for _, domain := range a.sortedDomains() {
str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain])
}
str = a.AnonymizeSchemeURI(str)
str = a.AnonymizeDNSLogLine(str)
// MAC handling runs after the IP passes so preserved IPv6 addresses are
// already out of the way; the separator guard skips matches embedded in a
// longer colon- or dash-separated sequence (such as an IPv6 tail).
str = a.anonymizeMACsInString(str, macColonRegex, ':')
str = a.anonymizeMACsInString(str, macDashRegex, '-')
if a.level >= LevelStrict {
str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
}
return 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.
func (a *Anonymizer) sortedDomains() []string {
if len(a.domainOrder) == len(a.domainAnonymizer) {
return a.domainOrder
}
a.domainOrder = a.domainOrder[:0]
for domain := range a.domainAnonymizer {
a.domainOrder = append(a.domainOrder, domain)
}
slices.SortFunc(a.domainOrder, func(x, y string) int {
if d := len(y) - len(x); d != 0 {
return d
}
return strings.Compare(x, y)
})
return a.domainOrder
}
// anonymizeMACsInString replaces MAC addresses matched by re, skipping
// matches that directly adjoin another sep so a six-group run inside a longer
// separated sequence is left alone.
func (a *Anonymizer) anonymizeMACsInString(str string, re *regexp.Regexp, sep byte) string {
matches := re.FindAllStringIndex(str, -1)
if len(matches) == 0 {
return str
}
var b strings.Builder
last := 0
for _, m := range matches {
if (m[0] > 0 && str[m[0]-1] == sep) || (m[1] < len(str) && str[m[1]] == sep) {
continue
}
b.WriteString(str[last:m[0]])
b.WriteString(a.AnonymizeMAC(str[m[0]:m[1]]))
last = m[1]
}
b.WriteString(str[last:])
return b.String()
}
// AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes.
func (a *Anonymizer) AnonymizeSchemeURI(text string) string {
re := regexp.MustCompile(`(?i)\b(wss?://|rels?://|stuns?:|turns?:|https?://)\S+\b`)
@@ -239,10 +520,79 @@ func isWellKnown(addr netip.Addr) bool {
"128.0.0.0", "8000::", // 2nd split subnet for default routes
}
if slices.Contains(wellKnown, addr.String()) {
return slices.Contains(wellKnown, addr.String())
}
// isInternal reports whether ip identifies a host only within the local
// network: IPv4 private (RFC 1918), CGNAT (RFC 6598), and link-local (v4 and
// v6). These are preserved at the default level so support can reason about
// the real topology, and replaced from the internal pools at the strict
// level. IPv6 ULA is deliberately not internal: its random global ID uniquely
// fingerprints the network, so it is anonymized at every level.
func isInternal(ip netip.Addr) bool {
return (ip.Is4() && ip.IsPrivate()) ||
ip.IsLinkLocalUnicast() ||
isCGNAT(ip)
}
func inPoolRange(ip, start, current netip.Addr) bool {
return ip.Compare(start) >= 0 && ip.Compare(current) <= 0
}
// isWellKnownMAC reports whether hw carries no stable host identity: all-zero
// or a group address (broadcast and multicast).
func isWellKnownMAC(hw net.HardwareAddr) bool {
if hw[0]&1 == 1 {
return true
}
for _, b := range hw {
if b != 0 {
return false
}
}
return true
}
// looksLikeWGKey reports whether s has the shape of a WireGuard key:
// 44 base64 characters decoding to 32 bytes.
func looksLikeWGKey(s string) bool {
if len(s) != 44 || s[43] != '=' {
return false
}
decoded, err := base64.StdEncoding.DecodeString(s)
return err == nil && len(decoded) == 32
}
func generateAnonymousKey() string {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return strings.Repeat("A", 43) + "="
}
return base64.StdEncoding.EncodeToString(buf)
}
// protectedSuffix returns the protected NetBird suffix baseDomain ends with,
// or empty. The match is label-anchored so an unrelated domain that merely
// ends in the same characters is not preserved.
func protectedSuffix(baseDomain string) string {
for _, d := range protectedDomains {
if baseDomain == d || strings.HasSuffix(baseDomain, "."+d) {
return d
}
}
return ""
}
func withTrailingDot(domain string, hasDot bool) string {
if hasDot {
return domain + "."
}
return domain
}
// isCGNAT reports whether addr is in 100.64.0.0/10 (RFC 6598), the range
// NetBird assigns overlay peer addresses from.
func isCGNAT(addr netip.Addr) bool {
cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0})
cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10)
+298
View File
@@ -1,8 +1,11 @@
package anonymize_test
import (
"bytes"
"encoding/base64"
"net/netip"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -44,6 +47,301 @@ func TestAnonymizeIP(t *testing.T) {
}
}
func TestParseLevel(t *testing.T) {
tests := []struct {
input string
expect anonymize.Level
}{
{"", anonymize.LevelDefault},
{"default", anonymize.LevelDefault},
{"DEFAULT", anonymize.LevelDefault},
{"strict", anonymize.LevelStrict},
{"STRICT", anonymize.LevelStrict},
// Unknown values must never yield less anonymization than requested.
{"garbage", anonymize.LevelStrict},
}
for _, tc := range tests {
t.Run("input="+tc.input, func(t *testing.T) {
assert.Equal(t, tc.expect, anonymize.ParseLevel(tc.input), "parsed level should match")
})
}
}
func TestAnonymizeIP_DefaultLevelInternalRanges(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
tests := []struct {
name string
ip string
expect string
}{
{"RFC1918 10/8", "10.1.2.3", "10.1.2.3"},
{"RFC1918 172.16/12", "172.16.5.5", "172.16.5.5"},
{"RFC1918 192.168/16", "192.168.1.1", "192.168.1.1"},
{"CGNAT", "100.64.0.5", "100.64.0.5"},
{"IPv4 link-local", "169.254.1.1", "169.254.1.1"},
{"IPv6 link-local", "fe80::1", "fe80::1"},
// ULA is anonymized even at the default level: its random global ID
// uniquely fingerprints the network, unlike shared RFC 1918 space.
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:ffff::"},
// 4-in-6 addresses classify like their unmapped IPv4 form.
{"4-in-6 RFC1918", "::ffff:192.168.1.1", "192.168.1.1"},
{"4-in-6 CGNAT", "::ffff:100.64.0.5", "100.64.0.5"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
assert.Equal(t, tc.expect, result.String(), "default level should preserve internal ranges except ULA")
})
}
}
func TestAnonymizeIP_StrictLevel(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
// Order matters: internal pool addresses are assigned sequentially.
tests := []struct {
name string
ip string
expect string
}{
{"RFC1918 192.168/16", "192.168.1.1", "198.18.0.0"},
{"Second RFC1918", "192.168.1.2", "198.18.0.1"},
{"Repeated RFC1918", "192.168.1.1", "198.18.0.0"},
{"RFC1918 10/8", "10.1.2.3", "198.18.0.2"},
{"RFC1918 172.16/12", "172.16.5.5", "198.18.0.3"},
{"CGNAT", "100.64.0.5", "198.18.0.4"},
{"IPv4 link-local", "169.254.1.1", "198.18.0.5"},
{"Public IPv4 uses public pool", "1.2.3.4", "198.51.100.0"},
{"IPv6 link-local", "fe80::1", "2001:db8:1::"},
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:1::1"},
{"Public IPv6 uses public pool", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
{"Loopback IPv4", "127.0.0.1", "127.0.0.1"},
{"Loopback IPv6", "::1", "::1"},
{"Unspecified", "0.0.0.0", "0.0.0.0"},
{"Multicast", "224.0.0.251", "224.0.0.251"},
{"Well known resolver", "8.8.8.8", "8.8.8.8"},
{"Well known split marker", "128.0.0.0", "128.0.0.0"},
{"In internal pool range", "198.18.0.3", "198.18.0.3"},
{"In public pool range", "198.51.100.0", "198.51.100.0"},
{"4-in-6 repeated RFC1918", "::ffff:192.168.1.1", "198.18.0.0"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
assert.Equal(t, tc.expect, result.String(), "strict level should replace internal ranges from the internal pools")
})
}
}
func TestAnonymizeString_StrictInternalIPs(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
input := "route 10.20.30.0/24 via 192.168.1.1 dev eth0 src 100.64.0.7"
firstPass := anonymizer.AnonymizeString(input)
secondPass := anonymizer.AnonymizeString(firstPass)
assert.NotContains(t, firstPass, "10.20.30.0", "private network address should be anonymized")
assert.NotContains(t, firstPass, "192.168.1.1", "private gateway should be anonymized")
assert.NotContains(t, firstPass, "100.64.0.7", "CGNAT address should be anonymized")
assert.Contains(t, firstPass, "/24", "prefix length should be preserved")
assert.Equal(t, firstPass, secondPass, "second pass should not further anonymize the string")
}
func TestAnonymizeMAC(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
first := anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f")
assert.Equal(t, "02:00:00:00:00:01", first, "first MAC should get the first placeholder")
assert.Equal(t, first, anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f"), "repeated MAC should map to the same placeholder")
assert.Equal(t, first, anonymizer.AnonymizeMAC("AA:BB:CC:DD:EE:0F"), "case should not affect the mapping")
assert.Equal(t, "02-00-00-00-00-01", anonymizer.AnonymizeMAC("AA-BB-CC-DD-EE-0F"), "dash form should keep its separator but share the mapping")
second := anonymizer.AnonymizeMAC("10:22:33:44:55:66")
assert.Equal(t, "02:00:00:00:00:02", second, "second distinct MAC should get the next placeholder")
tests := []struct {
name string
mac string
}{
{"Broadcast", "ff:ff:ff:ff:ff:ff"},
{"IPv4 multicast", "01:00:5e:00:00:fb"},
{"IPv6 multicast", "33:33:00:00:00:01"},
{"All zero", "00:00:00:00:00:00"},
{"Assigned placeholder", "02:00:00:00:00:01"},
{"Invalid", "not-a-mac"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.mac, anonymizer.AnonymizeMAC(tc.mac), "should be preserved")
})
}
}
func TestAnonymizeString_MACAddresses(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
tests := []struct {
name string
input string
expect string
}{
{
name: "nftables ether rule",
input: "ether saddr aa:bb:cc:dd:ee:ff drop",
expect: "ether saddr 02:00:00:00:00:01 drop",
},
{
name: "Windows dash form",
input: "Physical Address : AA-BB-CC-DD-EE-FF",
expect: "Physical Address : 02-00-00-00-00-01",
},
{
name: "IPv6 address tail is not treated as MAC",
input: "addr fe80:0:11:22:33:44:55:66 scope link",
expect: "addr fe80:0:11:22:33:44:55:66 scope link",
},
{
name: "broadcast MAC preserved",
input: "dst ff:ff:ff:ff:ff:ff type ARP",
expect: "dst ff:ff:ff:ff:ff:ff type ARP",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := anonymizer.AnonymizeString(tc.input)
assert.Equal(t, tc.expect, result, "MAC addresses should be anonymized at every level")
assert.Equal(t, result, anonymizer.AnonymizeString(result), "second pass should not change the result")
})
}
}
func TestAnonymizeWGKey(t *testing.T) {
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
t.Run("default level preserves keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
assert.Equal(t, key, anonymizer.AnonymizeWGKey(key), "default level should not touch WireGuard keys")
})
t.Run("strict level replaces keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
anon := anonymizer.AnonymizeWGKey(key)
assert.NotEqual(t, key, anon, "strict level should replace the key")
assert.Regexp(t, `^[A-Za-z0-9+/]{43}=$`, anon, "placeholder should keep the WireGuard key shape")
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(key), "repeated key should map to the same placeholder")
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(anon), "an assigned placeholder should pass through unchanged")
assert.Equal(t, "not-a-key", anonymizer.AnonymizeWGKey("not-a-key"), "non-key values should be preserved")
})
}
func TestAnonymizeString_WGKeys(t *testing.T) {
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
input := "peer " + key + " handshake completed"
t.Run("default level preserves keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
assert.Equal(t, input, anonymizer.AnonymizeString(input), "default level should not touch WireGuard keys in strings")
})
t.Run("strict level replaces keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
firstPass := anonymizer.AnonymizeString(input)
assert.NotContains(t, firstPass, key, "the key should not survive strict anonymization")
assert.Equal(t, anonymizer.AnonymizeWGKey(key), extractKey(t, firstPass), "string replacement should be consistent with AnonymizeWGKey")
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
})
}
func extractKey(t *testing.T, logLine string) string {
t.Helper()
fields := strings.Fields(logLine)
require.Len(t, fields, 4, "log line should keep its structure")
return fields[1]
}
func TestAnonymizeDomain_StrictLevel(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
t.Run("netbird peer name", func(t *testing.T) {
result := anonymizer.AnonymizeDomain("my-laptop.netbird.cloud")
assert.Regexp(t, `^peer-\d+\.netbird\.cloud$`, result, "peer name should be anonymized, suffix kept")
assert.NotContains(t, result, "my-laptop", "the peer name should not survive")
assert.Equal(t, result, anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), "repeated domain should map consistently")
assert.Equal(t, result, anonymizer.AnonymizeDomain(result), "an anonymized domain should pass through unchanged")
})
t.Run("bare netbird domain", func(t *testing.T) {
assert.Equal(t, "netbird.cloud", anonymizer.AnonymizeDomain("netbird.cloud"), "the bare protected suffix should be preserved")
})
t.Run("netbird infrastructure preserved", func(t *testing.T) {
assert.Equal(t, "api.netbird.io", anonymizer.AnonymizeDomain("api.netbird.io"),
"netbird.io hosts infrastructure, not peer names, and should stay readable")
})
t.Run("leading labels of other domains", func(t *testing.T) {
result := anonymizer.AnonymizeDomain("host1.corp.example.com")
assert.Regexp(t, `^host-\d+\.host-\d+\.anon-[a-zA-Z0-9]+\.domain$`, result, "every label should be anonymized")
for _, label := range []string{"host1", "corp", "example"} {
assert.NotContains(t, result, label, "no original label should survive")
}
assert.Equal(t, result, anonymizer.AnonymizeDomain("host1.corp.example.com"), "repeated domain should map consistently")
})
t.Run("same label maps consistently across domains", func(t *testing.T) {
first := anonymizer.AnonymizeDomain("shared.one.com")
second := anonymizer.AnonymizeDomain("shared.two.com")
assert.Equal(t, strings.Split(first, ".")[0], strings.Split(second, ".")[0], "the shared host label should get one placeholder")
})
t.Run("wildcard label preserved", func(t *testing.T) {
result := anonymizer.AnonymizeDomain("*.example.com")
assert.Regexp(t, `^\*\.anon-[a-zA-Z0-9]+\.domain$`, result, "the wildcard label should stay a wildcard")
})
}
func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
assert.Equal(t, "my-laptop.netbird.cloud", anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"),
"default level should preserve netbird FQDNs including the peer name")
assert.Regexp(t, `^sub\.anon-[a-zA-Z0-9]+\.domain$`, anonymizer.AnonymizeDomain("sub.example.com"),
"default level should keep subdomain labels")
}
func TestAnonymizeString_StrictPeerNames(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
// Seed like the bundle generator does from the status: base first, then
// the full FQDN, so replacement must prefer the longer mapping.
anonBase := anonymizer.AnonymizeDomain("example.com")
anonPeer := anonymizer.AnonymizeDomain("peer1.netbird.cloud")
anonHost := anonymizer.AnonymizeDomain("host1.example.com")
logLine := "connected to peer1.netbird.cloud via host1.example.com endpoint"
firstPass := anonymizer.AnonymizeString(logLine)
assert.NotContains(t, firstPass, "peer1", "the peer name should not survive in logs")
assert.NotContains(t, firstPass, "host1", "the host label should not survive in logs")
assert.Contains(t, firstPass, anonPeer, "the seeded peer mapping should be applied")
assert.Contains(t, firstPass, anonHost, "the seeded host mapping should be applied, not just the base mapping")
assert.NotContains(t, firstPass, "host1."+anonBase, "the base mapping must not preempt the longer FQDN mapping")
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
}
func TestAnonymizeDNSLogLine(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{})
tests := []struct {