From 5ff6ae89403275bdd684eae7d762b5bb5bf5e85f Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 10 Jun 2026 19:50:11 +0200 Subject: [PATCH] feat(frontend): c2 config card + execute modal (sprint 8 phase 1) - frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config, deleteC2Config, testC2Config, listCallbacks, executeC2) following the frozen M1+M2 backend contracts - frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult, C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse - frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config, useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2 - frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config card (url + write-only token + verify-tls + save/delete/test-connection), 503 disabled state, ConfirmDialog on delete - frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table (mono data cells), commands textarea pre-filled from rt.commands, Launch disabled until row selected + non-empty commands - frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit mode only, admin+redteam only (canEditEngagements gate) - frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT card, visible only when !isDone && canEditRT && hasC2Config; opens modal - Tests: 33 new tests across api/c2, components/C2ConfigCard, components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage (172 total, 139 baseline + 33 new, all passing) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/c2.ts | 60 +++++ frontend/src/api/types.ts | 49 ++++ frontend/src/components/C2ConfigCard.tsx | 240 ++++++++++++++++++ frontend/src/components/ExecuteViaC2Modal.tsx | 186 ++++++++++++++ frontend/src/hooks/useC2.ts | 81 ++++++ frontend/src/pages/EngagementFormPage.tsx | 7 + frontend/src/pages/SimulationFormPage.tsx | 33 ++- frontend/tests/EngagementFormPage.test.tsx | 108 ++++++++ frontend/tests/SimulationFormPage.test.tsx | 59 +++++ frontend/tests/api/c2.test.ts | 136 ++++++++++ .../tests/components/C2ConfigCard.test.tsx | 135 ++++++++++ .../components/ExecuteViaC2Modal.test.tsx | 160 ++++++++++++ 12 files changed, 1253 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/c2.ts create mode 100644 frontend/src/components/C2ConfigCard.tsx create mode 100644 frontend/src/components/ExecuteViaC2Modal.tsx create mode 100644 frontend/src/hooks/useC2.ts create mode 100644 frontend/tests/EngagementFormPage.test.tsx create mode 100644 frontend/tests/api/c2.test.ts create mode 100644 frontend/tests/components/C2ConfigCard.test.tsx create mode 100644 frontend/tests/components/ExecuteViaC2Modal.test.tsx diff --git a/frontend/src/api/c2.ts b/frontend/src/api/c2.ts new file mode 100644 index 0000000..d4fe677 --- /dev/null +++ b/frontend/src/api/c2.ts @@ -0,0 +1,60 @@ +import { apiClient } from './client'; +import type { + C2Config, + C2ConfigInput, + C2TestResult, + C2CallbacksResponse, + C2ExecuteInput, + C2ExecuteResponse, +} from './types'; + +export async function getC2Config(engagementId: number): Promise { + try { + const { data } = await apiClient.get(`/engagements/${engagementId}/c2-config`); + return data; + } catch (err: unknown) { + const e = err as { response?: { status?: number } }; + if (e?.response?.status === 404) return null; + throw err; + } +} + +export async function putC2Config( + engagementId: number, + input: C2ConfigInput, +): Promise { + const { data } = await apiClient.put( + `/engagements/${engagementId}/c2-config`, + input, + ); + return data; +} + +export async function deleteC2Config(engagementId: number): Promise { + await apiClient.delete(`/engagements/${engagementId}/c2-config`); +} + +export async function testC2Config(engagementId: number): Promise { + const { data } = await apiClient.post( + `/engagements/${engagementId}/c2-config/test`, + ); + return data; +} + +export async function listCallbacks(engagementId: number): Promise { + const { data } = await apiClient.get( + `/engagements/${engagementId}/c2/callbacks`, + ); + return data; +} + +export async function executeC2( + simulationId: number, + input: C2ExecuteInput, +): Promise { + const { data } = await apiClient.post( + `/simulations/${simulationId}/c2/execute`, + input, + ); + return data; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3c977eb..b51f45b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -154,3 +154,52 @@ export interface SimulationPatchInput { soc_comment?: string | null; incident_number?: string | null; } + +// C2 types + +export interface C2Config { + has_token: boolean; + url: string; + verify_tls: boolean; +} + +export interface C2ConfigInput { + url: string; + api_token?: string; + verify_tls: boolean; +} + +export interface C2TestResult { + ok: boolean; + error: string | null; +} + +export interface C2Callback { + display_id: number; + active: boolean; + host: string; + user: string; + domain: string; + last_checkin: string; +} + +export interface C2CallbacksResponse { + callbacks: C2Callback[]; +} + +export interface C2Task { + id: number; + mythic_task_display_id: number; + command: string; + status: string; + completed: boolean; +} + +export interface C2ExecuteInput { + callback_display_id: number; + commands: string[]; +} + +export interface C2ExecuteResponse { + tasks: C2Task[]; +} diff --git a/frontend/src/components/C2ConfigCard.tsx b/frontend/src/components/C2ConfigCard.tsx new file mode 100644 index 0000000..1363310 --- /dev/null +++ b/frontend/src/components/C2ConfigCard.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { extractApiError } from '@/api/client'; +import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2'; +import { ConfirmDialog } from './ConfirmDialog'; +import { FormField, TextInput } from './FormField'; +import { useToast } from '@/hooks/useToast'; + +interface C2ConfigCardProps { + engagementId: number; +} + +export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { + const { push } = useToast(); + + const configQuery = useC2Config(engagementId); + const updateMutation = useUpdateC2Config(engagementId); + const deleteMutation = useDeleteC2Config(engagementId); + const testMutation = useTestC2Config(engagementId); + + const config = configQuery.data; + const is503 = configQuery.error + ? (configQuery.error as { response?: { status?: number } })?.response?.status === 503 + : false; + + const [url, setUrl] = useState(''); + const [token, setToken] = useState(''); + const [verifyTls, setVerifyTls] = useState(true); + const [replaceToken, setReplaceToken] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + + // Sync URL and verifyTls from loaded config (but not token — write-only at API level) + useEffect(() => { + if (config) { + setUrl(config.url); + setVerifyTls(config.verify_tls); + } + }, [config]); + + const disabled = is503 || configQuery.isLoading; + + const onSave = async (e: FormEvent) => { + e.preventDefault(); + if (is503) return; + setTestResult(null); + + const input: { url: string; verify_tls: boolean; api_token?: string } = { + url: url.trim(), + verify_tls: verifyTls, + }; + // Send token only if: no existing config, OR user explicitly chose to replace + if (!config?.has_token || replaceToken) { + if (token.trim()) input.api_token = token.trim(); + } + + try { + await updateMutation.mutateAsync(input); + push('C2 configuration saved', 'success'); + setToken(''); + setReplaceToken(false); + } catch (err) { + push(extractApiError(err, 'Could not save C2 configuration'), 'error'); + } + }; + + const onDelete = async () => { + setShowDeleteConfirm(false); + setTestResult(null); + try { + await deleteMutation.mutateAsync(); + push('C2 configuration removed', 'success'); + setUrl(''); + setToken(''); + setVerifyTls(true); + setReplaceToken(false); + } catch (err) { + push(extractApiError(err, 'Could not remove C2 configuration'), 'error'); + } + }; + + const onTest = async () => { + setTestResult(null); + try { + const result = await testMutation.mutateAsync(); + setTestResult({ + ok: result.ok, + message: result.ok ? 'Connected' : (result.error ?? 'Connection failed'), + }); + } catch (err) { + setTestResult({ ok: false, message: extractApiError(err, 'Test failed') }); + } + }; + + const submitting = updateMutation.isPending || deleteMutation.isPending; + + return ( +
+

C2 configuration

+ + {is503 && ( +
+ C2 features are disabled (server has no encryption key configured). +
+ )} + + {configQuery.isLoading ? ( +

Loading…

+ ) : ( +
+ + setUrl(e.target.value)} + disabled={disabled} + /> + + + + {config?.has_token && !replaceToken ? ( +
+ + +
+ ) : ( + setToken(e.target.value)} + disabled={disabled} + /> + )} +
+ +
+ setVerifyTls(e.target.checked)} + disabled={disabled} + className="h-4 w-4 accent-primary" + /> + +
+ +
+ + + + + {testResult !== null && ( + + {testResult.message} + + )} + + {config?.has_token && ( + + )} +
+
+ )} + + {showDeleteConfirm && ( + setShowDeleteConfirm(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/ExecuteViaC2Modal.tsx b/frontend/src/components/ExecuteViaC2Modal.tsx new file mode 100644 index 0000000..2d621a0 --- /dev/null +++ b/frontend/src/components/ExecuteViaC2Modal.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react'; +import { extractApiError } from '@/api/client'; +import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2'; +import type { C2Callback } from '@/api/types'; +import { useToast } from '@/hooks/useToast'; + +interface ExecuteViaC2ModalProps { + simulationId: number; + engagementId: number; + initialCommands: string; + onClose: () => void; +} + +function formatCheckin(ts: string): string { + // Show ISO timestamp as-is — it's a data cell (font-mono) + return ts; +} + +export function ExecuteViaC2Modal({ + simulationId, + engagementId, + initialCommands, + onClose, +}: ExecuteViaC2ModalProps): JSX.Element { + const { push } = useToast(); + + const callbacksQuery = useC2Callbacks(engagementId, { enabled: true }); + const executeMutation = useExecuteC2(simulationId, engagementId); + + const [selectedId, setSelectedId] = useState(null); + const [commands, setCommands] = useState(initialCommands); + const [submitError, setSubmitError] = useState(null); + + const callbacks: C2Callback[] = callbacksQuery.data?.callbacks ?? []; + + const commandLines = commands + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + + const canLaunch = selectedId !== null && commandLines.length > 0; + + const onLaunch = async () => { + if (!canLaunch) return; + setSubmitError(null); + try { + const result = await executeMutation.mutateAsync({ + callback_display_id: selectedId, + commands: commandLines, + }); + push(`${result.tasks.length} task(s) submitted`, 'success'); + onClose(); + } catch (err) { + setSubmitError(extractApiError(err, 'Could not execute via C2')); + } + }; + + return ( +
+