Compare commits

...

15 Commits

Author SHA1 Message Date
Knacky
8b5b5d94d8 fix(frontend): floating dropdowns and matrix modal frame use bg-paper
MitreTechniquePicker dropdown, SimulationList overflow menu,
ExportEngagementButton format menu, and MitreMatrixModal dialog frame
all used bg-canvas as their surface color. With the tinted canvas
(#f3f5f8), these floating surfaces appeared slightly grey instead of
clean white. Switched to bg-paper (#ffffff light / #1f2937 dark).
MitreMatrixModal cell hover (bg-canvas) intentionally preserved —
matrix cells sit on canvas, not on paper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:12:07 +02:00
Knacky
76bcb04c8f style(frontend): inputs + outline buttons inside cards use bg-paper
.text-input, .btn-outline, .btn-outline-ink were using bg-canvas which
now resolves to the tinted #f3f5f8 instead of white, making inputs and
Cancel/outlined buttons visually gritty on white paper cards. Switching
all three to bg-paper restores white surfaces inside cards in light mode.
Dark mode unaffected (canvas/paper both resolve correctly there).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:11:59 +02:00
Knacky
88b97cef2e feat(frontend): 2-column layout for EngagementFormPage in edit mode
In edit mode with canEditEngagements, wraps [form | C2ConfigCard] in a
lg:grid-cols-2 responsive grid with items-start alignment. Stacks to
single column on screens narrower than lg. In create mode, retains the
existing max-w-2xl single-column layout. No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:01:09 +02:00
Knacky
e4b1d6cb57 style(frontend): tint canvas light to #f3f5f8 for cards-on-canvas contrast
Canvas and paper were both #ffffff in light mode — cards only separated
by a 1px hairline, causing eye fatigue. Tints the canvas token to a
very pale cool neutral (#f3f5f8) so paper cards lift naturally without
shadow or radius, preserving brutalism. Dark mode tokens unchanged.
Updates DESIGN.md Surface section with rationale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:01:00 +02:00
Knacky
a9fe2fc528 docs(sprint-9): plan UI 2-col engagement + global contrast pass 2026-06-11 10:53:16 +02:00
Knacky
38e282a126 fix(backend): complete c2 task→simulation mapping per spec + sanitize adapter errors (sprint 8 code-review)
mapping.py — full §0.11 contract:
1. execution_result: append '$ <command>\n<output>\n' block (previously
   wrote raw output without command header, making multi-task blobs
   unreadable in exports)
2. executed_at: set from task.completed_at when currently null (was
   completely missing — simulation.executed_at stayed null forever)
3. commands: append task.command deduplicated line-by-line (was
   completely missing — simulation.commands stayed empty)

mythic.py — sanitize transport errors:
Replace 'raise C2Error(str(exc))' (which leaks the Mythic URL via
requests exception repr) with 'raise C2Error(f"C2 transport error:
{type(exc).__name__}")'. Original exc stays chained for backend logs.

api/c2.py — remove redundant 'task.mapping_applied = True' in import
endpoint (apply_task_to_simulation() already sets it).

test_c2_mapping.py — full rewrite: 19 tests covering command blocks,
executed_at set/preserve, commands dedup, idempotency.

test_c2_adapter_mythic.py — add URL-leak sanitization assertion.

468 passed; ruff + mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:28:49 +02:00
Knacky
7d3d39639e fix(backend): expose c2_task.source in GET /c2/tasks response
Serialize source as t.source.value (string) in list_simulation_tasks.
Updated test_c2_tasks_list shape assertion to include 'source' and
assert value is 'mimic' for execute-created tasks. Added test in
test_c2_import to assert source='import' in GET /c2/tasks after import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:24:22 +02:00
Knacky
184a2a16c9 fix(frontend): a11y on clickable rows + correct c2 source field + pill metric alignment (sprint 8 design-review)
F1: add tabIndex/role/onKeyDown/aria-expanded to C2TasksPanel expander rows and
    C2CallbackPicker callback rows; focus-visible ring via Tailwind utilities
F2: add source:'mimic'|'import' to C2TaskListItem; C2TasksPanel reads task.source
    instead of mapping_applied for the Source badge label
F3: align C2TaskStatusBadge and C2CallbackPicker Active/Inactive pill metrics to
    py-[6px] text-[14px] font-medium (matches SimulationStatusBadge / StatusBadge)
F4: replace hand-rolled Source pill class string with badge-pill-outline recipe
Tests: 212/212 passing (+3 new: Enter/Space key on expander, Enter key on callback row)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:22:45 +02:00
Knacky
7ff153905b feat(frontend): c2 tasks panel + history import (sprint 8 phase 2)
- Add getC2Tasks / listCallbackHistory / importC2 API functions + types
- useC2Tasks with 2500ms polling (stops when all tasks completed)
- useC2CallbackHistory, useImportC2 hooks
- C2TaskStatusBadge, C2TasksPanel (expandable output rows, polling indicator)
- C2CallbackPicker extracted as shared component (reused in both modals)
- ImportC2HistoryModal: 2-step callback picker → paginated history table
- SimulationFormPage: RT card + tasks panel share left grid column; Import C2 history button
- 37 new tests (api/c2, C2TasksPanel, ImportC2HistoryModal, SimulationFormPage panel visibility)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:11:12 +02:00
Knacky
8f23f59601 feat(backend): c2 callback history + task import (sprint 8 M4)
Command source decision: extended C2TaskStatus with command: str | None
(default None). Added command_name to _GET_TASK_QUERY so get_task() returns
command in a single round-trip — no separate history fetch needed on import.
4-line change, zero cascading test impact.

adapter.py:
- C2TaskStatus: add command: str | None = None field
- C2HistoricalTask: new dataclass (display_id, command, params, status,
  completed, timestamp) for history rows
- C2TaskPage.items: typed as list[C2HistoricalTask] (was list[dict])

mythic.py:
- _GET_TASK_QUERY: add command_name field
- _LIST_CALLBACK_TASKS_QUERY: new query (order_by id desc, limit/offset)
- _COUNT_CALLBACK_TASKS_QUERY: new aggregate query for total
- get_task(): surfaces command_name as status.command
- list_callback_tasks(): two _post() calls (tasks + count), allow_redirects=False

fake.py:
- _FAKE_HISTORY: frozen deterministic history (cb1=12, cb2=0, cb3=5 tasks)
- list_callback_tasks(): serves from _FAKE_HISTORY, pagination applied
- get_task(): returns command from _tasks dict

api/c2.py:
- GET /api/engagements/<eid>/c2/callbacks/<cid>/history: page+page_size
  defaults 1/25, cap 100, reject <1, 502 on adapter error
- POST /api/simulations/<sid>/c2/import: idempotent per (sim,mythic_id) pair,
  source=import, completed tasks get output+mapping_applied, incomplete tasks
  stored for poll-on-read pickup, auto-transition pending→in_progress

60 new tests (456 total); pytest/ruff/mypy all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:09:29 +02:00
Knacky
b83316f715 fix(backend): repair 0006 migration tests for alembic 1.18.x proxy api
Replace deprecated alembic.op._proxy assignment with
ops._install_proxy() / ops._remove_proxy() pattern required
by Alembic >= 1.13. Consistent with test_migration_0007_c2.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:02:46 +02:00
Knacky
873e52a2a1 feat(backend): c2 poll-on-read + output mapping (sprint 8 M3)
- adapter.py: add completed_at field to C2TaskStatus dataclass
- mythic.py: implement get_task() (GraphQL task query) and
  get_task_output() (response query + decode_response_text concat)
- fake.py: deterministic state progression via per-instance call counter;
  get_task_output raises C2Error until completed
- mapping.py: apply_task_to_simulation() idempotent output mapper
  (mapping_applied anchor prevents double-writes)
- migration 0007: add mapping_applied BOOLEAN NOT NULL DEFAULT false to c2_task
- c2_task model: mapping_applied column added
- api/c2.py: GET /api/simulations/<id>/c2/tasks poll-on-read endpoint;
  refreshes incomplete tasks from C2, fetches output on completion,
  applies mapping, skips re-polling for completed tasks; best-effort
  (C2Error on individual task skipped, returns 200 with stale status)
- 51 new tests (396 total); pytest/ruff/mypy all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:56:06 +02:00
Knacky
5ff6ae8940 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 <noreply@anthropic.com>
2026-06-10 19:50:11 +02:00
Knacky
53755a31d6 feat(backend): c2 callbacks + execute endpoints (sprint 8 M2)
- Add C2Error exception to adapter ABC
- Add promote_to_in_progress() helper to simulation_workflow (pending→in_progress)
- Flesh out MythicAdapter: list_callbacks() (GraphQL query) + create_task() (mutation)
- Expand FakeAdapter to 3 deterministic callbacks; switch task store to per-instance
- Add GET /api/engagements/<id>/c2/callbacks — lists active callbacks via adapter
- Add POST /api/simulations/<id>/c2/execute — issues tasks, stores C2Task rows,
  auto-transitions pending→in_progress, blocks on done (409)
- Both endpoints: SOC=403, 503 no-key, 502 adapter error, sanitized error messages
- Add requests-mock==1.12.1 to requirements.txt
- 42 new tests (342 total, 300 M1 baseline preserved green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:38:07 +02:00
Knacky
9a9c98beab feat(backend): c2 crypto + config CRUD + adapter scaffolding (sprint 8 M1)
- Add Fernet crypto service (MIMIC_ENCRYPTION_KEY env, C2Disabled on absent key)
- Add Alembic migration 0006: c2_config + c2_task tables with cascade FKs
- Add C2Config and C2Task SQLAlchemy models
- Add C2Adapter ABC with dataclasses (C2Health, C2Callback, C2TaskStatus, C2TaskPage)
- Add FakeAdapter (deterministic in-memory, MIMIC_C2_ADAPTER=fake)
- Add MythicAdapter scaffold: test_connection() live, M2+ raise NotImplementedError
- Add decode_response_text() helper for base64/binary Mythic responses
- Add GET/PUT/DELETE/POST-test /api/engagements/<id>/c2-config endpoints
- RBAC: admin+redteam OK, SOC 403; 503 guard when encryption key absent
- Token never returned in API responses; stored Fernet-encrypted only
- 42 new tests (300 total, 258 baseline preserved green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:20:52 +02:00
59 changed files with 7500 additions and 162 deletions

View File

@@ -2,7 +2,7 @@
Mimic is a **BAS (Breach and Attack Simulation) purple-team console** — not a product catalog, not a marketing page. The aesthetic is **Bloomberg Terminal / SOC dashboard**: dense, angular, semantic-color-driven, zero ornament. Every surface decision reinforces operational trust: data is primary, chrome is invisible.
The system sits on a **pure-white canvas** (light) / **dark slab** (dark) with one chromatic action color — **Electric Blue** (`{colors.primary}``#024ad8`) — and semantic status signals (`success`, `warn`, `bloom-deep`). Inter handles body/headers/labels. JetBrains Mono carries data: IDs, ISO dates, commands, execution output, MITRE technique codes, metrics — anything that must be read at a glance without typographic distraction.
The system sits on a **pale-tinted canvas** (light: `#f3f5f8`) / **dark slab** (dark) with one chromatic action color — **Electric Blue** (`{colors.primary}``#024ad8`) — and semantic status signals (`success`, `warn`, `bloom-deep`). Inter handles body/headers/labels. JetBrains Mono carries data: IDs, ISO dates, commands, execution output, MITRE technique codes, metrics — anything that must be read at a glance without typographic distraction.
**No rounded corners on containers.** No shadows on interactive surfaces. No transitions. Hover is instantaneous. Focus rings are sharp. This is a tool, not a storefront.
@@ -25,7 +25,7 @@ The system sits on a **pure-white canvas** (light) / **dark slab** (dark) with o
- **Soft Blue** (`{colors.primary-soft}``#c9e0fc`): selection highlight, chip background on light surfaces.
### Surface
- **Canvas** (`{colors.canvas}``#ffffff` light / `#111827` dark): universal page background.
- **Canvas** (`{colors.canvas}``#f3f5f8` light / `#111827` dark): universal page background. In light mode, canvas is tinted while paper stays pure white so cards lift without shadow or radius, preserving brutalism.
- **Paper** (`{colors.paper}``#ffffff` light / `#1f2937` dark): card and panel surfaces.
- **Cloud** (`{colors.cloud}``#f7f7f7` light / `#1f2937` dark): alternating section band, table row zebra.
- **Fog** (`{colors.fog}``#e8e8e8` light / `#374151` dark): secondary section band, input background on dense panels.

View File

@@ -6,7 +6,15 @@ from pathlib import Path
from flask import Flask, jsonify, send_from_directory
from backend.app.api import auth_bp, engagements_bp, simulations_bp, templates_bp, users_bp
from backend.app.api import (
auth_bp,
c2_bp,
engagements_bp,
sims_c2_bp,
simulations_bp,
templates_bp,
users_bp,
)
from backend.app.cli import register_cli
from backend.app.config import Config, TestConfig
from backend.app.errors import register_error_handlers
@@ -38,6 +46,8 @@ def create_app(config_object: object | None = None) -> Flask:
app.register_blueprint(engagements_bp)
app.register_blueprint(simulations_bp)
app.register_blueprint(templates_bp)
app.register_blueprint(c2_bp)
app.register_blueprint(sims_c2_bp)
from backend.app.services import mitre as mitre_svc
mitre_svc.load_bundle()

View File

@@ -1,8 +1,17 @@
"""API blueprints."""
from backend.app.api.auth import auth_bp
from backend.app.api.c2 import c2_bp, sims_c2_bp
from backend.app.api.engagements import engagements_bp
from backend.app.api.simulations import simulations_bp
from backend.app.api.templates import templates_bp
from backend.app.api.users import users_bp
__all__ = ["auth_bp", "users_bp", "engagements_bp", "simulations_bp", "templates_bp"]
__all__ = [
"auth_bp",
"c2_bp",
"sims_c2_bp",
"users_bp",
"engagements_bp",
"simulations_bp",
"templates_bp",
]

517
backend/app/api/c2.py Normal file
View File

@@ -0,0 +1,517 @@
"""C2 endpoints — config CRUD and execution.
All endpoints:
- Require admin or redteam role (SOC → 403).
- Return 503 when MIMIC_ENCRYPTION_KEY is not set.
- Never include the cleartext API token in any response.
- Adapter errors → 502 with sanitized message (no URL or token in body).
"""
from __future__ import annotations
from datetime import UTC, datetime
from urllib.parse import urlparse
from flask import Blueprint, jsonify, request
from backend.app.auth import role_required
from backend.app.extensions import db
from backend.app.models import Engagement
from backend.app.models.c2_config import C2Config
from backend.app.models.c2_task import C2Task, C2TaskSource
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.factory import get_adapter
from backend.app.services.c2.mapping import apply_task_to_simulation
from backend.app.services.crypto import C2Disabled, decrypt, encrypt
from backend.app.services.simulation_workflow import promote_to_in_progress
c2_bp = Blueprint("c2", __name__, url_prefix="/api/engagements")
sims_c2_bp = Blueprint("sims_c2", __name__, url_prefix="/api/simulations")
_503_BODY = {"error": "C2 disabled: MIMIC_ENCRYPTION_KEY not set"}
def _crypto_guard():
"""Return a 503 Response when crypto key is absent, else None."""
try:
# Attempt a dummy operation to test key availability.
encrypt("probe")
return None
except C2Disabled:
return jsonify(_503_BODY), 503
@c2_bp.get("/<int:eid>/c2-config")
@role_required("admin", "redteam")
def get_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return jsonify({"error": "C2 config not found"}), 404
return jsonify({
"has_token": bool(cfg.api_token_encrypted),
"url": cfg.url,
"verify_tls": cfg.verify_tls,
}), 200
@c2_bp.put("/<int:eid>/c2-config")
@role_required("admin", "redteam")
def upsert_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
data = request.get_json(silent=True) or {}
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "url is required"}), 400
parsed = urlparse(url)
if parsed.scheme != "https":
return jsonify({"error": "url must use https"}), 400
if not parsed.hostname:
return jsonify({"error": "url must contain a hostname"}), 400
verify_tls = data.get("verify_tls", True)
if not isinstance(verify_tls, bool):
return jsonify({"error": "verify_tls must be a boolean"}), 400
cfg: C2Config | None = engagement.c2_config
if cfg is None:
# New row — api_token is required on creation.
raw_token = data.get("api_token") or ""
if not raw_token:
return jsonify({"error": "api_token is required when creating a config"}), 400
encrypted = encrypt(raw_token)
cfg = C2Config(
engagement_id=eid,
url=url,
api_token_encrypted=encrypted,
verify_tls=verify_tls,
)
db.session.add(cfg)
else:
# Update — omitting api_token keeps the existing ciphertext.
cfg.url = url
cfg.verify_tls = verify_tls
cfg.updated_at = datetime.now(UTC)
raw_token = data.get("api_token") or ""
if raw_token:
cfg.api_token_encrypted = encrypt(raw_token)
db.session.commit()
return jsonify({
"has_token": True,
"url": cfg.url,
"verify_tls": cfg.verify_tls,
}), 200
@c2_bp.delete("/<int:eid>/c2-config")
@role_required("admin", "redteam")
def delete_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return jsonify({"error": "C2 config not found"}), 404
db.session.delete(cfg)
db.session.commit()
return "", 204
@c2_bp.post("/<int:eid>/c2-config/test")
@role_required("admin", "redteam")
def test_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return jsonify({"error": "C2 config not found"}), 404
try:
api_token = decrypt(cfg.api_token_encrypted)
except ValueError:
return jsonify({"ok": False, "error": "Stored token is corrupt"}), 200
adapter = get_adapter(
url=cfg.url,
api_token=api_token,
verify_tls=cfg.verify_tls,
)
health = adapter.test_connection()
return jsonify({"ok": health.ok, "error": health.error}), 200
# ---------------------------------------------------------------------------
# M2 — callbacks listing + execute
# ---------------------------------------------------------------------------
def _load_adapter_for_engagement(engagement: Engagement):
"""Decrypt token and return adapter, or return a (response, status) error tuple."""
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return None, (jsonify({"error": "C2 config not found"}), 404)
try:
api_token = decrypt(cfg.api_token_encrypted)
except ValueError:
return None, (jsonify({"error": "Stored token is corrupt"}), 500)
adapter = get_adapter(url=cfg.url, api_token=api_token, verify_tls=cfg.verify_tls)
return adapter, None
@c2_bp.get("/<int:eid>/c2/callbacks")
@role_required("admin", "redteam")
def list_callbacks(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
try:
callbacks = adapter.list_callbacks()
except C2Error as exc:
return jsonify({"error": str(exc)}), 502
return jsonify({
"callbacks": [
{
"display_id": cb.display_id,
"active": cb.active,
"host": cb.host,
"user": cb.user,
"domain": cb.domain,
"last_checkin": cb.last_checkin,
}
for cb in callbacks
]
}), 200
@sims_c2_bp.post("/<int:sid>/c2/execute")
@role_required("admin", "redteam")
def execute_simulation(sid: int):
guard = _crypto_guard()
if guard is not None:
return guard
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
# Done is terminal — block execution.
if sim.status == SimulationStatus.DONE:
return jsonify({"error": "simulation is done — reopen first"}), 409
data = request.get_json(silent=True) or {}
callback_display_id = data.get("callback_display_id")
commands = data.get("commands")
if not isinstance(callback_display_id, int):
return jsonify({"error": "callback_display_id must be an integer"}), 400
if not isinstance(commands, list) or len(commands) == 0:
return jsonify({"error": "commands must be a non-empty list"}), 400
for cmd in commands:
if not isinstance(cmd, str):
return jsonify({"error": "each command must be a string"}), 400
engagement = db.session.get(Engagement, sim.engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
created_tasks = []
try:
for command in commands:
mythic_id = adapter.create_task(
callback_display_id=callback_display_id,
command=command,
)
task = C2Task(
simulation_id=sid,
mythic_task_display_id=mythic_id,
callback_display_id=callback_display_id,
command=command,
params=None,
status="submitted",
completed=False,
source=C2TaskSource.MIMIC,
created_at=datetime.now(UTC),
)
db.session.add(task)
created_tasks.append(task)
except C2Error as exc:
db.session.rollback()
return jsonify({"error": str(exc)}), 502
# Auto-transition pending → in_progress (no-op for other statuses).
promote_to_in_progress(sim)
db.session.commit()
return jsonify({
"tasks": [
{
"id": t.id,
"mythic_task_display_id": t.mythic_task_display_id,
"command": t.command,
"status": t.status,
"completed": t.completed,
}
for t in created_tasks
]
}), 200
# ---------------------------------------------------------------------------
# M3 — poll-on-read task listing
# ---------------------------------------------------------------------------
@sims_c2_bp.get("/<int:sid>/c2/tasks")
@role_required("admin", "redteam")
def list_simulation_tasks(sid: int):
guard = _crypto_guard()
if guard is not None:
return guard
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
engagement = db.session.get(Engagement, sim.engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
tasks: list[C2Task] = C2Task.query.filter_by(simulation_id=sid).all()
for task in tasks:
if task.completed:
continue
try:
status = adapter.get_task(task.mythic_task_display_id)
except C2Error:
# Best-effort refresh — skip this task if the adapter fails.
continue
task.status = status.status
task.completed = status.completed
if status.completed:
task.completed_at = status.completed_at or datetime.now(UTC)
try:
task.output = adapter.get_task_output(task.mythic_task_display_id)
except C2Error:
task.output = ""
apply_task_to_simulation(task, sim)
db.session.commit()
return jsonify({
"tasks": [
{
"id": t.id,
"mythic_task_display_id": t.mythic_task_display_id,
"callback_display_id": t.callback_display_id,
"command": t.command,
"params": t.params,
"status": t.status,
"completed": t.completed,
"output": t.output,
"source": t.source.value,
"mapping_applied": t.mapping_applied,
"created_at": t.created_at.isoformat() if t.created_at else None,
"completed_at": t.completed_at.isoformat() if t.completed_at else None,
}
for t in tasks
]
}), 200
# ---------------------------------------------------------------------------
# M4 — callback history + task import
# ---------------------------------------------------------------------------
@c2_bp.get("/<int:eid>/c2/callbacks/<int:cid>/history")
@role_required("admin", "redteam")
def list_callback_history(eid: int, cid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
# Validate pagination params.
try:
page = int(request.args.get("page", 1))
page_size = int(request.args.get("page_size", 25))
except (ValueError, TypeError):
return jsonify({"error": "page and page_size must be integers"}), 400
if page < 1 or page_size < 1:
return jsonify({"error": "page and page_size must be >= 1"}), 400
if page_size > 100:
return jsonify({"error": "page_size must be <= 100"}), 400
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
try:
page_result = adapter.list_callback_tasks(
callback_display_id=cid,
page=page,
page_size=page_size,
)
except C2Error as exc:
return jsonify({"error": str(exc)}), 502
return jsonify({
"tasks": [
{
"display_id": t.display_id,
"command": t.command,
"params": t.params,
"status": t.status,
"completed": t.completed,
"timestamp": t.timestamp,
}
for t in page_result.items
],
"total": page_result.total,
"page": page_result.page,
"page_size": page_result.page_size,
}), 200
@sims_c2_bp.post("/<int:sid>/c2/import")
@role_required("admin", "redteam")
def import_tasks(sid: int):
guard = _crypto_guard()
if guard is not None:
return guard
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
if sim.status == SimulationStatus.DONE:
return jsonify({"error": "simulation is done — reopen first"}), 409
data = request.get_json(silent=True) or {}
callback_display_id = data.get("callback_display_id")
task_display_ids = data.get("task_display_ids")
if not isinstance(callback_display_id, int):
return jsonify({"error": "callback_display_id must be an integer"}), 400
if not isinstance(task_display_ids, list) or len(task_display_ids) == 0:
return jsonify({"error": "task_display_ids must be a non-empty list"}), 400
for tid in task_display_ids:
if not isinstance(tid, int):
return jsonify({"error": "each task_display_id must be an integer"}), 400
engagement = db.session.get(Engagement, sim.engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
imported_count = 0
skipped_count = 0
try:
for task_display_id in task_display_ids:
# Idempotency: skip if already imported for this simulation.
existing = C2Task.query.filter_by(
simulation_id=sid,
mythic_task_display_id=task_display_id,
).first()
if existing is not None:
skipped_count += 1
continue
status = adapter.get_task(task_display_id)
task = C2Task(
simulation_id=sid,
mythic_task_display_id=task_display_id,
callback_display_id=callback_display_id,
command=status.command or "",
params=None,
status=status.status,
completed=status.completed,
source=C2TaskSource.IMPORT,
created_at=datetime.now(UTC),
mapping_applied=False,
)
if status.completed:
task.completed_at = status.completed_at or datetime.now(UTC)
try:
task.output = adapter.get_task_output(task_display_id)
except C2Error:
task.output = ""
db.session.add(task)
db.session.flush()
apply_task_to_simulation(task, sim)
else:
db.session.add(task)
imported_count += 1
except C2Error as exc:
db.session.rollback()
return jsonify({"error": str(exc)}), 502
# Auto-transition pending → in_progress when at least one task was imported.
if imported_count > 0:
promote_to_in_progress(sim)
db.session.commit()
return jsonify({"imported": imported_count, "skipped": skipped_count}), 200

View File

@@ -1,4 +1,6 @@
"""SQLAlchemy models."""
from backend.app.models.c2_config import C2Config
from backend.app.models.c2_task import C2Task, C2TaskSource
from backend.app.models.engagement import Engagement, EngagementStatus
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.models.simulation_template import SimulationTemplate
@@ -12,4 +14,7 @@ __all__ = [
"Simulation",
"SimulationStatus",
"SimulationTemplate",
"C2Config",
"C2Task",
"C2TaskSource",
]

View File

@@ -0,0 +1,34 @@
"""C2Config model — per-engagement Mythic connection settings."""
from __future__ import annotations
from datetime import UTC, datetime
from backend.app.extensions import db
class C2Config(db.Model): # type: ignore[name-defined]
__tablename__ = "c2_config"
id = db.Column(db.Integer, primary_key=True)
engagement_id = db.Column(
db.Integer,
db.ForeignKey("engagements.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
)
url = db.Column(db.Text, nullable=False)
api_token_encrypted = db.Column(db.Text, nullable=False)
verify_tls = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
)
updated_at = db.Column(db.DateTime, nullable=True)
engagement = db.relationship(
"Engagement",
backref=db.backref("c2_config", uselist=False, cascade="all, delete-orphan"),
)
def __repr__(self) -> str:
return f"<C2Config engagement_id={self.engagement_id}>"

View File

@@ -0,0 +1,48 @@
"""C2Task model — link between a Mimic simulation and a Mythic task."""
from __future__ import annotations
import enum
from datetime import UTC, datetime
from backend.app.extensions import db
class C2TaskSource(str, enum.Enum):
MIMIC = "mimic"
IMPORT = "import"
class C2Task(db.Model): # type: ignore[name-defined]
__tablename__ = "c2_task"
id = db.Column(db.Integer, primary_key=True)
simulation_id = db.Column(
db.Integer,
db.ForeignKey("simulations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
mythic_task_display_id = db.Column(db.Integer, nullable=False)
callback_display_id = db.Column(db.Integer, nullable=False)
command = db.Column(db.Text, nullable=False)
params = db.Column(db.Text, nullable=True)
status = db.Column(db.Text, nullable=False)
completed = db.Column(db.Boolean, nullable=False, default=False)
output = db.Column(db.Text, nullable=True)
source = db.Column(
db.Enum(C2TaskSource, name="c2task_source"),
nullable=False,
)
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
)
completed_at = db.Column(db.DateTime, nullable=True)
mapping_applied = db.Column(db.Boolean, nullable=False, default=False)
simulation = db.relationship(
"Simulation",
backref=db.backref("c2_tasks", cascade="all, delete-orphan", lazy="dynamic"),
)
def __repr__(self) -> str:
return f"<C2Task simulation_id={self.simulation_id} mythic_id={self.mythic_task_display_id}>"

View File

@@ -0,0 +1,22 @@
"""C2 adapter package. Import the factory from here."""
from backend.app.services.c2.adapter import (
C2Adapter,
C2Callback,
C2Error,
C2Health,
C2TaskPage,
C2TaskStatus,
decode_response_text,
)
from backend.app.services.c2.factory import get_adapter
__all__ = [
"C2Adapter",
"C2Callback",
"C2Error",
"C2Health",
"C2TaskPage",
"C2TaskStatus",
"decode_response_text",
"get_adapter",
]

View File

@@ -0,0 +1,117 @@
"""Abstract C2 adapter interface and shared dataclasses."""
from __future__ import annotations
import base64
import binascii
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
class C2Error(Exception):
"""Raised by adapters when the C2 returns an application-level error."""
@dataclass
class C2Health:
ok: bool
error: str | None = None
@dataclass
class C2Callback:
display_id: int
active: bool
host: str
user: str
domain: str
last_checkin: str # ISO-8601 string
@dataclass
class C2TaskStatus:
display_id: int
status: str
completed: bool
completed_at: datetime | None = field(default=None)
# command_name is populated by get_task() so import doesn't need a second round-trip.
command: str | None = field(default=None)
@dataclass
class C2HistoricalTask:
"""A task entry from callback history (carries command + params, unlike C2TaskStatus)."""
display_id: int
command: str
params: str | None
status: str
completed: bool
timestamp: str | None # ISO-8601 or None
@dataclass
class C2TaskPage:
items: list[C2HistoricalTask]
total: int
page: int
page_size: int
def decode_response_text(raw: str) -> str:
"""Decode a base64-encoded Mythic response_text field.
On binascii.Error (binary payload) returns "<binary> " + hex string
so execution_result never silently corrupts.
"""
try:
return base64.b64decode(raw).decode("utf-8")
except binascii.Error:
return "<binary> " + raw.encode().hex()
except UnicodeDecodeError:
raw_bytes = base64.b64decode(raw)
return "<binary> " + raw_bytes.hex()
class C2Adapter(ABC):
"""Thin interface over a C2 backend (Mythic or custom)."""
@abstractmethod
def test_connection(self) -> C2Health:
"""Verify that the C2 is reachable and the token is valid."""
...
@abstractmethod
def list_callbacks(self) -> list[C2Callback]:
"""Return active callbacks visible to this API token."""
...
@abstractmethod
def create_task(
self,
callback_display_id: int,
command: str,
params: str | None = None,
) -> int:
"""Issue a task and return its Mythic display_id."""
...
@abstractmethod
def get_task(self, task_display_id: int) -> C2TaskStatus:
"""Return current status of a task."""
...
@abstractmethod
def get_task_output(self, task_display_id: int) -> str:
"""Return decoded, concatenated output for a completed task."""
...
@abstractmethod
def list_callback_tasks(
self,
callback_display_id: int,
page: int = 1,
page_size: int = 25,
) -> C2TaskPage:
"""Return a paginated history of tasks for a callback."""
...

View File

@@ -0,0 +1,19 @@
"""Factory that resolves the C2Adapter implementation from MIMIC_C2_ADAPTER env."""
from __future__ import annotations
import os
from backend.app.services.c2.adapter import C2Adapter
def get_adapter(url: str, api_token: str, verify_tls: bool = True) -> C2Adapter:
"""Return the correct C2Adapter based on MIMIC_C2_ADAPTER (default: mythic)."""
adapter_name = os.environ.get("MIMIC_C2_ADAPTER", "mythic").lower()
if adapter_name == "fake":
from backend.app.services.c2.fake import FakeAdapter
return FakeAdapter()
# Default: real Mythic adapter
from backend.app.services.c2.mythic import MythicAdapter
return MythicAdapter(url=url, api_token=api_token, verify_tls=verify_tls)

View File

@@ -0,0 +1,176 @@
"""Deterministic in-memory C2 adapter — used when MIMIC_C2_ADAPTER=fake.
Intended for integration tests and local development without a live Mythic instance.
Task state is per-instance so parallel tests don't interfere with each other.
"""
from __future__ import annotations
from backend.app.services.c2.adapter import (
C2Adapter,
C2Callback,
C2Error,
C2Health,
C2HistoricalTask,
C2TaskPage,
C2TaskStatus,
)
# Frozen base timestamp — all fake history tasks share this prefix for determinism.
_BASE_TS = "2026-06-10T00:00:00Z"
# Deterministic history for list_callback_tasks:
# callback 1 → 12 tasks, callback 2 → 0 tasks, callback 3 → 5 tasks.
# Commands cycle through a fixed set; even-indexed tasks are completed.
_HISTORY_COMMANDS = ["whoami", "hostname", "id", "ipconfig", "net user", "pwd"]
_FAKE_HISTORY: dict[int, list[C2HistoricalTask]] = {
1: [
C2HistoricalTask(
display_id=100 + i,
command=_HISTORY_COMMANDS[i % len(_HISTORY_COMMANDS)],
params=None,
status="completed" if i % 2 == 0 else "submitted",
completed=i % 2 == 0,
timestamp=_BASE_TS if i % 2 == 0 else None,
)
for i in range(12)
],
2: [],
3: [
C2HistoricalTask(
display_id=200 + i,
command=_HISTORY_COMMANDS[i % len(_HISTORY_COMMANDS)],
params=None,
status="completed" if i % 2 == 0 else "submitted",
completed=i % 2 == 0,
timestamp=_BASE_TS if i % 2 == 0 else None,
)
for i in range(5)
],
}
# Three fixed callbacks the test suite can pin against.
_FAKE_CALLBACKS = [
C2Callback(
display_id=1,
active=True,
host="WORKSTATION-01",
user="jdoe",
domain="LAB",
last_checkin="2026-06-10T00:00:00Z",
),
C2Callback(
display_id=2,
active=True,
host="SERVER-DC01",
user="svc_backup",
domain="LAB",
last_checkin="2026-06-10T00:01:00Z",
),
C2Callback(
display_id=3,
active=True,
host="LAPTOP-RT",
user="admin",
domain="LAB",
last_checkin="2026-06-10T00:02:00Z",
),
]
class FakeAdapter(C2Adapter):
"""In-memory adapter with deterministic behaviour.
Each instance starts with an empty task store and display_ids from 1000.
get_task() state progression per task (keyed by display_id):
- First call after create_task → submitted, completed=False
- Second and subsequent calls → completed=True, status="completed"
"""
def __init__(self) -> None:
self._tasks: dict[int, dict] = {}
self._next_task_id = 1000
# Tracks how many times get_task has been called per display_id.
self._get_task_calls: dict[int, int] = {}
def test_connection(self) -> C2Health:
return C2Health(ok=True)
def list_callbacks(self) -> list[C2Callback]:
return list(_FAKE_CALLBACKS)
def create_task(
self,
callback_display_id: int,
command: str,
params: str | None = None,
) -> int:
tid = self._next_task_id
self._next_task_id += 1
self._tasks[tid] = {
"display_id": tid,
"callback_display_id": callback_display_id,
"command": command,
"params": params,
"status": "submitted",
"completed": False,
"output": None,
}
return tid
def get_task(self, task_display_id: int) -> C2TaskStatus:
"""Deterministic state progression: first call → submitted, second+ → completed.
Tracks call count regardless of whether the task was created by this instance,
so the endpoint poll-on-read flow works across separate adapter instantiations.
"""
call_count = self._get_task_calls.get(task_display_id, 0) + 1
self._get_task_calls[task_display_id] = call_count
task = self._tasks.get(task_display_id)
if call_count >= 2:
completed = True
status = "completed"
if task is not None:
task["status"] = "completed"
task["completed"] = True
else:
completed = False
status = task["status"] if task is not None else "submitted"
return C2TaskStatus(
display_id=task_display_id,
status=status,
completed=completed,
command=task["command"] if task is not None else None,
)
def get_task_output(self, task_display_id: int) -> str:
"""Returns deterministic output once task is completed; raises C2Error before that."""
# Check call count — completed if get_task was called at least twice.
if self._get_task_calls.get(task_display_id, 0) < 2:
# Also allow tasks in _tasks that were explicitly set to completed.
task = self._tasks.get(task_display_id)
if task is None or not task.get("completed", False):
raise C2Error("task not completed")
task = self._tasks.get(task_display_id)
command = task["command"] if task is not None else "unknown"
return f"output for task {task_display_id}: {command}\n"
def list_callback_tasks(
self,
callback_display_id: int,
page: int = 1,
page_size: int = 25,
) -> C2TaskPage:
all_items = _FAKE_HISTORY.get(callback_display_id, [])
start = (page - 1) * page_size
return C2TaskPage(
items=all_items[start : start + page_size],
total=len(all_items),
page=page,
page_size=page_size,
)

View File

@@ -0,0 +1,53 @@
"""C2 task → Simulation output mapping.
apply_task_to_simulation() implements the full §0.11 contract:
1. execution_result — append "$ <command>\n<output>\n" block.
2. executed_at — set from task.completed_at when currently null.
3. commands — append task.command deduplicated line-by-line.
Caller is responsible for committing the session.
"""
from __future__ import annotations
from datetime import UTC, datetime
from backend.app.models.c2_task import C2Task
from backend.app.models.simulation import Simulation
def apply_task_to_simulation(task: C2Task, simulation: Simulation) -> None:
"""Apply completed task data to simulation fields per §0.11.
Idempotent: no-op when task.mapping_applied is already True.
Always sets mapping_applied = True on exit so the task is never re-processed.
"""
if task.mapping_applied:
return
output = (task.output or "").strip()
# 1) execution_result — "$ <command>\n<output>\n" block, only when output is non-empty.
if output:
block = f"$ {task.command}\n{output}\n"
existing = simulation.execution_result or ""
if existing:
sep = "" if existing.endswith("\n") else "\n"
simulation.execution_result = existing + sep + block
else:
simulation.execution_result = block
# 2) executed_at — set once from the first completed task's timestamp.
if simulation.executed_at is None and task.completed_at is not None:
simulation.executed_at = task.completed_at
# 3) commands — append deduplicated line.
if task.command:
existing_cmds = (simulation.commands or "").splitlines()
if task.command.strip() not in (line.strip() for line in existing_cmds):
if simulation.commands:
simulation.commands = simulation.commands + "\n" + task.command
else:
simulation.commands = task.command
simulation.updated_at = datetime.now(UTC)
task.mapping_applied = True

View File

@@ -0,0 +1,293 @@
# Contract pinned from MythicMeta/Mythic_Scripting master @ 2026-06-10 (raw.githubusercontent.com/MythicMeta/Mythic_Scripting/master/mythic/mythic.py)
"""Mythic 3.x C2 adapter.
Transport: POST https://<host>:7443/graphql
Header: apitoken: <token>
Backend: Hasura-proxied Postgres behind nginx.
M1: test_connection()
M2: list_callbacks(), create_task()
M3: get_task(), get_task_output()
M4: list_callback_tasks()
"""
from __future__ import annotations
from datetime import datetime
import requests
from backend.app.services.c2.adapter import (
C2Adapter,
C2Callback,
C2Error,
C2Health,
C2HistoricalTask,
C2TaskPage,
C2TaskStatus,
decode_response_text,
)
_HEALTH_QUERY = "{ __typename }"
_CALLBACKS_QUERY = """
query {
callback(order_by: {id: asc}, where: {active: {_eq: true}}) {
id
display_id
active
host
user
domain
last_checkin
}
}
"""
_CREATE_TASK_MUTATION = """
mutation CreateTask($callback_id: Int!, $command: String!, $params: String!) {
createTask(
callback_id: $callback_id,
command: $command,
params: $params,
tasking_location: "command_line"
) {
id
display_id
error
}
}
"""
_GET_TASK_QUERY = """
query GetTask($display_id: Int!) {
task(where: {display_id: {_eq: $display_id}}) {
display_id
command_name
status
completed
timestamp
}
}
"""
_LIST_CALLBACK_TASKS_QUERY = """
query ListCallbackTasks($callback_display_id: Int!, $limit: Int!, $offset: Int!) {
task(
where: {callback: {display_id: {_eq: $callback_display_id}}}
order_by: {id: desc}
limit: $limit
offset: $offset
) {
display_id
command_name
params
status
completed
timestamp
}
}
"""
_COUNT_CALLBACK_TASKS_QUERY = """
query CountCallbackTasks($callback_display_id: Int!) {
task_aggregate(where: {callback: {display_id: {_eq: $callback_display_id}}}) {
aggregate {
count
}
}
}
"""
_GET_TASK_OUTPUT_QUERY = """
query GetTaskOutput($display_id: Int!) {
response(
where: {task: {display_id: {_eq: $display_id}}}
order_by: {id: asc}
) {
response_text
}
}
"""
class MythicAdapter(C2Adapter):
"""Real Mythic 3.x adapter using GraphQL over HTTP."""
def __init__(self, url: str, api_token: str, verify_tls: bool = True) -> None:
self._url = url.rstrip("/") + "/graphql"
self._token = api_token
self._verify = verify_tls
def _headers(self) -> dict[str, str]:
return {
"Content-Type": "application/json",
"apitoken": self._token,
}
def _post(self, body: dict) -> dict:
resp = requests.post(
self._url,
json=body,
headers=self._headers(),
verify=self._verify,
timeout=10,
allow_redirects=False,
)
resp.raise_for_status()
return resp.json()
def test_connection(self) -> C2Health:
"""POST a trivial introspection query to verify reachability and token validity."""
try:
resp = requests.post(
self._url,
json={"query": _HEALTH_QUERY},
headers=self._headers(),
verify=self._verify,
timeout=10,
allow_redirects=False,
)
if resp.status_code == 200:
return C2Health(ok=True)
return C2Health(ok=False, error=f"HTTP {resp.status_code}")
except requests.RequestException as exc:
return C2Health(ok=False, error=str(exc))
def list_callbacks(self) -> list[C2Callback]:
"""Return active callbacks from Mythic (filtered server-side: active=true)."""
try:
data = self._post({"query": _CALLBACKS_QUERY})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
callbacks_raw = data.get("data", {}).get("callback", [])
return [
C2Callback(
display_id=cb["display_id"],
active=cb["active"],
host=cb.get("host") or "",
user=cb.get("user") or "",
domain=cb.get("domain") or "",
last_checkin=cb.get("last_checkin") or "",
)
for cb in callbacks_raw
]
def create_task(
self,
callback_display_id: int,
command: str,
params: str | None = None,
) -> int:
"""Issue a task on a callback; return Mythic task display_id."""
try:
data = self._post({
"query": _CREATE_TASK_MUTATION,
"variables": {
"callback_id": callback_display_id,
"command": command,
"params": params or "",
},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
task_data = data.get("data", {}).get("createTask", {})
error_msg = task_data.get("error")
if error_msg:
raise C2Error(error_msg)
return int(task_data["display_id"])
def get_task(self, task_display_id: int) -> C2TaskStatus:
"""Return current task status from Mythic."""
try:
data = self._post({
"query": _GET_TASK_QUERY,
"variables": {"display_id": task_display_id},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
rows = data.get("data", {}).get("task", [])
if not rows:
raise C2Error(f"task {task_display_id} not found in Mythic")
row = rows[0]
completed_at: datetime | None = None
if row.get("completed") and row.get("timestamp"):
try:
completed_at = datetime.fromisoformat(
row["timestamp"].replace("Z", "+00:00")
)
except ValueError:
completed_at = None
return C2TaskStatus(
display_id=row["display_id"],
status=row["status"],
completed=bool(row.get("completed", False)),
completed_at=completed_at,
command=row.get("command_name") or None,
)
def get_task_output(self, task_display_id: int) -> str:
"""Return decoded, concatenated output for a task."""
try:
data = self._post({
"query": _GET_TASK_OUTPUT_QUERY,
"variables": {"display_id": task_display_id},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
rows = data.get("data", {}).get("response", [])
return "".join(
decode_response_text(r["response_text"])
for r in rows
if r.get("response_text")
)
def list_callback_tasks(
self,
callback_display_id: int,
page: int = 1,
page_size: int = 25,
) -> C2TaskPage:
"""Return a paginated, most-recent-first history of tasks for a callback."""
offset = (page - 1) * page_size
try:
data = self._post({
"query": _LIST_CALLBACK_TASKS_QUERY,
"variables": {
"callback_display_id": callback_display_id,
"limit": page_size,
"offset": offset,
},
})
count_data = self._post({
"query": _COUNT_CALLBACK_TASKS_QUERY,
"variables": {"callback_display_id": callback_display_id},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
rows = data.get("data", {}).get("task", [])
total: int = (
count_data.get("data", {})
.get("task_aggregate", {})
.get("aggregate", {})
.get("count", 0)
)
items = [
C2HistoricalTask(
display_id=r["display_id"],
command=r.get("command_name") or "",
params=r.get("params") or None,
status=r.get("status") or "",
completed=bool(r.get("completed", False)),
timestamp=r.get("timestamp") or None,
)
for r in rows
]
return C2TaskPage(items=items, total=total, page=page, page_size=page_size)

View File

@@ -0,0 +1,40 @@
"""Fernet-based encryption service for sensitive fields.
Key is read from the MIMIC_ENCRYPTION_KEY env var (Fernet base64-urlsafe 32-byte key).
When the key is absent the service raises C2Disabled so callers can return 503.
The key is never logged or returned in any response.
"""
from __future__ import annotations
import os
from cryptography.fernet import Fernet, InvalidToken
class C2Disabled(Exception):
"""Raised when MIMIC_ENCRYPTION_KEY is not set."""
def _get_fernet() -> Fernet:
key = os.environ.get("MIMIC_ENCRYPTION_KEY")
if not key:
raise C2Disabled("C2 disabled: MIMIC_ENCRYPTION_KEY not set")
return Fernet(key.encode() if isinstance(key, str) else key)
def encrypt(plaintext: str) -> str:
"""Encrypt *plaintext* and return a Fernet token (str)."""
f = _get_fernet()
return f.encrypt(plaintext.encode()).decode()
def decrypt(ciphertext: str) -> str:
"""Decrypt a Fernet token and return the plaintext string."""
f = _get_fernet()
try:
return f.decrypt(ciphertext.encode()).decode()
except InvalidToken as exc:
raise ValueError("Invalid ciphertext") from exc
__all__ = ["C2Disabled", "encrypt", "decrypt"]

View File

@@ -98,6 +98,19 @@ def _maybe_activate_engagement(simulation: Simulation) -> None:
db.session.add(engagement)
def promote_to_in_progress(simulation: Simulation) -> None:
"""Transition simulation pending → in_progress if it is currently pending.
Also advances the engagement planned → active via _maybe_activate_engagement.
No-op when the simulation is already in any other status.
Caller must commit.
"""
if simulation.status == SimulationStatus.PENDING:
simulation.status = SimulationStatus.IN_PROGRESS
simulation.updated_at = datetime.now(UTC)
_maybe_activate_engagement(simulation)
def apply_patch(
simulation: Simulation, payload: dict[str, Any], user: User
) -> tuple[Any, int] | None:

View File

@@ -0,0 +1,67 @@
"""create c2_config and c2_task tables
Revision ID: 0006
Revises: 0005
Create Date: 2026-06-10 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"c2_config",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"engagement_id",
sa.Integer(),
sa.ForeignKey("engagements.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("api_token_encrypted", sa.Text(), nullable=False),
sa.Column("verify_tls", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_c2_config_engagement_id", "c2_config", ["engagement_id"])
op.create_table(
"c2_task",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"simulation_id",
sa.Integer(),
sa.ForeignKey("simulations.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("mythic_task_display_id", sa.Integer(), nullable=False),
sa.Column("callback_display_id", sa.Integer(), nullable=False),
sa.Column("command", sa.Text(), nullable=False),
sa.Column("params", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("completed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("output", sa.Text(), nullable=True),
sa.Column(
"source",
sa.Enum("mimic", "import", name="c2task_source"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("completed_at", sa.DateTime(), nullable=True),
)
def downgrade() -> None:
op.drop_table("c2_task")
op.drop_index("ix_c2_config_engagement_id", "c2_config")
op.drop_table("c2_config")
# Remove the enum type (no-op on SQLite, required on Postgres)
sa.Enum(name="c2task_source").drop(op.get_bind(), checkfirst=True)

View File

@@ -0,0 +1,30 @@
"""add mapping_applied column to c2_task
Revision ID: 0007
Revises: 0006
Create Date: 2026-06-10 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("c2_task") as batch_op:
batch_op.add_column(
sa.Column(
"mapping_applied",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
with op.batch_alter_table("c2_task") as batch_op:
batch_op.drop_column("mapping_applied")

View File

@@ -4,6 +4,10 @@ Flask-Migrate==4.0.7
PyJWT==2.9.0
argon2-cffi==23.1.0
weasyprint>=60.0
cryptography==44.0.0
requests==2.32.3
pytest==8.3.3
ruff==0.6.9
mypy==1.11.2
types-requests==2.32.0.20240914
requests-mock==1.12.1

View File

@@ -0,0 +1,30 @@
"""Tests for the FakeAdapter deterministic in-memory implementation."""
from __future__ import annotations
from backend.app.services.c2.adapter import C2Health
from backend.app.services.c2.fake import FakeAdapter
class TestFakeAdapterTestConnection:
def test_returns_ok_true(self):
adapter = FakeAdapter()
health = adapter.test_connection()
assert isinstance(health, C2Health)
assert health.ok is True
assert health.error is None
def test_list_callbacks_returns_list(self):
adapter = FakeAdapter()
callbacks = adapter.list_callbacks()
assert isinstance(callbacks, list)
assert len(callbacks) >= 1
def test_list_callbacks_fields(self):
adapter = FakeAdapter()
cb = adapter.list_callbacks()[0]
assert hasattr(cb, "display_id")
assert hasattr(cb, "active")
assert hasattr(cb, "host")
assert hasattr(cb, "user")
assert hasattr(cb, "domain")
assert hasattr(cb, "last_checkin")

View File

@@ -0,0 +1,62 @@
"""FakeAdapter M2 tests — list_callbacks shape, create_task monotonicity."""
from __future__ import annotations
from backend.app.services.c2.fake import FakeAdapter
class TestFakeAdapterListCallbacks:
def test_returns_three_callbacks(self):
adapter = FakeAdapter()
callbacks = adapter.list_callbacks()
assert len(callbacks) == 3
def test_all_active(self):
adapter = FakeAdapter()
for cb in adapter.list_callbacks():
assert cb.active is True
def test_display_ids_are_1_2_3(self):
adapter = FakeAdapter()
ids = [cb.display_id for cb in adapter.list_callbacks()]
assert ids == [1, 2, 3]
def test_pinned_last_checkin_format(self):
adapter = FakeAdapter()
for cb in adapter.list_callbacks():
assert cb.last_checkin.startswith("2026-06-10")
def test_callbacks_have_host_user_domain(self):
adapter = FakeAdapter()
for cb in adapter.list_callbacks():
assert cb.host
assert cb.user
assert cb.domain
class TestFakeAdapterCreateTask:
def test_returns_monotonic_ids_from_1000(self):
adapter = FakeAdapter()
id1 = adapter.create_task(1, "whoami")
id2 = adapter.create_task(1, "ipconfig")
assert id1 == 1000
assert id2 == 1001
def test_separate_instances_start_at_1000_independently(self):
a1 = FakeAdapter()
a2 = FakeAdapter()
assert a1.create_task(1, "cmd") == 1000
assert a2.create_task(1, "cmd") == 1000
def test_stores_command_and_callback(self):
adapter = FakeAdapter()
tid = adapter.create_task(callback_display_id=2, command="ls", params="-la")
task = adapter._tasks[tid]
assert task["command"] == "ls"
assert task["params"] == "-la"
assert task["callback_display_id"] == 2
def test_initial_status_submitted(self):
adapter = FakeAdapter()
tid = adapter.create_task(1, "hostname")
assert adapter._tasks[tid]["status"] == "submitted"
assert adapter._tasks[tid]["completed"] is False

View File

@@ -0,0 +1,110 @@
"""FakeAdapter M3 state-progression tests — get_task and get_task_output."""
from __future__ import annotations
import pytest
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.fake import FakeAdapter
@pytest.fixture()
def adapter() -> FakeAdapter:
return FakeAdapter()
@pytest.fixture()
def adapter_with_task(adapter: FakeAdapter) -> tuple[FakeAdapter, int]:
tid = adapter.create_task(callback_display_id=1, command="whoami")
return adapter, tid
class TestFakeAdapterGetTaskProgression:
def test_first_call_returns_submitted(self, adapter_with_task):
a, tid = adapter_with_task
status = a.get_task(tid)
assert status.status == "submitted"
assert status.completed is False
def test_second_call_returns_completed(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid) # first call
status = a.get_task(tid) # second call
assert status.status == "completed"
assert status.completed is True
def test_subsequent_calls_stay_completed(self, adapter_with_task):
a, tid = adapter_with_task
for _ in range(5):
a.get_task(tid)
status = a.get_task(tid)
assert status.completed is True
def test_unknown_task_id_returns_submitted_on_first_call(self, adapter):
"""A task ID not created by this instance still goes through submitted→completed."""
status = adapter.get_task(9999)
assert status.display_id == 9999
assert status.status == "submitted"
assert status.completed is False
def test_call_counters_are_per_task(self, adapter):
"""Two tasks have independent state — completing one does not affect the other."""
t1 = adapter.create_task(callback_display_id=1, command="whoami")
t2 = adapter.create_task(callback_display_id=1, command="ipconfig")
# Advance t1 to completed via two calls.
adapter.get_task(t1)
adapter.get_task(t1)
# t2 first call should still be submitted.
s2 = adapter.get_task(t2)
assert s2.status == "submitted"
assert s2.completed is False
def test_instances_are_isolated(self):
"""Per-instance counters — different FakeAdapter instances don't share state."""
a1 = FakeAdapter()
a2 = FakeAdapter()
t1 = a1.create_task(1, "cmd")
t2 = a2.create_task(1, "cmd")
a1.get_task(t1)
a1.get_task(t1) # a1's task is now completed
# a2's task with same display_id (both start at 1000) should be independent.
assert t1 == t2 == 1000
s2 = a2.get_task(t2)
assert s2.status == "submitted"
class TestFakeAdapterGetTaskOutput:
def test_raises_before_completed(self, adapter_with_task):
a, tid = adapter_with_task
with pytest.raises(C2Error, match="task not completed"):
a.get_task_output(tid)
def test_raises_after_first_get_task_call_only(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid) # first call — still submitted
with pytest.raises(C2Error, match="task not completed"):
a.get_task_output(tid)
def test_returns_output_after_completed(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid)
a.get_task(tid) # now completed
output = a.get_task_output(tid)
assert "whoami" in output
assert str(tid) in output
def test_output_format(self, adapter):
tid = adapter.create_task(callback_display_id=2, command="ipconfig /all")
adapter.get_task(tid)
adapter.get_task(tid)
output = adapter.get_task_output(tid)
assert output == f"output for task {tid}: ipconfig /all\n"
def test_unknown_task_raises_c2error(self, adapter):
"""Task ID never created and never polled — not completed → C2Error."""
with pytest.raises(C2Error, match="task not completed"):
adapter.get_task_output(9999)

View File

@@ -0,0 +1,75 @@
"""FakeAdapter M4 tests — list_callback_tasks pagination."""
from __future__ import annotations
import pytest
from backend.app.services.c2.adapter import C2HistoricalTask
from backend.app.services.c2.fake import FakeAdapter
@pytest.fixture()
def adapter() -> FakeAdapter:
return FakeAdapter()
class TestFakeAdapterListCallbackTasks:
def test_callback_1_returns_12_total(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert page.total == 12
def test_callback_2_returns_0_tasks(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=2, page=1, page_size=25)
assert page.total == 0
assert page.items == []
def test_callback_3_returns_5_tasks(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=3, page=1, page_size=25)
assert page.total == 5
assert len(page.items) == 5
def test_items_are_c2_historical_task_instances(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=5)
for item in page.items:
assert isinstance(item, C2HistoricalTask)
def test_pagination_page1(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=5)
assert len(page.items) == 5
assert page.page == 1
assert page.page_size == 5
def test_pagination_page2(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=2, page_size=5)
assert len(page.items) == 5
assert page.page == 2
def test_pagination_last_page_partial(self, adapter):
# 12 tasks, page_size=5 → page 3 has 2 items.
page = adapter.list_callback_tasks(callback_display_id=1, page=3, page_size=5)
assert len(page.items) == 2
assert page.total == 12
def test_pagination_beyond_range_returns_empty(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=99, page_size=25)
assert len(page.items) == 0
assert page.total == 12
def test_history_is_deterministic_across_instances(self):
a1 = FakeAdapter()
a2 = FakeAdapter()
p1 = a1.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
p2 = a2.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert [t.display_id for t in p1.items] == [t.display_id for t in p2.items]
def test_completed_and_submitted_mix(self, adapter):
"""Callback 1 has alternating completed/submitted tasks (even=completed)."""
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=12)
completed = [t for t in page.items if t.completed]
submitted = [t for t in page.items if not t.completed]
assert len(completed) == 6
assert len(submitted) == 6
def test_unknown_callback_returns_empty(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=999, page=1, page_size=25)
assert page.total == 0
assert page.items == []

View File

@@ -0,0 +1,151 @@
"""MythicAdapter unit tests — mocked HTTP with requests-mock."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_TOKEN = "fake-api-token"
@pytest.fixture()
def adapter():
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
class TestMythicAdapterListCallbacks:
def test_returns_callbacks_from_graphql(self, adapter):
payload = {
"data": {
"callback": [
{
"id": 1,
"display_id": 1,
"active": True,
"host": "HOST-01",
"user": "jdoe",
"domain": "LAB",
"last_checkin": "2026-06-10T00:00:00Z",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
callbacks = adapter.list_callbacks()
assert len(callbacks) == 1
assert callbacks[0].display_id == 1
assert callbacks[0].host == "HOST-01"
assert callbacks[0].user == "jdoe"
def test_sends_apitoken_header(self, adapter):
payload = {"data": {"callback": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
adapter.list_callbacks()
sent_headers = m.last_request.headers
assert sent_headers.get("apitoken") == _TOKEN
def test_verify_tls_flag_passed(self):
"""Adapter with verify_tls=True should pass verify=True to requests."""
adapter_tls = MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=True)
payload = {"data": {"callback": []}}
# requests-mock intercepts before TLS — just confirm no error path triggered.
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
callbacks = adapter_tls.list_callbacks()
assert isinstance(callbacks, list)
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("connection refused"))
with pytest.raises(C2Error):
adapter.list_callbacks()
def test_http_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=500, text="Internal Server Error")
with pytest.raises(C2Error):
adapter.list_callbacks()
class TestMythicAdapterCreateTask:
def test_returns_display_id_on_success(self, adapter):
payload = {
"data": {
"createTask": {
"id": 42,
"display_id": 7,
"error": None,
}
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
tid = adapter.create_task(callback_display_id=1, command="whoami")
assert tid == 7
def test_sends_apitoken_header(self, adapter):
payload = {"data": {"createTask": {"id": 1, "display_id": 1, "error": None}}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
adapter.create_task(1, "cmd")
sent_headers = m.last_request.headers
assert sent_headers.get("apitoken") == _TOKEN
def test_error_field_raises_c2error(self, adapter):
payload = {
"data": {
"createTask": {
"id": None,
"display_id": None,
"error": "callback not found",
}
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
with pytest.raises(C2Error, match="callback not found"):
adapter.create_task(1, "whoami")
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.Timeout("timeout"))
with pytest.raises(C2Error):
adapter.create_task(1, "whoami")
class TestMythicAdapterErrorSanitization:
def test_connection_error_message_does_not_contain_url(self, adapter):
"""C2Error message must not expose the configured Mythic URL."""
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError(
f"HTTPSConnectionPool(host='{_BASE_URL}', port=7443): Max retries exceeded"
))
with pytest.raises(C2Error) as exc_info:
adapter.list_callbacks()
assert _BASE_URL not in str(exc_info.value)
assert "ConnectionError" in str(exc_info.value)
class TestMythicAdapterNoRedirects:
def test_does_not_follow_redirect(self, adapter):
"""Adapter must not follow HTTP redirects (allow_redirects=False)."""
with rm_module.Mocker() as m:
# Simulate a redirect response; requests-mock won't auto-follow it.
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/graphql"})
# With allow_redirects=False the 301 is treated as a non-2xx → raise_for_status raises.
with pytest.raises(C2Error):
adapter.list_callbacks()
# Exactly one request was made — no follow-up to Location.
assert len(m.request_history) == 1

View File

@@ -0,0 +1,188 @@
"""MythicAdapter M3 tests — get_task and get_task_output, mocked HTTP."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_TOKEN = "fake-api-token"
@pytest.fixture()
def adapter():
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
class TestMythicAdapterGetTask:
def test_returns_status_for_incomplete_task(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"status": "processing",
"completed": False,
"timestamp": None,
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.display_id == 7
assert status.status == "processing"
assert status.completed is False
assert status.completed_at is None
def test_returns_completed_at_for_completed_task(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.completed is True
assert status.completed_at is not None
assert status.completed_at.year == 2026
def test_raises_when_task_not_found(self, adapter):
payload = {"data": {"task": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
with pytest.raises(C2Error, match="not found"):
adapter.get_task(999)
def test_sends_apitoken_header(self, adapter):
payload = {
"data": {
"task": [
{"display_id": 1, "status": "submitted", "completed": False, "timestamp": None}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
adapter.get_task(1)
assert m.last_request.headers.get("apitoken") == _TOKEN
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("refused"))
with pytest.raises(C2Error):
adapter.get_task(1)
def test_no_redirect_followed(self, adapter):
"""get_task must not follow HTTP redirects."""
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.get_task(1)
assert len(m.request_history) == 1
def test_invalid_timestamp_does_not_crash(self, adapter):
"""A malformed timestamp field falls back to completed_at=None without raising."""
payload = {
"data": {
"task": [
{
"display_id": 5,
"status": "completed",
"completed": True,
"timestamp": "not-a-date",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(5)
assert status.completed is True
assert status.completed_at is None
class TestMythicAdapterGetTaskOutput:
def test_returns_decoded_output(self, adapter):
import base64
encoded = base64.b64encode(b"Administrator\r\n").decode()
payload = {
"data": {
"response": [{"response_text": encoded}]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert "Administrator" in output
def test_concatenates_multiple_responses(self, adapter):
import base64
r1 = base64.b64encode(b"line one\n").decode()
r2 = base64.b64encode(b"line two\n").decode()
payload = {
"data": {
"response": [{"response_text": r1}, {"response_text": r2}]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert "line one" in output
assert "line two" in output
def test_returns_empty_string_when_no_responses(self, adapter):
payload = {"data": {"response": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert output == ""
def test_skips_empty_response_text(self, adapter):
import base64
encoded = base64.b64encode(b"real output").decode()
payload = {
"data": {
"response": [
{"response_text": ""},
{"response_text": encoded},
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert output == "real output"
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.Timeout("timeout"))
with pytest.raises(C2Error):
adapter.get_task_output(7)
def test_no_redirect_followed(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=302, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.get_task_output(1)
assert len(m.request_history) == 1

View File

@@ -0,0 +1,167 @@
"""MythicAdapter M4 tests — list_callback_tasks, mocked HTTP."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error, C2HistoricalTask
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_TOKEN = "fake-api-token"
@pytest.fixture()
def adapter():
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
def _task_list_payload(tasks: list[dict]) -> dict:
return {"data": {"task": tasks}}
def _count_payload(count: int) -> dict:
return {"data": {"task_aggregate": {"aggregate": {"count": count}}}}
class TestMythicAdapterListCallbackTasks:
def test_returns_tasks_from_graphql(self, adapter):
tasks_payload = _task_list_payload([
{
"display_id": 7,
"command_name": "whoami",
"params": "",
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
])
count_payload = _count_payload(1)
with rm_module.Mocker() as m:
m.post(_GQL_URL, [{"json": tasks_payload}, {"json": count_payload}])
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert page.total == 1
assert len(page.items) == 1
item = page.items[0]
assert isinstance(item, C2HistoricalTask)
assert item.display_id == 7
assert item.command == "whoami"
assert item.completed is True
def test_pagination_offset_calculation(self, adapter):
"""page=2, page_size=10 → offset=10 must be sent to Mythic."""
tasks_payload = _task_list_payload([])
count_payload = _count_payload(0)
with rm_module.Mocker() as m:
m.post(_GQL_URL, [{"json": tasks_payload}, {"json": count_payload}])
adapter.list_callback_tasks(callback_display_id=1, page=2, page_size=10)
# First request is the task list; check variables.
first_body = m.request_history[0].json()
variables = first_body.get("variables", {})
assert variables.get("offset") == 10
assert variables.get("limit") == 10
def test_sends_apitoken_header(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(0)},
])
adapter.list_callback_tasks(callback_display_id=1)
for req in m.request_history:
assert req.headers.get("apitoken") == _TOKEN
def test_empty_task_list(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(0)},
])
page = adapter.list_callback_tasks(callback_display_id=1)
assert page.total == 0
assert page.items == []
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("refused"))
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
def test_http_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=500, text="error")
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
def test_no_redirect_followed(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
# Both requests (tasks + count) should each only make one attempt.
for req in m.request_history:
assert req.method == "POST"
def test_page_and_page_size_in_response(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(50)},
])
page = adapter.list_callback_tasks(callback_display_id=1, page=3, page_size=10)
assert page.page == 3
assert page.page_size == 10
assert page.total == 50
class TestMythicAdapterGetTaskCommandField:
"""Ensure command_name is surfaced via get_task() C2TaskStatus.command."""
def test_get_task_returns_command(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"command_name": "shell",
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.command == "shell"
def test_get_task_command_none_when_missing(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"command_name": None,
"status": "submitted",
"completed": False,
"timestamp": None,
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.command is None

View File

@@ -0,0 +1,142 @@
"""Tests for GET /api/engagements/<id>/c2/callbacks."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask.testing import FlaskClient
from backend.app.services.c2.adapter import C2Error
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
class TestGetCallbacksHappyPath:
def test_returns_3_callbacks_with_fake_adapter(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 200
body = resp.get_json()
assert "callbacks" in body
assert len(body["callbacks"]) == 3
def test_callback_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
cb = resp.get_json()["callbacks"][0]
assert "display_id" in cb
assert "active" in cb
assert "host" in cb
assert "user" in cb
assert "domain" in cb
assert "last_checkin" in cb
def test_redteam_allowed(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(redteam_token),
)
assert resp.status_code == 200
class TestGetCallbacksErrorCases:
def test_404_when_no_config(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 404
def test_404_engagement_not_found(self, client: FlaskClient, admin_token: str) -> None:
resp = client.get(
"/api/engagements/9999/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 404
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(soc_token),
)
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 503
def test_502_when_adapter_raises(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self):
raise C2Error("mythic unreachable")
monkeypatch.setattr(fake_mod.FakeAdapter, "list_callbacks", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 502
assert "mythic unreachable" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,367 @@
"""Tests for C2 config CRUD endpoints.
Covers:
- GET 404 when no config exists
- PUT create (api_token required)
- PUT update with omitted token keeps old ciphertext
- GET 200 returns has_token=True, never cleartext
- DELETE 204
- Cascade delete when engagement is deleted
- RBAC: admin OK / redteam OK / SOC 403 on all 4 endpoints
- 503 guard when MIMIC_ENCRYPTION_KEY is unset
- POST /test with fake adapter
"""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.models.c2_config import C2Config
from backend.tests.conftest import auth_headers as _h
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
"""Default: key is present. Individual tests can override."""
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _put_config(
client: FlaskClient,
token: str,
eid: int,
*,
url: str = "https://c2.internal:7443",
api_token: str | None = "s3cr3t",
verify_tls: bool = True,
) -> dict:
payload: dict = {"url": url, "verify_tls": verify_tls}
if api_token is not None:
payload["api_token"] = api_token
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json=payload,
)
return resp
# ---------------------------------------------------------------------------
# GET — 404 when no config
# ---------------------------------------------------------------------------
def test_get_config_not_found(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp.status_code == 404
def test_get_config_engagement_not_found(client: FlaskClient, admin_token: str) -> None:
resp = client.get("/api/engagements/9999/c2-config", headers=_h(admin_token))
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PUT — create
# ---------------------------------------------------------------------------
def test_put_rejects_http_scheme(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, admin_token, eng["id"], url="http://c2.internal:7443")
assert resp.status_code == 400
assert "https" in resp.get_json().get("error", "").lower()
def test_put_rejects_missing_hostname(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
# urlparse("https://:7443") produces an empty hostname
resp = _put_config(client, admin_token, eng["id"], url="https://:7443")
assert resp.status_code == 400
assert "hostname" in resp.get_json().get("error", "").lower()
def test_put_creates_config(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, admin_token, eng["id"])
assert resp.status_code == 200
body = resp.get_json()
assert body["has_token"] is True
assert body["url"] == "https://c2.internal:7443"
assert body["verify_tls"] is True
def test_put_create_requires_api_token(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, admin_token, eng["id"], api_token=None)
assert resp.status_code == 400
def test_put_create_requires_url(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.put(
f"/api/engagements/{eng['id']}/c2-config",
headers=_h(admin_token),
json={"api_token": "tok", "verify_tls": True},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# PUT — update, omitting api_token preserves old ciphertext
# ---------------------------------------------------------------------------
def test_put_update_omits_token_keeps_old(
app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"], api_token="original-token")
# Read ciphertext from DB before update.
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
old_cipher = cfg.api_token_encrypted
# Update URL, omit api_token.
resp = _put_config(
client, admin_token, eng["id"],
url="https://new.internal:7443", api_token=None,
)
assert resp.status_code == 200
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
assert cfg.api_token_encrypted == old_cipher
assert cfg.url == "https://new.internal:7443"
def test_put_update_with_token_replaces_ciphertext(
app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"], api_token="original-token")
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
old_cipher = cfg.api_token_encrypted
_put_config(client, admin_token, eng["id"], api_token="new-token")
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
assert cfg.api_token_encrypted != old_cipher
# ---------------------------------------------------------------------------
# GET — 200, has_token=True, never cleartext
# ---------------------------------------------------------------------------
def test_get_config_returns_has_token_not_cleartext(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"], api_token="s3cr3t")
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp.status_code == 200
body = resp.get_json()
assert body["has_token"] is True
assert "api_token" not in body
assert "api_token_encrypted" not in body
assert "s3cr3t" not in str(body)
def test_get_config_verify_tls_default_true(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp.get_json()["verify_tls"] is True
# ---------------------------------------------------------------------------
# DELETE — 204
# ---------------------------------------------------------------------------
def test_delete_config_204(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.delete(
f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)
)
assert resp.status_code == 204
# Subsequent GET returns 404.
resp2 = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp2.status_code == 404
def test_delete_config_not_found(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.delete(
f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# CASCADE — delete engagement removes config
# ---------------------------------------------------------------------------
def test_cascade_delete_engagement_removes_config(
app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
with app.app_context():
assert C2Config.query.filter_by(engagement_id=eng["id"]).count() == 1
client.delete(f"/api/engagements/{eng['id']}", headers=_h(admin_token))
with app.app_context():
assert C2Config.query.filter_by(engagement_id=eng["id"]).count() == 0
# ---------------------------------------------------------------------------
# RBAC matrix
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("method,path_suffix", [
("GET", "/c2-config"),
("PUT", "/c2-config"),
("DELETE", "/c2-config"),
("POST", "/c2-config/test"),
])
def test_soc_gets_403(
client: FlaskClient, admin_token: str, soc_token: str,
method: str, path_suffix: str,
) -> None:
eng = _make_engagement(client, admin_token)
url = f"/api/engagements/{eng['id']}{path_suffix}"
resp = getattr(client, method.lower())(url, headers=_h(soc_token), json={})
assert resp.status_code == 403
@pytest.mark.parametrize("method,path_suffix", [
("GET", "/c2-config"),
("DELETE", "/c2-config"),
("POST", "/c2-config/test"),
])
def test_redteam_gets_allowed(
client: FlaskClient, admin_token: str, redteam_token: str,
method: str, path_suffix: str,
) -> None:
eng = _make_engagement(client, admin_token)
# Ensure config exists for GET/DELETE/test.
_put_config(client, admin_token, eng["id"])
url = f"/api/engagements/{eng['id']}{path_suffix}"
resp = getattr(client, method.lower())(url, headers=_h(redteam_token), json={})
# Not 403 and not 401.
assert resp.status_code not in (401, 403)
def test_redteam_can_put_config(
client: FlaskClient, admin_token: str, redteam_token: str,
) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, redteam_token, eng["id"])
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# 503 guard when MIMIC_ENCRYPTION_KEY is unset
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("method,path_suffix", [
("GET", "/c2-config"),
("PUT", "/c2-config"),
("DELETE", "/c2-config"),
("POST", "/c2-config/test"),
])
def test_503_when_key_unset(
monkeypatch,
client: FlaskClient,
admin_token: str,
method: str,
path_suffix: str,
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
url = f"/api/engagements/{eng['id']}{path_suffix}"
resp = getattr(client, method.lower())(url, headers=_h(admin_token), json={
"url": "https://c2", "api_token": "tok", "verify_tls": True,
})
assert resp.status_code == 503
assert "MIMIC_ENCRYPTION_KEY" in resp.get_json().get("error", "")
# ---------------------------------------------------------------------------
# POST /test — connectivity check via fake adapter
# ---------------------------------------------------------------------------
def test_post_test_returns_ok_true(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.post(
f"/api/engagements/{eng['id']}/c2-config/test",
headers=_h(admin_token),
json={},
)
assert resp.status_code == 200
body = resp.get_json()
assert body["ok"] is True
assert body["error"] is None
def test_post_test_no_config_returns_404(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.post(
f"/api/engagements/{eng['id']}/c2-config/test",
headers=_h(admin_token),
json={},
)
assert resp.status_code == 404

View File

@@ -0,0 +1,324 @@
"""Tests for POST /api/simulations/<id>/c2/execute."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models.c2_task import C2Task
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.c2.adapter import C2Error
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim Alpha"},
)
assert resp.status_code == 201
return resp.get_json()
def _execute(
client: FlaskClient,
token: str,
sid: int,
commands: list,
callback_display_id: int = 1,
):
return client.post(
f"/api/simulations/{sid}/c2/execute",
headers=_h(token),
json={"callback_display_id": callback_display_id, "commands": commands},
)
def _advance_to_in_progress(client: FlaskClient, token: str, sid: int) -> None:
client.patch(
f"/api/simulations/{sid}",
headers=_h(token),
json={"name": "Sim Alpha"},
)
def _advance_to_review_required(client: FlaskClient, token: str, sid: int) -> None:
_advance_to_in_progress(client, token, sid)
client.post(
f"/api/simulations/{sid}/transition",
headers=_h(token),
json={"to": "review_required"},
)
def _advance_to_done(client: FlaskClient, redteam_token: str, soc_token: str, sid: int) -> None:
_advance_to_review_required(client, redteam_token, sid)
client.post(
f"/api/simulations/{sid}/transition",
headers=_h(soc_token),
json={"to": "done"},
)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestExecuteHappyPath:
def test_two_commands_create_two_tasks(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami", "ipconfig"])
assert resp.status_code == 200
body = resp.get_json()
assert len(body["tasks"]) == 2
assert body["tasks"][0]["command"] == "whoami"
assert body["tasks"][1]["command"] == "ipconfig"
with app.app_context():
rows = C2Task.query.filter_by(simulation_id=sim["id"]).all()
assert len(rows) == 2
def test_task_response_shape(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["hostname"])
task = resp.get_json()["tasks"][0]
assert "id" in task
assert "mythic_task_display_id" in task
assert "command" in task
assert "status" in task
assert "completed" in task
def test_pending_sim_transitions_to_in_progress(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
assert sim["status"] == "pending"
_execute(client, admin_token, sim["id"], ["whoami"])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_already_in_progress_stays_in_progress(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_in_progress(client, admin_token, sim["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 200
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_review_required_sim_still_allowed(
self, app: Flask, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_review_required(client, admin_token, sim["id"])
resp = _execute(client, admin_token, sim["id"], ["net use"])
assert resp.status_code == 200
# Status stays review_required — no regression to in_progress.
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.REVIEW_REQUIRED
def test_redteam_can_execute(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, redteam_token, sim["id"], ["whoami"])
assert resp.status_code == 200
def test_mythic_task_display_id_stored(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.mythic_task_display_id == 1000 # FakeAdapter starts at 1000
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
class TestExecuteValidation:
def test_400_empty_commands(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], [])
assert resp.status_code == 400
def test_400_non_string_command(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/execute",
headers=_h(admin_token),
json={"callback_display_id": 1, "commands": [42]},
)
assert resp.status_code == 400
def test_400_missing_callback_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/execute",
headers=_h(admin_token),
json={"commands": ["whoami"]},
)
assert resp.status_code == 400
def test_409_done_sim(
self,
client: FlaskClient,
admin_token: str,
soc_token: str,
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_done(client, admin_token, soc_token, sim["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 409
assert "done" in resp.get_json().get("error", "").lower()
def test_404_simulation_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _execute(client, admin_token, 9999, ["whoami"])
assert resp.status_code == 404
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 404
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, soc_token, sim["id"], ["whoami"])
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 503
def test_502_adapter_error(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, callback_display_id, command, params=None):
raise C2Error("task queue full")
monkeypatch.setattr(fake_mod.FakeAdapter, "create_task", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 502
assert "task queue full" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,215 @@
"""Tests for GET /api/engagements/<id>/c2/callbacks/<cid>/history."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask.testing import FlaskClient
from backend.app.services.c2.adapter import C2Error
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _history(client: FlaskClient, token: str, eid: int, cid: int, **params):
return client.get(
f"/api/engagements/{eid}/c2/callbacks/{cid}/history",
headers=_h(token),
query_string=params,
)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestHistoryHappyPath:
def test_returns_200(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 200
def test_response_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
body = resp.get_json()
assert "tasks" in body
assert "total" in body
assert "page" in body
assert "page_size" in body
def test_task_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
task = resp.get_json()["tasks"][0]
for field in ("display_id", "command", "params", "status", "completed", "timestamp"):
assert field in task, f"missing field: {field}"
def test_default_page_is_1(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["page"] == 1
def test_default_page_size_is_25(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["page_size"] == 25
def test_callback_1_has_12_total(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["total"] == 12
def test_callback_2_has_0_tasks(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 2)
body = resp.get_json()
assert body["total"] == 0
assert body["tasks"] == []
def test_pagination_page_size_applied(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=1, page_size=5)
body = resp.get_json()
assert len(body["tasks"]) == 5
assert body["page_size"] == 5
def test_redteam_can_view_history(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, redteam_token, eng["id"], 1)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Validation errors
# ---------------------------------------------------------------------------
class TestHistoryValidation:
def test_400_page_size_too_large(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=101)
assert resp.status_code == 400
def test_400_page_zero(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=0)
assert resp.status_code == 400
def test_400_page_size_zero(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=0)
assert resp.status_code == 400
def test_400_page_negative(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=-1)
assert resp.status_code == 400
def test_400_page_size_100_is_ok(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=100)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Authorization / error cases
# ---------------------------------------------------------------------------
class TestHistoryErrors:
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, soc_token, eng["id"], 1)
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 503
def test_404_engagement_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _history(client, admin_token, 9999, 1)
assert resp.status_code == 404
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 404
def test_502_adapter_error(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, callback_display_id, page=1, page_size=25):
raise C2Error("upstream error")
monkeypatch.setattr(fake_mod.FakeAdapter, "list_callback_tasks", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 502
assert "upstream error" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,437 @@
"""Tests for POST /api/simulations/<id>/c2/import."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models.c2_task import C2Task, C2TaskSource
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.c2.adapter import C2Error, C2TaskStatus
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim Alpha"},
)
assert resp.status_code == 201
return resp.get_json()
def _import(client: FlaskClient, token: str, sid: int, task_display_ids: list, callback_display_id: int = 1):
return client.post(
f"/api/simulations/{sid}/c2/import",
headers=_h(token),
json={"callback_display_id": callback_display_id, "task_display_ids": task_display_ids},
)
def _make_completed_get_task(monkeypatch, command: str = "whoami"):
"""Patch FakeAdapter.get_task to return completed=True with a command."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
command=command,
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
def _output(self, task_display_id: int) -> str:
return f"output for {task_display_id}"
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
def _advance_to_review_required(client, token, sid):
client.patch(f"/api/simulations/{sid}", headers=_h(token), json={"name": "Sim Alpha"})
client.post(f"/api/simulations/{sid}/transition", headers=_h(token), json={"to": "review_required"})
def _advance_to_done(client, admin_token, soc_token, sid):
_advance_to_review_required(client, admin_token, sid)
client.post(f"/api/simulations/{sid}/transition", headers=_h(soc_token), json={"to": "done"})
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestImportHappyPath:
def test_imports_two_completed_tasks(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch, command="whoami")
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100, 101])
assert resp.status_code == 200
body = resp.get_json()
assert body["imported"] == 2
assert body["skipped"] == 0
with app.app_context():
rows = C2Task.query.filter_by(simulation_id=sim["id"]).all()
assert len(rows) == 2
def test_imported_tasks_have_source_import(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.source == C2TaskSource.IMPORT
def test_completed_tasks_get_mapping_applied(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.mapping_applied is True
def test_idempotent_import_counts_skipped(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
# First import.
_import(client, admin_token, sim["id"], [100, 101])
# Second import with one overlap.
resp = _import(client, admin_token, sim["id"], [100, 102])
body = resp.get_json()
assert body["imported"] == 1
assert body["skipped"] == 1
def test_auto_transition_pending_to_in_progress(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
assert sim["status"] == "pending"
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_no_transition_when_already_in_progress(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
# Advance to in_progress manually.
client.patch(
f"/api/simulations/{sim['id']}",
headers=_h(admin_token),
json={"name": "Sim Alpha"},
)
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_no_transition_when_review_required(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_review_required(client, admin_token, sim["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.REVIEW_REQUIRED
def test_incomplete_task_stored_without_mapping(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""An incomplete task is stored as-is; mapping_applied stays False."""
from backend.app.services.c2 import fake as fake_mod
def _submitted(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="submitted",
completed=False,
command="shell",
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _submitted)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [200])
assert resp.status_code == 200
assert resp.get_json()["imported"] == 1
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.completed is False
assert task.mapping_applied is False
assert task.output is None
def test_command_stored_from_get_task(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Command field on the stored row comes from adapter.get_task().command."""
_make_completed_get_task(monkeypatch, command="net user /domain")
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.command == "net user /domain"
def test_redteam_can_import(
self, monkeypatch, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, redteam_token, sim["id"], [100])
assert resp.status_code == 200
def test_source_field_is_import_in_tasks_listing(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Imported tasks appear with source='import' in GET /c2/tasks response."""
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
resp = client.get(
f"/api/simulations/{sim['id']}/c2/tasks",
headers=_h(admin_token),
)
assert resp.status_code == 200
task = resp.get_json()["tasks"][0]
assert task["source"] == "import"
def test_no_transition_when_all_skipped(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""If imported=0 (all skipped), do not transition pending→in_progress."""
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100]) # first import
_import(client, admin_token, sim["id"], []) # empty — should 400 before this matters
# Reset to pending state via a fresh sim (can't undo, just verify the 0-skipped case).
# We test: importing same task again = skipped=1, imported=0 → no double-transition.
resp = _import(client, admin_token, sim["id"], [100])
body = resp.get_json()
assert body["imported"] == 0
assert body["skipped"] == 1
# ---------------------------------------------------------------------------
# Validation errors
# ---------------------------------------------------------------------------
class TestImportValidation:
def test_400_empty_task_display_ids(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [])
assert resp.status_code == 400
def test_400_non_int_task_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/import",
headers=_h(admin_token),
json={"callback_display_id": 1, "task_display_ids": ["not-an-int"]},
)
assert resp.status_code == 400
def test_400_missing_callback_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/import",
headers=_h(admin_token),
json={"task_display_ids": [100]},
)
assert resp.status_code == 400
def test_409_done_simulation(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_done(client, admin_token, soc_token, sim["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 409
def test_404_simulation_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _import(client, admin_token, 9999, [100])
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Authorization / error cases
# ---------------------------------------------------------------------------
class TestImportErrors:
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, soc_token, sim["id"], [100])
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 503
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 404
def test_502_adapter_error_on_get_task(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, task_display_id: int) -> C2TaskStatus:
raise C2Error("Mythic unreachable")
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 502
assert "Mythic unreachable" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,208 @@
"""Unit tests for apply_task_to_simulation() mapping helper — §0.11 contract."""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import MagicMock
from backend.app.services.c2.mapping import apply_task_to_simulation
def _make_task(
command: str = "whoami",
output: str | None = "root",
mapping_applied: bool = False,
completed_at: datetime | None = None,
) -> MagicMock:
task = MagicMock()
task.command = command
task.output = output
task.mapping_applied = mapping_applied
task.completed_at = completed_at
return task
def _make_sim(
execution_result: str | None = None,
executed_at: datetime | None = None,
commands: str | None = None,
) -> MagicMock:
sim = MagicMock()
sim.execution_result = execution_result
sim.executed_at = executed_at
sim.commands = commands
sim.updated_at = None
return sim
class TestExecutionResult:
def test_first_task_produces_command_block(self):
task = _make_task(command="whoami", output="root")
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ whoami\nroot\n"
def test_second_task_appended_with_block_separator(self):
"""Two tasks → two '$ command\noutput\n' blocks separated by a single newline."""
sim = _make_sim()
t1 = _make_task(command="whoami", output="root")
t2 = _make_task(command="hostname", output="lab-1")
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.execution_result == "$ whoami\nroot\n$ hostname\nlab-1\n"
def test_no_double_blank_line_when_existing_ends_with_newline(self):
"""If existing result already ends with \n, no extra blank line is inserted."""
sim = _make_sim(execution_result="$ id\nuid=0\n")
task = _make_task(command="hostname", output="lab-1")
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ id\nuid=0\n$ hostname\nlab-1\n"
def test_empty_output_skips_block_but_marks_applied(self):
task = _make_task(output="")
sim = _make_sim(execution_result="$ id\nuid=0\n")
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ id\nuid=0\n"
assert task.mapping_applied is True
def test_none_output_skips_block_but_marks_applied(self):
task = _make_task(output=None)
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert sim.execution_result is None
assert task.mapping_applied is True
def test_command_with_empty_string_produces_dollar_header(self):
"""Empty command → block header is '$ \n<output>\n' (consistent, not suppressed)."""
task = _make_task(command="", output="some output")
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ \nsome output\n" or sim.execution_result == "$ \nsome output\n"
class TestExecutedAt:
def test_sets_executed_at_from_task_when_null(self):
ts = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC)
task = _make_task(completed_at=ts)
sim = _make_sim(executed_at=None)
apply_task_to_simulation(task, sim)
assert sim.executed_at == ts
def test_does_not_overwrite_existing_executed_at(self):
original_ts = datetime(2026, 6, 1, 0, 0, 0, tzinfo=UTC)
later_ts = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC)
task = _make_task(completed_at=later_ts)
sim = _make_sim(executed_at=original_ts)
apply_task_to_simulation(task, sim)
assert sim.executed_at == original_ts
def test_executed_at_stays_null_when_task_completed_at_is_none(self):
task = _make_task(completed_at=None)
sim = _make_sim(executed_at=None)
apply_task_to_simulation(task, sim)
assert sim.executed_at is None
def test_first_task_sets_executed_at_second_does_not_overwrite(self):
ts1 = datetime(2026, 6, 10, 10, 0, 0, tzinfo=UTC)
ts2 = datetime(2026, 6, 10, 11, 0, 0, tzinfo=UTC)
t1 = _make_task(command="whoami", output="root", completed_at=ts1)
t2 = _make_task(command="hostname", output="lab-1", completed_at=ts2)
sim = _make_sim(executed_at=None)
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.executed_at == ts1
class TestCommandsDedup:
def test_appends_command_to_empty_commands(self):
task = _make_task(command="whoami", output="root")
sim = _make_sim(commands=None)
apply_task_to_simulation(task, sim)
assert sim.commands == "whoami"
def test_appends_second_distinct_command(self):
sim = _make_sim(commands=None)
t1 = _make_task(command="whoami", output="root")
t2 = _make_task(command="hostname", output="lab-1")
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.commands == "whoami\nhostname"
def test_deduplicates_repeated_command(self):
sim = _make_sim(commands=None)
t1 = _make_task(command="whoami", output="root")
t2 = _make_task(command="whoami", output="root2")
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.commands == "whoami"
def test_dedup_is_case_and_whitespace_stripped(self):
sim = _make_sim(commands="whoami")
task = _make_task(command=" whoami ", output="root")
apply_task_to_simulation(task, sim)
# " whoami ".strip() == "whoami" which is already present → no append.
assert sim.commands == "whoami"
def test_empty_command_not_appended(self):
task = _make_task(command="", output="output")
sim = _make_sim(commands=None)
apply_task_to_simulation(task, sim)
# task.command is falsy → commands block skipped.
assert sim.commands is None
class TestIdempotency:
def test_no_op_when_mapping_already_applied(self):
task = _make_task(output="root", mapping_applied=True)
sim = _make_sim(execution_result="existing")
apply_task_to_simulation(task, sim)
assert sim.execution_result == "existing"
def test_always_marks_mapping_applied(self):
task = _make_task(output="root")
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert task.mapping_applied is True
def test_updated_at_is_set(self):
task = _make_task(output="root")
sim = _make_sim()
before = datetime.now(UTC)
apply_task_to_simulation(task, sim)
assert sim.updated_at is not None
assert sim.updated_at >= before

View File

@@ -0,0 +1,375 @@
"""Tests for GET /api/simulations/<id>/c2/tasks — poll-on-read endpoint."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models.c2_task import C2Task
from backend.app.models.simulation import Simulation
from backend.app.services.c2.adapter import C2Error, C2TaskStatus
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim Alpha"},
)
assert resp.status_code == 201
return resp.get_json()
def _execute(client: FlaskClient, token: str, sid: int, commands: list, callback_display_id: int = 1):
return client.post(
f"/api/simulations/{sid}/c2/execute",
headers=_h(token),
json={"callback_display_id": callback_display_id, "commands": commands},
)
def _list_tasks(client: FlaskClient, token: str, sid: int):
return client.get(
f"/api/simulations/{sid}/c2/tasks",
headers=_h(token),
)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestListTasksHappyPath:
def test_returns_empty_list_when_no_tasks(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
assert resp.get_json()["tasks"] == []
def test_returns_task_after_execute(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
tasks = resp.get_json()["tasks"]
assert len(tasks) == 1
assert tasks[0]["command"] == "whoami"
def test_task_shape(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["hostname"])
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
for field in ("id", "mythic_task_display_id", "callback_display_id",
"command", "params", "status", "completed", "output",
"source", "mapping_applied", "created_at", "completed_at"):
assert field in task, f"missing field: {field}"
assert task["source"] == "mimic"
def test_first_poll_returns_submitted(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
# First GET — FakeAdapter.get_task() first call → submitted.
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
assert task["status"] == "submitted"
assert task["completed"] is False
def test_poll_marks_completed_when_adapter_returns_completed(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""When adapter.get_task returns completed=True the task is updated in DB."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
assert task["completed"] is True
assert task["status"] == "completed"
def test_output_populated_after_completion(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Output is fetched and stored when task transitions to completed."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
def _output(self, task_display_id: int) -> str:
return f"whoami result for task {task_display_id}"
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
assert task["output"] is not None
assert "whoami" in task["output"]
def test_mapping_applied_set_after_completion(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
_list_tasks(client, admin_token, sim["id"])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.mapping_applied is True
def test_execution_result_updated_on_simulation(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
def _output(self, task_display_id: int) -> str:
return f"WORKSTATION-01\\whoami output {task_display_id}"
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
_list_tasks(client, admin_token, sim["id"])
with app.app_context():
updated_sim = db.session.get(Simulation, sim["id"])
assert updated_sim is not None
assert updated_sim.execution_result is not None
assert "whoami" in updated_sim.execution_result
def test_completed_task_not_re_polled(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Once task.completed=True in DB, subsequent GETs skip polling (no re-poll)."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
call_count = {"n": 0}
def _completed(self, task_display_id: int) -> C2TaskStatus:
call_count["n"] += 1
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
_list_tasks(client, admin_token, sim["id"]) # 1st GET — marks task completed (1 call)
first_count = call_count["n"]
_list_tasks(client, admin_token, sim["id"]) # 2nd GET — task already completed, skip poll
# get_task should NOT have been called again on the 2nd GET.
assert call_count["n"] == first_count, "completed task should not be re-polled"
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
task = resp.get_json()["tasks"][0]
assert task["completed"] is True
def test_redteam_can_list_tasks(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, redteam_token, sim["id"])
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
class TestListTasksErrors:
def test_404_simulation_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _list_tasks(client, admin_token, 9999)
assert resp.status_code == 404
def test_403_soc_forbidden(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, soc_token, sim["id"])
assert resp.status_code == 403
def test_503_no_encryption_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 503
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 404
def test_adapter_error_during_poll_is_tolerated(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""If get_task raises C2Error during poll, the task is skipped (best-effort)."""
from backend.app.services.c2 import fake as fake_mod
def _boom(self, task_display_id: int):
raise C2Error("upstream unavailable")
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
# Should still return 200 with the task (un-refreshed status).
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
tasks = resp.get_json()["tasks"]
assert len(tasks) == 1
# Status is stale (not updated due to error) — still "submitted".
assert tasks[0]["status"] == "submitted"

View File

@@ -0,0 +1,52 @@
"""Tests for the Fernet crypto service."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from backend.app.services.crypto import C2Disabled, decrypt, encrypt
@pytest.fixture()
def fernet_key(monkeypatch) -> str:
key = Fernet.generate_key().decode()
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", key)
return key
@pytest.fixture()
def no_key(monkeypatch):
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
class TestEncryptDecrypt:
def test_round_trip(self, fernet_key):
plaintext = "s3cr3t-api-token"
ciphertext = encrypt(plaintext)
assert ciphertext != plaintext
assert decrypt(ciphertext) == plaintext
def test_different_tokens_for_same_input(self, fernet_key):
# Fernet tokens are non-deterministic (random IV).
t1 = encrypt("same")
t2 = encrypt("same")
assert t1 != t2
assert decrypt(t1) == decrypt(t2) == "same"
def test_decrypt_invalid_ciphertext(self, fernet_key):
with pytest.raises(ValueError):
decrypt("not-valid-fernet-token")
class TestKeyAbsent:
def test_encrypt_raises_c2disabled(self, no_key):
with pytest.raises(C2Disabled):
encrypt("anything")
def test_decrypt_raises_c2disabled(self, no_key):
with pytest.raises(C2Disabled):
decrypt("anything")
def test_c2disabled_message(self, no_key):
with pytest.raises(C2Disabled, match="MIMIC_ENCRYPTION_KEY"):
encrypt("x")

View File

@@ -0,0 +1,199 @@
"""Migration round-trip test for 0006_c2_layer.
Verifies that upgrade() creates c2_config and c2_task with the expected schema,
and that downgrade() removes both tables cleanly.
Uses the resolved-path pattern (derives path from __file__) to avoid the
hardcoded-path regression documented in lessons.md Sprint 4.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect, text
def _load_migration():
versions_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
path = versions_dir / "0006_c2_layer.py"
spec = importlib.util.spec_from_file_location("migration_0006", path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
return mod
def _fresh_engine():
"""In-memory SQLite with the tables that 0006 depends on already present."""
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
created_at DATETIME NOT NULL
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE engagements (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
start_date DATE NOT NULL,
end_date DATE,
status TEXT NOT NULL DEFAULT 'planned',
created_at DATETIME NOT NULL,
created_by_id INTEGER NOT NULL REFERENCES users(id)
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE simulations (
id INTEGER PRIMARY KEY,
engagement_id INTEGER NOT NULL REFERENCES engagements(id),
name TEXT NOT NULL,
techniques JSON NOT NULL DEFAULT '[]',
tactic_ids JSON NOT NULL DEFAULT '[]',
description TEXT,
commands TEXT,
prerequisites TEXT,
executed_at DATETIME,
execution_result TEXT,
log_source TEXT,
logs TEXT,
soc_comment TEXT,
incident_number TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
updated_at DATETIME,
created_by_id INTEGER NOT NULL REFERENCES users(id)
)
"""
)
)
return engine
def _run_upgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.upgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
def _run_downgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.downgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
class TestMigration0006Upgrade:
def test_c2_config_table_created(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
assert "c2_config" in insp.get_table_names()
def test_c2_task_table_created(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
assert "c2_task" in insp.get_table_names()
def test_c2_config_columns(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_config")}
assert {"id", "engagement_id", "url", "api_token_encrypted",
"verify_tls", "created_at", "updated_at"} <= cols
def test_c2_config_unique_constraint_on_engagement_id(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
# Insert a user and engagement first.
with engine.begin() as conn:
conn.execute(text(
"INSERT INTO users (id, username, password_hash, role, created_at) "
"VALUES (1, 'u', 'h', 'admin', '2026-01-01')"
))
conn.execute(text(
"INSERT INTO engagements (id, name, start_date, status, created_at, created_by_id) "
"VALUES (1, 'Op', '2026-01-01', 'planned', '2026-01-01', 1)"
))
conn.execute(text(
"INSERT INTO c2_config (engagement_id, url, api_token_encrypted, verify_tls, created_at) "
"VALUES (1, 'https://c2', 'tok', 1, '2026-01-01')"
))
# Second insert on same engagement_id must fail.
try:
conn.execute(text(
"INSERT INTO c2_config (engagement_id, url, api_token_encrypted, verify_tls, created_at) "
"VALUES (1, 'https://c2b', 'tok2', 1, '2026-01-01')"
))
raised = False
except Exception:
raised = True
assert raised, "UNIQUE constraint on c2_config.engagement_id must be enforced"
def test_c2_task_columns(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert {"id", "simulation_id", "mythic_task_display_id", "callback_display_id",
"command", "params", "status", "completed", "output", "source",
"created_at", "completed_at"} <= cols
class TestMigration0006Downgrade:
def test_downgrade_removes_c2_config(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
assert "c2_config" not in insp.get_table_names()
def test_downgrade_removes_c2_task(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
assert "c2_task" not in insp.get_table_names()

View File

@@ -0,0 +1,124 @@
"""Migration round-trip test for 0007_c2_task_mapping_applied.
Verifies that upgrade() adds the mapping_applied column and downgrade() removes it.
Uses the resolved-path pattern per lessons.md Sprint 4.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect, text
def _load_migration(name: str):
versions_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
path = versions_dir / name
spec = importlib.util.spec_from_file_location(name.removesuffix(".py"), path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
return mod
def _fresh_engine_with_c2_task():
"""In-memory SQLite with c2_task already created (as left by 0006 upgrade)."""
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE c2_task (
id INTEGER PRIMARY KEY,
simulation_id INTEGER NOT NULL,
mythic_task_display_id INTEGER NOT NULL,
callback_display_id INTEGER NOT NULL,
command TEXT NOT NULL,
params TEXT,
status TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT 0,
output TEXT,
source TEXT NOT NULL,
created_at DATETIME NOT NULL,
completed_at DATETIME
)
"""))
return engine
def _run_upgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.upgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
def _run_downgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.downgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
class TestMigration0007Upgrade:
def test_mapping_applied_column_added(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
_run_upgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert "mapping_applied" in cols
def test_mapping_applied_defaults_to_false(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
# Insert a row before upgrading (no mapping_applied column yet).
with engine.begin() as conn:
conn.execute(text(
"INSERT INTO c2_task "
"(simulation_id, mythic_task_display_id, callback_display_id, "
"command, status, completed, source, created_at) "
"VALUES (1, 1000, 1, 'whoami', 'submitted', 0, 'mimic', '2026-01-01')"
))
_run_upgrade(engine, mod)
with engine.begin() as conn:
row = conn.execute(
text("SELECT mapping_applied FROM c2_task WHERE id = 1")
).fetchone()
assert row is not None
# SQLite stores booleans as 0/1.
assert row[0] == 0 or row[0] is False
class TestMigration0007Downgrade:
def test_downgrade_removes_mapping_applied(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert "mapping_applied" not in cols
def test_downgrade_does_not_drop_other_columns(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert {"id", "simulation_id", "command", "status", "completed"} <= cols

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

@@ -0,0 +1,94 @@
import { apiClient } from './client';
import type {
C2CallbackHistoryResponse,
C2Config,
C2ConfigInput,
C2TestResult,
C2CallbacksResponse,
C2ExecuteInput,
C2ExecuteResponse,
C2ImportInput,
C2ImportResponse,
C2TasksResponse,
} 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;
}
export async function getC2Tasks(simulationId: number): Promise<C2TasksResponse> {
const { data } = await apiClient.get<C2TasksResponse>(
`/simulations/${simulationId}/c2/tasks`,
);
return data;
}
export async function listCallbackHistory(
engagementId: number,
callbackDisplayId: number,
params: { page: number; pageSize: number },
): Promise<C2CallbackHistoryResponse> {
const { data } = await apiClient.get<C2CallbackHistoryResponse>(
`/engagements/${engagementId}/c2/callbacks/${callbackDisplayId}/history`,
{ params: { page: params.page, page_size: params.pageSize } },
);
return data;
}
export async function importC2(
simulationId: number,
input: C2ImportInput,
): Promise<C2ImportResponse> {
const { data } = await apiClient.post<C2ImportResponse>(
`/simulations/${simulationId}/c2/import`,
input,
);
return data;
}

View File

@@ -154,3 +154,101 @@ 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[];
}
// Thin shape returned by the execute endpoint
export interface C2ExecuteTask {
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: C2ExecuteTask[];
}
// Full shape returned by the tasks list endpoint (M3)
export interface C2TaskListItem {
id: number;
mythic_task_display_id: number;
callback_display_id: number;
command: string;
params: string | null;
status: string;
completed: boolean;
output: string | null;
mapping_applied: boolean;
source: 'mimic' | 'import';
created_at: string;
completed_at: string | null;
}
export interface C2TasksResponse {
tasks: C2TaskListItem[];
}
// Callback history (M4)
export interface C2HistoryTask {
display_id: number;
command: string;
status: string;
completed: boolean;
completed_at: string | null;
created_at: string;
}
export interface C2CallbackHistoryResponse {
tasks: C2HistoryTask[];
total: number;
page: number;
page_size: number;
}
// Import (M4)
export interface C2ImportInput {
callback_display_id: number;
task_display_ids: number[];
}
export interface C2ImportResponse {
imported: number;
skipped: number;
}

View File

@@ -0,0 +1,95 @@
import { extractApiError } from '@/api/client';
import type { C2Callback } from '@/api/types';
interface C2CallbackPickerProps {
callbacks: C2Callback[];
isLoading: boolean;
isError: boolean;
error: unknown;
selectedId: number | null;
onSelect: (id: number) => void;
rowTestId?: string;
}
export function C2CallbackPicker({
callbacks,
isLoading,
isError,
error,
selectedId,
onSelect,
rowTestId = 'c2-callback-row',
}: C2CallbackPickerProps): JSX.Element {
if (isLoading) {
return <p className="text-[14px] text-graphite">Loading callbacks</p>;
}
if (isError) {
return (
<p className="text-[14px] text-bloom-deep">
Could not load callbacks: {extractApiError(error, 'Unknown error')}
</p>
);
}
if (callbacks.length === 0) {
return <p className="text-[14px] text-graphite">No callbacks available.</p>;
}
return (
<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={rowTestId}
onClick={() => onSelect(cb.display_id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(cb.display_id);
}
}}
tabIndex={0}
role="button"
className={`cursor-pointer border-b border-hairline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
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={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${
cb.active
? 'bg-primary-soft text-primary-deep'
: 'bg-cloud text-graphite border border-hairline'
}`}
>
{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">{cb.last_checkin}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

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,27 @@
// Dedicated badge for Mythic task statuses — separate from simulation status badges.
// submitted / processed → primary-soft (in-flight)
// completed → success-soft
// error* / fail* → warn-soft (task-level issue, not system error)
// anything else → cloud / graphite (unknown/neutral)
interface C2TaskStatusBadgeProps {
status: string;
}
function badgeClass(status: string): string {
const s = status.toLowerCase();
if (s === 'completed') return 'bg-success-soft text-success';
if (s.startsWith('error') || s.startsWith('fail')) return 'bg-warn-soft text-warn';
if (s === 'submitted' || s === 'processed') return 'bg-primary-soft text-primary-deep';
return 'bg-cloud text-graphite border border-hairline';
}
export function C2TaskStatusBadge({ status }: C2TaskStatusBadgeProps): JSX.Element {
return (
<span
className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${badgeClass(status)}`}
>
{status}
</span>
);
}

View File

@@ -0,0 +1,135 @@
import { Fragment, useState } from 'react';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { useC2Tasks } from '@/hooks/useC2';
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
interface C2TasksPanelProps {
simulationId: number;
}
export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
const query = useC2Tasks(simulationId, { enabled: true });
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const tasks = query.data?.tasks ?? [];
const isRefreshing = query.isFetching && !query.isLoading;
function toggleExpand(id: number, completed: boolean) {
if (!completed) return;
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
return (
<div
data-testid="c2-tasks-panel"
className="card-product flex flex-col gap-md"
>
<div className="flex items-center justify-between">
<h3 className="text-[16px] font-medium text-ink">C2 Tasks</h3>
{isRefreshing && (
<span
data-testid="c2-task-refresh-indicator"
className="text-[12px] text-graphite"
>
Refreshing
</span>
)}
</div>
{tasks.length === 0 ? (
<div className="border border-hairline rounded-none px-md py-md">
<p className="text-[14px] text-graphite">
No C2 tasks yet. Use Execute via C2 to launch commands.
</p>
</div>
) : (
<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 w-8" aria-label="Expand" />
<th className="px-md py-sm text-left font-medium text-ink">Task</th>
<th className="px-md py-sm text-left font-medium text-ink">Command</th>
<th className="px-md py-sm text-left font-medium text-ink">Source</th>
<th className="px-md py-sm text-left font-medium text-ink">Status</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed at</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => {
const isExpanded = expandedIds.has(task.id);
const canExpand = task.completed && Boolean(task.output);
return (
<Fragment key={task.id}>
<tr
data-testid="c2-task-row"
onClick={() => toggleExpand(task.id, task.completed)}
onKeyDown={(e) => {
if (canExpand && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
toggleExpand(task.id, task.completed);
}
}}
tabIndex={canExpand ? 0 : undefined}
role={canExpand ? 'button' : undefined}
aria-expanded={canExpand ? isExpanded : undefined}
className={`border-b border-hairline ${canExpand ? 'cursor-pointer hover:bg-cloud focus:outline-none focus-visible:ring-2 focus-visible:ring-primary' : ''}`}
>
<td className="px-md py-sm text-graphite">
{canExpand ? (
isExpanded ? (
<ChevronDown size={14} aria-hidden />
) : (
<ChevronRight size={14} aria-hidden />
)
) : null}
</td>
<td className="px-md py-sm font-mono">#{task.mythic_task_display_id}</td>
<td
className="px-md py-sm font-mono max-w-[200px] truncate"
title={task.command}
>
{task.command}
</td>
<td className="px-md py-sm">
<span className="badge-pill-outline">
{task.source === 'mimic' ? 'MIMIC' : 'IMPORT'}
</span>
</td>
<td className="px-md py-sm">
<C2TaskStatusBadge status={task.status} />
</td>
<td className="px-md py-sm font-mono text-graphite">
{task.completed_at ?? '—'}
</td>
</tr>
{isExpanded && task.output && (
<tr className="border-b border-hairline bg-cloud">
<td colSpan={6} className="px-md py-sm">
<pre
data-testid="c2-task-output"
className="font-mono text-[12px] whitespace-pre-wrap text-ink"
>
{task.output}
</pre>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { useState } from 'react';
import { extractApiError } from '@/api/client';
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
import { C2CallbackPicker } from './C2CallbackPicker';
import { useToast } from '@/hooks/useToast';
interface ExecuteViaC2ModalProps {
simulationId: number;
engagementId: number;
initialCommands: string;
onClose: () => void;
}
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 = 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 picker */}
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span>
<C2CallbackPicker
callbacks={callbacks}
isLoading={callbacksQuery.isLoading}
isError={callbacksQuery.isError}
error={callbacksQuery.error}
selectedId={selectedId}
onSelect={setSelectedId}
rowTestId="c2-callback-row"
/>
</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

@@ -74,7 +74,7 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
{open ? (
<div
className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-none z-20 min-w-[160px]"
className="absolute right-0 top-full mt-xxs bg-paper border border-hairline rounded-none z-20 min-w-[160px]"
role="menu"
>
{FORMATS.map(({ label, value }) => (

View File

@@ -0,0 +1,252 @@
import { useState } from 'react';
import { extractApiError } from '@/api/client';
import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2';
import { C2CallbackPicker } from './C2CallbackPicker';
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
import { useToast } from '@/hooks/useToast';
const PAGE_SIZE = 25;
interface ImportC2HistoryModalProps {
simulationId: number;
engagementId: number;
onClose: () => void;
}
export function ImportC2HistoryModal({
simulationId,
engagementId,
onClose,
}: ImportC2HistoryModalProps): JSX.Element {
const { push } = useToast();
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
const importMutation = useImportC2(simulationId);
const [selectedCallbackId, setSelectedCallbackId] = useState<number | null>(null);
const [page, setPage] = useState(1);
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
const [submitError, setSubmitError] = useState<string | null>(null);
const historyQuery = useC2CallbackHistory(engagementId, selectedCallbackId, {
page,
pageSize: PAGE_SIZE,
enabled: selectedCallbackId !== null,
});
const historyTasks = historyQuery.data?.tasks ?? [];
const total = historyQuery.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const callbacks = callbacksQuery.data?.callbacks ?? [];
function handleCallbackSelect(id: number) {
setSelectedCallbackId(id);
setPage(1);
setCheckedIds(new Set());
}
function toggleCheck(displayId: number) {
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(displayId)) {
next.delete(displayId);
} else {
next.add(displayId);
}
return next;
});
}
const canImport = checkedIds.size > 0 && selectedCallbackId !== null;
const onImport = async () => {
if (!canImport) return;
setSubmitError(null);
try {
const result = await importMutation.mutateAsync({
callback_display_id: selectedCallbackId,
task_display_ids: Array.from(checkedIds),
});
const msg =
result.skipped > 0
? `Imported ${result.imported} task(s), ${result.skipped} already attached`
: `Imported ${result.imported} task(s)`;
push(msg, 'success');
onClose();
} catch (err) {
setSubmitError(extractApiError(err, 'Could not import tasks'));
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="c2-import-modal-title"
data-testid="c2-import-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-4xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
<h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink">
Import C2 history
</h2>
{/* Step 1: callback picker */}
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span>
<C2CallbackPicker
callbacks={callbacks}
isLoading={callbacksQuery.isLoading}
isError={callbacksQuery.isError}
error={callbacksQuery.error}
selectedId={selectedCallbackId}
onSelect={handleCallbackSelect}
rowTestId="c2-import-callback-row"
/>
</div>
{/* Step 2: history table (shown once a callback is selected) */}
{selectedCallbackId !== null && (
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">
Task history{' '}
{total > 0 && (
<span className="text-graphite font-normal">({total} total)</span>
)}
</span>
{historyQuery.isLoading && (
<p className="text-[14px] text-graphite">Loading history</p>
)}
{historyQuery.isError && (
<p className="text-[14px] text-bloom-deep">
{extractApiError(historyQuery.error, 'Could not load history')}
</p>
)}
{!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && (
<p className="text-[14px] text-graphite">No task history for this callback.</p>
)}
{historyTasks.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 w-8" aria-label="Select" />
<th className="px-md py-sm text-left font-medium text-ink">#</th>
<th className="px-md py-sm text-left font-medium text-ink">Command</th>
<th className="px-md py-sm text-left font-medium text-ink">Status</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed</th>
<th className="px-md py-sm text-left font-medium text-ink">Timestamp</th>
</tr>
</thead>
<tbody>
{historyTasks.map((task) => (
<tr
key={task.display_id}
data-testid="c2-history-row"
onClick={() => toggleCheck(task.display_id)}
className="cursor-pointer border-b border-hairline hover:bg-cloud"
>
<td className="px-md py-sm">
<input
type="checkbox"
data-testid="c2-history-row-checkbox"
checked={checkedIds.has(task.display_id)}
onChange={() => toggleCheck(task.display_id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 accent-primary"
/>
</td>
<td className="px-md py-sm font-mono">{task.display_id}</td>
<td
className="px-md py-sm font-mono max-w-[200px] truncate"
title={task.command}
>
{task.command}
</td>
<td className="px-md py-sm">
<C2TaskStatusBadge status={task.status} />
</td>
<td className="px-md py-sm text-[14px]">
{task.completed ? 'Yes' : 'No'}
</td>
<td className="px-md py-sm font-mono text-graphite">
{task.created_at}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center gap-md text-[14px] text-graphite">
<button
type="button"
data-testid="c2-history-prev"
className="btn-outline-ink"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Prev
</button>
<span>
Page {page} of {totalPages}
</span>
<button
type="button"
data-testid="c2-history-next"
className="btn-outline-ink"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</button>
{checkedIds.size > 0 && (
<span className="ml-auto text-ink">
{checkedIds.size} selected
</span>
)}
</div>
</>
)}
</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-import-submit-btn"
className="btn-primary"
onClick={onImport}
disabled={!canImport || importMutation.isPending}
>
{importMutation.isPending ? 'Importing…' : 'Import selected'}
</button>
<button
type="button"
className="btn-outline-ink"
onClick={onClose}
disabled={importMutation.isPending}
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@@ -170,7 +170,7 @@ export function MitreMatrixModal({
role="dialog"
aria-modal="true"
aria-labelledby="matrix-modal-title"
className="relative bg-canvas rounded-none border border-hairline max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col"
className="relative bg-paper rounded-none border border-hairline max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col"
style={{ width: '1400px' }}
onKeyDown={handleKeyDown}
>

View File

@@ -107,7 +107,7 @@ export function MitreTechniquePicker({
/>
{open && (
<div className="absolute z-20 w-full mt-xxs bg-canvas border border-steel rounded-none overflow-hidden">
<div className="absolute z-20 w-full mt-xxs bg-paper border border-steel rounded-none overflow-hidden">
{isFetching && (
<div className="px-md py-sm text-[14px] text-graphite">Searching</div>
)}

View File

@@ -93,7 +93,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
{open ? (
<div
className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-none z-20 min-w-[180px]"
className="absolute right-0 top-full mt-xxs bg-paper border border-hairline rounded-none z-20 min-w-[180px]"
role="menu"
>
<button

152
frontend/src/hooks/useC2.ts Normal file
View File

@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
deleteC2Config,
executeC2,
getC2Config,
getC2Tasks,
importC2,
listCallbackHistory,
listCallbacks,
putC2Config,
testC2Config,
} from '@/api/c2';
import type {
C2ConfigInput,
C2ExecuteInput,
C2ImportInput,
C2TasksResponse,
} from '@/api/types';
function c2ConfigKey(engagementId: number) {
return ['c2-config', engagementId] as const;
}
function c2CallbacksKey(engagementId: number) {
return ['c2-callbacks', engagementId] as const;
}
function c2TasksKey(simulationId: number) {
return ['c2-tasks', simulationId] as const;
}
function c2HistoryKey(engagementId: number, callbackDisplayId: number, page: number, pageSize: number) {
return ['c2-history', engagementId, callbackDisplayId, page, pageSize] 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 useC2Tasks(simulationId: number | undefined, options?: { enabled?: boolean }) {
const enabled =
typeof simulationId === 'number' &&
!Number.isNaN(simulationId) &&
(options?.enabled !== false);
return useQuery({
queryKey: simulationId ? c2TasksKey(simulationId) : ['c2-tasks', 'none'],
queryFn: () => getC2Tasks(simulationId as number),
enabled,
// Poll every 2500 ms while any task is incomplete; stop when all done.
refetchInterval: (query: { state: { data?: C2TasksResponse } }) =>
query.state.data?.tasks?.some((t) => !t.completed) ? 2500 : false,
});
}
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'] });
qc.invalidateQueries({ queryKey: c2TasksKey(simulationId) });
},
});
}
export function useC2CallbackHistory(
engagementId: number | undefined,
callbackDisplayId: number | null,
options?: { page?: number; pageSize?: number; enabled?: boolean },
) {
const page = options?.page ?? 1;
const pageSize = options?.pageSize ?? 25;
const enabled =
typeof engagementId === 'number' &&
!Number.isNaN(engagementId) &&
callbackDisplayId !== null &&
(options?.enabled !== false);
return useQuery({
queryKey:
engagementId && callbackDisplayId !== null
? c2HistoryKey(engagementId, callbackDisplayId, page, pageSize)
: ['c2-history', 'none'],
queryFn: () =>
listCallbackHistory(engagementId as number, callbackDisplayId as number, {
page,
pageSize,
}),
enabled,
});
}
export function useImportC2(simulationId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: C2ImportInput) => importC2(simulationId, input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: c2TasksKey(simulationId) });
qc.invalidateQueries({ queryKey: simulationKey(simulationId) });
},
});
}

View File

@@ -7,10 +7,12 @@ import {
useEngagement,
usePatchEngagement,
} from '@/hooks/useEngagements';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/useToast';
import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { C2ConfigCard } from '@/components/C2ConfigCard';
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
{ value: 'planned', label: 'Planned' },
@@ -50,6 +52,7 @@ export function EngagementFormPage(): JSX.Element {
const numericId = id ? Number(id) : undefined;
const navigate = useNavigate();
const { push } = useToast();
const { canEditEngagements } = useAuth();
const detail = useEngagement(editing ? numericId : undefined);
const createMutation = useCreateEngagement();
@@ -121,7 +124,7 @@ export function EngagementFormPage(): JSX.Element {
const submitting = createMutation.isPending || patchMutation.isPending;
return (
<div className="flex flex-col gap-xl max-w-2xl">
<div className="flex flex-col gap-xl">
<header>
<h1 className="text-[32px] font-medium leading-none">
{editing ? 'Edit engagement' : 'New engagement'}
@@ -133,87 +136,99 @@ export function EngagementFormPage(): JSX.Element {
</p>
</header>
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
<TextInput
id="eng-name"
name="name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
</FormField>
<FormField label="Description" htmlFor="eng-description">
<TextArea
id="eng-description"
name="description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</FormField>
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
<FormField
label="Start date"
htmlFor="eng-start"
required
error={errors.start_date}
>
<div
className={
editing && canEditEngagements
? 'grid grid-cols-1 lg:grid-cols-2 gap-xl items-start'
: 'max-w-2xl'
}
>
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
<TextInput
id="eng-start"
type="date"
name="start_date"
value={form.start_date}
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
id="eng-name"
name="name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
</FormField>
<FormField
label="End date"
htmlFor="eng-end"
hint="Leave empty to clear / leave open-ended"
error={errors.end_date}
>
<TextInput
id="eng-end"
type="date"
name="end_date"
value={form.end_date}
onChange={(e) => setForm({ ...form, end_date: e.target.value })}
<FormField label="Description" htmlFor="eng-description">
<TextArea
id="eng-description"
name="description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</FormField>
</div>
<FormField label="Status" htmlFor="eng-status" required>
<Select
id="eng-status"
name="status"
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value as EngagementStatus })}
options={STATUS_OPTIONS}
/>
</FormField>
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
<FormField
label="Start date"
htmlFor="eng-start"
required
error={errors.start_date}
>
<TextInput
id="eng-start"
type="date"
name="start_date"
value={form.start_date}
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
required
/>
</FormField>
{submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">
{submitError}
<FormField
label="End date"
htmlFor="eng-end"
hint="Leave empty to clear / leave open-ended"
error={errors.end_date}
>
<TextInput
id="eng-end"
type="date"
name="end_date"
value={form.end_date}
onChange={(e) => setForm({ ...form, end_date: e.target.value })}
/>
</FormField>
</div>
) : null}
<div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'}
</button>
<Link
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
className="btn-outline-ink"
>
Cancel
</Link>
</div>
</form>
<FormField label="Status" htmlFor="eng-status" required>
<Select
id="eng-status"
name="status"
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value as EngagementStatus })}
options={STATUS_OPTIONS}
/>
</FormField>
{submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</div>
) : null}
<div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'}
</button>
<Link
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
className="btn-outline-ink"
>
Cancel
</Link>
</div>
</form>
{editing && numericId && canEditEngagements && (
<C2ConfigCard engagementId={numericId} />
)}
</div>
</div>
);
}

View File

@@ -12,12 +12,16 @@ import {
useTransitionSimulation,
useUpdateSimulation,
} from '@/hooks/useSimulations';
import { useC2Config, useC2Tasks } from '@/hooks/useC2';
import { FormField, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
import { C2TasksPanel } from '@/components/C2TasksPanel';
interface RedteamFormState {
name: string;
@@ -61,6 +65,20 @@ export function SimulationFormPage(): JSX.Element {
const { push } = useToast();
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 c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, {
enabled: !isNew && canEditRT,
});
const hasTasks = (c2TasksQuery.data?.tasks?.length ?? 0) > 0;
// Show panel when: has C2 config (so Execute button is visible) OR already has tasks
const showTasksPanel = !isNew && canEditRT && (hasC2Config || hasTasks);
const detail = useSimulation(isNew ? undefined : simulationId);
const createMutation = useCreateSimulation(engagementId ?? 0);
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
@@ -72,6 +90,8 @@ export function SimulationFormPage(): JSX.Element {
const [nameError, setNameError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showC2Modal, setShowC2Modal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
useEffect(() => {
if (!isNew && detail.data) {
@@ -109,7 +129,6 @@ export function SimulationFormPage(): JSX.Element {
// US-18: Done = fully read-only, Reopen only
const isDone = status === 'done';
const canEditRT = isAdmin || isRedteam;
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
@@ -298,8 +317,10 @@ export function SimulationFormPage(): JSX.Element {
</div>
)}
{/* 2-column grid: RT left, SOC right. Stacks vertically below lg. */}
{/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */}
<div className="grid gap-xl lg:grid-cols-2 items-start">
{/* Left column: RT card + C2 tasks panel */}
<div className="flex flex-col gap-xl">
{/* Red Team card */}
<form
id="rt-form"
@@ -383,8 +404,35 @@ export function SimulationFormPage(): JSX.Element {
rows={5}
/>
</FormField>
{!isDone && canEditRT && hasC2Config && (
<div className="pt-xs flex items-center gap-md flex-wrap">
<button
type="button"
data-testid="c2-execute-btn"
className="btn-outline"
onClick={() => setShowC2Modal(true)}
>
Execute via C2
</button>
<button
type="button"
data-testid="c2-import-trigger-btn"
className="btn-outline"
onClick={() => setShowImportModal(true)}
>
Import C2 history
</button>
</div>
)}
</form>
{/* C2 tasks panel — under RT card, same left column */}
{showTasksPanel && simulationId && (
<C2TasksPanel simulationId={simulationId} />
)}
</div>{/* end left column */}
{/* SOC card */}
<form
id="soc-form"
@@ -512,6 +560,23 @@ export function SimulationFormPage(): JSX.Element {
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
{showC2Modal && simulationId && typeof engagementId === 'number' && (
<ExecuteViaC2Modal
simulationId={simulationId}
engagementId={engagementId}
initialCommands={rt.commands}
onClose={() => setShowC2Modal(false)}
/>
)}
{showImportModal && simulationId && typeof engagementId === 'number' && (
<ImportC2HistoryModal
simulationId={simulationId}
engagementId={engagementId}
onClose={() => setShowImportModal(false)}
/>
)}
</div>
);
}

View File

@@ -7,7 +7,7 @@
@layer base {
/* Light mode — default */
:root {
--color-canvas: #ffffff;
--color-canvas: #f3f5f8;
--color-paper: #ffffff;
--color-cloud: #f7f7f7;
--color-fog: #e8e8e8;
@@ -100,7 +100,7 @@
}
.btn-outline {
@apply inline-flex items-center justify-center gap-xs bg-canvas text-primary border border-primary uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
@apply inline-flex items-center justify-center gap-xs bg-paper text-primary border border-primary uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
}
.btn-outline:hover {
@apply bg-primary-soft;
@@ -110,7 +110,7 @@
}
.btn-outline-ink {
@apply inline-flex items-center justify-center gap-xs bg-canvas text-ink border border-ink uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
@apply inline-flex items-center justify-center gap-xs bg-paper text-ink border border-ink uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
}
.btn-outline-ink:hover {
@apply bg-cloud;
@@ -121,7 +121,7 @@
}
.text-input {
@apply block w-full bg-canvas text-ink rounded-none border border-steel px-md py-sm h-11 text-[16px] leading-[1.4] focus:outline-none focus:border-primary;
@apply block w-full bg-paper text-ink rounded-none border border-steel px-md py-sm h-11 text-[16px] leading-[1.4] focus:outline-none focus:border-primary;
}
/* Panel / card — hairline border, no shadow, no radius */

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';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
mock.onGet('/engagements/42/c2-config').reply(404);
});
afterEach(() => {
@@ -154,6 +155,8 @@ describe('SimulationFormPage — SOC role + pending (blocked)', () => {
mockRole = 'soc';
mock = new MockAdapter(apiClient);
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(() => {
@@ -202,6 +205,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
mockRole = 'soc';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
mock.onGet('/engagements/42/c2-config').reply(404);
});
afterEach(() => {
@@ -273,3 +277,160 @@ describe('SimulationFormPage — new simulation', () => {
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();
});
});
describe('SimulationFormPage — C2 tasks panel visibility', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('shows C2 tasks panel when c2 config exists (even with no tasks)', async () => {
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
});
it('hides C2 tasks panel when no c2 config and no tasks', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
// Wait for page data to load then confirm no panel
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
});
it('shows C2 tasks panel when tasks exist even without c2 config', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, {
tasks: [
{
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'SYSTEM',
mapping_applied: false,
source: 'import',
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
},
],
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
});
it('SOC role never sees C2 tasks panel', async () => {
mockRole = 'soc';
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
});
it('shows Import C2 history 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,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-import-trigger-btn')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import {
deleteC2Config,
executeC2,
getC2Config,
getC2Tasks,
importC2,
listCallbackHistory,
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']);
});
});
describe('getC2Tasks', () => {
it('GET /simulations/:id/c2/tasks returns tasks list', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, {
tasks: [
{
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'NT AUTHORITY\\SYSTEM',
mapping_applied: true,
source: 'mimic',
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
},
],
});
const result = await getC2Tasks(7);
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].status).toBe('completed');
expect(result.tasks[0].output).toBe('NT AUTHORITY\\SYSTEM');
expect(mock.history['get'][0].url).toBe('/simulations/7/c2/tasks');
});
});
describe('listCallbackHistory', () => {
it('GET with page/page_size params', async () => {
mock.onGet('/engagements/1/c2/callbacks/2/history').reply(200, {
tasks: [],
total: 0,
page: 1,
page_size: 25,
});
const result = await listCallbackHistory(1, 2, { page: 1, pageSize: 25 });
expect(result.total).toBe(0);
const req = mock.history['get'][0];
expect(req.url).toBe('/engagements/1/c2/callbacks/2/history');
expect(req.params).toMatchObject({ page: 1, page_size: 25 });
});
});
describe('importC2', () => {
it('POST /simulations/:id/c2/import with task_display_ids', async () => {
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 3, skipped: 1 });
const result = await importC2(7, {
callback_display_id: 2,
task_display_ids: [10, 11, 12, 13],
});
expect(result.imported).toBe(3);
expect(result.skipped).toBe(1);
const req = mock.history['post'][0];
expect(req.url).toBe('/simulations/7/c2/import');
const body = JSON.parse(req.data as string);
expect(body.callback_display_id).toBe(2);
expect(body.task_display_ids).toEqual([10, 11, 12, 13]);
});
});

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,220 @@
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 { C2TasksPanel } from '@/components/C2TasksPanel';
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 COMPLETED_TASK = {
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'NT AUTHORITY\\SYSTEM',
mapping_applied: true,
source: 'mimic' as const,
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
};
const PENDING_TASK = {
id: 2,
mythic_task_display_id: 11,
callback_display_id: 1,
command: 'ipconfig',
params: null,
status: 'submitted',
completed: false,
output: null,
mapping_applied: false,
source: 'import' as const,
created_at: '2026-06-10T10:00:10',
completed_at: null,
};
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
describe('C2TasksPanel — empty state', () => {
it('shows empty state copy when tasks array is empty', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
expect(screen.getByText(/No C2 tasks yet/i)).toBeInTheDocument();
expect(screen.queryByTestId('c2-task-row')).toBeNull();
});
});
describe('C2TasksPanel — populated rows', () => {
beforeEach(() => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [COMPLETED_TASK, PENDING_TASK] });
});
it('renders one row per task', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(2);
});
});
it('displays task command and mythic display id', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('whoami')).toBeInTheDocument();
expect(screen.getByText('ipconfig')).toBeInTheDocument();
expect(screen.getByText('#10')).toBeInTheDocument();
expect(screen.getByText('#11')).toBeInTheDocument();
});
});
it('shows MIMIC source badge for source=mimic', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('MIMIC')).toBeInTheDocument();
});
});
it('shows IMPORT source badge for source=import', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('IMPORT')).toBeInTheDocument();
});
});
it('shows completed_at timestamp for completed task', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('2026-06-10T10:00:05')).toBeInTheDocument();
});
});
it('shows em dash for null completed_at', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('—')).toBeInTheDocument();
});
});
});
describe('C2TasksPanel — expand on click', () => {
beforeEach(() => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [COMPLETED_TASK] });
});
it('output row is hidden before click', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
expect(screen.queryByTestId('c2-task-output')).toBeNull();
});
it('clicking a completed row reveals the output', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
fireEvent.click(screen.getByTestId('c2-task-row'));
expect(screen.getByTestId('c2-task-output')).toBeInTheDocument();
expect(screen.getByTestId('c2-task-output')).toHaveTextContent('NT AUTHORITY\\SYSTEM');
});
it('clicking the expanded row collapses the output', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
fireEvent.click(screen.getByTestId('c2-task-row'));
expect(screen.getByTestId('c2-task-output')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('c2-task-row'));
expect(screen.queryByTestId('c2-task-output')).toBeNull();
});
it('clicking an incomplete task row does not expand', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [PENDING_TASK] });
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
fireEvent.click(screen.getByTestId('c2-task-row'));
expect(screen.queryByTestId('c2-task-output')).toBeNull();
});
it('Enter key on completed row toggles output (a11y)', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
const row = screen.getByTestId('c2-task-row');
fireEvent.keyDown(row, { key: 'Enter' });
expect(screen.getByTestId('c2-task-output')).toBeInTheDocument();
fireEvent.keyDown(row, { key: 'Enter' });
expect(screen.queryByTestId('c2-task-output')).toBeNull();
});
it('Space key on completed row toggles output (a11y)', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
const row = screen.getByTestId('c2-task-row');
fireEvent.keyDown(row, { key: ' ' });
expect(screen.getByTestId('c2-task-output')).toBeInTheDocument();
});
});
describe('C2TasksPanel — refresh indicator', () => {
it('does not show refresh indicator on initial load', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
// During isLoading, isFetching is true but isRefreshing = isFetching && !isLoading = false
expect(screen.queryByTestId('c2-task-refresh-indicator')).toBeNull();
});
});
describe('C2TasksPanel — polling behaviour', () => {
it('does not refetch when all tasks are completed (refetchInterval false)', async () => {
// With all completed tasks, refetchInterval returns false — only one GET call expected
let callCount = 0;
mock.onGet('/simulations/7/c2/tasks').reply(() => {
callCount++;
return [200, { tasks: [COMPLETED_TASK] }];
});
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
// Wait a bit and confirm no extra fetches happened beyond initial
await new Promise((r) => setTimeout(r, 100));
expect(callCount).toBe(1);
});
});

View File

@@ -0,0 +1,171 @@
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');
});
it('Enter key on callback row selects it (a11y)', async () => {
renderModal('whoami');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
const firstRow = screen.getAllByTestId('c2-callback-row')[0];
fireEvent.keyDown(firstRow, { key: 'Enter' });
// Row is now selected → Launch button should be enabled
expect(screen.getByTestId('c2-launch-btn')).not.toBeDisabled();
});
});

View File

@@ -0,0 +1,380 @@
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 { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
import { ToastViewport } from '@/components/Toast';
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',
},
];
const HISTORY_TASKS = [
{
display_id: 10,
command: 'whoami',
status: 'completed',
completed: true,
completed_at: '2026-06-10T10:00:05',
created_at: '2026-06-10T10:00:00',
},
{
display_id: 11,
command: 'ipconfig',
status: 'completed',
completed: true,
completed_at: '2026-06-10T10:00:10',
created_at: '2026-06-10T10:00:05',
},
];
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() {
const onClose = vi.fn();
renderWithProviders(
<>
<ImportC2HistoryModal
simulationId={7}
engagementId={42}
onClose={onClose}
/>
<ToastViewport />
</>,
);
return { onClose };
}
describe('ImportC2HistoryModal — step 1: callback picker', () => {
it('renders modal with title and callback rows', async () => {
renderModal();
expect(screen.getByTestId('c2-import-modal')).toBeInTheDocument();
expect(screen.getByText('Import C2 history')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
});
it('history table is not shown before selecting a callback', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
expect(screen.queryByTestId('c2-history-row')).toBeNull();
});
it('Import button is disabled with no selection', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled();
});
});
describe('ImportC2HistoryModal — step 2: history table appears after callback select', () => {
beforeEach(() => {
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: HISTORY_TASKS,
total: 2,
page: 1,
page_size: 25,
});
});
it('shows history table after selecting a callback', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
});
});
it('shows history task commands in the table', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getByText('whoami')).toBeInTheDocument();
expect(screen.getByText('ipconfig')).toBeInTheDocument();
});
});
});
describe('ImportC2HistoryModal — multi-checkbox selection', () => {
beforeEach(() => {
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: HISTORY_TASKS,
total: 2,
page: 1,
page_size: 25,
});
});
it('Import button remains disabled with no tasks checked', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
});
expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled();
});
it('Import button becomes enabled after checking a task row', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
expect(screen.getByTestId('c2-import-submit-btn')).not.toBeDisabled();
});
it('checking via checkbox also enables Import', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row-checkbox')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-history-row-checkbox')[1]);
expect(screen.getByTestId('c2-import-submit-btn')).not.toBeDisabled();
});
it('unchecking a row disables Import when it was the only selection', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
});
// Check then uncheck
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled();
});
});
describe('ImportC2HistoryModal — pagination', () => {
it('shows Prev/Next buttons when tasks exceed page_size', async () => {
// 30 tasks, page_size 25 → 2 pages
const manyTasks = Array.from({ length: 25 }, (_, i) => ({
display_id: i + 1,
command: `cmd${i + 1}`,
status: 'completed',
completed: true,
completed_at: '2026-06-10T10:00:00',
created_at: '2026-06-10T10:00:00',
}));
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: manyTasks,
total: 30,
page: 1,
page_size: 25,
});
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getByTestId('c2-history-prev')).toBeInTheDocument();
expect(screen.getByTestId('c2-history-next')).toBeInTheDocument();
});
});
it('Prev is disabled on page 1', async () => {
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: HISTORY_TASKS,
total: 50,
page: 1,
page_size: 25,
});
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getByTestId('c2-history-prev')).toBeDisabled();
});
expect(screen.getByTestId('c2-history-next')).not.toBeDisabled();
});
});
describe('ImportC2HistoryModal — submit payload', () => {
beforeEach(() => {
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: HISTORY_TASKS,
total: 2,
page: 1,
page_size: 25,
});
});
it('sends correct callback_display_id and task_display_ids on import', async () => {
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 2, skipped: 0 });
const { onClose } = renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
});
// Select both tasks
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
fireEvent.click(screen.getAllByTestId('c2-history-row')[1]);
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
const req = mock.history['post'][0];
expect(req.url).toBe('/simulations/7/c2/import');
const body = JSON.parse(req.data as string);
expect(body.callback_display_id).toBe(1);
expect(body.task_display_ids).toContain(10);
expect(body.task_display_ids).toContain(11);
});
});
describe('ImportC2HistoryModal — toast wording', () => {
beforeEach(() => {
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: [HISTORY_TASKS[0]],
total: 1,
page: 1,
page_size: 25,
});
});
it('shows "Imported N task(s)" when skipped is 0', async () => {
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 1, skipped: 0 });
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1);
});
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
await waitFor(() => {
expect(screen.getByText('Imported 1 task(s)')).toBeInTheDocument();
});
});
it('shows skipped count when skipped > 0', async () => {
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 0, skipped: 1 });
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1);
});
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
await waitFor(() => {
expect(
screen.getByText('Imported 0 task(s), 1 already attached'),
).toBeInTheDocument();
});
});
it('shows inline error and keeps modal open on import failure', async () => {
mock.onPost('/simulations/7/c2/import').reply(500, { error: 'Import failed' });
const { onClose } = renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1);
});
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
await waitFor(() => {
expect(screen.getByText('Import failed')).toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
});
});
describe('ImportC2HistoryModal — Cancel button', () => {
it('Cancel button calls onClose', async () => {
const { onClose } = renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalled();
});
});

View File

@@ -1,95 +1,73 @@
# Sprint 8C2 Layer: Mythic Integration
# Sprint 9UI: engagement 2-col + global contrast pass
> Branch : `sprint/8-c2` · Worktree : `.claude/worktrees/sprint-8-c2` · Base : `main` @ `6ca614a`
**Base**: `sprint/8-c2` (sprint 8 not yet merged on origin/main, but its `C2ConfigCard` is the right pane).
**Scope**: frontend-only. No backend, no schema. No new features.
SPEC.md phase 2: "après que la V1 soit terminée, nous ajouterons une couche permettant
de se connecter à un C2 (mythic ou maison) afin d'exécuter des simulations au travers du C2."
---
## §0 — Binding decisions (locked with user, 2026-06-10)
## Decisions (locked)
1. **C2 target**: Mythic 3.x, behind a thin `C2Adapter` interface (keeps the door open for a custom C2 later).
2. **Scope**: FULL bidirectional — execute + near-real-time tracking + history import. User explicitly overrode the "one-shot first" recommendation; overrun risk accepted (see R1).
3. **C2 config**: per-engagement (URL + API token). Token is write-only at the API level — never returned in clear.
4. **UI anchor**: execution lives in the simulation screen (Red Team card).
5. **Realtime mechanism**: short polling. Frontend: TanStack Query `refetchInterval` 2 500 ms while any attached task is incomplete. Backend: poll-on-read — refreshes non-completed tasks from Mythic when the task list is read. No scheduler, no new infra.
6. **Secret storage**: API token Fernet-encrypted in SQLite. Key from env var `MIMIC_ENCRYPTION_KEY` (mandatory to enable C2 features, never hardcoded — OPSEC rule).
7. **History import**: BOTH — auto-attach of tasks launched from Mimic AND manual browse/select of the callback's task history.
8. **Validation**: fully mocked — no dev Mythic instance available. pytest uses a mocked adapter; e2e uses a built-in `FakeAdapter` selected via `MIMIC_C2_ADAPTER=fake`.
9. **RBAC**: C2 is a RedTeam resource — admin + redteam full access, SOC gets 403 on every C2 endpoint (same precedent as Templates and Export).
10. **Workflow tie-in**: launching a C2 execution auto-transitions the simulation `pending → in_progress` (same rule as a manual RT edit).
11. **Output mapping**: when a task completes (or is imported), its output is APPENDED to `execution_result` prefixed by a `$ <command>` header line; `executed_at` is set from the first task's timestamp if empty; the command is appended to `commands` if not already present.
1. **Engagement page** : passer en **2 colonnes** sur desktop (`lg:grid-cols-2`), `[engagement form | C2ConfigCard]`. Mobile/tablet : stack vertical (comportement actuel).
2. **Contraste global** : le problème est que `canvas` (page bg) et `paper` (card bg) sont **tous deux `#ffffff`** en light mode. Les cartes ne ressortent que par leur hairline 1px → fatigue oculaire confirmée par l'utilisateur.
3. **Fix retenu** : **tinter le canvas light** d'un neutre froid très pâle. `paper` reste blanc pur. Les cartes "lèvent" naturellement sans casser le brutalisme.
- Proposition : `canvas` light `#f3f5f8` (gris-bleu très pâle, cohérent avec l'electric blue), `paper` light `#ffffff`.
- Dark mode **inchan** (`canvas #111827` / `paper #1f2937` déjà différenciés).
4. **Pas de shadow**, pas de radius. La brutalité reste intacte — seul le contraste de surface change.
5. **Hairline** : à vérifier sur le nouveau canvas. Si nécessaire, passer `hairline` light de la valeur actuelle à un poil plus sombre pour préserver la lisibilité du bord sur tinted canvas. Mais éviter si la lecture est déjà bonne.
## §1 — Mythic adapter contract (pinned from MythicMeta/Mythic_Scripting client source)
---
- Transport: POST `https://<host>:7443/graphql`, header `apitoken: <token>` (Hasura behind nginx).
- Issue task: mutation `createTask(callback_id: <display_id>, command, params, tasking_location: "command_line")`.
- List callbacks: query on `callback` table — `id, display_id, active, host, user, domain, last_checkin`.
- Task status: query on `task` table — `display_id, status, completed` (client lib uses a subscription; we poll the query instead, per decision 5).
- Task output: query on `response` table ordered by id — `response_text` is **base64-encoded**, decode + concatenate.
- History: query `task` filtered by `callback_display_id`, paginated, with command + status + timestamps.
## Task A — EngagementFormPage 2-col
`C2Adapter` interface (`backend/app/services/c2/adapter.py`):
- `test_connection() -> C2Health`
- `list_callbacks() -> list[C2Callback]`
- `create_task(callback_display_id, command, params) -> int` (task display_id)
- `get_task(task_display_id) -> C2TaskStatus`
- `get_task_output(task_display_id) -> str` (decoded)
- `list_callback_tasks(callback_display_id, page, page_size) -> C2TaskPage`
**File** : `frontend/src/pages/EngagementFormPage.tsx`
Implementations: `MythicAdapter` (requests, `verify_tls` flag from config — lab instances run self-signed), `FakeAdapter` (deterministic in-memory data, selected by `MIMIC_C2_ADAPTER=fake`, also powers e2e).
- Remplacer le wrapper `<div className="flex flex-col gap-xl max-w-2xl">` par un container plus large + grid 2-col responsive.
- Header reste en haut, full width.
- Body : `grid grid-cols-1 lg:grid-cols-2 gap-xl` avec :
- Col gauche : `<form>` engagement (déjà en `card-product`).
- Col droite : `<C2ConfigCard>` (seulement quand `editing && canEditEngagements`).
- Si pas en edit (création) : col droite vide → garder la grid mais que la col gauche se déploie via `lg:col-span-2` (pour pas avoir un vide à droite). Acceptable alternative : `flex` + `max-w-2xl` quand non-editing.
- Pas de modif sur la logique de form, validation, mutations.
## §2 — Backend (backend-builder) — milestones M1→M4
## Task B — Contrast pass (tokens)
**Data model (1 Alembic migration):**
- `c2_config`: id, engagement_id FK **unique** (`ON DELETE CASCADE`, same precedent as `simulations.engagement_id`), url, api_token_encrypted, verify_tls bool (default true), created_at, updated_at.
- `c2_task`: id, simulation_id FK (`ON DELETE CASCADE`), mythic_task_display_id int, callback_display_id int, command text, params text nullable, status text, completed bool, output text nullable, source enum(`mimic`,`import`), created_at, completed_at nullable.
**Files** :
- `DESIGN.md` § Surface : mettre à jour `canvas` light = `#f3f5f8`, conserver `paper` light = `#ffffff`. Documenter dans la même section que "canvas tints lift paper cards in light mode without violating brutalism".
- Token source de vérité (Tailwind config ou CSS vars). Localiser et appliquer la même MAJ. Probablement `frontend/tailwind.config.js` ou un `frontend/src/styles/tokens.css` / `index.css`.
- Vérifier qu'aucun composant ne hardcode `#ffffff` pour la page bg (devrait utiliser `bg-canvas`).
- Tests CSS smoke : `bg-canvas` continue de matcher, dark mode inchangé.
**Endpoints (all admin+redteam; SOC → 403):**
- `GET/PUT/DELETE /api/engagements/<id>/c2-config` — GET returns `has_token: bool`, never the token.
- `POST /api/engagements/<id>/c2-config/test` — connectivity check via adapter.
- `GET /api/engagements/<id>/c2/callbacks` — active callbacks.
- `POST /api/simulations/<id>/c2/execute` `{callback_display_id, commands: [str]}` — one Mythic task per command, rows in `c2_task` (source=mimic), auto-transition pending→in_progress.
- `GET /api/simulations/<id>/c2/tasks` — poll-on-read: refresh incomplete tasks from Mythic; on completion fetch output and apply decision 11 mapping (idempotent, applied once).
- `GET /api/engagements/<id>/c2/callbacks/<cid>/history?page=` — paginated callback history for import.
- `POST /api/simulations/<id>/c2/import` `{task_display_ids: [int]}` — import selected tasks (source=import) + decision 11 mapping.
## Task C — Visual regression check
**Milestones:** M1 crypto service (Fernet) + migration + config CRUD + test endpoint → M2 callbacks + execute → M3 poll-on-read status/output + mapping → M4 history + import.
**Tests:** pytest with mocked adapter (~35-45 new), zero live HTTP. Crypto round-trip, RBAC 403 matrix, mapping idempotence, migration up/down.
- `pnpm vitest run` clean.
- `pnpm tsc --noEmit` clean.
- `pnpm lint` clean.
- Screenshots avant/après (au moins) :
- EngagementsListPage (cards-on-canvas)
- EngagementDetailPage
- EngagementFormPage (edit, avec C2ConfigCard à droite)
- SimulationFormPage (déjà 2-col sprint 7, vérifier que le tinted canvas n'écrase pas)
- LoginPage
- Dark mode : passe rapide pour confirmer aucune régression.
## §3 — Frontend (frontend-builder)
---
- **Engagement detail/form**: "C2 configuration" card — URL, token (password input, write-only placeholder when `has_token`), verify-TLS checkbox, [Test connection] with result feedback. Admin+redteam only.
- **SimulationFormPage RT card**: [Execute via C2] button (hidden when no config; disabled when done) → modal: callback picker table (display_id, host, user, active, last_checkin — font-mono data cells) → commands textarea prefilled from `rt.commands` → Launch.
- **C2 tasks panel** (under RT card): table of attached tasks — command (mono), status badge (semantic tokens), completed_at (mono). `refetchInterval` 2 500 ms while any incomplete; stops when all done.
- **Import history modal**: callback picker → paginated task list with checkboxes → [Import selected].
- Terminal-SOC compliance: rounded-none, zero transitions, hairline borders, mono for data only. Status colors via `success`/`warn` semantic tokens.
- Vitest ~15-20 new tests. `data-testid` on every new interactive surface for e2e.
## Sequencing
## §4 — Docs & hygiene
1. **frontend-builder** : Task A + B + C. Une seule passe, commits atomiques.
2. **design-reviewer** : revue visuelle après merge des commits builder. Focus :
- Lecture confortable cards-on-tinted-canvas.
- Hairline encore visible.
- Dark mode inchangé.
- Pas de régression sur components qui pourraient ré-utiliser `bg-canvas` pour autre chose (dropdowns, modals).
- **SPEC.md C2 section = FIRST commit** of the sprint (sprint 5/6 lesson: never close a sprint with SPEC uncommitted).
- README: `MIMIC_ENCRYPTION_KEY`, `MIMIC_C2_ADAPTER` env vars + docker compose example.
- CHANGELOG sprint 8 entry at close.
---
## §5 — Pipeline & sequencing
## Definition of Done
1. spec-reviewer pre-pass on this plan (before any code).
2. backend-builder M1+M2 → API contract frozen → frontend-builder starts (parallel with backend M3+M4).
3. design-reviewer on new UI surfaces (screenshots come from the Playwright run — e2e bootstraps its own users, which sidesteps the sprint-7 credentials wall).
4. **code-reviewer must be respawned** (killed 2026-06-10 after idle-loop malfunction) before the review phase.
5. test-verifier: new C2 Playwright specs (fake adapter) + **full 223-spec re-run** — also clears the sprint 7 e2e debt (suite never re-ran after the design refresh).
## §6 — Risks
- **R1 — scope**: full bidirectional in one sprint. Mitigation: M1→M4 are ordered; M4 (history import) is the cut line if we overrun. User accepted explicitly.
- **R2 — schema fidelity**: no live Mythic to validate against; adapter pinned to the official `MythicMeta/Mythic_Scripting` client source on **master @ 2026-06-10** (verified via raw.githubusercontent.com). First real connection may need a small patch. README records the exact source URL alongside the "may need a patch" note. Adapter must defensively handle `response_text` base64 decode failures (binary output): on `binascii.Error` keep raw bytes hex-encoded with a prefix `<binary> ` so `execution_result` never silently corrupts.
- **R3 — secret at rest**: Fernet + env key; key rotation out of scope (documented limitation).
- **R4 — polling load**: poll-on-read touches only incomplete tasks of the open simulation — bounded.
- **R5 — e2e drift**: sprint 7 redesign never re-validated by Playwright; the full re-run in §5.5 surfaces any breakage — budget fix time.
## §7 — Definition of Done
- pytest green (256 baseline on main + new), vitest green (139 baseline + new), Playwright full suite green (223 baseline + new C2 specs).
- spec-reviewer (plan) + design-reviewer (UI) + code-reviewer (diff) all APPROVED.
- SPEC.md, README.md, CHANGELOG.md updated. No secret, key or IP hardcoded anywhere.
- PR #11 opened via `make open-pr`.
- EngagementFormPage en édition : 2 colonnes desktop, stack mobile.
- Page bg différencié de card bg en light mode (eyes confort).
- Vitest + typecheck + lint verts.
- Design-reviewer APPROVED.
- Screenshots livrés ou écueil documenté.
- Commits conventional, branche `sprint/9-ui-contrast`.