diff --git a/src/features/edgeGateways/EdgeGatewayLogsPage.vue b/src/features/edgeGateways/EdgeGatewayLogsPage.vue index 61ff5909..45dad814 100644 --- a/src/features/edgeGateways/EdgeGatewayLogsPage.vue +++ b/src/features/edgeGateways/EdgeGatewayLogsPage.vue @@ -36,7 +36,73 @@ const entryContext = (entry = {}) => { return context && typeof context === "object" && !Array.isArray(context) ? context : {}; }; -const formatDetailValue = (value) => { +const REDACTED_CONTEXT_VALUE = "[REDACTED]"; +const SENSITIVE_CONTEXT_KEY_PATTERN = /(?:token|secret|password|credential|api[_-]?key|authorization|auth|signature)/i; +const SENSITIVE_CONTEXT_QUERY_PATTERN = SENSITIVE_CONTEXT_KEY_PATTERN; + +const redactSensitiveUrlParams = (value) => { + const text = String(value || ""); + if (!text) { + return text; + } + + const redactParams = (url) => { + let redacted = false; + url.searchParams.forEach((_paramValue, key) => { + if (SENSITIVE_CONTEXT_QUERY_PATTERN.test(key)) { + url.searchParams.set(key, REDACTED_CONTEXT_VALUE); + redacted = true; + } + }); + return redacted ? url.toString() : text; + }; + + try { + return redactParams(new URL(text)); + } catch { + return text.replace(/([?&])([^=&#]+)=([^&#]*)/g, (match, separator, rawKey) => { + let key = rawKey; + try { + key = decodeURIComponent(rawKey.replace(/\+/g, " ")); + } catch { + key = rawKey; + } + return SENSITIVE_CONTEXT_QUERY_PATTERN.test(key) ? `${separator}${rawKey}=${REDACTED_CONTEXT_VALUE}` : match; + }); + } +}; + +const redactEdgeGatewayLogContext = (value, seen = new WeakSet()) => { + if (value === null || typeof value === "undefined") { + return value; + } + + if (typeof value === "string") { + return redactSensitiveUrlParams(value); + } + + if (typeof value !== "object") { + return value; + } + + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + + if (Array.isArray(value)) { + return value.map((item) => redactEdgeGatewayLogContext(item, seen)); + } + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + SENSITIVE_CONTEXT_KEY_PATTERN.test(key) ? REDACTED_CONTEXT_VALUE : redactEdgeGatewayLogContext(item, seen), + ]) + ); +}; + +const formatDetailValue = (value, { redactContext = false } = {}) => { if (value === null || typeof value === "undefined" || value === "") { return "None"; } @@ -44,13 +110,14 @@ const formatDetailValue = (value) => { return value ? "yes" : "no"; } if (typeof value === "object") { + const displayValue = redactContext ? redactEdgeGatewayLogContext(value) : value; try { - return JSON.stringify(value, null, 2); + return JSON.stringify(displayValue, null, 2); } catch { - return String(value); + return String(displayValue); } } - return String(value); + return redactContext && typeof value === "string" ? redactSensitiveUrlParams(value) : String(value); }; const detailRow = (label, value) => ({ @@ -61,7 +128,7 @@ const detailRow = (label, value) => ({ const contextDetailRow = (value) => ({ label: "Context", - value: formatDetailValue(value), + value: formatDetailValue(value, { redactContext: true }), multiline: true, actionsOnly: true, }); @@ -208,7 +275,9 @@ const relayActorLabel = (entry = {}) => { }; const normalizeRelayRole = (role = "") => { - const normalized = String(role || "").trim().toUpperCase(); + const normalized = String(role || "") + .trim() + .toUpperCase(); const aliases = { ENTRANCE: "ENTRY", IN: "ENTRY", @@ -260,7 +329,8 @@ const firstRelayText = (values = []) => { const relayDisplayName = (entry = {}) => { const context = entryContext(entry); - const actionContext = context.action_context && typeof context.action_context === "object" ? context.action_context : {}; + const actionContext = + context.action_context && typeof context.action_context === "object" ? context.action_context : {}; const binding = context.binding && typeof context.binding === "object" ? context.binding : {}; const request = relayRequestPayload(entry); return firstRelayText([ @@ -284,7 +354,8 @@ const relayIdentifier = (entry = {}) => { const relaySignalLabel = (entry = {}) => { const context = entryContext(entry); - const actionContext = context.action_context && typeof context.action_context === "object" ? context.action_context : {}; + const actionContext = + context.action_context && typeof context.action_context === "object" ? context.action_context : {}; const signal = context.signal && typeof context.signal === "object" ? context.signal : {}; const role = normalizeRelayRole(context.relay_role || actionContext.relay_role || actionContext.role); const action = String(context.action || signal.command_type || "RELAY").toUpperCase(); diff --git a/tests/unit/edge-gateway-logs-redaction.spec.js b/tests/unit/edge-gateway-logs-redaction.spec.js new file mode 100644 index 00000000..dd1b601f --- /dev/null +++ b/tests/unit/edge-gateway-logs-redaction.spec.js @@ -0,0 +1,108 @@ +// @vitest-environment jsdom +import { mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; + +import EdgeGatewayLogsPage from "@/features/edgeGateways/EdgeGatewayLogsPage.vue"; + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("EdgeGatewayLogsPage context redaction", () => { + it("redacts sensitive timeline context values before modal display and clipboard copy", async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + const wrapper = mount(EdgeGatewayLogsPage, { + props: { + timeline: [ + { + type: "log", + level: "INFO", + message: "Connected to broker", + created_at: "2026-06-01T10:00:00Z", + context: { + ws_url: "wss://broker.example/ws?token=raw-token&agentToken=raw-agent-token&connection=42", + installer_token: "raw-installer-token", + broker_shared_secret: "raw-broker-secret", + nested: { + callback: "/edge/callback?agent_token=raw-query-token&visible=true", + safe_label: "visible diagnostic value", + }, + }, + }, + ], + }, + attachTo: document.body, + }); + + await wrapper.get('[data-testid="gateway-log-entry-0"]').trigger("click"); + await wrapper.get('[data-testid="gateway-log-entry-context-view-0"]').trigger("click"); + + const modalText = wrapper.get('[data-testid="gateway-context-modal-body"]').text(); + expect(modalText).toContain("visible diagnostic value"); + expect(modalText).toContain("[REDACTED]"); + expect(modalText).not.toContain("raw-token"); + expect(modalText).not.toContain("raw-agent-token"); + expect(modalText).not.toContain("raw-installer-token"); + expect(modalText).not.toContain("raw-broker-secret"); + expect(modalText).not.toContain("raw-query-token"); + + await wrapper.get('[data-testid="gateway-context-modal-copy"]').trigger("click"); + await flushMicrotasks(); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0][0]).toBe(modalText); + expect(writeText.mock.calls[0][0]).not.toContain("raw-token"); + }); + + it("redacts sensitive relay context values before direct clipboard copy", async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + const wrapper = mount(EdgeGatewayLogsPage, { + props: { + relayLogs: [ + { + id: 12, + message: "Relay dispatched", + created_at: "2026-06-01T10:01:00Z", + context: { + handler: "broker", + signal: { + request: { on: true }, + }, + response: { online: true, on: true }, + relay_token: "raw-relay-token", + command_payload: { + authorization: "Bearer raw-auth-token", + target: "machine-1", + }, + broker_url: "https://broker.example/relay?secret=raw-url-secret&relay=7", + }, + }, + ], + }, + attachTo: document.body, + }); + + await wrapper.get('[data-testid="gateway-relay-entry-0"]').trigger("click"); + await wrapper.get('[data-testid="gateway-relay-entry-context-copy-0"]').trigger("click"); + await flushMicrotasks(); + + expect(writeText).toHaveBeenCalledTimes(1); + const copiedText = writeText.mock.calls[0][0]; + expect(copiedText).toContain("machine-1"); + expect(copiedText).toContain("[REDACTED]"); + expect(copiedText).not.toContain("raw-relay-token"); + expect(copiedText).not.toContain("raw-auth-token"); + expect(copiedText).not.toContain("raw-url-secret"); + }); +});