Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve protection mechanisms, design and documentation #26

Merged
merged 7 commits into from
Sep 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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