├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── sonar ├── cortex.yaml ├── lib ├── sonar.rb └── sonar │ ├── certificate.rb │ ├── cli │ ├── cli.rb │ └── rcfile.rb │ ├── client.rb │ ├── registration.rb │ ├── request.rb │ ├── search.rb │ ├── user.rb │ └── version.rb ├── sonar-client.gemspec └── spec ├── cassette └── valid_ms_registration.yml ├── fixtures ├── sonar-stock.rc └── sonar.rc ├── sonar ├── cli_spec.rb ├── client_spec.rb ├── registration_spec.rb ├── search_spec.rb └── user_spec.rb ├── sonar_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | coverage 6 | lib/bundler/man 7 | pkg 8 | rdoc 9 | spec/reports 10 | test/tmp 11 | test/version_tmp 12 | tmp 13 | 14 | # OSX 15 | .DS_Store 16 | 17 | # YARD artifacts 18 | .yardoc 19 | _yardoc 20 | doc/ 21 | 22 | Gemfile.lock 23 | 24 | # Specific to RubyMotion 25 | .dat* 26 | .repl_history 27 | build/ 28 | 29 | # for a library or gem, you might want to ignore these files since the code is 30 | # intended to run in multiple environments; otherwise, check them in: 31 | # Gemfile.lock 32 | .ruby-version 33 | .ruby-gemset 34 | 35 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 36 | .rvmrc 37 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format d 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Style/StringLiterals: 2 | Enabled: false 3 | Description: 'Single quotes are preferred when string interpolation is not needed.' 4 | 5 | Metrics/LineLength: 6 | Enabled: true 7 | Max: 120 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | cache: bundler 4 | rvm: 5 | - '2.4.5' 6 | - '2.5.5' 7 | - '2.6.5' 8 | - '2.7.1' 9 | - 'jruby-head' 10 | before_install: 11 | - gem install bundler 12 | - gem update bundler 13 | - "echo 'gem: --no-ri --no-rdoc' > ~/.gemrc" 14 | - rake --version 15 | before_script: 16 | - bundle exec rake --version 17 | script: bundle exec rspec 18 | notifications: 19 | email: 20 | - r7_labs@rapid7.com 21 | # 22 | # Environment variables should be set in Travis repository settings per 23 | # https://docs.travis-ci.com/user/environment-variables/#Defining-Variables-in-Repository-Settings 24 | # 25 | # Required vars are SONAR_API_URL, SONAR_EMAIL, and SONAR_TOKEN 26 | # 27 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in sonar-client.gemspec 4 | gemspec 5 | 6 | group :development do 7 | gem 'pry' 8 | end -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Rapid7, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sonar-client 2 | =============== 3 | 4 | Ruby API Wrapper and CLI for [Sonar](https://sonar.labs.rapid7.com) 5 | 6 | [![Gem Version](https://badge.fury.io/rb/sonar-client.svg)](http://badge.fury.io/rb/sonar-client) 7 | [![Build Status](https://travis-ci.org/rapid7/sonar-client.svg?branch=master)](https://travis-ci.org/rapid7/sonar-client) 8 | 9 | ## Installation 10 | 11 | Install the gem by running 12 | 13 | gem install sonar-client 14 | 15 | or add this line to your application's Gemfile: 16 | 17 | gem 'sonar-client' 18 | 19 | And then execute: 20 | 21 | $ bundle install 22 | 23 | ## Gem usage 24 | 25 | ```ruby 26 | require 'sonar' 27 | 28 | # If you're using Rails 3+, create an initializer 29 | # config/initializers/sonar.rb 30 | Sonar.configure do |config| 31 | config.api_url = 'https://sonar.labs.rapid7.com' 32 | config.api_version = 'v2' 33 | config.email = 'email@example.com' 34 | config.access_token = 'YOURTOKEN' 35 | end 36 | 37 | # If you're using straight ruby (no Rails), 38 | # create a Sonar::Client Object 39 | options = { 40 | api_url: 'https://sonar.labs.rapid7.com', 41 | api_version: 'v2', 42 | access_token: 'YOURTOKEN', 43 | email: 'email@example.com' 44 | } 45 | client = Sonar::Client.new(options) 46 | 47 | # Create a Client Object expecting you have an initializer in place 48 | # Sonar::Client Object 49 | client = Sonar::Client.new 50 | 51 | # Get fdns 52 | client.search(fdns: 'rapid7.com') 53 | # => responds with a Hashie object 54 | ``` 55 | 56 | ## Running the specs 57 | 58 | Until they're mocked, specs are run against a live API, either production, staging, or localhost (development). The config in `spec/spec_helper.rb` requires several credentials to be set as environment variables to make requests. Consider adding this to your `~/.bashrc` or export the variables before running the specs: 59 | 60 | ``` 61 | # Sonar config 62 | export SONAR_TOKEN=asldkstokenalskdjf 63 | export SONAR_API_URL=http://sonar.labs.rapid7.com/ 64 | export SONAR_EMAIL=youremail@example.com 65 | ``` 66 | 67 | Once you have the variables set, `rspec spec` will run all the specs. 68 | 69 | ## CLI dev setup 70 | 71 | From the project root directory 72 | ``` 73 | $ rake install 74 | sonar-client 0.0.1 built to pkg/sonar-client-0.0.1.gem. 75 | sonar-client (0.0.1) installed. 76 | $ sonar 77 | ``` 78 | 79 | On the first run, sonar will setup a sonar.rc config file in your user folder. Run `sonar config` to view the full path to your config file. Here's what your file will look like when it's first created: 80 | ``` 81 | email: YOUR_EMAIL 82 | access_token: SONAR_TOKEN 83 | api_url: https://sonar.labs.rapid7.com 84 | format: flat 85 | record_limit: 10000 86 | ``` 87 | Replace YOUR_EMAIL with the email you used to register on the [Sonar website](https://sonar.labs.rapid7.com). Replace SONAR_TOKEN with your API token found on the [Settings page](https://sonar.labs.rapid7.com/users/edit) of the Sonar website. The format option can either pretty-print the return JSON or display it in a flat output (by default). The record limit is the maximum number of records to return for a query. Responses are returned in 1000 record chunks that are streamed into the output to avoid API timeouts. Enclosing quotes around these two settings are not needed. These configurations can always be overwritten for a single command line query by specifying the option and argument: `--format pretty`. 88 | 89 | ## CLI usage 90 | 91 | Typing `sonar help` will list all the available commands. You can type `sonar help TASK` to get help for a specific command. If running locally from the root project directory, you may need to prefix `sonar` commands with `bundle exec`. A rdns search command might look like `bundle exec sonar search rdns .rapid7.com`. 92 | 93 | ## Contributing 94 | 95 | 1. Fork it 96 | 2. Create your feature branch (`git checkout -b feature/my-new-feature`) 97 | 3. Commit your changes (`git commit -am 'Add some feature'`) 98 | 4. Push to the branch (`git push origin my-new-feature`) 99 | 5. Create new Pull Request 100 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | require "rspec/core/rake_task" 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task default: :spec 8 | -------------------------------------------------------------------------------- /bin/sonar: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $LOAD_PATH.unshift(File.expand_path(File.join(File.dirname(__FILE__), "..", "lib"))) 4 | require 'bundler/setup' 5 | require 'sonar' 6 | 7 | begin 8 | Sonar::CLI.start(ARGV) 9 | rescue Sonar::Search::SearchError => e 10 | exit 1 11 | rescue Interrupt 12 | puts 'Quitting...' 13 | end 14 | -------------------------------------------------------------------------------- /cortex.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | info: 3 | title: Sonar Client 4 | x-cortex-git: 5 | github: 6 | alias: r7org 7 | repository: rapid7/sonar-client 8 | x-cortex-tag: sonar-client 9 | x-cortex-type: service 10 | x-cortex-domain-parents: 11 | - tag: octo-and-labs 12 | x-cortex-groups: 13 | - exposure:internal-ship 14 | openapi: 3.0.1 15 | servers: 16 | - url: "/" 17 | -------------------------------------------------------------------------------- /lib/sonar.rb: -------------------------------------------------------------------------------- 1 | require "faraday" 2 | require "sonar/cli/cli" 3 | require "sonar/client" 4 | require "sonar/version" 5 | require "sonar/registration" 6 | 7 | module Sonar 8 | class << self 9 | attr_accessor :api_url, :api_version, :access_token, :email 10 | 11 | ## 12 | # Configure default 13 | # 14 | # @yield Sonar client object 15 | def configure 16 | load_defaults 17 | yield self 18 | true 19 | end 20 | 21 | private 22 | 23 | def load_defaults 24 | self.api_url ||= "https://sonar.labs.rapid7.com" 25 | self.api_version ||= "v2" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/sonar/certificate.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Sonar 4 | module Certificate 5 | ## 6 | # Get certificate based on sha1 id 7 | # /api/v2/certificates/1e80c24b97c928bb1db7d4d3c05475a6a40a1186 8 | # 9 | # @return [Hashie::Mash] with response of certificate 10 | def get_certificate(options = {}) 11 | response = get_endpoint("certificates/#{options[:sha1]}", options) 12 | response if response 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/sonar/cli/cli.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'thor' 4 | require 'sonar/cli/rcfile' 5 | require 'sonar/search' 6 | require 'sonar/request' 7 | require 'awesome_print' 8 | require 'table_print' 9 | 10 | module Sonar 11 | class CLI < Thor 12 | class_option 'format', type: :string, desc: 'Flat JSON (include empty collections), JSON lines of collection data (default), or pretty printed [flat/lines/pretty]' 13 | 14 | def initialize(*) 15 | @config = Sonar::RCFile.instance.load_file 16 | @client = Sonar::Client.new(email: @config["email"], access_token: @config["access_token"], api_url: @config["api_url"]) 17 | super 18 | end 19 | 20 | desc 'profile', 'Display the current profile from sonar.rc' 21 | def profile 22 | ap @config 23 | end 24 | 25 | desc 'usage', 'Display API usage for current user' 26 | def usage 27 | ap @client.usage 28 | end 29 | 30 | desc 'search [QUERY TYPE] [QUERY TERM]', 'Search any query type from Sonar or specify \'all\' as QUERY TYPE to search them all.' 31 | method_option 'record_limit', type: :numeric, aliases: '-n', desc: 'Maximum number of records to fetch' 32 | def search(type, term) 33 | types = [type] 34 | 35 | if type == 'all' 36 | if term =~ Search::IS_IP 37 | types = @client.ip_search_type_names 38 | else 39 | types = @client.domain_search_type_names 40 | end 41 | end 42 | 43 | types.each do |type| 44 | @query = {} 45 | @query[type.to_sym] = term 46 | @query[:limit] = options['record_limit'] 47 | resp = @client.search(@query) 48 | handle_search_response(resp) 49 | end 50 | end 51 | 52 | desc 'types', 'List all Sonar query types' 53 | def types 54 | tp.set :io, $stdout 55 | tp Search::QUERY_TYPES, :name, { description: { width: 100 } }, :input 56 | end 57 | 58 | desc 'config', 'Sonar config file location' 59 | def config 60 | # TODO: add a way to set config 61 | puts "Your config file is located at #{RCFile.instance.path}" 62 | end 63 | 64 | private 65 | 66 | def print_json(data, format) 67 | case format 68 | when 'pretty' 69 | ap(data) 70 | when 'lines' 71 | if data.has_key?('collection') 72 | data['collection'].each { |l| puts l.to_json } 73 | else 74 | puts 'WARNING: Could not parse the response into lines, there was no collection.' 75 | puts data.to_json 76 | end 77 | else 78 | # TODO: use a faster JSON generator? 79 | puts(data.to_json) 80 | end 81 | end 82 | 83 | def handle_search_response(resp) 84 | errors = 0 85 | if resp.is_a?(Sonar::Request::RequestIterator) 86 | resp.each do |data| 87 | errors += 1 if data.key?('errors') || data.key?('error') 88 | print_json(cleanup_data(data), options['format']) 89 | end 90 | else 91 | errors += 1 if resp.key?('errors') || resp.key?('error') 92 | print_json(cleanup_data(resp), options['format']) 93 | end 94 | 95 | raise Search::SearchError.new("Encountered #{errors} errors while searching") if errors > 0 96 | end 97 | 98 | # Clean up whitespace and parse JSON values in responses 99 | def cleanup_data(data) 100 | return data unless data.is_a?(Hash) && data.has_key?('collection') 101 | data['collection'].each do |item| 102 | item.each_pair do |k,v| 103 | # Purge whitespace within values 104 | v.is_a?(::String) ? v.strip! : v 105 | 106 | # Parse JSON values 107 | if v.is_a?(Array) 108 | v.map! do |e| 109 | e = safe_parse_json(e) 110 | end 111 | else 112 | item[k] = safe_parse_json(v) 113 | end 114 | end 115 | end 116 | data 117 | end 118 | 119 | def safe_parse_json(s) 120 | JSON.parse(s) rescue s 121 | end 122 | 123 | # Merge Thor options with those stored in sonar.rc file 124 | # where all default options are set. 125 | def options 126 | original_options = super 127 | user_defaults = @config 128 | user_defaults.merge(original_options) 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/sonar/cli/rcfile.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'singleton' 3 | require 'yaml' 4 | 5 | module Sonar 6 | class RCFile 7 | include Singleton 8 | 9 | attr_accessor :path 10 | FILENAME = 'sonar.rc' 11 | 12 | def initialize 13 | @path = File.join(File.expand_path('~'), FILENAME) 14 | @data = load_file 15 | end 16 | 17 | def create_file 18 | File.open(@path, 'w') do |f| 19 | f.puts 'email: YOUR_EMAIL' 20 | f.puts 'access_token: SONAR_TOKEN' 21 | f.puts 'api_url: https://sonar.labs.rapid7.com' 22 | f.puts 'format: flat' 23 | f.puts 'record_limit: 10000' 24 | end 25 | warn = "Please set your email and API token in sonar.rc" 26 | puts "=" * warn.size 27 | puts "Config file setup at: #{@path}" 28 | puts warn 29 | puts "=" * warn.size 30 | end 31 | 32 | def load_file 33 | create_file unless File.exist?(@path) 34 | YAML.load_file(@path) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/sonar/client.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'faraday' 3 | require 'faraday/follow_redirects' 4 | require 'faraday/rashify' 5 | require 'forwardable' 6 | require 'sonar/request' 7 | require 'sonar/search' 8 | require 'sonar/user' 9 | require 'sonar/cli/cli' 10 | require 'sonar/registration' 11 | 12 | module Sonar 13 | class Client 14 | extend Forwardable 15 | 16 | include Request 17 | include Search 18 | include User 19 | include Registration 20 | 21 | attr_accessor :api_url, :api_version, :access_token, :email 22 | 23 | ## 24 | # Create a new Sonar::Client object 25 | # 26 | # @params options[Hash] 27 | def initialize(options = {}) 28 | @api_url = options.fetch(:api_url, default_api_url) 29 | @api_version = options.fetch(:api_version, default_api_version ) 30 | @access_token = options.fetch(:access_token, default_access_token) 31 | @email = options.fetch(:email, default_email) 32 | end 33 | 34 | ## 35 | # Create a Faraday::Connection object 36 | # 37 | # @return [Faraday::Connection] 38 | def connection 39 | params = {} 40 | @conn = Faraday.new(url: api_url, params: params, headers: default_headers, ssl: { verify: true }) do |faraday| 41 | faraday.use Faraday::FollowRedirects::Middleware 42 | faraday.use Faraday::Rashify::Middleware 43 | faraday.request :json 44 | 45 | faraday.response :json 46 | faraday.adapter Faraday.default_adapter 47 | end 48 | @conn.headers['X-Sonar-Token'] = access_token 49 | @conn.headers['X-Sonar-Email'] = email 50 | @conn 51 | end 52 | 53 | ## 54 | # Generic GET of Sonar search Objects 55 | def get_search_endpoint(type, params = {}) 56 | url = "/api/#{api_version}/search/#{type}" 57 | if params[:limit] 58 | RequestIterator.new(url, connection, params) 59 | else 60 | get(url, params) 61 | end 62 | end 63 | 64 | ## 65 | # Generic GET of Sonar Objects 66 | def get_endpoint(type, params = {}) 67 | url = "/api/#{api_version}/#{type}" 68 | get(url, params) 69 | end 70 | 71 | ## 72 | # Generic POST to Sonar 73 | def post_to_sonar(type, params = {}) 74 | url = "/api/#{api_version}/#{type}" 75 | post(url, params) 76 | end 77 | 78 | private 79 | 80 | # Returns the default value for the api url 81 | # 82 | # @return [String] the URL for the Sonar API 83 | def default_api_url 84 | begin 85 | Sonar.api_url 86 | rescue NoMethodError 87 | 'https://sonar.labs.rapid7.com' 88 | end 89 | end 90 | 91 | # Returns the default value for the api version 92 | # 93 | # @return [String] the Sonar API version to use 94 | def default_api_version 95 | begin 96 | Sonar.api_version || 'v2' 97 | rescue NoMethodError 98 | 'v2' 99 | end 100 | end 101 | 102 | # Returns the default value for the access token 103 | # 104 | # @return [String] if {Sonar} has a value configured 105 | # @return [nil] 106 | def default_access_token 107 | begin 108 | Sonar.access_token 109 | rescue NoMethodError 110 | '' 111 | end 112 | end 113 | 114 | # Returns the default value for the email address 115 | # 116 | # @return [String] if {Sonar} has a value configured 117 | # @return [nil] 118 | def default_email 119 | begin 120 | Sonar.email 121 | rescue NoMethodError 122 | '' 123 | end 124 | end 125 | 126 | def default_headers 127 | { 128 | accept: 'application/json', 129 | content_type: 'application/json', 130 | user_agent: "Sonar #{Sonar::VERSION} Ruby Gem" 131 | } 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/sonar/registration.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module Sonar 3 | module Registration 4 | 5 | # Takes a Metasploit Product Key and attempts 6 | # to register a Sonar account with it. 7 | def register_metasploit(product_key) 8 | post_params = { product_key: product_key.dup } 9 | post_to_sonar('metasploit_verify', post_params) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/sonar/request.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'multi_json' 3 | 4 | module Sonar 5 | module Request 6 | def extract_params(params) 7 | # extract something from Sonar if needed 8 | params 9 | end 10 | 11 | def get(path, options = {}) 12 | request(:get, path, options) 13 | end 14 | 15 | def post(path, options = {}) 16 | request(:post, path, options) 17 | end 18 | 19 | def put(path, options = {}) 20 | request(:put, path, options) 21 | end 22 | 23 | def request(method, path, options) 24 | response = connection.send(method) do |request| 25 | options.delete(:connection) 26 | case method 27 | when :get 28 | request.url(path, options) 29 | when :post, :put 30 | request.path = path 31 | request.body = MultiJson.encode(options) unless options.empty? 32 | end 33 | end 34 | 35 | response.body 36 | end 37 | 38 | class RequestIterator 39 | include Request 40 | 41 | attr_accessor :url, :connection, :params 42 | 43 | def initialize(url, connection, params = {}) 44 | self.url = url 45 | self.connection = connection 46 | self.params = params 47 | end 48 | 49 | def each 50 | more = true 51 | records_rcvd = 0 52 | while more && records_rcvd < params[:limit] 53 | # TODO: refactor to not pass around the connection 54 | params[:connection] = connection 55 | resp = get(url, params) 56 | params[:iterator_id] = resp.iterator_id 57 | records_rcvd += resp['collection'].size rescue 0 58 | more = resp['more'] 59 | yield resp 60 | end 61 | params.delete(:iterator_id) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/sonar/search.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Sonar 4 | module Search 5 | 6 | # Allow IP queries to be in the form of "1.", "1.2.", "1.2.3.", and "1.2.3.4" 7 | IS_IP = /^(\d{1,3}\.|\d{1,3}\.\d{1,3}\.|\d{1,3}\.\d{1,3}\.\d{1,3}\.|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/ 8 | 9 | # Implemented search query types 10 | QUERY_TYPES = [ 11 | { name: 'fdns', description: 'Domains to IP or IPs to Domain', input: 'domain' }, 12 | { name: 'ports', description: 'Open Ports', input: 'ip' }, 13 | { name: 'all', description: 'Search all appropriate search types for an IP or domain', input: 'all' } 14 | ] 15 | 16 | ## 17 | # Generic exception for errors encountered while searching 18 | ## 19 | class SearchError < StandardError 20 | end 21 | 22 | def ip_search_type_names 23 | ip_search_types.map { |type| type[:name] } 24 | end 25 | 26 | def domain_search_type_names 27 | domain_search_types.map { |type| type[:name] } 28 | end 29 | 30 | def ip_search_types 31 | QUERY_TYPES.select { |type| type[:input] == 'ip' } 32 | end 33 | 34 | def domain_search_types 35 | QUERY_TYPES.select { |type| type[:input] == 'domain' } 36 | end 37 | 38 | def query_type_names 39 | QUERY_TYPES.map { |type| type[:name] } 40 | end 41 | 42 | ## 43 | # Get search 44 | # 45 | # params take in search type as key and query as value 46 | # {fdns: 'rapid7.com'} 47 | # 48 | # @return [Hashie::Mash] with response of search 49 | def search(params = {}) 50 | type_query = params.select { |k, _v| query_type_names.include?(k.to_s) }.first 51 | fail ArgumentError, "The query type provided is invalid or not yet implemented." unless type_query 52 | type = type_query[0].to_sym 53 | params[:q] = type_query[1] 54 | params = extract_params(params) 55 | get_search_endpoint(type, params) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/sonar/user.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Sonar 4 | module User 5 | def usage 6 | get_search_endpoint("usage") 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/sonar/version.rb: -------------------------------------------------------------------------------- 1 | module Sonar 2 | VERSION = "0.2.0" 3 | end 4 | -------------------------------------------------------------------------------- /sonar-client.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'sonar/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "sonar-client" 8 | spec.version = Sonar::VERSION 9 | spec.authors = ["Paul Deardorff & HD Moore"] 10 | spec.email = ["paul_deardorff@rapid7.com", "hd_moore@rapid7.com"] 11 | spec.description = 'API Wrapper for Sonar' 12 | spec.summary = spec.description 13 | spec.homepage = "https://sonar.labs.rapid7.com" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 17 | spec.executables = ["sonar"] 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency 'faraday' 22 | spec.add_dependency 'faraday-rashify' 23 | spec.add_dependency 'rash_alt' 24 | spec.add_dependency 'faraday-follow_redirects' 25 | spec.add_dependency 'hashie' 26 | spec.add_dependency 'multi_json' 27 | spec.add_dependency 'thor' 28 | spec.add_dependency 'awesome_print' 29 | spec.add_dependency 'table_print' 30 | 31 | spec.add_development_dependency "bundler" 32 | spec.add_development_dependency "rake" 33 | spec.add_development_dependency "rspec" 34 | spec.add_development_dependency "simplecov" 35 | spec.add_development_dependency "simplecov-rcov" 36 | spec.add_development_dependency "yard" 37 | spec.add_development_dependency "vcr" 38 | spec.add_development_dependency "shoulda" 39 | spec.add_development_dependency "api_matchers" 40 | end 41 | -------------------------------------------------------------------------------- /spec/cassette/valid_ms_registration.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://sonar.labs.rapid7.com/api/v2/metasploit_verify 6 | body: 7 | encoding: UTF-8 8 | string: '{"product_key":"SOME-VALID-KEY"}' 9 | headers: 10 | Accept: 11 | - application/json 12 | Content-Type: 13 | - application/json 14 | User-Agent: 15 | - Sonar 0.0.8 Ruby Gem 16 | Accept-Encoding: 17 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Date: 24 | - Thu, 19 Nov 2015 04:53:25 GMT 25 | Server: 26 | - Apache 27 | X-Frame-Options: 28 | - SAMEORIGIN 29 | X-Xss-Protection: 30 | - 1; mode=block 31 | X-Content-Type-Options: 32 | - nosniff 33 | Cache-Control: 34 | - max-age=0, private, must-revalidate 35 | X-Runtime: 36 | - '3.898589' 37 | X-Powered-By: 38 | - Phusion Passenger 4.0.53 39 | Set-Cookie: 40 | - request_method=POST; path=/ 41 | Status: 42 | - 200 OK 43 | Transfer-Encoding: 44 | - chunked 45 | Content-Type: 46 | - application/json; charset=utf-8 47 | body: 48 | encoding: UTF-8 49 | string: '{"valid":true,"api_key":"YOUR-VALID-API-KEY","email":"metasploit-sdafsaefaef@rapid7.com"}' 50 | http_version: 51 | recorded_at: Thu, 19 Nov 2015 04:54:20 GMT 52 | - request: 53 | method: post 54 | uri: https://sonar.labs.rapid7.com/api/v2/metasploit_verify 55 | body: 56 | encoding: UTF-8 57 | string: '{"product_key":"SOME-VALID-KEY"}' 58 | headers: 59 | Accept: 60 | - application/json 61 | Content-Type: 62 | - application/json 63 | User-Agent: 64 | - Sonar 0.0.8 Ruby Gem 65 | X-Sonar-Token: 66 | - 6e28da9c72c1e2cecd2ac8ae890853fa72d26d55 67 | X-Sonar-Email: 68 | - marklinstop@gmail.com 69 | Accept-Encoding: 70 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 71 | response: 72 | status: 73 | code: 404 74 | message: Not Found 75 | headers: 76 | Content-Type: 77 | - application/json; charset=utf-8 78 | Date: 79 | - Mon, 23 Nov 2015 02:48:56 GMT 80 | Server: 81 | - Apache 82 | Status: 83 | - 404 Not Found 84 | X-Content-Type-Options: 85 | - nosniff 86 | X-Frame-Options: 87 | - sameorigin 88 | X-Powered-By: 89 | - Phusion Passenger 5.0.16 90 | X-Request-Id: 91 | - c5151aa7-f5d9-42d3-963e-6ce32a968921 92 | X-Runtime: 93 | - '0.003344' 94 | Content-Length: 95 | - '36' 96 | Connection: 97 | - keep-alive 98 | body: 99 | encoding: UTF-8 100 | string: '{"status":"404","error":"Not Found"}' 101 | http_version: 102 | recorded_at: Mon, 23 Nov 2015 02:49:51 GMT 103 | recorded_with: VCR 2.8.0 104 | -------------------------------------------------------------------------------- /spec/fixtures/sonar-stock.rc: -------------------------------------------------------------------------------- 1 | email: YOUR_EMAIL 2 | access_token: INVALID_SONAR_TOKEN 3 | api_url: https://sonar.labs.rapid7.com 4 | -------------------------------------------------------------------------------- /spec/fixtures/sonar.rc: -------------------------------------------------------------------------------- 1 | email: email@asdfasdfasfd.com 2 | access_token: asdfasdfasdfasdf 3 | api_url: https://sonar.labs.rapid7.com/ 4 | -------------------------------------------------------------------------------- /spec/sonar/cli_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Sonar::CLI do 5 | context 'with an invalid stock sonar.rc profile' do 6 | before do 7 | Sonar::RCFile.instance.path = "#{fixtures_path}/sonar-stock.rc" 8 | end 9 | it 'throws an exception because of errors' do 10 | expect { run_command('search fdns 8.8.8.8') }.to raise_error(Sonar::Search::SearchError) 11 | end 12 | end 13 | 14 | context "with a valid profile" do 15 | before do 16 | Sonar::RCFile.instance.path = "#{fixtures_path}/sonar.rc" 17 | end 18 | it "should return the profile" do 19 | output = run_command('profile') 20 | expect(output).to match(/email@asdfasdfasfd.com/) 21 | end 22 | 23 | context 'client that returns an rdns resp' do 24 | before do 25 | allow_any_instance_of(Sonar::Client).to receive(:search).and_return( 26 | { 'collection' => [{ 'address' => '192.168.1.1 ' }], 'more' => 'false' } 27 | ) 28 | end 29 | it 'strips whitespace from values' do 30 | output = run_command('search rdns 8.8.8.8') 31 | expect(output).to eq('{"collection":[{"address":"192.168.1.1"}],"more":"false"}') 32 | end 33 | it 'can return lines format' do 34 | output = run_command('search --format lines rdns 8.8.8.8') 35 | expect(output).to eq('{"address":"192.168.1.1"}') 36 | end 37 | end 38 | context 'client that returns processed reply with nested json' do 39 | before do 40 | allow_any_instance_of(Sonar::Client).to receive(:search).and_return( 41 | Sonar::Client.new.search(processed: '8.8.8.') 42 | ) 43 | end 44 | xit 'parses the nested value as a string' do 45 | output = run_command('search processed 8.8.8.') 46 | expect(JSON.parse(output)['collection'].first['value']['ip']).to eq('8.8.8.8') 47 | end 48 | end 49 | 50 | describe 'sonar types command' do 51 | it 'returns all sonar search types' do 52 | output = run_command('types') 53 | expect(output).to match(/Open Ports/) 54 | end 55 | end 56 | 57 | describe 'search all command' do 58 | before do 59 | allow_any_instance_of(Sonar::Client).to receive(:search).and_return( 60 | Sonar::Client.new.search(fdns: '208.118.227.20', exact: true) 61 | ) 62 | end 63 | it 'returns results when searching for an IP' do 64 | output = run_command('search all 208.118.227.20') 65 | expect(output).to match(/rapid7\.com/) 66 | end 67 | it 'returns results when searching for a domain' do 68 | output = run_command('search all rapid7.com') 69 | expect(output).to match(/208\.118\.227\.20/) 70 | end 71 | end 72 | end 73 | 74 | def run_command(args) 75 | capture(:stdout) { ret = Sonar::CLI.start(args.split) }.strip 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/sonar/client_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Sonar::Client do 5 | let(:client) { Sonar::Client.new } 6 | 7 | it "creates a Faraday::Connection" do 8 | expect(client.connection).to be_kind_of Faraday::Connection 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/sonar/registration_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Sonar::Registration do 5 | let(:client) { Sonar::Client.new } 6 | 7 | context 'POSTing a valid product key' do 8 | let (:resp) do 9 | VCR.use_cassette("valid_ms_registration") do 10 | client.register_metasploit("SOME-VALID-KEY") 11 | end 12 | end 13 | 14 | it 'responds that the license is valid' do 15 | expect(resp).to have_key('valid') 16 | expect(resp['valid']).to be(true) 17 | end 18 | it 'responds with a user email' do 19 | expect(resp).to have_key('email') 20 | expect(resp['email']).to eq('metasploit-sdafsaefaef@rapid7.com') 21 | end 22 | it 'responds with an api_key' do 23 | expect(resp).to have_key('api_key') 24 | expect(resp['api_key']).to match('YOUR-VALID-API-KEY') 25 | end 26 | end 27 | 28 | context 'POSTing an invalid product key' do 29 | let (:resp) { client.register_metasploit("DDXXXX") } 30 | 31 | it 'responds that the license is invalid' do 32 | expect(resp).to have_key('valid') 33 | expect(resp['valid']).to be(false) 34 | end 35 | it 'responds with an error message' do 36 | expect(resp).to have_key('error') 37 | expect(resp['error']).to match(/not appear to be valid/) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/sonar/search_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Sonar::Search do 5 | let(:dummy_class) { 6 | Class.new { extend Sonar::Search } 7 | } 8 | let(:client) { Sonar::Client.new } 9 | 10 | describe "#ip_search_type_names" do 11 | it 'includes ports' do 12 | expect(dummy_class.ip_search_type_names).to include('ports') 13 | end 14 | it 'does not include fdns' do 15 | expect(dummy_class.ip_search_type_names).to_not include('fdns') 16 | end 17 | end 18 | 19 | describe "#domain_search_type_names" do 20 | it 'includes fdns' do 21 | expect(dummy_class.domain_search_type_names).to include('fdns') 22 | end 23 | it 'does not include rdns' do 24 | expect(dummy_class.domain_search_type_names).to_not include('rdns') 25 | end 26 | end 27 | 28 | describe "parameters" do 29 | describe "query type" do 30 | context "with an invalid query type" do 31 | it "should raise an ArgumentError" do 32 | expect { client.search(invalid: 'something.org') }.to raise_error(ArgumentError) 33 | end 34 | end 35 | end 36 | 37 | describe "limit" do 38 | # The default size from APIv1/v2 is 25 records 39 | context "specifying the :limit to 3000 on #search" do 40 | let(:resp) { client.search(fdns: '.hp.com', limit: 3000) } 41 | 42 | it "should return a RequestIterator" do 43 | expect(resp.class).to eq(Sonar::Request::RequestIterator) 44 | end 45 | it "should return 120 x 25-record blocks" do 46 | num_blocks = 0 47 | resp.each do |resp_block| 48 | if resp_block 49 | expect(resp_block['collection'].size).to eq(25) 50 | num_blocks += 1 51 | end 52 | end 53 | expect(num_blocks).to eq(120) 54 | end 55 | end 56 | end 57 | end 58 | 59 | describe "fdns" do 60 | context "fdnsname" do 61 | let(:resp) { client.search(fdns: 'rapid7.com') } 62 | 63 | it "returns hashie response of search" do 64 | expect(resp.class).to eq(Hashie::Mash::Rash) 65 | end 66 | it "finds fdnsname multiple IP addresses for rapid7.com" do 67 | expect(resp['collection'].select { |x| x['address'] }.size).to be >= 2 68 | end 69 | end 70 | 71 | context "fdnsip" do 72 | let(:resp) { client.search(fdns: '208.118.227.10') } 73 | 74 | it "finds fdnsip rapid7 domains at 208.118.227.10" do 75 | expect(resp['collection'].any? { |x| x['address'].match('rapidseven') }).to be(true) 76 | end 77 | end 78 | 79 | context "validation" do 80 | let(:resp) { client.search(fdns: '188.40.56.11@#&#') } 81 | 82 | it "should error for invalid domain query type" do 83 | expect(resp["error"]).to eq("Invalid query") 84 | expect(resp["errors"].first).to eq("An unsupported gTLD or ccTLD was specified for: 188.40.56.11@#&#") 85 | end 86 | end 87 | end 88 | 89 | # TODO: actually check response 90 | context "ports" do 91 | let(:resp) { client.search(ports: '208.118.227.10') } 92 | 93 | it "should return a collection" do 94 | expect(resp).to have_key('collection') 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/sonar/user_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Sonar::User do 5 | let(:client) { Sonar::Client.new } 6 | 7 | context "with a valid client querying usage" do 8 | let(:res) { client.usage } 9 | 10 | it "should show usage information for the user" do 11 | expect(res.user.api_token).to_not be_nil 12 | expect(res.current_api_hits).to be >= 0 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/sonar_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | # Skip the configure in spec_helper so we can test defaults 5 | describe Sonar, skip_autoconfig: true do 6 | let(:client) { Sonar::Client.new } 7 | before :each do 8 | reset_sonar_config 9 | end 10 | 11 | context "configure defaults" do 12 | it "uses default API URL" do 13 | expect(client.api_url).to eq 'https://sonar.labs.rapid7.com' 14 | end 15 | it "uses default API VERSION" do 16 | expect(client.api_version).to eq 'v2' 17 | end 18 | end 19 | 20 | context "handles custom configuration for url and version" do 21 | let(:new_client) do 22 | Sonar::Client.new( 23 | api_url: 'https://somethingnew.com', 24 | api_version: 'v1' 25 | ) 26 | end 27 | 28 | it "::Client API_URL configuration" do 29 | expect(new_client.api_url).to eq 'https://somethingnew.com' 30 | end 31 | it "::Client API_VERSION configuration" do 32 | expect(new_client.api_version).to eq 'v1' 33 | end 34 | end 35 | 36 | context "when using a configure block and setting api_version" do 37 | before do 38 | Sonar.configure do |c| 39 | c.api_version = "v3" 40 | end 41 | end 42 | 43 | it "should have set the custom api_version" do 44 | expect(Sonar.api_version).to eq("v3") 45 | end 46 | it "should use the default api_url" do 47 | expect(Sonar.api_url).to eq("https://sonar.labs.rapid7.com") 48 | end 49 | end 50 | 51 | context "when making a request to the client with bad creds" do 52 | before do 53 | Sonar.configure do |c| 54 | c.email = "wrong@sowrong.com" 55 | c.access_token = "somewrongkey" 56 | c.api_version = "v2" 57 | end 58 | puts Sonar.api_url 59 | client = Sonar::Client.new 60 | @resp = client.search(fdns: "hp.com") 61 | end 62 | 63 | it "should return unauthorized" do 64 | expect(@resp["error"]).to eq("Could not authenticate") 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'sonar' 2 | require 'active_support' 3 | require 'vcr' 4 | require 'simplecov' 5 | require 'simplecov-rcov' 6 | require 'api_matchers' 7 | 8 | class SimpleCov::Formatter::MergedFormatter 9 | def format(result) 10 | SimpleCov::Formatter::HTMLFormatter.new.format(result) 11 | SimpleCov::Formatter::RcovFormatter.new.format(result) 12 | end 13 | end 14 | 15 | SimpleCov.formatter = SimpleCov::Formatter::MergedFormatter 16 | SimpleCov.start do 17 | add_filter '/vendor' 18 | end 19 | 20 | VCR.configure do |c| 21 | c.allow_http_connections_when_no_cassette = true 22 | c.cassette_library_dir = 'spec/cassette' 23 | c.hook_into :faraday 24 | c.configure_rspec_metadata! 25 | c.default_cassette_options = { record: :new_episodes } 26 | end 27 | 28 | RSpec.configure do |c| 29 | c.include APIMatchers::RSpecMatchers 30 | 31 | # 32 | # Add gem specific configuration for easy access 33 | # 34 | c.before(:each) do 35 | # TODO: move to using a gem like VCR for faking HTTP requests. 36 | # For now we'll test against the staging server using 37 | # real creds stored in env. 38 | Sonar.configure do |config| 39 | unless ENV['SONAR_TOKEN'] && ENV['SONAR_EMAIL'] 40 | fail ArgumentError, "Please configure Sonar for testing by setting SONAR_TOKEN, SONAR_EMAIL, 41 | and SONAR_API_URL in your environment." 42 | end 43 | config.api_url = ENV['SONAR_API_URL'] || 'http://localhost:3000' 44 | config.api_version = 'v2' 45 | config.access_token = ENV['SONAR_TOKEN'] 46 | config.email = ENV['SONAR_EMAIL'] 47 | end 48 | end 49 | end 50 | 51 | def capture(stream) 52 | begin 53 | stream = stream.to_s 54 | eval "$#{stream} = StringIO.new" 55 | yield 56 | result = eval("$#{stream}").string 57 | ensure 58 | eval("$#{stream} = #{stream.upcase}") 59 | end 60 | 61 | result 62 | end 63 | 64 | def fixtures_path 65 | File.expand_path('../fixtures', __FILE__) 66 | end 67 | 68 | def reset_sonar_config 69 | Sonar.remove_class_variable(:@@api_url) if Sonar.class_variable_defined?(:@@api_url) 70 | Sonar.remove_class_variable(:@@api_version) if Sonar.class_variable_defined?(:@@api_version) 71 | end 72 | --------------------------------------------------------------------------------