Files
probo/pkg/cookiebanner/pattern_analysis_worker.go
Émile Ré 1c3ce56b48 Mark page-world extension writes with EXTENSION source
The previous cleanup deleted every isExtensionCaller() site, including
the one in cookie/storage detectors that did fire reliably for the
residual case: page-world extensions (MV3 main world, userscripts with
@grant none) whose stack contains a chrome-/moz-/safari-web-extension
frame at the synchronous write. Recover that signal for free by
returning fromExtension from getInitiatorURL (it already walks the
stack and discards extension frames via continue), and have the cookie
and storage detectors report source: "extension" instead of "script"
when the flag is set.

End-to-end plumbing reuses the existing source column: extend the
cookie_source Postgres enum with EXTENSION, add the CookieSourceExtension
constant with a doc block describing each bucket's actual semantics,
add the handler.go switch cases, expose EXTENSION on the GraphQL and
MCP CookieSource enums, and add the Extension option to the console
source filter.

Update bestSource in the pattern analysis worker so a glob merging
only extension-attributed exact patterns is no longer silently rolled
up to PRE_EXISTING. New precedence is SCRIPT > EXTENSION > PRE_EXISTING,
matching the upsert SQL's "page-script wins" rule and the asymmetric
signal strength of each bucket.

Out of scope: any behavioural use of EXTENSION (auto-exclusion,
denylist classification, dashboard surfacing) -- that belongs in the
follow-up backend denylist plan.

Signed-off-by: Émile Ré <emile@probo.com>
2026-05-26 11:14:48 +02:00

762 lines
18 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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 (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/kit/worker"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
const patternMergeThreshold = 3
// durationUnits mirrors the snap table from cookie-utils.ts. The same
// tracker observed across different clients can have jitter in its
// max-age (e.g. an "Expires" header computed from Date.now() yields
// slightly different seconds each time). Snapping to the nearest
// human-meaningful unit absorbs that jitter so the patterns still
// merge. This is compliant because the resulting bucket matches the
// duration shown to end users in the cookie banner — two cookies that
// display the same human-readable lifetime will merge, two that
// display differently will not.
var durationUnits = [...]struct {
seconds int
snap int
}{
{365 * 24 * 3600, 21 * 24 * 3600}, // years, snap +-21 days
{30 * 24 * 3600, 2 * 24 * 3600}, // months, snap +-2 days
{7 * 24 * 3600, 12 * 3600}, // weeks, snap +-12 hours
{24 * 3600, 2 * 3600}, // days, snap +-2 hours
{3600, 5 * 60}, // hours, snap +-5 minutes
{60, 5}, // minutes, snap +-5 seconds
{1, 0}, // seconds, no snap
}
func durationBucket(maxAge *int) int {
if maxAge == nil || *maxAge <= 0 {
return -1
}
remaining := *maxAge
total := 0
for _, u := range durationUnits {
if remaining >= u.seconds-u.snap {
count := remaining / u.seconds
leftover := remaining - count*u.seconds
if leftover >= u.seconds-u.snap {
count++
remaining = 0
} else if leftover <= u.snap {
remaining = 0
} else {
remaining = leftover
}
total += count * u.seconds
}
}
return total
}
type patternAnalysisHandler struct {
svc *Service
pg *pg.Client
logger *log.Logger
}
func NewPatternAnalysisWorker(
svc *Service,
pgClient *pg.Client,
logger *log.Logger,
opts ...worker.Option,
) *worker.Worker[coredata.CookieBanner] {
h := &patternAnalysisHandler{
svc: svc,
pg: pgClient,
logger: logger,
}
return worker.New(
"tracker-pattern-analysis-worker",
h,
logger,
opts...,
)
}
func (h *patternAnalysisHandler) Claim(ctx context.Context) (coredata.CookieBanner, error) {
var banner coredata.CookieBanner
if err := h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := banner.LoadNextForPatternAnalysisForUpdateSkipLocked(ctx, tx); err != nil {
return err
}
return banner.ClearPatternAnalysisRequestedAt(ctx, tx)
},
); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return coredata.CookieBanner{}, worker.ErrNoTask
}
return coredata.CookieBanner{}, fmt.Errorf("cannot claim pattern analysis task: %w", err)
}
return banner, nil
}
func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.CookieBanner) error {
return h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
scope := coredata.NewScopeFromObjectID(banner.ID)
var uncategorised coredata.CookieCategory
hasUncategorised := true
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, banner.ID); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load uncategorised category: %w", err)
}
hasUncategorised = false
}
var exactPatterns coredata.TrackerPatterns
if err := exactPatterns.LoadAllByCookieBannerID(
ctx,
tx,
scope,
banner.ID,
coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypeExact), nil, new(false)),
nil,
); err != nil {
return fmt.Errorf("cannot load exact patterns: %w", err)
}
mergeGroups := findMergeGroups(exactPatterns, patternMergeThreshold)
consentChanged := false
for key, group := range mergeGroups {
var maxAge *int
if key.durationBucket >= 0 {
v := key.durationBucket
maxAge = &v
}
source := bestSource(group)
now := time.Now()
globPattern := &coredata.TrackerPattern{
ID: gid.New(banner.ID.TenantID(), coredata.TrackerPatternEntityType),
OrganizationID: group[0].OrganizationID,
CookieBannerID: banner.ID,
CookieCategoryID: key.categoryID,
TrackerType: key.trackerType,
Pattern: key.template,
MatchType: coredata.TrackerPatternMatchTypeGlob,
DisplayName: key.template,
MaxAgeSeconds: maxAge,
Description: "",
Source: source,
MappingRequestedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
inserted, err := globPattern.InsertIfNotExists(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot insert glob pattern %q: %w", key.template, err)
}
if !inserted {
if err := globPattern.LoadByBannerIDTypeAndPattern(ctx, tx, scope, banner.ID, key.trackerType, key.template, maxAge); err != nil {
return fmt.Errorf("cannot load existing glob pattern %q: %w", key.template, err)
}
if globPattern.CookieCategoryID != key.categoryID || globPattern.MatchType != coredata.TrackerPatternMatchTypeGlob {
continue
}
}
for _, exactPattern := range group {
var trackers coredata.DetectedTrackers
if err := trackers.RelinkByTrackerPatternID(ctx, tx, scope, exactPattern.ID, globPattern.ID); err != nil {
return fmt.Errorf("cannot relink detected trackers from pattern %q: %w", exactPattern.Pattern, err)
}
if err := exactPattern.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete orphaned exact pattern %q: %w", exactPattern.Pattern, err)
}
}
if !hasUncategorised || key.categoryID != uncategorised.ID {
consentChanged = true
}
h.logger.InfoCtx(
ctx,
"merged exact patterns into glob pattern",
log.String("template", key.template),
log.Int("count", len(group)),
log.String("banner_id", banner.ID.String()),
)
}
if _, err := h.adoptUncategorisedPatterns(ctx, tx, scope, banner); err != nil {
return fmt.Errorf("cannot adopt uncategorised patterns: %w", err)
}
var patterns coredata.TrackerPatterns
if err := patterns.RefreshLastMatchedAtByCookieBannerID(ctx, tx, scope, banner.ID); err != nil {
return fmt.Errorf("cannot refresh last_matched_at: %w", err)
}
if consentChanged {
if _, err := h.svc.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
}
return nil
},
)
}
type mergeGroupKey struct {
categoryID gid.GID
trackerType coredata.TrackerType
template string
durationBucket int
}
func findMergeGroups(
patterns coredata.TrackerPatterns,
threshold int,
) map[mergeGroupKey][]*coredata.TrackerPattern {
type memberKey struct {
groupKey mergeGroupKey
pattern *coredata.TrackerPattern
}
templateCounts := make(map[mergeGroupKey][]*coredata.TrackerPattern)
heuristicKeys := make(map[mergeGroupKey]bool)
seen := make(map[memberKey]bool)
for _, p := range patterns {
bucket := durationBucket(p.MaxAgeSeconds)
if tmpl, ok := heuristicTemplate(p.Pattern); ok {
key := mergeGroupKey{categoryID: p.CookieCategoryID, trackerType: p.TrackerType, template: tmpl, durationBucket: bucket}
mk := memberKey{key, p}
if !seen[mk] {
seen[mk] = true
templateCounts[key] = append(templateCounts[key], p)
}
heuristicKeys[key] = true
}
for _, tmpl := range templateCandidates(p.Pattern) {
key := mergeGroupKey{categoryID: p.CookieCategoryID, trackerType: p.TrackerType, template: tmpl, durationBucket: bucket}
mk := memberKey{key, p}
if !seen[mk] {
seen[mk] = true
templateCounts[key] = append(templateCounts[key], p)
}
}
}
type candidate struct {
key mergeGroupKey
fixedChars int
isHeuristic bool
patterns []*coredata.TrackerPattern
}
var candidates []candidate
for key, pats := range templateCounts {
isH := heuristicKeys[key]
effectiveThreshold := threshold
if isH {
effectiveThreshold = 1
}
if len(pats) >= effectiveThreshold {
candidates = append(candidates, candidate{key, len(strings.ReplaceAll(key.template, "*", "")), isH, pats})
}
}
// Sort: heuristic first, then descending specificity (more fixed
// characters), then descending coverage, then template name for a
// fully deterministic order.
sort.Slice(
candidates,
func(i, j int) bool {
if candidates[i].isHeuristic != candidates[j].isHeuristic {
return candidates[i].isHeuristic
}
if candidates[i].fixedChars != candidates[j].fixedChars {
return candidates[i].fixedChars > candidates[j].fixedChars
}
if len(candidates[i].patterns) != len(candidates[j].patterns) {
return len(candidates[i].patterns) > len(candidates[j].patterns)
}
return candidates[i].key.template < candidates[j].key.template
},
)
assigned := make(map[*coredata.TrackerPattern]bool)
groups := make(map[mergeGroupKey][]*coredata.TrackerPattern)
for _, c := range candidates {
effectiveThreshold := threshold
if c.isHeuristic {
effectiveThreshold = 1
}
var unassigned []*coredata.TrackerPattern
for _, p := range c.patterns {
if !assigned[p] {
unassigned = append(unassigned, p)
}
}
if len(unassigned) < effectiveThreshold {
continue
}
groups[c.key] = unassigned
for _, p := range unassigned {
assigned[p] = true
}
}
return groups
}
func heuristicTemplate(name string) (string, bool) {
tokens, seps := splitTokens(name)
if len(seps) == 0 {
return "", false
}
// Trim leading empty tokens (e.g. "__Secure-..." yields ["", "", ...]).
var prefix strings.Builder
for len(tokens) > 1 && tokens[0] == "" {
prefix.WriteString(string(seps[0]))
tokens = tokens[1:]
seps = seps[1:]
}
// Trim trailing empty tokens.
var suffix string
for len(tokens) > 1 && tokens[len(tokens)-1] == "" {
suffix = string(seps[len(seps)-1]) + suffix
tokens = tokens[:len(tokens)-1]
seps = seps[:len(seps)-1]
}
if len(seps) == 0 {
return "", false
}
changed := false
var (
resultTokens []string
resultSeps []byte
)
for i, t := range tokens {
if looksVariable(t) {
changed = true
if len(resultTokens) == 0 || resultTokens[len(resultTokens)-1] != "*" {
if i > 0 {
resultSeps = append(resultSeps, seps[i-1])
}
resultTokens = append(resultTokens, "*")
}
} else {
if i > 0 {
resultSeps = append(resultSeps, seps[i-1])
}
resultTokens = append(resultTokens, t)
}
}
if !changed {
return "", false
}
tmpl := prefix.String() + joinTokens(resultTokens, resultSeps) + suffix
if !templateHasFixedAnchor(tmpl) {
return "", false
}
return tmpl, true
}
func templateCandidates(name string) []string {
var candidates []string
for i, ch := range name {
if ch == '_' || ch == '-' {
tmpl := name[:i+1] + "*"
if templateHasFixedAnchor(tmpl) {
candidates = append(candidates, tmpl)
}
}
}
tokens, seps := splitTokens(name)
if len(tokens) >= 3 && len(seps) > 0 {
for pos := 1; pos < len(tokens)-1; pos++ {
left := joinTokens(tokens[:pos], seps[:pos-1])
right := joinTokens(tokens[pos+1:], seps[pos+1:])
tmpl := left + string(seps[pos-1]) + "*" + string(seps[pos]) + right
if templateHasFixedAnchor(tmpl) {
candidates = append(candidates, tmpl)
}
}
}
return candidates
}
func looksVariable(token string) bool {
if len(token) == 0 {
return false
}
hasDigit := false
hasLetter := false
allHex := true
allDigits := true
for _, ch := range token {
switch {
case ch >= '0' && ch <= '9':
hasDigit = true
case ch >= 'a' && ch <= 'f', ch >= 'A' && ch <= 'F':
hasLetter = true
allDigits = false
case ch >= 'g' && ch <= 'z', ch >= 'G' && ch <= 'Z':
hasLetter = true
allHex = false
allDigits = false
case ch == '-':
allHex = false
allDigits = false
default:
allHex = false
allDigits = false
}
}
if len(token) >= 8 && hasDigit && hasLetter {
return true
}
if len(token) >= 16 && allHex && hasDigit {
return true
}
if isUUIDShape(token) {
return true
}
if len(token) >= 8 && allDigits {
return true
}
return false
}
func isUUIDShape(s string) bool {
if len(s) != 36 {
return false
}
for i, ch := range s {
if i == 8 || i == 13 || i == 18 || i == 23 {
if ch != '-' {
return false
}
continue
}
if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') && (ch < 'A' || ch > 'F') {
return false
}
}
return true
}
func splitTokens(name string) ([]string, []byte) {
underscoreParts := strings.Split(name, "_")
var (
tokens []string
seps []byte
)
for i, part := range underscoreParts {
if i > 0 {
seps = append(seps, '_')
}
if isUUIDShape(part) || !strings.Contains(part, "-") {
tokens = append(tokens, part)
} else {
for j, sub := range strings.Split(part, "-") {
if j > 0 {
seps = append(seps, '-')
}
tokens = append(tokens, sub)
}
}
}
if len(seps) == 0 {
return []string{name}, nil
}
return tokens, seps
}
func joinTokens(tokens []string, seps []byte) string {
var b strings.Builder
for i, t := range tokens {
if i > 0 {
b.WriteByte(seps[i-1])
}
b.WriteString(t)
}
return b.String()
}
// templateHasFixedAnchor reports whether tmpl contains at least one
// character beyond separators and wildcards. Templates like "_*",
// "__*", "-*", "--*", "__*__" would merge unrelated third parties
// (e.g. __support__, __darkreader__wasEnabledForHost,
// __EXT_APP_REFRESH_BLACK_SUB_DOMAINS__) under a single glob, so
// candidates without any fixed alphanumeric anchor are rejected.
func templateHasFixedAnchor(tmpl string) bool {
for _, ch := range tmpl {
if ch != '*' && ch != '_' && ch != '-' {
return true
}
}
return false
}
func globMatch(pattern, name string) bool {
parts := strings.Split(pattern, "*")
if len(parts) == 1 {
return pattern == name
}
if !strings.HasPrefix(name, parts[0]) {
return false
}
name = name[len(parts[0]):]
last := parts[len(parts)-1]
if !strings.HasSuffix(name, last) {
return false
}
name = name[:len(name)-len(last)]
for _, part := range parts[1 : len(parts)-1] {
idx := strings.Index(name, part)
if idx == -1 {
return false
}
name = name[idx+len(part):]
}
return true
}
// bestSource rolls up the source values of a group of exact patterns
// being merged into a single glob. Precedence is SCRIPT > EXTENSION
// > PRE_EXISTING, mirroring both the upsert SQL's "page-script wins"
// rule and the asymmetric signal strength of each bucket: SCRIPT is
// high-confidence page evidence (a real page tracker), EXTENSION is
// high-confidence extension evidence, and PRE_EXISTING is the
// catch-all that may include extension state injected before SDK
// load. HTTP and nil collapse into PRE_EXISTING here, preserving
// the original two-value rollup behaviour for non-script values.
func bestSource(patterns []*coredata.TrackerPattern) *coredata.CookieSource {
var hasExtension bool
for _, p := range patterns {
if p.Source == nil {
continue
}
switch *p.Source {
case coredata.CookieSourceScript:
return p.Source
case coredata.CookieSourceExtension:
hasExtension = true
}
}
if hasExtension {
src := coredata.CookieSourceExtension
return &src
}
src := coredata.CookieSourcePreExisting
return &src
}
func (h *patternAnalysisHandler) adoptUncategorisedPatterns(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
banner coredata.CookieBanner,
) (bool, error) {
var uncategorised coredata.CookieCategory
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, banner.ID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return false, nil
}
return false, fmt.Errorf("cannot load uncategorised category: %w", err)
}
var globPatterns coredata.TrackerPatterns
if err := globPatterns.LoadAllByCookieBannerID(
ctx,
tx,
scope,
banner.ID,
coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypeGlob), nil, new(false)),
nil,
); err != nil {
return false, fmt.Errorf("cannot load glob patterns: %w", err)
}
if len(globPatterns) == 0 {
return false, nil
}
sort.Slice(
globPatterns,
func(i, j int) bool {
return len(globPatterns[i].Pattern) > len(globPatterns[j].Pattern)
},
)
exactMatchType := coredata.TrackerPatternMatchTypeExact
var uncategorisedExact coredata.TrackerPatterns
if err := uncategorisedExact.LoadAllByCookieBannerID(
ctx,
tx,
scope,
banner.ID,
coredata.NewTrackerPatternFilter(&exactMatchType, &uncategorised.ID, new(false)),
nil,
); err != nil {
return false, fmt.Errorf("cannot load uncategorised exact patterns: %w", err)
}
adopted := false
for _, ep := range uncategorisedExact {
var match *coredata.TrackerPattern
epBucket := durationBucket(ep.MaxAgeSeconds)
for _, gp := range globPatterns {
if ep.TrackerType == gp.TrackerType && globMatch(gp.Pattern, ep.Pattern) && durationBucket(gp.MaxAgeSeconds) == epBucket {
match = gp
break
}
}
if match == nil {
continue
}
var trackers coredata.DetectedTrackers
if err := trackers.RelinkByTrackerPatternID(ctx, tx, scope, ep.ID, match.ID); err != nil {
return false, fmt.Errorf("cannot relink detected trackers from pattern %q: %w", ep.Pattern, err)
}
if err := ep.Delete(ctx, tx, scope); err != nil {
return false, fmt.Errorf("cannot delete adopted exact pattern %q: %w", ep.Pattern, err)
}
adopted = true
h.logger.InfoCtx(
ctx,
"adopted uncategorised exact pattern into glob pattern",
log.String("exact_pattern", ep.Pattern),
log.String("glob_pattern", match.Pattern),
log.String("banner_id", banner.ID.String()),
)
}
return adopted, nil
}