From 4752fa5cb2b60023b443a52acdc0ab0b6128c57a Mon Sep 17 00:00:00 2001 From: Ryan Plessner Date: Mon, 4 Dec 2017 11:01:21 -0500 Subject: [PATCH 1/3] Update client to the latest version of Threat Stack V2 Api. Changes include: - Adding HAWK authentication - Update methods to always require a param that the api requires - Add new endpoints - Remove methods for the endpoints that were removed --- README.md | 10 +- lib/threatstack/client.rb | 149 ++++++++++++++++++------------ lib/threatstack/entities/agent.rb | 10 +- lib/threatstack/entities/alert.rb | 2 +- test/threatstack_client_test.rb | 50 +++++----- threatstack.gemspec | 1 + 6 files changed, 127 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index e33bfa3..2602767 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ Or install it yourself as: You can access all attributes on responses thanks to the method_missing function in Ruby. We only munged the attributes that don't correspond to snake_case. If you want to see a list of all available attributes for a serializable response object, simply do something like this: ``` -client = Threatstack::Client.new(API_TOKEN, organization_id: ORG_ID) -[threatstack] main> ts.alerts.first.attrs +client = Threatstack::Client.new(API_TOKEN, organization_id: ORG_ID, api_key: API_KEY) +[threatstack] main> ts.alerts("active").first.attrs => [:id, :title, :type, @@ -50,7 +50,7 @@ client = Threatstack::Client.new(API_TOKEN, organization_id: ORG_ID) ``` client = Threatstack::Client.new(API_TOKEN, organization_id: ORG_ID) ## All these are optional url params. See the Threatstack API Docs -alert = client.alerts(start: 3.days.ago, end: Time.now, count: 5).last +alert = client.alerts("active", start: 3.days.ago, end: Time.now, count: 5).last => #1496850520000, @@ -66,7 +66,7 @@ count = alert.count You can also limit the response if that's important to you: ``` -client.alerts(fields: ['title', 'alerts']) +client.alerts("active", fields: ['title', 'alerts']) => [#"CloudTrail Activity (IAM Policy Changes) : CreateAccessKey by ryan_canty", "severity"=>2}>] ``` @@ -80,7 +80,7 @@ client.alert('1234567890') ### Agents ``` -client.agents +client.agents('online') => [# ] client.agent('123123123') => # diff --git a/lib/threatstack/client.rb b/lib/threatstack/client.rb index a05cc17..5489e5c 100644 --- a/lib/threatstack/client.rb +++ b/lib/threatstack/client.rb @@ -1,55 +1,59 @@ require 'open-uri' require 'httparty' +require 'hawk' require 'threatstack/response' require 'threatstack/entities/agent' require 'threatstack/entities/alert' require 'threatstack/entities/ruleset' require 'threatstack/entities/rule' - module Threatstack class ThreatstackError < StandardError; end class Client - THREATSTACK_API = 'https://api.threatstack.com'.freeze - attr_reader :token, :org_id, :api_version, :last_pagination_token + THREATSTACK_API = 'api.threatstack.com'.freeze + PORT = 443 + attr_reader :org_id, :user_id, :api_key, :api_version, :last_pagination_token - def initialize(token, organization_id: nil, api_version: 'v2') - @api_version = api_version - @token = token + def initialize(organization_id, user_id, api_key, api_version: 'v2') @org_id = organization_id + @user_id = user_id + @api_key = api_key + @api_version = api_version if api_version == 'v1' raise ThreatstackError, "This version of threatstack-ruby does not support Threatstack API v1" end end - ### ALERTS ### - - def agents(params = {}) + ### AGENTS ### + def agents(status, params = {}) + if !valid_agent_status?(status) + raise ThreatstackError, "Must specify status with value of 'online' or 'offline'" + end + params[:status] = status response = do_request(:get, 'agents', params) Response.new(response['agents'], self, entity: :agent).agents end - def agent(agent_id, params = {}) + def agent(agent_id) raise ThreatstackError, "Must specify agent id" unless agent_id - response = do_request(:get, "agents/#{agent_id}", params) + response = do_request(:get, "agents/#{agent_id}") Agent.new(response, self) end ### ALERTS ### - def alerts(params = {}) + def alerts(status, params = {}) + if !valid_alert_status?(status) + raise ThreatstackError, "Must specify status with value of 'active' or 'dismissed'" + end + params[:status] = status response = do_request(:get, 'alerts', params) Response.new(response['alerts'], self, entity: :alert).alerts end - def dismissed_alerts(params = {}) - response = do_request(:get, 'alerts/dismissed', params) - Response.new(response['alerts'], self, entity: :alert).alerts - end - def alert(alert_id, params = {}) raise ThreatstackError, "Must specify alert id" unless alert_id - response = do_request(:get, "alerts/#{alert_id}", params) + response = do_request(:get, "alerts/#{alert_id}") Alert.new(response, self) end @@ -58,48 +62,31 @@ def severity_counts(params = {}) Response.new(response['severityCounts'], self, entity: :severity_count).list end - def event(alert_id, event_id, params = {}) - response = do_request(:get, "alerts/#{alert_id}/events/#{event_id}", params) - GenericObject.new(response['details'], self, entity: :event) + def events(alert_id) + response = do_request(:get, "alerts/#{alert_id}/events") + Response.new(response['events'], self, entity: :events).list end ### CVEs ### def vulnerabilities(params = {}) uri = "vulnerabilities" - uri += "/suppressed" if params[:suppressed] response = do_request(:get, uri, params) Response.new(response['cves'], self, entity: :cve).cves end - def vulnerability(vuln_id, params = {}) - raise ThreatstackError, "Must specify vulnerability id" unless vuln_id - response = do_request(:get, "vulnerabilities/#{vuln_id}", params) - Cve.new(response, self) - end - def package_vulnerabilities(package, params = {}) raise ThreatstackError, "Must specify package" unless package uri = "vulnerabilities/package/#{package}" - uri += "/suppressed" if params[:suppressed] response = do_request(:get, uri, params) Response.new(response['packages'], self, entity: :package).list end - def server_vulnerabilities(server, params = {}) - raise ThreatstackError, "Must specify server" unless server - uri = "vulnerabilities/server/#{server}" - uri += "/suppressed" if params[:suppressed] - response = do_request(:get, uri, params) - response['cves'] - end - - def cves_by_agent(agent, params = {}) - raise ThreatstackError, "Must specify agent" unless agent - uri = "vulnerabilities/agent/#{agent}" - uri += "/suppressed" if params[:suppressed] - response = do_request(:get, uri, params) - response['cves'] + def affected_servers(cve) + raise ThreatstackError, "Must specify a cve" unless cve + uri = "vulnerabilities/#{cve}/servers" + response = do_request(:get, uri) + response['servers'] end def vulnerability_suppressions(params = {}) @@ -114,12 +101,18 @@ def rulesets(params = {}) Response.new(response['rulesets'], self, entity: :ruleset).rulesets end - def ruleset(ruleset_id, params = {}) + def ruleset(ruleset_id) raise ThreatstackError, "Must specify ruleset id" unless ruleset_id - response = do_request(:get, "rulesets/#{ruleset_id}", params) + response = do_request(:get, "rulesets/#{ruleset_id}") Ruleset.new(response, self) end + def agents_for_ruleset(ruleset_id) + raise ThreatstackError, "Must specify ruleset id" unless ruleset_id + response = do_request(:get, "rulesets/#{ruleset_id}") + Response.new(response['agents'], self, entity: :agent).agents + end + ### Rules ### def rules(ruleset_id, params = {}) @@ -133,36 +126,78 @@ def rule(ruleset_id, rule_id, params = {}) Rule.new(response, self) end - ### Servers ### + ### EC2 Instances ### - def servers(monitored = true, params = {}) - uri = "servers" - uri += "/non-monitored" unless monitored + def instances(monitored = nil) + uri = "aws/ec2" + params = monitored ? { isMonitored: monitored } : {} response = do_request(:get, uri, params) Response.new(response['servers'], self, entity: :server).list end private + def valid_agent_status?(status) + status && (status == 'online' || status == 'offline') + end + + def valid_alert_status?(status) + status && (status == 'active' || status == 'dismissed') + end + def do_request(method, path, params = {}) - headers = { "Authorization" => token, "Organization-Id" => org_id } - response = HTTParty.public_send(method, build_uri(path, params), headers: headers).parsed_response - if response.instance_of?(Hash) && response['status'] == 'error' - raise ThreatstackError, response['message'] + convert_dates(params) + uri = build_uri(path, params, "https://#{THREATSTACK_API}:#{PORT}") + auth_info_uri = build_uri(path, params) + auth_token = calculate_auth_info(method, auth_info_uri) + headers = { "Authorization" => Hawk::Client.build_authorization_header(auth_token) } + + response = HTTParty.public_send(method, uri, headers: headers) + if !response.success? + raise ThreatstackError, "Response returned with status #{response.code} with message #{response.message}." end + + response_auth_header = response.headers['Server-Authorization'] + + auth_token[:payload] = response.body + auth_token[:content_type] = "application/json" + + auth_result = Hawk::Client.authenticate(response_auth_header, auth_token) + if auth_result['id'] != auth_token[:credentials]['id'] + raise ThreatstackError, "Response was not authentic" + end + @last_pagination_token = response['token'] response end - def build_uri(path, params = {}) - params[:from] = params[:from].utc if params[:from] - params[:until] = params[:until].utc if params[:until] + def convert_dates(params) + params[:from] = params[:from].utc.iso8601 if params[:from] + params[:until] = params[:until].utc.iso8601 if params[:until] params[:fields] = params[:fields].join(',') if params[:fields]&.is_a?(Array) + end + def build_uri(path, params = {}, root = '') query = params.each_pair.map { |k, v| "#{k}=#{v}" }.join('&') - uri = "#{THREATSTACK_API}/#{api_version}/#{path}" + uri = "#{root}/#{api_version}/#{path}" uri += "?#{URI::encode(query)}" if params.any? uri end + + def calculate_auth_info(method, request_uri) + ts = Time.now.to_i + nonce = SecureRandom.hex(4) + credentials = { :id => @user_id, :key => @api_key, :algorithm => 'sha256' } + { + :credentials => credentials, + :method => method.to_s.upcase, + :request_uri => request_uri, + :host => THREATSTACK_API, + :ext => @org_id, + :port => PORT, + :nonce => nonce, + :ts => ts + } + end end end diff --git a/lib/threatstack/entities/agent.rb b/lib/threatstack/entities/agent.rb index b467115..6b8d132 100644 --- a/lib/threatstack/entities/agent.rb +++ b/lib/threatstack/entities/agent.rb @@ -4,18 +4,10 @@ module Threatstack class Agent include Serializable attributes :id, :instance_id, :status, :activated_at, :last_reported_at, - :version, :name, :description, :hostname, :tags, :agent_type + :version, :name, :description, :hostname, :tags, :agent_type, :kernel def tags raw['tags'].map{ |t| Tag.new(t) } end - - def ruleset_ids - raw['rulesets'] - end - - def rulesets - ruleset_ids.each { |id| client.ruleset(id) } - end end end diff --git a/lib/threatstack/entities/alert.rb b/lib/threatstack/entities/alert.rb index 058cc56..4122e29 100644 --- a/lib/threatstack/entities/alert.rb +++ b/lib/threatstack/entities/alert.rb @@ -20,7 +20,7 @@ def ruleset end def events - event_ids&.map{ |event_id| client.event(id, event_id)} + client.events(id) end end end diff --git a/test/threatstack_client_test.rb b/test/threatstack_client_test.rb index b50f86a..ccf82c7 100644 --- a/test/threatstack_client_test.rb +++ b/test/threatstack_client_test.rb @@ -1,7 +1,7 @@ require 'test_helper' describe Threatstack::Client do - let(:client) { Threatstack::Client.new("fake_token") } + let(:client) { Threatstack::Client.new("fake_token", '', '') } let(:key) { nil } let(:payload_singular) { {'foo' => 'bar'} } let(:payload_plural) { {key => [payload_singular]} } @@ -17,12 +17,16 @@ end it 'returns an Alert object in an array' do + alert1 = { + 'id': 'foo' + } + client.stub :do_request, payload_plural do - a = client.alerts.first + a = client.alerts('active').first a.class.name.must_equal "Threatstack::Alert" a.foo.must_equal "bar" - b = client.dismissed_alerts.first + b = client.alerts('dismissed').first b.class.name.must_equal "Threatstack::Alert" b.foo.must_equal "bar" end @@ -33,8 +37,8 @@ a.foo.must_equal 'bar' end - client.stub :do_request, { 'details' => {'foo' => 'bar'}} do - a = client.event('a', 'b') + client.stub :do_request, { 'events' => [{'foo' => 'bar'}]} do + a = client.events('a').first a.class.name.must_equal 'Threatstack::GenericObject' a.foo.must_equal 'bar' end @@ -53,7 +57,7 @@ it 'returns an Agent object in an array' do client.stub :do_request, payload_plural do - a = client.agents.first + a = client.agents('online').first a.class.name.must_equal "Threatstack::Agent" a.foo.must_equal "bar" end @@ -62,19 +66,21 @@ describe 'when entity is vulnerability' do let(:key) { 'cves'} - it 'returns a Cve object' do - client.stub :do_request, payload_singular do - a = client.vulnerability('1') - a.class.name.must_equal 'Threatstack::Cve' - a.foo.must_equal 'bar' - end - end + + stub_response = { + 'cveNumber' => '1', + 'reportedPackage' => 'package', + 'systemPacage' => 'system', + 'vectorType' => 'network', + 'isSuppressed' => true, + 'severity' => 'high' + } it 'returns a Cve object in an array' do - client.stub :do_request, payload_plural do + client.stub :do_request, { 'cves' => [stub_response]} do a = client.vulnerabilities.first a.class.name.must_equal 'Threatstack::Cve' - a.foo.must_equal 'bar' + a.cve_number.must_equal '1' end end @@ -86,13 +92,11 @@ end end - it 'returns a list of cve strings' do - client.stub :do_request, {'cves' => ['CVE-123'] } do - a = client.server_vulnerabilities('server').first - a.must_equal 'CVE-123' - - b = client.cves_by_agent('1').first - b.must_equal 'CVE-123' + it 'returns a list of affected servers' do + client.stub :do_request, {'servers' => [{'agent_id' => 1, 'hostname' => 'host'}] } do + a = client.affected_servers('server').first + expected = {'agent_id' => 1, 'hostname' => 'host'} + a.must_equal expected end end @@ -144,7 +148,7 @@ let(:key) { 'servers' } it 'returns a list of servers' do client.stub :do_request, payload_plural do - a = client.servers.first + a = client.instances.first a.foo.must_equal 'bar' a.class.name.must_equal 'Threatstack::GenericObject' end diff --git a/threatstack.gemspec b/threatstack.gemspec index db8d27b..5aa3824 100644 --- a/threatstack.gemspec +++ b/threatstack.gemspec @@ -26,4 +26,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "minitest", "~> 5.0" spec.add_development_dependency "pry" spec.add_runtime_dependency "httparty" + spec.add_runtime_dependency "hawk-auth" end From 2dfc451d9727657dea055c668a81de7b83e673f8 Mon Sep 17 00:00:00 2001 From: Ryan Plessner Date: Thu, 9 Aug 2018 10:00:29 -0400 Subject: [PATCH 2/3] Remove reference to 'API_TOKEN' in the readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2602767..832627a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Or install it yourself as: You can access all attributes on responses thanks to the method_missing function in Ruby. We only munged the attributes that don't correspond to snake_case. If you want to see a list of all available attributes for a serializable response object, simply do something like this: ``` -client = Threatstack::Client.new(API_TOKEN, organization_id: ORG_ID, api_key: API_KEY) +client = Threatstack::Client.new(organization_id: ORG_ID, api_key: API_KEY) [threatstack] main> ts.alerts("active").first.attrs => [:id, :title, @@ -48,7 +48,7 @@ client = Threatstack::Client.new(API_TOKEN, organization_id: ORG_ID, api_key: AP ### Alerts ``` -client = Threatstack::Client.new(API_TOKEN, organization_id: ORG_ID) +client = Threatstack::Client.new(organization_id: ORG_ID) ## All these are optional url params. See the Threatstack API Docs alert = client.alerts("active", start: 3.days.ago, end: Time.now, count: 5).last => # Date: Thu, 9 Aug 2018 10:15:33 -0400 Subject: [PATCH 3/3] Remove the hash constructor syntax from the old version of the client and use arguments instead --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 832627a..fb71140 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Or install it yourself as: You can access all attributes on responses thanks to the method_missing function in Ruby. We only munged the attributes that don't correspond to snake_case. If you want to see a list of all available attributes for a serializable response object, simply do something like this: ``` -client = Threatstack::Client.new(organization_id: ORG_ID, api_key: API_KEY) +client = Threatstack::Client.new(ORG_ID, USER_ID, API_KEY) [threatstack] main> ts.alerts("active").first.attrs => [:id, :title, @@ -48,7 +48,7 @@ client = Threatstack::Client.new(organization_id: ORG_ID, api_key: API_KEY) ### Alerts ``` -client = Threatstack::Client.new(organization_id: ORG_ID) +client = Threatstack::Client.new(ORG_ID, USER_ID, API_KEY) ## All these are optional url params. See the Threatstack API Docs alert = client.alerts("active", start: 3.days.ago, end: Time.now, count: 5).last => #