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 3e6d7d639..3fdfb9e49 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 @@ -170,6 +170,7 @@ export default function CookieBannerResourcesPage({ + 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 04dd89153..a53a3da80 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 @@ -133,6 +133,7 @@ function resourceTypeLabel(type: string, __: (s: string) => string): string { case "BEACON": return __("Beacon"); case "FETCH": return __("Fetch"); case "MEDIA": return __("Media"); + case "SERVICE_WORKER": return __("Service Worker"); default: return type; } } diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx index e555e9b00..9e5d2f7e8 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx @@ -130,6 +130,7 @@ function trackerTypeLabel(type: string, __: (s: string) => string): string { case "LOCAL_STORAGE": return __("localStorage"); case "SESSION_STORAGE": return __("sessionStorage"); case "INDEXED_DB": return __("IndexedDB"); + case "CACHE_STORAGE": return __("Cache Storage"); default: return type; } } diff --git a/packages/cookie-banner/src/detectors/storage-detector.ts b/packages/cookie-banner/src/detectors/storage-detector.ts index 5520010ee..7ae7a3444 100644 --- a/packages/cookie-banner/src/detectors/storage-detector.ts +++ b/packages/cookie-banner/src/detectors/storage-detector.ts @@ -19,7 +19,11 @@ import { getInitiatorURL } from "./initiator"; interface DetectedStorageEntry { key: string; - storage_type: "local_storage" | "session_storage" | "indexed_db"; + storage_type: + | "local_storage" + | "session_storage" + | "indexed_db" + | "cache_storage"; value_size: number | null; initiator_url?: string; } @@ -42,6 +46,7 @@ export class StorageDetector implements Detector { private timer: ReturnType | null = null; private originalSetItem: typeof Storage.prototype.setItem | null = null; private originalIDBOpen: typeof IDBFactory.prototype.open | null = null; + private originalCachesOpen: typeof CacheStorage.prototype.open | null = null; constructor(baseUrl: URL, bannerId: string) { this.reportUrl = new URL(`${bannerId}/report`, baseUrl); @@ -51,7 +56,9 @@ export class StorageDetector implements Detector { start(): void { this.wrapStorage(); this.wrapIndexedDB(); + this.wrapCacheStorage(); this.scanExisting(); + this.scanCacheStorage(); } stop(): void { @@ -72,6 +79,10 @@ export class StorageDetector implements Detector { IDBFactory.prototype.open = this.originalIDBOpen; this.originalIDBOpen = null; } + if (this.originalCachesOpen && typeof caches !== "undefined") { + caches.open = this.originalCachesOpen; + this.originalCachesOpen = null; + } } private wrapStorage(): void { @@ -107,6 +118,20 @@ export class StorageDetector implements Detector { }; } + private wrapCacheStorage(): void { + if (typeof caches === "undefined") return; + + const originalOpen = caches.open.bind(caches); + this.originalCachesOpen = originalOpen; + + const self = this; + + caches.open = function (name: string): Promise { + self.onCacheStorageOpen(name); + return originalOpen(name); + }; + } + private onStorageWrite( key: string, value: string, @@ -143,6 +168,37 @@ export class StorageDetector implements Detector { this.scheduleFlush(); } + private onCacheStorageOpen(name: string): void { + const reportKey = `cache_storage:${name}`; + if (this.reported.has(reportKey)) return; + + this.reported.add(reportKey); + this.pending.set(reportKey, { + key: name, + storage_type: "cache_storage", + value_size: null, + }); + this.scheduleFlush(); + } + + // scanCacheStorage enumerates pre-existing cache buckets created + // before the SDK loaded. Service workers commonly create their + // caches eagerly on `install`, so without this scan we would miss + // any cache bucket whose creation predates the banner script. + private scanCacheStorage(): void { + if (typeof caches === "undefined") return; + caches + .keys() + .then((names) => { + for (const name of names) { + this.onCacheStorageOpen(name); + } + }) + .catch(() => { + // Insecure context or storage partition errors -- ignore. + }); + } + private scanExisting(): void { this.scanStorage(localStorage, "local_storage"); this.scanStorage(sessionStorage, "session_storage"); diff --git a/packages/cookie-banner/src/detectors/third-party-detector.ts b/packages/cookie-banner/src/detectors/third-party-detector.ts index d0171bd45..b985003d6 100644 --- a/packages/cookie-banner/src/detectors/third-party-detector.ts +++ b/packages/cookie-banner/src/detectors/third-party-detector.ts @@ -24,7 +24,8 @@ type ResourceType = | "font" | "beacon" | "fetch" - | "media"; + | "media" + | "service_worker"; interface DetectedResourceEntry { url: string; @@ -80,6 +81,7 @@ export class ThirdPartyDetector implements Detector { private timer: ReturnType | null = null; private observer: MutationObserver | null = null; private perfObserver: PerformanceObserver | null = null; + private originalSWRegister: typeof ServiceWorkerContainer.prototype.register | null = null; constructor(baseUrl: URL, bannerId: string) { this.reportUrl = new URL(`${bannerId}/report`, baseUrl); @@ -91,6 +93,8 @@ export class ThirdPartyDetector implements Detector { this.scanExisting(); this.observeMutations(); this.observePerformance(); + this.wrapServiceWorker(); + this.scanServiceWorkers(); } stop(): void { @@ -112,6 +116,15 @@ export class ThirdPartyDetector implements Detector { this.perfObserver.disconnect(); this.perfObserver = null; } + + if ( + this.originalSWRegister + && typeof navigator !== "undefined" + && navigator.serviceWorker + ) { + navigator.serviceWorker.register = this.originalSWRegister; + this.originalSWRegister = null; + } } private scanExisting(): void { @@ -179,6 +192,50 @@ export class ThirdPartyDetector implements Detector { } } + // wrapServiceWorker intercepts navigator.serviceWorker.register so + // each registration -- even ones initiated by third-party SDKs -- + // surfaces as a tracker_resource entry keyed on the worker script + // origin+path. + private wrapServiceWorker(): void { + if (typeof navigator === "undefined" || !navigator.serviceWorker) return; + + const sw = navigator.serviceWorker; + const originalRegister = sw.register.bind(sw); + this.originalSWRegister = originalRegister; + + const self = this; + + sw.register = function ( + scriptURL: string | URL, + options?: RegistrationOptions, + ): Promise { + const url = typeof scriptURL === "string" ? scriptURL : scriptURL.toString(); + self.processResource(url, "service_worker"); + return originalRegister(scriptURL, options); + }; + } + + // scanServiceWorkers enumerates registrations that pre-date the SDK + // (e.g. installed on a previous visit, restored from cache). + private scanServiceWorkers(): void { + if (typeof navigator === "undefined" || !navigator.serviceWorker) return; + + navigator.serviceWorker + .getRegistrations() + .then((registrations) => { + for (const r of registrations) { + const url + = r.active?.scriptURL + ?? r.installing?.scriptURL + ?? r.waiting?.scriptURL; + if (url) this.processResource(url, "service_worker"); + } + }) + .catch(() => { + // Some browsers throw NotSupportedError in insecure contexts. + }); + } + private processResource(src: string, resourceType: ResourceType): void { if (EXTENSION_URL_RE.test(src)) return; diff --git a/pkg/cmd/tracker-resource/create/create.go b/pkg/cmd/tracker-resource/create/create.go index 0c428c929..838da54c4 100644 --- a/pkg/cmd/tracker-resource/create/create.go +++ b/pkg/cmd/tracker-resource/create/create.go @@ -96,6 +96,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { huh.NewOption("Beacon", "BEACON"), huh.NewOption("Fetch / XHR", "FETCH"), huh.NewOption("Media", "MEDIA"), + huh.NewOption("Service Worker", "SERVICE_WORKER"), ). Value(&flagResourceType).Run(); err != nil { return err @@ -161,7 +162,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, IFRAME, IMAGE, STYLESHEET, FONT, BEACON, FETCH or MEDIA (required)") + cmd.Flags().StringVar(&flagResourceType, "resource-type", "", "Resource type: SCRIPT, IFRAME, IMAGE, STYLESHEET, FONT, BEACON, FETCH, MEDIA or SERVICE_WORKER (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/20260511T064746Z.sql b/pkg/coredata/migrations/20260511T064746Z.sql new file mode 100644 index 000000000..54bdc4aa7 --- /dev/null +++ b/pkg/coredata/migrations/20260511T064746Z.sql @@ -0,0 +1,21 @@ +-- 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. + +-- A registered service worker is a URL-shaped artifact, so it lives +-- in tracker_resources alongside scripts/iframes. +ALTER TYPE tracker_resource_type ADD VALUE IF NOT EXISTS 'SERVICE_WORKER'; + +-- A Cache Storage bucket is just a named string identifier with no +-- URL, so it lives in detected_trackers like the other storage types. +ALTER TYPE tracker_type ADD VALUE IF NOT EXISTS 'CACHE_STORAGE'; diff --git a/pkg/coredata/tracker_resource_type.go b/pkg/coredata/tracker_resource_type.go index d23a1d1b9..f43a294be 100644 --- a/pkg/coredata/tracker_resource_type.go +++ b/pkg/coredata/tracker_resource_type.go @@ -22,14 +22,15 @@ import ( type TrackerResourceType string const ( - TrackerResourceTypeScript TrackerResourceType = "SCRIPT" - TrackerResourceTypeIframe TrackerResourceType = "IFRAME" - TrackerResourceTypeImage TrackerResourceType = "IMAGE" - TrackerResourceTypeStylesheet TrackerResourceType = "STYLESHEET" - TrackerResourceTypeFont TrackerResourceType = "FONT" - TrackerResourceTypeBeacon TrackerResourceType = "BEACON" - TrackerResourceTypeFetch TrackerResourceType = "FETCH" - TrackerResourceTypeMedia TrackerResourceType = "MEDIA" + TrackerResourceTypeScript TrackerResourceType = "SCRIPT" + TrackerResourceTypeIframe TrackerResourceType = "IFRAME" + TrackerResourceTypeImage TrackerResourceType = "IMAGE" + TrackerResourceTypeStylesheet TrackerResourceType = "STYLESHEET" + TrackerResourceTypeFont TrackerResourceType = "FONT" + TrackerResourceTypeBeacon TrackerResourceType = "BEACON" + TrackerResourceTypeFetch TrackerResourceType = "FETCH" + TrackerResourceTypeMedia TrackerResourceType = "MEDIA" + TrackerResourceTypeServiceWorker TrackerResourceType = "SERVICE_WORKER" ) func TrackerResourceTypes() []TrackerResourceType { @@ -42,6 +43,7 @@ func TrackerResourceTypes() []TrackerResourceType { TrackerResourceTypeBeacon, TrackerResourceTypeFetch, TrackerResourceTypeMedia, + TrackerResourceTypeServiceWorker, } } @@ -77,6 +79,8 @@ func (s *TrackerResourceType) Scan(value any) error { *s = TrackerResourceTypeFetch case TrackerResourceTypeMedia: *s = TrackerResourceTypeMedia + case TrackerResourceTypeServiceWorker: + *s = TrackerResourceTypeServiceWorker default: return fmt.Errorf("invalid TrackerResourceType value: %q", v) } @@ -92,7 +96,8 @@ func (s TrackerResourceType) Value() (driver.Value, error) { TrackerResourceTypeFont, TrackerResourceTypeBeacon, TrackerResourceTypeFetch, - TrackerResourceTypeMedia: + TrackerResourceTypeMedia, + TrackerResourceTypeServiceWorker: return string(s), nil default: return nil, fmt.Errorf("invalid TrackerResourceType: %s", s) diff --git a/pkg/coredata/tracker_type.go b/pkg/coredata/tracker_type.go index feca1f145..876c7b4b7 100644 --- a/pkg/coredata/tracker_type.go +++ b/pkg/coredata/tracker_type.go @@ -26,6 +26,7 @@ const ( TrackerTypeLocalStorage TrackerType = "LOCAL_STORAGE" TrackerTypeSessionStorage TrackerType = "SESSION_STORAGE" TrackerTypeIndexedDB TrackerType = "INDEXED_DB" + TrackerTypeCacheStorage TrackerType = "CACHE_STORAGE" ) func TrackerTypes() []TrackerType { @@ -34,6 +35,7 @@ func TrackerTypes() []TrackerType { TrackerTypeLocalStorage, TrackerTypeSessionStorage, TrackerTypeIndexedDB, + TrackerTypeCacheStorage, } } @@ -61,6 +63,8 @@ func (s *TrackerType) Scan(value any) error { *s = TrackerTypeSessionStorage case TrackerTypeIndexedDB: *s = TrackerTypeIndexedDB + case TrackerTypeCacheStorage: + *s = TrackerTypeCacheStorage default: return fmt.Errorf("invalid TrackerType value: %q", v) } @@ -72,7 +76,8 @@ func (s TrackerType) Value() (driver.Value, error) { case TrackerTypeCookie, TrackerTypeLocalStorage, TrackerTypeSessionStorage, - TrackerTypeIndexedDB: + TrackerTypeIndexedDB, + TrackerTypeCacheStorage: return string(s), nil default: return nil, fmt.Errorf("invalid TrackerType: %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 3a9842479..b7541a3e5 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -52,6 +52,10 @@ enum TrackerType @goEnum( value: "go.probo.inc/probo/pkg/coredata.TrackerTypeIndexedDB" ) + CACHE_STORAGE + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerTypeCacheStorage" + ) } enum CookieBannerOrderField @@ -332,6 +336,10 @@ enum TrackerResourceType @goEnum( value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeMedia" ) + SERVICE_WORKER + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeServiceWorker" + ) } enum TrackerResourceOrderField diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index 918066394..3956a31b3 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -423,6 +423,8 @@ func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Re storageType = coredata.TrackerTypeSessionStorage case "indexed_db": storageType = coredata.TrackerTypeIndexedDB + case "cache_storage": + storageType = coredata.TrackerTypeCacheStorage default: continue } @@ -457,6 +459,8 @@ func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Re resourceType = coredata.TrackerResourceTypeFetch case "media": resourceType = coredata.TrackerResourceTypeMedia + case "service_worker": + resourceType = coredata.TrackerResourceTypeServiceWorker default: continue } diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 9f2a3a393..d27f2abce 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -9450,7 +9450,7 @@ components: description: Cookie category ID tracker_type: type: string - enum: [COOKIE, LOCAL_STORAGE, SESSION_STORAGE, INDEXED_DB] + enum: [COOKIE, LOCAL_STORAGE, SESSION_STORAGE, INDEXED_DB, CACHE_STORAGE] description: Type of tracker pattern: type: string @@ -9522,7 +9522,7 @@ components: description: Cookie category ID resource_type: type: string - enum: [SCRIPT, IFRAME, IMAGE, STYLESHEET, FONT, BEACON, FETCH, MEDIA] + enum: [SCRIPT, IFRAME, IMAGE, STYLESHEET, FONT, BEACON, FETCH, MEDIA, SERVICE_WORKER] description: Type of tracked resource origin: type: string @@ -10046,7 +10046,7 @@ components: $ref: "#/components/schemas/GID" tracker_type: type: string - enum: [COOKIE, LOCAL_STORAGE, SESSION_STORAGE, INDEXED_DB] + enum: [COOKIE, LOCAL_STORAGE, SESSION_STORAGE, INDEXED_DB, CACHE_STORAGE] pattern: type: string match_type: @@ -10186,7 +10186,7 @@ components: $ref: "#/components/schemas/GID" resource_type: type: string - enum: [SCRIPT, IFRAME, IMAGE, STYLESHEET, FONT, BEACON, FETCH, MEDIA] + enum: [SCRIPT, IFRAME, IMAGE, STYLESHEET, FONT, BEACON, FETCH, MEDIA, SERVICE_WORKER] origin: type: string path: