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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +164 -0
- data/lib/tenable/client.rb +57 -0
- data/lib/tenable/configuration.rb +103 -0
- data/lib/tenable/connection.rb +37 -0
- data/lib/tenable/error.rb +63 -0
- data/lib/tenable/middleware/authentication.rb +25 -0
- data/lib/tenable/middleware/logging.rb +41 -0
- data/lib/tenable/middleware/retry.rb +64 -0
- data/lib/tenable/models/asset.rb +24 -0
- data/lib/tenable/models/export.rb +43 -0
- data/lib/tenable/models/finding.rb +25 -0
- data/lib/tenable/models/scan.rb +26 -0
- data/lib/tenable/models/vulnerability.rb +35 -0
- data/lib/tenable/models/web_app_scan.rb +24 -0
- data/lib/tenable/models/web_app_scan_config.rb +23 -0
- data/lib/tenable/pagination.rb +72 -0
- data/lib/tenable/pollable.rb +31 -0
- data/lib/tenable/resources/asset_exports.rb +95 -0
- data/lib/tenable/resources/base.rb +135 -0
- data/lib/tenable/resources/exports.rb +104 -0
- data/lib/tenable/resources/scans.rb +256 -0
- data/lib/tenable/resources/vulnerabilities.rb +69 -0
- data/lib/tenable/resources/web_app_scans.rb +294 -0
- data/lib/tenable/version.rb +5 -0
- data/lib/tenable.rb +31 -0
- metadata +192 -0
|
@@ -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
|