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.
- checksums.yaml +7 -0
- data/.keyslothrc.example +14 -0
- data/.rspec +4 -0
- data/.rubocop.yml +98 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +45 -0
- data/Makefile +193 -0
- data/README.md +448 -0
- data/Rakefile +99 -0
- data/bin/keysloth +16 -0
- data/keysloth.gemspec +51 -0
- data/lib/keysloth/cli.rb +468 -0
- data/lib/keysloth/config.rb +129 -0
- data/lib/keysloth/crypto.rb +304 -0
- data/lib/keysloth/errors.rb +41 -0
- data/lib/keysloth/file_manager.rb +450 -0
- data/lib/keysloth/git_manager.rb +394 -0
- data/lib/keysloth/logger.rb +172 -0
- data/lib/keysloth/version.rb +6 -0
- data/lib/keysloth.rb +275 -0
- data/task/cr.md +59 -0
- data/task/plan.md +169 -0
- data/task/ragged_removing.md +103 -0
- data/task/task.md +43 -0
- data/task/test_plan.md +266 -0
- metadata +174 -0
|
@@ -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
|