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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +215 -0
- data/Rakefile +3 -0
- data/app/controllers/safe_bro/application_controller.rb +50 -0
- data/app/controllers/safe_bro/audits_controller.rb +35 -0
- data/app/controllers/safe_bro/flagged_commands_controller.rb +14 -0
- data/app/controllers/safe_bro/rules_controller.rb +18 -0
- data/app/controllers/safe_bro/sessions_controller.rb +67 -0
- data/app/controllers/safe_bro/unlocks_controller.rb +14 -0
- data/app/helpers/safe_bro/application_helper.rb +59 -0
- data/app/jobs/safe_bro/purge_job.rb +10 -0
- data/app/models/safe_bro/application_record.rb +6 -0
- data/app/models/safe_bro/audit.rb +19 -0
- data/app/models/safe_bro/command.rb +20 -0
- data/app/models/safe_bro/sensitive_access.rb +13 -0
- data/app/models/safe_bro/session.rb +52 -0
- data/app/views/safe_bro/flagged_commands/index.html.erb +22 -0
- data/app/views/safe_bro/rules/show.html.erb +53 -0
- data/app/views/safe_bro/sessions/index.html.erb +51 -0
- data/app/views/safe_bro/sessions/show.html.erb +91 -0
- data/app/views/safe_bro/shared/forbidden.html.erb +3 -0
- data/app/views/safe_bro/unlocks/index.html.erb +25 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20261114000001_create_safe_bro_tables.rb +52 -0
- data/lib/generators/safe_bro/install/install_generator.rb +35 -0
- data/lib/generators/safe_bro/install/templates/safe_bro.rb +45 -0
- data/lib/safe_bro/cipher.rb +45 -0
- data/lib/safe_bro/commands.rb +25 -0
- data/lib/safe_bro/configuration.rb +153 -0
- data/lib/safe_bro/encrypted_type.rb +16 -0
- data/lib/safe_bro/engine.rb +33 -0
- data/lib/safe_bro/errors.rb +41 -0
- data/lib/safe_bro/executor.rb +100 -0
- data/lib/safe_bro/guards/const_guard.rb +39 -0
- data/lib/safe_bro/guards/credentials_guard.rb +31 -0
- data/lib/safe_bro/guards/env_guard.rb +67 -0
- data/lib/safe_bro/guards/file_guard.rb +28 -0
- data/lib/safe_bro/guards/guarded.rb +43 -0
- data/lib/safe_bro/guards/irb_context.rb +23 -0
- data/lib/safe_bro/guards/method_guard.rb +31 -0
- data/lib/safe_bro/guards/network_guard.rb +98 -0
- data/lib/safe_bro/guards/sql_guard.rb +18 -0
- data/lib/safe_bro/parsed_command.rb +66 -0
- data/lib/safe_bro/recorder.rb +77 -0
- data/lib/safe_bro/shield.rb +83 -0
- data/lib/safe_bro/supervisor.rb +68 -0
- data/lib/safe_bro/terminal.rb +55 -0
- data/lib/safe_bro/validator.rb +74 -0
- data/lib/safe_bro/version.rb +3 -0
- data/lib/safe_bro.rb +98 -0
- data/lib/tasks/safe_bro.rake +7 -0
- 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
|