diff --git a/pkg/cookiebanner/prompts/tracker_identification.txt.tmpl b/pkg/cookiebanner/prompts/tracker_identification.txt.tmpl index a8e4951cf..7ee9256f7 100644 --- a/pkg/cookiebanner/prompts/tracker_identification.txt.tmpl +++ b/pkg/cookiebanner/prompts/tracker_identification.txt.tmpl @@ -38,7 +38,9 @@ Return a structured JSON response with: 5. The observed domains are useful signals but not conclusive on their own. Many sites load third-party tracker scripts through a first-party reverse proxy (e.g. t.example.com proxying PostHog). When a domain matches the scanned site, it reveals nothing about which third party set the tracker — rely on the naming convention or a database/web search instead. First-party proxy domains are filtered before they reach you, but if you still see the scanned site's own domain, ignore it as evidence. Well-known third-party tracking domains (e.g. doubleclick.net, facebook.com, analytics.google.com) remain strong evidence. -6. Be conservative with third_party_confidence. It measures certainty about the attribution (who set the tracker), nothing else: +6. A domain or full URL appearing INSIDE the pattern name is NOT a third-party signal when it matches . It is the site's own first-party origin — commonly a browser extension that appended the page URL to its key (e.g. "ethereum-https://example.com", where the wallet extension suffixes the site origin), or a tracker the site owner set on their own site. The site owner is never a third party of its own site, so you must NOT attribute the scanned site's own brand or domain as a third party. If the embedded own-domain is the ONLY vendor cue, return an empty third_party_name with third_party_confidence below 0.3. A genuine naming convention elsewhere in the key (e.g. _ga, _fbp, _hj) still attributes normally — judge that on its own merits, independent of the embedded site domain. + +7. Be conservative with third_party_confidence. It measures certainty about the attribution (who set the tracker), nothing else: - 0.9-1.0: exact pattern match found in database, or unmistakable naming convention + matching domain, or a name that embeds the vendor (e.g. "__darkreader__*" -> Dark Reader) - 0.7-0.8: strong signal from naming convention or domain, but not a database match - 0.5-0.6: reasonable guess based on partial naming patterns @@ -46,10 +48,10 @@ Return a structured JSON response with: Do not lower third_party_confidence just because the artifact is a browser-extension key, localStorage entry, or otherwise not a classic web tracker. The goal is to attribute the vendor, not to judge how "tracker-worthy" the artifact is — if the name unambiguously names its source, attribute it with high confidence. -7. If you truly cannot identify who set the tracker, set third_party_name to an empty string and third_party_confidence below 0.3. +8. If you truly cannot identify who set the tracker, set third_party_name to an empty string and third_party_confidence below 0.3. -8. Only attribute a tracker to a company or service when you have concrete evidence: an exact (perfect) match on the pattern in the database, an unmistakable naming convention where the tracker's meaningful prefix belongs to that vendor (including a vendor name embedded in the key), or a clear web search result whose tracker name shares that meaningful prefix. Absent a shared meaningful prefix or a perfect pattern match, do NOT imagine a vendor — never guess or invent attributions based on vague similarity, a shared generic word, or general knowledge. If no evidence supports a match, return an empty third_party_name with third_party_confidence below 0.3. +9. Only attribute a tracker to a company or service when you have concrete evidence: an exact (perfect) match on the pattern in the database, an unmistakable naming convention where the tracker's meaningful prefix belongs to that vendor (including a vendor name embedded in the key), or a clear web search result whose tracker name shares that meaningful prefix. Absent a shared meaningful prefix or a perfect pattern match, do NOT imagine a vendor — never guess or invent attributions based on vague similarity, a shared generic word, or general knowledge. If no evidence supports a match, return an empty third_party_name with third_party_confidence below 0.3. -9. For the category field, use one of: {{.Categories}}. +10. For the category field, use one of: {{.Categories}}. Most cookies fall under ANALYTICS or MARKETING. diff --git a/pkg/cookiebanner/tracker_mapping_agent.go b/pkg/cookiebanner/tracker_mapping_agent.go index 2873c38c0..fa616c201 100644 --- a/pkg/cookiebanner/tracker_mapping_agent.go +++ b/pkg/cookiebanner/tracker_mapping_agent.go @@ -171,9 +171,13 @@ func buildTrackerIdentificationPrompt( ) } -func buildAgentPrompt(tp coredata.TrackerPattern, domains []string) string { +func buildAgentPrompt(tp coredata.TrackerPattern, domains []string, siteDomain string) string { prompt := buildTrackerIdentificationPrompt(tp.Pattern, tp.TrackerType, tp.MatchType, tp.MaxAgeSeconds) + if siteDomain != "" { + prompt += fmt.Sprintf(" %s \n", siteDomain) + } + if len(domains) > 0 { prompt += fmt.Sprintf(" %s \n", strings.Join(domains, ", ")) } diff --git a/pkg/cookiebanner/tracker_mapping_agent_test.go b/pkg/cookiebanner/tracker_mapping_agent_test.go new file mode 100644 index 000000000..4ad01f14d --- /dev/null +++ b/pkg/cookiebanner/tracker_mapping_agent_test.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cookiebanner + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/coredata" +) + +func TestNameMatchesSiteDomain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + vendorName string + siteOrigin string + expected bool + }{ + { + name: "site brand matches own domain", + vendorName: "Letaido", + siteOrigin: "https://letaido.com", + expected: true, + }, + { + name: "case and spacing insensitive", + vendorName: " LET AIDO ", + siteOrigin: "https://letaido.com", + expected: true, + }, + { + name: "matches against subdomain origin", + vendorName: "Letaido", + siteOrigin: "https://app.letaido.com", + expected: true, + }, + { + name: "matches full domain form", + vendorName: "letaido.com", + siteOrigin: "https://letaido.com", + expected: true, + }, + { + name: "unrelated vendor is not the site", + vendorName: "Google Analytics", + siteOrigin: "https://letaido.com", + expected: false, + }, + { + name: "empty vendor name", + vendorName: "", + siteOrigin: "https://letaido.com", + expected: false, + }, + { + name: "empty origin", + vendorName: "Letaido", + siteOrigin: "", + expected: false, + }, + { + name: "unparseable origin", + vendorName: "Letaido", + siteOrigin: "not a url", + expected: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, nameMatchesSiteDomain(tt.vendorName, tt.siteOrigin)) + }, + ) + } +} + +func TestNormalizeAlnum(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + {name: "letters lowercased", input: "Letaido", expected: "letaido"}, + {name: "strips punctuation and spaces", input: "letaido.com - Inc", expected: "letaidocominc"}, + {name: "keeps digits", input: "auth0", expected: "auth0"}, + {name: "empty", input: "", expected: ""}, + {name: "only punctuation", input: "-_.:", expected: ""}, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, normalizeAlnum(tt.input)) + }, + ) + } +} + +func TestBuildAgentPrompt(t *testing.T) { + t.Parallel() + + tp := coredata.TrackerPattern{ + Pattern: "ethereum-https://letaido.com", + TrackerType: coredata.TrackerTypeLocalStorage, + MatchType: coredata.TrackerPatternMatchTypeExact, + } + + t.Run( + "emits scanned_site when domain supplied", + func(t *testing.T) { + t.Parallel() + + prompt := buildAgentPrompt(tp, nil, "letaido.com") + assert.Contains(t, prompt, " letaido.com ") + }, + ) + + t.Run( + "omits scanned_site when domain empty", + func(t *testing.T) { + t.Parallel() + + prompt := buildAgentPrompt(tp, nil, "") + assert.NotContains(t, prompt, "") + }, + ) + + t.Run( + "emits both scanned_site and observed_domains", + func(t *testing.T) { + t.Parallel() + + prompt := buildAgentPrompt(tp, []string{"doubleclick.net"}, "letaido.com") + assert.Contains(t, prompt, " letaido.com ") + assert.Contains(t, prompt, " doubleclick.net ") + assert.Less(t, strings.Index(prompt, ""), strings.Index(prompt, "")) + }, + ) +} diff --git a/pkg/cookiebanner/tracker_mapping_worker.go b/pkg/cookiebanner/tracker_mapping_worker.go index 4b85d732d..13abc6386 100644 --- a/pkg/cookiebanner/tracker_mapping_worker.go +++ b/pkg/cookiebanner/tracker_mapping_worker.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "go.gearno.de/kit/log" @@ -637,7 +638,9 @@ func (h *trackerMappingHandler) identifyWithAgent( domains = uri.FilterFirstPartyDomains(domains, siteOrigin) - prompt := buildAgentPrompt(tp, domains) + siteDomain := uri.ExtractDomain(siteOrigin) + + prompt := buildAgentPrompt(tp, domains, siteDomain) agentCtx, cancel := context.WithTimeout(ctx, h.agentTimeout) defer cancel() @@ -680,11 +683,67 @@ func (h *trackerMappingHandler) identifyWithAgent( return nil, nil } + // Backstop for the prompt rule that the scanned site is never a third + // party of itself: a pattern that embeds the site's own domain (e.g. + // an "ethereum-https://example.com" wallet-extension key, or an + // owner-set tracker) can lead the agent to attribute the site's own + // brand. Discard such attributions outright so the pattern falls + // through to the unmatched fallback instead of being mapped to the + // site owner. + if nameMatchesSiteDomain(identification.ThirdPartyName, siteOrigin) { + h.logger.InfoCtx( + ctx, + "agent attributed scanned site as third party, discarding", + log.String("pattern", tp.Pattern), + ) + + return nil, nil + } + return &agentIdentification{ result: identification, }, nil } +// nameMatchesSiteDomain reports whether a candidate vendor name refers to +// the scanned site itself. The site owner is never a third party of its +// own site, so an attribution whose name resolves to the site's own +// domain must be rejected. The comparison is alphanumeric-normalised and +// conservative (equality against the eTLD+1 and its primary label) to +// avoid suppressing unrelated vendors whose name merely overlaps. +func nameMatchesSiteDomain(name, siteOrigin string) bool { + domain := uri.ExtractDomain(siteOrigin) + if domain == "" { + return false + } + + normalizedName := normalizeAlnum(name) + if normalizedName == "" { + return false + } + + label, _, _ := strings.Cut(domain, ".") + + return normalizedName == normalizeAlnum(domain) || + normalizedName == normalizeAlnum(label) +} + +// normalizeAlnum lowercases s and keeps only ASCII letters and digits, +// so vendor names and domains can be compared free of spacing, +// punctuation, and casing differences (e.g. "Letaido" and "letaido.com" +// both reduce to a comparable form). +func normalizeAlnum(s string) string { + var b strings.Builder + + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + + return b.String() +} + // persistAgentIdentification writes a confident agent identification: // it resolves or creates the catalog third party and upserts the // catalog pattern row that links to it. It runs inside the caller's