feat(backend): c2 poll-on-read + output mapping (sprint 8 M3)
- adapter.py: add completed_at field to C2TaskStatus dataclass - mythic.py: implement get_task() (GraphQL task query) and get_task_output() (response query + decode_response_text concat) - fake.py: deterministic state progression via per-instance call counter; get_task_output raises C2Error until completed - mapping.py: apply_task_to_simulation() idempotent output mapper (mapping_applied anchor prevents double-writes) - migration 0007: add mapping_applied BOOLEAN NOT NULL DEFAULT false to c2_task - c2_task model: mapping_applied column added - api/c2.py: GET /api/simulations/<id>/c2/tasks poll-on-read endpoint; refreshes incomplete tasks from C2, fetches output on completion, applies mapping, skips re-polling for completed tasks; best-effort (C2Error on individual task skipped, returns 200 with stale status) - 51 new tests (396 total); pytest/ruff/mypy all green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ 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
|
||||
|
||||
@@ -297,3 +298,72 @@ def execute_simulation(sid: int):
|
||||
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,
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user