virustotal_api 0.4.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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