train-juniper 0.5.4

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.
data/Rakefile ADDED
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A Rakefile defines tasks to help maintain your project.
4
+ # Rake provides several task templates that are useful.
5
+
6
+ #------------------------------------------------------------------#
7
+ # Test Runner Tasks
8
+ #------------------------------------------------------------------#
9
+
10
+ # This task template will make a task named 'test', and run
11
+ # the tests that it finds.
12
+ require 'rake/testtask'
13
+
14
+ Rake::TestTask.new do |t|
15
+ t.libs.push 'lib'
16
+ t.test_files = FileList[
17
+ 'test/unit/*_test.rb',
18
+ 'test/integration/*_test.rb',
19
+ 'test/functional/*_test.rb',
20
+ 'test/security/*_test.rb'
21
+ ]
22
+ t.verbose = true
23
+ # Ideally, we'd run tests with warnings enabled,
24
+ # but the dependent gems have many warnings. As this
25
+ # is an example, let's disable them so the testing
26
+ # experience is cleaner.
27
+ t.warning = false
28
+ end
29
+
30
+ #------------------------------------------------------------------#
31
+ # Code Style Tasks
32
+ #------------------------------------------------------------------#
33
+ require 'rubocop/rake_task'
34
+
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]
44
+ end
45
+
46
+ #------------------------------------------------------------------#
47
+ # Security Tasks
48
+ #------------------------------------------------------------------#
49
+
50
+ desc 'Run security tests'
51
+ task 'test:security' do
52
+ ruby '-Ilib:test test/security/security_test.rb'
53
+ end
54
+
55
+ desc 'Run dependency vulnerability scan'
56
+ task 'security:dependencies' do
57
+ puts 'Running bundler-audit dependency scan...'
58
+ system('bundle exec bundle-audit update') or puts 'Failed to update vulnerability database'
59
+ system('bundle exec bundle-audit check') or abort('Vulnerable dependencies found')
60
+ end
61
+
62
+ desc 'Run secrets scanning'
63
+ task 'security:secrets' do
64
+ puts 'Running TruffleHog secrets scan...'
65
+ if system('which trufflehog > /dev/null 2>&1')
66
+ system('trufflehog filesystem --config=.trufflehog.yml --no-verification --no-update .') or abort('Secrets detected')
67
+ else
68
+ puts 'TruffleHog not installed. Install with: brew install trufflehog'
69
+ abort('TruffleHog required for secrets scanning')
70
+ end
71
+ end
72
+
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
78
+
79
+ desc 'Run comprehensive security scan'
80
+ task 'security:scan' do
81
+ puts 'Running comprehensive security scan...'
82
+ system('ruby security/security_scan.rb') or abort('Security scan failed')
83
+ end
84
+
85
+ desc 'Run all security checks'
86
+ task security: %w[security:dependencies security:brakeman test:security]
87
+
88
+ desc 'Run all tests including security'
89
+ task 'test:all' => %w[test security]
90
+
91
+ #------------------------------------------------------------------#
92
+ # Load Additional Tasks
93
+ #------------------------------------------------------------------#
94
+ Dir['tasks/*.rake'].each { |f| load f }
data/SECURITY.md ADDED
@@ -0,0 +1,79 @@
1
+ # Security Policy
2
+
3
+ ## Reporting Security Issues
4
+
5
+ The MITRE SAF team takes security seriously. If you discover a security vulnerability in the Train-Juniper plugin, please report it responsibly.
6
+
7
+ ### Contact Information
8
+
9
+ - **Email**: [saf-security@mitre.org](mailto:saf-security@mitre.org)
10
+ - **GitHub**: Use the [Security tab](https://github.com/mitre/train-juniper/security) to report vulnerabilities privately
11
+
12
+ ### What to Include
13
+
14
+ When reporting security issues, please provide:
15
+
16
+ 1. **Description** of the vulnerability
17
+ 2. **Steps to reproduce** the issue
18
+ 3. **Potential impact** assessment
19
+ 4. **Suggested fix** (if you have one)
20
+
21
+ ### Response Timeline
22
+
23
+ - **Acknowledgment**: Within 48 hours
24
+ - **Initial Assessment**: Within 7 days
25
+ - **Fix Timeline**: Varies by severity
26
+
27
+ ## Security Best Practices
28
+
29
+ ### For Users
30
+
31
+ - **Keep Updated**: Use the latest version of the plugin
32
+ - **Secure Credentials**: Never commit passwords or SSH keys to version control
33
+ - **Use SSH Keys**: Prefer SSH key authentication over passwords
34
+ - **Network Security**: Use VPNs and secure networks when connecting to network devices
35
+
36
+ ### For Contributors
37
+
38
+ - **Dependency Scanning**: Run `bundle audit` before submitting PRs
39
+ - **Credential Handling**: Never log or expose credentials in code
40
+ - **Input Validation**: Sanitize all user inputs
41
+ - **Test Security**: Include security tests for new features
42
+
43
+ ## Supported Versions
44
+
45
+ | Version | Supported |
46
+ |---------|-----------|
47
+ | 0.1.x | ✅ Yes |
48
+
49
+ ## Security Testing
50
+
51
+ The plugin includes comprehensive security testing:
52
+
53
+ ```bash
54
+ # Run security test suite
55
+ bundle exec ruby test/security/security_test.rb
56
+
57
+ # Check for vulnerable dependencies
58
+ bundle exec bundle-audit check
59
+
60
+ # Scan for potential security issues
61
+ bundle exec brakeman --no-pager
62
+ ```
63
+
64
+ ## Known Security Considerations
65
+
66
+ ### Network Device Access
67
+ - Train-Juniper requires SSH access to network infrastructure
68
+ - Ensure proper network segmentation and access controls
69
+ - Use dedicated service accounts with minimal required privileges
70
+
71
+ ### Credential Management
72
+ - Plugin supports environment variables for credential management
73
+ - Consider using secrets management systems in production
74
+ - Rotate credentials regularly
75
+
76
+ ### Logging and Debugging
77
+ - Debug mode may log sensitive command outputs
78
+ - Review log files for credential exposure
79
+ - Use `-l debug` sparingly in production environments
@@ -0,0 +1,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Connection definition for Juniper Train plugin.
4
+
5
+ # This plugin provides SSH connectivity to Juniper network devices,
6
+ # enabling InSpec to connect to and inspect Juniper routers and switches.
7
+ # Key capabilities:
8
+ # * SSH authentication to Juniper devices
9
+ # * Platform detection for JunOS devices
10
+ # * Command execution via SSH with prompt handling
11
+ # * File operations for configuration inspection
12
+
13
+ # Base Train transport functionality
14
+ require 'train'
15
+ require 'logger'
16
+
17
+ # Juniper-specific platform detection
18
+ require 'train-juniper/platform'
19
+
20
+ # Using Train's SSH transport for connectivity
21
+
22
+ module TrainPlugins
23
+ module Juniper
24
+ # Main connection class for Juniper devices
25
+ class Connection < Train::Plugins::Transport::BaseConnection
26
+ # Include Juniper-specific platform detection
27
+ include TrainPlugins::Juniper::Platform
28
+
29
+ attr_reader :ssh_session
30
+
31
+ def initialize(options)
32
+ # Configure SSH connection options for Juniper devices
33
+ # Support environment variables for authentication (following train-vsphere pattern)
34
+ @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)
47
+
48
+ @options[:keepalive] = true
49
+ @options[:keepalive_interval] = 60
50
+
51
+ # Setup logger
52
+ @logger = @options[:logger] || Logger.new(STDOUT, level: Logger::WARN)
53
+
54
+ # JunOS CLI prompt patterns
55
+ @cli_prompt = /[%>$#]\s*$/
56
+ @config_prompt = /[%#]\s*$/
57
+
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]}")
63
+
64
+ # Validate proxy configuration early (Train standard)
65
+ validate_proxy_options
66
+
67
+ super(@options)
68
+
69
+ # Establish SSH connection to Juniper device (unless in mock mode)
70
+ @logger.debug('Attempting to connect to Juniper device...')
71
+ connect unless @options[:mock]
72
+ end
73
+
74
+ # Secure string representation (never expose credentials)
75
+ def to_s
76
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} @host=#{@options[:host]} @user=#{@options[:user]}>"
77
+ end
78
+
79
+ def inspect
80
+ to_s
81
+ end
82
+
83
+ # File operations for Juniper configuration files
84
+ # Supports reading configuration files and operational data
85
+ def file_via_connection(path)
86
+ # For Juniper devices, "files" are typically configuration sections
87
+ # or operational command outputs rather than traditional filesystem paths
88
+ JuniperFile.new(self, path)
89
+ end
90
+
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
94
+ def upload(locals, remote)
95
+ raise NotImplementedError, "#{self.class} does not implement #upload() - network devices use command-based configuration"
96
+ end
97
+
98
+ def download(remotes, local)
99
+ raise NotImplementedError, "#{self.class} does not implement #download() - use run_command() to retrieve configuration data"
100
+ end
101
+
102
+ # Execute commands on Juniper device via SSH
103
+ def run_command_via_connection(cmd)
104
+ return mock_command_result(cmd) if @options[:mock]
105
+
106
+ begin
107
+ # Ensure we're connected
108
+ connect unless connected?
109
+
110
+ @logger.debug("Executing command: #{cmd}")
111
+
112
+ # Execute command via SSH session
113
+ output = @ssh_session.exec!(cmd)
114
+
115
+ @logger.debug("Command output: #{output}")
116
+
117
+ # Format JunOS result
118
+ format_junos_result(output, cmd)
119
+ rescue StandardError => e
120
+ @logger.error("Command execution failed: #{e.message}")
121
+ # Handle connection errors gracefully
122
+ CommandResult.new('', e.message, 1)
123
+ end
124
+ end
125
+
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
133
+ ].freeze
134
+
135
+ private
136
+
137
+ # Establish SSH connection to Juniper device
138
+ def connect
139
+ return if connected?
140
+
141
+ begin
142
+ # Use direct SSH connection (network device pattern)
143
+ # Defensive loading - only require if not fully loaded
144
+ require 'net/ssh' unless defined?(Net::SSH) && Net::SSH.respond_to?(:start)
145
+
146
+ @logger.debug('Establishing SSH connection to Juniper device')
147
+
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
162
+
163
+ # Add bastion host support if configured
164
+ if @options[:bastion_host]
165
+ require 'net/ssh/proxy/jump' unless defined?(Net::SSH::Proxy::Jump)
166
+
167
+ # Build proxy jump string from bastion options
168
+ bastion_user = @options[:bastion_user] || 'root'
169
+ bastion_port = @options[:bastion_port] || 22
170
+
171
+ proxy_jump = if bastion_port == 22
172
+ "#{bastion_user}@#{@options[:bastion_host]}"
173
+ else
174
+ "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
175
+ end
176
+
177
+ @logger.debug("Using bastion host: #{proxy_jump}")
178
+
179
+ # Set up automated password authentication via SSH_ASKPASS
180
+ bastion_password = @options[:bastion_password] || @options[:password] # Use explicit bastion password or fallback
181
+ if bastion_password
182
+ @ssh_askpass_script = create_ssh_askpass_script(bastion_password)
183
+ ENV['SSH_ASKPASS'] = @ssh_askpass_script
184
+ ENV['SSH_ASKPASS_REQUIRE'] = 'force' # Force use of SSH_ASKPASS even with terminal
185
+ @logger.debug('Configured SSH_ASKPASS for automated bastion authentication')
186
+ end
187
+
188
+ ssh_options[:proxy] = Net::SSH::Proxy::Jump.new(proxy_jump)
189
+ end
190
+
191
+ @logger.debug("Connecting to #{@options[:host]}:#{@options[:port]} as #{@options[:user]}")
192
+
193
+ # Direct SSH connection
194
+ @ssh_session = Net::SSH.start(@options[:host], @options[:user], ssh_options)
195
+ @logger.debug('SSH connection established successfully')
196
+
197
+ # Configure JunOS session for automation
198
+ test_and_configure_session
199
+ rescue StandardError => e
200
+ @logger.error("SSH connection failed: #{e.message}")
201
+
202
+ # Provide helpful error messages for common authentication issues
203
+ if (e.message.include?('Permission denied') || e.message.include?('command failed')) && @options[:bastion_host]
204
+ raise Train::TransportError, <<~ERROR
205
+ Failed to connect to Juniper device #{@options[:host]} via bastion #{@options[:bastion_host]}: #{e.message}
206
+
207
+ SSH bastion authentication with passwords is not supported due to ProxyCommand limitations.
208
+ Please use one of these alternatives:
209
+
210
+ 1. SSH Key Authentication (Recommended):
211
+ Use --key-files option to specify SSH private key files
212
+ #{' '}
213
+ 2. SSH Agent:
214
+ Ensure your SSH agent has the required keys loaded
215
+ #{' '}
216
+ 3. Direct Connection:
217
+ Connect directly to the device if network allows (remove bastion options)
218
+
219
+ For more details, see: https://mitre.github.io/train-juniper/troubleshooting/#bastion-authentication
220
+ ERROR
221
+ else
222
+ raise Train::TransportError, "Failed to connect to Juniper device #{@options[:host]}: #{e.message}"
223
+ end
224
+ end
225
+ end
226
+
227
+ # Check if SSH connection is active
228
+ def connected?
229
+ !@ssh_session.nil?
230
+ rescue StandardError
231
+ false
232
+ end
233
+
234
+ # Test connection and configure JunOS session
235
+ def test_and_configure_session
236
+ @logger.debug('Testing SSH connection and configuring JunOS session')
237
+
238
+ # Test connection first
239
+ @ssh_session.exec!('echo "connection test"')
240
+ @logger.debug('SSH connection test successful')
241
+
242
+ # Optimize CLI for automation
243
+ @ssh_session.exec!('set cli screen-length 0')
244
+ @ssh_session.exec!('set cli screen-width 0')
245
+ @ssh_session.exec!('set cli complete-on-space off') if @options[:disable_complete_on_space]
246
+
247
+ @logger.debug('JunOS session configured successfully')
248
+ rescue StandardError => e
249
+ @logger.warn("Failed to configure JunOS session: #{e.message}")
250
+ end
251
+
252
+ # Format JunOS command results (from implementation plan)
253
+ def format_junos_result(output, cmd)
254
+ # Parse JunOS-specific error patterns
255
+ if junos_error?(output)
256
+ CommandResult.new('', output, 1)
257
+ else
258
+ CommandResult.new(clean_output(output, cmd), '', 0)
259
+ end
260
+ end
261
+
262
+ # Check for JunOS error patterns (from implementation plan)
263
+ def junos_error?(output)
264
+ JUNOS_ERROR_PATTERNS.any? { |pattern| output.match?(pattern) }
265
+ end
266
+
267
+ # Clean command output
268
+ def clean_output(output, cmd)
269
+ # Handle nil output gracefully
270
+ return '' if output.nil?
271
+
272
+ # Remove command echo and prompts
273
+ lines = output.to_s.split("\n")
274
+ lines.reject! { |line| line.strip == cmd.strip }
275
+
276
+ # Remove JunOS prompt patterns from the end
277
+ lines.pop while lines.last && lines.last.strip.match?(/^[%>$#]+\s*$/)
278
+
279
+ lines.join("\n")
280
+ end
281
+
282
+ # Validate proxy configuration options (Train standard)
283
+ def validate_proxy_options
284
+ # Cannot use both bastion_host and proxy_command simultaneously
285
+ if @options[:bastion_host] && @options[:proxy_command]
286
+ raise Train::ClientError, 'Cannot specify both bastion_host and proxy_command'
287
+ end
288
+ end
289
+
290
+ # Create temporary SSH_ASKPASS script for automated password authentication
291
+ def create_ssh_askpass_script(password)
292
+ require 'tempfile'
293
+
294
+ script = Tempfile.new(['ssh_askpass', '.sh'])
295
+ script.write("#!/bin/bash\necho '#{password}'\n")
296
+ script.close
297
+ File.chmod(0o755, script.path)
298
+
299
+ @logger.debug("Created SSH_ASKPASS script at #{script.path}")
300
+ script.path
301
+ end
302
+
303
+ # Generate SSH proxy command for bastion host using ProxyJump (-J)
304
+ def generate_bastion_proxy_command(bastion_user, bastion_port)
305
+ args = ['ssh']
306
+
307
+ # SSH options for connection
308
+ args += ['-o', 'UserKnownHostsFile=/dev/null']
309
+ args += ['-o', 'StrictHostKeyChecking=no']
310
+ args += ['-o', 'LogLevel=ERROR']
311
+ args += ['-o', 'ForwardAgent=no']
312
+
313
+ # Use ProxyJump (-J) which handles password authentication properly
314
+ jump_host = if bastion_port == 22
315
+ "#{bastion_user}@#{@options[:bastion_host]}"
316
+ else
317
+ "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
318
+ end
319
+ args += ['-J', jump_host]
320
+
321
+ # Add SSH keys if specified
322
+ if @options[:key_files]
323
+ Array(@options[:key_files]).each do |key_file|
324
+ args += ['-i', key_file]
325
+ end
326
+ end
327
+
328
+ # Target connection - %h and %p will be replaced by Net::SSH
329
+ args += ['%h', '-p', '%p']
330
+
331
+ args.join(' ')
332
+ end
333
+
334
+ # Mock command execution for testing
335
+ def mock_command_result(cmd)
336
+ case cmd
337
+ when /show version/
338
+ CommandResult.new(mock_show_version_output, '', 0)
339
+ when /show chassis hardware/
340
+ CommandResult.new(mock_chassis_output, '', 0)
341
+ when /show configuration/
342
+ CommandResult.new("interfaces {\n ge-0/0/0 {\n unit 0;\n }\n}", '', 0)
343
+ when /show route/
344
+ CommandResult.new("inet.0: 5 destinations, 5 routes\n0.0.0.0/0 *[Static/5] 00:00:01\n", '', 0)
345
+ when /show system information/
346
+ CommandResult.new("Hardware: SRX240H2\nOS: JUNOS 12.1X47-D15.4\n", '', 0)
347
+ when /show interfaces/
348
+ CommandResult.new("Physical interface: ge-0/0/0, Enabled, Physical link is Up\n", '', 0)
349
+ else
350
+ CommandResult.new("% Unknown command: #{cmd}", '', 1)
351
+ end
352
+ end
353
+
354
+ # Mock JunOS version output for testing
355
+ def mock_show_version_output
356
+ <<~OUTPUT
357
+ Hostname: lab-srx
358
+ Model: SRX240H2
359
+ Junos: 12.1X47-D15.4
360
+ JUNOS Software Release [12.1X47-D15.4]
361
+ OUTPUT
362
+ end
363
+
364
+ # Mock chassis output for testing
365
+ def mock_chassis_output
366
+ <<~OUTPUT
367
+ Hardware inventory:
368
+ Item Version Part number Serial number Description
369
+ Chassis JN123456 SRX240H2
370
+ OUTPUT
371
+ end
372
+ end
373
+
374
+ # File abstraction for Juniper configuration and operational data
375
+ class JuniperFile
376
+ def initialize(connection, path)
377
+ @connection = connection
378
+ @path = path
379
+ end
380
+
381
+ def content
382
+ # For Juniper devices, translate file paths to appropriate commands
383
+ case @path
384
+ when %r{/config/(.*)}
385
+ # Configuration sections: /config/interfaces -> show configuration interfaces
386
+ section = ::Regexp.last_match(1)
387
+ result = @connection.run_command("show configuration #{section}")
388
+ result.stdout
389
+ when %r{/operational/(.*)}
390
+ # Operational data: /operational/interfaces -> show interfaces
391
+ section = ::Regexp.last_match(1)
392
+ result = @connection.run_command("show #{section}")
393
+ result.stdout
394
+ else
395
+ # Default to treating path as a show command
396
+ result = @connection.run_command("show #{@path}")
397
+ result.stdout
398
+ end
399
+ end
400
+
401
+ def exist?
402
+ !content.empty?
403
+ rescue StandardError
404
+ false
405
+ end
406
+ end
407
+ end
408
+ end