Compare commits
16 Commits
6ca614a3f3
...
sprint/9-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5b5d94d8 | ||
|
|
76bcb04c8f | ||
|
|
88b97cef2e | ||
|
|
e4b1d6cb57 | ||
|
|
a9fe2fc528 | ||
|
|
38e282a126 | ||
|
|
7d3d39639e | ||
|
|
184a2a16c9 | ||
|
|
7ff153905b | ||
|
|
8f23f59601 | ||
|
|
b83316f715 | ||
|
|
873e52a2a1 | ||
|
|
5ff6ae8940 | ||
|
|
53755a31d6 | ||
|
|
9a9c98beab | ||
|
|
813e69ee01 |
@@ -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.
|
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.
|
**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.
|
- **Soft Blue** (`{colors.primary-soft}` — `#c9e0fc`): selection highlight, chip background on light surfaces.
|
||||||
|
|
||||||
### Surface
|
### 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.
|
- **Paper** (`{colors.paper}` — `#ffffff` light / `#1f2937` dark): card and panel surfaces.
|
||||||
- **Cloud** (`{colors.cloud}` — `#f7f7f7` light / `#1f2937` dark): alternating section band, table row zebra.
|
- **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.
|
- **Fog** (`{colors.fog}` — `#e8e8e8` light / `#374151` dark): secondary section band, input background on dense panels.
|
||||||
|
|||||||
62
SPEC.md
62
SPEC.md
@@ -61,6 +61,68 @@ Prévoir un module d'authentification : dans un premier temps local à la bdd.
|
|||||||
Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests.
|
Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests.
|
||||||
Dans un second temps, 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 simulation au travers du C2.
|
Dans un second temps, 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 simulation au travers du C2.
|
||||||
|
|
||||||
|
## Intégration C2 (Sprint 8+)
|
||||||
|
|
||||||
|
Couche d'intégration C2 permettant d'exécuter les commandes d'une simulation à travers un Command & Control distant, suivre l'avancement des tâches en quasi-temps réel, et importer l'historique d'exécutions existant. **Implémentation de référence : Mythic 3.x**, derrière une interface `C2Adapter` mince qui ne ferme pas la porte à un C2 maison ultérieur.
|
||||||
|
|
||||||
|
**RBAC C2 = ressource Red Team uniquement** (précédent Templates + Export) : admin et redteam ont accès complet (config + exécution + import). SOC retourne 403 sur tous les endpoints C2 (pas de nav link, pas d'affichage du panneau C2).
|
||||||
|
|
||||||
|
**Configuration par engagement** : chaque engagement possède au plus une `c2_config` (URL Mythic + API token + flag `verify_tls`). Le token est **chiffré au repos** via `cryptography.Fernet` ; la clé est dérivée de l'env var `MIMIC_ENCRYPTION_KEY` (variable obligatoire pour activer la fonctionnalité C2 — jamais hardcodée, conforme à la règle OPSEC zero-secret-in-code). Le token n'est jamais renvoyé en clair par l'API — `GET /api/engagements/<id>/c2-config` retourne `has_token: bool` uniquement. Mise à jour via `PUT` ; suppression via `DELETE`. La suppression d'un engagement supprime en cascade sa `c2_config`.
|
||||||
|
|
||||||
|
**Sélection d'adapter** via l'env var `MIMIC_C2_ADAPTER` :
|
||||||
|
- `mythic` (défaut) : adapter Mythic réel (GraphQL via Hasura).
|
||||||
|
- `fake` : adapter en mémoire déterministe utilisé pour la validation Playwright et le dev local sans instance Mythic.
|
||||||
|
|
||||||
|
**Modèle de données — additions** :
|
||||||
|
|
||||||
|
`c2_config` (1 ligne par engagement au max) :
|
||||||
|
| Colonne | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | int PK | |
|
||||||
|
| `engagement_id` | int FK `engagements.id` ON DELETE CASCADE, **UNIQUE** | |
|
||||||
|
| `url` | text | endpoint Mythic, ex. `https://lab.internal:7443` |
|
||||||
|
| `api_token_encrypted` | text | Fernet ciphertext, jamais en clair |
|
||||||
|
| `verify_tls` | bool, défaut `true` | `false` autorisé pour labs auto-signés |
|
||||||
|
| `created_at`, `updated_at` | datetime | |
|
||||||
|
|
||||||
|
`c2_task` (lien simulation ↔ tâche Mythic) :
|
||||||
|
| Colonne | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | int PK | |
|
||||||
|
| `simulation_id` | int FK `simulations.id` ON DELETE CASCADE | |
|
||||||
|
| `mythic_task_display_id` | int | identifiant côté Mythic |
|
||||||
|
| `callback_display_id` | int | callback Mythic sur lequel la tâche tourne |
|
||||||
|
| `command` | text | commande envoyée |
|
||||||
|
| `params` | text nullable | paramètres associés |
|
||||||
|
| `status` | text | statut brut Mythic (`submitted`, `completed`, `error`, …) |
|
||||||
|
| `completed` | bool | `true` quand la tâche est terminée |
|
||||||
|
| `output` | text nullable | sortie décodée (base64 → utf-8 ; binaire → préfixe `<binary>` + hex) |
|
||||||
|
| `source` | enum `mimic` \| `import` | tâche lancée depuis Mimic ou importée a posteriori |
|
||||||
|
| `created_at` | datetime | |
|
||||||
|
| `completed_at` | datetime nullable | timestamp de complétion |
|
||||||
|
|
||||||
|
**Endpoints C2** (tous admin+redteam ; SOC = 403) :
|
||||||
|
- `GET /api/engagements/<id>/c2-config` — `{has_token, url, verify_tls}` (jamais le token en clair).
|
||||||
|
- `PUT /api/engagements/<id>/c2-config` — `{url, api_token?, verify_tls}`.
|
||||||
|
- `DELETE /api/engagements/<id>/c2-config`.
|
||||||
|
- `POST /api/engagements/<id>/c2-config/test` — test de connectivité via l'adapter, renvoie `{ok, error?}`.
|
||||||
|
- `GET /api/engagements/<id>/c2/callbacks` — callbacks actifs de l'instance Mythic configurée.
|
||||||
|
- `POST /api/simulations/<id>/c2/execute` `{callback_display_id, commands: [str]}` — une tâche Mythic par commande, stockées dans `c2_task` (source=`mimic`). **Auto-transition** : si la simulation est `pending`, elle passe à `in_progress` (même règle que l'édition manuelle RT — cf. § Fonctionnement).
|
||||||
|
- `GET /api/simulations/<id>/c2/tasks` — poll-on-read : à la lecture, rafraîchit le statut et l'output des `c2_task` non terminées depuis Mythic, applique le mapping de sortie (voir ci-dessous) à la simulation pour chaque tâche qui vient de se terminer (idempotent — appliqué une seule fois par tâche).
|
||||||
|
- `GET /api/engagements/<id>/c2/callbacks/<cid>/history?page=` — historique paginé des tâches d'un callback, pour l'import.
|
||||||
|
- `POST /api/simulations/<id>/c2/import` `{task_display_ids: [int]}` — import sélectif de tâches (source=`import`) + mapping de sortie.
|
||||||
|
|
||||||
|
**Mapping de sortie vers la simulation** (appliqué une fois par tâche, lors de la complétion ou de l'import) :
|
||||||
|
- `simulation.execution_result` reçoit en append le bloc `\n$ <command>\n<output>\n` (préserve l'existant, jamais d'écrasement).
|
||||||
|
- `simulation.executed_at` est renseigné depuis le timestamp de la première tâche complétée si le champ est vide ; sinon non modifié.
|
||||||
|
- `simulation.commands` reçoit en append la commande si elle n'y figure pas déjà (déduplication ligne par ligne).
|
||||||
|
|
||||||
|
**Suivi temps réel** : polling court — le frontend re-fetch `GET /api/simulations/<id>/c2/tasks` toutes les **2 500 ms** via TanStack Query `refetchInterval` tant qu'une tâche attachée n'est pas terminée ; le polling s'arrête automatiquement quand toutes les tâches sont `completed`. Pas d'infrastructure ajoutée côté serveur (pas de WebSocket, pas de scheduler).
|
||||||
|
|
||||||
|
**UI** : les contrôles C2 vivent dans la carte Red Team de l'écran simulation — bouton `[Execute via C2]` ouvrant une modale (picker de callback + textarea de commandes pré-remplie depuis `commands`), panneau des tâches attachées sous la carte, et modale d'import historique. Configuration C2 visible/éditable depuis l'écran de détail/édition d'engagement.
|
||||||
|
|
||||||
|
**Validation** : MVP entièrement mocké — pytest utilise un adapter mocké (zéro HTTP live), Playwright utilise l'adapter `fake` (déterministe). Le branchement contre une instance Mythic réelle est repoussé au premier usage opérationnel et peut nécessiter un patch mineur du contrat GraphQL.
|
||||||
|
|
||||||
## Stacks techniques
|
## Stacks techniques
|
||||||
* **FrontEnd** : WebUI
|
* **FrontEnd** : WebUI
|
||||||
- Stacks standard : ReactJS, Vite, TailWind etc...
|
- Stacks standard : ReactJS, Vite, TailWind etc...
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ from pathlib import Path
|
|||||||
|
|
||||||
from flask import Flask, jsonify, send_from_directory
|
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.cli import register_cli
|
||||||
from backend.app.config import Config, TestConfig
|
from backend.app.config import Config, TestConfig
|
||||||
from backend.app.errors import register_error_handlers
|
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(engagements_bp)
|
||||||
app.register_blueprint(simulations_bp)
|
app.register_blueprint(simulations_bp)
|
||||||
app.register_blueprint(templates_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
|
from backend.app.services import mitre as mitre_svc
|
||||||
mitre_svc.load_bundle()
|
mitre_svc.load_bundle()
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
"""API blueprints."""
|
"""API blueprints."""
|
||||||
from backend.app.api.auth import auth_bp
|
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.engagements import engagements_bp
|
||||||
from backend.app.api.simulations import simulations_bp
|
from backend.app.api.simulations import simulations_bp
|
||||||
from backend.app.api.templates import templates_bp
|
from backend.app.api.templates import templates_bp
|
||||||
from backend.app.api.users import users_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
517
backend/app/api/c2.py
Normal 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
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
"""SQLAlchemy models."""
|
"""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.engagement import Engagement, EngagementStatus
|
||||||
from backend.app.models.simulation import Simulation, SimulationStatus
|
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||||
from backend.app.models.simulation_template import SimulationTemplate
|
from backend.app.models.simulation_template import SimulationTemplate
|
||||||
@@ -12,4 +14,7 @@ __all__ = [
|
|||||||
"Simulation",
|
"Simulation",
|
||||||
"SimulationStatus",
|
"SimulationStatus",
|
||||||
"SimulationTemplate",
|
"SimulationTemplate",
|
||||||
|
"C2Config",
|
||||||
|
"C2Task",
|
||||||
|
"C2TaskSource",
|
||||||
]
|
]
|
||||||
|
|||||||
34
backend/app/models/c2_config.py
Normal file
34
backend/app/models/c2_config.py
Normal 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}>"
|
||||||
48
backend/app/models/c2_task.py
Normal file
48
backend/app/models/c2_task.py
Normal 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}>"
|
||||||
22
backend/app/services/c2/__init__.py
Normal file
22
backend/app/services/c2/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
117
backend/app/services/c2/adapter.py
Normal file
117
backend/app/services/c2/adapter.py
Normal 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."""
|
||||||
|
...
|
||||||
19
backend/app/services/c2/factory.py
Normal file
19
backend/app/services/c2/factory.py
Normal 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)
|
||||||
176
backend/app/services/c2/fake.py
Normal file
176
backend/app/services/c2/fake.py
Normal 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,
|
||||||
|
)
|
||||||
53
backend/app/services/c2/mapping.py
Normal file
53
backend/app/services/c2/mapping.py
Normal 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
|
||||||
293
backend/app/services/c2/mythic.py
Normal file
293
backend/app/services/c2/mythic.py
Normal 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)
|
||||||
40
backend/app/services/crypto.py
Normal file
40
backend/app/services/crypto.py
Normal 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"]
|
||||||
@@ -98,6 +98,19 @@ def _maybe_activate_engagement(simulation: Simulation) -> None:
|
|||||||
db.session.add(engagement)
|
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(
|
def apply_patch(
|
||||||
simulation: Simulation, payload: dict[str, Any], user: User
|
simulation: Simulation, payload: dict[str, Any], user: User
|
||||||
) -> tuple[Any, int] | None:
|
) -> tuple[Any, int] | None:
|
||||||
|
|||||||
67
backend/migrations/versions/0006_c2_layer.py
Normal file
67
backend/migrations/versions/0006_c2_layer.py
Normal 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)
|
||||||
30
backend/migrations/versions/0007_c2_task_mapping_applied.py
Normal file
30
backend/migrations/versions/0007_c2_task_mapping_applied.py
Normal 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")
|
||||||
@@ -4,6 +4,10 @@ Flask-Migrate==4.0.7
|
|||||||
PyJWT==2.9.0
|
PyJWT==2.9.0
|
||||||
argon2-cffi==23.1.0
|
argon2-cffi==23.1.0
|
||||||
weasyprint>=60.0
|
weasyprint>=60.0
|
||||||
|
cryptography==44.0.0
|
||||||
|
requests==2.32.3
|
||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
ruff==0.6.9
|
ruff==0.6.9
|
||||||
mypy==1.11.2
|
mypy==1.11.2
|
||||||
|
types-requests==2.32.0.20240914
|
||||||
|
requests-mock==1.12.1
|
||||||
|
|||||||
30
backend/tests/test_c2_adapter_fake.py
Normal file
30
backend/tests/test_c2_adapter_fake.py
Normal 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")
|
||||||
62
backend/tests/test_c2_adapter_fake_m2.py
Normal file
62
backend/tests/test_c2_adapter_fake_m2.py
Normal 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
|
||||||
110
backend/tests/test_c2_adapter_fake_m3.py
Normal file
110
backend/tests/test_c2_adapter_fake_m3.py
Normal 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)
|
||||||
75
backend/tests/test_c2_adapter_fake_m4.py
Normal file
75
backend/tests/test_c2_adapter_fake_m4.py
Normal 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 == []
|
||||||
151
backend/tests/test_c2_adapter_mythic.py
Normal file
151
backend/tests/test_c2_adapter_mythic.py
Normal 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
|
||||||
188
backend/tests/test_c2_adapter_mythic_m3.py
Normal file
188
backend/tests/test_c2_adapter_mythic_m3.py
Normal 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
|
||||||
167
backend/tests/test_c2_adapter_mythic_m4.py
Normal file
167
backend/tests/test_c2_adapter_mythic_m4.py
Normal 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
|
||||||
142
backend/tests/test_c2_callbacks.py
Normal file
142
backend/tests/test_c2_callbacks.py
Normal 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", "")
|
||||||
367
backend/tests/test_c2_config.py
Normal file
367
backend/tests/test_c2_config.py
Normal 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
|
||||||
324
backend/tests/test_c2_execute.py
Normal file
324
backend/tests/test_c2_execute.py
Normal 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", "")
|
||||||
215
backend/tests/test_c2_history.py
Normal file
215
backend/tests/test_c2_history.py
Normal 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", "")
|
||||||
437
backend/tests/test_c2_import.py
Normal file
437
backend/tests/test_c2_import.py
Normal 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", "")
|
||||||
208
backend/tests/test_c2_mapping.py
Normal file
208
backend/tests/test_c2_mapping.py
Normal 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
|
||||||
375
backend/tests/test_c2_tasks_list.py
Normal file
375
backend/tests/test_c2_tasks_list.py
Normal 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"
|
||||||
52
backend/tests/test_crypto.py
Normal file
52
backend/tests/test_crypto.py
Normal 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")
|
||||||
199
backend/tests/test_migration_0006_c2.py
Normal file
199
backend/tests/test_migration_0006_c2.py
Normal 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()
|
||||||
124
backend/tests/test_migration_0007_c2.py
Normal file
124
backend/tests/test_migration_0007_c2.py
Normal 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
94
frontend/src/api/c2.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -154,3 +154,101 @@ export interface SimulationPatchInput {
|
|||||||
soc_comment?: string | null;
|
soc_comment?: string | null;
|
||||||
incident_number?: string | null;
|
incident_number?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C2 types
|
||||||
|
|
||||||
|
export interface C2Config {
|
||||||
|
has_token: boolean;
|
||||||
|
url: string;
|
||||||
|
verify_tls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface C2ConfigInput {
|
||||||
|
url: string;
|
||||||
|
api_token?: string;
|
||||||
|
verify_tls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface C2TestResult {
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface C2Callback {
|
||||||
|
display_id: number;
|
||||||
|
active: boolean;
|
||||||
|
host: string;
|
||||||
|
user: string;
|
||||||
|
domain: string;
|
||||||
|
last_checkin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface C2CallbacksResponse {
|
||||||
|
callbacks: C2Callback[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|||||||
95
frontend/src/components/C2CallbackPicker.tsx
Normal file
95
frontend/src/components/C2CallbackPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
frontend/src/components/C2ConfigCard.tsx
Normal file
240
frontend/src/components/C2ConfigCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/src/components/C2TaskStatusBadge.tsx
Normal file
27
frontend/src/components/C2TaskStatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
frontend/src/components/C2TasksPanel.tsx
Normal file
135
frontend/src/components/C2TasksPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
frontend/src/components/ExecuteViaC2Modal.tsx
Normal file
129
frontend/src/components/ExecuteViaC2Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
|
|||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div
|
<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"
|
role="menu"
|
||||||
>
|
>
|
||||||
{FORMATS.map(({ label, value }) => (
|
{FORMATS.map(({ label, value }) => (
|
||||||
|
|||||||
252
frontend/src/components/ImportC2HistoryModal.tsx
Normal file
252
frontend/src/components/ImportC2HistoryModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -170,7 +170,7 @@ export function MitreMatrixModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="matrix-modal-title"
|
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' }}
|
style={{ width: '1400px' }}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function MitreTechniquePicker({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{open && (
|
{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 && (
|
{isFetching && (
|
||||||
<div className="px-md py-sm text-[14px] text-graphite">Searching…</div>
|
<div className="px-md py-sm text-[14px] text-graphite">Searching…</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
|||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div
|
<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"
|
role="menu"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
152
frontend/src/hooks/useC2.ts
Normal file
152
frontend/src/hooks/useC2.ts
Normal 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) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
useEngagement,
|
useEngagement,
|
||||||
usePatchEngagement,
|
usePatchEngagement,
|
||||||
} from '@/hooks/useEngagements';
|
} from '@/hooks/useEngagements';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
|
import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
|
||||||
import { LoadingState } from '@/components/LoadingState';
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
import { ErrorState } from '@/components/ErrorState';
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
|
import { C2ConfigCard } from '@/components/C2ConfigCard';
|
||||||
|
|
||||||
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
|
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
|
||||||
{ value: 'planned', label: 'Planned' },
|
{ value: 'planned', label: 'Planned' },
|
||||||
@@ -50,6 +52,7 @@ export function EngagementFormPage(): JSX.Element {
|
|||||||
const numericId = id ? Number(id) : undefined;
|
const numericId = id ? Number(id) : undefined;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
|
const { canEditEngagements } = useAuth();
|
||||||
|
|
||||||
const detail = useEngagement(editing ? numericId : undefined);
|
const detail = useEngagement(editing ? numericId : undefined);
|
||||||
const createMutation = useCreateEngagement();
|
const createMutation = useCreateEngagement();
|
||||||
@@ -121,7 +124,7 @@ export function EngagementFormPage(): JSX.Element {
|
|||||||
const submitting = createMutation.isPending || patchMutation.isPending;
|
const submitting = createMutation.isPending || patchMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-xl max-w-2xl">
|
<div className="flex flex-col gap-xl">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-[32px] font-medium leading-none">
|
<h1 className="text-[32px] font-medium leading-none">
|
||||||
{editing ? 'Edit engagement' : 'New engagement'}
|
{editing ? 'Edit engagement' : 'New engagement'}
|
||||||
@@ -133,6 +136,13 @@ export function EngagementFormPage(): JSX.Element {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<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">
|
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
|
||||||
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
|
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -214,6 +224,11 @@ export function EngagementFormPage(): JSX.Element {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{editing && numericId && canEditEngagements && (
|
||||||
|
<C2ConfigCard engagementId={numericId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ import {
|
|||||||
useTransitionSimulation,
|
useTransitionSimulation,
|
||||||
useUpdateSimulation,
|
useUpdateSimulation,
|
||||||
} from '@/hooks/useSimulations';
|
} from '@/hooks/useSimulations';
|
||||||
|
import { useC2Config, useC2Tasks } from '@/hooks/useC2';
|
||||||
import { FormField, TextArea, TextInput } from '@/components/FormField';
|
import { FormField, TextArea, TextInput } from '@/components/FormField';
|
||||||
import { LoadingState } from '@/components/LoadingState';
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
import { ErrorState } from '@/components/ErrorState';
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
|
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
|
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
|
||||||
|
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
|
||||||
|
import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
|
||||||
|
import { C2TasksPanel } from '@/components/C2TasksPanel';
|
||||||
|
|
||||||
interface RedteamFormState {
|
interface RedteamFormState {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -61,6 +65,20 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth();
|
const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth();
|
||||||
|
|
||||||
|
const canEditRT = isAdmin || isRedteam;
|
||||||
|
const c2ConfigQuery = useC2Config(
|
||||||
|
!isNew && typeof engagementId === 'number' ? engagementId : undefined,
|
||||||
|
{ enabled: !isNew && canEditRT },
|
||||||
|
);
|
||||||
|
const hasC2Config = c2ConfigQuery.data !== null && c2ConfigQuery.data !== undefined;
|
||||||
|
|
||||||
|
const 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 detail = useSimulation(isNew ? undefined : simulationId);
|
||||||
const createMutation = useCreateSimulation(engagementId ?? 0);
|
const createMutation = useCreateSimulation(engagementId ?? 0);
|
||||||
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
|
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
|
||||||
@@ -72,6 +90,8 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
const [nameError, setNameError] = useState<string | null>(null);
|
const [nameError, setNameError] = useState<string | null>(null);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showC2Modal, setShowC2Modal] = useState(false);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNew && detail.data) {
|
if (!isNew && detail.data) {
|
||||||
@@ -109,7 +129,6 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
// US-18: Done = fully read-only, Reopen only
|
// US-18: Done = fully read-only, Reopen only
|
||||||
const isDone = status === 'done';
|
const isDone = status === 'done';
|
||||||
|
|
||||||
const canEditRT = isAdmin || isRedteam;
|
|
||||||
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
|
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
|
||||||
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
|
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
|
||||||
|
|
||||||
@@ -298,8 +317,10 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
</div>
|
</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">
|
<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 */}
|
{/* Red Team card */}
|
||||||
<form
|
<form
|
||||||
id="rt-form"
|
id="rt-form"
|
||||||
@@ -383,8 +404,35 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
rows={5}
|
rows={5}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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>
|
</form>
|
||||||
|
|
||||||
|
{/* C2 tasks panel — under RT card, same left column */}
|
||||||
|
{showTasksPanel && simulationId && (
|
||||||
|
<C2TasksPanel simulationId={simulationId} />
|
||||||
|
)}
|
||||||
|
</div>{/* end left column */}
|
||||||
|
|
||||||
{/* SOC card */}
|
{/* SOC card */}
|
||||||
<form
|
<form
|
||||||
id="soc-form"
|
id="soc-form"
|
||||||
@@ -512,6 +560,23 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
onCancel={() => setShowDeleteConfirm(false)}
|
onCancel={() => setShowDeleteConfirm(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showC2Modal && simulationId && typeof engagementId === 'number' && (
|
||||||
|
<ExecuteViaC2Modal
|
||||||
|
simulationId={simulationId}
|
||||||
|
engagementId={engagementId}
|
||||||
|
initialCommands={rt.commands}
|
||||||
|
onClose={() => setShowC2Modal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showImportModal && simulationId && typeof engagementId === 'number' && (
|
||||||
|
<ImportC2HistoryModal
|
||||||
|
simulationId={simulationId}
|
||||||
|
engagementId={engagementId}
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
/* Light mode — default */
|
/* Light mode — default */
|
||||||
:root {
|
:root {
|
||||||
--color-canvas: #ffffff;
|
--color-canvas: #f3f5f8;
|
||||||
--color-paper: #ffffff;
|
--color-paper: #ffffff;
|
||||||
--color-cloud: #f7f7f7;
|
--color-cloud: #f7f7f7;
|
||||||
--color-fog: #e8e8e8;
|
--color-fog: #e8e8e8;
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.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 {
|
.btn-outline:hover {
|
||||||
@apply bg-primary-soft;
|
@apply bg-primary-soft;
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline-ink {
|
.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 {
|
.btn-outline-ink:hover {
|
||||||
@apply bg-cloud;
|
@apply bg-cloud;
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-input {
|
.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 */
|
/* Panel / card — hairline border, no shadow, no radius */
|
||||||
|
|||||||
108
frontend/tests/EngagementFormPage.test.tsx
Normal file
108
frontend/tests/EngagementFormPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,6 +67,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
|||||||
mockRole = 'redteam';
|
mockRole = 'redteam';
|
||||||
mock = new MockAdapter(apiClient);
|
mock = new MockAdapter(apiClient);
|
||||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||||
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -154,6 +155,8 @@ describe('SimulationFormPage — SOC role + pending (blocked)', () => {
|
|||||||
mockRole = 'soc';
|
mockRole = 'soc';
|
||||||
mock = new MockAdapter(apiClient);
|
mock = new MockAdapter(apiClient);
|
||||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||||
|
// SOC role: useC2Config disabled (canEditRT=false), so no request expected — stub anyway
|
||||||
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -202,6 +205,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
|||||||
mockRole = 'soc';
|
mockRole = 'soc';
|
||||||
mock = new MockAdapter(apiClient);
|
mock = new MockAdapter(apiClient);
|
||||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||||
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -273,3 +277,160 @@ describe('SimulationFormPage — new simulation', () => {
|
|||||||
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
||||||
|
let mock: MockAdapter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRole = 'redteam';
|
||||||
|
mock = new MockAdapter(apiClient);
|
||||||
|
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Execute via C2 button when c2 config exists', async () => {
|
||||||
|
mock.onGet('/engagements/42/c2-config').reply(200, {
|
||||||
|
has_token: true,
|
||||||
|
url: 'https://mythic.lab:7443',
|
||||||
|
verify_tls: true,
|
||||||
|
});
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('c2-execute-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Execute via C2 button when no c2 config (404)', async () => {
|
||||||
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Execute via C2 button when simulation is done', async () => {
|
||||||
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
|
||||||
|
mock.onGet('/engagements/42/c2-config').reply(200, {
|
||||||
|
has_token: true,
|
||||||
|
url: 'https://mythic.lab:7443',
|
||||||
|
verify_tls: true,
|
||||||
|
});
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
200
frontend/tests/api/c2.test.ts
Normal file
200
frontend/tests/api/c2.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
135
frontend/tests/components/C2ConfigCard.test.tsx
Normal file
135
frontend/tests/components/C2ConfigCard.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
220
frontend/tests/components/C2TasksPanel.test.tsx
Normal file
220
frontend/tests/components/C2TasksPanel.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
171
frontend/tests/components/ExecuteViaC2Modal.test.tsx
Normal file
171
frontend/tests/components/ExecuteViaC2Modal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
380
frontend/tests/components/ImportC2HistoryModal.test.tsx
Normal file
380
frontend/tests/components/ImportC2HistoryModal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
190
tasks/todo.md
190
tasks/todo.md
@@ -1,153 +1,73 @@
|
|||||||
# Sprint 7 — Design Refresh: Terminal-SOC Aesthetic
|
# Sprint 9 — UI: engagement 2-col + global contrast pass
|
||||||
|
|
||||||
> Branch : `sprint/7-design` · Worktree : `.claude/worktrees/sprint-7-design` · Base : `main` @ `e27babe`
|
**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.
|
||||||
|
|
||||||
## §0 — Binding decisions (locked with user, 2026-06-09)
|
---
|
||||||
|
|
||||||
1. **Visual direction**: Bloomberg / Terminal-SOC — dense, brutalist, semantic colors strong, no ornament.
|
## Decisions (locked)
|
||||||
2. **Border-radius**: **0 everywhere** except status pills (`rounded-pill`) and avatars (round). All buttons, cards, modals, inputs, dropdowns, tables, tags → angular.
|
|
||||||
3. **Palette**: KEEP current (`#024ad8` primary blue, `slab #111827`, `canvas/paper/cloud/fog/ink` light+dark vars). ADD semantic tokens `success-green` + `warn-amber` (confirmed scope add — needed for SOC-grade status legibility on dashboards and badges).
|
|
||||||
4. **Scope**: Refonte globale en 1 sprint (all 8 pages + 17 components + tokens + DESIGN.md).
|
|
||||||
5. **Monospace**: data-only — JetBrains Mono for IDs, dates ISO, commands, execution output, MITRE techniques, metrics. Inter stays for body/labels/headers.
|
|
||||||
6. **Mono font**: JetBrains Mono, bundled locally via `@fontsource-variable/jetbrains-mono` (consistent with existing Inter bundle).
|
|
||||||
7. **Modes**: KEEP light + dark both. Toggle stays.
|
|
||||||
8. **Animations**: **Brutalist — zero transition**. Remove all `transition-*` utilities, focus rings sharp, hover instantaneous.
|
|
||||||
9. **Display scale reduction**: locked. `display-xxl 72→40`, `display-xl 56→32`, `display-lg 44→28`, `display-md 32→24`, `display-sm 24→20`, `display-xs 20→16`. Headers stay modest in terminal aesthetic — no editorial flourish at hero scale.
|
|
||||||
|
|
||||||
## §1 — Pre-work checks (team-lead, before dispatch)
|
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 **inchangé** (`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.
|
||||||
|
|
||||||
- [ ] Confirm `tasks/lessons.md` has nothing contradicting this brief
|
---
|
||||||
- [ ] Verify uncommitted `.claude/agents/frontend-builder.md` patch (Skill mandatory) is restored in worktree — sprint hygiene
|
|
||||||
- [ ] Send plan to **spec-reviewer** for 2-pass approval (vs SPEC.md, vs §0 binding decisions). MUST be APPROVED before any code touches `frontend/`.
|
|
||||||
- [ ] After approval: dispatch frontend-builder with this todo as brief.
|
|
||||||
|
|
||||||
## §2 — Sprint hygiene (commit #1)
|
## Task A — EngagementFormPage 2-col
|
||||||
|
|
||||||
- [ ] `chore(agents): frontend-builder must invoke Skill frontend-design before UI work` — lands BEFORE design work so the agent itself triggers the Skill on first call this sprint.
|
**File** : `frontend/src/pages/EngagementFormPage.tsx`
|
||||||
|
|
||||||
## §3 — Foundation: DESIGN.md + tokens (commits #2–#4)
|
- 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.
|
||||||
|
|
||||||
### §3.1 DESIGN.md rewrite (commit #2)
|
## Task B — Contrast pass (tokens)
|
||||||
|
|
||||||
- [ ] Replace current HP-catalog doc (346 lines, off-brand) with terminal-SOC spec covering:
|
**Files** :
|
||||||
- **Overview**: brutalist BAS Purple Team console, angular surfaces, semantic color signals, data-monospace hybrid
|
- `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".
|
||||||
- **Colors**: keep all existing tokens with **role redefinition** for terminal-SOC context. Primary = neutral action. Bloom-deep/coral = destructive/alert. ADD `success` (green) + `warn` (amber) — locked §0 D3 — with light + dark variants and WCAG AA contrast on slab and canvas surfaces
|
- 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`.
|
||||||
- **Typography**: Inter (body/headers/labels) + JetBrains Mono (data). Concrete tier table with size/weight/line-height
|
- Vérifier qu'aucun composant ne hardcode `#ffffff` pour la page bg (devrait utiliser `bg-canvas`).
|
||||||
- **Layout**: tighter spacing (replace `section 80px` → `section 48px`; halve card padding on dense surfaces)
|
- Tests CSS smoke : `bg-canvas` continue de matcher, dark mode inchangé.
|
||||||
- **Shapes**: ALL radii = 0 except `rounded-pill` reserved for status badges and avatars
|
|
||||||
- **Components**: re-document `btn-*`, `text-input`, `card-*`, `badge-*`, `nav-*`, `modal-*` with brutalist specs (no shadow, hairline borders, zero transition)
|
|
||||||
- **Do's/Don'ts**: zero rounded on conteneurs; zero transitions; semantic colors only on status surfaces; mono ONLY for data, never headers
|
|
||||||
- **Iteration guide**
|
|
||||||
- [ ] Doc lives in English (in-repo).
|
|
||||||
|
|
||||||
### §3.2 Tailwind token refresh (commit #3)
|
## Task C — Visual regression check
|
||||||
|
|
||||||
- [ ] `frontend/tailwind.config.ts`:
|
- `pnpm vitest run` clean.
|
||||||
- `borderRadius`: keep `none: 0`, keep `pill: 9999px`. Drop or stop using `xs/sm/md/lg/xl` for surfaces — keep only if a badge variant needs `2px`
|
- `pnpm tsc --noEmit` clean.
|
||||||
- ADD `fontFamily.mono`: `['JetBrains Mono Variable', 'JetBrains Mono', 'ui-monospace', 'monospace']`
|
- `pnpm lint` clean.
|
||||||
- ADD semantic colors (locked §0): `success: { DEFAULT, soft }` (green) + `warn: { DEFAULT, soft }` (amber). Pull dark-mode variants from CSS vars too. Suggested anchors — `success #16a34a` (dark `#22c55e`), `warn #d97706` (dark `#f59e0b`); design-reviewer audits WCAG AA at both modes.
|
- Screenshots avant/après (au moins) :
|
||||||
- Reduce `display-*` scale (locked §0): `display-xxl 72px → 40px`, `display-xl 56→32`, `display-lg 44→28`, `display-md 32→24`, `display-sm 24→20`, `display-xs 20→16` — terminal headers are modest
|
- EngagementsListPage (cards-on-canvas)
|
||||||
- Drop `tracking[0.7px]` and uppercase from `button-md` (still ALLCAPS via class but no letter-spacing)
|
- EngagementDetailPage
|
||||||
- Drop shadow tokens or keep but ensure no component class applies them
|
- EngagementFormPage (edit, avec C2ConfigCard à droite)
|
||||||
- [ ] `frontend/src/styles/index.css`:
|
- SimulationFormPage (déjà 2-col sprint 7, vérifier que le tinted canvas n'écrase pas)
|
||||||
- Drop `font-size: 16.5px` root bump (back to `16px` standard)
|
- LoginPage
|
||||||
- Set body `line-height: 1.4`, tighten headings to 1.1
|
- Dark mode : passe rapide pour confirmer aucune régression.
|
||||||
- Rewrite `.btn-primary/ink/outline/outline-ink`: `rounded-none`, NO `transition-colors`, keep `uppercase`, drop `tracking-[0.7px]`, keep `h-11` (touch target)
|
|
||||||
- Rewrite `.text-input`: `rounded-none`, focus border-primary sharp (no halo), no transition
|
|
||||||
- Rewrite `.card-product` and any `.card-*`: `rounded-none`, no shadow, 1px hairline border for separation
|
|
||||||
- Rewrite `.badge-pill-*`: keep `rounded-pill` ONLY here (status badges); strip uppercase if applied
|
|
||||||
- Rewrite `.modal-backdrop`: same dark backdrop, no rounded for the modal frame itself
|
|
||||||
- ADD `.mono` utility or rely on Tailwind's `font-mono` (preferred) for data cells
|
|
||||||
|
|
||||||
### §3.3 JetBrains Mono bundle (commit #4)
|
---
|
||||||
|
|
||||||
- [ ] `cd frontend && npm i @fontsource-variable/jetbrains-mono`
|
## Sequencing
|
||||||
- [ ] `frontend/src/styles/fonts.css`: add `@import '@fontsource-variable/jetbrains-mono'`
|
|
||||||
- [ ] No CDN. Confirms via `npm ls @fontsource-variable/jetbrains-mono`.
|
|
||||||
|
|
||||||
## §4 — Component sweep (commit #5)
|
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).
|
||||||
|
|
||||||
Rule: `rounded-*` → `rounded-none` unless explicitly an avatar or status pill; remove `transition-*`; data text → `font-mono`.
|
---
|
||||||
|
|
||||||
- [ ] `Layout.tsx`: nav-bar/utility-strip already angular — confirm. Remove `transition-colors` on theme button and hover-underline transitions. Mono font for any data label exposed (e.g. user.role pill).
|
## Definition of Done
|
||||||
- [ ] `StatusBadge.tsx`: KEEP rounded → switch to `rounded-pill` (it's a status pill, locked exception). Audit semantic mapping (planned/active/closed → semantic tokens once added).
|
|
||||||
- [ ] `SimulationStatusBadge.tsx`: same — `rounded-pill`, semantic colors aligned with new tokens.
|
|
||||||
- [ ] `FormField.tsx`: angular inputs (already via `.text-input` recipe — confirm).
|
|
||||||
- [ ] `EmptyState.tsx`: angular wrapper. No rounded illustration container.
|
|
||||||
- [ ] `ErrorState.tsx`: angular. Bloom-deep border-left if signalling.
|
|
||||||
- [ ] `LoadingState.tsx`: drop any rounded spinner background. Spinner shape ok.
|
|
||||||
- [ ] `ConfirmDialog.tsx`: angular modal. Buttons via new `.btn-*` recipes.
|
|
||||||
- [ ] `Toast.tsx`: angular. Semantic color border-left strip.
|
|
||||||
- [ ] `ExportEngagementButton.tsx` (sprint 6): angular dropdown menu. Audit `rounded-*` in the menu/item classes.
|
|
||||||
- [ ] `MitreMatrixModal.tsx`: angular modal. Cells already grid — confirm no rounded.
|
|
||||||
- [ ] `MitreTechniquePicker.tsx`: angular dropdown.
|
|
||||||
- [ ] `MitreTechniquesField.tsx`: angular chips.
|
|
||||||
- [ ] `MitreTechniqueTag.tsx`: angular tag (NOT pill — terminal tag, not a status). Decide once and apply consistently across MITRE surfaces.
|
|
||||||
- [ ] `TemplatePickerModal.tsx`: angular modal.
|
|
||||||
- [ ] `SimulationList.tsx`: angular table. Data cells (commands, executed_at, MITRE techniques) → `font-mono`.
|
|
||||||
- [ ] `ProtectedRoute.tsx`: no visual surface, skip.
|
|
||||||
|
|
||||||
## §5 — Page sweep (commit #6)
|
- EngagementFormPage en édition : 2 colonnes desktop, stack mobile.
|
||||||
|
- Page bg différencié de card bg en light mode (eyes confort).
|
||||||
For each page: header/body/footer review, replace rounded card containers with angular hairline-bordered containers, ensure data cells use mono.
|
- Vitest + typecheck + lint verts.
|
||||||
|
- Design-reviewer APPROVED.
|
||||||
- [ ] `LoginPage.tsx`: angular form card. Drop ornament.
|
- Screenshots livrés ou écueil documenté.
|
||||||
- [ ] `EngagementsListPage.tsx`: angular table container (currently `.card-product` with rounded-xl). Data cells (dates) → mono.
|
- Commits conventional, branche `sprint/9-ui-contrast`.
|
||||||
- [ ] `EngagementDetailPage.tsx`: angular header section. Engagement metadata (start/end dates, IDs, created_at) in mono. Simulations table covered via SimulationList.
|
|
||||||
- [ ] `EngagementFormPage.tsx`: angular form. Date inputs ok.
|
|
||||||
- [ ] `SimulationFormPage.tsx`: angular form. Commands textarea → mono.
|
|
||||||
- [ ] `TemplatesListPage.tsx`: angular list.
|
|
||||||
- [ ] `TemplateFormPage.tsx`: angular form. Commands field → mono.
|
|
||||||
- [ ] `UsersAdminPage.tsx`: angular table. Username column → mono (it's an ID).
|
|
||||||
|
|
||||||
## §6 — Test refresh (commit #7)
|
|
||||||
|
|
||||||
- [ ] `cd frontend && npm run test -- --run` — identify failing assertions on class names (`rounded-xl`, `card-product`, etc.). Update tests to use semantic queries (role, name, data-testid) where possible; if test asserts on visual class, update assertion to the new class.
|
|
||||||
- [ ] No new vitest tests added (visual sprint, behavior unchanged).
|
|
||||||
- [ ] Playwright e2e: should be `data-testid`-driven — run full suite to confirm no regression. If breakage, fix the testid not the test logic.
|
|
||||||
|
|
||||||
## §7 — Reviews
|
|
||||||
|
|
||||||
- [ ] **spec-reviewer** (pre-dispatch, §1): plan validated vs SPEC.md and §0 binding decisions
|
|
||||||
- [ ] **frontend-builder** (§2-§6): implements, runs typecheck/lint/test, delivers screenshots for design-reviewer (every page + key states, light+dark)
|
|
||||||
- [ ] **design-reviewer** (post-frontend): reviews screenshots + diff vs new DESIGN.md. Brutalist consistency, mono-discipline (only data), zero-rounded discipline.
|
|
||||||
- [ ] **code-reviewer** (post-design): reviews frontend diff for duplication, lost reuse, dead code.
|
|
||||||
- [ ] **test-verifier**: skipped this sprint (no new US, no behavior change).
|
|
||||||
- [ ] **backend-builder**: idle, no work this sprint.
|
|
||||||
|
|
||||||
## §8 — Git workflow
|
|
||||||
|
|
||||||
- [ ] Branch: `sprint/7-design` (already created from origin/main)
|
|
||||||
- [ ] Commits: conventional, one per logical group (§2 to §7)
|
|
||||||
- [ ] PR via `make open-pr` (Gitea pattern, per memory)
|
|
||||||
- [ ] PR body in `tasks/pr-body-sprint-7.md`
|
|
||||||
- [ ] CHANGELOG.md sprint 7 section
|
|
||||||
|
|
||||||
## §9 — Risks & mitigations
|
|
||||||
|
|
||||||
- **R1 — Tests break en masse**: many vitest specs may assert on class strings (e.g., `rounded-xl` on cards). Mitigation: update assertions to semantic queries; budget half a phase to test repair.
|
|
||||||
- **R2 — Dark mode contrast lost**: angular + new semantic colors may break WCAG AA contrast on dark slab. Mitigation: design-reviewer audits both modes; adjust the dark variant hex to meet WCAG AA. Rollback the success/warn family only if no accessible green/amber is achievable on the dark slab.
|
|
||||||
- **R3 — Mono overflow**: JetBrains Mono is wider than Inter at same px. Cell widths in tables may overflow. Mitigation: keep `table-layout: fixed` and `word-break: break-word` (pattern reused from PDF export CSS sprint 6).
|
|
||||||
- **R4 — DESIGN.md rewrite churn**: replacing 346 lines is a big diff. Mitigation: rewrite atomically in commit #2, keep token names consistent so downstream commits don't drift.
|
|
||||||
- **R5 — User taste mismatch**: "Bloomberg/SOC" may not match user's mental image. Mitigation: design-reviewer screenshots → user check-in BEFORE merge.
|
|
||||||
|
|
||||||
## §10 — Definition of Done
|
|
||||||
|
|
||||||
- [ ] All §0 decisions reflected in DESIGN.md + tokens + components + pages
|
|
||||||
- [ ] `npm run typecheck` clean
|
|
||||||
- [ ] `npm run lint` clean
|
|
||||||
- [ ] `npm run test -- --run` all green
|
|
||||||
- [ ] Backend untouched — `git diff origin/main -- backend/` empty
|
|
||||||
- [ ] Playwright e2e green (223 baseline preserved)
|
|
||||||
- [ ] Screenshots delivered (light + dark) for every page + key states
|
|
||||||
- [ ] DESIGN.md rewritten, no HP/Forma/wordmark/chevron references
|
|
||||||
- [ ] CHANGELOG.md sprint 7 section
|
|
||||||
- [ ] PR opened on Gitea
|
|
||||||
- [ ] User merges PR → sprint closed → team idle ready for sprint 8
|
|
||||||
|
|
||||||
## §11 — Lessons being applied from prior sprints
|
|
||||||
|
|
||||||
- **SPEC/DESIGN commit-first**: DESIGN.md rewrite is commit #2 (after sprint hygiene). No design churn mid-sprint.
|
|
||||||
- **spec-reviewer 2-pass**: APPROVED before dispatch, not after.
|
|
||||||
- **Team idle policy**: 6 agents already mounted, no shutdown until PR merged.
|
|
||||||
- **frontend-builder MUST invoke `Skill frontend-design`** before UI work (the patch commits as #1, takes effect immediately for the same sprint).
|
|
||||||
|
|||||||
Reference in New Issue
Block a user