feat(c2): integrate Mythic command and control (sprint 8) #11
@@ -500,7 +500,6 @@ def import_tasks(sid: int):
|
|||||||
db.session.add(task)
|
db.session.add(task)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
apply_task_to_simulation(task, sim)
|
apply_task_to_simulation(task, sim)
|
||||||
task.mapping_applied = True
|
|
||||||
else:
|
else:
|
||||||
db.session.add(task)
|
db.session.add(task)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""C2 task → Simulation output mapping.
|
"""C2 task → Simulation output mapping.
|
||||||
|
|
||||||
apply_task_to_simulation() writes task output into the simulation's
|
apply_task_to_simulation() implements the full §0.11 contract:
|
||||||
execution_result field and marks the task as mapping_applied=True so that
|
1. execution_result — append "$ <command>\n<output>\n" block.
|
||||||
the operation is idempotent (safe to call multiple times for the same task).
|
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.
|
Caller is responsible for committing the session.
|
||||||
"""
|
"""
|
||||||
@@ -15,24 +16,38 @@ from backend.app.models.simulation import Simulation
|
|||||||
|
|
||||||
|
|
||||||
def apply_task_to_simulation(task: C2Task, simulation: Simulation) -> None:
|
def apply_task_to_simulation(task: C2Task, simulation: Simulation) -> None:
|
||||||
"""Write task output into simulation.execution_result (append, newline-separated).
|
"""Apply completed task data to simulation fields per §0.11.
|
||||||
|
|
||||||
No-op if task.mapping_applied is already True or task.output is empty.
|
Idempotent: no-op when task.mapping_applied is already True.
|
||||||
Marks task.mapping_applied = True on completion.
|
Always sets mapping_applied = True on exit so the task is never re-processed.
|
||||||
"""
|
"""
|
||||||
if task.mapping_applied:
|
if task.mapping_applied:
|
||||||
return
|
return
|
||||||
|
|
||||||
output = (task.output or "").strip()
|
output = (task.output or "").strip()
|
||||||
if not output:
|
|
||||||
task.mapping_applied = True
|
|
||||||
return
|
|
||||||
|
|
||||||
existing = (simulation.execution_result or "").rstrip("\n")
|
# 1) execution_result — "$ <command>\n<output>\n" block, only when output is non-empty.
|
||||||
if existing:
|
if output:
|
||||||
simulation.execution_result = existing + "\n" + output
|
block = f"$ {task.command}\n{output}\n"
|
||||||
else:
|
existing = simulation.execution_result or ""
|
||||||
simulation.execution_result = output
|
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)
|
simulation.updated_at = datetime.now(UTC)
|
||||||
task.mapping_applied = True
|
task.mapping_applied = True
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ class MythicAdapter(C2Adapter):
|
|||||||
try:
|
try:
|
||||||
data = self._post({"query": _CALLBACKS_QUERY})
|
data = self._post({"query": _CALLBACKS_QUERY})
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise C2Error(str(exc)) from exc
|
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
callbacks_raw = data.get("data", {}).get("callback", [])
|
callbacks_raw = data.get("data", {}).get("callback", [])
|
||||||
return [
|
return [
|
||||||
@@ -190,7 +190,7 @@ class MythicAdapter(C2Adapter):
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise C2Error(str(exc)) from exc
|
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
task_data = data.get("data", {}).get("createTask", {})
|
task_data = data.get("data", {}).get("createTask", {})
|
||||||
error_msg = task_data.get("error")
|
error_msg = task_data.get("error")
|
||||||
@@ -206,7 +206,7 @@ class MythicAdapter(C2Adapter):
|
|||||||
"variables": {"display_id": task_display_id},
|
"variables": {"display_id": task_display_id},
|
||||||
})
|
})
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise C2Error(str(exc)) from exc
|
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
rows = data.get("data", {}).get("task", [])
|
rows = data.get("data", {}).get("task", [])
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -238,7 +238,7 @@ class MythicAdapter(C2Adapter):
|
|||||||
"variables": {"display_id": task_display_id},
|
"variables": {"display_id": task_display_id},
|
||||||
})
|
})
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise C2Error(str(exc)) from exc
|
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
rows = data.get("data", {}).get("response", [])
|
rows = data.get("data", {}).get("response", [])
|
||||||
return "".join(
|
return "".join(
|
||||||
@@ -269,7 +269,7 @@ class MythicAdapter(C2Adapter):
|
|||||||
"variables": {"callback_display_id": callback_display_id},
|
"variables": {"callback_display_id": callback_display_id},
|
||||||
})
|
})
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise C2Error(str(exc)) from exc
|
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
rows = data.get("data", {}).get("task", [])
|
rows = data.get("data", {}).get("task", [])
|
||||||
total: int = (
|
total: int = (
|
||||||
|
|||||||
@@ -124,6 +124,20 @@ class TestMythicAdapterCreateTask:
|
|||||||
adapter.create_task(1, "whoami")
|
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:
|
class TestMythicAdapterNoRedirects:
|
||||||
def test_does_not_follow_redirect(self, adapter):
|
def test_does_not_follow_redirect(self, adapter):
|
||||||
"""Adapter must not follow HTTP redirects (allow_redirects=False)."""
|
"""Adapter must not follow HTTP redirects (allow_redirects=False)."""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit tests for apply_task_to_simulation() mapping helper."""
|
"""Unit tests for apply_task_to_simulation() mapping helper — §0.11 contract."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -7,91 +7,202 @@ from unittest.mock import MagicMock
|
|||||||
from backend.app.services.c2.mapping import apply_task_to_simulation
|
from backend.app.services.c2.mapping import apply_task_to_simulation
|
||||||
|
|
||||||
|
|
||||||
def _make_task(output: str | None = "whoami output", mapping_applied: bool = False) -> MagicMock:
|
def _make_task(
|
||||||
|
command: str = "whoami",
|
||||||
|
output: str | None = "root",
|
||||||
|
mapping_applied: bool = False,
|
||||||
|
completed_at: datetime | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
task = MagicMock()
|
task = MagicMock()
|
||||||
|
task.command = command
|
||||||
task.output = output
|
task.output = output
|
||||||
task.mapping_applied = mapping_applied
|
task.mapping_applied = mapping_applied
|
||||||
|
task.completed_at = completed_at
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
def _make_sim(execution_result: str | None = None) -> MagicMock:
|
def _make_sim(
|
||||||
|
execution_result: str | None = None,
|
||||||
|
executed_at: datetime | None = None,
|
||||||
|
commands: str | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
sim = MagicMock()
|
sim = MagicMock()
|
||||||
sim.execution_result = execution_result
|
sim.execution_result = execution_result
|
||||||
|
sim.executed_at = executed_at
|
||||||
|
sim.commands = commands
|
||||||
sim.updated_at = None
|
sim.updated_at = None
|
||||||
return sim
|
return sim
|
||||||
|
|
||||||
|
|
||||||
class TestApplyTaskToSimulation:
|
class TestExecutionResult:
|
||||||
def test_appends_output_to_empty_simulation(self):
|
def test_first_task_produces_command_block(self):
|
||||||
task = _make_task(output="whoami output")
|
task = _make_task(command="whoami", output="root")
|
||||||
sim = _make_sim(execution_result=None)
|
sim = _make_sim()
|
||||||
|
|
||||||
apply_task_to_simulation(task, sim)
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
assert sim.execution_result == "whoami output"
|
assert sim.execution_result == "$ whoami\nroot\n"
|
||||||
assert task.mapping_applied is True
|
|
||||||
|
|
||||||
def test_appends_with_newline_separator(self):
|
def test_second_task_appended_with_block_separator(self):
|
||||||
task = _make_task(output="second result")
|
"""Two tasks → two '$ command\noutput\n' blocks separated by a single newline."""
|
||||||
sim = _make_sim(execution_result="first result")
|
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)
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
assert sim.execution_result == "first result\nsecond result"
|
assert sim.execution_result == "$ id\nuid=0\n$ hostname\nlab-1\n"
|
||||||
|
|
||||||
def test_idempotent_when_already_applied(self):
|
def test_empty_output_skips_block_but_marks_applied(self):
|
||||||
task = _make_task(output="some output", mapping_applied=True)
|
|
||||||
sim = _make_sim(execution_result="existing")
|
|
||||||
|
|
||||||
apply_task_to_simulation(task, sim)
|
|
||||||
|
|
||||||
# execution_result must not be modified.
|
|
||||||
assert sim.execution_result == "existing"
|
|
||||||
|
|
||||||
def test_no_op_when_output_is_empty_string(self):
|
|
||||||
task = _make_task(output="")
|
task = _make_task(output="")
|
||||||
sim = _make_sim(execution_result="existing")
|
sim = _make_sim(execution_result="$ id\nuid=0\n")
|
||||||
|
|
||||||
apply_task_to_simulation(task, sim)
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
assert sim.execution_result == "existing"
|
assert sim.execution_result == "$ id\nuid=0\n"
|
||||||
# Still marks mapping_applied so we don't revisit it.
|
|
||||||
assert task.mapping_applied is True
|
assert task.mapping_applied is True
|
||||||
|
|
||||||
def test_no_op_when_output_is_none(self):
|
def test_none_output_skips_block_but_marks_applied(self):
|
||||||
task = _make_task(output=None)
|
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")
|
sim = _make_sim(execution_result="existing")
|
||||||
|
|
||||||
apply_task_to_simulation(task, sim)
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
assert sim.execution_result == "existing"
|
assert sim.execution_result == "existing"
|
||||||
assert task.mapping_applied is True
|
|
||||||
|
|
||||||
def test_strips_trailing_newlines_from_existing(self):
|
def test_always_marks_mapping_applied(self):
|
||||||
"""Existing execution_result with trailing newlines should not cause double blank lines."""
|
task = _make_task(output="root")
|
||||||
task = _make_task(output="new output")
|
sim = _make_sim()
|
||||||
sim = _make_sim(execution_result="old output\n\n")
|
|
||||||
|
|
||||||
apply_task_to_simulation(task, sim)
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
assert sim.execution_result == "old output\nnew output"
|
assert task.mapping_applied is True
|
||||||
|
|
||||||
def test_updated_at_is_set_on_sim(self):
|
def test_updated_at_is_set(self):
|
||||||
task = _make_task(output="something")
|
task = _make_task(output="root")
|
||||||
sim = _make_sim(execution_result=None)
|
sim = _make_sim()
|
||||||
before = datetime.now(UTC)
|
before = datetime.now(UTC)
|
||||||
|
|
||||||
apply_task_to_simulation(task, sim)
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
assert sim.updated_at is not None
|
assert sim.updated_at is not None
|
||||||
assert sim.updated_at >= before
|
assert sim.updated_at >= before
|
||||||
|
|
||||||
def test_multiple_tasks_accumulate(self):
|
|
||||||
sim = _make_sim(execution_result=None)
|
|
||||||
tasks = [_make_task(output=f"result {i}") for i in range(3)]
|
|
||||||
|
|
||||||
for t in tasks:
|
|
||||||
apply_task_to_simulation(t, sim)
|
|
||||||
|
|
||||||
lines = sim.execution_result.split("\n")
|
|
||||||
assert lines == ["result 0", "result 1", "result 2"]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user