diff --git a/.bundle/config b/.bundle/config
deleted file mode 100644
index 2865427..0000000
--- a/.bundle/config
+++ /dev/null
@@ -1,3 +0,0 @@
----
-BUNDLE_PATH: "vendor/bundle"
-BUNDLE_WITH: "test"
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..3d6d57d
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,10 @@
+
+- [x] audit-current-gemfile-and-usage: Inspect current Gemfile/Gemfile.lock and code usage that depends on pinned gems 🔴
+- [x] implement-rubyzip3-resolution: Decouple winrm-fs or point to compatible fork to allow rubyzip >= 3 🔴
+- [x] update-gemfile-and-warning-shim: Modernize Gemfile constraints and harden silence_warnings layer 🔴
+- [x] regenerate-lockfile-linux-bundler410: Generate Gemfile.lock with x86_64-linux platform and BUNDLED WITH 4.0.10 🔴
+- [x] deliver-migration-script-and-summary: Provide updated Gemfile content and safe migration shell script 🟡
+
+
+
+
diff --git a/.github/instructions/todos.instructions.md b/.github/instructions/todos.instructions.md
new file mode 100644
index 0000000..446e360
--- /dev/null
+++ b/.github/instructions/todos.instructions.md
@@ -0,0 +1,13 @@
+---
+applyTo: '**'
+---
+
+
+- [x] scope-review-targets: Identify critical files and review scope for bug/performance audit 🔴
+- [x] inspect-runtime-paths: Review core runtime modules for logic bugs and inefficiencies 🔴
+- [x] run-validation: Run available tests/commands to confirm suspected issues 🟡
+- [x] report-findings: Summarize findings by severity with file/line references 🔴
+
+
+
+
diff --git a/.gitignore b/.gitignore
index 1e3a7f1..59c1ac7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,13 @@
# Ignore Ruby, Bundler, and system files
/vendor/
+/.bundle/
/tmp/
*.log
*.swp
*.swo
*.DS_Store
/.vscode/
+/log/
/loot/
/profiles/*.yaml
diff --git a/Gemfile b/Gemfile
index 32a3367..ac36801 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,20 +1,26 @@
source 'https://rubygems.org'
-gem 'winrm', '~> 2.3'
-gem 'socksify', '~> 1.8'
-gem 'winrm-fs', '~> 1.3'
-gem 'colorize', '~> 0.8'
-gem 'concurrent-ruby', '~> 1.2'
-gem 'rubyzip', '~> 2.0'
-gem 'logging', '~> 2.4'
-gem 'nori', '~> 2.7'
-gem 'gssapi', '~> 1.3'
-
-# Bundler itself
-gem 'bundler', '>= 2.4', '< 3.0'
+gem 'winrm', '>= 2.3.9'
+gem 'socksify', '>= 1.8'
+gem 'colorize', '>= 0.8'
+gem 'concurrent-ruby', '>= 1.2'
+gem 'rubyzip', '>= 3.0'
+gem 'logging', '>= 2.4'
+gem 'nori', '>= 2.7'
+gem 'gssapi', '>= 1.3.1'
+gem 'ffi', '>= 1.17.4'
+gem 'readline', '>= 0.0.4'
+gem 'syslog', '>= 0.1.2'
+gem 'ostruct', '>= 0.6.0'
# Test/dev
group :test do
- gem 'rspec', '~> 3.12'
- gem 'mocha', '~> 1.15'
+ gem 'rspec', '>= 3.12'
+ gem 'mocha', '>= 1.15'
+end
+
+group :development do
+ gem 'tty-prompt'
+ gem 'tty-table'
+ gem 'tty-screen'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 1db0d34..3c955fe 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -2,13 +2,23 @@ GEM
remote: https://rubygems.org/
specs:
base64 (0.3.0)
- bigdecimal (4.0.1)
+ bigdecimal (4.1.2)
builder (3.3.0)
- colorize (0.8.1)
+ colorize (1.1.0)
concurrent-ruby (1.3.6)
diff-lcs (1.6.2)
erubi (1.13.1)
- ffi (1.17.2-x86_64-linux-gnu)
+ ffi (1.17.4)
+ ffi (1.17.4-aarch64-linux-gnu)
+ ffi (1.17.4-aarch64-linux-musl)
+ ffi (1.17.4-arm-linux-gnu)
+ ffi (1.17.4-arm-linux-musl)
+ ffi (1.17.4-arm64-darwin)
+ ffi (1.17.4-x86-linux-gnu)
+ ffi (1.17.4-x86-linux-musl)
+ ffi (1.17.4-x86_64-darwin)
+ ffi (1.17.4-x86_64-linux-gnu)
+ ffi (1.17.4-x86_64-linux-musl)
gssapi (1.3.1)
ffi (>= 1.0.1)
gyoku (1.4.0)
@@ -16,15 +26,25 @@ GEM
rexml (~> 3.0)
httpclient (2.9.0)
mutex_m
+ io-console (0.8.2)
little-plugger (1.1.4)
+ logger (1.7.0)
logging (2.4.0)
little-plugger (~> 1.1)
multi_json (~> 1.14)
- mocha (1.16.1)
- multi_json (1.18.0)
+ mocha (3.1.0)
+ ruby2_keywords (>= 0.0.5)
+ multi_json (1.20.1)
mutex_m (0.3.0)
nori (2.7.1)
bigdecimal
+ ostruct (0.6.3)
+ pastel (0.8.0)
+ tty-color (~> 0.5)
+ readline (0.0.4)
+ reline
+ reline (0.6.3)
+ io-console (~> 0.5)
rexml (3.4.4)
rspec (3.13.2)
rspec-core (~> 3.13.0)
@@ -35,14 +55,38 @@ GEM
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-mocks (3.13.7)
+ rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-support (3.13.6)
+ rspec-support (3.13.7)
+ ruby2_keywords (0.0.5)
rubyntlm (0.6.5)
base64
- rubyzip (2.4.1)
+ rubyzip (3.2.2)
socksify (1.8.1)
+ strings (0.2.1)
+ strings-ansi (~> 0.2)
+ unicode-display_width (>= 1.5, < 3.0)
+ unicode_utils (~> 1.4)
+ strings-ansi (0.2.0)
+ syslog (0.4.0)
+ logger
+ tty-color (0.6.0)
+ tty-cursor (0.7.1)
+ tty-prompt (0.23.1)
+ pastel (~> 0.8)
+ tty-reader (~> 0.8)
+ tty-reader (0.9.0)
+ tty-cursor (~> 0.7)
+ tty-screen (~> 0.8)
+ wisper (~> 2.0)
+ tty-screen (0.8.2)
+ tty-table (0.12.0)
+ pastel (~> 0.8)
+ strings (~> 0.2.0)
+ tty-screen (~> 0.8)
+ unicode-display_width (2.6.0)
+ unicode_utils (1.4.0)
winrm (2.3.9)
builder (>= 2.1.2)
erubi (~> 1.8)
@@ -53,28 +97,98 @@ GEM
nori (~> 2.0, >= 2.7.1)
rexml (~> 3.0)
rubyntlm (~> 0.6.0, >= 0.6.3)
- winrm-fs (1.3.5)
- erubi (~> 1.8)
- logging (>= 1.6.1, < 3.0)
- rubyzip (~> 2.0)
- winrm (~> 2.0)
+ wisper (2.0.1)
PLATFORMS
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ ruby
+ x86-linux-gnu
+ x86-linux-musl
+ x86_64-darwin
+ x86_64-linux
x86_64-linux-gnu
+ x86_64-linux-musl
DEPENDENCIES
- bundler (>= 2.4, < 3.0)
- colorize (~> 0.8)
- concurrent-ruby (~> 1.2)
- gssapi (~> 1.3)
- logging (~> 2.4)
- mocha (~> 1.15)
- nori (~> 2.7)
- rspec (~> 3.12)
- rubyzip (~> 2.0)
- socksify (~> 1.8)
- winrm (~> 2.3)
- winrm-fs (~> 1.3)
+ colorize (>= 0.8)
+ concurrent-ruby (>= 1.2)
+ ffi (>= 1.17.4)
+ gssapi (>= 1.3.1)
+ logging (>= 2.4)
+ mocha (>= 1.15)
+ nori (>= 2.7)
+ ostruct (>= 0.6.0)
+ readline (>= 0.0.4)
+ rspec (>= 3.12)
+ rubyzip (>= 3.0)
+ socksify (>= 1.8)
+ syslog (>= 0.1.2)
+ tty-prompt
+ tty-screen
+ tty-table
+ winrm (>= 2.3.9)
+
+CHECKSUMS
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+ bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
+ builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
+ colorize (1.1.0) sha256=30b5237f0603f6662ab8d1fc2bd4a96142b806c6415d79e45ef5fdc6a0cfc837
+ concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
+ diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
+ erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
+ ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf
+ ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
+ ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
+ ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
+ ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95
+ ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
+ ffi (1.17.4-x86-linux-gnu) sha256=38e150df5f4ca555e25beca4090823ae09657bceded154e3c52f8631c1ed72cf
+ ffi (1.17.4-x86-linux-musl) sha256=fbeec0fc7c795bcf86f623bb18d31ea1820f7bd580e1703a3d3740d527437809
+ ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
+ ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
+ ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
+ gssapi (1.3.1) sha256=c51cf30842ee39bd93ce7fc33e20405ff8a04cda9dec6092071b61258284aee1
+ gyoku (1.4.0) sha256=389d887384c777f271cb9377bb642f20bbe0c633d1ef5af78569d4db53c1a2cd
+ httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
+ io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
+ little-plugger (1.1.4) sha256=d5f347c00d9d648040ef7c17d6eb09d3d0719adf19ca30d1a3b6fb26d0a631bb
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ logging (2.4.0) sha256=ba8893a3c211b836f4131bb93b3eb3137a0c3b1fcd0ec3d570e324d8bdc00ccb
+ mocha (3.1.0) sha256=75f42d69ebfb1f10b32489dff8f8431d37a418120ecdfc07afe3bc183d4e1d56
+ multi_json (1.20.1) sha256=2f3934e805cc45ef91b551a1f89d0e9191abd06a5e04a2ef09a6a036c452ca6d
+ mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
+ nori (2.7.1) sha256=6166cd336959854762073e2fbae888593809cac1b3e904f4fb009313d7226861
+ ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
+ pastel (0.8.0) sha256=481da9fb7d2f6e6b1a08faf11fa10363172dc40fd47848f096ae21209f805a75
+ readline (0.0.4) sha256=6138eef17be2b98298b672c3ea63bf9cb5158d401324f26e1e84f235879c1d6a
+ reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
+ rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
+ rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
+ rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
+ rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
+ rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
+ rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
+ ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
+ rubyntlm (0.6.5) sha256=47013402b99ae29ee93f930af51edaec8c6008556f4be25705a422b4430314f5
+ rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
+ socksify (1.8.1) sha256=cf2a01720cc32490cc657b3233730620a03b92e98281726872ebebedfea9a856
+ strings (0.2.1) sha256=933293b3c95cf85b81eb44b3cf673e3087661ba739bbadfeadf442083158d6fb
+ strings-ansi (0.2.0) sha256=90262d760ea4a94cc2ae8d58205277a343409c288cbe7c29416b1826bd511c88
+ syslog (0.4.0) sha256=c4c38ae982fe72903ec41094b5e5f2dcbbc66f510d0225c9702e5e980d827472
+ tty-color (0.6.0) sha256=6f9c37ca3a4e2367fb2e6d09722762647d6f455c111f05b59f35730eeb24332a
+ tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
+ tty-prompt (0.23.1) sha256=fcdbce905238993f27eecfdf67597a636bc839d92192f6a0eef22b8166449ec8
+ tty-reader (0.9.0) sha256=c62972c985c0b1566f0e56743b6a7882f979d3dc32ff491ed490a076f899c2b1
+ tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
+ tty-table (0.12.0) sha256=fdc27a4750835c1a16efe19a0b857e3ced3652cc7aceafe6dca94908965b9939
+ unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
+ unicode_utils (1.4.0) sha256=b922d0cf2313b6b7136ada6645ce7154ffc86418ca07d53b058efe9eb72f2a40
+ winrm (2.3.9) sha256=ef6b767c5772d06e186300b506ea5e65afb849904a551f8482a5cfc2a1be5d06
+ wisper (2.0.1) sha256=ce17bc5c3a166f241a2e6613848b025c8146fce2defba505920c1d1f3f88fae6
BUNDLED WITH
- 2.4.20
+ 4.0.10
diff --git a/bin/evil-ctf.rb b/bin/evil-ctf.rb
index 93499ad..ebb8e38 100755
--- a/bin/evil-ctf.rb
+++ b/bin/evil-ctf.rb
@@ -4,6 +4,7 @@
# AWINRM CTF Edition
require 'optparse'
+require_relative '../lib/compat/silence_warnings'
require 'winrm'
require 'ipaddr'
require 'socket'
@@ -41,6 +42,16 @@ module EvilCTF; end
lib_path = File.join(base_path, 'lib')
$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
+# Auto-setup Bundler when the user requested the TUI so gems from
+# `vendor/bundle` are available even when running with plain `ruby`.
+if ARGV.any? { |a| a.to_s.start_with?('--tui') || a.to_s == '--tui' }
+ begin
+ require 'bundler/setup'
+ rescue LoadError
+ # bundler not available system-wide; user can run with `bundle exec` instead
+ end
+end
+
# Load modular components
require 'evil_ctf/session'
diff --git a/evil-ctf b/evil-ctf
new file mode 100755
index 0000000..6a61db0
--- /dev/null
+++ b/evil-ctf
@@ -0,0 +1,12 @@
+#!/bin/bash
+# Wrapper script to automatically use bundle exec with proper sudo handling
+
+# Check if running with sudo (check SUDO_USER or run without sudo)
+if [ -z "$SUDO_USER" ]; then
+ # Not running with sudo - check if we need it (for /etc/hosts modification)
+ # Just run with bundle exec
+ exec bundle exec ruby "$(dirname "${BASH_SOURCE[0]}")/evil-ctf.rb" "$@"
+else
+ # Running with sudo - preserve environment and use bundle exec
+ exec bundle exec ruby "$(dirname "${BASH_SOURCE[0]}")/evil-ctf.rb" "$@"
+fi
diff --git a/lib/compat/silence_warnings.rb b/lib/compat/silence_warnings.rb
new file mode 100644
index 0000000..db1f672
--- /dev/null
+++ b/lib/compat/silence_warnings.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+# Ruby 4.0 compatibility helper:
+# suppresses only known noisy winrm warnings while leaving all other warnings intact.
+module EvilCTF
+ module Compat
+ module SilenceWarnings
+ WINRM_OBJECT_ID_WARNING = /winrm\/psrp\/(fragment|message_fragmenter)\.rb:\d+: warning: redefining 'object_id' may cause serious problems/
+ WINRM_REDEFINE_WARNING = /winrm\/psrp\/.*warning: redefining 'object_id' may cause serious problems/
+
+ module WarningFilter
+ def warn(message, category: nil, **kwargs)
+ return if message.to_s.match?(WINRM_OBJECT_ID_WARNING)
+
+ super(message, category: category, **kwargs)
+ rescue ArgumentError
+ # Older Ruby warning signatures may not support category/kwargs.
+ super(message)
+ end
+ end
+
+ def self.enable!
+ return unless defined?(Warning)
+ return if @enabled
+
+ if Warning.respond_to?(:ignore)
+ Warning.ignore(WINRM_OBJECT_ID_WARNING)
+ Warning.ignore(WINRM_REDEFINE_WARNING)
+ else
+ Warning.singleton_class.prepend(WarningFilter)
+ end
+ @enabled = true
+ end
+ end
+ end
+end
+
+EvilCTF::Compat::SilenceWarnings.enable!
\ No newline at end of file
diff --git a/lib/config/profiles.rb b/lib/config/profiles.rb
new file mode 100644
index 0000000..90fa9de
--- /dev/null
+++ b/lib/config/profiles.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'yaml'
+
+module EvilCTF
+ module Config
+ module Profiles
+ module_function
+
+ def root_path(default)
+ default || File.expand_path('../..', __dir__)
+ end
+
+ def load_profiles(root_path: nil)
+ root = self.root_path(root_path)
+ profile_file = File.join(root, 'config', 'profiles.yaml')
+ return {} unless File.exist?(profile_file)
+
+ data = YAML.load_file(profile_file)
+ data.is_a?(Hash) ? symbolize_hash(data) : {}
+ rescue StandardError
+ {}
+ end
+
+ def load_profile(name:, root_path: nil)
+ return nil if name.to_s.strip.empty?
+
+ root = self.root_path(root_path)
+ local_file = File.join(root, 'profiles', "#{name}.yaml")
+ if File.exist?(local_file)
+ data = YAML.load_file(local_file)
+ if data.is_a?(Hash)
+ # If the file is wrapped in a top-level profile key, prefer that object.
+ nested = data[name.to_s] || data[name.to_sym]
+ return symbolize_hash(nested) if nested.is_a?(Hash)
+ return symbolize_hash(data)
+ end
+ end
+
+ profiles = load_profiles(root_path: root)
+ profile = profiles[name.to_sym] || profiles[name.to_s.to_sym]
+ profile.is_a?(Hash) ? symbolize_hash(profile) : nil
+ rescue StandardError
+ nil
+ end
+
+ def profile_names(root_path: nil)
+ root = self.root_path(root_path)
+ names = []
+
+ profiles_dir = File.join(root, 'profiles')
+ if Dir.exist?(profiles_dir)
+ Dir.glob(File.join(profiles_dir, '*.yaml')).sort.each do |path|
+ names << File.basename(path, '.yaml')
+ end
+ end
+
+ names.concat(load_profiles(root_path: root).keys.map(&:to_s))
+ names.uniq.sort
+ end
+
+ def symbolize_hash(hash)
+ hash.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
+ end
+ end
+ end
+end
diff --git a/lib/evil_ctf/app_state.rb b/lib/evil_ctf/app_state.rb
new file mode 100644
index 0000000..a17009c
--- /dev/null
+++ b/lib/evil_ctf/app_state.rb
@@ -0,0 +1,211 @@
+require 'thread'
+
+module EvilCTF
+ class AppState
+ def self.instance
+ @instance ||= new
+ end
+
+ def initialize
+ @mutex = Mutex.new
+ @sessions = []
+ @active_session = nil
+ @running_tasks = {}
+ @alerts = []
+ @last_scan_time = nil
+ @mode = :NORMAL
+ @results_buffer = []
+ @stream_buffer = []
+ @uploads = {}
+ @cli_input = ''
+ @cli_history = []
+ @menu_open = nil
+ @screen_width = 100
+ @screen_height = 30
+ @pane_focus = :sidebar
+ @layout_version = 0
+ @pending_connection = {}
+ @settings = {
+ logging_enabled: false,
+ theme: 'default',
+ scrollback_limit: 300
+ }
+ end
+
+ attr_reader :mutex
+
+ def sessions
+ @mutex.synchronize { @sessions.dup }
+ end
+
+ def add_session(s)
+ @mutex.synchronize { @sessions << s }
+ end
+
+ def active_session
+ @mutex.synchronize { @active_session }
+ end
+
+ def set_active_session(s)
+ @mutex.synchronize { @active_session = s }
+ end
+
+ # Alias for controller APIs that refer to current session.
+ def current_session
+ active_session
+ end
+
+ def set_current_session(s)
+ set_active_session(s)
+ end
+
+ def running_tasks
+ @mutex.synchronize { @running_tasks.dup }
+ end
+
+ def add_task(id, info)
+ @mutex.synchronize { @running_tasks[id] = info }
+ end
+
+ def remove_task(id)
+ @mutex.synchronize { @running_tasks.delete(id) }
+ end
+
+ def alerts
+ @mutex.synchronize { @alerts.dup }
+ end
+
+ def push_alert(a)
+ @mutex.synchronize { @alerts << a }
+ end
+
+ def last_scan_time
+ @mutex.synchronize { @last_scan_time }
+ end
+
+ def set_last_scan_time(t)
+ @mutex.synchronize { @last_scan_time = t }
+ end
+
+ def mode
+ @mutex.synchronize { @mode }
+ end
+
+ def set_mode(m)
+ @mutex.synchronize { @mode = m }
+ end
+
+ # CLI input / history helpers for interactive pane
+ def cli_input
+ @mutex.synchronize { @cli_input.dup }
+ end
+
+ def set_cli_input(val)
+ @mutex.synchronize { @cli_input = val.to_s }
+ end
+
+ def append_cli_history(val)
+ @mutex.synchronize do
+ @cli_history << val.to_s
+ @cli_history.shift while @cli_history.size > 200
+ end
+ end
+
+ def cli_history_snapshot
+ @mutex.synchronize { @cli_history.dup }
+ end
+
+ def menu_open
+ @mutex.synchronize { @menu_open }
+ end
+
+ def set_menu_open(sym)
+ @mutex.synchronize { @menu_open = sym }
+ end
+
+ def pane_focus
+ @mutex.synchronize { @pane_focus }
+ end
+
+ def set_pane_focus(focus)
+ @mutex.synchronize { @pane_focus = focus }
+ end
+
+ def screen_size
+ @mutex.synchronize { [@screen_width, @screen_height] }
+ end
+
+ def set_screen_size(width, height)
+ @mutex.synchronize do
+ @screen_width = width.to_i
+ @screen_height = height.to_i
+ end
+ end
+
+ def layout_version
+ @mutex.synchronize { @layout_version }
+ end
+
+ def bump_layout_version
+ @mutex.synchronize { @layout_version += 1 }
+ end
+
+ def append_result(line)
+ @mutex.synchronize do
+ @results_buffer << line.to_s
+ @results_buffer.shift while @results_buffer.size > 1000
+ end
+ end
+
+ def results_snapshot
+ @mutex.synchronize { @results_buffer.dup }
+ end
+
+ def append_stream(line)
+ @mutex.synchronize do
+ @stream_buffer << line.to_s
+ limit = [@settings[:scrollback_limit].to_i, 50].max
+ @stream_buffer.shift while @stream_buffer.size > limit
+ end
+ end
+
+ def stream_snapshot
+ @mutex.synchronize { @stream_buffer.dup }
+ end
+
+ def uploads
+ @mutex.synchronize { @uploads.dup }
+ end
+
+ def set_upload(id, info)
+ @mutex.synchronize { @uploads[id] = info }
+ end
+
+ def clear_upload(id)
+ @mutex.synchronize { @uploads.delete(id) }
+ end
+
+ def settings
+ @mutex.synchronize { @settings.dup }
+ end
+
+ def set_setting(key, value)
+ @mutex.synchronize { @settings[key.to_sym] = value }
+ end
+
+ def toggle_setting(key)
+ @mutex.synchronize do
+ sym = key.to_sym
+ @settings[sym] = !@settings[sym]
+ end
+ end
+
+ def pending_connection
+ @mutex.synchronize { @pending_connection.dup }
+ end
+
+ def set_pending_connection(data)
+ @mutex.synchronize { @pending_connection = (data || {}).dup }
+ end
+ end
+end
diff --git a/lib/evil_ctf/banner.rb b/lib/evil_ctf/banner.rb
index c661f27..5a4b63b 100644
--- a/lib/evil_ctf/banner.rb
+++ b/lib/evil_ctf/banner.rb
@@ -62,6 +62,23 @@ def self.risk_text(score)
# Show banner with optional color
def self.show_banner(shell, options, mode: :minimal, no_color: false)
+ # If the user requested the TUI, attempt to launch it and return.
+ if options && (options[:tui] || options[:use_tui] || ENV['EVILCTF_TUI'] == '1')
+ begin
+ require 'evil_ctf/tui'
+ # Prefer the interactive Rainfrog-like loop when available
+ if EvilCTF::TUI.respond_to?(:start_rainfrog)
+ EvilCTF::TUI.start_rainfrog(shell, options)
+ else
+ EvilCTF::TUI.start(shell, options)
+ end
+ rescue LoadError
+ puts " [!] TTY TUI not available. Install tty gems to enable TUI.".yellow
+ rescue => e
+ puts " [!] Failed to start TUI: #{e.message}".red
+ end
+ return
+ end
# Disable color if requested
if no_color
# Use plain text
diff --git a/lib/evil_ctf/cli.rb b/lib/evil_ctf/cli.rb
index 60b24d2..5ec3d3f 100644
--- a/lib/evil_ctf/cli.rb
+++ b/lib/evil_ctf/cli.rb
@@ -3,6 +3,8 @@
require 'optparse'
require_relative 'session'
+require_relative 'connection'
+require_relative '../config/profiles'
module EvilCTF
module CLI
@@ -16,7 +18,8 @@ def self.run(argv)
list_tools: false, enum: nil, fresh: false, hosts: nil,
kerberos: false, realm: nil, keytab: nil,
banner_mode: :minimal, debug: false,
- ipv6: nil, ipv6_hostname: nil
+ ipv6: nil, ipv6_hostname: nil,
+ verify: true
}
parser = OptionParser.new do |opts|
opts.banner = 'Usage: evil-ctf.rb [options]'
@@ -53,9 +56,11 @@ def self.run(argv)
opts.on('--realm REALM', 'Kerberos realm') { |v| options[:realm] = v }
opts.on('--keytab FILE', 'Kerberos keytab') { |v| options[:keytab] = v }
opts.on('--banner MODE', 'Banner mode (minimal|expanded)') { |v| options[:banner_mode] = v&.to_sym }
+ opts.on('--tui', 'Launch interactive TTY-based UI (uses tty gems)') { options[:tui] = true }
opts.on('--user-agent AGENT', 'Custom User-Agent for WinRM HTTP requests') { |v| options[:user_agent] = v }
opts.on('--log-session', 'Enable session logging to disk (log/ directory)') { options[:log_session] = true }
opts.on('--debug', 'Enable WinRM debug output (passes debug:true to WinRM client)') { options[:debug] = true }
+ opts.on('--no-verify', 'Skip connection validation') { options[:verify] = false }
opts.on('-h', '--help', 'Show help') { puts opts; exit 0 }
end
@@ -72,16 +77,10 @@ def self.run(argv)
# Profile loading: merge profile if --profile is given
if options[:profile]
- prof = nil
- # Try profiles/NAME.yaml first, then config/profiles.yaml
- prof_path1 = File.expand_path("../../profiles/#{options[:profile]}.yaml", __dir__)
- prof_path2 = File.expand_path("../../config/profiles.yaml", __dir__)
- if File.exist?(prof_path1)
- prof = YAML.load_file(prof_path1)
- elsif File.exist?(prof_path2)
- all_profiles = YAML.load_file(prof_path2)
- prof = all_profiles[options[:profile].to_s] if all_profiles
- end
+ prof = EvilCTF::Config::Profiles.load_profile(
+ name: options[:profile],
+ root_path: File.expand_path('../..', __dir__)
+ )
if prof
# Accept all keys from profile, including username, user, password, hash, port, ssl, etc.
options.merge!(prof.transform_keys(&:to_sym))
@@ -99,7 +98,41 @@ def self.run(argv)
return 1
end
- Session.run_session(options)
+ # Connection validation before session
+ if options[:verify]
+ endpoint = options[:endpoint] || "#{options[:ssl] ? 'https' : 'http'}://#{options[:ip]}:#{options[:port] || 5985}/wsman"
+ validation = EvilCTF::Session.test_connection(
+ endpoint: endpoint,
+ user: options[:user],
+ password: options[:password],
+ hash: options[:hash],
+ kerberos: options[:kerberos],
+ realm: options[:realm],
+ keytab: options[:keytab],
+ ssl: options[:ssl],
+ debug: options[:debug],
+ transport: options[:transport],
+ user_agent: options[:user_agent],
+ timeout: 10
+ )
+ unless validation[:ok]
+ puts "[!] Connection validation failed: #{validation[:error]}"
+ puts validation[:report] if validation[:report]
+ exit 1
+ end
+ puts "[+] Connection validated: #{validation[:hostname]}"
+ end
+
+ result = Session.run_session(options)
+
+ # Check for validation failure from session
+ if result.is_a?(Array) && !result[0]
+ puts "[!] Session validation failed: #{result[1]}" if result[1]
+ exit 1
+ elsif !result
+ puts "[!] Session failed"
+ exit 1
+ end
0
end
end
diff --git a/lib/evil_ctf/command_dispatcher.rb b/lib/evil_ctf/command_dispatcher.rb
new file mode 100644
index 0000000..758856f
--- /dev/null
+++ b/lib/evil_ctf/command_dispatcher.rb
@@ -0,0 +1,557 @@
+# frozen_string_literal: true
+
+require 'monitor'
+require 'ostruct'
+require_relative 'tools'
+require_relative 'execution'
+require_relative 'uploader'
+require_relative 'enums'
+require_relative 'sql_enum'
+
+module EvilCTF
+ # Dispatcher for handling commands in the EvilCTF session.
+ # Replaces the large case statement in session.rb with a handler-based approach.
+ class CommandDispatcher
+ def self.instance
+ @instance ||= new
+ end
+
+ def self.dispatch(name:, args: nil, shell:, session_options:, command_manager: nil, history: nil)
+ instance.dispatch(
+ name: name,
+ args: args,
+ shell: shell,
+ session_options: session_options,
+ command_manager: command_manager,
+ history: history
+ )
+ end
+
+ attr_reader :handlers
+
+ def initialize
+ @mutex = Monitor.new
+ @handlers = {}
+ @pass_through = true # Default: pass unknown commands through
+
+ # Pre-register all handlers
+ register_core_commands
+ end
+
+ def register(name, &block)
+ @mutex.synchronize do
+ @handlers[name] = block
+ end
+ end
+
+ def unregister(name)
+ @mutex.synchronize do
+ @handlers.delete(name)
+ end
+ end
+
+ # Dispatch a command and return a result hash.
+ # Returns:
+ # { ok: true, output: "