sxn 0.2.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/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
- data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
- data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
- data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
- data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
- data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
- data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
- data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
- data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
- data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
- data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
- data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
- data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
- data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
- data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
- data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
- data/.rspec +4 -0
- data/.rubocop.yml +121 -0
- data/.simplecov +51 -0
- data/CHANGELOG.md +49 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +329 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +54 -0
- data/Steepfile +50 -0
- data/bin/sxn +6 -0
- data/lib/sxn/CLI.rb +275 -0
- data/lib/sxn/commands/init.rb +137 -0
- data/lib/sxn/commands/projects.rb +350 -0
- data/lib/sxn/commands/rules.rb +435 -0
- data/lib/sxn/commands/sessions.rb +300 -0
- data/lib/sxn/commands/worktrees.rb +416 -0
- data/lib/sxn/commands.rb +13 -0
- data/lib/sxn/config/config_cache.rb +295 -0
- data/lib/sxn/config/config_discovery.rb +242 -0
- data/lib/sxn/config/config_validator.rb +562 -0
- data/lib/sxn/config.rb +259 -0
- data/lib/sxn/core/config_manager.rb +290 -0
- data/lib/sxn/core/project_manager.rb +307 -0
- data/lib/sxn/core/rules_manager.rb +306 -0
- data/lib/sxn/core/session_manager.rb +336 -0
- data/lib/sxn/core/worktree_manager.rb +281 -0
- data/lib/sxn/core.rb +13 -0
- data/lib/sxn/database/errors.rb +29 -0
- data/lib/sxn/database/session_database.rb +691 -0
- data/lib/sxn/database.rb +24 -0
- data/lib/sxn/errors.rb +76 -0
- data/lib/sxn/rules/base_rule.rb +367 -0
- data/lib/sxn/rules/copy_files_rule.rb +346 -0
- data/lib/sxn/rules/errors.rb +28 -0
- data/lib/sxn/rules/project_detector.rb +871 -0
- data/lib/sxn/rules/rules_engine.rb +485 -0
- data/lib/sxn/rules/setup_commands_rule.rb +307 -0
- data/lib/sxn/rules/template_rule.rb +262 -0
- data/lib/sxn/rules.rb +148 -0
- data/lib/sxn/runtime_validations.rb +96 -0
- data/lib/sxn/security/secure_command_executor.rb +364 -0
- data/lib/sxn/security/secure_file_copier.rb +478 -0
- data/lib/sxn/security/secure_path_validator.rb +258 -0
- data/lib/sxn/security.rb +15 -0
- data/lib/sxn/templates/common/gitignore.liquid +99 -0
- data/lib/sxn/templates/common/session-info.md.liquid +58 -0
- data/lib/sxn/templates/errors.rb +36 -0
- data/lib/sxn/templates/javascript/README.md.liquid +59 -0
- data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
- data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
- data/lib/sxn/templates/rails/database.yml.liquid +31 -0
- data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
- data/lib/sxn/templates/template_engine.rb +346 -0
- data/lib/sxn/templates/template_processor.rb +279 -0
- data/lib/sxn/templates/template_security.rb +410 -0
- data/lib/sxn/templates/template_variables.rb +713 -0
- data/lib/sxn/templates.rb +28 -0
- data/lib/sxn/ui/output.rb +103 -0
- data/lib/sxn/ui/progress_bar.rb +91 -0
- data/lib/sxn/ui/prompt.rb +116 -0
- data/lib/sxn/ui/table.rb +183 -0
- data/lib/sxn/ui.rb +12 -0
- data/lib/sxn/version.rb +5 -0
- data/lib/sxn.rb +63 -0
- data/rbs_collection.lock.yaml +180 -0
- data/rbs_collection.yaml +39 -0
- data/scripts/test.sh +31 -0
- data/sig/external/liquid.rbs +116 -0
- data/sig/external/thor.rbs +99 -0
- data/sig/external/tty.rbs +71 -0
- data/sig/sxn/cli.rbs +46 -0
- data/sig/sxn/commands/init.rbs +38 -0
- data/sig/sxn/commands/projects.rbs +72 -0
- data/sig/sxn/commands/rules.rbs +95 -0
- data/sig/sxn/commands/sessions.rbs +62 -0
- data/sig/sxn/commands/worktrees.rbs +82 -0
- data/sig/sxn/commands.rbs +6 -0
- data/sig/sxn/config/config_cache.rbs +67 -0
- data/sig/sxn/config/config_discovery.rbs +64 -0
- data/sig/sxn/config/config_validator.rbs +64 -0
- data/sig/sxn/config.rbs +74 -0
- data/sig/sxn/core/config_manager.rbs +67 -0
- data/sig/sxn/core/project_manager.rbs +52 -0
- data/sig/sxn/core/rules_manager.rbs +54 -0
- data/sig/sxn/core/session_manager.rbs +59 -0
- data/sig/sxn/core/worktree_manager.rbs +50 -0
- data/sig/sxn/core.rbs +87 -0
- data/sig/sxn/database/errors.rbs +37 -0
- data/sig/sxn/database/session_database.rbs +151 -0
- data/sig/sxn/database.rbs +83 -0
- data/sig/sxn/errors.rbs +89 -0
- data/sig/sxn/rules/base_rule.rbs +137 -0
- data/sig/sxn/rules/copy_files_rule.rbs +65 -0
- data/sig/sxn/rules/errors.rbs +33 -0
- data/sig/sxn/rules/project_detector.rbs +115 -0
- data/sig/sxn/rules/rules_engine.rbs +118 -0
- data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
- data/sig/sxn/rules/template_rule.rbs +44 -0
- data/sig/sxn/rules.rbs +287 -0
- data/sig/sxn/runtime_validations.rbs +16 -0
- data/sig/sxn/security/secure_command_executor.rbs +63 -0
- data/sig/sxn/security/secure_file_copier.rbs +79 -0
- data/sig/sxn/security/secure_path_validator.rbs +30 -0
- data/sig/sxn/security.rbs +128 -0
- data/sig/sxn/templates/errors.rbs +43 -0
- data/sig/sxn/templates/template_engine.rbs +50 -0
- data/sig/sxn/templates/template_processor.rbs +44 -0
- data/sig/sxn/templates/template_security.rbs +62 -0
- data/sig/sxn/templates/template_variables.rbs +103 -0
- data/sig/sxn/templates.rbs +104 -0
- data/sig/sxn/ui/output.rbs +50 -0
- data/sig/sxn/ui/progress_bar.rbs +39 -0
- data/sig/sxn/ui/prompt.rbs +38 -0
- data/sig/sxn/ui/table.rbs +43 -0
- data/sig/sxn/ui.rbs +63 -0
- data/sig/sxn/version.rbs +5 -0
- data/sig/sxn.rbs +29 -0
- metadata +635 -0
@@ -0,0 +1,478 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
require "openssl"
|
5
|
+
require "base64"
|
6
|
+
|
7
|
+
module Sxn
|
8
|
+
module Security
|
9
|
+
# SecureFileCopier provides secure file copying operations with strict security controls.
|
10
|
+
# It validates source and destination paths, preserves/enforces file permissions,
|
11
|
+
# supports file encryption using OpenSSL AES-256, and maintains an audit trail.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# copier = SecureFileCopier.new("/path/to/project")
|
15
|
+
# result = copier.copy_file("config/master.key", "session/master.key",
|
16
|
+
# permissions: 0600, encrypt: true)
|
17
|
+
#
|
18
|
+
class SecureFileCopier
|
19
|
+
# File operation result
|
20
|
+
class CopyResult
|
21
|
+
attr_reader :source_path, :destination_path, :operation, :encrypted, :checksum, :duration
|
22
|
+
|
23
|
+
def initialize(source_path, destination_path, operation, encrypted: false, checksum: nil, duration: 0)
|
24
|
+
@source_path = source_path
|
25
|
+
@destination_path = destination_path
|
26
|
+
@operation = operation
|
27
|
+
@encrypted = encrypted
|
28
|
+
@checksum = checksum
|
29
|
+
@duration = duration
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_h
|
33
|
+
{
|
34
|
+
source_path: @source_path,
|
35
|
+
destination_path: @destination_path,
|
36
|
+
operation: @operation,
|
37
|
+
encrypted: @encrypted,
|
38
|
+
checksum: @checksum,
|
39
|
+
duration: @duration
|
40
|
+
}
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Patterns that identify sensitive files requiring special handling
|
45
|
+
SENSITIVE_FILE_PATTERNS = [
|
46
|
+
/master\.key$/,
|
47
|
+
/credentials.*\.key$/,
|
48
|
+
/\.env$/,
|
49
|
+
/\.env\./,
|
50
|
+
/secrets\.yml$/,
|
51
|
+
/\.pem$/,
|
52
|
+
/\.p12$/,
|
53
|
+
/\.jks$/,
|
54
|
+
/\.npmrc$/,
|
55
|
+
/auth_token/i,
|
56
|
+
/api_key/i,
|
57
|
+
/password/i,
|
58
|
+
/secret/i
|
59
|
+
].freeze
|
60
|
+
|
61
|
+
# Default secure permissions for different file types
|
62
|
+
DEFAULT_PERMISSIONS = {
|
63
|
+
sensitive: 0o600, # Owner read/write only
|
64
|
+
config: 0o644, # Owner read/write, group/other read
|
65
|
+
executable: 0o755 # Owner all, group/other read/execute
|
66
|
+
}.freeze
|
67
|
+
|
68
|
+
# Maximum file size for operations (100MB)
|
69
|
+
MAX_FILE_SIZE = 100 * 1024 * 1024
|
70
|
+
|
71
|
+
# @param project_root [String] The absolute path to the project root directory
|
72
|
+
# @param logger [Logger] Optional logger for audit trail
|
73
|
+
def initialize(project_root, logger: nil)
|
74
|
+
@project_root = File.realpath(project_root)
|
75
|
+
@path_validator = SecurePathValidator.new(@project_root)
|
76
|
+
@logger = logger || Sxn.logger
|
77
|
+
@encryption_key = nil
|
78
|
+
rescue Errno::ENOENT
|
79
|
+
raise ArgumentError, "Project root does not exist: #{project_root}"
|
80
|
+
end
|
81
|
+
|
82
|
+
# Copies a file securely with validation and optional encryption
|
83
|
+
#
|
84
|
+
# @param source [String] Source file path (relative to project root)
|
85
|
+
# @param destination [String] Destination file path (relative to project root)
|
86
|
+
# @param permissions [Integer] File permissions to set (e.g., 0600)
|
87
|
+
# @param encrypt [Boolean] Whether to encrypt the file
|
88
|
+
# @param preserve_permissions [Boolean] Whether to preserve source permissions
|
89
|
+
# @param create_directories [Boolean] Whether to create destination directories
|
90
|
+
# @return [CopyResult] The operation result
|
91
|
+
# @raise [SecurityError] if operation violates security policies
|
92
|
+
def copy_file(source, destination, permissions: nil, encrypt: false,
|
93
|
+
preserve_permissions: false, create_directories: true)
|
94
|
+
start_time = Time.now
|
95
|
+
|
96
|
+
raw_source, raw_destination = @path_validator.validate_file_operation(
|
97
|
+
source, destination, allow_creation: true
|
98
|
+
)
|
99
|
+
|
100
|
+
# Normalize paths for consistent behavior in tests and cross-platform compatibility
|
101
|
+
validated_source = normalize_path_for_result(raw_source)
|
102
|
+
validated_destination = normalize_path_for_result(raw_destination)
|
103
|
+
|
104
|
+
validate_file_operation!(raw_source, raw_destination)
|
105
|
+
|
106
|
+
# Determine appropriate permissions (use normalized path for consistent method signatures)
|
107
|
+
target_permissions = determine_permissions(
|
108
|
+
validated_source, permissions, preserve_permissions
|
109
|
+
)
|
110
|
+
|
111
|
+
# Create destination directory if needed (use raw path for actual file operations)
|
112
|
+
create_destination_directory(raw_destination) if create_directories
|
113
|
+
|
114
|
+
# Perform the copy operation (use normalized paths for method signatures)
|
115
|
+
if encrypt
|
116
|
+
copy_with_encryption(validated_source, validated_destination, target_permissions)
|
117
|
+
encrypted = true
|
118
|
+
else
|
119
|
+
copy_without_encryption(validated_source, validated_destination, target_permissions)
|
120
|
+
encrypted = false
|
121
|
+
end
|
122
|
+
|
123
|
+
# Generate checksum for verification (use normalized path for method signature)
|
124
|
+
checksum = generate_checksum(validated_destination)
|
125
|
+
duration = Time.now - start_time
|
126
|
+
|
127
|
+
result = CopyResult.new(
|
128
|
+
normalize_path_for_result(validated_source),
|
129
|
+
normalize_path_for_result(validated_destination),
|
130
|
+
:copy,
|
131
|
+
encrypted: encrypted, checksum: checksum, duration: duration
|
132
|
+
)
|
133
|
+
|
134
|
+
audit_log("FILE_COPY", result)
|
135
|
+
result
|
136
|
+
end
|
137
|
+
|
138
|
+
# Creates a symbolic link securely
|
139
|
+
#
|
140
|
+
# @param source [String] Source file path (relative to project root)
|
141
|
+
# @param destination [String] Link path (relative to project root)
|
142
|
+
# @param force [Boolean] Whether to overwrite existing links
|
143
|
+
# @return [CopyResult] The operation result
|
144
|
+
# @raise [SecurityError] if operation violates security policies
|
145
|
+
def create_symlink(source, destination, force: false)
|
146
|
+
start_time = Time.now
|
147
|
+
|
148
|
+
validated_source, validated_destination = @path_validator.validate_file_operation(
|
149
|
+
source, destination, allow_creation: true
|
150
|
+
)
|
151
|
+
|
152
|
+
validate_file_operation!(validated_source, validated_destination)
|
153
|
+
|
154
|
+
# Remove existing symlink/file if force is true
|
155
|
+
File.unlink(validated_destination) if force && (File.exist?(validated_destination) || File.symlink?(validated_destination))
|
156
|
+
|
157
|
+
# Create the symlink
|
158
|
+
File.symlink(validated_source, validated_destination)
|
159
|
+
|
160
|
+
duration = Time.now - start_time
|
161
|
+
result = CopyResult.new(
|
162
|
+
normalize_path_for_result(validated_source),
|
163
|
+
normalize_path_for_result(validated_destination),
|
164
|
+
:symlink, duration: duration
|
165
|
+
)
|
166
|
+
|
167
|
+
audit_log("SYMLINK_CREATE", result)
|
168
|
+
result
|
169
|
+
end
|
170
|
+
|
171
|
+
# Encrypts a file in place using AES-256-GCM
|
172
|
+
#
|
173
|
+
# @param file_path [String] Path to file to encrypt (relative to project root)
|
174
|
+
# @param key [String] Encryption key (if nil, generates one)
|
175
|
+
# @return [String] Base64-encoded encryption key used
|
176
|
+
# @raise [SecurityError] if encryption fails
|
177
|
+
def encrypt_file(file_path, key: nil)
|
178
|
+
raw_path = @path_validator.validate_path(file_path)
|
179
|
+
validated_path = normalize_path_for_result(raw_path)
|
180
|
+
validate_file_exists!(validated_path)
|
181
|
+
|
182
|
+
encryption_key = key || generate_encryption_key
|
183
|
+
|
184
|
+
# Read original content (use real path for file operations)
|
185
|
+
real_path = denormalize_path_for_operations(validated_path)
|
186
|
+
original_content = File.binread(real_path)
|
187
|
+
|
188
|
+
# Encrypt content
|
189
|
+
encrypted_content = encrypt_content(original_content, encryption_key)
|
190
|
+
|
191
|
+
# Write encrypted content atomically (use real path for file operations)
|
192
|
+
temp_file = "#{real_path}.tmp"
|
193
|
+
File.binwrite(temp_file, encrypted_content)
|
194
|
+
File.rename(temp_file, real_path)
|
195
|
+
|
196
|
+
# Set secure permissions (use real path for file operations)
|
197
|
+
File.chmod(0o600, real_path)
|
198
|
+
|
199
|
+
audit_log("FILE_ENCRYPT", { file_path: validated_path })
|
200
|
+
Base64.strict_encode64(encryption_key)
|
201
|
+
rescue Sxn::PathValidationError => e
|
202
|
+
raise SecurityError, "Path validation failed: #{e.message}"
|
203
|
+
end
|
204
|
+
|
205
|
+
# Decrypts a file in place using AES-256-GCM
|
206
|
+
#
|
207
|
+
# @param file_path [String] Path to file to decrypt (relative to project root)
|
208
|
+
# @param key [String] Base64-encoded encryption key
|
209
|
+
# @return [Boolean] true if decryption successful
|
210
|
+
# @raise [SecurityError] if decryption fails
|
211
|
+
def decrypt_file(file_path, key)
|
212
|
+
raw_path = @path_validator.validate_path(file_path)
|
213
|
+
validated_path = normalize_path_for_result(raw_path)
|
214
|
+
validate_file_exists!(validated_path)
|
215
|
+
|
216
|
+
encryption_key = Base64.strict_decode64(key)
|
217
|
+
|
218
|
+
# Read encrypted content (use real path for file operations)
|
219
|
+
real_path = denormalize_path_for_operations(validated_path)
|
220
|
+
encrypted_content = File.binread(real_path)
|
221
|
+
|
222
|
+
# Decrypt content
|
223
|
+
original_content = decrypt_content(encrypted_content, encryption_key)
|
224
|
+
|
225
|
+
# Write decrypted content atomically (use real path for file operations)
|
226
|
+
temp_file = "#{real_path}.tmp"
|
227
|
+
File.binwrite(temp_file, original_content)
|
228
|
+
File.rename(temp_file, real_path)
|
229
|
+
|
230
|
+
audit_log("FILE_DECRYPT", { file_path: validated_path })
|
231
|
+
true
|
232
|
+
rescue StandardError => e
|
233
|
+
raise SecurityError, "Decryption failed: #{e.message}"
|
234
|
+
end
|
235
|
+
|
236
|
+
# Checks if a file appears to be sensitive based on its path
|
237
|
+
#
|
238
|
+
# @param file_path [String] Path to check
|
239
|
+
# @return [Boolean] true if file appears sensitive
|
240
|
+
def sensitive_file?(file_path)
|
241
|
+
SENSITIVE_FILE_PATTERNS.any? { |pattern| file_path.match?(pattern) }
|
242
|
+
end
|
243
|
+
|
244
|
+
# Validates file permissions are secure
|
245
|
+
#
|
246
|
+
# @param file_path [String] Path to check (relative to project root)
|
247
|
+
# @return [Boolean] true if permissions are secure
|
248
|
+
def secure_permissions?(file_path)
|
249
|
+
validated_path = @path_validator.validate_path(file_path, allow_creation: true)
|
250
|
+
return false unless File.exist?(validated_path)
|
251
|
+
|
252
|
+
stat = File.stat(validated_path)
|
253
|
+
mode = stat.mode & 0o777
|
254
|
+
|
255
|
+
if sensitive_file?(file_path)
|
256
|
+
# Sensitive files should not be readable by group/other
|
257
|
+
mode.nobits?(0o077)
|
258
|
+
else
|
259
|
+
# Non-sensitive files should not be world-writable
|
260
|
+
mode.nobits?(0o002)
|
261
|
+
end
|
262
|
+
rescue Sxn::PathValidationError
|
263
|
+
false
|
264
|
+
end
|
265
|
+
|
266
|
+
private
|
267
|
+
|
268
|
+
# Normalizes a path to remove system-specific symlink resolutions
|
269
|
+
# This helps maintain consistent path formats across different systems
|
270
|
+
def normalize_path_for_result(path)
|
271
|
+
# On macOS, File.realpath resolves /var to /private/var
|
272
|
+
# For consistency in tests and results, we normalize back
|
273
|
+
path.sub(%r{^/private/var/}, "/var/")
|
274
|
+
end
|
275
|
+
|
276
|
+
# Reverses path normalization to get real filesystem paths for operations
|
277
|
+
def denormalize_path_for_operations(path)
|
278
|
+
# Convert normalized paths back to real filesystem paths
|
279
|
+
# On macOS, /var is actually at /private/var
|
280
|
+
if path.start_with?("/var/") && !path.start_with?("/private/var/")
|
281
|
+
path.sub(%r{^/var/}, "/private/var/")
|
282
|
+
else
|
283
|
+
path
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
# Validates file operation security constraints
|
288
|
+
def validate_file_operation!(source_path, destination_path)
|
289
|
+
# Check source file exists and is readable
|
290
|
+
validate_file_exists!(source_path)
|
291
|
+
validate_file_readable!(source_path)
|
292
|
+
|
293
|
+
# Check file size limits
|
294
|
+
file_size = File.size(source_path)
|
295
|
+
raise SecurityError, "File too large for secure copying: #{file_size} bytes" if file_size > MAX_FILE_SIZE
|
296
|
+
|
297
|
+
# Check if source has dangerous permissions
|
298
|
+
Sxn.logger&.warn("Copying world-readable sensitive file: #{source_path}") if File.world_readable?(source_path) && sensitive_file?(source_path)
|
299
|
+
|
300
|
+
# Validate destination path doesn't overwrite critical files
|
301
|
+
return unless File.exist?(destination_path)
|
302
|
+
|
303
|
+
dest_stat = File.stat(destination_path)
|
304
|
+
return unless dest_stat.uid != Process.uid
|
305
|
+
|
306
|
+
raise SecurityError, "Cannot overwrite file owned by different user: #{destination_path}"
|
307
|
+
end
|
308
|
+
|
309
|
+
# Determines appropriate file permissions
|
310
|
+
def determine_permissions(source_path, explicit_permissions, preserve_permissions)
|
311
|
+
return explicit_permissions if explicit_permissions
|
312
|
+
|
313
|
+
if preserve_permissions
|
314
|
+
File.stat(source_path).mode & 0o777
|
315
|
+
elsif sensitive_file?(source_path)
|
316
|
+
DEFAULT_PERMISSIONS[:sensitive]
|
317
|
+
elsif File.executable?(source_path)
|
318
|
+
DEFAULT_PERMISSIONS[:executable]
|
319
|
+
else
|
320
|
+
DEFAULT_PERMISSIONS[:config]
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
# Creates destination directory with secure permissions
|
325
|
+
def create_destination_directory(destination_path)
|
326
|
+
directory = File.dirname(destination_path)
|
327
|
+
return if File.directory?(directory)
|
328
|
+
|
329
|
+
# For absolute paths under project root, convert to relative
|
330
|
+
if directory.start_with?(@project_root)
|
331
|
+
relative_directory = directory.sub("#{@project_root}/", "")
|
332
|
+
# Only validate if it's actually relative (not just the project root itself)
|
333
|
+
@path_validator.validate_path(relative_directory, allow_creation: true) unless relative_directory.empty?
|
334
|
+
end
|
335
|
+
|
336
|
+
# Create directory with secure permissions
|
337
|
+
FileUtils.mkdir_p(directory, mode: 0o755)
|
338
|
+
end
|
339
|
+
|
340
|
+
# Copies file without encryption
|
341
|
+
def copy_without_encryption(source_path, destination_path, permissions)
|
342
|
+
# Resolve paths back to real filesystem paths for actual operations
|
343
|
+
real_source = denormalize_path_for_operations(source_path)
|
344
|
+
real_destination = denormalize_path_for_operations(destination_path)
|
345
|
+
|
346
|
+
# Use atomic copy operation
|
347
|
+
temp_file = "#{real_destination}.tmp"
|
348
|
+
|
349
|
+
begin
|
350
|
+
FileUtils.cp(real_source, temp_file, preserve: false)
|
351
|
+
File.chmod(permissions, temp_file)
|
352
|
+
File.rename(temp_file, real_destination)
|
353
|
+
rescue StandardError => e
|
354
|
+
FileUtils.rm_f(temp_file)
|
355
|
+
raise SecurityError, "File copy failed: #{e.message}"
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
# Copies file with encryption
|
360
|
+
def copy_with_encryption(source_path, destination_path, permissions)
|
361
|
+
# Resolve paths back to real filesystem paths for actual operations
|
362
|
+
real_source = denormalize_path_for_operations(source_path)
|
363
|
+
real_destination = denormalize_path_for_operations(destination_path)
|
364
|
+
|
365
|
+
@encryption_key ||= generate_encryption_key
|
366
|
+
|
367
|
+
# Read and encrypt content
|
368
|
+
original_content = File.binread(real_source)
|
369
|
+
encrypted_content = encrypt_content(original_content, @encryption_key)
|
370
|
+
|
371
|
+
# Write encrypted content atomically
|
372
|
+
temp_file = "#{real_destination}.tmp"
|
373
|
+
|
374
|
+
begin
|
375
|
+
File.binwrite(temp_file, encrypted_content)
|
376
|
+
File.chmod(permissions, temp_file)
|
377
|
+
File.rename(temp_file, real_destination)
|
378
|
+
rescue StandardError => e
|
379
|
+
FileUtils.rm_f(temp_file)
|
380
|
+
raise SecurityError, "Encrypted file copy failed: #{e.message}"
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
# Encrypts content using AES-256-GCM
|
385
|
+
def encrypt_content(content, key)
|
386
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
387
|
+
cipher.encrypt
|
388
|
+
cipher.key = key
|
389
|
+
|
390
|
+
# Generate random IV
|
391
|
+
iv = cipher.random_iv
|
392
|
+
cipher.iv = iv
|
393
|
+
|
394
|
+
# Encrypt content
|
395
|
+
encrypted = cipher.update(content) + cipher.final
|
396
|
+
auth_tag = cipher.auth_tag
|
397
|
+
|
398
|
+
# Combine IV, auth tag, and encrypted content
|
399
|
+
[iv, auth_tag, encrypted].map { |part| Base64.strict_encode64(part) }.join(":")
|
400
|
+
end
|
401
|
+
|
402
|
+
# Decrypts content using AES-256-GCM
|
403
|
+
def decrypt_content(encrypted_content, key)
|
404
|
+
parts = encrypted_content.split(":")
|
405
|
+
raise SecurityError, "Invalid encrypted content format" unless parts.length == 3
|
406
|
+
|
407
|
+
iv, auth_tag, ciphertext = parts.map { |part| Base64.strict_decode64(part) }
|
408
|
+
|
409
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
410
|
+
cipher.decrypt
|
411
|
+
cipher.key = key
|
412
|
+
cipher.iv = iv
|
413
|
+
cipher.auth_tag = auth_tag
|
414
|
+
|
415
|
+
cipher.update(ciphertext) + cipher.final
|
416
|
+
rescue StandardError => e
|
417
|
+
raise SecurityError, "Decryption failed: #{e.message}"
|
418
|
+
end
|
419
|
+
|
420
|
+
# Generates a secure encryption key
|
421
|
+
def generate_encryption_key
|
422
|
+
OpenSSL::Random.random_bytes(32) # 256 bits
|
423
|
+
end
|
424
|
+
|
425
|
+
# Generates SHA-256 checksum for file verification
|
426
|
+
def generate_checksum(file_path)
|
427
|
+
# Resolve path back to real filesystem path for actual operations
|
428
|
+
real_path = denormalize_path_for_operations(file_path)
|
429
|
+
return nil unless File.exist?(real_path)
|
430
|
+
|
431
|
+
digest = OpenSSL::Digest.new("SHA256")
|
432
|
+
File.open(real_path, "rb") do |file|
|
433
|
+
while (chunk = file.read(8192))
|
434
|
+
digest.update(chunk)
|
435
|
+
end
|
436
|
+
end
|
437
|
+
digest.hexdigest
|
438
|
+
end
|
439
|
+
|
440
|
+
# Validation helpers
|
441
|
+
def validate_file_exists!(file_path)
|
442
|
+
# Use normalized path for error messages but check real path for existence
|
443
|
+
real_path = denormalize_path_for_operations(file_path)
|
444
|
+
return if File.exist?(real_path)
|
445
|
+
|
446
|
+
raise SecurityError, "Source file does not exist: #{file_path}"
|
447
|
+
end
|
448
|
+
|
449
|
+
def validate_file_readable!(file_path)
|
450
|
+
# Use normalized path for error messages but check real path for readability
|
451
|
+
real_path = denormalize_path_for_operations(file_path)
|
452
|
+
return if File.readable?(real_path)
|
453
|
+
|
454
|
+
raise SecurityError, "Source file is not readable: #{file_path}"
|
455
|
+
end
|
456
|
+
|
457
|
+
# Logs file operations for audit trail
|
458
|
+
def audit_log(event, details)
|
459
|
+
return unless @logger
|
460
|
+
|
461
|
+
log_entry = {
|
462
|
+
timestamp: Time.now.iso8601,
|
463
|
+
event: event,
|
464
|
+
pid: Process.pid,
|
465
|
+
user: ENV["USER"] || "unknown"
|
466
|
+
}
|
467
|
+
|
468
|
+
if details.is_a?(CopyResult)
|
469
|
+
log_entry.merge!(details.to_h)
|
470
|
+
else
|
471
|
+
log_entry.merge!(details)
|
472
|
+
end
|
473
|
+
|
474
|
+
@logger.info("SECURITY_AUDIT: #{log_entry.to_json}")
|
475
|
+
end
|
476
|
+
end
|
477
|
+
end
|
478
|
+
end
|