Redact edge gateway log context secrets
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user