From 71541ef187cd319e8678311180607a920fac8c0e Mon Sep 17 00:00:00 2001 From: jensmohr Date: Wed, 27 May 2026 18:31:44 +0200 Subject: [PATCH] Add lightweight observed request explorer --- src/background.js | 46 ++++ src/dashboard/dashboard.html | 3 + src/request-explorer/request-explorer.css | 228 +++++++++++++++++ src/request-explorer/request-explorer.html | 79 ++++++ src/request-explorer/request-explorer.js | 285 +++++++++++++++++++++ 5 files changed, 641 insertions(+) create mode 100644 src/request-explorer/request-explorer.css create mode 100644 src/request-explorer/request-explorer.html create mode 100644 src/request-explorer/request-explorer.js diff --git a/src/background.js b/src/background.js index cdb2b8e..39108da 100644 --- a/src/background.js +++ b/src/background.js @@ -81,6 +81,10 @@ async function handleVendorGetMessage(message, sender) { return handleListRecentConsentStatesMessage(); } + if (message.type === "list_recent_observed_requests") { + return handleListRecentObservedRequestsMessage(); + } + if (message.type === "purge_unlocked_evidence_records") { return handlePurgeUnlockedEvidenceRecordsMessage(); } @@ -316,6 +320,16 @@ async function handleListRecentConsentStatesMessage() { }; } +async function handleListRecentObservedRequestsMessage() { + const db = await openVendorGetDb(); + const observedRequests = await listRecentObservedRequests(db, 50); + + return { + success: true, + observedRequests + }; +} + function getLatestConsentState(db) { return new Promise((resolve, reject) => { const tx = db.transaction(["consent_states"], "readonly"); @@ -368,6 +382,38 @@ function listRecentConsentStates(db, limit) { }); } +function listRecentObservedRequests(db, limit) { + return new Promise((resolve, reject) => { + const observedRequests = []; + const tx = db.transaction( + [VENDORGET_STORE_NAMES.observedRequests], + "readonly" + ); + const requestsStore = tx.objectStore(VENDORGET_STORE_NAMES.observedRequests); + const lastSeenAtIndex = requestsStore.index("lastSeenAt"); + const cursorRequest = lastSeenAtIndex.openCursor(null, "prev"); + + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor || observedRequests.length >= limit) { + return; + } + + observedRequests.push(cursor.value); + + if (observedRequests.length < limit) { + cursor.continue(); + } + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(observedRequests); + }); +} + function listRecentGvlSnapshots(db, limit) { return new Promise((resolve, reject) => { const snapshots = []; diff --git a/src/dashboard/dashboard.html b/src/dashboard/dashboard.html index 07beca2..30c0e1b 100644 --- a/src/dashboard/dashboard.html +++ b/src/dashboard/dashboard.html @@ -102,6 +102,9 @@ GVL-Explorer öffnen + + Request-Explorer öffnen + Request-/Empfänger-Analyse öffnen diff --git a/src/request-explorer/request-explorer.css b/src/request-explorer/request-explorer.css new file mode 100644 index 0000000..39e588f --- /dev/null +++ b/src/request-explorer/request-explorer.css @@ -0,0 +1,228 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + font-family: Arial, sans-serif; + color: #e5edf5; + background: #111827; +} + +.explorer { + width: min(1180px, 100%); + margin: 0 auto; + padding: 24px; +} + +.explorer-header { + display: grid; + gap: 8px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #334155; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +h1 { + font-size: 24px; + font-weight: 700; +} + +h2 { + margin-bottom: 12px; + font-size: 15px; + font-weight: 700; +} + +h3 { + margin-top: 14px; + margin-bottom: 8px; + font-size: 13px; + color: #cbd5e1; +} + +p { + max-width: 760px; + font-size: 13px; + line-height: 1.5; + color: #cbd5e1; +} + +.back-link { + width: fit-content; + color: #bfdbfe; + font-size: 13px; +} + +.panel { + margin-bottom: 22px; + padding-bottom: 20px; + border-bottom: 1px solid #334155; +} + +.section-help { + margin-bottom: 4px; +} + +.request-overview { + display: grid; + grid-template-columns: repeat(2, minmax(0, 220px)); + gap: 10px; + margin: 8px 0 0; +} + +.request-overview div { + min-width: 0; + padding: 10px 12px; + border: 1px solid #334155; + border-radius: 4px; + background: #1f2937; +} + +.request-overview dt { + margin: 0 0 6px; + font-size: 12px; + color: #cbd5e1; +} + +.request-overview dd { + margin: 0; + font-size: 13px; + font-weight: 700; + overflow-wrap: anywhere; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + background: #1f2937; +} + +th, +td { + padding: 10px 12px; + border: 1px solid #334155; + text-align: left; +} + +th { + color: #cbd5e1; + font-weight: 700; + background: #182231; +} + +.request-list-wrap { + width: 100%; + overflow-x: auto; + border: 1px solid #334155; + border-radius: 4px; + background: #1f2937; +} + +.request-list { + min-width: 980px; + border: 0; +} + +.request-list th, +.request-list td { + padding: 8px 10px; + vertical-align: top; +} + +.request-list tbody tr { + cursor: pointer; +} + +.request-list tbody tr:hover, +.request-list tbody tr:focus { + outline: 0; + background: #263449; +} + +.request-list tbody tr.is-selected { + background: #1e3a5f; + box-shadow: inset 3px 0 0 #60a5fa; +} + +.request-list .numeric { + text-align: right; +} + +.request-list .path-cell, +.request-list .origin-cell { + max-width: 260px; + overflow-wrap: anywhere; +} + +.request-detail { + margin-top: 18px; +} + +.inspector-table th, +.inspector-table td { + vertical-align: top; +} + +.inspector-table th { + width: 230px; +} + +.inspector-table .inspector-value { + overflow-wrap: anywhere; + word-break: break-word; + user-select: text; +} + +.empty-state { + padding: 10px 12px; + border: 1px solid #334155; + border-radius: 4px; + background: #1f2937; +} + +.inspector-details { + margin-top: 12px; + font-size: 13px; + color: #cbd5e1; +} + +.inspector-details summary { + cursor: pointer; +} + +.inspector-details pre { + max-height: 260px; + margin: 10px 0 0; + padding: 10px 12px; + overflow: auto; + border: 1px solid #334155; + border-radius: 4px; + color: #e5edf5; + background: #0f172a; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .explorer { + padding: 16px; + } + + .request-overview { + grid-template-columns: 1fr; + } + + .inspector-table th { + width: 150px; + } +} diff --git a/src/request-explorer/request-explorer.html b/src/request-explorer/request-explorer.html new file mode 100644 index 0000000..6368260 --- /dev/null +++ b/src/request-explorer/request-explorer.html @@ -0,0 +1,79 @@ + + + + + + VG-Observe Request-Explorer + + + +
+
+ Zurück zum Dashboard +

Request-Explorer

+

+ Diese Ansicht zeigt zuletzt technisch beobachtete Requests. +

+
+
+
Geladene Einträge
+
-
+
+
+
Zuletzt beobachtet
+
-
+
+
+
+ +
+

Zuletzt beobachtete Requests

+ + +
+
+ + + + diff --git a/src/request-explorer/request-explorer.js b/src/request-explorer/request-explorer.js new file mode 100644 index 0000000..bbc8c8f --- /dev/null +++ b/src/request-explorer/request-explorer.js @@ -0,0 +1,285 @@ +"use strict"; + +const requestLoadedCount = document.getElementById("request-loaded-count"); +const requestLatestSeen = document.getElementById("request-latest-seen"); +const requestEmpty = document.getElementById("request-empty"); +const requestContent = document.getElementById("request-content"); +const requestList = document.getElementById("request-list"); +const requestDetailSummary = document.getElementById("request-detail-summary"); +const requestDetailJson = document.getElementById("request-detail-json"); +const requestDetailCorrelation = document.getElementById( + "request-detail-correlation" +); +const requestDetailFingerprintSource = document.getElementById( + "request-detail-fingerprint-source" +); +const requestDetailTechnicalFields = document.getElementById( + "request-detail-technical-fields" +); + +let observedRequests = []; +let selectedRequestFingerprint = null; + +document.addEventListener("DOMContentLoaded", async () => { + await renderObservedRequests(); +}); + +async function renderObservedRequests() { + try { + const result = await browser.runtime.sendMessage({ + type: "list_recent_observed_requests" + }); + + if (!result?.success) { + throw new Error(result?.error ?? "list_recent_observed_requests_failed"); + } + + observedRequests = result.observedRequests ?? []; + updateOverview(); + + if (observedRequests.length === 0) { + renderNoObservedRequests(); + return; + } + + requestEmpty.hidden = true; + requestContent.hidden = false; + + if (!findObservedRequest(selectedRequestFingerprint)) { + selectedRequestFingerprint = + observedRequests[0]?.requestFingerprint ?? null; + } + + renderRequestList(); + renderSelectedRequest(); + } catch (error) { + observedRequests = []; + updateOverview(); + requestEmpty.hidden = false; + requestContent.hidden = true; + requestEmpty.textContent = + "Beobachtete Requests konnten nicht geladen werden."; + console.warn("VG-Observe request list failed", error); + } +} + +function updateOverview() { + requestLoadedCount.textContent = String(observedRequests.length); + requestLatestSeen.textContent = formatNullable( + observedRequests[0]?.lastSeenAt ?? null + ); +} + +function renderNoObservedRequests() { + selectedRequestFingerprint = null; + requestList.textContent = ""; + clearRequestDetails(); + requestEmpty.hidden = false; + requestContent.hidden = true; + requestEmpty.textContent = "Keine beobachteten Requests vorhanden."; +} + +function renderRequestList() { + requestList.textContent = ""; + + for (const observedRequest of observedRequests) { + const row = document.createElement("tr"); + const isSelected = + observedRequest?.requestFingerprint === selectedRequestFingerprint; + + row.className = isSelected ? "is-selected" : ""; + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.setAttribute("aria-pressed", isSelected ? "true" : "false"); + + row.addEventListener("click", () => { + selectObservedRequest(observedRequest?.requestFingerprint ?? null); + }); + + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectObservedRequest(observedRequest?.requestFingerprint ?? null); + } + }); + + appendListCell(row, formatNullable(observedRequest?.lastSeenAt)); + appendListCell( + row, + formatNullable(observedRequest?.request?.origin), + "origin-cell" + ); + appendListCell( + row, + formatNullable(observedRequest?.request?.pathname), + "path-cell" + ); + appendListCell(row, formatThirdParty(observedRequest?.request?.thirdParty)); + appendListCell(row, formatYesNo(hasConsentParams(observedRequest))); + appendListCell(row, formatYesNo(hasConsentCorrelation(observedRequest))); + appendListCell(row, formatNullable(observedRequest?.seenCount), "numeric"); + + requestList.append(row); + } +} + +function appendListCell(row, value, className) { + const cell = document.createElement("td"); + + if (className) { + cell.className = className; + } + + cell.textContent = value; + row.append(cell); +} + +function selectObservedRequest(requestFingerprint) { + selectedRequestFingerprint = requestFingerprint; + renderRequestList(); + renderSelectedRequest(); +} + +function renderSelectedRequest() { + const observedRequest = findObservedRequest(selectedRequestFingerprint); + + clearRequestDetails(); + + if (!observedRequest) { + return; + } + + renderSummaryTable([ + ["Letzte Beobachtung", formatNullable(observedRequest?.lastSeenAt)], + ["Erste Beobachtung", formatNullable(observedRequest?.firstSeenAt)], + ["Empfaenger-Origin", formatNullable(observedRequest?.request?.origin)], + ["Pfad", formatNullable(observedRequest?.request?.pathname)], + ["Methode", formatNullable(observedRequest?.request?.method)], + ["Request-Typ", formatNullable(observedRequest?.request?.type)], + ["Third-Party", formatThirdParty(observedRequest?.request?.thirdParty)], + ["Consent-Parameter vorhanden", formatYesNo(hasConsentParams(observedRequest))], + [ + "Consent-Korrelation vorhanden", + formatYesNo(hasConsentCorrelation(observedRequest)) + ], + ["SeenCount", formatNullable(observedRequest?.seenCount)], + [ + "Request-Fingerprint", + formatNullable(observedRequest?.requestFingerprint) + ] + ]); + + requestDetailJson.textContent = JSON.stringify(observedRequest, null, 2); + requestDetailCorrelation.textContent = JSON.stringify( + observedRequest?.correlation ?? null, + null, + 2 + ); + requestDetailFingerprintSource.textContent = JSON.stringify( + observedRequest?.requestFingerprintSource ?? null, + null, + 2 + ); + requestDetailTechnicalFields.textContent = JSON.stringify( + buildTechnicalFieldExtract(observedRequest), + null, + 2 + ); +} + +function renderSummaryTable(rows) { + const table = document.createElement("table"); + const body = document.createElement("tbody"); + + table.className = "inspector-table"; + + for (const [label, value] of rows) { + const row = document.createElement("tr"); + const labelCell = document.createElement("th"); + const valueCell = document.createElement("td"); + + labelCell.scope = "row"; + labelCell.textContent = label; + valueCell.className = "inspector-value"; + valueCell.textContent = value; + row.append(labelCell, valueCell); + body.append(row); + } + + table.append(body); + requestDetailSummary.append(table); +} + +function clearRequestDetails() { + requestDetailSummary.textContent = ""; + requestDetailJson.textContent = ""; + requestDetailCorrelation.textContent = ""; + requestDetailFingerprintSource.textContent = ""; + requestDetailTechnicalFields.textContent = ""; +} + +function findObservedRequest(requestFingerprint) { + if (!requestFingerprint) { + return null; + } + + return ( + observedRequests.find((observedRequest) => { + return observedRequest?.requestFingerprint === requestFingerprint; + }) ?? null + ); +} + +function hasConsentParams(observedRequest) { + const consentParams = observedRequest?.consentParams; + + if (!consentParams || typeof consentParams !== "object") { + return false; + } + + return Object.values(consentParams).some((value) => { + return value !== null && value !== undefined && value !== ""; + }); +} + +function hasConsentCorrelation(observedRequest) { + return Boolean(observedRequest?.correlation?.stateFingerprint); +} + +function buildTechnicalFieldExtract(observedRequest) { + return { + schemaVersion: observedRequest?.schemaVersion ?? null, + recordedAt: observedRequest?.recordedAt ?? null, + recordingSource: observedRequest?.recordingSource ?? null, + firstSeenAt: observedRequest?.firstSeenAt ?? null, + lastSeenAt: observedRequest?.lastSeenAt ?? null, + seenCount: observedRequest?.seenCount ?? null, + context: observedRequest?.context ?? null, + request: observedRequest?.request ?? null, + consentParams: observedRequest?.consentParams ?? null + }; +} + +function formatNullable(value) { + if (value === null || value === undefined || value === "") { + return "-"; + } + + return String(value); +} + +function formatThirdParty(value) { + if (value === true) { + return "ja"; + } + + if (value === false) { + return "nein"; + } + + return "unbekannt"; +} + +function formatYesNo(value) { + return value ? "ja" : "nein"; +}