maquina-generators 0.5.0 → 0.6.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +94 -15
  3. data/lib/generators/maquina/app/app_generator.rb +9 -4
  4. data/lib/generators/maquina/app/templates/README.md.erb +6 -4
  5. data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +18 -5
  6. data/lib/generators/maquina/app/templates/config/database.yml.tt +12 -0
  7. data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +32 -1
  8. data/lib/generators/maquina/mission_control_jobs/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  9. data/lib/generators/maquina/mission_control_jobs/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  10. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  11. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/admin.html.erb +21 -0
  12. data/lib/generators/maquina/rack_attack/USAGE +8 -6
  13. data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +6 -2
  14. data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +139 -57
  15. data/lib/generators/maquina/security/USAGE +15 -0
  16. data/lib/generators/maquina/security/security_generator.rb +278 -0
  17. data/lib/generators/maquina/security/templates/app/controllers/backstage/security_controller.rb.tt +45 -0
  18. data/{test/tmp/app/controllers/backstage_controller.rb → lib/generators/maquina/security/templates/app/controllers/backstage_controller.rb.tt} +0 -1
  19. data/lib/generators/maquina/security/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  20. data/lib/generators/maquina/security/templates/app/models/security/abuse_event.rb +36 -0
  21. data/lib/generators/maquina/security/templates/app/models/security/abuse_report.rb +112 -0
  22. data/lib/generators/maquina/security/templates/app/models/security/record.rb +6 -0
  23. data/lib/generators/maquina/security/templates/app/views/backstage/security/_addresses.html.erb +37 -0
  24. data/lib/generators/maquina/security/templates/app/views/backstage/security/_blocked.html.erb +53 -0
  25. data/lib/generators/maquina/security/templates/app/views/backstage/security/_by_day.html.erb +33 -0
  26. data/lib/generators/maquina/security/templates/app/views/backstage/security/_hosts.html.erb +31 -0
  27. data/lib/generators/maquina/security/templates/app/views/backstage/security/_paths.html.erb +33 -0
  28. data/lib/generators/maquina/security/templates/app/views/backstage/security/_recent.html.erb +37 -0
  29. data/lib/generators/maquina/security/templates/app/views/backstage/security/_utc_time.html.erb +2 -0
  30. data/lib/generators/maquina/security/templates/app/views/backstage/security/show.html.erb +47 -0
  31. data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  32. data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt +40 -0
  33. data/lib/generators/maquina/security/templates/app/views/layouts/admin.html.erb +21 -0
  34. data/{test/tmp/app/views/layouts/solid_errors/application.html.erb → lib/generators/maquina/security/templates/app/views/layouts/backstage/security.html.erb} +1 -1
  35. data/lib/generators/maquina/security/templates/config/initializers/rack_attack_events.rb +34 -0
  36. data/lib/generators/maquina/security/templates/db/security_schema.rb.tt +27 -0
  37. data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +32 -1
  38. data/lib/generators/maquina/solid_errors/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  39. data/lib/generators/maquina/solid_errors/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  40. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  41. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/admin.html.erb +21 -0
  42. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.html.erb +65 -0
  43. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.text.erb +30 -0
  44. data/lib/generators/maquina/solid_errors/templates/config/initializers/solid_errors.rb.tt +12 -0
  45. data/lib/maquina_generators/version.rb +1 -1
  46. data/test/generators/maquina/app_generator_test.rb +21 -0
  47. data/test/generators/maquina/mission_control_jobs_generator_test.rb +28 -4
  48. data/test/generators/maquina/rack_attack_generator_test.rb +41 -19
  49. data/test/generators/maquina/security_generator_test.rb +380 -0
  50. data/test/generators/maquina/solid_errors_generator_test.rb +59 -4
  51. data/test/tmp/Gemfile +1 -3
  52. data/test/tmp/app/controllers/application_controller.rb +2 -0
  53. data/test/tmp/app/controllers/registrations_controller.rb +37 -0
  54. data/test/tmp/app/models/account.rb +5 -0
  55. data/test/tmp/app/models/current.rb +5 -0
  56. data/test/tmp/app/models/user.rb +12 -0
  57. data/test/tmp/app/views/registrations/new.html.erb +72 -0
  58. data/test/tmp/app/views/sessions/new.html.erb +46 -0
  59. data/test/tmp/config/locales/registration.en.yml +44 -0
  60. data/test/tmp/config/locales/registration.es.yml +44 -0
  61. data/test/tmp/config/routes.rb +2 -1
  62. data/test/tmp/db/migrate/20260927024163_create_accounts.rb +9 -0
  63. data/test/tmp/db/migrate/20260927024164_add_account_fields_to_users.rb +7 -0
  64. metadata +47 -32
  65. data/test/tmp/app/agents/error_redactor.rb +0 -34
  66. data/test/tmp/app/agents/errors_query.rb +0 -84
  67. data/test/tmp/app/agents/failure_triage.rb +0 -21
  68. data/test/tmp/app/agents/get_exception_tool.rb +0 -26
  69. data/test/tmp/app/agents/list_failures_tool.rb +0 -20
  70. data/test/tmp/app/agents/top_exceptions_tool.rb +0 -18
  71. data/test/tmp/app/helpers/solid_errors_helper.rb +0 -9
  72. data/test/tmp/app/javascript/controllers/backtrace_filter_controller.js +0 -27
  73. data/test/tmp/app/javascript/controllers/clipboard_controller.js +0 -36
  74. data/test/tmp/app/views/layouts/_admin_navigation.html.erb +0 -28
  75. data/test/tmp/app/views/solid_errors/errors/_actions.html.erb +0 -22
  76. data/test/tmp/app/views/solid_errors/errors/_delete_button.html.erb +0 -10
  77. data/test/tmp/app/views/solid_errors/errors/_error_card.html.erb +0 -63
  78. data/test/tmp/app/views/solid_errors/errors/_resolve_button.html.erb +0 -11
  79. data/test/tmp/app/views/solid_errors/errors/index.html.erb +0 -37
  80. data/test/tmp/app/views/solid_errors/errors/show/_actions.html.erb +0 -33
  81. data/test/tmp/app/views/solid_errors/errors/show/_error_details.html.erb +0 -95
  82. data/test/tmp/app/views/solid_errors/errors/show/_header.html.erb +0 -18
  83. data/test/tmp/app/views/solid_errors/errors/show/_properties.html.erb +0 -65
  84. data/test/tmp/app/views/solid_errors/errors/show.html.erb +0 -23
  85. data/test/tmp/app/views/solid_errors/occurrences/_backtrace_line.html.erb +0 -27
  86. data/test/tmp/app/views/solid_errors/occurrences/_collection.html.erb +0 -80
  87. data/test/tmp/app/views/solid_errors/occurrences/_occurrence.html.erb +0 -129
  88. data/test/tmp/bin/failures +0 -37
  89. data/test/tmp/bin/failures-mcp +0 -21
  90. data/test/tmp/config/initializers/solid_errors.rb +0 -12
@@ -2,6 +2,12 @@
2
2
 
3
3
  # Rack::Attack configuration
4
4
  # https://github.com/rack/rack-attack
5
+ #
6
+ # Coarse scanner-blocking only; per-action throttling belongs in controllers
7
+ # via `rate_limit`. Counters and bans live in Rails.cache (Solid Cache in
8
+ # production), so `SolidCache::Entry.where("key LIKE '%rack::attack%'")` is
9
+ # the forensic record — the middleware answers before Rails, so nothing here
10
+ # reaches the request log except through the subscriber at the bottom.
5
11
 
6
12
  # Safelist localhost
7
13
  Rack::Attack.safelist("allow-localhost") do |req|
@@ -9,66 +15,124 @@ Rack::Attack.safelist("allow-localhost") do |req|
9
15
  end
10
16
 
11
17
  # ---------------------------------------------------------------------------
12
- # Blocklists — common vulnerability scanner paths
18
+ # Blocklist — common vulnerability scanner paths, with a ban
13
19
  # ---------------------------------------------------------------------------
20
+ #
21
+ # A scanner sprays hundreds of guesses in a couple of minutes (a thousand
22
+ # WordPress REST probes in under five minutes from one address is ordinary).
23
+ # Refusing the matching path alone lets every other guess through to routing
24
+ # until the general throttle catches up, so the third scanner path bans the IP
25
+ # for a week — long enough to outlast a rented scanner host, short enough that
26
+ # a reassigned cloud address is not refused for the next tenant.
27
+ #
28
+ # Backup-archive guesses count as scanner paths too (`/<word>.zip` by the
29
+ # hundred in a few seconds). Files the app really serves live under
30
+ # /rails/active_storage, so that prefix is exempt.
14
31
 
15
- # Block PHP file requests
16
- Rack::Attack.blocklist("block-php") do |req|
17
- req.path.match?(/\.php\d?$/i)
18
- end
32
+ class Rack::Attack
33
+ # PHP file requests
34
+ PHP_PATH = /\.php\d?$/i
35
+
36
+ # Backup and dump guesses (`/backup.zip`, `/db.sql.gz`, `/site.tar.gz`)
37
+ ARCHIVE_PATH = /\.(zip|rar|7z|tar|tgz|gz|bz2|xz|sql|bak|backup|old)$/i
38
+
39
+ # WordPress paths
40
+ WORDPRESS_PATHS = %w[
41
+ /wp-admin
42
+ /wp-login
43
+ /wp-content
44
+ /wp-includes
45
+ /wp-json
46
+ /xmlrpc.php
47
+ /wp-config
48
+ /wp-cron
49
+ ].freeze
50
+
51
+ # Sensitive file access — anywhere in the path, since scanners probe them behind
52
+ # prefixes too (`/@fs/.env`, `/wp/.git/config`)
53
+ SENSITIVE_PATTERNS = %w[
54
+ /.env
55
+ /.git
56
+ /.htaccess
57
+ /.htpasswd
58
+ /.aws
59
+ /.ssh
60
+ /.svn
61
+ ].freeze
62
+
63
+ # Common scanner targets
64
+ SCANNER_PATHS = %w[
65
+ /cgi-bin
66
+ /phpmyadmin
67
+ /pma
68
+ /myadmin
69
+ /phpinfo
70
+ /mysqladmin
71
+ /admin/config.php
72
+ /solr/
73
+ /actuator
74
+ /telescope/requests
75
+ /debug
76
+ /_ignition
77
+ /vendor/phpunit
78
+ ].freeze
79
+
80
+ # The scanner ban's three knobs.
81
+ SCANNER_MAXRETRY = 3
82
+ SCANNER_FINDTIME = 10.minutes
83
+ SCANNER_BANTIME = 7.days
84
+
85
+ # An address still sending at twice the general throttle's limit has ignored
86
+ # every 429 since it hit the limit: banned for a day. A day rather than the
87
+ # scanner's week because a shared address (office, carrier NAT) can land here.
88
+ GENERAL_LIMIT = 300
89
+ GENERAL_PERIOD = 5.minutes
90
+ FLOOD_MAXRETRY = GENERAL_LIMIT * 2
91
+ FLOOD_BANTIME = 1.day
92
+
93
+ # Sign-in throttle.
94
+ LOGIN_PATH = "<%= options[:login_path] %>"
95
+ LOGIN_LIMIT = 5
96
+ LOGIN_PERIOD = 20.seconds
97
+
98
+ def self.scanner_path?(path)
99
+ path = path.downcase.squeeze("/")
100
+ return false if path.start_with?("/rails/active_storage")
19
101
 
20
- # Block WordPress paths
21
- WORDPRESS_PATHS = %w[
22
- /wp-admin
23
- /wp-login
24
- /wp-content
25
- /wp-includes
26
- /xmlrpc.php
27
- /wp-config
28
- /wp-cron
29
- ].freeze
30
-
31
- Rack::Attack.blocklist("block-wordpress") do |req|
32
- WORDPRESS_PATHS.any? { |path| req.path.downcase.start_with?(path) }
102
+ path.match?(PHP_PATH) ||
103
+ path.match?(ARCHIVE_PATH) ||
104
+ WORDPRESS_PATHS.any? { |prefix| path.start_with?(prefix) } ||
105
+ SENSITIVE_PATTERNS.any? { |dotfile| path.include?(dotfile) } ||
106
+ path.include?("/etc/passwd") ||
107
+ path.include?("/etc/shadow") ||
108
+ SCANNER_PATHS.any? { |prefix| path.start_with?(prefix) }
109
+ end
110
+
111
+ # What the general throttle and the flood ban count: every request but
112
+ # assets and Active Storage, by IP.
113
+ def self.counted_ip(req)
114
+ req.ip unless req.path.start_with?("/assets", "/rails/active_storage")
115
+ end
116
+
117
+ def self.banned?(ip)
118
+ Fail2Ban.banned?("scanner:#{ip}") || Allow2Ban.banned?("flood:#{ip}")
119
+ end
33
120
  end
34
121
 
35
- # Block sensitive file access
36
- SENSITIVE_PATTERNS = %w[
37
- /.env
38
- /.git
39
- /.htaccess
40
- /.htpasswd
41
- /.aws
42
- /.ssh
43
- /.svn
44
- ].freeze
45
-
46
- Rack::Attack.blocklist("block-sensitive-files") do |req|
47
- path = req.path.downcase
48
- SENSITIVE_PATTERNS.any? { |pattern| path.start_with?(pattern) } ||
49
- path.include?("/etc/passwd") ||
50
- path.include?("/etc/shadow")
122
+ Rack::Attack.blocklist("fail2ban/scanners") do |req|
123
+ Rack::Attack::Fail2Ban.filter("scanner:#{req.ip}",
124
+ maxretry: Rack::Attack::SCANNER_MAXRETRY, findtime: Rack::Attack::SCANNER_FINDTIME, bantime: Rack::Attack::SCANNER_BANTIME) do
125
+ Rack::Attack.scanner_path?(req.path)
126
+ end
51
127
  end
52
128
 
53
- # Block common scanner targets
54
- SCANNER_PATHS = %w[
55
- /cgi-bin
56
- /phpmyadmin
57
- /pma
58
- /myadmin
59
- /phpinfo
60
- /mysqladmin
61
- /admin/config.php
62
- /solr/
63
- /actuator
64
- /telescope/requests
65
- /debug
66
- /_ignition
67
- /vendor/phpunit
68
- ].freeze
69
-
70
- Rack::Attack.blocklist("block-scanner-targets") do |req|
71
- SCANNER_PATHS.any? { |path| req.path.downcase.start_with?(path) }
129
+ # Blocklists run before throttles, so this counts the requests the throttle
130
+ # goes on to refuse as well.
131
+ Rack::Attack.blocklist("allow2ban/flood") do |req|
132
+ if (ip = Rack::Attack.counted_ip(req))
133
+ Rack::Attack::Allow2Ban.filter("flood:#{ip}",
134
+ maxretry: Rack::Attack::FLOOD_MAXRETRY, findtime: Rack::Attack::GENERAL_PERIOD, bantime: Rack::Attack::FLOOD_BANTIME) { true }
135
+ end
72
136
  end
73
137
 
74
138
  # ---------------------------------------------------------------------------
@@ -76,13 +140,17 @@ end
76
140
  # ---------------------------------------------------------------------------
77
141
 
78
142
  # General rate limit: 300 requests per 5 minutes per IP
79
- Rack::Attack.throttle("req/ip", limit: 300, period: 5.minutes) do |req|
80
- req.ip unless req.path.start_with?("/assets")
143
+ #
144
+ # Active Storage is exempt alongside /assets. A page of proxied images is dozens
145
+ # of requests, and a refusal there renders as a broken image. Every one of those
146
+ # requests is already gated by an unguessable signed id.
147
+ Rack::Attack.throttle("req/ip", limit: Rack::Attack::GENERAL_LIMIT, period: Rack::Attack::GENERAL_PERIOD) do |req|
148
+ Rack::Attack.counted_ip(req)
81
149
  end
82
150
 
83
151
  # Login throttle: 5 attempts per 20 seconds per IP
84
- Rack::Attack.throttle("login/ip", limit: 5, period: 20.seconds) do |req|
85
- req.ip if req.path == "/session" && req.post?
152
+ Rack::Attack.throttle("login/ip", limit: Rack::Attack::LOGIN_LIMIT, period: Rack::Attack::LOGIN_PERIOD) do |req|
153
+ req.ip if req.path == Rack::Attack::LOGIN_PATH && req.post?
86
154
  end
87
155
 
88
156
  # ---------------------------------------------------------------------------
@@ -93,3 +161,17 @@ end
93
161
  Rack::Attack.blocklisted_responder = lambda do |_request|
94
162
  [403, {"content-type" => "text/plain"}, ["Forbidden\n"]]
95
163
  end
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Logging
167
+ # ---------------------------------------------------------------------------
168
+
169
+ # Every refused request is logged with its IP — the only trace it leaves in the
170
+ # app's log. Safelist matches stay silent.
171
+ ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload|
172
+ req = payload[:request]
173
+ match_type = req.env["rack.attack.match_type"]
174
+ next unless %i[blocklist throttle].include?(match_type)
175
+
176
+ Rails.logger.warn "[ATTACK] #{match_type} #{req.env["rack.attack.matched"]} #{req.ip} #{req.request_method} #{req.path}"
177
+ end
@@ -0,0 +1,15 @@
1
+ Description:
2
+ Records every Rack::Attack refusal as a Security::AbuseEvent in its own
3
+ `security` database and shows the last week on a backstage page.
4
+
5
+ Installs the maquina:rack_attack rules unless the initializer already
6
+ defines Rack::Attack.banned?, plus the recording subscriber, models,
7
+ db/security_schema.rb, the `security` database entries, a daily purge in
8
+ config/recurring.yml, and the controller, layout, views, route and admin
9
+ navigation tab. The page is behind the backstage Basic Auth and answers
10
+ 503 until credentials are set.
11
+
12
+ Examples:
13
+ rails g maquina:security --prefix /admin
14
+ rails g maquina:security --prefix /admin --login-path /sign_in
15
+ rails g maquina:security --prefix /admin --no-copy-views
@@ -0,0 +1,278 @@
1
+ require "rails/generators"
2
+ require "generators/maquina/rack_attack/rack_attack_generator"
3
+
4
+ module Maquina
5
+ module Generators
6
+ class SecurityGenerator < Rails::Generators::Base
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ ENVIRONMENTS = %w[development test production].freeze
10
+ # The end of the Jobs tab in the generated admin navigation: a new tab
11
+ # goes after it.
12
+ JOBS_TAB_END = %r{<span>Jobs</span>\n\s*<% end %>\n}
13
+
14
+ class_option :prefix, type: :string, required: true,
15
+ desc: "Base path prefix (e.g. /admin)"
16
+ class_option :user_env_var, type: :string, default: "SECURITY_USER",
17
+ desc: "Environment variable for HTTP auth username"
18
+ class_option :password_env_var, type: :string, default: "SECURITY_PASSWORD",
19
+ desc: "Environment variable for HTTP auth password"
20
+ class_option :login_path, type: :string, default: "/session",
21
+ desc: "Path whose POSTs the login throttle counts"
22
+ class_option :copy_views, type: :boolean, default: true,
23
+ desc: "Copy the Security dashboard views to the host app"
24
+ class_option :quiet, type: :boolean, default: false,
25
+ desc: "Suppress post-install instructions"
26
+
27
+ # 1. BackstageController
28
+ def create_backstage_controller
29
+ backstage_path = "app/controllers/backstage_controller.rb"
30
+ return if File.exist?(File.join(destination_root, backstage_path))
31
+
32
+ template "app/controllers/backstage_controller.rb.tt", backstage_path
33
+ end
34
+
35
+ # 2. Rack::Attack rules (maquina:rack_attack) and the recording subscriber
36
+ def install_rack_attack
37
+ initializer = File.join(destination_root, "config/initializers/rack_attack.rb")
38
+ current = File.exist?(initializer) ? File.read(initializer) : nil
39
+
40
+ unless current&.include?("def self.banned?")
41
+ @replaced_rack_attack = !current.nil?
42
+ invoke RackAttackGenerator, [],
43
+ login_path: options[:login_path], quiet: true, force: @replaced_rack_attack
44
+ end
45
+
46
+ copy_file "config/initializers/rack_attack_events.rb",
47
+ "config/initializers/rack_attack_events.rb"
48
+ end
49
+
50
+ # 3. Models
51
+ def create_models
52
+ %w[record abuse_event abuse_report].each do |name|
53
+ copy_file "app/models/security/#{name}.rb", "app/models/security/#{name}.rb"
54
+ end
55
+ end
56
+
57
+ # 4. Schema for the security database
58
+ def create_schema
59
+ template "db/security_schema.rb.tt", "db/security_schema.rb"
60
+ end
61
+
62
+ # 5. database.yml (and its example) gain a security database
63
+ def configure_database
64
+ %w[config/database.yml config/database.yml.example].each do |path|
65
+ add_security_database(path)
66
+ end
67
+ end
68
+
69
+ # 6. Daily purge of expired events
70
+ def add_recurring_purge
71
+ path = File.join(destination_root, "config/recurring.yml")
72
+ content = File.exist?(path) ? File.read(path) : ""
73
+ return if content.include?("purge_abuse_events")
74
+
75
+ if content.match?(/^production:\n/)
76
+ inject_into_file "config/recurring.yml", recurring_entry, after: /^production:\n/
77
+ else
78
+ @recurring_pending = true
79
+ end
80
+ end
81
+
82
+ # 7. Add gem to Gemfile
83
+ def add_gem
84
+ gemfile_path = File.join(destination_root, "Gemfile")
85
+ if File.exist?(gemfile_path)
86
+ content = File.read(gemfile_path)
87
+ unless content.include?('gem "rack-attack"')
88
+ append_to_file "Gemfile", "\ngem \"rack-attack\"\n"
89
+ end
90
+ end
91
+ end
92
+
93
+ # 8. Controller
94
+ def create_controller
95
+ template "app/controllers/backstage/security_controller.rb.tt",
96
+ "app/controllers/backstage/security_controller.rb"
97
+ end
98
+
99
+ # 9. Routes
100
+ def add_route
101
+ route %(get "#{options[:prefix]}/security", to: "backstage/security#show", as: :backstage_security)
102
+ end
103
+
104
+ # 10. Admin navigation
105
+ def create_admin_navigation
106
+ nav_path = "app/views/layouts/_admin_navigation.html.erb"
107
+ full_path = File.join(destination_root, nav_path)
108
+
109
+ unless File.exist?(full_path)
110
+ template "app/views/layouts/_admin_navigation.html.erb.tt", nav_path
111
+ return
112
+ end
113
+
114
+ content = File.read(full_path)
115
+ return if content.include?("#{options[:prefix]}/security")
116
+
117
+ if content.match?(JOBS_TAB_END)
118
+ inject_into_file nav_path, security_tab, after: JOBS_TAB_END
119
+ else
120
+ @nav_pending = true
121
+ end
122
+ end
123
+
124
+ # 11. Layout
125
+ def copy_layout
126
+ copy_file "app/views/layouts/backstage/security.html.erb",
127
+ "app/views/layouts/backstage/security.html.erb"
128
+ end
129
+
130
+ # 11b. Backstage dashboard — the Overview tab of the admin navigation.
131
+ # Shared with solid_errors and mission_control_jobs, so every step
132
+ # is guarded for when more than one of them runs.
133
+ def create_backstage_dashboard
134
+ install_backstage_dashboard
135
+ end
136
+
137
+ # 12. Views
138
+ def copy_views
139
+ return unless options[:copy_views]
140
+
141
+ view_files.each { |f| copy_file f, f }
142
+ end
143
+
144
+ # 13. Bundle install
145
+ def run_bundle_install
146
+ return unless rails_app?
147
+
148
+ Bundler.with_unbundled_env do
149
+ system("bundle install", chdir: destination_root)
150
+ end
151
+ end
152
+
153
+ # 14. Post-install message
154
+ def show_post_install
155
+ return if options[:quiet]
156
+
157
+ say ""
158
+ say "Security dashboard has been installed!", :green
159
+ say ""
160
+ say "Next steps:", :yellow
161
+ say " - Create the security database: bin/rails db:prepare"
162
+ say " - Set credentials: bin/rails credentials:edit"
163
+ say " backstage:"
164
+ say " username: your_user"
165
+ say " password: your_password"
166
+ say " - Or set ENV vars: #{options[:user_env_var]}, #{options[:password_env_var]}"
167
+ say " - Visit #{options[:prefix]}/security"
168
+ if @replaced_rack_attack
169
+ say ""
170
+ say "config/initializers/rack_attack.rb was replaced with the maquina:rack_attack rules.", :yellow
171
+ end
172
+ if @recurring_pending
173
+ say ""
174
+ say "Add to config/recurring.yml under your production (or shared) key:", :yellow
175
+ say recurring_entry
176
+ end
177
+ if @database_pending
178
+ say ""
179
+ say "Add a `security` database to each environment in config/database.yml (migrations_paths: db/security_migrate).", :yellow
180
+ end
181
+ if @nav_pending
182
+ say ""
183
+ say "Add a Security tab linking to #{options[:prefix]}/security in app/views/layouts/_admin_navigation.html.erb", :yellow
184
+ end
185
+ say ""
186
+ end
187
+
188
+ private
189
+
190
+ def rails_app?
191
+ File.exist?(File.join(destination_root, "bin/rails"))
192
+ end
193
+
194
+ # Idempotent install of the shared backstage dashboard (controller, layout,
195
+ # prefix-aware view, and the prefix-root route). Safe to run from this
196
+ # generator, solid_errors and mission_control_jobs.
197
+ def install_backstage_dashboard
198
+ unless File.exist?(File.join(destination_root, "app/controllers/backstage_dashboard_controller.rb"))
199
+ copy_file "app/controllers/backstage_dashboard_controller.rb",
200
+ "app/controllers/backstage_dashboard_controller.rb"
201
+ end
202
+
203
+ unless File.exist?(File.join(destination_root, "app/views/layouts/admin.html.erb"))
204
+ copy_file "app/views/layouts/admin.html.erb", "app/views/layouts/admin.html.erb"
205
+ end
206
+
207
+ unless File.exist?(File.join(destination_root, "app/views/backstage_dashboard/index.html.erb"))
208
+ template "app/views/backstage_dashboard/index.html.erb.tt",
209
+ "app/views/backstage_dashboard/index.html.erb"
210
+ end
211
+
212
+ routes_path = File.join(destination_root, "config/routes.rb")
213
+ if File.exist?(routes_path) && !File.read(routes_path).include?("backstage_dashboard#index")
214
+ route %(get "#{options[:prefix]}" => "backstage_dashboard#index", as: :backstage_dashboard)
215
+ end
216
+ end
217
+
218
+ def view_files
219
+ views_dir = File.join(self.class.source_root, "app/views/backstage/security")
220
+ Dir.glob("**/*.erb", base: views_dir).map do |file|
221
+ File.join("app/views/backstage/security", file)
222
+ end
223
+ end
224
+
225
+ def recurring_entry
226
+ <<~YAML.gsub(/^/, " ")
227
+ purge_abuse_events:
228
+ command: "Security::AbuseEvent.expired.delete_all"
229
+ schedule: every day at 4am
230
+ YAML
231
+ end
232
+
233
+ def security_tab
234
+ <<-ERB
235
+
236
+ <%= link_to "#{options[:prefix]}/security",
237
+ class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium \#{request.path.start_with?('#{options[:prefix]}/security') ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'}" do %>
238
+ <%= icon_for(:slash, class: "size-4") %>
239
+ <span>Security</span>
240
+ <% end %>
241
+ ERB
242
+ end
243
+
244
+ # Appends a `security:` entry to each environment. A single-database
245
+ # SQLite environment (Rails' default for development and test) is first
246
+ # nested under `primary:`, which is the same database under a name.
247
+ # Any other adapter is left for the app to configure.
248
+ def add_security_database(path)
249
+ full_path = File.join(destination_root, path)
250
+ return unless File.exist?(full_path)
251
+
252
+ ENVIRONMENTS.each do |env|
253
+ content = File.read(full_path)
254
+ block = content[/^#{env}:\n(?:(?: .*)?\n)*?(?=\n*(?:^\S|\z))/]
255
+ next if block.nil? || block.include?(" security:")
256
+
257
+ if block.include?(" primary:")
258
+ inject_into_file path, security_entry(env), after: block
259
+ elsif content.include?("adapter: sqlite3")
260
+ primary = block.sub(/\A#{env}:\n/, "").gsub(/^(?=.)/, " ")
261
+ gsub_file(path, block) { "#{env}:\n primary:\n#{primary}#{security_entry(env)}" }
262
+ else
263
+ @database_pending = true
264
+ end
265
+ end
266
+ end
267
+
268
+ def security_entry(env)
269
+ <<~YAML.gsub(/^/, " ")
270
+ security:
271
+ <<: *default
272
+ database: storage/#{env}_security.sqlite3
273
+ migrations_paths: db/security_migrate
274
+ YAML
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rack::Attack's last week, read from the `security` database. Behind the same
4
+ # backstage Basic Auth as Solid Errors and Mission Control Jobs, and closed
5
+ # (503) until those credentials are configured, so it is never left open.
6
+ class Backstage::SecurityController < BackstageController
7
+ layout "backstage/security"
8
+
9
+ before_action :require_backstage_credentials
10
+ before_action :authenticate
11
+
12
+ def show
13
+ @report = Security::AbuseReport.new
14
+ end
15
+
16
+ private
17
+
18
+ def authenticate
19
+ authenticate_or_request_with_http_basic("Backstage") do |user, pass|
20
+ secure_match?(user, backstage_username) & secure_match?(pass, backstage_password)
21
+ end
22
+ end
23
+
24
+ def require_backstage_credentials
25
+ return if backstage_username.present? && backstage_password.present?
26
+
27
+ head :service_unavailable
28
+ end
29
+
30
+ def secure_match?(given, expected)
31
+ ActiveSupport::SecurityUtils.secure_compare(given.to_s, expected.to_s)
32
+ end
33
+
34
+ def backstage
35
+ @backstage ||= Rails.application.credentials.backstage || {}
36
+ end
37
+
38
+ def backstage_username
39
+ backstage[:username] || ENV["<%= options[:user_env_var] %>"]
40
+ end
41
+
42
+ def backstage_password
43
+ backstage[:password] || ENV["<%= options[:password_env_var] %>"]
44
+ end
45
+ end
@@ -4,5 +4,4 @@ class BackstageController < ActionController::Base
4
4
  helper MaquinaComponents::IconsHelper
5
5
  helper MaquinaComponents::EmptyHelper
6
6
  helper MaquinaComponentsHelper
7
- helper SolidErrorsHelper
8
7
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Admin overview — a backstage surface alongside the SolidErrors and Mission
4
+ # Control Jobs engines, under the same backstage Basic auth.
5
+ #
6
+ # Out of the box it shows the installed engine entry points and an empty metrics
7
+ # area. Populate @metrics in #index with aggregate, non-sensitive counts for
8
+ # your app — counts, not names or per-record detail.
9
+ class BackstageDashboardController < BackstageController
10
+ layout "admin"
11
+
12
+ before_action :require_backstage_credentials
13
+ before_action :authenticate_backstage
14
+
15
+ def index
16
+ @generated_at = Time.current
17
+
18
+ # Add overview metrics here. Each entry renders as a card:
19
+ #
20
+ # @metrics = [
21
+ # {label: "Accounts", icon: :folder, value: Account.count, hint: "tenants"},
22
+ # {label: "Users", icon: :user, value: User.count, hint: "members"}
23
+ # ]
24
+ #
25
+ # Keep them aggregate-only. :total and :hint are optional.
26
+ @metrics = []
27
+ end
28
+
29
+ private
30
+
31
+ # Closed (503) until credentials.backstage is set. Without this an unset
32
+ # username and password are both "", and an empty Basic header matches them.
33
+ def require_backstage_credentials
34
+ backstage = Rails.application.credentials.backstage || {}
35
+ head :service_unavailable if backstage[:username].blank? || backstage[:password].blank?
36
+ end
37
+
38
+ # Same backstage credentials the engines use.
39
+ def authenticate_backstage
40
+ authenticate_or_request_with_http_basic("Admin") do |username, password|
41
+ backstage = Rails.application.credentials.backstage || {}
42
+
43
+ ActiveSupport::SecurityUtils.secure_compare(username.to_s, backstage[:username].to_s) &
44
+ ActiveSupport::SecurityUtils.secure_compare(password.to_s, backstage[:password].to_s)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,36 @@
1
+ # One refused request, as Rack::Attack saw it. Rows are written by the
2
+ # `rack.attack` subscriber in config/initializers/rack_attack_events.rb and never
3
+ # updated — the table is the record of who was throttled or blocked, which
4
+ # the middleware otherwise leaves nowhere (it answers ahead of Rails, so the
5
+ # request log never sees these, and the cache's counters expire in minutes).
6
+ #
7
+ # `kind` is decided at write time: a throttle is `throttled`; a scanner path is
8
+ # `blocked` until the address's ban is written (Fail2Ban's third strike within
9
+ # its window), after which every refused request from it is `banned` — so the
10
+ # set of banned addresses is exact, not inferred from counts.
11
+ class Security::AbuseEvent < Security::Record
12
+ # Read from here everywhere the retention is stated (config/recurring.yml
13
+ # sweeps `expired`), so the promise and the clock cannot drift.
14
+ RETENTION = 30.days
15
+
16
+ KINDS = %w[throttled blocked banned].freeze
17
+
18
+ PATH_LIMIT = 1024
19
+ USER_AGENT_LIMIT = 512
20
+
21
+ validates :kind, inclusion: {in: KINDS}
22
+ validates :rule, :ip, :request_method, :path, presence: true
23
+
24
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
25
+ scope :within, ->(window) { where(created_at: window.ago..) }
26
+ scope :expired, -> { where(created_at: ...RETENTION.ago) }
27
+ scope :throttled, -> { where(kind: "throttled") }
28
+ scope :blocked, -> { where(kind: %w[blocked banned]) }
29
+ scope :banned, -> { where(kind: "banned") }
30
+
31
+ def self.record!(kind:, rule:, ip:, request_method:, path:, host: nil, user_agent: nil)
32
+ create!(kind:, rule:, ip:, request_method:, host:,
33
+ path: path.to_s.truncate(PATH_LIMIT, omission: ""),
34
+ user_agent: user_agent&.truncate(USER_AGENT_LIMIT, omission: ""))
35
+ end
36
+ end