Redact edge gateway log context secrets

This commit is contained in:
Jeppe B
2026-06-01 22:13:32 +02:00
parent a76944b00c
commit 91b15faba6
2 changed files with 187 additions and 8 deletions
@@ -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();
@@ -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");
});
});