diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPage.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPage.tsx index aa69d28d0..3e6d7d639 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPage.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPage.tsx @@ -164,6 +164,12 @@ export default function CookieBannerResourcesPage({ + + + + + + @@ -202,7 +208,7 @@ export default function CookieBannerResourcesPage({ {__("No uncategorised resources")}

- {__("All detected scripts and iframes have been categorised. New resources will appear here when detected.")} + {__("All detected resources have been categorised. New resources will appear here when detected.")}

diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRow.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRow.tsx index fcb5295f5..04dd89153 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRow.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRow.tsx @@ -127,6 +127,12 @@ function resourceTypeLabel(type: string, __: (s: string) => string): string { switch (type) { case "SCRIPT": return __("Script"); case "IFRAME": return __("Iframe"); + case "IMAGE": return __("Image"); + case "STYLESHEET": return __("Stylesheet"); + case "FONT": return __("Font"); + case "BEACON": return __("Beacon"); + case "FETCH": return __("Fetch"); + case "MEDIA": return __("Media"); default: return type; } } diff --git a/packages/cookie-banner/src/detectors/third-party-detector.ts b/packages/cookie-banner/src/detectors/third-party-detector.ts index 8ff656196..d0171bd45 100644 --- a/packages/cookie-banner/src/detectors/third-party-detector.ts +++ b/packages/cookie-banner/src/detectors/third-party-detector.ts @@ -16,15 +16,61 @@ import type { Detector } from "./detector"; import { NotFoundError } from "../errors"; import { fetchJSON } from "../http"; +type ResourceType = + | "script" + | "iframe" + | "image" + | "stylesheet" + | "font" + | "beacon" + | "fetch" + | "media"; + interface DetectedResourceEntry { url: string; - resource_type: "script" | "iframe"; + resource_type: ResourceType; } const DEBOUNCE_MS = 2_000; const MAX_ITEMS_PER_REQUEST = 100; const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//; +// Map browser-reported PerformanceResourceTiming.initiatorType to the +// server-side tracker_resource_type. Anything we cannot classify is +// dropped rather than reported as "other" to keep the table tidy. +function mapInitiatorType(it: string): ResourceType | null { + switch (it) { + case "script": + return "script"; + case "iframe": + return "iframe"; + case "img": + case "image": + case "imageset": + case "input": + return "image"; + case "css": + case "link": + return "stylesheet"; + case "font": + return "font"; + case "beacon": + case "ping": + return "beacon"; + case "fetch": + case "xmlhttprequest": + return "fetch"; + case "video": + case "audio": + case "track": + case "embed": + case "object": + return "media"; + default: + return null; + } +} + export class ThirdPartyDetector implements Detector { private readonly reportUrl: URL; private readonly pageOrigin: string; @@ -33,6 +79,7 @@ export class ThirdPartyDetector implements Detector { private readonly pending: Map = new Map(); private timer: ReturnType | null = null; private observer: MutationObserver | null = null; + private perfObserver: PerformanceObserver | null = null; constructor(baseUrl: URL, bannerId: string) { this.reportUrl = new URL(`${bannerId}/report`, baseUrl); @@ -43,6 +90,7 @@ export class ThirdPartyDetector implements Detector { start(): void { this.scanExisting(); this.observeMutations(); + this.observePerformance(); } stop(): void { @@ -59,14 +107,19 @@ export class ThirdPartyDetector implements Detector { this.observer.disconnect(); this.observer = null; } + + if (this.perfObserver) { + this.perfObserver.disconnect(); + this.perfObserver = null; + } } private scanExisting(): void { for (const script of document.querySelectorAll("script[src]")) { - this.processElement(script.src, "script"); + this.processResource(script.src, "script"); } for (const iframe of document.querySelectorAll("iframe[src]")) { - this.processElement(iframe.src, "iframe"); + this.processResource(iframe.src, "iframe"); } if (this.pending.size > 0) { @@ -81,16 +134,16 @@ export class ThirdPartyDetector implements Detector { if (!(node instanceof HTMLElement)) continue; if (node instanceof HTMLScriptElement && node.src) { - this.processElement(node.src, "script"); + this.processResource(node.src, "script"); } else if (node instanceof HTMLIFrameElement && node.src) { - this.processElement(node.src, "iframe"); + this.processResource(node.src, "iframe"); } for (const script of node.querySelectorAll("script[src]")) { - this.processElement(script.src, "script"); + this.processResource(script.src, "script"); } for (const iframe of node.querySelectorAll("iframe[src]")) { - this.processElement(iframe.src, "iframe"); + this.processResource(iframe.src, "iframe"); } } } @@ -102,7 +155,31 @@ export class ThirdPartyDetector implements Detector { }); } - private processElement(src: string, resourceType: "script" | "iframe"): void { + // observePerformance picks up resources the DOM scan misses: tracking + // pixels (), beacons, fetch/XHR call-homes, CSS-loaded fonts and + // sub-stylesheets, video/audio embeds. `buffered: true` replays any + // entries that fired before the observer was attached, so we catch + // bootstrap resources too. + private observePerformance(): void { + if (typeof PerformanceObserver === "undefined") return; + + try { + this.perfObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries() as PerformanceResourceTiming[]) { + const rt = mapInitiatorType(entry.initiatorType); + if (rt) this.processResource(entry.name, rt); + } + }); + this.perfObserver.observe({ type: "resource", buffered: true }); + } catch { + // Older browsers may not support the `type` option or the + // `'resource'` entry type. Silently degrade to MutationObserver + // coverage only. + this.perfObserver = null; + } + } + + private processResource(src: string, resourceType: ResourceType): void { if (EXTENSION_URL_RE.test(src)) return; let parsed: URL; @@ -112,6 +189,7 @@ export class ThirdPartyDetector implements Detector { return; } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return; if (parsed.origin === this.pageOrigin || parsed.origin === this.proboOrigin) return; const identifier = parsed.origin + parsed.pathname; diff --git a/pkg/cmd/tracker-resource/create/create.go b/pkg/cmd/tracker-resource/create/create.go index f316a84df..0c428c929 100644 --- a/pkg/cmd/tracker-resource/create/create.go +++ b/pkg/cmd/tracker-resource/create/create.go @@ -90,6 +90,12 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { Options( huh.NewOption("Script", "SCRIPT"), huh.NewOption("Iframe", "IFRAME"), + huh.NewOption("Image", "IMAGE"), + huh.NewOption("Stylesheet", "STYLESHEET"), + huh.NewOption("Font", "FONT"), + huh.NewOption("Beacon", "BEACON"), + huh.NewOption("Fetch / XHR", "FETCH"), + huh.NewOption("Media", "MEDIA"), ). Value(&flagResourceType).Run(); err != nil { return err @@ -155,7 +161,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&flagCategoryID, "category-id", "", "Cookie category ID (required)") _ = cmd.MarkFlagRequired("category-id") - cmd.Flags().StringVar(&flagResourceType, "resource-type", "", "Resource type: SCRIPT or IFRAME (required)") + cmd.Flags().StringVar(&flagResourceType, "resource-type", "", "Resource type: SCRIPT, IFRAME, IMAGE, STYLESHEET, FONT, BEACON, FETCH or MEDIA (required)") cmd.Flags().StringVar(&flagOrigin, "origin", "", "Origin URL (required)") cmd.Flags().StringVar(&flagPath, "path", "", "Resource path (required)") cmd.Flags().StringVar(&flagDisplayName, "display-name", "", "Display name (required)") diff --git a/pkg/coredata/migrations/20260511T063925Z.sql b/pkg/coredata/migrations/20260511T063925Z.sql new file mode 100644 index 000000000..ef4f0f99b --- /dev/null +++ b/pkg/coredata/migrations/20260511T063925Z.sql @@ -0,0 +1,24 @@ +-- 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. + +-- Extend tracker_resource_type to cover everything the in-page +-- PerformanceObserver can attribute: tracking pixels, fetch/XHR +-- call-homes, beacons, web fonts, cross-origin stylesheets, and +-- video/audio embeds. +ALTER TYPE tracker_resource_type ADD VALUE IF NOT EXISTS 'IMAGE'; +ALTER TYPE tracker_resource_type ADD VALUE IF NOT EXISTS 'STYLESHEET'; +ALTER TYPE tracker_resource_type ADD VALUE IF NOT EXISTS 'FONT'; +ALTER TYPE tracker_resource_type ADD VALUE IF NOT EXISTS 'BEACON'; +ALTER TYPE tracker_resource_type ADD VALUE IF NOT EXISTS 'FETCH'; +ALTER TYPE tracker_resource_type ADD VALUE IF NOT EXISTS 'MEDIA'; diff --git a/pkg/coredata/tracker_resource_type.go b/pkg/coredata/tracker_resource_type.go index 0ec8e50c3..d23a1d1b9 100644 --- a/pkg/coredata/tracker_resource_type.go +++ b/pkg/coredata/tracker_resource_type.go @@ -22,14 +22,26 @@ import ( type TrackerResourceType string const ( - TrackerResourceTypeScript TrackerResourceType = "SCRIPT" - TrackerResourceTypeIframe TrackerResourceType = "IFRAME" + TrackerResourceTypeScript TrackerResourceType = "SCRIPT" + TrackerResourceTypeIframe TrackerResourceType = "IFRAME" + TrackerResourceTypeImage TrackerResourceType = "IMAGE" + TrackerResourceTypeStylesheet TrackerResourceType = "STYLESHEET" + TrackerResourceTypeFont TrackerResourceType = "FONT" + TrackerResourceTypeBeacon TrackerResourceType = "BEACON" + TrackerResourceTypeFetch TrackerResourceType = "FETCH" + TrackerResourceTypeMedia TrackerResourceType = "MEDIA" ) func TrackerResourceTypes() []TrackerResourceType { return []TrackerResourceType{ TrackerResourceTypeScript, TrackerResourceTypeIframe, + TrackerResourceTypeImage, + TrackerResourceTypeStylesheet, + TrackerResourceTypeFont, + TrackerResourceTypeBeacon, + TrackerResourceTypeFetch, + TrackerResourceTypeMedia, } } @@ -53,6 +65,18 @@ func (s *TrackerResourceType) Scan(value any) error { *s = TrackerResourceTypeScript case TrackerResourceTypeIframe: *s = TrackerResourceTypeIframe + case TrackerResourceTypeImage: + *s = TrackerResourceTypeImage + case TrackerResourceTypeStylesheet: + *s = TrackerResourceTypeStylesheet + case TrackerResourceTypeFont: + *s = TrackerResourceTypeFont + case TrackerResourceTypeBeacon: + *s = TrackerResourceTypeBeacon + case TrackerResourceTypeFetch: + *s = TrackerResourceTypeFetch + case TrackerResourceTypeMedia: + *s = TrackerResourceTypeMedia default: return fmt.Errorf("invalid TrackerResourceType value: %q", v) } @@ -62,7 +86,13 @@ func (s *TrackerResourceType) Scan(value any) error { func (s TrackerResourceType) Value() (driver.Value, error) { switch s { case TrackerResourceTypeScript, - TrackerResourceTypeIframe: + TrackerResourceTypeIframe, + TrackerResourceTypeImage, + TrackerResourceTypeStylesheet, + TrackerResourceTypeFont, + TrackerResourceTypeBeacon, + TrackerResourceTypeFetch, + TrackerResourceTypeMedia: return string(s), nil default: return nil, fmt.Errorf("invalid TrackerResourceType: %s", s) diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index d241ead76..3a9842479 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -308,6 +308,30 @@ enum TrackerResourceType @goEnum( value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeIframe" ) + IMAGE + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeImage" + ) + STYLESHEET + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeStylesheet" + ) + FONT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeFont" + ) + BEACON + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeBeacon" + ) + FETCH + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeFetch" + ) + MEDIA + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeMedia" + ) } enum TrackerResourceOrderField diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index 0072fe743..918066394 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -445,6 +445,18 @@ func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Re resourceType = coredata.TrackerResourceTypeScript case "iframe": resourceType = coredata.TrackerResourceTypeIframe + case "image": + resourceType = coredata.TrackerResourceTypeImage + case "stylesheet": + resourceType = coredata.TrackerResourceTypeStylesheet + case "font": + resourceType = coredata.TrackerResourceTypeFont + case "beacon": + resourceType = coredata.TrackerResourceTypeBeacon + case "fetch": + resourceType = coredata.TrackerResourceTypeFetch + case "media": + resourceType = coredata.TrackerResourceTypeMedia default: continue } diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index cb3c6ad74..9f2a3a393 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -9522,7 +9522,7 @@ components: description: Cookie category ID resource_type: type: string - enum: [SCRIPT, IFRAME] + enum: [SCRIPT, IFRAME, IMAGE, STYLESHEET, FONT, BEACON, FETCH, MEDIA] description: Type of tracked resource origin: type: string @@ -10186,7 +10186,7 @@ components: $ref: "#/components/schemas/GID" resource_type: type: string - enum: [SCRIPT, IFRAME] + enum: [SCRIPT, IFRAME, IMAGE, STYLESHEET, FONT, BEACON, FETCH, MEDIA] origin: type: string path: