From e37d5ff141d4f5d6befaa813ca41c8fb3fba1db7 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 27 Mar 2026 04:19:26 +0800 Subject: [PATCH 1/4] feat: show ssh pam exec and sftp --- .../components/terminal-utils.ts | 81 ++++++++++++++++--- 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts b/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts index 359e85a9786..934170350ef 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts @@ -7,6 +7,21 @@ export const stripAnsiCodes = (text: string): string => { return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1b\][0-9];[^\x07]*\x07/g, ""); }; +// Check if a string contains mostly printable characters +const isPrintableText = (text: string): boolean => { + if (text.length === 0) return false; + let printableCount = 0; + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + // Allow printable ASCII, newlines, tabs, and common unicode + if ((code >= 32 && code <= 126) || code === 10 || code === 13 || code === 9 || code > 127) { + printableCount += 1; + } + } + // Consider it printable if at least 80% of characters are printable + return printableCount / text.length >= 0.8; +}; + export type AggregatedTerminalEvent = { timestamp: string; eventType: string; @@ -15,22 +30,64 @@ export type AggregatedTerminalEvent = { 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) +// Decode a single event's base64 data, returning empty string on failure or binary data +const decodeEventData = (event: TTerminalEvent): string => { + try { + const decoded = stripAnsiCodes(atob(event.data)); + if (!isPrintableText(decoded)) { + return ""; + } + return decoded; + } catch { + return ""; + } +}; + +// Check if input is echoed in output (common in interactive terminal sessions) +const isInputEchoedInOutput = (events: TTerminalEvent[]): boolean => { + const inputEvents = events.filter((e) => e.eventType === "input"); const outputEvents = events.filter((e) => e.eventType === "output"); - if (outputEvents.length === 0) return []; + if (inputEvents.length === 0 || outputEvents.length === 0) return false; - // Decode each event and track character positions to map back to original events - const decodedEvents: { text: string; event: TTerminalEvent }[] = outputEvents.map((e) => { - try { - return { text: stripAnsiCodes(atob(e.data)), event: e }; - } catch { - return { text: "", event: e }; - } + // Decode all output into a single string + const allOutput = outputEvents.map((e) => decodeEventData(e)).join(""); + + // Check if all non-empty inputs appear in the output (echoed) + // If any input is not echoed, return false (likely exec or SFTP) + const hasUnechoedInput = inputEvents.some((inputEvent) => { + const inputText = decodeEventData(inputEvent).trim(); + return inputText && !allOutput.includes(inputText); }); + return !hasUnechoedInput; +}; + +// Aggregate consecutive output events to avoid character-by-character display +export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => { + // Determine if input is echoed in output (interactive terminal vs exec/SFTP) + const inputEchoed = isInputEchoedInOutput(events); + + // If input is echoed, only show output to avoid duplication + // Otherwise, include both input and output (for exec, SFTP, etc.) + let terminalEvents = inputEchoed + ? events.filter((e) => e.eventType === "output") + : events.filter((e) => e.eventType === "input" || e.eventType === "output"); + + // Fall back to input events if no output events (e.g., SFTP with only input messages) + if (terminalEvents.length === 0) { + terminalEvents = events.filter((e) => e.eventType === "input"); + } + + if (terminalEvents.length === 0) return []; + + // Decode each event and filter out binary/non-printable data + const decodedEvents: { text: string; event: TTerminalEvent }[] = terminalEvents + .map((e) => ({ text: decodeEventData(e), event: e })) + .filter((e) => e.text.length > 0); + + if (decodedEvents.length === 0) return []; + // Build a character-to-event index mapping // This tracks which original event each character came from const charToEventIndex: number[] = []; @@ -86,7 +143,7 @@ export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTer return validSegments.map((segment) => { // Find the original event that corresponds to the start of this segment const eventIndex = charToEventIndex[segment.startCharIndex] ?? 0; - const originalEvent = decodedEvents[eventIndex]?.event ?? outputEvents[0]; + const originalEvent = decodedEvents[eventIndex]?.event ?? terminalEvents[0]; return { timestamp: originalEvent.timestamp, From 4c8897636b3d79d27faabfbd80e435f57fe06f43 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 27 Mar 2026 05:05:15 +0800 Subject: [PATCH 2/4] feat: added channel type classification --- .../services/pam-session/pam-session-enums.ts | 6 + .../pam-session/pam-session-schemas.ts | 5 + frontend/src/hooks/api/pam/enums.ts | 6 + frontend/src/hooks/api/pam/types/index.ts | 4 +- .../components/TerminalEventView.tsx | 19 +- .../components/terminal-utils.ts | 177 ++++++++++++------ 6 files changed, 154 insertions(+), 63 deletions(-) diff --git a/backend/src/ee/services/pam-session/pam-session-enums.ts b/backend/src/ee/services/pam-session/pam-session-enums.ts index 33afe95e4ae..26ca5a41789 100644 --- a/backend/src/ee/services/pam-session/pam-session-enums.ts +++ b/backend/src/ee/services/pam-session/pam-session-enums.ts @@ -4,3 +4,9 @@ export enum PamSessionStatus { Ended = "ended", // Ended by user or automatically expired after expiresAt timestamp Terminated = "terminated" // Terminated by an admin } + +export enum TerminalChannelType { + Terminal = "terminal", // Interactive SSH terminal session + Exec = "exec", // SSH exec command + Sftp = "sftp" // SFTP file transfer session +} 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 b336d15c803..4c0ab37b703 100644 --- a/backend/src/ee/services/pam-session/pam-session-schemas.ts +++ b/backend/src/ee/services/pam-session/pam-session-schemas.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { PamSessionsSchema } from "@app/db/schemas"; +import { TerminalChannelType } from "./pam-session-enums"; + export const PamSessionCommandLogSchema = z.object({ input: z.string(), output: z.string(), @@ -11,11 +13,14 @@ export const PamSessionCommandLogSchema = z.object({ // SSH Terminal Event schemas export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]); +export const TerminalChannelTypeSchema = z.nativeEnum(TerminalChannelType); + export const HttpEventTypeSchema = z.enum(["request", "response"]); export const TerminalEventSchema = z.object({ timestamp: z.coerce.date(), eventType: TerminalEventTypeSchema, + channelType: TerminalChannelTypeSchema.optional(), // Optional for backwards compatibility with existing logs data: z.string(), // Base64 encoded binary data elapsedTime: z.number() // Seconds since session start (for replay) }); diff --git a/frontend/src/hooks/api/pam/enums.ts b/frontend/src/hooks/api/pam/enums.ts index f252f4c517c..d8f4e18d005 100644 --- a/frontend/src/hooks/api/pam/enums.ts +++ b/frontend/src/hooks/api/pam/enums.ts @@ -34,6 +34,12 @@ export enum PamSessionStatus { Terminated = "terminated" } +export enum TerminalChannelType { + Terminal = "terminal", + Exec = "exec", + Sftp = "sftp" +} + // Accounts export enum PamAccountOrderBy { Name = "name" diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index a3e0873debc..1366b318d86 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -4,7 +4,8 @@ import { PamAccountView, PamResourceOrderBy, PamResourceType, - PamSessionStatus + PamSessionStatus, + TerminalChannelType } from "../enums"; import { TActiveDirectoryAccount, TActiveDirectoryResource } from "./active-directory-resource"; import { TAwsIamAccount, TAwsIamResource } from "./aws-iam-resource"; @@ -68,6 +69,7 @@ export type TPamCommandLog = { export type TTerminalEvent = { timestamp: string; eventType: "input" | "output" | "resize" | "error"; + channelType?: TerminalChannelType; // Optional for backwards compatibility with existing logs data: string; // Base64 encoded binary data elapsedTime: number; // Seconds since session start (for replay) }; diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx index a8b8d68c3cf..59e94e96281 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx @@ -4,7 +4,7 @@ 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 { TerminalChannelType, TTerminalEvent } from "@app/hooks/api/pam"; import { aggregateTerminalEvents } from "./terminal-utils"; @@ -45,6 +45,13 @@ export const TerminalEventView = ({ events }: Props) => { filteredEvents.map((event, index) => { const eventKey = `${event.timestamp}-${index}`; + const channelLabel = + event.channelType === TerminalChannelType.Exec + ? "Exec" + : event.channelType === TerminalChannelType.Sftp + ? "SFTP" + : null; + return (
{
{new Date(event.timestamp).toLocaleString()} + {channelLabel && ( + + {channelLabel} + + )} + {event.eventType === "input" && ( + + Command + + )}
diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts b/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts index 934170350ef..18ba9617b63 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts @@ -1,8 +1,7 @@ -import { TTerminalEvent } from "@app/hooks/api/pam"; +import { TerminalChannelType, 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, ""); }; @@ -13,23 +12,13 @@ const isPrintableText = (text: string): boolean => { let printableCount = 0; for (let i = 0; i < text.length; i += 1) { const code = text.charCodeAt(i); - // Allow printable ASCII, newlines, tabs, and common unicode if ((code >= 32 && code <= 126) || code === 10 || code === 13 || code === 9 || code > 127) { printableCount += 1; } } - // Consider it printable if at least 80% of characters are printable return printableCount / text.length >= 0.8; }; -export type AggregatedTerminalEvent = { - timestamp: string; - eventType: string; - data: string; - elapsedTime: number; - eventCount: number; -}; - // Decode a single event's base64 data, returning empty string on failure or binary data const decodeEventData = (event: TTerminalEvent): string => { try { @@ -43,53 +32,28 @@ const decodeEventData = (event: TTerminalEvent): string => { } }; -// Check if input is echoed in output (common in interactive terminal sessions) -const isInputEchoedInOutput = (events: TTerminalEvent[]): boolean => { - const inputEvents = events.filter((e) => e.eventType === "input"); - const outputEvents = events.filter((e) => e.eventType === "output"); - - if (inputEvents.length === 0 || outputEvents.length === 0) return false; - - // Decode all output into a single string - const allOutput = outputEvents.map((e) => decodeEventData(e)).join(""); - - // Check if all non-empty inputs appear in the output (echoed) - // If any input is not echoed, return false (likely exec or SFTP) - const hasUnechoedInput = inputEvents.some((inputEvent) => { - const inputText = decodeEventData(inputEvent).trim(); - return inputText && !allOutput.includes(inputText); - }); - - return !hasUnechoedInput; +export type AggregatedTerminalEvent = { + timestamp: string; + eventType: string; + channelType?: string; + data: string; + elapsedTime: number; + eventCount: number; }; -// Aggregate consecutive output events to avoid character-by-character display -export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => { - // Determine if input is echoed in output (interactive terminal vs exec/SFTP) - const inputEchoed = isInputEchoedInOutput(events); - - // If input is echoed, only show output to avoid duplication - // Otherwise, include both input and output (for exec, SFTP, etc.) - let terminalEvents = inputEchoed - ? events.filter((e) => e.eventType === "output") - : events.filter((e) => e.eventType === "input" || e.eventType === "output"); - - // Fall back to input events if no output events (e.g., SFTP with only input messages) - if (terminalEvents.length === 0) { - terminalEvents = events.filter((e) => e.eventType === "input"); - } - - if (terminalEvents.length === 0) return []; +// Aggregate consecutive output events using prompt-based segmentation +const aggregateOutputEvents = ( + outputEvents: TTerminalEvent[], + channelType?: string +): AggregatedTerminalEvent[] => { + if (outputEvents.length === 0) return []; - // Decode each event and filter out binary/non-printable data - const decodedEvents: { text: string; event: TTerminalEvent }[] = terminalEvents + const decodedEvents: { text: string; event: TTerminalEvent }[] = outputEvents .map((e) => ({ text: decodeEventData(e), event: e })) .filter((e) => e.text.length > 0); if (decodedEvents.length === 0) return []; - // Build a character-to-event index mapping - // This tracks which original event each character came from const charToEventIndex: number[] = []; decodedEvents.forEach((decoded, eventIndex) => { for (let i = 0; i < decoded.text.length; i += 1) { @@ -98,9 +62,6 @@ export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTer }); const allText = decodedEvents.map((d) => d.text).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"); @@ -113,7 +74,6 @@ export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTer const hasPrompt = promptPattern.test(line); if (hasPrompt && currentSegmentLines.length > 0) { - // Found a new prompt, save current segment and start new one segments.push({ text: currentSegmentLines.join("\n"), startCharIndex: currentSegmentStartChar @@ -121,15 +81,12 @@ export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTer currentSegmentLines = [line]; currentSegmentStartChar = currentCharIndex; } else { - // Add line to current segment currentSegmentLines.push(line); } - // Account for line length + newline character (except for last line) currentCharIndex += line.length + (lineIndex < lines.length - 1 ? 1 : 0); }); - // Add the last segment if (currentSegmentLines.length > 0) { segments.push({ text: currentSegmentLines.join("\n"), @@ -137,20 +94,118 @@ export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTer }); } - // Filter out empty segments and convert to aggregated events const validSegments = segments.filter((seg) => seg.text.trim().length > 0); return validSegments.map((segment) => { - // Find the original event that corresponds to the start of this segment const eventIndex = charToEventIndex[segment.startCharIndex] ?? 0; - const originalEvent = decodedEvents[eventIndex]?.event ?? terminalEvents[0]; + const originalEvent = decodedEvents[eventIndex]?.event ?? outputEvents[0]; return { timestamp: originalEvent.timestamp, eventType: "output", + channelType, data: segment.text, elapsedTime: originalEvent.elapsedTime, eventCount: 1 }; }); }; + +// Convert input events to aggregated format +const convertInputEvents = ( + inputEvents: TTerminalEvent[], + channelType?: string +): AggregatedTerminalEvent[] => { + const results: AggregatedTerminalEvent[] = []; + + inputEvents.forEach((event) => { + const text = decodeEventData(event); + if (text.trim()) { + results.push({ + timestamp: event.timestamp, + eventType: "input", + channelType, + data: text, + elapsedTime: event.elapsedTime, + eventCount: 1 + }); + } + }); + + return results; +}; + +// Process events that have channelType (new format) +const processEventsWithChannelType = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => { + const results: AggregatedTerminalEvent[] = []; + + // Group events by channelType + const terminalEvents = events.filter((e) => e.channelType === TerminalChannelType.Terminal); + const execEvents = events.filter((e) => e.channelType === TerminalChannelType.Exec); + const sftpEvents = events.filter((e) => e.channelType === TerminalChannelType.Sftp); + + // Terminal: only show output (input is echoed) + const terminalOutputs = terminalEvents.filter((e) => e.eventType === "output"); + results.push(...aggregateOutputEvents(terminalOutputs, TerminalChannelType.Terminal)); + + // Exec: show both input (command) and output (result) + const execInputs = execEvents.filter((e) => e.eventType === "input"); + const execOutputs = execEvents.filter((e) => e.eventType === "output"); + results.push(...convertInputEvents(execInputs, TerminalChannelType.Exec)); + results.push(...aggregateOutputEvents(execOutputs, TerminalChannelType.Exec)); + + // SFTP: show only input messages (no meaningful output) + const sftpInputs = sftpEvents.filter((e) => e.eventType === "input"); + results.push(...convertInputEvents(sftpInputs, TerminalChannelType.Sftp)); + + // Sort by timestamp to maintain chronological order + results.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + return results; +}; + +// Backwards compatibility: process legacy events without channelType +// Uses heuristic - only show output events (assumes interactive terminal) +const processLegacyEvents = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => { + const outputEvents = events.filter((e) => e.eventType === "output"); + + // If there are output events, aggregate them (interactive terminal behavior) + if (outputEvents.length > 0) { + return aggregateOutputEvents(outputEvents); + } + + // Fallback: if no output events, show input events (might be SFTP-like) + const inputEvents = events.filter((e) => e.eventType === "input"); + return convertInputEvents(inputEvents); +}; + +// Main aggregation function +export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => { + if (events.length === 0) return []; + + // Check if any events have channelType (new format) + const hasChannelType = events.some((e) => e.channelType !== undefined); + + if (hasChannelType) { + // New format: use explicit channelType for routing + // Handle mixed case: events with channelType + legacy events without + const eventsWithChannelType = events.filter((e) => e.channelType !== undefined); + const eventsWithoutChannelType = events.filter((e) => e.channelType === undefined); + + const results: AggregatedTerminalEvent[] = []; + results.push(...processEventsWithChannelType(eventsWithChannelType)); + + // Process legacy events (if any) separately + if (eventsWithoutChannelType.length > 0) { + results.push(...processLegacyEvents(eventsWithoutChannelType)); + } + + // Re-sort after merging + results.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + return results; + } + + // Legacy format: use heuristic approach + return processLegacyEvents(events); +}; From 3a35ba78b5a58691d8c879009bf5e2e9e2e1e94e Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 27 Mar 2026 05:14:57 +0800 Subject: [PATCH 3/4] fix: addressed lint --- .../components/TerminalEventView.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx index 59e94e96281..7d3ef29e071 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx @@ -8,6 +8,11 @@ import { TerminalChannelType, TTerminalEvent } from "@app/hooks/api/pam"; import { aggregateTerminalEvents } from "./terminal-utils"; +const CHANNEL_LABEL_MAP: Record = { + [TerminalChannelType.Exec]: "Exec", + [TerminalChannelType.Sftp]: "SFTP" +}; + type Props = { events: TTerminalEvent[]; }; @@ -45,12 +50,7 @@ export const TerminalEventView = ({ events }: Props) => { filteredEvents.map((event, index) => { const eventKey = `${event.timestamp}-${index}`; - const channelLabel = - event.channelType === TerminalChannelType.Exec - ? "Exec" - : event.channelType === TerminalChannelType.Sftp - ? "SFTP" - : null; + const channelLabel = event.channelType ? CHANNEL_LABEL_MAP[event.channelType] : null; return (
Date: Tue, 31 Mar 2026 04:40:24 +0800 Subject: [PATCH 4/4] chore: used tailwind classes --- .../components/TerminalEventView.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx index 7d3ef29e071..3ed77e1fc63 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/TerminalEventView.tsx @@ -55,13 +55,13 @@ export const TerminalEventView = ({ events }: Props) => { return (
-
+
{new Date(event.timestamp).toLocaleString()} {channelLabel && ( - + {channelLabel} )} @@ -73,14 +73,14 @@ export const TerminalEventView = ({ events }: Props) => {
-
+
); }) ) : ( -
+
{search.length ? (
No terminal output matches search criteria
@@ -88,7 +88,7 @@ export const TerminalEventView = ({ events }: Props) => { ) : (
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.