From f9965963e8d0d3982b31bd9c33930baab501d581 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 13 Nov 2025 21:18:18 +0800 Subject: [PATCH] feat: session logging for ssh --- .../src/ee/routes/v1/pam-session-router.ts | 8 +- .../services/pam-session/pam-session-fns.ts | 6 +- .../pam-session/pam-session-schemas.ts | 12 +- .../services/pam-session/pam-session.types.ts | 5 +- frontend/src/hooks/api/pam/types/index.ts | 22 ++- .../components/CommandLogView.tsx | 129 ++++++++++++++++ .../components/PamSessionLogsSection.tsx | 140 +++--------------- .../components/TerminalEventView.tsx | 86 +++++++++++ .../components/terminal-utils.ts | 72 +++++++++ .../components/PamSessionRow.tsx | 95 ++++++++---- .../components/PamSessionsTable.tsx | 28 +++- 11 files changed, 438 insertions(+), 165 deletions(-) create mode 100644 frontend/src/pages/pam/PamSessionsByIDPage/components/CommandLogView.tsx create mode 100644 frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx create mode 100644 frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index c31ae0d78..90fe4fa9b 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -5,7 +5,11 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; -import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas"; +import { + PamSessionCommandLogSchema, + SanitizedSessionSchema, + TerminalEventSchema +} from "@app/ee/services/pam-session/pam-session-schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -72,7 +76,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { sessionId: z.string().uuid() }), body: z.object({ - logs: PamSessionCommandLogSchema.array() + logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) }), response: { 200: z.object({ diff --git a/backend/src/ee/services/pam-session/pam-session-fns.ts b/backend/src/ee/services/pam-session/pam-session-fns.ts index 4afe205b5..842dfbb6d 100644 --- a/backend/src/ee/services/pam-session/pam-session-fns.ts +++ b/backend/src/ee/services/pam-session/pam-session-fns.ts @@ -2,7 +2,7 @@ import { TPamSessions } from "@app/db/schemas"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; -import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types"; +import { TPamSanitizedSession, TPamSessionCommandLog, TTerminalEvent } from "./pam-session.types"; export const decryptSessionCommandLogs = async ({ projectId, @@ -22,7 +22,7 @@ export const decryptSessionCommandLogs = async ({ cipherTextBlob: encryptedLogs }); - return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog; + return JSON.parse(decryptedPlainTextBlob.toString()) as (TPamSessionCommandLog | TTerminalEvent)[]; }; export const decryptSession = async ( @@ -32,7 +32,7 @@ export const decryptSession = async ( ) => { return { ...session, - commandLogs: session.encryptedLogsBlob + logs: session.encryptedLogsBlob ? await decryptSessionCommandLogs({ projectId, encryptedLogs: session.encryptedLogsBlob, diff --git a/backend/src/ee/services/pam-session/pam-session-schemas.ts b/backend/src/ee/services/pam-session/pam-session-schemas.ts index 2bc1d5345..db2493196 100644 --- a/backend/src/ee/services/pam-session/pam-session-schemas.ts +++ b/backend/src/ee/services/pam-session/pam-session-schemas.ts @@ -8,8 +8,18 @@ export const PamSessionCommandLogSchema = z.object({ timestamp: z.coerce.date() }); +// SSH Terminal Event schemas +export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]); + +export const TerminalEventSchema = z.object({ + timestamp: z.coerce.date(), + eventType: TerminalEventTypeSchema, + data: z.string(), // Base64 encoded binary data + elapsedTime: z.number() // Seconds since session start (for replay) +}); + export const SanitizedSessionSchema = PamSessionsSchema.omit({ encryptedLogsBlob: true }).extend({ - commandLogs: PamSessionCommandLogSchema.array() + logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) }); diff --git a/backend/src/ee/services/pam-session/pam-session.types.ts b/backend/src/ee/services/pam-session/pam-session.types.ts index 0c87a9fa4..893f930e5 100644 --- a/backend/src/ee/services/pam-session/pam-session.types.ts +++ b/backend/src/ee/services/pam-session/pam-session.types.ts @@ -1,12 +1,13 @@ import { z } from "zod"; -import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas"; +import { PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema } from "./pam-session-schemas"; export type TPamSessionCommandLog = z.infer; +export type TTerminalEvent = z.infer; export type TPamSanitizedSession = z.infer; // DTOs export type TUpdateSessionLogsDTO = { sessionId: string; - logs: TPamSessionCommandLog[]; + logs: (TPamSessionCommandLog | TTerminalEvent)[]; }; diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 055cb8dea..6f37c97c3 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -21,6 +21,22 @@ export type TPamFolder = { updatedAt: string; }; +// Session log types +export type TPamCommandLog = { + input: string; + output: string; + timestamp: string; +}; + +export type TTerminalEvent = { + timestamp: string; + eventType: "input" | "output" | "resize" | "error"; + data: string; // Base64 encoded binary data + elapsedTime: number; // Seconds since session start (for replay) +}; + +export type TPamSessionLog = TPamCommandLog | TTerminalEvent; + export type TPamSession = { id: string; projectId: string; @@ -39,11 +55,7 @@ export type TPamSession = { endedAt?: string | null; createdAt: string; updatedAt: string; - commandLogs: { - input: string; - output: string; - timestamp: string; - }[]; + logs: TPamSessionLog[]; }; // Resource DTOs diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/CommandLogView.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/CommandLogView.tsx new file mode 100644 index 000000000..d70754ccf --- /dev/null +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/CommandLogView.tsx @@ -0,0 +1,129 @@ +import { useMemo, useState } from "react"; +import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Input } from "@app/components/v2"; +import { HighlightText } from "@app/components/v2/HighlightText"; +import { TPamCommandLog } from "@app/hooks/api/pam"; + +import { formatLogContent } from "./PamSessionLogsSection.utils"; + +type Props = { + logs: TPamCommandLog[]; +}; + +export const CommandLogView = ({ logs }: Props) => { + const [expandedLogTimestamps, setExpandedLogTimestamps] = useState>(new Set()); + const [search, setSearch] = useState(""); + + const toggleExpand = (timestamp: string) => { + setExpandedLogTimestamps((prev) => { + if (prev.has(timestamp)) { + return new Set(); + } + return new Set([timestamp]); + }); + }; + + const filteredLogs = useMemo( + () => + logs.filter((log) => { + const searchValue = search.trim().toLowerCase(); + return ( + log.input.toLowerCase().includes(searchValue) || + log.output.toLowerCase().includes(searchValue) + ); + }), + [logs, search] + ); + + return ( + <> +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search logs..." + className="flex-1 bg-mineshaft-800" + containerClassName="bg-transparent" + /> +
+ +
+ {filteredLogs.length > 0 ? ( + filteredLogs.map((log, index) => { + const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp); + const formattedInput = formatLogContent(log.input); + const logKey = `${log.timestamp}-${index}`; + + return ( + + ); + }) + ) : ( +
+ {search.length ? ( +
+
No logs match search criteria
+
+ ) : ( +
+
Session logs are not yet available
+
+ Logs will be uploaded after the session duration has elapsed. +
+ If logs do not appear after some time, please contact your Gateway administrators. +
+
+ )} +
+ )} +
+ + ); +}; diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx index 7a4945540..86fd7c1dc 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx @@ -1,44 +1,19 @@ -import { useMemo, useState } from "react"; -import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { twMerge } from "tailwind-merge"; +import { PamResourceType, TPamCommandLog, TPamSession, TTerminalEvent } from "@app/hooks/api/pam"; -import { Input } from "@app/components/v2"; -import { HighlightText } from "@app/components/v2/HighlightText"; -import { TPamSession } from "@app/hooks/api/pam"; - -import { formatLogContent } from "./PamSessionLogsSection.utils"; +import { CommandLogView } from "./CommandLogView"; +import { TerminalEventView } from "./TerminalEventView"; type Props = { session: TPamSession; }; export const PamSessionLogsSection = ({ session }: Props) => { - const [expandedLogTimestamps, setExpandedLogTimestamps] = useState>(new Set()); - const [search, setSearch] = useState(""); - - const toggleExpand = (timestamp: string) => { - setExpandedLogTimestamps((prev) => { - if (prev.has(timestamp)) { - return new Set(); - } - return new Set([timestamp]); - }); - }; - - const filteredLogs = useMemo( - () => - session.commandLogs.filter((log) => { - const { input, output } = log; - - const searchValue = search.trim().toLowerCase(); - - return ( - input.toLowerCase().includes(searchValue) || output.toLowerCase().includes(searchValue) - ); - }), - [session.commandLogs, search] - ); + // Determine log type based on resource type + const isSSHSession = session.resourceType === PamResourceType.SSH; + const isDatabaseSession = + session.resourceType === PamResourceType.Postgres || + session.resourceType === PamResourceType.MySQL; + const hasLogs = session.logs.length > 0; return (
@@ -46,91 +21,20 @@ export const PamSessionLogsSection = ({ session }: Props) => {

Session Logs

-
- { - const newSearch = e.target.value; - setSearch(newSearch); - }} - leftIcon={} - placeholder="Search logs..." - className="flex-1 bg-mineshaft-800" - containerClassName="bg-transparent" - /> -
-
- {filteredLogs.length > 0 ? ( - filteredLogs.map((log) => { - const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp); - const formattedInput = formatLogContent(log.input); - - return ( - - ); - }) - ) : ( -
- {search.length ? ( -
-
No logs match search criteria
-
- ) : ( -
-
Session logs are not yet available
-
- Logs will be uploaded after the session duration has elapsed. -
- If logs do not appear after some time, please contact your Gateway administrators. -
-
- )} + {isDatabaseSession && hasLogs && } + {isSSHSession && hasLogs && } + {!hasLogs && ( +
+
+
Session logs are not yet available
+
+ Logs will be uploaded after the session duration has elapsed. +
+ If logs do not appear after some time, please contact your Gateway administrators. +
- )} -
+
+ )}
); }; diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx new file mode 100644 index 000000000..a8b8d68c3 --- /dev/null +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx @@ -0,0 +1,86 @@ +import { useMemo, useState } from "react"; +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Input } from "@app/components/v2"; +import { HighlightText } from "@app/components/v2/HighlightText"; +import { TTerminalEvent } from "@app/hooks/api/pam"; + +import { aggregateTerminalEvents } from "./terminal-utils"; + +type Props = { + events: TTerminalEvent[]; +}; + +export const TerminalEventView = ({ events }: Props) => { + const [search, setSearch] = useState(""); + + const aggregatedEvents = useMemo(() => aggregateTerminalEvents(events), [events]); + + const filteredEvents = useMemo( + () => + aggregatedEvents.filter((event) => { + const searchValue = search.trim().toLowerCase(); + if (!searchValue) return true; + return event.data.toLowerCase().includes(searchValue); + }), + [aggregatedEvents, search] + ); + + return ( + <> +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search terminal output..." + className="flex-1 bg-mineshaft-800" + containerClassName="bg-transparent" + /> +
+ +
+ {filteredEvents.length > 0 ? ( + filteredEvents.map((event, index) => { + const eventKey = `${event.timestamp}-${index}`; + + return ( +
+
+
+ {new Date(event.timestamp).toLocaleString()} +
+
+ +
+ +
+
+ ); + }) + ) : ( +
+ {search.length ? ( +
+
No terminal output matches search criteria
+
+ ) : ( +
+
Terminal session logs are not yet available
+
+ Logs will be uploaded after the session duration has elapsed. +
+ If logs do not appear after some time, please contact your Gateway administrators. +
+
+ )} +
+ )} +
+ + ); +}; diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts b/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts new file mode 100644 index 000000000..5db8a4549 --- /dev/null +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts @@ -0,0 +1,72 @@ +import { TTerminalEvent } from "@app/hooks/api/pam"; + +// Strip ANSI escape codes from terminal output +export const stripAnsiCodes = (text: string): string => { + // Remove ANSI escape sequences + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1b\][0-9];[^\x07]*\x07/g, ""); +}; + +export type AggregatedTerminalEvent = { + timestamp: string; + eventType: string; + data: string; + elapsedTime: number; + eventCount: number; +}; + +// Aggregate consecutive output events to avoid character-by-character display +export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => { + // Filter to only show output events (input is echoed, so redundant) + const outputEvents = events.filter((e) => e.eventType === "output"); + + if (outputEvents.length === 0) return []; + + // First, combine all events into one string to process + const allText = outputEvents + .map((e) => { + try { + return stripAnsiCodes(atob(e.data)); + } catch { + return ""; + } + }) + .join(""); + + // Split on lines that contain shell prompts + // Pattern matches: user@hostname:path# or user@hostname:path$ + const promptPattern = /^[\w-]+@[\w-]+[^\s]*[:#$]\s+/; + + const lines = allText.split("\n"); + const segments: string[] = []; + let currentSegment: string[] = []; + + lines.forEach((line) => { + const hasPrompt = promptPattern.test(line); + + if (hasPrompt && currentSegment.length > 0) { + // Found a new prompt, save current segment and start new one + segments.push(currentSegment.join("\n")); + currentSegment = [line]; + } else { + // Add line to current segment + currentSegment.push(line); + } + }); + + // Add the last segment + if (currentSegment.length > 0) { + segments.push(currentSegment.join("\n")); + } + + // Filter out empty segments and convert to aggregated events + const validSegments = segments.filter((seg) => seg.trim().length > 0); + + return validSegments.map((segment) => ({ + timestamp: outputEvents[0].timestamp, + eventType: "output", + data: segment, + elapsedTime: outputEvents[0].elapsedTime, + eventCount: Math.ceil(outputEvents.length / validSegments.length) + })); +}; diff --git a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx index 8721560c4..b20b50fee 100644 --- a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx +++ b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { faBoxOpen, faChevronDown, @@ -25,18 +25,19 @@ import { } from "@app/components/v2"; import { HighlightText } from "@app/components/v2/HighlightText"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; -import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam"; +import { PAM_RESOURCE_TYPE_MAP, TTerminalEvent, TPamSession } from "@app/hooks/api/pam"; import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils"; +import { aggregateTerminalEvents } from "../../PamSessionsByIDPage/components/terminal-utils"; import { PamSessionStatusBadge } from "./PamSessionStatusBadge"; type Props = { session: TPamSession; search: string; - filteredCommandLogs: TPamSession["commandLogs"]; + filteredLogs: TPamSession["logs"]; }; -export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) => { +export const PamSessionRow = ({ session, search, filteredLogs }: Props) => { const router = useRouter(); const [showAllLogs, setShowAllLogs] = useState(false); @@ -55,8 +56,24 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) = const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType]; + // Check if logs are terminal events and aggregate them + const processedLogs = useMemo(() => { + if (filteredLogs.length === 0) return []; + + // Check if first log is a terminal event + const isTerminalEvents = "data" in filteredLogs[0]; + + if (isTerminalEvents) { + // Aggregate terminal events for better display + return aggregateTerminalEvents(filteredLogs as TTerminalEvent[]); + } + + // Return command logs as-is + return filteredLogs; + }, [filteredLogs]); + const LOGS_TO_SHOW = 5; - const logsToShow = showAllLogs ? filteredCommandLogs : filteredCommandLogs.slice(0, LOGS_TO_SHOW); + const logsToShow = showAllLogs ? processedLogs : processedLogs.slice(0, LOGS_TO_SHOW); return ( <> @@ -135,8 +152,8 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) = {(isAllowed: boolean) => ( - {filteredCommandLogs.length > 0 && ( + {filteredLogs.length > 0 && ( - {logsToShow.map((log) => { - const formattedInput = formatLogContent(log.input); + {logsToShow.map((log, idx) => { + // Handle command logs (database sessions) + if ("input" in log && "output" in log) { + const formattedInput = formatLogContent(log.input); - return ( -
-
- - {new Date(log.timestamp).toLocaleString()} -
+ return ( +
+
+ + {new Date(log.timestamp).toLocaleString()} +
-
- +
+ +
+
+ +
-
- + ); + } + + // Handle aggregated terminal events (SSH sessions) + if ("data" in log && typeof log.data === "string") { + return ( +
+
+ + {new Date(log.timestamp).toLocaleString()} +
+ +
+ +
-
- ); + ); + } + + return null; })} - {filteredCommandLogs.length > LOGS_TO_SHOW && ( + {filteredLogs.length > LOGS_TO_SHOW && (
)} diff --git a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionsTable.tsx b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionsTable.tsx index 2f4f9f7ef..63cc22651 100644 --- a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionsTable.tsx +++ b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionsTable.tsx @@ -113,7 +113,7 @@ export const PamSessionsTable = ({ sessions }: Props) => { id, resourceName, userId, - commandLogs + logs } = session; const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType]; @@ -131,11 +131,25 @@ export const PamSessionsTable = ({ sessions }: Props) => { const filteredLogs = searchValue.length >= 2 - ? commandLogs.filter( - (log) => - log.input.toLowerCase().includes(searchValue) || - log.output.toLowerCase().includes(searchValue) - ) + ? logs.filter((log) => { + // Handle command logs (database sessions) + if ("input" in log && "output" in log) { + return ( + log.input.toLowerCase().includes(searchValue) || + log.output.toLowerCase().includes(searchValue) + ); + } + // Handle terminal events (SSH sessions) + if ("data" in log) { + try { + const decodedData = atob(log.data); + return decodedData.toLowerCase().includes(searchValue); + } catch { + return false; + } + } + return false; + }) : []; return { @@ -385,7 +399,7 @@ export const PamSessionsTable = ({ sessions }: Props) => { key={session.id} session={session} search={search.trim().toLowerCase()} - filteredCommandLogs={filteredLogs} + filteredLogs={filteredLogs} /> ))}