From 246a82774bef6bd6ad2d1bea6274ed13b2eb6efa Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 21 Apr 2026 23:08:39 -0400 Subject: [PATCH] feat(web): SessionDrawer + Sessions tab in AttackerDetail Adds asciinema-player dependency, SessionDrawer.tsx that pages the transcripts API (500 events per request) and rebuilds a v2 .cast blob for playback, and a Session Transcripts section in AttackerDetail that deep-links into the drawer. Truncation banner surfaces the 10 MB per-session cap when it's been hit. --- decnet_web/package.json | 1 + decnet_web/src/components/AttackerDetail.tsx | 107 ++++++++++ decnet_web/src/components/SessionDrawer.tsx | 209 +++++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 decnet_web/src/components/SessionDrawer.tsx diff --git a/decnet_web/package.json b/decnet_web/package.json index 4e82f89a..0a490954 100644 --- a/decnet_web/package.json +++ b/decnet_web/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "asciinema-player": "^3.8.0", "axios": "^1.14.0", "lucide-react": "^1.7.0", "react": "^19.2.4", diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 1d0d5af0..15a9ff2b 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer, Paperclip } from 'lucide-react'; import api from '../utils/api'; import ArtifactDrawer from './ArtifactDrawer'; +import SessionDrawer from './SessionDrawer'; import './Dashboard.css'; interface AttackerBehavior { @@ -707,6 +708,7 @@ const AttackerDetail: React.FC = () => { commands: true, fingerprints: true, artifacts: true, + sessions: true, }); // Captured file-drop artifacts (ssh inotify farm) for this attacker. @@ -719,6 +721,17 @@ const AttackerDetail: React.FC = () => { }; const [artifacts, setArtifacts] = useState([]); const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); + + // PTY session transcripts (sessrec) for this attacker. + type SessionLog = { + id: number; + timestamp: string; + decky: string; + service: string; + fields: string; + }; + const [sessions, setSessions] = useState([]); + const [session, setSession] = useState<{ decky: string; sid: string; fields: Record } | null>(null); const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); // Commands pagination state @@ -785,6 +798,19 @@ const AttackerDetail: React.FC = () => { fetchArtifacts(); }, [id]); + useEffect(() => { + if (!id) return; + const fetchSessions = async () => { + try { + const res = await api.get(`/attackers/${id}/transcripts`); + setSessions(res.data.data ?? []); + } catch { + setSessions([]); + } + }; + fetchSessions(); + }, [id]); + if (loading) { return (
@@ -1166,6 +1192,87 @@ const AttackerDetail: React.FC = () => { /> )} + {/* Recorded PTY Sessions (SSH / Telnet) */} +
SESSION TRANSCRIPTS ({sessions.length})} + open={openSections.sessions} + onToggle={() => toggle('sessions')} + > + {sessions.length > 0 ? ( +
+ + + + + + + + + + + + + {sessions.map((row) => { + let fields: Record = {}; + try { fields = JSON.parse(row.fields || '{}'); } catch {} + const sid = fields.sid ? String(fields.sid) : null; + const dur = fields.duration_s; + const bytes = fields.bytes; + return ( + + + + + + + + + ); + })} + +
TIMESTAMPDECKYSERVICEDURATIONBYTES
+ {new Date(row.timestamp).toLocaleString()} + {row.decky}{fields.service ?? row.service} + {dur ? `${dur}s` : '—'} + + {bytes ? `${bytes} B` : '—'} + + {sid && ( + + )} +
+
+ ) : ( +
+ NO SESSION TRANSCRIPTS RECORDED FROM THIS ATTACKER +
+ )} +
+ + {session && ( + setSession(null)} + /> + )} + {/* UUID footer */}
UUID: {attacker.uuid} diff --git a/decnet_web/src/components/SessionDrawer.tsx b/decnet_web/src/components/SessionDrawer.tsx new file mode 100644 index 00000000..eeee3ead --- /dev/null +++ b/decnet_web/src/components/SessionDrawer.tsx @@ -0,0 +1,209 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { X, AlertTriangle } from 'lucide-react'; +import api from '../utils/api'; +// @ts-expect-error -- ships without type defs; 3.x CJS build is used directly +import * as AsciinemaPlayer from 'asciinema-player'; +import 'asciinema-player/dist/bundle/asciinema-player.css'; + +interface SessionDrawerProps { + decky: string; + sid: string; + fields: Record; + onClose: () => void; +} + +interface TranscriptPage { + sid: string; + service: string; + header: Record; + events: [number, string, string][]; + offset: number; + limit: number; + total: number; + has_more: boolean; + truncated: boolean; +} + +const PAGE_SIZE = 500; + +const Row: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( +
+
{label}
+
{value ?? }
+
+); + +function buildCastBlob(header: Record, events: [number, string, string][]): string { + const headerLine = JSON.stringify({ + version: 2, + width: header.width ?? 80, + height: header.height ?? 24, + timestamp: header.timestamp, + env: header.env, + }); + const eventLines = events.map(([t, ch, d]) => JSON.stringify([t, ch, d])); + return [headerLine, ...eventLines].join('\n') + '\n'; +} + +const SessionDrawer: React.FC = ({ decky, sid, fields, onClose }) => { + const [header, setHeader] = useState | null>(null); + const [events, setEvents] = useState<[number, string, string][]>([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [truncated, setTruncated] = useState(false); + const playerContainer = useRef(null); + const playerInstance = useRef(null); + + useEffect(() => { + let cancelled = false; + const fetchAll = async () => { + setLoading(true); + setError(null); + let offset = 0; + let hdr: Record | null = null; + const allEvents: [number, string, string][] = []; + let truncFlag = false; + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const res = await api.get( + `/transcripts/${encodeURIComponent(decky)}/${encodeURIComponent(sid)}`, + { params: { offset, limit: PAGE_SIZE } }, + ); + if (cancelled) return; + if (!hdr) hdr = res.data.header; + truncFlag = truncFlag || res.data.truncated; + allEvents.push(...res.data.events); + if (offset === 0) { + setHeader(hdr); + setEvents([...allEvents]); + setLoading(false); + } else { + setEvents([...allEvents]); + } + if (!res.data.has_more) break; + offset += PAGE_SIZE; + setLoadingMore(true); + } + setTruncated(truncFlag); + setLoadingMore(false); + } catch (err: any) { + if (cancelled) return; + const status = err?.response?.status; + setError( + status === 403 ? 'Admin role required to view transcripts.' : + status === 404 ? 'Transcript not found (shard may have rotated).' : + 'Failed to load transcript — see console.' + ); + console.error('transcript fetch failed', err); + setLoading(false); + } + }; + fetchAll(); + return () => { cancelled = true; }; + }, [decky, sid]); + + // Re-mount the player whenever the event window grows. asciinema-player + // doesn't expose a public feed() API in v3, so we rebuild from the full + // in-memory blob each time — cheap for v1-scale sessions (≤ 10 MB cap). + useEffect(() => { + if (!header || !playerContainer.current || events.length === 0) return; + if (playerInstance.current) { + try { playerInstance.current.dispose(); } catch { /* ignore */ } + playerInstance.current = null; + } + const cast = buildCastBlob(header, events); + const blob = new Blob([cast], { type: 'application/x-asciicast' }); + const url = URL.createObjectURL(blob); + playerInstance.current = AsciinemaPlayer.create(url, playerContainer.current, { + fit: 'width', + terminalFontSize: '12px', + }); + return () => { + URL.revokeObjectURL(url); + if (playerInstance.current) { + try { playerInstance.current.dispose(); } catch { /* ignore */ } + playerInstance.current = null; + } + }; + }, [header, events]); + + const service = fields.service; + const srcIp = fields.src_ip; + const duration = fields.duration_s; + const bytes = fields.bytes; + + return ( +
+
e.stopPropagation()} + style={{ + width: 'min(920px, 100%)', height: '100%', + backgroundColor: 'var(--bg-color, #0d1117)', + borderLeft: '1px solid var(--border-color, #30363d)', + padding: '24px', overflowY: 'auto', + color: 'var(--text-color)', + }} + > +
+
+
+ SESSION TRANSCRIPT · {decky} +
+
+ {sid} +
+
+ +
+ + {truncated && ( +
+ + Session exceeded 10 MB cap — playback is truncated. +
+ )} + + {error && ( +
{error}
+ )} + +
+
+ {loading &&
LOADING TRANSCRIPT…
} + {loadingMore &&
loading more events…
} +
+ +
+

+ METADATA +

+ + + + + +
+
+
+ ); +}; + +export default SessionDrawer;