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.
- checksums.yaml +7 -0
- data/.expeditor/buildkite/coverage.sh +46 -0
- data/.expeditor/buildkite/run_linux_tests.sh +16 -0
- data/.expeditor/config.yml +61 -0
- data/.expeditor/coverage.pipeline.yml +19 -0
- data/.expeditor/update_version.sh +12 -0
- data/.expeditor/verify.pipeline.yml +44 -0
- data/.rspec +4 -0
- data/.rubocop.yml +57 -0
- data/CHANGELOG.md +158 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/CONTRIBUTING.md +161 -0
- data/DEVELOPMENT.md +315 -0
- data/Gemfile +23 -0
- data/LICENSE.md +9 -0
- data/NOTICE.md +9 -0
- data/README.md +237 -0
- data/Rakefile +37 -0
- data/SECURITY.md +100 -0
- data/VERSION +1 -0
- data/cliff.toml +80 -0
- data/docs/README.md +1 -0
- data/lib/train-k8s-container/ansi_sanitizer.rb +31 -0
- data/lib/train-k8s-container/connection.rb +102 -0
- data/lib/train-k8s-container/errors.rb +22 -0
- data/lib/train-k8s-container/kubectl_command_builder.rb +87 -0
- data/lib/train-k8s-container/kubectl_exec_client.rb +176 -0
- data/lib/train-k8s-container/kubernetes_name_validator.rb +44 -0
- data/lib/train-k8s-container/platform.rb +93 -0
- data/lib/train-k8s-container/pty_session.rb +156 -0
- data/lib/train-k8s-container/result_processor.rb +94 -0
- data/lib/train-k8s-container/retry_handler.rb +35 -0
- data/lib/train-k8s-container/session_manager.rb +95 -0
- data/lib/train-k8s-container/shell_detector.rb +198 -0
- data/lib/train-k8s-container/transport.rb +30 -0
- data/lib/train-k8s-container/version.rb +7 -0
- data/lib/train-k8s-container.rb +12 -0
- data/sonar-project.properties +17 -0
- data/train-k8s-container.gemspec +49 -0
- 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
|