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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c4906ce3efcad72d1fe0d51afcd111672739b28a722a4745bf4b012b8f611fda
4
+ data.tar.gz: ba294a2ffd0d1434f2dd99463a6f858fc8ea1be017dcd0a17217d199726d4a6c
5
+ SHA512:
6
+ metadata.gz: 38c8fc9959f76ccc547f8a7aa661a36270a1239a1a53c6c8c75816e88516fd46dc1e33ac9f5b0d373102de83479c801eec7de54f42acc0631985655bebabbe39
7
+ data.tar.gz: e1d93726cfd4dde7107fd6f0abbc1cd08144203657178e04dfa139d313720909373bd57f71b1bff5507fa25f5d6d3dca31b105a0e1ffd38b722abf37b7c29557
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Ruby Dev SRL (https://www.rubydev.ro)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # SafeBro
2
+
3
+ Audited, guarded Rails console access, with a built-in review UI. One gem.
4
+
5
+ - **Records** every console session: who, why, and every command (encrypted at rest).
6
+ - **Locks** sensitive data until the person runs `unlock!` and explains why: encrypted attributes, credentials, secret `ENV` values, key files, protected systems like Redis or Elasticsearch.
7
+ - **Checks** each command before it runs: forbidden ones are blocked, suspicious ones are flagged.
8
+ - **Protects its own audit trail** from the console.
9
+ - **Lets auditors review** sessions in a built-in UI: approve, or flag with notes. It's a section of the [BroHub](https://github.com/rubydevro/bro_hub) dashboard, the same shell [EnvBro](https://github.com/rubydevro/env_bro) uses.
10
+
11
+ It covers what [console1984](https://github.com/basecamp/console1984) and [audits1984](https://github.com/basecamp/audits1984) do together, rebuilt from scratch as a single gem. Rails 7.1+, Ruby 3.2+, IRB 1.13+.
12
+
13
+ > **Read [What this is not](#what-this-is-not).** SafeBro is a deterrent and an audit trail. It is not a sandbox.
14
+
15
+ ## Install
16
+
17
+ ```ruby
18
+ gem "safe_bro"
19
+ ```
20
+
21
+ ```sh
22
+ bin/rails generate safe_bro:install # BroHub (mount + initializer), then SafeBro's initializer and migrations. --mount-path=/bro_hub skips the question
23
+ bin/rails db:migrate
24
+ ```
25
+
26
+ The generator pulls in [BroHub](https://github.com/rubydevro/bro_hub) (a dependency) and mounts it once:
27
+
28
+ ```ruby
29
+ mount BroHub::Engine => "/bro_hub" # the review UI is at /bro_hub/safe_bro
30
+ ```
31
+
32
+ Then in `config/initializers/safe_bro.rb` say who may review sessions:
33
+
34
+ ```ruby
35
+ SafeBro.configure do |config|
36
+ config.can_audit = ->(user) { user.admin? }
37
+ end
38
+ ```
39
+
40
+ ### One dashboard for all the Bro gems
41
+
42
+ SafeBro shows up as a **SafeBro · audit** section in BroHub's sidebar (Sessions, Flagged commands, Unlocks, Rules), only for people who pass `can_audit`. Who is signed in is configured **once**, in BroHub (`BroHub.configure { |c| c.current_user = ... }`, which by default calls `current_user` on your `ApplicationController`). SafeBro uses that unless you set `config.current_user` / `config.user_label` yourself.
43
+
44
+ - SafeBro works with just BroHub installed. Add [EnvBro](https://github.com/rubydevro/env_bro) and it appears as a second section, with no extra mount or setup.
45
+ - Already mounting `SafeBro::Engine` yourself? That keeps working: you get the same look with only the SafeBro section. Remove your mount to join the dashboard.
46
+
47
+ By default SafeBro acts only in `production`. Nothing is installed by loading the gem; protection starts when a console starts (`config.protected_environments` to change it).
48
+
49
+ ### Telling SafeBro who is at the console
50
+
51
+ SafeBro reads the user from `CONSOLE_USER`. **A name is only as trustworthy as whatever sets it.** Prefer the server setting it, not the client:
52
+
53
+ ```
54
+ # ~/.ssh/authorized_keys on the server, one line per person
55
+ environment="CONSOLE_USER=jane" ssh-ed25519 AAAA... jane@laptop
56
+ ```
57
+
58
+ (needs `PermitUserEnvironment yes` in `sshd_config`). A client-side `SetEnv CONSOLE_USER=...` works too, but then anyone can claim to be anyone.
59
+
60
+ With no `CONSOLE_USER` the console refuses to start. Use `config.username_resolver = -> { ... }` to resolve it another way, or `config.ask_for_username_if_empty = true` to ask on the terminal.
61
+
62
+ ## What it looks like
63
+
64
+ ```
65
+ $ rails console
66
+
67
+ You have access to production data here. That's a big deal. ...
68
+
69
+ jane, why are you using this console today?
70
+ > Fixing invoice 12, ticket #4521
71
+
72
+ myapp(prod):001> Invoice.find(12).customer.email
73
+ => "{\"p\":\"iu6+LfnNlurC6sL++J...\"}" # an encrypted attribute, shown as ciphertext while locked
74
+
75
+ myapp(prod):002> ENV["STRIPE_SECRET_KEY"]
76
+ SafeBro: flagged for review - Access to env is protected. (STRIPE_SECRET_KEY) Run unlock! ...
77
+
78
+ myapp(prod):003> unlock! :env
79
+ Before you access sensitive data ... say why and where we can find the consent or ticket.
80
+ > Ticket #4521: customer asked us to rotate their key
81
+
82
+ Unlocked. We pay extra close attention to everything you do now. Type lock! to protect it again.
83
+ ```
84
+
85
+ Commands:
86
+
87
+ | Command | What it does |
88
+ | --- | --- |
89
+ | `unlock!` | Unlock everything. Asks for a justification and records it. |
90
+ | `unlock! :credentials` | Unlock only some resources (`encrypted_data`, `credentials`, `env`, `files`, `network`). |
91
+ | `lock!` | Lock again. |
92
+
93
+ Everything typed while unlocked is linked to that justification in the review UI.
94
+
95
+ ## What is protected
96
+
97
+ | Resource | While locked |
98
+ | --- | --- |
99
+ | `encrypted_data` | Active Record encrypted attributes read as ciphertext, and can't be written. (Needs Active Record Encryption in your app.) |
100
+ | `credentials` | `Rails.application.credentials`, `secret_key_base`, `key_generator`, `message_verifier`, and the encrypted file/key classes raise when called from the console. |
101
+ | `env` | `ENV["SOME_SECRET"]` raises for names matching `config.protected_env_patterns`. Dumping `ENV` (`to_h`, `each`, `inspect`, ...) filters those values. |
102
+ | `files` | `master.key`, `credentials/*.key` and `.env*` files can't be read or copied (`File`, `IO`, `Pathname`, `open`, `FileUtils`). |
103
+ | `network` | Connections to `config.protected_urls` fail, including writes on connections opened earlier (a pool). |
104
+
105
+ Library code is not affected: Rails reading its credentials while it runs your command works. Only calls made **directly from what the person typed** are blocked (SafeBro tells them apart by the file name IRB evaluates code under), except `network` and `inspect`, which apply to anything running during a command.
106
+
107
+ ## Guardrails
108
+
109
+ Each command is checked before it runs:
110
+
111
+ - **Blocked**: reopening protected classes (`class ActiveRecord::Base`, ...), referencing `SafeBro`/`IRB`/`ObjectSpace`, and calling `eval`, `instance_eval`, `class_eval`, `system`, `exec`, `spawn`, backticks, `IO.popen`, ...
112
+ - **Flagged**: commands that mention `secret`, `credentials`, `ENV`, `master.key`, ... run, but are marked suspicious.
113
+ - **Runtime backstops** for the obvious ways around static checks: `send(:eval, ...)`, `Object.const_get("Safe" + "Bro")`, `"SafeBro".constantize`, and so on.
114
+ - **The audit trail can't be touched**: any SQL mentioning SafeBro's tables from the console raises.
115
+ - **Frozen internals**: SafeBro's classes and config are frozen once installed, so they can't be monkey patched.
116
+
117
+ All of it is configurable:
118
+
119
+ ```ruby
120
+ config.forbidden_methods += [ "Kernel#open" ] # "Owner#method" or "Owner.method"
121
+ config.forbidden_constants[:locked] += [ "Net::HTTP" ] # only while locked (or :always)
122
+ config.suspicious_terms += [ "DROP TABLE" ]
123
+ config.protected_resources = %i[ encrypted_data credentials env ]
124
+ config.protected_urls = [ "redis://cache.internal:6379" ]
125
+ config.protected_env_patterns += [ /STRIPE/ ]
126
+ config.protected_file_patterns += [ %r{config/.*\.pem\z} ]
127
+ ```
128
+
129
+ ## Reviewing sessions
130
+
131
+ Mount point is chosen by the generator (default `/bro_hub`, SafeBro at `/bro_hub/safe_bro`). The sidebar has four pages:
132
+
133
+ - **Sessions**: a filterable list (review tab, user, dates, "needs attention" = unlocked or flagged commands). Open one to read what happened, and approve it or flag it with notes. After deciding you land on the next pending session within the same filter.
134
+ - **Flagged commands**: every command that was blocked or ran and looked suspicious, newest first, each linking to its session.
135
+ - **Unlocks**: who unlocked sensitive data, which resources, and the reason they gave.
136
+ - **Rules**: a read-only view of what's locked, blocked and flagged right now (never keys or URL passwords). Change it in the initializer.
137
+
138
+ - Audits are append-only; the latest one decides the status and the history is kept.
139
+ - An auditor can't review their own session (`config.allow_self_audit = true` to allow).
140
+ - Command text and audit notes are encrypted at rest with a key derived from `secret_key_base` (or `config.encryption_key`, 32 bytes).
141
+ - The UI sends `Cache-Control: no-store` and requires SSL in production.
142
+ - Hiding the section from someone is navigation only: every SafeBro page checks `can_audit` itself.
143
+
144
+ ### Notifications
145
+
146
+ SafeBro instruments `ActiveSupport::Notifications`, so alerting is yours to wire up:
147
+
148
+ ```ruby
149
+ ActiveSupport::Notifications.subscribe(/\.safe_bro\z/) do |name, _start, _finish, _id, payload|
150
+ # session_started, unlocked, command_flagged, session_flagged
151
+ SecurityMailer.console_event(name, payload).deliver_later
152
+ end
153
+ ```
154
+
155
+ ### Retention
156
+
157
+ Sessions older than `config.retention` (90 days) are removed, flagged ones are kept (`config.keep_flagged`). Schedule `SafeBro::PurgeJob` (daily) or run `bin/rails safe_bro:purge`. `config.retention = nil` keeps everything.
158
+
159
+ ### `rails runner`
160
+
161
+ `config.audit_runner = true` also records `rails runner` invocations as sessions (`CONSOLE_USER` and `CONSOLE_REASON` are used if set). Runners aren't shielded; they run scripts, not a person exploring.
162
+
163
+ ### Non-interactive consoles
164
+
165
+ With no terminal there is nobody to ask, so set `CONSOLE_REASON` (e.g. `CONSOLE_REASON="deploy check" rails console`). `unlock!` always needs a terminal.
166
+
167
+ ## For gem authors
168
+
169
+ Anything sensitive your gem exposes can use the same lock:
170
+
171
+ ```ruby
172
+ config.protected_resources += [ :billing ] # host app config
173
+
174
+ SafeBro.guard!(:billing) # in your code, before revealing something
175
+ ```
176
+
177
+ `SafeBro.guard!` does nothing outside a console command, and raises `SafeBro::ProtectedResource` inside one until the person runs `unlock! :billing`.
178
+
179
+ ## What this is not
180
+
181
+ Ruby is too dynamic to sandbox. Someone determined who has a console on your production box can get around the static checks and the runtime guards, for example by reading process memory, speaking to the database driver directly, using `dbconsole`, or loading code from disk. SafeBro raises the cost, catches mistakes, makes honest people stop and explain, and leaves a record that dishonest attempts show up in. That's valuable, but it's not a boundary.
182
+
183
+ If you need a boundary, build it outside Ruby:
184
+
185
+ - Don't leave the credentials key on machines people shell into. Deliver it to the app process only.
186
+ - Give console users a database role that can't read what they shouldn't.
187
+ - Control who can SSH in, and set `CONSOLE_USER` from the server side.
188
+ - Have an auditor actually review sessions. An unreviewed audit trail deters nothing.
189
+
190
+ Not covered: `rails dbconsole`, a raw `psql`/`mysql` session, other process managers or shells, and Pry (SafeBro hooks IRB).
191
+
192
+ ## Differences from console1984 + audits1984
193
+
194
+ - One gem, one engine, one migration set (no shared base-model coupling between two gems), and a UI that plugs into the same dashboard as the other Bro gems.
195
+ - Rails credentials, `ENV` and key files are first-class protected resources, not just encrypted attributes.
196
+ - `unlock!` can be scoped to a resource, and gem authors can add their own with `SafeBro.guard!`.
197
+ - Commands are encrypted with SafeBro's own key, so Active Record Encryption needn't be configured to use it (it's only needed for the `encrypted_data` protection).
198
+ - Static checks use Ruby's tokenizer rather than a parser, so code that doesn't parse can't hide a name from them.
199
+ - Audits are append-only with history, auditors can't approve themselves, and flagged sessions survive retention.
200
+ - `rails runner` sessions can be recorded; notifications are `ActiveSupport::Notifications`.
201
+ - Not carried over: Pry and other console support (SafeBro hooks IRB only).
202
+
203
+ ## Development
204
+
205
+ ```sh
206
+ bin/rails test # Rails 8.x
207
+ BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rails test # Rails 7.1
208
+ bin/rubocop
209
+ ```
210
+
211
+ The suite includes tests that boot a real `rails console` in `test/dummy`, one with piped input and one driven through a pseudo-terminal, to prove the IRB hook works on the IRB you have.
212
+
213
+ ## License
214
+
215
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,50 @@
1
+ module SafeBro
2
+ class ApplicationController < SafeBro.config.parent_controller.constantize
3
+ include BroHub::Layout
4
+ bro_hub_section :safe_bro
5
+
6
+ protect_from_forgery with: :exception
7
+
8
+ before_action :safe_bro_require_ssl
9
+ before_action :safe_bro_no_store
10
+ before_action :safe_bro_authorize!
11
+
12
+ helper_method :safe_bro_user, :safe_bro_user_label
13
+
14
+ rescue_from SafeBro::Error, with: :safe_bro_forbidden
15
+
16
+ private
17
+ # The shell shows who is signed in, by SafeBro's rules.
18
+ def bro_hub_user
19
+ safe_bro_user
20
+ end
21
+
22
+ def safe_bro_user
23
+ return @safe_bro_user if defined?(@safe_bro_user)
24
+
25
+ @safe_bro_user = SafeBro.config.current_user.call(self)
26
+ end
27
+
28
+ def safe_bro_user_label
29
+ SafeBro.config.user_label.call(safe_bro_user) if safe_bro_user
30
+ end
31
+
32
+ def safe_bro_authorize!
33
+ raise SafeBro::Error, "Not allowed" unless safe_bro_user && SafeBro.config.can_audit.call(safe_bro_user)
34
+ end
35
+
36
+ def safe_bro_require_ssl
37
+ head :forbidden if SafeBro.config.require_ssl && !request.ssl?
38
+ end
39
+
40
+ # Sessions contain what people typed in production: keep it out of caches.
41
+ def safe_bro_no_store
42
+ response.headers["Cache-Control"] = "no-store"
43
+ response.headers["Pragma"] = "no-cache"
44
+ end
45
+
46
+ def safe_bro_forbidden
47
+ render "safe_bro/shared/forbidden", status: :forbidden
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,35 @@
1
+ module SafeBro
2
+ class AuditsController < ApplicationController
3
+ def create
4
+ session = Session.find(params[:session_id])
5
+ if !SafeBro.config.allow_self_audit && safe_bro_user_label.to_s == session.username
6
+ return redirect_to session_path(session), alert: "You can't audit your own session."
7
+ end
8
+
9
+ audit = session.audits.new(audit_params.merge(auditor: safe_bro_user_label))
10
+ if audit.save
11
+ redirect_to next_path_after(session), notice: "Session ##{session.id} #{audit.status}."
12
+ else
13
+ redirect_to session_path(session, filter_params), alert: audit.errors.full_messages.to_sentence
14
+ end
15
+ end
16
+
17
+ private
18
+ def audit_params
19
+ params.require(:audit).permit(:status, :notes)
20
+ end
21
+
22
+ def filter_params
23
+ params.permit(:attention, :username, :from, :to).to_h.symbolize_keys.merge(status: params[:filter_status]).compact_blank
24
+ end
25
+
26
+ # After deciding, move on to the next session still waiting, within the same filter.
27
+ def next_path_after(session)
28
+ scope = Session.review_pending.where.not(id: session.id)
29
+ scope = scope.needing_attention if params[:attention] == "1"
30
+ scope = scope.where(username: params[:username]) if params[:username].present?
31
+ following = scope.chronological.first
32
+ following ? session_path(following, filter_params) : root_path(status: params[:filter_status].presence || "pending")
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,14 @@
1
+ module SafeBro
2
+ # Every command that was blocked or flagged, newest first.
3
+ class FlaggedCommandsController < ApplicationController
4
+ PER_PAGE = SafeBro::ApplicationHelper::PER_PAGE
5
+
6
+ def index
7
+ @page = [ params[:page].to_i, 0 ].max
8
+ scope = Command.flagged
9
+ @total = scope.count
10
+ @commands = scope.includes(:session).order(created_at: :desc, id: :desc).limit(PER_PAGE).offset(@page * PER_PAGE)
11
+ @blocked_total = Command.blocked.count
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ module SafeBro
2
+ # What SafeBro blocks, flags and locks right now. Read only: change it in the initializer.
3
+ class RulesController < ApplicationController
4
+ def show
5
+ @config = SafeBro.config
6
+ @protected_hosts = @config.protected_urls.filter_map { |url| host_of(url) }
7
+ end
8
+
9
+ private
10
+ # Only host and port: a URL can carry a password.
11
+ def host_of(url)
12
+ uri = URI.parse(url.to_s.include?("://") ? url.to_s : "tcp://#{url}")
13
+ [ uri.host, uri.port ].compact.join(":").presence
14
+ rescue URI::InvalidURIError
15
+ "(unreadable URL)"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,67 @@
1
+ module SafeBro
2
+ class SessionsController < ApplicationController
3
+ PER_PAGE = SafeBro::ApplicationHelper::PER_PAGE
4
+ STATUSES = %w[ pending approved flagged ].freeze
5
+
6
+ helper_method :filter_params
7
+
8
+ def index
9
+ @filter = filter_params
10
+ @sessions = filtered_sessions.chronological.limit(PER_PAGE).offset(page * PER_PAGE)
11
+ @total = filtered_sessions.count
12
+ @page = page
13
+ load_counts
14
+ end
15
+
16
+ def show
17
+ @session = Session.find(params[:id])
18
+ @commands = @session.commands.includes(:sensitive_access).chronological
19
+ @audits = @session.audits.order(:id)
20
+ @can_audit_this = can_audit?(@session)
21
+ @next_session = filtered_sessions.review_pending.where.not(id: @session.id).chronological.first
22
+ end
23
+
24
+ private
25
+ # One query each, instead of two per row, plus the totals for the page heading.
26
+ def load_counts
27
+ ids = @sessions.map(&:id)
28
+ @unlock_counts = SensitiveAccess.where(session_id: ids).group(:session_id).count
29
+ @flag_counts = Command.flagged.where(session_id: ids).group(:session_id).count
30
+ matching = filtered_sessions.select(:id)
31
+ @unlock_total = SensitiveAccess.where(session_id: matching).count
32
+ @flag_total = Command.flagged.where(session_id: matching).count
33
+ end
34
+
35
+ def filtered_sessions
36
+ scope = Session.all
37
+ scope = scope.where(review_status: filter_params[:status]) if STATUSES.include?(filter_params[:status])
38
+ scope = scope.needing_attention if filter_params[:attention] == "1"
39
+ scope = scope.where(username: filter_params[:username]) if filter_params[:username].present?
40
+ scope = scope.on_or_after(parse_date(filter_params[:from]).beginning_of_day) if parse_date(filter_params[:from])
41
+ scope = scope.before(parse_date(filter_params[:to]).end_of_day) if parse_date(filter_params[:to])
42
+ scope
43
+ end
44
+
45
+ def filter_params
46
+ @filter_params ||= begin
47
+ permitted = params.permit(:status, :attention, :username, :from, :to).to_h.symbolize_keys
48
+ permitted[:status] = "pending" unless params.key?(:status)
49
+ permitted.compact_blank
50
+ end
51
+ end
52
+
53
+ def page
54
+ [ params[:page].to_i, 0 ].max
55
+ end
56
+
57
+ def parse_date(value)
58
+ Date.iso8601(value.to_s) if value.present?
59
+ rescue ArgumentError
60
+ nil
61
+ end
62
+
63
+ def can_audit?(session)
64
+ SafeBro.config.allow_self_audit || safe_bro_user_label.to_s != session.username
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,14 @@
1
+ module SafeBro
2
+ # Every time someone unlocked sensitive data, with the reason they gave.
3
+ class UnlocksController < ApplicationController
4
+ PER_PAGE = SafeBro::ApplicationHelper::PER_PAGE
5
+
6
+ def index
7
+ @page = [ params[:page].to_i, 0 ].max
8
+ @total = SensitiveAccess.count
9
+ @unlocks = SensitiveAccess.includes(:session).order(started_at: :desc, id: :desc).limit(PER_PAGE).offset(@page * PER_PAGE)
10
+ @command_counts = Command.where(sensitive_access_id: @unlocks.map(&:id)).group(:sensitive_access_id).count
11
+ @open_total = SensitiveAccess.where(ended_at: nil).count
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,59 @@
1
+ module SafeBro
2
+ module ApplicationHelper
3
+ PER_PAGE = 50
4
+
5
+ REVIEW_LABELS = { "pending" => "Pending", "approved" => "Appropriate", "flagged" => "Flagged" }.freeze
6
+ REVIEW_TONES = { "pending" => "muted", "approved" => "green", "flagged" => "red" }.freeze
7
+
8
+ def safe_bro_time(time)
9
+ return "-" unless time
10
+
11
+ content_tag(:time, time.utc.strftime("%Y-%m-%d %H:%M"), datetime: time.iso8601, title: time.utc.to_s)
12
+ end
13
+
14
+ def safe_bro_duration(seconds)
15
+ return "#{seconds}s" if seconds < 60
16
+ return "#{seconds / 60}m" if seconds < 3600
17
+
18
+ "#{seconds / 3600}h #{seconds % 3600 / 60}m"
19
+ end
20
+
21
+ def safe_bro_review_label(status)
22
+ REVIEW_LABELS.fetch(status.to_s, status.to_s.capitalize)
23
+ end
24
+
25
+ def safe_bro_review_tone(status)
26
+ REVIEW_TONES.fetch(status.to_s, "muted")
27
+ end
28
+
29
+ # Coloured review status, as text.
30
+ def review_status_text(status)
31
+ content_tag(:span, safe_bro_review_label(status), class: "bh-status bh-tone-#{safe_bro_review_tone(status)}")
32
+ end
33
+
34
+ def command_flag(command)
35
+ case command.status
36
+ when "blocked" then content_tag(:span, "Blocked", class: "bh-flag")
37
+ when "suspicious" then content_tag(:span, "Flagged", class: "bh-flag")
38
+ end
39
+ end
40
+
41
+ def safe_bro_count(count, singular, plural = singular.pluralize)
42
+ "#{count} #{count == 1 ? singular : plural}"
43
+ end
44
+
45
+ # "← Newer / Older →" for pages of PER_PAGE rows. `page` counts from 0.
46
+ def safe_bro_pager(total:, page:, noun:)
47
+ newer = link_to("← Newer", url_for(request.query_parameters.merge(page: page - 1))) if page > 0
48
+ older = link_to("Older →", url_for(request.query_parameters.merge(page: page + 1))) if (page + 1) * PER_PAGE < total
49
+ content_tag(:div, class: "bh-meta", style: "display:flex;justify-content:space-between;gap:16px;margin-top:14px") do
50
+ safe_join([ content_tag(:span, safe_bro_count(total, noun)), content_tag(:span, safe_join([ newer, older ].compact, " ")) ])
51
+ end
52
+ end
53
+
54
+ # Short, readable text for a list of config values (never raw secrets: callers pass patterns and names).
55
+ def safe_bro_rule(value)
56
+ value.respond_to?(:source) ? value.inspect : value.to_s
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,10 @@
1
+ module SafeBro
2
+ # Schedule this (daily is plenty) to enforce config.retention.
3
+ class PurgeJob < ActiveJob::Base
4
+ queue_as :default
5
+
6
+ def perform
7
+ SafeBro::Session.purge!
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ module SafeBro
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ self.table_name_prefix = "safe_bro_"
5
+ end
6
+ end
@@ -0,0 +1,19 @@
1
+ require "safe_bro/encrypted_type"
2
+
3
+ module SafeBro
4
+ # An auditor's verdict on a session. Audits are append-only: re-auditing adds a new row,
5
+ # so the history of who decided what is kept.
6
+ class Audit < ApplicationRecord
7
+ belongs_to :session
8
+
9
+ enum :status, { approved: 1, flagged: 2 }
10
+
11
+ attribute :notes, SafeBro::EncryptedType.new
12
+
13
+ validates :auditor, presence: true
14
+ validates :notes, presence: true, if: :flagged?
15
+
16
+ after_create { session.update!(review_status: status) }
17
+ after_create { SafeBro.notify(:session_flagged, session: session, auditor: auditor, notes: notes) if flagged? }
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ require "safe_bro/encrypted_type"
2
+
3
+ module SafeBro
4
+ class Command < ApplicationRecord
5
+ belongs_to :session
6
+ belongs_to :sensitive_access, optional: true
7
+
8
+ attribute :statements, SafeBro::EncryptedType.new
9
+
10
+ # executed: ran normally. suspicious: ran, but flagged. blocked: never ran.
11
+ enum :status, { executed: 0, suspicious: 1, blocked: 2 }
12
+
13
+ scope :flagged, -> { where(status: [ statuses[:suspicious], statuses[:blocked] ]) }
14
+ scope :chronological, -> { order(:created_at, :id) }
15
+
16
+ def flagged?
17
+ suspicious? || blocked?
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ module SafeBro
2
+ # A window during which the console was unlocked, with the justification given for it.
3
+ class SensitiveAccess < ApplicationRecord
4
+ belongs_to :session
5
+ has_many :commands, dependent: :nullify
6
+
7
+ validates :justification, :resources, :started_at, presence: true
8
+
9
+ def open?
10
+ ended_at.nil?
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,52 @@
1
+ module SafeBro
2
+ # One console session: who, why, from where, and everything they did.
3
+ class Session < ApplicationRecord
4
+ has_many :commands, dependent: :delete_all
5
+ has_many :sensitive_accesses, dependent: :delete_all
6
+ has_many :audits, dependent: :delete_all
7
+
8
+ # The latest audit decides the status.
9
+ enum :review_status, { pending: 0, approved: 1, flagged: 2 }, prefix: :review
10
+
11
+ validates :username, :reason, :started_at, presence: true
12
+
13
+ scope :chronological, -> { order(started_at: :desc, id: :desc) }
14
+ scope :on_or_after, ->(time) { where(started_at: time..) }
15
+ scope :before, ->(time) { where(started_at: ...time) }
16
+
17
+ # Sessions that deserve a look: someone unlocked sensitive data or a command was flagged.
18
+ scope :needing_attention, -> {
19
+ where(id: SensitiveAccess.select(:session_id)).or(where(id: Command.flagged.select(:session_id)))
20
+ }
21
+
22
+ # Sessions that nobody has audited, and aren't empty housekeeping.
23
+ scope :awaiting_review, -> { where(review_status: :pending) }
24
+
25
+ class << self
26
+ # Deletes old sessions and everything under them. Flagged ones stay unless told otherwise.
27
+ def purge!(older_than: SafeBro.config.retention, keep_flagged: SafeBro.config.keep_flagged)
28
+ return 0 if older_than.nil?
29
+
30
+ scope = where(started_at: ...older_than.ago)
31
+ scope = scope.where.not(review_status: :flagged) if keep_flagged
32
+ scope.find_each.sum { |session| session.destroy ? 1 : 0 }
33
+ end
34
+ end
35
+
36
+ def sensitive?
37
+ sensitive_accesses.exists? || commands.flagged.exists?
38
+ end
39
+
40
+ def open?
41
+ ended_at.nil?
42
+ end
43
+
44
+ def duration
45
+ ((ended_at || Time.current) - started_at).to_i
46
+ end
47
+
48
+ def last_audit
49
+ audits.order(:id).last
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,22 @@
1
+ <% bro_hub_crumb "Flagged commands" %>
2
+ <%= bro_hub_hero eyebrow: "Flagged commands", title: @total, tone: (:red if @total > 0), subtitle: (@total == 1 ? "command" : "commands"),
3
+ lead: "Everything that was blocked before it ran, or that ran and looked suspicious. Newest first.",
4
+ stats: [ [ @blocked_total, "blocked", :red ], [ @total - @blocked_total, "ran, flagged", :amber ] ] %>
5
+
6
+ <div class="bh-table-wrap">
7
+ <div class="bh-table" style="--cols: 150px 120px minmax(240px,1.2fr) minmax(200px,1fr) 90px 80px; --min: 940px">
8
+ <div class="bh-row is-head"><span>When</span><span>User</span><span>Command</span><span>Why</span><span>Outcome</span><span>Session</span></div>
9
+ <% @commands.each do |command| %>
10
+ <%= link_to session_path(command.session_id), class: "bh-row bh-edge-red" do %>
11
+ <span class="bh-tone-violet"><%= safe_bro_time(command.created_at) %></span>
12
+ <span class="bh-cell-strong"><%= command.session.username %></span>
13
+ <span style="overflow-wrap:anywhere"><%= truncate(command.statements.to_s, length: 140) %></span>
14
+ <span class="bh-cell-text"><%= command.flag_reason %></span>
15
+ <span class="bh-status <%= command.blocked? ? "bh-tone-red" : "bh-tone-amber" %>"><%= command.blocked? ? "Blocked" : "Ran" %></span>
16
+ <span class="bh-tone-violet">#<%= command.session_id %></span>
17
+ <% end %>
18
+ <% end %>
19
+ <% if @commands.empty? %><div class="bh-empty">Nothing flagged.</div><% end %>
20
+ <%= safe_bro_pager total: @total, page: @page, noun: "flagged command" %>
21
+ </div>
22
+ </div>