Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion client/anonymize/anonymize.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,12 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
return domain
}

// A reverse zone names an address prefix, so it follows the address rules,
// which also keeps its digit labels intact.
if zone, ok := a.anonymizeReverseZone(baseDomain); ok {
return withTrailingDot(zone, hasDot)
}

if suffix := protectedSuffix(baseDomain); suffix != "" {
if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
return domain
Expand Down Expand Up @@ -405,6 +411,10 @@ func (a *Anonymizer) AnonymizeString(str string) string {
ipv4Regex := regexp.MustCompile(`\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b`)
ipv6Regex := regexp.MustCompile(`\b([0-9a-fA-F:]+:+[0-9a-fA-F]{0,4})(?:%[0-9a-zA-Z]+)?(?:\/[0-9]{1,3})?(?::[0-9]{1,5})?\b`)

// Reverse zones go first and are then held out of the passes below: their
// labels are digits, which the address patterns would otherwise consume.
str, restoreZones := a.replaceReverseZones(str)

str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)

Expand All @@ -425,7 +435,7 @@ func (a *Anonymizer) AnonymizeString(str string) string {
str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
}

return str
return restoreZones(str)
}

// sortedDomains returns the domain mappings longest-first, so a full-FQDN
Expand Down
174 changes: 174 additions & 0 deletions client/anonymize/reverse_zone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package anonymize

import (
"encoding/hex"
"net/netip"
"regexp"
"strconv"
"strings"
)

const (
reverseZoneSuffixV4 = ".in-addr.arpa"
reverseZoneSuffixV6 = ".ip6.arpa"

v6Nibbles = 32
v4Octets = 4
)

// reverseZoneRegexes match a reverse zone or a full reverse name in free text.
// They are applied before the address passes of AnonymizeString, whose IPv4
// pattern would otherwise consume the digit labels of a zone and replace parts
// of it with unrelated addresses.
var reverseZoneRegexes = []*regexp.Regexp{
regexp.MustCompile(`(?:[0-9]{1,3}\.){1,4}in-addr\.arpa\b`),
regexp.MustCompile(`(?:[0-9a-fA-F]\.){1,32}ip6\.arpa\b`),
}

// anonymizeReverseZone maps a reverse zone to the zone of the anonymized form
// of the prefix it encodes, so it follows the address rules rather than the
// domain ones: the zone of an address that is preserved is preserved too, and
// the zone of one that is replaced names the replacement. This keeps a reverse
// zone recognizable as such, and consistent with the addresses it belongs to
// elsewhere in the same output. It reports false for anything that is not a
// reverse zone.
func (a *Anonymizer) anonymizeReverseZone(domain string) (string, bool) {
prefix, labelCount, suffix, ok := parseReverseZone(domain)
if !ok {
return "", false
}

anonymized := a.AnonymizeIP(prefix)
if anonymized == prefix {
return domain, true
}

return reverseZoneName(anonymized, labelCount) + suffix, true
}

// replaceReverseZones anonymizes every reverse zone in str and swaps each one
// for a placeholder, returning a function that puts the anonymized zones back.
// The placeholders carry no dots, digits or colons, so no later pass matches
// them.
func (a *Anonymizer) replaceReverseZones(str string) (string, func(string) string) {
var zones []string

for _, re := range reverseZoneRegexes {
str = re.ReplaceAllStringFunc(str, func(match string) string {
zone, ok := a.anonymizeReverseZone(match)
if !ok {
return match
}

zones = append(zones, zone)
return reverseZonePlaceholder(len(zones) - 1)
})
}

if len(zones) == 0 {
return str, func(s string) string { return s }
}

return str, func(s string) string {
for i, zone := range zones {
s = strings.ReplaceAll(s, reverseZonePlaceholder(i), zone)
}
return s
}
}

func reverseZonePlaceholder(index int) string {
return "\x00reversezone" + strconv.Itoa(index) + "\x00"
}

// parseReverseZone turns a reverse zone into the address of the prefix its
// labels spell backwards, padding the absent low-order part with zeroes, and
// returns the label count and zone suffix so the name can be rebuilt.
func parseReverseZone(domain string) (netip.Addr, int, string, bool) {
lower := strings.ToLower(domain)

switch {
case strings.HasSuffix(lower, reverseZoneSuffixV4):
labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV4), ".")
addr, ok := reverseZoneAddrV4(labels)
return addr, len(labels), reverseZoneSuffixV4, ok
case strings.HasSuffix(lower, reverseZoneSuffixV6):
labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV6), ".")
addr, ok := reverseZoneAddrV6(labels)
return addr, len(labels), reverseZoneSuffixV6, ok
default:
return netip.Addr{}, 0, "", false
}
}

func reverseZoneAddrV4(labels []string) (netip.Addr, bool) {
if len(labels) == 0 || len(labels) > v4Octets {
return netip.Addr{}, false
}

var octets [v4Octets]byte
for i, label := range labels {
octet, err := strconv.ParseUint(label, 10, 8)
if err != nil {
return netip.Addr{}, false
}
octets[len(labels)-1-i] = byte(octet)
}

return netip.AddrFrom4(octets), true
}

func reverseZoneAddrV6(labels []string) (netip.Addr, bool) {
if len(labels) == 0 || len(labels) > v6Nibbles {
return netip.Addr{}, false
}

nibbles := make([]byte, 0, v6Nibbles)
for i := len(labels) - 1; i >= 0; i-- {
if len(labels[i]) != 1 || !isHexDigit(labels[i][0]) {
return netip.Addr{}, false
}
nibbles = append(nibbles, labels[i][0])
}
for len(nibbles) < v6Nibbles {
nibbles = append(nibbles, '0')
}

var groups []string
for i := 0; i < len(nibbles); i += 4 {
groups = append(groups, string(nibbles[i:i+4]))
}

addr, err := netip.ParseAddr(strings.Join(groups, ":"))
if err != nil {
return netip.Addr{}, false
}

return addr, true
}

// reverseZoneName spells the first labelCount labels of addr backwards, the
// inverse of parseReverseZone, without the zone suffix.
func reverseZoneName(addr netip.Addr, labelCount int) string {
labels := make([]string, 0, labelCount)

if addr.Is4() {
octets := addr.As4()
for i := labelCount - 1; i >= 0; i-- {
labels = append(labels, strconv.Itoa(int(octets[i])))
}
return strings.Join(labels, ".")
}

address := addr.As16()
nibbles := hex.EncodeToString(address[:])
for i := labelCount - 1; i >= 0; i-- {
labels = append(labels, string(nibbles[i]))
}

return strings.Join(labels, ".")
}

func isHexDigit(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
171 changes: 171 additions & 0 deletions client/anonymize/reverse_zone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package anonymize

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func newLeveledAnonymizer(level Level) *Anonymizer {
a := NewAnonymizer(DefaultAddresses())
a.SetLevel(level)
return a
}

// TestAnonymizeDomainReverseZone covers reverse zones going through the address
// rules instead of the domain ones, so a zone stays a zone and an address that
// is preserved keeps the zone that names it.
func TestAnonymizeDomainReverseZone(t *testing.T) {
// 100.64.0.0/10 is the overlay range, which is CGNAT: preserved at the
// default level and replaced from the internal pool at the strict one
const overlayZone = "64.100.in-addr.arpa"

t.Run("overlay zone preserved at the default level", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
assert.Equal(t, overlayZone, a.AnonymizeDomain(overlayZone), "should keep the zone of a preserved address")
})

t.Run("private zone preserved at the default level", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
assert.Equal(t, "168.192.in-addr.arpa", a.AnonymizeDomain("168.192.in-addr.arpa"), "should keep the zone of a private address")
})

t.Run("overlay zone replaced at the strict level", func(t *testing.T) {
a := newLeveledAnonymizer(LevelStrict)

got := a.AnonymizeDomain(overlayZone)
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got)
assert.NotEqual(t, overlayZone, got, "should replace the encoded prefix")
assert.Len(t, strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV4), "."), 2,
"should keep the label count, got %q", got)
})

t.Run("public zone replaced at the default level", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)

got := a.AnonymizeDomain("113.0.203.in-addr.arpa")
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got)
assert.NotEqual(t, "113.0.203.in-addr.arpa", got, "should replace a public prefix")
})

t.Run("zone of an address keeps that address mapping", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)

anonymizedAddr := a.AnonymizeIPString("203.0.113.7")
got := a.AnonymizeDomain("7.113.0.203.in-addr.arpa")

octets := strings.Split(anonymizedAddr, ".")
want := octets[3] + "." + octets[2] + "." + octets[1] + "." + octets[0] + reverseZoneSuffixV4
assert.Equal(t, want, got, "should name the same replacement as the address itself")
})

t.Run("ipv6 nibble labels stay single digits", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)

zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6
got := a.AnonymizeDomain(zone)

require.True(t, strings.HasSuffix(got, reverseZoneSuffixV6), "should stay a reverse zone, got %q", got)
labels := strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV6), ".")
assert.Len(t, labels, 28, "should keep every nibble label, got %q", got)
for _, label := range labels {
assert.Len(t, label, 1, "nibble label %q should stay a single digit", label)
}
})

t.Run("trailing dot is kept", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)
assert.Equal(t, "64.100.in-addr.arpa.", a.AnonymizeDomain("64.100.in-addr.arpa."), "should keep the trailing dot")
})

t.Run("a domain that only looks like a zone is anonymized as a domain", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)

got := a.AnonymizeDomain("not-a-zone.in-addr.arpa")
assert.NotContains(t, got, "in-addr.arpa", "should fall back to domain anonymization")
})
}

// TestAnonymizeStringReverseZone verifies that a zone inside free text, such as
// a DNS log line, is not chewed up by the address passes. The IPv4 pattern
// matches any run of dotted digits, which a reverse zone is made of.
func TestAnonymizeStringReverseZone(t *testing.T) {
t.Run("ipv6 zone survives the address passes", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)

zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6
got := a.AnonymizeString("question: domain=" + zone + " type=PTR")

assert.Contains(t, got, "type=PTR", "should keep the rest of the line")
assert.NotContains(t, got, "198.51.100", "should not rewrite nibble labels as an address")

labels := strings.Split(strings.TrimSuffix(strings.TrimPrefix(got, "question: domain="), reverseZoneSuffixV6+" type=PTR"), ".")
assert.Len(t, labels, 28, "should keep every nibble label, got %q", got)
})

t.Run("preserved ipv4 zone is untouched", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)

line := "reverse zone 64.100.in-addr.arpa registered"
assert.Equal(t, line, a.AnonymizeString(line), "should keep the zone of a preserved address")
})

t.Run("public ipv4 zone is replaced consistently", func(t *testing.T) {
a := newLeveledAnonymizer(LevelDefault)

got := a.AnonymizeString("zone 113.0.203.in-addr.arpa and address 203.0.113.7")
assert.NotContains(t, got, "113.0.203.in-addr.arpa", "should replace the zone")
assert.NotContains(t, got, "203.0.113.7", "should replace the address")
assert.Contains(t, got, reverseZoneSuffixV4, "should keep the zone suffix")
})
}

func TestParseReverseZone(t *testing.T) {
tests := []struct {
name string
zone string
addr string
labels int
}{
{name: "v4 two labels", zone: "0.100" + reverseZoneSuffixV4, addr: "100.0.0.0", labels: 2},
{name: "v4 three labels", zone: "1.168.192" + reverseZoneSuffixV4, addr: "192.168.1.0", labels: 3},
{name: "v4 full address", zone: "7.113.0.203" + reverseZoneSuffixV4, addr: "203.0.113.7", labels: 4},
{
name: "v6 prefix",
zone: "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6,
addr: "2::",
labels: 28,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
addr, labels, suffix, ok := parseReverseZone(tc.zone)
require.True(t, ok, "should decode the reverse zone")
assert.Equal(t, tc.addr, addr.String(), "should decode to the encoded prefix")
assert.Equal(t, tc.labels, labels, "should count the labels")
assert.Equal(t, tc.zone, reverseZoneName(addr, labels)+suffix, "should re-encode to the original zone")
})
}
}

func TestParseReverseZoneRejectsNonZones(t *testing.T) {
tests := []string{
"example.com",
"in-addr.arpa",
"x.100" + reverseZoneSuffixV4,
"256" + reverseZoneSuffixV4,
"1.2.3.4.5" + reverseZoneSuffixV4,
"ab" + reverseZoneSuffixV6,
"g" + reverseZoneSuffixV6,
}

for _, zone := range tests {
t.Run(zone, func(t *testing.T) {
_, _, _, ok := parseReverseZone(zone)
assert.False(t, ok, "should reject %q", zone)
})
}
}
Loading
Loading