greydgl--pentestgpt
471 行
14 KiB
Python
471 行
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Main CLI entry point for PentestGPT."""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import sys
|
|
|
|
from rich.console import Console
|
|
from rich.text import Text
|
|
|
|
|
|
def parse_arguments() -> argparse.Namespace:
|
|
"""Parse command line arguments."""
|
|
parser = argparse.ArgumentParser(
|
|
prog="pentestgpt",
|
|
description="PentestGPT - AI-Powered CTF Challenge Solver",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Interactive TUI mode (default) - HTB machine
|
|
pentestgpt --target 10.10.11.234
|
|
|
|
# Web challenge
|
|
pentestgpt --target https://ctf.example.com/challenge1
|
|
|
|
# With challenge context/hints
|
|
pentestgpt --target 10.10.11.100 --instruction "Wordpress site, focus on plugin vulnerabilities"
|
|
|
|
# Non-interactive mode
|
|
pentestgpt --target challenge.htb --non-interactive
|
|
|
|
# Custom model
|
|
pentestgpt --target 10.10.11.50 --model claude-opus-4-20250514
|
|
|
|
For more information: https://github.com/yourusername/pentestgpt
|
|
""",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-t",
|
|
"--target",
|
|
type=str,
|
|
required=True,
|
|
help="Target CTF challenge or machine (URL, IP address, domain, or file path)",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-i",
|
|
"--instruction",
|
|
type=str,
|
|
help="Custom challenge context, hints, or instructions",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-m",
|
|
"--model",
|
|
type=str,
|
|
help="Claude model to use (default: claude-sonnet-4-5-20250929)",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-n",
|
|
"--non-interactive",
|
|
action="store_true",
|
|
help="Run in non-interactive mode (no TUI, exits on completion)",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-v",
|
|
"--verbose",
|
|
action="store_true",
|
|
help="Enable verbose output",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-d",
|
|
"--debug",
|
|
action="store_true",
|
|
help="Enable debug mode with detailed logging",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--raw",
|
|
action="store_true",
|
|
help="Raw output mode for debugging (no TUI, no spinner, direct streaming)",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--version",
|
|
action="version",
|
|
version="%(prog)s 1.0.0",
|
|
)
|
|
|
|
# Session management
|
|
parser.add_argument(
|
|
"-r",
|
|
"--resume",
|
|
action="store_true",
|
|
help="Resume the most recent session for the target",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--session-id",
|
|
type=str,
|
|
help="Resume a specific session by ID",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--list-sessions",
|
|
action="store_true",
|
|
help="List available sessions and exit",
|
|
)
|
|
|
|
# Telemetry
|
|
parser.add_argument(
|
|
"--no-telemetry",
|
|
action="store_true",
|
|
help="Disable anonymous telemetry data collection",
|
|
)
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
def validate_environment() -> None:
|
|
"""
|
|
Validate environment (optional checks).
|
|
|
|
Note: API key is no longer required here as Claude Code manages
|
|
its own configuration.
|
|
"""
|
|
# No required validations - Claude Code handles API configuration
|
|
pass
|
|
|
|
|
|
def print_banner() -> None:
|
|
"""Print welcome banner."""
|
|
console = Console()
|
|
|
|
banner = Text()
|
|
banner.append("🚩 ", style="bold #6366f1")
|
|
banner.append("PentestGPT", style="bold #6366f1")
|
|
banner.append(" v1.0.0", style="dim")
|
|
banner.append("\n")
|
|
banner.append("AI-Powered CTF Challenge Solver", style="dim italic")
|
|
|
|
console.print()
|
|
console.print(banner)
|
|
console.print()
|
|
|
|
|
|
async def run_cli_mode(args: argparse.Namespace) -> None:
|
|
"""Run in non-interactive CLI mode."""
|
|
from pentestgpt.core.agent import run_pentest
|
|
from pentestgpt.core.session import SessionStore
|
|
|
|
console = Console()
|
|
|
|
# Determine session to resume
|
|
resume_session = args.session_id
|
|
if args.resume and not resume_session:
|
|
sessions = SessionStore()
|
|
latest = sessions.get_latest(args.target)
|
|
if latest:
|
|
resume_session = latest.session_id
|
|
console.print(f"[dim]Resuming session: {resume_session}[/]")
|
|
|
|
console.print(f"[bold]Target:[/] {args.target}")
|
|
if args.instruction:
|
|
console.print(f"[bold]Challenge Context:[/] {args.instruction}")
|
|
if args.debug:
|
|
console.print("[dim]Debug mode: enabled[/]")
|
|
console.print()
|
|
|
|
# Allow up to 3 attempts
|
|
max_attempts = 3
|
|
attempt = 1
|
|
custom_instruction = args.instruction
|
|
total_cost = 0.0
|
|
|
|
while attempt <= max_attempts:
|
|
if attempt > 1:
|
|
console.print(f"\n[bold cyan]Attempt {attempt}/{max_attempts}[/]")
|
|
console.print("[dim]Adding persistence context...[/]\n")
|
|
|
|
with console.status("[bold cyan]Solving CTF challenge...", spinner="dots"):
|
|
result = await run_pentest(
|
|
target=args.target,
|
|
custom_instruction=custom_instruction,
|
|
model=args.model,
|
|
debug=args.debug,
|
|
resume_session=resume_session if attempt == 1 else None,
|
|
)
|
|
|
|
total_cost += result.get("cost_usd", 0)
|
|
|
|
console.print()
|
|
if result["success"]:
|
|
# Display flags found
|
|
flags_found = result.get("flags_found", [])
|
|
if flags_found:
|
|
console.print(
|
|
f"[bold green]🚩 Challenge solved! {len(flags_found)} flag(s) found:[/]"
|
|
)
|
|
for flag_data in flags_found:
|
|
console.print(f" • [bold cyan]{flag_data['flag']}[/]")
|
|
|
|
console.print(f"\n{result['output']}")
|
|
|
|
# Display session info
|
|
session_id = result.get("session_id", "")
|
|
if session_id:
|
|
console.print(f"\n[dim]Session: {session_id}[/]")
|
|
|
|
if total_cost > 0:
|
|
console.print(f"[dim]Total cost: ${total_cost:.4f}[/]")
|
|
|
|
# Success! Exit cleanly
|
|
return
|
|
|
|
else:
|
|
# No flags found - this is a problem
|
|
console.print("[bold red]⚠ CHALLENGE INCOMPLETE - NO FLAGS CAPTURED[/]")
|
|
console.print("[yellow]The agent stopped without capturing flags.[/]")
|
|
|
|
console.print(f"\n{result['output']}")
|
|
|
|
if attempt < max_attempts:
|
|
console.print(f"\n[bold yellow]Attempts remaining: {max_attempts - attempt}[/]")
|
|
console.print(
|
|
"[dim]The agent will retry with stronger persistence instructions.[/]\n"
|
|
)
|
|
|
|
# Add feedback for next attempt
|
|
if custom_instruction:
|
|
custom_instruction += "\n\nPREVIOUS ATTEMPT FEEDBACK: You stopped without capturing flags. This is unacceptable. You MUST capture at least one flag. Try different approaches, enumerate harder, and do not stop until you have flags."
|
|
else:
|
|
custom_instruction = "IMPORTANT: The previous attempt failed to capture any flags. You MUST capture at least one flag. Do not stop until flags are found. Try all available techniques systematically."
|
|
|
|
attempt += 1
|
|
continue
|
|
else:
|
|
console.print(f"\n[bold red]✗ Maximum attempts reached ({max_attempts})[/]")
|
|
console.print(
|
|
"[yellow]Consider providing more specific hints via --instruction[/]"
|
|
)
|
|
|
|
# Display session info for resumption
|
|
session_id = result.get("session_id", "")
|
|
if session_id:
|
|
console.print(
|
|
f"\n[dim]Session: {session_id} (resume with --session-id {session_id})[/]"
|
|
)
|
|
|
|
if total_cost > 0:
|
|
console.print(f"[dim]Total cost: ${total_cost:.4f}[/]")
|
|
|
|
sys.exit(1)
|
|
else:
|
|
console.print(
|
|
f"[bold red]✗ Challenge failed:[/] {result.get('error', 'Unknown error')}"
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
async def run_raw_mode(args: argparse.Namespace) -> None:
|
|
"""Run in raw CLI mode with streaming output for debugging."""
|
|
from pentestgpt.core.config import load_config
|
|
from pentestgpt.core.controller import AgentController
|
|
from pentestgpt.core.events import Event, EventBus, EventType
|
|
from pentestgpt.core.session import SessionStore
|
|
|
|
# Print startup info
|
|
print(f"[INFO] Target: {args.target}")
|
|
if args.instruction:
|
|
print(f"[INFO] Instruction: {args.instruction}")
|
|
if args.model:
|
|
print(f"[INFO] Model: {args.model}")
|
|
if args.debug:
|
|
print("[INFO] Debug mode: enabled")
|
|
print("[INFO] Starting agent...", flush=True)
|
|
|
|
# Set up event handlers that print directly to stdout
|
|
events = EventBus.get()
|
|
|
|
def on_message(event: Event) -> None:
|
|
text = event.data.get("text", "")
|
|
msg_type = event.data.get("type", "info")
|
|
if text:
|
|
print(f"[{msg_type.upper()}] {text}", flush=True)
|
|
|
|
def on_tool(event: Event) -> None:
|
|
status = event.data.get("status")
|
|
name = event.data.get("name", "unknown")
|
|
if status == "start":
|
|
args_data = event.data.get("args", {})
|
|
print(f"[TOOL] {name}: {args_data}", flush=True)
|
|
else:
|
|
print(f"[TOOL] {name} done", flush=True)
|
|
|
|
def on_flag(event: Event) -> None:
|
|
flag = event.data.get("flag", "")
|
|
if flag:
|
|
print(f"[FLAG] {flag}", flush=True)
|
|
|
|
def on_state(event: Event) -> None:
|
|
state = event.data.get("state", "")
|
|
details = event.data.get("details", "")
|
|
if details:
|
|
print(f"[STATE] {state}: {details}", flush=True)
|
|
else:
|
|
print(f"[STATE] {state}", flush=True)
|
|
|
|
events.subscribe(EventType.MESSAGE, on_message)
|
|
events.subscribe(EventType.TOOL, on_tool)
|
|
events.subscribe(EventType.FLAG_FOUND, on_flag)
|
|
events.subscribe(EventType.STATE_CHANGED, on_state)
|
|
|
|
# Determine session to resume
|
|
resume_session = args.session_id
|
|
if args.resume and not resume_session:
|
|
sessions = SessionStore()
|
|
latest = sessions.get_latest(args.target)
|
|
if latest:
|
|
resume_session = latest.session_id
|
|
print(f"[INFO] Resuming session: {resume_session}", flush=True)
|
|
|
|
# Build config
|
|
config_kwargs = {"target": args.target}
|
|
if args.instruction:
|
|
config_kwargs["custom_instruction"] = args.instruction
|
|
if args.model:
|
|
config_kwargs["llm_model"] = args.model
|
|
|
|
config = load_config(**config_kwargs)
|
|
|
|
# Build task
|
|
task = f"Solve this CTF challenge and capture the flag(s): {args.target}"
|
|
if args.instruction:
|
|
task += f"\n\nChallenge context: {args.instruction}"
|
|
|
|
# Run agent with controller
|
|
controller = AgentController(config)
|
|
|
|
try:
|
|
result = await controller.run(task, resume_session_id=resume_session)
|
|
|
|
# Print final result
|
|
if result.get("success"):
|
|
flags = result.get("flags_found", [])
|
|
cost = result.get("cost_usd", 0)
|
|
session_id = result.get("session_id", "")
|
|
print(
|
|
f"[DONE] Flags: {len(flags)}, Cost: ${cost:.4f}, Session: {session_id}",
|
|
flush=True,
|
|
)
|
|
if not flags:
|
|
print("[WARN] No flags captured", flush=True)
|
|
sys.exit(1)
|
|
else:
|
|
error = result.get("error", "Unknown error")
|
|
print(f"[ERROR] {error}", flush=True)
|
|
sys.exit(1)
|
|
|
|
except Exception as e:
|
|
print(f"[ERROR] {e!s}", flush=True)
|
|
import traceback
|
|
|
|
print(f"[TRACE] {traceback.format_exc()}", flush=True)
|
|
sys.exit(1)
|
|
|
|
|
|
async def run_tui_mode(args: argparse.Namespace) -> None:
|
|
"""Run in interactive TUI mode."""
|
|
from pentestgpt.core.session import SessionStore
|
|
from pentestgpt.interface.tui import run_tui
|
|
|
|
# Determine session to resume
|
|
resume_session = args.session_id
|
|
if args.resume and not resume_session:
|
|
sessions = SessionStore()
|
|
latest = sessions.get_latest(args.target)
|
|
if latest:
|
|
resume_session = latest.session_id
|
|
|
|
await run_tui(
|
|
target=args.target,
|
|
custom_instruction=args.instruction,
|
|
model=args.model,
|
|
debug=args.debug,
|
|
resume_session=resume_session,
|
|
)
|
|
|
|
|
|
def list_sessions(target: str | None = None) -> None:
|
|
"""List available sessions."""
|
|
from pentestgpt.core.session import SessionStore
|
|
|
|
console = Console()
|
|
sessions = SessionStore()
|
|
session_list = sessions.list_sessions(target)
|
|
|
|
if not session_list:
|
|
console.print("[dim]No sessions found.[/]")
|
|
return
|
|
|
|
console.print(f"[bold]Sessions{f' for {target}' if target else ''}:[/]\n")
|
|
console.print(f"{'ID':<10} {'Date':<18} {'Target':<25} {'Status':<12} {'Flags':<6}")
|
|
console.print("-" * 75)
|
|
|
|
for s in session_list:
|
|
date_str = s.created_at.strftime("%Y-%m-%d %H:%M")
|
|
target_str = s.target[:23] + ".." if len(s.target) > 25 else s.target
|
|
flags_count = len(s.flags_found)
|
|
console.print(
|
|
f"{s.session_id:<10} {date_str:<18} {target_str:<25} {s.status.value:<12} {flags_count:<6}"
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point."""
|
|
# Parse arguments
|
|
args = parse_arguments()
|
|
|
|
# Handle --list-sessions
|
|
if args.list_sessions:
|
|
list_sessions(args.target if hasattr(args, "target") else None)
|
|
return
|
|
|
|
# Validate environment
|
|
validate_environment()
|
|
|
|
# Initialize telemetry (enabled by default, use --no-telemetry to disable)
|
|
from pentestgpt.core.langfuse import init_langfuse, shutdown_langfuse
|
|
|
|
init_langfuse(disabled=args.no_telemetry)
|
|
|
|
# Print banner in CLI mode
|
|
if args.non_interactive:
|
|
print_banner()
|
|
|
|
# Run appropriate mode
|
|
try:
|
|
if args.raw:
|
|
asyncio.run(run_raw_mode(args))
|
|
elif args.non_interactive:
|
|
asyncio.run(run_cli_mode(args))
|
|
else:
|
|
asyncio.run(run_tui_mode(args))
|
|
|
|
except KeyboardInterrupt:
|
|
console = Console()
|
|
console.print("\n\n[yellow]Operation cancelled by user[/]")
|
|
sys.exit(130)
|
|
|
|
except Exception as e:
|
|
console = Console()
|
|
console.print(f"\n[bold red]Error:[/] {e!s}", style="red")
|
|
# Always show traceback for debugging
|
|
import traceback
|
|
|
|
console.print("\n[dim]Traceback:[/]")
|
|
console.print(traceback.format_exc())
|
|
sys.exit(1)
|
|
|
|
finally:
|
|
shutdown_langfuse()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|