keysloth 0.1.1

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.
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+ require 'open3'
6
+
7
+ module KeySloth
8
+ # Класс для управления Git операциями KeySloth
9
+ #
10
+ # Обеспечивает клонирование репозиториев, работу с ветками,
11
+ # коммиты и отправку изменений. Использует SSH ключи для аутентификации.
12
+ #
13
+ # @example Использование
14
+ # git_manager = KeySloth::GitManager.new('git@github.com:company/secrets.git')
15
+ #
16
+ # # Получение зашифрованных файлов
17
+ # files = git_manager.pull_encrypted_files('main')
18
+ #
19
+ # # Подготовка репозитория для записи
20
+ # git_manager.prepare_repository('main')
21
+ #
22
+ # # Запись и отправка файлов
23
+ # git_manager.write_encrypted_files(encrypted_files)
24
+ # git_manager.commit_and_push('Update secrets', 'main')
25
+ #
26
+ # @author KeySloth Team
27
+ # @since 0.1.0
28
+ class GitManager
29
+ # Инициализация менеджера Git операций
30
+ #
31
+ # @param repo_url [String] URL Git репозитория (SSH)
32
+ # @param logger [KeySloth::Logger] Логгер для вывода сообщений
33
+ # @raise [RepositoryError] при некорректном URL репозитория
34
+ def initialize(repo_url, logger = nil)
35
+ @repo_url = repo_url&.to_s
36
+ @logger = logger || Logger.new(level: :error)
37
+ @temp_dir = nil
38
+ @ssh_tmp_dir = nil
39
+ @git_env = {}
40
+
41
+ validate_repo_url!
42
+ check_git_available!
43
+ prepare_git_environment
44
+ end
45
+
46
+ # Получает зашифрованные файлы из репозитория
47
+ #
48
+ # @param branch [String] Ветка для получения файлов (по умолчанию 'main')
49
+ # @return [Array<Hash>] Массив хешей с данными файлов
50
+ # Каждый хеш содержит:
51
+ # - :name [String] Имя файла
52
+ # - :content [String] Содержимое файла
53
+ # @raise [RepositoryError] при ошибках работы с репозиторием
54
+ def pull_encrypted_files(branch = 'main')
55
+ @logger.info("Клонируем репозиторий: #{@repo_url}")
56
+
57
+ begin
58
+ clone_repository
59
+ checkout_branch(branch)
60
+ ensure_branch_up_to_date(branch)
61
+
62
+ files = collect_encrypted_files
63
+ @logger.info("Найдено #{files.size} зашифрованных файлов")
64
+
65
+ files
66
+ rescue StandardError => e
67
+ @logger.error('Неожиданная ошибка при получении файлов', e)
68
+ raise RepositoryError, "Не удалось получить файлы: #{e.message}"
69
+ end
70
+ end
71
+
72
+ # Подготавливает репозиторий для записи изменений
73
+ #
74
+ # @param branch [String] Ветка для работы
75
+ # @raise [RepositoryError] при ошибках подготовки репозитория
76
+ def prepare_repository(branch = 'main')
77
+ @logger.info("Подготавливаем репозиторий для записи в ветку: #{branch}")
78
+
79
+ begin
80
+ clone_repository
81
+ checkout_branch(branch)
82
+ ensure_branch_up_to_date(branch)
83
+ rescue StandardError => e
84
+ @logger.error('Ошибка подготовки репозитория', e)
85
+ raise RepositoryError, "Не удалось подготовить репозиторий: #{e.message}"
86
+ end
87
+ end
88
+
89
+ # Записывает зашифрованные файлы в репозиторий
90
+ #
91
+ # @param encrypted_files [Array<Hash>] Массив зашифрованных файлов
92
+ # Каждый хеш должен содержать:
93
+ # - :path [String] Относительный путь файла в репозитории
94
+ # - :content [String] Зашифрованное содержимое файла
95
+ # @raise [RepositoryError] при ошибках записи файлов
96
+ def write_encrypted_files(encrypted_files)
97
+ @logger.info("Записываем #{encrypted_files.size} зашифрованных файлов")
98
+
99
+ begin
100
+ # Очищаем существующие .enc файлы
101
+ clear_encrypted_files
102
+
103
+ # Записываем новые зашифрованные файлы
104
+ encrypted_files.each do |file_data|
105
+ file_path = File.join(@temp_dir, file_data[:path])
106
+
107
+ # Создаем директорию если не существует
108
+ FileUtils.mkdir_p(File.dirname(file_path))
109
+
110
+ # Записываем файл
111
+ File.write(file_path, file_data[:content])
112
+ @logger.debug("Записан файл: #{file_data[:path]}")
113
+ end
114
+ rescue StandardError => e
115
+ @logger.error('Ошибка записи зашифрованных файлов', e)
116
+ raise RepositoryError, "Не удалось записать файлы: #{e.message}"
117
+ end
118
+ end
119
+
120
+ # Создает коммит и отправляет изменения в репозиторий
121
+ #
122
+ # @param commit_message [String] Сообщение коммита
123
+ # @param branch [String] Ветка для отправки
124
+ # @raise [RepositoryError] при ошибках коммита или отправки
125
+ def commit_and_push(commit_message, branch = 'main')
126
+ @logger.info("Создаем коммит и отправляем в ветку: #{branch}")
127
+
128
+ begin
129
+ add_all_changes
130
+
131
+ unless has_changes?
132
+ @logger.info('Нет изменений для коммита')
133
+ return
134
+ end
135
+
136
+ ensure_git_user_config!
137
+ create_commit(commit_message)
138
+ push_to_remote(branch)
139
+
140
+ @logger.info('Изменения успешно отправлены в репозиторий')
141
+ rescue StandardError => e
142
+ @logger.error('Ошибка коммита или отправки', e)
143
+ raise RepositoryError, "Не удалось отправить изменения: #{e.message}"
144
+ end
145
+ end
146
+
147
+ # Очищает временные файлы
148
+ def cleanup
149
+ if @temp_dir && Dir.exist?(@temp_dir)
150
+ @logger.debug("Очищаем временную директорию: #{@temp_dir}")
151
+ FileUtils.remove_entry(@temp_dir)
152
+ @temp_dir = nil
153
+ end
154
+
155
+ if @ssh_tmp_dir && Dir.exist?(@ssh_tmp_dir)
156
+ @logger.debug("Очищаем временные SSH ключи: #{@ssh_tmp_dir}")
157
+ FileUtils.remove_entry(@ssh_tmp_dir)
158
+ @ssh_tmp_dir = nil
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ # Валидирует URL репозитория
165
+ #
166
+ # @raise [RepositoryError] при некорректном URL
167
+ def validate_repo_url!
168
+ if @repo_url.nil? || @repo_url.empty?
169
+ raise RepositoryError, 'URL репозитория не может быть пустым'
170
+ end
171
+
172
+ unless @repo_url.match?(%r{\A[\w\-\.]+@[\w\-\.]+:[\w\-\./]+\.git\z})
173
+ raise RepositoryError, 'Поддерживаются только SSH URL репозиториев (git@host:repo.git)'
174
+ end
175
+ end
176
+
177
+ # Проверяет доступность git команды
178
+ #
179
+ # @raise [RepositoryError] если git недоступен
180
+ def check_git_available!
181
+ _, _, status = Open3.capture3('git --version')
182
+ raise RepositoryError, 'Git не установлен или недоступен в системе' unless status.success?
183
+ end
184
+
185
+ # Настраивает окружение Git и SSH
186
+ def prepare_git_environment
187
+ ssh_command = build_ssh_command
188
+ @git_env = {}
189
+ @git_env['GIT_SSH_COMMAND'] = ssh_command if ssh_command
190
+ end
191
+
192
+ # Клонирует репозиторий во временную директорию
193
+ #
194
+ # @raise [RepositoryError] при ошибках клонирования
195
+ def clone_repository
196
+ return if @temp_dir
197
+
198
+ @temp_dir = Dir.mktmpdir('keysloth_repo_')
199
+ @logger.debug("Создана временная директория: #{@temp_dir}")
200
+
201
+ depth_flag = ENV['KEYSLOTH_FULL_CLONE'].to_s.downcase == 'true' ? [] : ['--depth', '1']
202
+ cmd = ['git', 'clone', '--quiet'] + depth_flag + [@repo_url, @temp_dir]
203
+ run_git(cmd)
204
+
205
+ @logger.debug('Репозиторий успешно клонирован')
206
+ end
207
+
208
+ # Переключается на указанную ветку
209
+ #
210
+ # @param branch [String] Имя ветки
211
+ # @raise [RepositoryError] при ошибках переключения ветки
212
+ def checkout_branch(branch)
213
+ @logger.debug("Переключаемся на ветку: #{branch}")
214
+
215
+ # Проверяем, существует ли локальная ветка
216
+ stdout, = run_git(['git', 'rev-parse', '--verify', branch], chdir: @temp_dir,
217
+ allow_failure: true)
218
+ if stdout && !stdout.strip.empty?
219
+ run_git(['git', 'checkout', branch], chdir: @temp_dir)
220
+ @logger.debug("Успешно переключились на ветку: #{branch}")
221
+ return
222
+ end
223
+
224
+ # Пробуем создать локальную ветку, отслеживающую origin/<branch>
225
+ _, stderr, status = Open3.capture3(@git_env, 'git', 'checkout', '-b', branch, '--track',
226
+ "origin/#{branch}", chdir: @temp_dir)
227
+ if status.success?
228
+ @logger.debug("Создана и выбрана новая ветка: #{branch}")
229
+ return
230
+ end
231
+
232
+ raise RepositoryError, "Ветка '#{branch}' не найдена в репозитории: #{stderr.strip}"
233
+ end
234
+
235
+ # Проверяет актуальность локальной ветки
236
+ #
237
+ # @param branch [String] Имя ветки
238
+ # @raise [RepositoryError] если ветка не актуальна
239
+ def ensure_branch_up_to_date(branch)
240
+ run_git(['git', 'fetch', 'origin', branch], chdir: @temp_dir)
241
+
242
+ # Пытаемся fast-forward pull
243
+ _, _, status = Open3.capture3(@git_env, 'git', 'pull', '--ff-only', 'origin', branch,
244
+ chdir: @temp_dir)
245
+ return if status.success?
246
+
247
+ # Если shallow-история мешает, пробуем развернуть историю
248
+ @logger.debug('Повторная попытка pull после развертывания истории (unshallow)')
249
+ _, _, fetch_status = Open3.capture3(@git_env, 'git', 'fetch', '--unshallow', chdir: @temp_dir)
250
+ if fetch_status.success?
251
+ out2, err2, st2 = Open3.capture3(@git_env, 'git', 'pull', '--ff-only', 'origin', branch,
252
+ chdir: @temp_dir)
253
+ return if st2.success?
254
+
255
+ raise RepositoryError,
256
+ "Не удалось обновить ветку fast-forward: #{(err2.empty? ? out2 : err2).strip}"
257
+ end
258
+
259
+ raise RepositoryError, 'Не удалось выполнить git pull --ff-only'
260
+ end
261
+
262
+ # Собирает все зашифрованные файлы из репозитория
263
+ #
264
+ # @return [Array<Hash>] Массив данных файлов
265
+ def collect_encrypted_files
266
+ files = []
267
+
268
+ Dir.glob('**/*.enc', base: @temp_dir).sort.each do |relative_path|
269
+ full_path = File.join(@temp_dir, relative_path)
270
+ next unless File.file?(full_path)
271
+
272
+ files << {
273
+ name: relative_path,
274
+ content: File.read(full_path)
275
+ }
276
+ end
277
+
278
+ files
279
+ end
280
+
281
+ # Очищает существующие зашифрованные файлы
282
+ def clear_encrypted_files
283
+ @logger.debug('Очищаем существующие .enc файлы')
284
+
285
+ Dir.glob(File.join(@temp_dir, '**/*.enc')).each do |file_path|
286
+ File.delete(file_path)
287
+ @logger.debug("Удален файл: #{File.basename(file_path)}")
288
+ end
289
+ end
290
+
291
+ # Добавляет все изменения в индекс Git
292
+ def add_all_changes
293
+ run_git(['git', 'add', '-A'], chdir: @temp_dir)
294
+ end
295
+
296
+ # Проверяет наличие изменений
297
+ #
298
+ # @return [Boolean] true если есть изменения
299
+ def has_changes?
300
+ stdout, = run_git(['git', 'status', '--porcelain'], chdir: @temp_dir)
301
+ !stdout.strip.empty?
302
+ end
303
+
304
+ # Создает коммит
305
+ #
306
+ # @param message [String] Сообщение коммита
307
+ def create_commit(message)
308
+ run_git(['git', 'commit', '-m', message], chdir: @temp_dir)
309
+ @logger.debug("Создан коммит: #{message}")
310
+ end
311
+
312
+ # Отправляет изменения в удаленный репозиторий
313
+ #
314
+ # @param branch [String] Имя ветки
315
+ def push_to_remote(branch)
316
+ run_git(['git', 'push', 'origin', branch], chdir: @temp_dir)
317
+ @logger.debug("Изменения отправлены в origin/#{branch}")
318
+ end
319
+
320
+ # Формирует команду SSH для GIT_SSH_COMMAND в зависимости от окружения
321
+ #
322
+ # Приоритет источников ключей: KEYSLOTH_SSH_KEY_PATH → SSH_PRIVATE_KEY/SSH_PUBLIC_KEY → системные ключи
323
+ # Возвращает nil, если следует использовать системный SSH без явного указания ключа
324
+ def build_ssh_command
325
+ explicit_key_path = ENV.fetch('KEYSLOTH_SSH_KEY_PATH', nil)
326
+ if explicit_key_path && !explicit_key_path.empty?
327
+ return %(ssh -i #{explicit_key_path} -o IdentitiesOnly=yes)
328
+ end
329
+
330
+ if ENV['SSH_PRIVATE_KEY']
331
+ @ssh_tmp_dir = Dir.mktmpdir('keysloth_ssh_')
332
+ private_key_path = File.join(@ssh_tmp_dir, 'id_rsa')
333
+ public_key_path = File.join(@ssh_tmp_dir, 'id_rsa.pub')
334
+
335
+ File.write(private_key_path, ENV['SSH_PRIVATE_KEY'])
336
+ File.chmod(0o600, private_key_path)
337
+ File.write(public_key_path, ENV['SSH_PUBLIC_KEY']) if ENV['SSH_PUBLIC_KEY']
338
+
339
+ # В CI отключаем проверку хостов (опционально)
340
+ return %(ssh -i #{private_key_path} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
341
+ end
342
+
343
+ # Системные ключи/ssh-agent — используем настройки по умолчанию
344
+ nil
345
+ end
346
+
347
+ # Проверяет, что git настроен с именем и email автора коммита
348
+ def ensure_git_user_config!
349
+ name_out, = run_git(['git', 'config', '--get', 'user.name'], chdir: @temp_dir,
350
+ allow_failure: true)
351
+ email_out, = run_git(['git', 'config', '--get', 'user.email'], chdir: @temp_dir,
352
+ allow_failure: true)
353
+
354
+ if name_out.to_s.strip.empty? || email_out.to_s.strip.empty?
355
+ raise RepositoryError,
356
+ 'Требуются глобальные настройки Git: user.name и user.email. ' \
357
+ 'Настройте их командой: git config --global user.name "Your Name"; ' \
358
+ 'git config --global user.email "you@example.com"'
359
+ end
360
+ end
361
+
362
+ # Унифицированный запуск git-команд с логированием и обработкой ошибок
363
+ # Возвращает [stdout, stderr]
364
+ def run_git(cmd, chdir: nil, allow_failure: false)
365
+ start_msg = cmd.is_a?(Array) ? cmd.join(' ') : cmd.to_s
366
+ @logger.debug("Выполняем команду: #{start_msg}")
367
+
368
+ options = {}
369
+ options[:chdir] = chdir if chdir
370
+
371
+ stdout, stderr, status = if cmd.is_a?(Array)
372
+ Open3.capture3(@git_env, *cmd, **options)
373
+ else
374
+ Open3.capture3(@git_env, cmd, **options)
375
+ end
376
+
377
+ return [stdout, stderr] if status.success?
378
+
379
+ exit_code = status.respond_to?(:exitstatus) ? status.exitstatus : 'unknown'
380
+ @logger.debug("Код выхода: #{exit_code}\nSTDERR: #{stderr.strip}")
381
+ return [stdout, stderr] if allow_failure
382
+
383
+ base_msg = (stderr.strip.empty? ? stdout.strip : stderr.strip)
384
+ advice = 'Совет: проверьте доступ к репозиторию, корректность ветки и SSH-настройки.'
385
+ raise RepositoryError, [base_msg, advice].reject(&:empty?).join("\n")
386
+ end
387
+
388
+ # Метод сохранен для обратной совместимости интерфейса (не используется)
389
+ def create_commit_signature
390
+ # Используем конфигурацию git, поэтому сигнатура не формируется вручную
391
+ nil
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module KeySloth
6
+ # Класс для многоуровневого логирования KeySloth
7
+ #
8
+ # Обеспечивает логирование с различными уровнями детализации:
9
+ # - ERROR: только критические ошибки
10
+ # - INFO: основная информация о процессе (по умолчанию)
11
+ # - DEBUG: подробная отладочная информация
12
+ #
13
+ # @example Использование логгера
14
+ # logger = KeySloth::Logger.new
15
+ # logger.info("Начинаем операцию")
16
+ # logger.debug("Подробная информация")
17
+ # logger.error("Критическая ошибка")
18
+ #
19
+ # @author KeySloth Team
20
+ # @since 0.1.0
21
+ class Logger
22
+ # Инициализация логгера
23
+ #
24
+ # @param level [Symbol] Уровень логирования (:error, :info, :debug)
25
+ # @param output [IO] Поток вывода (по умолчанию STDOUT)
26
+ def initialize(level: :info, output: $stdout)
27
+ @logger = ::Logger.new(output)
28
+ @logger.level = log_level_constant(level)
29
+ @logger.formatter = proc do |severity, datetime, _progname, msg|
30
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
31
+ end
32
+ end
33
+
34
+ # Логирование информационных сообщений
35
+ #
36
+ # @param message [String] Сообщение для логирования
37
+ def info(message)
38
+ @logger.info(message)
39
+ end
40
+
41
+ # Логирование отладочных сообщений
42
+ #
43
+ # @param message [String] Сообщение для логирования
44
+ def debug(message)
45
+ @logger.debug(message)
46
+ end
47
+
48
+ # Логирование предупреждений
49
+ #
50
+ # @param message [String] Сообщение для логирования
51
+ def warn(message)
52
+ @logger.warn(message)
53
+ end
54
+
55
+ # Логирование ошибок
56
+ #
57
+ # @param message [String] Сообщение об ошибке
58
+ # @param exception [Exception, nil] Исключение для детального логирования (опционально)
59
+ def error(message, exception = nil)
60
+ if exception
61
+ @logger.error("#{message}: #{exception.message}")
62
+ @logger.debug("Backtrace: #{exception.backtrace.join("\n")}")
63
+ else
64
+ @logger.error(message)
65
+ end
66
+ end
67
+
68
+ # Изменяет уровень логирования
69
+ #
70
+ # @param level [Symbol] Новый уровень логирования (:error, :info, :debug)
71
+ def level=(level)
72
+ @logger.level = log_level_constant(level)
73
+ end
74
+
75
+ # Возвращает текущий уровень логирования
76
+ #
77
+ # @return [Integer] Константа уровня логирования
78
+ def level
79
+ @logger.level
80
+ end
81
+
82
+ # Аудит логирование для операций безопасности
83
+ #
84
+ # @param operation [String] Тип операции (pull, push, validate, etc.)
85
+ # @param details [Hash] Детали операции
86
+ def audit(operation, details = {})
87
+ timestamp = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')
88
+
89
+ audit_message = "[AUDIT] #{timestamp} | Operation: #{operation}"
90
+
91
+ # Добавляем детали если они есть
92
+ unless details.empty?
93
+ details_str = details.map { |k, v| "#{k}=#{sanitize_log_value(v, k)}" }.join(', ')
94
+ audit_message += " | #{details_str}"
95
+ end
96
+
97
+ @logger.info(audit_message)
98
+ end
99
+
100
+ # Логирование операций безопасности с метриками
101
+ #
102
+ # @param operation [String] Тип операции
103
+ # @param result [Symbol] Результат операции (:success, :failure, :warning)
104
+ # @param duration [Float] Длительность операции в секундах (опционально)
105
+ # @param details [Hash] Дополнительные детали
106
+ def security_log(operation, result, duration: nil, details: {})
107
+ level_method = case result
108
+ when :success then :info
109
+ when :failure then :error
110
+ when :warning then :warn
111
+ else :info
112
+ end
113
+
114
+ message = "[SECURITY] #{operation.upcase}: #{result.to_s.upcase}"
115
+ message += " (#{duration.round(2)}s)" if duration
116
+
117
+ unless details.empty?
118
+ details_str = details.map { |k, v| "#{k}=#{sanitize_log_value(v, k)}" }.join(', ')
119
+ message += " | #{details_str}"
120
+ end
121
+
122
+ send(level_method, message)
123
+
124
+ # Дублируем критические ошибки в аудит лог
125
+ audit(operation, details.merge(result: result, duration: duration)) if result == :failure
126
+ end
127
+
128
+ private
129
+
130
+ # Преобразует символьный уровень в константу Logger
131
+ #
132
+ # @param level [Symbol] Символьный уровень логирования
133
+ # @return [Integer] Константа уровня логирования
134
+ # @raise [ArgumentError] при неизвестном уровне логирования
135
+ def log_level_constant(level)
136
+ case level
137
+ when :debug
138
+ ::Logger::DEBUG
139
+ when :info
140
+ ::Logger::INFO
141
+ when :warn
142
+ ::Logger::WARN
143
+ when :error
144
+ ::Logger::ERROR
145
+ else
146
+ raise ArgumentError, "Неизвестный уровень логирования: #{level}"
147
+ end
148
+ end
149
+
150
+ # Очищает значения для безопасного логирования
151
+ #
152
+ # @param value [Object] Значение для очистки
153
+ # @param key [Object] Ключ (опционально) для дополнительной проверки
154
+ # @return [String] Очищенное значение
155
+ def sanitize_log_value(value, key = nil)
156
+ return 'nil' if value.nil?
157
+
158
+ str_value = value.to_s
159
+ key_str = key.to_s if key
160
+
161
+ # Скрываем пароли и ключи (проверяем и ключ и значение)
162
+ sensitive_pattern = /password|key|secret|token/i
163
+ return '[HIDDEN]' if str_value.match?(sensitive_pattern) ||
164
+ key_str&.match?(sensitive_pattern)
165
+
166
+ # Обрезаем длинные значения
167
+ return "#{str_value[0, 50]}..." if str_value.length > 50
168
+
169
+ str_value
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeySloth
4
+ # Версия gem'а KeySloth
5
+ VERSION = '0.1.1'
6
+ end