Some checks are pending
Build Ghidra Plugin / build (push) Waiting to run
- http_client: Defensive copy before .pop() to avoid mutating caller's dict - analysis.py: Add debug logging for fallback paths instead of silent swallow - docker.py: Add debug logging to PortPool exception handlers - docker.py: Fix file descriptor leak in _try_acquire_port with inner try/except - docker.py: Lazy PortPool initialization via property to avoid side effects - server.py: Wrap initial discovery in _instances_lock for thread safety - server.py: Call configure_logging() at startup with GHYDRAMCP_DEBUG support - pagination.py: Use SHA-256 instead of MD5 for query hash consistency - base.py: Add proper type annotations (Dict[str, Any]) - filtering.py: Use List[str] from typing for consistency - filtering.py: Add docstrings to private helper methods - structs.py: Rename project_fields param to fields for API consistency - logging.py: Fix import path from deprecated mcp.server.fastmcp to fastmcp
404 lines
13 KiB
Python
404 lines
13 KiB
Python
"""Analysis mixin for GhydraMCP.
|
|
|
|
Provides tools for program analysis operations.
|
|
"""
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastmcp import Context
|
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
|
|
|
from ..config import get_config
|
|
from ..core.logging import logger
|
|
from .base import GhydraMixinBase
|
|
|
|
|
|
class AnalysisMixin(GhydraMixinBase):
|
|
"""Mixin for analysis operations.
|
|
|
|
Provides tools for:
|
|
- Running program analysis
|
|
- Call graph analysis
|
|
- Data flow analysis
|
|
- UI state queries
|
|
- Comment management
|
|
"""
|
|
|
|
@mcp_tool()
|
|
def analysis_run(
|
|
self,
|
|
port: Optional[int] = None,
|
|
analysis_options: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Run analysis on the current program.
|
|
|
|
Args:
|
|
port: Ghidra instance port (optional)
|
|
analysis_options: Analysis options to enable/disable
|
|
|
|
Returns:
|
|
Analysis operation result
|
|
"""
|
|
try:
|
|
port = self.get_instance_port(port)
|
|
except ValueError as e:
|
|
return {"success": False, "error": {"code": "NO_INSTANCE", "message": str(e)}}
|
|
|
|
response = self.safe_post(port, "analysis", analysis_options or {})
|
|
return self.simplify_response(response)
|
|
|
|
@mcp_tool()
|
|
def analysis_get_callgraph(
|
|
self,
|
|
name: Optional[str] = None,
|
|
address: Optional[str] = None,
|
|
max_depth: int = 3,
|
|
port: Optional[int] = None,
|
|
page_size: int = 50,
|
|
grep: Optional[str] = None,
|
|
grep_ignorecase: bool = True,
|
|
return_all: bool = False,
|
|
fields: Optional[List[str]] = None,
|
|
ctx: Optional[Context] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Get function call graph with edge pagination.
|
|
|
|
Args:
|
|
name: Starting function name (mutually exclusive with address)
|
|
address: Starting function address
|
|
max_depth: Maximum call depth (default: 3)
|
|
port: Ghidra instance port (optional)
|
|
page_size: Edges per page (default: 50, max: 500)
|
|
grep: Regex pattern to filter edges
|
|
grep_ignorecase: Case-insensitive grep (default: True)
|
|
return_all: Return all edges without pagination
|
|
fields: Field names to keep per edge (e.g. ['from', 'to']). Reduces response size.
|
|
ctx: FastMCP context (auto-injected)
|
|
|
|
Returns:
|
|
Call graph with paginated edges
|
|
"""
|
|
try:
|
|
port = self.get_instance_port(port)
|
|
except ValueError as e:
|
|
return {"success": False, "error": {"code": "NO_INSTANCE", "message": str(e)}}
|
|
|
|
config = get_config()
|
|
|
|
params = {"max_depth": max_depth}
|
|
if address:
|
|
params["address"] = address
|
|
func_id = address
|
|
elif name:
|
|
params["name"] = name
|
|
func_id = name
|
|
else:
|
|
func_id = "entry_point"
|
|
|
|
response = self.safe_get(port, "analysis/callgraph", params)
|
|
simplified = self.simplify_response(response)
|
|
|
|
if not simplified.get("success", True):
|
|
return simplified
|
|
|
|
result = simplified.get("result", {})
|
|
edges = result.get("edges", []) if isinstance(result, dict) else []
|
|
nodes = result.get("nodes", []) if isinstance(result, dict) else []
|
|
|
|
if not edges:
|
|
return simplified
|
|
|
|
query_params = {
|
|
"tool": "analysis_get_callgraph",
|
|
"port": port,
|
|
"name": name,
|
|
"address": address,
|
|
"max_depth": max_depth,
|
|
"grep": grep,
|
|
}
|
|
session_id = self._get_session_id(ctx)
|
|
|
|
paginated = self.filtered_paginate(
|
|
data=edges,
|
|
query_params=query_params,
|
|
tool_name="analysis_get_callgraph",
|
|
session_id=session_id,
|
|
page_size=min(page_size, config.max_page_size),
|
|
grep=grep,
|
|
grep_ignorecase=grep_ignorecase,
|
|
return_all=return_all,
|
|
fields=fields,
|
|
)
|
|
|
|
if paginated.get("success") and not paginated.get("guarded"):
|
|
paginated["result"] = {
|
|
"root_function": func_id,
|
|
"max_depth": max_depth,
|
|
"nodes": nodes,
|
|
"edges": paginated.get("result", []),
|
|
"total_nodes": len(nodes),
|
|
}
|
|
|
|
return paginated
|
|
|
|
@mcp_tool()
|
|
def analysis_get_dataflow(
|
|
self,
|
|
address: str,
|
|
direction: str = "forward",
|
|
max_steps: int = 50,
|
|
port: Optional[int] = None,
|
|
page_size: int = 50,
|
|
grep: Optional[str] = None,
|
|
grep_ignorecase: bool = True,
|
|
return_all: bool = False,
|
|
fields: Optional[List[str]] = None,
|
|
ctx: Optional[Context] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Perform data flow analysis with step pagination.
|
|
|
|
Args:
|
|
address: Starting address in hex format
|
|
direction: "forward" or "backward" (default: "forward")
|
|
max_steps: Maximum analysis steps (default: 50)
|
|
port: Ghidra instance port (optional)
|
|
page_size: Steps per page (default: 50, max: 500)
|
|
grep: Regex pattern to filter steps
|
|
grep_ignorecase: Case-insensitive grep (default: True)
|
|
return_all: Return all steps without pagination
|
|
fields: Field names to keep per step. Reduces response size.
|
|
ctx: FastMCP context (auto-injected)
|
|
|
|
Returns:
|
|
Data flow steps with pagination
|
|
"""
|
|
if not address:
|
|
return {
|
|
"success": False,
|
|
"error": {
|
|
"code": "MISSING_PARAMETER",
|
|
"message": "Address parameter is required",
|
|
},
|
|
}
|
|
|
|
try:
|
|
port = self.get_instance_port(port)
|
|
except ValueError as e:
|
|
return {"success": False, "error": {"code": "NO_INSTANCE", "message": str(e)}}
|
|
|
|
config = get_config()
|
|
|
|
params = {
|
|
"address": address,
|
|
"direction": direction,
|
|
"max_steps": max_steps,
|
|
}
|
|
|
|
response = self.safe_get(port, "analysis/dataflow", params)
|
|
simplified = self.simplify_response(response)
|
|
|
|
if not simplified.get("success", True):
|
|
return simplified
|
|
|
|
result = simplified.get("result", {})
|
|
steps = result.get("steps", []) if isinstance(result, dict) else []
|
|
|
|
if not steps:
|
|
return simplified
|
|
|
|
query_params = {
|
|
"tool": "analysis_get_dataflow",
|
|
"port": port,
|
|
"address": address,
|
|
"direction": direction,
|
|
"max_steps": max_steps,
|
|
"grep": grep,
|
|
}
|
|
session_id = self._get_session_id(ctx)
|
|
|
|
paginated = self.filtered_paginate(
|
|
data=steps,
|
|
query_params=query_params,
|
|
tool_name="analysis_get_dataflow",
|
|
session_id=session_id,
|
|
page_size=min(page_size, config.max_page_size),
|
|
grep=grep,
|
|
grep_ignorecase=grep_ignorecase,
|
|
return_all=return_all,
|
|
fields=fields,
|
|
)
|
|
|
|
# Merge metadata into result (skip if guarded)
|
|
if paginated.get("success") and not paginated.get("guarded"):
|
|
paginated["result"] = {
|
|
"start_address": address,
|
|
"direction": direction,
|
|
"steps": paginated.get("result", []),
|
|
}
|
|
if isinstance(result, dict):
|
|
for key in ["sources", "sinks", "total_steps"]:
|
|
if key in result:
|
|
paginated["result"][key] = result[key]
|
|
|
|
return paginated
|
|
|
|
@mcp_tool()
|
|
def ui_get_current_address(self, port: Optional[int] = None) -> Dict[str, Any]:
|
|
"""Get the address currently selected in Ghidra's UI.
|
|
|
|
Args:
|
|
port: Ghidra instance port (optional)
|
|
|
|
Returns:
|
|
Current address information
|
|
"""
|
|
try:
|
|
port = self.get_instance_port(port)
|
|
except ValueError as e:
|
|
return {"success": False, "error": {"code": "NO_INSTANCE", "message": str(e)}}
|
|
|
|
response = self.safe_get(port, "address")
|
|
return self.simplify_response(response)
|
|
|
|
@mcp_tool()
|
|
def ui_get_current_function(self, port: Optional[int] = None) -> Dict[str, Any]:
|
|
"""Get the function currently selected in Ghidra's UI.
|
|
|
|
Args:
|
|
port: Ghidra instance port (optional)
|
|
|
|
Returns:
|
|
Current function information
|
|
"""
|
|
try:
|
|
port = self.get_instance_port(port)
|
|
except ValueError as e:
|
|
return {"success": False, "error": {"code": "NO_INSTANCE", "message": str(e)}}
|
|
|
|
response = self.safe_get(port, "function")
|
|
return self.simplify_response(response)
|
|
|
|
@mcp_tool()
|
|
def comments_get(
|
|
self,
|
|
address: str,
|
|
comment_type: str = "plate",
|
|
port: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Get a comment at the specified address.
|
|
|
|
Args:
|
|
address: Memory address in hex format
|
|
comment_type: "plate", "pre", "post", "eol", "repeatable"
|
|
port: Ghidra instance port (optional)
|
|
|
|
Returns:
|
|
Comment text and metadata
|
|
"""
|
|
if not address:
|
|
return {
|
|
"success": False,
|
|
"error": {
|
|
"code": "MISSING_PARAMETER",
|
|
"message": "Address parameter is required",
|
|
},
|
|
}
|
|
|
|
try:
|
|
port = self.get_instance_port(port)
|
|
except ValueError as e:
|
|
return {"success": False, "error": {"code": "NO_INSTANCE", "message": str(e)}}
|
|
|
|
response = self.safe_get(port, f"memory/{address}/comments/{comment_type}")
|
|
return self.simplify_response(response)
|
|
|
|
@mcp_tool()
|
|
def comments_set(
|
|
self,
|
|
address: str,
|
|
comment: str = "",
|
|
comment_type: str = "plate",
|
|
port: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Set a comment at the specified address.
|
|
|
|
Args:
|
|
address: Memory address in hex format
|
|
comment: Comment text (empty string removes comment)
|
|
comment_type: "plate", "pre", "post", "eol", "repeatable"
|
|
port: Ghidra instance port (optional)
|
|
|
|
Returns:
|
|
Operation result
|
|
"""
|
|
if not address:
|
|
return {
|
|
"success": False,
|
|
"error": {
|
|
"code": "MISSING_PARAMETER",
|
|
"message": "Address parameter is required",
|
|
},
|
|
}
|
|
|
|
try:
|
|
port = self.get_instance_port(port)
|
|
except ValueError as e:
|
|
return {"success": False, "error": {"code": "NO_INSTANCE", "message": str(e)}}
|
|
|
|
payload = {"comment": comment}
|
|
response = self.safe_post(port, f"memory/{address}/comments/{comment_type}", payload)
|
|
return self.simplify_response(response)
|
|
|
|
@mcp_tool()
|
|
def functions_set_comment(
|
|
self,
|
|
address: str,
|
|
comment: str = "",
|
|
port: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Set a decompiler-friendly comment (function comment with fallback).
|
|
|
|
Args:
|
|
address: Memory address (preferably function entry point)
|
|
comment: Comment text (empty string removes comment)
|
|
port: Ghidra instance port (optional)
|
|
|
|
Returns:
|
|
Operation result
|
|
"""
|
|
if not address:
|
|
return {
|
|
"success": False,
|
|
"error": {
|
|
"code": "MISSING_PARAMETER",
|
|
"message": "Address parameter is required",
|
|
},
|
|
}
|
|
|
|
try:
|
|
port = self.get_instance_port(port)
|
|
except ValueError as e:
|
|
return {"success": False, "error": {"code": "NO_INSTANCE", "message": str(e)}}
|
|
|
|
# Try setting as function comment first
|
|
payload = {"comment": comment}
|
|
response = self.safe_patch(port, f"functions/{address}", payload)
|
|
if response.get("success", False):
|
|
return self.simplify_response(response)
|
|
|
|
# Log why function comment failed before falling back
|
|
error = response.get("error", {})
|
|
logger.debug(
|
|
"Function comment at %s failed (%s), falling back to pre-comment",
|
|
address,
|
|
error.get("code", "UNKNOWN"),
|
|
)
|
|
|
|
# Fallback to pre-comment
|
|
return self.comments_set(
|
|
address=address,
|
|
comment=comment,
|
|
comment_type="pre",
|
|
port=port,
|
|
)
|