greydgl--pentestgpt
492 行
17 KiB
Python
492 行
17 KiB
Python
"""Main TUI application for PentestGPT using Textual framework."""
|
|
|
|
import asyncio
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
|
|
from rich.text import Text
|
|
from textual import events
|
|
from textual.app import App, ComposeResult
|
|
from textual.binding import Binding
|
|
from textual.containers import Grid, Vertical, VerticalScroll
|
|
from textual.reactive import reactive
|
|
from textual.screen import ModalScreen
|
|
from textual.widgets import Button, Input, Label, Static
|
|
|
|
from pentestgpt.core.controller import AgentController
|
|
from pentestgpt.core.events import Event, EventBus, EventType
|
|
from pentestgpt.interface.components.activity_feed import ActivityFeed
|
|
from pentestgpt.interface.components.splash import SplashScreen
|
|
|
|
|
|
class HelpScreen(ModalScreen[None]):
|
|
"""Modal screen showing keyboard shortcuts and help."""
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create the help dialog."""
|
|
yield Grid(
|
|
Label("🚩 PentestGPT CTF Solver - Help", id="help_title"),
|
|
Label(
|
|
"F1 Help\n"
|
|
"Ctrl+P Pause/Resume agent\n"
|
|
"Ctrl+Q Quit\n"
|
|
"Ctrl+C Quit\n"
|
|
"↑/↓ Scroll feed\n"
|
|
"PgUp/PgDn Fast scroll\n"
|
|
"Enter Send instruction (when paused)",
|
|
id="help_content",
|
|
),
|
|
id="help_dialog",
|
|
)
|
|
|
|
def on_key(self, _event: events.Key) -> None:
|
|
"""Close help on any key press."""
|
|
self.app.pop_screen()
|
|
|
|
|
|
class QuitScreen(ModalScreen[None]):
|
|
"""Modal screen for quit confirmation."""
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create the quit confirmation dialog."""
|
|
yield Grid(
|
|
Label("🚩 Quit PentestGPT CTF Solver?", id="quit_title"),
|
|
Grid(
|
|
Button("Yes", variant="error", id="btn_quit_confirm"),
|
|
Button("No", variant="default", id="btn_quit_cancel"),
|
|
id="quit_buttons",
|
|
),
|
|
id="quit_dialog",
|
|
)
|
|
|
|
def on_mount(self) -> None:
|
|
"""Focus cancel button by default."""
|
|
cancel_button = self.query_one("#btn_quit_cancel", Button)
|
|
cancel_button.focus()
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
"""Handle button clicks."""
|
|
if event.button.id == "btn_quit_confirm":
|
|
self.app.exit()
|
|
else:
|
|
self.app.pop_screen()
|
|
|
|
|
|
class PentestGPTApp(App[None]):
|
|
"""Main TUI application for PentestGPT CTF Solver."""
|
|
|
|
CSS_PATH = "styles.tcss"
|
|
TITLE = "PentestGPT - CTF Challenge Solver"
|
|
|
|
show_splash: reactive[bool] = reactive(default=True)
|
|
agent_state: reactive[str] = reactive(default="idle")
|
|
|
|
BINDINGS: ClassVar[list[Binding | tuple[str, str] | tuple[str, str, str]]] = [
|
|
Binding("f1", "toggle_help", "Help", priority=True),
|
|
Binding("ctrl+p", "toggle_pause", "Pause/Resume", priority=True),
|
|
Binding("ctrl+q", "request_quit", "Quit", priority=True),
|
|
Binding("ctrl+c", "request_quit", "Quit", priority=True),
|
|
]
|
|
|
|
def __init__(
|
|
self,
|
|
target: str,
|
|
custom_instruction: str | None = None,
|
|
model: str | None = None,
|
|
debug: bool = False,
|
|
resume_session: str | None = None,
|
|
) -> None:
|
|
"""Initialize the TUI app."""
|
|
super().__init__()
|
|
self.target = target
|
|
self.custom_instruction = custom_instruction
|
|
self.model = model
|
|
self._debug = debug
|
|
self.resume_session = resume_session
|
|
|
|
self._agent_thread: threading.Thread | None = None
|
|
self._agent_stop_event = threading.Event()
|
|
self._activity_feed: ActivityFeed | None = None
|
|
self._controller: AgentController | None = None
|
|
self._events: EventBus | None = None # Initialize in on_mount
|
|
|
|
def _setup_event_handlers(self) -> None:
|
|
"""Subscribe to agent events for UI updates."""
|
|
if self._events is None:
|
|
return
|
|
self._events.subscribe(EventType.STATE_CHANGED, self._on_state_change)
|
|
self._events.subscribe(EventType.MESSAGE, self._on_agent_message)
|
|
self._events.subscribe(EventType.FLAG_FOUND, self._on_flag)
|
|
self._events.subscribe(EventType.TOOL, self._on_tool)
|
|
|
|
def _on_state_change(self, event: Event) -> None:
|
|
"""Handle agent state changes."""
|
|
state = event.data.get("state", "unknown")
|
|
details = event.data.get("details", "")
|
|
|
|
# Update reactive state (triggers watch_agent_state)
|
|
self.call_from_thread(setattr, self, "agent_state", state)
|
|
|
|
# Log state change
|
|
if self._activity_feed and details:
|
|
self.call_from_thread(
|
|
self._activity_feed.add_message,
|
|
f"Agent: {details}",
|
|
"info",
|
|
)
|
|
|
|
def _on_agent_message(self, event: Event) -> None:
|
|
"""Handle agent messages."""
|
|
if not self._activity_feed:
|
|
return
|
|
text = event.data.get("text", "")
|
|
msg_type = event.data.get("type", "info")
|
|
if text:
|
|
self.call_from_thread(
|
|
self._activity_feed.add_message,
|
|
text,
|
|
msg_type,
|
|
)
|
|
|
|
def _on_flag(self, event: Event) -> None:
|
|
"""Handle flag found events."""
|
|
if not self._activity_feed:
|
|
return
|
|
flag = event.data.get("flag", "")
|
|
if flag:
|
|
self.call_from_thread(
|
|
self._activity_feed.add_message,
|
|
f"🚩 FLAG FOUND: {flag}",
|
|
"success",
|
|
)
|
|
|
|
def _on_tool(self, event: Event) -> None:
|
|
"""Handle tool events."""
|
|
if not self._activity_feed:
|
|
return
|
|
status = event.data.get("status", "")
|
|
name = event.data.get("name", "")
|
|
args = event.data.get("args", {})
|
|
result = event.data.get("result")
|
|
|
|
if status == "start":
|
|
self.call_from_thread(
|
|
self._activity_feed.add_tool_execution,
|
|
name,
|
|
args,
|
|
"running",
|
|
None,
|
|
)
|
|
elif status == "complete":
|
|
self.call_from_thread(
|
|
self._activity_feed.add_tool_execution,
|
|
name,
|
|
args,
|
|
"completed",
|
|
result,
|
|
)
|
|
|
|
def watch_agent_state(self, state: str) -> None:
|
|
"""React to agent state changes - show/hide input field."""
|
|
try:
|
|
user_input = self.query_one("#user_input", Input)
|
|
# Show input when paused, hide otherwise
|
|
user_input.display = state == "paused"
|
|
if state == "paused":
|
|
user_input.focus()
|
|
except Exception:
|
|
pass
|
|
|
|
# Update status bar
|
|
self._update_status_bar()
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create the initial UI layout."""
|
|
if self.show_splash:
|
|
yield SplashScreen(id="splash_screen")
|
|
|
|
def watch_show_splash(self, show_splash: bool) -> None:
|
|
"""React to splash screen visibility changes."""
|
|
if not show_splash and hasattr(self, "_driver") and self._driver is not None:
|
|
# Remove splash screen
|
|
try:
|
|
splash = self.query_one("#splash_screen")
|
|
splash.remove()
|
|
except Exception:
|
|
pass
|
|
|
|
# Build main interface
|
|
self._build_main_interface()
|
|
|
|
def _build_main_interface(self) -> None:
|
|
"""Build the main application interface."""
|
|
# Main container
|
|
main_container = Vertical(id="main_container")
|
|
self.mount(main_container)
|
|
|
|
# Header
|
|
header = self._create_header()
|
|
main_container.mount(header)
|
|
|
|
# Activity feed
|
|
feed = ActivityFeed(id="activity_feed")
|
|
self._activity_feed = feed
|
|
|
|
content_area = VerticalScroll(feed, id="content_area")
|
|
main_container.mount(content_area)
|
|
|
|
# User input field (hidden by default, shown when paused)
|
|
user_input = Input(
|
|
placeholder="Type instruction and press Enter...",
|
|
id="user_input",
|
|
)
|
|
user_input.display = False
|
|
main_container.mount(user_input)
|
|
|
|
# Status bar
|
|
status_bar = self._create_status_bar()
|
|
main_container.mount(status_bar)
|
|
|
|
def _create_header(self) -> Static:
|
|
"""Create the header bar."""
|
|
header_text = Text()
|
|
header_text.append("🚩 ", style="bold #6366f1")
|
|
header_text.append("PentestGPT", style="bold #6366f1")
|
|
header_text.append(" CTF Solver", style="bold #6366f1")
|
|
header_text.append(" v1.0", style="dim")
|
|
header_text.append(" │ ", style="dim")
|
|
header_text.append("Target: ", style="dim")
|
|
header_text.append(self.target, style="bold")
|
|
|
|
if self.model:
|
|
header_text.append(" │ ", style="dim")
|
|
header_text.append(f"Model: {self.model}", style="dim")
|
|
|
|
header = Static(header_text, id="header")
|
|
return header
|
|
|
|
def _create_status_bar(self) -> Static:
|
|
"""Create the status bar."""
|
|
status_text = self._build_status_text()
|
|
status_bar = Static(status_text, id="status_bar")
|
|
return status_bar
|
|
|
|
def _build_status_text(self) -> Text:
|
|
"""Build status bar text based on current state."""
|
|
status_text = Text()
|
|
|
|
# Agent state
|
|
status_text.append("Agent: ", style="dim")
|
|
state_styles = {
|
|
"idle": ("Idle", "dim"),
|
|
"running": ("Running", "bold #6366f1"),
|
|
"paused": ("PAUSED", "bold #f59e0b"),
|
|
"completed": ("Completed", "bold #10b981"),
|
|
"error": ("Error", "bold #ef4444"),
|
|
}
|
|
state_label, state_style = state_styles.get(self.agent_state, ("Unknown", "dim"))
|
|
status_text.append(state_label, style=state_style)
|
|
|
|
if self._debug:
|
|
from pentestgpt.core.agent import DEBUG_LOG
|
|
|
|
status_text.append(" │ ", style="dim")
|
|
status_text.append("Debug: ", style="dim")
|
|
status_text.append(str(DEBUG_LOG), style="bold #f59e0b")
|
|
|
|
status_text.append(" │ ", style="dim")
|
|
status_text.append("Ctrl+P", style="bold #8b5cf6")
|
|
status_text.append(" Pause ", style="dim")
|
|
status_text.append("F1", style="bold #8b5cf6")
|
|
status_text.append(" Help ", style="dim")
|
|
status_text.append("Ctrl+Q", style="bold #8b5cf6")
|
|
status_text.append(" Quit", style="dim")
|
|
|
|
return status_text
|
|
|
|
def _update_status_bar(self) -> None:
|
|
"""Update status bar with current state."""
|
|
try:
|
|
status_bar = self.query_one("#status_bar", Static)
|
|
status_bar.update(self._build_status_text())
|
|
except Exception:
|
|
pass
|
|
|
|
def on_mount(self) -> None:
|
|
"""Handle app mount - show splash then start agent."""
|
|
# Initialize event bus after app is mounted
|
|
self._events = EventBus.get()
|
|
self._setup_event_handlers()
|
|
|
|
# Show splash for 2.5 seconds
|
|
self.set_timer(2.5, self._hide_splash)
|
|
|
|
def _hide_splash(self) -> None:
|
|
"""Hide splash screen and start main app."""
|
|
self.show_splash = False
|
|
# Start agent after splash
|
|
self.call_later(self._start_agent)
|
|
|
|
def _start_agent(self) -> None:
|
|
"""Start the agent in a background thread using AgentController."""
|
|
from pentestgpt.core.config import load_config
|
|
|
|
if self._activity_feed:
|
|
self._activity_feed.add_message(
|
|
"🚩 CTF Solver initializing...",
|
|
message_type="info",
|
|
)
|
|
|
|
def run_agent() -> None:
|
|
"""Run the agent with controller."""
|
|
try:
|
|
# Build config
|
|
config_kwargs: dict[str, Any] = {
|
|
"target": self.target,
|
|
"working_directory": Path("/workspace"),
|
|
}
|
|
if self.custom_instruction:
|
|
config_kwargs["custom_instruction"] = self.custom_instruction
|
|
if self.model:
|
|
config_kwargs["llm_model"] = self.model
|
|
|
|
config = load_config(**config_kwargs)
|
|
|
|
# Create controller
|
|
self._controller = AgentController(config)
|
|
|
|
# Build task
|
|
task = f"Solve this CTF challenge and capture the flag(s): {self.target}"
|
|
if self.custom_instruction:
|
|
task += f"\n\nChallenge context: {self.custom_instruction}"
|
|
|
|
# Run agent with controller
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
result = loop.run_until_complete(
|
|
self._controller.run(task, resume_session_id=self.resume_session)
|
|
)
|
|
loop.close()
|
|
|
|
# Show final result summary
|
|
if self._activity_feed:
|
|
if result.get("success"):
|
|
flags = result.get("flags_found", [])
|
|
cost = result.get("cost_usd", 0)
|
|
session_id = result.get("session_id", "")
|
|
if flags:
|
|
self.call_from_thread(
|
|
self._activity_feed.add_message,
|
|
f"🚩 Challenge complete! {len(flags)} flag(s) | Cost: ${cost:.4f} | Session: {session_id}",
|
|
"success",
|
|
)
|
|
else:
|
|
self.call_from_thread(
|
|
self._activity_feed.add_message,
|
|
f"⚠ Challenge ended without flags | Cost: ${cost:.4f} | Session: {session_id}",
|
|
"warning",
|
|
)
|
|
else:
|
|
error = result.get("error", "Unknown error")
|
|
self.call_from_thread(
|
|
self._activity_feed.add_message,
|
|
f"✗ Agent error: {error}",
|
|
"error",
|
|
)
|
|
|
|
except Exception as e:
|
|
if self._activity_feed:
|
|
self.call_from_thread(
|
|
self._activity_feed.add_message,
|
|
f"✗ Agent error: {e!s}",
|
|
"error",
|
|
)
|
|
|
|
self._agent_thread = threading.Thread(target=run_agent, daemon=True)
|
|
self._agent_thread.start()
|
|
|
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
"""Handle user input submission."""
|
|
if event.input.id == "user_input":
|
|
text = event.value.strip()
|
|
if text and self._events:
|
|
# Emit user input event
|
|
self._events.emit_input(text)
|
|
if self._activity_feed:
|
|
self._activity_feed.add_message(
|
|
f"📝 Instruction queued: {text[:50]}...",
|
|
message_type="info",
|
|
)
|
|
event.input.value = ""
|
|
|
|
def action_toggle_pause(self) -> None:
|
|
"""Toggle pause/resume state (Ctrl+P)."""
|
|
if self.show_splash or not self._controller:
|
|
return
|
|
|
|
if self.agent_state == "paused":
|
|
self._controller.resume()
|
|
elif self.agent_state == "running":
|
|
self._controller.pause()
|
|
|
|
def action_toggle_help(self) -> None:
|
|
"""Show/hide help screen."""
|
|
if self.show_splash:
|
|
return
|
|
|
|
if isinstance(self.screen, HelpScreen):
|
|
self.pop_screen()
|
|
else:
|
|
self.push_screen(HelpScreen())
|
|
|
|
def action_request_quit(self) -> None:
|
|
"""Show quit confirmation."""
|
|
if self.show_splash:
|
|
self.exit()
|
|
return
|
|
|
|
self.push_screen(QuitScreen())
|
|
|
|
def on_unmount(self) -> None:
|
|
"""Cleanup when app closes."""
|
|
# Signal agent to stop
|
|
self._agent_stop_event.set()
|
|
|
|
# Wait for agent thread
|
|
if self._agent_thread and self._agent_thread.is_alive():
|
|
self._agent_thread.join(timeout=2.0)
|
|
|
|
|
|
async def run_tui(
|
|
target: str,
|
|
custom_instruction: str | None = None,
|
|
model: str | None = None,
|
|
debug: bool = False,
|
|
resume_session: str | None = None,
|
|
) -> None:
|
|
"""Run the TUI application.
|
|
|
|
Args:
|
|
target: Target URL/IP/domain
|
|
custom_instruction: Optional challenge context
|
|
model: Optional model override
|
|
debug: Enable debug mode
|
|
resume_session: Optional session ID to resume
|
|
"""
|
|
app = PentestGPTApp(
|
|
target=target,
|
|
custom_instruction=custom_instruction,
|
|
model=model,
|
|
debug=debug,
|
|
resume_session=resume_session,
|
|
)
|
|
await app.run_async()
|
|
|
|
|
|
# For testing
|
|
if __name__ == "__main__":
|
|
import asyncio
|
|
|
|
asyncio.run(run_tui("example.com"))
|