From d17c8ba044ba3c6c81ba2d2422cac578fad94ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Mon, 11 May 2026 10:38:30 +0400 Subject: [PATCH] cookiebanner: capture script initiator URL on detected trackers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When third-party JS sets a cookie or writes to local/sessionStorage inside a customer page, the SDK now walks the synchronous call stack to find the first non-extension, non-Probo, non-first-party http(s) URL. That origin+path is sent as initiator_url on the report payload, persisted in a new nullable column on detected_trackers, and preserved across upserts via COALESCE. This unlocks per-vendor attribution for cookies and storage writes without needing pattern name matching, so future categorisation logic can simply look up the initiator URL in the existing tracker_resources table and inherit that vendor's category. GraphQL/MCP exposure is intentionally deferred -- the column is captured now, surfaced later. Signed-off-by: Émile Ré --- .../src/detectors/cookie-detector.ts | 13 ++++- .../cookie-banner/src/detectors/initiator.ts | 57 +++++++++++++++++++ .../src/detectors/storage-detector.ts | 12 +++- pkg/cookiebanner/service.go | 18 ++++-- pkg/coredata/detected_tracker.go | 5 ++ pkg/coredata/migrations/20260511T063353Z.sql | 19 +++++++ pkg/server/api/cookiebanner/v1/handler.go | 54 ++++++++++++++---- 7 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 packages/cookie-banner/src/detectors/initiator.ts create mode 100644 pkg/coredata/migrations/20260511T063353Z.sql diff --git a/packages/cookie-banner/src/detectors/cookie-detector.ts b/packages/cookie-banner/src/detectors/cookie-detector.ts index 746ed95bb..ab3cb254d 100644 --- a/packages/cookie-banner/src/detectors/cookie-detector.ts +++ b/packages/cookie-banner/src/detectors/cookie-detector.ts @@ -16,11 +16,13 @@ import { isDeletion, parseCookieName, parseMaxAgeSeconds } from "../cookie-utils import type { Detector } from "./detector"; import { NotFoundError } from "../errors"; import { fetchJSON } from "../http"; +import { getInitiatorURL } from "./initiator"; interface DetectedCookieEntry { name: string; max_age_seconds: number | null; source: "script" | "pre-existing" | "http"; + initiator_url?: string; } const DEBOUNCE_MS = 2_000; @@ -34,6 +36,7 @@ function isExtensionCaller(): boolean { export class CookieDetector implements Detector { private readonly reportUrl: URL; + private readonly proboOrigin: string; private readonly knownNames: Set; private readonly reported: Set = new Set(); private readonly pending: Map = new Map(); @@ -43,6 +46,7 @@ export class CookieDetector implements Detector { constructor(baseUrl: URL, bannerId: string, knownNames: Set) { this.reportUrl = new URL(`${bannerId}/report`, baseUrl); + this.proboOrigin = baseUrl.origin; this.knownNames = knownNames; } @@ -103,9 +107,16 @@ export class CookieDetector implements Detector { if (!name || this.knownNames.has(name) || this.reported.has(name)) return; const maxAgeSeconds = parseMaxAgeSeconds(raw); + const initiatorUrl = getInitiatorURL(this.proboOrigin); this.reported.add(name); - this.pending.set(name, { name, max_age_seconds: maxAgeSeconds, source: "script" }); + const entry: DetectedCookieEntry = { + name, + max_age_seconds: maxAgeSeconds, + source: "script", + }; + if (initiatorUrl) entry.initiator_url = initiatorUrl; + this.pending.set(name, entry); this.scheduleFlush(); } diff --git a/packages/cookie-banner/src/detectors/initiator.ts b/packages/cookie-banner/src/detectors/initiator.ts new file mode 100644 index 000000000..910f3554f --- /dev/null +++ b/packages/cookie-banner/src/detectors/initiator.ts @@ -0,0 +1,57 @@ +// 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. + +const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//; +const STACK_URL_RE = /https?:\/\/[^\s)'"`]+/g; +const LINE_COL_SUFFIX_RE = /:\d+(?::\d+)?$/; +const MAX_INITIATOR_URL_LENGTH = 1024; + +// getInitiatorURL walks the current call stack and returns the first +// third-party script URL (as origin+pathname). It deliberately skips: +// - browser extension URLs (chrome/moz/safari-web-extension://) +// - the Probo SDK's own origin (instrumentation frames) +// - the page's own origin (we want the third-party loader, not first-party code) +// +// Returns null when no third-party frame is found (anonymous/eval/inline +// scripts, or writes originating from the page itself). +export function getInitiatorURL(proboOrigin: string): string | null { + const stack = new Error().stack; + if (!stack) return null; + + STACK_URL_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = STACK_URL_RE.exec(stack)) !== null) { + const raw = m[0]; + if (EXTENSION_URL_RE.test(raw)) continue; + + const cleaned = raw.replace(LINE_COL_SUFFIX_RE, ""); + + let parsed: URL; + try { + parsed = new URL(cleaned); + } catch { + continue; + } + + if (parsed.origin === proboOrigin) continue; + if (parsed.origin === location.origin) continue; + + const result = parsed.origin + parsed.pathname; + if (result.length > MAX_INITIATOR_URL_LENGTH) { + return result.slice(0, MAX_INITIATOR_URL_LENGTH); + } + return result; + } + return null; +} diff --git a/packages/cookie-banner/src/detectors/storage-detector.ts b/packages/cookie-banner/src/detectors/storage-detector.ts index 06da6732c..5520010ee 100644 --- a/packages/cookie-banner/src/detectors/storage-detector.ts +++ b/packages/cookie-banner/src/detectors/storage-detector.ts @@ -15,11 +15,13 @@ import type { Detector } from "./detector"; import { NotFoundError } from "../errors"; import { fetchJSON } from "../http"; +import { getInitiatorURL } from "./initiator"; interface DetectedStorageEntry { key: string; storage_type: "local_storage" | "session_storage" | "indexed_db"; value_size: number | null; + initiator_url?: string; } const DEBOUNCE_MS = 2_000; @@ -34,6 +36,7 @@ function isExtensionCaller(): boolean { export class StorageDetector implements Detector { private readonly reportUrl: URL; + private readonly proboOrigin: string; private readonly reported: Set = new Set(); private readonly pending: Map = new Map(); private timer: ReturnType | null = null; @@ -42,6 +45,7 @@ export class StorageDetector implements Detector { constructor(baseUrl: URL, bannerId: string) { this.reportUrl = new URL(`${bannerId}/report`, baseUrl); + this.proboOrigin = baseUrl.origin; } start(): void { @@ -113,12 +117,16 @@ export class StorageDetector implements Detector { const reportKey = `${storageType}:${key}`; if (this.reported.has(reportKey)) return; + const initiatorUrl = getInitiatorURL(this.proboOrigin); + this.reported.add(reportKey); - this.pending.set(reportKey, { + const entry: DetectedStorageEntry = { key, storage_type: storageType, value_size: value.length * 2, - }); + }; + if (initiatorUrl) entry.initiator_url = initiatorUrl; + this.pending.set(reportKey, entry); this.scheduleFlush(); } diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 5637acfd5..bc718a6ef 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -113,6 +113,7 @@ type ( Name string MaxAgeSeconds *int Source coredata.CookieSource + InitiatorURL *string } ReportDetectedCookiesRequest struct { @@ -120,9 +121,10 @@ type ( } DetectedStorageItem struct { - Key string - StorageType coredata.TrackerType - ValueSize *int + Key string + StorageType coredata.TrackerType + ValueSize *int + InitiatorURL *string } DetectedResourceItem struct { @@ -2035,6 +2037,7 @@ func (s *Service) ReportDetectedTrackers( Identifier: dc.Name, MaxAgeSeconds: dc.MaxAgeSeconds, Source: &dc.Source, + InitiatorURL: dc.InitiatorURL, }, &inserted, ); err != nil { @@ -2051,9 +2054,10 @@ func (s *Service) ReportDetectedTrackers( uncategorised.ID, now, detectedTrackerInfo{ - TrackerType: ds.StorageType, - Identifier: ds.Key, - ValueSize: ds.ValueSize, + TrackerType: ds.StorageType, + Identifier: ds.Key, + ValueSize: ds.ValueSize, + InitiatorURL: ds.InitiatorURL, }, &inserted, ); err != nil { @@ -2096,6 +2100,7 @@ type detectedTrackerInfo struct { MaxAgeSeconds *int Source *coredata.CookieSource ValueSize *int + InitiatorURL *string } func (s *Service) reportDetectedTracker( @@ -2168,6 +2173,7 @@ func (s *Service) reportDetectedTracker( MaxAgeSeconds: info.MaxAgeSeconds, Source: info.Source, ValueSize: info.ValueSize, + InitiatorURL: info.InitiatorURL, LastDetectedAt: now, CreatedAt: now, UpdatedAt: now, diff --git a/pkg/coredata/detected_tracker.go b/pkg/coredata/detected_tracker.go index 59854a689..21d6ca0c0 100644 --- a/pkg/coredata/detected_tracker.go +++ b/pkg/coredata/detected_tracker.go @@ -35,6 +35,7 @@ type ( MaxAgeSeconds *int `db:"max_age_seconds"` Source *CookieSource `db:"source"` ValueSize *int `db:"value_size"` + InitiatorURL *string `db:"initiator_url"` LastDetectedAt time.Time `db:"last_detected_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` @@ -59,6 +60,7 @@ INSERT INTO detected_trackers ( max_age_seconds, source, value_size, + initiator_url, last_detected_at, created_at, updated_at @@ -72,6 +74,7 @@ INSERT INTO detected_trackers ( @max_age_seconds, @source, @value_size, + @initiator_url, @last_detected_at, @created_at, @updated_at @@ -83,6 +86,7 @@ ON CONFLICT (cookie_banner_id, tracker_type, identifier) DO UPDATE ) THEN EXCLUDED.source ELSE detected_trackers.source END, + initiator_url = COALESCE(EXCLUDED.initiator_url, detected_trackers.initiator_url), updated_at = EXCLUDED.updated_at ` @@ -97,6 +101,7 @@ ON CONFLICT (cookie_banner_id, tracker_type, identifier) DO UPDATE "source": dt.Source, "source_script": CookieSourceScript, "value_size": dt.ValueSize, + "initiator_url": dt.InitiatorURL, "last_detected_at": dt.LastDetectedAt, "created_at": dt.CreatedAt, "updated_at": dt.UpdatedAt, diff --git a/pkg/coredata/migrations/20260511T063353Z.sql b/pkg/coredata/migrations/20260511T063353Z.sql new file mode 100644 index 000000000..ccd9c3359 --- /dev/null +++ b/pkg/coredata/migrations/20260511T063353Z.sql @@ -0,0 +1,19 @@ +-- 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. + +-- Capture the third-party script URL (origin+path) that triggered a +-- detected cookie or storage write, so the auto-categorisation worker +-- can attribute the artifact to a known vendor via the existing +-- tracker_resources table. +ALTER TABLE detected_trackers ADD COLUMN initiator_url TEXT; diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index 7753071cb..0072fe743 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "time" @@ -221,16 +222,45 @@ func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) { } type detectedCookieEntry struct { - Name string `json:"name"` - MaxAgeSeconds *int `json:"max_age_seconds"` - Source string `json:"source"` + Name string `json:"name"` + MaxAgeSeconds *int `json:"max_age_seconds"` + Source string `json:"source"` + InitiatorURL *string `json:"initiator_url,omitempty"` } type reportDetectedCookiesBody struct { Cookies []detectedCookieEntry `json:"cookies"` } -const maxDetectedCookiesPerRequest = 100 +const ( + maxDetectedCookiesPerRequest = 100 + maxInitiatorURLLength = 1024 +) + +// sanitizeInitiatorURL validates and normalises a script URL captured +// in the customer page's stack trace. It drops the value when it does +// not parse as an http(s) URL with a host, or when it exceeds the +// length cap. Returns nil for missing or invalid input. +func sanitizeInitiatorURL(raw *string) *string { + if raw == nil { + return nil + } + s := strings.TrimSpace(*raw) + if s == "" || len(s) > maxInitiatorURLLength { + return nil + } + u, err := url.Parse(s) + if err != nil { + return nil + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil + } + if u.Host == "" { + return nil + } + return &s +} func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Request) { bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) @@ -278,6 +308,7 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req Name: name, MaxAgeSeconds: c.MaxAgeSeconds, Source: source, + InitiatorURL: sanitizeInitiatorURL(c.InitiatorURL), }, ) } @@ -306,9 +337,10 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req } type detectedStorageEntry struct { - Key string `json:"key"` - StorageType string `json:"storage_type"` - ValueSize *int `json:"value_size"` + Key string `json:"key"` + StorageType string `json:"storage_type"` + ValueSize *int `json:"value_size"` + InitiatorURL *string `json:"initiator_url,omitempty"` } type detectedResourceEntry struct { @@ -372,6 +404,7 @@ func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Re Name: name, MaxAgeSeconds: c.MaxAgeSeconds, Source: source, + InitiatorURL: sanitizeInitiatorURL(c.InitiatorURL), }, ) } @@ -397,9 +430,10 @@ func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Re req.Storage = append( req.Storage, cookiebanner.DetectedStorageItem{ - Key: key, - StorageType: storageType, - ValueSize: s.ValueSize, + Key: key, + StorageType: storageType, + ValueSize: s.ValueSize, + InitiatorURL: sanitizeInitiatorURL(s.InitiatorURL), }, ) }