train-juniper 0.7.4 → 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: 6da9bb07db2daaa3ca21d2fef18b0a200ff15597495dece758e0e509d10c13ad
4
- data.tar.gz: 86e802113118c172748e2187e09db4c95c080f393dc083932a65386d19c82f3c
3
+ metadata.gz: b7571220250289d5866fa3074789e166f5000cde3b93a0174889e24d1a8ac335
4
+ data.tar.gz: 276162fb0254fc498b4ad562ba3377a1d0d2ba84faccd630eb67f084185798b8
5
5
  SHA512:
6
- metadata.gz: 0137dee8f2547f0973d2e513491574e624f55a605993ca8e6211e1c84c77341fe840d45704903e25eaeacb6e043b1846556f2f124ee08863c591357e34c37ecb
7
- data.tar.gz: ddd016a7cf4cc47d68e9e0f89283d874ea563abab20cc6f2e822d67658694bac3782d3cef0ef4f2a1976bfbf4b2a8652050ff87c9b9d13c18864a5c26a169482
6
+ metadata.gz: 3007c4a588b1024c1553cfa22f9d606628d335578438c4d78c927fb3fa4fe99fad50dcac50da6a184998934a913c218d40a51d6b54fcaaf575dc6b3df7e8ffa8
7
+ data.tar.gz: 8c52911377b95ef3e51a2e36c4e7b0cc9eaaf3ee9d3877af16f3fdb869e5929904d65bda70344ba049037f6a1cbc138bfd119b18e0d2785a864fd0819a177e7d
data/.env.example CHANGED
@@ -1,16 +1,20 @@
1
- # Example .env file for Windows testing
2
- # Copy this to .env and fill in your actual values
1
+ # Example environment configuration for train-juniper
2
+ # Copy this file to .env and update with your values
3
3
 
4
- # Target Juniper device (behind bastion)
5
- JUNIPER_HOST=192.168.1.100
4
+ # Direct connection to Juniper device
5
+ JUNIPER_HOST=192.168.1.1
6
+ JUNIPER_PORT=22
6
7
  JUNIPER_USER=admin
7
- JUNIPER_PASSWORD=device_password
8
+ JUNIPER_PASSWORD=your-password-here
8
9
 
9
- # Bastion/Jump host
10
- BASTION_HOST=bastion.example.com
11
- BASTION_USER=jumpuser
12
- BASTION_PASSWORD=bastion_password
10
+ # Optional: Connection timeout in seconds
11
+ CONNECTION_TIMEOUT=60
13
12
 
14
- # Optional: Custom ports
15
- # JUNIPER_PORT=22
16
- # BASTION_PORT=22
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
19
+
20
+ # Note: If JUNIPER_BASTION_PASSWORD is not set, it will use JUNIPER_PASSWORD
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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
+
8
26
  ## [0.7.4] - 2025-06-24
9
27
 
10
28
  ### Added
@@ -18,6 +36,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
18
36
  - Add host key acceptance instructions and InSpec testing examples
19
37
  - Improve Windows documentation and standardize on inspec shell
20
38
 
39
+ ### Fixed
40
+
41
+ - Clean up ENV in Windows plink test to prevent CI failures
42
+ - Remove trailing whitespace to pass linting
43
+
21
44
  ### Miscellaneous Tasks
22
45
 
23
46
  - Fix linting issues in Windows test script
@@ -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
@@ -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
@@ -30,7 +30,7 @@ module TrainPlugins
30
30
  parts << '-batch' # Non-interactive mode
31
31
  parts << '-ssh' # Force SSH protocol (not telnet)
32
32
  parts << '-pw'
33
- parts << (password.include?(' ') ? "\"#{password}\"" : password)
33
+ parts << Shellwords.escape(password)
34
34
 
35
35
  if port && port != 22
36
36
  parts << '-P'
@@ -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.4'
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.4
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-24 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