feat(c2): integrate Mythic command and control (sprint 8) #11

Merged
knacky merged 16 commits from sprint/8-c2 into main 2026-06-11 10:29:19 +00:00
12 changed files with 1253 additions and 1 deletions
Showing only changes of commit 5ff6ae8940 - Show all commits

60
frontend/src/api/c2.ts Normal file
View File

@@ -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<C2Config | null> {
try {
const { data } = await apiClient.get<C2Config>(`/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<C2Config> {
const { data } = await apiClient.put<C2Config>(
`/engagements/${engagementId}/c2-config`,
input,
);
return data;
}
export async function deleteC2Config(engagementId: number): Promise<void> {
await apiClient.delete(`/engagements/${engagementId}/c2-config`);
}
export async function testC2Config(engagementId: number): Promise<C2TestResult> {
const { data } = await apiClient.post<C2TestResult>(
`/engagements/${engagementId}/c2-config/test`,
);
return data;
}
export async function listCallbacks(engagementId: number): Promise<C2CallbacksResponse> {
const { data } = await apiClient.get<C2CallbacksResponse>(
`/engagements/${engagementId}/c2/callbacks`,
);
return data;
}
export async function executeC2(
simulationId: number,
input: C2ExecuteInput,
): Promise<C2ExecuteResponse> {
const { data } = await apiClient.post<C2ExecuteResponse>(
`/simulations/${simulationId}/c2/execute`,
input,
);
return data;
}

View File

@@ -154,3 +154,52 @@ export interface SimulationPatchInput {
soc_comment?: string | null; soc_comment?: string | null;
incident_number?: 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[];
}

View File

@@ -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 (
<div
data-testid="c2-config-card"
className="card-product flex flex-col gap-md"
>
<h2 className="text-[20px] font-medium text-ink">C2 configuration</h2>
{is503 && (
<div
role="alert"
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
>
C2 features are disabled (server has no encryption key configured).
</div>
)}
{configQuery.isLoading ? (
<p className="text-[14px] text-graphite">Loading</p>
) : (
<form onSubmit={onSave} noValidate className="flex flex-col gap-md">
<FormField
label="URL"
htmlFor="c2-url"
hint="HTTPS required (e.g. https://mythic.lab:7443)"
>
<TextInput
id="c2-url"
data-testid="c2-url-input"
type="url"
name="url"
placeholder="https://mythic.lab:7443"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={disabled}
/>
</FormField>
<FormField label="API token" htmlFor="c2-token">
{config?.has_token && !replaceToken ? (
<div className="flex items-center gap-md">
<TextInput
id="c2-token"
data-testid="c2-token-input"
type="password"
name="api_token"
placeholder="••••••••"
value=""
readOnly
disabled={disabled}
/>
<button
type="button"
className="btn-text-link text-[14px] whitespace-nowrap"
onClick={() => setReplaceToken(true)}
disabled={disabled}
>
Replace token
</button>
</div>
) : (
<TextInput
id="c2-token"
data-testid="c2-token-input"
type="password"
name="api_token"
placeholder={config?.has_token ? 'Enter new token' : 'API token'}
value={token}
onChange={(e) => setToken(e.target.value)}
disabled={disabled}
/>
)}
</FormField>
<div className="flex items-center gap-sm">
<input
id="c2-verify-tls"
data-testid="c2-verify-tls"
type="checkbox"
checked={verifyTls}
onChange={(e) => setVerifyTls(e.target.checked)}
disabled={disabled}
className="h-4 w-4 accent-primary"
/>
<label htmlFor="c2-verify-tls" className="text-[14px] text-ink">
Verify TLS certificate
</label>
</div>
<div className="flex items-center gap-md flex-wrap">
<button
type="submit"
data-testid="c2-save-btn"
className="btn-primary"
disabled={disabled || submitting}
>
{updateMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
data-testid="c2-test-btn"
className="btn-outline"
onClick={onTest}
disabled={disabled || testMutation.isPending || !config}
>
{testMutation.isPending ? 'Testing…' : 'Test connection'}
</button>
{testResult !== null && (
<span
className={`text-[14px] ${testResult.ok ? 'text-success' : 'text-bloom-deep'}`}
>
{testResult.message}
</span>
)}
{config?.has_token && (
<button
type="button"
data-testid="c2-delete-btn"
className="btn-text-link text-bloom-deep ml-auto"
onClick={() => setShowDeleteConfirm(true)}
disabled={disabled || submitting}
>
Delete configuration
</button>
)}
</div>
</form>
)}
{showDeleteConfirm && (
<ConfirmDialog
title="Delete C2 configuration"
description="This will remove the C2 configuration for this engagement. The API token will be permanently deleted."
confirmLabel="Delete"
cancelLabel="Cancel"
destructive
onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</div>
);
}

View File

@@ -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<number | null>(null);
const [commands, setCommands] = useState(initialCommands);
const [submitError, setSubmitError] = useState<string | null>(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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby="c2-modal-title"
data-testid="c2-modal"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
<div className="relative card-product w-full max-w-3xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
<h2 id="c2-modal-title" className="text-[20px] font-medium text-ink">
Execute via C2
</h2>
{/* Callback table */}
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span>
{callbacksQuery.isLoading && (
<p className="text-[14px] text-graphite">Loading callbacks</p>
)}
{callbacksQuery.isError && (
<p className="text-[14px] text-bloom-deep">
Could not load callbacks: {extractApiError(callbacksQuery.error, 'Unknown error')}
</p>
)}
{!callbacksQuery.isLoading && callbacks.length === 0 && !callbacksQuery.isError && (
<p className="text-[14px] text-graphite">No callbacks available.</p>
)}
{callbacks.length > 0 && (
<div className="border border-hairline overflow-x-auto">
<table className="w-full text-[14px]">
<thead>
<tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink">Display ID</th>
<th className="px-md py-sm text-left font-medium text-ink">Active</th>
<th className="px-md py-sm text-left font-medium text-ink">Host</th>
<th className="px-md py-sm text-left font-medium text-ink">User</th>
<th className="px-md py-sm text-left font-medium text-ink">Domain</th>
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th>
</tr>
</thead>
<tbody>
{callbacks.map((cb) => {
const isSelected = selectedId === cb.display_id;
return (
<tr
key={cb.display_id}
data-testid="c2-callback-row"
onClick={() => setSelectedId(cb.display_id)}
className={`cursor-pointer border-b border-hairline ${
isSelected
? 'bg-primary-soft'
: 'hover:bg-cloud'
}`}
>
<td className="px-md py-sm font-mono">{cb.display_id}</td>
<td className="px-md py-sm">
<span
className={`badge-pill-${cb.active ? 'active' : 'inactive'}`}
>
{cb.active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-md py-sm font-mono">{cb.host}</td>
<td className="px-md py-sm font-mono">{cb.user}</td>
<td className="px-md py-sm font-mono">{cb.domain}</td>
<td className="px-md py-sm font-mono">{formatCheckin(cb.last_checkin)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Commands */}
<div className="flex flex-col gap-xs">
<label htmlFor="c2-commands" className="text-[14px] font-medium text-ink">
Commands
</label>
<textarea
id="c2-commands"
data-testid="c2-commands-textarea"
value={commands}
onChange={(e) => setCommands(e.target.value)}
className="text-input min-h-[112px] py-sm font-mono text-[14px]"
placeholder="One command per line"
/>
<span className="text-[12px] text-graphite">
{commandLines.length} command{commandLines.length !== 1 ? 's' : ''} one task per line
</span>
</div>
{submitError && (
<p role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</p>
)}
{/* Footer */}
<div className="flex items-center gap-md pt-xs">
<button
type="button"
data-testid="c2-launch-btn"
className="btn-primary"
onClick={onLaunch}
disabled={!canLaunch || executeMutation.isPending}
>
{executeMutation.isPending ? 'Launching…' : 'Launch'}
</button>
<button
type="button"
className="btn-outline-ink"
onClick={onClose}
disabled={executeMutation.isPending}
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
deleteC2Config,
executeC2,
getC2Config,
listCallbacks,
putC2Config,
testC2Config,
} from '@/api/c2';
import type { C2ConfigInput, C2ExecuteInput } from '@/api/types';
function c2ConfigKey(engagementId: number) {
return ['c2-config', engagementId] as const;
}
function c2CallbacksKey(engagementId: number) {
return ['c2-callbacks', engagementId] as const;
}
function simulationKey(id: number) {
return ['simulations', id] as const;
}
export function useC2Config(engagementId: number | undefined, options?: { enabled?: boolean }) {
const enabled =
typeof engagementId === 'number' &&
!Number.isNaN(engagementId) &&
(options?.enabled !== false);
return useQuery({
queryKey: engagementId ? c2ConfigKey(engagementId) : ['c2-config', 'none'],
queryFn: () => getC2Config(engagementId as number),
enabled,
});
}
export function useUpdateC2Config(engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: C2ConfigInput) => putC2Config(engagementId, input),
onSuccess: () => qc.invalidateQueries({ queryKey: c2ConfigKey(engagementId) }),
});
}
export function useDeleteC2Config(engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => deleteC2Config(engagementId),
onSuccess: () => qc.invalidateQueries({ queryKey: c2ConfigKey(engagementId) }),
});
}
export function useTestC2Config(engagementId: number) {
return useMutation({
mutationFn: () => testC2Config(engagementId),
});
}
export function useC2Callbacks(engagementId: number | undefined, options?: { enabled?: boolean }) {
const enabled =
typeof engagementId === 'number' &&
!Number.isNaN(engagementId) &&
(options?.enabled !== false);
return useQuery({
queryKey: engagementId ? c2CallbacksKey(engagementId) : ['c2-callbacks', 'none'],
queryFn: () => listCallbacks(engagementId as number),
enabled,
});
}
export function useExecuteC2(simulationId: number, engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: C2ExecuteInput) => executeC2(simulationId, input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(simulationId) });
qc.invalidateQueries({ queryKey: ['engagements', engagementId, 'simulations'] });
},
});
}

View File

@@ -7,10 +7,12 @@ import {
useEngagement, useEngagement,
usePatchEngagement, usePatchEngagement,
} from '@/hooks/useEngagements'; } from '@/hooks/useEngagements';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { FormField, Select, TextArea, TextInput } from '@/components/FormField'; import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { C2ConfigCard } from '@/components/C2ConfigCard';
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [ const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
{ value: 'planned', label: 'Planned' }, { value: 'planned', label: 'Planned' },
@@ -50,6 +52,7 @@ export function EngagementFormPage(): JSX.Element {
const numericId = id ? Number(id) : undefined; const numericId = id ? Number(id) : undefined;
const navigate = useNavigate(); const navigate = useNavigate();
const { push } = useToast(); const { push } = useToast();
const { canEditEngagements } = useAuth();
const detail = useEngagement(editing ? numericId : undefined); const detail = useEngagement(editing ? numericId : undefined);
const createMutation = useCreateEngagement(); const createMutation = useCreateEngagement();
@@ -214,6 +217,10 @@ export function EngagementFormPage(): JSX.Element {
</Link> </Link>
</div> </div>
</form> </form>
{editing && numericId && canEditEngagements && (
<C2ConfigCard engagementId={numericId} />
)}
</div> </div>
); );
} }

View File

@@ -12,12 +12,14 @@ import {
useTransitionSimulation, useTransitionSimulation,
useUpdateSimulation, useUpdateSimulation,
} from '@/hooks/useSimulations'; } from '@/hooks/useSimulations';
import { useC2Config } from '@/hooks/useC2';
import { FormField, TextArea, TextInput } from '@/components/FormField'; import { FormField, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge'; import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { MitreTechniquesField } from '@/components/MitreTechniquesField'; import { MitreTechniquesField } from '@/components/MitreTechniquesField';
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
interface RedteamFormState { interface RedteamFormState {
name: string; name: string;
@@ -61,6 +63,13 @@ export function SimulationFormPage(): JSX.Element {
const { push } = useToast(); const { push } = useToast();
const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth(); const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth();
const canEditRT = isAdmin || isRedteam;
const c2ConfigQuery = useC2Config(
!isNew && typeof engagementId === 'number' ? engagementId : undefined,
{ enabled: !isNew && canEditRT },
);
const hasC2Config = c2ConfigQuery.data !== null && c2ConfigQuery.data !== undefined;
const detail = useSimulation(isNew ? undefined : simulationId); const detail = useSimulation(isNew ? undefined : simulationId);
const createMutation = useCreateSimulation(engagementId ?? 0); const createMutation = useCreateSimulation(engagementId ?? 0);
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0); const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
@@ -72,6 +81,7 @@ export function SimulationFormPage(): JSX.Element {
const [nameError, setNameError] = useState<string | null>(null); const [nameError, setNameError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showC2Modal, setShowC2Modal] = useState(false);
useEffect(() => { useEffect(() => {
if (!isNew && detail.data) { if (!isNew && detail.data) {
@@ -109,7 +119,6 @@ export function SimulationFormPage(): JSX.Element {
// US-18: Done = fully read-only, Reopen only // US-18: Done = fully read-only, Reopen only
const isDone = status === 'done'; const isDone = status === 'done';
const canEditRT = isAdmin || isRedteam;
const socCanEdit = isSoc && (status === 'review_required' || status === 'done'); const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress'); const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
@@ -383,6 +392,19 @@ export function SimulationFormPage(): JSX.Element {
rows={5} rows={5}
/> />
</FormField> </FormField>
{!isDone && canEditRT && hasC2Config && (
<div className="pt-xs">
<button
type="button"
data-testid="c2-execute-btn"
className="btn-outline"
onClick={() => setShowC2Modal(true)}
>
Execute via C2
</button>
</div>
)}
</form> </form>
{/* SOC card */} {/* SOC card */}
@@ -512,6 +534,15 @@ export function SimulationFormPage(): JSX.Element {
onCancel={() => setShowDeleteConfirm(false)} onCancel={() => setShowDeleteConfirm(false)}
/> />
)} )}
{showC2Modal && simulationId && typeof engagementId === 'number' && (
<ExecuteViaC2Modal
simulationId={simulationId}
engagementId={engagementId}
initialCommands={rt.commands}
onClose={() => setShowC2Modal(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { EngagementFormPage } from '@/pages/EngagementFormPage';
import { renderWithProviders } from './utils';
import type { Engagement } from '@/api/types';
const ENGAGEMENT: Engagement = {
id: 5,
name: 'Test Engagement',
description: null,
start_date: '2026-06-01',
end_date: null,
status: 'active',
created_at: '2026-06-01T08:00:00',
created_by: { id: 1, username: 'alice' },
};
type MockRole = 'admin' | 'redteam' | 'soc';
let mockRole: MockRole = 'admin';
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: mockRole, created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: mockRole === 'admin',
isRedteam: mockRole === 'redteam',
isSoc: mockRole === 'soc',
canEditEngagements: mockRole === 'admin' || mockRole === 'redteam',
}),
}));
function EditPage() {
return (
<Routes>
<Route path="/engagements/:id/edit" element={<EngagementFormPage />} />
</Routes>
);
}
function NewPage() {
return (
<Routes>
<Route path="/engagements/new" element={<EngagementFormPage />} />
</Routes>
);
}
describe('EngagementFormPage — C2 config card visibility', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
mock.onGet('/engagements/5').reply(200, ENGAGEMENT);
mock.onGet('/engagements/5/c2-config').reply(404);
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
it('shows C2 config card in EDIT mode for admin', async () => {
mockRole = 'admin';
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/5/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-config-card')).toBeInTheDocument();
});
});
it('shows C2 config card in EDIT mode for redteam', async () => {
mockRole = 'redteam';
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/5/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-config-card')).toBeInTheDocument();
});
});
it('does NOT show C2 config card in EDIT mode for SOC', async () => {
mockRole = 'soc';
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/5/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-config-card')).toBeNull();
});
it('does NOT show C2 config card on the NEW engagement form', async () => {
mockRole = 'admin';
renderWithProviders(<NewPage />, {
routerProps: { initialEntries: ['/engagements/new'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /create engagement/i })).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-config-card')).toBeNull();
});
});

View File

@@ -67,6 +67,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
mockRole = 'redteam'; mockRole = 'redteam';
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM); mock.onGet('/simulations/7').reply(200, BASE_SIM);
mock.onGet('/engagements/42/c2-config').reply(404);
}); });
afterEach(() => { afterEach(() => {
@@ -154,6 +155,8 @@ describe('SimulationFormPage — SOC role + pending (blocked)', () => {
mockRole = 'soc'; mockRole = 'soc';
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM); mock.onGet('/simulations/7').reply(200, BASE_SIM);
// SOC role: useC2Config disabled (canEditRT=false), so no request expected — stub anyway
mock.onGet('/engagements/42/c2-config').reply(404);
}); });
afterEach(() => { afterEach(() => {
@@ -202,6 +205,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
mockRole = 'soc'; mockRole = 'soc';
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' }); mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
mock.onGet('/engagements/42/c2-config').reply(404);
}); });
afterEach(() => { afterEach(() => {
@@ -273,3 +277,58 @@ describe('SimulationFormPage — new simulation', () => {
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument();
}); });
}); });
describe('SimulationFormPage — Execute via C2 button visibility', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('shows Execute via C2 button when c2 config exists', async () => {
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-execute-btn')).toBeInTheDocument();
});
});
it('hides Execute via C2 button when no c2 config (404)', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
});
it('hides Execute via C2 button when simulation is done', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
});
});

View File

@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import {
deleteC2Config,
executeC2,
getC2Config,
listCallbacks,
putC2Config,
testC2Config,
} from '@/api/c2';
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
describe('getC2Config', () => {
it('returns config on 200', async () => {
mock.onGet('/engagements/1/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
const result = await getC2Config(1);
expect(result).toEqual({ has_token: true, url: 'https://mythic.lab:7443', verify_tls: true });
});
it('returns null on 404', async () => {
mock.onGet('/engagements/1/c2-config').reply(404);
const result = await getC2Config(1);
expect(result).toBeNull();
});
it('throws on other errors', async () => {
mock.onGet('/engagements/1/c2-config').reply(503);
await expect(getC2Config(1)).rejects.toThrow();
});
});
describe('putC2Config', () => {
it('sends PUT to correct URL with body', async () => {
mock.onPut('/engagements/2/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: false,
});
const result = await putC2Config(2, {
url: 'https://mythic.lab:7443',
api_token: 'secret',
verify_tls: false,
});
expect(result.has_token).toBe(true);
expect(result.verify_tls).toBe(false);
const req = mock.history['put'][0];
expect(req.url).toBe('/engagements/2/c2-config');
const body = JSON.parse(req.data as string);
expect(body.api_token).toBe('secret');
expect(body.verify_tls).toBe(false);
});
});
describe('deleteC2Config', () => {
it('sends DELETE to correct URL', async () => {
mock.onDelete('/engagements/3/c2-config').reply(204);
await expect(deleteC2Config(3)).resolves.toBeUndefined();
expect(mock.history['delete'][0].url).toBe('/engagements/3/c2-config');
});
});
describe('testC2Config', () => {
it('sends POST and returns test result', async () => {
mock.onPost('/engagements/1/c2-config/test').reply(200, { ok: true, error: null });
const result = await testC2Config(1);
expect(result.ok).toBe(true);
expect(result.error).toBeNull();
expect(mock.history['post'][0].url).toBe('/engagements/1/c2-config/test');
});
it('returns error message when connection fails', async () => {
mock
.onPost('/engagements/1/c2-config/test')
.reply(200, { ok: false, error: 'Connection refused' });
const result = await testC2Config(1);
expect(result.ok).toBe(false);
expect(result.error).toBe('Connection refused');
});
});
describe('listCallbacks', () => {
it('sends GET and returns callbacks', async () => {
mock.onGet('/engagements/1/c2/callbacks').reply(200, {
callbacks: [
{
display_id: 1,
active: true,
host: 'WIN-TARGET',
user: 'administrator',
domain: 'lab.local',
last_checkin: '2026-06-10T10:00:00',
},
],
});
const result = await listCallbacks(1);
expect(result.callbacks).toHaveLength(1);
expect(result.callbacks[0].display_id).toBe(1);
expect(result.callbacks[0].host).toBe('WIN-TARGET');
expect(mock.history['get'][0].url).toBe('/engagements/1/c2/callbacks');
});
});
describe('executeC2', () => {
it('sends POST with callback_display_id and commands', async () => {
mock.onPost('/simulations/5/c2/execute').reply(200, {
tasks: [
{ id: 1, mythic_task_display_id: 42, command: 'whoami', status: 'submitted', completed: false },
],
});
const result = await executeC2(5, {
callback_display_id: 1,
commands: ['whoami'],
});
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].command).toBe('whoami');
const req = mock.history['post'][0];
expect(req.url).toBe('/simulations/5/c2/execute');
const body = JSON.parse(req.data as string);
expect(body.callback_display_id).toBe(1);
expect(body.commands).toEqual(['whoami']);
});
});

View File

@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { C2ConfigCard } from '@/components/C2ConfigCard';
import { renderWithProviders } from '../utils';
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: true,
isRedteam: false,
isSoc: false,
canEditEngagements: true,
}),
}));
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
describe('C2ConfigCard — no config (404)', () => {
it('renders the card with empty fields when no config exists', async () => {
mock.onGet('/engagements/1/c2-config').reply(404);
renderWithProviders(<C2ConfigCard engagementId={1} />);
// Wait for loading to finish — query resolves to null on 404
await waitFor(() => {
expect(screen.getByTestId('c2-url-input')).toBeInTheDocument();
});
expect(screen.getByTestId('c2-token-input')).toBeInTheDocument();
expect(screen.getByTestId('c2-verify-tls')).toBeInTheDocument();
expect(screen.getByTestId('c2-save-btn')).toBeInTheDocument();
// Delete button only shown when has_token
expect(screen.queryByTestId('c2-delete-btn')).toBeNull();
});
});
describe('C2ConfigCard — with config (has_token=true)', () => {
beforeEach(() => {
mock.onGet('/engagements/1/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
});
it('shows Replace token affordance when has_token=true', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByText('Replace token')).toBeInTheDocument();
});
// Token input shows placeholder bullets (readOnly)
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
expect(tokenInput.readOnly).toBe(true);
expect(tokenInput.placeholder).toBe('••••••••');
});
it('shows Delete configuration button when has_token=true', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-delete-btn')).toBeInTheDocument();
});
});
it('clicking Replace token makes input editable', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByText('Replace token')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Replace token'));
await waitFor(() => {
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
expect(tokenInput.readOnly).toBeFalsy();
});
});
it('Test connection button is enabled when config exists', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
});
});
it('shows Connected on successful test', async () => {
mock.onPost('/engagements/1/c2-config/test').reply(200, { ok: true, error: null });
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
});
fireEvent.click(screen.getByTestId('c2-test-btn'));
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
});
it('shows error message on failed test', async () => {
mock
.onPost('/engagements/1/c2-config/test')
.reply(200, { ok: false, error: 'Connection refused' });
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
});
fireEvent.click(screen.getByTestId('c2-test-btn'));
await waitFor(() => {
expect(screen.getByText('Connection refused')).toBeInTheDocument();
});
});
});
describe('C2ConfigCard — 503 disabled state', () => {
it('shows 503 banner and disables all inputs', async () => {
mock.onGet('/engagements/1/c2-config').reply(503);
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(
screen.getByText(/C2 features are disabled/i),
).toBeInTheDocument();
});
expect(screen.getByTestId('c2-save-btn')).toBeDisabled();
expect(screen.getByTestId('c2-url-input')).toBeDisabled();
expect(screen.getByTestId('c2-token-input')).toBeDisabled();
expect(screen.getByTestId('c2-verify-tls')).toBeDisabled();
});
});

View File

@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
import { renderWithProviders } from '../utils';
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'redteam', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: false,
isRedteam: true,
isSoc: false,
canEditEngagements: true,
}),
}));
const CALLBACKS = [
{
display_id: 1,
active: true,
host: 'WIN-TARGET',
user: 'administrator',
domain: 'lab.local',
last_checkin: '2026-06-10T10:00:00',
},
{
display_id: 2,
active: false,
host: 'WIN-DC01',
user: 'SYSTEM',
domain: 'lab.local',
last_checkin: '2026-06-10T09:00:00',
},
];
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
mock.onGet('/engagements/42/c2/callbacks').reply(200, { callbacks: CALLBACKS });
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
function renderModal(initialCommands = 'whoami\nipconfig') {
const onClose = vi.fn();
renderWithProviders(
<ExecuteViaC2Modal
simulationId={7}
engagementId={42}
initialCommands={initialCommands}
onClose={onClose}
/>,
);
return { onClose };
}
describe('ExecuteViaC2Modal', () => {
it('renders modal with title and callback table', async () => {
renderModal();
expect(screen.getByTestId('c2-modal')).toBeInTheDocument();
expect(screen.getByText('Execute via C2')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
});
it('renders callback rows with mono data', async () => {
renderModal();
await waitFor(() => {
expect(screen.getByText('WIN-TARGET')).toBeInTheDocument();
expect(screen.getByText('WIN-DC01')).toBeInTheDocument();
expect(screen.getByText('administrator')).toBeInTheDocument();
});
});
it('Launch button is disabled before selecting a callback', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
expect(screen.getByTestId('c2-launch-btn')).toBeDisabled();
});
it('Launch button is disabled when commands are empty', async () => {
renderModal('');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
expect(screen.getByTestId('c2-launch-btn')).toBeDisabled();
});
it('Launch button enabled after selecting row and having commands', async () => {
renderModal('whoami');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
expect(screen.getByTestId('c2-launch-btn')).not.toBeDisabled();
});
it('calls executeC2 with correct body and closes modal on success', async () => {
mock.onPost('/simulations/7/c2/execute').reply(200, {
tasks: [
{ id: 1, mythic_task_display_id: 10, command: 'whoami', status: 'submitted', completed: false },
{ id: 2, mythic_task_display_id: 11, command: 'ipconfig', status: 'submitted', completed: false },
],
});
const { onClose } = renderModal('whoami\nipconfig');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
fireEvent.click(screen.getByTestId('c2-launch-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
const req = mock.history['post'][0];
const body = JSON.parse(req.data as string);
expect(body.callback_display_id).toBe(1);
expect(body.commands).toEqual(['whoami', 'ipconfig']);
});
it('shows inline error and keeps modal open on executeC2 failure', async () => {
mock.onPost('/simulations/7/c2/execute').reply(500, { error: 'Mythic unreachable' });
const { onClose } = renderModal('whoami');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
fireEvent.click(screen.getByTestId('c2-launch-btn'));
await waitFor(() => {
expect(screen.getByText('Mythic unreachable')).toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
});
it('Cancel button calls onClose', async () => {
const { onClose } = renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it('prefills commands textarea from initialCommands', async () => {
renderModal('net user\nwhoami /all');
const textarea = screen.getByTestId('c2-commands-textarea') as HTMLTextAreaElement;
expect(textarea.value).toBe('net user\nwhoami /all');
});
});