tenable-ruby-sdk 0.1.0

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.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenable
4
+ module Models
5
+ # Represents a Tenable.io scan.
6
+ Scan = Data.define(:id, :uuid, :name, :status, :folder_id, :type, :creation_date, :last_modification_date) do
7
+ # Builds a Scan from a raw API response hash.
8
+ #
9
+ # @param data [Hash] raw API response hash with string keys
10
+ # @return [Scan]
11
+ def self.from_api(data)
12
+ data = data.transform_keys(&:to_sym)
13
+ new(
14
+ id: data[:id],
15
+ uuid: data[:uuid],
16
+ name: data[:name],
17
+ status: data[:status],
18
+ folder_id: data[:folder_id],
19
+ type: data[:type],
20
+ creation_date: data[:creation_date],
21
+ last_modification_date: data[:last_modification_date]
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenable
4
+ module Models
5
+ # Represents a vulnerability finding from the Tenable.io API.
6
+ Vulnerability = Data.define(
7
+ :plugin_id, :plugin_name, :severity, :severity_id, :state,
8
+ :asset, :output, :first_found, :last_found, :last_fixed,
9
+ :vpr_score, :cvss_base_score, :cve
10
+ ) do
11
+ # Builds a Vulnerability from a raw API response hash.
12
+ #
13
+ # @param data [Hash] raw API response hash with string keys
14
+ # @return [Vulnerability]
15
+ def self.from_api(data) # rubocop:disable Metrics/MethodLength
16
+ data = data.transform_keys(&:to_sym)
17
+ new(
18
+ plugin_id: data[:plugin_id],
19
+ plugin_name: data[:plugin_name],
20
+ severity: data[:severity],
21
+ severity_id: data[:severity_id],
22
+ state: data[:state],
23
+ asset: data[:asset] ? Asset.from_api(data[:asset]) : nil,
24
+ output: data[:output],
25
+ first_found: data[:first_found],
26
+ last_found: data[:last_found],
27
+ last_fixed: data[:last_fixed],
28
+ vpr_score: data[:vpr_score],
29
+ cvss_base_score: data[:cvss_base_score],
30
+ cve: data[:cve] || []
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenable
4
+ module Models
5
+ # Represents a web application scan instance.
6
+ WebAppScan = Data.define(:scan_id, :config_id, :status, :started_at, :completed_at, :findings_count) do
7
+ # Builds a WebAppScan from a raw API response hash.
8
+ #
9
+ # @param data [Hash] raw API response hash with string keys
10
+ # @return [WebAppScan]
11
+ def self.from_api(data)
12
+ data = data.transform_keys(&:to_sym)
13
+ new(
14
+ scan_id: data[:scan_id],
15
+ config_id: data[:config_id],
16
+ status: data[:status],
17
+ started_at: data[:started_at],
18
+ completed_at: data[:completed_at],
19
+ findings_count: data[:findings_count] || 0
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenable
4
+ module Models
5
+ # Represents a web application scan configuration.
6
+ WebAppScanConfig = Data.define(:config_id, :name, :target, :status, :tracking_id) do
7
+ # Builds a WebAppScanConfig from a raw API response hash.
8
+ #
9
+ # @param data [Hash] raw API response hash with string keys
10
+ # @return [WebAppScanConfig]
11
+ def self.from_api(data)
12
+ data = data.transform_keys(&:to_sym)
13
+ new(
14
+ config_id: data[:config_id],
15
+ name: data[:name],
16
+ target: data[:target],
17
+ status: data[:status],
18
+ tracking_id: data[:tracking_id]
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenable
4
+ # Provides lazy, offset-based pagination over API list endpoints.
5
+ #
6
+ # Fetches pages on demand and yields individual items via {#each},
7
+ # keeping memory usage constant regardless of total result count.
8
+ # Includes Enumerable for standard collection methods.
9
+ class Pagination
10
+ include Enumerable
11
+
12
+ # @return [Integer] maximum items per page
13
+ MAX_PAGE_SIZE = 200
14
+
15
+ # Creates a new paginator.
16
+ #
17
+ # @param limit [Integer] items per page (capped at {MAX_PAGE_SIZE})
18
+ # @yield [offset, limit] block that fetches a page of results
19
+ # @yieldparam offset [Integer] the current offset
20
+ # @yieldparam limit [Integer] the page size
21
+ # @yieldreturn [Hash] a hash containing +"items"+ and +"total"+ keys
22
+ def initialize(limit: MAX_PAGE_SIZE, &fetcher)
23
+ @limit = [limit, MAX_PAGE_SIZE].min
24
+ @fetcher = fetcher
25
+ end
26
+
27
+ # Iterates over all paginated items.
28
+ #
29
+ # @yield [item] yields each item
30
+ # @return [Enumerator] if no block is given
31
+ #
32
+ # @example Iterate over all results
33
+ # paginator = Tenable::Pagination.new { |offset, limit| fetch_page(offset, limit) }
34
+ # paginator.each { |item| process(item) }
35
+ #
36
+ # @example Use Enumerable methods
37
+ # paginator.first(10)
38
+ # paginator.select { |item| item['severity'] > 2 }
39
+ def each(&block)
40
+ return enum_for(:each) unless block
41
+
42
+ offset = 0
43
+ loop do
44
+ page = @fetcher.call(offset, @limit)
45
+ items = extract_items(page)
46
+ total = extract_total(page)
47
+
48
+ items.each(&block)
49
+
50
+ offset += @limit
51
+ break if offset >= total || items.empty?
52
+ end
53
+ end
54
+
55
+ # Returns a lazy enumerator over all paginated items.
56
+ #
57
+ # @return [Enumerator::Lazy]
58
+ def lazy
59
+ each.lazy
60
+ end
61
+
62
+ private
63
+
64
+ def extract_items(page)
65
+ page[:items] || page['items'] || []
66
+ end
67
+
68
+ def extract_total(page)
69
+ page[:total] || page['total'] || 0
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenable
4
+ # Shared polling behavior for resources that need to wait on async operations.
5
+ #
6
+ # Uses monotonic clock to avoid issues with system clock changes.
7
+ module Pollable
8
+ # Polls a block until it returns a truthy value or the timeout expires.
9
+ #
10
+ # @param timeout [Integer] maximum seconds to wait
11
+ # @param poll_interval [Integer] seconds between polls
12
+ # @param label [String] descriptive label for timeout error messages
13
+ # @yield block that returns a truthy value when the operation is complete
14
+ # @yieldreturn [Object, nil] truthy to stop polling, nil/false to continue
15
+ # @return [Object] the truthy value returned by the block
16
+ # @raise [Tenable::TimeoutError] if the timeout expires before the block returns truthy
17
+ def poll_until(timeout:, poll_interval:, label:)
18
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
19
+ loop do
20
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
21
+ raise Tenable::TimeoutError, "#{label} timed out after #{timeout}s"
22
+ end
23
+
24
+ result = yield
25
+ return result if result
26
+
27
+ sleep(poll_interval)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenable
4
+ module Resources
5
+ # Provides access to the Tenable.io asset export endpoints.
6
+ #
7
+ # Exports allow bulk retrieval of asset data in chunks.
8
+ #
9
+ # @example Full export workflow
10
+ # asset_exports = client.asset_exports
11
+ # result = asset_exports.export(chunk_size: 100)
12
+ # asset_exports.wait_for_completion(result["export_uuid"])
13
+ # asset_exports.each(result["export_uuid"]) { |asset| process(asset) }
14
+ class AssetExports < Base
15
+ # @return [Integer] default seconds between status polls
16
+ DEFAULT_POLL_INTERVAL = 2
17
+
18
+ # @return [Integer] default timeout in seconds for waiting on export completion
19
+ DEFAULT_TIMEOUT = 300
20
+
21
+ # Initiates a new asset export.
22
+ #
23
+ # @param body [Hash] export request parameters (e.g., +chunk_size+, +filters+)
24
+ # @return [Hash] response containing the export UUID
25
+ def export(body = {})
26
+ post('/assets/export', body)
27
+ end
28
+
29
+ # Retrieves the status of an asset export.
30
+ #
31
+ # @param export_uuid [String] the export UUID
32
+ # @return [Hash] status data including +"status"+ and +"chunks_available"+
33
+ def status(export_uuid)
34
+ validate_path_segment!(export_uuid, name: 'export_uuid')
35
+ get("/assets/export/#{export_uuid}/status")
36
+ end
37
+
38
+ # Downloads a single chunk of asset export data.
39
+ #
40
+ # @param export_uuid [String] the export UUID
41
+ # @param chunk_id [Integer] the chunk identifier
42
+ # @return [Array<Hash>] array of asset records
43
+ def download_chunk(export_uuid, chunk_id)
44
+ validate_path_segment!(export_uuid, name: 'export_uuid')
45
+ validate_path_segment!(chunk_id, name: 'chunk_id')
46
+ get("/assets/export/#{export_uuid}/chunks/#{chunk_id}")
47
+ end
48
+
49
+ # Cancels an in-progress asset export.
50
+ #
51
+ # @param export_uuid [String] the export UUID
52
+ # @return [Hash] cancellation response
53
+ def cancel(export_uuid)
54
+ validate_path_segment!(export_uuid, name: 'export_uuid')
55
+ post("/assets/export/#{export_uuid}/cancel")
56
+ end
57
+
58
+ # Iterates over all available chunks for a completed export.
59
+ #
60
+ # @param export_uuid [String] the export UUID
61
+ # @yield [record] yields each asset record
62
+ # @yieldparam record [Hash] a single asset record
63
+ # @return [void]
64
+ def each(export_uuid, &block)
65
+ validate_path_segment!(export_uuid, name: 'export_uuid')
66
+ return enum_for(:each, export_uuid) unless block
67
+
68
+ status_data = status(export_uuid)
69
+ chunks = status_data['chunks_available'] || []
70
+ chunks.each do |chunk_id|
71
+ records = download_chunk(export_uuid, chunk_id)
72
+ records.each(&block)
73
+ end
74
+ end
75
+
76
+ # Polls until the export reaches FINISHED or ERROR status.
77
+ #
78
+ # @param export_uuid [String] the export UUID
79
+ # @param timeout [Integer] maximum seconds to wait (default: 300)
80
+ # @param poll_interval [Integer] seconds between status checks (default: 2)
81
+ # @return [Hash] the final status data when export is FINISHED
82
+ # @raise [Tenable::TimeoutError] if the export does not finish within the timeout
83
+ # @raise [Tenable::ApiError] if the export status is ERROR
84
+ def wait_for_completion(export_uuid, timeout: DEFAULT_TIMEOUT, poll_interval: DEFAULT_POLL_INTERVAL)
85
+ validate_path_segment!(export_uuid, name: 'export_uuid')
86
+ poll_until(timeout: timeout, poll_interval: poll_interval, label: "Asset export #{export_uuid}") do
87
+ status_data = status(export_uuid)
88
+ raise Tenable::ApiError, "Asset export #{export_uuid} failed" if status_data['status'] == 'ERROR'
89
+
90
+ status_data if status_data['status'] == 'FINISHED'
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenable
4
+ module Resources
5
+ # Base class for all API resource classes. Provides HTTP helpers
6
+ # and response handling with automatic error mapping.
7
+ class Base
8
+ include Pollable
9
+
10
+ # @param connection [Connection] an initialized API connection
11
+ def initialize(connection)
12
+ @connection = connection
13
+ end
14
+
15
+ private
16
+
17
+ # Performs a GET request.
18
+ #
19
+ # @param path [String] the API endpoint path
20
+ # @param params [Hash] query parameters
21
+ # @return [Hash, Array, nil] parsed JSON response
22
+ # @raise [AuthenticationError] on 401 responses
23
+ # @raise [RateLimitError] on 429 responses
24
+ # @raise [ApiError] on other non-2xx responses
25
+ # @raise [ParseError] if the response is not valid JSON
26
+ def get(path, params = {})
27
+ response = @connection.faraday.get(path, params)
28
+ handle_response(response)
29
+ end
30
+
31
+ # Performs a POST request with a JSON body.
32
+ #
33
+ # @param path [String] the API endpoint path
34
+ # @param body [Hash, nil] request body (serialized to JSON)
35
+ # @return [Hash, Array, nil] parsed JSON response
36
+ # @raise [AuthenticationError] on 401 responses
37
+ # @raise [RateLimitError] on 429 responses
38
+ # @raise [ApiError] on other non-2xx responses
39
+ # @raise [ParseError] if the response is not valid JSON
40
+ def post(path, body = nil)
41
+ response = @connection.faraday.post(path) do |req|
42
+ req.headers['Content-Type'] = 'application/json'
43
+ req.body = JSON.generate(body) if body
44
+ end
45
+ handle_response(response)
46
+ end
47
+
48
+ # Performs a PUT request with a JSON body.
49
+ #
50
+ # @param path [String] the API endpoint path
51
+ # @param body [Hash, nil] request body (serialized to JSON)
52
+ # @return [Hash, Array, nil] parsed JSON response
53
+ def put(path, body = nil)
54
+ response = @connection.faraday.put(path) do |req|
55
+ req.headers['Content-Type'] = 'application/json'
56
+ req.body = JSON.generate(body) if body
57
+ end
58
+ handle_response(response)
59
+ end
60
+
61
+ # Performs a PATCH request with a JSON body.
62
+ #
63
+ # @param path [String] the API endpoint path
64
+ # @param body [Hash, nil] request body (serialized to JSON)
65
+ # @return [Hash, Array, nil] parsed JSON response
66
+ def patch(path, body = nil)
67
+ response = @connection.faraday.patch(path) do |req|
68
+ req.headers['Content-Type'] = 'application/json'
69
+ req.body = JSON.generate(body) if body
70
+ end
71
+ handle_response(response)
72
+ end
73
+
74
+ # Performs a DELETE request.
75
+ #
76
+ # @param path [String] the API endpoint path
77
+ # @param params [Hash] query parameters
78
+ # @return [Hash, Array, nil] parsed JSON response
79
+ def delete(path, params = {})
80
+ response = @connection.faraday.delete(path, params)
81
+ handle_response(response)
82
+ end
83
+
84
+ def handle_response(response)
85
+ raise_for_status(response)
86
+ parse_body(response)
87
+ end
88
+
89
+ def parse_body(response)
90
+ return nil if response.body.nil? || response.body.empty?
91
+
92
+ JSON.parse(response.body)
93
+ rescue JSON::ParserError
94
+ raise ParseError, "Failed to parse response: #{response.body[0..100]}"
95
+ end
96
+
97
+ # Performs a GET request and returns the raw response body without JSON parsing.
98
+ # Useful for binary downloads (e.g., PDF, Nessus files).
99
+ #
100
+ # @param path [String] the API endpoint path
101
+ # @param params [Hash] query parameters
102
+ # @return [String] raw response body
103
+ # @raise [AuthenticationError] on 401 responses
104
+ # @raise [RateLimitError] on 429 responses
105
+ # @raise [ApiError] on other non-2xx responses
106
+ def get_raw(path, params = {})
107
+ response = @connection.faraday.get(path, params)
108
+ raise_for_status(response)
109
+ response.body
110
+ end
111
+
112
+ # Validates that a value is safe to interpolate into a URL path segment.
113
+ # Rejects path traversal attempts (/, ..) and non-printable characters.
114
+ #
115
+ # @param value [String, Integer] the path segment to validate
116
+ # @param name [String] parameter name for error messages
117
+ # @raise [ArgumentError] if the value contains unsafe characters
118
+ def validate_path_segment!(value, name: 'id')
119
+ str = value.to_s
120
+ return unless str.empty? || str.include?('/') || str.include?('..') || str.match?(/[^[:print:]]/)
121
+
122
+ raise ArgumentError, "Invalid #{name}: contains unsafe characters"
123
+ end
124
+
125
+ def raise_for_status(response)
126
+ case response.status
127
+ when 200..299 then nil
128
+ when 401 then raise AuthenticationError
129
+ when 429 then raise RateLimitError.new(status_code: response.status, body: response.body)
130
+ else raise ApiError.new(status_code: response.status, body: response.body)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenable
4
+ module Resources
5
+ # Provides access to the Tenable.io vulnerability export endpoints.
6
+ #
7
+ # Exports allow bulk retrieval of vulnerability data in chunks.
8
+ #
9
+ # @example Full export workflow
10
+ # exports = client.exports
11
+ # result = exports.export(num_assets: 50)
12
+ # exports.wait_for_completion(result["export_uuid"])
13
+ # exports.each(result["export_uuid"]) { |vuln| process(vuln) }
14
+ class Exports < Base
15
+ # @return [Integer] default seconds between status polls
16
+ DEFAULT_POLL_INTERVAL = 2
17
+
18
+ # @return [Integer] default timeout in seconds for waiting on export completion
19
+ DEFAULT_TIMEOUT = 300
20
+
21
+ # Initiates a new vulnerability export.
22
+ #
23
+ # @param body [Hash] export request parameters (e.g., +num_assets+, +filters+)
24
+ # @return [Hash] response containing the export UUID
25
+ # @raise [ApiError] on non-2xx responses
26
+ #
27
+ # @example
28
+ # client.exports.export(num_assets: 50)
29
+ def export(body = {})
30
+ post('/vulns/export', body)
31
+ end
32
+
33
+ # Retrieves the status of an export.
34
+ #
35
+ # @param export_uuid [String] the export UUID
36
+ # @return [Hash] status data including +"status"+ and +"chunks_available"+
37
+ def status(export_uuid)
38
+ validate_path_segment!(export_uuid, name: 'export_uuid')
39
+ get("/vulns/export/#{export_uuid}/status")
40
+ end
41
+
42
+ # Downloads a single chunk of export data.
43
+ #
44
+ # @param export_uuid [String] the export UUID
45
+ # @param chunk_id [Integer] the chunk identifier
46
+ # @return [Array<Hash>] array of vulnerability records
47
+ def download_chunk(export_uuid, chunk_id)
48
+ validate_path_segment!(export_uuid, name: 'export_uuid')
49
+ validate_path_segment!(chunk_id, name: 'chunk_id')
50
+ get("/vulns/export/#{export_uuid}/chunks/#{chunk_id}")
51
+ end
52
+
53
+ # Cancels an in-progress vulnerability export.
54
+ #
55
+ # @param export_uuid [String] the export UUID
56
+ # @return [Hash] cancellation response
57
+ def cancel(export_uuid)
58
+ validate_path_segment!(export_uuid, name: 'export_uuid')
59
+ post("/vulns/export/#{export_uuid}/cancel")
60
+ end
61
+
62
+ # Iterates over all available chunks for a completed export.
63
+ #
64
+ # @param export_uuid [String] the export UUID
65
+ # @yield [record] yields each vulnerability record
66
+ # @yieldparam record [Hash] a single vulnerability record
67
+ # @return [void]
68
+ #
69
+ # @example
70
+ # client.exports.each(uuid) do |vuln|
71
+ # puts vuln["plugin_name"]
72
+ # end
73
+ def each(export_uuid, &block)
74
+ validate_path_segment!(export_uuid, name: 'export_uuid')
75
+ return enum_for(:each, export_uuid) unless block
76
+
77
+ status_data = status(export_uuid)
78
+ chunks = status_data['chunks_available'] || []
79
+ chunks.each do |chunk_id|
80
+ records = download_chunk(export_uuid, chunk_id)
81
+ records.each(&block)
82
+ end
83
+ end
84
+
85
+ # Polls until the export reaches FINISHED or ERROR status.
86
+ #
87
+ # @param export_uuid [String] the export UUID
88
+ # @param timeout [Integer] maximum seconds to wait (default: 300)
89
+ # @param poll_interval [Integer] seconds between status checks (default: 2)
90
+ # @return [Hash] the final status data when export is FINISHED
91
+ # @raise [Tenable::TimeoutError] if the export does not finish within the timeout
92
+ # @raise [Tenable::ApiError] if the export status is ERROR
93
+ def wait_for_completion(export_uuid, timeout: DEFAULT_TIMEOUT, poll_interval: DEFAULT_POLL_INTERVAL)
94
+ validate_path_segment!(export_uuid, name: 'export_uuid')
95
+ poll_until(timeout: timeout, poll_interval: poll_interval, label: "Export #{export_uuid}") do
96
+ status_data = status(export_uuid)
97
+ raise Tenable::ApiError, "Export #{export_uuid} failed" if status_data['status'] == 'ERROR'
98
+
99
+ status_data if status_data['status'] == 'FINISHED'
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end