feat(backend): c2 callback history + task import (sprint 8 M4)

Command source decision: extended C2TaskStatus with command: str | None
(default None). Added command_name to _GET_TASK_QUERY so get_task() returns
command in a single round-trip — no separate history fetch needed on import.
4-line change, zero cascading test impact.

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-06-10 20:09:29 +02:00
parent b83316f715
commit 8f23f59601
8 changed files with 1146 additions and 8 deletions

View File

@@ -10,10 +10,45 @@ from backend.app.services.c2.adapter import (
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(
@@ -109,6 +144,7 @@ class FakeAdapter(C2Adapter):
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:
@@ -130,14 +166,11 @@ class FakeAdapter(C2Adapter):
page: int = 1,
page_size: int = 25,
) -> C2TaskPage:
items = [
t for t in self._tasks.values()
if t["callback_display_id"] == callback_display_id
]
all_items = _FAKE_HISTORY.get(callback_display_id, [])
start = (page - 1) * page_size
return C2TaskPage(
items=items[start : start + page_size],
total=len(items),
items=all_items[start : start + page_size],
total=len(all_items),
page=page,
page_size=page_size,
)