diff --git a/bin/NETRAVE b/bin/NETRAVE index 9bddb4e..ad78a4d 100644 --- a/bin/NETRAVE +++ b/bin/NETRAVE @@ -15,76 +15,88 @@ include Utilities # rubocop:disable Style/MixinUsage # binding.b(do: 'irb') @loggman = LoggMan.new -# Create .env file if it doesn't exist -File.open('.env', 'w') {} unless File.exist?('.env') +begin + # Create .env file if it doesn't exist + File.open('.env', 'w') {} unless File.exist?('.env') -# Load environment variables from .env file -Dotenv.load - -# Initialize AlertQueueManager, RingBuffer, and AlertManager -@alert_queue_manager = AlertQueueManager.new(@loggman) - -# Initialize DatabaseManager -db_manager = DatabaseManager.new(@loggman, @alert_queue_manager) - -# Get database details from environment variables -db_details = { - username: ENV['DB_USERNAME'], - password: ENV['DB_PASSWORD'], - key: ENV['DB_SECRET_KEY'], - database: ENV['DB_DATABASE'] -} - -# Decrypt password -dec_pass = decrypt_string_chacha20(db_details[:password], db_details[:key]) - -# If any of the necessary details are missing, run the first run setup -if db_details.values.any?(&:nil?) - @loggman.log_warn('Missing or incomplete configuration. Running first run setup.') - first_run_init = FirstRunInit.new(@loggman, @alert_queue_manager, db_manager) - first_run_init.run - # Reload environment variables after first run setup + # Load environment variables from .env file Dotenv.load + + # Initialize AlertQueueManager, RingBuffer, and AlertManager + @alert_queue_manager = AlertQueueManager.new(@loggman) + + # Initialize DatabaseManager + db_manager = DatabaseManager.new(@loggman, @alert_queue_manager) + + # Get database details from environment variables db_details = { username: ENV['DB_USERNAME'], password: ENV['DB_PASSWORD'], key: ENV['DB_SECRET_KEY'], database: ENV['DB_DATABASE'] } - # Decrypt password again after potentially updating config + + # Decrypt password dec_pass = decrypt_string_chacha20(db_details[:password], db_details[:key]) + + # If any of the necessary details are missing, run the first run setup + if db_details.values.any?(&:nil?) + @loggman.log_warn('Missing or incomplete configuration. Running first run setup.') + first_run_init = FirstRunInit.new(@loggman, @alert_queue_manager, db_manager) + first_run_init.run + # Reload environment variables after first run setup + Dotenv.load + db_details = { + username: ENV['DB_USERNAME'], + password: ENV['DB_PASSWORD'], + key: ENV['DB_SECRET_KEY'], + database: ENV['DB_DATABASE'] + } + # Decrypt password again after potentially updating config + dec_pass = decrypt_string_chacha20(db_details[:password], db_details[:key]) + end + + # Test connection + unless db_manager.test_db_connection(db_details[:username], dec_pass, db_details[:database]) + @loggman.log_warn('Failed to connect to the database with existing configuration. Please re-enter your details.') + first_run_init = FirstRunInit.new(@loggman, @alert_queue_manager, db_manager) + first_run_init.run + # Reload environment variables after potentially updating config + Dotenv.load + db_details = { + username: ENV['DB_USERNAME'], + password: ENV['DB_PASSWORD'], + key: ENV['DB_SECRET_KEY'], + database: ENV['DB_DATABASE'] + } + # Decrypt password again after potentially updating config + dec_pass = decrypt_string_chacha20(db_details[:password], db_details[:key]) + end + + # Test connection again after potentially updating config + if db_manager.test_db_connection(db_details[:username], dec_pass, db_details[:database]) + @loggman.log_info('Successfully connected to the database.') + else + @loggman.log_error('Failed to connect to the database. Please check your configuration.') + exit 1 + end + + @loggman.log_warn('Program successfully ran with no errors') + + # TODO: Add the rest of application logic here + + # End of the program + + # wait for the alert_queue_manager to block before we exit. + @alert_queue_manager.shutdown + + # Shush Rubocop. I know I shouldn't rescue an exception. I am just using it to log exceptions so the + # program doesn't crash silently +rescue Exception => e # rubocop:disable Lint/RescueException + # Log the exception + @loggman.log_error("An unhandled exception has occurred: #{e.class}: #{e.message}") + @loggman.log_error(e.backtrace.join("\n")) + + # Re-raise the original exception + raise end - -# Test connection -unless db_manager.test_db_connection(db_details[:username], dec_pass, db_details[:database]) - @loggman.log_warn('Failed to connect to the database with existing configuration. Please re-enter your details.') - first_run_init = FirstRunInit.new(@loggman, @alert_queue_manager, db_manager) - first_run_init.run - # Reload environment variables after potentially updating config - Dotenv.load - db_details = { - username: ENV['DB_USERNAME'], - password: ENV['DB_PASSWORD'], - key: ENV['DB_SECRET_KEY'], - database: ENV['DB_DATABASE'] - } - # Decrypt password again after potentially updating config - dec_pass = decrypt_string_chacha20(db_details[:password], db_details[:key]) -end - -# Test connection again after potentially updating config -if db_manager.test_db_connection(db_details[:username], dec_pass, db_details[:database]) - @loggman.log_info('Successfully connected to the database.') -else - @loggman.log_error('Failed to connect to the database. Please check your configuration.') - exit 1 -end - -@loggman.log_warn('Program successfully ran with no errors') - -# TODO: Add the rest of application logic here - -# End of the program - -# wait for the alert_queue_manager to block before we exit. -@alert_queue_manager.shutdown diff --git a/lib/Gemfile b/lib/Gemfile index 4a0537e..d00ac42 100644 --- a/lib/Gemfile +++ b/lib/Gemfile @@ -38,5 +38,3 @@ gem 'flay', '~> 2.13' gem 'pcaprub', '~> 0.13.1' gem 'packetfu', '~> 2.0' - -gem 'sudo', '~> 0.2.0' diff --git a/lib/Gemfile.lock b/lib/Gemfile.lock index d286094..19b0af1 100644 --- a/lib/Gemfile.lock +++ b/lib/Gemfile.lock @@ -2,18 +2,18 @@ GEM remote: https://rubygems.org/ specs: ast (2.4.2) - atk (4.1.7) - glib2 (= 4.1.7) + atk (4.1.8) + glib2 (= 4.1.8) backport (1.2.0) base64 (0.1.1) benchmark (0.2.1) - cairo (1.17.8) + cairo (1.17.12) native-package-installer (>= 1.0.3) pkg-config (>= 1.2.2) red-colors - cairo-gobject (4.1.7) + cairo-gobject (4.1.8) cairo (>= 1.16.2) - glib2 (= 4.1.7) + glib2 (= 4.1.8) console (1.17.2) fiber-annotation fiber-local @@ -32,23 +32,23 @@ GEM path_expander (~> 1.0) ruby_parser (~> 3.0) sexp_processor (~> 4.0) - gdk3 (4.1.7) - cairo-gobject (= 4.1.7) - gdk_pixbuf2 (= 4.1.7) - pango (= 4.1.7) - gdk_pixbuf2 (4.1.7) - gio2 (= 4.1.7) - gio2 (4.1.7) + gdk3 (4.1.8) + cairo-gobject (= 4.1.8) + gdk_pixbuf2 (= 4.1.8) + pango (= 4.1.8) + gdk_pixbuf2 (4.1.8) + gio2 (= 4.1.8) + gio2 (4.1.8) fiddle - gobject-introspection (= 4.1.7) - glib2 (4.1.7) + gobject-introspection (= 4.1.8) + glib2 (4.1.8) native-package-installer (>= 1.0.3) pkg-config (>= 1.3.5) - gobject-introspection (4.1.7) - glib2 (= 4.1.7) - gtk3 (4.1.7) - atk (= 4.1.7) - gdk3 (= 4.1.7) + gobject-introspection (4.1.8) + glib2 (= 4.1.8) + gtk3 (4.1.8) + atk (= 4.1.8) + gdk3 (= 4.1.8) jaro_winkler (1.5.6) json (2.6.3) kramdown (2.4.0) @@ -56,25 +56,26 @@ GEM kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) kwalify (0.7.2) + language_server-protocol (3.17.0.3) matrix (0.4.2) mysql2 (0.5.5) - native-package-installer (1.1.5) - nokogiri (1.15.2-x86_64-linux) + native-package-installer (1.1.8) + nokogiri (1.15.3-x86_64-linux) racc (~> 1.4) openssl (3.1.0) packetfu (2.0.0) pcaprub (~> 0.13.1) - pango (4.1.7) - cairo-gobject (= 4.1.7) - gobject-introspection (= 4.1.7) + pango (4.1.8) + cairo-gobject (= 4.1.8) + gobject-introspection (= 4.1.8) parallel (1.23.0) parser (3.2.2.3) ast (~> 2.4.1) racc path_expander (1.1.1) pcaprub (0.13.1) - pkg-config (1.5.1) - racc (1.7.0) + pkg-config (1.5.2) + racc (1.7.1) rainbow (3.1.1) rbs (2.8.4) red-colors (0.3.0) @@ -83,14 +84,15 @@ GEM kwalify (~> 0.7.0) parser (~> 3.2.0) rainbow (>= 2.0, < 4.0) - regexp_parser (2.8.0) + regexp_parser (2.8.1) reverse_markdown (2.1.1) nokogiri rexml (3.2.5) - rubocop (1.52.0) + rubocop (1.54.1) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.2.2.3) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) @@ -103,7 +105,7 @@ GEM ruby_parser (3.20.2) sexp_processor (~> 4.16) securerandom (0.2.2) - sequel (5.69.0) + sequel (5.70.0) sexp_processor (4.17.0) solargraph (0.49.0) backport (~> 1.2) @@ -121,7 +123,6 @@ GEM thor (~> 1.0) tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) - sudo (0.2.0) thor (1.2.2) tilt (2.2.0) tracer (0.2.2) @@ -149,7 +150,6 @@ DEPENDENCIES securerandom (~> 0.2.2) sequel (~> 5.69) solargraph (~> 0.49.0) - sudo (~> 0.2.0) tracer (~> 0.2.2) yaml (~> 0.2.1) diff --git a/lib/utils/alert_queue_manager.rb b/lib/utils/alert_queue_manager.rb index 6af23a3..36e8cb6 100644 --- a/lib/utils/alert_queue_manager.rb +++ b/lib/utils/alert_queue_manager.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative 'alert' +require_relative 'alert_manager' require_relative 'logg_man' # Class for managing the queue of alerts. This class also manages a little bit of concurrency diff --git a/lib/utils/first_run_init.rb b/lib/utils/first_run_init.rb index 958f162..a424bd2 100644 --- a/lib/utils/first_run_init.rb +++ b/lib/utils/first_run_init.rb @@ -7,6 +7,7 @@ require_relative 'database_manager' require_relative 'system_information_gather' require_relative 'utilities' require_relative 'alert_manager' +require_relative 'networking_genie' # first run class class FirstRunInit @@ -25,18 +26,18 @@ class FirstRunInit first_run_setup end - def first_run_setup # rubocop:disable Metrics/MethodLength, Metrics/AbcSize - db_details = ask_for_db_details + def first_run_setup # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + ask_for_db_details + dec_pass = decrypt_string_chacha20(ENV['DB_PASSWORD'], ENV['DB_SECRET_KEY']) + connection_established = @db_manager.test_db_connection(ENV['DB_USERNAME'], dec_pass.to_s, ENV['DB_DATABASE']) - enc_pass = ENV['DB_PASSWORD'] - enc_key = ENV['DB_SECRET_KEY'] - dec_pass = decrypt_string_chacha20(enc_pass, enc_key) - until @db_manager.test_db_connection(ENV['DB_USERNAME'], dec_pass.to_s, ENV['DB_DATABASE']) + until connection_established Curses.setpos(4, 0) - Curses.addstr("Whoops! We couldn't connect to the database with the details you provided. Please try again!") + alert = Alert.new("We couldn't connect to the database with the details you provided Please try again!", :warning) + @alert_queue_manager.enqueue_alert(alert) Curses.refresh - new_db_details = ask_for_db_details - db_details.merge!(new_db_details) # Update db_details with new details + ask_for_db_details + connection_established = @db_manager.test_db_connection(ENV['DB_USERNAME'], dec_pass.to_s, ENV['DB_DATABASE']) end @db_manager.create_system_info_table @@ -55,6 +56,15 @@ class FirstRunInit @db_manager.store_system_info(system_info) @db_manager.store_services(services) + + # ask for sudo permissions to setup the networking stuff we need + ask_for_sudo(@loggman) + + # Set up networking + networking_genie = NetworkingGenie.new(@loggman, @alert_queue_manager) + main_interface = networking_genie.find_main_interface + dummy_interface = networking_genie.create_dummy_interface('netrave0') + networking_genie.setup_traffic_mirroring(main_interface, dummy_interface) end def ask_for_db_details # rubocop:disable Metrics/MethodLength, Metrics/AbcSize @@ -64,8 +74,6 @@ class FirstRunInit Curses.setpos(1, 0) Curses.addstr('Please enter your database username: ') Curses.refresh - alert = Alert.new('This is a test alert', :error) - @alert_queue_manager.enqueue_alert(alert) username = DCI.catch_input(true) @loggman.log_info('Database Username entered!') diff --git a/lib/utils/logg_man.rb b/lib/utils/logg_man.rb index af35426..093041f 100644 --- a/lib/utils/logg_man.rb +++ b/lib/utils/logg_man.rb @@ -3,9 +3,20 @@ require 'logger' # LoggMan class for handling logs +# Yes I am aware that this is a strange thing to do, wrap the stdlib Logger like this... +# I like to call my logger with LoggMan, don't judge me. class LoggMan def initialize @logger = Logger.new('netrave.log') + @logger.formatter = proc do |severity, datetime, _progname, msg| + date_format = datetime.strftime('%Y-%m-%d %H:%M:%S') + if msg.is_a?(Exception) + backtrace = msg.backtrace.map { |line| "\n\t#{line}" }.join + "[#{severity}] (#{date_format}) #{msg.message} (#{msg.class})#{backtrace}" + else + "[#{severity}] (#{date_format}) #{msg}\n" + end + end end def log_info(message) diff --git a/lib/utils/networking_genie.rb b/lib/utils/networking_genie.rb index 9597410..ea0e1bf 100644 --- a/lib/utils/networking_genie.rb +++ b/lib/utils/networking_genie.rb @@ -3,12 +3,13 @@ require 'English' require 'socket' require_relative 'logg_man' -require_relative 'alert' -require_relative 'alert_queue_manager' +require_relative 'alert_manager' # The class for setting up all the necessary system networking stuff for NETRAVE to work with without # interferring with the rest of the system class NetworkingGenie + attr_accessor :main_interface, :dummy_interface + include Utilities def initialize(logger, alert_queue_manager) @@ -17,12 +18,16 @@ class NetworkingGenie end def find_main_interface # rubocop:disable Metrics/MethodLength + alert = Alert.new('Identifying the main network interface...', :info) + @alert_queue_manager.enqueue_alert(alert) @loggman.log_info('Identifying main network interface...') route_info = `routel`.split("\n") default_route = route_info.find { |line| line.include?('default') } if default_route main_interface = default_route.split.last - @loggman.log_info("Main network interface identified: #{main_interface}") + @loggman.log_info("Main network interface identified as: #{main_interface}") + alert = Alert.new("Main network interface identified as: #{main_interface}", :info) + @alert_queue_manager.enqueue_alert(alert) main_interface else @loggman.log_error('Failed to identify main network interface.') @@ -33,7 +38,9 @@ class NetworkingGenie nil end - def create_dummy_interface(interface_name = 'dummy0') + def create_dummy_interface(interface_name = 'netrave0') # rubocop:disable Metrics/MethodLength + alert = Alert.new('Creating the NETRAVE dummy interface...', :info) + @alert_queue_manager.enqueue_alert(alert) # Check if the dummy module is loaded use_sudo('modprobe dummy') @@ -41,7 +48,7 @@ class NetworkingGenie if `ip link show #{interface_name}`.empty? # Create the dummy interface use_sudo("ip link add #{interface_name} type dummy") - + @dummy_interface = interface_name # Set the interface up use_sudo("ip link set #{interface_name} up") else @@ -49,14 +56,17 @@ class NetworkingGenie alert = Alert.new("Interface #{interface_name} already exists.", :info) @alert_queue_manager.enqueue_alert(alert) end + + # Return the name of the dummy interface + interface_name end def setup_traffic_mirroring(main_interface, dummy_interface) # rubocop:disable Metrics/MethodLength commands = [ "tc qdisc del dev #{main_interface} ingress", "tc qdisc add dev #{main_interface} handle ffff: ingress", - "tc filter add dev #{main_interface} parent ffff: u32 match - u32 0 0 action mirred egress mirror dev #{dummy_interface}" + "tc filter add dev #{main_interface} parent ffff: u32 match " \ + "u32 0 0 action mirred egress mirror dev #{dummy_interface}" ] begin diff --git a/lib/utils/utilities.rb b/lib/utils/utilities.rb index c205b46..6362490 100644 --- a/lib/utils/utilities.rb +++ b/lib/utils/utilities.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true +require 'English' require 'securerandom' require 'digest' require 'base64' require 'openssl' -require 'sudo' +require 'pty' +require 'expect' # Utiltiies Module -module Utilities +module Utilities # rubocop:disable Metrics/ModuleLength # Converts speed from Gbps to Mbps if necessary def convert_speed_to_mbps(speed) return nil unless speed.is_a?(String) && speed.downcase.match?(/\A\d+(gbps|mbps)\z/i) @@ -75,17 +77,53 @@ module Utilities nil end - def ask_for_sudo(logger) + def ask_for_sudo(logger) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize @loggman = logger - @loggman.log_info('Asking for sudo password... (This log entry will be removed)') - Curses.addstr('Please enter your sudo password: ') + @loggman.log_info('Asking for sudo and explaining why...') + lines = [ + 'We require sudo permissions to complete certain steps.', + 'Granting a program sudo access is a significant decision.', + 'We treat your sudo password with the utmost care.', + 'We handle it "As a Bomb".', + '', + 'As soon as we receive your sudo password, it is encrypted.', + 'The unencrypted password is then wiped from the system.', + 'This includes the sudo cache.', + 'When we need to use your sudo password, we decrypt it.', + 'We use it, and then immediately wipe it again.', + 'We only ever store the encrypted version of your password.', + 'We delete even that as soon as we finish the operations.', + 'However, even with these precautions, there is always a risk.', + 'If an attacker were to gain access to this program,', + 'they could potentially decrypt your password.', + 'Therefore, you should only enter your sudo password', + 'if you fully understand and accept these risks.', + '', + '', + 'Please enter your sudo password ONLY if you understand', + 'and accept the risks described above: ' + ] + Curses.clear + lines.each_with_index do |line, index| + Curses.setpos(index, 0) # Move the cursor to the beginning of the next line + Curses.addstr(line) + end # use the dynamic curses input gem in secure mode to collect the sudo password sudo_password = DCI.catch_input(false) @loggman.log_info('Sudo password received. (This log entry will be removed)') + @loggman.log_info("Entered sudo password: #{sudo_password}") + + # Generate a new secret key + key = SecureRandom.random_bytes(32) # generates a random string of 32 bytes + + # encode the key for storage + @secret_key = Base64.encode64(key) # Encrypt the sudo password right away and store it in an environment variable encrypted_sudo_password = encrypt_string_chacha20(sudo_password, @secret_key) + @loggman.log_info("Encrypted sudo password: #{encrypted_sudo_password}") ENV['SPW'] = encrypted_sudo_password + ENV['SDSECRET_KEY'] = @secret_key # Clear the unencrypted sudo password from memory sudo_password.replace(' ' * sudo_password.length) @@ -96,7 +134,7 @@ module Utilities use_sudo('ls') true - rescue Sudo::Wrapper::InvalidPassword + rescue PTY::ChildExited false end @@ -104,32 +142,72 @@ module Utilities # Retrieve the encrypted sudo password from the environment variable encrypted_sudo_password = ENV['SPW'] + # Retrieve the secret key from the environment variable + @secret_key = ENV['SDSECRET_KEY'] + # Decrypt the sudo password sudo_password = decrypt_string_chacha20(encrypted_sudo_password, @secret_key) # Invalidate the user's cached credentials - Sudo::Wrapper.run('sudo -k', password: sudo_password) + PTY.spawn('sudo -S -k') do |r, w, _pid| + w.sync = true + r.expect(/password/) { w.puts sudo_password } + end # Clear the sudo password from memory sudo_password.replace(' ' * sudo_password.length) - # Remove the encrypted sudo password from the environment variables + # Remove the encrypted sudo password and the secret key from the environment variables ENV.delete('SPW') + ENV.delete('SECRET_KEY') end - def use_sudo(command) + def use_sudo(command) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + @secret_key = ENV['SDSECRET_KEY'] # Retrieve the encrypted sudo password from the environment variable encrypted_sudo_password = ENV['SPW'] # Decrypt the sudo password sudo_password = decrypt_string_chacha20(encrypted_sudo_password, @secret_key) + # this is only here for debugging + @loggman.log_info("Decrypted sudo password: #{sudo_password}") + + # Log the command + @loggman.log_info("Running command: #{command}") + # Use the sudo password to run the command - result = Sudo::Wrapper.run(command, password: sudo_password) + exit_status = nil + begin + Timeout.timeout(60) do + PTY.spawn("sudo -S #{command} 2>&1") do |r, w, pid| + w.sync = true + r.expect(/password/) { w.puts sudo_password } + while IO.select([r], nil, nil, 0.1) + output = r.read_nonblock(1000) + output.each_line { |line| @loggman.log_info("Command output: #{line.strip}") } + end + begin + Process.wait(pid) + exit_status = $CHILD_STATUS.exitstatus + ensure + # Clear the sudo password from memory + sudo_password.replace(' ' * sudo_password.length) + end + end + end + rescue Timeout::Error + @loggman.log_error("Command '#{command}' timed out") + rescue Errno::EIO + # This error is expected when the process has finished + end - # Clear the sudo password from memory - sudo_password.replace(' ' * sudo_password.length) + if exit_status&.zero? + @loggman.log_info("Command '#{command}' completed successfully") + else + @loggman.log_error("Command '#{command}' failed with exit status #{exit_status}") + end - result + exit_status end end