virustotal_api 0.4.0 → 0.5.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +26 -0
  3. data/.gitignore +1 -0
  4. data/.rubocop.yml +12 -5
  5. data/CHANGELOG.md +35 -9
  6. data/README.md +97 -59
  7. data/lib/virustotal_api.rb +7 -7
  8. data/lib/virustotal_api/analysis.rb +16 -0
  9. data/lib/virustotal_api/base.rb +41 -10
  10. data/lib/virustotal_api/domain.rb +18 -0
  11. data/lib/virustotal_api/exceptions.rb +3 -0
  12. data/lib/virustotal_api/file.rb +48 -0
  13. data/lib/virustotal_api/group.rb +18 -0
  14. data/lib/virustotal_api/ip.rb +18 -0
  15. data/lib/virustotal_api/uri.rb +2 -1
  16. data/lib/virustotal_api/url.rb +38 -0
  17. data/lib/virustotal_api/user.rb +18 -0
  18. data/lib/virustotal_api/version.rb +2 -1
  19. data/test/analysis_test.rb +26 -0
  20. data/test/base_test.rb +13 -25
  21. data/test/domain_test.rb +27 -0
  22. data/test/exceptions_test.rb +22 -0
  23. data/test/file_test.rb +63 -0
  24. data/test/fixtures/analysis.yml +544 -0
  25. data/test/fixtures/domain.yml +830 -0
  26. data/test/fixtures/file_analyse.yml +52 -0
  27. data/test/fixtures/file_find.yml +853 -0
  28. data/test/fixtures/file_not_found.yml +52 -0
  29. data/test/fixtures/file_rate_limit.yml +52 -0
  30. data/test/fixtures/file_unauthorized.yml +51 -0
  31. data/test/fixtures/file_upload.yml +54 -0
  32. data/test/fixtures/group_find.yml +216 -0
  33. data/test/fixtures/ip.yml +716 -0
  34. data/test/fixtures/unscanned_url_find.yml +44 -0
  35. data/test/fixtures/url_analyse.yml +52 -0
  36. data/test/fixtures/url_find.yml +599 -0
  37. data/test/fixtures/user_find.yml +213 -0
  38. data/test/group_test.rb +27 -0
  39. data/test/{ip_report_test.rb → ip_test.rb} +6 -4
  40. data/test/uri_test.rb +1 -1
  41. data/test/url_test.rb +47 -0
  42. data/test/user_test.rb +26 -0
  43. data/test/version_test.rb +1 -2
  44. data/virustotal_api.gemspec +10 -8
  45. metadata +99 -69
  46. data/.circleci/config.yml +0 -19
  47. data/lib/virustotal_api/domain_report.rb +0 -36
  48. data/lib/virustotal_api/file_report.rb +0 -37
  49. data/lib/virustotal_api/file_rescan.rb +0 -35
  50. data/lib/virustotal_api/file_scan.rb +0 -37
  51. data/lib/virustotal_api/ip_report.rb +0 -36
  52. data/lib/virustotal_api/url_report.rb +0 -41
  53. data/lib/virustotal_api/url_scan.rb +0 -35
  54. data/test/domain_report_test.rb +0 -32
  55. data/test/file_report_test.rb +0 -36
  56. data/test/file_rescan_test.rb +0 -32
  57. data/test/file_scan_test.rb +0 -30
  58. data/test/fixtures/domain_report.yml +0 -311
  59. data/test/fixtures/ip_report.yml +0 -1323
  60. data/test/fixtures/queue_unscanned_url_report.yml +0 -46
  61. data/test/fixtures/report.yml +0 -110
  62. data/test/fixtures/report_not_found.yml +0 -42
  63. data/test/fixtures/request_forbidden.yml +0 -38
  64. data/test/fixtures/rescan.yml +0 -47
  65. data/test/fixtures/scan.yml +0 -49
  66. data/test/fixtures/unscanned_url_report.yml +0 -43
  67. data/test/fixtures/url_report.yml +0 -95
  68. data/test/fixtures/url_scan.yml +0 -48
  69. data/test/url_report_test.rb +0 -57
  70. data/test/url_scan_test.rb +0 -30
@@ -3,22 +3,50 @@
3
3
  require 'virustotal_api/exceptions'
4
4
  require 'rest-client'
5
5
  require 'json'
6
+ require 'base64'
6
7
 
8
+ # The base VirustotalAPI module.
7
9
  module VirustotalAPI
10
+ # The base class implementing the raw calls to Virustotal API V3.
8
11
  class Base
12
+ attr_reader :report, :report_url, :id
13
+
14
+ def initialize(report)
15
+ @report = report
16
+ @report_url = report&.dig('data', 'links', 'self')
17
+ @id = report&.dig('data', 'id')
18
+ end
19
+
9
20
  # @return [String] string of API URI class method
10
21
  def self.api_uri
11
22
  VirustotalAPI::URI
12
23
  end
13
24
 
14
- # @param [RestClient::Response] response
15
- # @return [Hash] the parsed JSON.
16
- def self.parse(response)
17
- if response.code == 204
18
- raise(RateLimitError, 'maximum number of 4 requests per minute reached')
19
- end
20
-
25
+ # The actual method performing a call to Virustotal
26
+ #
27
+ # @param [String] url The url of the API
28
+ # @param [String] api_key The key for virustotal
29
+ # @param [String] method The HTTP method to use
30
+ # @param [Hash] options Options to pass as payload
31
+ # @return [VirustotalAPI::Domain] Report Search Result
32
+ def self.perform(url, api_key, method = :get, options = {})
33
+ response = RestClient::Request.execute(
34
+ method: method,
35
+ url: api_uri + url,
36
+ headers: { 'x-apikey': api_key },
37
+ payload: options
38
+ )
21
39
  JSON.parse(response.body)
40
+ rescue RestClient::NotFound
41
+ {}
42
+ rescue RestClient::Unauthorized
43
+ # Raise a custom exception not to expose the underlying
44
+ # HTTP client.
45
+ raise VirustotalAPI::Unauthorized
46
+ rescue RestClient::TooManyRequests
47
+ # Raise a custom exception not to expose the underlying
48
+ # HTTP client.
49
+ raise VirustotalAPI::RateLimitError
22
50
  end
23
51
 
24
52
  # @return [String] string of API URI instance method
@@ -27,11 +55,14 @@ module VirustotalAPI
27
55
  end
28
56
 
29
57
  # @return [Boolean] if report for resource exists
30
- # 0 => not_present, 1 => exists, -1 => invalid_ip_address
31
58
  def exists?
32
- response_code = report.fetch('response_code') { nil }
59
+ !report.empty?
60
+ end
33
61
 
34
- response_code == 1
62
+ # Generate a URL identifier.
63
+ # @see https://developers.virustotal.com/v3.0/reference#url
64
+ def self.url_identifier(url)
65
+ Base64.encode64(url).strip.gsub('=', '')
35
66
  end
36
67
  end
37
68
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module VirustotalAPI
6
+ # A class for '/domains' API
7
+ class Domain < Base
8
+ # Find a domain.
9
+ #
10
+ # @param [String] domain The domain to search
11
+ # @param [String] api_key for virustotal
12
+ # @return [VirustotalAPI::Domain] Report Search Result
13
+ def self.find(domain, api_key)
14
+ report = perform("/domains/#{domain}", api_key)
15
+ new(report)
16
+ end
17
+ end
18
+ end
@@ -3,4 +3,7 @@
3
3
  module VirustotalAPI
4
4
  class RateLimitError < RuntimeError
5
5
  end
6
+
7
+ class Unauthorized < RuntimeError
8
+ end
6
9
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module VirustotalAPI
6
+ # A class for '/files' API
7
+ class File < Base
8
+ # Find a hash.
9
+ #
10
+ # @param [String] resource file as a md5/sha1/sha256 hash
11
+ # @param [String] api_key The key for virustotal
12
+ # @return [VirustotalAPI::File] Report Search Result
13
+ def self.find(resource, api_key)
14
+ report = perform("/files/#{resource}", api_key)
15
+ new(report)
16
+ end
17
+
18
+ # Upload a new file.
19
+ #
20
+ # @param [String] file_path for file to be sent for scan
21
+ # @param [String] api_key The key for virustotal
22
+ # @param [Hash] opts hash for additional options
23
+ # @return [VirusotalAPI::File] Report
24
+ def self.upload(file_path, api_key, opts = {})
25
+ filename = opts.fetch('filename') { ::File.basename(file_path) }
26
+ report = perform('/files', api_key, :post, filename: filename, file: ::File.open(file_path, 'r'))
27
+ new(report)
28
+ end
29
+
30
+ # Analyse a hash again.
31
+ #
32
+ # @param [String] resource file as a md5/sha1/sha256 hash
33
+ # @param [String] api_key The key for virustotal
34
+ # @return [VirustotalAPI::File] Report
35
+ def self.analyse(resource, api_key)
36
+ report = perform("/files/#{resource}/analyse", api_key, :post)
37
+ new(report)
38
+ end
39
+
40
+ # Check if the submitted hash is detected by an AV engine.
41
+ #
42
+ # @param [String] engine The engine to check.
43
+ # @return [Boolean] true if detected
44
+ def detected_by(engine)
45
+ report&.dig('data', 'attributes', 'last_analysis_results', engine, 'category') == 'malicious'
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module VirustotalAPI
6
+ # A class for '/groups' API
7
+ class Group < Base
8
+ # Find a Group.
9
+ #
10
+ # @param [String] group_id to find
11
+ # @param [String] api_key The key for virustotal
12
+ # @return [VirustotalAPI::User] Report
13
+ def self.find(group_id, api_key)
14
+ report = perform("/groups/#{group_id}", api_key)
15
+ new(report)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module VirustotalAPI
6
+ # A class for '/ip_addresses' API
7
+ class IP < Base
8
+ # Find an IP.
9
+ #
10
+ # @param [String] ip address The IP to find.
11
+ # @param [String] api_key The key for virustotal
12
+ # @return [VirustotalAPI::IPReport] Report
13
+ def self.find(ip, api_key)
14
+ report = perform("/ip_addresses/#{ip}", api_key)
15
+ new(report)
16
+ end
17
+ end
18
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VirustotalAPI
4
- URI = 'https://www.virustotal.com/vtapi/v2'
4
+ # The API base URI
5
+ URI = 'https://www.virustotal.com/api/v3'
5
6
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module VirustotalAPI
6
+ # A class for '/urls' API
7
+ class URL < Base
8
+ # Find a URL.
9
+ #
10
+ # @param [String] resource as an ip/domain/url
11
+ # @param [String] api_key The key for virustotal
12
+ # @return [VirustotalAPI::URL] Report
13
+ def self.find(resource, api_key)
14
+ report = perform("/urls/#{url_identifier(resource)}", api_key)
15
+ new(report)
16
+ end
17
+
18
+ # Analyse a URL again.
19
+ #
20
+ # @param [String] resource as an ip/domain/url
21
+ # @param [String] api_key The key for virustotal
22
+ # @return [VirustotalAPI::URL] Report
23
+ def self.analyse(resource, api_key)
24
+ report = perform("/urls/#{url_identifier(resource)}/analyse", api_key, :post)
25
+ new(report)
26
+ end
27
+
28
+ # Upload a URL for detection.
29
+ #
30
+ # @param [String] resource as an ip/domain/url
31
+ # @param [String] api_key The key for virustotal
32
+ # @return [VirustotalAPI::URL] Report
33
+ def self.upload(resource, api_key)
34
+ report = perform('/urls', api_key, :post, url: resource)
35
+ new(report)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module VirustotalAPI
6
+ # A class for '/users' API
7
+ class User < Base
8
+ # Find a User.
9
+ #
10
+ # @param [String] user_key with id or api_key
11
+ # @param [String] api_key The key for virustotal
12
+ # @return [VirustotalAPI::User] Report
13
+ def self.find(user_key, api_key)
14
+ report = perform("/users/#{user_key}", api_key)
15
+ new(report)
16
+ end
17
+ end
18
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VirustotalAPI
4
- VERSION = '0.4.0'
4
+ # The GEM version
5
+ VERSION = '0.5.3'
5
6
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require './test/test_helper'
4
+
5
+ class VirustotalAPIAnalysisTest < Minitest::Test
6
+ def setup
7
+ @url = 'http://www.google.com'
8
+ @api_key = 'theapikey'
9
+ end
10
+
11
+ def test_todo
12
+ VCR.use_cassette('url_find') do
13
+ vtreport = VirustotalAPI::URL.find(@url, @api_key)
14
+
15
+ @id = vtreport.id
16
+ assert @id.is_a?(String)
17
+ end
18
+
19
+ VCR.use_cassette('analysis') do
20
+ analysis = VirustotalAPI::Analysis.find(@id, @api_key)
21
+
22
+ assert analysis.exists?
23
+ assert analysis.id.is_a?(String)
24
+ end
25
+ end
26
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable LineLength
4
3
  require './test/test_helper'
5
4
 
6
5
  class VirustotalAPIBaseTest < Minitest::Test
@@ -15,45 +14,34 @@ class VirustotalAPIBaseTest < Minitest::Test
15
14
 
16
15
  # Instance Method
17
16
  def test_api_uri_instance_method
18
- base_uri = 'https://www.virustotal.com/vtapi/v2'
19
- vt_base = VirustotalAPI::Base.new
17
+ base_uri = 'https://www.virustotal.com/api/v3'
18
+ vt_base = VirustotalAPI::Base.new(nil)
20
19
 
21
20
  assert vt_base.api_uri.is_a?(String)
22
- assert vt_base.api_uri, base_uri
21
+ assert_equal base_uri, vt_base.api_uri
23
22
  end
24
23
 
25
24
  # Class Method
26
25
  def test_api_uri_class_method
27
- base_uri = 'https://www.virustotal.com/vtapi/v2'
26
+ base_uri = 'https://www.virustotal.com/api/v3'
28
27
 
29
28
  assert VirustotalAPI::Base.api_uri.is_a?(String)
30
- assert VirustotalAPI::Base.api_uri, base_uri
29
+ assert_equal base_uri, VirustotalAPI::Base.api_uri
31
30
  end
32
31
 
33
- def test_parse_code_200
34
- mock_response200 = Minitest::Mock.new
35
- mock_response200.expect(:code, 200)
36
- mock_response200.expect(:body, '{}')
37
-
38
- assert VirustotalAPI::Base.parse(mock_response200), {}
39
- end
40
-
41
- def test_parse_code_204
42
- mock_response204 = Minitest::Mock.new
43
- mock_response204.expect(:code, 204)
44
- mock_response204.expect(:body, '{}')
32
+ def test_exists?
33
+ VCR.use_cassette('file_find') do
34
+ virustotal_report = VirustotalAPI::File.find(@sha256, @api_key)
45
35
 
46
- assert_raises VirustotalAPI::RateLimitError do
47
- VirustotalAPI::Base.parse(mock_response204)
36
+ assert virustotal_report.exists?
48
37
  end
49
38
  end
50
39
 
51
- # Test using FileReport
52
- def test_exists?
53
- VCR.use_cassette('report') do
54
- virustotal_report = VirustotalAPI::FileReport.find(@sha256, @api_key)
40
+ def test_not_exists?
41
+ VCR.use_cassette('file_not_found') do
42
+ virustotal_report = VirustotalAPI::File.find(@sha256, @api_key)
55
43
 
56
- assert virustotal_report.exists?, true
44
+ assert !virustotal_report.exists?
57
45
  end
58
46
  end
59
47
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require './test/test_helper'
4
+
5
+ class VirustotalAPIDomainTest < Minitest::Test
6
+ def setup
7
+ @domain = 'virustotal.com'
8
+ @api_key = 'testapikey'
9
+ end
10
+
11
+ def test_class_exists
12
+ assert VirustotalAPI::Domain
13
+ end
14
+
15
+ def test_report_response
16
+ VCR.use_cassette('domain') do
17
+ vtdomain_report = VirustotalAPI::Domain.find(@domain, @api_key)
18
+
19
+ # Make sure that the JSON was parsed
20
+ assert vtdomain_report.exists?
21
+ assert vtdomain_report.is_a?(VirustotalAPI::Domain)
22
+ assert vtdomain_report.report.is_a?(Hash)
23
+ assert vtdomain_report.id.is_a?(String)
24
+ assert vtdomain_report.report_url.is_a?(String)
25
+ end
26
+ end
27
+ end
@@ -3,7 +3,29 @@
3
3
  require './test/test_helper'
4
4
 
5
5
  class RateLimitErrorTest < Minitest::Test
6
+ def setup
7
+ @sha256 = '01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b'
8
+ @api_key = 'testapikey'
9
+ end
10
+
6
11
  def test_class_exists
7
12
  assert VirustotalAPI::RateLimitError
13
+ assert VirustotalAPI::Unauthorized
14
+ end
15
+
16
+ def test_unauthorized
17
+ VCR.use_cassette('file_unauthorized') do
18
+ assert_raises VirustotalAPI::Unauthorized do
19
+ VirustotalAPI::File.find(@sha256, @api_key)
20
+ end
21
+ end
22
+ end
23
+
24
+ def test_rate_limit
25
+ VCR.use_cassette('file_rate_limit') do
26
+ assert_raises VirustotalAPI::RateLimitError do
27
+ VirustotalAPI::File.analyse(@sha256, @api_key)
28
+ end
29
+ end
8
30
  end
9
31
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require './test/test_helper'
4
+
5
+ class VirustotalAPIFileTest < Minitest::Test
6
+ def setup
7
+ @sha256 = '01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b'
8
+ @file_path = File.expand_path('test/fixtures/null_file')
9
+ @api_key = 'testapikey'
10
+ end
11
+
12
+ def test_class_exists
13
+ assert VirustotalAPI::File
14
+ end
15
+
16
+ def test_report_response
17
+ VCR.use_cassette('file_find') do
18
+ vt_file_report = VirustotalAPI::File.find(@sha256, @api_key)
19
+
20
+ # Make sure that the JSON was parsed
21
+ assert vt_file_report.exists?
22
+ assert vt_file_report.is_a?(VirustotalAPI::File)
23
+ assert vt_file_report.report.is_a?(Hash)
24
+ assert vt_file_report.id.is_a?(String)
25
+ assert vt_file_report.report_url.is_a?(String)
26
+ end
27
+ end
28
+
29
+ def test_find
30
+ id = '01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b'
31
+ permalink = "https://www.virustotal.com/api/v3/files/#{id}"
32
+
33
+ VCR.use_cassette('file_find') do
34
+ vt_file_report = VirustotalAPI::File.find(@sha256, @api_key)
35
+
36
+ assert_equal permalink, vt_file_report.report_url
37
+ assert_equal id, vt_file_report.id
38
+ assert vt_file_report.detected_by('Avira')
39
+ assert !vt_file_report.detected_by('Acronis')
40
+ assert !vt_file_report.detected_by('Yeyeyeye') # not present in file
41
+ end
42
+ end
43
+
44
+ def test_upload
45
+ VCR.use_cassette('file_upload') do
46
+ vt_file_upload = VirustotalAPI::File.upload(@file_path, @api_key)
47
+
48
+ assert vt_file_upload.exists?
49
+ assert vt_file_upload.report.is_a?(Hash)
50
+ assert vt_file_upload.id.is_a?(String)
51
+ end
52
+ end
53
+
54
+ def test_analyse
55
+ VCR.use_cassette('file_analyse') do
56
+ vt_file_analyse = VirustotalAPI::File.analyse(@sha256, @api_key)
57
+
58
+ assert vt_file_analyse.exists?
59
+ assert vt_file_analyse.report.is_a?(Hash)
60
+ assert vt_file_analyse.id.is_a?(String)
61
+ end
62
+ end
63
+ end