diff --git a/pkg/cookiebanner/tracker_mapping_worker.go b/pkg/cookiebanner/tracker_mapping_worker.go index c81f6c6ea..7a63f15ad 100644 --- a/pkg/cookiebanner/tracker_mapping_worker.go +++ b/pkg/cookiebanner/tracker_mapping_worker.go @@ -280,10 +280,11 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker // deterministicResult carries the outcome of the pure-SQL catalog // signals (existing link, pattern, sibling origin, domain overlap) from // the read phase to the agent and persist phases. domains holds the -// unfiltered initiator domains observed for the pattern (used by the -// sibling re-enqueue cascade); commonThirdPartyPreexisted records -// whether a catalog third party was already known before this run, so -// the cascade only fires when this run is the one that resolved it. +// observed initiator domains for the pattern with shared-infrastructure +// hosts removed (used by the sibling re-enqueue cascade); +// commonThirdPartyPreexisted records whether a catalog third party was +// already known before this run, so the cascade only fires when this run +// is the one that resolved it. type deterministicResult struct { origin string commonPatternID *gid.GID @@ -312,9 +313,6 @@ func (h *trackerMappingHandler) resolveDeterministic( return res, fmt.Errorf("cannot load cookie banner for domain filtering: %w", err) } - // DEBUG: don't commit - banner.Origin = "https://t.probo.com" - res.origin = banner.Origin if tp.CommonTrackerPatternID != nil { @@ -349,16 +347,22 @@ func (h *trackerMappingHandler) resolveDeterministic( return res, err } - res.domains = loaded + // Shared tracker-delivery infrastructure (tag managers, CDPs, generic + // CDNs) initiates trackers for many unrelated vendors, so a shared + // initiator domain among them is not a same-vendor signal. Strip them + // once here so no downstream domain-overlap heuristic (sibling + // grouping, catalog domain match, or the sibling re-enqueue cascade) + // can group unrelated trackers on, say, a common googletagmanager.com. + res.domains = uri.FilterSharedInfrastructureDomains(loaded) // Sibling matching is an org-local co-occurrence signal: two // patterns served from the same origin on the same banner are likely // the same vendor, even when that origin is the site's own // (first-party) host — a tracker proxied through first-party still - // co-occurs with its siblings. So it intentionally runs on the - // unfiltered domains; the ambiguity guard in - // resolveThirdPartyFromSiblings prevents grouping unrelated - // first-party scripts. + // co-occurs with its siblings. So it intentionally keeps first-party + // domains (shared infrastructure was already removed above); the + // ambiguity guard in resolveThirdPartyFromSiblings prevents grouping + // unrelated first-party scripts. siblingMatch, err := h.matchBySiblingOrigin(ctx, tx, tp, res.domains) if err != nil { return res, fmt.Errorf("cannot match by sibling origin: %w", err) diff --git a/pkg/cookiebanner/tracker_mapping_worker_test.go b/pkg/cookiebanner/tracker_mapping_worker_test.go index 78709ecf8..5bb459c37 100644 --- a/pkg/cookiebanner/tracker_mapping_worker_test.go +++ b/pkg/cookiebanner/tracker_mapping_worker_test.go @@ -512,8 +512,10 @@ func TestMatchBySiblingOrigin_SiblingWithThirdPartyID(t *testing.T) { } // Detected trackers store the eTLD+1 (uri.ExtractDomain), so the - // sibling lookup matches on that exact value. - initiatorDomain := "googletagmanager.com" + // sibling lookup matches on that exact value. Use a vendor-specific + // domain rather than shared infrastructure (e.g. googletagmanager.com), + // which resolveDeterministic strips before sibling grouping. + initiatorDomain := "google-analytics.com" siblingDetected := coredata.DetectedTracker{ ID: gid.New(fx.scope.GetTenantID(), coredata.DetectedTrackerEntityType), CookieBannerID: fx.banner.ID, @@ -569,7 +571,7 @@ func TestMatchBySiblingOrigin_SiblingWithThirdPartyID(t *testing.T) { require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { var err error - got, err = h.matchBySiblingOrigin(ctx, tx, unmappedPattern, []string{"googletagmanager.com"}) + got, err = h.matchBySiblingOrigin(ctx, tx, unmappedPattern, []string{"google-analytics.com"}) return err })) @@ -1120,7 +1122,10 @@ func TestProcess_BackfillsCommonThirdPartyFromSibling(t *testing.T) { UpdatedAt: now, } - initiatorDomain := "googletagmanager.com" + // A vendor-specific initiator domain: resolveDeterministic strips + // shared infrastructure (e.g. googletagmanager.com) before sibling + // grouping, so the backfill must be driven by a real vendor domain. + initiatorDomain := "google-analytics.com" siblingDetected := coredata.DetectedTracker{ ID: gid.New(fx.scope.GetTenantID(), coredata.DetectedTrackerEntityType), diff --git a/pkg/uri/uri.go b/pkg/uri/uri.go index d404ce325..8a75933dd 100644 --- a/pkg/uri/uri.go +++ b/pkg/uri/uri.go @@ -125,3 +125,117 @@ func FilterFirstPartyDomains(domains []string, siteOrigin string) []string { return filtered } + +// sharedInfrastructureDomains are eTLD+1 hosts that deliver trackers on +// behalf of many unrelated vendors: tag managers, customer-data +// platforms, and generic CDNs / static-asset / app-hosting domains. A +// tracker whose initiator domain is one of these tells us nothing about +// which vendor set it (e.g. a Meta pixel and a LinkedIn tag both loaded +// through Google Tag Manager would otherwise look like the same third +// party), so domain-overlap heuristics must ignore them. +// +// This set is the tuning surface for that exclusion. Two rules keep it +// safe to extend: +// +// - Only add domains that serve content for many *unrelated* vendors. +// Omit vendor-specific domains such as google-analytics.com: those +// are a legitimate same-vendor signal even though Google also runs +// Tag Manager and gstatic. +// - Entries are eTLD+1, so they must not collapse onto a vendor. We +// skip cloudflare.com for this reason (cdnjs.cloudflare.com shares +// its eTLD+1 with Cloudflare-the-vendor's own properties). +// +// For exhaustive, maintained coverage this would ideally be sourced from +// a community dataset (DuckDuckGo Tracker Radar's `cnames`/CDN entries or +// Disconnect's services list) vendored into the repo, rather than hand +// curated. This list covers the common offenders without that dependency; +// excluding a domain only ever makes grouping more conservative, so +// over-inclusion is the safe failure mode. +var sharedInfrastructureDomains = map[string]struct{}{ + // Tag managers, customer-data platforms, and tag delivery. + "googletagmanager.com": {}, + "segment.io": {}, + "segment.com": {}, + "tealium.com": {}, + "tiqcdn.com": {}, + "ensighten.com": {}, + "adobedtm.com": {}, + "mparticle.com": {}, + "rudderlabs.com": {}, + "rudderstack.com": {}, + "tagcommander.com": {}, + "commander1.com": {}, + + // Commercial CDNs and edge networks. + "cloudfront.net": {}, + "akamai.net": {}, + "akamaihd.net": {}, + "akamaized.net": {}, + "akamaiedge.net": {}, + "edgekey.net": {}, + "edgesuite.net": {}, + "fastly.net": {}, + "fastlylb.net": {}, + "azureedge.net": {}, + "azurefd.net": {}, + "edgecastcdn.net": {}, + "llnwd.net": {}, + "hwcdn.net": {}, + "cachefly.net": {}, + "stackpathdns.com": {}, + "stackpathcdn.com": {}, + "netdna-cdn.com": {}, + "netdna-ssl.com": {}, + "kxcdn.com": {}, + "b-cdn.net": {}, + + // Library, package, and static-asset CDNs. + "jsdelivr.net": {}, + "unpkg.com": {}, + "bootstrapcdn.com": {}, + "maxcdn.com": {}, + "cdnjs.com": {}, + "jquery.com": {}, + "aspnetcdn.com": {}, + "skypack.dev": {}, + "esm.sh": {}, + "googleapis.com": {}, + "gstatic.com": {}, + + // Font and media CDNs. + "typekit.net": {}, + "fontawesome.com": {}, + "cloudinary.com": {}, + "imgix.net": {}, + + // Object storage and generic app / static hosting. + "amazonaws.com": {}, + "github.io": {}, + "githubusercontent.com": {}, + "herokuapp.com": {}, + "vercel.app": {}, + "netlify.app": {}, + "pages.dev": {}, + "web.app": {}, + "firebaseapp.com": {}, + "wp.com": {}, +} + +// FilterSharedInfrastructureDomains removes eTLD+1 domains that belong to +// shared tracker-delivery infrastructure (tag managers, customer-data +// platforms, and generic CDNs). Such domains initiate trackers for many +// unrelated vendors, so a shared initiator domain among them is not a +// same-vendor signal and must not drive domain-overlap grouping. The +// input domains are expected to be eTLD+1 strings (as produced by +// ExtractDomain). +func FilterSharedInfrastructureDomains(domains []string) []string { + filtered := make([]string, 0, len(domains)) + + for _, d := range domains { + if _, shared := sharedInfrastructureDomains[strings.ToLower(d)]; !shared { + filtered = append(filtered, d) + } + } + + return filtered +} diff --git a/pkg/uri/uri_test.go b/pkg/uri/uri_test.go index d2b3f03cc..4f6d49870 100644 --- a/pkg/uri/uri_test.go +++ b/pkg/uri/uri_test.go @@ -364,3 +364,56 @@ func TestFilterFirstPartyDomains(t *testing.T) { }) } } + +func TestFilterSharedInfrastructureDomains(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + domains []string + want []string + }{ + { + name: "removes tag manager domain", + domains: []string{"googletagmanager.com", "posthog.com"}, + want: []string{"posthog.com"}, + }, + { + name: "removes generic cdn domain", + domains: []string{"cloudfront.net", "hotjar.com"}, + want: []string{"hotjar.com"}, + }, + { + name: "keeps vendor-specific domains", + domains: []string{"google-analytics.com", "stripe.com"}, + want: []string{"google-analytics.com", "stripe.com"}, + }, + { + name: "case insensitive match", + domains: []string{"GoogleTagManager.com", "Segment.IO"}, + want: []string{}, + }, + { + name: "mixed infra and vendor", + domains: []string{"gstatic.com", "doubleclick.net", "jsdelivr.net"}, + want: []string{"doubleclick.net"}, + }, + { + name: "empty domains list", + domains: []string{}, + want: []string{}, + }, + { + name: "nil domains list", + domains: nil, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, FilterSharedInfrastructureDomains(tt.domains)) + }) + } +}