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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4428bf34013845866814e344ce4f67460a6af1a70fc33f33641a9412fee9dccb
4
- data.tar.gz: 40f7a51485657e5616c744fa379283c8c0dfc1dbaa6d3a307ac4e9c909884d0d
3
+ metadata.gz: abb9487cdbf492d9b94f9b45699e2a434095509e128d5928863e5fdff3c73241
4
+ data.tar.gz: 0d9d6746cb16f185eef54b199aaf02b4509e66cc9292af3475913bae89f3e36c
5
5
  SHA512:
6
- metadata.gz: 31d2993f5e85a04ff1c1940644242df90e1c19066339eb8be395ec1378767fce53958278318f58e1fea7a51c0ea5f0923f5447f8f204f5b374e4e8ca6e4d91bd
7
- data.tar.gz: f0da0acd3ea9398723a62cbb5a5f73a9af010c440218a7a01f62ba2f4071eb33833d7d284b5e4b55d166f0e61c3b47988bd85cd3854dad6eb00f2155551b1dd0
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.6.2] - 2025-06-18
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
- - Fix Windows installation issue by relaxing FFI dependency to match InSpec 7 (>= 1.15.5, < 1.17.0) (#2)
13
- - Fix mock mode platform detection to correctly show JunOS version instead of gem version
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
- ### Added
24
+ ### Miscellaneous Tasks
18
25
 
19
- - Add `mock?` method to properly support mock mode in platform detection
20
- - Add comprehensive mock mode documentation to README
26
+ - Update .gitignore for untracked files
21
27
 
22
- ### Changed
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
- - Update release process documentation to reflect trusted publishing setup
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: network
67
+ Families: bsd
68
68
  Release: 21.4R3-S1.6
69
- Arch: network
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
- # Using Train standard bastion host options
81
- $ inspec shell -t "juniper://admin@10.1.1.1?bastion_host=jump.example.com&bastion_user=netadmin&bastion_password=jumppass"
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 "juniper://admin@10.1.1.1?bastion_host=jump.example.com&bastion_user=netadmin&bastion_port=2222&bastion_password=jumppass"
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 JUNIPER_BASTION_USER=netadmin
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
- # Same password for both bastion and device (common scenario)
98
+ # Different passwords via environment
94
99
  export JUNIPER_BASTION_HOST=jump.example.com
95
- export JUNIPER_BASTION_USER=admin
96
- export JUNIPER_PASSWORD=shared_password # Used for both when bastion_password not set
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 | root | `JUNIPER_BASTION_USER` |
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 `bastion_password` not provided, falls back to using `password` for bastion authentication
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
- # Choices of rubocop rules to enforce are deeply personal.
37
- # Here, we set things up so that your plugin will use the Bundler-installed
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
- desc 'Run Brakeman security vulnerability scan'
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 security:brakeman test:security]
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
- @options[:host] ||= ENV.fetch('JUNIPER_HOST', nil)
36
- @options[:user] ||= ENV.fetch('JUNIPER_USER', nil)
37
- @options[:password] ||= ENV.fetch('JUNIPER_PASSWORD', nil)
38
- @options[:port] ||= ENV['JUNIPER_PORT']&.to_i || 22
39
- @options[:timeout] ||= ENV['JUNIPER_TIMEOUT']&.to_i || 30
40
-
41
- # Proxy/bastion environment variables (Train standard)
42
- # Only set from environment if not explicitly provided
43
- @options[:bastion_host] = @options[:bastion_host] || ENV.fetch('JUNIPER_BASTION_HOST', nil)
44
- @options[:bastion_user] = @options[:bastion_user] || ENV['JUNIPER_BASTION_USER'] || 'root'
45
- @options[:bastion_port] = @options[:bastion_port] || ENV['JUNIPER_BASTION_PORT']&.to_i || 22
46
- @options[:proxy_command] = @options[:proxy_command] || ENV.fetch('JUNIPER_PROXY_COMMAND', nil)
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 without exposing credentials
59
- safe_options = @options.except(:password, :proxy_command, :key_files)
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 proxy configuration early (Train standard)
65
- validate_proxy_options
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
- # File operations for Juniper configuration files
84
- # Supports reading configuration files and operational data
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
- # File transfer operations (following network device pattern)
92
- # Network devices don't support traditional file upload/download
93
- # Use run_command() for configuration management instead
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
- return mock_command_result(cmd) if @options[:mock]
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: #{cmd}")
159
+ @logger.debug("Executing command: #{safe_cmd}")
111
160
 
112
161
  # Execute command via SSH session
113
- output = @ssh_session.exec!(cmd)
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, cmd)
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 from implementation plan
127
- JUNOS_ERROR_PATTERNS = [
128
- /^error:/i,
129
- /syntax error/i,
130
- /invalid command/i,
131
- /unknown command/i,
132
- /missing argument/i
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] || 'root'
169
- bastion_port = @options[:bastion_port] || 22
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
- case cmd
344
- when /show version/
345
- CommandResult.new(mock_show_version_output, '', 0)
346
- when /show chassis hardware/
347
- CommandResult.new(mock_chassis_output, '', 0)
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
- # For dedicated transport plugins, we use force_platform! to bypass
15
- # Train's automatic platform detection, which might run commands before
16
- # the connection is ready. This is the standard pattern used by official
17
- # Train plugins like train-k8s-container.
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
- # Detect JunOS version from device output
49
- # This runs safely after the connection is established
50
- def detect_junos_version
51
- # Return cached version if already detected
52
- return @detected_junos_version if defined?(@detected_junos_version)
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 @detected_junos_version = nil
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
- # Check if connection is ready before running commands
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
- # Execute 'show version' command to get JunOS information
70
- logger&.debug("Running 'show version' command")
71
- result = run_command_via_connection('show version')
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
- # Parse JunOS version from output using multiple patterns
82
- version = extract_version_from_output(result.stdout)
76
+ value = extraction_block.call(result.stdout)
83
77
 
84
- if version
85
- logger&.debug("Detected JunOS version: #{version}")
86
- @detected_junos_version = version
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 JunOS version from: #{result.stdout[0..100]}")
89
- @detected_junos_version = nil
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
- # If version detection fails, log and return nil
93
- logger&.debug("JunOS version detection failed: #{e.message}")
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\w\.-]+)/, # "Junos: 12.1X47-D15.4"
105
- /JUNOS Software Release \[([\d\w\.-]+)\]/, # "JUNOS Software Release [12.1X47-D15.4]"
106
- /junos version ([\d\w\.-]+)/i, # "junos version 21.4R3"
107
- /Model: \S+, JUNOS Base OS boot \[([\d\w\.-]+)\]/, # Some hardware variants
108
- /([\d]+\.[\d]+[\w\.-]*)/ # Generic version pattern
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
- # This runs safely after the connection is established
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
- # Return cached architecture if already detected
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\w\.-]+\s+built\s+[\d-]+\s+[\d:]+\s+by\s+builder\s+on\s+(\S+)/, # Build architecture
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 # Let connection handle env vars and defaults
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
@@ -7,6 +7,7 @@
7
7
 
8
8
  module TrainPlugins
9
9
  module Juniper
10
- VERSION = '0.6.2'
10
+ # Version number of the train-juniper plugin
11
+ VERSION = '0.7.0'
11
12
  end
12
13
  end
@@ -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.6.2
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-19 00:00:00.000000000 Z
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.