Skip to content

Commit

Permalink
Merge pull request #26 from basecamp/protect-db-drivers
Browse files Browse the repository at this point in the history
Improve protection mechanisms, design and documentation
  • Loading branch information
jorgemanrubia authored Sep 4, 2021
2 parents 61a2429 + 7e58724 commit 8fcea30
Show file tree
Hide file tree
Showing 47 changed files with 940 additions and 324 deletions.
3 changes: 2 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ GIT
PATH
remote: .
specs:
console1984 (0.1.6)
console1984 (0.1.7)
colorize
parser

GEM
remote: https://rubygems.org/
Expand Down
17 changes: 17 additions & 0 deletions config/command_protections.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
forbidden_reopening:
- ActiveRecord
- Console1984
- PG
- Mysql2
forbidden_constant_reference:
always:
- Console1984
protected:
- PG
- Mysql2
- ActiveRecord::ActiveRecordEncryption
suspicious_terms:
- console_1984
- Console1984
- secret
- credentials
9 changes: 0 additions & 9 deletions config/routes.rb

This file was deleted.

1 change: 1 addition & 0 deletions console1984.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Gem::Specification.new do |spec|
spec.files = Dir['{app,config,db,lib}/**/*', 'MIT-LICENSE', 'Rakefile', 'README.md', 'test/fixtures/**/*']

spec.add_dependency 'colorize'
spec.add_dependency 'parser'

spec.add_development_dependency 'benchmark-ips'
spec.add_development_dependency 'mocha'
Expand Down
53 changes: 36 additions & 17 deletions lib/console1984.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,57 @@
class_loader = Zeitwerk::Loader.for_gem
class_loader.setup

# = Console 1984
#
# Console1984 is an IRB-based Rails console extension that does
# three things:
#
# * Record console sessions with their user, reason and commands.
# * Protect encrypted data by showing the ciphertexts when you visualize it.
# * Protect access to external systems that contain sensitive information (such as Redis
# or Elasticsearch).
#
# == Session logging
#
# The console will record the session, its user and the commands entered. The logic to
# persist sessions is handled by the configured session logger, which is
# Console1984::SessionsLogger::Database by default.
#
# == Execution of commands
#
# The console will work in two modes:
#
# * Protected: It won't show encrypted information (it will show the ciphertexts instead)
# and it won't allow connections to protected urls.
# * Unprotected: it allows access to encrypted information and protected urls. The commands
# executed in this mode as flagged as sensitive.
#
# Console1984::CommandExecutor handles the execution of commands applying the corresponding
# protection mechanisms.´
#
# == Internal tampering prevention
#
# Finally, console1984 includes protection mechanisms against internal tampering while using
# the console. For example, to prevent the user from deleting audit trails. See
# Console1984::Shield and Console1984::CommandValidator to learn more.
module Console1984
include Messages, Freezeable

mattr_accessor :supervisor, default: Supervisor.new

mattr_reader :config, default: Config.new
mattr_accessor :class_loader

thread_mattr_accessor :currently_protected_urls, default: []
mattr_accessor :class_loader

class << self
Config::PROPERTIES.each do |property|
delegate property, to: :config
end

# Returns whether the console is currently running in protected mode or not.
def running_protected_environment?
protected_environments.collect(&:to_sym).include?(Rails.env.to_sym)
end

def protecting(&block)
protecting_connections do
ActiveRecord::Encryption.protecting_encrypted_data(&block)
end
end

private
def protecting_connections
old_currently_protected_urls = self.currently_protected_urls
self.currently_protected_urls = protected_urls
yield
ensure
self.currently_protected_urls = old_currently_protected_urls
end
end
end

Expand Down
94 changes: 94 additions & 0 deletions lib/console1984/command_executor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Supervise execution of console commands:
#
# * It will {validate commands}[rdoc-ref:Console1984::CommandValidator] before running
# them.
# * It will execute the commands in {protected mode}[rdoc-ref:Console1984::Shield#with_protected_mode]
# if needed.
# * It will log the command execution, and flag suspicious attempts and forbidden commands
# appropriately.
class Console1984::CommandExecutor
include Console1984::Freezeable

delegate :username_resolver, :session_logger, :shield, to: Console1984

# Logs and validates +commands+, and executes the passed block in a protected environment.
#
# Suspicious commands will be executed but flagged as suspicious. Forbidden commands will
# be prevented and flagged too.
def execute(commands, &block)
run_as_system { session_logger.before_executing commands }
validate_command commands
execute_in_protected_mode(&block)
rescue Console1984::Errors::ForbiddenCommand, FrozenError
flag_suspicious(commands)
rescue Console1984::Errors::SuspiciousCommand
flag_suspicious(commands)
execute_in_protected_mode(&block)
rescue FrozenError
flag_suspicious(commands)
ensure
run_as_system { session_logger.after_executing commands }
end

# Executes the passed block in protected mode.
#
# See Console1984::Shield::Modes.
def execute_in_protected_mode(&block)
run_as_user do
shield.with_protected_mode(&block)
end
end

# Executes the passed block as a user.
#
# While the block is being executed, #executing_user_command? will return true.
# This method helps implementing certain protection mechanisms that should only act with
# user commands.
def run_as_user(&block)
run_command true, &block
end

# Executes the passed block as the system.
#
# While the block is being executed, #executing_user_command? will return false.
def run_as_system(&block)
run_command false, &block
end

# Returns whether the system is currently executing a user command.
def executing_user_command?
@executing_user_command
end

# Validates the command.
#
# See Console1984::CommandValidator.
def validate_command(command)
command_validator.validate(command)
end

private
COMMAND_VALIDATOR_CONFIG_FILE_PATH = Console1984::Engine.root.join("config/command_protections.yml")

def command_validator
@command_validator ||= build_command_validator
end

def build_command_validator
Console1984::CommandValidator.from_config(YAML.safe_load(File.read(COMMAND_VALIDATOR_CONFIG_FILE_PATH)).symbolize_keys)
end

def flag_suspicious(commands)
puts "Forbidden command attempted: #{commands.join("\n")}"
run_as_system { session_logger.suspicious_commands_attempted commands }
nil
end

def run_command(run_by_user, &block)
original_value = @executing_user_command
@executing_user_command = run_by_user
block.call
ensure
@executing_user_command = original_value
end
end
71 changes: 71 additions & 0 deletions lib/console1984/command_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Validates console commands.
#
# This performs an static analysis of console commands. The analysis is meant to happen
# *before* commands are executed, so that they can prevent the execution if needed.
#
# The validation itself happens as a chain of validation objects. The system will invoke
# each validation in order. Validations will raise an error if the validation fails (typically
# a Console1984::Errors::ForbiddenCommand or Console1984::Errors::SuspiciousCommands).
#
# Internally, validations will receive a Console1984::CommandValidator::ParsedCommand object. This
# exposes parsed constructs in addition to the raw strings so that validations can use those.
#
# There is a convenience method .from_config that lets you instantiate a validation setup from
# a config hash (e.g to customize validations via YAML).
#
# See +config/command_protections.yml+ and the validations in +lib/console1984/command_validator+.
class Console1984::CommandValidator
include Console1984::Freezeable

def initialize
@validations_by_name = HashWithIndifferentAccess.new
end

class << self
# Instantiates a command validator that will configure the validations based on the config passed.
#
# For each key in +config+, it will derive the class Console1984::CommandValidator::#{key.camelize}Validation
# and will instantiate the validation passed the values as params.
#
# For example for this config:
#
# { forbidden_reopening: [ActiveRecord, Console1984] }
#
# It will instantiate Console1984::CommandValidator::ForbiddenReopeningValidation passing
# +["ActiveRecord", "Console1984"]+ in the constructor.
#
# # See +config/command_protections.yml+ as an example.
def from_config(config)
Console1984::CommandValidator.new.tap do |validator|
config.each do |validator_name, validator_config|
validator_class = "Console1984::CommandValidator::#{validator_name.to_s.camelize}Validation".constantize
validator_config.try(:symbolize_keys!)
validator.add_validation validator_name, validator_class.new(validator_config)
end
end
end
end

# Adds a +validation+ to the chain indexed by the provided +name+
#
# Validations are executed in the order they are added.
def add_validation(name, validation)
validations_by_name[name] = validation
end

# Executes the chain of validations passing a {parsed command}[rdoc-ref:Console1984::CommandValidator::ParsedCommand]
# created with the +command+ string passed by parameter.
#
# The validations are executed in the order they were added. If one validation raises an error, the error will
# raise and the rest of validations won't get checked.
def validate(command)
parsed_command = ParsedCommand.new(command)

validations_by_name.values.each do |validation|
validation.validate(parsed_command)
end
end

private
attr_reader :validations_by_name
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Validates references to a configured set of constants.
class Console1984::CommandValidator::ForbiddenConstantReferenceValidation
include Console1984::Freezeable

# +config+ will be a hash like:
#
# { always: [ Console1984 ], protected: [ PG, Mysql2 ] }
def initialize(shield = Console1984.shield, config)
# We make shield an injectable dependency for testing purposes. Everything is frozen
# for security purposes, so stubbing won't work.
@shield = shield

@forbidden_constants_names = config[:always] || []
@constant_names_forbidden_in_protected_mode = config[:protected] || []
end

# Raises a Console1984::Errors::ForbiddenCommand if a banned constant is referenced.
def validate(parsed_command)
if contains_invalid_const_reference?(parsed_command, @forbidden_constants_names) ||
(@shield.protected_mode? && contains_invalid_const_reference?(parsed_command, @constant_names_forbidden_in_protected_mode))
raise Console1984::Errors::ForbiddenCommand
end
end

private
def contains_invalid_const_reference?(parsed_command, banned_constants)
parsed_command.constants.find do |constant_name|
banned_constants.find { |banned_constant| "#{constant_name}::".start_with?("#{banned_constant}::") }
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Validates attempts to reopen classes and modules based on a configured set.
class Console1984::CommandValidator::ForbiddenReopeningValidation
include Console1984::Freezeable

attr_reader :banned_class_or_module_names

def initialize(banned_classes_or_modules)
@banned_class_or_module_names = banned_classes_or_modules.collect(&:to_s)
end

# Raises a Console1984::Errors::ForbiddenCommand if an banned class or module reopening
# is detected.
def validate(parsed_command)
if contains_invalid_class_or_module_declaration?(parsed_command)
raise Console1984::Errors::ForbiddenCommand
end
end

private
def contains_invalid_class_or_module_declaration?(parsed_command)
parsed_command.declared_classes_or_modules.find { |class_or_module_name| banned?(class_or_module_name) }
end

def banned?(class_or_module_name)
@banned_class_or_module_names.find do |banned_class_or_module_name|
"#{class_or_module_name}::".start_with?("#{banned_class_or_module_name}::")
end
end
end
Loading

0 comments on commit 8fcea30

Please sign in to comment.