diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 17484a382..fd8ef6ac8 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -142,6 +142,23 @@ type ( Cookies []DetectedCookie } + DetectedStorageItem struct { + Key string + StorageType coredata.TrackerType + ValueSize *int + } + + DetectedResourceItem struct { + Origin string + ResourceType coredata.TrackerType + } + + ReportDetectedTrackersRequest struct { + Cookies []DetectedCookie + Storage []DetectedStorageItem + Resources []DetectedResourceItem + } + BannerConfig struct { BannerID gid.GID `json:"banner_id"` Version int `json:"version"` @@ -361,7 +378,7 @@ func (s *Service) ensureDraftVersion( scope coredata.Scoper, banner *coredata.CookieBanner, categories coredata.CookieCategories, - allPatterns coredata.CookiePatterns, + allPatterns coredata.TrackerPatterns, ) (*coredata.CookieBannerVersion, error) { snapshot := buildSnapshot(banner, categories, allPatterns) @@ -432,15 +449,16 @@ func (s *Service) ensureDraftVersionForBanner( return nil, fmt.Errorf("cannot load cookie categories: %w", err) } - var allPatterns coredata.CookiePatterns + var allPatterns coredata.TrackerPatterns if err := allPatterns.LoadAllByCookieBannerID( ctx, tx, scope, bannerID, coredata.NewCookiePatternFilter(nil, nil, new(false)), + nil, ); err != nil { - return nil, fmt.Errorf("cannot load cookie patterns: %w", err) + return nil, fmt.Errorf("cannot load tracker patterns: %w", err) } return s.ensureDraftVersion(ctx, tx, scope, &banner, categories, allPatterns) @@ -2356,3 +2374,155 @@ func (s *Service) ReportDetectedCookies( }, ) } + +func (s *Service) ReportDetectedTrackers( + ctx context.Context, + bannerID gid.GID, + req ReportDetectedTrackersRequest, +) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + scope := coredata.NewScopeFromObjectID(bannerID) + + var banner coredata.CookieBanner + if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrBannerNotFound + } + return fmt.Errorf("cannot load cookie banner: %w", err) + } + + var uncategorised coredata.CookieCategory + if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, banner.ID); err != nil { + return fmt.Errorf("cannot load uncategorised category: %w", err) + } + + inserted := 0 + now := time.Now() + + for _, dc := range req.Cookies { + if err := s.reportDetectedTracker( + ctx, tx, scope, &banner, uncategorised.ID, now, + coredata.TrackerTypeCookie, dc.Name, dc.MaxAgeSeconds, &dc.Source, nil, + &inserted, + ); err != nil { + return err + } + } + + for _, ds := range req.Storage { + if err := s.reportDetectedTracker( + ctx, tx, scope, &banner, uncategorised.ID, now, + ds.StorageType, ds.Key, nil, nil, ds.ValueSize, + &inserted, + ); err != nil { + return err + } + } + + for _, dr := range req.Resources { + if err := s.reportDetectedTracker( + ctx, tx, scope, &banner, uncategorised.ID, now, + dr.ResourceType, dr.Origin, nil, nil, nil, + &inserted, + ); err != nil { + return err + } + } + + if inserted > 0 { + if err := banner.SetPatternAnalysisRequested(ctx, tx); err != nil { + return fmt.Errorf("cannot request pattern analysis: %w", err) + } + } + + return nil + }, + ) +} + +func (s *Service) reportDetectedTracker( + ctx context.Context, + tx pg.Tx, + scope coredata.Scoper, + banner *coredata.CookieBanner, + uncategorisedID gid.GID, + now time.Time, + trackerType coredata.TrackerType, + identifier string, + maxAgeSeconds *int, + source *coredata.CookieSource, + valueSize *int, + inserted *int, +) error { + var matchedPattern coredata.TrackerPattern + err := matchedPattern.FindMatchingPattern(ctx, tx, scope, banner.ID, trackerType, identifier) + if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot find matching tracker pattern: %w", err) + } + + if err == nil && matchedPattern.Excluded { + return nil + } + + var patternID *gid.GID + if err == nil { + patternID = &matchedPattern.ID + matchedPattern.LastMatchedAt = &now + matchedPattern.UpdatedAt = now + if updateErr := matchedPattern.Update(ctx, tx, scope); updateErr != nil { + return fmt.Errorf("cannot update tracker pattern last_matched_at: %w", updateErr) + } + } else { + newPattern := &coredata.TrackerPattern{ + ID: gid.New(scope.GetTenantID(), coredata.TrackerPatternEntityType), + OrganizationID: banner.OrganizationID, + CookieBannerID: banner.ID, + CookieCategoryID: uncategorisedID, + TrackerType: trackerType, + Pattern: identifier, + MatchType: coredata.CookiePatternMatchTypeExact, + DisplayName: identifier, + Description: "", + MaxAgeSeconds: maxAgeSeconds, + Source: source, + CreatedAt: now, + UpdatedAt: now, + } + wasInserted, err := newPattern.InsertIfNotExists(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot insert tracker pattern: %w", err) + } + if wasInserted { + patternID = &newPattern.ID + *inserted++ + } else { + var existingPattern coredata.TrackerPattern + if err := existingPattern.FindMatchingPattern(ctx, tx, scope, banner.ID, trackerType, identifier); err != nil { + return fmt.Errorf("cannot load existing tracker pattern: %w", err) + } + patternID = &existingPattern.ID + } + } + + tracker := &coredata.DetectedTracker{ + ID: gid.New(scope.GetTenantID(), coredata.DetectedTrackerEntityType), + CookieBannerID: banner.ID, + TrackerPatternID: patternID, + TrackerType: trackerType, + Identifier: identifier, + MaxAgeSeconds: maxAgeSeconds, + Source: source, + ValueSize: valueSize, + LastDetectedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + + if _, err := tracker.InsertIfNotExists(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert detected tracker: %w", err) + } + + return nil +} diff --git a/pkg/cookiebanner/snapshot.go b/pkg/cookiebanner/snapshot.go index 343346ade..04af57904 100644 --- a/pkg/cookiebanner/snapshot.go +++ b/pkg/cookiebanner/snapshot.go @@ -72,12 +72,15 @@ func sortConsentCategories(categories coredata.CookieCategories) { func buildSnapshot( banner *coredata.CookieBanner, categories coredata.CookieCategories, - allPatterns coredata.CookiePatterns, + allPatterns coredata.TrackerPatterns, ) coredata.CookieBannerVersionSnapshot { sortConsentCategories(categories) cookiesByCategory := make(map[gid.GID]coredata.CookieItems) for _, p := range allPatterns { + if p.TrackerType != coredata.TrackerTypeCookie { + continue + } cookiesByCategory[p.CookieCategoryID] = append( cookiesByCategory[p.CookieCategoryID], coredata.CookieItem{ diff --git a/pkg/coredata/detected_tracker.go b/pkg/coredata/detected_tracker.go new file mode 100644 index 000000000..e445b32d0 --- /dev/null +++ b/pkg/coredata/detected_tracker.go @@ -0,0 +1,172 @@ +// 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 coredata + +import ( + "context" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" +) + +type ( + DetectedTracker struct { + ID gid.GID `db:"id"` + CookieBannerID gid.GID `db:"cookie_banner_id"` + TrackerPatternID *gid.GID `db:"tracker_pattern_id"` + TrackerType TrackerType `db:"tracker_type"` + Identifier string `db:"identifier"` + MaxAgeSeconds *int `db:"max_age_seconds"` + Source *CookieSource `db:"source"` + ValueSize *int `db:"value_size"` + LastDetectedAt time.Time `db:"last_detected_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + DetectedTrackers []*DetectedTracker +) + +func (dt *DetectedTracker) InsertIfNotExists( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) (bool, error) { + q := ` +INSERT INTO detected_trackers ( + id, + tenant_id, + cookie_banner_id, + tracker_pattern_id, + tracker_type, + identifier, + max_age_seconds, + source, + value_size, + last_detected_at, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @cookie_banner_id, + @tracker_pattern_id, + @tracker_type, + @identifier, + @max_age_seconds, + @source, + @value_size, + @last_detected_at, + @created_at, + @updated_at +) +ON CONFLICT (cookie_banner_id, tracker_type, identifier) DO UPDATE + SET last_detected_at = EXCLUDED.last_detected_at, + source = CASE WHEN detected_trackers.source IS NULL OR (detected_trackers.source != @source_script AND EXCLUDED.source = @source_script) THEN EXCLUDED.source ELSE detected_trackers.source END, + updated_at = EXCLUDED.updated_at +` + + args := pgx.StrictNamedArgs{ + "id": dt.ID, + "tenant_id": scope.GetTenantID(), + "cookie_banner_id": dt.CookieBannerID, + "tracker_pattern_id": dt.TrackerPatternID, + "tracker_type": dt.TrackerType, + "identifier": dt.Identifier, + "max_age_seconds": dt.MaxAgeSeconds, + "source": dt.Source, + "source_script": CookieSourceScript, + "value_size": dt.ValueSize, + "last_detected_at": dt.LastDetectedAt, + "created_at": dt.CreatedAt, + "updated_at": dt.UpdatedAt, + } + + result, err := tx.Exec(ctx, q, args) + if err != nil { + return false, fmt.Errorf("cannot insert detected tracker: %w", err) + } + + return result.RowsAffected() > 0, nil +} + +func (dts *DetectedTrackers) CountByTrackerPatternID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + trackerPatternID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + detected_trackers +WHERE + %s + AND tracker_pattern_id = @tracker_pattern_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"tracker_pattern_id": trackerPatternID} + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot scan count: %w", err) + } + + return count, nil +} + +func (dts *DetectedTrackers) RelinkByTrackerPatternID( + ctx context.Context, + tx pg.Tx, + scope Scoper, + sourcePatternID gid.GID, + targetPatternID gid.GID, +) error { + q := ` +UPDATE detected_trackers +SET + tracker_pattern_id = @target_pattern_id, + updated_at = @updated_at +WHERE + %s + AND tracker_pattern_id = @source_pattern_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "source_pattern_id": sourcePatternID, + "target_pattern_id": targetPatternID, + "updated_at": time.Now(), + } + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot relink detected trackers to pattern: %w", err) + } + + return nil +} diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index 6d068b908..973e173dc 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -112,6 +112,8 @@ const ( CookieBannerTranslationEntityType uint16 = 86 AgentRunEntityType uint16 = 87 CookiePatternEntityType uint16 = 88 + TrackerPatternEntityType uint16 = 89 + DetectedTrackerEntityType uint16 = 90 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -282,6 +284,10 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &AgentRun{ID: id}, true case CookiePatternEntityType: return &CookiePattern{ID: id}, true + case TrackerPatternEntityType: + return &TrackerPattern{ID: id}, true + case DetectedTrackerEntityType: + return &DetectedTracker{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/tracker_pattern.go b/pkg/coredata/tracker_pattern.go new file mode 100644 index 000000000..240f09e04 --- /dev/null +++ b/pkg/coredata/tracker_pattern.go @@ -0,0 +1,480 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" +) + +type ( + TrackerPattern struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + CookieBannerID gid.GID `db:"cookie_banner_id"` + CookieCategoryID gid.GID `db:"cookie_category_id"` + TrackerType TrackerType `db:"tracker_type"` + Pattern string `db:"pattern"` + MatchType CookiePatternMatchType `db:"match_type"` + DisplayName string `db:"display_name"` + Description string `db:"description"` + Excluded bool `db:"excluded"` + MaxAgeSeconds *int `db:"max_age_seconds"` + Source *CookieSource `db:"source"` + LastMatchedAt *time.Time `db:"last_matched_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + TrackerPatterns []*TrackerPattern +) + +func (tp *TrackerPattern) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := `SELECT organization_id FROM tracker_patterns WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, tp.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + + return nil, fmt.Errorf("cannot query tracker pattern authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (tp *TrackerPattern) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + trackerPatternID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + tracker_type, + pattern, + match_type, + display_name, + description, + excluded, + max_age_seconds, + source, + last_matched_at, + created_at, + updated_at +FROM + tracker_patterns +WHERE + %s + AND id = @tracker_pattern_id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"tracker_pattern_id": trackerPatternID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query tracker patterns: %w", err) + } + + pattern, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrackerPattern]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect tracker pattern: %w", err) + } + + *tp = pattern + + return nil +} + +func (tp *TrackerPattern) FindMatchingPattern( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + trackerType TrackerType, + identifier string, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + tracker_type, + pattern, + match_type, + display_name, + description, + excluded, + max_age_seconds, + source, + last_matched_at, + created_at, + updated_at +FROM + tracker_patterns +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND tracker_type = @tracker_type + AND ( + (match_type = @match_type_prefix AND starts_with(@identifier, pattern)) + OR (match_type = @match_type_exact AND pattern = @identifier) + ) +ORDER BY + CASE WHEN match_type = @match_type_exact AND pattern = @identifier THEN 0 + WHEN match_type = @match_type_prefix THEN 1 + ELSE 2 + END, + LENGTH(pattern) DESC +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "cookie_banner_id": cookieBannerID, + "tracker_type": trackerType, + "identifier": identifier, + "match_type_prefix": CookiePatternMatchTypePrefix, + "match_type_exact": CookiePatternMatchTypeExact, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query tracker patterns: %w", err) + } + + pattern, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrackerPattern]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect tracker pattern: %w", err) + } + + *tp = pattern + + return nil +} + +func (tp *TrackerPattern) Insert( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO tracker_patterns ( + id, + tenant_id, + organization_id, + cookie_banner_id, + cookie_category_id, + tracker_type, + pattern, + match_type, + display_name, + description, + excluded, + max_age_seconds, + source, + last_matched_at, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @cookie_banner_id, + @cookie_category_id, + @tracker_type, + @pattern, + @match_type, + @display_name, + @description, + @excluded, + @max_age_seconds, + @source, + @last_matched_at, + @created_at, + @updated_at +) +` + + args := pgx.StrictNamedArgs{ + "id": tp.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": tp.OrganizationID, + "cookie_banner_id": tp.CookieBannerID, + "cookie_category_id": tp.CookieCategoryID, + "tracker_type": tp.TrackerType, + "pattern": tp.Pattern, + "match_type": tp.MatchType, + "display_name": tp.DisplayName, + "description": tp.Description, + "excluded": tp.Excluded, + "max_age_seconds": tp.MaxAgeSeconds, + "source": tp.Source, + "last_matched_at": tp.LastMatchedAt, + "created_at": tp.CreatedAt, + "updated_at": tp.UpdatedAt, + } + + _, err := tx.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_tracker_patterns_unique_pattern_per_banner" { + return ErrResourceAlreadyExists + } + } + return fmt.Errorf("cannot insert tracker pattern: %w", err) + } + + return nil +} + +func (tp *TrackerPattern) InsertIfNotExists( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) (bool, error) { + q := ` +INSERT INTO tracker_patterns ( + id, + tenant_id, + organization_id, + cookie_banner_id, + cookie_category_id, + tracker_type, + pattern, + match_type, + display_name, + description, + excluded, + max_age_seconds, + source, + last_matched_at, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @cookie_banner_id, + @cookie_category_id, + @tracker_type, + @pattern, + @match_type, + @display_name, + @description, + @excluded, + @max_age_seconds, + @source, + @last_matched_at, + @created_at, + @updated_at +) +ON CONFLICT (cookie_banner_id, tracker_type, pattern) DO NOTHING +` + + args := pgx.StrictNamedArgs{ + "id": tp.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": tp.OrganizationID, + "cookie_banner_id": tp.CookieBannerID, + "cookie_category_id": tp.CookieCategoryID, + "tracker_type": tp.TrackerType, + "pattern": tp.Pattern, + "match_type": tp.MatchType, + "display_name": tp.DisplayName, + "description": tp.Description, + "excluded": tp.Excluded, + "max_age_seconds": tp.MaxAgeSeconds, + "source": tp.Source, + "last_matched_at": tp.LastMatchedAt, + "created_at": tp.CreatedAt, + "updated_at": tp.UpdatedAt, + } + + result, err := tx.Exec(ctx, q, args) + if err != nil { + return false, fmt.Errorf("cannot insert tracker pattern: %w", err) + } + + return result.RowsAffected() > 0, nil +} + +func (tp *TrackerPattern) Update( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +UPDATE tracker_patterns +SET + cookie_category_id = @cookie_category_id, + display_name = @display_name, + max_age_seconds = @max_age_seconds, + description = @description, + excluded = @excluded, + last_matched_at = @last_matched_at, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": tp.ID, + "cookie_category_id": tp.CookieCategoryID, + "display_name": tp.DisplayName, + "max_age_seconds": tp.MaxAgeSeconds, + "description": tp.Description, + "excluded": tp.Excluded, + "last_matched_at": tp.LastMatchedAt, + "updated_at": tp.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := tx.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_tracker_patterns_unique_pattern_per_banner" { + return ErrResourceAlreadyExists + } + } + return fmt.Errorf("cannot update tracker pattern: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} + +func (tp *TrackerPattern) Delete( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +DELETE FROM tracker_patterns +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": tp.ID} + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete tracker pattern: %w", err) + } + + return nil +} + +func (tps *TrackerPatterns) LoadAllByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + filter *CookiePatternFilter, + trackerType *TrackerType, +) error { + trackerTypeFragment := "TRUE" + if trackerType != nil { + trackerTypeFragment = "tracker_type = @tracker_type" + } + + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + tracker_type, + pattern, + match_type, + display_name, + description, + excluded, + max_age_seconds, + source, + last_matched_at, + created_at, + updated_at +FROM + tracker_patterns +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND %s + AND %s +ORDER BY + created_at ASC, id ASC; +` + + q = fmt.Sprintf(q, scope.SQLFragment(), trackerTypeFragment, filter.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) + + if trackerType != nil { + args["tracker_type"] = *trackerType + } + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query tracker patterns: %w", err) + } + + patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[TrackerPattern]) + if err != nil { + return fmt.Errorf("cannot collect tracker patterns: %w", err) + } + + *tps = patterns + + return nil +} diff --git a/pkg/coredata/tracker_type.go b/pkg/coredata/tracker_type.go new file mode 100644 index 000000000..bdb3217e2 --- /dev/null +++ b/pkg/coredata/tracker_type.go @@ -0,0 +1,90 @@ +// 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 coredata + +import ( + "database/sql/driver" + "fmt" +) + +type TrackerType string + +const ( + TrackerTypeCookie TrackerType = "COOKIE" + TrackerTypeLocalStorage TrackerType = "LOCAL_STORAGE" + TrackerTypeSessionStorage TrackerType = "SESSION_STORAGE" + TrackerTypeIndexedDB TrackerType = "INDEXED_DB" + TrackerTypeScript TrackerType = "SCRIPT" + TrackerTypeIframe TrackerType = "IFRAME" +) + +func TrackerTypes() []TrackerType { + return []TrackerType{ + TrackerTypeCookie, + TrackerTypeLocalStorage, + TrackerTypeSessionStorage, + TrackerTypeIndexedDB, + TrackerTypeScript, + TrackerTypeIframe, + } +} + +func (s TrackerType) String() string { + return string(s) +} + +func (s *TrackerType) Scan(value any) error { + var v string + switch val := value.(type) { + case string: + v = val + case []byte: + v = string(val) + default: + return fmt.Errorf("unsupported type for TrackerType: %T", value) + } + + switch TrackerType(v) { + case TrackerTypeCookie: + *s = TrackerTypeCookie + case TrackerTypeLocalStorage: + *s = TrackerTypeLocalStorage + case TrackerTypeSessionStorage: + *s = TrackerTypeSessionStorage + case TrackerTypeIndexedDB: + *s = TrackerTypeIndexedDB + case TrackerTypeScript: + *s = TrackerTypeScript + case TrackerTypeIframe: + *s = TrackerTypeIframe + default: + return fmt.Errorf("invalid TrackerType value: %q", v) + } + return nil +} + +func (s TrackerType) Value() (driver.Value, error) { + switch s { + case TrackerTypeCookie, + TrackerTypeLocalStorage, + TrackerTypeSessionStorage, + TrackerTypeIndexedDB, + TrackerTypeScript, + TrackerTypeIframe: + return string(s), nil + default: + return nil, fmt.Errorf("invalid TrackerType: %s", s) + } +} diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index 70af8a2da..b1cc4bae0 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -53,6 +53,7 @@ func NewMux( r.Get("/consents/{visitorID}", h.handleGetConsent) r.Post("/consents", h.handlePostConsent) r.Post("/detected-cookies", h.handleReportDetectedCookies) + r.Post("/detected-trackers", h.handleReportDetectedTrackers) }) return r @@ -268,3 +269,135 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req w.WriteHeader(http.StatusNoContent) } + +type detectedStorageEntry struct { + Key string `json:"key"` + StorageType string `json:"storage_type"` + ValueSize *int `json:"value_size"` +} + +type detectedResourceEntry struct { + Origin string `json:"origin"` + ResourceType string `json:"resource_type"` +} + +type reportDetectedTrackersBody struct { + Cookies []detectedCookieEntry `json:"cookies"` + Storage []detectedStorageEntry `json:"storage"` + Resources []detectedResourceEntry `json:"resources"` +} + +const maxDetectedTrackersPerRequest = 100 + +func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Request) { + bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) + if err != nil { + jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id")) + return + } + + var body reportDetectedTrackersBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonutil.RenderBadRequest(w, fmt.Errorf("invalid request body")) + return + } + + total := len(body.Cookies) + len(body.Storage) + len(body.Resources) + if total == 0 { + jsonutil.RenderBadRequest(w, fmt.Errorf("no items provided")) + return + } + + if total > maxDetectedTrackersPerRequest { + jsonutil.RenderBadRequest(w, fmt.Errorf("too many items, maximum is %d", maxDetectedTrackersPerRequest)) + return + } + + var req cookiebanner.ReportDetectedTrackersRequest + + for _, c := range body.Cookies { + name := strings.TrimSpace(c.Name) + if name == "" { + continue + } + + var source coredata.CookieSource + switch strings.TrimSpace(c.Source) { + case "pre-existing": + source = coredata.CookieSourcePreExisting + default: + source = coredata.CookieSourceScript + } + + req.Cookies = append(req.Cookies, cookiebanner.DetectedCookie{ + Name: name, + MaxAgeSeconds: c.MaxAgeSeconds, + Source: source, + }) + } + + for _, s := range body.Storage { + key := strings.TrimSpace(s.Key) + if key == "" { + continue + } + + var storageType coredata.TrackerType + switch strings.TrimSpace(s.StorageType) { + case "local_storage": + storageType = coredata.TrackerTypeLocalStorage + case "session_storage": + storageType = coredata.TrackerTypeSessionStorage + case "indexed_db": + storageType = coredata.TrackerTypeIndexedDB + default: + continue + } + + req.Storage = append(req.Storage, cookiebanner.DetectedStorageItem{ + Key: key, + StorageType: storageType, + ValueSize: s.ValueSize, + }) + } + + for _, res := range body.Resources { + origin := strings.TrimSpace(res.Origin) + if origin == "" { + continue + } + + var resourceType coredata.TrackerType + switch strings.TrimSpace(res.ResourceType) { + case "script": + resourceType = coredata.TrackerTypeScript + case "iframe": + resourceType = coredata.TrackerTypeIframe + default: + continue + } + + req.Resources = append(req.Resources, cookiebanner.DetectedResourceItem{ + Origin: origin, + ResourceType: resourceType, + }) + } + + if len(req.Cookies)+len(req.Storage)+len(req.Resources) == 0 { + jsonutil.RenderBadRequest(w, fmt.Errorf("no valid items provided")) + return + } + + if err := h.cookieBannerSvc.ReportDetectedTrackers(r.Context(), bannerID, req); err != nil { + if errors.Is(err, cookiebanner.ErrBannerNotFound) { + jsonutil.RenderNotFound(w, fmt.Errorf("banner not found")) + return + } + + h.logger.ErrorCtx(r.Context(), "cannot report detected trackers", log.Error(err)) + jsonutil.RenderInternalServerError(w) + return + } + + w.WriteHeader(http.StatusNoContent) +}