Redact edge gateway diagnostics

This commit is contained in:
Jeppe B
2026-06-01 22:27:19 +02:00
parent 5aad9eda8d
commit fd7cfbd3c3
4 changed files with 116 additions and 13 deletions
@@ -21,7 +21,7 @@ import {
setDepartmentGatewayCutover,
updateEdgeGateway,
} from "@/services/edgeGateways.js";
import { normalizeEdgeGatewayError, normalizeGatewayWebSocketClose } from "@/features/edgeGateways/edgeGatewayErrors.js";
import { normalizeEdgeGatewayError, normalizeGatewayWebSocketClose, redactEdgeGatewayDiagnostic } from "@/features/edgeGateways/edgeGatewayErrors.js";
import EdgeGatewayOverviewPage from "@/features/edgeGateways/EdgeGatewayOverviewPage.vue";
import EdgeGatewayInventoryPage from "@/features/edgeGateways/EdgeGatewayInventoryPage.vue";
import EdgeGatewayTasksPage from "@/features/edgeGateways/EdgeGatewayTasksPage.vue";
@@ -183,7 +183,7 @@ const terminalGatewayBrokerDiagnostics = (gateway = selectedGatewayView.value) =
if (value === null || value === undefined || value === "") {
return;
}
lines.push(`${label}: ${value}`);
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
};
push("Gateway ID", gateway?.id);
@@ -1,4 +1,6 @@
<script setup>
import { redactEdgeGatewayDiagnostic } from "@/features/edgeGateways/edgeGatewayErrors.js";
const props = defineProps({
status: {
type: String,
@@ -44,7 +46,10 @@ const shortcuts = [
{ id: "docker ps", label: "docker ps" },
];
const diagnosticsText = () => (Array.isArray(props.diagnostics) ? props.diagnostics.join("\n") : "");
const diagnosticsText = () =>
Array.isArray(props.diagnostics)
? props.diagnostics.map((entry) => redactEdgeGatewayDiagnostic(entry)).join("\n")
: "";
const copyDiagnostics = async () => {
const text = diagnosticsText();
+58 -9
View File
@@ -55,6 +55,36 @@ const WEBSOCKET_CLOSE_MESSAGES = {
socket_error: "Terminal websocket connection failed.",
};
const SENSITIVE_DIAGNOSTIC_KEYS = new Set([
"accesstoken",
"agenttoken",
"apikey",
"authorization",
"auth",
"clientsecret",
"jwt",
"key",
"password",
"secret",
"signature",
"token",
]);
const normalizeDiagnosticKey = (key) =>
String(key || "")
.replace(/[^a-z0-9]/gi, "")
.toLowerCase();
const isSensitiveDiagnosticKey = (key) => SENSITIVE_DIAGNOSTIC_KEYS.has(normalizeDiagnosticKey(key));
const redactSensitiveAssignments = (value) =>
String(value)
.replace(/\b(authorization|auth)\s*[:=]\s*(?:Bearer|Basic)\s+[^\s,;&]+/gi, (_match, key) => `${key}=***`)
.replace(
/([?&;,\s]?(access[_-]?token|agent[_-]?token|api[_-]?key|authorization|auth|client[_-]?secret|jwt|key|password|secret|signature|token)\s*[:=]\s*)("[^"\s,;&]*"|'[^'\s,;&]*'|[^\s,;&]+)/gi,
(_match, prefix) => `${prefix}***`
);
export function redactEdgeGatewayUrl(value) {
if (!value) {
return null;
@@ -62,17 +92,33 @@ export function redactEdgeGatewayUrl(value) {
try {
const url = new URL(String(value), typeof window !== "undefined" ? window.location.origin : "http://localhost");
["token", "agentToken", "agent_token"].forEach((key) => {
if (url.searchParams.has(key)) {
if (url.username) {
url.username = "***";
}
if (url.password) {
url.password = "***";
}
Array.from(url.searchParams.keys()).forEach((key) => {
if (isSensitiveDiagnosticKey(key)) {
url.searchParams.set(key, "***");
}
});
return url.toString();
} catch (_error) {
return String(value).replace(/([?&](?:token|agentToken|agent_token)=)[^&]+/gi, "$1***");
return redactSensitiveAssignments(String(value));
}
}
export function redactEdgeGatewayDiagnostic(value) {
if (value === null || value === undefined) {
return value;
}
return redactSensitiveAssignments(
String(value).replace(/\b(?:wss?|https?|mqtts?):\/\/[^\s<>"')]+/gi, (match) => redactEdgeGatewayUrl(match))
);
}
const formatDiagnosticLines = (diagnostics, prefix = "Diagnostic") => {
if (!diagnostics || typeof diagnostics !== "object" || Array.isArray(diagnostics)) {
return [];
@@ -83,20 +129,23 @@ const formatDiagnosticLines = (diagnostics, prefix = "Diagnostic") => {
if (value === null || value === undefined || value === "") {
return;
}
lines.push(`${label}: ${value}`);
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
};
push(`${prefix} code`, diagnostics.reason_code || diagnostics.error_code);
push("Broker URL", diagnostics.broker_url);
push("WebSocket URL", redactEdgeGatewayUrl(diagnostics.ws_url));
push("WebSocket URL", diagnostics.ws_url);
push("Derived URL warning", diagnostics.derived_warning);
if (diagnostics.broker_presence && typeof diagnostics.broker_presence === "object") {
push("Broker connected", diagnostics.broker_presence.connected === true ? "yes" : "no");
push("Broker connection", diagnostics.broker_presence.connection_id);
push("Broker last seen", diagnostics.broker_presence.last_seen_at);
push("Broker age", Number.isFinite(Number(diagnostics.broker_presence.age_seconds))
? `${Number(diagnostics.broker_presence.age_seconds)}s`
: null);
push(
"Broker age",
Number.isFinite(Number(diagnostics.broker_presence.age_seconds))
? `${Number(diagnostics.broker_presence.age_seconds)}s`
: null
);
push("Broker last error", diagnostics.broker_presence.last_error);
push("Broker disconnect reason", diagnostics.broker_presence.disconnect_reason);
}
@@ -180,7 +229,7 @@ export function normalizeGatewayWebSocketClose(event = {}, context = {}) {
details.push(`WebSocket code: ${code}`);
}
if (reason) {
details.push(`Close reason: ${reason}`);
details.push(`Close reason: ${redactEdgeGatewayDiagnostic(reason)}`);
}
if (wasClean !== null) {
details.push(`Clean close: ${wasClean ? "yes" : "no"}`);
@@ -2,7 +2,13 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { inferEdgeGatewayErrorCode, normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
import {
inferEdgeGatewayErrorCode,
normalizeEdgeGatewayError,
normalizeGatewayWebSocketClose,
redactEdgeGatewayDiagnostic,
redactEdgeGatewayUrl,
} from "@/features/edgeGateways/edgeGatewayErrors.js";
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
const tasksSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayTasksPage.vue"), "utf8");
@@ -49,4 +55,47 @@ describe("edge gateway workflow helpers", () => {
expect(normalized.title).toMatch(/legitimationsoplysninger/i);
expect(normalized.message).toContain("Invalid edge gateway token");
});
it("redacts broker diagnostic secrets before rendering technical details", () => {
expect(
redactEdgeGatewayUrl(
"wss://brokerUser:brokerPass@broker.internal/socket?access_token=ACCESSSECRET&signature=SIGSECRET&token=BROKER_TOKEN"
)
).toBe("wss://***:***@broker.internal/socket?access_token=***&signature=***&token=***");
expect(
redactEdgeGatewayDiagnostic(
"authorization=Bearer LASTERRSECRET url=wss://u:p@broker.internal/?jwt=ERRJWT signed_url=https://host/path?signature=DISCSIG&key=DISCKEY"
)
).not.toMatch(/LASTERRSECRET|ERRJWT|DISCSIG|DISCKEY|u:p/);
const normalized = normalizeEdgeGatewayError({
response: {
data: {
data: {
diagnostics: {
broker_url:
"wss://brokerUser:brokerPass@broker.internal/socket?access_token=ACCESSSECRET&signature=SIGSECRET&token=BROKER_TOKEN",
ws_url: "wss://agentUser:agentPass@gw.internal/terminal?token=WS_TOKEN&jwt=JWTSECRET",
broker_presence: {
last_error: "mqtt failed authorization=Bearer LASTERRSECRET url=wss://u:p@broker.internal/?jwt=ERRJWT",
disconnect_reason: "disconnect signed_url=https://host/path?signature=DISCSIG&key=DISCKEY",
},
},
},
},
},
});
const details = normalized.details.join("\n");
expect(details).toContain("Broker URL: wss://***:***@broker.internal/socket?access_token=***");
expect(details).toContain("WebSocket URL: wss://***:***@gw.internal/terminal?token=***&jwt=***");
expect(details).not.toMatch(
/brokerUser|brokerPass|ACCESSSECRET|SIGSECRET|BROKER_TOKEN|agentUser|agentPass|WS_TOKEN|JWTSECRET|LASTERRSECRET|ERRJWT|DISCSIG|DISCKEY|u:p/
);
const close = normalizeGatewayWebSocketClose({
reason: "disconnect signed_url=https://host/path?signature=DISCSIG&key=DISCKEY",
});
expect(close.details.join("\n")).not.toMatch(/DISCSIG|DISCKEY/);
});
});