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