diff --git a/bin/ssh_scan b/bin/ssh_scan index 281cbe3c..3f2e4866 100755 --- a/bin/ssh_scan +++ b/bin/ssh_scan @@ -261,8 +261,7 @@ puts JSON.pretty_generate(results) if options["unit_test"] == true results.each do |result| - if result["compliance"] && - result["compliance"][:compliant] == false + if result.compliant == false exit 1 #non-zero means a false else exit 0 #non-zero means pass diff --git a/lib/ssh_scan.rb b/lib/ssh_scan.rb index 947fb635..0312dece 100644 --- a/lib/ssh_scan.rb +++ b/lib/ssh_scan.rb @@ -14,6 +14,7 @@ require 'ssh_scan/update' require 'ssh_scan/fingerprint_database' require 'ssh_scan/grader' +require 'ssh_scan/result' #Monkey Patches require 'string_ext' diff --git a/lib/ssh_scan/banner.rb b/lib/ssh_scan/banner.rb index 636d6b73..b9720554 100644 --- a/lib/ssh_scan/banner.rb +++ b/lib/ssh_scan/banner.rb @@ -21,8 +21,8 @@ def self.read(string) # or "SSH-number" then return the number, else return # "unknown" def ssh_version() - if version = @string.match(/SSH-(\d+[\.\d+]+)/)[1] - return version.to_f + if match = @string.match(/SSH-(\d+[\.\d+]+)/) + return match[1].to_f else return "unknown" end diff --git a/lib/ssh_scan/client.rb b/lib/ssh_scan/client.rb index a0620a7f..8f6db21a 100644 --- a/lib/ssh_scan/client.rb +++ b/lib/ssh_scan/client.rb @@ -6,8 +6,8 @@ module SSHScan class Client - def initialize(target, port, timeout = 3) - @target = target + def initialize(ip, port, timeout = 3) + @ip = ip @timeout = timeout @port = port @@ -16,9 +16,23 @@ def initialize(target, port, timeout = 3) @kex_init_raw = SSHScan::Constants::DEFAULT_KEY_INIT.to_binary_s end + def ip + @ip + end + + def port + @port + end + + def banner + @server_banner + end + def connect() + @error = nil + begin - @sock = Socket.tcp(@target, @port, connect_timeout: @timeout) + @sock = Socket.tcp(@ip, @port, connect_timeout: @timeout) rescue SocketError => e @error = SSHScan::Error::ConnectionRefused.new(e.message) @sock = nil @@ -56,58 +70,53 @@ def connect() end end - def get_kex_result(kex_init_raw = @kex_init_raw) - # Common options for all cases - result = {} - result[:ssh_scan_version] = SSHScan::VERSION - result[:ip] = @target - result[:port] = @port + def error? + !@error.nil? + end + + def error + @error + end + def get_kex_result(kex_init_raw = @kex_init_raw) if !@sock - result[:error] = @error - return result + @error = "Socket is no longer valid" + return nil end - # Assemble and print results - result[:server_banner] = @server_banner.to_s - result[:ssh_version] = @server_banner.ssh_version - result[:os] = @server_banner.os_guess.common - result[:os_cpe] = @server_banner.os_guess.cpe - result[:ssh_lib] = @server_banner.ssh_lib_guess.common - result[:ssh_lib_cpe] = @server_banner.ssh_lib_guess.cpe - begin @sock.write(kex_init_raw) resp = @sock.read(4) if resp.nil? - result[:error] = SSHScan::Error::NoKexResponse.new( + @error = SSHScan::Error::NoKexResponse.new( "service did not respond to our kex init request" ) @sock = nil - return result + return nil end resp += @sock.read(resp.unpack("N").first) @sock.close kex_exchange_init = SSHScan::KeyExchangeInit.read(resp) - result.merge!(kex_exchange_init.to_hash) rescue Errno::ETIMEDOUT => e @error = SSHScan::Error::ConnectTimeout.new(e.message) @sock = nil + return nil rescue Errno::ECONNREFUSED, Errno::ENETUNREACH, Errno::ECONNRESET, Errno::EACCES, Errno::EHOSTUNREACH - result[:error] = SSHScan::Error::NoKexResponse.new( + @error = SSHScan::Error::NoKexResponse.new( "service did not respond to our kex init request" ) @sock = nil + return nil end - return result + return kex_exchange_init.to_hash end end end diff --git a/lib/ssh_scan/policy_manager.rb b/lib/ssh_scan/policy_manager.rb index 53a04bbb..64d6cbf8 100644 --- a/lib/ssh_scan/policy_manager.rb +++ b/lib/ssh_scan/policy_manager.rb @@ -9,8 +9,8 @@ def initialize(result, policy) def out_of_policy_encryption return [] if @policy.encryption.empty? target_encryption = - @result[:encryption_algorithms_client_to_server] | - @result[:encryption_algorithms_server_to_client] + @result.encryption_algorithms_client_to_server | + @result.encryption_algorithms_server_to_client outliers = [] target_encryption.each do |target_enc| outliers << target_enc unless @policy.encryption.include?(target_enc) @@ -21,8 +21,8 @@ def out_of_policy_encryption def missing_policy_encryption return [] if @policy.encryption.empty? target_encryption = - @result[:encryption_algorithms_client_to_server] | - @result[:encryption_algorithms_server_to_client] + @result.encryption_algorithms_client_to_server | + @result.encryption_algorithms_server_to_client outliers = [] @policy.encryption.each do |encryption| if target_encryption.include?(encryption) == false @@ -35,8 +35,8 @@ def missing_policy_encryption def out_of_policy_macs return [] if @policy.macs.empty? target_macs = - @result[:mac_algorithms_server_to_client] | - @result[:mac_algorithms_client_to_server] + @result.mac_algorithms_server_to_client | + @result.mac_algorithms_client_to_server outliers = [] target_macs.each do |target_mac| outliers << target_mac unless @policy.macs.include?(target_mac) @@ -47,8 +47,8 @@ def out_of_policy_macs def missing_policy_macs return [] if @policy.macs.empty? target_macs = - @result[:mac_algorithms_server_to_client] | - @result[:mac_algorithms_client_to_server] + @result.mac_algorithms_server_to_client | + @result.mac_algorithms_client_to_server outliers = [] @policy.macs.each do |mac| @@ -61,7 +61,7 @@ def missing_policy_macs def out_of_policy_kex return [] if @policy.kex.empty? - target_kexs = @result[:key_algorithms] + target_kexs = @result.key_algorithms outliers = [] target_kexs.each do |target_kex| outliers << target_kex unless @policy.kex.include?(target_kex) @@ -71,7 +71,7 @@ def out_of_policy_kex def missing_policy_kex return [] if @policy.kex.empty? - target_kex = @result[:key_algorithms] + target_kex = @result.key_algorithms outliers = [] @policy.kex.each do |kex| @@ -85,8 +85,8 @@ def missing_policy_kex def out_of_policy_compression return [] if @policy.compression.empty? target_compressions = - @result[:compression_algorithms_server_to_client] | - @result[:compression_algorithms_client_to_server] + @result.compression_algorithms_server_to_client | + @result.compression_algorithms_client_to_server outliers = [] target_compressions.each do |target_compression| outliers << target_compression unless @@ -98,8 +98,8 @@ def out_of_policy_compression def missing_policy_compression return [] if @policy.compression.empty? target_compressions = - @result[:compression_algorithms_server_to_client] | - @result[:compression_algorithms_client_to_server] + @result.compression_algorithms_server_to_client | + @result.compression_algorithms_client_to_server outliers = [] @policy.compression.each do |compression| @@ -112,8 +112,8 @@ def missing_policy_compression def out_of_policy_auth_methods return [] if @policy.auth_methods.empty? - return [] if @result["auth_methods"].nil? - target_auth_methods = @result["auth_methods"] + return [] if @result.auth_methods.empty? + target_auth_methods = @result.auth_methods outliers = [] if not @policy.auth_methods.empty? @@ -128,7 +128,7 @@ def out_of_policy_auth_methods def out_of_policy_ssh_version return false if @policy.ssh_version.nil? - target_ssh_version = @result[:ssh_version] + target_ssh_version = @result.ssh_version if @policy.ssh_version if target_ssh_version < @policy.ssh_version return true diff --git a/lib/ssh_scan/result.rb b/lib/ssh_scan/result.rb new file mode 100644 index 00000000..1b979481 --- /dev/null +++ b/lib/ssh_scan/result.rb @@ -0,0 +1,304 @@ +require 'json' +require 'ssh_scan/banner' +require 'ipaddr' +require 'string_ext' +require 'set' + +module SSHScan + class Result + def initialize() + @version = SSHScan::VERSION + @fingerprints = nil + @duplicate_host_key_ips = Set.new() + end + + def version + @version + end + + def ip + @ip + end + + def ip=(ip) + unless ip.is_a?(String) && ip.ip_addr? + raise ArgumentError, "Invalid attempt to set IP to a non-IP address value" + end + + @ip = ip + end + + def port + @port + end + + def port=(port) + unless port.is_a?(Integer) && port > 0 && port <= 65535 + raise ArgumentError, "Invalid attempt to set port to a non-port value" + end + + @port = port + end + + def banner() + @banner || SSHScan::Banner.new("") + end + + def hostname=(hostname) + @hostname = hostname + end + + def hostname() + @hostname || "" + end + + def banner=(banner) + unless banner.is_a?(SSHScan::Banner) + raise ArgumentError, "Invalid attempt to set banner with a non-banner object" + end + + @banner = banner + end + + def ssh_version=(ssh_version) + unless ssh_version.is_a?(Float) + raise ArgumentError, "Invalid attempt to set ssh_version to a non-port value" + end + + @ssh_version = ssh_version + end + + def ssh_version + @ssh_version + end + + def os_guess_common + self.banner.os_guess.common + end + + def os_guess_cpe + self.banner.os_guess.cpe + end + + def ssh_lib_guess_common + self.banner.ssh_lib_guess.common + end + + def ssh_lib_guess_cpe + self.banner.ssh_lib_guess.cpe + end + + def cookie + @cookie || "" + end + + def key_algorithms + @hex_result_hash ? @hex_result_hash[:key_algorithms] : [] + end + + def server_host_key_algorithms + @hex_result_hash ? @hex_result_hash[:server_host_key_algorithms] : [] + end + + def encryption_algorithms_client_to_server + @hex_result_hash ? @hex_result_hash[:encryption_algorithms_client_to_server] : [] + end + + def encryption_algorithms_server_to_client + @hex_result_hash ? @hex_result_hash[:encryption_algorithms_server_to_client] : [] + end + + def mac_algorithms_client_to_server + @hex_result_hash ? @hex_result_hash[:mac_algorithms_client_to_server] : [] + end + + def mac_algorithms_server_to_client + @hex_result_hash ? @hex_result_hash[:mac_algorithms_server_to_client] : [] + end + + def compression_algorithms_client_to_server + @hex_result_hash ? @hex_result_hash[:compression_algorithms_client_to_server] : [] + end + + def compression_algorithms_server_to_client + @hex_result_hash ? @hex_result_hash[:compression_algorithms_server_to_client] : [] + end + + def languages_client_to_server + @hex_result_hash ? @hex_result_hash[:languages_client_to_server] : [] + end + + def languages_server_to_client + @hex_result_hash ? @hex_result_hash[:languages_server_to_client] : [] + end + + def set_kex_result(kex_result) + @hex_result_hash = kex_result.to_hash + end + + def set_auth_methods(auth_methods) + @auth_methods = auth_methods + end + + def set_start_time + @start_time = Time.now + end + + def start_time + @start_time + end + + def set_end_time + @end_time = Time.now + end + + def scan_duration + if start_time.nil? + raise "Cannot calculate scan duration without start_time set" + end + + if end_time.nil? + raise "Cannot calculate scan duration without end_time set" + end + + end_time - start_time + end + + def end_time + @end_time + end + + def auth_methods=(auth_methods) + @auth_methods = auth_methods + end + + def fingerprints=(fingerprints) + @fingerprints = fingerprints + end + + def fingerprints + @fingerprints + end + + def duplicate_host_key_ips=(duplicate_host_key_ips) + @duplicate_host_key_ips = duplicate_host_key_ips + end + + def duplicate_host_key_ips + @duplicate_host_key_ips + end + + def auth_methods() + @auth_methods || [] + end + + def set_compliance(compliance) + @compliance = compliance + end + + def compliance_policy=(policy) + @compliance_policy = policy + end + + def compliance_policy + @compliance_policy + end + + def compliant=(compliance_status) + @compliance_status = compliance_status + end + + def compliant? + @compliance_status + end + + def set_client_attributes(client) + self.ip = client.ip + self.port = client.port || 22 + self.banner = client.banner || SSHScan::Banner.new("") + end + + def recommendations=(recommendations) + @compliance_recommendations = recommendations + end + + def references=(references) + @compliance_references = references + end + + def recommendations + @compliance_recommendations + end + + def references + @compliance_references + end + + def error=(error) + @error = error.to_s + end + + def error? + !@error.nil? + end + + def error + @error + end + + def grade=(grade) + @compliance_grade = grade + end + + def grade + @compliance_grade + end + + def to_hash + hashed_object = { + "ssh_scan_version" => self.version, + "ip" => self.ip, + "hostname" => self.hostname, + "port" => self.port, + "server_banner" => self.banner.to_s, + "ssh_version" => self.ssh_version, + "os" => self.os_guess_common, + "os_cpe" => self.os_guess_cpe, + "ssh_lib" => self.ssh_lib_guess_common, + "ssh_lib_cpe" => self.ssh_lib_guess_cpe, + "key_algorithms" => self.key_algorithms, + "encryption_algorithms_client_to_server" => self.encryption_algorithms_client_to_server, + "encryption_algorithms_server_to_client" => self.encryption_algorithms_server_to_client, + "mac_algorithms_client_to_server" => self.mac_algorithms_client_to_server, + "mac_algorithms_server_to_client" => self.mac_algorithms_server_to_client, + "compression_algorithms_client_to_server" => self.compression_algorithms_client_to_server, + "compression_algorithms_server_to_client" => self.compression_algorithms_server_to_client, + "languages_client_to_server" => self.languages_client_to_server, + "languages_server_to_client" => self.languages_server_to_client, + "auth_methods" => self.auth_methods, + "fingerprints" => self.fingerprints, + "duplicate_host_key_ips" => self.duplicate_host_key_ips, + "compliance" => { + "policy" => self.compliance_policy, + "compliant" => self.compliant?, + "recommendations" => self.recommendations, + "references" => self.references, + "grade" => self.grade + }, + "start_time" => self.start_time, + "end_time" => self.end_time, + "scan_duration_seconds" => self.scan_duration, + } + + if self.error? + hashed_object.error = self.error + end + + hashed_object + end + + def to_json + self.to_hash.to_json + end + end +end \ No newline at end of file diff --git a/lib/ssh_scan/scan_engine.rb b/lib/ssh_scan/scan_engine.rb index 792d1c63..15e2d537 100644 --- a/lib/ssh_scan/scan_engine.rb +++ b/lib/ssh_scan/scan_engine.rb @@ -4,6 +4,7 @@ #require 'ssh_scan/fingerprint_database' require 'net/ssh' require 'logger' +require 'open3' module SSHScan # Handle scanning of targets. @@ -19,41 +20,65 @@ def scan_target(socket, opts) port = 22 end timeout = opts["timeout"] - result = [] + + result = SSHScan::Result.new() + result.port = port - start_time = Time.now + # Start the scan timer + result.set_start_time if target.fqdn? + result.hostname = target + + # If doesn't resolve as IPv6, we'll try IPv4 if target.resolve_fqdn_as_ipv6.nil? client = SSHScan::Client.new( target.resolve_fqdn_as_ipv4.to_s, port, timeout ) client.connect() - result = client.get_kex_result() - result[:hostname] = target - return result if result.include?(:error) + result.set_client_attributes(client) + kex_result = client.get_kex_result() + result.set_kex_result(kex_result) unless kex_result.nil? + result.error = client.error if client.error? + # If it does resolve as IPv6, we're try IPv6 else client = SSHScan::Client.new( target.resolve_fqdn_as_ipv6.to_s, port, timeout ) client.connect() - result = client.get_kex_result() - if result.include?(:error) + result.set_client_attributes(client) + kex_result = client.get_kex_result() + result.set_kex_result(kex_result) unless kex_result.nil? + result.error = client.error if client.error? + + # If resolves as IPv6, but somehow we get an client error, fall-back to IPv4 + if result.error? client = SSHScan::Client.new( target.resolve_fqdn_as_ipv4.to_s, port, timeout ) client.connect() - result = client.get_kex_result() - result[:hostname] = target - return result if result.include?(:error) + result.set_client_attributes(client) + kex_result = client.get_kex_result() + result.set_kex_result(kex_result) unless kex_result.nil? + result.error = client.error if client.error? end end else client = SSHScan::Client.new(target, port, timeout) client.connect() - result = client.get_kex_result() - result[:hostname] = target.resolve_ptr - return result if result.include?(:error) + result.set_client_attributes(client) + kex_result = client.get_kex_result() + result.set_kex_result(kex_result) + + # Attempt to suppliment a hostname that wasn't provided + result.hostname = target.resolve_ptr + + result.error = client.error if client.error? + end + + if result.error? + result.set_end_time + return result end # Connect and get results (Net-SSH) @@ -69,57 +94,54 @@ def scan_target(socket, opts) net_ssh_session, :auth_methods => ["none"] ) auth_session.authenticate("none", "test", "test") - result['auth_methods'] = auth_session.allowed_auth_methods + result.auth_methods = auth_session.allowed_auth_methods net_ssh_session.close rescue Net::SSH::ConnectionTimeout => e - result[:error] = e - result[:error] = SSHScan::Error::ConnectTimeout.new(e.message) + result.error = SSHScan::Error::ConnectTimeout.new(e.message) rescue Net::SSH::Disconnect => e - result[:error] = e - result[:error] = SSHScan::Error::Disconnected.new(e.message) + result.error = SSHScan::Error::Disconnected.new(e.message) rescue Net::SSH::Exception => e if e.to_s.match(/could not settle on/) - result[:error] = e + result.error = e else raise e end - else - result['fingerprints'] = {} - host_keys = `ssh-keyscan -t rsa,dsa #{target} 2>/dev/null`.split - host_keys_len = host_keys.length - 1 - - for i in 0..host_keys_len - if host_keys[i].eql? "ssh-dss" - pkey = SSHScan::Crypto::PublicKey.new(host_keys[i + 1]) - result['fingerprints'].merge!({ - "dsa" => { - "known_bad" => pkey.bad_key?.to_s, - "md5" => pkey.fingerprint_md5, - "sha1" => pkey.fingerprint_sha1, - "sha256" => pkey.fingerprint_sha256, - } - }) - end + end - if host_keys[i].eql? "ssh-rsa" - pkey = SSHScan::Crypto::PublicKey.new(host_keys[i + 1]) - result['fingerprints'].merge!({ - "rsa" => { - "known_bad" => pkey.bad_key?.to_s, - "md5" => pkey.fingerprint_md5, - "sha1" => pkey.fingerprint_sha1, - "sha256" => pkey.fingerprint_sha256, - } - }) - end + # Figure out what rsa or dsa fingerprints exist + fingerprints = {} + + host_keys = `ssh-keyscan -t rsa,dsa #{target} 2>/dev/null`.split + host_keys_len = host_keys.length - 1 + + for i in 0..host_keys_len + if host_keys[i].eql? "ssh-dss" + pkey = SSHScan::Crypto::PublicKey.new(host_keys[i + 1]) + fingerprints.merge!({ + "dsa" => { + "known_bad" => pkey.bad_key?.to_s, + "md5" => pkey.fingerprint_md5, + "sha1" => pkey.fingerprint_sha1, + "sha256" => pkey.fingerprint_sha256, + } + }) + end + + if host_keys[i].eql? "ssh-rsa" + pkey = SSHScan::Crypto::PublicKey.new(host_keys[i + 1]) + fingerprints.merge!({ + "rsa" => { + "known_bad" => pkey.bad_key?.to_s, + "md5" => pkey.fingerprint_md5, + "sha1" => pkey.fingerprint_sha1, + "sha256" => pkey.fingerprint_sha256, + } + }) end end - # Add scan times - end_time = Time.now - result['start_time'] = start_time.to_s - result['end_time'] = end_time.to_s - result['scan_duration_seconds'] = end_time - start_time + result.fingerprints = fingerprints + result.set_end_time return result end @@ -156,13 +178,14 @@ def scan(opts) opts['fingerprint_database'] ) results.each do |result| - fingerprint_db.clear_fingerprints(result[:ip]) - if result['fingerprints'] - result['fingerprints'].values.each do |host_key_algo| + fingerprint_db.clear_fingerprints(result.ip) + + if result.fingerprints + result.fingerprints.values.each do |host_key_algo| host_key_algo.each do |fingerprint| key, value = fingerprint next if key == "known_bad" - fingerprint_db.add_fingerprint(value, result[:ip]) + fingerprint_db.add_fingerprint(value, result.ip) end end end @@ -170,20 +193,20 @@ def scan(opts) # Decorate all the results with duplicate keys results.each do |result| - if result['fingerprints'] - ip = result[:ip] - result['duplicate_host_key_ips'] = [] - result['fingerprints'].values.each do |host_key_algo| + if result.fingerprints + ip = result.ip + result.duplicate_host_key_ips = [] + result.fingerprints.values.each do |host_key_algo| host_key_algo.each do |fingerprint| key, value = fingerprint next if key == "known_bad" fingerprint_db.find_fingerprints(value).each do |other_ip| next if ip == other_ip - result['duplicate_host_key_ips'] << other_ip + result.duplicate_host_key_ips << other_ip end end end - result['duplicate_host_key_ips'].uniq! + result.duplicate_host_key_ips end end @@ -191,32 +214,36 @@ def scan(opts) results.each do |result| # Do this only when we have all the information we need if !opts["policy"].nil? && - !result[:key_algorithms].nil? && - !result[:server_host_key_algorithms].nil? && - !result[:encryption_algorithms_client_to_server].nil? && - !result[:encryption_algorithms_server_to_client].nil? && - !result[:mac_algorithms_client_to_server].nil? && - !result[:mac_algorithms_server_to_client].nil? && - !result[:compression_algorithms_client_to_server].nil? && - !result[:compression_algorithms_server_to_client].nil? && - !result[:languages_client_to_server].nil? && - !result[:languages_server_to_client].nil? + !result.key_algorithms.empty? && + !result.server_host_key_algorithms.empty? && + !result.encryption_algorithms_client_to_server.empty? && + !result.encryption_algorithms_server_to_client.empty? && + !result.mac_algorithms_client_to_server.empty? && + !result.mac_algorithms_server_to_client.empty? && + !result.compression_algorithms_client_to_server.empty? && + !result.compression_algorithms_server_to_client.empty? && + !result.languages_client_to_server.empty? && + !result.languages_server_to_client.empty? policy = SSHScan::Policy.from_file(opts["policy"]) policy_mgr = SSHScan::PolicyManager.new(result, policy) - result['compliance'] = policy_mgr.compliance_results + compliance_results = policy_mgr.compliance_results + result.compliance_policy = compliance_results[:policy] + result.compliant = compliance_results[:compliant] + result.recommendations = compliance_results[:recommendations] + result.references = compliance_results[:references] end end # Decorate complaince results with a grade results.each do |result| - if result['compliance'] + if result.compliance_policy grader = SSHScan::Grader.new(result) - result['compliance'][:grade] = grader.grade + result.grade = grader.grade end end - return results + return results.map {|r| r.to_hash} end end end diff --git a/spec/ssh_scan/result_spec.rb b/spec/ssh_scan/result_spec.rb new file mode 100644 index 00000000..b20bec8c --- /dev/null +++ b/spec/ssh_scan/result_spec.rb @@ -0,0 +1,197 @@ +require 'spec_helper' +require 'rspec' +require 'ssh_scan/result' + +describe SSHScan::Result do + it "should have sane defaults" do + result = SSHScan::Result.new() + + expect(result).to be_kind_of(SSHScan::Result) + expect(result.version).to eql(SSHScan::VERSION) + expect(result.ip).to be_nil + expect(result.port).to be_nil + expect(result.banner).to be_kind_of(SSHScan::Banner) + expect(result.banner.to_s).to eql("") + expect(result.hostname).to eql("") + expect(result.ssh_version).to eql(nil) + expect(result.os_guess_common).to eql("unknown") + expect(result.os_guess_cpe).to eql("o:unknown") + expect(result.ssh_lib_guess_common).to eql("unknown") + expect(result.ssh_lib_guess_cpe).to eql("a:unknown") + expect(result.cookie).to eql("") + expect(result.key_algorithms).to eql([]) + expect(result.server_host_key_algorithms).to eql([]) + expect(result.encryption_algorithms_client_to_server).to eql([]) + expect(result.encryption_algorithms_server_to_client).to eql([]) + expect(result.mac_algorithms_client_to_server).to eql([]) + expect(result.mac_algorithms_server_to_client).to eql([]) + expect(result.compression_algorithms_client_to_server).to eql([]) + expect(result.compression_algorithms_server_to_client).to eql([]) + expect(result.languages_client_to_server).to eql([]) + expect(result.languages_server_to_client).to eql([]) + expect(result.start_time).to be_nil + expect(result.end_time).to be_nil + expect{ result.scan_duration }.to raise_error( + RuntimeError, + "Cannot calculate scan duration without start_time set" + ) + end + + context "when setting IP" do + it "should allow setting result.ip" do + result = SSHScan::Result.new() + expect(result.ip).to be_nil + + result.ip = "192.168.1.1" + expect(result.ip).to eql("192.168.1.1") + end + + it "should prevent setting result.ip to invalid values" do + result = SSHScan::Result.new() + expect(result.ip).to be_nil + + invalid_inputs = [ + "hello", + 123, + "192.168.10.265" + ] + + invalid_inputs.each do |invalid_input| + expect { result.ip = invalid_input}.to raise_error( + ArgumentError, + "Invalid attempt to set IP to a non-IP address value" + ) + expect(result.ip).to be_nil + end + end + end + + context "when setting Port" do + it "should allow setting result.port" do + result = SSHScan::Result.new() + expect(result.port).to be_nil + + result.port = 31337 + expect(result.port).to eql(31337) + end + + it "should prevent setting result.port to invalid values" do + result = SSHScan::Result.new() + expect(result.port).to be_nil + + invalid_inputs = [ + 65537, + -1, + "", + "22" + ] + + invalid_inputs.each do |invalid_input| + expect { result.port = invalid_input}.to raise_error( + ArgumentError, + "Invalid attempt to set port to a non-port value" + ) + expect(result.ip).to be_nil + end + end + end + + context "when setting banner" do + it "should allow setting result.banner" do + banner = SSHScan::Banner.new("This is my SSH Banner") + result = SSHScan::Result.new() + expect(result.banner).to be_kind_of(SSHScan::Banner) + expect(result.banner.to_s).to eql("") + + result.banner = banner + expect(result.banner).to be_kind_of(SSHScan::Banner) + expect(result.banner.to_s).to eql("This is my SSH Banner") + end + + it "should prevent setting result.banner to invalid values" do + result = SSHScan::Result.new() + expect(result.banner).to be_kind_of(SSHScan::Banner) + expect(result.banner.to_s).to eql("") + + invalid_inputs = [ + 65537, + -1, + "", + "22", + ] + + invalid_inputs.each do |invalid_input| + expect { result.banner = invalid_input}.to raise_error( + ArgumentError, + "Invalid attempt to set banner with a non-banner object" + ) + expect(result.banner).to be_kind_of(SSHScan::Banner) + expect(result.banner.to_s).to eql("") + end + end + end + + context "when setting ssh_version" do + it "should allow setting result.ssh_version" do + result = SSHScan::Result.new() + expect(result.ssh_version).to be_nil + + result.ssh_version = 2.0 + expect(result.ssh_version).to be_kind_of(Float) + end + + it "should prevent setting result.ssh_version to invalid values" do + result = SSHScan::Result.new() + expect(result.ssh_version).to be_nil + + invalid_inputs = [ + 65537, + -1, + "", + "22", + ] + + invalid_inputs.each do |invalid_input| + expect { result.ssh_version = invalid_input}.to raise_error( + ArgumentError, + "Invalid attempt to set ssh_version to a non-port value" + ) + expect(result.ssh_version).to be_nil + end + end + end + + context "when setting hostname" do + it "should allow setting result.hostname" do + result = SSHScan::Result.new() + expect(result.hostname).to eql("") + + result.hostname = "bananas.example.com" + expect(result.hostname).to eql("bananas.example.com") + end + end + + context "when exporting the Result object to different Objects" do + it "should translate the result into a valid hash" do + result = SSHScan::Result.new() + result.set_start_time + result.set_end_time + + result_hash = result.to_hash + expect(result_hash).to be_kind_of(Hash) + end + + it "should translate the result into a valid JSON string" do + result = SSHScan::Result.new() + result.set_start_time + result.set_end_time + + result_json_string = result.to_json + expect(result_json_string).to be_kind_of(String) + + # Make sure we're generating valid JSON documents + expect(JSON.parse(result_json_string)).to be_kind_of(Hash) + end + end + +end \ No newline at end of file