train-juniper 0.7.3 → 0.8.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: c7074cafad165e2055edc5be8495a250025f4fc06ad658e740b77248e3d2273c
4
- data.tar.gz: febffacb8ad71ffc912a76b3be460f598e48e20b0c500de48f10c3cefe11c549
3
+ metadata.gz: b7571220250289d5866fa3074789e166f5000cde3b93a0174889e24d1a8ac335
4
+ data.tar.gz: 276162fb0254fc498b4ad562ba3377a1d0d2ba84faccd630eb67f084185798b8
5
5
  SHA512:
6
- metadata.gz: 4c47e243ab6d5922904bd7898b4c1da8dff8ac4986f3b60629e4662c566e79ace9bbec8a0dfda2ee400e51ec7f060f2c871b11be07d84130ce1f1ac1d23b4d76
7
- data.tar.gz: af8b001261139a75f38da8be9711c3c9f14c8a618bd8195234c750262ef40e4a772cd9a081a59deb2880e1c78ac5ad44846ba7c902cc360420a4d4a4b5092be6
6
+ metadata.gz: 3007c4a588b1024c1553cfa22f9d606628d335578438c4d78c927fb3fa4fe99fad50dcac50da6a184998934a913c218d40a51d6b54fcaaf575dc6b3df7e8ffa8
7
+ data.tar.gz: 8c52911377b95ef3e51a2e36c4e7b0cc9eaaf3ee9d3877af16f3fdb869e5929904d65bda70344ba049037f6a1cbc138bfd119b18e0d2785a864fd0819a177e7d
data/.env.example CHANGED
@@ -1,29 +1,20 @@
1
- # Train-Juniper Environment Variables Example
2
- # Copy this file to .env and fill in your actual values
3
- # The plugin automatically detects these environment variables
1
+ # Example environment configuration for train-juniper
2
+ # Copy this file to .env and update with your values
4
3
 
5
- # Juniper Device Connection
6
- JUNIPER_HOST=your.device.hostname.com
7
- JUNIPER_USER=your_username
8
- JUNIPER_PASSWORD=your_password
4
+ # Direct connection to Juniper device
5
+ JUNIPER_HOST=192.168.1.1
9
6
  JUNIPER_PORT=22
10
- JUNIPER_TIMEOUT=60
7
+ JUNIPER_USER=admin
8
+ JUNIPER_PASSWORD=your-password-here
11
9
 
12
- # Bastion/Jump Host (optional - only if device is behind jump host)
13
- JUNIPER_BASTION_HOST=your.jumphost.com
14
- JUNIPER_BASTION_USER=your_jump_username
15
- JUNIPER_BASTION_PORT=22
16
- JUNIPER_BASTION_PASSWORD=your_jump_password
10
+ # Optional: Connection timeout in seconds
11
+ CONNECTION_TIMEOUT=60
17
12
 
18
- # Custom Proxy Command (alternative to bastion host)
19
- # JUNIPER_PROXY_COMMAND=ssh your.jumphost.com -W %h:%p
13
+ # Optional: Bastion/jump host configuration
14
+ # Uncomment and configure if you need to connect through a bastion host
15
+ # JUNIPER_BASTION_HOST=jump.example.com
16
+ # JUNIPER_BASTION_USER=admin
17
+ # JUNIPER_BASTION_PORT=22
18
+ # JUNIPER_BASTION_PASSWORD=bastion-password-here
20
19
 
21
- # Usage Examples:
22
- # 1. Basic connection (auto-detects above variables):
23
- # inspec detect -t juniper://
24
- #
25
- # 2. Override specific values:
26
- # inspec detect -t juniper://different_user@different_host --password override_pass
27
- #
28
- # 3. Test connection:
29
- # source .env && inspec detect -t juniper:// -l debug
20
+ # Note: If JUNIPER_BASTION_PASSWORD is not set, it will use JUNIPER_PASSWORD
data/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ 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] - 2025-07-04
9
+
10
+ ### Documentation
11
+
12
+ - Update roadmap with v0.7.4 accomplishments and clear prioritization
13
+
14
+ ### Fixed
15
+
16
+ - Resolve UUID warning and migrate to XML parsing ([#4](https://github.com/mitre/train-juniper/issues/4))
17
+
18
+ ### Refactor
19
+
20
+ - Improve code quality based on deep review
21
+
22
+ ### Testing
23
+
24
+ - Achieve 100% line coverage with cross-platform Windows plink test
25
+
26
+ ## [0.7.4] - 2025-06-24
27
+
28
+ ### Added
29
+
30
+ - Add Windows plink.exe support for bastion authentication
31
+
32
+ ### Documentation
33
+
34
+ - Add Windows bastion setup guide and improve navigation
35
+ - Fix MkDocs link warnings
36
+ - Add host key acceptance instructions and InSpec testing examples
37
+ - Improve Windows documentation and standardize on inspec shell
38
+
39
+ ### Fixed
40
+
41
+ - Clean up ENV in Windows plink test to prevent CI failures
42
+ - Remove trailing whitespace to pass linting
43
+
44
+ ### Miscellaneous Tasks
45
+
46
+ - Fix linting issues in Windows test script
47
+
48
+ ### Testing
49
+
50
+ - Add Windows testing scripts and guide
51
+ - Add .env file support to Windows test scripts
52
+
8
53
  ## [0.7.3] - 2025-06-23
9
54
 
10
55
  ### Documentation
data/CONTRIBUTING.md CHANGED
@@ -146,7 +146,7 @@ Releases are managed by project maintainers:
146
146
 
147
147
  ## Community
148
148
 
149
- - Follow our [Code of Conduct](CODE_OF_CONDUCT)
149
+ - Follow our [Code of Conduct](CODE_OF_CONDUCT.md)
150
150
  - Be respectful and collaborative
151
151
  - Help others learn and contribute
152
152
 
data/README.md CHANGED
@@ -409,7 +409,7 @@ This gem supports a wide range of platforms to ensure maximum compatibility:
409
409
  | `x86_64-linux-musl` | Alpine Linux | Docker containers |
410
410
  | `x86_64-darwin` | Intel macOS | Older Mac workstations |
411
411
  | `arm64-darwin-*` | Apple Silicon macOS | Modern Mac workstations |
412
- | `x64-mingw-ucrt` | Windows (UCRT) | Windows 10/11 with modern Ruby |
412
+ | `x64-mingw-ucrt` | Windows (UCRT) | Windows 10/11 with modern Ruby ([bastion setup](windows-bastion-setup.md)) |
413
413
  | `x86_64-freebsd` | FreeBSD | Network appliances (JunOS heritage) |
414
414
  | `x86_64-solaris` | Solaris/illumos | Enterprise environments |
415
415
 
@@ -418,10 +418,11 @@ This gem supports a wide range of platforms to ensure maximum compatibility:
418
418
 
419
419
  ### Documentation
420
420
 
421
- - **[Installation Guide](installation)** - Complete installation instructions
422
- - **[Basic Usage](basic-usage)** - Getting started with the plugin
423
- - **[Release Process](RELEASE_PROCESS)** - How to cut releases and publish gems
424
- - **[Project Roadmap](ROADMAP)** - Future development plans and contribution opportunities
421
+ - **[Installation Guide](installation.md)** - Complete installation instructions
422
+ - **[Basic Usage](basic-usage.md)** - Getting started with the plugin
423
+ - **[Windows Bastion Setup](windows-bastion-setup.md)** - Windows bastion/jump host authentication guide
424
+ - **[Release Process](RELEASE_PROCESS.md)** - How to cut releases and publish gems
425
+ - **[Project Roadmap](ROADMAP.md)** - Future development plans and contribution opportunities
425
426
 
426
427
  ### Plugin Development Resources
427
428
 
@@ -438,7 +439,7 @@ We welcome contributions! Here's how to get started:
438
439
  4. Run `bundle exec rake test` to ensure tests pass
439
440
  5. Submit a pull request
440
441
 
441
- Please see our [Contributing Guide](CONTRIBUTING) for more details.
442
+ Please see our [Contributing Guide](CONTRIBUTING.md) for more details.
442
443
 
443
444
  ## Support and Contact
444
445
 
@@ -472,12 +473,12 @@ Special thanks to the Train and InSpec communities for their excellent documenta
472
473
 
473
474
  Licensed under the Apache-2.0 license, except as noted below.
474
475
 
475
- See [LICENSE](LICENSE) for full details.
476
+ See [LICENSE](LICENSE.md) for full details.
476
477
 
477
478
  ### Notice
478
479
 
479
480
  This software was produced for the U.S. Government under contract and is subject to Federal Acquisition Regulation Clause 52.227-14.
480
481
 
481
- See [NOTICE](NOTICE) for full details.
482
+ See [NOTICE](NOTICE.md) for full details.
482
483
 
483
484
  © 2025 The MITRE Corporation.
@@ -1,26 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'train-juniper/constants'
4
+ require 'train-juniper/connection/windows_proxy'
5
+ require 'train-juniper/connection/ssh_askpass'
4
6
 
5
7
  module TrainPlugins
6
8
  module Juniper
7
9
  # Handles bastion host proxy configuration and authentication
8
10
  module BastionProxy
11
+ include WindowsProxy
12
+ include SshAskpass
13
+
9
14
  # Configure bastion proxy for SSH connection
10
15
  # @param ssh_options [Hash] SSH options to modify
11
16
  def configure_bastion_proxy(ssh_options)
12
- require 'net/ssh/proxy/jump' unless defined?(Net::SSH::Proxy::Jump)
13
-
14
- # Build proxy jump string from bastion options
15
17
  bastion_user = @options[:bastion_user] || @options[:user]
16
18
  bastion_port = @options[:bastion_port]
19
+ bastion_password = @options[:bastion_password] || @options[:password]
20
+
21
+ # On Windows with password auth, use plink.exe if available
22
+ if Gem.win_platform? && bastion_password && plink_available?
23
+ configure_plink_proxy(ssh_options, bastion_user, bastion_port, bastion_password)
24
+ else
25
+ configure_standard_proxy(ssh_options, bastion_user, bastion_port)
26
+ end
27
+ end
17
28
 
18
- proxy_jump = if bastion_port == Constants::DEFAULT_SSH_PORT
19
- "#{bastion_user}@#{@options[:bastion_host]}"
20
- else
21
- "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
22
- end
29
+ private
30
+
31
+ # Configure standard SSH proxy using Net::SSH::Proxy::Jump
32
+ # @param ssh_options [Hash] SSH options to modify
33
+ # @param bastion_user [String] Username for bastion
34
+ # @param bastion_port [Integer] Port for bastion
35
+ def configure_standard_proxy(ssh_options, bastion_user, bastion_port)
36
+ require 'net/ssh/proxy/jump' unless defined?(Net::SSH::Proxy::Jump)
23
37
 
38
+ proxy_jump = build_proxy_jump_string(bastion_user, bastion_port)
24
39
  @logger.debug("Using bastion host: #{proxy_jump}")
25
40
 
26
41
  # Set up automated password authentication via SSH_ASKPASS
@@ -29,49 +44,34 @@ module TrainPlugins
29
44
  ssh_options[:proxy] = Net::SSH::Proxy::Jump.new(proxy_jump)
30
45
  end
31
46
 
32
- # Set up SSH_ASKPASS for bastion password authentication
33
- def setup_bastion_password_auth
34
- bastion_password = @options[:bastion_password] || @options[:password]
35
- return unless bastion_password
36
-
37
- @ssh_askpass_script = create_ssh_askpass_script(bastion_password)
38
- ENV['SSH_ASKPASS'] = @ssh_askpass_script
39
- ENV['SSH_ASKPASS_REQUIRE'] = 'force'
40
- @logger.debug('Configured SSH_ASKPASS for automated bastion authentication')
47
+ # Configure plink.exe proxy for Windows password authentication
48
+ # @param ssh_options [Hash] SSH options to modify
49
+ # @param bastion_user [String] Username for bastion
50
+ # @param bastion_port [Integer] Port for bastion
51
+ # @param bastion_password [String] Password for bastion
52
+ def configure_plink_proxy(ssh_options, bastion_user, bastion_port, bastion_password)
53
+ require 'net/ssh/proxy/command' unless defined?(Net::SSH::Proxy::Command)
54
+
55
+ proxy_cmd = build_plink_proxy_command(
56
+ @options[:bastion_host],
57
+ bastion_user,
58
+ bastion_port,
59
+ bastion_password
60
+ )
61
+
62
+ @logger.debug('Using plink.exe for bastion proxy')
63
+ ssh_options[:proxy] = Net::SSH::Proxy::Command.new(proxy_cmd)
41
64
  end
42
65
 
43
- # Create temporary SSH_ASKPASS script for automated password authentication
44
- # @param password [String] The password to use
45
- # @return [String] Path to the created script
46
- def create_ssh_askpass_script(password)
47
- require 'tempfile'
48
-
49
- if Gem.win_platform?
50
- # :nocov:
51
- # Create Windows PowerShell script
52
- script = Tempfile.new(['ssh_askpass', '.ps1'])
53
- # PowerShell handles escaping better, just escape quotes
54
- escaped_password = password.gsub("'", "''")
55
- script.write("Write-Output '#{escaped_password}'\r\n")
56
- script.close
57
-
58
- # Create a wrapper batch file to execute PowerShell with bypass policy
59
- wrapper = Tempfile.new(['ssh_askpass_wrapper', '.bat'])
60
- wrapper.write("@echo off\r\npowershell.exe -ExecutionPolicy Bypass -File \"#{script.path}\"\r\n")
61
- wrapper.close
62
-
63
- @logger.debug("Created SSH_ASKPASS PowerShell script at #{script.path} with wrapper at #{wrapper.path}")
64
- wrapper.path
65
- # :nocov:
66
+ # Build proxy jump string from bastion options
67
+ # @param bastion_user [String] Username for bastion
68
+ # @param bastion_port [Integer] Port for bastion
69
+ # @return [String] Proxy jump string
70
+ def build_proxy_jump_string(bastion_user, bastion_port)
71
+ if bastion_port == Constants::DEFAULT_SSH_PORT
72
+ "#{bastion_user}@#{@options[:bastion_host]}"
66
73
  else
67
- # Create Unix shell script
68
- script = Tempfile.new(['ssh_askpass', '.sh'])
69
- script.write("#!/bin/bash\necho '#{password}'\n")
70
- script.close
71
- File.chmod(0o755, script.path)
72
-
73
- @logger.debug("Created SSH_ASKPASS script at #{script.path}")
74
- script.path
74
+ "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
75
75
  end
76
76
  end
77
77
 
@@ -88,11 +88,7 @@ module TrainPlugins
88
88
  end
89
89
 
90
90
  # Use ProxyJump (-J) which handles password authentication properly
91
- jump_host = if bastion_port == Constants::DEFAULT_SSH_PORT
92
- "#{bastion_user}@#{@options[:bastion_host]}"
93
- else
94
- "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
95
- end
91
+ jump_host = build_proxy_jump_string(bastion_user, bastion_port)
96
92
  args += ['-J', jump_host]
97
93
 
98
94
  # Add SSH keys if specified
@@ -40,9 +40,9 @@ module TrainPlugins
40
40
  # Format JunOS result
41
41
  format_junos_result(output, safe_cmd)
42
42
  rescue StandardError => e
43
- log_error(e, 'Command execution failed')
43
+ log_error(e, "Command execution failed for: #{safe_cmd}")
44
44
  # Handle connection errors gracefully
45
- error_result(e.message)
45
+ error_result("#{e.message} (command: #{safe_cmd})")
46
46
  # :nocov:
47
47
  end
48
48
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+
5
+ module TrainPlugins
6
+ module Juniper
7
+ # SSH_ASKPASS script management for automated password authentication
8
+ module SshAskpass
9
+ # Set up SSH_ASKPASS for bastion password authentication
10
+ def setup_bastion_password_auth
11
+ bastion_password = @options[:bastion_password] || @options[:password]
12
+ return unless bastion_password
13
+
14
+ @ssh_askpass_script = create_ssh_askpass_script(bastion_password)
15
+ ENV['SSH_ASKPASS'] = @ssh_askpass_script
16
+ ENV['SSH_ASKPASS_REQUIRE'] = 'force'
17
+ @logger.debug('Configured SSH_ASKPASS for automated bastion authentication')
18
+ end
19
+
20
+ # Create temporary SSH_ASKPASS script for automated password authentication
21
+ # @param password [String] The password to use
22
+ # @return [String] Path to the created script
23
+ def create_ssh_askpass_script(password)
24
+ if Gem.win_platform?
25
+ create_windows_askpass_script(password)
26
+ else
27
+ create_unix_askpass_script(password)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # Create Windows PowerShell script for SSH_ASKPASS
34
+ # @param password [String] The password to use
35
+ # @return [String] Path to the wrapper batch file
36
+ def create_windows_askpass_script(password)
37
+ # :nocov:
38
+ # Create Windows PowerShell script
39
+ script = Tempfile.new(['ssh_askpass', '.ps1'])
40
+ # PowerShell handles escaping better, just escape quotes
41
+ escaped_password = password.gsub("'", "''")
42
+ script.write("Write-Output '#{escaped_password}'\r\n")
43
+ script.close
44
+
45
+ # Create a wrapper batch file to execute PowerShell with bypass policy
46
+ wrapper = Tempfile.new(['ssh_askpass_wrapper', '.bat'])
47
+ wrapper.write("@echo off\r\npowershell.exe -ExecutionPolicy Bypass -File \"#{script.path}\"\r\n")
48
+ wrapper.close
49
+
50
+ @logger.debug("Created SSH_ASKPASS PowerShell script at #{script.path} with wrapper at #{wrapper.path}")
51
+ wrapper.path
52
+ # :nocov:
53
+ end
54
+
55
+ # Create Unix shell script for SSH_ASKPASS
56
+ # @param password [String] The password to use
57
+ # @return [String] Path to the created script
58
+ def create_unix_askpass_script(password)
59
+ script = Tempfile.new(['ssh_askpass', '.sh'])
60
+ script.write("#!/bin/bash\necho '#{password}'\n")
61
+ script.close
62
+ File.chmod(0o755, script.path)
63
+
64
+ @logger.debug("Created SSH_ASKPASS script at #{script.path}")
65
+ script.path
66
+ end
67
+ end
68
+ end
69
+ end
@@ -17,6 +17,9 @@ module TrainPlugins
17
17
 
18
18
  # Default SSH options for Juniper connections
19
19
  # @note verify_host_key is set to :never for network device compatibility
20
+ # Rationale: Network devices often regenerate SSH keys after firmware updates
21
+ # and operate in controlled environments where MITM attacks are mitigated by
22
+ # network segmentation. This matches standard network automation practices.
20
23
  SSH_DEFAULTS = {
21
24
  verify_host_key: :never
22
25
  }.freeze
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module TrainPlugins
6
+ module Juniper
7
+ # Windows-specific proxy handling using plink.exe
8
+ # This pattern is used by various Ruby projects for Windows SSH support,
9
+ # including hglib.rb (Mercurial) and follows Net::SSH::Proxy::Command patterns
10
+ module WindowsProxy
11
+ # Check if plink.exe is available on Windows
12
+ # @return [Boolean] true if plink.exe is found in PATH
13
+ def plink_available?
14
+ return false unless Gem.win_platform?
15
+
16
+ ENV['PATH'].split(File::PATH_SEPARATOR).any? do |path|
17
+ File.exist?(File.join(path, 'plink.exe'))
18
+ end
19
+ end
20
+
21
+ # Build plink.exe proxy command for Windows bastion authentication
22
+ # @param bastion_host [String] Bastion hostname
23
+ # @param user [String] Username for bastion
24
+ # @param port [Integer] Port for bastion
25
+ # @param password [String] Password for bastion
26
+ # @return [String] Complete plink command string
27
+ def build_plink_proxy_command(bastion_host, user, port, password)
28
+ parts = []
29
+ parts << 'plink.exe'
30
+ parts << '-batch' # Non-interactive mode
31
+ parts << '-ssh' # Force SSH protocol (not telnet)
32
+ parts << '-pw'
33
+ parts << Shellwords.escape(password)
34
+
35
+ if port && port != 22
36
+ parts << '-P'
37
+ parts << port.to_s
38
+ end
39
+
40
+ parts << "#{user}@#{bastion_host}"
41
+ parts << '-nc'
42
+ parts << '%h:%p' # Netcat mode for proxying
43
+
44
+ parts.join(' ')
45
+ end
46
+ end
47
+ end
48
+ end
@@ -102,10 +102,10 @@ module TrainPlugins
102
102
  end
103
103
 
104
104
  @options[:keepalive] = true
105
- @options[:keepalive_interval] = 60
105
+ @options[:keepalive_interval] = Constants::SSH_KEEPALIVE_INTERVAL
106
106
 
107
107
  # Setup logger
108
- @logger = @options[:logger] || Logger.new(STDOUT, level: Logger::WARN)
108
+ @logger = @options[:logger] || Logger.new(STDOUT, level: Constants::DEFAULT_LOG_LEVEL)
109
109
 
110
110
  # JunOS CLI prompt patterns
111
111
  @cli_prompt = /[%>$#]\s*$/
@@ -187,6 +187,38 @@ module TrainPlugins
187
187
  false
188
188
  end
189
189
 
190
+ # Required by Train framework for node identification
191
+ # @return [String] URI that uniquely identifies this connection
192
+ # @example Direct connection
193
+ # "juniper://admin@device.example.com:22"
194
+ # @example Bastion connection
195
+ # "juniper://admin@device.example.com:22?via=jumpuser@bastion.example.com:2222"
196
+ def uri
197
+ base_uri = "juniper://#{@options[:user]}@#{@options[:host]}:#{@options[:port]}"
198
+
199
+ # Include bastion information if connecting through a jump host
200
+ if @options[:bastion_host]
201
+ bastion_user = @options[:bastion_user] || @options[:user]
202
+ bastion_port = @options[:bastion_port] || 22
203
+ bastion_info = "via=#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
204
+ "#{base_uri}?#{bastion_info}"
205
+ else
206
+ base_uri
207
+ end
208
+ end
209
+
210
+ # Optional method for better UUID generation using device-specific identifiers
211
+ # @return [String] Unique identifier for this device/connection
212
+ # @note Tries to get Juniper device serial number, falls back to hostname
213
+ def unique_identifier
214
+ # Don't attempt device detection in mock mode
215
+ return @options[:host] if @options[:mock]
216
+
217
+ # Use the platform module's serial detection which follows DRY principle
218
+ serial = detect_junos_serial
219
+ serial || @options[:host]
220
+ end
221
+
190
222
  # List of sensitive option keys to redact in logs
191
223
  SENSITIVE_OPTIONS = %i[password bastion_password key_files proxy_command].freeze
192
224
  private_constant :SENSITIVE_OPTIONS
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
4
+
3
5
  module TrainPlugins
4
6
  module Juniper
5
7
  # Common constants used across the plugin
@@ -9,6 +11,14 @@ module TrainPlugins
9
11
  DEFAULT_SSH_PORT = 22
10
12
  # @return [Range] Valid port range for SSH connections
11
13
  PORT_RANGE = (1..65_535)
14
+ # @return [Integer] SSH keepalive interval in seconds
15
+ SSH_KEEPALIVE_INTERVAL = 60
16
+ # @return [Integer] Maximum keepalive count before disconnect
17
+ SSH_KEEPALIVE_MAX_COUNT = 3
18
+
19
+ # Logging Configuration
20
+ # @return [Integer] Default log level
21
+ DEFAULT_LOG_LEVEL = Logger::WARN
12
22
 
13
23
  # Standard SSH Options for network devices
14
24
  STANDARD_SSH_OPTIONS = {
@@ -39,10 +39,56 @@ module TrainPlugins
39
39
  OUTPUT
40
40
  end
41
41
 
42
+ # Mock chassis hardware XML output
43
+ # @return [String] mock XML output for 'show chassis hardware | display xml' command
44
+ def self.mock_chassis_xml_output
45
+ <<~OUTPUT
46
+ <rpc-reply xmlns:junos="http://xml.juniper.net/junos/12.1X47/junos">
47
+ <chassis-inventory xmlns="http://xml.juniper.net/junos/12.1X47/junos-chassis">
48
+ <chassis junos:style="inventory">
49
+ <name>Chassis</name>
50
+ <serial-number>JN123456</serial-number>
51
+ <description>SRX240H2</description>
52
+ </chassis>
53
+ </chassis-inventory>
54
+ </rpc-reply>
55
+ OUTPUT
56
+ end
57
+
58
+ # Mock version XML output
59
+ # @return [String] mock XML output for 'show version | display xml' command
60
+ def self.mock_version_xml_output
61
+ <<~OUTPUT
62
+ <rpc-reply xmlns:junos="http://xml.juniper.net/junos/12.1X47/junos">
63
+ <software-information>
64
+ <host-name>lab-srx</host-name>
65
+ <product-model>SRX240H2</product-model>
66
+ <product-name>srx240h2</product-name>
67
+ <junos-version>12.1X47-D15.4</junos-version>
68
+ </software-information>
69
+ </rpc-reply>
70
+ OUTPUT
71
+ end
72
+
42
73
  # Get mock response for a command
43
74
  # @param cmd [String] the command to get response for
44
75
  # @return [Array<String, Integer>] tuple of [output, exit_status]
45
76
  def self.response_for(cmd)
77
+ # Check if command includes display modifiers
78
+ if cmd.include?('| display xml')
79
+ # Handle XML output requests
80
+ # Use simple string split to avoid ReDoS vulnerability
81
+ base_cmd = cmd.split('|').first.strip
82
+
83
+ case base_cmd
84
+ when 'show chassis hardware'
85
+ return [mock_chassis_xml_output, 0]
86
+ when 'show version'
87
+ return [mock_version_xml_output, 0]
88
+ end
89
+ end
90
+
91
+ # Standard text response handling
46
92
  response = RESPONSES.find { |pattern, _| cmd.match?(/#{pattern}/) }
47
93
 
48
94
  if response
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'rexml/document'
4
+
3
5
  # Platform definition file for Juniper network devices.
4
6
  # This defines the "juniper" platform within Train's platform detection system.
5
7
 
@@ -51,6 +53,33 @@ module TrainPlugins::Juniper
51
53
 
52
54
  private
53
55
 
56
+ # Generic XML extraction helper
57
+ # @param output [String] XML output from command
58
+ # @param xpath_patterns [Array<String>] XPath patterns to try in order
59
+ # @param command_desc [String] Description of command for error messages
60
+ # @yield [REXML::Element] Optional block to process the found element
61
+ # @return [String, nil] Extracted text or result of block processing
62
+ def extract_from_xml(output, xpath_patterns, command_desc)
63
+ return nil if output.nil? || output.empty?
64
+
65
+ doc = REXML::Document.new(output)
66
+
67
+ # Try each XPath pattern until we find an element
68
+ element = nil
69
+ xpath_patterns.each do |xpath|
70
+ element = doc.elements[xpath]
71
+ break if element
72
+ end
73
+
74
+ return nil unless element
75
+
76
+ # If block given, let it process the element, otherwise return text
77
+ block_given? ? yield(element) : element.text&.strip
78
+ rescue StandardError => e
79
+ logger&.warn("Failed to parse XML output from '#{command_desc}': #{e.message}")
80
+ nil
81
+ end
82
+
54
83
  # Generic detection helper for version and architecture
55
84
  # @param attribute_name [String] Name of the attribute to detect
56
85
  # @param command [String] Command to run (default: 'show version')
@@ -95,79 +124,77 @@ module TrainPlugins::Juniper
95
124
  # @return [String, nil] JunOS version string or nil if not detected
96
125
  # @note This runs safely after the connection is established
97
126
  def detect_junos_version
98
- detect_attribute('junos_version') { |output| extract_version_from_output(output) }
127
+ detect_attribute('junos_version', 'show version | display xml') { |output| extract_version_from_xml(output) }
99
128
  end
100
129
 
101
- # Extract version string from JunOS show version output
102
- # @param output [String] Raw output from 'show version' command
130
+ # Extract version string from JunOS show version XML output
131
+ # @param output [String] XML output from 'show version | display xml' command
103
132
  # @return [String, nil] Extracted version string or nil
104
- def extract_version_from_output(output)
105
- return nil if output.nil? || output.empty?
106
-
107
- # Try multiple JunOS version patterns
108
- patterns = [
109
- /Junos:\s+([\w\d.-]+)/, # "Junos: 12.1X47-D15.4"
110
- /JUNOS Software Release \[([\w\d.-]+)\]/, # "JUNOS Software Release [12.1X47-D15.4]"
111
- /junos version ([\w\d.-]+)/i, # "junos version 21.4R3"
112
- /Model: \S+, JUNOS Base OS boot \[([\w\d.-]+)\]/, # Some hardware variants
113
- /([\d]+\.[\d]+[\w.-]*)/ # Generic version pattern
133
+ def extract_version_from_xml(output)
134
+ xpath_patterns = [
135
+ '//junos-version',
136
+ '//package-information/name[text()="junos"]/following-sibling::comment',
137
+ '//software-information/version'
114
138
  ]
115
139
 
116
- patterns.each do |pattern|
117
- match = output.match(pattern)
118
- return match[1] if match
119
- end
120
-
121
- nil
140
+ extract_from_xml(output, xpath_patterns, 'show version | display xml')
122
141
  end
123
142
 
124
143
  # Detect JunOS architecture from device output
125
144
  # @return [String, nil] Architecture string or nil if not detected
126
145
  # @note This runs safely after the connection is established
127
146
  def detect_junos_architecture
128
- detect_attribute('junos_architecture') { |output| extract_architecture_from_output(output) }
147
+ detect_attribute('junos_architecture', 'show version | display xml') { |output| extract_architecture_from_xml(output) }
129
148
  end
130
149
 
131
- # Extract architecture string from JunOS show version output
132
- # @param output [String] Raw output from 'show version' command
150
+ # Extract architecture string from JunOS show version XML output
151
+ # @param output [String] XML output from 'show version | display xml' command
133
152
  # @return [String, nil] Architecture string (x86_64, arm64, etc.) or nil
134
- def extract_architecture_from_output(output)
135
- return nil if output.nil? || output.empty?
136
-
137
- # Try multiple JunOS architecture patterns
138
- patterns = [
139
- /Model:\s+(\S+)/, # "Model: SRX240H2" -> extract model as arch indicator
140
- /Junos:\s+[\w\d.-]+\s+built\s+[\d-]+\s+[\d:]+\s+by\s+builder\s+on\s+(\S+)/, # Build architecture
141
- /JUNOS.*\[([\w-]+)\]/, # JUNOS package architecture
142
- /Architecture:\s+(\S+)/i, # Direct architecture line
143
- /Platform:\s+(\S+)/i, # Platform designation
144
- /Processor.*:\s*(\S+)/i # Processor type
153
+ def extract_architecture_from_xml(output)
154
+ xpath_patterns = [
155
+ '//product-model',
156
+ '//software-information/product-model',
157
+ '//chassis-inventory/chassis/description'
145
158
  ]
146
159
 
147
- patterns.each do |pattern|
148
- match = output.match(pattern)
149
- next unless match
160
+ extract_from_xml(output, xpath_patterns, 'show version | display xml') do |element|
161
+ model = element.text.strip
150
162
 
151
- arch_value = match[1]
152
- # Convert model names to architecture indicators
153
- case arch_value
163
+ # Map model names to architecture
164
+ case model
154
165
  when /SRX\d+/i
155
- return 'x86_64' # Most SRX models are x86_64
166
+ 'x86_64' # Most SRX models are x86_64
156
167
  when /MX\d+/i
157
- return 'x86_64' # MX routers are typically x86_64
168
+ 'x86_64' # MX routers are typically x86_64
158
169
  when /EX\d+/i
159
- return 'arm64' # Many EX switches use ARM
170
+ 'arm64' # Many EX switches use ARM
160
171
  when /QFX\d+/i
161
- return 'x86_64' # QFX switches typically x86_64
162
- when /^(x86_64|amd64|i386|arm64|aarch64|sparc|mips)$/i
163
- return arch_value.downcase
172
+ 'x86_64' # QFX switches typically x86_64
164
173
  else
165
- # Return the model as-is if we can't map it
166
- return arch_value
174
+ # Default to x86_64 for unknown models
175
+ 'x86_64'
167
176
  end
168
177
  end
178
+ end
169
179
 
170
- nil
180
+ # Detect JunOS serial number from device output
181
+ # @return [String, nil] Serial number string or nil if not detected
182
+ # @note This runs safely after the connection is established
183
+ def detect_junos_serial
184
+ detect_attribute('junos_serial', 'show chassis hardware | display xml') { |output| extract_serial_from_xml(output) }
185
+ end
186
+
187
+ # Extract serial number from JunOS chassis hardware XML output
188
+ # @param output [String] XML output from 'show chassis hardware | display xml' command
189
+ # @return [String, nil] Serial number string or nil
190
+ def extract_serial_from_xml(output)
191
+ xpath_patterns = [
192
+ '//chassis/serial-number',
193
+ '//chassis-sub-module/serial-number',
194
+ '//module/serial-number[1]'
195
+ ]
196
+
197
+ extract_from_xml(output, xpath_patterns, 'show chassis hardware | display xml')
171
198
  end
172
199
  end
173
200
  end
@@ -8,6 +8,6 @@
8
8
  module TrainPlugins
9
9
  module Juniper
10
10
  # Version number of the train-juniper plugin
11
- VERSION = '0.7.3'
11
+ VERSION = '0.8.0'
12
12
  end
13
13
  end
@@ -62,7 +62,7 @@ Gem::Specification.new do |spec|
62
62
 
63
63
  # Community plugins typically use train-core for smaller footprint
64
64
  # train-core provides core functionality without cloud dependencies
65
- spec.add_dependency 'train-core', '~> 3.12.13'
65
+ spec.add_dependency 'train-core', '~> 3.12', '>= 3.12.13'
66
66
 
67
67
  # SSH connectivity dependencies - match train-core's exact version range
68
68
  spec.add_dependency 'net-ssh', '>= 2.9', '< 8.0'
metadata CHANGED
@@ -1,20 +1,23 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: train-juniper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.8.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-23 00:00:00.000000000 Z
11
+ date: 2025-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: train-core
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.12'
20
+ - - ">="
18
21
  - !ruby/object:Gem::Version
19
22
  version: 3.12.13
20
23
  type: :runtime
@@ -22,6 +25,9 @@ dependencies:
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
27
  - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '3.12'
30
+ - - ">="
25
31
  - !ruby/object:Gem::Version
26
32
  version: 3.12.13
27
33
  - !ruby/object:Gem::Dependency
@@ -227,8 +233,10 @@ files:
227
233
  - lib/train-juniper/connection/bastion_proxy.rb
228
234
  - lib/train-juniper/connection/command_executor.rb
229
235
  - lib/train-juniper/connection/error_handling.rb
236
+ - lib/train-juniper/connection/ssh_askpass.rb
230
237
  - lib/train-juniper/connection/ssh_session.rb
231
238
  - lib/train-juniper/connection/validation.rb
239
+ - lib/train-juniper/connection/windows_proxy.rb
232
240
  - lib/train-juniper/constants.rb
233
241
  - lib/train-juniper/file_abstraction/juniper_file.rb
234
242
  - lib/train-juniper/helpers/environment.rb