diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 193c3a2..a27f778 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['2.4.2'] + ruby-version: ['2.7.0'] steps: - uses: actions/checkout@v4 - name: Set up Ruby @@ -36,7 +36,7 @@ jobs: if: ${{ github.ref == 'refs/heads/master' }} strategy: matrix: - ruby-version: [ '2.4.2' ] + ruby-version: [ '2.7.0' ] steps: - uses: actions/checkout@v4 - name: Set up Ruby diff --git a/HISTORY b/HISTORY index cd01ca6..4c6d5e8 100644 --- a/HISTORY +++ b/HISTORY @@ -1,3 +1,9 @@ +=== 4.6.0 2026-02-10 +- Bump the minimum version of httparty to 0.23.3 to ensure protection against CVE-2025-68696 +- Refactor Client to use dedicated internal HTTP clients for different API endpoints +- Fix Verification API methods to use correct version parameter +- Increase minimum required version for Ruby to 2.7.0 + === 4.5.1 2025-04-07 - Fix Verification URLs diff --git a/README.md b/README.md index 274ffa1..1b92c2d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The official Ruby bindings for the latest version (v205) of the [Sift API](https ## Requirements - * Ruby 2.0.0 or above. + * Ruby 2.7.0 or above. ## Installation diff --git a/lib/sift/client.rb b/lib/sift/client.rb index b510ae3..167d510 100644 --- a/lib/sift/client.rb +++ b/lib/sift/client.rb @@ -2,9 +2,6 @@ require "multi_json" require "base64" -require_relative "./client/decision" -require_relative "./error" - module Sift # Represents the payload returned from a call through the track API @@ -94,17 +91,36 @@ class Client API_ENDPOINT = ENV["SIFT_RUBY_API_URL"] || 'https://api.siftscience.com' API3_ENDPOINT = ENV["SIFT_RUBY_API3_URL"] || 'https://api3.siftscience.com' + # Maintain backward compatibility for users who may rely on HTTParty methods include HTTParty base_uri API_ENDPOINT attr_reader :api_key, :account_id - def self.build_auth_header(api_key) - { "Authorization" => "Basic #{Base64.encode64(api_key)}" } - end + class << self + def build_auth_header(api_key) + { "Authorization" => "Basic #{Base64.encode64(api_key)}" } + end - def self.user_agent - "sift-ruby/#{VERSION}" + def user_agent + "sift-ruby/#{VERSION}" + end + + # Factory methods for internal API executors that inherit from the current class context. + # This ensures that subclasses of Client propagate their HTTParty configuration + # to these internal clients. + + def api_client + @api_client ||= Class.new(self) do + base_uri API_ENDPOINT + end + end + + def api3_client + @api3_client ||= Class.new(self) do + base_uri API3_ENDPOINT + end + end end # Constructor @@ -256,7 +272,7 @@ def track(event, properties = {}, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.post(path, options) + response = self.class.api_client.post(path, options) Response.new(response.body, response.code, response.response) end @@ -319,7 +335,7 @@ def score(user_id, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.get(Sift.score_api_path(user_id, version), options) + response = self.class.api_client.get(Sift.score_api_path(user_id, version), options) Response.new(response.body, response.code, response.response) end @@ -382,7 +398,7 @@ def get_user_score(user_id, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.get(Sift.user_score_api_path(user_id, @version), options) + response = self.class.api_client.get(Sift.user_score_api_path(user_id, @version), options) Response.new(response.body, response.code, response.response) end @@ -434,7 +450,7 @@ def rescore_user(user_id, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.post(Sift.user_score_api_path(user_id, @version), options) + response = self.class.api_client.post(Sift.user_score_api_path(user_id, @version), options) Response.new(response.body, response.code, response.response) end @@ -532,7 +548,7 @@ def unlabel(user_id, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.delete(Sift.users_label_api_path(user_id, version), options) + response = self.class.api_client.delete(Sift.users_label_api_path(user_id, version), options) Response.new(response.body, response.code, response.response) end @@ -569,8 +585,7 @@ def get_workflow_status(run_id, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - uri = API3_ENDPOINT + Sift.workflow_status_path(account_id, run_id) - response = self.class.get(uri, options) + response = self.class.api3_client.get(Sift.workflow_status_path(account_id, run_id), options) Response.new(response.body, response.code, response.response) end @@ -607,8 +622,7 @@ def get_user_decisions(user_id, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - uri = API3_ENDPOINT + Sift.user_decisions_api_path(account_id, user_id) - response = self.class.get(uri, options) + response = self.class.api3_client.get(Sift.user_decisions_api_path(account_id, user_id), options) Response.new(response.body, response.code, response.response) end @@ -645,8 +659,7 @@ def get_order_decisions(order_id, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - uri = API3_ENDPOINT + Sift.order_decisions_api_path(account_id, order_id) - response = self.class.get(uri, options) + response = self.class.api3_client.get(Sift.order_decisions_api_path(account_id, order_id), options) Response.new(response.body, response.code, response.response) end @@ -685,8 +698,7 @@ def get_session_decisions(user_id, session_id, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - uri = API3_ENDPOINT + Sift.session_decisions_api_path(account_id, user_id, session_id) - response = self.class.get(uri, options) + response = self.class.api3_client.get(Sift.session_decisions_api_path(account_id, user_id, session_id), options) Response.new(response.body, response.code, response.response) end @@ -725,8 +737,7 @@ def get_content_decisions(user_id, content_id, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - uri = API3_ENDPOINT + Sift.content_decisions_api_path(account_id, user_id, content_id) - response = self.class.get(uri, options) + response = self.class.api3_client.get(Sift.content_decisions_api_path(account_id, user_id, content_id), options) Response.new(response.body, response.code, response.response) end @@ -768,7 +779,7 @@ def verification_send(properties = {}, opts = {}) :headers => build_default_headers_post(api_key) } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.post(Sift.verification_api_send_path(@version), options) + response = self.class.api_client.post(Sift.verification_api_send_path(version), options) Response.new(response.body, response.code, response.response) end @@ -787,7 +798,7 @@ def verification_resend(properties = {}, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.post(Sift.verification_api_resend_path(@version), options) + response = self.class.api_client.post(Sift.verification_api_resend_path(version), options) Response.new(response.body, response.code, response.response) end @@ -806,7 +817,7 @@ def verification_check(properties = {}, opts = {}) } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.post(Sift.verification_api_check_path(@version), options) + response = self.class.api_client.post(Sift.verification_api_check_path(version), options) Response.new(response.body, response.code, response.response) end @@ -831,7 +842,7 @@ def create_psp_merchant_profile(properties = {}, opts = {}) :basic_auth => { :username => api_key, :password => "" } } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.post(API_ENDPOINT + Sift.psp_merchant_api_path(account_id), options) + response = self.class.api_client.post(Sift.psp_merchant_api_path(account_id), options) Response.new(response.body, response.code, response.response) end @@ -858,7 +869,7 @@ def update_psp_merchant_profile(merchant_id, properties = {}, opts = {}) :basic_auth => { :username => api_key, :password => "" } } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.put(API_ENDPOINT + Sift.psp_merchant_id_api_path(account_id, merchant_id), options) + response = self.class.api_client.put(Sift.psp_merchant_id_api_path(account_id, merchant_id), options) Response.new(response.body, response.code, response.response) end @@ -882,7 +893,7 @@ def get_a_psp_merchant_profile(merchant_id, opts = {}) :basic_auth => { :username => api_key, :password => "" } } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.get(API_ENDPOINT + Sift.psp_merchant_id_api_path(account_id, merchant_id), options) + response = self.class.api_client.get(Sift.psp_merchant_id_api_path(account_id, merchant_id), options) Response.new(response.body, response.code, response.response) end @@ -911,7 +922,7 @@ def get_psp_merchant_profiles(batch_size = nil, batch_token = nil, opts = {}) :query => query } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.get(API_ENDPOINT + Sift.psp_merchant_api_path(account_id), options) + response = self.class.api_client.get(Sift.psp_merchant_api_path(account_id), options) Response.new(response.body, response.code, response.response) end @@ -926,7 +937,7 @@ def handle_response(response) end def decision_instance - @decision_instance ||= Decision.new(api_key, account_id) + @decision_instance ||= Decision.new(api_key, account_id, self.class) end def delete_nils(properties) @@ -943,4 +954,8 @@ def delete_nils(properties) end end end + + require_relative "./client/decision" + require_relative "./error" + end diff --git a/lib/sift/client/decision.rb b/lib/sift/client/decision.rb index 2054197..09a3ab2 100644 --- a/lib/sift/client/decision.rb +++ b/lib/sift/client/decision.rb @@ -10,11 +10,12 @@ class Client class Decision FILTER_PARAMS = %w{ limit entity_type abuse_types from } - attr_reader :account_id, :api_key + attr_reader :account_id, :api_key, :client_class - def initialize(api_key, account_id) + def initialize(api_key, account_id, client_class = Sift::Client) @account_id = account_id @api_key = api_key + @client_class = client_class end def list(options = {}) @@ -25,7 +26,8 @@ def list(options = {}) else Router.get(index_path, { query: build_query(getter), - headers: auth_header + headers: auth_header, + client_class: client_class }) end end @@ -44,7 +46,7 @@ def apply_to(configs = {}) getter = Utils::HashGetter.new(configs) configs[:account_id] = account_id - ApplyTo.new(api_key, getter.get(:decision_id), configs).run + ApplyTo.new(api_key, getter.get(:decision_id), configs, client_class).run end def index_path @@ -54,7 +56,7 @@ def index_path private def request_next_page(path) - Router.get(path, headers: auth_header) + Router.get(path, headers: auth_header, client_class: client_class) end def auth_header @@ -63,4 +65,3 @@ def auth_header end end end - diff --git a/lib/sift/client/decision/apply_to.rb b/lib/sift/client/decision/apply_to.rb index b4274f8..d62e874 100644 --- a/lib/sift/client/decision/apply_to.rb +++ b/lib/sift/client/decision/apply_to.rb @@ -21,7 +21,7 @@ class ApplyTo time } - attr_reader :decision_id, :configs, :getter, :api_key + attr_reader :decision_id, :configs, :getter, :api_key, :client_class PROPERTIES.each do |attribute| class_eval %{ @@ -31,11 +31,12 @@ def #{attribute} } end - def initialize(api_key, decision_id, configs) + def initialize(api_key, decision_id, configs, client_class = Sift::Client) @api_key = api_key @decision_id = decision_id @configs = configs @getter = Utils::HashGetter.new(configs) + @client_class = client_class end def run @@ -58,7 +59,8 @@ def run def send_request Router.post(path, { body: request_body, - headers: headers + headers: headers, + client_class: client_class }) end @@ -79,6 +81,8 @@ def errors validator.valid_order? elsif applying_to_session? validator.valid_session? + elsif applying_to_content? + validator.valid_content? else validator.valid_user? end diff --git a/lib/sift/router.rb b/lib/sift/router.rb index 69e4015..1da1888 100644 --- a/lib/sift/router.rb +++ b/lib/sift/router.rb @@ -2,20 +2,23 @@ require_relative "./client" module Sift - class Router - include HTTParty + class Router < Client class << self def get(path, options = {}) + client_class = options.delete(:client_class) || Sift::Client + options[:base_uri] = nil serialize_body(options) add_default_headers(options) - wrap_response(super(path, options)) + wrap_response(client_class.api3_client.get(path, options)) end def post(path, options = {}) + client_class = options.delete(:client_class) || Sift::Client + options[:base_uri] = nil serialize_body(options) add_default_headers(options) - wrap_response(super(path, options)) + wrap_response(client_class.api3_client.post(path, options)) end def serialize_body(options) diff --git a/lib/sift/version.rb b/lib/sift/version.rb index 659643f..9f7968d 100644 --- a/lib/sift/version.rb +++ b/lib/sift/version.rb @@ -1,5 +1,5 @@ module Sift - VERSION = "4.5.1" + VERSION = "4.6.0" API_VERSION = "205" VERIFICATION_API_VERSION = "1.1" end diff --git a/sift.gemspec b/sift.gemspec index 6900b76..682261c 100644 --- a/sift.gemspec +++ b/sift.gemspec @@ -7,10 +7,10 @@ Gem::Specification.new do |s| s.version = Sift::VERSION s.platform = Gem::Platform::RUBY s.authors = ["Fred Sadaghiani", "Yoav Schatzberg", "Jacob Burnim"] - s.email = ["support@siftscience.com"] - s.homepage = "http://siftscience.com" - s.summary = %q{Sift Science Ruby API Gem} - s.description = %q{Sift Science Ruby API. Please see http://siftscience.com for more details.} + s.email = ["support@sift.com"] + s.homepage = "http://sift.com" + s.summary = %q{Sift Ruby API Gem} + s.description = %q{Sift Ruby API. Please see http://sift.com for more details.} s.rubyforge_project = "sift" @@ -19,6 +19,8 @@ Gem::Specification.new do |s| s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } s.require_paths = ["lib"] + s.required_ruby_version = '>= 2.7.0' + # Gems that must be intalled for sift to compile and build s.add_development_dependency "rspec", "~> 3.5" s.add_development_dependency "rspec_junit_formatter" @@ -26,7 +28,7 @@ Gem::Specification.new do |s| s.add_development_dependency "webmock", ">= 1.16.0", "< 2" # Gems that must be intalled for sift to work - s.add_dependency "httparty", ">= 0.11.0" + s.add_dependency "httparty", ">= 0.23.3" s.add_dependency "multi_json", ">= 1.0" s.add_development_dependency("rake") diff --git a/spec/unit/client_validationapi_spec.rb b/spec/unit/client_validationapi_spec.rb index 5d79d53..a006606 100644 --- a/spec/unit/client_validationapi_spec.rb +++ b/spec/unit/client_validationapi_spec.rb @@ -88,4 +88,50 @@ def valid_check_properties end + # Regression tests for verification API version bug fix + # These tests ensure verification methods use verification_version (1.1) + # instead of the events API version (205) + + it "Uses verification API version (1.1) not events API version (205) for verification_send" do + api_key = "test_key" + response_json = { :status => 0, :error_message => "OK"} + + # Should call v1.1, NOT v205 + stub_request(:post, "https://test_key:@api.siftscience.com/v1.1/verification/send") + .to_return(:status => 200, :body => MultiJson.dump(response_json)) + + # Client defaults: version=205 (events), verification_version=1.1 + response = Sift::Client.new(:api_key => api_key).verification_send(valid_send_properties) + + expect(response.ok?).to eq(true) + end + + it "Uses verification API version (1.1) not events API version (205) for verification_resend" do + api_key = "test_key" + response_json = { :status => 0, :error_message => "OK"} + + # Should call v1.1, NOT v205 + stub_request(:post, "https://test_key:@api.siftscience.com/v1.1/verification/resend") + .to_return(:status => 200, :body => MultiJson.dump(response_json)) + + # Client defaults: version=205 (events), verification_version=1.1 + response = Sift::Client.new(:api_key => api_key).verification_resend(valid_resend_properties) + + expect(response.ok?).to eq(true) + end + + it "Uses verification API version (1.1) not events API version (205) for verification_check" do + api_key = "test_key" + response_json = { :status => 0, :error_message => "OK"} + + # Should call v1.1, NOT v205 + stub_request(:post, "https://test_key:@api.siftscience.com/v1.1/verification/check") + .to_return(:status => 200, :body => MultiJson.dump(response_json)) + + # Client defaults: version=205 (events), verification_version=1.1 + response = Sift::Client.new(:api_key => api_key).verification_check(valid_check_properties) + + expect(response.ok?).to eq(true) + end + end diff --git a/spec/unit/configuration_spec.rb b/spec/unit/configuration_spec.rb new file mode 100644 index 0000000..8717f9e --- /dev/null +++ b/spec/unit/configuration_spec.rb @@ -0,0 +1,67 @@ +require_relative "../spec_helper" +require "sift" +require "logger" + +describe "Sift::Client Configuration Patterns" do + let(:api_key) { "test_api_key" } + + it "propagates global Sift::Client configuration to internal clients" do + Sift::Client.default_timeout 5 + + # Internal executors should inherit this + expect(Sift::Client.api_client.default_options[:timeout]).to eq(5) + expect(Sift::Client.api3_client.default_options[:timeout]).to eq(5) + + # Reset + Sift::Client.default_timeout 2 + end + + it "allows independent subclass configurations" do + class SubclassA < Sift::Client; end + class SubclassB < Sift::Client; end + + SubclassA.default_timeout 10 + SubclassB.default_timeout 20 + + expect(SubclassA.api_client.default_options[:timeout]).to eq(10) + expect(SubclassB.api_client.default_options[:timeout]).to eq(20) + + # Ensure they didn't leak to parent + expect(Sift::Client.api_client.default_options[:timeout]).to be <= 5 + end + + it "propagates complex settings like loggers to subclasses" do + class LoggingClient < Sift::Client; end + + logger = Logger.new(nil) + LoggingClient.logger logger, :debug, :curl + + expect(LoggingClient.api_client.default_options[:logger]).to eq(logger) + expect(LoggingClient.api_client.default_options[:log_level]).to eq(:debug) + expect(LoggingClient.api_client.default_options[:log_format]).to eq(:curl) + + # Ensure Sift::Client remains untouched + expect(Sift::Client.api_client.default_options[:logger]).to be_nil + end + + it "respects inheritance chain for Decisions and Router" do + class DecisionClient < Sift::Client; end + DecisionClient.default_timeout 15 + + client = DecisionClient.new(api_key: api_key, account_id: "acc") + + # Verify the executor used by the Router is the one from DecisionClient + expect(DecisionClient.api3_client.default_options[:timeout]).to eq(15) + + # We want to ensure that when Router.get is called, it uses DecisionClient.api3_client + # In lib/sift/router.rb, we have: + # wrap_response(client_class.api3_client.get(path, options)) + + expect(DecisionClient.api3_client).to receive(:get).and_call_original + + # Mock the request to avoid network calls + stub_request(:get, /api3.siftscience.com/) + + client.get_user_decisions("user_1") + end +end