Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/ee/services/pam-session/pam-session-enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions backend/src/ee/services/pam-session/pam-session-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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)
});
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/hooks/api/pam/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export enum PamSessionStatus {
Terminated = "terminated"
}

export enum TerminalChannelType {
Terminal = "terminal",
Exec = "exec",
Sftp = "sftp"
}

// Accounts
export enum PamAccountOrderBy {
Name = "name"
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/hooks/api/pam/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ 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";

const CHANNEL_LABEL_MAP: Record<string, string> = {
[TerminalChannelType.Exec]: "Exec",
[TerminalChannelType.Sftp]: "SFTP"
};

type Props = {
events: TTerminalEvent[];
};
Expand Down Expand Up @@ -45,33 +50,45 @@ export const TerminalEventView = ({ events }: Props) => {
filteredEvents.map((event, index) => {
const eventKey = `${event.timestamp}-${index}`;

const channelLabel = event.channelType ? CHANNEL_LABEL_MAP[event.channelType] : null;

return (
<div
key={eventKey}
className="flex w-full flex-col rounded-md border border-mineshaft-700 bg-mineshaft-800 p-3"
className="flex w-full flex-col rounded-md border border-border bg-card p-3"
>
<div className="flex items-center justify-between text-bunker-400">
<div className="flex items-center justify-between text-muted">
<div className="flex items-center gap-2 text-xs">
<span>{new Date(event.timestamp).toLocaleString()}</span>
{channelLabel && (
<span className="rounded bg-card px-1.5 py-0.5 text-label">
{channelLabel}
</span>
)}
{event.eventType === "input" && (
<span className="rounded bg-primary-900/30 px-1.5 py-0.5 text-primary-400">
Command
</span>
)}
Comment thread
sheensantoscapadngan marked this conversation as resolved.
</div>
</div>

<div className="mt-2 font-mono whitespace-pre-wrap text-bunker-100">
<div className="mt-2 font-mono whitespace-pre-wrap text-foreground">
<HighlightText text={event.data} highlight={search} />
</div>
</div>
);
})
) : (
<div className="flex grow items-center justify-center text-bunker-300">
<div className="flex grow items-center justify-center text-label">
{search.length ? (
<div className="text-center">
<div className="mb-2">No terminal output matches search criteria</div>
</div>
) : (
<div className="text-center">
<div className="mb-2">Terminal session logs are not yet available</div>
<div className="text-xs text-bunker-400">
<div className="text-xs text-muted">
Logs will be uploaded after the session duration has elapsed.
<br />
If logs do not appear after some time, please contact your Gateway administrators.
Expand Down
164 changes: 138 additions & 26 deletions frontend/src/pages/pam/PamSessionsByIDPage/components/terminal-utils.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,59 @@
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, "");
};

// 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);
if ((code >= 32 && code <= 126) || code === 10 || code === 13 || code === 9 || code > 127) {
printableCount += 1;
}
}
return printableCount / text.length >= 0.8;
};

// 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 "";
}
};

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[] => {
// Filter to only show output events (input is echoed, so redundant)
const outputEvents = events.filter((e) => e.eventType === "output");

// Aggregate consecutive output events using prompt-based segmentation
const aggregateOutputEvents = (
outputEvents: TTerminalEvent[],
channelType?: string
): AggregatedTerminalEvent[] => {
if (outputEvents.length === 0) return [];

// 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 };
}
});
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) {
Expand All @@ -41,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");
Expand All @@ -56,44 +74,138 @@ 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
});
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"),
startCharIndex: currentSegmentStartChar
});
}

// 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 ?? 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);
};
Loading