train-juniper 0.6.2 → 0.7.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 +4 -4
- data/CHANGELOG.md +27 -10
- data/README.md +30 -13
- data/Rakefile +21 -14
- data/lib/train-juniper/connection.rb +243 -65
- data/lib/train-juniper/platform.rb +50 -82
- data/lib/train-juniper/transport.rb +13 -1
- data/lib/train-juniper/version.rb +2 -1
- data/train-juniper.gemspec +16 -0
- metadata +142 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: abb9487cdbf492d9b94f9b45699e2a434095509e128d5928863e5fdff3c73241
|
4
|
+
data.tar.gz: 0d9d6746cb16f185eef54b199aaf02b4509e66cc9292af3475913bae89f3e36c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8c7c246e820daa1d436c03d4f95e7acab6bef59c600c9f18d99fcc94b63989a0adf00a483661fc362db634871446b469aa0bed753a6852b40d2da768709882d8
|
7
|
+
data.tar.gz: db80d5fdefcb80294734f162b67f48f21219859ebed3ee294bcb1449876be0a7c7758e4ed613c8fc3e40a9d7d67c25df12316cc56e31352dc648eda2c8184c59
|
data/CHANGELOG.md
CHANGED
@@ -5,23 +5,40 @@ All notable changes to this project will be documented in this file.
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
7
|
|
8
|
-
## [0.
|
8
|
+
## [0.7.0] - 2025-06-23
|
9
|
+
|
10
|
+
### Added
|
11
|
+
|
12
|
+
- Add security enhancements and input validation
|
13
|
+
- V0.7.0 - enhanced security, YARD docs, and Windows support
|
14
|
+
|
15
|
+
### Documentation
|
16
|
+
|
17
|
+
- Update roadmap and fix documentation issues
|
9
18
|
|
10
19
|
### Fixed
|
11
20
|
|
12
|
-
-
|
13
|
-
-
|
14
|
-
- Update release workflow to use trusted publishing with OIDC authentication
|
15
|
-
- Update workflows to use Ruby 3.3 for improved trusted publishing support
|
21
|
+
- Empty environment variables no longer override CLI flags
|
22
|
+
- Remove Brakeman from security tasks
|
16
23
|
|
17
|
-
###
|
24
|
+
### Miscellaneous Tasks
|
18
25
|
|
19
|
-
-
|
20
|
-
- Add comprehensive mock mode documentation to README
|
26
|
+
- Update .gitignore for untracked files
|
21
27
|
|
22
|
-
###
|
28
|
+
### Refactor
|
29
|
+
|
30
|
+
- Apply DRY principles throughout codebase
|
31
|
+
- Extract common version detection pattern
|
32
|
+
|
33
|
+
### Styling
|
34
|
+
|
35
|
+
- Fix RuboCop offenses in connection files
|
36
|
+
|
37
|
+
## [0.6.2] - 2025-06-19
|
38
|
+
|
39
|
+
### Fixed
|
23
40
|
|
24
|
-
-
|
41
|
+
- Windows FFI compatibility and mock mode platform detection
|
25
42
|
|
26
43
|
## [0.6.1] - 2025-06-18
|
27
44
|
|
data/README.md
CHANGED
@@ -64,9 +64,9 @@ $ inspec detect -t juniper://admin@192.168.1.1 --password yourpassword
|
|
64
64
|
|
65
65
|
== Platform Details
|
66
66
|
Name: juniper
|
67
|
-
Families:
|
67
|
+
Families: bsd
|
68
68
|
Release: 21.4R3-S1.6
|
69
|
-
Arch:
|
69
|
+
Arch: x86_64
|
70
70
|
|
71
71
|
# Interactive shell
|
72
72
|
$ inspec shell -t juniper://admin@192.168.1.1 --password yourpassword
|
@@ -77,23 +77,29 @@ inspec> command('show version').stdout
|
|
77
77
|
### With Bastion Host (Jump Host)
|
78
78
|
|
79
79
|
```bash
|
80
|
-
#
|
81
|
-
$ inspec shell -t
|
80
|
+
# Simplified: Same username/password for bastion and device (most common)
|
81
|
+
$ inspec shell -t juniper://admin@10.1.1.1 --password yourpassword \
|
82
|
+
--bastion-host jump.example.com
|
83
|
+
|
84
|
+
# Different credentials for bastion and device
|
85
|
+
$ inspec shell -t juniper://admin@10.1.1.1 --password device_password \
|
86
|
+
--bastion-host jump.example.com --bastion-user netadmin \
|
87
|
+
--bastion-password jump_password
|
82
88
|
|
83
89
|
# With custom port
|
84
|
-
$ inspec shell -t
|
90
|
+
$ inspec shell -t juniper://admin@10.1.1.1 --password yourpassword \
|
91
|
+
--bastion-host jump.example.com --bastion-port 2222
|
85
92
|
|
86
93
|
# Using environment variables (recommended for automation)
|
87
94
|
export JUNIPER_BASTION_HOST=jump.example.com
|
88
|
-
export
|
89
|
-
export JUNIPER_BASTION_PASSWORD=jump_password
|
90
|
-
export JUNIPER_PASSWORD=device_password
|
95
|
+
export JUNIPER_PASSWORD=shared_password # Used for both bastion and device
|
91
96
|
$ inspec shell -t juniper://admin@10.1.1.1
|
92
97
|
|
93
|
-
#
|
98
|
+
# Different passwords via environment
|
94
99
|
export JUNIPER_BASTION_HOST=jump.example.com
|
95
|
-
export JUNIPER_BASTION_USER=
|
96
|
-
export
|
100
|
+
export JUNIPER_BASTION_USER=netadmin
|
101
|
+
export JUNIPER_BASTION_PASSWORD=jump_password
|
102
|
+
export JUNIPER_PASSWORD=device_password
|
97
103
|
$ inspec shell -t juniper://admin@10.1.1.1
|
98
104
|
```
|
99
105
|
|
@@ -135,6 +141,16 @@ inspec detect -t juniper:// # Reads from .env automatically
|
|
135
141
|
|
136
142
|
## Configuration Options
|
137
143
|
|
144
|
+
### Option Priority
|
145
|
+
|
146
|
+
The plugin uses the following priority order for configuration values:
|
147
|
+
|
148
|
+
1. **Command-line flags** (highest priority) - e.g., `--bastion-user`
|
149
|
+
2. **Environment variables** - e.g., `JUNIPER_BASTION_USER`
|
150
|
+
3. **Defaults/Fallbacks** (lowest priority) - e.g., bastion_user falls back to main user
|
151
|
+
|
152
|
+
This allows maximum flexibility while providing sensible defaults for common scenarios.
|
153
|
+
|
138
154
|
### Connection Options
|
139
155
|
|
140
156
|
| Option | Description | Default | Environment Variable |
|
@@ -152,7 +168,7 @@ inspec detect -t juniper:// # Reads from .env automatically
|
|
152
168
|
| Option | Description | Default | Environment Variable |
|
153
169
|
|--------|-------------|---------|---------------------|
|
154
170
|
| `bastion_host` | SSH bastion/jump host | - | `JUNIPER_BASTION_HOST` |
|
155
|
-
| `bastion_user` | SSH bastion username |
|
171
|
+
| `bastion_user` | SSH bastion username | Falls back to main `user` | `JUNIPER_BASTION_USER` |
|
156
172
|
| `bastion_port` | SSH bastion port | 22 | `JUNIPER_BASTION_PORT` |
|
157
173
|
| `bastion_password` | Password for bastion authentication | - | `JUNIPER_BASTION_PASSWORD` |
|
158
174
|
| `proxy_command` | Custom SSH ProxyCommand | - | `JUNIPER_PROXY_COMMAND` |
|
@@ -161,7 +177,8 @@ inspec detect -t juniper:// # Reads from .env automatically
|
|
161
177
|
|
162
178
|
**Notes**:
|
163
179
|
- Cannot specify both `bastion_host` and `proxy_command` simultaneously
|
164
|
-
- If `
|
180
|
+
- If `bastion_user` not provided, falls back to using main `user` for bastion authentication
|
181
|
+
- If `bastion_password` not provided, falls back to using main `password` for bastion authentication
|
165
182
|
- Supports automated password authentication via SSH_ASKPASS mechanism
|
166
183
|
|
167
184
|
### InSpec Configuration File
|
data/Rakefile
CHANGED
@@ -33,14 +33,8 @@ end
|
|
33
33
|
require 'rubocop/rake_task'
|
34
34
|
|
35
35
|
RuboCop::RakeTask.new(:lint) do |t|
|
36
|
-
#
|
37
|
-
|
38
|
-
# train gem's copy of the Train project's rubocop.yml file (which
|
39
|
-
# is indeed packaged with the train gem).
|
40
|
-
require 'train/globals'
|
41
|
-
train_rubocop_yml = File.join(Train.src_root, '.rubocop.yml')
|
42
|
-
|
43
|
-
t.options = ['--display-cop-names', '--config', train_rubocop_yml]
|
36
|
+
# Use our local .rubocop.yml configuration
|
37
|
+
t.options = ['--display-cop-names', '--config', '.rubocop.yml']
|
44
38
|
end
|
45
39
|
|
46
40
|
#------------------------------------------------------------------#
|
@@ -70,11 +64,7 @@ task 'security:secrets' do
|
|
70
64
|
end
|
71
65
|
end
|
72
66
|
|
73
|
-
|
74
|
-
task 'security:brakeman' do
|
75
|
-
puts 'Running Brakeman security scan...'
|
76
|
-
system('bundle exec brakeman --exit-on-warn --quiet --force') or abort('Security vulnerabilities found')
|
77
|
-
end
|
67
|
+
# Brakeman removed - it's for Rails apps, not Ruby gems
|
78
68
|
|
79
69
|
desc 'Run comprehensive security scan'
|
80
70
|
task 'security:scan' do
|
@@ -83,7 +73,7 @@ task 'security:scan' do
|
|
83
73
|
end
|
84
74
|
|
85
75
|
desc 'Run all security checks'
|
86
|
-
task security: %w[security:dependencies
|
76
|
+
task security: %w[security:dependencies test:security]
|
87
77
|
|
88
78
|
desc 'Run all tests including security'
|
89
79
|
task 'test:all' => %w[test security]
|
@@ -93,6 +83,23 @@ task 'test:all' => %w[test security]
|
|
93
83
|
#------------------------------------------------------------------#
|
94
84
|
Dir['tasks/*.rake'].each { |f| load f }
|
95
85
|
|
86
|
+
#------------------------------------------------------------------#
|
87
|
+
# Documentation Tasks
|
88
|
+
#------------------------------------------------------------------#
|
89
|
+
begin
|
90
|
+
require 'yard'
|
91
|
+
YARD::Rake::YardocTask.new do |t|
|
92
|
+
t.files = ['lib/**/*.rb']
|
93
|
+
t.options = ['--no-private']
|
94
|
+
t.stats_options = ['--list-undoc']
|
95
|
+
end
|
96
|
+
rescue LoadError
|
97
|
+
desc 'YARD documentation task'
|
98
|
+
task :yard do
|
99
|
+
puts 'YARD is not available. Run `bundle install` to install it.'
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
96
103
|
#------------------------------------------------------------------#
|
97
104
|
# Bundler Gem Tasks
|
98
105
|
#------------------------------------------------------------------#
|
@@ -28,22 +28,51 @@ module TrainPlugins
|
|
28
28
|
|
29
29
|
attr_reader :ssh_session
|
30
30
|
|
31
|
+
# Configuration mapping for environment variables
|
32
|
+
ENV_CONFIG = {
|
33
|
+
host: { env: 'JUNIPER_HOST' },
|
34
|
+
user: { env: 'JUNIPER_USER' },
|
35
|
+
password: { env: 'JUNIPER_PASSWORD' },
|
36
|
+
port: { env: 'JUNIPER_PORT', type: :int, default: 22 },
|
37
|
+
timeout: { env: 'JUNIPER_TIMEOUT', type: :int, default: 30 },
|
38
|
+
bastion_host: { env: 'JUNIPER_BASTION_HOST' },
|
39
|
+
bastion_user: { env: 'JUNIPER_BASTION_USER' },
|
40
|
+
bastion_port: { env: 'JUNIPER_BASTION_PORT', type: :int, default: 22 },
|
41
|
+
bastion_password: { env: 'JUNIPER_BASTION_PASSWORD' },
|
42
|
+
proxy_command: { env: 'JUNIPER_PROXY_COMMAND' }
|
43
|
+
}.freeze
|
44
|
+
|
45
|
+
# Initialize a new Juniper connection
|
46
|
+
# @param options [Hash] Connection options
|
47
|
+
# @option options [String] :host The hostname or IP address of the Juniper device
|
48
|
+
# @option options [String] :user The username for authentication
|
49
|
+
# @option options [String] :password The password for authentication (optional if using key_files)
|
50
|
+
# @option options [Integer] :port The SSH port (default: 22)
|
51
|
+
# @option options [Integer] :timeout Connection timeout in seconds (default: 30)
|
52
|
+
# @option options [String] :bastion_host Jump/bastion host for connection
|
53
|
+
# @option options [String] :proxy_command SSH proxy command
|
54
|
+
# @option options [Logger] :logger Custom logger instance
|
55
|
+
# @option options [Boolean] :mock Enable mock mode for testing
|
31
56
|
def initialize(options)
|
32
57
|
# Configure SSH connection options for Juniper devices
|
33
58
|
# Support environment variables for authentication (following train-vsphere pattern)
|
34
59
|
@options = options.dup
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
60
|
+
|
61
|
+
# Apply environment variable configuration using DRY approach
|
62
|
+
ENV_CONFIG.each do |key, config|
|
63
|
+
# Skip if option already has a value from command line
|
64
|
+
next if @options[key]
|
65
|
+
|
66
|
+
# Get value from environment
|
67
|
+
env_val = config[:type] == :int ? env_int(config[:env]) : env_value(config[:env])
|
68
|
+
|
69
|
+
# Only apply env value if it exists, otherwise use default (but not for nil CLI values)
|
70
|
+
if env_val
|
71
|
+
@options[key] = env_val
|
72
|
+
elsif !@options.key?(key) && config[:default]
|
73
|
+
@options[key] = config[:default]
|
74
|
+
end
|
75
|
+
end
|
47
76
|
|
48
77
|
@options[:keepalive] = true
|
49
78
|
@options[:keepalive_interval] = 60
|
@@ -55,14 +84,11 @@ module TrainPlugins
|
|
55
84
|
@cli_prompt = /[%>$#]\s*$/
|
56
85
|
@config_prompt = /[%#]\s*$/
|
57
86
|
|
58
|
-
# Log connection info
|
59
|
-
|
60
|
-
@logger.debug("Juniper connection initialized with options: #{safe_options.inspect}")
|
61
|
-
@logger.debug("Environment: JUNIPER_BASTION_USER=#{ENV.fetch('JUNIPER_BASTION_USER',
|
62
|
-
nil)} -> bastion_user=#{@options[:bastion_user]}")
|
87
|
+
# Log connection info safely
|
88
|
+
log_connection_info
|
63
89
|
|
64
|
-
# Validate
|
65
|
-
|
90
|
+
# Validate all connection options
|
91
|
+
validate_connection_options!
|
66
92
|
|
67
93
|
super(@options)
|
68
94
|
|
@@ -80,42 +106,65 @@ module TrainPlugins
|
|
80
106
|
to_s
|
81
107
|
end
|
82
108
|
|
83
|
-
#
|
84
|
-
#
|
109
|
+
# Access Juniper configuration and operational data as pseudo-files
|
110
|
+
# @param path [String] The pseudo-file path to access
|
111
|
+
# @return [JuniperFile] A file-like object for accessing Juniper data
|
112
|
+
# @example Access interface configuration
|
113
|
+
# file = connection.file('/config/interfaces')
|
114
|
+
# puts file.content
|
115
|
+
# @example Access operational data
|
116
|
+
# file = connection.file('/operational/interfaces')
|
117
|
+
# puts file.content
|
85
118
|
def file_via_connection(path)
|
86
119
|
# For Juniper devices, "files" are typically configuration sections
|
87
120
|
# or operational command outputs rather than traditional filesystem paths
|
88
121
|
JuniperFile.new(self, path)
|
89
122
|
end
|
90
123
|
|
91
|
-
#
|
92
|
-
#
|
93
|
-
#
|
124
|
+
# Upload files to Juniper device (not supported)
|
125
|
+
# @param locals [String, Array<String>] Local file path(s)
|
126
|
+
# @param remote [String] Remote destination path
|
127
|
+
# @raise [NotImplementedError] Always raises as uploads are not supported
|
128
|
+
# @note Network devices use command-based configuration instead of file uploads
|
94
129
|
def upload(locals, remote)
|
95
130
|
raise NotImplementedError, "#{self.class} does not implement #upload() - network devices use command-based configuration"
|
96
131
|
end
|
97
132
|
|
133
|
+
# Download files from Juniper device (not supported)
|
134
|
+
# @param remotes [String, Array<String>] Remote file path(s)
|
135
|
+
# @param local [String] Local destination path
|
136
|
+
# @raise [NotImplementedError] Always raises as downloads are not supported
|
137
|
+
# @note Use run_command() to retrieve configuration data instead
|
98
138
|
def download(remotes, local)
|
99
139
|
raise NotImplementedError, "#{self.class} does not implement #download() - use run_command() to retrieve configuration data"
|
100
140
|
end
|
101
141
|
|
102
142
|
# Execute commands on Juniper device via SSH
|
143
|
+
# @param cmd [String] The JunOS command to execute
|
144
|
+
# @return [CommandResult] Result object with stdout, stderr, and exit status
|
145
|
+
# @raise [Train::ClientError] If command contains dangerous characters
|
146
|
+
# @example
|
147
|
+
# result = connection.run_command('show version')
|
148
|
+
# puts result.stdout
|
103
149
|
def run_command_via_connection(cmd)
|
104
|
-
|
150
|
+
# Sanitize command to prevent injection
|
151
|
+
safe_cmd = sanitize_command(cmd)
|
152
|
+
|
153
|
+
return mock_command_result(safe_cmd) if @options[:mock]
|
105
154
|
|
106
155
|
begin
|
107
156
|
# Ensure we're connected
|
108
157
|
connect unless connected?
|
109
158
|
|
110
|
-
@logger.debug("Executing command: #{
|
159
|
+
@logger.debug("Executing command: #{safe_cmd}")
|
111
160
|
|
112
161
|
# Execute command via SSH session
|
113
|
-
output = @ssh_session.exec!(
|
162
|
+
output = @ssh_session.exec!(safe_cmd)
|
114
163
|
|
115
164
|
@logger.debug("Command output: #{output}")
|
116
165
|
|
117
166
|
# Format JunOS result
|
118
|
-
format_junos_result(output,
|
167
|
+
format_junos_result(output, safe_cmd)
|
119
168
|
rescue StandardError => e
|
120
169
|
@logger.error("Command execution failed: #{e.message}")
|
121
170
|
# Handle connection errors gracefully
|
@@ -123,17 +172,91 @@ module TrainPlugins
|
|
123
172
|
end
|
124
173
|
end
|
125
174
|
|
126
|
-
# JunOS error patterns
|
127
|
-
|
128
|
-
/^error:/i,
|
129
|
-
/syntax error/i,
|
130
|
-
/invalid command/i,
|
131
|
-
/
|
132
|
-
|
175
|
+
# JunOS error patterns organized by type
|
176
|
+
JUNOS_ERRORS = {
|
177
|
+
configuration: [/^error:/i, /configuration database locked/i],
|
178
|
+
syntax: [/syntax error/i],
|
179
|
+
command: [/invalid command/i, /unknown command/i],
|
180
|
+
argument: [/missing argument/i]
|
181
|
+
}.freeze
|
182
|
+
|
183
|
+
# Flattened error patterns for quick matching
|
184
|
+
JUNOS_ERROR_PATTERNS = JUNOS_ERRORS.values.flatten.freeze
|
185
|
+
|
186
|
+
# SSH option mapping configuration
|
187
|
+
SSH_OPTION_MAPPING = {
|
188
|
+
port: :port,
|
189
|
+
password: :password,
|
190
|
+
timeout: :timeout,
|
191
|
+
keepalive: :keepalive,
|
192
|
+
keepalive_interval: :keepalive_interval,
|
193
|
+
keys: ->(opts) { Array(opts[:key_files]) if opts[:key_files] },
|
194
|
+
keys_only: ->(opts) { opts[:keys_only] if opts[:key_files] }
|
195
|
+
}.freeze
|
196
|
+
|
197
|
+
# Default SSH options for Juniper connections
|
198
|
+
# @note verify_host_key is set to :never for network device compatibility
|
199
|
+
SSH_DEFAULTS = {
|
200
|
+
verify_host_key: :never
|
201
|
+
}.freeze
|
202
|
+
|
203
|
+
# Mock response configuration
|
204
|
+
MOCK_RESPONSES = {
|
205
|
+
'show version' => :mock_show_version_output,
|
206
|
+
'show chassis hardware' => :mock_chassis_output,
|
207
|
+
'show configuration' => "interfaces {\n ge-0/0/0 {\n unit 0;\n }\n}",
|
208
|
+
'show route' => "inet.0: 5 destinations, 5 routes\n0.0.0.0/0 *[Static/5] 00:00:01\n",
|
209
|
+
'show system information' => "Hardware: SRX240H2\nOS: JUNOS 12.1X47-D15.4\n",
|
210
|
+
'show interfaces' => "Physical interface: ge-0/0/0, Enabled, Physical link is Up\n"
|
211
|
+
}.freeze
|
212
|
+
|
213
|
+
# Command sanitization patterns
|
214
|
+
# Note: Pipe (|) is allowed as it's commonly used in JunOS commands
|
215
|
+
DANGEROUS_COMMAND_PATTERNS = [
|
216
|
+
/[;&<>$`]/, # Shell metacharacters (excluding pipe)
|
217
|
+
/\n|\r/, # Newlines that could inject commands
|
218
|
+
/\\(?![nrt])/ # Escape sequences (except valid ones like \n, \r, \t)
|
133
219
|
].freeze
|
134
220
|
|
221
|
+
# Check connection health
|
222
|
+
# @return [Boolean] true if connection is healthy, false otherwise
|
223
|
+
# @example
|
224
|
+
# if connection.healthy?
|
225
|
+
# puts "Connection is healthy"
|
226
|
+
# end
|
227
|
+
def healthy?
|
228
|
+
return false unless connected?
|
229
|
+
|
230
|
+
result = run_command_via_connection('show version')
|
231
|
+
result.exit_status.zero?
|
232
|
+
rescue StandardError
|
233
|
+
false
|
234
|
+
end
|
235
|
+
|
236
|
+
# List of sensitive option keys to redact in logs
|
237
|
+
SENSITIVE_OPTIONS = %i[password bastion_password key_files proxy_command].freeze
|
238
|
+
private_constant :SENSITIVE_OPTIONS
|
239
|
+
|
135
240
|
private
|
136
241
|
|
242
|
+
# Log connection info without exposing sensitive data
|
243
|
+
def log_connection_info
|
244
|
+
safe_options = @options.except(*SENSITIVE_OPTIONS)
|
245
|
+
@logger.debug("Juniper connection initialized with options: #{safe_options.inspect}")
|
246
|
+
@logger.debug("Environment: JUNIPER_BASTION_USER=#{env_value('JUNIPER_BASTION_USER')} -> bastion_user=#{@options[:bastion_user]}")
|
247
|
+
end
|
248
|
+
|
249
|
+
# Sanitize command to prevent injection attacks
|
250
|
+
def sanitize_command(cmd)
|
251
|
+
cmd_str = cmd.to_s.strip
|
252
|
+
|
253
|
+
if DANGEROUS_COMMAND_PATTERNS.any? { |pattern| cmd_str.match?(pattern) }
|
254
|
+
raise Train::ClientError, "Invalid characters in command: #{cmd_str.inspect}"
|
255
|
+
end
|
256
|
+
|
257
|
+
cmd_str
|
258
|
+
end
|
259
|
+
|
137
260
|
# Establish SSH connection to Juniper device
|
138
261
|
def connect
|
139
262
|
return if connected?
|
@@ -145,28 +268,15 @@ module TrainPlugins
|
|
145
268
|
|
146
269
|
@logger.debug('Establishing SSH connection to Juniper device')
|
147
270
|
|
148
|
-
ssh_options =
|
149
|
-
port: @options[:port] || 22,
|
150
|
-
password: @options[:password],
|
151
|
-
timeout: @options[:timeout] || 30,
|
152
|
-
verify_host_key: :never,
|
153
|
-
keepalive: @options[:keepalive],
|
154
|
-
keepalive_interval: @options[:keepalive_interval]
|
155
|
-
}
|
156
|
-
|
157
|
-
# Add SSH key authentication if specified
|
158
|
-
if @options[:key_files]
|
159
|
-
ssh_options[:keys] = Array(@options[:key_files])
|
160
|
-
ssh_options[:keys_only] = @options[:keys_only]
|
161
|
-
end
|
271
|
+
ssh_options = build_ssh_options
|
162
272
|
|
163
273
|
# Add bastion host support if configured
|
164
274
|
if @options[:bastion_host]
|
165
275
|
require 'net/ssh/proxy/jump' unless defined?(Net::SSH::Proxy::Jump)
|
166
276
|
|
167
277
|
# Build proxy jump string from bastion options
|
168
|
-
bastion_user = @options[:bastion_user] ||
|
169
|
-
bastion_port = @options[:bastion_port]
|
278
|
+
bastion_user = @options[:bastion_user] || @options[:user] # Use explicit bastion user or fallback to main user
|
279
|
+
bastion_port = @options[:bastion_port]
|
170
280
|
|
171
281
|
proxy_jump = if bastion_port == 22
|
172
282
|
"#{bastion_user}@#{@options[:bastion_host]}"
|
@@ -286,8 +396,76 @@ module TrainPlugins
|
|
286
396
|
lines.join("\n")
|
287
397
|
end
|
288
398
|
|
399
|
+
# Helper method to safely get environment variable value
|
400
|
+
# Returns nil if env var is not set or is empty string
|
401
|
+
def env_value(key)
|
402
|
+
value = ENV.fetch(key, nil)
|
403
|
+
return nil if value.nil? || value.empty?
|
404
|
+
|
405
|
+
value
|
406
|
+
end
|
407
|
+
|
408
|
+
# Helper method to get environment variable as integer
|
409
|
+
# Returns nil if env var is not set, empty, or not a valid integer
|
410
|
+
def env_int(key)
|
411
|
+
value = env_value(key)
|
412
|
+
return nil unless value
|
413
|
+
|
414
|
+
value.to_i
|
415
|
+
rescue ArgumentError
|
416
|
+
nil
|
417
|
+
end
|
418
|
+
|
419
|
+
# Build SSH connection options from @options
|
420
|
+
def build_ssh_options
|
421
|
+
SSH_DEFAULTS.merge(
|
422
|
+
SSH_OPTION_MAPPING.each_with_object({}) do |(ssh_key, option_key), opts|
|
423
|
+
value = option_key.is_a?(Proc) ? option_key.call(@options) : @options[option_key]
|
424
|
+
opts[ssh_key] = value unless value.nil?
|
425
|
+
end
|
426
|
+
)
|
427
|
+
end
|
428
|
+
|
429
|
+
# Validate all connection options
|
430
|
+
def validate_connection_options!
|
431
|
+
validate_required_options!
|
432
|
+
validate_option_types!
|
433
|
+
validate_proxy_options!
|
434
|
+
end
|
435
|
+
|
436
|
+
# Validate required options are present
|
437
|
+
def validate_required_options!
|
438
|
+
raise Train::ClientError, 'Host is required' unless @options[:host]
|
439
|
+
raise Train::ClientError, 'User is required' unless @options[:user]
|
440
|
+
end
|
441
|
+
|
442
|
+
# Validate option types and ranges
|
443
|
+
def validate_option_types!
|
444
|
+
validate_port! if @options[:port]
|
445
|
+
validate_timeout! if @options[:timeout]
|
446
|
+
validate_bastion_port! if @options[:bastion_port]
|
447
|
+
end
|
448
|
+
|
449
|
+
# Validate port is in valid range
|
450
|
+
def validate_port!
|
451
|
+
port = @options[:port].to_i
|
452
|
+
raise Train::ClientError, "Invalid port: #{@options[:port]} (must be 1-65535)" unless port.between?(1, 65_535)
|
453
|
+
end
|
454
|
+
|
455
|
+
# Validate timeout is positive number
|
456
|
+
def validate_timeout!
|
457
|
+
timeout = @options[:timeout]
|
458
|
+
raise Train::ClientError, "Invalid timeout: #{timeout} (must be positive number)" unless timeout.is_a?(Numeric) && timeout.positive?
|
459
|
+
end
|
460
|
+
|
461
|
+
# Validate bastion port is in valid range
|
462
|
+
def validate_bastion_port!
|
463
|
+
port = @options[:bastion_port].to_i
|
464
|
+
raise Train::ClientError, "Invalid bastion_port: #{@options[:bastion_port]} (must be 1-65535)" unless port.between?(1, 65_535)
|
465
|
+
end
|
466
|
+
|
289
467
|
# Validate proxy configuration options (Train standard)
|
290
|
-
def validate_proxy_options
|
468
|
+
def validate_proxy_options!
|
291
469
|
# Cannot use both bastion_host and proxy_command simultaneously
|
292
470
|
if @options[:bastion_host] && @options[:proxy_command]
|
293
471
|
raise Train::ClientError, 'Cannot specify both bastion_host and proxy_command'
|
@@ -340,19 +518,11 @@ module TrainPlugins
|
|
340
518
|
|
341
519
|
# Mock command execution for testing
|
342
520
|
def mock_command_result(cmd)
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
CommandResult.new(
|
348
|
-
when /show configuration/
|
349
|
-
CommandResult.new("interfaces {\n ge-0/0/0 {\n unit 0;\n }\n}", '', 0)
|
350
|
-
when /show route/
|
351
|
-
CommandResult.new("inet.0: 5 destinations, 5 routes\n0.0.0.0/0 *[Static/5] 00:00:01\n", '', 0)
|
352
|
-
when /show system information/
|
353
|
-
CommandResult.new("Hardware: SRX240H2\nOS: JUNOS 12.1X47-D15.4\n", '', 0)
|
354
|
-
when /show interfaces/
|
355
|
-
CommandResult.new("Physical interface: ge-0/0/0, Enabled, Physical link is Up\n", '', 0)
|
521
|
+
response = MOCK_RESPONSES.find { |pattern, _| cmd.match?(/#{pattern}/) }
|
522
|
+
|
523
|
+
if response
|
524
|
+
output = response[1].is_a?(Symbol) ? send(response[1]) : response[1]
|
525
|
+
CommandResult.new(output, '', 0)
|
356
526
|
else
|
357
527
|
CommandResult.new("% Unknown command: #{cmd}", '', 1)
|
358
528
|
end
|
@@ -380,11 +550,19 @@ module TrainPlugins
|
|
380
550
|
|
381
551
|
# File abstraction for Juniper configuration and operational data
|
382
552
|
class JuniperFile
|
553
|
+
# Initialize a new JuniperFile
|
554
|
+
# @param connection [Connection] The Juniper connection instance
|
555
|
+
# @param path [String] The virtual file path
|
383
556
|
def initialize(connection, path)
|
384
557
|
@connection = connection
|
385
558
|
@path = path
|
386
559
|
end
|
387
560
|
|
561
|
+
# Get the content of the virtual file
|
562
|
+
# @return [String] The command output based on the path
|
563
|
+
# @example
|
564
|
+
# file = connection.file('/config/interfaces')
|
565
|
+
# file.content # Returns output of 'show configuration interfaces'
|
388
566
|
def content
|
389
567
|
# For Juniper devices, translate file paths to appropriate commands
|
390
568
|
case @path
|
@@ -5,16 +5,19 @@
|
|
5
5
|
|
6
6
|
module TrainPlugins::Juniper
|
7
7
|
# Platform detection mixin for Juniper network devices
|
8
|
+
# @note This module is mixed into the Connection class to provide platform detection
|
8
9
|
module Platform
|
9
10
|
# Platform name constant for consistency
|
10
11
|
PLATFORM_NAME = 'juniper'
|
11
12
|
|
12
13
|
# Platform detection for Juniper network devices
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
16
|
-
#
|
17
|
-
#
|
14
|
+
# @return [Train::Platform] Platform object with JunOS details
|
15
|
+
# @note Uses force_platform! to bypass Train's automatic detection
|
16
|
+
# @example
|
17
|
+
# platform = connection.platform
|
18
|
+
# platform.name #=> "juniper"
|
19
|
+
# platform.release #=> "12.1X47-D15.4"
|
20
|
+
# platform.arch #=> "x86_64"
|
18
21
|
def platform
|
19
22
|
# Return cached platform if already computed
|
20
23
|
return @platform if defined?(@platform)
|
@@ -45,67 +48,66 @@ module TrainPlugins::Juniper
|
|
45
48
|
|
46
49
|
private
|
47
50
|
|
48
|
-
#
|
49
|
-
#
|
50
|
-
|
51
|
-
|
52
|
-
|
51
|
+
# Generic detection helper for version and architecture
|
52
|
+
# @param attribute_name [String] Name of the attribute to detect
|
53
|
+
# @param command [String] Command to run (default: 'show version')
|
54
|
+
# @yield [String] Block that extracts the attribute from command output
|
55
|
+
# @return [String, nil] Detected attribute value or nil
|
56
|
+
def detect_attribute(attribute_name, command = 'show version', &extraction_block)
|
57
|
+
cache_var = "@detected_#{attribute_name}"
|
58
|
+
return instance_variable_get(cache_var) if instance_variable_defined?(cache_var)
|
53
59
|
|
54
|
-
# Only try version detection if we have an active connection
|
55
60
|
unless respond_to?(:run_command_via_connection)
|
56
61
|
logger&.debug('run_command_via_connection not available yet')
|
57
|
-
return
|
62
|
+
return instance_variable_set(cache_var, nil)
|
58
63
|
end
|
59
64
|
|
60
65
|
logger&.debug("Mock mode: #{@options&.dig(:mock)}, Connected: #{connected?}")
|
61
66
|
|
62
67
|
begin
|
63
|
-
|
64
|
-
unless connected?
|
65
|
-
logger&.debug('Not connected, skipping version detection')
|
66
|
-
return @detected_junos_version = nil
|
67
|
-
end
|
68
|
+
return instance_variable_set(cache_var, nil) unless connected?
|
68
69
|
|
69
|
-
#
|
70
|
-
|
71
|
-
result
|
70
|
+
# Reuse cached command result if available
|
71
|
+
result = @cached_show_version_result || run_command_via_connection(command)
|
72
|
+
@cached_show_version_result ||= result if command == 'show version' && result&.exit_status&.zero?
|
72
73
|
|
73
|
-
unless result&.exit_status&.zero?
|
74
|
-
logger&.debug("Command failed with exit status: #{result&.exit_status}")
|
75
|
-
return @detected_junos_version = nil
|
76
|
-
end
|
77
|
-
|
78
|
-
# Cache the result for architecture detection to avoid duplicate calls
|
79
|
-
@cached_show_version_result = result
|
74
|
+
return instance_variable_set(cache_var, nil) unless result&.exit_status&.zero?
|
80
75
|
|
81
|
-
|
82
|
-
version = extract_version_from_output(result.stdout)
|
76
|
+
value = extraction_block.call(result.stdout)
|
83
77
|
|
84
|
-
if
|
85
|
-
logger&.debug("Detected
|
86
|
-
|
78
|
+
if value
|
79
|
+
logger&.debug("Detected #{attribute_name}: #{value}")
|
80
|
+
instance_variable_set(cache_var, value)
|
87
81
|
else
|
88
|
-
logger&.debug("Could not parse
|
89
|
-
|
82
|
+
logger&.debug("Could not parse #{attribute_name} from: #{result.stdout[0..100]}")
|
83
|
+
instance_variable_set(cache_var, nil)
|
90
84
|
end
|
91
85
|
rescue StandardError => e
|
92
|
-
#
|
93
|
-
|
94
|
-
@detected_junos_version = nil
|
86
|
+
logger&.debug("#{attribute_name} detection failed: #{e.message}")
|
87
|
+
instance_variable_set(cache_var, nil)
|
95
88
|
end
|
96
89
|
end
|
97
90
|
|
91
|
+
# Detect JunOS version from device output
|
92
|
+
# @return [String, nil] JunOS version string or nil if not detected
|
93
|
+
# @note This runs safely after the connection is established
|
94
|
+
def detect_junos_version
|
95
|
+
detect_attribute('junos_version') { |output| extract_version_from_output(output) }
|
96
|
+
end
|
97
|
+
|
98
98
|
# Extract version string from JunOS show version output
|
99
|
+
# @param output [String] Raw output from 'show version' command
|
100
|
+
# @return [String, nil] Extracted version string or nil
|
99
101
|
def extract_version_from_output(output)
|
100
102
|
return nil if output.nil? || output.empty?
|
101
103
|
|
102
104
|
# Try multiple JunOS version patterns
|
103
105
|
patterns = [
|
104
|
-
/Junos:\s+([\d
|
105
|
-
/JUNOS Software Release \[([\d
|
106
|
-
/junos version ([\d
|
107
|
-
/Model: \S+, JUNOS Base OS boot \[([\d
|
108
|
-
/([\d]+\.[\d]+[\w
|
106
|
+
/Junos:\s+([\w\d.-]+)/, # "Junos: 12.1X47-D15.4"
|
107
|
+
/JUNOS Software Release \[([\w\d.-]+)\]/, # "JUNOS Software Release [12.1X47-D15.4]"
|
108
|
+
/junos version ([\w\d.-]+)/i, # "junos version 21.4R3"
|
109
|
+
/Model: \S+, JUNOS Base OS boot \[([\w\d.-]+)\]/, # Some hardware variants
|
110
|
+
/([\d]+\.[\d]+[\w.-]*)/ # Generic version pattern
|
109
111
|
]
|
110
112
|
|
111
113
|
patterns.each do |pattern|
|
@@ -117,56 +119,22 @@ module TrainPlugins::Juniper
|
|
117
119
|
end
|
118
120
|
|
119
121
|
# Detect JunOS architecture from device output
|
120
|
-
#
|
122
|
+
# @return [String, nil] Architecture string or nil if not detected
|
123
|
+
# @note This runs safely after the connection is established
|
121
124
|
def detect_junos_architecture
|
122
|
-
|
123
|
-
return @detected_junos_architecture if defined?(@detected_junos_architecture)
|
124
|
-
|
125
|
-
# Only try architecture detection if we have an active connection
|
126
|
-
return @detected_junos_architecture = nil unless respond_to?(:run_command_via_connection)
|
127
|
-
|
128
|
-
begin
|
129
|
-
# Check if connection is ready before running commands
|
130
|
-
return @detected_junos_architecture = nil unless connected?
|
131
|
-
|
132
|
-
# Reuse version detection result to avoid duplicate 'show version' calls
|
133
|
-
# Both version and architecture come from the same command output
|
134
|
-
if defined?(@detected_junos_version) && @detected_junos_version
|
135
|
-
# We already have the output from version detection, parse architecture from it
|
136
|
-
result = @cached_show_version_result
|
137
|
-
else
|
138
|
-
# Execute 'show version' command and cache the result
|
139
|
-
result = run_command_via_connection('show version')
|
140
|
-
@cached_show_version_result = result if result&.exit_status&.zero?
|
141
|
-
end
|
142
|
-
|
143
|
-
return @detected_junos_architecture = nil unless result&.exit_status&.zero?
|
144
|
-
|
145
|
-
# Parse architecture from output using multiple patterns
|
146
|
-
arch = extract_architecture_from_output(result.stdout)
|
147
|
-
|
148
|
-
if arch
|
149
|
-
logger&.debug("Detected JunOS architecture: #{arch}")
|
150
|
-
@detected_junos_architecture = arch
|
151
|
-
else
|
152
|
-
logger&.debug("Could not parse JunOS architecture from: #{result.stdout[0..100]}")
|
153
|
-
@detected_junos_architecture = nil
|
154
|
-
end
|
155
|
-
rescue StandardError => e
|
156
|
-
# If architecture detection fails, log and return nil
|
157
|
-
logger&.debug("JunOS architecture detection failed: #{e.message}")
|
158
|
-
@detected_junos_architecture = nil
|
159
|
-
end
|
125
|
+
detect_attribute('junos_architecture') { |output| extract_architecture_from_output(output) }
|
160
126
|
end
|
161
127
|
|
162
128
|
# Extract architecture string from JunOS show version output
|
129
|
+
# @param output [String] Raw output from 'show version' command
|
130
|
+
# @return [String, nil] Architecture string (x86_64, arm64, etc.) or nil
|
163
131
|
def extract_architecture_from_output(output)
|
164
132
|
return nil if output.nil? || output.empty?
|
165
133
|
|
166
134
|
# Try multiple JunOS architecture patterns
|
167
135
|
patterns = [
|
168
136
|
/Model:\s+(\S+)/, # "Model: SRX240H2" -> extract model as arch indicator
|
169
|
-
/Junos:\s+[\d
|
137
|
+
/Junos:\s+[\w\d.-]+\s+built\s+[\d-]+\s+[\d:]+\s+by\s+builder\s+on\s+(\S+)/, # Build architecture
|
170
138
|
/JUNOS.*\[([\w-]+)\]/, # JUNOS package architecture
|
171
139
|
/Architecture:\s+(\S+)/i, # Direct architecture line
|
172
140
|
/Platform:\s+(\S+)/i, # Platform designation
|
@@ -7,6 +7,13 @@ require 'train-juniper/connection'
|
|
7
7
|
|
8
8
|
module TrainPlugins
|
9
9
|
module Juniper
|
10
|
+
# Transport plugin for Juniper JunOS devices
|
11
|
+
# @example Create a connection
|
12
|
+
# conn = Train.create('juniper',
|
13
|
+
# host: '192.168.1.1',
|
14
|
+
# user: 'admin',
|
15
|
+
# password: 'secret'
|
16
|
+
# )
|
10
17
|
class Transport < Train.plugin(1)
|
11
18
|
name 'juniper'
|
12
19
|
|
@@ -20,7 +27,7 @@ module TrainPlugins
|
|
20
27
|
|
21
28
|
# Proxy/Bastion host support (Train standard options)
|
22
29
|
option :bastion_host, default: nil
|
23
|
-
option :bastion_user, default: nil #
|
30
|
+
option :bastion_user, default: nil # Falls back to main user if not specified
|
24
31
|
option :bastion_port, default: 22
|
25
32
|
option :bastion_password, default: nil # Separate password for bastion authentication
|
26
33
|
option :proxy_command, default: nil
|
@@ -45,6 +52,11 @@ module TrainPlugins
|
|
45
52
|
option :disable_complete_on_space, default: false
|
46
53
|
|
47
54
|
# Create and return a connection to a Juniper device
|
55
|
+
# @param _instance_opts [Hash] Instance options (unused, for compatibility)
|
56
|
+
# @return [TrainPlugins::Juniper::Connection] Cached connection instance
|
57
|
+
# @example
|
58
|
+
# transport = TrainPlugins::Juniper::Transport.new(options)
|
59
|
+
# conn = transport.connection
|
48
60
|
def connection(_instance_opts = nil)
|
49
61
|
# Cache the connection instance for reuse
|
50
62
|
# @options contains parsed connection details from train URI
|
data/train-juniper.gemspec
CHANGED
@@ -70,4 +70,20 @@ Gem::Specification.new do |spec|
|
|
70
70
|
# FFI dependency - required by train-core
|
71
71
|
# Match InSpec 7's FFI version range for compatibility
|
72
72
|
spec.add_dependency 'ffi', '>= 1.15.5', '< 1.17.0'
|
73
|
+
|
74
|
+
# Development dependencies
|
75
|
+
spec.add_development_dependency 'bundler', '~> 2.0'
|
76
|
+
spec.add_development_dependency 'byebug', '~> 11.1'
|
77
|
+
spec.add_development_dependency 'minitest', '~> 5.0'
|
78
|
+
spec.add_development_dependency 'mocha', '~> 2.0'
|
79
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
80
|
+
spec.add_development_dependency 'rubocop', '~> 1.0'
|
81
|
+
spec.add_development_dependency 'simplecov', '~> 0.22'
|
82
|
+
spec.add_development_dependency 'yard', '~> 0.9'
|
83
|
+
|
84
|
+
# Security testing
|
85
|
+
spec.add_development_dependency 'bundler-audit', '~> 0.9'
|
86
|
+
|
87
|
+
# Documentation generation
|
88
|
+
spec.add_development_dependency 'redcarpet', '~> 3.5'
|
73
89
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: train-juniper
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- MITRE Corporation
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-06-
|
11
|
+
date: 2025-06-23 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: train-core
|
@@ -64,6 +64,146 @@ dependencies:
|
|
64
64
|
- - "<"
|
65
65
|
- !ruby/object:Gem::Version
|
66
66
|
version: 1.17.0
|
67
|
+
- !ruby/object:Gem::Dependency
|
68
|
+
name: bundler
|
69
|
+
requirement: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - "~>"
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '2.0'
|
74
|
+
type: :development
|
75
|
+
prerelease: false
|
76
|
+
version_requirements: !ruby/object:Gem::Requirement
|
77
|
+
requirements:
|
78
|
+
- - "~>"
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: '2.0'
|
81
|
+
- !ruby/object:Gem::Dependency
|
82
|
+
name: byebug
|
83
|
+
requirement: !ruby/object:Gem::Requirement
|
84
|
+
requirements:
|
85
|
+
- - "~>"
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: '11.1'
|
88
|
+
type: :development
|
89
|
+
prerelease: false
|
90
|
+
version_requirements: !ruby/object:Gem::Requirement
|
91
|
+
requirements:
|
92
|
+
- - "~>"
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: '11.1'
|
95
|
+
- !ruby/object:Gem::Dependency
|
96
|
+
name: minitest
|
97
|
+
requirement: !ruby/object:Gem::Requirement
|
98
|
+
requirements:
|
99
|
+
- - "~>"
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '5.0'
|
102
|
+
type: :development
|
103
|
+
prerelease: false
|
104
|
+
version_requirements: !ruby/object:Gem::Requirement
|
105
|
+
requirements:
|
106
|
+
- - "~>"
|
107
|
+
- !ruby/object:Gem::Version
|
108
|
+
version: '5.0'
|
109
|
+
- !ruby/object:Gem::Dependency
|
110
|
+
name: mocha
|
111
|
+
requirement: !ruby/object:Gem::Requirement
|
112
|
+
requirements:
|
113
|
+
- - "~>"
|
114
|
+
- !ruby/object:Gem::Version
|
115
|
+
version: '2.0'
|
116
|
+
type: :development
|
117
|
+
prerelease: false
|
118
|
+
version_requirements: !ruby/object:Gem::Requirement
|
119
|
+
requirements:
|
120
|
+
- - "~>"
|
121
|
+
- !ruby/object:Gem::Version
|
122
|
+
version: '2.0'
|
123
|
+
- !ruby/object:Gem::Dependency
|
124
|
+
name: rake
|
125
|
+
requirement: !ruby/object:Gem::Requirement
|
126
|
+
requirements:
|
127
|
+
- - "~>"
|
128
|
+
- !ruby/object:Gem::Version
|
129
|
+
version: '13.0'
|
130
|
+
type: :development
|
131
|
+
prerelease: false
|
132
|
+
version_requirements: !ruby/object:Gem::Requirement
|
133
|
+
requirements:
|
134
|
+
- - "~>"
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: '13.0'
|
137
|
+
- !ruby/object:Gem::Dependency
|
138
|
+
name: rubocop
|
139
|
+
requirement: !ruby/object:Gem::Requirement
|
140
|
+
requirements:
|
141
|
+
- - "~>"
|
142
|
+
- !ruby/object:Gem::Version
|
143
|
+
version: '1.0'
|
144
|
+
type: :development
|
145
|
+
prerelease: false
|
146
|
+
version_requirements: !ruby/object:Gem::Requirement
|
147
|
+
requirements:
|
148
|
+
- - "~>"
|
149
|
+
- !ruby/object:Gem::Version
|
150
|
+
version: '1.0'
|
151
|
+
- !ruby/object:Gem::Dependency
|
152
|
+
name: simplecov
|
153
|
+
requirement: !ruby/object:Gem::Requirement
|
154
|
+
requirements:
|
155
|
+
- - "~>"
|
156
|
+
- !ruby/object:Gem::Version
|
157
|
+
version: '0.22'
|
158
|
+
type: :development
|
159
|
+
prerelease: false
|
160
|
+
version_requirements: !ruby/object:Gem::Requirement
|
161
|
+
requirements:
|
162
|
+
- - "~>"
|
163
|
+
- !ruby/object:Gem::Version
|
164
|
+
version: '0.22'
|
165
|
+
- !ruby/object:Gem::Dependency
|
166
|
+
name: yard
|
167
|
+
requirement: !ruby/object:Gem::Requirement
|
168
|
+
requirements:
|
169
|
+
- - "~>"
|
170
|
+
- !ruby/object:Gem::Version
|
171
|
+
version: '0.9'
|
172
|
+
type: :development
|
173
|
+
prerelease: false
|
174
|
+
version_requirements: !ruby/object:Gem::Requirement
|
175
|
+
requirements:
|
176
|
+
- - "~>"
|
177
|
+
- !ruby/object:Gem::Version
|
178
|
+
version: '0.9'
|
179
|
+
- !ruby/object:Gem::Dependency
|
180
|
+
name: bundler-audit
|
181
|
+
requirement: !ruby/object:Gem::Requirement
|
182
|
+
requirements:
|
183
|
+
- - "~>"
|
184
|
+
- !ruby/object:Gem::Version
|
185
|
+
version: '0.9'
|
186
|
+
type: :development
|
187
|
+
prerelease: false
|
188
|
+
version_requirements: !ruby/object:Gem::Requirement
|
189
|
+
requirements:
|
190
|
+
- - "~>"
|
191
|
+
- !ruby/object:Gem::Version
|
192
|
+
version: '0.9'
|
193
|
+
- !ruby/object:Gem::Dependency
|
194
|
+
name: redcarpet
|
195
|
+
requirement: !ruby/object:Gem::Requirement
|
196
|
+
requirements:
|
197
|
+
- - "~>"
|
198
|
+
- !ruby/object:Gem::Version
|
199
|
+
version: '3.5'
|
200
|
+
type: :development
|
201
|
+
prerelease: false
|
202
|
+
version_requirements: !ruby/object:Gem::Requirement
|
203
|
+
requirements:
|
204
|
+
- - "~>"
|
205
|
+
- !ruby/object:Gem::Version
|
206
|
+
version: '3.5'
|
67
207
|
description: Provides SSH connectivity to Juniper Networks devices running JunOS for
|
68
208
|
InSpec compliance testing and infrastructure inspection. Supports platform detection,
|
69
209
|
command execution, and configuration file access.
|