- {__("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: