├── .gitignore ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── bin └── .gitkeep ├── examples ├── output.json ├── rest.rb ├── rest │ └── helpers.rb └── rpc.rb ├── lib ├── qmap.rb └── qmap │ ├── application.rb │ ├── application │ └── scheduler.rb │ ├── core_ext │ └── array.rb │ ├── nmap.rb │ └── version.rb └── qmap.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in qmap.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | Copyright (c) 2023 Ecsypno Single Member P.C. . 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ported to Peplum 2 | 3 | For the continuation of this project please see [Peplum::Nmap](https://github.com/peplum/peplum-nmap). 4 | 5 | # Qmap 6 | 7 | QMap is a distributed network mapper/security scanner backed by: 8 | 9 | * [Cuboid](https://github.com/qadron/cuboid) for the distributed architecture. 10 | * [nmap](https://nmap.org/) for the scanning engine. 11 | * [ruby-nmap](https://github.com/postmodern/ruby-nmap) for the Ruby middleware. 12 | 13 | Its basic function is to distribute the scanning of IP ranges across multiple machines and thus parallelize an otherwise 14 | quite time consuming task. 15 | 16 | ## Installation 17 | 18 | $ git clone git@github.com:qadron/qmap.git 19 | $ cd qmap 20 | $ bundle install 21 | 22 | ## Usage 23 | 24 | See the `examples/` directory. 25 | 26 | ### Grid 27 | 28 | Qmap can initiate scans from the same machine, but the idea behind it is to use a _Grid_ which transparently load-balances 29 | and line-aggregates, in order to combine resources and perform a faster scan than one single machine could. 30 | 31 | That _Grid_ technology is graciously provided by [Cuboid](https://github.com/qadron/cuboid) and can be setup like so: 32 | 33 | ``` 34 | $ bundle exec irb 35 | irb(main):001:0> require 'qmap' 36 | => true 37 | irb(main):002:0> Qmap::Application.spawn( :agent, address: Socket.gethostname ) 38 | I, [2023-05-21T19:11:20.772790 #359147] INFO -- System: Logfile at: /home/zapotek/.cuboid/logs/Agent-359147-8499.log 39 | I, [2023-05-21T19:11:20.772886 #359147] INFO -- System: [PID 359147] RPC Server started. 40 | I, [2023-05-21T19:11:20.772892 #359147] INFO -- System: Listening on xps:8499 41 | ``` 42 | 43 | And at the terminal of another machine: 44 | 45 | ``` 46 | $ bundle exec irb 47 | irb(main):001:0> require 'qmap' 48 | => true 49 | irb(main):002:0> Qmap::Application.spawn( :agent, address: Socket.gethostname, peer: 'xps:8499' ) 50 | I, [2023-05-21T19:12:38.897746 #359221] INFO -- System: Logfile at: /home/zapotek/.cuboid/logs/Agent-359221-5786.log 51 | I, [2023-05-21T19:12:38.998472 #359221] INFO -- System: [PID 359221] RPC Server started. 52 | I, [2023-05-21T19:12:38.998494 #359221] INFO -- System: Listening on xps:5786 53 | ``` 54 | 55 | That's a _Grid_ of 2 Qmap _Agents_, both of them available to provide scanner _Instances_ that can be used to parallelize 56 | network mapping/security scans. 57 | 58 | If those 2 machines use a different pipe to the network you wish to scan, the result will be that the network resources 59 | are going to be in a way combined; or if the scan is too CPU intensive for just one machine, this will split the workload 60 | amongst the 2. 61 | 62 | The cool thing is that it doesn't matter to which you refer for _Instance_ _spawning_, the appropriate one is going to 63 | be the one providing it. 64 | 65 | You can then configure the _REST_ service to use any of those 2 _Agents_ and perform your scan -- 66 | see [examples/rest.rb](https://github.com/qadron/qmap/blob/master/examples/rest.rb). 67 | 68 | The _REST_ service is good for integration, so it's your safe bet; you can however also take advantage of the internal 69 | _RPC_ protocol and opt for something more like [examples/rpc.rb](https://github.com/qadron/qmap/blob/master/examples/rpc.rb). 70 | 71 | ## Contributing 72 | 73 | Bug reports and pull requests are welcome on GitHub at https://github.com/qadron/qmap. 74 | 75 | ## Funding 76 | 77 | QMap is a [Qadron](https://github.com/qadron/) project and as such funded by [Ecsypno Single Member P.C.](https://ecsypno.com). 78 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | task default: %i[] 5 | -------------------------------------------------------------------------------- /bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qadron/qmap/7a94ece74301c6978bb84649dca6141bddf569f0/bin/.gitkeep -------------------------------------------------------------------------------- /examples/output.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosts": { 3 | "192.168.1.1": { 4 | "start_time": "2023-05-21 19:30:39 +0300", 5 | "end_time": "2023-05-21 19:31:20 +0300", 6 | "status": "up", 7 | "addresses": [ 8 | "192.168.1.1" 9 | ], 10 | "mac": null, 11 | "vendor": null, 12 | "ipv4": "192.168.1.1", 13 | "ipv6": null, 14 | "hostname": "mywebui.net", 15 | "hostnames": [ 16 | "mywebui.net" 17 | ], 18 | "os": null, 19 | "uptime": null, 20 | "ports": { 21 | "53": { 22 | "protocol": "tcp", 23 | "state": "open", 24 | "reason": "syn-ack", 25 | "reason_ttl": "syn-ack", 26 | "service": "dnsmasq 2.85", 27 | "scripts": { 28 | "dns-nsid": { 29 | "output": "\n bind.version: dnsmasq-2.85", 30 | "data": { 31 | "bind.version": "dnsmasq-2.85" 32 | } 33 | } 34 | } 35 | }, 36 | "80": { 37 | "protocol": "tcp", 38 | "state": "open", 39 | "reason": "syn-ack", 40 | "reason_ttl": "syn-ack", 41 | "service": "http", 42 | "scripts": { 43 | "http-server-header": { 44 | "output": "httpd/2.7 (Netgear; D86)", 45 | "data": [ 46 | "httpd/2.7 (Netgear; D86)" 47 | ] 48 | }, 49 | "http-title": { 50 | "output": "NETGEAR\nRequested resource was http://mywebui.net/index.html?sessionId=00000005%2DqJnmUh51Zh2tSmWlMkdD4dXOUxF7xn3", 51 | "data": { 52 | "title": "NETGEAR", 53 | "redirect_url": "http://mywebui.net/index.html?sessionId=00000005%2DqJnmUh51Zh2tSmWlMkdD4dXOUxF7xn3" 54 | } 55 | } 56 | } 57 | }, 58 | "5510": { 59 | "protocol": "tcp", 60 | "state": "filtered", 61 | "reason": "no-response", 62 | "reason_ttl": "no-response", 63 | "service": "secureidprop", 64 | "scripts": { 65 | } 66 | } 67 | } 68 | }, 69 | "192.168.1.43": { 70 | "start_time": "2023-05-21 19:30:40 +0300", 71 | "end_time": "2023-05-21 19:33:15 +0300", 72 | "status": "up", 73 | "addresses": [ 74 | "192.168.1.43" 75 | ], 76 | "mac": null, 77 | "vendor": null, 78 | "ipv4": "192.168.1.43", 79 | "ipv6": null, 80 | "hostname": "deco_X60.net", 81 | "hostnames": [ 82 | "deco_X60.net" 83 | ], 84 | "os": null, 85 | "uptime": null, 86 | "ports": { 87 | "80": { 88 | "protocol": "tcp", 89 | "state": "open", 90 | "reason": "syn-ack", 91 | "reason_ttl": "syn-ack", 92 | "service": "http", 93 | "scripts": { 94 | "fingerprint-strings": { 95 | "output": "\n FourOhFourRequest: \n HTTP/1.0 404 Not Found\n Connection: close\n Content-Type: text/html\n

Not Found

The requested URL /nice%20ports%2C/Tri%6Eity.txt%2ebak was not found on this server.\n GetRequest, HTTPOptions: \n HTTP/1.0 200 OK\n Connection: close\n ETag: \"86b-110-5ce64ee5\"\n Last-Modified: Thu, 23 May 2019 07:42:29 GMT\n Date: Sun, 21 May 2023 16:30:46 GMT\n X-Frame-Options: deny\n Content-Security-Policy: frame-ancestors 'none'\n Content-Type: text/html\n Content-Length: 272\n \n \n \n \n \n \n \n Help: \n HTTP/0.9 400 Bad Request\n Connection: Keep-Alive\n Keep-Alive: timeout=20\n Content-Type: text/html\n

Bad Request

\n RTSPRequest: \n HTTP/1.0 400 Bad Request\n Connection: Keep-Alive\n Keep-Alive: timeout=20\n Content-Type: text/html\n

Bad Request

", 96 | "data": { 97 | "FourOhFourRequest": "\n HTTP/1.0 404 Not Found\n Connection: close\n Content-Type: text/html\n

Not Found

The requested URL /nice%20ports%2C/Tri%6Eity.txt%2ebak was not found on this server.", 98 | "GetRequest, HTTPOptions": "\n HTTP/1.0 200 OK\n Connection: close\n ETag: \"86b-110-5ce64ee5\"\n Last-Modified: Thu, 23 May 2019 07:42:29 GMT\n Date: Sun, 21 May 2023 16:30:46 GMT\n X-Frame-Options: deny\n Content-Security-Policy: frame-ancestors 'none'\n Content-Type: text/html\n Content-Length: 272\n \n \n \n \n \n \n ", 99 | "Help": "\n HTTP/0.9 400 Bad Request\n Connection: Keep-Alive\n Keep-Alive: timeout=20\n Content-Type: text/html\n

Bad Request

", 100 | "RTSPRequest": "\n HTTP/1.0 400 Bad Request\n Connection: Keep-Alive\n Keep-Alive: timeout=20\n Content-Type: text/html\n

Bad Request

" 101 | } 102 | }, 103 | "http-title": { 104 | "output": "Site doesn't have a title (text/html).", 105 | "data": null 106 | } 107 | } 108 | }, 109 | "443": { 110 | "protocol": "tcp", 111 | "state": "open", 112 | "reason": "syn-ack", 113 | "reason_ttl": "syn-ack", 114 | "service": "https", 115 | "scripts": { 116 | "http-title": { 117 | "output": "Site doesn't have a title (text/html).", 118 | "data": null 119 | }, 120 | "ssl-cert": { 121 | "output": "Subject: commonName=tplinkdeco.net/countryName=CN\nNot valid before: 2010-01-01T00:00:00\nNot valid after: 2030-12-31T00:00:00", 122 | "data": { 123 | "subject": { 124 | "countryName": "CN", 125 | "commonName": "tplinkdeco.net" 126 | }, 127 | "issuer": { 128 | "countryName": "CN", 129 | "commonName": "tplinkdeco.net" 130 | }, 131 | "pubkey": { 132 | "exponent": "BIGNUM: 0x5574815bd108", 133 | "bits": "2048", 134 | "modulus": "BIGNUM: 0x5574815bd148", 135 | "type": "rsa" 136 | }, 137 | "extensions": [ 138 | { 139 | "name": "X509v3 Basic Constraints", 140 | "value": "CA:FALSE" 141 | }, 142 | { 143 | "name": "Netscape Comment", 144 | "value": "OpenSSL Generated Certificate" 145 | }, 146 | { 147 | "name": "X509v3 Subject Key Identifier", 148 | "value": "9D:23:AE:93:2D:A9:BB:3D:F1:4B:1E:5B:23:C5:86:30:C9:2E:B3:23" 149 | }, 150 | { 151 | "name": "X509v3 Authority Key Identifier", 152 | "value": "11:D9:C7:7D:04:5B:F3:B3:B7:2A:3C:02:56:88:75:63:48:A4:47:BB" 153 | } 154 | ], 155 | "validity": { 156 | "notAfter": "2030-12-31T00:00:00", 157 | "notBefore": "2010-01-01T00:00:00" 158 | } 159 | } 160 | }, 161 | "ssl-date": { 162 | "output": "TLS randomness does not represent time", 163 | "data": null 164 | } 165 | } 166 | }, 167 | "1900": { 168 | "protocol": "tcp", 169 | "state": "open", 170 | "reason": "syn-ack", 171 | "reason_ttl": "syn-ack", 172 | "service": "MiniUPnP 1.8", 173 | "scripts": { 174 | "fingerprint-strings": { 175 | "output": "\n FourOhFourRequest, GetRequest: \n HTTP/1.0 404 Not Found\n Content-Type: text/html\n Connection: close\n Content-Length: 134\n Server: TP-LINK/TP-LINK UPnP/1.1 MiniUPnPd/1.8\n Ext:\n 404 Not Found

Not Found

The requested URL was not found on this server.\n GenericLines: \n 501 Not Implemented\n Content-Type: text/html\n Connection: close\n Content-Length: 149\n Server: TP-LINK/TP-LINK UPnP/1.1 MiniUPnPd/1.8\n Ext:\n 501 Not Implemented

Not Implemented

The HTTP Method is not implemented by this server.\n HTTPOptions: \n HTTP/1.0 501 Not Implemented\n Content-Type: text/html\n Connection: close\n Content-Length: 149\n Server: TP-LINK/TP-LINK UPnP/1.1 MiniUPnPd/1.8\n Ext:\n 501 Not Implemented

Not Implemented

The HTTP Method is not implemented by this server.\n RTSPRequest: \n RTSP/1.0 501 Not Implemented\n Content-Type: text/html\n Connection: close\n Content-Length: 149\n Server: TP-LINK/TP-LINK UPnP/1.1 MiniUPnPd/1.8\n Ext:\n 501 Not Implemented

Not Implemented

The HTTP Method is not implemented by this server.\n SIPOptions: \n SIP/2.0 501 Not Implemented\n Content-Type: text/html\n Connection: close\n Content-Length: 149\n Server: TP-LINK/TP-LINK UPnP/1.1 MiniUPnPd/1.8\n Ext:\n 501 Not Implemented

Not Implemented

The HTTP Method is not implemented by this server.", 176 | "data": { 177 | "FourOhFourRequest, GetRequest": "\n HTTP/1.0 404 Not Found\n Content-Type: text/html\n Connection: close\n Content-Length: 134\n Server: TP-LINK/TP-LINK UPnP/1.1 MiniUPnPd/1.8\n Ext:\n 404 Not Found

Not Found

The requested URL was not found on this server.", 178 | "GenericLines": "\n 501 Not Implemented\n Content-Type: text/html\n Connection: close\n Content-Length: 149\n Server: TP-LINK/TP-LINK UPnP/1.1 MiniUPnPd/1.8\n Ext:\n 501 Not Implemented

Not Implemented

The HTTP Method is not implemented by this server.", 179 | "HTTPOptions": "\n HTTP/1.0 501 Not Implemented\n Content-Type: text/html\n Connection: close\n Content-Length: 149\n Server: TP-LINK/TP-LINK UPnP/1.1 MiniUPnPd/1.8\n Ext:\n 501 Not Implemented

Not Implemented

The HTTP Method is not implemented by this server.", 180 | "RTSPRequest": "\n RTSP/1.0 501 Not Implemented\n Content-Type: text/html\n Connection: close\n Content-Length: 149\n Server: TP-LINK/TP-LINK UPnP/1.1 MiniUPnPd/1.8\n Ext:\n 501 Not Implemented

Not Implemented

The HTTP Method is not implemented by this server.", 181 | "SIPOptions": "\n SIP/2.0 501 Not Implemented\n Content-Type: text/html\n Connection: close\n Content-Length: 149\n Server: TP-LINK/TP-LINK UPnP/1.1 MiniUPnPd/1.8\n Ext:\n 501 Not Implemented

Not Implemented

The HTTP Method is not implemented by this server." 182 | } 183 | } 184 | } 185 | } 186 | } 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /examples/rest.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'qmap' 4 | require 'pp' 5 | require_relative 'rest/helpers' 6 | 7 | # Boot up our REST QMap server for easy integration. 8 | rest_pid = Qmap::Application.spawn( :rest, daemonize: true ) 9 | at_exit { Cuboid::Processes::Manager.kill rest_pid } 10 | 11 | # Wait for the REST server to boot up. 12 | while sleep 1 13 | begin 14 | request :get 15 | rescue Errno::ECONNREFUSED 16 | next 17 | end 18 | 19 | break 20 | end 21 | 22 | # Assign a QMap Agent to the REST service for it to provide us with scanner Instances. 23 | request :put, 'agent/url', Qmap::Application.spawn( :agent, daemonize: true ).url 24 | 25 | # Create a new scanner Instance (process) and run a scan with the following options. 26 | request :post, 'instances', { 27 | targets: ['192.168.1.*'], 28 | connect_scan: true, 29 | service_scan: true, 30 | default_script: true, 31 | 32 | # Split on-line hosts into groups of 5 at a maximum and use one Instance to scan each group. 33 | max_instances: 5 34 | } 35 | 36 | # The ID is used to represent that instance and allow us to manage it from here on out. 37 | instance_id = response_data['id'] 38 | 39 | while sleep( 1 ) 40 | # Continue looping while instance status is 'busy'. 41 | request :get, "instances/#{instance_id}" 42 | break if !response_data['busy'] 43 | end 44 | 45 | puts '*' * 88 46 | 47 | # Get the scan report. 48 | request :get, "instances/#{instance_id}/report.json" 49 | 50 | # Print out the report. 51 | puts JSON.pretty_generate( JSON.load( response_data['data'] ) ) 52 | 53 | # Shutdown the Instance. 54 | request :delete, "instances/#{instance_id}" 55 | -------------------------------------------------------------------------------- /examples/rest/helpers.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'tmpdir' 3 | require 'net/http' 4 | 5 | def response 6 | if @last_response['Content-Type'].include? 'json' 7 | data = JSON.load( @last_response.body ) 8 | else 9 | data = @last_response.body 10 | end 11 | { 12 | code: @last_response.code, 13 | data: data 14 | } 15 | end 16 | 17 | def response_data 18 | response[:data] 19 | end 20 | 21 | def request( method, resource = nil, parameters = nil ) 22 | uri = URI( "http://127.0.0.1:7331/#{resource}" ) 23 | 24 | Net::HTTP.start( uri.host, uri.port) do |http| 25 | case method 26 | when :get 27 | uri.query = URI.encode_www_form( parameters ) if parameters 28 | request = Net::HTTP::Get.new( uri ) 29 | 30 | when :post 31 | request = Net::HTTP::Post.new( uri ) 32 | request.body = parameters.to_json 33 | 34 | when :delete 35 | request = Net::HTTP::Delete.new( uri ) 36 | 37 | when :put 38 | request = Net::HTTP::Put.new( uri ) 39 | request.body = parameters.to_json 40 | end 41 | 42 | @last_response = http.request( request ) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /examples/rpc.rb: -------------------------------------------------------------------------------- 1 | require 'pp' 2 | require 'qmap' 3 | 4 | # Spawn a QMap Agent as a daemon. 5 | qmap_agent = Qmap::Application.spawn( :agent, daemonize: true ) 6 | 7 | # Spawn and connect to a QMap Instance. 8 | qmap = Qmap::Application.connect( qmap_agent.spawn ) 9 | # Don't forget this! 10 | at_exit { qmap.shutdown } 11 | 12 | # Run a distributed scan. 13 | qmap.run( 14 | targets: ['192.168.1.*'], 15 | connect_scan: true, 16 | service_scan: true, 17 | default_script: true, 18 | 19 | # Split on-line hosts into groups of 5 at a maximum and use one Instance to scan each group. 20 | max_instances: 5 21 | ) 22 | 23 | # Waiting to complete. 24 | sleep 1 while qmap.running? 25 | 26 | # Hooray! 27 | puts JSON.pretty_generate( qmap.generate_report.data ) 28 | -------------------------------------------------------------------------------- /lib/qmap.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "qmap/version" 4 | 5 | module Qmap 6 | class Error < StandardError; end 7 | # Your code goes here... 8 | 9 | require "qmap/application" 10 | end 11 | -------------------------------------------------------------------------------- /lib/qmap/application.rb: -------------------------------------------------------------------------------- 1 | require 'cuboid' 2 | require 'json' 3 | 4 | require 'qmap' 5 | require 'qmap/nmap' 6 | 7 | module Qmap 8 | class Application < Cuboid::Application 9 | require 'qmap/application/scheduler' 10 | 11 | class Error < Qmap::Error; end 12 | 13 | # 100MB RAM should be more than enough for nmap and ruby, 14 | provision_memory 100 * 1024 * 1024 15 | 16 | # 100MB disk space should be more than enough for the temp nmap reports, 17 | provision_disk 100 * 1024 * 1024 18 | 19 | validate_options_with :validate_options 20 | serialize_with JSON 21 | 22 | instance_service_for :scheduler, Scheduler 23 | 24 | def run 25 | options = @options.dup 26 | 27 | # We have a master so we're not the scheduler, run the payload. 28 | if (master_info = options.delete( 'master' )) 29 | report_data = native_app.run( options ) 30 | 31 | master = Processes::Instances.connect( master_info['url'], master_info['token'] ) 32 | master.scheduler.report report_data, Cuboid::Options.rpc.url 33 | 34 | # We're the scheduler Instance. 35 | else 36 | max_instances = options.delete('max_instances') 37 | targets = options.delete('targets') 38 | groups = native_app.group( targets, max_instances ) 39 | 40 | # Workload turned out to be less than our maximum allowed instances. 41 | # Don't spawn the max if we don't have to. 42 | if groups.size < max_instances 43 | instance_num = groups.size 44 | 45 | # Workload distribution turned out as expected. 46 | elsif groups.size == max_instances 47 | instance_num = max_instances 48 | 49 | # What the hell did just happen1? 50 | else 51 | fail Error, 'Workload distribution error, uneven grouping!' 52 | end 53 | 54 | instance_num.times.each do |i| 55 | # Get as many workers as necessary/possible. 56 | break unless self.scheduler.get_worker 57 | end 58 | 59 | # We couldn't get the workers we were going for, Grid reached its capacity, 60 | # re-balance distribution. 61 | if self.scheduler.workers.size < groups.size 62 | groups = native_app.group( targets, self.scheduler.workers.size ) 63 | end 64 | 65 | self.scheduler.workers.values.each do |worker| 66 | worker.run options.merge( 67 | targets: groups.pop, 68 | master: { 69 | url: Cuboid::Options.rpc.url, 70 | token: Cuboid::Options.datastore.token 71 | } 72 | ) 73 | end 74 | 75 | self.scheduler.wait 76 | end 77 | end 78 | 79 | def report( data ) 80 | super native_app.merge( data ) 81 | end 82 | 83 | private 84 | 85 | def validate_options( options ) 86 | if !Cuboid::Options.agent.url 87 | fail Error, 'Missing Agent!' 88 | end 89 | 90 | if !options.include? 'targets' 91 | fail Error, 'Options: Missing :targets' 92 | end 93 | 94 | if !options['master'] && !options.include?( 'max_instances' ) 95 | fail Error, 'Options: Missing :max_instances' 96 | end 97 | 98 | @options = options 99 | true 100 | end 101 | 102 | # Implements: 103 | # * `.run` -- Worker; executes its payload against `targets`. 104 | # * `.group` -- Splits given `targets` into groups for each worker. 105 | # * `.merge` -- Merges results from multiple workers. 106 | # 107 | # That's all we need to turn any application into a super version of itself, in this case `nmap`. 108 | def native_app 109 | NMap 110 | end 111 | 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/qmap/application/scheduler.rb: -------------------------------------------------------------------------------- 1 | module Qmap 2 | class Application 3 | 4 | class Scheduler 5 | 6 | def initialize(*) 7 | super 8 | 9 | @done_signal = Queue.new 10 | end 11 | 12 | def get_worker 13 | worker_info = agent.spawn 14 | return if !worker_info 15 | 16 | worker = Qmap::Application.connect( worker_info ) 17 | self.workers[worker.url] = worker 18 | worker 19 | end 20 | 21 | def workers 22 | @workers ||= {} 23 | end 24 | 25 | def report( data, url ) 26 | return if !(worker = workers.delete( url )) 27 | 28 | report_data << data 29 | 30 | worker.shutdown {} 31 | return unless workers.empty? 32 | 33 | Qmap::Application.report report_data 34 | 35 | @done_signal << nil 36 | end 37 | 38 | def wait 39 | @done_signal.pop 40 | end 41 | 42 | def agent 43 | @agent ||= Processes::Agents.connect( Cuboid::Options.agent.url ) 44 | end 45 | 46 | private 47 | 48 | def report_data 49 | @report_data ||= [] 50 | end 51 | 52 | end 53 | 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/qmap/core_ext/array.rb: -------------------------------------------------------------------------------- 1 | class Array 2 | 3 | def chunk( pieces = 2 ) 4 | return self if pieces <= 0 5 | 6 | len = self.length 7 | mid = len / pieces 8 | chunks = [] 9 | start = 0 10 | 11 | 1.upto( pieces ) do |i| 12 | last = start + mid 13 | last = last - 1 unless len % pieces >= i 14 | chunks << self[ start..last ] || [] 15 | start = last + 1 16 | end 17 | 18 | chunks 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/qmap/nmap.rb: -------------------------------------------------------------------------------- 1 | require 'tmpdir' 2 | require 'nmap/command' 3 | require 'nmap/xml' 4 | 5 | require_relative 'core_ext/array' 6 | 7 | module Qmap 8 | module NMap 9 | 10 | DEFAULT_OPTIONS = { 11 | 'output_normal' => '/dev/null', 12 | 'quiet' => true 13 | } 14 | 15 | PING_REPORT = "#{Dir.tmpdir}/nmap-ping-#{Process.pid}.xml" 16 | SCAN_REPORT = "#{Dir.tmpdir}/nmap-scan-#{Process.pid}.xml" 17 | 18 | at_exit do 19 | FileUtils.rm_f PING_REPORT 20 | FileUtils.rm_f SCAN_REPORT 21 | end 22 | 23 | def run( options ) 24 | _run options.merge( output_xml: SCAN_REPORT ) 25 | report_from_xml( SCAN_REPORT ) 26 | end 27 | 28 | def group( targets, chunks ) 29 | @hosts ||= self.live_hosts( targets ) 30 | @hosts.chunk( chunks ).reject { |chunk| chunk.empty? } 31 | end 32 | 33 | def merge( data ) 34 | report = { 'hosts' => {} } 35 | data.each do |d| 36 | report['hosts'].merge! d['hosts'] 37 | end 38 | report 39 | end 40 | 41 | private 42 | 43 | def live_hosts( targets ) 44 | _run targets: targets, 45 | ping: true, 46 | output_xml: PING_REPORT 47 | 48 | hosts_from_xml( PING_REPORT ) 49 | end 50 | 51 | def set_default_options( nmap ) 52 | set_options( nmap, DEFAULT_OPTIONS ) 53 | end 54 | 55 | def set_options( nmap, options ) 56 | options.each do |k, v| 57 | nmap.send "#{k}=", v 58 | end 59 | end 60 | 61 | def _run( options = {}, &block ) 62 | Nmap::Command.run do |nmap| 63 | set_default_options nmap 64 | set_options nmap, options 65 | block.call nmap if block_given? 66 | end 67 | end 68 | 69 | def hosts_from_xml( xml ) 70 | hosts = [] 71 | Nmap::XML.open( xml ) do |xml| 72 | xml.each_host do |host| 73 | hosts << host.ip 74 | end 75 | end 76 | hosts 77 | end 78 | 79 | def report_from_xml( xml ) 80 | report_data = {} 81 | Nmap::XML.open( xml ) do |xml| 82 | xml.each_host do |host| 83 | report_data['hosts'] ||= {} 84 | report_data['hosts'][host.ip] = host_to_hash( host ) 85 | 86 | report_data['hosts'][host.ip]['ports'] = {} 87 | host.each_port do |port| 88 | report_data['hosts'][host.ip]['ports'][port.number] = port_to_hash( port ) 89 | end 90 | end 91 | end 92 | report_data 93 | end 94 | 95 | def host_to_hash( host ) 96 | h = {} 97 | %w(start_time end_time status addresses mac vendor ipv4 ipv6 hostname hostnames os uptime).each do |k| 98 | v = host.send( k ) 99 | next if !v 100 | 101 | if v.is_a? Array 102 | h[k] = v.map(&:to_s) 103 | else 104 | h[k] = v.to_s 105 | end 106 | end 107 | 108 | if host.host_script 109 | h['scripts'] = {} 110 | host.host_script.scripts.each do |name, script| 111 | h['scripts'][name] = { 112 | output: script.output, 113 | data: script.data 114 | } 115 | end 116 | end 117 | 118 | h 119 | end 120 | 121 | def port_to_hash( port ) 122 | h = {} 123 | 124 | %w(protocol state reason reason_ttl).each do |k| 125 | h[k] = port.send( k ) 126 | end 127 | h['service'] = port.service.to_s 128 | 129 | h['scripts'] ||= {} 130 | port.scripts.each do |name, script| 131 | h['scripts'][name] = { 132 | output: script.output, 133 | data: script.data 134 | } 135 | end 136 | 137 | h 138 | end 139 | 140 | extend self 141 | 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/qmap/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Qmap 4 | VERSION = "0.1.0" 5 | end 6 | -------------------------------------------------------------------------------- /qmap.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/qmap/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "qmap" 7 | spec.version = Qmap::VERSION 8 | spec.authors = ["Tasos Laskos"] 9 | spec.email = ["tasos.laskos@gmail.com"] 10 | 11 | spec.summary = "Distributed NMap." 12 | spec.description = "Distributed NMap." 13 | spec.homepage = "http://ecsypno.com/" 14 | spec.required_ruby_version = ">= 2.6.0" 15 | 16 | spec.files = Dir.glob( 'bin/*') 17 | spec.files += %w(bin/.gitkeep) 18 | spec.files += Dir.glob( 'lib/**/*') 19 | spec.files += Dir.glob( 'examples/**/*') 20 | spec.files += %w(qmap.gemspec) 21 | 22 | spec.add_dependency 'cuboid' 23 | spec.add_dependency 'ruby-nmap' 24 | end 25 | --------------------------------------------------------------------------------