safe_bro 0.1.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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +215 -0
  4. data/Rakefile +3 -0
  5. data/app/controllers/safe_bro/application_controller.rb +50 -0
  6. data/app/controllers/safe_bro/audits_controller.rb +35 -0
  7. data/app/controllers/safe_bro/flagged_commands_controller.rb +14 -0
  8. data/app/controllers/safe_bro/rules_controller.rb +18 -0
  9. data/app/controllers/safe_bro/sessions_controller.rb +67 -0
  10. data/app/controllers/safe_bro/unlocks_controller.rb +14 -0
  11. data/app/helpers/safe_bro/application_helper.rb +59 -0
  12. data/app/jobs/safe_bro/purge_job.rb +10 -0
  13. data/app/models/safe_bro/application_record.rb +6 -0
  14. data/app/models/safe_bro/audit.rb +19 -0
  15. data/app/models/safe_bro/command.rb +20 -0
  16. data/app/models/safe_bro/sensitive_access.rb +13 -0
  17. data/app/models/safe_bro/session.rb +52 -0
  18. data/app/views/safe_bro/flagged_commands/index.html.erb +22 -0
  19. data/app/views/safe_bro/rules/show.html.erb +53 -0
  20. data/app/views/safe_bro/sessions/index.html.erb +51 -0
  21. data/app/views/safe_bro/sessions/show.html.erb +91 -0
  22. data/app/views/safe_bro/shared/forbidden.html.erb +3 -0
  23. data/app/views/safe_bro/unlocks/index.html.erb +25 -0
  24. data/config/routes.rb +11 -0
  25. data/db/migrate/20261114000001_create_safe_bro_tables.rb +52 -0
  26. data/lib/generators/safe_bro/install/install_generator.rb +35 -0
  27. data/lib/generators/safe_bro/install/templates/safe_bro.rb +45 -0
  28. data/lib/safe_bro/cipher.rb +45 -0
  29. data/lib/safe_bro/commands.rb +25 -0
  30. data/lib/safe_bro/configuration.rb +153 -0
  31. data/lib/safe_bro/encrypted_type.rb +16 -0
  32. data/lib/safe_bro/engine.rb +33 -0
  33. data/lib/safe_bro/errors.rb +41 -0
  34. data/lib/safe_bro/executor.rb +100 -0
  35. data/lib/safe_bro/guards/const_guard.rb +39 -0
  36. data/lib/safe_bro/guards/credentials_guard.rb +31 -0
  37. data/lib/safe_bro/guards/env_guard.rb +67 -0
  38. data/lib/safe_bro/guards/file_guard.rb +28 -0
  39. data/lib/safe_bro/guards/guarded.rb +43 -0
  40. data/lib/safe_bro/guards/irb_context.rb +23 -0
  41. data/lib/safe_bro/guards/method_guard.rb +31 -0
  42. data/lib/safe_bro/guards/network_guard.rb +98 -0
  43. data/lib/safe_bro/guards/sql_guard.rb +18 -0
  44. data/lib/safe_bro/parsed_command.rb +66 -0
  45. data/lib/safe_bro/recorder.rb +77 -0
  46. data/lib/safe_bro/shield.rb +83 -0
  47. data/lib/safe_bro/supervisor.rb +68 -0
  48. data/lib/safe_bro/terminal.rb +55 -0
  49. data/lib/safe_bro/validator.rb +74 -0
  50. data/lib/safe_bro/version.rb +3 -0
  51. data/lib/safe_bro.rb +98 -0
  52. data/lib/tasks/safe_bro.rake +7 -0
  53. metadata +141 -0
@@ -0,0 +1,41 @@
1
+ module SafeBro
2
+ class Error < StandardError; end
3
+
4
+ # Setup problems that stop a console from starting (fail closed).
5
+ class MissingUsername < Error
6
+ def initialize(msg = "SafeBro needs to know who you are. Set CONSOLE_USER (e.g. via SSH SetEnv), or enable config.ask_for_username_if_empty.")
7
+ super
8
+ end
9
+ end
10
+
11
+ class MissingReason < Error
12
+ def initialize(msg = "SafeBro needs a reason for this session. Set CONSOLE_REASON when there is no terminal to ask on.")
13
+ super
14
+ end
15
+ end
16
+
17
+ class NotInteractive < Error
18
+ def initialize(msg = "This needs an interactive terminal.")
19
+ super
20
+ end
21
+ end
22
+
23
+ # Raised when a console command is not allowed. The command is not executed.
24
+ class ForbiddenCommand < Error; end
25
+
26
+ # Raised when a command tries to change SafeBro's own audit trail.
27
+ class TamperingAttempt < ForbiddenCommand; end
28
+
29
+ # Raised when a command touches a protected resource while the console is locked.
30
+ class ProtectedResource < ForbiddenCommand
31
+ attr_reader :resource
32
+
33
+ def initialize(resource, detail = nil)
34
+ @resource = resource
35
+ super([ "Access to #{resource} is protected.", detail, "Run unlock! to get access (you will be asked why)." ].compact.join(" "))
36
+ end
37
+ end
38
+
39
+ # Not blocked, but recorded as suspicious.
40
+ class SuspiciousCommand < Error; end
41
+ end
@@ -0,0 +1,100 @@
1
+ module SafeBro
2
+ # Supervises every command typed into the console:
3
+ #
4
+ # * records it (before it runs, so a crash can't hide it)
5
+ # * validates it, blocking forbidden commands and flagging suspicious ones
6
+ # * runs it with the shield's protections on
7
+ # * flags attempts to reach protected resources that happen while it runs
8
+ class Executor
9
+ # console_path: the file name IRB gives the code it evaluates, e.g. "(irb)" or "(myapp)" (Rails
10
+ # names it after the app). It is how we tell typed code from library code.
11
+ def execute(commands, console_path: nil, &block)
12
+ @console_path = console_path if console_path
13
+ statements = Array(commands)
14
+ command = run_as_system { SafeBro.recorder.record_command(statements) }
15
+
16
+ if (suspicion = validate(command, statements))
17
+ flag(command, :suspicious, suspicion.message)
18
+ end
19
+
20
+ begin
21
+ execute_in_protected_mode(&block)
22
+ rescue ProtectedResource, ForbiddenCommand => error
23
+ flag(command, error.is_a?(ProtectedResource) ? :suspicious : :blocked, error.message)
24
+ raise masked(error)
25
+ rescue FrozenError => error
26
+ # Someone tried to alter a frozen SafeBro class.
27
+ flag(command, :blocked, "Attempt to modify protected code: #{error.message}")
28
+ raise masked(error)
29
+ rescue => error
30
+ raise masked(error)
31
+ end
32
+ end
33
+
34
+ # Runs the block with the shield's protections on. The mode itself decides what is
35
+ # protected, depending on what is currently unlocked.
36
+ def execute_in_protected_mode(&block)
37
+ run_as_user { SafeBro.shield.protect(&block) }
38
+ end
39
+
40
+ def run_as_user(&block)
41
+ run_command(true, &block)
42
+ end
43
+
44
+ def run_as_system(&block)
45
+ run_command(false, &block)
46
+ end
47
+
48
+ def executing_user_command?
49
+ @executing_user_command == true
50
+ end
51
+
52
+ # Was this method called straight from what the person typed (as opposed to from library code)?
53
+ # IRB evaluates input under a fixed file name, and C-implemented methods like send or
54
+ # instance_eval don't add frames, so the caller location is still the console.
55
+ def from_console?(locations)
56
+ executing_user_command? && locations.first&.path.to_s == (@console_path || "(irb)")
57
+ end
58
+
59
+ private
60
+ # Returns a suspicion (command still runs) or raises ForbiddenCommand (it doesn't).
61
+ def validate(command, statements)
62
+ run_as_system { SafeBro.validator.validate(statements, locked: SafeBro.shield.locked?) }
63
+ nil
64
+ rescue SuspiciousCommand => suspicion
65
+ suspicion
66
+ rescue ForbiddenCommand => error
67
+ flag(command, :blocked, error.message)
68
+ raise masked(error)
69
+ end
70
+
71
+ def flag(command, status, reason)
72
+ run_as_system { SafeBro.recorder.flag(command, status, reason) }
73
+ Terminal.alert "#{status == :blocked ? 'blocked' : 'flagged for review'} - #{reason}"
74
+ end
75
+
76
+ def run_command(by_user)
77
+ original = @executing_user_command
78
+ @executing_user_command = by_user
79
+ yield
80
+ ensure
81
+ @executing_user_command = original
82
+ end
83
+
84
+ # Error messages are built lazily and IRB prints them after the command finished, i.e.
85
+ # outside the shield. Build them inside it, so they can't leak what the command couldn't read.
86
+ def masked(error)
87
+ %i[ message to_s inspect detailed_message ].each do |name|
88
+ next unless error.respond_to?(name)
89
+
90
+ original = error.method(name)
91
+ error.define_singleton_method(name) do |*args, **kwargs|
92
+ SafeBro.executor.execute_in_protected_mode { original.call(*args, **kwargs) }
93
+ end
94
+ end
95
+ error
96
+ rescue FrozenError
97
+ error
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,39 @@
1
+ module SafeBro
2
+ module Guards
3
+ # Constants can be built from strings (Object.const_get("Safe" + "Bro"), "SafeBro".constantize),
4
+ # which the validator can't see. Resolve them through the validator when asked from the console.
5
+ module ConstGuard
6
+ module Lookup
7
+ def const_get(name, *args)
8
+ ConstGuard.check!(name, caller_locations(1, 1)) if SafeBro.executor.executing_user_command?
9
+ super
10
+ end
11
+ end
12
+
13
+ module StringLookup
14
+ def constantize
15
+ ConstGuard.check!(self, caller_locations(1, 1)) if SafeBro.executor.executing_user_command?
16
+ super
17
+ end
18
+
19
+ def safe_constantize
20
+ ConstGuard.check!(self, caller_locations(1, 1)) if SafeBro.executor.executing_user_command?
21
+ super
22
+ end
23
+ end
24
+
25
+ def self.check!(name, locations)
26
+ return unless SafeBro.executor.from_console?(locations)
27
+
28
+ SafeBro.executor.run_as_system { SafeBro.validator.validate(name.to_s, locked: SafeBro.shield.locked?) }
29
+ rescue SuspiciousCommand
30
+ nil
31
+ end
32
+
33
+ def self.install
34
+ Module.prepend(Lookup)
35
+ String.prepend(StringLookup)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ module SafeBro
2
+ module Guards
3
+ # Rails credentials, the keys that open them, and the secret everything is derived from.
4
+ module CredentialsGuard
5
+ # inspect prints the decrypted values once they have been loaded.
6
+ module Inspect
7
+ def inspect
8
+ if SafeBro.executor.executing_user_command? && SafeBro.shield.protects?(:credentials)
9
+ "#<#{self.class.name} [protected]>"
10
+ else
11
+ super
12
+ end
13
+ end
14
+ end
15
+
16
+ def self.install
17
+ if defined?(ActiveSupport::EncryptedConfiguration)
18
+ Guarded.install(ActiveSupport::EncryptedConfiguration,
19
+ %i[ read config options [] fetch dig method_missing instance_variable_get ], :credentials,
20
+ inherited: %i[ instance_variable_get ])
21
+ ActiveSupport::EncryptedConfiguration.prepend(Inspect)
22
+ end
23
+ if defined?(ActiveSupport::EncryptedFile)
24
+ Guarded.install(ActiveSupport::EncryptedFile, %i[ read write change key read_env_key read_key_file ], :credentials)
25
+ end
26
+ Guarded.install(Rails::Application, %i[ secret_key_base secrets key_generator message_verifier message_verifiers ], :credentials)
27
+ Guarded.install(Rails::Application::Configuration, %i[ secret_key_base ], :credentials)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,67 @@
1
+ module SafeBro
2
+ module Guards
3
+ # ENV holds secrets in most deployments. Reading a protected variable by name raises; dumping
4
+ # the whole thing returns it with the protected values filtered.
5
+ module EnvGuard
6
+ FILTERED = "[FILTERED]".freeze
7
+ KEYED = %i[ [] fetch ].freeze
8
+ MULTI = %i[ values_at fetch_values slice ].freeze
9
+ DUMPS = %i[ to_h to_hash to_a each each_pair select filter reject values key ].freeze
10
+ # Shown whenever the console prints a value, not only when typed directly.
11
+ DISPLAY = %i[ inspect ].freeze
12
+
13
+ def self.redacted
14
+ ENV.to_h.to_h { |name, value| [ name, SafeBro.config.protected_env_name?(name) ? FILTERED : value ] }
15
+ end
16
+
17
+ def self.guard_name!(name, caller_locations)
18
+ return unless SafeBro.config.protected_env_name?(name)
19
+
20
+ SafeBro.guard!(:env, "(#{name})", locations: caller_locations)
21
+ end
22
+
23
+ module Guard
24
+ EnvGuard::KEYED.each do |method_name|
25
+ define_method(method_name) do |name, *args, **kwargs, &block|
26
+ EnvGuard.guard_name!(name, caller_locations(1, 1)) if SafeBro.executor.executing_user_command?
27
+ super(name, *args, **kwargs, &block)
28
+ end
29
+ end
30
+
31
+ EnvGuard::MULTI.each do |method_name|
32
+ define_method(method_name) do |*names, **kwargs, &block|
33
+ if SafeBro.executor.executing_user_command?
34
+ locations = caller_locations(1, 1)
35
+ names.flatten.each { |name| EnvGuard.guard_name!(name, locations) }
36
+ end
37
+ super(*names, **kwargs, &block)
38
+ end
39
+ end
40
+
41
+ EnvGuard::DUMPS.each do |method_name|
42
+ define_method(method_name) do |*args, **kwargs, &block|
43
+ if SafeBro.executor.executing_user_command? && SafeBro.executor.from_console?(caller_locations(1, 1)) && SafeBro.shield.protects?(:env)
44
+ EnvGuard.redacted.public_send(method_name, *args, **kwargs, &block)
45
+ else
46
+ super(*args, **kwargs, &block)
47
+ end
48
+ end
49
+ end
50
+
51
+ EnvGuard::DISPLAY.each do |method_name|
52
+ define_method(method_name) do |*args, **kwargs, &block|
53
+ if SafeBro.executor.executing_user_command? && SafeBro.shield.protects?(:env)
54
+ EnvGuard.redacted.public_send(method_name, *args, **kwargs, &block)
55
+ else
56
+ super(*args, **kwargs, &block)
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ def self.install
63
+ ENV.singleton_class.prepend(Guard)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,28 @@
1
+ require "pathname"
2
+ require "fileutils"
3
+
4
+ module SafeBro
5
+ module Guards
6
+ # Files that hold secrets (master.key, .env) can't be read from the console while locked.
7
+ module FileGuard
8
+ def self.protected_path?(path)
9
+ return false unless path.respond_to?(:to_path) || path.is_a?(String)
10
+
11
+ expanded = File.expand_path(path.respond_to?(:to_path) ? path.to_path : path)
12
+ SafeBro.config.protected_file_patterns.any? { |pattern| pattern.match?(expanded) }
13
+ rescue ArgumentError, TypeError
14
+ false
15
+ end
16
+
17
+ def self.install
18
+ by_path = ->(_receiver, args) { protected_path?(args.first) }
19
+ Guarded.install(IO, %i[ read binread readlines foreach ], :files, condition: by_path, scope: :singleton)
20
+ Guarded.install(File, %i[ new open ], :files, condition: by_path, scope: :singleton, inherited: %i[ new open ])
21
+ copying = ->(_receiver, args) { Array(args.first).any? { |path| protected_path?(path) } }
22
+ Guarded.install(FileUtils, %i[ cp copy copy_file cp_r mv move install ], :files, condition: copying, scope: :singleton)
23
+ Guarded.install(Kernel, %i[ open ], :files, condition: by_path)
24
+ Guarded.install(Pathname, %i[ read binread readlines ], :files, condition: ->(receiver, _) { protected_path?(receiver) })
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,43 @@
1
+ module SafeBro
2
+ module Guards
3
+ # Builds and prepends a module that makes the given methods raise ProtectedResource when
4
+ # they are called straight from the console while the resource is locked. Library code
5
+ # calling the same methods is unaffected.
6
+ module Guarded
7
+ class << self
8
+ # condition: ->(receiver, args) { true } narrows which calls are guarded.
9
+ # inherited: names to guard even though the owner doesn't define them itself (the guard
10
+ # then sits in front of the inherited method).
11
+ def install(target, methods, resource, condition: nil, scope: :instance, inherited: [])
12
+ owner = scope == :singleton ? target.singleton_class : target
13
+ guard_module = Module.new
14
+ guarded = 0
15
+
16
+ Array(methods).each do |name|
17
+ visibility = visibility_of(owner, name) || (:public if inherited.include?(name)) or next
18
+ guard_module.define_method(name) do |*args, **kwargs, &block|
19
+ if SafeBro.executor.executing_user_command? && (condition.nil? || condition.call(self, args))
20
+ SafeBro.guard!(resource, "(#{name})", locations: caller_locations(1, 1))
21
+ end
22
+ super(*args, **kwargs, &block)
23
+ end
24
+ guard_module.send(visibility, name)
25
+ guarded += 1
26
+ end
27
+
28
+ owner.prepend(guard_module) if guarded > 0
29
+ guard_module
30
+ end
31
+
32
+ private
33
+ # Only methods the owner itself defines, so subclasses aren't guarded twice.
34
+ def visibility_of(owner, name)
35
+ if owner.public_instance_methods(false).include?(name) then :public
36
+ elsif owner.protected_instance_methods(false).include?(name) then :protected
37
+ elsif owner.private_instance_methods(false).include?(name) then :private
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,23 @@
1
+ module SafeBro
2
+ module Guards
3
+ # Routes everything typed into IRB through the executor.
4
+ module IrbContext
5
+ # Printing the result of a command evaluates #inspect on it, which can read protected data.
6
+ def inspect_last_value(...)
7
+ SafeBro.executor.execute_in_protected_mode { super }
8
+ end
9
+
10
+ def evaluate(statement, ...)
11
+ # irb passes an IRB::Statement; its #code is what was typed.
12
+ code = statement.respond_to?(:code) ? statement.code : statement
13
+ SafeBro.executor.execute(Array(code), console_path: safe_bro_console_path) { super }
14
+ end
15
+
16
+ private
17
+ # The file name the evaluated code runs under.
18
+ def safe_bro_console_path
19
+ respond_to?(:eval_path) ? eval_path : irb_path
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ module SafeBro
2
+ module Guards
3
+ # Runtime backstop for config.forbidden_methods. The validator catches `eval(...)` written
4
+ # out, this catches `send(:eval, ...)`, `method(:eval).call`, and friends.
5
+ module MethodGuard
6
+ def self.install
7
+ SafeBro.config.forbidden_methods.each do |entry|
8
+ owner_name, separator, method_name = entry.partition(/[#.]/)
9
+ owner = owner_name.safe_constantize or next
10
+ scope = separator == "." ? :singleton : :instance
11
+ install_one(owner, method_name.to_sym, scope, entry)
12
+ end
13
+ end
14
+
15
+ def self.install_one(target, name, scope, entry)
16
+ owner = scope == :singleton ? target.singleton_class : target
17
+ visibility = %i[ public protected private ].find { |v| owner.send(:"#{v}_instance_methods", false).include?(name) } or return
18
+
19
+ guard = Module.new
20
+ guard.define_method(name) do |*args, **kwargs, &block|
21
+ if SafeBro.executor.executing_user_command? && SafeBro.executor.from_console?(caller_locations(1, 1))
22
+ raise ForbiddenCommand, "Calling #{entry} is not allowed."
23
+ end
24
+ super(*args, **kwargs, &block)
25
+ end
26
+ guard.send(visibility, name)
27
+ owner.prepend(guard)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,98 @@
1
+ require "socket"
2
+ require "uri"
3
+
4
+ module SafeBro
5
+ module Guards
6
+ # Stops connections to protected systems (Redis, Elasticsearch, ...) while the network
7
+ # resource is locked. It checks new connections and writes on existing ones, so a
8
+ # connection pool opened before the command started doesn't get a free pass.
9
+ module NetworkGuard
10
+ class << self
11
+ def check!(host:, port:)
12
+ return unless SafeBro.executor.executing_user_command? && SafeBro.shield.protects?(:network)
13
+ return unless protected?(host, port)
14
+
15
+ raise ProtectedResource.new(:network, "(#{host}:#{port})")
16
+ end
17
+
18
+ # Existing connection: look at who it is connected to.
19
+ def check_socket!(socket)
20
+ address = socket.respond_to?(:remote_address) ? socket.remote_address : socket.to_io.remote_address
21
+ check!(host: address.ip_address, port: address.ip_port) if address.ip?
22
+ rescue SocketError, Errno::ENOTCONN, IOError
23
+ nil # not connected (yet): nothing to protect
24
+ end
25
+
26
+ def protected?(host, port)
27
+ targets.any? do |target|
28
+ target[:port] == port.to_i && (target[:host] == host.to_s || target[:addresses].include?(host.to_s))
29
+ end
30
+ end
31
+
32
+ def reset!
33
+ @targets = nil
34
+ end
35
+
36
+ private
37
+ def targets
38
+ @targets ||= SafeBro.config.protected_urls.map do |url|
39
+ host, port = host_and_port(url)
40
+ { host: host, port: port.to_i, addresses: resolve(host, port) }
41
+ end
42
+ end
43
+
44
+ def resolve(host, port)
45
+ Addrinfo.getaddrinfo(host, port, nil, :STREAM).map(&:ip_address)
46
+ rescue SocketError
47
+ []
48
+ end
49
+
50
+ def host_and_port(url)
51
+ uri = URI.parse(url.to_s)
52
+ return [ uri.host, uri.port ] if uri.host
53
+
54
+ host, _, port = url.to_s.rpartition(":")
55
+ [ host, port ]
56
+ rescue URI::InvalidURIError
57
+ host, _, port = url.to_s.rpartition(":")
58
+ [ host, port ]
59
+ end
60
+ end
61
+
62
+ module Connect
63
+ def initialize(host, port, *args, **kwargs)
64
+ NetworkGuard.check!(host: host, port: port)
65
+ super
66
+ end
67
+ end
68
+
69
+ module Writes
70
+ %i[ write write_nonblock syswrite ].each do |name|
71
+ define_method(name) do |*args, **kwargs, &block|
72
+ NetworkGuard.check_socket!(self) if SafeBro.executor.executing_user_command?
73
+ super(*args, **kwargs, &block)
74
+ end
75
+ end
76
+ end
77
+
78
+ module SocketConnect
79
+ %i[ connect connect_nonblock ].each do |name|
80
+ define_method(name) do |remote, *args, **kwargs|
81
+ if SafeBro.executor.executing_user_command?
82
+ address = remote.is_a?(Addrinfo) ? remote : Addrinfo.new(remote)
83
+ NetworkGuard.check!(host: address.ip_address, port: address.ip_port) if address.ip?
84
+ end
85
+ super(remote, *args, **kwargs)
86
+ end
87
+ end
88
+ end
89
+
90
+ def self.install
91
+ TCPSocket.prepend(Connect)
92
+ BasicSocket.prepend(Writes)
93
+ Socket.prepend(SocketConnect)
94
+ OpenSSL::SSL::SSLSocket.prepend(Writes) if defined?(OpenSSL::SSL::SSLSocket)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,18 @@
1
+ module SafeBro
2
+ module Guards
3
+ # Every statement ActiveRecord runs goes through the adapter's #log. A command that
4
+ # mentions SafeBro's tables (to read, change or delete the audit trail) is stopped here.
5
+ module SqlGuard
6
+ TABLES = /safe_bro_(sessions|sensitive_accesses|commands|audits)/i
7
+
8
+ private
9
+ def log(sql, *args, **kwargs, &block)
10
+ if SafeBro.executor.executing_user_command? && sql.to_s.match?(TABLES)
11
+ raise TamperingAttempt, "The audit trail can't be touched from the console."
12
+ end
13
+
14
+ super
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,66 @@
1
+ require "ripper"
2
+
3
+ module SafeBro
4
+ # A lenient lexical view of a console command. It uses Ripper's tokenizer, which keeps
5
+ # producing tokens for code that doesn't parse, so a syntax trick can't hide a constant
6
+ # or method name from the validations.
7
+ class ParsedCommand
8
+ attr_reader :source
9
+
10
+ def initialize(source)
11
+ @source = Array(source).join("\n")
12
+ end
13
+
14
+ def tokens
15
+ @tokens ||= Ripper.lex(source).map { |(_pos, type, text, _state)| [ type, text ] }
16
+ end
17
+
18
+ # Constant paths as written: "ActiveRecord::Encryption::Config" (leading :: dropped).
19
+ def constant_paths
20
+ @constant_paths ||= begin
21
+ paths = []
22
+ significant = tokens.reject { |type, _| type == :on_sp || type == :on_ignored_nl || type == :on_nl }
23
+ significant.each_with_index do |(type, text), i|
24
+ next unless type == :on_const
25
+
26
+ previous = significant[i - 1]
27
+ after_scope = i > 0 && previous == [ :on_op, "::" ]
28
+ qualified = after_scope && i > 1 && significant[i - 2][0] == :on_const
29
+ if qualified && paths.any?
30
+ paths[-1] = "#{paths.last}::#{text}"
31
+ else
32
+ paths << text
33
+ end
34
+ end
35
+ paths
36
+ end
37
+ end
38
+
39
+ # Names after `class`/`module`: the things this command (re)opens.
40
+ def reopened_constants
41
+ @reopened_constants ||= begin
42
+ significant = tokens.reject { |type, _| type == :on_sp }
43
+ significant.each_with_index.filter_map do |(type, text), i|
44
+ next unless type == :on_kw && %w[ class module ].include?(text)
45
+
46
+ name = +""
47
+ significant[(i + 1)..].each do |t, x|
48
+ break unless t == :on_const || (t == :on_op && x == "::")
49
+ name << x
50
+ end
51
+ name.delete_prefix("::").presence
52
+ end
53
+ end
54
+ end
55
+
56
+ # Identifiers that might be method calls, plus backtick/%x shell execution as "`".
57
+ def method_names
58
+ @method_names ||= tokens.filter_map do |type, text|
59
+ case type
60
+ when :on_ident, :on_const, :on_kw then text
61
+ when :on_backtick then "`"
62
+ end
63
+ end.uniq
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,77 @@
1
+ module SafeBro
2
+ # Writes the audit trail. Everything here runs as the system (never as a user command),
3
+ # and silences SQL logging so the trail doesn't spill into the console.
4
+ class Recorder
5
+ attr_reader :session, :sensitive_access
6
+
7
+ def start_session(username:, reason:, source: "console")
8
+ quietly do
9
+ @session = Session.create!(
10
+ username: username, reason: reason, source: source,
11
+ hostname: Socket.gethostname, pid: Process.pid, started_at: Time.current
12
+ )
13
+ end
14
+ SafeBro.notify(:session_started, session: @session)
15
+ @session
16
+ end
17
+
18
+ def finish_session
19
+ return unless @session
20
+
21
+ quietly do
22
+ end_sensitive_access
23
+ @session.update!(ended_at: Time.current)
24
+ end
25
+ @session = nil
26
+ end
27
+
28
+ # resources: array of symbols, e.g. [:all] or [:credentials]
29
+ def start_sensitive_access(resources, justification)
30
+ quietly do
31
+ end_sensitive_access
32
+ @sensitive_access = session.sensitive_accesses.create!(
33
+ resources: resources.join(","), justification: justification, started_at: Time.current
34
+ )
35
+ end
36
+ SafeBro.notify(:unlocked, session: session, access: @sensitive_access)
37
+ @sensitive_access
38
+ end
39
+
40
+ def end_sensitive_access
41
+ return unless @sensitive_access
42
+
43
+ quietly { @sensitive_access.update!(ended_at: Time.current) }
44
+ @sensitive_access = nil
45
+ end
46
+
47
+ # Records a command before it runs, linked to the current unlock window if any.
48
+ def record_command(statements)
49
+ quietly do
50
+ session.commands.create!(statements: Array(statements).join("\n").strip, sensitive_access: sensitive_access)
51
+ end
52
+ end
53
+
54
+ def flag(command, status, reason)
55
+ quietly { command.update!(status: status, flag_reason: reason) }
56
+ SafeBro.notify(:command_flagged, session: session, command: command, status: status, reason: reason)
57
+ command
58
+ end
59
+
60
+ private
61
+ def quietly(&block)
62
+ SafeBro.executor.run_as_system do
63
+ if SafeBro.debug
64
+ ensure_connected(&block)
65
+ else
66
+ ActiveRecord::Base.logger.silence { ensure_connected(&block) }
67
+ end
68
+ end
69
+ end
70
+
71
+ def ensure_connected
72
+ connection = Session.connection
73
+ connection.reconnect! unless connection.active?
74
+ yield
75
+ end
76
+ end
77
+ end