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:
@@ -12,6 +12,8 @@ M4: list_callback_tasks()
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from backend.app.services.c2.adapter import (
|
||||
@@ -21,6 +23,7 @@ from backend.app.services.c2.adapter import (
|
||||
C2Health,
|
||||
C2TaskPage,
|
||||
C2TaskStatus,
|
||||
decode_response_text,
|
||||
)
|
||||
|
||||
_HEALTH_QUERY = "{ __typename }"
|
||||
@@ -54,6 +57,28 @@ mutation CreateTask($callback_id: Int!, $command: String!, $params: String!) {
|
||||
}
|
||||
"""
|
||||
|
||||
_GET_TASK_QUERY = """
|
||||
query GetTask($display_id: Int!) {
|
||||
task(where: {display_id: {_eq: $display_id}}) {
|
||||
display_id
|
||||
status
|
||||
completed
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
_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."""
|
||||
@@ -144,10 +169,52 @@ class MythicAdapter(C2Adapter):
|
||||
return int(task_data["display_id"])
|
||||
|
||||
def get_task(self, task_display_id: int) -> C2TaskStatus:
|
||||
raise NotImplementedError("M3")
|
||||
"""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(str(exc)) 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,
|
||||
)
|
||||
|
||||
def get_task_output(self, task_display_id: int) -> str:
|
||||
raise NotImplementedError("M3")
|
||||
"""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(str(exc)) 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,
|
||||
|
||||
Reference in New Issue
Block a user