package debug import ( "archive/zip" "bytes" "encoding/json" "fmt" "net" "net/netip" "net/url" "os" "path/filepath" "reflect" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/configs" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/netiputil" ) func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte { t.Helper() b, err := netiputil.EncodePrefix(p) require.NoError(t, err) return b } func TestAnonymizeStateFile(t *testing.T) { testState := map[string]json.RawMessage{ "null_state": json.RawMessage("null"), "test_state": mustMarshal(map[string]any{ // Test simple fields "public_ip": "203.0.113.1", "private_ip": "192.168.1.1", "protected_ip": "100.64.0.1", "well_known_ip": "8.8.8.8", "ipv6_addr": "2001:db8::1", "private_ipv6": "fd00::1", "domain": "test.example.com", "uri": "stun:stun.example.com:3478", "uri_with_ip": "turn:203.0.113.1:3478", "netbird_domain": "device.netbird.cloud", // Test CIDR ranges "public_cidr": "203.0.113.0/24", "private_cidr": "192.168.0.0/16", "protected_cidr": "100.64.0.0/10", "ipv6_cidr": "2001:db8::/32", "private_ipv6_cidr": "fd00::/8", // Test nested structures "nested": map[string]any{ "ip": "203.0.113.2", "domain": "nested.example.com", "more_nest": map[string]any{ "ip": "203.0.113.3", "domain": "deep.example.com", }, }, // Test arrays "string_array": []any{ "203.0.113.4", "test1.example.com", "test2.example.com", }, "object_array": []any{ map[string]any{ "ip": "203.0.113.5", "domain": "array1.example.com", }, map[string]any{ "ip": "203.0.113.6", "domain": "array2.example.com", }, }, // Test multiple occurrences of same value "duplicate_ip": "203.0.113.1", // Same as public_ip "duplicate_domain": "test.example.com", // Same as domain // Test URIs with various schemes "stun_uri": "stun:stun.example.com:3478", "turns_uri": "turns:turns.example.com:5349", "http_uri": "http://web.example.com:80", "https_uri": "https://secure.example.com:443", // Test strings that might look like IPs but aren't "not_ip": "300.300.300.300", "partial_ip": "192.168", "ip_like_string": "1234.5678", // Test mixed content strings "mixed_content": "Server at 203.0.113.1 (test.example.com) on port 80", // Test empty and special values "empty_string": "", "null_value": nil, "numeric_value": 42, "boolean_value": true, }), "route_state": mustMarshal(map[string]any{ "routes": []any{ map[string]any{ "network": "203.0.113.0/24", "gateway": "203.0.113.1", "domains": []any{ "route1.example.com", "route2.example.com", }, }, map[string]any{ "network": "2001:db8::/32", "gateway": "2001:db8::1", "domains": []any{ "route3.example.com", "route4.example.com", }, }, }, // Test map with IP/CIDR keys "refCountMap": map[string]any{ "203.0.113.1/32": map[string]any{ "Count": 1, "Out": map[string]any{ "IP": "192.168.0.1", "Intf": map[string]any{ "Name": "eth0", "Index": 1, }, }, }, "2001:db8::1/128": map[string]any{ "Count": 1, "Out": map[string]any{ "IP": "fe80::1", "Intf": map[string]any{ "Name": "eth0", "Index": 1, }, }, }, "10.0.0.1/32": map[string]any{ // private IP should remain unchanged "Count": 1, "Out": map[string]any{ "IP": "192.168.0.1", }, }, }, }), } anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) // Pre-seed the domains we need to verify in the test assertions anonymizer.AnonymizeDomain("test.example.com") anonymizer.AnonymizeDomain("nested.example.com") anonymizer.AnonymizeDomain("deep.example.com") anonymizer.AnonymizeDomain("array1.example.com") err := anonymizeStateFile(&testState, anonymizer) require.NoError(t, err) // Helper function to unmarshal and get nested values var state map[string]any err = json.Unmarshal(testState["test_state"], &state) require.NoError(t, err) // Test null state remains unchanged require.Equal(t, "null", string(testState["null_state"])) // Basic assertions assert.NotEqual(t, "203.0.113.1", state["public_ip"]) assert.Equal(t, "192.168.1.1", state["private_ip"]) // Private IP unchanged assert.Equal(t, "100.64.0.1", state["protected_ip"]) // Protected IP unchanged assert.Equal(t, "8.8.8.8", state["well_known_ip"]) // Well-known IP unchanged assert.NotEqual(t, "2001:db8::1", state["ipv6_addr"]) assert.NotEqual(t, "fd00::1", state["private_ipv6"]) // ULA IPv6 anonymized (global ID is a fingerprint) assert.NotEqual(t, "test.example.com", state["domain"]) assert.True(t, strings.HasSuffix(state["domain"].(string), ".domain")) assert.Equal(t, "device.netbird.cloud", state["netbird_domain"]) // Netbird domain unchanged // CIDR ranges assert.NotEqual(t, "203.0.113.0/24", state["public_cidr"]) assert.Contains(t, state["public_cidr"], "/24") // Prefix preserved assert.Equal(t, "192.168.0.0/16", state["private_cidr"]) // Private CIDR unchanged assert.Equal(t, "100.64.0.0/10", state["protected_cidr"]) // Protected CIDR unchanged assert.NotEqual(t, "2001:db8::/32", state["ipv6_cidr"]) assert.Contains(t, state["ipv6_cidr"], "/32") // IPv6 prefix preserved // Nested structures nested := state["nested"].(map[string]any) assert.NotEqual(t, "203.0.113.2", nested["ip"]) assert.NotEqual(t, "nested.example.com", nested["domain"]) moreNest := nested["more_nest"].(map[string]any) assert.NotEqual(t, "203.0.113.3", moreNest["ip"]) assert.NotEqual(t, "deep.example.com", moreNest["domain"]) // Arrays strArray := state["string_array"].([]any) assert.NotEqual(t, "203.0.113.4", strArray[0]) assert.NotEqual(t, "test1.example.com", strArray[1]) assert.True(t, strings.HasSuffix(strArray[1].(string), ".domain")) objArray := state["object_array"].([]any) firstObj := objArray[0].(map[string]any) assert.NotEqual(t, "203.0.113.5", firstObj["ip"]) assert.NotEqual(t, "array1.example.com", firstObj["domain"]) // Duplicate values should be anonymized consistently assert.Equal(t, state["public_ip"], state["duplicate_ip"]) assert.Equal(t, state["domain"], state["duplicate_domain"]) // URIs assert.NotContains(t, state["stun_uri"], "stun.example.com") assert.NotContains(t, state["turns_uri"], "turns.example.com") assert.NotContains(t, state["http_uri"], "web.example.com") assert.NotContains(t, state["https_uri"], "secure.example.com") // Non-IP strings should remain unchanged assert.Equal(t, "300.300.300.300", state["not_ip"]) assert.Equal(t, "192.168", state["partial_ip"]) assert.Equal(t, "1234.5678", state["ip_like_string"]) // Mixed content should have IPs and domains replaced mixedContent := state["mixed_content"].(string) assert.NotContains(t, mixedContent, "203.0.113.1") assert.NotContains(t, mixedContent, "test.example.com") assert.Contains(t, mixedContent, "Server at ") assert.Contains(t, mixedContent, " on port 80") // Special values should remain unchanged assert.Equal(t, "", state["empty_string"]) assert.Nil(t, state["null_value"]) assert.Equal(t, float64(42), state["numeric_value"]) assert.Equal(t, true, state["boolean_value"]) // Check route state var routeState map[string]any err = json.Unmarshal(testState["route_state"], &routeState) require.NoError(t, err) routes := routeState["routes"].([]any) route1 := routes[0].(map[string]any) assert.NotEqual(t, "203.0.113.0/24", route1["network"]) assert.Contains(t, route1["network"], "/24") assert.NotEqual(t, "203.0.113.1", route1["gateway"]) domains := route1["domains"].([]any) assert.True(t, strings.HasSuffix(domains[0].(string), ".domain")) assert.True(t, strings.HasSuffix(domains[1].(string), ".domain")) // Check map keys are anonymized refCountMap := routeState["refCountMap"].(map[string]any) hasPublicIPKey := false hasIPv6Key := false hasPrivateIPKey := false for key := range refCountMap { if strings.Contains(key, "203.0.113.1") { hasPublicIPKey = true } if strings.Contains(key, "2001:db8::1") { hasIPv6Key = true } if key == "10.0.0.1/32" { hasPrivateIPKey = true } } assert.False(t, hasPublicIPKey, "public IP in key should be anonymized") assert.False(t, hasIPv6Key, "IPv6 in key should be anonymized") assert.True(t, hasPrivateIPKey, "private IP in key should remain unchanged") } func mustMarshal(v any) json.RawMessage { data, err := json.Marshal(v) if err != nil { panic(err) } return data } func TestAnonymizeNetworkMap(t *testing.T) { origV6Prefix := netip.MustParsePrefix("2001:db8:abcd::5/64") networkMap := &mgmProto.NetworkMap{ PeerConfig: &mgmProto.PeerConfig{ Address: "203.0.113.5", AddressV6: mustEncodePrefix(t, origV6Prefix), Dns: "1.2.3.4", Fqdn: "peer1.corp.example.com", SshConfig: &mgmProto.SSHConfig{ SshPubKey: []byte("ssh-rsa AAAAB3NzaC1..."), }, }, RemotePeers: []*mgmProto.RemotePeerConfig{ { AllowedIps: []string{ "203.0.113.1/32", "2001:db8:1234::1/128", "192.168.1.1/32", "100.64.0.1/32", "10.0.0.1/32", }, Fqdn: "peer2.corp.example.com", SshConfig: &mgmProto.SSHConfig{ SshPubKey: []byte("ssh-rsa AAAAB3NzaC2..."), }, }, }, Routes: []*mgmProto.Route{ { Network: "197.51.100.0/24", Domains: []string{"prod.example.com", "staging.example.com"}, NetID: "net-123abc", }, }, DNSConfig: &mgmProto.DNSConfig{ NameServerGroups: []*mgmProto.NameServerGroup{ { NameServers: []*mgmProto.NameServer{ {IP: "8.8.8.8"}, {IP: "1.1.1.1"}, {IP: "203.0.113.53"}, }, Domains: []string{"example.com", "internal.example.com"}, }, }, CustomZones: []*mgmProto.CustomZone{ { Domain: "custom.example.com", Records: []*mgmProto.SimpleRecord{ { Name: "www.custom.example.com", Type: 1, RData: "203.0.113.10", }, { Name: "internal.custom.example.com", Type: 1, RData: "192.168.1.10", }, }, }, }, }, } // Create anonymizer with test addresses anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) // Anonymize the network map err := anonymizeNetworkMap(networkMap, anonymizer) require.NoError(t, err) // Test PeerConfig anonymization peerCfg := networkMap.PeerConfig require.NotEqual(t, "203.0.113.5", peerCfg.Address) // Verify DNS and FQDN are properly anonymized require.NotEqual(t, "1.2.3.4", peerCfg.Dns) require.NotEqual(t, "peer1.corp.example.com", peerCfg.Fqdn) require.True(t, strings.HasSuffix(peerCfg.Fqdn, ".domain")) // Verify AddressV6 is anonymized but preserves prefix length anonV6Prefix, err := netiputil.DecodePrefix(peerCfg.AddressV6) require.NoError(t, err) assert.Equal(t, origV6Prefix.Bits(), anonV6Prefix.Bits(), "prefix length must be preserved") assert.NotEqual(t, origV6Prefix.Addr(), anonV6Prefix.Addr(), "IPv6 address must be anonymized") // Verify SSH key is replaced require.Equal(t, []byte("ssh-placeholder-key"), peerCfg.SshConfig.SshPubKey) // Test RemotePeers anonymization remotePeer := networkMap.RemotePeers[0] // Verify FQDN is anonymized require.NotEqual(t, "peer2.corp.example.com", remotePeer.Fqdn) require.True(t, strings.HasSuffix(remotePeer.Fqdn, ".domain")) // Check that public IPs are anonymized but private IPs are preserved for _, allowedIP := range remotePeer.AllowedIps { ip, _, err := net.ParseCIDR(allowedIP) require.NoError(t, err) if ip.IsPrivate() || isInCGNATRange(ip) { require.Contains(t, []string{ "192.168.1.1/32", "100.64.0.1/32", "10.0.0.1/32", }, allowedIP) } else { require.NotContains(t, []string{ "203.0.113.1/32", "2001:db8:1234::1/128", }, allowedIP) } } // Test Routes anonymization route := networkMap.Routes[0] require.NotEqual(t, "197.51.100.0/24", route.Network) for _, domain := range route.Domains { require.True(t, strings.HasSuffix(domain, ".domain")) require.NotContains(t, domain, "example.com") } // Test DNS config anonymization dnsConfig := networkMap.DNSConfig nameServerGroup := dnsConfig.NameServerGroups[0] // Verify well-known DNS servers are preserved require.Equal(t, "8.8.8.8", nameServerGroup.NameServers[0].IP) require.Equal(t, "1.1.1.1", nameServerGroup.NameServers[1].IP) // Verify public DNS server is anonymized require.NotEqual(t, "203.0.113.53", nameServerGroup.NameServers[2].IP) // Verify domains are anonymized for _, domain := range nameServerGroup.Domains { require.True(t, strings.HasSuffix(domain, ".domain")) require.NotContains(t, domain, "example.com") } // Test CustomZones anonymization customZone := dnsConfig.CustomZones[0] require.True(t, strings.HasSuffix(customZone.Domain, ".domain")) require.NotContains(t, customZone.Domain, "example.com") // Verify records are properly anonymized for _, record := range customZone.Records { require.True(t, strings.HasSuffix(record.Name, ".domain")) require.NotContains(t, record.Name, "example.com") ip := net.ParseIP(record.RData) if ip != nil { if !ip.IsPrivate() { require.NotEqual(t, "203.0.113.10", record.RData) } else { require.Equal(t, "192.168.1.10", record.RData) } } } } func TestIsSensitiveEnvVar(t *testing.T) { tests := []struct { key string sensitive bool }{ {"NB_SETUP_KEY", true}, {"NB_API_TOKEN", true}, {"NB_CLIENT_SECRET", true}, {"NB_PASSWORD", true}, {"NB_CREDENTIAL", true}, {"NB_LOG_LEVEL", false}, {"NB_MANAGEMENT_URL", false}, {"NB_HOSTNAME", false}, {"HOME", false}, {"PATH", false}, } for _, tt := range tests { t.Run(tt.key, func(t *testing.T) { assert.Equal(t, tt.sensitive, isSensitiveEnvVar(tt.key)) }) } } func TestSanitizeServiceEnvVars(t *testing.T) { tests := []struct { name string anonymize bool input map[string]any check func(t *testing.T, params map[string]any) }{ { name: "no env vars key", anonymize: false, input: map[string]any{"management_url": "https://mgmt.example.com"}, check: func(t *testing.T, params map[string]any) { t.Helper() assert.Equal(t, "https://mgmt.example.com", params["management_url"], "non-env fields should be untouched") _, ok := params[jsonKeyServiceEnv] assert.False(t, ok, "service_env_vars should not be added") }, }, { name: "non-NB vars are masked", anonymize: false, input: map[string]any{ jsonKeyServiceEnv: map[string]any{ "HOME": "/root", "PATH": "/usr/bin", "NB_LOG_LEVEL": "debug", }, }, check: func(t *testing.T, params map[string]any) { t.Helper() env := params[jsonKeyServiceEnv].(map[string]any) assert.Equal(t, maskedValue, env["HOME"], "non-NB_ var should be masked") assert.Equal(t, maskedValue, env["PATH"], "non-NB_ var should be masked") assert.Equal(t, "debug", env["NB_LOG_LEVEL"], "safe NB_ var should pass through") }, }, { name: "sensitive NB vars are masked", anonymize: false, input: map[string]any{ jsonKeyServiceEnv: map[string]any{ "NB_SETUP_KEY": "abc123", "NB_API_TOKEN": "tok_xyz", "NB_LOG_LEVEL": "info", }, }, check: func(t *testing.T, params map[string]any) { t.Helper() env := params[jsonKeyServiceEnv].(map[string]any) assert.Equal(t, maskedValue, env["NB_SETUP_KEY"], "sensitive NB_ var should be masked") assert.Equal(t, maskedValue, env["NB_API_TOKEN"], "sensitive NB_ var should be masked") assert.Equal(t, "info", env["NB_LOG_LEVEL"], "safe NB_ var should pass through") }, }, { name: "safe NB vars anonymized when anonymize is true", anonymize: true, input: map[string]any{ jsonKeyServiceEnv: map[string]any{ "NB_MANAGEMENT_URL": "https://mgmt.example.com:443", "NB_LOG_LEVEL": "debug", "NB_SETUP_KEY": "secret", "SOME_OTHER": "val", }, }, check: func(t *testing.T, params map[string]any) { t.Helper() env := params[jsonKeyServiceEnv].(map[string]any) // Safe NB_ values should be anonymized (not the original, not masked) mgmtVal := env["NB_MANAGEMENT_URL"].(string) assert.NotEqual(t, "https://mgmt.example.com:443", mgmtVal, "should be anonymized") assert.NotEqual(t, maskedValue, mgmtVal, "should not be masked") logVal := env["NB_LOG_LEVEL"].(string) assert.NotEqual(t, maskedValue, logVal, "safe NB_ var should not be masked") // Sensitive and non-NB_ still masked assert.Equal(t, maskedValue, env["NB_SETUP_KEY"]) assert.Equal(t, maskedValue, env["SOME_OTHER"]) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) g := &BundleGenerator{ anonymize: tt.anonymize, anonymizer: anonymizer, } g.sanitizeServiceEnvVars(tt.input) tt.check(t, tt.input) }) } } func TestAddServiceParams(t *testing.T) { t.Run("missing service.json returns nil", func(t *testing.T) { g := &BundleGenerator{ anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), } origStateDir := configs.StateDir configs.StateDir = t.TempDir() t.Cleanup(func() { configs.StateDir = origStateDir }) err := g.addServiceParams() assert.NoError(t, err) }) t.Run("management_url anonymized when anonymize is true", func(t *testing.T) { dir := t.TempDir() origStateDir := configs.StateDir configs.StateDir = dir t.Cleanup(func() { configs.StateDir = origStateDir }) input := map[string]any{ jsonKeyManagementURL: "https://api.example.com:443", jsonKeyServiceEnv: map[string]any{ "NB_LOG_LEVEL": "trace", }, } data, err := json.Marshal(input) require.NoError(t, err) require.NoError(t, os.WriteFile(filepath.Join(dir, serviceParamsFile), data, 0600)) var buf bytes.Buffer zw := zip.NewWriter(&buf) g := &BundleGenerator{ anonymize: true, anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), archive: zw, } require.NoError(t, g.addServiceParams()) require.NoError(t, zw.Close()) zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) require.NoError(t, err) require.Len(t, zr.File, 1) assert.Equal(t, serviceParamsBundle, zr.File[0].Name) rc, err := zr.File[0].Open() require.NoError(t, err) defer rc.Close() var result map[string]any require.NoError(t, json.NewDecoder(rc).Decode(&result)) mgmt := result[jsonKeyManagementURL].(string) assert.NotEqual(t, "https://api.example.com:443", mgmt, "management_url should be anonymized") assert.NotEmpty(t, mgmt) env := result[jsonKeyServiceEnv].(map[string]any) assert.NotEqual(t, maskedValue, env["NB_LOG_LEVEL"], "safe NB_ var should not be masked") }) t.Run("management_url preserved when anonymize is false", func(t *testing.T) { dir := t.TempDir() origStateDir := configs.StateDir configs.StateDir = dir t.Cleanup(func() { configs.StateDir = origStateDir }) input := map[string]any{ jsonKeyManagementURL: "https://api.example.com:443", } data, err := json.Marshal(input) require.NoError(t, err) require.NoError(t, os.WriteFile(filepath.Join(dir, serviceParamsFile), data, 0600)) var buf bytes.Buffer zw := zip.NewWriter(&buf) g := &BundleGenerator{ anonymize: false, anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), archive: zw, } require.NoError(t, g.addServiceParams()) require.NoError(t, zw.Close()) zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) require.NoError(t, err) rc, err := zr.File[0].Open() require.NoError(t, err) defer rc.Close() var result map[string]any require.NoError(t, json.NewDecoder(rc).Decode(&result)) assert.Equal(t, "https://api.example.com:443", result[jsonKeyManagementURL], "management_url should be preserved") }) } // Helper function to check if IP is in CGNAT range func isInCGNATRange(ip net.IP) bool { cgnat := net.IPNet{ IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32), } return cgnat.Contains(ip) } func TestAnonymizeFirewallRules(t *testing.T) { // Example iptables-save output iptablesSave := `# Generated by iptables-save v1.8.7 on Thu Dec 19 10:00:00 2024 *filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT ACCEPT [0:0] -A INPUT -s 192.168.1.0/24 -j ACCEPT -A INPUT -s 44.192.140.1/32 -j DROP -A FORWARD -s 10.0.0.0/8 -j DROP -A FORWARD -s 44.192.140.0/24 -d 52.84.12.34/24 -j ACCEPT COMMIT *nat :PREROUTING ACCEPT [0:0] :INPUT ACCEPT [0:0] :OUTPUT ACCEPT [0:0] :POSTROUTING ACCEPT [0:0] -A POSTROUTING -s 192.168.100.0/24 -j MASQUERADE -A PREROUTING -d 44.192.140.10/32 -p tcp -m tcp --dport 80 -j DNAT --to-destination 192.168.1.10:80 COMMIT` // Example iptables -v -n -L output iptablesVerbose := `Chain INPUT (policy ACCEPT 0 packets, 0 bytes) pkts bytes target prot opt in out source destination 0 0 ACCEPT all -- * * 192.168.1.0/24 0.0.0.0/0 100 1024 DROP all -- * * 44.192.140.1 0.0.0.0/0 Chain FORWARD (policy ACCEPT 0 packets, 0 bytes) pkts bytes target prot opt in out source destination 0 0 DROP all -- * * 10.0.0.0/8 0.0.0.0/0 25 256 ACCEPT all -- * * 44.192.140.0/24 52.84.12.34/24 Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes) pkts bytes target prot opt in out source destination` // Example ip6tables-save output ip6tablesSave := `# Generated by ip6tables-save v1.8.7 on Thu Dec 19 10:00:00 2024 *filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT ACCEPT [0:0] -A INPUT -s fd00:1234::1/128 -j ACCEPT -A INPUT -s 2607:f8b0:4005::1/128 -j DROP -A FORWARD -s 2001:db8::/32 -d 2607:f8b0:4005::200e/128 -j ACCEPT COMMIT` // Example nftables output with IPv6 nftablesRules := `table inet filter { chain input { type filter hook input priority filter; policy accept; ip saddr 192.168.1.1 accept ip saddr 44.192.140.1 drop ip6 saddr 2607:f8b0:4005::1 drop ip6 saddr fd00:1234::1 accept } chain forward { type filter hook forward priority filter; policy accept; ip saddr 10.0.0.0/8 drop ip saddr 44.192.140.0/24 ip daddr 52.84.12.34/24 accept ip6 saddr 2001:db8::/32 ip6 daddr 2607:f8b0:4005::200e/128 accept } }` anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) // Test iptables-save anonymization anonIptablesSave := anonymizer.AnonymizeString(iptablesSave) // Private IP addresses should remain unchanged assert.Contains(t, anonIptablesSave, "192.168.1.0/24") assert.Contains(t, anonIptablesSave, "10.0.0.0/8") assert.Contains(t, anonIptablesSave, "192.168.100.0/24") assert.Contains(t, anonIptablesSave, "192.168.1.10") // Public IP addresses should be anonymized to the default range assert.NotContains(t, anonIptablesSave, "44.192.140.1") assert.NotContains(t, anonIptablesSave, "44.192.140.0/24") assert.NotContains(t, anonIptablesSave, "52.84.12.34") assert.Contains(t, anonIptablesSave, "198.51.100.") // Default anonymous range // Structure should be preserved assert.Contains(t, anonIptablesSave, "*filter") assert.Contains(t, anonIptablesSave, ":INPUT ACCEPT [0:0]") assert.Contains(t, anonIptablesSave, "COMMIT") assert.Contains(t, anonIptablesSave, "-j MASQUERADE") assert.Contains(t, anonIptablesSave, "--dport 80") // Test iptables verbose output anonymization anonIptablesVerbose := anonymizer.AnonymizeString(iptablesVerbose) // Private IP addresses should remain unchanged assert.Contains(t, anonIptablesVerbose, "192.168.1.0/24") assert.Contains(t, anonIptablesVerbose, "10.0.0.0/8") // Public IP addresses should be anonymized to the default range assert.NotContains(t, anonIptablesVerbose, "44.192.140.1") assert.NotContains(t, anonIptablesVerbose, "44.192.140.0/24") assert.NotContains(t, anonIptablesVerbose, "52.84.12.34") assert.Contains(t, anonIptablesVerbose, "198.51.100.") // Default anonymous range // Structure and counters should be preserved assert.Contains(t, anonIptablesVerbose, "Chain INPUT (policy ACCEPT 0 packets, 0 bytes)") assert.Contains(t, anonIptablesVerbose, "100 1024 DROP") assert.Contains(t, anonIptablesVerbose, "pkts bytes target") // Test nftables anonymization anonNftables := anonymizer.AnonymizeString(nftablesRules) // Private IP addresses should remain unchanged assert.Contains(t, anonNftables, "192.168.1.1") assert.Contains(t, anonNftables, "10.0.0.0/8") // Public IP addresses should be anonymized to the default range assert.NotContains(t, anonNftables, "44.192.140.1") assert.NotContains(t, anonNftables, "44.192.140.0/24") assert.NotContains(t, anonNftables, "52.84.12.34") assert.Contains(t, anonNftables, "198.51.100.") // Default anonymous range // Structure should be preserved assert.Contains(t, anonNftables, "table inet filter {") assert.Contains(t, anonNftables, "chain input {") assert.Contains(t, anonNftables, "type filter hook input priority filter; policy accept;") // IPv6 public addresses in nftables should be anonymized assert.NotContains(t, anonNftables, "2607:f8b0:4005::1") assert.NotContains(t, anonNftables, "2607:f8b0:4005::200e") assert.NotContains(t, anonNftables, "2001:db8::") assert.Contains(t, anonNftables, "2001:db8:ffff::") // Default anonymous v6 range // ULA addresses in nftables should be anonymized (global ID is a fingerprint) assert.NotContains(t, anonNftables, "fd00:1234::1") // IPv6 nftables structure preserved assert.Contains(t, anonNftables, "ip6 saddr") assert.Contains(t, anonNftables, "ip6 daddr") // Test ip6tables-save anonymization anonIp6tablesSave := anonymizer.AnonymizeString(ip6tablesSave) // ULA IPv6 should be anonymized (global ID is a fingerprint) assert.NotContains(t, anonIp6tablesSave, "fd00:1234::1/128") // Public IPv6 addresses should be anonymized assert.NotContains(t, anonIp6tablesSave, "2607:f8b0:4005::1") assert.NotContains(t, anonIp6tablesSave, "2607:f8b0:4005::200e") assert.NotContains(t, anonIp6tablesSave, "2001:db8::") assert.Contains(t, anonIp6tablesSave, "2001:db8:ffff::") // Default anonymous v6 range // Structure should be preserved assert.Contains(t, anonIp6tablesSave, "*filter") assert.Contains(t, anonIp6tablesSave, "COMMIT") assert.Contains(t, anonIp6tablesSave, "-j DROP") assert.Contains(t, anonIp6tablesSave, "-j ACCEPT") } // TestAddConfig_AllFieldsCovered uses reflection to ensure every field in // profilemanager.Config is either rendered in the debug bundle or explicitly // excluded. When a new field is added to Config, this test fails until the // developer either dumps it in addConfig/addCommonConfigFields or adds it to // the excluded set with a justification. func TestAddConfig_AllFieldsCovered(t *testing.T) { excluded := map[string]string{ "PrivateKey": "sensitive: WireGuard private key", "PreSharedKey": "sensitive: WireGuard pre-shared key", "SSHKey": "sensitive: SSH private key", "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", "Name": "non-config: profile name is not needed for debug purposes", "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", "DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle", } mURL, _ := url.Parse("https://api.example.com:443") aURL, _ := url.Parse("https://admin.example.com:443") bTrue := true iVal := 42 cfg := &profilemanager.Config{ PrivateKey: "priv", PreSharedKey: "psk", ManagementURL: mURL, AdminURL: aURL, WgIface: "wt0", WgPort: 51820, NetworkMonitor: &bTrue, IFaceBlackList: []string{"eth0"}, DisableIPv6Discovery: true, RosenpassEnabled: true, RosenpassPermissive: true, ServerSSHAllowed: &bTrue, RemoteJobsAllowed: &bTrue, EnableSSHRoot: &bTrue, EnableSSHSFTP: &bTrue, EnableSSHLocalPortForwarding: &bTrue, EnableSSHRemotePortForwarding: &bTrue, DisableSSHAuth: &bTrue, SSHJWTCacheTTL: &iVal, DisableClientRoutes: true, DisableServerRoutes: true, DisableDNS: true, DisableFirewall: true, BlockLANAccess: true, BlockInbound: true, DisableNotifications: &bTrue, DNSLabels: domain.List{}, SSHKey: "sshkey", NATExternalIPs: []string{"1.2.3.4"}, CustomDNSAddress: "1.1.1.1:53", DisableAutoConnect: true, DNSRouteInterval: 5 * time.Second, ClientCertPath: "/tmp/cert", ClientCertKeyPath: "/tmp/key", LazyConnection: "on", DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret", MTU: 1280, DisableIPv6: true, SyncMessageVersion: func(v int) *int { return &v }(1), } for _, anonymize := range []bool{false, true} { t.Run("anonymize="+map[bool]string{true: "true", false: "false"}[anonymize], func(t *testing.T) { g := &BundleGenerator{ anonymizer: newAnonymizerForTest(), internalConfig: cfg, anonymize: anonymize, } var sb strings.Builder g.addCommonConfigFields(&sb) rendered := sb.String() + renderAddConfigSpecific(g) // DebugBundleUploadURL is an MDM-provided value that can carry // credentials or signed query tokens. It is deliberately excluded // above; assert it never reaches the rendered bundle — neither the // field name nor the token — in either anonymize mode. assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle") assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle") val := reflect.ValueOf(cfg).Elem() typ := val.Type() var missing []string for i := 0; i < typ.NumField(); i++ { name := typ.Field(i).Name if _, ok := excluded[name]; ok { continue } if !strings.Contains(rendered, name+":") { missing = append(missing, name) } } if len(missing) > 0 { t.Fatalf("Config field(s) not present in debug bundle output: %v\n"+ "Either render the field in addCommonConfigFields/addConfig, "+ "or add it to the excluded map with a justification.", missing) } }) } } // renderAddConfigSpecific renders the fields handled by the anonymize/non-anonymize // branches in addConfig (ManagementURL, AdminURL, NATExternalIPs, CustomDNSAddress). // addCommonConfigFields covers the rest. Keeping this in the test mirrors the // production shape without needing to write an actual zip. func renderAddConfigSpecific(g *BundleGenerator) string { var sb strings.Builder if g.anonymize { if g.internalConfig.ManagementURL != nil { sb.WriteString("ManagementURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()) + "\n") } if g.internalConfig.AdminURL != nil { sb.WriteString("AdminURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.AdminURL.String()) + "\n") } sb.WriteString("NATExternalIPs: x\n") if g.internalConfig.CustomDNSAddress != "" { sb.WriteString("CustomDNSAddress: " + g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress) + "\n") } } else { if g.internalConfig.ManagementURL != nil { sb.WriteString("ManagementURL: " + g.internalConfig.ManagementURL.String() + "\n") } if g.internalConfig.AdminURL != nil { sb.WriteString("AdminURL: " + g.internalConfig.AdminURL.String() + "\n") } sb.WriteString("NATExternalIPs: x\n") if g.internalConfig.CustomDNSAddress != "" { sb.WriteString("CustomDNSAddress: " + g.internalConfig.CustomDNSAddress + "\n") } } return sb.String() } func newAnonymizerForTest() *anonymize.Anonymizer { return anonymize.NewAnonymizer(anonymize.DefaultAddresses()) } func TestRemoveStaleBundles(t *testing.T) { dir := t.TempDir() stale := filepath.Join(dir, "netbird.debug.111.zip") fresh := filepath.Join(dir, "netbird.debug.222.zip") other := filepath.Join(dir, "netbird.debug.333.txt") owned := filepath.Join(dir, "netbird.debug.444.zip") abandoned := filepath.Join(dir, "netbird.debug.555.zip") for _, p := range []string{stale, fresh, other, owned, abandoned} { require.NoError(t, os.WriteFile(p, []byte("x"), 0o600)) } exported, err := ExportBundle(owned) require.NoError(t, err) exportedAbandoned, err := ExportBundle(abandoned) require.NoError(t, err) old := time.Now().Add(-2 * time.Hour) for _, p := range []string{stale, other, exported} { require.NoError(t, os.Chtimes(p, old, old)) } ancient := time.Now().Add(-exportedBundleMaxAge - time.Hour) require.NoError(t, os.Chtimes(exportedAbandoned, ancient, ancient)) RemoveStaleBundles(dir, time.Hour) assert.NoFileExists(t, stale, "bundle older than maxAge should be removed") assert.FileExists(t, fresh, "bundle younger than maxAge must survive, it may still be uploading") assert.FileExists(t, other, "files outside the bundle pattern must not be touched") assert.NoFileExists(t, owned) assert.FileExists(t, exported, "exported bundle is caller-owned and must survive maxAge") assert.NoFileExists(t, exportedAbandoned, "exported bundle older than exportedBundleMaxAge is abandoned") } func TestBundleIncludesNetworkMap(t *testing.T) { for _, anonymize := range []bool{false, true} { t.Run(fmt.Sprintf("anonymize=%t", anonymize), func(t *testing.T) { g := NewBundleGenerator(GeneratorDependencies{ SyncResponse: &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 1}}, }, BundleConfig{Anonymize: anonymize}) require.Contains(t, bundleEntries(t, g), "network_map.json") }) } } func TestBundleOmitsNetworkMapWithoutSyncResponse(t *testing.T) { g := NewBundleGenerator(GeneratorDependencies{}, BundleConfig{}) require.NotContains(t, bundleEntries(t, g), "network_map.json") }