cookiebanner: capture script initiator URL on detected trackers

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é <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-11 10:38:30 +04:00
parent 04fdaed772
commit d17c8ba044
7 changed files with 159 additions and 19 deletions

View File

@@ -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<string>;
private readonly reported: Set<string> = new Set();
private readonly pending: Map<string, DetectedCookieEntry> = new Map();
@@ -43,6 +46,7 @@ export class CookieDetector implements Detector {
constructor(baseUrl: URL, bannerId: string, knownNames: Set<string>) {
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();
}

View File

@@ -0,0 +1,57 @@
// 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.
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;
}

View File

@@ -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<string> = new Set();
private readonly pending: Map<string, DetectedStorageEntry> = new Map();
private timer: ReturnType<typeof setTimeout> | 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();
}