diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..009ea3d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + pull_request: + branches: + - master + - dev + - dev-* + +jobs: + ci: + runs-on: ubuntu-latest + name: CI + steps: + - name: Notify slack success + if: success() + id: slack # IMPORTANT: reference this step ID value in future Slack steps + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@v1.1.1 + with: + channel: github-actions + status: STARTING + color: warning + + - uses: actions/checkout@v1 + - uses: actions/setup-ruby@v1 + with: + ruby-version: 2.6.x + - name: Install dependencies + run: | + gem install bundler + bundler install + - name: Run tests + run: bundler exec rspec + + - name: Notify slack success + if: success() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@v1.1.1 + with: + message_id: ${{ steps.slack.outputs.message_id }} + channel: github-actions + status: SUCCESS + color: good + + - name: Notify slack fail + if: failure() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@v1.1.1 + with: + message_id: ${{ steps.slack.outputs.message_id }} + channel: github-actions + status: FAILED + color: danger diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..2bec702 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,60 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + name: Publish + runs-on: ubuntu-latest + steps: + - name: Notify slack success + if: success() + id: slack # IMPORTANT: reference this step ID value in future Slack steps + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@v1.1.1 + with: + channel: github-actions + status: STARTING + color: warning + + - uses: actions/checkout@v2 + - name: Set up Ruby 2.6 + uses: actions/setup-ruby@v1 + with: + version: 2.6.x + + - name: Publish to RubyGems + run: | + mkdir -p $HOME/.gem + touch $HOME/.gem/credentials + chmod 0600 $HOME/.gem/credentials + printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials + gem build *.gemspec + gem push *.gem + env: + GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} + + - name: Notify slack success + if: success() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@v1.1.1 + with: + message_id: ${{ steps.slack.outputs.message_id }} + channel: github-actions + status: SUCCESS + color: good + + - name: Notify slack fail + if: failure() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@v1.1.1 + with: + message_id: ${{ steps.slack.outputs.message_id }} + channel: github-actions + status: FAILED + color: danger \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..31475ca --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,45 @@ +name: Testing + +on: + push: + branches: + - '*' + - '!master' + +jobs: + test: + name: Testing + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v1 + - uses: actions/setup-ruby@v1 + with: + ruby-version: 2.6.x + - name: Install dependencies + run: | + gem install bundler + bundler install + - name: Run tests + run: bundler exec rspec + + - name: Notify slack success + if: success() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@v1.1.1 + with: + message_id: ${{ steps.slack.outputs.message_id }} + channel: github-actions + status: SUCCESS + color: good + + - name: Notify slack fail + if: failure() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + uses: voxmedia/github-action-slack-notify-build@v1.1.1 + with: + message_id: ${{ steps.slack.outputs.message_id }} + channel: github-actions + status: FAILED + color: danger \ No newline at end of file diff --git a/.rakeTasks b/.rakeTasks new file mode 100644 index 0000000..c6865d9 --- /dev/null +++ b/.rakeTasks @@ -0,0 +1,7 @@ + + diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..8dd84f7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +--- +language: ruby +cache: bundler +rvm: + - 2.7.1 +before_install: gem install bundler -v 2.1.2 diff --git a/Gemfile b/Gemfile index 5914d4b..7b08844 100644 --- a/Gemfile +++ b/Gemfile @@ -3,4 +3,7 @@ source "https://rubygems.org" gemspec gem "rspec" gem "rake" -gem "httpclient" \ No newline at end of file +gem "httpclient" +gem "parseconfig" +gem "webmock" +gem "codecov", :require => false, :group => :test \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 4ee5fa5..1d3e55a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,13 +1,26 @@ PATH remote: . specs: - securenative (0.1.5) + securenative (0.1.16) GEM remote: https://rubygems.org/ specs: + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) + codecov (0.1.17) + json + simplecov + url + crack (0.4.3) + safe_yaml (~> 1.0.0) diff-lcs (1.3) + docile (1.3.2) + hashdiff (1.0.1) httpclient (2.8.3) + json (2.3.0) + parseconfig (1.0.8) + public_suffix (4.0.5) rake (12.3.3) rspec (3.8.0) rspec-core (~> 3.8.0) @@ -22,16 +35,29 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.8.0) rspec-support (3.8.2) + safe_yaml (1.0.5) + simplecov (0.18.5) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov-html (0.12.2) + url (0.3.2) + webmock (3.8.3) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) PLATFORMS ruby DEPENDENCIES bundler (~> 2.0) + codecov httpclient + parseconfig rake rspec securenative! + webmock BUNDLED WITH - 2.1.0.pre.1 + 2.1.4 diff --git a/README.md b/README.md index 909e2c1..f12c8a4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,32 @@ -# SecureNative -**[SecureNative](https://www.securenative.com/) is rethinking-security-as-a-service, disrupting the cyber security space and the way enterprises consume and implement security solutions.** - -## Installation +

+ SecureNative Logo +

+ +

+ A Cloud-Native Security Monitoring and Protection for Modern Applications +

+

+ + Github Actions + + + + + Gem Version +

+

+ Documentation | + Quick Start | + Blog | + Chat with us on Slack! +

+
+ + +[SecureNative](https://www.securenative.com/) performs user monitoring by analyzing user interactions with your application and various factors such as network, devices, locations and access patterns to stop and prevent account takeover attacks. + + +## Install the SDK Add this line to your application's Gemfile: @@ -9,7 +34,7 @@ Add this line to your application's Gemfile: gem 'securenative' ``` -And then execute: +Then execute: $ bundle @@ -18,85 +43,128 @@ Or install it yourself as: $ gem install securenative ## Initialize the SDK -Retrieve your API key from settings page of your SecureNative account and use the following in your code to initialize and use the sdk. + +To get your *API KEY*, login to your SecureNative account and go to project settings page: + +### Option 1: Initialize via Config file +SecureNative can automatically load your config from *securenative.cfg* file or from the file that is specified in your *SECURENATIVE_CONFIG_FILE* env variable: + ```ruby require 'securenative' -# Do some cool stuff here -# ... -# ... - -begin - SecureNative.init('YOUR_API_KEY') # Should be called before any other call to securenative -rescue SecureNativeSDKException => e - # Do some error handling -end + +secureative = SecureNative.init +``` +### Option 2: Initialize via API Key + +```ruby +require 'securenative' + + +securenative = SecureNative.init_with_api_key('YOUR_API_KEY') +``` + +### Option 3: Initialize via ConfigurationBuilder +```ruby +require 'securenative' + + +securenative = SecureNative.init_with_options(SecureNative.config_builder(api_key = 'API_KEY', max_event = 10, log_level = 'ERROR')) +``` + +## Getting SecureNative instance +Once initialized, sdk will create a singleton instance which you can get: +```ruby +require 'securenative' + + +secureNative = SecureNative.instance ``` ## Tracking events -Once the SDK has been initialized, you can start sending new events with the `track` function: + +Once the SDK has been initialized, tracking requests sent through the SDK +instance. Make sure you build event with the EventBuilder: + + ```ruby +require 'securenative' +require 'securenative/enums/event_types' +require 'securenative/event_options_builder' +require 'securenative/models/user_traits' +require 'securenative/context/context_builder' + + +securenative = SecureNative.instance + +context = securenative.context_builder(ip = '127.0.0.1', client_token = 'SECURED_CLIENT_TOKEN', + headers = { 'user-agent' => 'Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Mobile/7B405' }) + +event_options = EventOptionsBuilder(event_type = EventTypes::LOG_IN, + user_id = 'USER_ID', user_traits = UserTraits('USER_NAME', 'USER_EMAIL'), + context = context, properties = {prop1 => 'CUSTOM_PARAM_VALUE', prop2 => true, prop3 => 3}).build + +securenative.track(event_options) + ``` + +You can also create request context from requests: + ```ruby require 'securenative' -require 'securenative/event_type' - - -def login - # Do some cool stuff here - # ... - # ... - - SecureNative.track(Event.new( - event_type = EventTypes::LOG_IN, - user: User.new("1", "Jon Snow", "jon@snow.com"), - ip: "1.2.3.4", - remote_ip: "5.6.7.8", - user_agent: "Mozilla/5.0 (Linux; U; Android 4.4.2; zh-cn; GT-I9500 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko)Version/4.0 MQQBrowser/5.0 QQ-URL-Manager Mobile Safari/537.36", - sn_cookie: "cookie")) +require 'securenative/enums/event_types' +require 'securenative/event_options_builder' +require 'securenative/models/user_traits' + + +def track(request) + securenative = SecureNative.instance + context = SecureNative.context_builder.from_http_request(request).build + + event_options = EventOptionsBuilder(event_type = EventTypes::LOG_IN, + user_id = 'USER_ID', user_traits = UserTraits('USER_NAME', 'USER_EMAIL'), + context = context, properties = {prop1 => 'CUSTOM_PARAM_VALUE', prop2 => true, prop3 => 3}).build + + securenative.track(event_options) end ``` -## Verification events -Once the SDK has been initialized, you can start sending new events with the `verify` function: +## Verify events + +**Example** + ```ruby require 'securenative' -require 'securenative/event_type' - - -def reset_password - res = SecureNative.verify(Event.new( - event_type = EventTypes::PASSWORD_RESET, - user: User.new("1", "Jon Snow", "jon@snow.com"), - ip: "1.2.3.4", - remote_ip: "5.6.7.8", - user_agent: "Mozilla/5.0 (Linux; U; Android 4.4.2; zh-cn; GT-I9500 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko)Version/4.0 MQQBrowser/5.0 QQ-URL-Manager Mobile Safari/537.36", - sn_cookie: "cookie")) - - if res['riskLevel'] == 'high' - return 'Cannot change password' - else res['riskLevel'] == 'medium' - return 'MFA' - end +require 'securenative/enums/event_types' +require 'securenative/event_options_builder' +require 'securenative/models/user_traits' + + +def track(request) + securenative = SecureNative.instance + context = SecureNative.context_builder.from_http_request(request).build + + event_options = EventOptionsBuilder(event_type = EventTypes::LOG_IN, + user_id = 'USER_ID', user_traits = UserTraits('USER_NAME', 'USER_EMAIL'), + context = context, properties = {prop1 => 'CUSTOM_PARAM_VALUE', prop2 => true, prop3 => 3}).build + + verify_result = securenative.verify(event_options) + verify_result.risk_level # Low, Medium, High + verify_result.score # Risk score: 0 -1 (0 - Very Low, 1 - Very High) + verify_result.triggers # ["TOR", "New IP", "New City"] end ``` -## Using webhooks -You can use the SDK to verify incoming webhooks from SecureNative, just call the `veriy_webhook` function which return a boolean which indicates if the webhook came from Secure Native servers. +## Webhook signature verification + +Apply our filter to verify the request is from us, for example: + ```ruby require 'securenative' -begin - SecureNative.init('YOUR_API_KEY') # Should be called before any other call to securenative -rescue SecureNativeSDKException => e - # Do some error handling -end -def handle_some_code(headers, body) - sig_header = headers["X-SecureNative"] - if SecureNative.verify_webhook(sig_header, body) - # Handle the webhook - level = body['riskLevel'] - else - # This request wasn't sent from Secure Native servers, you can dismiss/investigate it - end +def webhook_endpoint(request) + securenative = SecureNative.instance + + # Checks if request is verified + is_verified = securenative.verify_request_payload(request) end -``` + ``` \ No newline at end of file diff --git a/Rakefile b/Rakefile index 6a164d1..f912feb 100644 --- a/Rakefile +++ b/Rakefile @@ -1,2 +1,6 @@ require "bundler/gem_tasks" -task :default => :spec \ No newline at end of file +task :default => :spec + +require 'rspec/core/rake_task' +task :default => :spec +RSpec::Core::RakeTask.new \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..99a25fc --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.16 \ No newline at end of file diff --git a/lib/securenative.rb b/lib/securenative.rb deleted file mode 100644 index ee6f2d3..0000000 --- a/lib/securenative.rb +++ /dev/null @@ -1,45 +0,0 @@ -require_relative 'securenative/sn_exception' -require_relative 'securenative/secure_native_sdk' -require 'logger' - -$securenative = nil -$logger = Logger.new(STDOUT) -$logger.level = Logger::INFO - -module SecureNative - def self.init(api_key, options: SecureNativeOptions.new) - if $securenative == nil - $securenative = SecureNativeSDK.new(api_key, options: options) - else - $logger.info("This SDK was already initialized") - raise StandardError.new("This SDK was already initialized") - end - end - - def self.track(event) - sdk = _get_or_throw - sdk.track(event) - end - - def self.verify(event) - sdk = _get_or_throw - sdk.verify(event) - end - - def self.verify_webhook(hmac_header, body) - sdk = _get_or_throw - sdk.verify_webhook(hmac_header = hmac_header, body = body) - end - - def self.flush - sdk = _get_or_throw - sdk.flush - end - - def self._get_or_throw - if $securenative == nil - raise SecureNativeSDKException.new - end - $securenative - end -end \ No newline at end of file diff --git a/lib/securenative/api_manager.rb b/lib/securenative/api_manager.rb new file mode 100644 index 0000000..0d6e641 --- /dev/null +++ b/lib/securenative/api_manager.rb @@ -0,0 +1,30 @@ +require 'json' +require_relative 'logger' + +class ApiManager + def initialize(event_manager, securenative_options) + @event_manager = event_manager + @options = securenative_options + end + + def track(event_options) + Logger.debug('Track event call') + event = SDKEvent.new(event_options, @options) + @event_manager.send_async(event, ApiRoute::TRACK) + end + + def verify(event_options) + Logger.debug('Verify event call') + event = SDKEvent.new(event_options, @options) + + begin + res = JSON.parse(@event_manager.send_sync(event, ApiRoute::VERIFY, false)) + return VerifyResult.new(res['riskLevel'], res['score'], res['triggers']) + rescue StandardError => e + Logger.debug('Failed to call verify; {}'.format(e)) + end + return VerifyResult.new(RiskLevel::LOW, 0, nil) if @options.fail_over_strategy == FailOverStrategy::FAIL_OPEN + + VerifyResult.new(RiskLevel::HIGH, 1, nil) + end +end \ No newline at end of file diff --git a/lib/securenative/config.rb b/lib/securenative/config.rb deleted file mode 100644 index 2a02d27..0000000 --- a/lib/securenative/config.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Config - SDK_VERSION = '0.1.5' - MAX_ALLOWED_PARAMS = 6 - API_URL_PROD = "https://api.securenative.com/collector/api/v1" - API_URL_STG = "https://api.securenative-stg.com/collector/api/v1" - TRACK_EVENT = "/track" - VERIFY_EVENT = "/verify" - FLOW_EVENT = "/flow" - CIPHER_SIZE = 256 - AES_BLOCK_SIZE = 32 -end \ No newline at end of file diff --git a/lib/securenative/config/configuration_builder.rb b/lib/securenative/config/configuration_builder.rb new file mode 100644 index 0000000..8cd563b --- /dev/null +++ b/lib/securenative/config/configuration_builder.rb @@ -0,0 +1,26 @@ +class ConfigurationBuilder + attr_reader :api_key, :api_url, :interval, :max_events, :timeout, :auto_send, :disable, :log_level, :fail_over_strategy + attr_writer :api_key, :api_url, :interval, :max_events, :timeout, :auto_send, :disable, :log_level, :fail_over_strategy + + def initialize(api_key = nil, api_url = 'https://api.securenative.com/collector/api/v1', interval = 1000, + max_events = 1000, timeout = 1500, auto_send = true, disable = false, log_level = 'FATAL', + fail_over_strategy = FailOverStrategy::FAIL_OPEN) + @api_key = api_key + @api_url = api_url + @interval = interval + @max_events = max_events + @timeout = timeout + @auto_send = auto_send + @disable = disable + @log_level = log_level + @fail_over_strategy = fail_over_strategy + end + + def self.default_config_builder + ConfigurationBuilder() + end + + def self.default_securenative_options + SecureNativeOptions() + end +end \ No newline at end of file diff --git a/lib/securenative/config/configuration_manager.rb b/lib/securenative/config/configuration_manager.rb new file mode 100644 index 0000000..cd28445 --- /dev/null +++ b/lib/securenative/config/configuration_manager.rb @@ -0,0 +1,53 @@ +require 'parseconfig' + +class ConfigurationManager + DEFAULT_CONFIG_FILE = 'securenative.cfg'.freeze + CUSTOM_CONFIG_FILE_ENV_NAME = 'SECURENATIVE_COMFIG_FILE'.freeze + @config = nil + + def self.read_resource_file(resource_path) + @config = ParseConfig.new(resource_path) + + properties = {} + @config.get_groups.each { |group| + group.each do |key, value| + properties[key.upcase] = value + end + } + properties + end + + def self._get_resource_path(env_name) + Env.fetch(env_name, ENV[DEFAULT_CONFIG_FILE]) + end + + def self.config_builder + ConfigurationBuilder.default_config_builder + end + + def self._get_env_or_default(properties, key, default) + return Env[key] if Env[key] + return properties[key] if properties[key] + + default + end + + def self.load_config + options = ConfigurationBuilder().default_securenative_options + + resource_path = DEFAULT_CONFIG_FILE + resource_path = Env[CUSTOM_CONFIG_FILE_ENV_NAME] if Env[CUSTOM_CONFIG_FILE_ENV_NAME] + + properties = read_resource_file(resource_path) + + ConfigurationBuilder(_get_env_or_default(properties, 'SECURENATIVE_API_KEY', options.api_key), + _get_env_or_default(properties, 'SECURENATIVE_API_URL', options.api_url), + _get_env_or_default(properties, 'SECURENATIVE_INTERVAL', options.interval), + _get_env_or_default(properties, 'SECURENATIVE_MAX_EVENTS', options.max_events), + _get_env_or_default(properties, 'SECURENATIVE_TIMEOUT', options.timeout), + _get_env_or_default(properties, 'SECURENATIVE_AUTO_SEND', options.auto_send), + _get_env_or_default(properties, 'SECURENATIVE_DISABLE', options.disable), + _get_env_or_default(properties, 'SECURENATIVE_LOG_LEVEL', options.log_level), + _get_env_or_default(properties, 'SECURENATIVE_FAILOVER_STRATEGY', options.fail_over_strategy)) + end +end \ No newline at end of file diff --git a/lib/securenative/config/securenative_options.rb b/lib/securenative/config/securenative_options.rb new file mode 100644 index 0000000..b801cb8 --- /dev/null +++ b/lib/securenative/config/securenative_options.rb @@ -0,0 +1,18 @@ +class SecureNativeOptions + attr_reader :api_key, :api_url, :interval, :max_events, :timeout, :auto_send, :disable, :log_level, :fail_over_strategy + attr_writer :api_key, :api_url, :interval, :max_events, :timeout, :auto_send, :disable, :log_level, :fail_over_strategy + + def initialize(api_key = nil, api_url = "https://api.securenative.com/collector/api/v1", interval = 1000, + max_events = 1000, timeout = 1500, auto_send = true, disable = false, log_level = "FATAL", + fail_over_strategy = FailOverStrategy::FAIL_OPEN) + @api_key = api_key + @api_url = api_url + @interval = interval + @max_events = max_events + @timeout = timeout + @auto_send = auto_send + @disable = disable + @log_level = log_level + @fail_over_strategy = fail_over_strategy + end +end \ No newline at end of file diff --git a/lib/securenative/context/context_builder.rb b/lib/securenative/context/context_builder.rb new file mode 100644 index 0000000..297d42f --- /dev/null +++ b/lib/securenative/context/context_builder.rb @@ -0,0 +1,59 @@ +class ContextBuilder + attr_reader :context + + def initialize(client_token = nil, ip = nil, remote_ip = nil, headers = nil, url = nil, method = nil, body = nil) + @context = SecureNativeContext(client_token, ip, remote_ip, headers, url, method, body) + end + + def client_token(client_token) + @context.client_token = client_token + end + + def ip(ip) + @context.ip = ip + end + + def remote_ip(remote_ip) + @context.remote_ip = remote_ip + end + + def headers(headers) + @context.headers = headers + end + + def url(url) + @context.url = url + end + + def method(method) + @context.method = method + end + + def body(body) + @context.body = body + end + + def self.default_context_builder + ContextBuilder() + end + + def self.from_http_request(request) + begin + client_token = request.cookies[RequestUtils.SECURENATIVE_COOKIE] + rescue StandardError + client_token = nil + end + + begin + headers = request.headers + rescue StandardError + headers = nil + end + + client_token = RequestUtils.get_secure_header_from_request(headers) if Utils.null_or_empty?(client_token) + + ContextBuilder(url = request.url, method = request.method, header = headers, client_token = client_token, + client_ip = RequestUtils.get_client_ip_from_request(request), + remote_ip = RequestUtils.get_remote_ip_from_request(request), nil) + end +end \ No newline at end of file diff --git a/lib/securenative/context/securenative_context.rb b/lib/securenative/context/securenative_context.rb new file mode 100644 index 0000000..efb2a46 --- /dev/null +++ b/lib/securenative/context/securenative_context.rb @@ -0,0 +1,14 @@ +class SecureNativeContext + attr_reader :client_token, :ip, :remote_ip, :headers, :url, :method, :body + attr_writer :client_token, :ip, :remote_ip, :headers, :url, :method, :body + + def initialize(client_token = nil, ip = nil, remote_ip = nil, headers = nil, url = nil, method = nil, body = nil) + @client_token = client_token + @ip = ip + @remote_ip = remote_ip + @headers = headers + @url = url + @method = method + @body = body + end +end \ No newline at end of file diff --git a/lib/securenative/enums/api_route.rb b/lib/securenative/enums/api_route.rb new file mode 100644 index 0000000..84c4a00 --- /dev/null +++ b/lib/securenative/enums/api_route.rb @@ -0,0 +1,4 @@ +module ApiRoute + TRACK = 'track'.freeze + VERIFY = 'verify'.freeze +end \ No newline at end of file diff --git a/lib/securenative/enums/event_types.rb b/lib/securenative/enums/event_types.rb new file mode 100644 index 0000000..76be140 --- /dev/null +++ b/lib/securenative/enums/event_types.rb @@ -0,0 +1,21 @@ +module EventTypes + LOG_IN = 'sn.user.login'.freeze + LOG_IN_CHALLENGE = 'sn.user.login.challenge'.freeze + LOG_IN_FAILURE = 'sn.user.login.failure'.freeze + LOG_OUT = 'sn.user.logout'.freeze + SIGN_UP = 'sn.user.signup'.freeze + AUTH_CHALLENGE = 'sn.user.auth.challenge'.freeze + AUTH_CHALLENGE_SUCCESS = 'sn.user.auth.challenge.success'.freeze + AUTH_CHALLENGE_FAILURE = 'sn.user.auth.challenge.failure'.freeze + TWO_FACTOR_DISABLE = 'sn.user.2fa.disable'.freeze + EMAIL_UPDATE = 'sn.user.email.update'.freeze + PASSWORD_REST = 'sn.user.password.reset'.freeze + PASSWORD_REST_SUCCESS = 'sn.user.password.reset.success'.freeze + PASSWORD_UPDATE = 'sn.user.password.update'.freeze + PASSWORD_REST_FAILURE = 'sn.user.password.reset.failure'.freeze + USER_INVITE = 'sn.user.invite'.freeze + ROLE_UPDATE = 'sn.user.role.update'.freeze + PROFILE_UPDATE = 'sn.user.profile.update'.freeze + PAGE_VIEW = 'sn.user.page.view'.freeze + VERIFY = 'sn.verify'.freeze +end \ No newline at end of file diff --git a/lib/securenative/enums/failover_strategy.rb b/lib/securenative/enums/failover_strategy.rb new file mode 100644 index 0000000..3ba4df6 --- /dev/null +++ b/lib/securenative/enums/failover_strategy.rb @@ -0,0 +1,4 @@ +module FailOverStrategy + FAIL_OPEN = 'fail-open'.freeze + FAIL_CLOSED = 'fail-closed'.freeze +end \ No newline at end of file diff --git a/lib/securenative/enums/risk_level.rb b/lib/securenative/enums/risk_level.rb new file mode 100644 index 0000000..01343c4 --- /dev/null +++ b/lib/securenative/enums/risk_level.rb @@ -0,0 +1,5 @@ +module RiskLevel + LOW = 'low'.freeze + MEDIUM = 'medium'.freeze + HIGH = 'high'.freeze +end \ No newline at end of file diff --git a/lib/securenative/event_manager.rb b/lib/securenative/event_manager.rb index fb7f4c8..cc6525b 100644 --- a/lib/securenative/event_manager.rb +++ b/lib/securenative/event_manager.rb @@ -1,88 +1,149 @@ -require_relative 'securenative_options' -require_relative 'http_client' -require_relative 'sn_exception' -require 'json' -require 'thread' +require_relative 'logger' class QueueItem - def initialize(url, body) + attr_reader :url, :body, :retry + attr_writer :url, :body, :retry + + def initialize(url, body, _retry) @url = url @body = body + @retry = _retry end - - attr_reader :url - attr_reader :body end class EventManager - def initialize(api_key, options: SecureNativeOptions.new, http_client: HttpClient.new) - if api_key == nil - raise SecureNativeSDKException.new + def initialize(options = SecureNativeOptions(), http_client = nil) + if options.api_key.nil? + raise SecureNativeSDKException('API key cannot be None, please get your API key from SecureNative console.') end - @api_key = api_key + @http_client = if http_client.nil? + SecureNativeHttpClient(options) + else + http_client + end + + @queue = [] + @thread = Thread.new(run) + @thread.start + @options = options - @http_client = http_client - @queue = Queue.new - - if @options.auto_send - interval_seconds = [(@options.interval / 1000).floor, 1].max - Thread.new do - loop do - flush - sleep(interval_seconds) - end - end - end + @send_enabled = false + @attempt = 0 + @coefficients = [1, 1, 2, 3, 5, 8, 13] + @thread = nil + @interval = options.interval end - def send_async(event, path) - q_item = QueueItem.new(build_url(path), event) - @queue.push(q_item) - end + def send_async(event, resource_path) + if @options.disable + Logger.warning('SDK is disabled. no operation will be performed') + return + end - def send_sync(event, path) - @http_client.post( - build_url(path), - @api_key, - event.to_hash.to_json - ) + item = QueueItem(resource_path, JSON.parse(EventManager.serialize(event)), false) + @queue.append(item) end def flush - if is_queue_full - i = @options.max_events - 1 - while i >= 0 - item = @queue.pop - @http_client.post( - build_url(item.url), - @api_key, - item.body.to_hash.to_json - ) - end - else - q = Array.new(@queue.size) {@queue.pop} - q.each do |item| - @http_client.post( - build_url(item.url), - @api_key, - item.body - ) - @queue = Queue.new - end + @queue.each do |item| + @http_client.post(item.url, item.body) end end - private + def send_sync(event, resource_path, _retry) + if @options.disable + Logger.warning('SDK is disabled. no operation will be performed') + return + end + + Logger.debug('Attempting to send event {}'.format(event)) + res = @http_client.post(resource_path, JSON.parse(EventManager.serialize(event))) - def build_url(path) - @options.api_url + path + if res.status_code != 200 + Logger.info('SecureNative failed to call endpoint {} with event {}. adding back to queue'.format(resource_path, event)) + item = QueueItem(resource_path, JSON.parse(EventManager.serialize(event)), _retry) + @queue.append(item) + end + + res + end + + def run + loop do + next unless !@queue.empty? && @send_enabled + + @queue.each do |item| + begin + res = @http_client.post(item.url, item.body) + if res.status_code == 401 + item.retry = false + elsif res.status_code != 200 + raise SecureNativeHttpException(res.status_code) + end + Logger.debug('Event successfully sent; {}'.format(item.body)) + return res + rescue StandardError => e + Logger.error('Failed to send event; {}'.format(e)) + if item.retry + @attempt = 0 if @coefficients.length == @attempt + 1 + + back_off = @coefficients[@attempt] * @options.interval + Logger.debug('Automatic back-off of {}'.format(back_off)) + @send_enabled = false + sleep back_off + @send_enabled = true + end + end + end + sleep @interval / 1000 + end end - private + def start_event_persist + Logger.debug('Starting automatic event persistence') + if @options.auto_send || @send_enabled + @send_enabled = true + else + Logger.debug('Automatic event persistence is disabled, you should persist events manually') + end + end - def is_queue_full - @queue.length > @options.max_events + def stop_event_persist + if @send_enabled + Logger.debug('Attempting to stop automatic event persistence') + begin + flush + @thread&.stop + Logger.debug('Stopped event persistence') + rescue StandardError => e + Logger.error('Could not stop event scheduler; {}'.format(e)) + end + end end + def self.serialize(obj) + { + rid: obj.rid, + eventType: obj.event_type, + userId: obj.user_id, + userTraits: { + name: obj.user_traits.name, + email: obj.user_traits.email, + createdAt: obj.user_traits.created_at + }, + request: { + cid: obj.request.cid, + vid: obj.request.vid, + fp: obj.request.fp, + ip: obj.request.ip, + remoteIp: obj.request.remote_ip, + method: obj.request.method, + url: obj.request.url, + headers: obj.request.headers + }, + timestamp: obj.timestamp, + properties: obj.properties + } + end end \ No newline at end of file diff --git a/lib/securenative/event_options.rb b/lib/securenative/event_options.rb deleted file mode 100644 index 0896e8d..0000000 --- a/lib/securenative/event_options.rb +++ /dev/null @@ -1,88 +0,0 @@ -require 'securerandom' - -class User - def initialize(user_id: "", user_email: "", user_name: "") - @user_id = user_id - @user_email = user_email - @user_name = user_name - end - - attr_reader :user_id - attr_reader :user_email - attr_reader :user_name -end - -class Event - def initialize(event_type, user: User(), ip: "127.0.0.1", remote_ip: "127.0.0.1", user_agent: "unknown", sn_cookie: nil, params: nil) - @event_type = event_type - @user = user - @ip = ip - @remote_ip = remote_ip - @user_agent = user_agent - @cid = "" - @fp = "" - - if params != nil - if params.instance_of?(CustomParam) - raise ArgumentError.new("custom params should be a list of CustomParams") - else - @params = params - end - end - @params = params - - if sn_cookie - @cid, @cid = Utils.parse_cookie(sn_cookie) - end - @vid = SecureRandom.uuid - @ts = Time.now.getutc.to_i - end - - def to_hash - p = Array.new - if @params - @params.each do |param| - p << {:key => param.key, :value => param.value} - end - end - - { - :eventType => @event_type, - :user => { - :id => @user.user_id, - :email => @user.user_email, - :name => @user.user_name - }, - :remoteIP => @remote_ip, - :ip => @ip, - :cid => @cid, - :fp => @fp, - :ts => @ts, - :vid => @vid, - :userAgent => @user_agent, - :device => Hash.new, - :params => p - } - end - - attr_reader :cid - attr_reader :params - attr_reader :user_agent - attr_reader :user - attr_reader :remote_ip - attr_reader :event_type - attr_reader :fp - attr_reader :ip - attr_reader :ts - attr_reader :vid -end - -class CustomParam - def initialize(key, value) - @key = key - @value = value - end - - attr_reader :key - attr_reader :value -end diff --git a/lib/securenative/event_options_builder.rb b/lib/securenative/event_options_builder.rb new file mode 100644 index 0000000..f931a21 --- /dev/null +++ b/lib/securenative/event_options_builder.rb @@ -0,0 +1,21 @@ +class EventOptionsBuilder + MAX_PROPERTIES_SIZE = 10 + + def initialize(event_type, user_id, user_traits, user_name, email, created_at, context, properties, timestamp) + @event_options = EventOptions(event_type) + @event_options.user_id = user_id + @event_options.user_traits = user_traits if user_traits + @event_options.user_traits = UserTraits(name, email, created_at) if user_name && email && created_at + @event_options.context = context + @event_options.properties = properties + @event_options.timestamp = timestamp + end + + def build + if !@event_options.properties.nil? && @event_options.properties.length > MAX_PROPERTIES_SIZE + raise SecureNativeInvalidOptionsException('You can have only up to {} custom properties', MAX_PROPERTIES_SIZE) + end + + @event_options + end +end \ No newline at end of file diff --git a/lib/securenative/event_type.rb b/lib/securenative/event_type.rb deleted file mode 100644 index 8749bdf..0000000 --- a/lib/securenative/event_type.rb +++ /dev/null @@ -1,21 +0,0 @@ -module EventType - LOG_IN = "sn.user.login" - LOG_IN_CHALLENGE = "sn.user.login.challenge" - LOG_IN_FAILURE = "sn.user.login.failure" - LOG_OUT = "sn.user.logout" - SIGN_UP = "sn.user.signup" - AUTH_CHALLENGE = "sn.user.auth.challenge" - AUTH_CHALLENGE_SUCCESS = "sn.user.auth.challenge.success" - AUTH_CHALLENGE_FAILURE = "sn.user.auth.challenge.failure" - TWO_FACTOR_DISABLE = "sn.user.2fa.disable" - EMAIL_UPDATE = "sn.user.email.update" - PASSWORD_RESET = "sn.user.password.reset" - PASSWORD_RESET_SUCCESS = "sn.user.password.reset.success" - PASSWORD_UPDATE = "sn.user.password.update" - PASSWORD_RESET_FAILURE = "sn.user.password.reset.failure" - USER_INVITE = "sn.user.invite" - ROLE_UPDATE = "sn.user.role.update" - PROFILE_UPDATE = "sn.user.profile.update" - PAGE_VIEW = "sn.user.page.view" - VERIFY = "sn.verify" -end \ No newline at end of file diff --git a/lib/securenative/exceptions/securenative_config_exception.rb b/lib/securenative/exceptions/securenative_config_exception.rb new file mode 100644 index 0000000..1c6bb67 --- /dev/null +++ b/lib/securenative/exceptions/securenative_config_exception.rb @@ -0,0 +1,2 @@ +class SecureNativeConfigException < StandardError +end \ No newline at end of file diff --git a/lib/securenative/exceptions/securenative_http_exception.rb b/lib/securenative/exceptions/securenative_http_exception.rb new file mode 100644 index 0000000..85314aa --- /dev/null +++ b/lib/securenative/exceptions/securenative_http_exception.rb @@ -0,0 +1,2 @@ +class SecureNativeHttpException < StandardError +end \ No newline at end of file diff --git a/lib/securenative/exceptions/securenative_invalid_options_exception.rb b/lib/securenative/exceptions/securenative_invalid_options_exception.rb new file mode 100644 index 0000000..6b8be6d --- /dev/null +++ b/lib/securenative/exceptions/securenative_invalid_options_exception.rb @@ -0,0 +1,2 @@ +class SecureNativeInvalidOptionsException < StandardError +end \ No newline at end of file diff --git a/lib/securenative/exceptions/securenative_invalid_uri_exception.rb b/lib/securenative/exceptions/securenative_invalid_uri_exception.rb new file mode 100644 index 0000000..68b722b --- /dev/null +++ b/lib/securenative/exceptions/securenative_invalid_uri_exception.rb @@ -0,0 +1,2 @@ +class SecureNativeInvalidUriException < StandardError +end \ No newline at end of file diff --git a/lib/securenative/exceptions/securenative_parse_exception.rb b/lib/securenative/exceptions/securenative_parse_exception.rb new file mode 100644 index 0000000..ce65aee --- /dev/null +++ b/lib/securenative/exceptions/securenative_parse_exception.rb @@ -0,0 +1,2 @@ +class SecureNativeParseException < StandardError +end \ No newline at end of file diff --git a/lib/securenative/exceptions/securenative_sdk_Illegal_state_exception.rb b/lib/securenative/exceptions/securenative_sdk_Illegal_state_exception.rb new file mode 100644 index 0000000..536ef7e --- /dev/null +++ b/lib/securenative/exceptions/securenative_sdk_Illegal_state_exception.rb @@ -0,0 +1,2 @@ +class SecureNativeSDKIllegalStateException < StandardError +end \ No newline at end of file diff --git a/lib/securenative/exceptions/securenative_sdk_exception.rb b/lib/securenative/exceptions/securenative_sdk_exception.rb new file mode 100644 index 0000000..02412f5 --- /dev/null +++ b/lib/securenative/exceptions/securenative_sdk_exception.rb @@ -0,0 +1,2 @@ +class SecureNativeSDKException < StandardError +end \ No newline at end of file diff --git a/lib/securenative/http/http_response.rb b/lib/securenative/http/http_response.rb new file mode 100644 index 0000000..27541aa --- /dev/null +++ b/lib/securenative/http/http_response.rb @@ -0,0 +1,10 @@ +class HttpResponse + attr_reader :ok, :status_code, :body + attr_writer :ok, :status_code, :body + + def initialize(ok, status_code, body) + @ok = ok + @status_code = status_code + @body = body + end +end \ No newline at end of file diff --git a/lib/securenative/http/securenative_http_client.rb b/lib/securenative/http/securenative_http_client.rb new file mode 100644 index 0000000..a764b62 --- /dev/null +++ b/lib/securenative/http/securenative_http_client.rb @@ -0,0 +1,30 @@ +require 'httpclient' + +class SecureNativeHttpClient + AUTHORIZATION_HEADER = 'Authorization'.freeze + VERSION_HEADER = 'SN-Version'.freeze + USER_AGENT_HEADER = 'User-Agent'.freeze + USER_AGENT_HEADER_VALUE = 'SecureNative-python'.freeze + CONTENT_TYPE_HEADER = 'Content-Type'.freeze + CONTENT_TYPE_HEADER_VALUE = 'application/json'.freeze + + def __init__(securenative_options) + @options = securenative_options + @client = HTTPClient.new + end + + def _headers + { + CONTENT_TYPE_HEADER => CONTENT_TYPE_HEADER_VALUE, + USER_AGENT_HEADER => USER_AGENT_HEADER_VALUE, + VERSION_HEADER => VersionUtils.version, + AUTHORIZATION_HEADER => options.api_key + } + end + + def post(path, body) + url = '{}/{}'.format(@options.api_url, path) + headers = _headers + @client.post(url, body, headers) + end +end \ No newline at end of file diff --git a/lib/securenative/http_client.rb b/lib/securenative/http_client.rb deleted file mode 100644 index bf28c92..0000000 --- a/lib/securenative/http_client.rb +++ /dev/null @@ -1,20 +0,0 @@ -require 'httpclient' - -class HttpClient - def initialize - @client = HTTPClient.new - end - - def headers(api_key) - { - "Content-Type" => 'application/json', - "User-Agent" => 'SecureNative-ruby', - "Sn-Version" => Config::SDK_VERSION, - "Authorization" => api_key - } - end - - def post(url, api_key, body) - @client.post(url, body, self.headers(api_key)) - end -end \ No newline at end of file diff --git a/lib/securenative/logger.rb b/lib/securenative/logger.rb new file mode 100644 index 0000000..96bc718 --- /dev/null +++ b/lib/securenative/logger.rb @@ -0,0 +1,42 @@ +require 'logger' + +class Logger + @logger = Logger.new(STDOUT) + + def self.init_logger(level) + @logger.level = case level + when 'WARN' + Logger::WARN + when 'DEBUG' + Logger::DEBUG + when 'ERROR' + Logger::ERROR + when 'FATAL' + Logger::FATAL + when 'INFO' + Logger::INFO + else + Logger::FATAL + end + + @logger.formatter = proc do |severity, datetime, progname, msg| + "[#{datetime}] #{severity} (#{progname}): #{msg}\n" + end + end + + def self.info(msg) + @logger.info(msg) + end + + def self.debug(msg) + @logger.debug(msg) + end + + def self.warning(msg) + @logger.warning(msg) + end + + def self.error(msg) + @logger.error(msg) + end +end \ No newline at end of file diff --git a/lib/securenative/models/client_token.rb b/lib/securenative/models/client_token.rb new file mode 100644 index 0000000..8f3c9dd --- /dev/null +++ b/lib/securenative/models/client_token.rb @@ -0,0 +1,10 @@ +class ClientToken + attr_reader :cid, :vid, :fp + attr_writer :cid, :vid, :fp + + def initialize(cid, vid, fp) + @cid = cid + @vid = vid + @fp = fp + end +end diff --git a/lib/securenative/models/device.rb b/lib/securenative/models/device.rb new file mode 100644 index 0000000..5ce321d --- /dev/null +++ b/lib/securenative/models/device.rb @@ -0,0 +1,8 @@ +class Device + attr_reader :device_id + attr_writer :device_id + + def initialize(device_id) + @device_id = device_id + end +end \ No newline at end of file diff --git a/lib/securenative/models/event_options.rb b/lib/securenative/models/event_options.rb new file mode 100644 index 0000000..fb8569b --- /dev/null +++ b/lib/securenative/models/event_options.rb @@ -0,0 +1,13 @@ +class EventOptions + attr_reader :event, :user_id, :user_traits, :context, :properties, :timestamp + attr_writer :event, :user_id, :user_traits, :context, :properties, :timestamp + + def initialize(event, user_id = nil, user_traits = nil, context = nil, properties = nil, timestamp = nil) + @event = event + @user_id = user_id + @user_traits = user_traits + @context = context + @properties = properties + @timestamp = timestamp + end +end \ No newline at end of file diff --git a/lib/securenative/models/request_context.rb b/lib/securenative/models/request_context.rb new file mode 100644 index 0000000..8995750 --- /dev/null +++ b/lib/securenative/models/request_context.rb @@ -0,0 +1,15 @@ +class RequestContext + attr_reader :cid, :vid, :fp, :ip, :remote_ip, :headers, :url, :method + attr_writer :cid, :vid, :fp, :ip, :remote_ip, :headers, :url, :method + + def initialize(cid = nil, vid = nil, fp = nil, ip = nil, remote_ip = nil, headers = nil, url = nil, method = nil) + @cid = cid + @vid = vid + @fp = fp + @ip = ip + @remote_ip = remote_ip + @headers = headers + @url = url + @method = method + end +end diff --git a/lib/securenative/models/request_options.rb b/lib/securenative/models/request_options.rb new file mode 100644 index 0000000..fc1d47c --- /dev/null +++ b/lib/securenative/models/request_options.rb @@ -0,0 +1,10 @@ +class RequestOptions + attr_reader :url, :body, :retry + attr_writer :url, :body, :retry + + def initialize(url, body, _retry) + @url = url + @body = body + @retry = _retry + end +end \ No newline at end of file diff --git a/lib/securenative/models/sdk_event.rb b/lib/securenative/models/sdk_event.rb new file mode 100644 index 0000000..4195fae --- /dev/null +++ b/lib/securenative/models/sdk_event.rb @@ -0,0 +1,25 @@ +class SDKEvent + attr_reader :context, :rid, :event_type, :user_id, :user_traits, :request, :timestamp, :properties + attr_writer :context, :rid, :event_type, :user_id, :user_traits, :request, :timestamp, :properties + + def initialize(event_options, securenative_options) + @context = if !event_options.context.nil? + event_options.context + else + ContextBuilder.default_context_builder + end + + client_token = EncryptionUtils.decrypt(@context.client_token, securenative_options.api_key) + + @rid = SecureRandom.uuid.to_str + @event_type = event_options.event + @user_id = event_options.user_id + @user_traits = event_options.user_traits + @request = RequestContext(cid = client_token ? client_token.cid : '', vid = client_token ? client_token.vid : '', + fp = client_token ? client_token.fp : '', ip = @context.ip, remote_ip = @context.remote_ip, + method = @context.method, url = @context.url, headers = @context.headers) + + @timestamp = DateUtils.to_timestamp(event_options.timestamp) + @properties = event_options.properties + end +end \ No newline at end of file diff --git a/lib/securenative/models/user_traits.rb b/lib/securenative/models/user_traits.rb new file mode 100644 index 0000000..7499e66 --- /dev/null +++ b/lib/securenative/models/user_traits.rb @@ -0,0 +1,10 @@ +class UserTraits + attr_reader :name, :email, :created_at + attr_writer :name, :email, :created_at + + def initialize(name = nil, email = nil, created_at = nil) + @name = name + @email = email + @created_at = created_at + end +end \ No newline at end of file diff --git a/lib/securenative/models/verify_result.rb b/lib/securenative/models/verify_result.rb new file mode 100644 index 0000000..c5e74b0 --- /dev/null +++ b/lib/securenative/models/verify_result.rb @@ -0,0 +1,10 @@ +class VerifyResult + attr_reader :risk_level, :score, :triggers + attr_writer :risk_level, :score, :triggers + + def initialize(risk_level = nil, score = nil, triggers = nil) + @risk_level = risk_level + @score = score + @triggers = triggers + end +end \ No newline at end of file diff --git a/lib/securenative/secure_native_sdk.rb b/lib/securenative/secure_native_sdk.rb deleted file mode 100644 index 48bb924..0000000 --- a/lib/securenative/secure_native_sdk.rb +++ /dev/null @@ -1,78 +0,0 @@ -require_relative 'event_manager' -require_relative 'config' -require_relative 'sn_exception' -require_relative 'utils' -require 'json' -require 'logger' - -class SecureNativeSDK - def initialize(api_key, options: SecureNativeOptions.new) - if api_key == nil - raise SecureNativeSDKException.new - end - - if options.debug - @logger = Logger.new(STDOUT) - @logger.level = Logger::INFO - @logger.info("sn logging was activated") - end - - @api_key = api_key - @options = options - @event_manager = EventManager.new(@api_key, options: @options) - end - - def api_key - @api_key - end - - def version - Config::SDK_VERSION - end - - def track(event) - if @options.debug - @logger.info("Track event was called") - end - validate_event(event) - @event_manager.send_async(event, Config::TRACK_EVENT) - end - - def verify(event) - if @options.debug - @logger.info("Verify event was called") - end - validate_event(event) - res = @event_manager.send_sync(event, Config::VERIFY_EVENT) - if res.status_code == 200 - return JSON.parse(res.body) - end - nil - end - - def flow(event) # Note: For future purposes - validate_event(event) - @event_manager.send_async(event, Config::FLOW_EVENT) - end - - def verify_webhook(hmac_header, body) - if @options.debug - @logger.info("Verify webhook was called") - end - Utils.verify_signature(@api_key, body, hmac_header) - end - - def flush - @event_manager.flush - end - - private - - def validate_event(event) - unless event.params.nil? - if event.params.length > Config::MAX_ALLOWED_PARAMS - event.params = event.params[0, Config::MAX_ALLOWED_PARAMS] - end - end - end -end \ No newline at end of file diff --git a/lib/securenative/securenative.iml b/lib/securenative/securenative.iml new file mode 100644 index 0000000..c87f26a --- /dev/null +++ b/lib/securenative/securenative.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/lib/securenative/securenative.rb b/lib/securenative/securenative.rb new file mode 100644 index 0000000..dd83e58 --- /dev/null +++ b/lib/securenative/securenative.rb @@ -0,0 +1,82 @@ +require_relative 'logger' +require_relative 'utils/signature_utils' + +class SecureNative + attr_reader :options + + def initialize(options) + @securenative = nil + raise SecureNativeSDKException('You must pass your SecureNative api key') if Utils.null_or_empty?(options.api_key) + + @options = options + @event_manager = EventManager(@options) + + @event_manager.start_event_persist unless @options.api_url.nil? + + @api_manager = ApiManager.new(@event_manager, @options) + Logger.init_logger(@options.log_level) + end + + def self.init_with_options(options) + if @securenative.nil? + @securenative = SecureNative.new(options) + @securenative + else + Logger.debug('This SDK was already initialized.') + raise SecureNativeSDKException('This SDK was already initialized.') + end + end + + def self.init_with_api_key(api_key) + raise SecureNativeConfigException('You must pass your SecureNative api key') if Utils.null_or_empty?(api_key) + + if @securenative.nil? + options = ConfigurationBuilder(api_key = api_key) + @securenative = SecureNative.new(options) + @securenative + else + Logger.debug('This SDK was already initialized.') + raise SecureNativeSDKException(u('This SDK was already initialized.')) + end + end + + def self.init + options = ConfigurationManager.load_config + init_with_options(options) + end + + def self.instance + raise SecureNativeSDKIllegalStateException() if @securenative.nil? + + @securenative + end + + def self.config_builder(api_key = nil, api_url = 'https://api.securenative.com/collector/api/v1', interval = 1000, + max_events = 1000, timeout = 1500, auto_send = true, disable = false, log_level = 'FATAL', + fail_over_strategy = FailOverStrategy::FAIL_OPEN) + ConfigurationBuilder(api_key, api_url, interval, max_events, timeout, auto_send, disable, log_level, fail_over_strategy) + end + + def self.context_builder(client_token = nil, ip = nil, remote_ip = nil, headers = nil, url = nil, method = nil, body = nil) + ContextBuilder(client_token, ip, remote_ip, headers, url, method, body) + end + + def track(event_options) + @api_manager.track(event_options) + end + + def verify(event_options) + @api_manager.verify(event_options) + end + + def self._flush + @securenative = nil + end + + def verify_request_payload(request) + request_signature = request.header[SignatureUtils.SIGNATURE_HEADER] + body = request.body + + SignatureUtils.valid_signature?(@options.api_key, body, request_signature) + end +end \ No newline at end of file diff --git a/lib/securenative/securenative_options.rb b/lib/securenative/securenative_options.rb deleted file mode 100644 index 7af850a..0000000 --- a/lib/securenative/securenative_options.rb +++ /dev/null @@ -1,19 +0,0 @@ -require_relative 'config' - -class SecureNativeOptions - def initialize(api_url: Config::API_URL_PROD, interval: 1000, max_events: 1000, timeout: 1500, auto_send: true, debug: false) - @timeout = timeout - @max_events = max_events - @api_url = api_url - @interval = interval - @auto_send = auto_send - @debug = debug - end - - attr_reader :timeout - attr_reader :max_events - attr_reader :api_url - attr_reader :interval - attr_reader :auto_send - attr_reader :debug -end \ No newline at end of file diff --git a/lib/securenative/sn_exception.rb b/lib/securenative/sn_exception.rb deleted file mode 100644 index bfa85e1..0000000 --- a/lib/securenative/sn_exception.rb +++ /dev/null @@ -1,5 +0,0 @@ -class SecureNativeSDKException < StandardError - def initialize(msg: "API key cannot be nil, please get your API key from SecureNative console") - super - end -end \ No newline at end of file diff --git a/lib/securenative/utils.rb b/lib/securenative/utils.rb deleted file mode 100644 index 229894b..0000000 --- a/lib/securenative/utils.rb +++ /dev/null @@ -1,74 +0,0 @@ -require_relative 'config' -require "logger" -require "base64" -require "json" -require 'openssl' - - -module Utils - def self.verify_signature(secret, text_body, header_signature) - begin - key = secret.encode('utf-8') - body = text_body.encode('utf-8') - calculated_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha512'), key, body) - calculated_signature.eql? header_signature - rescue Exception - return false - end - end - - def self.parse_cookie(cookie = nil) - fp = "" - cid = "" - unless cookie - return fp, cid - end - - begin - decoded_cookie = Base64.decode64(cookie) - unless decoded_cookie - decoded_cookie = "{}" - end - jsonified = JSON.generate(decoded_cookie) - if jsonified["fp"] - fp = jsonified["fp"] - end - if jsonified["cid"] - cid = jsonified["cid"] - end - rescue Exception - ensure - return fp, cid - end - end - - def self.encrypt(plain_text, key) - cipher = OpenSSL::Cipher::AES.new(Config::CIPHER_SIZE, :CBC).encrypt - cipher.padding = 0 - - if plain_text.size % Config::AES_BLOCK_SIZE != 0 - logger = Logger.new(STDOUT) - logger.level = Logger::WARN - logger.fatal("data not multiple of block length") - return nil - end - - key = Digest::SHA1.hexdigest key - cipher.key = key.slice(0, Config::AES_BLOCK_SIZE) - s = cipher.update(plain_text) + cipher.final - - s.unpack('H*')[0].upcase - end - - def self.decrypt(encrypted, key) - cipher = OpenSSL::Cipher::AES.new(Config::CIPHER_SIZE, :CBC).decrypt - cipher.padding = 0 - - key = Digest::SHA1.hexdigest key - cipher.key = key.slice(0, Config::AES_BLOCK_SIZE) - s = [encrypted].pack("H*").unpack("C*").pack("c*") - - rv = cipher.update(s) + cipher.final - return rv.strip - end -end \ No newline at end of file diff --git a/lib/securenative/utils/date_utils.rb b/lib/securenative/utils/date_utils.rb new file mode 100644 index 0000000..cd9ac5a --- /dev/null +++ b/lib/securenative/utils/date_utils.rb @@ -0,0 +1,7 @@ +class DateUtils + def self.to_timestamp(date) + return Time.now.strftime('%Y-%m-%dT%H:%M:%S.%L%Z') if date.nil? + + Time.strptime(date, '%Y-%m-%dT%H:%M:%S.%L%Z') + end +end \ No newline at end of file diff --git a/lib/securenative/utils/encryption_utils.rb b/lib/securenative/utils/encryption_utils.rb new file mode 100644 index 0000000..67c8ef9 --- /dev/null +++ b/lib/securenative/utils/encryption_utils.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'openssl' + +class EncryptionUtils + BLOCK_SIZE = 16 + KEY_SIZE = 32 + + def self.encrypt(text, cipher_key) + cipher = OpenSSL::Cipher::AES.new(KEY_SIZE, :CBC).encrypt + cipher.padding = 0 + + if text.size % BLOCK_SIZE != 0 + logger = Logger.new(STDOUT) + logger.level = Logger::WARN + logger.fatal('data not multiple of block length') + return nil + end + + cipher_key = Digest::SHA1.hexdigest cipher_key + cipher.key = cipher_key.slice(0, BLOCK_SIZE) + s = cipher.update(text) + cipher.final + + s.unpack('H*')[0].upcase + end + + def self.decrypt(encrypted, cipher_key) + cipher = OpenSSL::Cipher::AES.new(KEY_SIZE, :CBC).decrypt + cipher.padding = 0 + + cipher_key = Digest::SHA1.hexdigest cipher_key + cipher.key = cipher_key.slice(0, BLOCK_SIZE) + s = [encrypted].pack('H*').unpack('C*').pack('c*') + + rv = cipher.update(s) + cipher.final + rv.strip + end +end \ No newline at end of file diff --git a/lib/securenative/utils/ip_utils.rb b/lib/securenative/utils/ip_utils.rb new file mode 100644 index 0000000..91b892c --- /dev/null +++ b/lib/securenative/utils/ip_utils.rb @@ -0,0 +1,22 @@ +class IpUtils + VALID_IPV4_PATTERN = '(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])'.freeze + VALID_IPV6_PATTERN = '([0-9a-f]{1,4}:){7}([0-9a-f]){1,4}'.freeze + + def self.ip_address?(ip_address) + return true if IpUtils.VALID_IPV4_PATTERN.match(ip_address) + return true if IpUtils.VALID_IPV6_PATTERN.match(ip_address) + + false + end + + def self.valid_public_ip?(ip_address) + ip = IPAddr.new(ip_address) + return false if ip.loopback? || ip.private? || ip.link_local? || ip.untrusted? || ip.tainted? + + true + end + + def self.loop_back?(ip_address) + IPAddr.new(ip_address).loopback? + end +end \ No newline at end of file diff --git a/lib/securenative/utils/request_utils.rb b/lib/securenative/utils/request_utils.rb new file mode 100644 index 0000000..fc9b0ac --- /dev/null +++ b/lib/securenative/utils/request_utils.rb @@ -0,0 +1,21 @@ +class RequestUtils + SECURENATIVE_COOKIE = '_sn'.freeze + SECURENATIVE_HEADER = 'x-securenative'.freeze + + def self.get_secure_header_from_request(headers) + return headers[RequestUtils.SECURENATIVE_HEADER] unless headers.nil? + + [] + end + + def self.get_client_ip_from_request(request) + x_forwarded_for = request.env['HTTP_X_FORWARDED_FOR'] + return x_forwarded_for unless x_forwarded_for.nil? + + request.env['REMOTE_ADDR'] + end + + def self.get_remote_ip_from_request(request) + request.remote_ip + end +end \ No newline at end of file diff --git a/lib/securenative/utils/signature_utils.rb b/lib/securenative/utils/signature_utils.rb new file mode 100644 index 0000000..9482563 --- /dev/null +++ b/lib/securenative/utils/signature_utils.rb @@ -0,0 +1,14 @@ +require 'openssl' + +class SignatureUtils + SIGNATURE_HEADER = 'x-securenative'.freeze + + def self.valid_signature?(api_key, payload, header_signature) + key = api_key.encode('utf-8') + body = payload.encode('utf-8') + calculated_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha512'), key, body) + calculated_signature.eql? header_signature + rescue StandardError + false + end +end \ No newline at end of file diff --git a/lib/securenative/utils/utils.rb b/lib/securenative/utils/utils.rb new file mode 100644 index 0000000..57641ad --- /dev/null +++ b/lib/securenative/utils/utils.rb @@ -0,0 +1,9 @@ +class Utils + def self.null_or_empty?(string) + return true if !string.nil? && !string.empty? + + return true unless string.nil? + + false + end +end \ No newline at end of file diff --git a/lib/securenative/utils/version_utils.rb b/lib/securenative/utils/version_utils.rb new file mode 100644 index 0000000..d46e495 --- /dev/null +++ b/lib/securenative/utils/version_utils.rb @@ -0,0 +1,10 @@ +class VersionUtils + def self.version + path = 'VERSION' + file = File.open(path) + version = file.read + file.close + + version + end +end \ No newline at end of file diff --git a/securenative.gemspec b/securenative.gemspec index 769e8ad..0e2cc88 100644 --- a/securenative.gemspec +++ b/securenative.gemspec @@ -1,14 +1,14 @@ lib = File.expand_path("lib", __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require "securenative/config" +require_relative "lib/securenative/utils/version_utils" Gem::Specification.new do |spec| spec.name = "securenative" - spec.version = Config::SDK_VERSION + spec.version = VersionUtils.version spec.authors = ["SecureNative"] spec.email = ["support@securenative.com"] - spec.summary = %q{SecureNative SDK for ruby} + spec.summary = %q{SecureNative SDK for Ruby} spec.homepage = "https://www.securenative.com" spec.license = "MIT" @@ -24,5 +24,5 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_development_dependency "bundler", "~> 2.0" - spec.add_development_dependency "rake", "~> 10.0" + spec.add_development_dependency "rake", "~> 12.3.3" end diff --git a/spec/spec_api_manager.rb b/spec/spec_api_manager.rb new file mode 100644 index 0000000..74fb759 --- /dev/null +++ b/spec/spec_api_manager.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'webmock/rspec' + +describe ApiManager do + let(:context) do + ContextBuilder(ip = '127.0.0.1', client_token = 'SECURED_CLIENT_TOKEN', headers = {'user-agent' => 'Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us)' \ + 'AppleWebKit/531.21.10 (KHTML, like Gecko) Mobile/7B405'}) + end + let(:event_options) do + EventOptionsBuilder(event_type = EventTypes.LOG_IN, user_id = 'USER_ID', + user_traits = UserTraits('USER_NAME', 'USER_EMAIL'), + properties = {prop1: 'CUSTOM_PARAM_VALUE', prop2: true, prop3: 3}).build + end + + it 'tracks an event' do + options = ConfigurationBuilder(api_key = 'YOUR_API_KEY', auto_send = true, interval = 10, api_url = 'https://api.securenative-stg.com/collector/api/v1') + + expected = '{"eventType":"sn.user.login","userId":"USER_ID","userTraits":{' \ + '"name":"USER_NAME","email":"USER_EMAIL","createdAt":null},"request":{' \ + '"cid":null,"vid":null,"fp":null,"ip":"127.0.0.1","remoteIp":null,"headers":{' \ + '"user-agent":"Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us) ' \ + 'AppleWebKit/531.21.10 (KHTML, like Gecko) Mobile/7B405"},"url":null,"method":null},' \ + '"properties":{"prop2":true,"prop1":"CUSTOM_PARAM_VALUE","prop3":3}}' + + stub_request(:post, 'https://api.securenative-stg.com/collector/api/v1/track') + .with(body: JSON.parse(expected)).to_return(status: 200) + event_manager = EventManager.new(options) + event_manager.start_event_persist + api_manager = ApiManager.new(event_manager, options) + + begin + api_manager.track(:event_options) + ensure + event_manager.stop_event_persist + end + end + + it 'uses invalid options' do + options = ConfigurationBuilder(api_key = 'YOUR_API_KEY', auto_send = true, interval = 10, api_url = 'https://api.securenative-stg.com/collector/api/v1') + + properties = {} + (0..12).each do |i| + properties[i] = i + end + + stub_request(:post, 'https://api.securenative-stg.com/collector/api/v1/track').to_return(status: 200) + event_manager = EventManager.new(options) + event_manager.start_event_persist + api_manager = ApiManager.new(event_manager, options) + + begin + expect { api_manager.track(EventOptionsBuilder(event_type = EventTypes.LOG_IN, properties = properties).build) } + .to raise_error(SecureNativeInvalidOptionsException) + ensure + event_manager.stop_event_persist + end + end + + it 'verifies an event' do + options = ConfigurationBuilder(api_key = 'YOUR_API_KEY', api_url = 'https://api.securenative-stg.com/collector/api/v1') + + stub_request(:post, 'https://api.securenative-stg.com/collector/api/v1/track') + .with(body: {riskLevel: 'medium', score: 0.32, triggers: ['New IP', 'New City']}).to_return(status: 200) + verify_result = VerifyResult.new(RiskLevel.LOW, 0, nil) + + event_manager = EventManager.new(options) + event_manager.start_event_persist + api_manager = ApiManager.new(event_manager, options) + + result = api_manager.verify(:event_options) + + expect(result).not_to be_nil + expect(result.risk_level).to eq(verify_result.risk_level) + expect(result.score).to eq(verify_result.score) + expect(result.triggers).to eq(verify_result.triggers) + end +end \ No newline at end of file diff --git a/spec/spec_context_builder.rb b/spec/spec_context_builder.rb new file mode 100644 index 0000000..9c47f00 --- /dev/null +++ b/spec/spec_context_builder.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'webmock/rspec' + +describe ContextBuilder do + it 'creates context from request' do + stub_request(:any, 'www.example.com') + .to_return(body: nil, status: 200, + headers: { 'x-securenative': '71532c1fad2c7f56118f7969e401f3cf080239140d208e7934e6a530818c37e544a0c2330a487bcc6fe4f662a57f265a3ed9f37871e80529128a5e4f2ca02db0fb975ded401398f698f19bb0cafd68a239c6caff99f6f105286ab695eaf3477365bdef524f5d70d9be1d1d474506b433aed05d7ed9a435eeca357de57817b37c638b6bb417ffb101eaf856987615a77a' }, + remote_ip: '', uri: 'www.securenative.com', method: 'Post', ip: '51.68.201.122', + client_token: '71532c1fad2c7f56118f7969e401f3cf080239140d208e7934e6a530818c37e544a0c2330a487bcc6fe4f662a57f265a3ed9f37871e80529128a5e4f2ca02db0fb975ded401398f698f19bb0cafd68a239c6caff99f6f105286ab695eaf3477365bdef524f5d70d9be1d1d474506b433aed05d7ed9a435eeca357de57817b37c638b6bb417ffb101eaf856987615a77a') + + request = Net::HTTP.get('www.example.com', '/') + context = ContextBuilder.from_http_request(request) + + expect(context.client_token).to eq('71532c1fad2c7f56118f7969e401f3cf080239140d208e7934e6a530818c37e544a0c2330a487bcc6fe4f662a57f265a3ed9f37871e80529128a5e4f2ca02db0fb975ded401398f698f19bb0cafd68a239c6caff99f6f105286ab695eaf3477365bdef524f5d70d9be1d1d474506b433aed05d7ed9a435eeca357de57817b37c638b6bb417ffb101eaf856987615a77a') + expect(context.ip).to eq('51.68.201.122') + expect(context.method).to eq('Post') + expect(context.uri).to eq('www.securenative.com') + expect(context.remote_ip).to eq('') + expect(context.headers).to eq({ 'x-securenative': '71532c1fad2c7f56118f7969e401f3cf080239140d208e7934e6a530818c37e544a0c2330a487bcc6fe4f662a57f265a3ed9f37871e80529128a5e4f2ca02db0fb975ded401398f698f19bb0cafd68a239c6caff99f6f105286ab695eaf3477365bdef524f5d70d9be1d1d474506b433aed05d7ed9a435eeca357de57817b37c638b6bb417ffb101eaf856987615a77a' }) + expect(context.body).to be_nil + end + + it 'creates context from request with cookie' do + stub_request(:any, 'www.example.com') + .to_return(body: nil, status: 200, + cookies: { '_sn': '71532c1fad2c7f56118f7969e401f3cf080239140d208e7934e6a530818c37e544a0c2330a487bcc6fe4f662a57f265a3ed9f37871e80529128a5e4f2ca02db0fb975ded401398f698f19bb0cafd68a239c6caff99f6f105286ab695eaf3477365bdef524f5d70d9be1d1d474506b433aed05d7ed9a435eeca357de57817b37c638b6bb417ffb101eaf856987615a77a' }, + remote_ip: '', uri: 'www.securenative.com', method: 'Post', ip: '51.68.201.122', + client_token: '71532c1fad2c7f56118f7969e401f3cf080239140d208e7934e6a530818c37e544a0c2330a487bcc6fe4f662a57f265a3ed9f37871e80529128a5e4f2ca02db0fb975ded401398f698f19bb0cafd68a239c6caff99f6f105286ab695eaf3477365bdef524f5d70d9be1d1d474506b433aed05d7ed9a435eeca357de57817b37c638b6bb417ffb101eaf856987615a77a') + + request = Net::HTTP.get('www.example.com', '/') + context = ContextBuilder.from_http_request(request) + + expect(context.client_token).to eq('71532c1fad2c7f56118f7969e401f3cf080239140d208e7934e6a530818c37e544a0c2330a487bcc6fe4f662a57f265a3ed9f37871e80529128a5e4f2ca02db0fb975ded401398f698f19bb0cafd68a239c6caff99f6f105286ab695eaf3477365bdef524f5d70d9be1d1d474506b433aed05d7ed9a435eeca357de57817b37c638b6bb417ffb101eaf856987615a77a') + expect(context.ip).to eq('51.68.201.122') + expect(context.method).to eq('Post') + expect(context.uri).to eq('www.securenative.com') + expect(context.remote_ip).to eq('') + expect(context.body).to be_nil + end + + it 'creates default context builder' do + context = ContextBuilder.default_context_builder.build + + expect(context.client_token).not_to be_nil + expect(context.ip).not_to be_nil + expect(context.method).not_to be_nil + expect(context.url).not_to be_nil + expect(context.remote_ip).not_to be_nil + expect(context.headers).not_to be_nil + expect(context.body).not_to be_nil + end + + it 'creates custom context with context builder' do + context = ContextBuilder(url = '/some-url', client_token = 'SECRET_TOKEN', ip = '10.0.0.0', body = '{ "name": "YOUR_NAME" }', method = 'Get', remote_ip = '10.0.0.1', headers = { header1: 'value1' }) + + expect(context.url).to eq('/some-url') + expect(context.client_token).to eq('SECRET_TOKEN') + expect(context.ip).to eq('10.0.0.0') + expect(context.body).to eq('{ "name": "YOUR_NAME" }') + expect(context.method).to eq('Get') + expect(context.remote_ip).to eq('10.0.0.0') + expect(context.headers).to eq({ header1: 'value1' }) + end +end \ No newline at end of file diff --git a/spec/spec_date_utils.rb b/spec/spec_date_utils.rb new file mode 100644 index 0000000..5765b09 --- /dev/null +++ b/spec/spec_date_utils.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +describe DateUtils do + it 'converts to timestamp' do + iso_8601_date = '2020-05-20T15:07:13Z' + result = DateUtils.to_timestamp(iso_8601_date) + + expect(result).to eq(Time.now.strftime('%Y-%m-%dT%H:%M:%S.%L%Z')) + end +end \ No newline at end of file diff --git a/spec/spec_encryption_utils.rb b/spec/spec_encryption_utils.rb new file mode 100644 index 0000000..469d7c6 --- /dev/null +++ b/spec/spec_encryption_utils.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +describe EncryptionUtils do + let(:SECRET_KEY) { 'B00C42DAD33EAC6F6572DA756EA4915349C0A4F6' } + let(:PAYLOAD) { '{"cid":"198a41ff-a10f-4cda-a2f3-a9ca80c0703b","vi":"148a42ff-b40f-4cda-a2f3-a8ca80c0703b","fp":"6d8cabd95987f8318b1fe01593d5c2a5.24700f9f1986800ab4fcc880530dd0ed"}' } + let(:CID) { '198a41ff-a10f-4cda-a2f3-a9ca80c0703b' } + let(:FP) { '6d8cabd95987f8318b1fe01593d5c2a5.24700f9f1986800ab4fcc880530dd0ed' } + + it 'decrypts' do + result = EncryptionUtils.encrypt(:PAYLOAD, :SECRET_KEY) + + expect(result).not_to be_nil + expect(result.length).to eq(:PAYLOAD.length) + end + + it 'encrypts' do + encrypted_payload = '5208ae703cc2fa0851347f55d3b76d3fd6035ee081d71a401e8bc92ebdc25d42440f62310bda60628537744ac03f200d78da9e61f1019ce02087b7ce6c976e7b2d8ad6aa978c532cea8f3e744cc6a5cafedc4ae6cd1b08a4ef75d6e37aa3c0c76954d16d57750be2980c2c91ac7ef0bbd0722abd59bf6be22493ea9b9759c3ff4d17f17ab670b0b6fc320e6de982313f1c4e74c0897f9f5a32d58e3e53050ae8fdbebba9009d0d1250fe34dcde1ebb42acbc22834a02f53889076140f0eb8db1' + result = EncryptionUtils.decrypt(encrypted_payload, :SECRET_KEY) + + expect(result.cid).to eq(:CID) + expect(result.fp).to eq(:FP) + end +end \ No newline at end of file diff --git a/spec/spec_event_manager.rb b/spec/spec_event_manager.rb new file mode 100644 index 0000000..9111381 --- /dev/null +++ b/spec/spec_event_manager.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'webmock/rspec' + +class SampleEvent + attr_reader :event_type, :timestamp, :rid, :user_id, :user_traits, :request, :properties + + def initialize + @event_type = 'custom-event' + @timestamp = Time.now.strftime('%Y-%m-%dT%H:%M:%S.%L%Z') + @rid = '432532' + @user_id = '1' + @user_traits = UserTraits('some user', 'email@securenative.com') + @request = RequestContext() + @properties = [] + end +end + +describe EventManager do + let(:event) { SampleEvent() } + + it 'successfully sends sync event with status code 200' do + options = ConfigurationBuilder(api_key = 'YOUR_API_KEY', api_url = 'https://api.securenative-stg.com/collector/api/v1') + + res_body = '{"data": true}' + stub_request(:post, 'https://api.securenative-stg.com/collector/api/v1/some-path/to-api') + .with(body: JSON.parse(res_body)).to_return(status: 200) + event_manager = EventManager.new(options) + data = event_manager.send_sync(:event, 'some-path/to-api', false) + + expect(res_body).to eq(data.text) + end + + it 'fails when send sync event status code is 401' do + options = ConfigurationBuilder(api_key = 'YOUR_API_KEY', api_url = 'https://api.securenative-stg.com/collector/api/v1') + + stub_request(:post, 'https://api.securenative-stg.com/collector/api/v1/some-path/to-api').to_return(status: 401) + event_manager = EventManager.new(options) + res = event_manager.send_sync(:event, 'some-path/to-api', false) + + expect(res.status_code).to eq(401) + end + + it 'fails when send sync event status code is 500' do + options = ConfigurationBuilder(api_key = 'YOUR_API_KEY', api_url = 'https://api.securenative-stg.com/collector/api/v1') + + stub_request(:post, 'https://api.securenative-stg.com/collector/api/v1/some-path/to-api').to_return(status: 500) + event_manager = EventManager.new(options) + res = event_manager.send_sync(:event, 'some-path/to-api', false) + + expect(res.status_code).to eq(500) + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..baa347b --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'bundler/setup' +require 'simplecov' +require 'codecov' + +SimpleCov.start +SimpleCov.formatter = SimpleCov::Formatter::Codecov + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = '.rspec_status' + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end \ No newline at end of file diff --git a/spec/spec_ip_utils.rb b/spec/spec_ip_utils.rb new file mode 100644 index 0000000..100d83b --- /dev/null +++ b/spec/spec_ip_utils.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +describe IpUtils do + it 'checks if ip address valid ipv4' do + valid_ipv4 = '172.16.254.1' + expect(IpUtils.ip_address?(valid_ipv4)).to be_truthy + end + + it 'checks if ip address valid ipv6' do + valid_ipv6 = '2001:db8:1234:0000:0000:0000:0000:0000' + expect(IpUtils.ip_address?(valid_ipv6)).to be_truthy + end + + it 'checks if ip address invalid ipv4' do + invalid_ipv4 = '172.16.2541' + expect(IpUtils.ip_address?(invalid_ipv4)).to be_falsey + end + + it 'checks if ip address invalid ipv6' do + invalid_ipv6 = '2001:db8:1234:0000' + expect(IpUtils.ip_address?(invalid_ipv6)).to be_falsey + end + + it 'checks if valid public ip' do + ip = '64.71.222.37' + expect(IpUtils.valid_public_ip?(ip)).to be_truthy + end + + it 'checks if not valid public ip' do + ip = '10.0.0.0' + expect(IpUtils.valid_public_ip?(ip)).to be_falsey + end + + it 'checks if valid loopback ip' do + ip = '127.0.0.1' + expect(IpUtils.loop_back?(ip)).to be_truthy + end +end \ No newline at end of file diff --git a/spec/spec_securenative.rb b/spec/spec_securenative.rb new file mode 100644 index 0000000..7b85bc0 --- /dev/null +++ b/spec/spec_securenative.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +describe SecureNative do + it 'gets sdk instance without init throws' do + expect { SecureNative.instance }.to raise_error(SecureNativeSDKIllegalStateException) + end + + it 'inits sdk without api key and throws' do + expect { SecureNative.init_with_options(ConfigurationManager.config_builder) }.to raise_error(SecureNativeSDKException) + end + + it 'inits sdk with empty api key and throws' do + expect { SecureNative.init_with_api_key('') }.to raise_error(SecureNativeSDKException) + end + + it 'inits sdk with api key and defaults' do + SecureNative._flush + api_key = 'API_KEY' + securenative = SecureNative.init_with_api_key(api_key) + options = securenative.options + + expect(options.api_key).to eq(api_key) + expect(options.api_url).to eq('https://api.securenative.com/collector/api/v1') + expect(options.interval).to eq(1000) + expect(options.timeout).to eq(1500) + expect(options.max_events).to eq(100) + expect(options.auto_send).to eq(true) + expect(options.disable).to eq(false) + expect(options.log_level).to eq('FATAL') + expect(options.fail_over_strategy).to eq(FailOverStrategy::FAIL_OPEN) + end + + it 'inits sdk twice and throws' do + expect { SecureNative.init_with_api_key('API_KEY') }.to raise_error(SecureNativeSDKException) + end + + it 'inits sdk with api key and gets instance' do + SecureNative._flush + api_key = 'API_KEY' + securenative = SecureNative.init_with_api_key(api_key) + + expect(securenative).to eq(SecureNative.instance) + end + + it 'inits sdk with builder' do + SecureNative._flush + securenative = SecureNative.init_with_options(SecureNative.config_builder(api_key = 'API_KEY', max_events = 10, log_level = 'ERROR')) + options = securenative.options + + expect(options.api_key).to eq('API_KEY') + expect(options.max_events).to eq(10) + expect(options.log_level).to eq('ERROR') + end +end \ No newline at end of file diff --git a/spec/spec_securenative_http_client.rb b/spec/spec_securenative_http_client.rb new file mode 100644 index 0000000..cc6e2ad --- /dev/null +++ b/spec/spec_securenative_http_client.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +describe SecureNativeHttpClient do + it 'makes a simple post call' do + options = ConfigurationBuilder(api_key = 'YOUR_API_KEY', api_url = 'https://api.securenative-stg.com/collector/api/v1') + + stub_request(:post, 'https://api.securenative-stg.com/collector/api/v1/track') + .with(body: { event: 'SOME_EVENT_NAME' }).to_return(status: 200) + client = SecureNativeHttpClient(options) + payload = '{"event": "SOME_EVENT_NAME"}' + + res = client.post('track', payload) + + expect(res.ok).to eq(true) + expect(res.status_code).to eq(200) + expect(res.text).to eq(payload) + end +end \ No newline at end of file diff --git a/spec/spec_signature_utils.rb b/spec/spec_signature_utils.rb new file mode 100644 index 0000000..9d54d6d --- /dev/null +++ b/spec/spec_signature_utils.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +describe SignatureUtils do + it 'verifies request payload' do + signature = 'c4574c1748064735513697750c6223ff36b03ae3b85b160ce8788557d01e1d9d1c9cd942074323ee0061d3dcc8c94359c5acfa6eee8e2da095b3967b1a88ab73' + payload = '{"id":"4a9157ffbd18cfbd73a57298","type":"security-action","flow":{"id":"62298c73a9bb433fbd1f75984a9157fd","name":"Block user that violates geo velocity"},"userId":"73a9bb433fbd1f75984a9157","userTraits":{"name":"John Doe","email":"john.doe@gmail.com"},"request":{"ip":"10.0.0.0","fp":"9bb433fb984a9157d1f7598"},"action":"block","properties":{"type":"customer"},"timestamp":"2020-02-23T22:28:55.387Z"}' + secret_key = 'B00C42DAD33EAC6F6572DA756EA4915349C0A4F6' + + expect(SignatureUtils.valid_signature?(secret_key, payload, signature)).to be_truthy + end + + it 'verifies request empty signature' do + expect(SignatureUtils.valid_signature?('', '', 'B00C42DAD33EAC6F6572DA756EA4915349C0A4F6')).to be_falsey + end +end \ No newline at end of file diff --git a/test/test_event_manager.rb b/test/test_event_manager.rb deleted file mode 100644 index 7ac6fc7..0000000 --- a/test/test_event_manager.rb +++ /dev/null @@ -1,57 +0,0 @@ -require_relative '../lib/securenative/event_manager' -require_relative '../lib/securenative/securenative_options' -require_relative '../lib/securenative/event_type' -require_relative '../lib/securenative/event_options' -require 'rspec/autorun' -require 'securerandom' - -class MockHttpClient - def post(path, data, _) - { - :path => path, - :data => data, - :headers => { - "Content-Type" => 'application/json', - "User-Agent" => 'SecureNative-ruby', - "Sn-Version" => Config::SDK_VERSION, - "Authorization" => data - } - } - end -end - -def build_event(type = EventType::LOG_IN, id) - Event.new(event_type = type, - user: User.new(user_id: id, user_email: 'support@securenative.com', user_name: 'support'), - params: [CustomParam.new('key', 'val')] - ) -end - -describe EventManager do - let(:api_key) {ENV["SN_API_KEY"]} - let(:event_manager) {EventManager.new(api_key, options: SecureNativeOptions.new, http_client: MockHttpClient.new)} - - it "post a synced request" do - user_id = SecureRandom.uuid - url = "https://postman-echo.com/post" - event = build_event(EventType::LOG_IN, user_id) - item = event_manager.send_async(event, url) - event_manager.flush - - expect(item).to be_instance_of(Thread::Queue) - end - - it "post an a-synced request" do - user_id = SecureRandom.uuid - url = "https://postman-echo.com/post" - event = build_event(EventType::LOG_IN, user_id) - - res = event_manager.send_sync(event, url) - expect(res).not_to be_empty - expect(res[:data]).to eq(api_key) - expect(res[:headers]["Content-Type"]).to eq("application/json") - expect(res[:headers]["User-Agent"]).to eq("SecureNative-ruby") - expect(res[:headers]["Sn-Version"]).to eq(Config::SDK_VERSION) - expect(res[:headers]["Authorization"]).to eq(api_key) - end -end diff --git a/test/test_http_client.rb b/test/test_http_client.rb deleted file mode 100644 index 3529468..0000000 --- a/test/test_http_client.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'rspec/autorun' -require 'json' -require_relative '../lib/securenative/http_client' -require_relative '../lib/securenative/config' - -describe HttpClient do - let(:client) {HttpClient.new} - - it "post a request to postman-echo" do - api_key = "some api key" - url = "https://postman-echo.com/post" - res = client.post(url, api_key, "") - - expect(res.status).to eq(200) - res_body = JSON.parse(res.body) - expect(res_body["headers"]['content-type']).to eq("application/json") - expect(res_body["headers"]['user-agent']).to eq("SecureNative-ruby") - expect(res_body["headers"]['sn-version']).to eq(Config::SDK_VERSION) - expect(res_body["headers"]['authorization']).to eq(api_key) - end -end \ No newline at end of file diff --git a/test/test_sdk.rb b/test/test_sdk.rb deleted file mode 100644 index cf2b88b..0000000 --- a/test/test_sdk.rb +++ /dev/null @@ -1,49 +0,0 @@ -require_relative '../lib/securenative' -require_relative '../lib/securenative/securenative_options' -require_relative '../lib/securenative/event_type' -require_relative '../lib/securenative/event_options' -require 'rspec/autorun' -require 'securerandom' -require 'json' - -def build_event(type = EventType::LOG_IN, id) - Event.new(event_type = type, - user: User.new(user_id: id, user_email: 'support@securenative.com', user_name: 'support'), - params: [CustomParam.new('key', 'val')] - ) -end - -describe SecureNativeSDK do - let(:sn_options) {SecureNativeOptions.new} - let(:sn) {nil} - - it "use sdk without api key" do - api_key = nil - expect {SecureNative.init(api_key, options: sn_options)}.to raise_error(SecureNativeSDKException) - end - - it "check for singleton use" do - sn = SecureNative.init(ENV["SN_API_KEY"], options: sn_options) - expect {sn.init(ENV["SN_API_KEY"], options: sn_options)}.to raise_error(StandardError) - end - - it "track an event" do - sn = SecureNative._get_or_throw - user_id = SecureRandom.uuid - event = build_event(EventType::LOG_IN, user_id) - sn.track(event) - expect(sn.flush).not_to be_empty - end - - it "verify an event" do - sn = SecureNative._get_or_throw - user_id = SecureRandom.uuid - event = build_event(EventType::LOG_OUT, user_id) - res = sn.verify(event) - - expect(res).not_to be_empty - expect(res['triggers']).not_to be_empty - expect(res['riskLevel']).to eq('high') - expect(res['score']).to eq(0.64) - end -end \ No newline at end of file diff --git a/test/test_utils.rb b/test/test_utils.rb deleted file mode 100644 index 0ca29a5..0000000 --- a/test/test_utils.rb +++ /dev/null @@ -1,48 +0,0 @@ -require 'rspec/autorun' -require "base64" -require_relative '../lib/securenative/utils' - -describe Utils do - it "verifies a signature" do - secret = "super secretive secret" - body = "random body" - true_signature = "acfd06a839604c12d21649508c1b3fa25d84e043d2b1e26b5967b9f9883184227c80c249decd9229205c7d072d2c7dc0f5c525cf4c14209d34f72306de97d50c" - false_signature = "c80c249decd9229205c7d072d2c7dc0f5c525cf4c14209d34f72306de97d50cacfd06a839604c12d21649508c1b3fa25d84e043d2b1e26b5967b9f9883184227" - expect(Utils.verify_signature(secret, body, true_signature)).to eq(true) - expect(Utils.verify_signature(secret, body, false_signature)).to eq(false) - end - - it "successfully parse a cookie" do - cookie = Utils.parse_cookie(Base64.encode64('{"fp": fp, "cid": cid}')) - expect(cookie).to_not be_empty - expect(cookie[0]).to eq("fp") - expect(cookie[1]).to eq("cid") - end - - it "fails at parsing a cookie" do - cookie = Utils.parse_cookie - expect(cookie).to_not be_empty - expect(cookie[0]).to be_empty - expect(cookie[1]).to be_empty - end - - it "encrypt text" do - plain_text = "Some random plain text for test" - key = "6EA4915349C0AAC6F6572DA4F6B00C42DAD33E75" - encrypted = "B8D770D0F7388EC3DF62B0097C36C05CAB03E934F2E2760536004F87FE11644C" - - res = Utils.encrypt(plain_text, key) - expect(res).to_not be_empty - expect(res).to eq(encrypted) - end - - it "decrypt text" do - encrypted_text = "B8D770D0F7388EC3DF62B0097C36C05CAB03E934F2E2760536004F87FE11644C" - decrypted = "Some random plain text for test" - key = "6EA4915349C0AAC6F6572DA4F6B00C42DAD33E75" - - res = Utils.decrypt(encrypted_text, key) - expect(res).to_not be_empty - expect(res).to eq(decrypted) - end -end \ No newline at end of file