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,53 @@
1
+ <% bro_hub_crumb "Rules" %>
2
+ <% protected_now = @config.enabled? %>
3
+ <%= bro_hub_hero eyebrow: "Effective configuration · read only", title: "Rules", subtitle: (protected_now ? "active here" : "off here"),
4
+ tone: (protected_now ? :green : :muted),
5
+ lead: "What SafeBro locks, blocks and flags right now. Change it in config/initializers/safe_bro.rb." %>
6
+
7
+ <% tags = ->(values, tone = nil) {
8
+ values.any? ? tag.div(safe_join(values.map { |value| tag.span(safe_bro_rule(value), class: [ "bh-tag", tone ].compact.join(" "), style: "text-transform:none") }, " "), class: "bh-tags") : tag.span("none", class: "bh-cell-muted")
9
+ } %>
10
+
11
+ <div class="bh-split">
12
+ <section class="bh-split-main">
13
+ <h2 class="bh-h2">Locked until <code>unlock!</code></h2>
14
+ <dl class="bh-dl">
15
+ <dt>Resources</dt><dd><%= tags.call(@config.protected_resources, "is-amber") %></dd>
16
+ <dt>ENV names</dt><dd><%= tags.call(@config.protected_env_patterns) %></dd>
17
+ <dt>Files</dt><dd><%= tags.call(@config.protected_file_patterns) %></dd>
18
+ <dt>Systems</dt><dd><%= tags.call(@protected_hosts) %></dd>
19
+ </dl>
20
+
21
+ <h2 class="bh-h2" style="margin-top:36px">Blocked before it runs</h2>
22
+ <dl class="bh-dl">
23
+ <dt>Can't reopen</dt><dd><%= tags.call(@config.forbidden_reopening, "is-red") %></dd>
24
+ <dt>Never referenced</dt><dd><%= tags.call(Array(@config.forbidden_constants[:always]), "is-red") %></dd>
25
+ <dt>While locked</dt><dd><%= tags.call(Array(@config.forbidden_constants[:locked]), "is-red") %></dd>
26
+ <dt>Can't be called</dt><dd><%= tags.call(@config.forbidden_methods, "is-red") %></dd>
27
+ </dl>
28
+
29
+ <h2 class="bh-h2" style="margin-top:36px">Flagged when mentioned</h2>
30
+ <%= tags.call(@config.suspicious_terms, "is-amber") %>
31
+ </section>
32
+
33
+ <div class="bh-split-aside">
34
+ <section class="bh-block">
35
+ <h2 class="bh-h2">Where it runs</h2>
36
+ <dl class="bh-dl">
37
+ <dt>Environments</dt><dd><%= @config.protected_environments.join(", ") %></dd>
38
+ <dt>Forced</dt><dd><%= @config.enabled.nil? ? "no" : @config.enabled ? "on" : "off" %></dd>
39
+ <dt>Runners</dt><dd><%= @config.audit_runner ? "recorded" : "not recorded" %></dd>
40
+ <dt>Ask for a name</dt><dd><%= @config.ask_for_username_if_empty ? "yes" : "no, console refuses to start" %></dd>
41
+ </dl>
42
+ </section>
43
+ <section class="bh-block">
44
+ <h2 class="bh-h2">Review</h2>
45
+ <dl class="bh-dl">
46
+ <dt>Keep sessions</dt><dd><%= @config.retention ? distance_of_time_in_words(@config.retention) : "forever" %></dd>
47
+ <dt>Keep flagged</dt><dd><%= @config.keep_flagged ? "yes, even past that" : "no" %></dd>
48
+ <dt>Self review</dt><dd><%= @config.allow_self_audit ? "allowed" : "not allowed" %></dd>
49
+ <dt>Require SSL</dt><dd><%= @config.require_ssl ? "yes" : "no" %></dd>
50
+ </dl>
51
+ </section>
52
+ </div>
53
+ </div>
@@ -0,0 +1,51 @@
1
+ <% status = @filter[:status] || "all" %>
2
+ <% bro_hub_crumb "Console sessions" %>
3
+ <% content_for :bro_hub_crumb_actions do %>
4
+ <% [ [ "Pending", "pending" ], [ "Appropriate", "approved" ], [ "Flagged", "flagged" ], [ "All", "all" ] ].each do |label, value| %>
5
+ <%= bro_hub_crumb_link label, root_path(@filter.merge(status: value)), active: status == value %>
6
+ <% end %>
7
+ <% end %>
8
+
9
+ <%= bro_hub_hero eyebrow: "Console sessions",
10
+ title: @total,
11
+ tone: { "approved" => :green, "flagged" => :red }[status],
12
+ subtitle: { "pending" => "awaiting a verdict", "approved" => "approved", "flagged" => "flagged" }.fetch(status, @total == 1 ? "session" : "sessions"),
13
+ lead: "Who used a console, why, and what they did. Review each one: approve it or flag it.",
14
+ stats: [ [ @unlock_total, "unlocks", :amber ], [ @flag_total, "flagged commands", :red ] ] %>
15
+
16
+ <%= form_with url: root_path, method: :get, class: "bh-filters" do %>
17
+ <%= hidden_field_tag :status, status %>
18
+ <%= text_field_tag :username, @filter[:username], placeholder: "Filter by user", class: "bh-input is-strong", "aria-label": "Filter by user" %>
19
+ <label class="bh-field">From <%= date_field_tag :from, @filter[:from], class: "bh-input" %></label>
20
+ <label class="bh-field">To <%= date_field_tag :to, @filter[:to], class: "bh-input" %></label>
21
+ <% attention = @filter[:attention] == "1" %>
22
+ <%= link_to "Needs attention only", root_path(@filter.merge(status: status, attention: (attention ? nil : "1"))),
23
+ class: [ "bh-chip", ("is-on" if attention) ], title: "Unlocked sensitive data or flagged commands" %>
24
+ <%= hidden_field_tag :attention, "1" if attention %>
25
+ <%= submit_tag "Filter", name: nil, class: "bh-chip" %>
26
+ <%= link_to "Reset", root_path(status: status), class: "bh-link-quiet" %>
27
+ <% end %>
28
+
29
+ <div class="bh-table-wrap">
30
+ <div class="bh-table" style="--cols: 150px 120px minmax(220px,1fr) 80px 64px 200px 110px; --min: 920px">
31
+ <div class="bh-row is-head"><span>Started</span><span>User</span><span>Reason</span><span>Source</span><span>Length</span><span>Flags</span><span>Review</span></div>
32
+ <% @sessions.each do |session| %>
33
+ <% unlocked = @unlock_counts.fetch(session.id, 0) %>
34
+ <% flagged = @flag_counts.fetch(session.id, 0) %>
35
+ <%= link_to session_path(session, @filter), class: [ "bh-row", ("bh-edge-red" if flagged > 0), ("bh-edge-amber" if flagged.zero? && unlocked > 0) ] do %>
36
+ <span class="bh-tone-violet"><%= safe_bro_time(session.started_at) %></span>
37
+ <span class="bh-cell-strong"><%= session.username %></span>
38
+ <span class="bh-cell-text"><%= truncate(session.reason, length: 90) %></span>
39
+ <span class="bh-cell-muted"><%= session.source %></span>
40
+ <span><%= safe_bro_duration(session.duration) %><%= " (open)" if session.open? %></span>
41
+ <span class="bh-tags">
42
+ <%= content_tag(:span, safe_bro_count(unlocked, "unlock"), class: "bh-tag is-amber") if unlocked > 0 %>
43
+ <%= content_tag(:span, "#{flagged} flagged", class: "bh-tag is-red") if flagged > 0 %>
44
+ </span>
45
+ <%= review_status_text(session.review_status) %>
46
+ <% end %>
47
+ <% end %>
48
+ <% if @sessions.empty? %><div class="bh-empty">Nothing to review.</div><% end %>
49
+ <%= safe_bro_pager total: @total, page: @page, noun: "session" %>
50
+ </div>
51
+ </div>
@@ -0,0 +1,91 @@
1
+ <% bro_hub_crumb "Console sessions", "##{@session.id}" %>
2
+ <% content_for :bro_hub_crumb_actions do %>
3
+ <%= bro_hub_crumb_link "← All sessions", root_path(filter_params) %>
4
+ <% end %>
5
+
6
+ <% flagged_count = @commands.count(&:flagged?) %>
7
+ <%= bro_hub_hero eyebrow: "#{@session.source} · #{@session.hostname} (pid #{@session.pid})",
8
+ title: "Session ##{@session.id}",
9
+ subtitle: review_status_text(@session.review_status),
10
+ lead: safe_join([ content_tag(:strong, @session.username), " was on a console for #{safe_bro_duration(@session.duration)}. Reason given: “#{@session.reason}”" ]) %>
11
+
12
+ <div class="bh-split">
13
+ <section class="bh-split-main">
14
+ <div class="bh-block-head">
15
+ <h2 class="bh-h2">What they ran</h2>
16
+ <span class="bh-meta"><%= safe_bro_count(@commands.size, "command") %> · <%= flagged_count %> flagged</span>
17
+ </div>
18
+
19
+ <div class="bh-log">
20
+ <% if @commands.empty? %><div class="bh-cmd"><div class="bh-cmd-meta"><span>Nothing was typed.</span></div></div><% end %>
21
+ <% previous_access = nil %>
22
+ <% @commands.each do |command| %>
23
+ <% if command.sensitive_access && command.sensitive_access != previous_access %>
24
+ <div class="bh-unlock">
25
+ <span style="text-transform:uppercase">Unlocked</span> (<%= command.sensitive_access.resources %>) at <%= safe_bro_time(command.sensitive_access.started_at) %>:
26
+ “<%= command.sensitive_access.justification %>”
27
+ </div>
28
+ <% elsif command.sensitive_access.nil? && previous_access %>
29
+ <div class="bh-cmd"><div class="bh-cmd-meta"><span>Locked again</span></div></div>
30
+ <% end %>
31
+ <% previous_access = command.sensitive_access %>
32
+ <div class="bh-cmd <%= "is-flagged is-#{command.status}" if command.flagged? %> <%= "is-unlocked" if command.sensitive_access && !command.flagged? %> <%= "is-sensitive" if command.sensitive_access %>">
33
+ <div class="bh-cmd-meta">
34
+ <%= safe_bro_time(command.created_at) %>
35
+ <%= command_flag(command) %>
36
+ <% if command.flag_reason.present? %><span class="bh-note"><%= command.flag_reason %></span><% end %>
37
+ </div>
38
+ <pre class="bh-cmd-code"><%= command.statements %></pre>
39
+ </div>
40
+ <% end %>
41
+ </div>
42
+ </section>
43
+
44
+ <div class="bh-split-aside">
45
+ <section class="bh-block">
46
+ <h2 class="bh-h2">Details</h2>
47
+ <dl class="bh-dl">
48
+ <dt>User</dt><dd><%= @session.username %></dd>
49
+ <dt>Reason</dt><dd><%= @session.reason %></dd>
50
+ <dt>Started</dt><dd><%= safe_bro_time(@session.started_at) %></dd>
51
+ <dt>Ended</dt><dd><%= @session.ended_at ? safe_bro_time(@session.ended_at) : "still open, or ended abruptly" %></dd>
52
+ <dt>Source</dt><dd><%= @session.source %></dd>
53
+ <dt>Host</dt><dd><%= @session.hostname %> (pid <%= @session.pid %>)</dd>
54
+ <dt>Unlocks</dt><dd class="bh-tone-amber"><%= @session.sensitive_accesses.size %></dd>
55
+ <dt>Flagged</dt><dd class="bh-tone-red"><%= flagged_count %></dd>
56
+ </dl>
57
+ </section>
58
+
59
+ <section class="bh-block">
60
+ <h2 class="bh-h2">Verdict</h2>
61
+ <% if @can_audit_this %>
62
+ <%= form_with url: session_audits_path(@session), scope: :audit do |form| %>
63
+ <% filter_params.each do |key, value| %>
64
+ <%= hidden_field_tag(key == :status ? :filter_status : key, value) %>
65
+ <% end %>
66
+ <div class="bh-seg">
67
+ <label class="is-ok"><%= form.radio_button :status, "approved", checked: true %><span>Appropriate</span></label>
68
+ <label class="is-bad"><%= form.radio_button :status, "flagged" %><span>Flag</span></label>
69
+ </div>
70
+ <label class="bh-field" style="margin-top:22px">Notes
71
+ <%= form.text_area :notes, rows: 4, class: "bh-textarea" %>
72
+ </label>
73
+ <div class="bh-hint">Required when flagging. Stored encrypted.</div>
74
+ <%= form.submit "Save and next", class: "bh-btn", style: "margin-top:20px" %>
75
+ <% end %>
76
+ <% else %>
77
+ <p class="bh-hint">This is your own session. Someone else has to review it.</p>
78
+ <% end %>
79
+
80
+ <% if @audits.any? %>
81
+ <h2 class="bh-h2" style="margin-top:32px">History</h2>
82
+ <dl class="bh-dl">
83
+ <% @audits.each do |audit| %>
84
+ <dt><%= review_status_text(audit.status == "approved" ? "approved" : audit.status) %></dt>
85
+ <dd><%= audit.auditor %> <span class="bh-cell-muted"><%= safe_bro_time(audit.created_at) %></span><br><%= audit.notes %></dd>
86
+ <% end %>
87
+ </dl>
88
+ <% end %>
89
+ </section>
90
+ </div>
91
+ </div>
@@ -0,0 +1,3 @@
1
+ <% bro_hub_crumb "Not allowed" %>
2
+ <%= bro_hub_hero eyebrow: "Console sessions", title: "Not allowed", tone: :red,
3
+ lead: "You don't have permission to review console sessions. Ask an admin if you need access." %>
@@ -0,0 +1,25 @@
1
+ <% bro_hub_crumb "Unlocks" %>
2
+ <%= bro_hub_hero eyebrow: "Unlocks", title: @total, tone: (:amber if @total > 0), subtitle: (@total == 1 ? "unlock" : "unlocks"),
3
+ lead: "Every time someone unlocked sensitive data, with the reason they gave. Newest first.",
4
+ stats: [ [ @open_total, "still open", :amber ] ] %>
5
+
6
+ <div class="bh-table-wrap">
7
+ <div class="bh-table" style="--cols: 150px 120px 140px minmax(240px,1fr) 90px 80px; --min: 900px">
8
+ <div class="bh-row is-head"><span>Unlocked</span><span>User</span><span>Scope</span><span>Why</span><span>Commands</span><span>Session</span></div>
9
+ <% @unlocks.each do |unlock| %>
10
+ <%= link_to session_path(unlock.session_id), class: "bh-row bh-edge-amber" do %>
11
+ <span class="bh-tone-violet"><%= safe_bro_time(unlock.started_at) %></span>
12
+ <span class="bh-cell-strong"><%= unlock.session.username %></span>
13
+ <span class="bh-tags">
14
+ <span class="bh-tag is-amber"><%= unlock.resources %></span>
15
+ <%= content_tag(:span, "open", class: "bh-tag") if unlock.open? %>
16
+ </span>
17
+ <span class="bh-cell-text"><%= unlock.justification %></span>
18
+ <span><%= @command_counts.fetch(unlock.id, 0) %></span>
19
+ <span class="bh-tone-violet">#<%= unlock.session_id %></span>
20
+ <% end %>
21
+ <% end %>
22
+ <% if @unlocks.empty? %><div class="bh-empty">Nobody unlocked anything.</div><% end %>
23
+ <%= safe_bro_pager total: @total, page: @page, noun: "unlock" %>
24
+ </div>
25
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ SafeBro::Engine.routes.draw do
2
+ root "sessions#index"
3
+
4
+ resources :sessions, only: %i[ index show ] do
5
+ resources :audits, only: :create
6
+ end
7
+
8
+ get "flagged" => "flagged_commands#index", as: :flagged_commands
9
+ get "unlocks" => "unlocks#index", as: :unlocks
10
+ get "rules" => "rules#show", as: :rules
11
+ end
@@ -0,0 +1,52 @@
1
+ class CreateSafeBroTables < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :safe_bro_sessions do |t|
4
+ t.string :username, null: false
5
+ t.text :reason, null: false
6
+ t.string :source, null: false, default: "console"
7
+ t.string :hostname
8
+ t.integer :pid
9
+ t.integer :review_status, null: false, default: 0
10
+ t.datetime :started_at, null: false
11
+ t.datetime :ended_at
12
+
13
+ t.timestamps
14
+
15
+ t.index :started_at
16
+ t.index [ :username, :started_at ]
17
+ t.index [ :review_status, :started_at ]
18
+ end
19
+
20
+ create_table :safe_bro_sensitive_accesses do |t|
21
+ t.references :session, null: false, index: true
22
+ t.string :resources, null: false, default: "all"
23
+ t.text :justification, null: false
24
+ t.datetime :started_at, null: false
25
+ t.datetime :ended_at
26
+
27
+ t.timestamps
28
+ end
29
+
30
+ create_table :safe_bro_commands do |t|
31
+ t.references :session, null: false, index: false
32
+ t.references :sensitive_access, index: true
33
+ t.text :statements
34
+ t.integer :status, null: false, default: 0
35
+ t.text :flag_reason
36
+
37
+ t.timestamps
38
+
39
+ t.index [ :session_id, :created_at ]
40
+ t.index :status
41
+ end
42
+
43
+ create_table :safe_bro_audits do |t|
44
+ t.references :session, null: false, index: true
45
+ t.string :auditor, null: false
46
+ t.integer :status, null: false
47
+ t.text :notes
48
+
49
+ t.timestamps
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,35 @@
1
+ require "rails/generators"
2
+ require "generators/bro_hub/installable"
3
+
4
+ module SafeBro
5
+ module Generators
6
+ # bin/rails generate safe_bro:install
7
+ class InstallGenerator < Rails::Generators::Base
8
+ # BroHub first (one mount for all the Bro gems), then SafeBro itself.
9
+ include BroHub::Generators::Installable
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Sets up the BroHub dashboard (the review UI lives in it), adds the SafeBro initializer and copies the migrations."
14
+
15
+ def copy_initializer
16
+ copy_file "safe_bro.rb", "config/initializers/safe_bro.rb"
17
+ end
18
+
19
+ def copy_migrations
20
+ rails_command "safe_bro:install:migrations", inline: true
21
+ end
22
+
23
+ def next_steps
24
+ say <<~TEXT
25
+
26
+ SafeBro is installed. Next:
27
+ 1. bin/rails db:migrate
28
+ 2. Say who may review sessions in config/initializers/safe_bro.rb (config.can_audit)
29
+ 3. Make sure every console user arrives with CONSOLE_USER set (see the README)
30
+ 4. Visit #{bro_hub_path}/safe_bro (BroHub mounts SafeBro under its own path)
31
+ TEXT
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ SafeBro.configure do |config|
2
+ # --- Who may review console sessions in the SafeBro UI --------------------------------
3
+ # Nobody until you say so. The review UI is a section of the BroHub dashboard, so who is
4
+ # signed in comes from BroHub.config.current_user (your ApplicationController's `current_user`
5
+ # by default). Override it for SafeBro only with config.current_user below.
6
+ config.can_audit = ->(user) { false }
7
+ # config.current_user = ->(controller) { controller.current_user }
8
+ # config.user_label = ->(user) { user.email }
9
+ # config.allow_self_audit = false
10
+
11
+ # --- Where SafeBro acts ----------------------------------------------------------------
12
+ # Consoles started in these environments are recorded and guarded.
13
+ # config.protected_environments = %w[ production ]
14
+
15
+ # How SafeBro learns who is at the console. Set it where you can trust it: with SSH,
16
+ # `environment="CONSOLE_USER=jane"` on the key in authorized_keys. A SetEnv from the
17
+ # client can be set to anything.
18
+ # config.username_resolver = -> { ENV["CONSOLE_USER"] }
19
+ # config.ask_for_username_if_empty = false
20
+
21
+ # --- What is protected until someone runs unlock! ---------------------------------------
22
+ # config.protected_resources = %i[ encrypted_data credentials env files network ]
23
+ # config.protected_urls = [ "redis://cache.internal:6379", "https://search.internal:9200" ]
24
+ # config.protected_env_patterns += [ /STRIPE/ ]
25
+ # config.protected_file_patterns += [ %r{config/.*\.pem\z} ]
26
+
27
+ # --- Guardrails -------------------------------------------------------------------------
28
+ # config.forbidden_methods += [ "Kernel#open" ]
29
+ # config.suspicious_terms += [ "DROP TABLE" ]
30
+
31
+ # --- Retention ---------------------------------------------------------------------------
32
+ # Sessions older than this are removed by SafeBro::PurgeJob (schedule it) or
33
+ # `bin/rails safe_bro:purge`. Flagged sessions are kept. nil keeps everything.
34
+ # config.retention = 90.days
35
+ # config.keep_flagged = true
36
+
37
+ # Also record `rails runner` invocations as sessions.
38
+ # config.audit_runner = false
39
+ end
40
+
41
+ # Get told when something needs attention:
42
+ #
43
+ # ActiveSupport::Notifications.subscribe("command_flagged.safe_bro") do |*, payload|
44
+ # SecurityMailer.console_flag(payload[:session], payload[:reason]).deliver_later
45
+ # end
@@ -0,0 +1,45 @@
1
+ module SafeBro
2
+ # Recorded commands are encrypted at rest so that someone who can read the database (or a
3
+ # backup) can't read what people typed, which may include customer data.
4
+ module Cipher
5
+ # The encryptor serializes values before encrypting; we only ever pass strings.
6
+ module Raw
7
+ def self.dump(value) = value
8
+ def self.load(value) = value
9
+ end
10
+
11
+ class << self
12
+ def encrypt(text)
13
+ return text if text.nil?
14
+
15
+ encryptor.encrypt_and_sign(text.to_s)
16
+ end
17
+
18
+ def decrypt(cipher_text)
19
+ return cipher_text if cipher_text.nil?
20
+
21
+ encryptor.decrypt_and_verify(cipher_text) || "[could not decrypt]"
22
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature
23
+ "[could not decrypt]"
24
+ end
25
+
26
+ def reset!
27
+ @encryptor = nil
28
+ end
29
+
30
+ private
31
+ def encryptor
32
+ @encryptor ||= ActiveSupport::MessageEncryptor.new(key, cipher: "aes-256-gcm", serializer: Raw)
33
+ end
34
+
35
+ def key
36
+ configured = SafeBro.config.encryption_key
37
+ configured = configured.call if configured.respond_to?(:call)
38
+ configured ||= Rails.application.key_generator.generate_key("safe_bro/commands", 32)
39
+ raise Error, "SafeBro encryption_key must be 32 bytes" unless configured.bytesize == 32
40
+
41
+ configured
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,25 @@
1
+ module SafeBro
2
+ # The commands people can type into the console: `unlock!` and `lock!`.
3
+ module Commands
4
+ class Unlock < IRB::Command::Base
5
+ category "SafeBro"
6
+ description "Get access to protected data (asks why, and records it). Optionally name what: unlock! :credentials"
7
+
8
+ def execute(arg)
9
+ resources = arg.to_s.scan(/\w+/).map(&:to_sym)
10
+ SafeBro.shield.unlock(resources.presence || [ :all ])
11
+ nil
12
+ end
13
+ end
14
+
15
+ class Lock < IRB::Command::Base
16
+ category "SafeBro"
17
+ description "Protect sensitive data again."
18
+
19
+ def execute(_arg)
20
+ SafeBro.shield.lock
21
+ nil
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,153 @@
1
+ module SafeBro
2
+ class Configuration
3
+ # --- Where it runs -------------------------------------------------------------------
4
+
5
+ # SafeBro only acts on consoles started in these environments. Override with `enabled`.
6
+ attr_accessor :protected_environments
7
+
8
+ # true/false to force it on or off regardless of the environment.
9
+ attr_accessor :enabled
10
+
11
+ # Also record `rails runner` invocations as sessions.
12
+ attr_accessor :audit_runner
13
+
14
+ # --- Who is using the console --------------------------------------------------------
15
+
16
+ # -> { "name" } The identity of the person at the console. Trust this only as much as the
17
+ # layer that sets it (e.g. SSH `environment=` in authorized_keys is trustworthy, `SetEnv`
18
+ # from the client is not).
19
+ attr_accessor :username_resolver
20
+
21
+ # Ask on the terminal when the resolver returns nothing (otherwise the console refuses to start).
22
+ attr_accessor :ask_for_username_if_empty
23
+
24
+ # --- What is protected ---------------------------------------------------------------
25
+
26
+ # Which resources need unlock! (see Shield::RESOURCES).
27
+ attr_accessor :protected_resources
28
+
29
+ # URLs (https://host:port, redis://host:port, host:port) that can't be reached while locked.
30
+ attr_accessor :protected_urls
31
+
32
+ # ENV names that can't be read while locked (matched against the variable name).
33
+ attr_accessor :protected_env_patterns
34
+
35
+ # Files that can't be read from the console while locked.
36
+ attr_accessor :protected_file_patterns
37
+
38
+ # --- Guardrails ----------------------------------------------------------------------
39
+
40
+ # Classes/modules the console may not reopen (class Foo / module Foo / Foo.class_eval...).
41
+ attr_accessor :forbidden_reopening
42
+
43
+ # Constants the console may not reference. :always, or only while :locked.
44
+ attr_accessor :forbidden_constants
45
+
46
+ # "Owner#instance_method" or "Owner.singleton_method" that can't be called from the console.
47
+ attr_accessor :forbidden_methods
48
+
49
+ # Commands containing one of these are allowed but recorded as suspicious.
50
+ attr_accessor :suspicious_terms
51
+
52
+ # Freeze SafeBro's own classes once installed so they can't be monkey patched.
53
+ attr_accessor :freeze_internals
54
+
55
+ # --- Storage and review --------------------------------------------------------------
56
+
57
+ # Keep sessions this long, nil to keep forever (see SafeBro::PurgeJob / safe_bro:purge).
58
+ attr_accessor :retention
59
+
60
+ # Don't purge sessions an auditor flagged.
61
+ attr_accessor :keep_flagged
62
+
63
+ # 32 bytes (or a ->{ } returning them) used to encrypt recorded commands. Defaults to a key
64
+ # derived from the app's secret_key_base.
65
+ attr_accessor :encryption_key
66
+
67
+ # Controller the engine controllers inherit from (gives access to your authentication).
68
+ attr_accessor :parent_controller
69
+
70
+ # ->(controller) { user or nil }. Defaults to BroHub.config.current_user, so configuring BroHub once is enough.
71
+ attr_accessor :current_user
72
+
73
+ # ->(user) { "label" } shown in the UI and stored on audits. Defaults to BroHub.config.user_label.
74
+ attr_accessor :user_label
75
+
76
+ # ->(user) { true/false } may this user review sessions in the UI?
77
+ attr_accessor :can_audit
78
+
79
+ # May an auditor approve their own sessions? (compared by user_label vs session username)
80
+ attr_accessor :allow_self_audit
81
+
82
+ attr_accessor :require_ssl
83
+
84
+ # Texts shown in the console.
85
+ attr_accessor :welcome_message, :unlock_prompt, :unlocked_message, :locked_message
86
+
87
+ def initialize
88
+ @protected_environments = %w[ production ]
89
+ @enabled = nil
90
+ @audit_runner = false
91
+
92
+ @username_resolver = -> { ENV["CONSOLE_USER"] }
93
+ @ask_for_username_if_empty = false
94
+
95
+ @protected_resources = %i[ encrypted_data credentials env files network ]
96
+ @protected_urls = []
97
+ @protected_env_patterns = [ /SECRET/i, /TOKEN/i, /PASSWORD/i, /PASSPHRASE/i, /CREDENTIAL/i, /API_?KEY/i, /PRIVATE/i, /MASTER_?KEY/i, /DATABASE_URL/i, /\A[A-Z_]*KEY\z/ ]
98
+ @protected_file_patterns = [ %r{config/master\.key\z}, %r{config/credentials/.+\.key\z}, %r{(\A|/)\.env(\.[^/]+)?\z} ]
99
+
100
+ @forbidden_reopening = %w[ ActiveRecord ApplicationRecord SafeBro IRB ]
101
+ @forbidden_constants = {
102
+ always: %w[ SafeBro IRB ObjectSpace ],
103
+ locked: %w[ ActiveRecord::Encryption Open3 PTY Fiddle ]
104
+ }
105
+ @forbidden_methods = %w[
106
+ Kernel#eval Kernel.eval BasicObject#instance_eval BasicObject#instance_exec
107
+ Module#class_eval Module#module_eval Module#class_exec Module#module_exec
108
+ Kernel#system Kernel#exec Kernel#spawn Kernel#` Kernel#fork
109
+ Kernel.system Kernel.exec Kernel.spawn Kernel.` Kernel.fork
110
+ Process.spawn Process.exec Process.fork IO.popen
111
+ ]
112
+ @suspicious_terms = %w[ secret credentials master.key ENV safe_bro SafeBro ]
113
+ @freeze_internals = true
114
+
115
+ @retention = 90.days
116
+ @keep_flagged = true
117
+ @encryption_key = nil
118
+
119
+ @parent_controller = "::ApplicationController"
120
+ @current_user = ->(controller) { BroHub.config.current_user.call(controller) }
121
+ @user_label = ->(user) { BroHub.config.user_label.call(user) }
122
+ @can_audit = ->(_user) { false }
123
+ @allow_self_audit = false
124
+ @require_ssl = defined?(Rails) && Rails.env.production?
125
+
126
+ @welcome_message = "You have access to production data here. That's a big deal. Everything you type is recorded and " \
127
+ "reviewed. Be careful, be brief, and don't look at data you don't need."
128
+ @unlock_prompt = "Before you access sensitive data (encrypted attributes, credentials, ENV, protected systems), " \
129
+ "say why and where we can find the consent or ticket that covers it."
130
+ @unlocked_message = "Unlocked. We pay extra close attention to everything you do now. Type lock! to protect it again."
131
+ @locked_message = "Locked again. When we review, we may reach out about what you did while unlocked."
132
+ end
133
+
134
+ def enabled?
135
+ return enabled unless enabled.nil?
136
+
137
+ protected_environments.map(&:to_s).include?(Rails.env.to_s)
138
+ end
139
+
140
+ def protected_env_name?(name)
141
+ protected_env_patterns.any? { |pattern| pattern.match?(name.to_s) }
142
+ end
143
+
144
+ # Freezes everything so it can't be altered from a console.
145
+ def lock_down!
146
+ [ protected_environments, protected_resources, protected_urls, protected_env_patterns, protected_file_patterns,
147
+ forbidden_reopening, forbidden_methods, suspicious_terms ].each(&:freeze)
148
+ forbidden_constants.each_value(&:freeze)
149
+ forbidden_constants.freeze
150
+ freeze
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,16 @@
1
+ module SafeBro
2
+ # ActiveRecord attribute type that stores text encrypted with SafeBro::Cipher.
3
+ class EncryptedType < ActiveRecord::Type::Text
4
+ def serialize(value)
5
+ super(value.nil? ? nil : Cipher.encrypt(value))
6
+ end
7
+
8
+ def deserialize(value)
9
+ value.nil? ? nil : Cipher.decrypt(super)
10
+ end
11
+
12
+ def changed_in_place?(raw_old_value, new_value)
13
+ deserialize(raw_old_value) != new_value
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,33 @@
1
+ module SafeBro
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace SafeBro
4
+
5
+ # The review UI is a section of the BroHub dashboard. Registered before routes are drawn,
6
+ # so BroHub can mount this engine under <hub>/safe_bro.
7
+ initializer "safe_bro.bro_hub" do
8
+ BroHub.register :safe_bro,
9
+ name: "SafeBro", label: "SafeBro · audit", accent: :red, order: 10,
10
+ engine: "SafeBro::Engine",
11
+ visible_to: ->(user) { SafeBro.config.can_audit.call(user) },
12
+ items: [
13
+ { label: "Sessions", path: "/", match: %r{\A/(sessions(/|\z)|\z)}, badge: -> { SafeBro::Session.review_pending.count } },
14
+ { label: "Flagged commands", path: "/flagged" },
15
+ { label: "Unlocks", path: "/unlocks" },
16
+ { label: "Rules", path: "/rules" }
17
+ ]
18
+ end
19
+
20
+ # Nothing is installed by merely loading the gem. Protections start with the console.
21
+ console do
22
+ SafeBro.supervisor.run_console if SafeBro.enabled?
23
+ end
24
+
25
+ runner do
26
+ SafeBro.supervisor.run_runner if SafeBro.enabled? && SafeBro.config.audit_runner
27
+ end
28
+
29
+ rake_tasks do
30
+ load File.expand_path("../tasks/safe_bro.rake", __dir__)
31
+ end
32
+ end
33
+ end