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,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,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
|