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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6020ebd94aaa44a36cf4eeebeca87d7ceaf6730f3ff15dbe377b3d332cf8a90e
|
|
4
|
+
data.tar.gz: 5debd6afde04c5f130fb3c1e867f68b0fbbdfbe160c2fffac20bb0ad4fa488dd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bc1dc472bbe24ee07fa0447d3c87a60e09451cad4f31714d667e5bba43c1d098e5b1d82006b01a431cfc2a8401e760a007a95507123358e54e6c7282cfaf9758
|
|
7
|
+
data.tar.gz: 3f0ce6c911b2a72ed66b9e805541e6e4ae4864b3948c1f9e89807c4b58b9005d3747192f8fdbbdd550765ea6d6b8f97b63cfdd8d6bdc4515a3d7853458d2ca80
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vudx00
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# tenable-ruby-sdk
|
|
2
|
+
|
|
3
|
+
> **Unofficial** — This project is not affiliated with, endorsed by, or sponsored by Tenable, Inc. "Tenable" is a registered trademark of Tenable, Inc.
|
|
4
|
+
|
|
5
|
+
Ruby SDK for the [Tenable.io API](https://developer.tenable.com/reference/navigate). Covers vulnerability management, bulk exports, VM scans, and WAS v2 web application scanning.
|
|
6
|
+
|
|
7
|
+
Requires Ruby >= 3.2. Uses [Faraday](https://github.com/lostisland/faraday) for HTTP.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Add to your Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem 'tenable-ruby-sdk'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then `bundle install`.
|
|
18
|
+
|
|
19
|
+
## Authentication
|
|
20
|
+
|
|
21
|
+
Get your API keys from Tenable.io under **Settings > My Account > API Keys**.
|
|
22
|
+
|
|
23
|
+
Pass them directly:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
client = Tenable::Client.new(
|
|
27
|
+
access_key: "your-access-key",
|
|
28
|
+
secret_key: "your-secret-key"
|
|
29
|
+
)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or set environment variables and omit them:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
export TENABLE_ACCESS_KEY="your-access-key"
|
|
36
|
+
export TENABLE_SECRET_KEY="your-secret-key"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
client = Tenable::Client.new
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### List Vulnerabilities
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
data = client.vulnerabilities.list
|
|
49
|
+
data["vulnerabilities"].each do |vuln|
|
|
50
|
+
puts "#{vuln['plugin_name']} (severity: #{vuln['severity']})"
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Export Vulnerabilities
|
|
55
|
+
|
|
56
|
+
For large datasets, use the export workflow:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
exports = client.exports
|
|
60
|
+
|
|
61
|
+
# Start the export
|
|
62
|
+
result = exports.export(num_assets: 50)
|
|
63
|
+
uuid = result["export_uuid"]
|
|
64
|
+
|
|
65
|
+
# Wait for it to finish (polls automatically, 5 min timeout by default)
|
|
66
|
+
exports.wait_for_completion(uuid)
|
|
67
|
+
|
|
68
|
+
# Iterate over all chunks
|
|
69
|
+
exports.each(uuid) do |vuln|
|
|
70
|
+
puts vuln["plugin"]["name"]
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### VM Scans
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# List scans
|
|
78
|
+
client.scans.list
|
|
79
|
+
|
|
80
|
+
# Launch a scan
|
|
81
|
+
client.scans.launch(scan_id)
|
|
82
|
+
|
|
83
|
+
# Check status
|
|
84
|
+
client.scans.status(scan_id)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Web App Scans (WAS v2)
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
was = client.web_app_scans
|
|
91
|
+
|
|
92
|
+
# Create a scan config
|
|
93
|
+
config = was.create_config(name: "My Scan", target: "https://example.com")
|
|
94
|
+
config_id = config["config_id"]
|
|
95
|
+
|
|
96
|
+
# Launch the scan
|
|
97
|
+
scan = was.launch(config_id)
|
|
98
|
+
scan_id = scan["scan_id"]
|
|
99
|
+
|
|
100
|
+
# Wait for completion (polls until terminal status)
|
|
101
|
+
was.wait_until_complete(config_id, scan_id)
|
|
102
|
+
|
|
103
|
+
# Get findings
|
|
104
|
+
was.findings(config_id)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
All options with their defaults:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
client = Tenable::Client.new(
|
|
113
|
+
access_key: "...", # or TENABLE_ACCESS_KEY env var
|
|
114
|
+
secret_key: "...", # or TENABLE_SECRET_KEY env var
|
|
115
|
+
base_url: "https://cloud.tenable.com", # must be HTTPS
|
|
116
|
+
timeout: 30, # request timeout (seconds)
|
|
117
|
+
open_timeout: 10, # connection timeout (seconds)
|
|
118
|
+
max_retries: 3, # retry attempts (0-10)
|
|
119
|
+
logger: Logger.new($stdout) # nil = silent (default)
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Error Handling
|
|
124
|
+
|
|
125
|
+
All errors inherit from `Tenable::Error`:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
begin
|
|
129
|
+
client.vulnerabilities.list
|
|
130
|
+
rescue Tenable::AuthenticationError => e
|
|
131
|
+
# Bad or missing API keys (401)
|
|
132
|
+
rescue Tenable::RateLimitError => e
|
|
133
|
+
# Rate limited and retries exhausted (429)
|
|
134
|
+
e.status_code # => 429
|
|
135
|
+
rescue Tenable::TimeoutError => e
|
|
136
|
+
# Request or export poll timed out
|
|
137
|
+
rescue Tenable::ApiError => e
|
|
138
|
+
# Any other API error
|
|
139
|
+
e.status_code # => Integer
|
|
140
|
+
e.body # => String
|
|
141
|
+
rescue Tenable::ConnectionError => e
|
|
142
|
+
# Network failure
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Rate limiting (429) and server errors (5xx) are retried automatically with exponential backoff before raising.
|
|
147
|
+
|
|
148
|
+
## Thread Safety
|
|
149
|
+
|
|
150
|
+
The client is frozen after initialization with eagerly instantiated resources. Safe to use across threads.
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
bundle install
|
|
156
|
+
bundle exec rspec # run tests
|
|
157
|
+
bundle exec rubocop # lint
|
|
158
|
+
bundle exec yard doc # generate docs
|
|
159
|
+
bundle audit check # check for vulnerable dependencies
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenable
|
|
4
|
+
# Primary entry point for the Tenable.io API.
|
|
5
|
+
#
|
|
6
|
+
# @example Basic usage
|
|
7
|
+
# client = Tenable::Client.new(access_key: "ak", secret_key: "sk")
|
|
8
|
+
# client.vulnerabilities.list
|
|
9
|
+
class Client
|
|
10
|
+
# @return [Configuration] the client configuration
|
|
11
|
+
attr_reader :configuration
|
|
12
|
+
|
|
13
|
+
# @return [Resources::Vulnerabilities]
|
|
14
|
+
attr_reader :vulnerabilities
|
|
15
|
+
|
|
16
|
+
# @return [Resources::Exports]
|
|
17
|
+
attr_reader :exports
|
|
18
|
+
|
|
19
|
+
# @return [Resources::AssetExports]
|
|
20
|
+
attr_reader :asset_exports
|
|
21
|
+
|
|
22
|
+
# @return [Resources::Scans]
|
|
23
|
+
attr_reader :scans
|
|
24
|
+
|
|
25
|
+
# @return [Resources::WebAppScans]
|
|
26
|
+
attr_reader :web_app_scans
|
|
27
|
+
|
|
28
|
+
# Creates a new Tenable API client.
|
|
29
|
+
#
|
|
30
|
+
# @param options [Hash] configuration options passed to {Configuration#initialize}
|
|
31
|
+
# @option options [String] :access_key Tenable.io API access key
|
|
32
|
+
# @option options [String] :secret_key Tenable.io API secret key
|
|
33
|
+
# @option options [String] :base_url API base URL (default: https://cloud.tenable.com)
|
|
34
|
+
# @option options [Integer] :timeout request timeout in seconds (default: 30)
|
|
35
|
+
# @option options [Integer] :open_timeout connection open timeout in seconds (default: 10)
|
|
36
|
+
# @option options [Integer] :max_retries maximum retry attempts (default: 3)
|
|
37
|
+
# @option options [Logger] :logger optional logger instance
|
|
38
|
+
# @raise [ArgumentError] if required credentials are missing or options are invalid
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# client = Tenable::Client.new(
|
|
42
|
+
# access_key: "your-access-key",
|
|
43
|
+
# secret_key: "your-secret-key",
|
|
44
|
+
# timeout: 60
|
|
45
|
+
# )
|
|
46
|
+
def initialize(**)
|
|
47
|
+
@configuration = Configuration.new(**)
|
|
48
|
+
connection = Connection.new(@configuration)
|
|
49
|
+
@vulnerabilities = Resources::Vulnerabilities.new(connection)
|
|
50
|
+
@exports = Resources::Exports.new(connection)
|
|
51
|
+
@asset_exports = Resources::AssetExports.new(connection)
|
|
52
|
+
@scans = Resources::Scans.new(connection)
|
|
53
|
+
@web_app_scans = Resources::WebAppScans.new(connection)
|
|
54
|
+
freeze
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenable
|
|
4
|
+
# Holds and validates all configuration options for the Tenable client.
|
|
5
|
+
#
|
|
6
|
+
# Configuration values can be passed directly or read from environment
|
|
7
|
+
# variables (+TENABLE_ACCESS_KEY+, +TENABLE_SECRET_KEY+).
|
|
8
|
+
class Configuration
|
|
9
|
+
# @return [String] the API access key
|
|
10
|
+
attr_reader :access_key
|
|
11
|
+
|
|
12
|
+
# @return [String] the API secret key
|
|
13
|
+
attr_reader :secret_key
|
|
14
|
+
|
|
15
|
+
# @return [String] the API base URL
|
|
16
|
+
attr_reader :base_url
|
|
17
|
+
|
|
18
|
+
# @return [Integer] the request timeout in seconds
|
|
19
|
+
attr_reader :timeout
|
|
20
|
+
|
|
21
|
+
# @return [Integer] the connection open timeout in seconds
|
|
22
|
+
attr_reader :open_timeout
|
|
23
|
+
|
|
24
|
+
# @return [Integer] the maximum number of retry attempts
|
|
25
|
+
attr_reader :max_retries
|
|
26
|
+
|
|
27
|
+
# @return [Logger, nil] optional logger instance
|
|
28
|
+
attr_reader :logger
|
|
29
|
+
|
|
30
|
+
DEFAULTS = {
|
|
31
|
+
base_url: 'https://cloud.tenable.com',
|
|
32
|
+
timeout: 30,
|
|
33
|
+
open_timeout: 10,
|
|
34
|
+
max_retries: 3
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# Creates a new Configuration instance.
|
|
38
|
+
#
|
|
39
|
+
# @param access_key [String, nil] API access key (falls back to +TENABLE_ACCESS_KEY+ env var)
|
|
40
|
+
# @param secret_key [String, nil] API secret key (falls back to +TENABLE_SECRET_KEY+ env var)
|
|
41
|
+
# @param base_url [String, nil] API base URL (default: https://cloud.tenable.com)
|
|
42
|
+
# @param timeout [Integer, nil] request timeout in seconds (default: 30)
|
|
43
|
+
# @param open_timeout [Integer, nil] connection open timeout in seconds (default: 10)
|
|
44
|
+
# @param max_retries [Integer, nil] max retry attempts, 0-10 (default: 3)
|
|
45
|
+
# @param logger [Logger, nil] optional logger for request/response logging
|
|
46
|
+
# @raise [ArgumentError] if credentials are missing, base_url is invalid, or numeric values are out of range
|
|
47
|
+
def initialize(access_key: nil, secret_key: nil, base_url: nil, timeout: nil, open_timeout: nil, max_retries: nil,
|
|
48
|
+
logger: nil)
|
|
49
|
+
@access_key = access_key || ENV.fetch('TENABLE_ACCESS_KEY', nil)
|
|
50
|
+
@secret_key = secret_key || ENV.fetch('TENABLE_SECRET_KEY', nil)
|
|
51
|
+
@base_url = base_url || DEFAULTS[:base_url]
|
|
52
|
+
@timeout = timeout || DEFAULTS[:timeout]
|
|
53
|
+
@open_timeout = open_timeout || DEFAULTS[:open_timeout]
|
|
54
|
+
@max_retries = max_retries.nil? ? DEFAULTS[:max_retries] : max_retries
|
|
55
|
+
@logger = logger
|
|
56
|
+
|
|
57
|
+
validate!
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate!
|
|
64
|
+
validate_credentials!
|
|
65
|
+
validate_base_url!
|
|
66
|
+
validate_timeouts!
|
|
67
|
+
validate_max_retries!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_credentials!
|
|
71
|
+
raise ArgumentError, 'access_key is required (pass directly or set TENABLE_ACCESS_KEY)' if blank?(@access_key)
|
|
72
|
+
raise ArgumentError, 'secret_key is required (pass directly or set TENABLE_SECRET_KEY)' if blank?(@secret_key)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_base_url!
|
|
76
|
+
uri = URI.parse(@base_url)
|
|
77
|
+
raise ArgumentError, "base_url must use HTTPS: #{@base_url}" unless uri.scheme == 'https'
|
|
78
|
+
rescue URI::InvalidURIError
|
|
79
|
+
raise ArgumentError, "base_url is not a valid URL: #{@base_url}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_timeouts!
|
|
83
|
+
unless @timeout.is_a?(Integer) && @timeout.positive?
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"timeout must be positive, got #{@timeout}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return if @open_timeout.is_a?(Integer) && @open_timeout.positive?
|
|
89
|
+
|
|
90
|
+
raise ArgumentError, "open_timeout must be positive, got #{@open_timeout}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_max_retries!
|
|
94
|
+
return if @max_retries.is_a?(Integer) && @max_retries >= 0 && @max_retries <= 10
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, "max_retries must be between 0 and 10, got #{@max_retries}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def blank?(value)
|
|
100
|
+
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenable
|
|
4
|
+
# Manages the Faraday HTTP connection with configured middleware.
|
|
5
|
+
#
|
|
6
|
+
# Automatically configures authentication, retry, and logging middleware
|
|
7
|
+
# based on the provided {Configuration}.
|
|
8
|
+
class Connection
|
|
9
|
+
# @return [Faraday::Connection] the underlying Faraday connection
|
|
10
|
+
attr_reader :faraday
|
|
11
|
+
|
|
12
|
+
# Creates a new connection from the given configuration.
|
|
13
|
+
#
|
|
14
|
+
# @param config [Configuration] a validated configuration instance
|
|
15
|
+
# @raise [ArgumentError] if the base_url does not use HTTPS
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@config = config
|
|
18
|
+
@faraday = build_connection
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def build_connection
|
|
24
|
+
Faraday.new(url: @config.base_url) do |f|
|
|
25
|
+
f.headers['Accept'] = 'application/json'
|
|
26
|
+
f.use Middleware::Authentication,
|
|
27
|
+
access_key: @config.access_key,
|
|
28
|
+
secret_key: @config.secret_key
|
|
29
|
+
f.use Middleware::Retry, max_retries: @config.max_retries
|
|
30
|
+
f.use Middleware::Logging, logger: @config.logger
|
|
31
|
+
f.options.timeout = @config.timeout
|
|
32
|
+
f.options.open_timeout = @config.open_timeout
|
|
33
|
+
f.adapter Faraday.default_adapter
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenable
|
|
4
|
+
# Base error class for all Tenable SDK errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when API authentication fails (HTTP 401).
|
|
8
|
+
class AuthenticationError < Error
|
|
9
|
+
DEFAULT_MESSAGE = 'Authentication failed. Verify your access key and secret key are correct.'
|
|
10
|
+
|
|
11
|
+
def initialize(msg = DEFAULT_MESSAGE)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised for non-success HTTP responses from the Tenable API.
|
|
17
|
+
class ApiError < Error
|
|
18
|
+
# @return [Integer, nil] the HTTP status code
|
|
19
|
+
attr_reader :status_code
|
|
20
|
+
|
|
21
|
+
# @return [String, nil] the response body
|
|
22
|
+
attr_reader :body
|
|
23
|
+
|
|
24
|
+
# @param msg [String, nil] custom error message
|
|
25
|
+
# @param status_code [Integer, nil] HTTP status code
|
|
26
|
+
# @param body [String, nil] response body
|
|
27
|
+
def initialize(msg = nil, status_code: nil, body: nil)
|
|
28
|
+
@status_code = status_code
|
|
29
|
+
@body = body
|
|
30
|
+
message = msg || "API request failed with status #{status_code}"
|
|
31
|
+
message = "#{message}: #{body}" if body
|
|
32
|
+
super(message)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Raised when the API rate limit is exceeded and retries are exhausted (HTTP 429).
|
|
37
|
+
class RateLimitError < ApiError
|
|
38
|
+
def initialize(msg = 'Rate limit exceeded. Retries exhausted.', **kwargs)
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raised when a network connection to the Tenable API cannot be established.
|
|
44
|
+
class ConnectionError < Error
|
|
45
|
+
def initialize(msg = 'Connection to Tenable API failed. Check your network and base_url configuration.')
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Raised when an API request exceeds the configured timeout.
|
|
51
|
+
class TimeoutError < Error
|
|
52
|
+
def initialize(msg = 'Request timed out. Consider increasing the timeout configuration.')
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Raised when the API response body cannot be parsed as JSON.
|
|
58
|
+
class ParseError < Error
|
|
59
|
+
def initialize(msg = 'Failed to parse API response. The response may be malformed.')
|
|
60
|
+
super
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenable
|
|
4
|
+
module Middleware
|
|
5
|
+
class Authentication < Faraday::Middleware
|
|
6
|
+
def initialize(app, access_key:, secret_key:)
|
|
7
|
+
super(app)
|
|
8
|
+
@access_key = access_key
|
|
9
|
+
@secret_key = secret_key
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def on_request(env)
|
|
13
|
+
env.request_headers['X-ApiKeys'] = "accessKey=#{@access_key};secretKey=#{@secret_key};"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def inspect
|
|
17
|
+
"#<#{self.class.name} [REDACTED]>"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_s
|
|
21
|
+
inspect
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenable
|
|
4
|
+
module Middleware
|
|
5
|
+
class Logging < Faraday::Middleware
|
|
6
|
+
API_KEY_PATTERN = /accessKey=[^;]*;?\s*secretKey=[^;]*/
|
|
7
|
+
|
|
8
|
+
def initialize(app, logger: nil)
|
|
9
|
+
super(app)
|
|
10
|
+
@logger = logger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
log_request(env) if @logger
|
|
15
|
+
@app.call(env).on_complete do |response_env|
|
|
16
|
+
log_response(response_env) if @logger
|
|
17
|
+
end
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
@logger&.error("Tenable request error: #{e.message}")
|
|
20
|
+
raise
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def log_request(env)
|
|
26
|
+
headers = redact_headers(env.request_headers)
|
|
27
|
+
@logger.debug("Tenable request: #{env.method.upcase} #{env.url} headers=#{headers}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def log_response(env)
|
|
31
|
+
@logger.debug("Tenable response: status=#{env.status}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def redact_headers(headers)
|
|
35
|
+
headers.transform_values do |value|
|
|
36
|
+
value.is_a?(String) ? value.gsub(API_KEY_PATTERN, '[REDACTED]') : value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenable
|
|
4
|
+
module Middleware
|
|
5
|
+
class Retry < Faraday::Middleware
|
|
6
|
+
DEFAULT_MAX_RETRIES = 3
|
|
7
|
+
RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504].freeze
|
|
8
|
+
BASE_DELAY = 1
|
|
9
|
+
|
|
10
|
+
def initialize(app, max_retries: DEFAULT_MAX_RETRIES)
|
|
11
|
+
super(app)
|
|
12
|
+
@max_retries = max_retries
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
attempt = 0
|
|
17
|
+
loop do
|
|
18
|
+
attempt += 1
|
|
19
|
+
response = @app.call(env.dup)
|
|
20
|
+
|
|
21
|
+
return response unless retryable?(response.status)
|
|
22
|
+
|
|
23
|
+
raise_if_exhausted(response, attempt) if attempt >= @max_retries
|
|
24
|
+
|
|
25
|
+
sleep(retry_delay(response, attempt))
|
|
26
|
+
end
|
|
27
|
+
rescue Faraday::Error
|
|
28
|
+
raise if attempt >= @max_retries
|
|
29
|
+
|
|
30
|
+
sleep(BASE_DELAY * (2**(attempt - 1)))
|
|
31
|
+
retry
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def retryable?(status)
|
|
37
|
+
RETRYABLE_STATUS_CODES.include?(status)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def retry_delay(response, attempt)
|
|
41
|
+
retry_after = response.headers&.[]('Retry-After')
|
|
42
|
+
return retry_after.to_i if retry_after
|
|
43
|
+
|
|
44
|
+
BASE_DELAY * (2**(attempt - 1))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def raise_if_exhausted(response, attempts)
|
|
48
|
+
if response.status == 429
|
|
49
|
+
raise Tenable::RateLimitError.new(
|
|
50
|
+
"Rate limit exceeded after #{attempts} attempts",
|
|
51
|
+
status_code: response.status,
|
|
52
|
+
body: response.body
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
raise Tenable::ApiError.new(
|
|
57
|
+
"Request failed after #{attempts} attempts",
|
|
58
|
+
status_code: response.status,
|
|
59
|
+
body: response.body
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenable
|
|
4
|
+
module Models
|
|
5
|
+
# Represents an asset (host/device) from the Tenable.io API.
|
|
6
|
+
Asset = Data.define(:uuid, :hostname, :ipv4, :operating_system, :fqdn, :netbios_name) do
|
|
7
|
+
# Builds an Asset from a raw API response hash.
|
|
8
|
+
#
|
|
9
|
+
# @param data [Hash] raw API response hash with string keys
|
|
10
|
+
# @return [Asset]
|
|
11
|
+
def self.from_api(data)
|
|
12
|
+
data = data.transform_keys(&:to_sym)
|
|
13
|
+
new(
|
|
14
|
+
uuid: data[:uuid],
|
|
15
|
+
hostname: data[:hostname],
|
|
16
|
+
ipv4: data[:ipv4],
|
|
17
|
+
operating_system: data[:operating_system] || [],
|
|
18
|
+
fqdn: data[:fqdn] || [],
|
|
19
|
+
netbios_name: data[:netbios_name]
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenable
|
|
4
|
+
module Models
|
|
5
|
+
# Represents the status of a vulnerability export job.
|
|
6
|
+
Export = Data.define(:uuid, :status, :chunks_available, :chunks_failed, :chunks_cancelled) do
|
|
7
|
+
# Builds an Export from a raw API response hash.
|
|
8
|
+
#
|
|
9
|
+
# @param data [Hash] raw API response hash with string keys
|
|
10
|
+
# @return [Export]
|
|
11
|
+
def self.from_api(data)
|
|
12
|
+
data = data.transform_keys(&:to_sym)
|
|
13
|
+
new(
|
|
14
|
+
uuid: data[:uuid],
|
|
15
|
+
status: data[:status],
|
|
16
|
+
chunks_available: data[:chunks_available] || [],
|
|
17
|
+
chunks_failed: data[:chunks_failed] || [],
|
|
18
|
+
chunks_cancelled: data[:chunks_cancelled] || []
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Boolean] true if the export has completed successfully
|
|
23
|
+
def finished?
|
|
24
|
+
status == 'FINISHED'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] true if the export is still processing
|
|
28
|
+
def processing?
|
|
29
|
+
status == 'PROCESSING'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] true if the export encountered an error
|
|
33
|
+
def error?
|
|
34
|
+
status == 'ERROR'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Boolean] true if the export is queued
|
|
38
|
+
def queued?
|
|
39
|
+
status == 'QUEUED'
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenable
|
|
4
|
+
module Models
|
|
5
|
+
# Represents a web application scan finding.
|
|
6
|
+
Finding = Data.define(:finding_id, :severity, :url, :name, :description, :remediation, :plugin_id) do
|
|
7
|
+
# Builds a Finding from a raw API response hash.
|
|
8
|
+
#
|
|
9
|
+
# @param data [Hash] raw API response hash with string keys
|
|
10
|
+
# @return [Finding]
|
|
11
|
+
def self.from_api(data)
|
|
12
|
+
data = data.transform_keys(&:to_sym)
|
|
13
|
+
new(
|
|
14
|
+
finding_id: data[:finding_id],
|
|
15
|
+
severity: data[:severity],
|
|
16
|
+
url: data[:url],
|
|
17
|
+
name: data[:name],
|
|
18
|
+
description: data[:description],
|
|
19
|
+
remediation: data[:remediation],
|
|
20
|
+
plugin_id: data[:plugin_id]
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|