From 0e38d19874f3606a41d27470287584148c01aa63 Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Tue, 17 Mar 2026 09:44:17 +0530 Subject: [PATCH 01/12] Create run.ps1 to support Windows OS --- run.ps1 | 1110 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1110 insertions(+) create mode 100644 run.ps1 diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..57303d7 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,1110 @@ +# ============================================================ +# Pentest Copilot - All-in-one launcher (Windows PowerShell) +# Configures, builds, and runs the entire stack. +# +# Commands: start | config | dev | stop | logs | status | help +# Flags: -Quick Skip all configuration prompts +# ============================================================ + +$ErrorActionPreference = "Stop" + +# -- Colors & formatting ----------------------------------- +$Red = "Red" +$Green = "Green" +$Yellow = "Yellow" +$Blue = "Cyan" +$Cyan = "Cyan" +$White = "White" + +# -- Paths ------------------------------------------------- +$ScriptDir = $PSScriptRoot +$ConfigToml = Join-Path $ScriptDir "config.toml" +$ConfigTomlTmpl = Join-Path $ScriptDir "config.toml.template" + +# Nested Join-Path for PS 5.1 compatibility +$BackDir = Join-Path $ScriptDir "backend" +$FrontDir = Join-Path $ScriptDir "frontend" +$DynamicEnv = Join-Path $BackDir ".env" +$DynamicEnvTmpl = Join-Path $BackDir ".env.template" +$FrontendEnv = Join-Path $FrontDir ".env" +$FrontendTmpl = Join-Path $FrontDir ".env.template" +$SshKeysDir = Join-Path $ScriptDir "ssh-keys" +$ComposeOverride = Join-Path $ScriptDir "docker-compose.override.yml" + +$script:ComposeExe = "docker" +$script:ComposeBaseArgs = @("compose") + +$script:DeployMode = "" +$script:ComposeFile = "" +$script:DevMode = $false +$script:QuickMode = $false +$script:StateFile = Join-Path $ScriptDir ".run-state" +$script:NeedSshKeyMount = $false + +# -- Helpers ------------------------------------------------ +function Print-Banner { + Write-Host "" + Write-Host "+------------------------------------------+" -ForegroundColor $Blue + Write-Host "| Pentest Copilot . Launcher |" -ForegroundColor $Blue + Write-Host "+------------------------------------------+" -ForegroundColor $Blue + Write-Host "" +} + +function Show-Commands { + Write-Host " Commands:" -ForegroundColor $White + Write-Host " start " -NoNewline -ForegroundColor $Cyan + Write-Host "Guided start: choose normal or developer mode" + Write-Host " start -Quick " -NoNewline -ForegroundColor $Cyan + Write-Host "Quick start: skip prompts, use existing config" + Write-Host " config " -NoNewline -ForegroundColor $Cyan + Write-Host "Update model keys, Google search, tracing, or exploit box settings" + Write-Host " dev " -NoNewline -ForegroundColor $Cyan + Write-Host "Start directly in developer mode" + Write-Host " dev -Quick " -NoNewline -ForegroundColor $Cyan + Write-Host "Quick dev start: skip prompts" + Write-Host " stop " -NoNewline -ForegroundColor $Cyan + Write-Host "Stop all containers" + Write-Host " logs " -NoNewline -ForegroundColor $Cyan + Write-Host "Tail container logs" + Write-Host " status " -NoNewline -ForegroundColor $Cyan + Write-Host "Show container status" + Write-Host " help " -NoNewline -ForegroundColor $Cyan + Write-Host "Show full help" + Write-Host "" +} + +function info { param($msg) Write-Host " [v] $msg" -ForegroundColor $Green } +function warn { param($msg) Write-Host " [!] $msg" -ForegroundColor $Yellow } +function err { param($msg) Write-Host " [X] $msg" -ForegroundColor $Red } +function section { param($msg) Write-Host "`n-- $msg --" -ForegroundColor $Cyan } +function hint { param($msg) Write-Host " $msg" -ForegroundColor DarkGray } + +function Prompt-Input { + param($label) + Write-Host " $label " -NoNewline -ForegroundColor $Cyan + return Read-Host +} + +function Confirm-Action { + param($msg, $default = "n") + $suffix = "[y/N]" + if ($default -eq "y") { $suffix = "[Y/n]" } + Write-Host " $msg ${suffix}: " -NoNewline -ForegroundColor $Cyan + $resp = Read-Host + if ([string]::IsNullOrWhiteSpace($resp)) { + return ($default -eq "y") + } + return ($resp -match "^[Yy]") +} + +# State Management +function Save-RunState { + $stateLines = @( + "DEPLOY_MODE=$($script:DeployMode)", + "COMPOSE_FILE=$($script:ComposeFile)", + "DEV_MODE=$($script:DevMode)" + ) + $stateLines | Set-Content $script:StateFile +} + +function Load-RunState { + if (Test-Path $script:StateFile) { + $content = Get-Content $script:StateFile + foreach ($line in $content) { + if ($line -match "^DEPLOY_MODE=(.*)") { $script:DeployMode = $Matches[1] } + if ($line -match "^COMPOSE_FILE=(.*)") { $script:ComposeFile = $Matches[1] } + if ($line -match "^DEV_MODE=(.*)") { $script:DevMode = [System.Convert]::ToBoolean($Matches[1]) } + } + return $true + } + return $false +} + +# Config Parsers +function Escape-EnvVal { + param($val) + if ($null -eq $val) { return "" } + return $val -replace "\\", "\\" -replace '"', '\"' +} + +function Set-EnvVar { + param($file, $var, $val) + if (-not (Test-Path $file)) { New-Item -ItemType File -Path $file -Force | Out-Null } + $lines = @() + if (Test-Path $file) { $lines = Get-Content $file } + $escaped = Escape-EnvVal $val + $newLine = "${var}=""${escaped}""" + $found = $false + $newLines = @() + foreach ($line in $lines) { + if ($line -match "^${var}=") { + $newLines += $newLine + $found = $true + } else { + $newLines += $line + } + } + if (-not $found) { $newLines += $newLine } + $newLines | Set-Content $file +} + +function Get-EnvVar { + param($file, $var) + if (-not (Test-Path $file)) { return "" } + $line = Get-Content $file | Where-Object { $_ -match "^${var}=" } | Select-Object -First 1 + if ($line) { + $val = $line -replace "^${var}=", "" + if ($val -match '^"(.*)"$') { + $val = $Matches[1] -replace '\\"', '"' -replace '\\\\', '\' + } + return $val + } + return "" +} + +function Set-TomlVar { + param($file, $key, $val) + if (-not (Test-Path $file)) { New-Item -ItemType File -Path $file -Force | Out-Null } + $lines = @() + if (Test-Path $file) { $lines = Get-Content $file } + $found = $false + $newLines = @() + $newLine = "${key} = ""${val}""" + foreach ($line in $lines) { + if ($line -match "^${key}\s*=" -or $line -match "^#\s*${key}\s*=") { + $newLines += $newLine + $found = $true + } else { + $newLines += $line + } + } + if (-not $found) { $newLines += $newLine } + $newLines | Set-Content $file +} + +function Get-TomlVar { + param($file, $key) + if (-not (Test-Path $file)) { return "" } + $line = Get-Content $file | Where-Object { $_ -match "^${key}\s*=" } | Select-Object -First 1 + if ($line) { + $val = $line -replace "^${key}\s*=\s*", "" + $val = $val -replace "\s*#.*", "" + $val = $val.Trim().Trim('"') + return $val + } + return "" +} + +function Mask-Key { + param($key) + if ([string]::IsNullOrEmpty($key) -or $key.Length -le 8) { return "****" } + return "$($key.Substring(0, 4))...$($key.Substring($key.Length - 4))" +} + +# -- Prerequisites ----------------------------------------- +function Check-Prerequisites { + section "Checking Prerequisites" + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + err "Docker is not installed. Install it from https://docs.docker.com/get-docker/" + exit 1 + } + info "Docker version: $((docker --version)[0])" + + if (docker compose version 2>$null) { + $script:ComposeExe = "docker" + $script:ComposeBaseArgs = @("compose") + } else { + $script:ComposeExe = "docker-compose" + $script:ComposeBaseArgs = @() + } + + if (-not (docker info 2>$null)) { + err "Docker daemon is not running." + exit 1 + } + info "Docker daemon is running" +} + +function Check-Pnpm { + if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) { + err "pnpm is not installed. Install it via: corepack enable && corepack prepare pnpm@latest --activate" + exit 1 + } +} + +function Ensure-ConfigDefaults { + if (-not (Test-Path $ConfigTomlTmpl)) { + err "Template not found: $ConfigTomlTmpl" + exit 1 + } + if (-not (Test-Path $ConfigToml)) { + Copy-Item $ConfigTomlTmpl $ConfigToml + info "Created config.toml from template" + } + + if ($script:DevMode) { + $curMongo = Get-TomlVar $ConfigToml "mongo_uri" + $curRedis = Get-TomlVar $ConfigToml "redis_url" + if ($curMongo -match "mongodb:" -and $curMongo -notmatch "localhost") { + Set-TomlVar $ConfigToml "mongo_uri" "mongodb://localhost:27017/pentestcopilot" + } + if ($curRedis -match "redis:" -and $curRedis -notmatch "localhost") { + Set-TomlVar $ConfigToml "redis_url" "redis://localhost:6379" + } + } + + $frontendUrl = Get-TomlVar $ConfigToml "base_url_frontend" + if ([string]::IsNullOrEmpty($frontendUrl)) { + $frontendUrl = "http://localhost:3000" + Set-TomlVar $ConfigToml "base_url_frontend" $frontendUrl + } + $corsCur = Get-TomlVar $ConfigToml "cors_origins" + if ([string]::IsNullOrEmpty($corsCur)) { + Set-TomlVar $ConfigToml "cors_origins" $frontendUrl + } + + $secret = Get-TomlVar $ConfigToml "secret" + if ([string]::IsNullOrEmpty($secret) -or $secret -eq "thisismysessionsecret!123") { + $bytes = New-Object Byte[] 32 + (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($bytes) + $generated = [Convert]::ToBase64String($bytes) -replace '[/+=]', '' + Set-TomlVar $ConfigToml "secret" $generated + info "Generated session secret" + } + + $content = Get-Content $ConfigToml -Raw + if ($content -notmatch "\[tracing\]") { + Add-Content $ConfigToml "`n[tracing]`nenabled = ""false""`npublic_key = """"`nsecret_key = """"`nbase_url = ""https://cloud.langfuse.com""" + } +} + +function Ensure-EnvDefaults { + if (-not (Test-Path $DynamicEnvTmpl)) { + err "Template not found: $DynamicEnvTmpl" + exit 1 + } + if (-not (Test-Path $DynamicEnv) -or (Get-Item $DynamicEnv).Length -eq 0) { + Copy-Item $DynamicEnvTmpl $DynamicEnv + info "Created .env from template" + } +} + +function Ensure-FrontendEnv { + if (-not (Test-Path $FrontendTmpl)) { + err "Template not found: $FrontendTmpl" + exit 1 + } + if (-not (Test-Path $FrontendEnv)) { + Copy-Item $FrontendTmpl $FrontendEnv + info "Created frontend/.env from template" + } +} + +# -- Mode Selection ----------------------------------------- +function Set-NormalMode { + $script:DevMode = $false + $script:ComposeFile = "docker-compose.yml" + $script:DeployMode = "core" +} + +function Set-NormalKaliMode { + $script:DevMode = $false + $script:ComposeFile = "docker-compose.kali.yml" + $script:DeployMode = "kali" +} + +function Configure-DevModeChoice { + $script:DevMode = $true + $script:ComposeFile = "docker-compose.dev.yml" + $script:DeployMode = "dev" +} + +function Select-LaunchMode { + section "Choose How To Run" + Write-Host " 1) Normal mode" -ForegroundColor $Green + Write-Host " Best for most people. Pentest Copilot starts the application for you" -ForegroundColor DarkGray + Write-Host " using Docker with guided questions for the required setup." -ForegroundColor DarkGray + Write-Host "" + Write-Host " 2) Developer mode" -ForegroundColor $Yellow + Write-Host " Best for advanced users. Docker starts only the supporting services" -ForegroundColor DarkGray + Write-Host " (like MongoDB and Redis), and you run the frontend/backend manually." -ForegroundColor DarkGray + Write-Host "" + $choice = Prompt-Input "Choose [1/2]:" + if ($choice -eq "2") { + Configure-DevModeChoice + } else { + Set-NormalMode + info "Selected normal mode" + } +} + +function Detect-RunningMode { + $hasKali = docker ps --filter "name=kali" --format "{{.Id}}" | Select-Object -First 1 + $hasBackend = docker ps --filter "name=pentest-copilot-backend" --format "{{.Id}}" | Select-Object -First 1 + + if ($hasKali -and $hasBackend) { + Set-NormalKaliMode + info "Detected running: Full Docker + Kali" + return $true + } elseif ($hasBackend) { + Set-NormalMode + info "Detected running: Core Docker mode" + return $true + } elseif ($hasKali) { + $script:DevMode = $true + $script:ComposeFile = "docker-compose.dev.yml" + $script:DeployMode = "dev" + info "Detected running: Developer mode (with Kali)" + return $true + } else { + $hasMongo = docker ps --filter "name=pentest-copilot-mongodb" --format "{{.Id}}" | Select-Object -First 1 + if ($hasMongo) { + $script:DevMode = $true + $script:ComposeFile = "docker-compose.dev.yml" + $script:DeployMode = "dev" + info "Detected running: Developer mode" + return $true + } + } + return $false +} + +function Select-ModeForOperations { + if (Detect-RunningMode) { return } + + section "Environment Select" + Write-Host " 1) core`n 2) kali`n 3) dev" + $choice = Prompt-Input "Choose [1/2/3]:" + if ($choice -eq "2") { Set-NormalKaliMode } + elseif ($choice -eq "3") { Configure-DevModeChoice } + else { Set-NormalMode } +} + +# -- Smart configuration (skip if already configured) ------ +function Is-ModelConfigured { + $key = Get-EnvVar $DynamicEnv "MODEL_API_KEY" + return -not [string]::IsNullOrWhiteSpace($key) +} + +function Is-GoogleConfigured { + $key = Get-EnvVar $DynamicEnv "GOOGLE-API-KEY" + $cx = Get-EnvVar $DynamicEnv "CUSTOM-SEARCH-ENGINE-ID" + return ($key -ne "" -and $cx -ne "") +} + +function Is-LangfuseConfigured { + $enabled = Get-TomlVar $ConfigToml "enabled" + return ($enabled -eq "true") +} + +function Is-ExploitBoxConfigured { + $shHost = Get-EnvVar $DynamicEnv "SSH_HOST" + return ($shHost -ne "") +} + +function Configure-RequiredStartupSmart { + Ensure-EnvDefaults + + if (Is-ModelConfigured) { + Show-ConfigSummary + if (Confirm-Action "Keep current configuration and start?" "y") { + info "Using existing configuration" + return + } + Write-Host "" + } + + # Model keys -- always prompt if not configured + if (-not (Is-ModelConfigured)) { + Configure-ModelKeys + } else { + if (Confirm-Action "Reconfigure model API keys?" "n") { + Configure-ModelKeys + } else { + info "Keeping current model config" + } + } + + # Google search -- optional + if (-not (Is-GoogleConfigured)) { + Write-Host "" + hint "Google Search enables web search during pentesting. (optional, set up later via Settings UI)" + if (Confirm-Action "Configure Google Search API now?" "n") { + Configure-GoogleSearch + } else { + info "Skipped -- configure anytime via Settings UI or .\run.ps1 config" + } + } + + # Langfuse -- optional + if (-not (Is-LangfuseConfigured)) { + Write-Host "" + hint "Langfuse provides LLM call tracing & observability. (optional, requires restart to change)" + if (Confirm-Action "Configure Langfuse tracing now?" "n") { + Configure-Langfuse + } else { + info "Skipped -- configure anytime via .\run.ps1 config (requires restart)" + } + } + + # Exploit box -- prompt if not configured + if (-not (Is-ExploitBoxConfigured)) { + Write-Host "" + Configure-ExploitBox + } else { + if (Confirm-Action "Reconfigure exploit box?" "n") { + Configure-ExploitBox + } else { + info "Keeping current exploit box config" + } + } +} +function Configure-ModelKeys { + section "Model API Keys" + hint "You can change this anytime via the Settings UI (no restart needed)." + $curP = Get-EnvVar $DynamicEnv "MODEL_PROVIDER" + if ([string]::IsNullOrEmpty($curP)) { $curP = "openai" } + Write-Host "" + Write-Host " Providers: openai, anthropic, openai-compatible" -ForegroundColor DarkGray + $p = Prompt-Input "Provider [$curP]:" + if ([string]::IsNullOrEmpty($p)) { $p = $curP } + Set-EnvVar $DynamicEnv "MODEL_PROVIDER" $p + $model_provider = $p + + $curM = Get-EnvVar $DynamicEnv "MODEL" + if ([string]::IsNullOrEmpty($curM)) { $curM = "gpt-4o" } + $m = Prompt-Input "Model name [$curM]:" + if ([string]::IsNullOrEmpty($m)) { $m = $curM } + Set-EnvVar $DynamicEnv "MODEL" $m + + if ($model_provider -eq "anthropic") { + Write-Host "" + Write-Host " 1) API Key 2) Connect Claude Account (OAuth)" + $auth_choice = Prompt-Input "Choose [1/2]:" + if ($auth_choice -eq "2") { + Configure-ClaudeOAuth + } else { + $k = Prompt-Input "API key:" + if ($k) { Set-EnvVar $DynamicEnv "MODEL_API_KEY" $k } + } + } else { + $k = Prompt-Input "API key:" + if ($k) { Set-EnvVar $DynamicEnv "MODEL_API_KEY" $k } + } + + $b = Prompt-Input "Base URL override (Enter to skip):" + if ($b) { Set-EnvVar $DynamicEnv "MODEL_BASE_PATH" $b } + + info "Model API keys saved" +} + +function Configure-ClaudeOAuth { + section "Claude OAuth (PKCE)" + $clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + $authUrl = "https://claude.ai/oauth/authorize" + $tokenUrl = "https://console.anthropic.com/v1/oauth/token" + $redirectUri = "https://console.anthropic.com/oauth/code/callback" + $scopes = "org:create_api_key user:profile user:inference" + + # PKCE Helpers + $bytes = New-Object Byte[] 32 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + $verifier = [Convert]::ToBase64String($bytes).Replace('+', '-').Replace('/', '_').Replace('=', '') + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + $hash = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($verifier)) + $challenge = [Convert]::ToBase64String($hash).Replace('+', '-').Replace('/', '_').Replace('=', '') + + $state = [Guid]::NewGuid().ToString("N") + + $encodedRedirect = [System.Uri]::EscapeDataString($redirectUri) + $encodedScopes = [System.Uri]::EscapeDataString($scopes) + + $fullAuthUrl = "${authUrl}?code=true&client_id=${clientId}&response_type=code&redirect_uri=${encodedRedirect}&scope=${encodedScopes}&code_challenge=${challenge}&code_challenge_method=S256&state=${state}" + + info "Open this URL to authorize Claude:" + Write-Host " $fullAuthUrl" -ForegroundColor $Cyan + Write-Host "" + $authCode = Prompt-Input "Paste the authorization code:" + if ([string]::IsNullOrEmpty($authCode)) { err "No code provided"; return } + $authCode = $authCode.Split('#')[0].Trim() + + try { + $body = @{ + code = $authCode + state = $state + grant_type = "authorization_code" + client_id = $clientId + redirect_uri = $redirectUri + code_verifier = $verifier + } + $response = Invoke-RestMethod -Method Post -Uri $tokenUrl -Body $body + + $access_token = $response.access_token + $refresh_token = $response.refresh_token + $expires_in = $response.expires_in + $expires_at = [DateTimeOffset]::Now.ToUnixTimeSeconds() + $expires_in + + if ($access_token) { + Set-EnvVar $DynamicEnv "ANTHROPIC_OAUTH_ACCESS_TOKEN" $access_token + Set-EnvVar $DynamicEnv "ANTHROPIC_OAUTH_REFRESH_TOKEN" $refresh_token + Set-EnvVar $DynamicEnv "ANTHROPIC_OAUTH_EXPIRES_AT" $expires_at + Set-EnvVar $DynamicEnv "MODEL_API_KEY" "" + info "Claude account connected via OAuth" + } + } catch { + err "OAuth flow failed: $($_.Exception.Message)" + } +} + +function Configure-GoogleSearch { + section "Google Search" + hint "Optional. You can set this up later via the Settings UI." + Ensure-EnvDefaults + Write-Host "" + Write-Host " Required only if you want the Google search tool to work." -ForegroundColor DarkGray + Write-Host " You need both a Google API key and a Custom Search Engine ID." -ForegroundColor DarkGray + + $curKey = Get-EnvVar $DynamicEnv "GOOGLE-API-KEY" + $keyStatus = if ($curKey) { "configured ($(Mask-Key $curKey))" } else { "not set" } + $k = Prompt-Input "Google API key [$keyStatus]:" + if ($k) { Set-EnvVar $DynamicEnv "GOOGLE-API-KEY" $k } + + $curCx = Get-EnvVar $DynamicEnv "CUSTOM-SEARCH-ENGINE-ID" + $displayCx = if ($curCx) { $curCx } else { "not set" } + $cx = Prompt-Input "Custom Search Engine ID [$displayCx]:" + if ($cx) { Set-EnvVar $DynamicEnv "CUSTOM-SEARCH-ENGINE-ID" $cx } + info "Google search settings saved" +} + +function Configure-Langfuse { + section "Langfuse Tracing" + hint "Requires a container restart to take effect. Change via .\run.ps1 config." + Ensure-ConfigDefaults + Write-Host "" + Write-Host " Langfuse provides LLM observability. Get keys at https://cloud.langfuse.com" -ForegroundColor DarkGray + Write-Host "" + if (Confirm-Action "Enable Langfuse tracing?" "n") { + Set-TomlVar $ConfigToml "enabled" "true" + $curPk = Get-TomlVar $ConfigToml "public_key" + $displayPk = if ($curPk) { $curPk } else { "not set" } + $pk = Prompt-Input "Langfuse Public Key (pk-lf-...) [$displayPk]:" + if ($pk) { Set-TomlVar $ConfigToml "public_key" $pk } + + $curSk = Get-TomlVar $ConfigToml "secret_key" + $displaySk = if ($curSk) { $curSk } else { "not set" } + $sk = Prompt-Input "Langfuse Secret Key (sk-lf-...) [$displaySk]:" + if ($sk) { Set-TomlVar $ConfigToml "secret_key" $sk } + + $curBu = Get-TomlVar $ConfigToml "base_url" + $displayBu = if ($curBu) { $curBu } else { "https://cloud.langfuse.com" } + $bu = Prompt-Input "Langfuse Base URL [$displayBu]:" + if ($bu) { Set-TomlVar $ConfigToml "base_url" $bu } + info "Langfuse tracing enabled" + } else { + Set-TomlVar $ConfigToml "enabled" "false" + info "Langfuse tracing disabled" + } +} + +function Configure-ExploitBox { + section "Exploit Box Connection" + hint "The exploit box is the SSH target where pentesting commands run. You can change this later via Settings UI." + Ensure-EnvDefaults + Write-Host "" + Write-Host " 1) Kali VM exploit box spin up " -NoNewline -ForegroundColor $White + Write-Host "(first build can take 15-30+ min)" -ForegroundColor DarkGray + Write-Host " 2) Connect to external exploit box (any VM via SSH, including your local computer)" -ForegroundColor $White + Write-Host " 3) No exploit box " -NoNewline -ForegroundColor $Red + Write-Host "(reduces Pentest Copilot functionality significantly)" -ForegroundColor $Red + $choice = Prompt-Input "Choose [1/2/3]:" + + if ($choice -eq "1") { + Configure-DockerKali + } elseif ($choice -eq "2") { + Configure-ExternalSSH + } else { + echo "" + warn "No exploit box selected -- Pentest Copilot will have reduced functionality (no terminal, shells, or command execution on a target)." + Clear-ExploitBoxConfig + } +} + +function Clear-ExploitBoxConfig { + if ($script:DevMode) { $script:DeployMode = "dev" } + else { Set-NormalMode } + Set-EnvVar $DynamicEnv "SSH_HOST" "" + Set-EnvVar $DynamicEnv "SSH_PORT" "22" + Set-EnvVar $DynamicEnv "SSH_USERNAME" "" + Set-EnvVar $DynamicEnv "SSH_PASSWORD" "" + Set-EnvVar $DynamicEnv "SSH_PRIVATE_KEY" "" + Set-EnvVar $DynamicEnv "SSH_PRIVATE_KEY_PASSPHRASE" "" +} + +function Configure-DockerKali { + Write-Host "" + warn "The Kali image is large. The first build may take 15-30+ minutes depending" + warn "on your internet speed and hardware. Subsequent starts reuse the cached image." + Write-Host "" + if ($script:DevMode) { + $script:DeployMode = "dev-kali" + Set-EnvVar $DynamicEnv "SSH_HOST" "localhost" + Set-EnvVar $DynamicEnv "SSH_PORT" "4242" + } else { + Set-NormalKaliMode + Set-EnvVar $DynamicEnv "SSH_HOST" "kali" + Set-EnvVar $DynamicEnv "SSH_PORT" "22" + } + Set-EnvVar $DynamicEnv "SSH_USERNAME" "root" + Set-EnvVar $DynamicEnv "SSH_PASSWORD" "" + Set-EnvVar $DynamicEnv "SSH_PRIVATE_KEY" "" + info "Docker Kali configured" +} + +function Configure-ExternalSSH { + $defHost = if ($script:DevMode) { "localhost" } else { "host.docker.internal" } + $defPort = "22" + $defUser = "$($env:USERNAME)" + + Write-Host "" + hint "Enter host (use $defHost for your local machine, or any VM IP/hostname for remote)" + $h = Prompt-Input "Exploit box host [$defHost]:" + if (-not $h) { $h = $defHost } + $p = Prompt-Input "SSH Port [$defPort]:" + if (-not $p) { $p = $defPort } + $u = Prompt-Input "SSH Username [$defUser]:" + if (-not $u) { $u = $defUser } + + Set-EnvVar $DynamicEnv "SSH_HOST" $h + Set-EnvVar $DynamicEnv "SSH_PORT" $p + Set-EnvVar $DynamicEnv "SSH_USERNAME" $u + + Write-Host "" + Write-Host " 1) Password 2) Private key" + $auth = Prompt-Input "Auth [1/2]:" + if ($auth -eq "2") { + $keyPath = Prompt-Input "Path to private key:" + if (-not (Test-Path $keyPath)) { err "File not found: $keyPath"; return } + + if ($script:DevMode) { + Set-EnvVar $DynamicEnv "SSH_PRIVATE_KEY" (Resolve-Path $keyPath).Path + Set-EnvVar $DynamicEnv "SSH_PASSWORD" "" + Set-EnvVar $DynamicEnv "SSH_PRIVATE_KEY_PASSPHRASE" "" + } else { + if (-not (Test-Path $SshKeysDir)) { New-Item -ItemType Directory -Path $SshKeysDir -Force | Out-Null } + $keyName = Split-Path $keyPath -Leaf + $dest = Join-Path $SshKeysDir $keyName + Copy-Item $keyPath $dest -Force + # Windows equivalent of chmod 600 + icacls $dest /inheritance:r /grant:r "$($env:USERNAME):R" | Out-Null + + Set-EnvVar $DynamicEnv "SSH_PRIVATE_KEY" "/ssh-keys/$keyName" + $pass = Prompt-Input "Passphrase (Enter if none):" + Set-EnvVar $DynamicEnv "SSH_PRIVATE_KEY_PASSPHRASE" $pass + Set-EnvVar $DynamicEnv "SSH_PASSWORD" "" + $script:NeedSshKeyMount = $true + } + } else { + $pass = Prompt-Input "SSH Password:" + Set-EnvVar $DynamicEnv "SSH_PASSWORD" $pass + Set-EnvVar $DynamicEnv "SSH_PRIVATE_KEY" "" + Set-EnvVar $DynamicEnv "SSH_PRIVATE_KEY_PASSPHRASE" "" + } + info "Exploit box configured" +} + +function Show-ConfigSummary { + section "Current Configuration" + + $p = Get-EnvVar $DynamicEnv "MODEL_PROVIDER" + $m = Get-EnvVar $DynamicEnv "MODEL" + $k = Get-EnvVar $DynamicEnv "MODEL_API_KEY" + $displayP = if ($p) { $p } else { "openai" } + $displayM = if ($m) { $m } else { "gpt-4o" } + if ($k) { + $displayL = if ($script:DevMode) { "$displayP/$displayM" } else { "$displayP / $displayM" } + Write-Host " " -NoNewline; Write-Host "*" -ForegroundColor $Green -NoNewline + Write-Host " Model: " -NoNewline -ForegroundColor $White; Write-Host "$displayL " -NoNewline -ForegroundColor $White + Write-Host "(key: $(Mask-Key $k))" + } else { + Write-Host " " -NoNewline; Write-Host "*" -ForegroundColor $Red -NoNewline + Write-Host " Model: " -NoNewline -ForegroundColor $White; Write-Host "not configured " -NoNewline -ForegroundColor $White + Write-Host "<- required" -ForegroundColor $Yellow + } + + $gk = Get-EnvVar $DynamicEnv "GOOGLE-API-KEY" + $gc = Get-EnvVar $DynamicEnv "CUSTOM-SEARCH-ENGINE-ID" + if ($gk -and $gc) { + Write-Host " " -NoNewline; Write-Host "*" -ForegroundColor $Green -NoNewline + Write-Host " Google Search: configured" + } else { + Write-Host " " -NoNewline; Write-Host "-" -ForegroundColor DarkGray -NoNewline + Write-Host " Google Search: not set" -ForegroundColor DarkGray + } + + $sh = Get-EnvVar $DynamicEnv "SSH_HOST" + $su = Get-EnvVar $DynamicEnv "SSH_USERNAME" + $displayU = if ($su) { $su } else { "root" } + if ($sh) { + Write-Host " " -NoNewline; Write-Host "*" -ForegroundColor $Green -NoNewline + Write-Host " Exploit Box: $displayU@$sh" + } else { + Write-Host " " -NoNewline; Write-Host "-" -ForegroundColor DarkGray -NoNewline + Write-Host " Exploit Box: not set " -NoNewline -ForegroundColor DarkGray + Write-Host "(optional)" -ForegroundColor DarkGray + } + Write-Host "" +} + +# Compose Override logic +function Ensure-ComposeOverride { + $hasKeys = Test-Path $SshKeysDir + if ($hasKeys) { + $keys = Get-ChildItem $SshKeysDir | Measure-Object + if ($keys.Count -gt 0) { $script:NeedSshKeyMount = $true } + } + + $sshHost = Get-EnvVar $DynamicEnv "SSH_HOST" + $needsGateway = ($sshHost -eq "host.docker.internal" -and -not $script:DevMode) + + if ($script:NeedSshKeyMount -or $needsGateway) { + $override = "services:`n backend:`n" + if ($script:NeedSshKeyMount) { + $override += " volumes:`n - ./ssh-keys:/ssh-keys:ro`n" + } + if ($needsGateway) { + $override += " extra_hosts:`n - `"host.docker.internal:host-gateway`"`n" + } + $override | Set-Content $ComposeOverride -Force + } else { + if (Test-Path $ComposeOverride) { Remove-Item $ComposeOverride } + } +} + +# Execution +function Invoke-Compose { + param($ArgsList) + $finalArgs = @() + foreach ($arg in $script:ComposeBaseArgs) { $finalArgs += $arg } + if (-not [string]::IsNullOrEmpty($script:ComposeFile)) { + $finalArgs += "-f"; $finalArgs += $script:ComposeFile + } + if (Test-Path $ComposeOverride) { + $finalArgs += "-f"; $finalArgs += $ComposeOverride + } + if ($ArgsList -is [array]) { + foreach ($arg in $ArgsList) { $finalArgs += $arg } + } else { + $finalArgs += $ArgsList + } + & $script:ComposeExe $finalArgs +} + +function Launch-App { + param($buildFlag) + if ([string]::IsNullOrEmpty($script:ComposeFile)) { Set-NormalMode } + + if ($script:DevMode) { + section "Launching Developer Mode" + Check-Pnpm + info "Installing dependencies (backend + frontend)..." + Set-Location $BackDir; pnpm install; Set-Location $ScriptDir + Set-Location $FrontDir; pnpm install; Set-Location $ScriptDir + info "Dependencies installed" + + $svc = @("mongodb", "redis") + if ($script:DeployMode -match "kali") { $svc += "kali" } + info "Starting infrastructure: " -NoNewline; Write-Host ($svc -join " ") -ForegroundColor $White + Write-Host "" + + $composeArgs = @("up") + if ($buildFlag) { $composeArgs += $buildFlag } + $composeArgs += "-d" + $composeArgs += $svc + Invoke-Compose $composeArgs + + section "Developer Mode -- Infrastructure Running" + Write-Host "" + Write-Host " MongoDB " -NoNewline -ForegroundColor $Green; Write-Host "localhost:27017" + Write-Host " Redis " -NoNewline -ForegroundColor $Green; Write-Host "localhost:6379" + if ($script:DeployMode -match "kali") { + Write-Host " Kali SSH " -NoNewline -ForegroundColor $Green; Write-Host "ssh root@localhost -p 4242" + } + Write-Host "" + section "Start Frontend & Backend Manually" + Write-Host "" + Write-Host " Backend " -NoNewline -ForegroundColor $Cyan; Write-Host "(two terminals):" + hint "cd backend && pnpm run watch # Terminal 1" + hint "cd backend && pnpm run dev # Terminal 2" + Write-Host "" + Write-Host " Frontend:" -ForegroundColor $Cyan + hint "cd frontend && pnpm run dev" + Write-Host "" + $fUrl = Get-TomlVar $ConfigToml "base_url_frontend" + $bUrl = Get-EnvVar $FrontendEnv "NEXT_PUBLIC_BACKEND_URI" + $displayF = if ($fUrl) { $fUrl } else { "http://localhost:3000" } + $displayB = if ($bUrl) { $bUrl } else { "http://localhost:8080" } + Write-Host " Endpoints: " -NoNewline -ForegroundColor $Cyan + Write-Host "Frontend $displayF | Backend $displayB" + Write-Host "" + hint "Config files: config.toml (restart-required) | backend/.env (hot-reloadable)" + hint "Additional features (Burp, Browser Agent, VNC) can be configured in the Settings UI." + Write-Host "" + return + } + + section "Launching Stack" + Ensure-ComposeOverride + Save-RunState + + # Ensure backend/.env exists before compose up + if (-not (Test-Path $DynamicEnv)) { + Copy-Item $DynamicEnvTmpl $DynamicEnv + info "Created .env from template" + } + + info "Compose file: $script:ComposeFile" + if ($buildFlag -eq "--build") { + warn "Building images -- may take a while on first run..." + } + + Write-Host "" + $composeArgs = @("up") + if ($buildFlag) { $composeArgs += $buildFlag } + $composeArgs += "-d" + Invoke-Compose $composeArgs + Write-Host "" + + Write-Host "" + $fUrl = Get-TomlVar $ConfigToml "base_url_frontend" + $bUrl = Get-EnvVar $FrontendEnv "NEXT_PUBLIC_BACKEND_URI" + $displayF = if ($fUrl) { $fUrl } else { "http://localhost:3000" } + $displayB = if ($bUrl) { $bUrl } else { "http://localhost:8080" } + Write-Host " Frontend " -NoNewline -ForegroundColor $Green; Write-Host "$displayF" + Write-Host " Backend " -NoNewline -ForegroundColor $Green; Write-Host "$displayB" + Write-Host " MongoDB " -NoNewline -ForegroundColor $Green; Write-Host "localhost:27017" + Write-Host " Redis " -NoNewline -ForegroundColor $Green; Write-Host "localhost:6379" + + if ($script:DeployMode -match "kali") { + Write-Host "" + Write-Host " Kali SSH " -NoNewline -ForegroundColor $Green; Write-Host "ssh root@localhost -p 4242" + Write-Host " Kali Web Shell " -NoNewline -ForegroundColor $Green; Write-Host "http://localhost:4200" + } + Write-Host "" + hint "Config files: config.toml (restart-required) | .env (hot-reloadable via Settings UI)" + hint "Additional features (Burp, Browser Agent, VNC) can be configured in the Settings UI." + Write-Host "" + info "Commands: .\run.ps1 stop | .\run.ps1 logs | .\run.ps1 status | .\run.ps1 config" +} + +# Commands + +# -- Config options continued ----------------------------------- +function Configure-StaticFull { + section "Static Configuration (Developer Mode)" + hint "These settings require a process restart to take effect." + Ensure-ConfigDefaults + Ensure-FrontendEnv + + $curF = Get-TomlVar $ConfigToml "base_url_frontend" + if (-not $curF) { $curF = "http://localhost:3000" } + $f = Prompt-Input "Frontend URL [$curF]:" + if ($f) { Set-TomlVar $ConfigToml "base_url_frontend" $f } else { $f = $curF } + + $curP = Get-TomlVar $ConfigToml "port" + if (-not $curP) { $curP = "8080" } + $p = Prompt-Input "Backend port [$curP]:" + if ($p) { Set-TomlVar $ConfigToml "port" $p } + + $curD = Get-TomlVar $ConfigToml "deployment" + if (-not $curD) { $curD = "LOCAL" } + $d = Prompt-Input "Deployment [LOCAL/PROD] [$curD]:" + if ($d) { Set-TomlVar $ConfigToml "deployment" $d } + + $curB = Get-EnvVar $FrontendEnv "NEXT_PUBLIC_BACKEND_URI" + if (-not $curB) { $curB = "http://localhost:8080" } + $b = Prompt-Input "Backend URL for frontend [$curB]:" + if ($b) { Set-EnvVar $FrontendEnv "NEXT_PUBLIC_BACKEND_URI" $b } + + $curFD = Get-EnvVar $FrontendEnv "NEXT_PUBLIC_DEPLOYMENT" + if (-not $curFD) { $curFD = "LOCAL" } + $fd = Prompt-Input "Frontend deployment mode [LOCAL/PRODUCTION] [$curFD]:" + if ($fd) { Set-EnvVar $FrontendEnv "NEXT_PUBLIC_DEPLOYMENT" $fd } + + Set-TomlVar $ConfigToml "cors_origins" $f + + section "Database" + hint "Change only if using external MongoDB/Redis." + $curM = Get-TomlVar $ConfigToml "mongo_uri" + if (-not $curM) { $curM = "mongodb://localhost:27017/pentestcopilot" } + $m = Prompt-Input "MongoDB URI [$curM]:" + if ($m) { Set-TomlVar $ConfigToml "mongo_uri" $m } + + $curR = Get-TomlVar $ConfigToml "redis_url" + if (-not $curR) { $curR = "redis://localhost:6379" } + $r = Prompt-Input "Redis URL [$curR]:" + if ($r) { Set-TomlVar $ConfigToml "redis_url" $r } + + info "Developer-mode static config updated" +} + +# Commands +function Cmd-Start { + Check-Prerequisites + if (-not $script:QuickMode) { Select-LaunchMode } + elseif (-not (Load-RunState)) { Set-NormalMode } + + Ensure-ConfigDefaults + Ensure-EnvDefaults + Ensure-FrontendEnv + + if ($script:QuickMode) { + if (Is-ModelConfigured) { + Show-ConfigSummary + info "Quick start -- using existing configuration" + } else { + warn "No model API key configured -- you must set this before using the agent." + hint "Configure via the Settings UI after starting, or re-run without -q." + } + } else { + if ($script:DevMode) { Configure-StaticFull } + Configure-RequiredStartupSmart + } + Launch-App "" +} + +function Format-StatusIcon { + param($status) + if ($status -match "healthy|running") { return Write-Output "*" -NoEnumerate } + if ($status -match "exited|dead") { return Write-Output "*" -NoEnumerate } + if ($status -match "restarting") { return Write-Output "*" -NoEnumerate } + return Write-Output "-" -NoEnumerate +} + +function Get-StatusColor { + param($status) + if ($status -match "healthy|running") { return "Green" } + if ($status -match "exited|dead") { return "Red" } + if ($status -match "restarting") { return "Yellow" } + return "DarkGray" +} + +function Cmd-Status { + Check-Prerequisites + Select-ModeForOperations + section "Container Status" + + $psJson = Invoke-Compose @("ps", "--format", "json") | ConvertFrom-Json + if ($null -eq $psJson) { + warn "No containers found." + return + } + + foreach ($c in $psJson) { + $icon = Format-StatusIcon $c.Status + $color = Get-StatusColor $c.Status + $uptime = $c.Status -replace "Up ", "" -replace " \(healthy\)", "" -replace "About ", "~" + + Write-Host " " -NoNewline + Write-Host $icon -ForegroundColor $color -NoNewline + Write-Host " " -NoNewline + Write-Host "$($c.Service.PadRight(12)) " -NoNewline -ForegroundColor $White + Write-Host "$($uptime.PadRight(20))" -NoNewline + + if ($c.Publishers) { + $ports = $c.Publishers | ForEach-Object { $_.PublishedPort } | Sort-Object | Get-Unique + $portString = $ports -join ", " + Write-Host " ports: $portString" -ForegroundColor DarkGray + } else { + Write-Host "" + } + } + + section "Quick Access" + Write-Host "" + $fUrl = Get-TomlVar $ConfigToml "base_url_frontend" + $bUrl = Get-EnvVar $FrontendEnv "NEXT_PUBLIC_BACKEND_URI" + $displayF = if ($fUrl) { $fUrl } else { "http://localhost:3000" } + $displayB = if ($bUrl) { $bUrl } else { "http://localhost:8080" } + Write-Host " Frontend " -NoNewline -ForegroundColor $Green; Write-Host "$displayF" + Write-Host " Backend " -NoNewline -ForegroundColor $Green; Write-Host "$displayB" + + if ($script:DeployMode -match "kali") { + Write-Host " Kali SSH " -NoNewline -ForegroundColor $Green; Write-Host "ssh root@localhost -p 4242" + Write-Host " Kali Web Shell " -NoNewline -ForegroundColor $Green; Write-Host "http://localhost:4200" + } + Write-Host "" +} + +function Cmd-Logs { + param($rest) + Check-Prerequisites + Select-ModeForOperations + $lArgs = @("logs", "-f") + if ($rest) { $lArgs += $rest } + Invoke-Compose $lArgs +} + +function Cmd-Config { + Check-Prerequisites + Select-ModeForOperations + Ensure-EnvDefaults + Ensure-ConfigDefaults + + section "Configuration" + Write-Host "" + Write-Host " 1) Model API keys " -NoNewline -ForegroundColor $White; Write-Host "(changeable at runtime via Settings UI)" -ForegroundColor DarkGray + Write-Host " 2) Google search " -NoNewline -ForegroundColor $White; Write-Host "(changeable at runtime via Settings UI)" -ForegroundColor DarkGray + Write-Host " 3) Langfuse tracing " -NoNewline -ForegroundColor $White; Write-Host "(requires container restart)" -ForegroundColor DarkGray + Write-Host " 4) Exploit box " -NoNewline -ForegroundColor $White; Write-Host "(changeable at runtime via Settings UI)" -ForegroundColor DarkGray + Write-Host " 5) All of the above" + if ($script:DevMode) { + Write-Host " 6) Server / Database / CORS " -NoNewline -ForegroundColor $White; Write-Host "(requires process restart)" -ForegroundColor DarkGray + } + Write-Host "" + + $max = if ($script:DevMode) { 6 } else { 5 } + $c = Prompt-Input "Choose [1-$max]:" + switch ($c) { + "1" { Configure-ModelKeys } + "2" { Configure-GoogleSearch } + "3" { Configure-Langfuse } + "4" { Configure-ExploitBox } + "5" { Configure-ModelKeys; Configure-GoogleSearch; Configure-Langfuse; Configure-ExploitBox } + "6" { if ($script:DevMode) { Configure-StaticFull } } + } + + Write-Host "" + if (-not $script:DevMode) { + if (Confirm-Action "Restart containers to apply changes?" "y") { + Ensure-ComposeOverride + Invoke-Compose "restart" + info "Containers restarted" + } else { + hint "Runtime-editable settings (Model, Google, SSH) take effect without restart." + hint "Langfuse & server settings require a restart: .\run.ps1 stop && .\run.ps1 start" + } + } else { + info "Config saved." + hint "Runtime-editable settings take effect immediately. Restart backend for static config changes." + } +} + +# -- Main --------------------------------------------------- +Print-Banner +$cmd = "start" +if ($args.Count -gt 0) { $cmd = $args[0] } +foreach ($arg in $args) { + if ($arg -eq "-Quick" -or $arg -eq "-q") { $script:QuickMode = $true } +} + +switch ($cmd) { + "start" { Cmd-Start } + "stop" { Check-Prerequisites; Select-ModeForOperations; Invoke-Compose "down" } + "status" { Cmd-Status } + "dev" { $script:DevMode = $true; Cmd-Start } + "config" { Cmd-Config } + "logs" { $extra = if ($args.Count -gt 1) { $args[1..($args.Count-1)] } else { $null }; Cmd-Logs $extra } + "help" { Show-Commands } + Default { Show-Commands } +} From e9e199bae18641386ccf1ad5596776997f99769b Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Wed, 18 Mar 2026 17:16:14 +0530 Subject: [PATCH 02/12] minor bug fixed --- backend/Dockerfile | 2 +- run.ps1 | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 29d447f..d339d1c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -31,7 +31,7 @@ ENV DISPLAY=:99 RUN pnpm run build COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh && sed -i 's/\r$//' /usr/local/bin/entrypoint.sh EXPOSE 8080 6080 diff --git a/run.ps1 b/run.ps1 index 57303d7..053828b 100644 --- a/run.ps1 +++ b/run.ps1 @@ -251,6 +251,15 @@ function Ensure-ConfigDefaults { if ($curRedis -match "redis:" -and $curRedis -notmatch "localhost") { Set-TomlVar $ConfigToml "redis_url" "redis://localhost:6379" } + } else { + $curMongo = Get-TomlVar $ConfigToml "mongo_uri" + $curRedis = Get-TomlVar $ConfigToml "redis_url" + if ($curMongo -match "localhost") { + Set-TomlVar $ConfigToml "mongo_uri" "mongodb://mongodb:27017/pentestcopilot" + } + if ($curRedis -match "localhost") { + Set-TomlVar $ConfigToml "redis_url" "redis://redis:6379" + } } $frontendUrl = Get-TomlVar $ConfigToml "base_url_frontend" @@ -300,6 +309,14 @@ function Ensure-FrontendEnv { } } +function Sync-ComposeFileFromConfig { + if ($script:DevMode) { return } + $sshHost = Get-EnvVar $DynamicEnv "SSH_HOST" + if ($sshHost -eq "parrot") { + Set-NormalParrotMode + } +} + # -- Mode Selection ----------------------------------------- function Set-NormalMode { $script:DevMode = $false @@ -409,6 +426,7 @@ function Configure-RequiredStartupSmart { Show-ConfigSummary if (Confirm-Action "Keep current configuration and start?" "y") { info "Using existing configuration" + Sync-ComposeFileFromConfig return } Write-Host "" @@ -458,6 +476,8 @@ function Configure-RequiredStartupSmart { info "Keeping current exploit box config" } } + + Sync-ComposeFileFromConfig } function Configure-ModelKeys { section "Model API Keys" @@ -962,6 +982,7 @@ function Cmd-Start { if (Is-ModelConfigured) { Show-ConfigSummary info "Quick start -- using existing configuration" + Sync-ComposeFileFromConfig } else { warn "No model API key configured -- you must set this before using the agent." hint "Configure via the Settings UI after starting, or re-run without -q." From e20ea655f2590943bba938252ddba7394faf90cb Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Wed, 18 Mar 2026 17:38:47 +0530 Subject: [PATCH 03/12] Update run.ps1 --- run.ps1 | 69 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/run.ps1 b/run.ps1 index 053828b..c6af840 100644 --- a/run.ps1 +++ b/run.ps1 @@ -685,6 +685,18 @@ function Configure-ExternalSSH { $defHost = if ($script:DevMode) { "localhost" } else { "host.docker.internal" } $defPort = "22" $defUser = "$($env:USERNAME)" + + $curHost = Get-EnvVar $DynamicEnv "SSH_HOST" + $curPort = Get-EnvVar $DynamicEnv "SSH_PORT" + $curUser = Get-EnvVar $DynamicEnv "SSH_USERNAME" + + if (-not [string]::IsNullOrEmpty($curHost)) { + if ($curHost -notmatch "^(localhost|host\.docker\.internal|kali)$") { + $defHost = $curHost + if ($curPort) { $defPort = $curPort } + if ($curUser) { $defUser = $curUser } + } + } Write-Host "" hint "Enter host (use $defHost for your local machine, or any VM IP/hostname for remote)" @@ -743,11 +755,11 @@ function Show-ConfigSummary { $displayM = if ($m) { $m } else { "gpt-4o" } if ($k) { $displayL = if ($script:DevMode) { "$displayP/$displayM" } else { "$displayP / $displayM" } - Write-Host " " -NoNewline; Write-Host "*" -ForegroundColor $Green -NoNewline + Write-Host " " -NoNewline; Write-Host "●" -ForegroundColor $Green -NoNewline Write-Host " Model: " -NoNewline -ForegroundColor $White; Write-Host "$displayL " -NoNewline -ForegroundColor $White Write-Host "(key: $(Mask-Key $k))" } else { - Write-Host " " -NoNewline; Write-Host "*" -ForegroundColor $Red -NoNewline + Write-Host " " -NoNewline; Write-Host "●" -ForegroundColor $Red -NoNewline Write-Host " Model: " -NoNewline -ForegroundColor $White; Write-Host "not configured " -NoNewline -ForegroundColor $White Write-Host "<- required" -ForegroundColor $Yellow } @@ -755,10 +767,10 @@ function Show-ConfigSummary { $gk = Get-EnvVar $DynamicEnv "GOOGLE-API-KEY" $gc = Get-EnvVar $DynamicEnv "CUSTOM-SEARCH-ENGINE-ID" if ($gk -and $gc) { - Write-Host " " -NoNewline; Write-Host "*" -ForegroundColor $Green -NoNewline + Write-Host " " -NoNewline; Write-Host "●" -ForegroundColor $Green -NoNewline Write-Host " Google Search: configured" } else { - Write-Host " " -NoNewline; Write-Host "-" -ForegroundColor DarkGray -NoNewline + Write-Host " " -NoNewline; Write-Host "○" -ForegroundColor DarkGray -NoNewline Write-Host " Google Search: not set" -ForegroundColor DarkGray } @@ -766,10 +778,10 @@ function Show-ConfigSummary { $su = Get-EnvVar $DynamicEnv "SSH_USERNAME" $displayU = if ($su) { $su } else { "root" } if ($sh) { - Write-Host " " -NoNewline; Write-Host "*" -ForegroundColor $Green -NoNewline + Write-Host " " -NoNewline; Write-Host "●" -ForegroundColor $Green -NoNewline Write-Host " Exploit Box: $displayU@$sh" } else { - Write-Host " " -NoNewline; Write-Host "-" -ForegroundColor DarkGray -NoNewline + Write-Host " " -NoNewline; Write-Host "○" -ForegroundColor DarkGray -NoNewline Write-Host " Exploit Box: not set " -NoNewline -ForegroundColor DarkGray Write-Host "(optional)" -ForegroundColor DarkGray } @@ -893,6 +905,12 @@ function Launch-App { if ($buildFlag) { $composeArgs += $buildFlag } $composeArgs += "-d" Invoke-Compose $composeArgs + + if ($LASTEXITCODE -ne 0 -and $buildFlag -eq "--build") { + Write-Host "" + warn "Compose exited with an error -- retrying without build..." + Invoke-Compose @("up", "-d") + } Write-Host "" Write-Host "" @@ -996,10 +1014,10 @@ function Cmd-Start { function Format-StatusIcon { param($status) - if ($status -match "healthy|running") { return Write-Output "*" -NoEnumerate } - if ($status -match "exited|dead") { return Write-Output "*" -NoEnumerate } - if ($status -match "restarting") { return Write-Output "*" -NoEnumerate } - return Write-Output "-" -NoEnumerate + if ($status -match "healthy|running") { return Write-Output "●" -NoEnumerate } + if ($status -match "exited|dead") { return Write-Output "●" -NoEnumerate } + if ($status -match "restarting") { return Write-Output "●" -NoEnumerate } + return Write-Output "○" -NoEnumerate } function Get-StatusColor { @@ -1111,6 +1129,33 @@ function Cmd-Config { } } +function Cmd-Help { + Write-Host "Usage: .\run.ps1 [command] [flags]`n" + Show-Commands + Write-Host " Flags:" -ForegroundColor $White + Write-Host " --quick, -q, -Quick " -NoNewline -ForegroundColor $Cyan + Write-Host "Skip all configuration prompts and use existing config." + Write-Host " Ideal for subsequent starts after initial setup." + Write-Host "" + Write-Host "Default behavior:" + Write-Host " - .\run.ps1 / .\run.ps1 start: guided start with smart prompts (skips already-configured items)" + Write-Host " - .\run.ps1 start -q: instant start using existing config (no prompts at all)" + Write-Host " - Normal mode: guided setup, best for most users" + Write-Host " - Developer mode: advanced setup, run frontend/backend manually" + Write-Host "" + Write-Host " Configuration Layers:" -ForegroundColor $White + Write-Host " config.toml " -NoNewline -ForegroundColor $Cyan + Write-Host "Static settings (server, DB, session, Langfuse)." + Write-Host " Changes require a restart." + Write-Host " backend\.env " -NoNewline -ForegroundColor $Cyan + Write-Host "Dynamic settings (model, SSH, VNC, Burp, Magnitude)." + Write-Host " Changes take effect immediately (editable via Settings UI)." + Write-Host " frontend\.env " -NoNewline -ForegroundColor $Cyan + Write-Host "Frontend build settings (NEXT_PUBLIC_*)." + Write-Host " Changes require a frontend rebuild." + Write-Host "" +} + # -- Main --------------------------------------------------- Print-Banner $cmd = "start" @@ -1126,6 +1171,6 @@ switch ($cmd) { "dev" { $script:DevMode = $true; Cmd-Start } "config" { Cmd-Config } "logs" { $extra = if ($args.Count -gt 1) { $args[1..($args.Count-1)] } else { $null }; Cmd-Logs $extra } - "help" { Show-Commands } - Default { Show-Commands } + "help" { Cmd-Help } + Default { err "Unknown command: $cmd"; Write-Host "Run .\run.ps1 help for usage."; exit 1 } } From 60c8c4efa88847111cca6570d85d789891fb4d2a Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Wed, 18 Mar 2026 20:06:22 +0530 Subject: [PATCH 04/12] Update Dockerfile --- kali/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kali/Dockerfile b/kali/Dockerfile index 5831d66..0b0b3d0 100644 --- a/kali/Dockerfile +++ b/kali/Dockerfile @@ -24,7 +24,7 @@ RUN mkdir -p /var/run/sshd && \ EXPOSE 22 4200 1194/udp 9020 COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +RUN chmod +x /entrypoint.sh && sed -i 's/\r$//' /entrypoint.sh # ── Layer 2: Base utilities + build deps (needed for runtime tool installs) ── RUN apt-get update && \ @@ -70,7 +70,7 @@ RUN apt-get update && \ # Additional security tools (Go-based & Python-based — uncomment to pre-install) COPY tools.sh /tools.sh -RUN chmod +x /tools.sh +RUN chmod +x /tools.sh && sed -i 's/\r$//' /tools.sh # RUN /tools.sh ENTRYPOINT ["/entrypoint.sh"] From 4b09ff3435aba7d9f240244f7c8b3189b1e3887d Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Wed, 18 Mar 2026 20:26:29 +0530 Subject: [PATCH 05/12] Create README-Windows.md --- README-Windows.md | 135 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 README-Windows.md diff --git a/README-Windows.md b/README-Windows.md new file mode 100644 index 0000000..a8043f4 --- /dev/null +++ b/README-Windows.md @@ -0,0 +1,135 @@ +

+ Pentest Copilot Banner +

+ +# Pentest Copilot + +![GitHub License](https://img.shields.io/github/license/bugbasesecurity/pentest-copilot) +![GitHub Repo stars](https://img.shields.io/github/stars/bugbasesecurity/pentest-copilot) +![GitHub forks](https://img.shields.io/github/forks/bugbasesecurity/pentest-copilot) + +An open-source, AI-driven penetration testing agent. Connects to a Kali attack box, runs tools autonomously, analyzes results, and iterates. You describe the target. It does the rest. + +Built for real-world engagements, boot2root boxes, and CTFs. + +

+ +

+ +

+ +

+ +## In Action + +Pentest Copilot performing an auth bypass in [OWASP Juice Shop](https://owasp.org/www-project-juice-shop/): + + + + +Watch it on [YouTube](https://www.youtube.com/watch?v=L0bjYzuICWo) + +## What It Does + +- **Agentic execution** - the AI runs commands directly on the attack box, reads output, decides next steps, and loops. Up to 25 iterations per turn, no manual nudging required. +- **16 agent tools** - bash, Python scripts, tool installation, shell management, Google search, subagent spawning, Burp Suite (proxy history, Repeater, Intruder, Collaborator), and browser automation. +- **100+ capabilities** - curated registry of security tools and Python packages across 7 categories (network, rev, pwn, crypto, forensics, stego, core). Select what you need, the agent installs the rest. +- **Burp Suite integration** - proxy history viewer, send requests to Repeater/Intruder, Collaborator for out-of-band testing. All accessible to the agent and through the UI. +- **Browser agent** - real browser automation via [Magnitude](https://github.com/magnitude-dev/magnitude). Test login flows, fill forms, interact with JavaScript-heavy apps. Optionally proxy traffic through Burp. In Docker mode, watch the browser via the built-in VNC stream; in developer mode, the browser opens on your local desktop. +- **VPN management** - upload `.ovpn` profiles and connect/disconnect from the browser. Multiple simultaneous connections supported. +- **Subagent parallelism** - spawn background agents to run tasks concurrently (e.g. directory brute-force + subdomain enum at the same time). +- **Safety checks** - dangerous commands (recursive deletes, device writes, fork bombs) require explicit approval, even in auto-run mode. +- **Bring your own model** - OpenAI, Anthropic (API key or OAuth), Google, Mistral, or any OpenAI-compatible endpoint. + +## Quick Start + +```powershell +git clone https://github.com/bugbasesecurity/pentest-copilot.git +cd pentest-copilot +.\run.ps1 start +``` + +Open `http://localhost:3000`, register, and start a session. + +`run.sh` handles config file generation, Docker builds, and container orchestration. On first run it prompts for your model provider and API key. Use `.\run.ps1 start -q` to skip prompts on subsequent runs. + +```powershell +.\run.ps1 stop # Stop all containers +.\run.ps1 logs # Tail logs +.\run.ps1 status # Container status +.\run.ps1 config # Update configuration +.\run.ps1 dev # Developer mode (infra only, run frontend/backend locally) +.\run.ps1 help # Full help +``` + +### System Requirements + +| | Minimum | +|---|---| +| RAM | 8 GB (+2 GB if using the built-in Kali container) | +| Disk | 20 GB | +| Docker | v20+ with Compose v2+ | +| Node.js | v22+ (dev mode only) | +| pnpm | v9+ (dev mode only) | + +## Documentation + +Full documentation lives in the **[Wiki](https://github.com/bugbasesecurity/pentest-copilot/wiki)**: + +- [Getting Started](https://github.com/bugbasesecurity/pentest-copilot/wiki/Home) - setup, configuration, environment variables +- [Architecture](https://github.com/bugbasesecurity/pentest-copilot/wiki/Architecture) - system design, agent loop, subagents +- [Usage](https://github.com/bugbasesecurity/pentest-copilot/wiki/Usage) - workflow, consent model, chat interface +- [Features](https://github.com/bugbasesecurity/pentest-copilot/wiki/Features) - full feature overview +- [Settings](https://github.com/bugbasesecurity/pentest-copilot/wiki/Settings) - models, SSH, VNC, Burp, Magnitude +- [Capabilities](https://github.com/bugbasesecurity/pentest-copilot/wiki/Capabilities) - tool registry and buckets +- [Agent Tools](https://github.com/bugbasesecurity/pentest-copilot/wiki/Agent-Tools) - all 16 tools and consent behavior +- [Burp Suite Integration](https://github.com/bugbasesecurity/pentest-copilot/wiki/Burp-Suite-Integration) - setup and usage +- [Browser Agent](https://github.com/bugbasesecurity/pentest-copilot/wiki/Browser-Agent) - Magnitude configuration +- [VPN Management](https://github.com/bugbasesecurity/pentest-copilot/wiki/VPN-Management) - profile management +- [Slash Commands](https://github.com/bugbasesecurity/pentest-copilot/wiki/Slash-Commands) - session utilities +- [Changelog](https://github.com/bugbasesecurity/pentest-copilot/wiki/Changelog) - what's new + +## Local Development + +```powershell +.\run.ps1 dev # Starts MongoDB + Redis in Docker +``` + +Then in separate terminals: + +```powershell +cd backend; pnpm install; pnpm run watch # TypeScript compiler +cd backend; pnpm run dev # Backend server (port 8080) +cd frontend; pnpm install; pnpm run dev # Frontend (port 3000) +``` + +See the [Wiki](https://github.com/bugbasesecurity/pentest-copilot/wiki/Home) for detailed setup instructions. + +## Authors + +- Dhruva Goyal - [dhruva@bugbase.ai](mailto:dhruva@bugbase.ai) | [LinkedIn](https://www.linkedin.com/in/dhruva-goyal/) | [GitHub](https://github.com/shero4) | [X](https://x.com/dhruvagoyal) +- Aditya Peela - [aditya@bugbase.ai](mailto:aditya@bugbase.ai) | [LinkedIn](https://www.linkedin.com/in/aditya-peela/) | [GitHub](https://github.com/adityamhn) | [X](https://x.com/adityapeela) +- Sitaraman Subramanian - [sitaraman@bugbase.ai](mailto:sitaraman@bugbase.ai) | [LinkedIn](https://www.linkedin.com/in/sitaraman-s/) | [GitHub](https://github.com/hackerbone) | [X](https://x.com/situuu_ig) + +## Citations + +```bibtex +@article{goyal2024hacking, + title={Hacking, the lazy way: LLM augmented pentesting}, + author={Goyal, Dhruva and Subramanian, Sitaraman and Peela, Aditya}, + journal={arXiv preprint arXiv:2409.09493}, + year={2024} +} +``` + +## Contributing + +Contributions welcome. See the [Contributing Guide](./CONTRIBUTING.md) and [Code of Conduct](./CODE_OF_CONDUCT.md). + +## License + +[MIT License](./LICENSE) + +## Disclaimer + +Pentest Copilot is intended for authorized security testing only. Always have explicit permission before testing any system. From dc535462436db210b1f0098abc88e7755efd84b4 Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Wed, 18 Mar 2026 20:37:35 +0530 Subject: [PATCH 06/12] Update Dockerfile --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index d339d1c..00b2349 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,7 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /usr/src/backend COPY package.json pnpm-lock.yaml* ./ -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile +RUN --mount=type=cache,id=pnpm-debian,target=/pnpm/store pnpm install --frozen-lockfile # Install Patchright Chromium for Magnitude browser agent RUN pnpm exec patchright install chromium From 1768b49f9fddd14c22b8a63d49fc60304b6b3c38 Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Wed, 18 Mar 2026 21:01:16 +0530 Subject: [PATCH 07/12] updated Readme.md - Instructions to use run.ps1 --- README-Windows.md | 135 ---------------------------------------------- README.md | 29 ++++++++++ 2 files changed, 29 insertions(+), 135 deletions(-) delete mode 100644 README-Windows.md diff --git a/README-Windows.md b/README-Windows.md deleted file mode 100644 index a8043f4..0000000 --- a/README-Windows.md +++ /dev/null @@ -1,135 +0,0 @@ -

- Pentest Copilot Banner -

- -# Pentest Copilot - -![GitHub License](https://img.shields.io/github/license/bugbasesecurity/pentest-copilot) -![GitHub Repo stars](https://img.shields.io/github/stars/bugbasesecurity/pentest-copilot) -![GitHub forks](https://img.shields.io/github/forks/bugbasesecurity/pentest-copilot) - -An open-source, AI-driven penetration testing agent. Connects to a Kali attack box, runs tools autonomously, analyzes results, and iterates. You describe the target. It does the rest. - -Built for real-world engagements, boot2root boxes, and CTFs. - -

- -

- -

- -

- -## In Action - -Pentest Copilot performing an auth bypass in [OWASP Juice Shop](https://owasp.org/www-project-juice-shop/): - - - - -Watch it on [YouTube](https://www.youtube.com/watch?v=L0bjYzuICWo) - -## What It Does - -- **Agentic execution** - the AI runs commands directly on the attack box, reads output, decides next steps, and loops. Up to 25 iterations per turn, no manual nudging required. -- **16 agent tools** - bash, Python scripts, tool installation, shell management, Google search, subagent spawning, Burp Suite (proxy history, Repeater, Intruder, Collaborator), and browser automation. -- **100+ capabilities** - curated registry of security tools and Python packages across 7 categories (network, rev, pwn, crypto, forensics, stego, core). Select what you need, the agent installs the rest. -- **Burp Suite integration** - proxy history viewer, send requests to Repeater/Intruder, Collaborator for out-of-band testing. All accessible to the agent and through the UI. -- **Browser agent** - real browser automation via [Magnitude](https://github.com/magnitude-dev/magnitude). Test login flows, fill forms, interact with JavaScript-heavy apps. Optionally proxy traffic through Burp. In Docker mode, watch the browser via the built-in VNC stream; in developer mode, the browser opens on your local desktop. -- **VPN management** - upload `.ovpn` profiles and connect/disconnect from the browser. Multiple simultaneous connections supported. -- **Subagent parallelism** - spawn background agents to run tasks concurrently (e.g. directory brute-force + subdomain enum at the same time). -- **Safety checks** - dangerous commands (recursive deletes, device writes, fork bombs) require explicit approval, even in auto-run mode. -- **Bring your own model** - OpenAI, Anthropic (API key or OAuth), Google, Mistral, or any OpenAI-compatible endpoint. - -## Quick Start - -```powershell -git clone https://github.com/bugbasesecurity/pentest-copilot.git -cd pentest-copilot -.\run.ps1 start -``` - -Open `http://localhost:3000`, register, and start a session. - -`run.sh` handles config file generation, Docker builds, and container orchestration. On first run it prompts for your model provider and API key. Use `.\run.ps1 start -q` to skip prompts on subsequent runs. - -```powershell -.\run.ps1 stop # Stop all containers -.\run.ps1 logs # Tail logs -.\run.ps1 status # Container status -.\run.ps1 config # Update configuration -.\run.ps1 dev # Developer mode (infra only, run frontend/backend locally) -.\run.ps1 help # Full help -``` - -### System Requirements - -| | Minimum | -|---|---| -| RAM | 8 GB (+2 GB if using the built-in Kali container) | -| Disk | 20 GB | -| Docker | v20+ with Compose v2+ | -| Node.js | v22+ (dev mode only) | -| pnpm | v9+ (dev mode only) | - -## Documentation - -Full documentation lives in the **[Wiki](https://github.com/bugbasesecurity/pentest-copilot/wiki)**: - -- [Getting Started](https://github.com/bugbasesecurity/pentest-copilot/wiki/Home) - setup, configuration, environment variables -- [Architecture](https://github.com/bugbasesecurity/pentest-copilot/wiki/Architecture) - system design, agent loop, subagents -- [Usage](https://github.com/bugbasesecurity/pentest-copilot/wiki/Usage) - workflow, consent model, chat interface -- [Features](https://github.com/bugbasesecurity/pentest-copilot/wiki/Features) - full feature overview -- [Settings](https://github.com/bugbasesecurity/pentest-copilot/wiki/Settings) - models, SSH, VNC, Burp, Magnitude -- [Capabilities](https://github.com/bugbasesecurity/pentest-copilot/wiki/Capabilities) - tool registry and buckets -- [Agent Tools](https://github.com/bugbasesecurity/pentest-copilot/wiki/Agent-Tools) - all 16 tools and consent behavior -- [Burp Suite Integration](https://github.com/bugbasesecurity/pentest-copilot/wiki/Burp-Suite-Integration) - setup and usage -- [Browser Agent](https://github.com/bugbasesecurity/pentest-copilot/wiki/Browser-Agent) - Magnitude configuration -- [VPN Management](https://github.com/bugbasesecurity/pentest-copilot/wiki/VPN-Management) - profile management -- [Slash Commands](https://github.com/bugbasesecurity/pentest-copilot/wiki/Slash-Commands) - session utilities -- [Changelog](https://github.com/bugbasesecurity/pentest-copilot/wiki/Changelog) - what's new - -## Local Development - -```powershell -.\run.ps1 dev # Starts MongoDB + Redis in Docker -``` - -Then in separate terminals: - -```powershell -cd backend; pnpm install; pnpm run watch # TypeScript compiler -cd backend; pnpm run dev # Backend server (port 8080) -cd frontend; pnpm install; pnpm run dev # Frontend (port 3000) -``` - -See the [Wiki](https://github.com/bugbasesecurity/pentest-copilot/wiki/Home) for detailed setup instructions. - -## Authors - -- Dhruva Goyal - [dhruva@bugbase.ai](mailto:dhruva@bugbase.ai) | [LinkedIn](https://www.linkedin.com/in/dhruva-goyal/) | [GitHub](https://github.com/shero4) | [X](https://x.com/dhruvagoyal) -- Aditya Peela - [aditya@bugbase.ai](mailto:aditya@bugbase.ai) | [LinkedIn](https://www.linkedin.com/in/aditya-peela/) | [GitHub](https://github.com/adityamhn) | [X](https://x.com/adityapeela) -- Sitaraman Subramanian - [sitaraman@bugbase.ai](mailto:sitaraman@bugbase.ai) | [LinkedIn](https://www.linkedin.com/in/sitaraman-s/) | [GitHub](https://github.com/hackerbone) | [X](https://x.com/situuu_ig) - -## Citations - -```bibtex -@article{goyal2024hacking, - title={Hacking, the lazy way: LLM augmented pentesting}, - author={Goyal, Dhruva and Subramanian, Sitaraman and Peela, Aditya}, - journal={arXiv preprint arXiv:2409.09493}, - year={2024} -} -``` - -## Contributing - -Contributions welcome. See the [Contributing Guide](./CONTRIBUTING.md) and [Code of Conduct](./CODE_OF_CONDUCT.md). - -## License - -[MIT License](./LICENSE) - -## Disclaimer - -Pentest Copilot is intended for authorized security testing only. Always have explicit permission before testing any system. diff --git a/README.md b/README.md index 8e8594d..a335947 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,25 @@ Open `http://localhost:3000`, register, and start a session. ./run.sh help # Full help ``` +```powershell +git clone https://github.com/bugbasesecurity/pentest-copilot.git +cd pentest-copilot +.\run.ps1 start +``` + +Open `http://localhost:3000`, register, and start a session. + +`run.sh` handles config file generation, Docker builds, and container orchestration. On first run it prompts for your model provider and API key. Use `.\run.ps1 start -q` to skip prompts on subsequent runs. + +```powershell +.\run.ps1 stop # Stop all containers +.\run.ps1 logs # Tail logs +.\run.ps1 status # Container status +.\run.ps1 config # Update configuration +.\run.ps1 dev # Developer mode (infra only, run frontend/backend locally) +.\run.ps1 help # Full help +``` + ### System Requirements | | Minimum | @@ -95,6 +114,10 @@ Full documentation lives in the **[Wiki](https://github.com/bugbasesecurity/pent ./run.sh dev # Starts MongoDB + Redis in Docker ``` +```powershell +.\run.ps1 dev # Starts MongoDB + Redis in Docker +``` + Then in separate terminals: ```bash @@ -103,6 +126,12 @@ cd backend && pnpm run dev # Backend server (port 8080) cd frontend && pnpm install && pnpm run dev # Frontend (port 3000) ``` +```powershell +cd backend; pnpm install; pnpm run watch # TypeScript compiler +cd backend; pnpm run dev # Backend server (port 8080) +cd frontend; pnpm install; pnpm run dev # Frontend (port 3000) +``` + See the [Wiki](https://github.com/bugbasesecurity/pentest-copilot/wiki/Home) for detailed setup instructions. ## Authors From 6d71234fd983877d8caee44fdb59976e284d3b4d Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Thu, 19 Mar 2026 20:13:24 +0530 Subject: [PATCH 08/12] Update Dockerfile --- backend/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 00b2349..b8c63a7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,8 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /usr/src/backend COPY package.json pnpm-lock.yaml* ./ -RUN --mount=type=cache,id=pnpm-debian,target=/pnpm/store pnpm install --frozen-lockfile - +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile # Install Patchright Chromium for Magnitude browser agent RUN pnpm exec patchright install chromium From eb2054d20d49f1cc791a3e4f81645283bb8349ad Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Thu, 19 Mar 2026 23:51:04 +0530 Subject: [PATCH 09/12] Update Dockerfile --- backend/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/Dockerfile b/backend/Dockerfile index b8c63a7..d339d1c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,6 +17,7 @@ WORKDIR /usr/src/backend COPY package.json pnpm-lock.yaml* ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile + # Install Patchright Chromium for Magnitude browser agent RUN pnpm exec patchright install chromium From 2cd5242f9b667bddeb1f328997d8140c33b19290 Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Sat, 21 Mar 2026 19:52:44 +0530 Subject: [PATCH 10/12] noVNC Fix Updated the run.sh output to show the correct port number. --- backend/src/controllers/user.controller.ts | 18 +++++++++--------- backend/src/controllers/vnc.controller.ts | 8 ++++---- run.sh | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index a0dc74b..fa6bf94 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -672,7 +672,7 @@ export const autoSetupVNC = async (_req: Request, res: Response) => { const randomPassword = generateRandomPassword(); await execCmd( `mkdir -p ~/.vnc && ` + - `echo '#!/bin/bash\\nexport DISPLAY=${VNC_DISPLAY}\\n[ -f $$HOME/.Xresources ] && xrdb $$HOME/.Xresources\\nif command -v startxfce4 >/dev/null 2>&1; then\\n startxfce4 &\\nelif command -v openbox-session >/dev/null 2>&1; then\\n openbox-session &\\nelse\\n xterm &\\nfi' > ~/.vnc/xstartup && ` + + `echo -e '#!/bin/bash\nexport DISPLAY=${VNC_DISPLAY}\nunset SESSION_MANAGER\n[ -f $$HOME/.Xresources ] && xrdb $$HOME/.Xresources\nif command -v xfce4-session >/dev/null 2>&1; then\n xfce4-session &\nelif command -v openbox-session >/dev/null 2>&1; then\n openbox-session &\nelse\n xterm &\nfi' > ~/.vnc/xstartup && ` + `chmod +x ~/.vnc/xstartup` ); // Kill all existing VNC/Xvfb processes for a clean start @@ -694,11 +694,11 @@ export const autoSetupVNC = async (_req: Request, res: Response) => { if (isXvncDirect) { await execCmd( `${vncBin} ${VNC_DISPLAY} -geometry 1280x800 -depth 24 -rfbport ${VNC_RFBPORT} ` + - `-SecurityTypes VncAuth -PasswordFile ~/.vnc/passwd ` + + `-rfbauth ~/.vnc/passwd ` + `-pn > /dev/null 2>&1 &` ); await new Promise((resolve) => setTimeout(resolve, 1500)); - await execCmd(`export DISPLAY=${VNC_DISPLAY} && ~/.vnc/xstartup &`); + await execCmd(`export DISPLAY=${VNC_DISPLAY} && nohup ~/.vnc/xstartup > /dev/null 2>&1 &`); } else if (isX11vnc) { await execCmd( "command -v Xvfb >/dev/null 2>&1 || " + @@ -706,7 +706,7 @@ export const autoSetupVNC = async (_req: Request, res: Response) => { ); await execCmd(`Xvfb ${VNC_DISPLAY} -screen 0 1280x800x24 > /dev/null 2>&1 &`); await new Promise((resolve) => setTimeout(resolve, 2000)); - await execCmd(`export DISPLAY=${VNC_DISPLAY} && ~/.vnc/xstartup &`); + await execCmd(`export DISPLAY=${VNC_DISPLAY} && nohup ~/.vnc/xstartup > /dev/null 2>&1 &`); await execCmd( `x11vnc -display ${VNC_DISPLAY} -rfbport ${VNC_RFBPORT} -passwd '${escapedPw}' ` + `-forever -shared -noxdamage > /dev/null 2>&1 &` @@ -1102,7 +1102,7 @@ export const repairVNC = async (req: Request, res: Response) => { try { await execSSHCommand( `mkdir -p ~/.vnc && ` + - `echo '#!/bin/bash\\nexport DISPLAY=${VNC_DISPLAY}\\n[ -f $$HOME/.Xresources ] && xrdb $$HOME/.Xresources\\nif command -v startxfce4 >/dev/null 2>&1; then\\n startxfce4 &\\nelif command -v openbox-session >/dev/null 2>&1; then\\n openbox-session &\\nelse\\n xterm &\\nfi' > ~/.vnc/xstartup && ` + + `echo -e '#!/bin/bash\nexport DISPLAY=${VNC_DISPLAY}\nunset SESSION_MANAGER\n[ -f $$HOME/.Xresources ] && xrdb $$HOME/.Xresources\nif command -v xfce4-session >/dev/null 2>&1; then\n xfce4-session &\nelif command -v openbox-session >/dev/null 2>&1; then\n openbox-session &\nelse\n xterm &\nfi' > ~/.vnc/xstartup && ` + `chmod +x ~/.vnc/xstartup` ); log.push(`Configured xstartup with DISPLAY=${VNC_DISPLAY}`); @@ -1145,11 +1145,11 @@ export const repairVNC = async (req: Request, res: Response) => { console.log(`[VNC Repair] Starting Xvnc directly: ${vncBin} ${VNC_DISPLAY} rfbport=${VNC_RFBPORT}`); await execSSHCommand( `${vncBin} ${VNC_DISPLAY} -geometry 1280x800 -depth 24 -rfbport ${VNC_RFBPORT} ` + - `-SecurityTypes VncAuth -PasswordFile ~/.vnc/passwd ` + + `-rfbauth ~/.vnc/passwd ` + `-pn > /dev/null 2>&1 &` ); await new Promise((r) => setTimeout(r, 1500)); - await execSSHCommand(`export DISPLAY=${VNC_DISPLAY} && ~/.vnc/xstartup &`); + await execSSHCommand(`export DISPLAY=${VNC_DISPLAY} && nohup ~/.vnc/xstartup > /dev/null 2>&1 &`); log.push(`Started Xvnc directly on ${VNC_DISPLAY}`); console.log(`[VNC Repair] Started Xvnc directly on ${VNC_DISPLAY}`); } else if (isX11vnc) { @@ -1167,7 +1167,7 @@ export const repairVNC = async (req: Request, res: Response) => { ); console.log("[VNC Repair] Xvfb process check:", xvfbCheck.trim() || "(not found)"); - await execSSHCommand(`export DISPLAY=${VNC_DISPLAY} && ~/.vnc/xstartup &`); + await execSSHCommand(`export DISPLAY=${VNC_DISPLAY} && nohup ~/.vnc/xstartup > /dev/null 2>&1 &`); const escaped = (savedPassword || "").replace(/'/g, "'\\''"); await execSSHCommand( `x11vnc -display ${VNC_DISPLAY} -rfbport ${VNC_RFBPORT} -passwd '${escaped}' -forever -shared -noxdamage > /dev/null 2>&1 &` @@ -1546,7 +1546,7 @@ export const startMagnitudeAgent = async (req: Request, res: Response) => { targetUrl, }); } catch (agentError: any) { - try { await agent.stop(); } catch {} + try { await agent.stop(); } catch { } return res.status(500).json({ message: `Browser agent failed: ${formatMagnitudeError(agentError)}`, goal, diff --git a/backend/src/controllers/vnc.controller.ts b/backend/src/controllers/vnc.controller.ts index 5fd122e..bf4e40b 100644 --- a/backend/src/controllers/vnc.controller.ts +++ b/backend/src/controllers/vnc.controller.ts @@ -75,7 +75,7 @@ export const getVNCCredentials = async (req: Request, res: Response) => { // Ensure xstartup exists with DISPLAY export await exec( - `mkdir -p ~/.vnc && echo '#!/bin/bash\\nexport DISPLAY=${VNC_DISPLAY}\\n[ -f $$HOME/.Xresources ] && xrdb $$HOME/.Xresources\\nif command -v startxfce4 >/dev/null 2>&1; then\\n startxfce4 &\\nelif command -v openbox-session >/dev/null 2>&1; then\\n openbox-session &\\nelse\\n xterm &\\nfi' > ~/.vnc/xstartup && chmod +x ~/.vnc/xstartup` + `mkdir -p ~/.vnc && echo -e '#!/bin/bash\\nexport DISPLAY=${VNC_DISPLAY}\\nunset SESSION_MANAGER\\n[ -f $$HOME/.Xresources ] && xrdb $$HOME/.Xresources\\nif command -v xfce4-session >/dev/null 2>&1; then\\n xfce4-session &\\nelif command -v openbox-session >/dev/null 2>&1; then\\n openbox-session &\\nelse\\n xterm &\\nfi' > ~/.vnc/xstartup && chmod +x ~/.vnc/xstartup` ); // Kill all existing VNC/Xvfb for a clean start @@ -103,18 +103,18 @@ export const getVNCCredentials = async (req: Request, res: Response) => { if (isXvncDirect) { await exec( `${vncBin} ${VNC_DISPLAY} -geometry 1280x800 -depth 24 -rfbport ${VNC_RFBPORT} ` + - `-SecurityTypes VncAuth -PasswordFile ~/.vnc/passwd ` + + `-rfbauth ~/.vnc/passwd ` + `-pn > /dev/null 2>&1 &` ); await new Promise((r) => setTimeout(r, 1500)); - await exec(`export DISPLAY=${VNC_DISPLAY} && ~/.vnc/xstartup &`); + await exec(`export DISPLAY=${VNC_DISPLAY} && nohup ~/.vnc/xstartup > /dev/null 2>&1 &`); } else if (isX11vnc) { await exec( "command -v Xvfb >/dev/null 2>&1 || (export DEBIAN_FRONTEND=noninteractive && sudo apt-get install -y -qq xvfb 2>&1 || true)" ); await exec(`Xvfb ${VNC_DISPLAY} -screen 0 1280x800x24 > /dev/null 2>&1 &`); await new Promise((r) => setTimeout(r, 2000)); - await exec(`export DISPLAY=${VNC_DISPLAY} && ~/.vnc/xstartup &`); + await exec(`export DISPLAY=${VNC_DISPLAY} && nohup ~/.vnc/xstartup > /dev/null 2>&1 &`); await exec( `x11vnc -display ${VNC_DISPLAY} -rfbport ${VNC_RFBPORT} -passwd '${escapedPassword}' -forever -shared -noxdamage > /dev/null 2>&1 &` ); diff --git a/run.sh b/run.sh index 48fa910..3e3cf3d 100755 --- a/run.sh +++ b/run.sh @@ -1069,7 +1069,7 @@ launch() { if [[ "${DEPLOY_MODE:-}" == "kali" ]]; then echo echo -e " ${GREEN}Kali SSH${NC} ssh root@localhost -p 4242" - echo -e " ${GREEN}Kali noVNC${NC} http://localhost:4200" + echo -e " ${GREEN}Kali noVNC${NC} http://localhost:9020" fi echo @@ -1332,7 +1332,7 @@ cmd_status() { echo -e " ${GREEN}Frontend${NC} http://localhost:3000" echo -e " ${GREEN}Backend${NC} http://localhost:8080" echo -e " ${GREEN}Kali SSH${NC} ssh root@localhost -p 4242" - echo -e " ${GREEN}Kali noVNC${NC} http://localhost:4200" + echo -e " ${GREEN}Kali noVNC${NC} http://localhost:9020" echo else section "Quick Access" From 8a64a3a177191f42466371d5b378f01cd211244c Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Mon, 23 Mar 2026 20:13:23 +0530 Subject: [PATCH 11/12] Added new CTF Solver module --- backend/src/capabilities/registry.ts | 200 +++++++-- backend/src/controllers/ctf.controller.ts | 215 +++++++++ backend/src/models/Sessions/Sessions.model.ts | 44 ++ backend/src/routes/ctf.routes.ts | 19 + backend/src/server.ts | 2 + backend/src/services/agent.service.ts | 62 ++- backend/src/services/agent.tools.ts | 2 + backend/src/services/ctf.service.ts | 422 ++++++++++++++++++ backend/src/services/slash-commands.ts | 211 +++++++++ backend/src/services/subagent.manager.ts | 21 +- backend/src/tools/handlers/spawn-shell.ts | 21 +- backend/src/tools/handlers/view-image.ts | 157 +++++++ backend/src/tools/registry.ts | 2 + backend/src/tools/types.ts | 1 + backend/src/utils/copilot/prompts.ts | 53 ++- .../src/app/session/[session_id]/ctf/page.js | 42 ++ frontend/src/components/common/Sidebar.jsx | 22 + .../components/pages/session/ctf/CTFPage.jsx | 403 +++++++++++++++++ .../pages/session/sessionId/ChatInput.jsx | 115 ++++- .../pages/session/sessionId/ChatView.jsx | 5 +- .../pages/session/sessionId/ToolCallBlock.jsx | 2 + .../components/session/AgentToolsPanel.jsx | 6 + frontend/src/services/ctf.service.js | 72 +++ .../src/styles/components/CTF.module.scss | 405 +++++++++++++++++ 24 files changed, 2442 insertions(+), 62 deletions(-) create mode 100644 backend/src/controllers/ctf.controller.ts create mode 100644 backend/src/routes/ctf.routes.ts create mode 100644 backend/src/services/ctf.service.ts create mode 100644 backend/src/tools/handlers/view-image.ts create mode 100644 frontend/src/app/session/[session_id]/ctf/page.js create mode 100644 frontend/src/components/pages/session/ctf/CTFPage.jsx create mode 100644 frontend/src/services/ctf.service.js create mode 100644 frontend/src/styles/components/CTF.module.scss diff --git a/backend/src/capabilities/registry.ts b/backend/src/capabilities/registry.ts index 1336726..88d3d0f 100644 --- a/backend/src/capabilities/registry.ts +++ b/backend/src/capabilities/registry.ts @@ -26,7 +26,7 @@ const coreBucket: CapabilityBucket = { promptContext: `You have a full Linux shell (run_bash) with: python3, gcc/g++, make, git, curl, wget, netcat (nc), socat, ssh, file, strings, xxd, base64, openssl, jq, tmux, and standard unix utilities. Python packages available (run_python_script): requests, pyyaml, beautifulsoup4, -Pillow, python-magic, chepy. You can install additional packages with pip or apt if needed. +Pillow, python-magic, chepy. You can install additional packages with python3 -m pip or apt if needed. Write and run Python scripts for any complex logic. Use chepy for encoding/decoding chains.`, capabilities: [ { @@ -175,7 +175,7 @@ Write and run Python scripts for any complex logic. Use chepy for encoding/decod bucket: "core", label: "requests", description: "HTTP library. Default for any HTTP interaction.", - installCommand: "pip install requests", + installCommand: "python3 -m pip install requests", checkCommand: "python3 -c 'import requests'", size: "2 MB", }, @@ -185,7 +185,7 @@ Write and run Python scripts for any complex logic. Use chepy for encoding/decod bucket: "core", label: "PyYAML", description: "YAML parsing for challenge configs.", - installCommand: "pip install pyyaml", + installCommand: "python3 -m pip install pyyaml", checkCommand: "python3 -c 'import yaml'", size: "1 MB", }, @@ -195,7 +195,7 @@ Write and run Python scripts for any complex logic. Use chepy for encoding/decod bucket: "core", label: "BeautifulSoup4", description: "HTML/XML parsing.", - installCommand: "pip install beautifulsoup4", + installCommand: "python3 -m pip install beautifulsoup4", checkCommand: "python3 -c 'import bs4'", size: "1 MB", }, @@ -205,7 +205,7 @@ Write and run Python scripts for any complex logic. Use chepy for encoding/decod bucket: "core", label: "Pillow", description: "Image manipulation. Used across stego, forensics, and misc.", - installCommand: "pip install Pillow", + installCommand: "python3 -m pip install Pillow", checkCommand: "python3 -c 'import PIL'", size: "15 MB", }, @@ -215,7 +215,7 @@ Write and run Python scripts for any complex logic. Use chepy for encoding/decod bucket: "core", label: "python-magic", description: "Programmatic file command — detect MIME types in scripts.", - installCommand: "pip install python-magic", + installCommand: "python3 -m pip install python-magic", checkCommand: "python3 -c 'import magic'", size: "1 MB", }, @@ -226,10 +226,20 @@ Write and run Python scripts for any complex logic. Use chepy for encoding/decod label: "Chepy", description: "CyberChef in Python. Encoding, decoding, hashing, compression, crypto — 300+ operations chained fluently.", - installCommand: "pip install chepy", + installCommand: "python3 -m pip install chepy", checkCommand: "python3 -c 'import chepy'", size: "10 MB", }, + { + name: "pexpect", + type: "python_package", + bucket: "core", + label: "pexpect", + description: "Expect-like automation for interactive CLI programs from Python.", + installCommand: "python3 -m pip install pexpect", + checkCommand: "python3 -c 'import pexpect'", + size: "1 MB", + }, ], }; @@ -246,6 +256,8 @@ const revBucket: CapabilityBucket = { - objdump/readelf/nm: quick static analysis of ELF structure and symbols - upx: upx -d packed_binary to unpack UPX-compressed binaries - uncompyle6/pycdc: decompile Python .pyc files back to source +- frida: frida -U -f com.app.target -l hook.js — dynamic instrumentation (Android/iOS/Linux) +- objection: objection -g com.app.target explore — mobile runtime exploration (Frida-powered) Python: capstone (disasm), keystone (asm), pyelftools, lief (binary patching), r2pipe (script r2), unicorn (CPU emulation)`, capabilities: [ @@ -282,7 +294,7 @@ Python: capstone (disasm), keystone (asm), pyelftools, lief (binary patching), "Debugger with exploit-dev plugin. Heap visualization, telescope, ROP search, vmmap.", usageHint: "gdb ./binary then checksec, info functions, disass main, telescope", installCommand: - "apt install -y gdb && pip install pwndbg", + "apt install -y gdb && python3 -m pip install pwndbg", checkCommand: "which gdb", size: "150 MB", }, @@ -323,7 +335,7 @@ Python: capstone (disasm), keystone (asm), pyelftools, lief (binary patching), bucket: "rev", label: "uncompyle6", description: "Decompile Python 2/3 bytecode (.pyc) back to source.", - installCommand: "pip install uncompyle6", + installCommand: "python3 -m pip install uncompyle6", checkCommand: "python3 -c 'import uncompyle6'", size: "5 MB", }, @@ -353,7 +365,7 @@ Python: capstone (disasm), keystone (asm), pyelftools, lief (binary patching), bucket: "rev", label: "Capstone", description: "Multi-arch disassembly framework. Disassemble x86, ARM, MIPS from Python.", - installCommand: "pip install capstone", + installCommand: "python3 -m pip install capstone", checkCommand: "python3 -c 'import capstone'", size: "5 MB", }, @@ -363,7 +375,7 @@ Python: capstone (disasm), keystone (asm), pyelftools, lief (binary patching), bucket: "rev", label: "Keystone", description: "Multi-arch assembler. Assemble instructions from Python.", - installCommand: "pip install keystone-engine", + installCommand: "python3 -m pip install keystone-engine", checkCommand: "python3 -c 'import keystone'", size: "5 MB", }, @@ -373,7 +385,7 @@ Python: capstone (disasm), keystone (asm), pyelftools, lief (binary patching), bucket: "rev", label: "pyelftools", description: "Pure-Python ELF parsing. Read sections, symbols, relocations, DWARF debug info.", - installCommand: "pip install pyelftools", + installCommand: "python3 -m pip install pyelftools", checkCommand: "python3 -c 'import elftools'", size: "2 MB", }, @@ -383,7 +395,7 @@ Python: capstone (disasm), keystone (asm), pyelftools, lief (binary patching), bucket: "rev", label: "LIEF", description: "Parse and modify ELF, PE, Mach-O binaries. Patch imports, sections, entrypoints.", - installCommand: "pip install lief", + installCommand: "python3 -m pip install lief", checkCommand: "python3 -c 'import lief'", size: "15 MB", }, @@ -393,7 +405,7 @@ Python: capstone (disasm), keystone (asm), pyelftools, lief (binary patching), bucket: "rev", label: "r2pipe", description: "Radare2 Python bindings. Script r2 analysis from Python.", - installCommand: "pip install r2pipe", + installCommand: "python3 -m pip install r2pipe", checkCommand: "python3 -c 'import r2pipe'", size: "1 MB", }, @@ -404,10 +416,32 @@ Python: capstone (disasm), keystone (asm), pyelftools, lief (binary patching), label: "Unicorn", description: "CPU emulator. Emulate x86, ARM, MIPS code snippets without running the full binary.", - installCommand: "pip install unicorn", + installCommand: "python3 -m pip install unicorn", checkCommand: "python3 -c 'import unicorn'", size: "10 MB", }, + { + name: "frida-tools", + type: "python_package", + bucket: "rev", + label: "Frida", + description: "Dynamic instrumentation toolkit. Hook functions, trace calls, bypass checks at runtime on Android/iOS/Linux/Windows.", + usageHint: "frida -U -f com.app.target -l hook.js OR frida-ps -U", + installCommand: "python3 -m pip install frida-tools", + checkCommand: "which frida", + size: "40 MB", + }, + { + name: "objection", + type: "python_package", + bucket: "rev", + label: "Objection", + description: "Runtime mobile exploration powered by Frida. SSL pinning bypass, root detection bypass, memory dumping.", + usageHint: "objection -g com.app.target explore", + installCommand: "python3 -m pip install objection", + checkCommand: "which objection", + size: "15 MB", + }, ], }; @@ -437,7 +471,7 @@ const pwnBucket: CapabilityBucket = { description: "Find ROP gadgets in any binary. --ropchain auto-generates chains.", usageHint: "ROPgadget --binary ./vuln [--ropchain]", - installCommand: "pip install ROPgadget", + installCommand: "python3 -m pip install ROPgadget", checkCommand: "which ROPgadget", size: "3 MB", }, @@ -448,7 +482,7 @@ const pwnBucket: CapabilityBucket = { label: "Ropper", description: "Alternative gadget finder. Better filtering, JOP/SOP support.", usageHint: "ropper -f ./vuln --search \"pop rdi\"", - installCommand: "pip install ropper", + installCommand: "python3 -m pip install ropper", checkCommand: "which ropper", size: "5 MB", }, @@ -514,7 +548,7 @@ const pwnBucket: CapabilityBucket = { description: "The exploit dev framework. Remote/local process interaction, ELF parsing, ROP builder, shellcraft, cyclic patterns, format string helpers, asm/disasm.", usageHint: "from pwn import *; checksec --file=./binary (bundled)", - installCommand: "pip install pwntools", + installCommand: "python3 -m pip install pwntools", checkCommand: "python3 -c 'import pwn'", size: "50 MB", }, @@ -526,7 +560,7 @@ const pwnBucket: CapabilityBucket = { description: "Symbolic execution engine. Auto-solve crackmes, find inputs that reach specific code paths, bypass checks.", usageHint: "import angr; p = angr.Project('./binary')", - installCommand: "pip install angr", + installCommand: "python3 -m pip install angr", checkCommand: "python3 -c 'import angr'", size: "400 MB", }, @@ -590,7 +624,7 @@ Python: pycryptodome (AES/RSA/DES/hashing), gmpy2 (fast bignum math), label: "hashid", description: "Identify unknown hash types. Feed it a hash, get possible algorithms.", usageHint: "hashid ''", - installCommand: "pip install hashid", + installCommand: "python3 -m pip install hashid", checkCommand: "which hashid", size: "1 MB", }, @@ -601,7 +635,7 @@ Python: pycryptodome (AES/RSA/DES/hashing), gmpy2 (fast bignum math), label: "xortool", description: "Analyze XOR-encrypted data. Guess key length and probable key.", usageHint: "xortool encrypted_file", - installCommand: "pip install xortool", + installCommand: "python3 -m pip install xortool", checkCommand: "which xortool", size: "1 MB", }, @@ -612,7 +646,7 @@ Python: pycryptodome (AES/RSA/DES/hashing), gmpy2 (fast bignum math), label: "PyCryptodome", description: "Full crypto toolkit. AES, RSA, DES, ChaCha20, hashing, PKCS padding, stream ciphers, MACs.", - installCommand: "pip install pycryptodome", + installCommand: "python3 -m pip install pycryptodome", checkCommand: "python3 -c 'import Crypto'", size: "15 MB", }, @@ -623,7 +657,7 @@ Python: pycryptodome (AES/RSA/DES/hashing), gmpy2 (fast bignum math), label: "gmpy2", description: "Fast arbitrary-precision arithmetic with GMP. Modular exponentiation, inversion, GCD. 10-100x faster than native Python for big numbers.", - installCommand: "pip install gmpy2", + installCommand: "python3 -m pip install gmpy2", checkCommand: "python3 -c 'import gmpy2'", size: "10 MB", }, @@ -634,7 +668,7 @@ Python: pycryptodome (AES/RSA/DES/hashing), gmpy2 (fast bignum math), label: "SymPy", description: "Symbolic math. Solve equations, simplify expressions, number theory functions (factorint, isprime, discrete_log).", - installCommand: "pip install sympy", + installCommand: "python3 -m pip install sympy", checkCommand: "python3 -c 'import sympy'", size: "30 MB", }, @@ -645,7 +679,7 @@ Python: pycryptodome (AES/RSA/DES/hashing), gmpy2 (fast bignum math), label: "Z3 Solver", description: "SMT constraint solver from Microsoft. Solve systems of equations, bit-vector constraints, boolean satisfiability.", - installCommand: "pip install z3-solver", + installCommand: "python3 -m pip install z3-solver", checkCommand: "python3 -c 'import z3'", size: "30 MB", }, @@ -655,7 +689,7 @@ Python: pycryptodome (AES/RSA/DES/hashing), gmpy2 (fast bignum math), bucket: "crypto", label: "primefac", description: "Integer factorization (trial division, Pollard rho, ECM).", - installCommand: "pip install primefac", + installCommand: "python3 -m pip install primefac", checkCommand: "python3 -c 'import primefac'", size: "1 MB", }, @@ -665,7 +699,7 @@ Python: pycryptodome (AES/RSA/DES/hashing), gmpy2 (fast bignum math), bucket: "crypto", label: "factordb-python", description: "Query factordb.com for known factorizations of large numbers.", - installCommand: "pip install factordb-python", + installCommand: "python3 -m pip install factordb-python", checkCommand: "python3 -c 'import factordb'", size: "1 MB", }, @@ -687,6 +721,12 @@ const forensicsBucket: CapabilityBucket = { - pdf-parser: pdf-parser.py -s /JavaScript document.pdf - oletools: olevba document.docm — extract VBA macros - ffmpeg: ffmpeg -i audio.wav -lavfi showspectrumpic=s=1024x512 spectrogram.png +- tesseract: tesseract image.png stdout — OCR text extraction from images +- mediainfo: mediainfo video.mp4 — detailed media file metadata +- testdisk/photorec: photorec /d output/ disk.img — partition recovery and file carving +- djxl: djxl input.jxl output.png — decode JPEG XL images +- pdftotext: pdftotext document.pdf - — extract text from PDFs +- 7z: 7z x archive.7z — handle 7z, RAR, ISO archives Python: scapy (packet crafting), dpkt/pyshark (pcap parsing), oletools, PyPDF2 (PDF manipulation)`, capabilities: [ @@ -732,7 +772,7 @@ Python: scapy (packet crafting), dpkt/pyshark (pcap parsing), description: "Memory forensics framework. Analyze RAM dumps — list processes, extract files, find credentials.", usageHint: "vol -f memory.dmp windows.pslist, vol -f memory.dmp windows.filescan", - installCommand: "pip install volatility3", + installCommand: "python3 -m pip install volatility3", checkCommand: "which vol || python3 -c 'import volatility3'", size: "30 MB", }, @@ -764,7 +804,7 @@ Python: scapy (packet crafting), dpkt/pyshark (pcap parsing), bucket: "forensics", label: "oletools", description: "Analyze Microsoft Office documents. Extract VBA macros, detect malicious content.", - installCommand: "pip install oletools", + installCommand: "python3 -m pip install oletools", checkCommand: "python3 -c 'import oletools'", size: "5 MB", }, @@ -795,7 +835,7 @@ Python: scapy (packet crafting), dpkt/pyshark (pcap parsing), bucket: "forensics", label: "Scapy", description: "Packet crafting and analysis in Python. Parse pcaps, forge packets, decode protocols.", - installCommand: "pip install scapy", + installCommand: "python3 -m pip install scapy", checkCommand: "python3 -c 'import scapy'", size: "15 MB", }, @@ -805,7 +845,7 @@ Python: scapy (packet crafting), dpkt/pyshark (pcap parsing), bucket: "forensics", label: "dpkt", description: "Lightweight pcap/packet parsing. Faster than scapy for simple pcap analysis.", - installCommand: "pip install dpkt", + installCommand: "python3 -m pip install dpkt", checkCommand: "python3 -c 'import dpkt'", size: "2 MB", }, @@ -815,7 +855,7 @@ Python: scapy (packet crafting), dpkt/pyshark (pcap parsing), bucket: "forensics", label: "PyShark", description: "Python wrapper for tshark. Use Wireshark's dissectors from Python.", - installCommand: "pip install pyshark", + installCommand: "python3 -m pip install pyshark", checkCommand: "python3 -c 'import pyshark'", size: "5 MB", }, @@ -825,10 +865,76 @@ Python: scapy (packet crafting), dpkt/pyshark (pcap parsing), bucket: "forensics", label: "PyPDF2", description: "PDF parsing and manipulation from Python.", - installCommand: "pip install PyPDF2", + installCommand: "python3 -m pip install PyPDF2", checkCommand: "python3 -c 'import PyPDF2'", size: "3 MB", }, + { + name: "tesseract", + type: "binary", + bucket: "forensics", + label: "Tesseract OCR", + description: "Extract text from images. Supports 100+ languages.", + usageHint: "tesseract image.png stdout OR tesseract image.png output -l eng", + installCommand: "apt install -y tesseract-ocr", + checkCommand: "which tesseract", + size: "15 MB", + }, + { + name: "libjxl", + type: "binary", + bucket: "forensics", + label: "JPEG XL Tools", + description: "Decode/encode JPEG XL images. djxl converts .jxl to PNG/JPEG for analysis.", + usageHint: "djxl input.jxl output.png", + installCommand: "apt install -y libjxl-tools", + checkCommand: "which djxl", + size: "5 MB", + }, + { + name: "mediainfo", + type: "binary", + bucket: "forensics", + label: "MediaInfo", + description: "Display detailed metadata for audio/video files. Codecs, bitrate, resolution, embedded text tracks.", + usageHint: "mediainfo video.mp4", + installCommand: "apt install -y mediainfo", + checkCommand: "which mediainfo", + size: "5 MB", + }, + { + name: "testdisk", + type: "binary", + bucket: "forensics", + label: "TestDisk / PhotoRec", + description: "Recover lost partitions (testdisk) and carve files from disk images (photorec). Handles FAT, NTFS, ext, HFS+.", + usageHint: "photorec /d output/ disk.img", + installCommand: "apt install -y testdisk", + checkCommand: "which testdisk", + size: "5 MB", + }, + { + name: "p7zip", + type: "binary", + bucket: "forensics", + label: "7-Zip", + description: "Handle 7z, RAR, ISO, and other archive formats. Broader format support than unzip/tar.", + usageHint: "7z x archive.7z OR 7z l archive.7z", + installCommand: "apt install -y p7zip-full", + checkCommand: "which 7z", + size: "5 MB", + }, + { + name: "poppler-utils", + type: "binary", + bucket: "forensics", + label: "Poppler Utils", + description: "PDF text extraction and rendering. pdftotext, pdfimages, pdfinfo for analyzing PDF documents.", + usageHint: "pdftotext document.pdf - OR pdfimages -all document.pdf images/", + installCommand: "apt install -y poppler-utils", + checkCommand: "which pdftotext", + size: "10 MB", + }, ], }; @@ -878,7 +984,7 @@ Technique: always check — file, strings, exiftool, binwalk, xxd first. Then sp description: "Automated stego analysis. Runs multiple checks: LSB, color planes, trailing data, EXIF, binwalk.", usageHint: "stegoveritas image.png", - installCommand: "pip install stegoveritas", + installCommand: "python3 -m pip install stegoveritas", checkCommand: "which stegoveritas", size: "20 MB", }, @@ -899,7 +1005,7 @@ Technique: always check — file, strings, exiftool, binwalk, xxd first. Then sp bucket: "stego", label: "NumPy", description: "Array manipulation for bulk pixel/audio data processing.", - installCommand: "pip install numpy", + installCommand: "python3 -m pip install numpy", checkCommand: "python3 -c 'import numpy'", size: "30 MB", }, @@ -909,7 +1015,7 @@ Technique: always check — file, strings, exiftool, binwalk, xxd first. Then sp bucket: "stego", label: "SciPy", description: "Signal processing. FFT, audio analysis, image filtering for stego.", - installCommand: "pip install scipy", + installCommand: "python3 -m pip install scipy", checkCommand: "python3 -c 'import scipy'", size: "40 MB", }, @@ -1046,7 +1152,7 @@ Python: paramiko (SSH), dnspython (DNS), impacket (SMB/Kerberos/LDAP)`, bucket: "network", label: "dirsearch", description: "Web path scanner with recursive brute-force capabilities.", - installCommand: "pip install dirsearch", + installCommand: "python3 -m pip install dirsearch", checkCommand: "which dirsearch", size: "5 MB", }, @@ -1056,7 +1162,7 @@ Python: paramiko (SSH), dnspython (DNS), impacket (SMB/Kerberos/LDAP)`, bucket: "network", label: "wfuzz", description: "Web application brute-forcer with flexible payloads and filters.", - installCommand: "pip install wfuzz", + installCommand: "python3 -m pip install wfuzz", checkCommand: "which wfuzz", size: "5 MB", }, @@ -1176,7 +1282,7 @@ Python: paramiko (SSH), dnspython (DNS), impacket (SMB/Kerberos/LDAP)`, bucket: "network", label: "Paramiko", description: "SSH client library. Programmatic remote command execution.", - installCommand: "pip install paramiko", + installCommand: "python3 -m pip install paramiko", checkCommand: "python3 -c 'import paramiko'", size: "5 MB", }, @@ -1186,7 +1292,7 @@ Python: paramiko (SSH), dnspython (DNS), impacket (SMB/Kerberos/LDAP)`, bucket: "network", label: "dnspython", description: "DNS toolkit. Queries, zone transfers, record manipulation from Python.", - installCommand: "pip install dnspython", + installCommand: "python3 -m pip install dnspython", checkCommand: "python3 -c 'import dns'", size: "2 MB", }, @@ -1196,7 +1302,7 @@ Python: paramiko (SSH), dnspython (DNS), impacket (SMB/Kerberos/LDAP)`, bucket: "network", label: "Impacket", description: "Network protocol library. SMB, LDAP, Kerberos, MSSQL. Essential for AD/Windows.", - installCommand: "pip install impacket", + installCommand: "python3 -m pip install impacket", checkCommand: "python3 -c 'import impacket'", size: "20 MB", }, @@ -1257,7 +1363,7 @@ Python: requests-html (JS rendering), PyJWT (JWT decode/encode)`, label: "XSSer", description: "Automated XSS detection and exploitation framework.", usageHint: "xsser -u \"http://target/page?q=XSS\"", - installCommand: "pip install xsser", + installCommand: "python3 -m pip install xsser", checkCommand: "which xsser", size: "5 MB", }, @@ -1268,7 +1374,7 @@ Python: requests-html (JS rendering), PyJWT (JWT decode/encode)`, label: "Wapiti", description: "Web vulnerability scanner. Detects XSS, SQLi, LFI, RCE, SSRF, XXE.", usageHint: "wapiti -u http://target -f html -o report.html", - installCommand: "pip install wapiti3", + installCommand: "python3 -m pip install wapiti3", checkCommand: "which wapiti", size: "15 MB", }, @@ -1279,7 +1385,7 @@ Python: requests-html (JS rendering), PyJWT (JWT decode/encode)`, label: "Arjun", description: "HTTP parameter discovery. Find hidden GET/POST parameters.", usageHint: "arjun -u http://target/page", - installCommand: "pip install arjun", + installCommand: "python3 -m pip install arjun", checkCommand: "which arjun", size: "5 MB", }, @@ -1290,7 +1396,7 @@ Python: requests-html (JS rendering), PyJWT (JWT decode/encode)`, label: "jwt_tool", description: "JWT attack toolkit. Algorithm confusion, none attack, brute-force secret.", usageHint: "python3 jwt_tool.py -T", - installCommand: "git clone https://github.com/ticarpi/jwt_tool /opt/jwt_tool && pip install -r /opt/jwt_tool/requirements.txt && ln -sf /opt/jwt_tool/jwt_tool.py /usr/local/bin/jwt_tool", + installCommand: "git clone https://github.com/ticarpi/jwt_tool /opt/jwt_tool && python3 -m pip install -r /opt/jwt_tool/requirements.txt && ln -sf /opt/jwt_tool/jwt_tool.py /usr/local/bin/jwt_tool", checkCommand: "which jwt_tool || test -f /opt/jwt_tool/jwt_tool.py", size: "5 MB", }, @@ -1300,7 +1406,7 @@ Python: requests-html (JS rendering), PyJWT (JWT decode/encode)`, bucket: "web", label: "requests-html", description: "HTTP requests with JavaScript rendering support.", - installCommand: "pip install requests-html", + installCommand: "python3 -m pip install requests-html", checkCommand: "python3 -c 'import requests_html'", size: "5 MB", }, @@ -1310,7 +1416,7 @@ Python: requests-html (JS rendering), PyJWT (JWT decode/encode)`, bucket: "web", label: "PyJWT", description: "Decode, encode and verify JWT tokens from Python scripts.", - installCommand: "pip install PyJWT", + installCommand: "python3 -m pip install PyJWT", checkCommand: "python3 -c 'import jwt'", size: "1 MB", }, diff --git a/backend/src/controllers/ctf.controller.ts b/backend/src/controllers/ctf.controller.ts new file mode 100644 index 0000000..27d20d8 --- /dev/null +++ b/backend/src/controllers/ctf.controller.ts @@ -0,0 +1,215 @@ +import { Response, Request } from "express"; +import SessionsModel from "../models/Sessions/Sessions.model"; +import { + loginWithCredentials, + verifyToken, + fetchChallenges, + syncToWorkspace, + sanitizeDirName, + SyncProgressEvent, +} from "../services/ctf.service"; +import { execSSHCommand } from "../services/ssh.service"; +import { WORKSPACE_DIR } from "../utils/commandSafety"; + +export const connectCtf = async (req: Request, res: Response) => { + try { + const userId = res.locals.userId; + const { sessionId } = req.params; + const { url, username, password, apiToken } = req.body; + + if (!url) return res.status(400).json({ message: "CTFd URL is required" }); + + const session = await SessionsModel.findOne({ sessionId, uid: userId }); + if (!session) return res.status(404).json({ message: "Session not found" }); + + const cleanUrl = url.replace(/\/+$/, ""); + let ctfName: string; + let authMethod: "token" | "credentials"; + let sessionCookie: string | undefined; + let storedToken: string | undefined; + + if (apiToken) { + ctfName = await verifyToken(cleanUrl, apiToken); + authMethod = "token"; + storedToken = apiToken; + } else if (username && password) { + const result = await loginWithCredentials(cleanUrl, username, password); + sessionCookie = result.sessionCookie; + ctfName = result.ctfName; + authMethod = "credentials"; + } else { + return res.status(400).json({ message: "Provide either apiToken or username+password" }); + } + + await SessionsModel.updateOne( + { sessionId, uid: userId }, + { + $set: { + ctfConfig: { + url: cleanUrl, + ctfName, + authMethod, + apiToken: storedToken, + username, + sessionCookie, + }, + }, + }, + ); + + return res.status(200).json({ + message: "Connected to CTF", + ctfName, + url: cleanUrl, + }); + } catch (err: any) { + console.error("[CTF] connect error:", err.message); + return res.status(400).json({ message: err.message || "Failed to connect to CTF" }); + } +}; + +export const getCtfConfig = async (req: Request, res: Response) => { + try { + const userId = res.locals.userId; + const { sessionId } = req.params; + + const session = await SessionsModel.findOne({ sessionId, uid: userId }); + if (!session) return res.status(404).json({ message: "Session not found" }); + + if (!session.ctfConfig) { + return res.status(200).json({ connected: false }); + } + + return res.status(200).json({ + connected: true, + url: session.ctfConfig.url, + ctfName: session.ctfConfig.ctfName, + authMethod: session.ctfConfig.authMethod, + lastSynced: session.ctfConfig.lastSynced || null, + }); + } catch (err: any) { + console.error("[CTF] getConfig error:", err.message); + return res.status(400).json({ message: "Failed to get CTF config" }); + } +}; + +function sendSSE(res: Response, event: SyncProgressEvent) { + res.write(`data: ${JSON.stringify(event)}\n\n`); +} + +export const syncCtf = async (req: Request, res: Response) => { + const userId = res.locals.userId; + const { sessionId } = req.params; + + try { + const session = await SessionsModel.findOne({ sessionId, uid: userId }); + if (!session) { + return res.status(404).json({ message: "Session not found" }); + } + if (!session.ctfConfig) { + return res.status(400).json({ message: "No CTF connected" }); + } + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + + const { url, sessionCookie, apiToken, ctfName } = session.ctfConfig; + + const onProgress = (event: SyncProgressEvent) => sendSSE(res, event); + + const challenges = await fetchChallenges(url, sessionCookie, apiToken, onProgress); + + const result = await syncToWorkspace( + ctfName, + challenges, + url, + sessionCookie, + apiToken, + onProgress, + ); + + await SessionsModel.updateOne( + { sessionId, uid: userId }, + { $set: { "ctfConfig.lastSynced": new Date() } }, + ); + + sendSSE(res, { + phase: "done", + total: challenges.length, + synced: result.synced, + updated: result.updated, + skipped: result.skipped, + }); + + res.end(); + } catch (err: any) { + console.error("[CTF] sync error:", err.message); + if (res.headersSent) { + sendSSE(res, { phase: "error", detail: err.message || "Sync failed" }); + res.end(); + } else { + res.status(400).json({ message: err.message || "Failed to sync challenges" }); + } + } +}; + +export const disconnectCtf = async (req: Request, res: Response) => { + try { + const userId = res.locals.userId; + const { sessionId } = req.params; + + const session = await SessionsModel.findOne({ sessionId, uid: userId }); + if (!session) return res.status(404).json({ message: "Session not found" }); + + await SessionsModel.updateOne( + { sessionId, uid: userId }, + { $unset: { ctfConfig: 1 } }, + ); + + return res.status(200).json({ message: "Disconnected from CTF" }); + } catch (err: any) { + console.error("[CTF] disconnect error:", err.message); + return res.status(400).json({ message: "Failed to disconnect from CTF" }); + } +}; + +export const getCtfChallenges = async (req: Request, res: Response) => { + try { + const userId = res.locals.userId; + const { sessionId } = req.params; + + const session = await SessionsModel.findOne({ sessionId, uid: userId }) + .select("ctfConfig.ctfName ctfConfig.activeSolve") + .lean(); + + if (!session?.ctfConfig?.ctfName) { + return res.status(200).json({ challenges: [], activeSolve: null }); + } + + const safeCTFName = sanitizeDirName(session.ctfConfig.ctfName); + let challenges: Array<{ name: string; category: string; value: number; safeDir: string }> = []; + + try { + const home = (await execSSHCommand("echo $HOME")).trim(); + const resolvedWs = WORKSPACE_DIR.replace(/^~/, home); + const indexPath = `${resolvedWs}/${safeCTFName}/challenges.json`; + const raw = await execSSHCommand(`cat "${indexPath}" 2>/dev/null || echo "[]"`); + challenges = JSON.parse(raw.trim()); + } catch (err: any) { + console.warn("[CTF] Failed to read challenges.json from attack box:", err.message); + } + + const activeSolve = session.ctfConfig.activeSolve + ? { name: session.ctfConfig.activeSolve.name, safeDir: session.ctfConfig.activeSolve.safeDir } + : null; + + return res.status(200).json({ challenges, activeSolve }); + } catch (err: any) { + console.error("[CTF] getCtfChallenges error:", err.message); + return res.status(400).json({ message: "Failed to get challenges" }); + } +}; diff --git a/backend/src/models/Sessions/Sessions.model.ts b/backend/src/models/Sessions/Sessions.model.ts index 41be1a1..9be2c75 100644 --- a/backend/src/models/Sessions/Sessions.model.ts +++ b/backend/src/models/Sessions/Sessions.model.ts @@ -79,6 +79,26 @@ export interface ConnectionStateDoc { lastError?: string; } +export interface CtfActiveSolveDoc { + name: string; + safeDir: string; + challengeTxt: string; + files: string[]; + userNotes?: string; + setAt: Date; +} + +export interface CtfConfigDoc { + url: string; + ctfName: string; + authMethod: "token" | "credentials"; + apiToken?: string; + username?: string; + sessionCookie?: string; + lastSynced?: Date; + activeSolve?: CtfActiveSolveDoc; +} + export interface SessionDoc extends mongoose.Document { uid: mongoose.Types.ObjectId; sessionId: string; @@ -103,6 +123,7 @@ export interface SessionDoc extends mongoose.Document { subagents: SubagentDoc[]; connectionState: ConnectionStateDoc; disabledAgentTools?: string[]; + ctfConfig?: CtfConfigDoc; } const ToolCallSchema = new Schema( @@ -241,6 +262,29 @@ const SessionSchema = new Schema({ type: [{ type: String }], default: [], }, + ctfConfig: { + type: { + url: { type: String, required: true }, + ctfName: { type: String, required: true }, + authMethod: { type: String, required: true, enum: ["token", "credentials"] }, + apiToken: { type: String }, + username: { type: String }, + sessionCookie: { type: String }, + lastSynced: { type: Date }, + activeSolve: { + type: { + name: { type: String, required: true }, + safeDir: { type: String, required: true }, + challengeTxt: { type: String, required: true }, + files: { type: [String], default: [] }, + userNotes: { type: String }, + setAt: { type: Date, default: Date.now }, + }, + default: undefined, + }, + }, + default: undefined, + }, }); export { AgentMessageSchema }; diff --git a/backend/src/routes/ctf.routes.ts b/backend/src/routes/ctf.routes.ts new file mode 100644 index 0000000..7a69670 --- /dev/null +++ b/backend/src/routes/ctf.routes.ts @@ -0,0 +1,19 @@ +import express from "express"; +import { verifySess } from "../middlewares/VerifySession.middleware"; +import { + connectCtf, + getCtfConfig, + syncCtf, + disconnectCtf, + getCtfChallenges, +} from "../controllers/ctf.controller"; + +const router = express.Router(); + +router.post("/:sessionId/connect", [verifySess], connectCtf); +router.get("/:sessionId/config", [verifySess], getCtfConfig); +router.get("/:sessionId/challenges", [verifySess], getCtfChallenges); +router.post("/:sessionId/sync", [verifySess], syncCtf); +router.post("/:sessionId/disconnect", [verifySess], disconnectCtf); + +export { router as ctfRoutes }; diff --git a/backend/src/server.ts b/backend/src/server.ts index aea465c..6ca0645 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -17,6 +17,7 @@ import { shellRoutes } from "./routes/shell.routes"; import { vpnRoutes } from "./routes/vpn.routes"; import { vncRoutes } from "./routes/vnc.routes"; import { burpRoutes } from "./routes/burp.routes"; +import { ctfRoutes } from "./routes/ctf.routes"; import getSecrets from "./utils/getSecrets"; import { initTracing } from "./utils/tracing"; import { setupShellWebSocket } from "./services/shell.socket"; @@ -194,6 +195,7 @@ const initializeApp = async () => { app.use("/api/copilot", vpnRoutes); app.use("/api/copilot", vncRoutes); app.use("/api/burp", burpRoutes); + app.use("/api/ctf", ctfRoutes); app.use(function (err: any, req: any, res: any, next: any) { console.log("Error occurred but handled - ", err); diff --git a/backend/src/services/agent.service.ts b/backend/src/services/agent.service.ts index cdf8ce5..8e53fea 100644 --- a/backend/src/services/agent.service.ts +++ b/backend/src/services/agent.service.ts @@ -16,7 +16,8 @@ import { ToolExecutionCallbacks, } from "./agent.tools"; import { shouldSummarize, summarizeMessages, messagesToOpenAI } from "./context.service"; -import { buildSystemPrompt, AgentPromptConfig } from "../utils/copilot/prompts"; +import { buildSystemPrompt, AgentPromptConfig, BoxEnvInfo } from "../utils/copilot/prompts"; +import { WORKSPACE_DIR } from "../utils/commandSafety"; import UserModel from "../models/User/User.model"; import { sessionLifecycle } from "./session.lifecycle"; import { SubagentManager } from "./subagent.manager"; @@ -190,8 +191,10 @@ function buildShellStatusMessage(shellManager: ShellManager, turnIndex: number): async function buildSystemMessage( sessionId: string, userId: string, + envInfo?: BoxEnvInfo, ): Promise { const user = await UserModel.findById(userId); + const session = await SessionsModel.findOne({ sessionId }).select("ctfConfig").lean(); const now = new Date(); const promptConfig: AgentPromptConfig = { sessionId, @@ -200,8 +203,30 @@ async function buildSystemMessage( currentDate: now.toISOString().split("T")[0], currentDay: now.toLocaleDateString("en-US", { weekday: "long" }), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + envInfo, }; + if (session?.ctfConfig?.ctfName) { + const safeName = session.ctfConfig.ctfName + .replace(/[\/\\:*?"<>|]/g, "_") + .replace(/\s+/g, "_"); + const wsBase = envInfo?.workspacePath ?? "~/pentest-workspace"; + promptConfig.ctfConfig = { + ctfName: session.ctfConfig.ctfName, + workspacePath: `${wsBase}/${safeName}`, + }; + + if (session.ctfConfig.activeSolve) { + promptConfig.ctfConfig.activeSolve = { + name: session.ctfConfig.activeSolve.name, + challengeTxt: session.ctfConfig.activeSolve.challengeTxt, + files: session.ctfConfig.activeSolve.files, + challengeDir: `${wsBase}/${safeName}/${session.ctfConfig.activeSolve.safeDir}`, + userNotes: session.ctfConfig.activeSolve.userNotes, + }; + } + } + return { id: `sys_${sessionId}`, role: "system", @@ -245,10 +270,41 @@ export async function runAgentLoop(params: { } } - const subagentManager = new SubagentManager(sessionId, shellManager); - const spawnedSubagentIds: string[] = []; + // Detect attack box environment and rebuild system message with real info + let envInfo: BoxEnvInfo | undefined; + if (shellManager.isConnected) { + try { + const { output: envOut } = await shellManager.execInShell( + `echo "$USER|||$HOME|||$(uname -s)|||$(uname -m)"`, + 10_000, + ); + const parts = envOut.trim().split("|||"); + if (parts.length >= 4) { + const home = parts[1]; + const resolvedWs = WORKSPACE_DIR.replace(/^~/, home); + envInfo = { + user: parts[0], + home, + os: `${parts[2]} (${parts[3]})`, + workspacePath: resolvedWs, + }; + } + } catch (err: any) { + console.warn(`[agent] Failed to detect box environment: ${err.message}`); + } + } let messages = [...session.messages]; + + // Replace system message with one that has resolved environment info + if (envInfo && messages.length > 0 && messages[0].role === "system") { + const updatedSysMsg = await buildSystemMessage(sessionId, userId, envInfo); + messages[0] = updatedSysMsg; + } + + const subagentManager = new SubagentManager(sessionId, shellManager); + subagentManager.envInfo = envInfo; + const spawnedSubagentIds: string[] = []; const turnIndex = session.turnIndex; let iteration = 0; const newMessages: AgentMessageDoc[] = []; diff --git a/backend/src/services/agent.tools.ts b/backend/src/services/agent.tools.ts index 425c313..0dbfefc 100644 --- a/backend/src/services/agent.tools.ts +++ b/backend/src/services/agent.tools.ts @@ -59,6 +59,8 @@ export function buildExecutionContext(params: { Promise.resolve(shellManager.readOutput(shellId, fromOffset)), closeShell: (shellId: string) => shellManager.closeShell(shellId), + resizeShell: (shellId: string, cols: number, rows: number) => + shellManager.resizeShell(shellId, cols, rows), listShells: () => shellManager.getShellList(), getShellInfo: (shellId: string) => diff --git a/backend/src/services/ctf.service.ts b/backend/src/services/ctf.service.ts new file mode 100644 index 0000000..f12b297 --- /dev/null +++ b/backend/src/services/ctf.service.ts @@ -0,0 +1,422 @@ +import axios, { AxiosInstance } from "axios"; +import * as cheerio from "cheerio"; +import { Client as SSHClient } from "ssh2"; +import { buildSSHConfig } from "../utils/sshConfig"; +import { WORKSPACE_DIR } from "../utils/commandSafety"; + +export interface CTFdChallenge { + id: number; + name: string; + category: string; + description: string; + value: number; + files: string[]; +} + +export interface SyncProgressEvent { + phase: "fetch" | "sync" | "done" | "error"; + current?: number; + total?: number; + name?: string; + action?: "new" | "updated" | "skipped"; + detail?: string; + synced?: number; + updated?: number; + skipped?: number; +} + +export type ProgressCallback = (event: SyncProgressEvent) => void; + +class SSHSession { + private client: SSHClient | null = null; + private ready = false; + + async connect(): Promise { + const config = buildSSHConfig(); + return new Promise((resolve, reject) => { + const client = new SSHClient(); + client + .on("ready", () => { + this.client = client; + this.ready = true; + resolve(); + }) + .on("error", (err) => { + this.ready = false; + reject(err); + }) + .on("close", () => { + this.ready = false; + }) + .connect({ ...config, keepaliveInterval: 10_000, readyTimeout: 30_000 }); + }); + } + + async exec(command: string): Promise { + for (let attempt = 1; attempt <= 3; attempt++) { + try { + if (!this.client || !this.ready) { + console.warn("[CTF] SSH session lost, reconnecting..."); + try { this.client?.end(); } catch {} + this.client = null; + await this.connect(); + } + return await this.doExec(command); + } catch (err: any) { + this.ready = false; + if (attempt === 3) throw err; + const delay = 1500 * attempt; + console.warn(`[CTF] SSH exec attempt ${attempt} failed, retrying in ${delay}ms...`); + await sleep(delay); + try { this.client?.end(); } catch {} + this.client = null; + } + } + throw new Error("SSH exec failed after retries"); + } + + private doExec(command: string): Promise { + return new Promise((resolve, reject) => { + this.client!.exec(command, (err, stream) => { + if (err) { + this.ready = false; + return reject(err); + } + let output = ""; + stream.on("data", (data: Buffer) => { output += data.toString(); }); + stream.stderr.on("data", (data: Buffer) => { output += data.toString(); }); + stream.on("close", () => resolve(output)); + }); + }); + } + + close(): void { + if (this.client) { + try { this.client.end(); } catch {} + this.client = null; + this.ready = false; + } + } +} + +export function sanitizeDirName(name: string): string { + return name + .replace(/[\/\\:*?"<>|]/g, "_") + .replace(/\s+/g, "_") + .replace(/\.{2,}/g, "_") + .replace(/^\.+|\.+$/g, "") + .substring(0, 200); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function shellEscape(s: string): string { + return s.replace(/'/g, "'\\''"); +} + +function buildClient(baseURL: string, cookie?: string, token?: string): AxiosInstance { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Token ${token}`; + if (cookie) headers["Cookie"] = cookie; + return axios.create({ baseURL, headers, maxRedirects: 5, timeout: 30_000 }); +} + +export async function loginWithCredentials( + url: string, + username: string, + password: string, +): Promise<{ sessionCookie: string; ctfName: string }> { + const baseURL = url.replace(/\/+$/, ""); + + const loginPageRes = await axios.get(`${baseURL}/login`, { + maxRedirects: 5, + timeout: 15_000, + headers: { "User-Agent": "PentestCopilot/1.0" }, + }); + + const $ = cheerio.load(loginPageRes.data); + const nonce = $('input[name="nonce"]').val() as string; + if (!nonce) throw new Error("Could not extract CSRF nonce from CTFd login page"); + + const ctfName = $("title").text().trim().replace(/\s*\|.*$/, "") || "CTF"; + + const params = new URLSearchParams(); + params.append("name", username); + params.append("password", password); + params.append("nonce", nonce); + params.append("_submit", "Submit"); + + const loginRes = await axios.post(`${baseURL}/login`, params.toString(), { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "PentestCopilot/1.0", + Cookie: extractSetCookies(loginPageRes.headers["set-cookie"]), + }, + maxRedirects: 0, + validateStatus: (s) => s >= 200 && s < 400, + timeout: 15_000, + }); + + const setCookies = loginRes.headers["set-cookie"]; + if (!setCookies || setCookies.length === 0) { + throw new Error("Login failed - no session cookie received. Check your credentials."); + } + + const sessionCookie = extractSetCookies(setCookies); + if (!sessionCookie) { + throw new Error("Login failed - could not parse session cookie."); + } + + return { sessionCookie, ctfName }; +} + +export async function verifyToken(url: string, token: string): Promise { + const baseURL = url.replace(/\/+$/, ""); + const client = buildClient(baseURL, undefined, token); + const res = await client.get("/api/v1/challenges", { params: { limit: 1 } }); + if (!res.data?.success) throw new Error("API token verification failed"); + + const pageRes = await axios.get(baseURL, { timeout: 10_000, headers: { "User-Agent": "PentestCopilot/1.0" } }); + const $ = cheerio.load(pageRes.data); + return $("title").text().trim().replace(/\s*\|.*$/, "") || "CTF"; +} + +export async function fetchChallenges( + url: string, + cookie?: string, + token?: string, + onProgress?: ProgressCallback, +): Promise { + const baseURL = url.replace(/\/+$/, ""); + const client = buildClient(baseURL, cookie, token); + + console.log(`[CTF] Fetching challenge list from ${baseURL}...`); + const listRes = await client.get("/api/v1/challenges"); + if (!listRes.data?.success) throw new Error("Failed to fetch challenges from CTFd"); + + const rawChallenges: any[] = listRes.data.data || []; + const challenges: CTFdChallenge[] = []; + const total = rawChallenges.length; + console.log(`[CTF] Found ${total} challenges, fetching details...`); + + for (let i = 0; i < rawChallenges.length; i++) { + const ch = rawChallenges[i]; + onProgress?.({ + phase: "fetch", + current: i + 1, + total, + name: ch.name, + }); + + try { + const detailRes = await client.get(`/api/v1/challenges/${ch.id}`); + const detail = detailRes.data?.data || {}; + + const files: string[] = (detail.files || []) + .map((f: any) => (typeof f === "string" ? f : f.location || "")) + .filter(Boolean); + + challenges.push({ + id: ch.id, + name: detail.name || ch.name, + category: detail.category || ch.category || "Uncategorized", + description: stripHtml(detail.description || ""), + value: detail.value ?? ch.value ?? 0, + files, + }); + } catch (err: any) { + console.warn(`[CTF] Failed to fetch details for challenge ${ch.id}: ${err.message}`); + challenges.push({ + id: ch.id, + name: ch.name, + category: ch.category || "Uncategorized", + description: "", + value: ch.value ?? 0, + files: [], + }); + } + } + + return challenges; +} + +export async function syncToWorkspace( + ctfName: string, + challenges: CTFdChallenge[], + ctfdBaseURL: string, + cookie?: string, + token?: string, + onProgress?: ProgressCallback, +): Promise<{ synced: number; updated: number; skipped: number }> { + const ssh = new SSHSession(); + await ssh.connect(); + + try { + return await doSync(ssh, ctfName, challenges, ctfdBaseURL, cookie, token, onProgress); + } finally { + ssh.close(); + } +} + +async function doSync( + ssh: SSHSession, + ctfName: string, + challenges: CTFdChallenge[], + ctfdBaseURL: string, + cookie?: string, + token?: string, + onProgress?: ProgressCallback, +): Promise<{ synced: number; updated: number; skipped: number }> { + const safeCTFName = sanitizeDirName(ctfName); + const baseURL = ctfdBaseURL.replace(/\/+$/, ""); + + // Resolve ~ to actual home path so it works inside quotes + const home = (await ssh.exec("echo $HOME")).trim(); + const resolvedWorkspace = WORKSPACE_DIR.replace(/^~/, home); + const ctfDir = `${resolvedWorkspace}/${safeCTFName}`; + + console.log(`[CTF] Starting sync for "${ctfName}" -> ${ctfDir}`); + + await ssh.exec(`mkdir -p "${ctfDir}"`); + + const existingRaw = await ssh.exec(`ls -1 "${ctfDir}" 2>/dev/null || true`); + const existingDirs = new Set( + existingRaw.split("\n").map((l) => l.trim()).filter(Boolean), + ); + + const curlAuth = buildCurlAuth(cookie, token); + + let synced = 0; + let updated = 0; + let skipped = 0; + const total = challenges.length; + + for (let i = 0; i < challenges.length; i++) { + const ch = challenges[i]; + const safeName = sanitizeDirName(ch.name); + const challengeDir = `${ctfDir}/${safeName}`; + const isExisting = existingDirs.has(safeName); + + if (isExisting) { + let didUpdate = false; + + const challengeTxt = buildChallengeTxt(ch); + const oldContent = await ssh.exec( + `cat "${challengeDir}/challenge.txt" 2>/dev/null || echo ""`, + ); + + if (oldContent.trim() !== challengeTxt.trim()) { + await writeChallengeTxt(ssh, challengeDir, challengeTxt); + didUpdate = true; + } + + if (ch.files.length > 0) { + const existingFilesRaw = await ssh.exec( + `ls -1 "${challengeDir}" 2>/dev/null || true`, + ); + const existingFiles = new Set( + existingFilesRaw.split("\n").map((l) => l.trim()).filter(Boolean), + ); + + for (const filePath of ch.files) { + const fileName = extractFileName(filePath); + if (existingFiles.has(fileName)) continue; + + const fileUrl = resolveFileUrl(filePath, baseURL); + await ssh.exec( + `curl -sS -L --retry 3 --max-time 120 ${curlAuth} -o '${shellEscape(`${challengeDir}/${fileName}`)}' '${shellEscape(fileUrl)}'`, + ); + didUpdate = true; + } + } + + if (didUpdate) { + updated++; + onProgress?.({ phase: "sync", current: i + 1, total, name: ch.name, action: "updated" }); + } else { + skipped++; + onProgress?.({ phase: "sync", current: i + 1, total, name: ch.name, action: "skipped" }); + } + } else { + const challengeTxt = buildChallengeTxt(ch); + + await ssh.exec(`mkdir -p "${challengeDir}"`); + await writeChallengeTxt(ssh, challengeDir, challengeTxt); + + for (const filePath of ch.files) { + const fileName = extractFileName(filePath); + const fileUrl = resolveFileUrl(filePath, baseURL); + await ssh.exec( + `curl -sS -L --retry 3 --max-time 120 ${curlAuth} -o '${shellEscape(`${challengeDir}/${fileName}`)}' '${shellEscape(fileUrl)}'`, + ); + } + + synced++; + onProgress?.({ phase: "sync", current: i + 1, total, name: ch.name, action: "new" }); + } + + if (i % 10 === 0) { + console.log(`[CTF] Progress: ${i + 1}/${total} challenges processed`); + } + } + + console.log(`[CTF] Sync complete: ${synced} new, ${updated} updated, ${skipped} unchanged`); + + const index = challenges.map((ch) => ({ + name: ch.name, + category: ch.category, + value: ch.value, + safeDir: sanitizeDirName(ch.name), + })); + const indexJson = JSON.stringify(index, null, 2).replace(/'/g, "'\\''"); + await ssh.exec(`printf '%s' '${indexJson}' > "${ctfDir}/challenges.json"`); + console.log(`[CTF] Wrote challenges.json with ${index.length} entries`); + + return { synced, updated, skipped }; +} + +async function writeChallengeTxt(ssh: SSHSession, challengeDir: string, content: string): Promise { + const escaped = content.replace(/\\/g, "\\\\").replace(/'/g, "'\\''"); + await ssh.exec(`printf '%s' '${escaped}' > "${challengeDir}/challenge.txt"`); +} + +function buildCurlAuth(cookie?: string, token?: string): string { + const parts: string[] = []; + if (token) parts.push(`-H 'Authorization: Token ${shellEscape(token)}'`); + if (cookie) parts.push(`-b '${shellEscape(cookie)}'`); + return parts.join(" "); +} + +function resolveFileUrl(filePath: string, baseURL: string): string { + return filePath.startsWith("http") + ? filePath + : `${baseURL}/${filePath.replace(/^\//, "")}`; +} + +function extractFileName(filePath: string): string { + const rawName = filePath.split("?")[0].split("/").pop() || "attachment"; + return sanitizeDirName(decodeURIComponent(rawName)); +} + +function buildChallengeTxt(ch: CTFdChallenge): string { + return [ + `Challenge: ${ch.name}`, + `Category: ${ch.category}`, + `Points: ${ch.value}`, + ``, + `Description:`, + ch.description || "(no description)", + ].join("\n"); +} + +function extractSetCookies(setCookies: string[] | undefined): string { + if (!setCookies) return ""; + return setCookies.map((c) => c.split(";")[0]).join("; "); +} + +function stripHtml(html: string): string { + const $ = cheerio.load(html); + return $.text().trim(); +} diff --git a/backend/src/services/slash-commands.ts b/backend/src/services/slash-commands.ts index 9e2077a..e5c2208 100644 --- a/backend/src/services/slash-commands.ts +++ b/backend/src/services/slash-commands.ts @@ -3,6 +3,9 @@ import SessionsModel, { AgentMessageDoc } from "../models/Sessions/Sessions.mode import { SSEWriter } from "./agent.service"; import { invoke_llm, invoke_llm_streaming, getProvider } from "../utils/llm/providers"; import { sessionLifecycle } from "./session.lifecycle"; +import { sanitizeDirName } from "./ctf.service"; +import { execSSHCommand } from "./ssh.service"; +import { WORKSPACE_DIR } from "../utils/commandSafety"; export interface SlashCommandDef { name: string; @@ -52,6 +55,12 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [ description: "Reset agent state to idle (useful if agent is stuck)", usage: "/reset", }, + { + name: "solve", + description: "Focus on a specific CTF challenge (auto-suggests from synced challenges)", + usage: "/solve [extra notes]", + args: [{ name: "challenge", required: true, description: "Challenge name or partial match" }], + }, ]; export function parseSlashCommand(input: string): { command: string; args: string } | null { @@ -563,4 +572,206 @@ Use markdown formatting. Be thorough but concise.`, sse.write("done", { message: "Slash command completed" }); sse.end(); }, + + solve: async ({ sessionId, args, sse }) => { + const session = await SessionsModel.findOne({ sessionId }); + if (!session) { + sse.write("slash_command_result", { command: "solve", success: false, content: "Session not found." }); + sse.write("done", { message: "Slash command completed" }); + sse.end(); + return; + } + + if (!session.ctfConfig?.ctfName) { + sse.write("slash_command_result", { + command: "solve", + success: false, + content: "No CTF connected. Connect to a CTFd instance first.", + }); + sse.write("done", { message: "Slash command completed" }); + sse.end(); + return; + } + + const ctfName = session.ctfConfig.ctfName; + const safeCTFName = sanitizeDirName(ctfName); + + if (args.toLowerCase() === "clear" || args.toLowerCase() === "none") { + await SessionsModel.updateOne( + { sessionId }, + { $unset: { "ctfConfig.activeSolve": 1 } }, + ); + sse.write("slash_command_result", { + command: "solve", + success: true, + content: "Challenge focus cleared. The agent will no longer target a specific challenge.", + }); + sse.write("done", { message: "Slash command completed" }); + sse.end(); + return; + } + + if (!args.trim()) { + sse.write("slash_command_result", { + command: "solve", + success: false, + content: "Usage: `/solve ` — specify which challenge to focus on.\nUse `/solve clear` to deactivate challenge focus.", + }); + sse.write("done", { message: "Slash command completed" }); + sse.end(); + return; + } + + let home: string; + let resolvedWs: string; + let ctfDir: string; + + try { + home = (await execSSHCommand("echo $HOME")).trim(); + resolvedWs = WORKSPACE_DIR.replace(/^~/, home); + ctfDir = `${resolvedWs}/${safeCTFName}`; + } catch (err: any) { + sse.write("slash_command_result", { + command: "solve", + success: false, + content: "Cannot connect to the attack box via SSH. Configure SSH settings first.", + }); + sse.write("done", { message: "Slash command completed" }); + sse.end(); + return; + } + + let challenges: Array<{ name: string; category: string; value: number; safeDir: string }> = []; + try { + const raw = await execSSHCommand(`cat "${ctfDir}/challenges.json" 2>/dev/null || echo "[]"`); + challenges = JSON.parse(raw.trim()); + } catch { + sse.write("slash_command_result", { + command: "solve", + success: false, + content: "No challenges synced yet. Run sync from the CTF settings panel first.", + }); + sse.write("done", { message: "Slash command completed" }); + sse.end(); + return; + } + + if (challenges.length === 0) { + sse.write("slash_command_result", { + command: "solve", + success: false, + content: "No challenges synced yet. Run sync from the CTF settings panel first.", + }); + sse.write("done", { message: "Slash command completed" }); + sse.end(); + return; + } + + const queryRaw = args.replace(/^["']|["']$/g, "").trim(); + let userNotes = ""; + let query = queryRaw; + + const quotedMatch = args.match(/^["'](.+?)["']\s*(.*)/); + if (quotedMatch) { + query = quotedMatch[1].trim(); + userNotes = quotedMatch[2].trim(); + } + + const queryLower = query.toLowerCase(); + let matched = challenges.filter((c) => c.name.toLowerCase() === queryLower); + if (matched.length === 0) { + matched = challenges.filter((c) => c.safeDir.toLowerCase() === queryLower); + } + if (matched.length === 0) { + matched = challenges.filter((c) => c.name.toLowerCase().includes(queryLower)); + } + if (matched.length === 0) { + matched = challenges.filter((c) => c.safeDir.toLowerCase().includes(queryLower)); + } + + if (matched.length === 0) { + const listing = challenges.map((c) => `- **${c.name}** (${c.category}, ${c.value} pts)`).join("\n"); + sse.write("slash_command_result", { + command: "solve", + success: false, + content: `No challenge matching "${query}" found.\n\n### Available challenges:\n${listing}`, + }); + sse.write("done", { message: "Slash command completed" }); + sse.end(); + return; + } + + if (matched.length > 1) { + const exactName = matched.filter((c) => c.name.toLowerCase() === queryLower); + if (exactName.length === 1) { + matched = exactName; + } else { + const listing = matched.map((c) => `- **${c.name}** (${c.category}, ${c.value} pts)`).join("\n"); + sse.write("slash_command_result", { + command: "solve", + success: false, + content: `Multiple challenges match "${query}". Be more specific:\n${listing}`, + }); + sse.write("done", { message: "Slash command completed" }); + sse.end(); + return; + } + } + + const challenge = matched[0]; + const challengeDir = `${ctfDir}/${challenge.safeDir}`; + + let challengeTxt = ""; + try { + challengeTxt = await execSSHCommand(`cat "${challengeDir}/challenge.txt" 2>/dev/null`); + } catch { + challengeTxt = `Challenge: ${challenge.name}\nCategory: ${challenge.category}\nPoints: ${challenge.value}`; + } + + let files: string[] = []; + try { + const lsOut = await execSSHCommand(`ls -1 "${challengeDir}" 2>/dev/null`); + files = lsOut.split("\n").map((l) => l.trim()).filter((l) => l && l !== "challenge.txt"); + } catch {} + + await SessionsModel.updateOne( + { sessionId }, + { + $set: { + "ctfConfig.activeSolve": { + name: challenge.name, + safeDir: challenge.safeDir, + challengeTxt, + files, + userNotes: userNotes || undefined, + setAt: new Date(), + }, + }, + }, + ); + + const lines: string[] = [ + `### Solving: ${challenge.name}`, + ``, + `**Category:** ${challenge.category} | **Points:** ${challenge.value}`, + `**Working directory:** \`${challengeDir}\``, + ``, + challengeTxt.includes("Description:") ? "" : `${challengeTxt}\n`, + files.length > 0 ? `**Files:**\n${files.map((f) => `- \`${challengeDir}/${f}\``).join("\n")}` : "*No attached files*", + ]; + + if (userNotes) { + lines.push("", `**Your notes:** ${userNotes}`); + } + + lines.push("", "The agent is now focused on this challenge. Send a message to start solving, or add more context."); + + sse.write("slash_command_result", { + command: "solve", + success: true, + content: lines.filter((l) => l !== undefined).join("\n"), + }); + sse.write("done", { message: "Slash command completed" }); + sse.end(); + }, }; diff --git a/backend/src/services/subagent.manager.ts b/backend/src/services/subagent.manager.ts index 86c67bc..52e454f 100644 --- a/backend/src/services/subagent.manager.ts +++ b/backend/src/services/subagent.manager.ts @@ -33,7 +33,17 @@ function truncateOutput(output: string): string { ); } -function buildSubagentSystemPrompt(task: string, parentSessionId: string): string { +interface BoxEnvInfo { + user: string; + home: string; + os: string; + workspacePath: string; +} + +function buildSubagentSystemPrompt(task: string, parentSessionId: string, envInfo?: BoxEnvInfo): string { + const boxDesc = envInfo ? `${envInfo.os} attack box` : "attack box"; + const userDesc = envInfo ? ` as ${envInfo.user}` : ""; + return ` You are a Pentest Copilot subagent. You are a specialized parallel worker launched by the main agent to investigate a specific aspect of a penetration test. @@ -46,7 +56,7 @@ ${task} - Focus exclusively on the task described above. Be thorough but efficient. - Execute tools autonomously to achieve the task goal. - Think step-by-step: explain your reasoning briefly before each action. -- You have access to the same Kali Linux attack box as the main agent. +- You have access to the same ${boxDesc} as the main agent${userDesc}. - Use spawn_shell for long-running processes or netcat listeners. - When done, provide a clear structured summary of your findings. - You cannot spawn further subagents. @@ -54,7 +64,8 @@ ${task} - Parent Session ID: ${parentSessionId} -- Attack box: Kali Linux with root access +- Attack box: ${boxDesc}${envInfo ? `\n- User: ${envInfo.user} (home: ${envInfo.home})` : ""} +- Working directory: ${envInfo?.workspacePath ?? "~/pentest-workspace"} @@ -71,6 +82,7 @@ export class SubagentManager extends EventEmitter { private shellManager: ShellManager; private runningSubagents: Map = new Map(); private completionCallbacks: Map void>> = new Map(); + envInfo?: BoxEnvInfo; constructor(sessionId: string, shellManager: ShellManager) { super(); @@ -149,7 +161,7 @@ export class SubagentManager extends EventEmitter { const systemMsg: AgentMessageDoc = { id: `sys_${subagentId}`, role: "system", - content: buildSubagentSystemPrompt(task, this.sessionId), + content: buildSubagentSystemPrompt(task, this.sessionId, this.envInfo), timestamp: new Date(), turnIndex: 0, }; @@ -192,6 +204,7 @@ export class SubagentManager extends EventEmitter { readShellOutput: (shellId, fromOffset) => Promise.resolve(this.shellManager.readOutput(shellId, fromOffset)), closeShell: (shellId) => this.shellManager.closeShell(shellId), + resizeShell: (shellId, cols, rows) => this.shellManager.resizeShell(shellId, cols, rows), listShells: () => this.shellManager.getShellList(), getShellInfo: (shellId) => this.shellManager.getShell(shellId), onOutput: onChunk, diff --git a/backend/src/tools/handlers/spawn-shell.ts b/backend/src/tools/handlers/spawn-shell.ts index 244bdc5..4660e21 100644 --- a/backend/src/tools/handlers/spawn-shell.ts +++ b/backend/src/tools/handlers/spawn-shell.ts @@ -21,6 +21,14 @@ const spawnShell: ToolDefinition = { "'reverse-shell' for shells that will receive reverse connections from targets, " + "'listener' for netcat/socat listeners waiting for connections.", }, + rows: { + type: "number", + description: "Terminal rows (default 50). Set higher for TUI/game challenges (e.g. 60).", + }, + cols: { + type: "number", + description: "Terminal columns (default 200). Set wider for TUI/game challenges (e.g. 120).", + }, }, required: ["label"], }, @@ -32,8 +40,19 @@ const spawnShell: ToolDefinition = { try { const shellId = await ctx.spawnShell(label, "pty", purpose); + + const rows = args.rows ? Math.max(1, Math.min(args.rows, 500)) : undefined; + const cols = args.cols ? Math.max(1, Math.min(args.cols, 500)) : undefined; + if (rows || cols) { + ctx.resizeShell(shellId, cols || 200, rows || 50); + } + + const sizeInfo = (rows || cols) + ? `\nterminal size: ${cols || 200}x${rows || 50}` + : ""; + return { - output: `Shell spawned successfully.\nshell_id: ${shellId}\nlabel: ${label}\npurpose: ${purpose}\n\nYou can now use this shell_id with write_to_shell and read_shell.`, + output: `Shell spawned successfully.\nshell_id: ${shellId}\nlabel: ${label}\npurpose: ${purpose}${sizeInfo}\n\nYou can now use this shell_id with write_to_shell and read_shell.`, exitCode: 0, }; } catch (err: any) { diff --git a/backend/src/tools/handlers/view-image.ts b/backend/src/tools/handlers/view-image.ts new file mode 100644 index 0000000..460ec31 --- /dev/null +++ b/backend/src/tools/handlers/view-image.ts @@ -0,0 +1,157 @@ +import { ToolDefinition } from "../types"; +import { invoke_llm } from "../../utils/llm/providers"; +import { WORKSPACE_DIR } from "../../utils/commandSafety"; + +const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB + +const MIME_MAP: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + bmp: "image/bmp", + webp: "image/webp", + tiff: "image/tiff", + tif: "image/tiff", + svg: "image/svg+xml", +}; + +const viewImage: ToolDefinition = { + name: "view_image", + description: + "View an image file on the attack box using AI vision. Returns a text description and any visible text/flags. " + + "Use this to read text, flags, QR codes, or understand visual content in images (PNG, JPEG, GIF, BMP, WEBP, TIFF).", + parameters: { + type: "object", + properties: { + image_path: { + type: "string", + description: + "Path to the image file on the attack box (absolute or relative to ~/pentest-workspace)", + }, + question: { + type: "string", + description: + "Optional: what to look for in the image. Defaults to describing the image and extracting any visible text.", + }, + }, + required: ["image_path"], + }, + timeoutMs: 120_000, + + async execute(args, ctx) { + const { image_path, question } = args; + if (!image_path) { + return { output: "Error: image_path is required", exitCode: 1 }; + } + + const rawPath = + image_path.startsWith("/") || image_path.startsWith("~") + ? image_path + : `${WORKSPACE_DIR}/${image_path}`; + + // Replace leading ~ with $HOME so shell expansion works inside double quotes + const resolvedPath = rawPath.replace(/^~(?=\/|$)/, "$HOME"); + + const { output: sizeOut, exitCode: sizeExit } = await ctx.runCommand( + `stat -c %s "${resolvedPath}" 2>/dev/null || stat -f %z "${resolvedPath}" 2>/dev/null`, + 10_000, + ); + + if (sizeExit !== 0 || !sizeOut.trim()) { + return { + output: `Error: File not found or not accessible: ${resolvedPath}`, + exitCode: 1, + }; + } + + const fileSize = parseInt(sizeOut.trim(), 10); + if (isNaN(fileSize)) { + return { output: `Error: Could not determine file size`, exitCode: 1 }; + } + if (fileSize > MAX_IMAGE_SIZE_BYTES) { + return { + output: + `Error: Image is too large (${(fileSize / 1024 / 1024).toFixed(1)} MB, max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB). ` + + `Resize or crop it first using Pillow or ffmpeg, then try again.`, + exitCode: 1, + }; + } + if (fileSize === 0) { + return { output: "Error: File is empty (0 bytes)", exitCode: 1 }; + } + + const ext = resolvedPath.split(".").pop()?.toLowerCase() ?? ""; + let mimeType = MIME_MAP[ext]; + + if (!mimeType) { + const { output: fileOut } = await ctx.runCommand( + `file --mime-type -b "${resolvedPath}"`, + 10_000, + ); + const detected = fileOut.trim(); + if (detected.startsWith("image/")) { + mimeType = detected; + } else { + return { + output: `Error: Not a recognized image format (detected: ${detected}). Convert to PNG/JPEG first.`, + exitCode: 1, + }; + } + } + + const { output: b64Out, exitCode: b64Exit } = await ctx.runCommand( + `base64 -w 0 "${resolvedPath}" 2>/dev/null || base64 -i "${resolvedPath}" 2>/dev/null`, + 30_000, + ); + + if (b64Exit !== 0 || !b64Out.trim()) { + return { + output: `Error: Failed to read image file: ${resolvedPath}`, + exitCode: 1, + }; + } + + const base64Data = b64Out.trim(); + const dataUri = `data:${mimeType};base64,${base64Data}`; + + const userPrompt = + question || + "Describe this image in detail. Extract and reproduce ALL visible text exactly as written, including any flags, codes, or hidden messages."; + + try { + const result = await invoke_llm({ + messages: [ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: dataUri, detail: "high" }, + }, + { type: "text", text: userPrompt }, + ], + }, + ], + temperature: 0.2, + generationName: "view_image", + }); + + if (!result.content) { + return { + output: "Error: Vision model returned no response", + exitCode: 1, + }; + } + + return { output: result.content, exitCode: 0 }; + } catch (err: any) { + return { + output: `Error calling vision model: ${err.message ?? err}`, + exitCode: 1, + }; + } + }, +}; + +export default viewImage; diff --git a/backend/src/tools/registry.ts b/backend/src/tools/registry.ts index d61deb6..83c9d02 100644 --- a/backend/src/tools/registry.ts +++ b/backend/src/tools/registry.ts @@ -18,6 +18,7 @@ import burpCollaborator from "./handlers/burp-collaborator"; // import burpProxyControl from "./handlers/burp-proxy-control"; import burpProxyHistory from "./handlers/burp-proxy-history"; import magnitudeBrowser from "./handlers/magnitude-browser"; +import viewImage from "./handlers/view-image"; class ToolRegistry { private tools: Map = new Map(); @@ -90,3 +91,4 @@ toolRegistry.register(burpCollaborator); // toolRegistry.register(burpProxyControl); toolRegistry.register(burpProxyHistory); toolRegistry.register(magnitudeBrowser); +toolRegistry.register(viewImage); diff --git a/backend/src/tools/types.ts b/backend/src/tools/types.ts index a09ccbb..a72e221 100644 --- a/backend/src/tools/types.ts +++ b/backend/src/tools/types.ts @@ -15,6 +15,7 @@ export interface ExecutionContext { writeToShell: (shellId: string, data: string) => Promise; readShellOutput: (shellId: string, fromOffset?: number) => Promise<{ data: string; offset: number }>; closeShell: (shellId: string) => Promise; + resizeShell: (shellId: string, cols: number, rows: number) => void; listShells: () => ShellInfo[]; getShellInfo: (shellId: string) => ShellInfo | undefined; spawnSubagent?: (task: string) => Promise; diff --git a/backend/src/utils/copilot/prompts.ts b/backend/src/utils/copilot/prompts.ts index c1a4677..0bb4a68 100644 --- a/backend/src/utils/copilot/prompts.ts +++ b/backend/src/utils/copilot/prompts.ts @@ -5,6 +5,13 @@ import { } from "../../capabilities/registry"; import { readEnvFile } from "../envWriter"; +export interface BoxEnvInfo { + user: string; + home: string; + os: string; + workspacePath: string; +} + export interface AgentPromptConfig { sessionId: string; installedCapabilities?: string[]; @@ -12,6 +19,18 @@ export interface AgentPromptConfig { currentDate?: string; currentDay?: string; timezone?: string; + envInfo?: BoxEnvInfo; + ctfConfig?: { + ctfName: string; + workspacePath: string; + activeSolve?: { + name: string; + challengeTxt: string; + files: string[]; + challengeDir: string; + userNotes?: string; + }; + }; } export function buildSystemPrompt(config: AgentPromptConfig): string { @@ -157,10 +176,15 @@ ${burpRequestFormatting} const tz = config.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; const time = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); + const ei = config.envInfo; + const boxDesc = ei ? `${ei.os} attack box` : "attack box"; + const userDesc = ei ? `${ei.user}` : "current user"; + const wsPath = ei?.workspacePath ?? "~/pentest-workspace"; + return ` You are Pentest Copilot, an autonomous penetration testing agent specializing in identifying vulnerabilities and exploiting security weaknesses in computer systems and networks. -You operate on a Kali Linux attack box with direct tool access via function calls. You make decisions independently — you do not ask for permission to run commands (except when installing new tools). +You operate on a ${boxDesc} with direct tool access via function calls. You make decisions independently — you do not ask for permission to run commands (except when installing new tools). @@ -175,7 +199,7 @@ You operate on a Kali Linux attack box with direct tool access via function call -${capCtx || "Standard Kali Linux tools available via run_bash."} +${capCtx || "Standard tools available via run_bash."} ${installSection} @@ -183,8 +207,9 @@ ${installSection} - Date: ${date} (${day}) - Time: ${time} ${tz} - Session ID: ${config.sessionId} — use this in output file names (e.g., ${config.sessionId}-nmap.txt) -- Attack box: Kali Linux with root access -- Working directory: ~/pentest-workspace — all commands run here by default. Files, scripts, and tool output are stored in this directory. Do NOT delete or write outside this workspace on the attack box. +- Attack box: ${boxDesc} +- User: ${userDesc}${ei ? ` (home: ${ei.home})` : ""} +- Working directory: ${wsPath} — all commands run here by default. Files, scripts, and tool output are stored in this directory. Always use this absolute path when referencing workspace files in scripts. Do NOT delete or write outside this workspace on the attack box. - For reverse shells on target machines, you may operate from any directory. When spawning a shell for a reverse connection, use purpose "reverse-shell".${wordlistSection} @@ -199,7 +224,25 @@ ${burpSection} - Destructive system commands (rm -rf /, disk wipes, shutdowns) are blocked and require explicit user approval regardless of auto-run settings. - +${config.ctfConfig ? ` +A CTF ("${config.ctfConfig.ctfName}") is connected. Challenge files are synced to ${config.ctfConfig.workspacePath}. +Each subdirectory is one challenge containing a challenge.txt (name, category, points, description) and any attached files. +When the user asks you to work on a challenge, start by reading its challenge.txt and inspecting the files in its directory. +Flag format is typically CTF{...} — always look for flag patterns in command output, decoded data, and images. + + +${config.ctfConfig.activeSolve ? ` +You are currently solving: "${config.ctfConfig.activeSolve.name}" +Working directory: ${config.ctfConfig.activeSolve.challengeDir} +Always cd to this directory before running commands for this challenge. + +${config.ctfConfig.activeSolve.challengeTxt} + +${config.ctfConfig.activeSolve.files.length > 0 ? `Files in challenge directory:\n${config.ctfConfig.activeSolve.files.map(f => `- ${f}`).join("\n")}` : "No attached files in challenge directory."} +${config.ctfConfig.activeSolve.userNotes ? `\nAdditional context from user:\n${config.ctfConfig.activeSolve.userNotes}` : ""} + + +` : ""}` : ""} After each significant finding, maintain a structured summary in your response: - TARGETS: IPs/hostnames with current status - PORTS: port/service/version tuples discovered diff --git a/frontend/src/app/session/[session_id]/ctf/page.js b/frontend/src/app/session/[session_id]/ctf/page.js new file mode 100644 index 0000000..0ef9707 --- /dev/null +++ b/frontend/src/app/session/[session_id]/ctf/page.js @@ -0,0 +1,42 @@ +"use client"; + +import Loader from "@/components/common/loader/Loader"; +import CTFPage from "@/components/pages/session/ctf/CTFPage"; +import { use } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { updateSessions } from "@/store/user.slice"; + +const CtfPageRoute = ({ params }) => { + const { session_id: sessionId } = use(params); + const { user, sessions } = useSelector((state) => state.user); + const dispatch = useDispatch(); + + const ctfSession = sessions.find((session) => session.type === "ctf"); + + if (!ctfSession) { + const allSessions = sessions.map((session) => ({ + ...session, + is_active: false, + })); + + dispatch( + updateSessions([ + ...allSessions, + { + id: sessionId + "/ctf", + is_main: false, + is_active: true, + type: "ctf", + }, + ]) + ); + } + + if (!user) { + return ; + } + + return ; +}; + +export default CtfPageRoute; diff --git a/frontend/src/components/common/Sidebar.jsx b/frontend/src/components/common/Sidebar.jsx index 99e7cca..9cc834b 100644 --- a/frontend/src/components/common/Sidebar.jsx +++ b/frontend/src/components/common/Sidebar.jsx @@ -18,6 +18,7 @@ import { updateSessions } from "@/store/user.slice"; import { FiMonitor } from "react-icons/fi"; import { MdOutlineDeleteSweep } from "react-icons/md"; import { TbRadar, TbWorldWww } from "react-icons/tb"; +import { FaFlag } from "react-icons/fa"; import { useAgentStreamStore } from "@/store/agentStream.store"; import ContextUsageIndicator from "@/components/agent/ContextUsageIndicator"; @@ -107,11 +108,24 @@ const Sidebar = ({ sessionId }) => { router.push(`/session/${sessionId}/browser-agent`); }; + const navigateToCtf = () => { + const ctfId = `${sessionId}/ctf`; + let updatedSess = [...sessions]; + const exists = updatedSess.find((s) => s.id === ctfId); + if (!exists) { + updatedSess = updatedSess.map((s) => ({ ...s, is_active: false })); + updatedSess.push({ id: ctfId, is_main: false, is_active: true, type: "ctf" }); + dispatch(updateSessions(updatedSess)); + } + router.push(`/session/${sessionId}/ctf`); + }; + const isOnWorkspace = pathname === `/session/${sessionId}`; const isOnVPN = pathname?.includes("/vpn"); const isOnGUI = pathname?.includes("/gui"); const isOnBurp = pathname?.includes("/burp"); const isOnBrowserAgent = pathname?.includes("/browser-agent"); + const isOnCtf = pathname?.includes("/ctf"); return (
@@ -239,6 +253,14 @@ const Sidebar = ({ sessionId }) => { Browser Agent
+ +
+ + CTF +
diff --git a/frontend/src/components/pages/session/ctf/CTFPage.jsx b/frontend/src/components/pages/session/ctf/CTFPage.jsx new file mode 100644 index 0000000..448dfe6 --- /dev/null +++ b/frontend/src/components/pages/session/ctf/CTFPage.jsx @@ -0,0 +1,403 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { Button, message } from "antd"; +import { DisconnectOutlined, LinkOutlined, SyncOutlined } from "@ant-design/icons"; +import { FaFlag } from "react-icons/fa"; +import { useQuery, useMutation, useQueryClient } from "react-query"; +import { + connectCtf, + getCtfConfig, + syncCtfStream, + disconnectCtf, +} from "@/services/ctf.service"; +import styles from "@/styles/components/CTF.module.scss"; + +function sanitizeDirName(name) { + return name + .replace(/[\/\\:*?"<>|]/g, "_") + .replace(/\s+/g, "_") + .replace(/\.{2,}/g, "_") + .replace(/^\.+|\.+$/g, "") + .substring(0, 200); +} + +const CTFPage = ({ sessionId }) => { + const queryClient = useQueryClient(); + + const [url, setUrl] = useState(""); + const [authMethod, setAuthMethod] = useState("credentials"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [apiToken, setApiToken] = useState(""); + + const [syncing, setSyncing] = useState(false); + const [syncProgress, setSyncProgress] = useState(null); + const [syncResult, setSyncResult] = useState(null); + const [syncError, setSyncError] = useState(null); + + const abortRef = useRef(null); + const logEndRef = useRef(null); + + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [syncProgress]); + + const { + data: config, + isLoading: configLoading, + } = useQuery( + ["ctf-config", sessionId], + () => getCtfConfig(sessionId), + { refetchOnWindowFocus: false, retry: 1 } + ); + + const startSync = useCallback(() => { + setSyncing(true); + setSyncProgress(null); + setSyncResult(null); + setSyncError(null); + + const abort = syncCtfStream( + sessionId, + (event) => { + if (event.phase === "done") { + setSyncResult(event); + setSyncProgress(null); + setSyncing(false); + queryClient.invalidateQueries(["ctf-config", sessionId]); + message.success("Sync complete"); + } else if (event.phase === "error") { + setSyncError(event.detail || "Sync failed"); + setSyncProgress(null); + setSyncing(false); + message.error(event.detail || "Sync failed"); + } else { + setSyncProgress(event); + } + }, + () => { + setSyncing(false); + }, + (err) => { + setSyncError(err.message || "Sync failed"); + setSyncProgress(null); + setSyncing(false); + message.error(err.message || "Sync failed"); + }, + ); + + abortRef.current = abort; + }, [sessionId, queryClient]); + + const connectMutation = useMutation( + (body) => connectCtf(sessionId, body), + { + onSuccess: (data) => { + message.success(`Connected to ${data.ctfName}`); + queryClient.invalidateQueries(["ctf-config", sessionId]); + startSync(); + }, + onError: (err) => { + const msg = err?.response?.data?.message || "Failed to connect to CTF"; + message.error(msg); + }, + } + ); + + const disconnectMutation = useMutation( + () => disconnectCtf(sessionId), + { + onSuccess: () => { + setSyncResult(null); + setSyncError(null); + setSyncProgress(null); + queryClient.invalidateQueries(["ctf-config", sessionId]); + message.success("Disconnected from CTF"); + }, + onError: () => { + message.error("Failed to disconnect"); + }, + } + ); + + const handleConnect = () => { + if (!url.trim()) { + message.warning("CTFd URL is required"); + return; + } + const body = { url: url.trim() }; + if (authMethod === "credentials") { + if (!username.trim() || !password.trim()) { + message.warning("Username and password are required"); + return; + } + body.username = username.trim(); + body.password = password.trim(); + } else { + if (!apiToken.trim()) { + message.warning("API token is required"); + return; + } + body.apiToken = apiToken.trim(); + } + connectMutation.mutate(body); + }; + + if (configLoading) { + return ( +
+
+
+

Loading CTF configuration...

+
+
+ ); + } + + if (!config?.connected) { + return ( +
+
+ +

CTF Integration

+

+ Connect to a CTFd-based platform to pull challenges into your workspace. +

+ +
+
+ + setUrl(e.target.value)} + placeholder="https://ctf.example.com" + /> +
+ +
+ +
+ + +
+
+ + {authMethod === "credentials" ? ( + <> +
+ + setUsername(e.target.value)} + placeholder="Username" + /> +
+
+ + setPassword(e.target.value)} + placeholder="Password" + onKeyDown={(e) => e.key === "Enter" && handleConnect()} + /> +
+ + ) : ( +
+ + setApiToken(e.target.value)} + placeholder="ctfd_xxxxxxxxxxxxx" + onKeyDown={(e) => e.key === "Enter" && handleConnect()} + /> +
+ )} + + + + {syncing && syncProgress && ( + + )} +
+
+
+ ); + } + + const workspacePath = `~/pentest-workspace/${sanitizeDirName(config.ctfName)}`; + + return ( +
+
+
+

{config.ctfName}

+ Connected +
+
+ + +
+
+ +
+
+
+ Platform + {config.url} +
+
+ Auth + + {config.authMethod === "credentials" ? "Credentials" : "API Token"} + +
+
+ Workspace + {workspacePath} +
+
+ Last Synced + + {config.lastSynced + ? new Date(config.lastSynced).toLocaleString() + : "Never"} + +
+
+ + {syncing && syncProgress && ( + + )} + + {!syncing && syncResult && ( + + )} + + {!syncing && syncError && ( +
{syncError}
+ )} + +
+ Challenges are stored as folders on the attack box at{" "} + {workspacePath}. Each challenge has a{" "} + challenge.txt with the description and any attached files. +

+ Ask the copilot to ls {workspacePath} to see all challenges, + or pick a specific one to work on. +
+
+
+ ); +}; + +const SyncProgressPanel = ({ progress }) => { + const { phase, current, total, name, action } = progress; + const pct = total > 0 ? Math.round((current / total) * 100) : 0; + + const phaseLabel = phase === "fetch" ? "Fetching challenges" : "Syncing to workspace"; + + const actionIcon = + action === "new" ? "+" : + action === "updated" ? "~" : + action === "skipped" ? "-" : ""; + + const actionClass = + action === "new" ? styles.actionNew : + action === "updated" ? styles.actionUpdated : + action === "skipped" ? styles.actionSkipped : ""; + + return ( +
+
+ {phaseLabel} + + {current}/{total} + +
+
+
+
+ {name && ( +
+ {actionIcon && ( + + {actionIcon} + + )} + {name} +
+ )} +
+ ); +}; + +const SyncResultPanel = ({ result }) => { + const parts = []; + if (result.synced > 0) parts.push(`${result.synced} new`); + if (result.updated > 0) parts.push(`${result.updated} updated`); + if (result.skipped > 0) parts.push(`${result.skipped} unchanged`); + + return ( +
+ + {result.total} challenge{result.total !== 1 ? "s" : ""} processed + + {parts.length > 0 && ( + + {parts.join(" \u00b7 ")} + + )} +
+ ); +}; + +export default CTFPage; diff --git a/frontend/src/components/pages/session/sessionId/ChatInput.jsx b/frontend/src/components/pages/session/sessionId/ChatInput.jsx index e78f4f9..246b1ff 100644 --- a/frontend/src/components/pages/session/sessionId/ChatInput.jsx +++ b/frontend/src/components/pages/session/sessionId/ChatInput.jsx @@ -2,6 +2,7 @@ import React, { useRef, useState, useCallback, useMemo, useEffect } from "react" import styles from "@/styles/components/Chat.module.scss"; import { SendOutlined, PauseCircleOutlined, CloseOutlined } from "@ant-design/icons"; import { TbRadar } from "react-icons/tb"; +import { getCtfChallenges } from "@/services/ctf.service"; const SLASH_COMMANDS = [ { name: "summarize", description: "Summarize the entire session so far" }, @@ -12,9 +13,11 @@ const SLASH_COMMANDS = [ { name: "export", description: "Export findings as a structured report" }, { name: "shells", description: "List all shell sessions" }, { name: "reset", description: "Reset agent state to idle" }, + { name: "solve", description: "Focus on a CTF challenge" }, ]; export default function ChatInput({ + sessionId, onSend, onPause, agentState, @@ -27,6 +30,10 @@ export default function ChatInput({ const textareaRef = useRef(null); const menuRef = useRef(null); + const [challengeList, setChallengeList] = useState([]); + const [challengeSelectedIndex, setChallengeSelectedIndex] = useState(0); + const challengeFetchedRef = useRef(null); + const isRunning = agentState === "running"; const canSend = !isRunning && (value.trim().length > 0 || !!burpAttachment) && !disabled; @@ -41,10 +48,45 @@ export default function ChatInput({ const showMenu = slashMatches.length > 0 && !isRunning; + const isSolveArgMode = useMemo(() => { + const trimmed = value.trimStart().toLowerCase(); + return trimmed.startsWith("/solve ") && !isRunning; + }, [value, isRunning]); + + const challengeQuery = useMemo(() => { + if (!isSolveArgMode) return ""; + return value.trimStart().slice(7).replace(/^["']|["']$/g, "").trim().toLowerCase(); + }, [value, isSolveArgMode]); + + const filteredChallenges = useMemo(() => { + if (!isSolveArgMode || challengeList.length === 0) return []; + if (!challengeQuery) return challengeList; + return challengeList.filter( + (c) => + c.name.toLowerCase().includes(challengeQuery) || + c.category.toLowerCase().includes(challengeQuery), + ); + }, [isSolveArgMode, challengeList, challengeQuery]); + + const showChallengeMenu = filteredChallenges.length > 0 && isSolveArgMode; + + useEffect(() => { + if (!isSolveArgMode || !sessionId) return; + if (challengeFetchedRef.current === sessionId) return; + challengeFetchedRef.current = sessionId; + getCtfChallenges(sessionId) + .then((data) => setChallengeList(data.challenges || [])) + .catch(() => setChallengeList([])); + }, [isSolveArgMode, sessionId]); + useEffect(() => { setSelectedIndex(0); }, [slashMatches.length]); + useEffect(() => { + setChallengeSelectedIndex(0); + }, [filteredChallenges.length]); + const acceptCommand = useCallback( (cmd) => { setValue(`/${cmd.name} `); @@ -53,6 +95,23 @@ export default function ChatInput({ [], ); + const acceptChallenge = useCallback( + (ch, send) => { + const quoted = `/solve "${ch.name}" `; + if (send) { + setValue(`/solve "${ch.name}"`); + setTimeout(() => { + onSend(`/solve "${ch.name}"`); + setValue(""); + }, 0); + } else { + setValue(quoted); + textareaRef.current?.focus(); + } + }, + [onSend], + ); + const handleSend = useCallback(() => { if (!canSend) return; onSend(value.trim()); @@ -64,6 +123,40 @@ export default function ChatInput({ const handleKeyDown = useCallback( (e) => { + if (showChallengeMenu) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setChallengeSelectedIndex((prev) => + prev < filteredChallenges.length - 1 ? prev + 1 : 0, + ); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setChallengeSelectedIndex((prev) => + prev > 0 ? prev - 1 : filteredChallenges.length - 1, + ); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + const ch = filteredChallenges[challengeSelectedIndex]; + if (ch) acceptChallenge(ch, false); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + const ch = filteredChallenges[challengeSelectedIndex]; + if (ch) acceptChallenge(ch, true); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setValue(""); + return; + } + } + if (showMenu) { if (e.key === "ArrowDown") { e.preventDefault(); @@ -107,7 +200,7 @@ export default function ChatInput({ handleSend(); } }, - [handleSend, showMenu, slashMatches, selectedIndex, acceptCommand, onSend], + [handleSend, showMenu, showChallengeMenu, slashMatches, selectedIndex, filteredChallenges, challengeSelectedIndex, acceptCommand, acceptChallenge, onSend], ); const handleInput = useCallback(() => { @@ -170,6 +263,26 @@ export default function ChatInput({
)} + {showChallengeMenu && ( +
+
Challenges
+ {filteredChallenges.map((ch, i) => ( +
setChallengeSelectedIndex(i)} + onMouseDown={(e) => { + e.preventDefault(); + acceptChallenge(ch, true); + }} + > + {ch.name} + {ch.category} · {ch.value} pts +
+ ))} +
+ )} + {showMenu && (
Commands
diff --git a/frontend/src/components/pages/session/sessionId/ChatView.jsx b/frontend/src/components/pages/session/sessionId/ChatView.jsx index e1add74..f3f2a8d 100644 --- a/frontend/src/components/pages/session/sessionId/ChatView.jsx +++ b/frontend/src/components/pages/session/sessionId/ChatView.jsx @@ -33,6 +33,7 @@ export default function ChatView({ sessionId }) { pendingManualExecution, setPendingManualExecution, subagents, + setTokenUsage, startStream, abort, } = useAgentStream({ @@ -80,11 +81,12 @@ export default function ChatView({ sessionId }) { setAgentState("idle"); setPendingConsent(null); setPendingManualExecution(null); + setTokenUsage(null); abort(); }; window.addEventListener("context-cleared", handleContextCleared); return () => window.removeEventListener("context-cleared", handleContextCleared); - }, [sessionId, setMessages, setAgentState, setPendingConsent, setPendingManualExecution, abort]); + }, [sessionId, setMessages, setAgentState, setPendingConsent, setPendingManualExecution, setTokenUsage, abort]); const [burpAttachment, setBurpAttachment] = useState(null); @@ -259,6 +261,7 @@ export default function ChatView({ sessionId }) { )} { + const res = await apiClient.post(`/ctf/${sessionId}/connect`, body); + return res.data; +}; + +export const getCtfConfig = async (sessionId) => { + const res = await apiClient.get(`/ctf/${sessionId}/config`); + return res.data; +}; + +export const syncCtfStream = (sessionId, onEvent, onDone, onError) => { + const controller = new AbortController(); + + (async () => { + try { + const response = await fetch(`${apiBaseURL}/ctf/${sessionId}/sync`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.message || `Sync failed (${response.status})`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const event = JSON.parse(line.slice(6)); + onEvent(event); + } catch { + // skip malformed lines + } + } + } + + onDone?.(); + } catch (err) { + if (err.name !== "AbortError") { + onError?.(err); + } + } + })(); + + return () => controller.abort(); +}; + +export const getCtfChallenges = async (sessionId) => { + const res = await apiClient.get(`/ctf/${sessionId}/challenges`); + return res.data; +}; + +export const disconnectCtf = async (sessionId) => { + const res = await apiClient.post(`/ctf/${sessionId}/disconnect`); + return res.data; +}; diff --git a/frontend/src/styles/components/CTF.module.scss b/frontend/src/styles/components/CTF.module.scss new file mode 100644 index 0000000..c39be11 --- /dev/null +++ b/frontend/src/styles/components/CTF.module.scss @@ -0,0 +1,405 @@ +.ctfContainer { + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; + background: var(--primary-bg); + color: var(--primary-text); +} + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + padding: 3rem; + text-align: center; + gap: 0.75rem; + + h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--primary-text); + margin: 0; + } + + p { + font-size: 0.82rem; + color: var(--secondary-text); + max-width: 400px; + line-height: 1.5; + margin: 0; + } +} + +.emptyIcon { + font-size: 2.5rem; + color: var(--secondary-text); + opacity: 0.5; + margin-bottom: 0.5rem; +} + +.spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border-subtle); + border-top-color: var(--primary-text); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.connectForm { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + max-width: 380px; + margin-top: 1rem; +} + +.formGroup { + display: flex; + flex-direction: column; + gap: 0.3rem; + + label { + font-size: 0.72rem; + font-weight: 500; + color: var(--secondary-text); + text-transform: uppercase; + letter-spacing: 0.04em; + } +} + +.formInput { + background: var(--secondary-bg); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 0.55rem 0.75rem; + font-size: 0.82rem; + color: var(--primary-text); + outline: none; + transition: border-color 0.15s; + font-family: inherit; + + &::placeholder { + color: var(--secondary-text); + opacity: 0.5; + } + + &:focus { + border-color: var(--border-default); + } +} + +.authToggle { + display: flex; + border: 1px solid var(--border-subtle); + border-radius: 6px; + overflow: hidden; +} + +.authOption { + flex: 1; + padding: 0.45rem 0.75rem; + font-size: 0.75rem; + font-weight: 500; + text-align: center; + cursor: pointer; + color: var(--secondary-text); + background: transparent; + transition: all 0.15s; + border: none; + + &:hover { + color: var(--primary-text); + background: var(--surface-hover); + } +} + +.authOptionActive { + composes: authOption; + color: var(--primary-text); + background: var(--surface-active); +} + +.connectBtn { + margin-top: 0.5rem; +} + +// Header + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 2rem; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 0.75rem; + + h2 { + font-size: 1rem; + font-weight: 600; + margin: 0; + color: var(--primary-text); + } +} + +.ctfBadge { + font-size: 0.65rem; + font-weight: 600; + padding: 0.15rem 0.5rem; + border-radius: 10px; + background: rgba(126, 231, 135, 0.15); + color: #7ee787; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.headerRight { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.refreshBtn, +.disconnectBtn { + font-size: 0.75rem !important; + border-radius: 6px !important; +} + +.refreshBtn { + color: #7ee787 !important; + border-color: rgba(126, 231, 135, 0.4) !important; + background: rgba(126, 231, 135, 0.08) !important; + + &:hover { + border-color: #7ee787 !important; + background: rgba(126, 231, 135, 0.15) !important; + } + + &:disabled { + color: var(--secondary-text) !important; + border-color: var(--border-subtle) !important; + background: transparent !important; + } +} + +.disconnectBtn { + color: var(--secondary-text) !important; + border-color: var(--border-subtle) !important; + + &:hover { + color: #ff6b6b !important; + border-color: #ff6b6b !important; + } +} + +// Connected content + +.connectedContent { + padding: 2rem; + flex: 1; +} + +.infoCard { + background: var(--secondary-bg); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 1.25rem; + max-width: 520px; +} + +.infoRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--border-subtle); + } +} + +.infoLabel { + font-size: 0.75rem; + font-weight: 500; + color: var(--secondary-text); +} + +.infoValue { + font-size: 0.8rem; + font-weight: 500; + color: var(--primary-text); + font-family: "JetBrains Mono", monospace; +} + +.workspacePath { + composes: infoValue; + color: #7ee787; +} + +.helpText { + margin-top: 1.5rem; + font-size: 0.78rem; + color: var(--secondary-text); + line-height: 1.6; + max-width: 520px; + + code { + font-family: "JetBrains Mono", monospace; + font-size: 0.72rem; + background: var(--secondary-bg); + border: 1px solid var(--border-subtle); + padding: 0.1rem 0.35rem; + border-radius: 3px; + color: var(--primary-text); + } +} + +// Sync result banners + +.syncResult { + margin-top: 1rem; + padding: 0.75rem 1rem; + border-radius: 6px; + font-size: 0.78rem; + font-weight: 500; + max-width: 520px; +} + +.syncSuccess { + composes: syncResult; + background: rgba(126, 231, 135, 0.08); + border: 1px solid rgba(126, 231, 135, 0.2); + color: #7ee787; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.resultSummary { + font-weight: 600; + font-size: 0.8rem; +} + +.resultBreakdown { + font-size: 0.72rem; + opacity: 0.85; +} + +.syncError { + composes: syncResult; + background: rgba(255, 107, 107, 0.1); + border: 1px solid rgba(255, 107, 107, 0.2); + color: #ff6b6b; +} + +// Progress panel + +.progressPanel { + margin-top: 1rem; + background: var(--secondary-bg); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 1rem 1.25rem; + max-width: 520px; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.progressHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.6rem; +} + +.progressPhase { + font-size: 0.72rem; + font-weight: 600; + color: var(--primary-text); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.progressCount { + font-size: 0.72rem; + font-weight: 500; + color: var(--secondary-text); + font-family: "JetBrains Mono", monospace; +} + +.progressBarTrack { + width: 100%; + height: 4px; + border-radius: 2px; + background: var(--border-subtle); + overflow: hidden; +} + +.progressBarFill { + height: 100%; + border-radius: 2px; + background: #7ee787; + transition: width 0.25s ease; + min-width: 0; +} + +.progressCurrent { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.progressAction { + font-size: 0.7rem; + font-weight: 700; + font-family: "JetBrains Mono", monospace; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + flex-shrink: 0; +} + +.actionNew { + color: #7ee787; + background: rgba(126, 231, 135, 0.15); +} + +.actionUpdated { + color: #79c0ff; + background: rgba(121, 192, 255, 0.15); +} + +.actionSkipped { + color: var(--secondary-text); + background: var(--border-subtle); +} + +.progressName { + font-size: 0.78rem; + color: var(--primary-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} From a649581bb34509634f815016af219f0dcbc6f9a4 Mon Sep 17 00:00:00 2001 From: Jithin George Netticadan Date: Fri, 27 Mar 2026 23:47:58 +0530 Subject: [PATCH 12/12] Update run.ps1 --- run.ps1 | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/run.ps1 b/run.ps1 index c6af840..240d88a 100644 --- a/run.ps1 +++ b/run.ps1 @@ -208,7 +208,9 @@ function Check-Prerequisites { err "Docker is not installed. Install it from https://docs.docker.com/get-docker/" exit 1 } - info "Docker version: $((docker --version)[0])" + $v = @(docker --version)[0] + info "Docker version: $($v.Trim())" + if (docker compose version 2>$null) { $script:ComposeExe = "docker" @@ -356,8 +358,8 @@ function Select-LaunchMode { } function Detect-RunningMode { - $hasKali = docker ps --filter "name=kali" --format "{{.Id}}" | Select-Object -First 1 - $hasBackend = docker ps --filter "name=pentest-copilot-backend" --format "{{.Id}}" | Select-Object -First 1 + $hasKali = docker ps --filter "name=kali" --format "{{.ID}}" | Select-Object -First 1 + $hasBackend = docker ps --filter "name=pentest-copilot-backend" --format "{{.ID}}" | Select-Object -First 1 if ($hasKali -and $hasBackend) { Set-NormalKaliMode @@ -374,7 +376,7 @@ function Detect-RunningMode { info "Detected running: Developer mode (with Kali)" return $true } else { - $hasMongo = docker ps --filter "name=pentest-copilot-mongodb" --format "{{.Id}}" | Select-Object -First 1 + $hasMongo = docker ps --filter "name=pentest-copilot-mongodb" --format "{{.ID}}" | Select-Object -First 1 if ($hasMongo) { $script:DevMode = $true $script:ComposeFile = "docker-compose.dev.yml"