train-k8s-container-mitre 2.0.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.expeditor/buildkite/coverage.sh +46 -0
  3. data/.expeditor/buildkite/run_linux_tests.sh +16 -0
  4. data/.expeditor/config.yml +61 -0
  5. data/.expeditor/coverage.pipeline.yml +19 -0
  6. data/.expeditor/update_version.sh +12 -0
  7. data/.expeditor/verify.pipeline.yml +44 -0
  8. data/.rspec +4 -0
  9. data/.rubocop.yml +57 -0
  10. data/CHANGELOG.md +158 -0
  11. data/CODE_OF_CONDUCT.md +13 -0
  12. data/CONTRIBUTING.md +161 -0
  13. data/DEVELOPMENT.md +315 -0
  14. data/Gemfile +23 -0
  15. data/LICENSE.md +9 -0
  16. data/NOTICE.md +9 -0
  17. data/README.md +237 -0
  18. data/Rakefile +37 -0
  19. data/SECURITY.md +100 -0
  20. data/VERSION +1 -0
  21. data/cliff.toml +80 -0
  22. data/docs/README.md +1 -0
  23. data/lib/train-k8s-container/ansi_sanitizer.rb +31 -0
  24. data/lib/train-k8s-container/connection.rb +102 -0
  25. data/lib/train-k8s-container/errors.rb +22 -0
  26. data/lib/train-k8s-container/kubectl_command_builder.rb +87 -0
  27. data/lib/train-k8s-container/kubectl_exec_client.rb +176 -0
  28. data/lib/train-k8s-container/kubernetes_name_validator.rb +44 -0
  29. data/lib/train-k8s-container/platform.rb +93 -0
  30. data/lib/train-k8s-container/pty_session.rb +156 -0
  31. data/lib/train-k8s-container/result_processor.rb +94 -0
  32. data/lib/train-k8s-container/retry_handler.rb +35 -0
  33. data/lib/train-k8s-container/session_manager.rb +95 -0
  34. data/lib/train-k8s-container/shell_detector.rb +198 -0
  35. data/lib/train-k8s-container/transport.rb +30 -0
  36. data/lib/train-k8s-container/version.rb +7 -0
  37. data/lib/train-k8s-container.rb +12 -0
  38. data/sonar-project.properties +17 -0
  39. data/train-k8s-container.gemspec +49 -0
  40. metadata +107 -0
data/SECURITY.md ADDED
@@ -0,0 +1,100 @@
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-k8s-container 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-k8s-container/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 (critical: 7-14 days, high: 14-30 days)
26
+
27
+ ## Supported Versions
28
+
29
+ | Version | Supported |
30
+ |---------|-----------|
31
+ | 2.x.x | Yes |
32
+ | < 2.0 | No |
33
+
34
+ ## Security Best Practices
35
+
36
+ ### For Users
37
+
38
+ - **Keep Updated**: Use the latest version of the plugin
39
+ - **Secure Credentials**: Never commit kubeconfig files to version control
40
+ - **Use RBAC**: Configure minimal Kubernetes RBAC permissions for scanner service accounts
41
+ - **Network Security**: Use network policies to restrict pod-to-pod communication
42
+
43
+ ### For Contributors
44
+
45
+ - **Dependency Scanning**: Run `bundle audit` before submitting PRs
46
+ - **Credential Handling**: Never log or expose credentials in code
47
+ - **Input Validation**: Sanitize all user inputs
48
+ - **Test Security**: Include security tests for new features
49
+
50
+ ## Security Testing
51
+
52
+ The plugin includes comprehensive security testing:
53
+
54
+ ```bash
55
+ # Check for vulnerable dependencies
56
+ bundle exec bundle-audit check --update
57
+
58
+ # Run security workflow locally
59
+ bundle exec rake security
60
+ ```
61
+
62
+ ## Security Measures
63
+
64
+ This project implements comprehensive automated security scanning:
65
+
66
+ ### Secret Scanning
67
+ - **Tool**: TruffleHog OSS
68
+ - **Frequency**: Every push, pull request, and weekly
69
+ - **Coverage**: 800+ secret types (API keys, tokens, credentials)
70
+
71
+ ### Vulnerability Scanning
72
+ - **Tool**: bundler-audit
73
+ - **Frequency**: Every push, pull request, and weekly
74
+ - **Database**: Ruby Advisory Database (continuously updated)
75
+
76
+ ### Software Bill of Materials (SBOM)
77
+ - **Tool**: CycloneDX Ruby
78
+ - **Format**: JSON
79
+ - **Retention**: 90 days in GitHub artifacts
80
+ - **Standard**: OWASP CycloneDX specification
81
+
82
+ ## Known Security Considerations
83
+
84
+ ### kubectl Execution
85
+ This plugin executes commands via `kubectl exec`. Security considerations:
86
+
87
+ - **Command injection**: Commands are sanitized with `Shellwords.escape`
88
+ - **ANSI sequences**: Output is sanitized to prevent terminal escape attacks (CVE-2021-25743)
89
+ - **Credentials**: Uses kubeconfig authentication (same security as kubectl)
90
+ - **RFC 1123 validation**: Pod and container names are validated
91
+
92
+ ### Container Access
93
+ - Requires existing kubectl access to target namespace/pod
94
+ - Does not bypass Kubernetes RBAC
95
+ - Runs commands with container's default user permissions
96
+
97
+ ## Contact
98
+
99
+ - **Security issues**: [saf-security@mitre.org](mailto:saf-security@mitre.org)
100
+ - **General questions**: [saf@mitre.org](mailto:saf@mitre.org) or open a GitHub issue
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 2.0.0
data/cliff.toml ADDED
@@ -0,0 +1,80 @@
1
+ # git-cliff configuration for train-k8s-container
2
+ # See: https://git-cliff.org/docs/configuration
3
+
4
+ [changelog]
5
+ # changelog header
6
+ header = """
7
+ # Changelog
8
+
9
+ All notable changes to this project will be documented in this file.
10
+
11
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
12
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
13
+
14
+ """
15
+ # template for the changelog body
16
+ body = """
17
+ {% if version %}\
18
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
19
+ {% else %}\
20
+ ## [Unreleased]
21
+ {% endif %}\
22
+ {% for group, commits in commits | group_by(attribute="group") %}
23
+ ### {{ group | striptags | trim | upper_first }}
24
+ {% for commit in commits %}
25
+ - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\
26
+ {% endfor %}
27
+ {% endfor %}\n
28
+ """
29
+ # remove the leading and trailing whitespace from the template
30
+ trim = true
31
+ # changelog footer
32
+ footer = """
33
+ <!-- generated by git-cliff -->
34
+ """
35
+
36
+ [git]
37
+ # parse the commits based on https://www.conventionalcommits.org
38
+ conventional_commits = true
39
+ # filter out the commits that are not conventional
40
+ filter_unconventional = false
41
+ # process each line of a commit as an individual commit
42
+ split_commits = false
43
+ # regex for preprocessing the commit messages
44
+ commit_preprocessors = [
45
+ # Extract issue numbers from commit messages
46
+ { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/mitre/train-k8s-container/issues/${2}))"},
47
+ ]
48
+ # regex for parsing and grouping commits
49
+ commit_parsers = [
50
+ { message = "^feat", group = "Added" },
51
+ { message = "^fix", group = "Fixed" },
52
+ { message = "^doc", group = "Documentation" },
53
+ { message = "^perf", group = "Performance" },
54
+ { message = "^refactor", group = "Refactor" },
55
+ { message = "^style", group = "Styling" },
56
+ { message = "^test", group = "Testing" },
57
+ { message = "^chore\\(release\\): prepare for", skip = true },
58
+ { message = "^chore\\(deps\\)", skip = true },
59
+ { message = "^chore\\(pr\\)", skip = true },
60
+ { message = "^chore\\(pull\\)", skip = true },
61
+ { message = "^chore|^ci", group = "Miscellaneous Tasks" },
62
+ { body = ".*security", group = "Security" },
63
+ { message = "^revert", group = "Revert" },
64
+ ]
65
+ # protect breaking changes from being skipped due to matching a skipping commit_parser
66
+ protect_breaking_commits = false
67
+ # filter out the commits that are not matched by commit parsers
68
+ filter_commits = false
69
+ # glob pattern for matching git tags
70
+ tag_pattern = "v[0-9]*"
71
+ # regex for skipping tags
72
+ skip_tags = ""
73
+ # regex for ignoring tags
74
+ ignore_tags = ""
75
+ # sort the tags topologically
76
+ topo_order = false
77
+ # sort the commits inside sections by oldest/newest order
78
+ sort_commits = "oldest"
79
+ # limit the number of commits included in the changelog.
80
+ # limit_commits = 42
data/docs/README.md ADDED
@@ -0,0 +1 @@
1
+ # Docs for train-k8s-container
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module K8sContainer
5
+ # ANSI sequence sanitization module
6
+ # Removes ANSI escape sequences and normalizes line endings
7
+ # Addresses CVE-2021-25743 (terminal escape sequence injection)
8
+ module AnsiSanitizer
9
+ # Pre-compiled regexes for performance (frozen constants)
10
+ CSI_REGEX = /\e\[([;\d]+)?[A-Za-z]/
11
+ OSC_REGEX = /\e\][^\a]*\a/
12
+ CURSOR_REGEX = /\e\[A|\e\[C|\e\[K/
13
+ LINE_ENDING_REGEX = /\r\n?/
14
+
15
+ # Remove ANSI escape sequences and normalize line endings
16
+ # @param text [String] The text to sanitize
17
+ # @return [String] Sanitized text with ANSI sequences removed
18
+ def self.sanitize(text)
19
+ return '' if text.nil? || text.empty?
20
+
21
+ # Use single string mutation instead of chaining to reduce allocations
22
+ result = text.dup
23
+ result.gsub!(CSI_REGEX, '') # CSI sequences (colors, cursor movement)
24
+ result.gsub!(OSC_REGEX, '') # OSC sequences (terminal title, etc)
25
+ result.gsub!(CURSOR_REGEX, '') # Cursor movement (up, forward, erase)
26
+ result.gsub!(LINE_ENDING_REGEX, "\n") # Normalize all line endings
27
+ result
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'train'
4
+ require 'train/plugins'
5
+ require 'train/file/remote/linux'
6
+ require 'train/file/remote/windows'
7
+ require_relative 'platform'
8
+ require_relative 'kubernetes_name_validator'
9
+
10
+ module TrainPlugins
11
+ module K8sContainer
12
+ # Connection class for Kubernetes container transport
13
+ # Executes commands inside containers via kubectl exec
14
+ class Connection < Train::Plugins::Transport::BaseConnection
15
+ include TrainPlugins::K8sContainer::Platform
16
+
17
+ # URI format: k8s-container://<namespace>/<pod>/<container_name>
18
+ # @example k8s-container://default/shell-demo/nginx
19
+
20
+ def initialize(options)
21
+ super
22
+
23
+ # Parse URI path format (InSpec converts k8s-container://target to path="//target"):
24
+ # - k8s-container://pod/container → path="//pod/container" (default namespace)
25
+ # - k8s-container://namespace/pod/container → path="//namespace/pod/container"
26
+ path_parts = options[:path]&.split('/')&.reject(&:empty?)
27
+
28
+ if path_parts&.length == 2
29
+ # Format: //pod/container (default namespace)
30
+ @namespace = options[:namespace] || TrainPlugins::K8sContainer::KubectlExecClient::DEFAULT_NAMESPACE
31
+ @pod = options[:pod] || path_parts.first
32
+ @container_name = options[:container_name] || path_parts[1]
33
+ elsif path_parts&.length == 3
34
+ # Format: //namespace/pod/container
35
+ @namespace = options[:namespace] || path_parts.first
36
+ @pod = options[:pod] || path_parts[1]
37
+ @container_name = options[:container_name] || path_parts[2]
38
+ else
39
+ # No valid path - must use explicit options
40
+ @namespace = options[:namespace] || TrainPlugins::K8sContainer::KubectlExecClient::DEFAULT_NAMESPACE
41
+ @pod = options[:pod]
42
+ @container_name = options[:container_name]
43
+ end
44
+
45
+ validate_parameters
46
+ end
47
+
48
+ def uri
49
+ "k8s-container://#{@namespace}/#{@pod}/#{@container_name}"
50
+ end
51
+
52
+ # Delegate to kubectl_client for consistent identifier across all components
53
+ def unique_identifier
54
+ kubectl_client.unique_identifier
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :pod, :container_name, :namespace
60
+
61
+ def kubectl_client
62
+ @kubectl_client ||= KubectlExecClient.new(
63
+ pod:,
64
+ namespace:,
65
+ container_name:
66
+ )
67
+ end
68
+
69
+ def run_command_via_connection(cmd, &)
70
+ kubectl_client.execute(cmd)
71
+ end
72
+
73
+ def validate_parameters
74
+ raise ArgumentError, 'Missing Parameter `pod`' unless pod
75
+ raise ArgumentError, 'Missing Parameter `container_name`' unless container_name
76
+
77
+ # Validate Kubernetes resource names (RFC 1123 compliance, injection prevention)
78
+ KubernetesNameValidator.validate!(pod, resource_type: 'pod')
79
+ KubernetesNameValidator.validate!(namespace, resource_type: 'namespace')
80
+ KubernetesNameValidator.validate!(container_name, resource_type: 'container')
81
+ end
82
+
83
+ def file_via_connection(path, *_args)
84
+ # Basic path traversal prevention
85
+ raise ArgumentError, 'File path cannot be nil' if path.nil?
86
+ raise ArgumentError, 'File path cannot be empty' if path.empty?
87
+
88
+ # Detect container OS to use appropriate file handler
89
+ detect_shell # Triggers OS detection in ShellDetector
90
+ container_os = @shell_detector&.container_os || :unknown
91
+
92
+ case container_os
93
+ when :windows
94
+ ::Train::File::Remote::Windows.new(self, path)
95
+ else
96
+ # Default to Linux for unix and unknown (distroless still uses Unix paths)
97
+ ::Train::File::Remote::Linux.new(self, path)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'train'
4
+
5
+ module TrainPlugins
6
+ module K8sContainer
7
+ # Base error class for k8s-container transport
8
+ class K8sContainerError < Train::TransportError; end
9
+
10
+ # kubectl binary not found in PATH
11
+ class KubectlNotFoundError < K8sContainerError; end
12
+
13
+ # Container not found in specified pod
14
+ class ContainerNotFoundError < K8sContainerError; end
15
+
16
+ # Pod not found in specified namespace
17
+ class PodNotFoundError < K8sContainerError; end
18
+
19
+ # Container has no shell (distroless) and command requires one
20
+ class ShellNotAvailableError < K8sContainerError; end
21
+ end
22
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module TrainPlugins
6
+ module K8sContainer
7
+ # Builds kubectl exec command strings with proper escaping
8
+ # Consolidates all command building logic to eliminate duplication
9
+ class KubectlCommandBuilder
10
+ attr_reader :kubectl_path, :pod, :namespace, :container_name
11
+
12
+ def initialize(kubectl_path:, pod:, namespace:, container_name:)
13
+ @kubectl_path = kubectl_path
14
+ @pod = pod
15
+ @namespace = namespace
16
+ @container_name = container_name
17
+ end
18
+
19
+ # Base kubectl exec command components (used by all builders)
20
+ def base_command
21
+ [
22
+ @kubectl_path, 'exec', '--stdin',
23
+ @pod, '-n', @namespace, '-c', @container_name,
24
+ ]
25
+ end
26
+
27
+ # Build command for Unix shell execution
28
+ # @param shell_path [String] Path to shell (e.g., '/bin/bash')
29
+ # @param command [String] Command to execute
30
+ # @return [String] Complete kubectl command
31
+ def with_shell(shell_path, command)
32
+ [
33
+ *base_command,
34
+ '--', shell_path, '-c', Shellwords.escape(command),
35
+ ].join(' ')
36
+ end
37
+
38
+ # Build command for Windows shell execution
39
+ # @param shell_path [String] Shell name (e.g., 'cmd.exe', 'powershell.exe')
40
+ # @param command [String] Command to execute
41
+ # @return [String] Complete kubectl command
42
+ def with_windows_shell(shell_path, command)
43
+ flag = shell_flag_for(shell_path)
44
+ [
45
+ *base_command,
46
+ '--', shell_path, flag, Shellwords.escape(command),
47
+ ].join(' ')
48
+ end
49
+
50
+ # Build command for direct binary execution (no shell)
51
+ # @param command [String] Command to execute (will be split on spaces)
52
+ # @return [String] Complete kubectl command
53
+ def direct_binary(command)
54
+ [
55
+ *base_command,
56
+ '--',
57
+ ].concat(command.split).join(' ')
58
+ end
59
+
60
+ # Build command with hardcoded /bin/sh (for raw execution)
61
+ # @param command [String] Command to execute
62
+ # @return [String] Complete kubectl command
63
+ def with_raw_shell(command)
64
+ [
65
+ *base_command,
66
+ '--', '/bin/sh', '-c', Shellwords.escape(command),
67
+ ].join(' ')
68
+ end
69
+
70
+ private
71
+
72
+ # Get the appropriate shell flag for Windows shells
73
+ # @param shell_path [String] Shell path
74
+ # @return [String] Shell flag ('/c' for cmd, '-Command' for PowerShell)
75
+ def shell_flag_for(shell_path)
76
+ case shell_path
77
+ when 'cmd.exe'
78
+ '/c'
79
+ when 'powershell.exe', 'pwsh.exe'
80
+ '-Command'
81
+ else
82
+ '-c' # Fallback to Unix-style
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mixlib/shellout' unless defined?(Mixlib::ShellOut)
4
+ require 'shellwords'
5
+ require 'logger'
6
+ require 'train/options'
7
+ require 'train/extras'
8
+ require_relative 'retry_handler'
9
+ require_relative 'session_manager'
10
+ require_relative 'ansi_sanitizer'
11
+ require_relative 'kubectl_command_builder'
12
+ require_relative 'result_processor'
13
+
14
+ module TrainPlugins
15
+ module K8sContainer
16
+ # Kubectl exec client for executing commands in Kubernetes containers
17
+ # Supports both one-off execution (Mixlib::ShellOut) and persistent sessions (PTY)
18
+ class KubectlExecClient
19
+ attr_reader :pod, :container_name, :namespace
20
+
21
+ DEFAULT_NAMESPACE = 'default'
22
+ DEFAULT_TIMEOUT = 60
23
+ SHELL_DETECTION_TIMEOUT = 5
24
+
25
+ def initialize(pod:, namespace: nil, container_name: nil, kubectl_path: 'kubectl', timeout: DEFAULT_TIMEOUT, logger: nil, use_pty: nil)
26
+ @pod = pod
27
+ @container_name = container_name
28
+ @namespace = namespace
29
+ @kubectl_path = kubectl_path
30
+ @timeout = timeout
31
+ @logger = logger || default_logger
32
+ @shell_detector = nil # Will be created lazily
33
+ # Default to enabled (opt-out via use_pty: false or TRAIN_K8S_SESSION_MODE=false)
34
+ @use_pty = use_pty.nil? ? (ENV['TRAIN_K8S_SESSION_MODE'] != 'false') : use_pty
35
+ @pty_fallback_disabled = false
36
+ @command_builder = KubectlCommandBuilder.new(
37
+ kubectl_path: @kubectl_path,
38
+ pod: @pod,
39
+ namespace: @namespace,
40
+ container_name: @container_name
41
+ )
42
+ end
43
+
44
+ def execute(command, opts = {})
45
+ @logger.debug("Executing command in #{@namespace}/#{@pod}/#{@container_name}: #{command}")
46
+
47
+ if @use_pty && pty_available? && !@pty_fallback_disabled
48
+ execute_via_pty(command, opts)
49
+ else
50
+ execute_via_shellout(command, opts)
51
+ end
52
+ rescue Errno::ENOENT => e
53
+ @logger.error("kubectl not found at '#{@kubectl_path}': #{e.message}")
54
+ raise KubectlNotFoundError, "kubectl not found at '#{@kubectl_path}'"
55
+ rescue Mixlib::ShellOut::CommandTimeout
56
+ @logger.error("Command timed out after #{opts[:timeout] || @timeout}s: #{command}")
57
+ raise Train::CommandTimeoutReached, "Command timed out: #{command}"
58
+ end
59
+
60
+ # Raw execution for shell detection (uses /bin/sh directly)
61
+ def execute_raw(command)
62
+ instruction = @command_builder.with_raw_shell(command)
63
+ run_shellout(instruction, timeout: SHELL_DETECTION_TIMEOUT)
64
+ rescue Errno::ENOENT => _e
65
+ Train::Extras::CommandResult.new('', '', 1)
66
+ end
67
+
68
+ # Unique identifier for this connection (namespace/pod/container)
69
+ # Used for session management and logging
70
+ def unique_identifier
71
+ "#{@namespace}/#{@pod}/#{@container_name}"
72
+ end
73
+
74
+ private
75
+
76
+ def pty_available?
77
+ # PTY only works on Unix-like operating systems
78
+ !RUBY_PLATFORM.match?(/windows|mswin|msys|mingw|cygwin/)
79
+ end
80
+
81
+ def execute_via_pty(command, opts)
82
+ @logger.debug('Using PTY session for execution')
83
+ shell = detect_shell
84
+ raise ShellNotAvailableError, 'No shell available for PTY mode' unless shell
85
+
86
+ session = create_pty_session(shell, opts)
87
+ session.execute(command)
88
+ rescue PtySession::SessionClosedError => e
89
+ # Try reconnecting once
90
+ @logger.warn("PTY session closed: #{e.message}, attempting reconnect")
91
+ SessionManager.instance.cleanup_session(session_key)
92
+ session = create_pty_session(shell, opts)
93
+ session.execute(command)
94
+ rescue PtySession::PtyError, ShellNotAvailableError => e
95
+ @logger.error("PTY execution failed: #{e.message}, falling back to one-off execution")
96
+ @pty_fallback_disabled = true
97
+ execute_via_shellout(command, opts)
98
+ end
99
+
100
+ def execute_via_shellout(command, opts)
101
+ @logger.debug('Using one-off execution (Mixlib::ShellOut)')
102
+
103
+ RetryHandler.with_retry(max_retries: opts[:max_retries] || 3, logger: @logger) do
104
+ shell = detect_shell
105
+
106
+ result = if shell
107
+ execute_with_shell(shell, command, opts)
108
+ else
109
+ execute_without_shell(command, opts)
110
+ end
111
+
112
+ ResultProcessor.process(result, command, @logger)
113
+ end
114
+ end
115
+
116
+ # Session key reuses unique_identifier for consistency
117
+ def session_key
118
+ unique_identifier
119
+ end
120
+
121
+ # Execute a command via Mixlib::ShellOut and return Train::Extras::CommandResult
122
+ # Consolidates the repeated shellout pattern for DRY compliance
123
+ # @param instruction [String] The full kubectl command to execute
124
+ # @param timeout [Integer] Command timeout in seconds
125
+ # @return [Train::Extras::CommandResult] The command result
126
+ def run_shellout(instruction, timeout:)
127
+ shell = Mixlib::ShellOut.new(instruction, timeout: timeout)
128
+ res = shell.run_command
129
+ Train::Extras::CommandResult.new(res.stdout, res.stderr, res.exitstatus)
130
+ end
131
+
132
+ # Lazy-load and cache shell detector (caching at instance level)
133
+ # ShellDetector also caches detection result internally for efficiency
134
+ # This double-caching prevents both repeated object creation and detection attempts
135
+ def detect_shell
136
+ require_relative 'shell_detector' unless defined?(ShellDetector)
137
+ @shell_detector ||= ShellDetector.new(self)
138
+ @shell_detector.detect
139
+ end
140
+
141
+ def execute_with_shell(shell_path, command, opts)
142
+ instruction = if ShellDetector.windows_shell?(shell_path)
143
+ @command_builder.with_windows_shell(shell_path, command)
144
+ else
145
+ @command_builder.with_shell(shell_path, command)
146
+ end
147
+ run_shellout(instruction, timeout: opts[:timeout] || @timeout)
148
+ end
149
+
150
+ def create_pty_session(shell, opts)
151
+ SessionManager.instance.get_session(
152
+ session_key,
153
+ kubectl_cmd: @command_builder.base_command.join(' '),
154
+ shell: shell,
155
+ timeout: opts[:timeout] || @timeout,
156
+ logger: @logger
157
+ )
158
+ end
159
+
160
+ def execute_without_shell(command, opts)
161
+ # For distroless - can only execute simple binaries
162
+ if command.match?(/[|&;<>()$`\\"]/)
163
+ raise ShellNotAvailableError,
164
+ "Container has no shell - cannot execute complex command with operators: #{command}"
165
+ end
166
+
167
+ instruction = @command_builder.direct_binary(command)
168
+ run_shellout(instruction, timeout: opts[:timeout] || @timeout)
169
+ end
170
+
171
+ def default_logger
172
+ Logger.new($stdout, level: ENV['TRAIN_K8S_LOG_LEVEL'] || Logger::WARN)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module K8sContainer
5
+ # Validates Kubernetes resource names to prevent injection attacks
6
+ # Follows RFC 1123 DNS subdomain naming standard
7
+ module KubernetesNameValidator
8
+ # RFC 1123 DNS subdomain name:
9
+ # - Lowercase alphanumeric characters, '-' or '.'
10
+ # - Must start and end with alphanumeric
11
+ # - Maximum 253 characters
12
+ VALID_NAME_REGEX = /\A[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)?\z/
13
+ MAX_NAME_LENGTH = 253
14
+
15
+ # Validate a Kubernetes resource name (pod, namespace, container)
16
+ # @param name [String] The name to validate
17
+ # @param resource_type [String] Type of resource (for error messages)
18
+ # @raise [ArgumentError] If name is invalid
19
+ # @return [String] The validated name
20
+ def self.validate!(name, resource_type: 'resource')
21
+ raise ArgumentError, "#{resource_type} name cannot be nil" if name.nil?
22
+ raise ArgumentError, "#{resource_type} name cannot be empty" if name.empty?
23
+
24
+ raise ArgumentError, "#{resource_type} name too long (max #{MAX_NAME_LENGTH} chars): #{name}" if name.length > MAX_NAME_LENGTH
25
+
26
+ unless VALID_NAME_REGEX.match?(name)
27
+ raise ArgumentError, "Invalid #{resource_type} name '#{name}': must be RFC 1123 DNS subdomain " \
28
+ "(lowercase alphanumeric, '-' or '.', start/end with alphanumeric)"
29
+ end
30
+
31
+ name
32
+ end
33
+
34
+ # Check if name is valid without raising
35
+ # @param name [String] The name to check
36
+ # @return [Boolean] True if valid
37
+ def self.valid?(name)
38
+ return false if name.nil? || name.empty? || name.length > MAX_NAME_LENGTH
39
+
40
+ VALID_NAME_REGEX.match?(name)
41
+ end
42
+ end
43
+ end
44
+ end