rails_nexus 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +33 -6
  4. data/SECURITY.md +11 -0
  5. data/THIRD_PARTY_NOTICES.md +14 -0
  6. data/app/assets/config/rails_nexus_manifest.js +1 -1
  7. data/app/assets/javascripts/rails_nexus/application.js +55 -0
  8. data/app/controllers/rails_nexus/analytics_controller.rb +13 -26
  9. data/app/controllers/rails_nexus/application_controller.rb +6 -2
  10. data/app/controllers/rails_nexus/backup_controller.rb +1 -2
  11. data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -9
  12. data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
  13. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
  14. data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
  15. data/app/controllers/rails_nexus/settings_controller.rb +1 -39
  16. data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
  17. data/app/controllers/rails_nexus/stats_controller.rb +43 -43
  18. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
  19. data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
  20. data/app/javascript/rails_nexus/application.js +15 -3
  21. data/app/models/rails_nexus/backup_config.rb +31 -0
  22. data/app/models/rails_nexus/logged_exception.rb +27 -13
  23. data/app/services/rails_nexus/backup_service.rb +233 -98
  24. data/app/views/layouts/rails_nexus/application.html.erb +1 -1
  25. data/app/views/rails_nexus/stats/index.html.erb +1 -1
  26. data/lib/generators/rails_nexus/install_generator.rb +1 -1
  27. data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
  28. data/lib/generators/rails_nexus/templates/rails_nexus.rb +3 -0
  29. data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +1 -1
  30. data/lib/rails_nexus/configuration.rb +10 -0
  31. data/lib/rails_nexus/database_adapter.rb +53 -0
  32. data/lib/rails_nexus/engine.rb +0 -13
  33. data/lib/rails_nexus/logger.rb +1 -12
  34. data/lib/rails_nexus/notifications.rb +27 -20
  35. data/lib/rails_nexus/version.rb +1 -1
  36. data/lib/rails_nexus/webhook_client.rb +151 -0
  37. data/lib/rails_nexus.rb +37 -23
  38. data/rails_nexus.gemspec +3 -2
  39. metadata +8 -16
  40. data/config/importmap.rb +0 -15
@@ -24,14 +24,14 @@ module RailsNexus
24
24
  scope :unassigned, -> { where(assigned_to: [nil, ""]) }
25
25
  scope :active, -> { not_muted.not_snoozed }
26
26
 
27
- def self.ransackable_attributes(auth_object = nil)
27
+ def self.ransackable_attributes(_auth_object = nil)
28
28
  %w[action_name backtrace cause_chain controller_name created_at device_type environment
29
29
  assigned_to comments_count exception_class fingerprint id instance_variables local_variables
30
30
  message muted occurrence_count platform platform_version priority remote_ip request
31
31
  snoozed_until system_health updated_at user_agent user_id user_info]
32
32
  end
33
33
 
34
- def self.ransackable_associations(auth_object = nil)
34
+ def self.ransackable_associations(_auth_object = nil)
35
35
  []
36
36
  end
37
37
 
@@ -299,8 +299,8 @@ module RailsNexus
299
299
 
300
300
  # Look for ActiveRecord collection iteration patterns:
301
301
  # e.g., .each, .map, .find_each near ActiveRecord calls
302
- has_iteration = app_lines.any? { |l| l.match?(/\.each|\.map|\.select|\.reject|\.flat_map|\.find_each/) }
303
- has_ar_call = app_lines.any? { |l| l.match?(/ActiveRecord|_callback|association|belongs_to|has_many|load_target|reload/) }
302
+ app_lines.any? { |l| l.match?(/\.each|\.map|\.select|\.reject|\.flat_map|\.find_each/) }
303
+ app_lines.any? { |l| l.match?(/ActiveRecord|_callback|association|belongs_to|has_many|load_target|reload/) }
304
304
 
305
305
  # Also detect: repeated similar backtrace across multiple exceptions of same class
306
306
  fingerprint = backtrace_fingerprint(app_lines)
@@ -435,9 +435,11 @@ module RailsNexus
435
435
  end
436
436
 
437
437
  begin
438
+ stdout, _stderr, status = Open3.capture3("ps", "-o", "rss=,vsz=", "-p", Process.pid.to_s)
439
+ memory = status.success? ? stdout.split.map(&:to_i) : []
438
440
  health[:memory] = {
439
- rss_kb: `ps -o rss= -p #{Process.pid} 2>/dev/null`.strip.to_i,
440
- vsz_kb: `ps -o vsz= -p #{Process.pid} 2>/dev/null`.strip.to_i
441
+ rss_kb: memory[0] || 0,
442
+ vsz_kb: memory[1] || 0
441
443
  }
442
444
  rescue StandardError
443
445
  health[:memory] = { error: "unable to capture" }
@@ -495,17 +497,28 @@ module RailsNexus
495
497
  if request.is_a?(String)
496
498
  write_attribute :request, request
497
499
  elsif request.respond_to?(:env) && request.env.is_a?(Hash)
498
- max = request.env.keys.max { |a, b| a.length <=> b.length }
499
- env = request.env.keys.sort.inject [] do |memo, key|
500
- memo << "* %-*s: %s" % [max.length, key, request.env[key].to_s.strip]
500
+ filtered_env = RailsNexus.filter_sensitive_data(request.env)
501
+ keys = filtered_env.keys.map(&:to_s).sort
502
+ max_length = keys.map(&:length).max || 0
503
+ env = keys.each_with_object([]) do |key, memo|
504
+ value = filtered_env[key] || filtered_env[key.to_sym]
505
+ memo << ("* %-*s: %s" % [max_length, key, value.to_s.strip])
501
506
  end
502
507
  write_attribute(:environment, (env << "* Process: #{$$}" << "* Server : #{self.class.host_name}").join("\n"))
503
508
 
504
509
  method_str = request.respond_to?(:get?) && request.get? ? "" : " #{request.respond_to?(:method) ? request.method.to_s.upcase : "GET"}"
510
+ parameters = request.respond_to?(:parameters) ? RailsNexus.filter_sensitive_data(request.parameters) : {}
511
+ request_path = if request.respond_to?(:path)
512
+ request.path
513
+ elsif request.respond_to?(:fullpath)
514
+ request.fullpath.to_s.split("?", 2).first
515
+ else
516
+ "/"
517
+ end
505
518
  write_attribute(:request, [
506
- "* URL:#{method_str} #{request.respond_to?(:protocol) ? request.protocol : "http://"}#{request.respond_to?(:env) ? request.env["HTTP_HOST"] : "localhost"}#{request.respond_to?(:fullpath) ? request.fullpath : "/"}",
519
+ "* URL:#{method_str} #{request.respond_to?(:protocol) ? request.protocol : "http://"}#{filtered_env["HTTP_HOST"] || "localhost"}#{request_path}",
507
520
  "* Format: #{request.respond_to?(:format) ? request.format.to_s : "html"}",
508
- "* Parameters: #{request.respond_to?(:parameters) ? request.parameters.inspect : "{}"}",
521
+ "* Parameters: #{parameters.inspect}",
509
522
  "* Rails Root: #{rails_root}"
510
523
  ].join("\n"))
511
524
  else
@@ -545,10 +558,11 @@ module RailsNexus
545
558
 
546
559
  # Build hourly time series of error counts
547
560
  def self.build_time_series(days: 30)
561
+ days = (Integer(days, exception: false) || 30).clamp(1, 90)
548
562
  start_time = days.days.ago.beginning_of_hour
563
+ bucket = RailsNexus::DatabaseAdapter.time_bucket_expression
549
564
  raw = where("created_at >= ?", start_time)
550
- .group(Arel.sql("DATE_FORMAT(created_at, '%Y-%m-%d %H:00')"))
551
- .order(Arel.sql("DATE_FORMAT(created_at, '%Y-%m-%d %H:00')"))
565
+ .group(Arel.sql(bucket))
552
566
  .count
553
567
 
554
568
  # Fill gaps with zeros
@@ -3,6 +3,8 @@
3
3
  require "open3"
4
4
  require "json"
5
5
  require "fileutils"
6
+ require "shellwords"
7
+ require "tempfile"
6
8
 
7
9
  module RailsNexus
8
10
  class BackupService
@@ -26,7 +28,8 @@ module RailsNexus
26
28
 
27
29
  begin
28
30
  @stage = "preparing storage"
29
- FileUtils.mkdir_p(@config.storage_path_expanded)
31
+ validate_identifier!(@config.name, "backup name")
32
+ FileUtils.mkdir_p(validated_path(@config.storage_path_expanded, "storage path"))
30
33
 
31
34
  # Step 1: Dump database
32
35
  @stage = "database dump"
@@ -112,11 +115,13 @@ module RailsNexus
112
115
 
113
116
  # ─── MySQL ─────────────────────────────────────────────────────
114
117
  def dump_mysql
118
+ validate_identifier!(@config.database_name, "database name")
119
+ validate_host!(@config.host) if @config.host.present?
120
+ validate_port!(@config.port) if @config.port.present?
115
121
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
116
122
  password = resolve_password(@config.password)
117
123
  cmd = ["mysqldump"]
118
124
  cmd << "--user=#{resolve_env(@config.username)}" if @config.username.present?
119
- cmd << "--password=#{password}" if password.present?
120
125
  cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
121
126
  cmd << "--port=#{@config.port}" if @config.port.present?
122
127
  cmd << "--result-file=#{raw_path}"
@@ -130,15 +135,20 @@ module RailsNexus
130
135
  @config.mysql_additional_options
131
136
  end
132
137
  if additional_options.present?
133
- additional_options.split(" ").each { |opt| cmd << opt }
138
+ cmd.concat(validated_mysql_options(additional_options))
134
139
  end
135
140
 
136
141
  # Skip tables
137
- @config.skip_tables_list.each { |t| cmd << "--ignore-table=#{@config.database_name}.#{t}" }
142
+ @config.skip_tables_list.each do |table|
143
+ validate_identifier!(table, "table name")
144
+ cmd << "--ignore-table=#{@config.database_name}.#{table}"
145
+ end
138
146
 
139
147
  cmd << @config.database_name
140
148
 
141
- stdout, stderr, status = capture_command(cmd.join(" "))
149
+ env = {}
150
+ env["MYSQL_PWD"] = password if password.present?
151
+ stdout, stderr, status = capture_command(env, *cmd)
142
152
  unless status.success?
143
153
  raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
144
154
  end
@@ -148,6 +158,9 @@ module RailsNexus
148
158
 
149
159
  # ─── PostgreSQL ────────────────────────────────────────────────
150
160
  def dump_postgresql
161
+ validate_identifier!(@config.database_name, "database name")
162
+ validate_host!(@config.host) if @config.host.present?
163
+ validate_port!(@config.port) if @config.port.present?
151
164
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
152
165
  password = resolve_password(@config.password)
153
166
  cmd = ["pg_dump"]
@@ -160,14 +173,17 @@ module RailsNexus
160
173
  cmd << "--no-privileges"
161
174
 
162
175
  # Skip tables
163
- @config.skip_tables_list.each { |t| cmd << "--exclude-table=#{t}" }
176
+ @config.skip_tables_list.each do |table|
177
+ validate_identifier!(table, "table name")
178
+ cmd << "--exclude-table=#{table}"
179
+ end
164
180
 
165
181
  cmd << @config.database_name
166
182
 
167
183
  env = {}
168
184
  env["PGPASSWORD"] = password if password.present?
169
185
 
170
- stdout, stderr, status = Open3.capture3(env, cmd.join(" "))
186
+ stdout, stderr, status = capture_command(env, *cmd)
171
187
  unless status.success?
172
188
  raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
173
189
  end
@@ -175,20 +191,20 @@ module RailsNexus
175
191
  raw_path
176
192
  end
177
193
 
178
- def capture_command(*command)
179
- Open3.capture3(*command)
194
+ def capture_command(*command, **options)
195
+ Open3.capture3(*command, **options)
180
196
  end
181
197
 
182
198
  # ─── SQLite ────────────────────────────────────────────────────
183
199
  def dump_sqlite
184
200
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
185
- db_path = @config.database_name
201
+ db_path = validated_path(@config.database_name, "SQLite database path")
186
202
 
187
203
  unless File.exist?(db_path)
188
204
  raise "SQLite database not found: #{db_path}"
189
205
  end
190
206
 
191
- stdout, stderr, status = Open3.capture3("sqlite3 \"#{db_path}\" .dump")
207
+ stdout, stderr, status = capture_command("sqlite3", db_path, ".dump")
192
208
  unless status.success?
193
209
  raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
194
210
  end
@@ -200,6 +216,9 @@ module RailsNexus
200
216
 
201
217
  # ─── MongoDB ───────────────────────────────────────────────────
202
218
  def dump_mongodb
219
+ validate_identifier!(@config.database_name, "database name")
220
+ validate_host!(@config.host.presence || "localhost")
221
+ validate_port!(@config.port || 27_017)
203
222
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
204
223
  dir_path = raw_path.gsub(/\.sql$/, "")
205
224
  FileUtils.mkdir_p(dir_path)
@@ -211,21 +230,31 @@ module RailsNexus
211
230
  cmd << "--db=#{@config.database_name}"
212
231
  cmd << "--out=#{dir_path}"
213
232
  cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
214
- cmd << "--password=#{password}" if password.present?
215
233
  cmd << "--authenticationDatabase=admin" if @config.username.present?
216
234
 
217
235
  # Skip collections
218
- @config.skip_tables_list.each { |t| cmd << "--excludeCollection=#{t}" }
236
+ @config.skip_tables_list.each do |collection|
237
+ validate_identifier!(collection, "collection name")
238
+ cmd << "--excludeCollection=#{collection}"
239
+ end
219
240
 
220
- stdout, stderr, status = Open3.capture3(cmd.join(" "))
241
+ stdout, stderr, status = if password.present?
242
+ with_secret_file("rails_nexus_mongo", "password: #{password.to_json}\n") do |config_path|
243
+ capture_command(*cmd, "--config=#{config_path}")
244
+ end
245
+ else
246
+ capture_command(*cmd)
247
+ end
221
248
  unless status.success?
222
- FileUtils.rm_rf(dir_path) if Dir.exist?(dir_path)
249
+ FileUtils.rm_rf(dir_path)
223
250
  raise "mongodump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
224
251
  end
225
252
 
226
253
  # Tar the dump directory
227
254
  tar_path = "#{dir_path}.tar"
228
- stdout, stderr, status = Open3.capture3("tar -cf \"#{tar_path}\" -C \"#{File.dirname(dir_path)}\" \"#{File.basename(dir_path)}\"")
255
+ stdout, stderr, status = capture_command(
256
+ "tar", "-cf", tar_path, "-C", File.dirname(dir_path), File.basename(dir_path)
257
+ )
229
258
  unless status.success?
230
259
  raise "tar failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
231
260
  end
@@ -240,16 +269,14 @@ module RailsNexus
240
269
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
241
270
  raw_path = raw_path.gsub(/\.sql$/, ".rdb")
242
271
 
243
- host = @config.host || "localhost"
272
+ host = @config.host.presence || "localhost"
244
273
  port = @config.port || 6379
245
274
  password = resolve_password(@config.password)
275
+ validate_host!(host)
276
+ validate_port!(port)
246
277
 
247
278
  # Trigger BGSAVE first
248
- cmd = "redis-cli -h #{host} -p #{port}"
249
- cmd += " -a #{password}" if password.present?
250
- cmd += " BGSAVE"
251
-
252
- stdout, stderr, status = Open3.capture3(cmd)
279
+ _, stderr, status = capture_redis(host, port, password, "BGSAVE")
253
280
  unless status.success?
254
281
  @errors << "redis BGSAVE failed: #{stderr}"
255
282
  return nil
@@ -260,8 +287,7 @@ module RailsNexus
260
287
 
261
288
  # Try to find the dump.rdb on the server or use redis-cli to copy
262
289
  # If local Redis, copy the dump.rdb directly
263
- redis_dir_cmd = "#{cmd.gsub(' BGSAVE', '')} CONFIG GET dir"
264
- dir_out, = Open3.capture3(redis_dir_cmd)
290
+ dir_out, = capture_redis(host, port, password, "CONFIG", "GET", "dir")
265
291
  redis_dir = dir_out.split("\n").last || "/var/lib/redis"
266
292
 
267
293
  redis_rdb = File.join(redis_dir, "dump.rdb")
@@ -277,23 +303,20 @@ module RailsNexus
277
303
  end
278
304
 
279
305
  def dump_all_keys(host, port, password, output_path)
280
- cmd = "redis-cli -h #{host} -p #{port}"
281
- cmd += " -a #{password}" if password.present?
282
-
283
- keys_out, = Open3.capture3("#{cmd} KEYS '*'")
306
+ keys_out, = capture_redis(host, port, password, "KEYS", "*")
284
307
  keys = keys_out.split("\n").reject { |k| k.start_with?("redis") }
285
308
 
286
309
  File.open(output_path, "w") do |f|
287
310
  keys.each do |key|
288
- type_out, = Open3.capture3("#{cmd} TYPE \"#{key}\"")
311
+ type_out, = capture_redis(host, port, password, "TYPE", key)
289
312
  type = type_out.strip.split("\n").last
290
313
 
291
314
  val_out, = case type
292
- when "string" then Open3.capture3("#{cmd} GET \"#{key}\"")
293
- when "list" then Open3.capture3("#{cmd} LRANGE \"#{key}\" 0 -1")
294
- when "set" then Open3.capture3("#{cmd} SMEMBERS \"#{key}\"")
295
- when "hash" then Open3.capture3("#{cmd} HGETALL \"#{key}\"")
296
- else Open3.capture3("#{cmd} DUMP \"#{key}\"")
315
+ when "string" then capture_redis(host, port, password, "GET", key)
316
+ when "list" then capture_redis(host, port, password, "LRANGE", key, "0", "-1")
317
+ when "set" then capture_redis(host, port, password, "SMEMBERS", key)
318
+ when "hash" then capture_redis(host, port, password, "HGETALL", key)
319
+ else capture_redis(host, port, password, "DUMP", key)
297
320
  end
298
321
 
299
322
  f.puts("SET #{key.inspect} #{val_out.strip.inspect}")
@@ -325,14 +348,19 @@ module RailsNexus
325
348
  @temp_files << dump_path
326
349
  end
327
350
 
328
- @config.archive_paths_list.each { |p| cmd << p }
351
+ @config.archive_paths_list.each do |path|
352
+ cmd << validated_path(path, "archive path")
353
+ end
329
354
 
330
355
  # Exclude patterns
331
356
  if @config.archive_excludes_list.any?
332
- @config.archive_excludes_list.each { |e| cmd << "--exclude=#{e}" }
357
+ @config.archive_excludes_list.each do |pattern|
358
+ validate_text_argument!(pattern, "archive exclusion")
359
+ cmd << "--exclude=#{pattern}"
360
+ end
333
361
  end
334
362
 
335
- stdout, stderr, status = Open3.capture3(cmd.join(" "))
363
+ _, stderr, status = capture_command(*cmd)
336
364
  unless status.success?
337
365
  @errors << "archive tar failed: #{stderr}"
338
366
  return dump_path
@@ -358,7 +386,7 @@ module RailsNexus
358
386
 
359
387
  def compress_bzip2(file_path)
360
388
  bz2_path = "#{file_path}.bz2"
361
- stdout, stderr, status = Open3.capture3("bzip2 -zk \"#{file_path}\"")
389
+ _, stderr, status = capture_command("bzip2", "-zk", file_path)
362
390
  unless status.success?
363
391
  @errors << "bzip2 failed: #{stderr}"
364
392
  return file_path
@@ -377,14 +405,14 @@ module RailsNexus
377
405
 
378
406
  # Best practice: AES-256-CBC + PBKDF2 + 600K iterations + random salt
379
407
  # (AES-GCM not available in all OpenSSL builds)
380
- cmd = "openssl enc -aes-256-cbc"
381
- cmd += " -pbkdf2 -iter 600000"
382
- cmd += " -salt"
383
- cmd += " -in \"#{file_path}\""
384
- cmd += " -out \"#{enc_path}\""
385
- cmd += " -pass pass:#{password}"
386
-
387
- stdout, stderr, status = Open3.capture3(cmd)
408
+ env = { "RAILS_NEXUS_OPENSSL_PASSWORD" => password.to_s }
409
+ cmd = [
410
+ "openssl", "enc", "-aes-256-cbc", "-pbkdf2", "-iter", "600000",
411
+ "-salt", "-in", file_path, "-out", enc_path,
412
+ "-pass", "env:RAILS_NEXUS_OPENSSL_PASSWORD"
413
+ ]
414
+
415
+ _, stderr, status = capture_command(env, *cmd)
388
416
  unless status.success?
389
417
  @errors << "OpenSSL encryption failed: #{stderr}"
390
418
  return file_path
@@ -398,15 +426,16 @@ module RailsNexus
398
426
  enc_path = "#{file_path}.gpg"
399
427
  password = resolve_secret(@config.gpg_password)
400
428
 
401
- # Best practice: AES256 with s2k-mode=iter for key derivation
402
- cmd = "echo #{password.shellescape} | gpg --batch --yes --symmetric " \
403
- "--cipher-algo AES256 " \
404
- "--s2k-mode 3 " \
405
- "--s2k-count 65011712 " \
406
- "--passphrase-fd 0 " \
407
- "-o \"#{enc_path}\" \"#{file_path}\""
408
-
409
- stdout, stderr, status = Open3.capture3(cmd)
429
+ # Best practice: AES256 with an iteration-counted S2K. The passphrase is
430
+ # provided through a mode-0600 temporary file, never process arguments.
431
+ _, stderr, status = with_secret_file("rails_nexus_gpg", password.to_s) do |password_path|
432
+ capture_command(
433
+ "gpg", "--batch", "--yes", "--symmetric",
434
+ "--cipher-algo", "AES256", "--s2k-mode", "3",
435
+ "--s2k-count", "65011712", "--passphrase-file", password_path,
436
+ "-o", enc_path, file_path
437
+ )
438
+ end
410
439
  unless status.success?
411
440
  @errors << "GPG encryption failed: #{stderr}"
412
441
  return file_path
@@ -425,8 +454,7 @@ module RailsNexus
425
454
  FileUtils.mkdir_p(chunk_dir)
426
455
 
427
456
  # Split 50MB chunks
428
- cmd = "split -b 50m \"#{file_path}\" \"#{chunk_dir}/part_\""
429
- stdout, stderr, status = Open3.capture3(cmd)
457
+ _, stderr, status = capture_command("split", "-b", "50m", file_path, File.join(chunk_dir, "part_"))
430
458
 
431
459
  unless status.success?
432
460
  @errors << "split failed: #{stderr}"
@@ -445,6 +473,10 @@ module RailsNexus
445
473
  return unless @config.rsync_enabled?
446
474
  return unless @config.rsync_host.present?
447
475
 
476
+ validate_host!(@config.rsync_host)
477
+ validate_port!(@config.rsync_port.presence || 22)
478
+ validate_remote_component!(@config.rsync_user, "rsync user")
479
+ validate_remote_path!(@config.rsync_path)
448
480
  remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
449
481
 
450
482
  # Build rsync command
@@ -471,6 +503,7 @@ module RailsNexus
471
503
  # Add excludes
472
504
  if @config.respond_to?(:rsync_excludes) && @config.rsync_excludes.present?
473
505
  @config.rsync_excludes.split(",").map(&:strip).reject(&:blank?).each do |excl|
506
+ validate_text_argument!(excl, "rsync exclusion")
474
507
  cmd << "--exclude=#{excl}"
475
508
  end
476
509
  end
@@ -500,16 +533,20 @@ module RailsNexus
500
533
  end
501
534
 
502
535
  def sync_s3_aws(file_path)
536
+ validate_s3_bucket!(@config.s3_bucket)
503
537
  bucket = @config.s3_bucket
504
538
  prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
505
539
 
506
- cmd = "AWS_ACCESS_KEY_ID=#{@config.s3_access_key} " \
507
- "AWS_SECRET_ACCESS_KEY=#{@config.s3_secret_key} " \
508
- "aws s3 cp \"#{file_path}\" " \
509
- "s3://#{bucket}/#{prefix}" \
510
- "--region #{@config.s3_region || 'us-east-1'}"
540
+ env = {
541
+ "AWS_ACCESS_KEY_ID" => @config.s3_access_key.to_s,
542
+ "AWS_SECRET_ACCESS_KEY" => @config.s3_secret_key.to_s
543
+ }
544
+ cmd = [
545
+ "aws", "s3", "cp", file_path, "s3://#{bucket}/#{prefix}",
546
+ "--region", @config.s3_region.presence || "us-east-1"
547
+ ]
511
548
 
512
- stdout, stderr, status = Open3.capture3(cmd)
549
+ stdout, stderr, status = capture_command(env, *cmd)
513
550
  unless status.success?
514
551
  @errors << "S3 upload failed: #{stderr}"
515
552
  end
@@ -518,16 +555,20 @@ module RailsNexus
518
555
  end
519
556
 
520
557
  def sync_s3_s3cmd(file_path)
558
+ validate_s3_bucket!(@config.s3_bucket)
521
559
  bucket = @config.s3_bucket
522
560
  prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
523
561
 
524
- cmd = "s3cmd put \"#{file_path}\" " \
525
- "s3://#{bucket}/#{prefix} " \
526
- "--access_key=#{@config.s3_access_key} " \
527
- "--secret_key=#{@config.s3_secret_key} " \
528
- "--region=#{@config.s3_region || 'us-east-1'}"
562
+ env = {
563
+ "AWS_ACCESS_KEY" => @config.s3_access_key.to_s,
564
+ "AWS_SECRET_KEY" => @config.s3_secret_key.to_s
565
+ }
566
+ cmd = [
567
+ "s3cmd", "put", file_path, "s3://#{bucket}/#{prefix}",
568
+ "--region=#{@config.s3_region.presence || 'us-east-1'}"
569
+ ]
529
570
 
530
- stdout, stderr, status = Open3.capture3(cmd)
571
+ stdout, stderr, status = capture_command(env, *cmd)
531
572
  unless status.success?
532
573
  @errors << "S3 upload (s3cmd) failed: #{stderr}"
533
574
  end
@@ -548,22 +589,12 @@ module RailsNexus
548
589
 
549
590
  while files.size > @config.keep_count
550
591
  old_file = files.shift
551
- if File.directory?(old_file)
552
- FileUtils.rm_rf(old_file)
553
- else
554
- File.delete(old_file) if File.exist?(old_file)
555
- end
592
+ FileUtils.rm_rf(old_file)
556
593
  end
557
594
  end
558
595
 
559
596
  def cleanup_temp_files
560
- @temp_files.each do |f|
561
- if File.directory?(f)
562
- FileUtils.rm_rf(f)
563
- else
564
- File.delete(f) if File.exist?(f)
565
- end
566
- end
597
+ @temp_files.each { |file| FileUtils.rm_rf(file) }
567
598
  end
568
599
 
569
600
  # ════════════════════════════════════════════════════════════════
@@ -585,13 +616,23 @@ module RailsNexus
585
616
  def execute_notify_command(status, error = nil)
586
617
  return if @config.notify_command.blank?
587
618
 
588
- cmd = @config.notify_command
589
- .gsub("{STATUS}", status.to_s)
590
- .gsub("{MODEL}", @config.name)
591
- .gsub("{ERROR}", error.to_s)
592
- .gsub("{TIME}", Time.current.iso8601)
619
+ command = Shellwords.split(@config.notify_command.to_s)
620
+ raise ArgumentError, "notify command is empty" if command.empty?
621
+ if %w[sh bash dash zsh ksh fish env].include?(File.basename(command.first))
622
+ raise ArgumentError, "shell-based notify commands are not supported"
623
+ end
593
624
 
594
- Open3.capture3(cmd)
625
+ replacements = {
626
+ "{STATUS}" => status.to_s,
627
+ "{MODEL}" => @config.name.to_s,
628
+ "{ERROR}" => error.to_s,
629
+ "{TIME}" => Time.current.iso8601
630
+ }
631
+ command.map! do |argument|
632
+ replacements.reduce(argument) { |value, (placeholder, replacement)| value.gsub(placeholder, replacement) }
633
+ end
634
+
635
+ capture_command(*command)
595
636
  rescue StandardError
596
637
  # Don't fail backup because notification failed
597
638
  end
@@ -608,12 +649,10 @@ module RailsNexus
608
649
 
609
650
  # Build mail command based on available MTA
610
651
  if command_exists?("mail")
611
- cmd = "echo #{body.shellescape} | mail -s #{subject.shellescape} #{@config.email_to}"
612
- Open3.capture3(cmd)
652
+ capture_command("mail", "-s", subject, @config.email_to.to_s, stdin_data: body)
613
653
  elsif command_exists?("sendmail")
614
654
  mail_content = "To: #{@config.email_to}\nSubject: #{subject}\n\n#{body}"
615
- cmd = "echo #{mail_content.shellescape} | sendmail -t"
616
- Open3.capture3(cmd)
655
+ capture_command("sendmail", "-t", stdin_data: mail_content)
617
656
  else
618
657
  @errors << "No mail command found (mail/sendmail)"
619
658
  end
@@ -649,16 +688,12 @@ module RailsNexus
649
688
  resolve_secret(value)
650
689
  end
651
690
 
652
- def resolve_env(value)
653
- resolve_secret(value)
654
- end
691
+ alias_method :resolve_env, :resolve_password
655
692
 
656
693
  def resolve_secret(value)
657
694
  return nil if value.blank?
658
695
  case value
659
- when /^ENV\[(.+?)\]$/
660
- ENV[$1]
661
- when /^\$\{(.+?)\}$/
696
+ when /^ENV\[(.+?)\]$/, /^\$\{(.+?)\}$/
662
697
  ENV[$1]
663
698
  when /^credentials\[/
664
699
  # credentials[:key] or credentials[:key][:subkey]
@@ -670,7 +705,107 @@ module RailsNexus
670
705
  end
671
706
 
672
707
  def command_exists?(cmd)
673
- system("which #{cmd} > /dev/null 2>&1")
708
+ executable = cmd.to_s
709
+ return false unless executable.match?(/\A[a-zA-Z0-9_.+-]+\z/)
710
+
711
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
712
+ path = File.join(directory, executable)
713
+ File.file?(path) && File.executable?(path)
714
+ end
715
+ end
716
+
717
+ def capture_redis(host, port, password, *arguments)
718
+ env = {}
719
+ env["REDISCLI_AUTH"] = password if password.present?
720
+ capture_command(env, "redis-cli", "-h", host.to_s, "-p", port.to_s, *arguments.map(&:to_s))
721
+ end
722
+
723
+ def with_secret_file(prefix, contents)
724
+ Tempfile.create([prefix, ".conf"]) do |file|
725
+ file.chmod(0o600)
726
+ file.write(contents)
727
+ file.flush
728
+ yield file.path
729
+ end
730
+ end
731
+
732
+ def validated_mysql_options(value)
733
+ allowed = %r{\A(?:
734
+ --ssl-mode=(?:DISABLED|PREFERRED|REQUIRED|VERIFY_CA|VERIFY_IDENTITY)|
735
+ --ssl-(?:ca|cert|key)=\S+|
736
+ --protocol=(?:TCP|SOCKET)|
737
+ --socket=\S+|
738
+ --default-character-set=[a-zA-Z0-9_-]+|
739
+ --max-allowed-packet=\d+[KMG]?|
740
+ --set-gtid-purged=(?:OFF|ON|AUTO)|
741
+ --column-statistics=[01]|
742
+ --no-tablespaces|
743
+ --skip-lock-tables
744
+ )\z}x
745
+
746
+ Shellwords.split(value.to_s).tap do |options|
747
+ invalid = options.reject { |option| option.match?(allowed) }
748
+ raise ArgumentError, "unsupported MySQL option: #{invalid.first}" if invalid.any?
749
+ end
750
+ rescue ArgumentError => error
751
+ raise ArgumentError, "invalid MySQL additional options: #{error.message}"
752
+ end
753
+
754
+ def validate_identifier!(value, label)
755
+ candidate = value.to_s
756
+ unless candidate.match?(/\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/) && !candidate.include?("..")
757
+ raise ArgumentError, "invalid #{label}"
758
+ end
759
+ candidate
760
+ end
761
+
762
+ def validate_port!(value)
763
+ port = Integer(value)
764
+ raise ArgumentError, "invalid port" unless port.between?(1, 65_535)
765
+ port
766
+ rescue ArgumentError, TypeError
767
+ raise ArgumentError, "invalid port"
768
+ end
769
+
770
+ def validate_host!(value)
771
+ host = value.to_s
772
+ unless host.match?(/\A[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\z/)
773
+ raise ArgumentError, "invalid host"
774
+ end
775
+ host
776
+ end
777
+
778
+ def validated_path(value, label)
779
+ validate_text_argument!(value, label)
780
+ File.expand_path(value.to_s)
781
+ end
782
+
783
+ def validate_remote_component!(value, label)
784
+ candidate = value.to_s
785
+ raise ArgumentError, "invalid #{label}" unless candidate.match?(/\A[a-zA-Z0-9_.-]+\z/)
786
+ candidate
787
+ end
788
+
789
+ def validate_remote_path!(value)
790
+ path = value.to_s
791
+ raise ArgumentError, "invalid rsync path" unless path.start_with?("/")
792
+ validate_text_argument!(path, "rsync path")
793
+ end
794
+
795
+ def validate_s3_bucket!(value)
796
+ bucket = value.to_s
797
+ unless bucket.length.between?(3, 63) && bucket.match?(/\A[a-z0-9][a-z0-9.-]*[a-z0-9]\z/) && !bucket.include?("..")
798
+ raise ArgumentError, "invalid S3 bucket"
799
+ end
800
+ bucket
801
+ end
802
+
803
+ def validate_text_argument!(value, label)
804
+ candidate = value.to_s
805
+ if candidate.empty? || candidate.include?("\0") || candidate.include?("\n") || candidate.include?("\r")
806
+ raise ArgumentError, "invalid #{label}"
807
+ end
808
+ candidate
674
809
  end
675
810
 
676
811
  def failure_message(exception)