From f5f37526e0492d0db67c098cc7a11906b30850e9 Mon Sep 17 00:00:00 2001 From: Brian Greunke Date: Wed, 14 Oct 2026 12:20:12 -0500 Subject: [PATCH] fix: require gql 4 and fastmcp 3.2 for mythic-c2 capabilities (ENG-8860) Drop the gql<4.0 cap and raise the fastmcp floor to >=3.2.0 to clear the flagged CVEs. Copy the gql 4 get_operation_name shim into mythic-c2-readonly (server and skill script) so mythic 0.2.x keeps working. Bump capability versions. Co-Authored-By: Claude Opus 5.5 --- .../mythic-c2-readonly/capability.yaml | 2 +- capabilities/mythic-c2-readonly/mcp/server.py | 92 +++++++----- .../mythic-c2-readonly/mcp/test_server.py | 32 +++- .../mythic-c2-readonly/scripts/mythic_read.py | 141 ++++++++++++------ .../scripts/test_mythic_read.py | 64 ++++---- capabilities/mythic-c2/capability.yaml | 2 +- capabilities/mythic-c2/mcp_server.py | 2 +- capabilities/mythic-c2/test_mcp.py | 2 +- 8 files changed, 216 insertions(+), 121 deletions(-) diff --git a/capabilities/mythic-c2-readonly/capability.yaml b/capabilities/mythic-c2-readonly/capability.yaml index cfa17f5..7befef6 100644 --- a/capabilities/mythic-c2-readonly/capability.yaml +++ b/capabilities/mythic-c2-readonly/capability.yaml @@ -1,6 +1,6 @@ schema: 1 name: mythic-c2-readonly -version: "1.0.0" +version: "1.0.1" description: > Read-only Mythic C2 integration. Query callbacks, task history (command input/output), credentials, downloaded files, artifacts, keylogs, diff --git a/capabilities/mythic-c2-readonly/mcp/server.py b/capabilities/mythic-c2-readonly/mcp/server.py index daf0746..0db7d21 100644 --- a/capabilities/mythic-c2-readonly/mcp/server.py +++ b/capabilities/mythic-c2-readonly/mcp/server.py @@ -2,9 +2,9 @@ # /// script # requires-python = ">=3.11" # dependencies = [ -# "fastmcp>=2.0", +# "fastmcp>=3.2.0", # "mythic>=0.2", -# "gql[aiohttp,websockets]>=3.0,<4.0", +# "gql[aiohttp,websockets]>=4.0", # "pydantic>=2.0,<3.0", # ] # /// @@ -31,7 +31,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path -from typing import Annotated, Literal, TypeAlias, TypedDict, TypeVar +from typing import Annotated, Any, Literal, TypeAlias, TypedDict, TypeVar from fastmcp import FastMCP from mythic import mythic as mythic_sdk, mythic_utilities @@ -39,6 +39,31 @@ from pydantic import BaseModel, Field, JsonValue, ValidationInfo, field_validator +def _patch_mythic_for_gql4() -> None: + """Make mythic's ``get_operation_name`` accept gql 4's ``GraphQLRequest``. + + mythic 0.2.x expects ``gql(query)`` to return a ``DocumentNode`` (gql 3.x). + gql 4.0 returns a ``GraphQLRequest`` wrapper, so the SDK raises + ``AttributeError: 'GraphQLRequest' object has no attribute 'definitions'``. + This shim unwraps ``.document`` and still accepts a plain ``DocumentNode``. + Copy of ``mythic-c2/lib/mythic_api.py`` (capabilities cannot share code, ENG-7049). + + Remove once ``mythic`` ships a release that supports gql 4.0 natively. + """ + + async def _get_operation_name(graphql_data: Any) -> str: + doc = getattr(graphql_data, "document", graphql_data) + definitions = getattr(doc, "definitions", None) or [] + if definitions and getattr(definitions[0], "name", None): + return definitions[0].name.value + return "" + + mythic_utilities.get_operation_name = _get_operation_name + + +_patch_mythic_for_gql4() + + # ── Type aliases ───────────────────────────────────────────────────── # GraphQL variables: Hasura inputs we send are always int (IDs/limits/offsets) or str. @@ -402,9 +427,7 @@ async def _ensure_connected() -> Mythic: if _config is None: _config = _default_config() if not _config["password"] and not _config["api_token"]: - raise RuntimeError( - "Set MYTHIC_PASSWORD or MYTHIC_API_TOKEN env var." - ) + raise RuntimeError("Set MYTHIC_PASSWORD or MYTHIC_API_TOKEN env var.") try: if _config["api_token"]: @@ -437,9 +460,7 @@ async def _ensure_connected() -> Mythic: async def _gql(query: str, variables: GqlVariables | None = None) -> JsonObject: client = await _ensure_connected() - result = await mythic_utilities.graphql_post( - mythic=client, query=query, variables=variables or {} - ) + result = await mythic_utilities.graphql_post(mythic=client, query=query, variables=variables or {}) return result if isinstance(result, dict) else {} @@ -605,14 +626,12 @@ async def list_tasks( # Mythic SDK types callback_display_id as int (not Optional), so we can't # unify the two calls via kwargs without fighting the type checker. if callback_id is not None: - rows = await mythic_sdk.get_all_tasks( - client, custom_return_attributes=attrs, callback_display_id=callback_id - ) + rows = await mythic_sdk.get_all_tasks(client, custom_return_attributes=attrs, callback_display_id=callback_id) else: rows = await mythic_sdk.get_all_tasks(client, custom_return_attributes=attrs) tasks = [Task.model_validate(row) for row in rows] tasks.sort(key=lambda t: t.id, reverse=True) - return tasks[offset: offset + limit] + return tasks[offset : offset + limit] @mcp.tool @@ -623,17 +642,11 @@ async def get_task_output( ) -> TaskOutput | None: """Get decoded task output with optional line paging. Returns None if the task has no output.""" client = await _ensure_connected() - responses = await mythic_sdk.get_all_task_and_subtask_output_by_id( - mythic=client, task_display_id=display_id - ) + responses = await mythic_sdk.get_all_task_and_subtask_output_by_id(mythic=client, task_display_id=display_id) if not responses: return None - parts = [ - _decode_b64(str(text)) - for r in responses - if (text := r.get("response_text") or r.get("response")) - ] + parts = [_decode_b64(str(text)) for r in responses if (text := r.get("response_text") or r.get("response"))] lines = "\n".join(parts).split("\n") stop = offset + max_lines if max_lines is not None else None sliced = lines[offset:stop] @@ -770,12 +783,14 @@ async def list_keylogs( offset: Annotated[int, "Offset for pagination"] = 0, ) -> list[Keylog]: """List keylog captures.""" - where, decls, variables = _build_where({ - "callback_display_id": { - "predicate": "task: {callback: {display_id: {_eq: $callback_display_id}}}", - "value": callback_id, - }, - }) + where, decls, variables = _build_where( + { + "callback_display_id": { + "predicate": "task: {callback: {display_id: {_eq: $callback_display_id}}}", + "value": callback_id, + }, + } + ) variables.update({"limit": limit, "offset": offset}) where_clause = f"{where}, " if where else "" result = await _gql( @@ -854,10 +869,7 @@ async def list_processes( host=host, path=None, limit=limit, - columns=( - "id, task_id, timestamp, host, name_text, parent_path_text, " - "full_path_text, metadata, os, success" - ), + columns=("id, task_id, timestamp, host, name_text, parent_path_text, " "full_path_text, metadata, os, success"), ) @@ -886,12 +898,14 @@ async def list_tokens( limit: Annotated[int, "Maximum results to return"] = 50, ) -> list[Token]: """List Windows token captures.""" - where, decls, variables = _build_where({ - "callback_display_id": { - "predicate": "task: {callback: {display_id: {_eq: $callback_display_id}}}", - "value": callback_id, - }, - }) + where, decls, variables = _build_where( + { + "callback_display_id": { + "predicate": "task: {callback: {display_id: {_eq: $callback_display_id}}}", + "value": callback_id, + }, + } + ) variables["limit"] = limit where_clause = f"{where}, " if where else "" result = await _gql( @@ -988,7 +1002,9 @@ def _valid_search_types(raw: str | None) -> set[SearchType]: @mcp.tool async def search( query: Annotated[str, "Search term"], - types: Annotated[str | None, "Comma-separated types to search (tasks,credentials,files,artifacts,keylogs). Default: all"] = None, + types: Annotated[ + str | None, "Comma-separated types to search (tasks,credentials,files,artifacts,keylogs). Default: all" + ] = None, limit: Annotated[int, "Maximum results per type"] = 10, ) -> SearchResult: """Search across tasks, credentials, files, artifacts, and keylogs concurrently.""" diff --git a/capabilities/mythic-c2-readonly/mcp/test_server.py b/capabilities/mythic-c2-readonly/mcp/test_server.py index f3382b5..27db885 100644 --- a/capabilities/mythic-c2-readonly/mcp/test_server.py +++ b/capabilities/mythic-c2-readonly/mcp/test_server.py @@ -4,9 +4,9 @@ # dependencies = [ # "pytest>=8.0", # "pytest-asyncio>=0.23", -# "fastmcp>=2.0", +# "fastmcp>=3.2.0", # "mythic>=0.2", -# "gql[aiohttp,websockets]>=3.0,<4.0", +# "gql[aiohttp,websockets]>=4.0", # ] # /// """Tests for mythic-readonly MCP server — no Mythic instance required.""" @@ -26,8 +26,8 @@ class TestToolRegistration: def test_expected_tools_registered(self): import asyncio - tools = asyncio.run(server.mcp.get_tools()) - tool_names = set(tools) if isinstance(tools, dict) else {t.name for t in tools} + tools = asyncio.run(server.mcp.list_tools()) + tool_names = {t.name for t in tools} expected = { "get_status", "list_callbacks", @@ -45,14 +45,12 @@ def test_expected_tools_registered(self): "list_tokens", "search", } - assert expected == tool_names, ( - f"Unexpected: {tool_names - expected}, Missing: {expected - tool_names}" - ) + assert expected == tool_names, f"Unexpected: {tool_names - expected}, Missing: {expected - tool_names}" def test_tool_count(self): import asyncio - tools = asyncio.run(server.mcp.get_tools()) + tools = asyncio.run(server.mcp.list_tools()) assert len(tools) == 15 @@ -99,6 +97,7 @@ def test_decode_b64_empty(self): def test_decode_b64_valid(self): import base64 + encoded = base64.b64encode(b"secret output").decode() assert server._decode_b64(encoded) == "secret output" @@ -187,5 +186,22 @@ def test_valid_search_types_strips_whitespace(self): assert server._valid_search_types("tasks, credentials ") == {"tasks", "credentials"} +class TestGqlOperationName: + """mythic's get_operation_name must accept gql 4 ``GraphQLRequest`` and gql 3 ``DocumentNode``.""" + + @pytest.mark.parametrize("build", ["gql", "parse"]) + def test_get_operation_name(self, build): + import asyncio + + from gql import gql + from graphql import parse + from mythic import mythic_utilities + + query = "query GetThing { callback { id } }" + arg = gql(query) if build == "gql" else parse(query) + assert asyncio.run(mythic_utilities.get_operation_name(arg)) == "GetThing" + assert mythic_utilities.get_operation_name is server.mythic_utilities.get_operation_name + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/capabilities/mythic-c2-readonly/skills/mythic-c2-readonly/scripts/mythic_read.py b/capabilities/mythic-c2-readonly/skills/mythic-c2-readonly/scripts/mythic_read.py index 21b3e71..462fd25 100644 --- a/capabilities/mythic-c2-readonly/skills/mythic-c2-readonly/scripts/mythic_read.py +++ b/capabilities/mythic-c2-readonly/skills/mythic-c2-readonly/scripts/mythic_read.py @@ -3,7 +3,7 @@ # requires-python = ">=3.11" # dependencies = [ # "mythic>=0.2", -# "gql[aiohttp,websockets]>=3.0,<4.0", +# "gql[aiohttp,websockets]>=4.0", # "pydantic>=2.0,<3.0", # ] # /// @@ -40,6 +40,31 @@ from mythic import mythic as mythic_sdk, mythic_utilities from pydantic import BaseModel, Field, ValidationInfo, field_validator + +def _patch_mythic_for_gql4() -> None: + """Make mythic's ``get_operation_name`` accept gql 4's ``GraphQLRequest``. + + mythic 0.2.x expects ``gql(query)`` to return a ``DocumentNode`` (gql 3.x). + gql 4.0 returns a ``GraphQLRequest`` wrapper, so the SDK raises + ``AttributeError: 'GraphQLRequest' object has no attribute 'definitions'``. + This shim unwraps ``.document`` and still accepts a plain ``DocumentNode``. + Copy of ``mythic-c2/lib/mythic_api.py`` (capabilities cannot share code, ENG-7049). + + Remove once ``mythic`` ships a release that supports gql 4.0 natively. + """ + + async def _get_operation_name(graphql_data: Any) -> str: + doc = getattr(graphql_data, "document", graphql_data) + definitions = getattr(doc, "definitions", None) or [] + if definitions and getattr(definitions[0], "name", None): + return definitions[0].name.value + return "" + + mythic_utilities.get_operation_name = _get_operation_name + + +_patch_mythic_for_gql4() + _T = TypeVar("_T", bound=BaseModel) TEMP_DIR = Path("/tmp/mythic-readonly") @@ -378,11 +403,12 @@ async def cmd_tasks(client: Any, args: argparse.Namespace) -> None: "operator{username},callback{display_id,host}" ) tasks_raw = await mythic_sdk.get_all_tasks( - client, custom_return_attributes=attrs, + client, + custom_return_attributes=attrs, callback_display_id=args.callback, ) tasks_raw.sort(key=lambda x: x.get("id", 0), reverse=True) - page_raw = tasks_raw[args.offset: args.offset + args.limit] + page_raw = tasks_raw[args.offset : args.offset + args.limit] if args.json or args.detail: print_json(page_raw) @@ -403,13 +429,13 @@ async def cmd_tasks(client: Any, args: argparse.Namespace) -> None: ] print_table(headers, rows, max_widths={2: 60}) if len(tasks_raw) > args.offset + args.limit: - print(f"\n({len(tasks_raw)} total tasks — showing {args.offset + 1}-{args.offset + len(tasks)}, use --offset to page)") + print( + f"\n({len(tasks_raw)} total tasks — showing {args.offset + 1}-{args.offset + len(tasks)}, use --offset to page)" + ) async def cmd_task_output(client: Any, args: argparse.Namespace) -> None: - responses = await mythic_sdk.get_all_task_and_subtask_output_by_id( - mythic=client, task_display_id=args.id - ) + responses = await mythic_sdk.get_all_task_and_subtask_output_by_id(mythic=client, task_display_id=args.id) if not responses: print(f"No output for task {args.id}") return @@ -423,9 +449,9 @@ async def cmd_task_output(client: Any, args: argparse.Namespace) -> None: total = len(lines) if args.offset > 0 or args.max_lines is not None: - sliced = lines[args.offset:] + sliced = lines[args.offset :] if args.max_lines is not None: - sliced = sliced[:args.max_lines] + sliced = sliced[: args.max_lines] print("\n".join(sliced)) shown_end = min(args.offset + len(sliced), total) if shown_end < total: @@ -454,10 +480,7 @@ async def cmd_credentials(client: Any, args: argparse.Namespace) -> None: creds = _parse_list(CredentialInList, creds_raw) headers = ["ID", "TYPE", "REALM", "ACCOUNT", "CREDENTIAL", "COMMENT"] - rows = [ - [str(c.id), c.type, c.realm, c.account, c.credential_text, c.comment] - for c in creds - ] + rows = [[str(c.id), c.type, c.realm, c.account, c.credential_text, c.comment] for c in creds] print_table(headers, rows, max_widths={4: 50, 5: 40}) @@ -478,7 +501,7 @@ async def cmd_files(client: Any, args: argparse.Namespace) -> None: results.extend(batch) if len(results) >= args.limit: break - page_raw = results[:args.limit] + page_raw = results[: args.limit] if args.json or args.detail: print_json(page_raw) @@ -611,7 +634,7 @@ async def cmd_screenshots(client: Any, args: argparse.Namespace) -> None: results.extend(batch) if len(results) >= args.limit: break - page_raw = results[:args.limit] + page_raw = results[: args.limit] if args.json or args.detail: print_json(page_raw) @@ -619,10 +642,7 @@ async def cmd_screenshots(client: Any, args: argparse.Namespace) -> None: screenshots = _parse_list(ScreenshotInList, page_raw) headers = ["FILE_ID", "HOST", "TIMESTAMP"] - rows = [ - [s.agent_file_id, s.host, s.timestamp[:16]] - for s in screenshots - ] + rows = [[s.agent_file_id, s.host, s.timestamp[:16]] for s in screenshots] print_table(headers, rows) @@ -704,14 +724,16 @@ async def cmd_file_browser(client: Any, args: argparse.Namespace) -> None: rows = [] for e in entries: perms = e.metadata.get("permissions", {}) - rows.append([ - e.name_text, - e.full_path_text, - e.host, - "dir" if e.can_have_children else "file", - str(e.metadata.get("size", "")), - perms.get("permissions", "") if isinstance(perms, dict) else "", - ]) + rows.append( + [ + e.name_text, + e.full_path_text, + e.host, + "dir" if e.can_have_children else "file", + str(e.metadata.get("size", "")), + perms.get("permissions", "") if isinstance(perms, dict) else "", + ] + ) print_table(headers, rows, max_widths={1: 50}) @@ -751,10 +773,7 @@ async def cmd_tokens(client: Any, args: argparse.Namespace) -> None: tokens = _parse_list(TokenInList, tokens_raw) headers = ["ID", "USER", "HOST", "GROUPS", "PRIVILEGES", "DESCRIPTION"] - rows = [ - [str(t.id), t.user, t.host, str(t.groups), str(t.privileges), t.description] - for t in tokens - ] + rows = [[str(t.id), t.user, t.host, str(t.groups), str(t.privileges), t.description] for t in tokens] print_table(headers, rows, max_widths={3: 40, 4: 40}) @@ -764,46 +783,66 @@ async def cmd_search(client: Any, args: argparse.Namespace) -> None: queries = {} if "tasks" in types: - queries["tasks"] = gql(client, """ + queries["tasks"] = gql( + client, + """ query SearchTasks($s: String!, $l: Int!) { task(where: {_or: [{display_params: {_ilike: $s}}, {command_name: {_ilike: $s}}, {comment: {_ilike: $s}}]}, order_by: {id: desc}, limit: $l) { display_id, command_name, display_params, status, timestamp, callback { display_id, host } } - }""", {"s": term, "l": args.limit}) + }""", + {"s": term, "l": args.limit}, + ) if "credentials" in types: - queries["credentials"] = gql(client, """ + queries["credentials"] = gql( + client, + """ query SearchCreds($s: String!, $l: Int!) { credential(where: {_or: [{account: {_ilike: $s}}, {realm: {_ilike: $s}}, {credential_text: {_ilike: $s}}, {comment: {_ilike: $s}}]}, order_by: {id: desc}, limit: $l) { id, type, realm, account, credential_text, comment } - }""", {"s": term, "l": args.limit}) + }""", + {"s": term, "l": args.limit}, + ) if "files" in types: - queries["files"] = gql(client, """ + queries["files"] = gql( + client, + """ query SearchFiles($s: String!, $l: Int!) { filemeta(where: {_or: [{filename_utf8: {_ilike: $s}}, {full_remote_path_utf8: {_ilike: $s}}]}, order_by: {id: desc}, limit: $l) { agent_file_id, filename_utf8, full_remote_path_utf8, host, is_download_from_agent } - }""", {"s": term, "l": args.limit}) + }""", + {"s": term, "l": args.limit}, + ) if "artifacts" in types: - queries["artifacts"] = gql(client, """ + queries["artifacts"] = gql( + client, + """ query SearchArtifacts($s: String!, $l: Int!) { taskartifact(where: {_or: [{artifact_text: {_ilike: $s}}, {base_artifact: {_ilike: $s}}]}, order_by: {id: desc}, limit: $l) { id, artifact_text, base_artifact, host } - }""", {"s": term, "l": args.limit}) + }""", + {"s": term, "l": args.limit}, + ) if "keylogs" in types: - queries["keylogs"] = gql(client, """ + queries["keylogs"] = gql( + client, + """ query SearchKeylogs($s: String!, $l: Int!) { keylog(where: {_or: [{keystrokes_text: {_ilike: $s}}, {window: {_ilike: $s}}]}, order_by: {id: desc}, limit: $l) { id, keystrokes_text, window } - }""", {"s": term, "l": args.limit}) + }""", + {"s": term, "l": args.limit}, + ) keys = list(queries.keys()) raw = await asyncio.gather(*queries.values(), return_exceptions=True) @@ -931,11 +970,21 @@ async def main() -> None: client = await connect() commands = { - "status": cmd_status, "callbacks": cmd_callbacks, "callback": cmd_callback, - "tasks": cmd_tasks, "task-output": cmd_task_output, "credentials": cmd_credentials, - "files": cmd_files, "file-contents": cmd_file_contents, "artifacts": cmd_artifacts, - "keylogs": cmd_keylogs, "screenshots": cmd_screenshots, "processes": cmd_processes, - "file-browser": cmd_file_browser, "tokens": cmd_tokens, "search": cmd_search, + "status": cmd_status, + "callbacks": cmd_callbacks, + "callback": cmd_callback, + "tasks": cmd_tasks, + "task-output": cmd_task_output, + "credentials": cmd_credentials, + "files": cmd_files, + "file-contents": cmd_file_contents, + "artifacts": cmd_artifacts, + "keylogs": cmd_keylogs, + "screenshots": cmd_screenshots, + "processes": cmd_processes, + "file-browser": cmd_file_browser, + "tokens": cmd_tokens, + "search": cmd_search, } await commands[args.command](client, args) diff --git a/capabilities/mythic-c2-readonly/skills/mythic-c2-readonly/scripts/test_mythic_read.py b/capabilities/mythic-c2-readonly/skills/mythic-c2-readonly/scripts/test_mythic_read.py index c51e40d..93e3a60 100644 --- a/capabilities/mythic-c2-readonly/skills/mythic-c2-readonly/scripts/test_mythic_read.py +++ b/capabilities/mythic-c2-readonly/skills/mythic-c2-readonly/scripts/test_mythic_read.py @@ -5,7 +5,7 @@ # "pytest>=8.0", # "pytest-asyncio>=0.23", # "mythic>=0.2", -# "gql[aiohttp,websockets]>=3.0,<4.0", +# "gql[aiohttp,websockets]>=4.0", # "pydantic>=2.0,<3.0", # ] # /// @@ -376,7 +376,6 @@ def make_args(**kwargs: Any) -> argparse.Namespace: return argparse.Namespace(**defaults) - # ── Helper unit tests ──────────────────────────────────────────────── @@ -542,10 +541,7 @@ async def test_json_output(self, capsys): @pytest.mark.asyncio async def test_pagination_message(self, capsys): - many_tasks = [ - {**TASKS_DATA[0], "id": i, "display_id": i} - for i in range(30) - ] + many_tasks = [{**TASKS_DATA[0], "id": i, "display_id": i} for i in range(30)] mock_get = AsyncMock(return_value=many_tasks) with patch.object(mythic_read.mythic_sdk, "get_all_tasks", mock_get): await mythic_read.cmd_tasks(MagicMock(), make_args(callback=1, limit=5)) @@ -558,9 +554,7 @@ class TestCmdTaskOutput: @pytest.mark.asyncio async def test_decoded_output(self, capsys): mock_get = AsyncMock(return_value=TASK_OUTPUT_DATA) - with patch.object( - mythic_read.mythic_sdk, "get_all_task_and_subtask_output_by_id", mock_get - ): + with patch.object(mythic_read.mythic_sdk, "get_all_task_and_subtask_output_by_id", mock_get): await mythic_read.cmd_task_output(MagicMock(), make_args(id=1)) out = capsys.readouterr().out # base64 "a2FsaQ==" should decode to "kali" @@ -570,9 +564,7 @@ async def test_decoded_output(self, capsys): @pytest.mark.asyncio async def test_no_output(self, capsys): mock_get = AsyncMock(return_value=[]) - with patch.object( - mythic_read.mythic_sdk, "get_all_task_and_subtask_output_by_id", mock_get - ): + with patch.object(mythic_read.mythic_sdk, "get_all_task_and_subtask_output_by_id", mock_get): await mythic_read.cmd_task_output(MagicMock(), make_args(id=999)) out = capsys.readouterr().out assert "No output" in out @@ -581,12 +573,8 @@ async def test_no_output(self, capsys): async def test_offset_and_max_lines(self, capsys): lines = [{"response_text": f"line{i}"} for i in range(20)] mock_get = AsyncMock(return_value=lines) - with patch.object( - mythic_read.mythic_sdk, "get_all_task_and_subtask_output_by_id", mock_get - ): - await mythic_read.cmd_task_output( - MagicMock(), make_args(id=1, offset=5, max_lines=3) - ) + with patch.object(mythic_read.mythic_sdk, "get_all_task_and_subtask_output_by_id", mock_get): + await mythic_read.cmd_task_output(MagicMock(), make_args(id=1, offset=5, max_lines=3)) out = capsys.readouterr().out assert "line5" in out assert "line7" in out @@ -790,9 +778,7 @@ async def test_table_output(self, capsys): async def test_host_and_path_filters(self): mock_gql = AsyncMock(return_value=FILE_BROWSER_DATA) with patch.object(mythic_read.mythic_utilities, "graphql_post", mock_gql): - await mythic_read.cmd_file_browser( - MagicMock(), make_args(host="KALI", path="/opt", limit=100) - ) + await mythic_read.cmd_file_browser(MagicMock(), make_args(host="KALI", path="/opt", limit=100)) call_args = mock_gql.call_args args_str = str(call_args) assert "%KALI%" in args_str @@ -891,10 +877,21 @@ class TestBuildParser: def test_all_commands_registered(self): parser = mythic_read.build_parser() commands = { - "status", "callbacks", "callback", "tasks", "task-output", - "credentials", "files", "file-contents", "artifacts", - "keylogs", "screenshots", "processes", "file-browser", - "tokens", "search", + "status", + "callbacks", + "callback", + "tasks", + "task-output", + "credentials", + "files", + "file-contents", + "artifacts", + "keylogs", + "screenshots", + "processes", + "file-browser", + "tokens", + "search", } # Parse each command to verify it's registered for cmd in commands: @@ -927,5 +924,22 @@ def test_detail_flag(self): assert args.detail is True +class TestGqlOperationName: + """mythic's get_operation_name must accept gql 4 ``GraphQLRequest`` and gql 3 ``DocumentNode``.""" + + @pytest.mark.parametrize("build", ["gql", "parse"]) + def test_get_operation_name(self, build): + import asyncio + + from gql import gql + from graphql import parse + from mythic import mythic_utilities + + query = "query GetThing { callback { id } }" + arg = gql(query) if build == "gql" else parse(query) + assert asyncio.run(mythic_utilities.get_operation_name(arg)) == "GetThing" + assert mythic_utilities.get_operation_name is mythic_read.mythic_utilities.get_operation_name + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/capabilities/mythic-c2/capability.yaml b/capabilities/mythic-c2/capability.yaml index 6c1b0b6..cc8aa31 100644 --- a/capabilities/mythic-c2/capability.yaml +++ b/capabilities/mythic-c2/capability.yaml @@ -1,6 +1,6 @@ schema: 1 name: mythic-c2 -version: "2.1.0" +version: "2.1.1" description: > Mythic C2 integration. Read-only observation (callbacks, tasks, credentials, files, artifacts, keylogs, screenshots, processes, tokens, diff --git a/capabilities/mythic-c2/mcp_server.py b/capabilities/mythic-c2/mcp_server.py index 26d6aee..f685a13 100644 --- a/capabilities/mythic-c2/mcp_server.py +++ b/capabilities/mythic-c2/mcp_server.py @@ -2,7 +2,7 @@ # /// script # requires-python = ">=3.11" # dependencies = [ -# "fastmcp>=2.0", +# "fastmcp>=3.2.0", # "mythic>=0.2", # ] # /// diff --git a/capabilities/mythic-c2/test_mcp.py b/capabilities/mythic-c2/test_mcp.py index 1e4f47e..2bab92d 100644 --- a/capabilities/mythic-c2/test_mcp.py +++ b/capabilities/mythic-c2/test_mcp.py @@ -4,7 +4,7 @@ # dependencies = [ # "pytest>=8.0", # "pytest-asyncio>=0.23", -# "fastmcp>=2.0", +# "fastmcp>=3.2.0", # "mythic>=0.2", # ] # ///