maquina-generators 0.5.0 → 0.7.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +125 -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/clave/clave_generator.rb +48 -0
  8. data/lib/generators/maquina/clave/templates/app/controllers/registration/verifications_controller.rb.tt +10 -1
  9. data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt +10 -1
  10. data/lib/generators/maquina/clave/templates/app/models/email_verification.rb.tt +18 -0
  11. data/lib/generators/maquina/clave/templates/config/locales/clave.en.yml +2 -0
  12. data/lib/generators/maquina/clave/templates/config/locales/clave.es.yml +2 -0
  13. data/lib/generators/maquina/csp_reports/USAGE +18 -0
  14. data/lib/generators/maquina/csp_reports/csp_reports_generator.rb +231 -0
  15. data/lib/generators/maquina/csp_reports/templates/app/controllers/csp_reports_controller.rb +24 -0
  16. data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation.rb +89 -0
  17. data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation_report.rb +30 -0
  18. data/lib/generators/maquina/csp_reports/templates/app/views/backstage/security/_csp_violations.html.erb +42 -0
  19. data/lib/generators/maquina/csp_reports/templates/db/create_csp_violations.rb.tt +23 -0
  20. data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +32 -1
  21. data/lib/generators/maquina/mission_control_jobs/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  22. data/lib/generators/maquina/mission_control_jobs/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  23. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  24. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/admin.html.erb +21 -0
  25. data/lib/generators/maquina/rack_attack/USAGE +8 -6
  26. data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +6 -2
  27. data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +139 -57
  28. data/lib/generators/maquina/security/USAGE +15 -0
  29. data/lib/generators/maquina/security/security_generator.rb +278 -0
  30. data/lib/generators/maquina/security/templates/app/controllers/backstage/security_controller.rb.tt +45 -0
  31. data/{test/tmp/app/controllers/backstage_controller.rb → lib/generators/maquina/security/templates/app/controllers/backstage_controller.rb.tt} +0 -1
  32. data/lib/generators/maquina/security/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  33. data/lib/generators/maquina/security/templates/app/models/security/abuse_event.rb +36 -0
  34. data/lib/generators/maquina/security/templates/app/models/security/abuse_report.rb +112 -0
  35. data/lib/generators/maquina/security/templates/app/models/security/record.rb +6 -0
  36. data/lib/generators/maquina/security/templates/app/views/backstage/security/_addresses.html.erb +37 -0
  37. data/lib/generators/maquina/security/templates/app/views/backstage/security/_blocked.html.erb +53 -0
  38. data/lib/generators/maquina/security/templates/app/views/backstage/security/_by_day.html.erb +33 -0
  39. data/lib/generators/maquina/security/templates/app/views/backstage/security/_hosts.html.erb +31 -0
  40. data/lib/generators/maquina/security/templates/app/views/backstage/security/_paths.html.erb +33 -0
  41. data/lib/generators/maquina/security/templates/app/views/backstage/security/_recent.html.erb +37 -0
  42. data/lib/generators/maquina/security/templates/app/views/backstage/security/_utc_time.html.erb +2 -0
  43. data/lib/generators/maquina/security/templates/app/views/backstage/security/show.html.erb +47 -0
  44. data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  45. data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt +40 -0
  46. data/lib/generators/maquina/security/templates/app/views/layouts/admin.html.erb +21 -0
  47. 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
  48. data/lib/generators/maquina/security/templates/config/initializers/rack_attack_events.rb +34 -0
  49. data/lib/generators/maquina/security/templates/db/security_schema.rb.tt +27 -0
  50. data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +32 -1
  51. data/lib/generators/maquina/solid_errors/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  52. data/lib/generators/maquina/solid_errors/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  53. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  54. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/admin.html.erb +21 -0
  55. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.html.erb +65 -0
  56. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.text.erb +30 -0
  57. data/lib/generators/maquina/solid_errors/templates/config/initializers/solid_errors.rb.tt +12 -0
  58. data/lib/maquina_generators/version.rb +1 -1
  59. data/test/generators/maquina/app_generator_test.rb +21 -0
  60. data/test/generators/maquina/clave_generator_test.rb +74 -0
  61. data/test/generators/maquina/csp_reports_generator_test.rb +151 -0
  62. data/test/generators/maquina/mission_control_jobs_generator_test.rb +28 -4
  63. data/test/generators/maquina/rack_attack_generator_test.rb +41 -19
  64. data/test/generators/maquina/security_generator_test.rb +380 -0
  65. data/test/generators/maquina/solid_errors_generator_test.rb +59 -4
  66. data/test/tmp/.gitignore +5 -0
  67. data/test/tmp/.rubocop.yml +11 -0
  68. data/test/tmp/.standard.yml +1 -0
  69. data/test/tmp/Gemfile +12 -2
  70. data/test/tmp/Procfile.dev +3 -0
  71. data/test/tmp/README.md +89 -0
  72. data/test/tmp/app/controllers/home_controller.rb +6 -0
  73. data/test/tmp/app/javascript/application.js +1 -0
  74. data/test/tmp/app/views/home/index.html.erb +204 -0
  75. data/test/tmp/app/views/layouts/application.html.erb +10 -0
  76. data/test/tmp/config/application.rb +15 -0
  77. data/test/tmp/config/database.yml +90 -0
  78. data/test/tmp/config/database.yml.example +90 -0
  79. data/test/tmp/config/environments/development.rb +8 -0
  80. data/test/tmp/config/environments/production.rb +6 -0
  81. data/test/tmp/config/initializers/generators.rb +4 -0
  82. data/test/tmp/config/routes.rb +1 -1
  83. metadata +60 -32
  84. data/test/tmp/app/agents/error_redactor.rb +0 -34
  85. data/test/tmp/app/agents/errors_query.rb +0 -84
  86. data/test/tmp/app/agents/failure_triage.rb +0 -21
  87. data/test/tmp/app/agents/get_exception_tool.rb +0 -26
  88. data/test/tmp/app/agents/list_failures_tool.rb +0 -20
  89. data/test/tmp/app/agents/top_exceptions_tool.rb +0 -18
  90. data/test/tmp/app/helpers/solid_errors_helper.rb +0 -9
  91. data/test/tmp/app/javascript/controllers/backtrace_filter_controller.js +0 -27
  92. data/test/tmp/app/javascript/controllers/clipboard_controller.js +0 -36
  93. data/test/tmp/app/views/layouts/_admin_navigation.html.erb +0 -28
  94. data/test/tmp/app/views/solid_errors/errors/_actions.html.erb +0 -22
  95. data/test/tmp/app/views/solid_errors/errors/_delete_button.html.erb +0 -10
  96. data/test/tmp/app/views/solid_errors/errors/_error_card.html.erb +0 -63
  97. data/test/tmp/app/views/solid_errors/errors/_resolve_button.html.erb +0 -11
  98. data/test/tmp/app/views/solid_errors/errors/index.html.erb +0 -37
  99. data/test/tmp/app/views/solid_errors/errors/show/_actions.html.erb +0 -33
  100. data/test/tmp/app/views/solid_errors/errors/show/_error_details.html.erb +0 -95
  101. data/test/tmp/app/views/solid_errors/errors/show/_header.html.erb +0 -18
  102. data/test/tmp/app/views/solid_errors/errors/show/_properties.html.erb +0 -65
  103. data/test/tmp/app/views/solid_errors/errors/show.html.erb +0 -23
  104. data/test/tmp/app/views/solid_errors/occurrences/_backtrace_line.html.erb +0 -27
  105. data/test/tmp/app/views/solid_errors/occurrences/_collection.html.erb +0 -80
  106. data/test/tmp/app/views/solid_errors/occurrences/_occurrence.html.erb +0 -129
  107. data/test/tmp/bin/failures +0 -37
  108. data/test/tmp/bin/failures-mcp +0 -21
  109. data/test/tmp/config/initializers/solid_errors.rb +0 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79618453431401c828cd9f89037c0111810fe7ff1b3328986fb99fea3282e010
4
- data.tar.gz: e33ebfe0accac9a78432b1548648fce1ca8de0176370673f84665a7928eefeef
3
+ metadata.gz: 3a31ee05a1ed941cc88119c5721629c3c5089081dde3e7833d1768cb9598b202
4
+ data.tar.gz: 5bc0462a7009085c8a752791090347501a59886cab23eb74ba7bdd5ff0ff2f08
5
5
  SHA512:
6
- metadata.gz: c5c84a544b6295a6dfd32833b8d65febdb7e9d1de3e79da20c67e55cc7d5102c585a61b76f640ae3eb01cd1e59a57172c7930a90ea86f47bbcf7e49583709cf6
7
- data.tar.gz: 27361ba6b7f8c51778eee636c43715ce8aa6dcce88c5a6c929c4715d4980b27db240fdbaba8139b3859d920976ad8fc1655881cf965c891099063c63cf88b635
6
+ metadata.gz: 85c73f484e3ed4e09e28ec2bebf42ca13ea1dd84693b9e7abbffa0746f14aa69930d95607c0c592085ad6acd88ba2a5d81afd90e602c8265cf9122e05d86a3ef
7
+ data.tar.gz: bf4cec22c128729f8425b71d345a6ef166e6c9f4d18172860ef1eca3bbb894997397ce227297168f54218d6d26c87dc707dbedbd7b268594620543d22f1b6437
data/README.md CHANGED
@@ -19,12 +19,14 @@ A collection of Rails generators from the Maquina umbrella. Each generator produ
19
19
  - **Migrations:** 4 migrations (accounts, users, sessions, email_verifications)
20
20
  - **Test helper:** `sign_in_as(user)` and `sign_out` for integration tests
21
21
 
22
+ Sign-in codes are encrypted at rest with Active Record encryption and compared in constant time. The generator adds the encryption keys to `config/credentials.yml.enc` when they are missing; without a master key, run `bin/rails db:encryption:init`.
23
+
22
24
  #### Installation
23
25
 
24
26
  Add to your Gemfile:
25
27
 
26
28
  ```ruby
27
- gem "maquina_generators", group: :development
29
+ gem "maquina-generators", group: :development
28
30
  ```
29
31
 
30
32
  Run the generator:
@@ -55,6 +57,7 @@ All generated code lives in your app -- edit it directly:
55
57
  - **Session duration:** Edit `authentication.rb` (default: 30 days)
56
58
  - **Code expiration:** Edit controllers (default: 15 minutes)
57
59
  - **Cooldown between codes:** Edit `EmailVerification::COOLDOWN_MINUTES` (default: 15)
60
+ - **Wrong guesses before a code locks:** Edit `EmailVerification::MAX_ATTEMPTS` (default: 5)
58
61
  - **Colors/styling:** Edit view templates (default: indigo)
59
62
  - **Email sender:** Edit `app/mailers/verification_mailer.rb`
60
63
  - **Translations:** Edit `config/locales/clave.*.yml`
@@ -114,8 +117,10 @@ All generated code lives in your app -- edit it directly:
114
117
 
115
118
  - **BackstageController:** Inherits from `ActionController::Base` (bypasses app's ApplicationController concerns)
116
119
  - **Initializer:** Credentials-first auth with ENV variable fallback, database connection config
117
- - **Route:** Mounts `SolidErrors::Engine` under a configurable prefix
118
- - **Admin navigation:** Shared navigation bar linking Solid Errors and Mission Control Jobs dashboards
120
+ - **Route:** Mounts `SolidErrors::Engine` at `<prefix>/errors`
121
+ - **Mailer templates:** Self-contained `error_occurred` HTML and text templates, so `send_emails` works without the dashboard's helpers
122
+ - **Admin navigation:** Shared navigation bar with Overview, Errors, Jobs and Security tabs
123
+ - **Admin overview:** `BackstageDashboardController` at the prefix root with cards for each installed dashboard and a `@metrics` slot for your own aggregate counts; answers 503 until backstage credentials are set
119
124
  - **Custom layout:** Tailwind-styled layout with admin navigation and toast flash messages
120
125
  - **Stimulus controllers:** `clipboard_controller.js` and `backtrace_filter_controller.js`
121
126
  - **Custom views:** Tailwind-styled views to override the gem defaults (included by default, use `--no-copy-views` to skip)
@@ -156,8 +161,9 @@ Credentials are resolved in order:
156
161
  - **BackstageController:** Inherits from `ActionController::Base` with maquina_components helpers (bypasses app's ApplicationController concerns)
157
162
  - **Helper:** `MissionControlHelper` with `job_status_badge_variant` and `nav_icon_for_section`
158
163
  - **Initializer:** Sets base controller class, credentials-first auth with ENV variable fallback
159
- - **Route:** Mounts `MissionControl::Jobs::Engine` under a configurable prefix
160
- - **Admin navigation:** Shared navigation bar linking Solid Errors and Mission Control Jobs dashboards
164
+ - **Route:** Mounts `MissionControl::Jobs::Engine` at `<prefix>/jobs`
165
+ - **Admin navigation:** Shared navigation bar with Overview, Errors, Jobs and Security tabs
166
+ - **Admin overview:** `BackstageDashboardController` at the prefix root with cards for each installed dashboard and a `@metrics` slot for your own aggregate counts; answers 503 until backstage credentials are set
161
167
  - **Custom layout:** Tailwind-styled layout with admin navigation, toast flash messages, application/server selection, and tab navigation
162
168
  - **Custom views:** Tailwind-styled views for jobs, queues, workers, and recurring tasks (included by default, use `--no-copy-views` to skip)
163
169
 
@@ -218,28 +224,132 @@ rails g maquina:solid_queue --database postgresql # PostgreSQL
218
224
 
219
225
  ### Rack Attack -- Request Protection
220
226
 
221
- **Rack Attack** installs the [rack-attack](https://github.com/rack/rack-attack) gem with default security rules to block common vulnerability scans and throttle abusive requests.
227
+ **Rack Attack** installs the [rack-attack](https://github.com/rack/rack-attack) gem with rules that ban vulnerability scanners and addresses that ignore the throttle.
222
228
 
223
229
  #### What it generates
224
230
 
225
- - **Initializer:** `config/initializers/rack_attack.rb` with blocklists, safelists, and throttles
231
+ - **Initializer:** `config/initializers/rack_attack.rb` with the bans, throttles, safelist, 403 responder and an `[ATTACK]` log line per refusal. Its knobs are constants on `Rack::Attack` (`SCANNER_*`, `GENERAL_*`, `FLOOD_*`, `LOGIN_*`) and `Rack::Attack.banned?(ip)` answers whether an address is banned now.
226
232
 
227
233
  #### Usage
228
234
 
229
235
  ```bash
230
236
  rails g maquina:rack_attack
237
+ rails g maquina:rack_attack --login-path /sign_in # Throttle a different sign-in path
231
238
  ```
232
239
 
233
240
  The generator automatically runs `bundle install`.
234
241
 
235
242
  #### Default Protections
236
243
 
237
- - **Blocklists:** PHP files (`*.php`), WordPress paths (`wp-admin`, `wp-login`, etc.), sensitive files (`.env`, `.git`, `/etc/passwd`, etc.), scanner targets (`phpmyadmin`, `cgi-bin`, etc.)
244
+ - **Scanner ban (Fail2Ban):** three scanner paths in 10 minutes bans the IP for 7 days. Scanner paths are PHP files (`*.php`), WordPress paths (`wp-admin`, `wp-login`, etc.), sensitive files anywhere in the path (`.env`, `.git`, `/etc/passwd`, etc.), backup archives (`.zip`, `.sql`, `.tar.gz`, `.bak`, etc.) and scanner targets (`phpmyadmin`, `cgi-bin`, etc.). `/rails/active_storage` is exempt.
245
+ - **Flood ban (Allow2Ban):** 600 requests in 5 minutes (twice the general throttle) bans the IP for 1 day.
246
+ - **Throttles:** 300 requests/5min per IP (general; `/assets` and `/rails/active_storage` exempt), 5 `POST /session`/20s per IP
238
247
  - **Safelists:** Localhost (`127.0.0.1`, `::1`)
239
- - **Throttles:** 300 requests/5min per IP (general), 5 login attempts/20s per IP
240
- - **Responses:** 403 Forbidden for blocklisted, 429 Too Many Requests for throttled
248
+ - **Responses:** 403 Forbidden for blocklisted and banned, 429 Too Many Requests for throttled
249
+
250
+ Bans live in `Rails.cache`, so they need a shared cache store (Solid Cache) in production. Customize rules in `config/initializers/rack_attack.rb`. To see the refusals, add `maquina:security`.
251
+
252
+ ---
253
+
254
+ ### Security -- Rack::Attack with an Abuse Dashboard
255
+
256
+ **Security** records every Rack::Attack refusal in its own database and summarises the last week on a backstage page next to Solid Errors and Mission Control Jobs.
257
+
258
+ #### What it generates
259
+
260
+ - **Rack::Attack rules:** runs `maquina:rack_attack` unless `config/initializers/rack_attack.rb` already defines `Rack::Attack.banned?` (an older initializer without it is replaced)
261
+ - **Subscriber:** `config/initializers/rack_attack_events.rb` writes a `Security::AbuseEvent` per throttled, blocked or banned request; a failed write never turns a 403 into a 500
262
+ - **Models:** `Security::Record` (`connects_to` the `security` database), `Security::AbuseEvent` (30-day retention), `Security::AbuseReport` (the page's figures)
263
+ - **Database:** `db/security_schema.rb` and a `security:` entry in every multi-database environment of `config/database.yml` (and `config/database.yml.example`)
264
+ - **Retention:** a daily `purge_abuse_events` task in `config/recurring.yml`
265
+ - **BackstageController**, **controller** (`Backstage::SecurityController`), **route** (`<prefix>/security`), **layout** and **views**: stats, throttled addresses, blocked addresses (banned now, expired or not yet banned), scanner paths, sign-in throttles, targeted hosts, refusals by day, recent refusals (views skipped with `--no-copy-views`)
266
+ - **Admin navigation:** a Security tab, added to an existing `_admin_navigation` partial too
267
+ - **Admin overview:** the shared dashboard at the prefix root, if Solid Errors or Mission Control Jobs have not installed it already
268
+
269
+ #### What the page shows
270
+
271
+ The last 7 days of refusals, top five of each, read from `Security::AbuseEvent` through `Security::AbuseReport`. It is a briefing, not a log viewer: no filters, no pagination. Times are UTC.
272
+
273
+ - **Stats:** refused requests, throttled, blocked or banned, and distinct addresses
274
+ - **Throttled addresses:** over the general or sign-in limit, busiest first, with the rules they hit
275
+ - **Blocked addresses:** every address a blocklist refused, marked **Banned now** (Rack::Attack's own answer at render time), **Expired**, or **Not banned** -- an address with a strike or two on the scanner rule, or a slow scanner that stays under three paths in ten minutes
276
+ - **Most requested blocked paths:** what the scanners were after. A banned address is refused on every path, its ordinary pages included, so only the paths `Rack::Attack.scanner_path?` recognises are listed
277
+ - **Sign-in throttles:** addresses refused on `POST <login-path>`, kept apart so scanner noise never buries them
278
+ - **Targeted hosts:** which hostnames the refusals were aimed at
279
+ - **By day:** throttled, blocked and banned per day, zeros kept so a spike reads against its neighbours
280
+ - **Recent refusals:** the last 20, newest first
281
+
282
+ #### Usage
283
+
284
+ ```bash
285
+ rails g maquina:security --prefix /admin
286
+ bin/rails db:prepare
287
+ ```
288
+
289
+ The generator automatically runs `bundle install`. The page needs maquina_components.
290
+
291
+ #### Options
292
+
293
+ ```bash
294
+ rails g maquina:security --prefix /admin # Default
295
+ rails g maquina:security --prefix /admin --login-path /sign_in # Different sign-in path
296
+ rails g maquina:security --prefix /admin --no-copy-views # Without views
297
+ rails g maquina:security --prefix /backstage \
298
+ --user-env-var ADMIN_USER --password-env-var ADMIN_PASSWORD # Custom env vars
299
+ ```
300
+
301
+ #### Authentication
302
+
303
+ HTTP Basic Auth, with credentials resolved in order:
304
+
305
+ 1. `Rails.application.credentials.backstage.username` / `.password`
306
+ 2. `ENV["SECURITY_USER"]` / `ENV["SECURITY_PASSWORD"]` (configurable)
307
+
308
+ The page answers 503 until one of them is set, so it is never left open.
309
+
310
+ #### Trying it locally
311
+
312
+ Localhost is safelisted, so your own requests are never refused. Rack::Attack trusts `X-Forwarded-For` from `127.0.0.1`, so send one to stand in for another address:
313
+
314
+ ```bash
315
+ for path in /wp-login.php /.env /backup.zip /; do
316
+ curl -s -o /dev/null -w "%{http_code} $path\n" -H "X-Forwarded-For: 203.0.113.10" http://localhost:3000$path
317
+ done
318
+ # 403 /wp-login.php
319
+ # 403 /.env
320
+ # 403 /backup.zip <- third scanner path: banned for 7 days
321
+ # 403 / <- banned, so every path is refused
322
+ ```
323
+
324
+ Development's `:memory_store` keeps bans inside the server process, so they reset on restart and a `bin/rails runner` script cannot see them. Production needs a shared store (Solid Cache).
325
+
326
+ ---
327
+
328
+ ### CSP Reports -- Content Security Policy Violation Endpoint
329
+
330
+ **CSP Reports** gives the browser somewhere to send Content Security Policy violations and shows them on the Security page. It needs `maquina:security`, whose `security` database stores them.
331
+
332
+ #### What it generates
333
+
334
+ - **Controller:** `CspReportsController` at `POST /csp-reports` -- an API controller with no session or CSRF that accepts both the legacy `report-uri` body and the Reporting API body, capped at 64 KB. Reported URLs lose their query and fragment, and browser-extension violations are dropped
335
+ - **Models:** `Security::CspViolation` (30-day retention) and `Security::CspViolationReport` (the page's figures)
336
+ - **Database:** a migration in `db/security_migrate` and the `csp_violations` table in `db/security_schema.rb`
337
+ - **Retention:** a daily `purge_csp_violations` task in `config/recurring.yml`
338
+ - **Security page:** a section listing the last 7 days of violations, most frequent first
339
+ - **Policy:** points `report_uri` at the endpoint in `config/initializers/content_security_policy.rb` (no `report-to`, which switches Chrome to the batched Reporting API) and gives anonymous requests a nonce -- Rails' session-id nonce is empty without a session
340
+ - **Rack::Attack:** takes the endpoint out of the general throttle and caps it at 100 reports per 5 minutes per IP
341
+
342
+ Every step is idempotent. Anything it cannot place safely is listed after the install instead of being forced.
343
+
344
+ #### Usage
345
+
346
+ ```bash
347
+ rails g maquina:csp_reports
348
+ rails g maquina:csp_reports --path /security/csp-reports # Different endpoint path
349
+ bin/rails db:migrate
350
+ ```
241
351
 
242
- Customize rules in `config/initializers/rack_attack.rb`.
352
+ Watch the Security page while the policy is report-only, then set `content_security_policy_report_only = false` to enforce it.
243
353
 
244
354
  ---
245
355
 
@@ -259,10 +369,10 @@ Customize rules in `config/initializers/rack_attack.rb`.
259
369
  8. Sets up ActiveStorage JavaScript imports
260
370
  9. Adds turbo morphing, `yield :head`, and simplifies `<main>` tag in layout
261
371
  10. Optionally installs authentication (`maquina:clave` or `maquina:registration`)
262
- 11. Invokes sub-generators: `maquina:rack_attack`, `maquina:mission_control_jobs`, `maquina:solid_errors`
263
- 12. Runs external installers: `solid_queue:install`, `solid_errors:install`, `solid_cache:install`, `solid_cable:install`, `maquina_components:install`
372
+ 11. Invokes sub-generators: `maquina:mission_control_jobs`, `maquina:solid_errors`
373
+ 12. Runs external installers: `solid_queue:install`, `solid_errors:install`, `solid_cache:install`, `solid_cable:install`, `maquina_components:install`, then `maquina:security` (which installs the `maquina:rack_attack` rules)
264
374
  13. Restores custom layouts overwritten by gem installers
265
- 14. Configures multi-database `database.yml` (primary, queue, cache, cable, errors)
375
+ 14. Configures multi-database `database.yml` (primary, queue, cache, cable, errors, security)
266
376
  15. Creates a HomeController with root route
267
377
  16. Generates a README and `database.yml.example`
268
378
  17. Runs `db:prepare`
@@ -277,7 +387,7 @@ rails g maquina:app --auth registration
277
387
 
278
388
  #### Options
279
389
 
280
- - `--prefix` (default: `/admin`) -- Base path prefix for backstage tools (Solid Errors, Mission Control Jobs)
390
+ - `--prefix` (default: `/admin`) -- Base path prefix for backstage tools (Solid Errors, Mission Control Jobs, Security)
281
391
  - `--port` (default: `3000`) -- Default port for the development server
282
392
  - `--auth` (default: `none`) -- Authentication type: `none`, `clave`, or `registration`
283
393
 
@@ -199,7 +199,6 @@ module Maquina
199
199
  def install_maquina_generators
200
200
  return unless rails_app?
201
201
 
202
- generate "maquina:rack_attack", "--quiet"
203
202
  generate "maquina:mission_control_jobs", "--prefix", options[:prefix], "--quiet"
204
203
  generate "maquina:solid_errors", "--prefix", options[:prefix], "--quiet"
205
204
 
@@ -210,6 +209,10 @@ module Maquina
210
209
  system("bin/rails generate solid_cable:install", chdir: destination_root)
211
210
  system("bin/rails generate maquina_components:install", chdir: destination_root)
212
211
  end
212
+
213
+ # After solid_queue:install, so config/recurring.yml exists for the purge.
214
+ # Installs the maquina:rack_attack rules too.
215
+ generate "maquina:security", "--prefix", options[:prefix], "--quiet"
213
216
  end
214
217
 
215
218
  # 13. Restore custom layouts overwritten by gem installers
@@ -323,10 +326,12 @@ module Maquina
323
326
  say " 2. Start the app: bin/dev"
324
327
  say ""
325
328
  say "Installed components:", :yellow
326
- say " - Rack::Attack: rate limiting and blocklists (config/initializers/rack_attack.rb)"
329
+ say " - Rack::Attack: scanner and flood bans, throttles (config/initializers/rack_attack.rb)"
330
+ say " - Security: Rack::Attack refusals at #{options[:prefix]}/security"
327
331
  say " - Solid Queue: background jobs (config/solid_queue.yml)"
328
- say " - Mission Control Jobs: job dashboard at #{options[:prefix]}/mission_control_jobs"
329
- say " - Solid Errors: error tracking at #{options[:prefix]}/solid_errors"
332
+ say " - Mission Control Jobs: job dashboard at #{options[:prefix]}/jobs"
333
+ say " - Solid Errors: error tracking at #{options[:prefix]}/errors"
334
+ say " - Admin overview: #{options[:prefix]}"
330
335
  say " - Maquina Components: UI components"
331
336
  if options[:auth] != "none"
332
337
  say ""
@@ -34,15 +34,17 @@ Your app will be running at **http://localhost:<%= options[:port] %>**.
34
34
  | **WebSockets** | Solid Cable |
35
35
  | **Rich Text** | Action Text |
36
36
  | **File Storage** | Active Storage (S3-ready in production) |
37
- | **Multi-database** | Separate databases for queue, cache, cable, and errors |
37
+ | **Multi-database** | Separate databases for queue, cache, cable, errors, and security |
38
38
 
39
39
  ## Development URLs
40
40
 
41
41
  | URL | Description |
42
42
  |-----|-------------|
43
43
  | http://localhost:<%= options[:port] %> | Application |
44
- | http://localhost:<%= options[:port] %><%= options[:prefix] %>/mission_control_jobs | Jobs dashboard |
45
- | http://localhost:<%= options[:port] %><%= options[:prefix] %>/solid_errors | Errors dashboard |
44
+ | http://localhost:<%= options[:port] %><%= options[:prefix] %> | Admin overview |
45
+ | http://localhost:<%= options[:port] %><%= options[:prefix] %>/errors | Errors dashboard |
46
+ | http://localhost:<%= options[:port] %><%= options[:prefix] %>/jobs | Jobs dashboard |
47
+ | http://localhost:<%= options[:port] %><%= options[:prefix] %>/security | Security dashboard |
46
48
  <% if options[:auth] != "none" -%>
47
49
  | http://localhost:<%= options[:port] %>/session/new | Sign in |
48
50
  <% if options[:auth] == "registration" -%>
@@ -65,7 +67,7 @@ Edit credentials with:
65
67
  bin/rails credentials:edit
66
68
  ```
67
69
 
68
- Add backstage credentials for the jobs and errors dashboards:
70
+ Add backstage credentials for the admin dashboards (overview, errors, jobs, security):
69
71
 
70
72
  ```yaml
71
73
  backstage:
@@ -32,17 +32,17 @@
32
32
  <%% if authenticated? %>
33
33
  <section class="max-w-5xl mx-auto px-5 py-12">
34
34
  <p class="text-sm font-semibold uppercase tracking-wider text-primary mb-4">Admin Tools</p>
35
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
35
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
36
36
  <%%= render "components/card" do %>
37
37
  <%%= render "components/card/header" do %>
38
38
  <div class="flex items-center gap-2">
39
- <%%= icon_for(:activity, class: "size-5 text-primary") %>
39
+ <%%= icon_for(:triangle_alert, class: "size-5 text-primary") %>
40
40
  <%%= render "components/card/title", text: "Solid Errors" %>
41
41
  </div>
42
42
  <%%= render "components/card/description", text: "Browse, resolve, and clean up application exceptions in real time." %>
43
43
  <%% end %>
44
44
  <%%= render "components/card/footer" do %>
45
- <%%= link_to "Open Dashboard", "<%= options[:prefix] %>/solid_errors", data: { component: "button", variant: "outline", size: "sm" } %>
45
+ <%%= link_to "Open Dashboard", "<%= options[:prefix] %>/errors", data: { component: "button", variant: "outline", size: "sm" } %>
46
46
  <%% end %>
47
47
  <%% end %>
48
48
 
@@ -55,7 +55,20 @@
55
55
  <%%= render "components/card/description", text: "Monitor queues, retry failed jobs, and manage background processing." %>
56
56
  <%% end %>
57
57
  <%%= render "components/card/footer" do %>
58
- <%%= link_to "Open Dashboard", "<%= options[:prefix] %>/mission_control_jobs", data: { component: "button", variant: "outline", size: "sm" } %>
58
+ <%%= link_to "Open Dashboard", "<%= options[:prefix] %>/jobs", data: { component: "button", variant: "outline", size: "sm" } %>
59
+ <%% end %>
60
+ <%% end %>
61
+
62
+ <%%= render "components/card" do %>
63
+ <%%= render "components/card/header" do %>
64
+ <div class="flex items-center gap-2">
65
+ <%%= icon_for(:slash, class: "size-5 text-primary") %>
66
+ <%%= render "components/card/title", text: "Security" %>
67
+ </div>
68
+ <%%= render "components/card/description", text: "See what Rack::Attack throttled, blocked, and banned this week." %>
69
+ <%% end %>
70
+ <%%= render "components/card/footer" do %>
71
+ <%%= link_to "Open Dashboard", "<%= options[:prefix] %>/security", data: { component: "button", variant: "outline", size: "sm" } %>
59
72
  <%% end %>
60
73
  <%% end %>
61
74
  </div>
@@ -109,7 +122,7 @@
109
122
  <%%= render "components/card/title", text: "Rate Limiting" %>
110
123
  <%% end %>
111
124
  <%%= render "components/card/content" do %>
112
- <p class="text-sm text-muted-foreground">Rack::Attack configured with blocklists and throttles. Protect login, registration, and API endpoints.</p>
125
+ <p class="text-sm text-muted-foreground">Rack::Attack bans scanners and floods and throttles sign-in. Every refusal shows on the Security dashboard.</p>
113
126
  <%% end %>
114
127
  <%% end %>
115
128
 
@@ -29,6 +29,10 @@ development:
29
29
  <<: *default
30
30
  database: storage/development_errors.sqlite3
31
31
  migrations_paths: db/errors_migrate
32
+ security:
33
+ <<: *default
34
+ database: storage/development_security.sqlite3
35
+ migrations_paths: db/security_migrate
32
36
 
33
37
  # Warning: The database defined as "test" will be erased and
34
38
  # re-generated from your development database when you run "rake".
@@ -53,6 +57,10 @@ test:
53
57
  <<: *default
54
58
  database: storage/test_errors.sqlite3
55
59
  migrations_paths: db/errors_migrate
60
+ security:
61
+ <<: *default
62
+ database: storage/test_security.sqlite3
63
+ migrations_paths: db/security_migrate
56
64
 
57
65
  # Store production database in the storage/ directory, which by default
58
66
  # is mounted as a persistent Docker volume in config/deploy.yml.
@@ -76,3 +84,7 @@ production:
76
84
  <<: *default
77
85
  database: storage/production_errors.sqlite3
78
86
  migrations_paths: db/errors_migrate
87
+ security:
88
+ <<: *default
89
+ database: storage/production_security.sqlite3
90
+ migrations_paths: db/security_migrate
@@ -1,5 +1,6 @@
1
1
  require "rails/generators"
2
2
  require "rails/generators/migration"
3
+ require "active_support/encrypted_configuration"
3
4
 
4
5
  module Maquina
5
6
  module Generators
@@ -42,6 +43,23 @@ module Maquina
42
43
  end
43
44
 
44
45
  # 2. Controllers
46
+ # EmailVerification encrypts its code, which needs Active Record
47
+ # encryption keys. They go into the app's credentials when missing; with
48
+ # no master key to write them, the post-install message says what to run.
49
+ def configure_encryption_keys
50
+ credentials = app_credentials
51
+ if credentials.nil?
52
+ @encryption_pending = true
53
+ return
54
+ end
55
+
56
+ content = credentials.read
57
+ return if content.match?(/^active_record_encryption:/)
58
+
59
+ credentials.write(content.rstrip + (content.strip.empty? ? "" : "\n\n") + encryption_keys_yaml)
60
+ @encryption_written = true
61
+ end
62
+
45
63
  def create_controllers
46
64
  template "app/controllers/concerns/authentication.rb.tt",
47
65
  "app/controllers/concerns/authentication.rb"
@@ -172,6 +190,15 @@ module Maquina
172
190
  say " 4. Visit /registration/new # Sign-up page"
173
191
  end
174
192
  say ""
193
+ if @encryption_written
194
+ say "Added Active Record encryption keys to config/credentials.yml.enc (EmailVerification encrypts its code).", :yellow
195
+ say ""
196
+ elsif @encryption_pending
197
+ say "EmailVerification encrypts its code. Generate keys and add them to your credentials:", :yellow
198
+ say " bin/rails db:encryption:init"
199
+ say " bin/rails credentials:edit"
200
+ say ""
201
+ end
175
202
  say "Optional:", :yellow
176
203
  say " - Edit app/controllers/concerns/authentication.rb to customize redirect URLs"
177
204
  say " - Edit config/locales/clave.*.yml to customize messages"
@@ -181,6 +208,27 @@ module Maquina
181
208
 
182
209
  private
183
210
 
211
+ # The app's credentials, or nil when there is no key to decrypt them with.
212
+ def app_credentials
213
+ key_path = File.join(destination_root, "config/master.key")
214
+ return unless File.exist?(key_path) || ENV["RAILS_MASTER_KEY"].present?
215
+
216
+ ActiveSupport::EncryptedConfiguration.new(
217
+ config_path: File.join(destination_root, "config/credentials.yml.enc"),
218
+ key_path: key_path, env_key: "RAILS_MASTER_KEY", raise_if_missing_key: false
219
+ )
220
+ end
221
+
222
+ # The same shape `bin/rails db:encryption:init` prints.
223
+ def encryption_keys_yaml
224
+ <<~YAML
225
+ active_record_encryption:
226
+ primary_key: #{SecureRandom.alphanumeric(32)}
227
+ deterministic_key: #{SecureRandom.alphanumeric(32)}
228
+ key_derivation_salt: #{SecureRandom.alphanumeric(32)}
229
+ YAML
230
+ end
231
+
184
232
  def migration_version
185
233
  "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
186
234
  end
@@ -26,7 +26,16 @@ class Registration::VerificationsController < ApplicationController
26
26
  return
27
27
  end
28
28
 
29
- if @verification.code != params[:code]&.upcase&.strip
29
+ # Too many wrong guesses: kill the code so a fresh one is required.
30
+ if @verification.locked?
31
+ @verification.update!(expires_at: Time.current)
32
+ Rails.logger.warn "[AUTH] Verification locked after #{@verification.attempts} attempts for #{@email} from #{request.remote_ip}"
33
+ flash.now[:alert] = t("flash.registrations.code_locked")
34
+ render :new, status: :unprocessable_entity
35
+ return
36
+ end
37
+
38
+ unless @verification.code_matches?(params[:code])
30
39
  @verification.increment!(:attempts)
31
40
  Rails.logger.warn "[AUTH] Invalid verification code attempt for #{@email} from #{request.remote_ip} (attempt #{@verification.attempts})"
32
41
  flash.now[:alert] = t("flash.registrations.invalid_code")
@@ -27,7 +27,16 @@ class Session::VerificationsController < ApplicationController
27
27
  return
28
28
  end
29
29
 
30
- if @verification.code != params[:code]&.upcase&.strip
30
+ # Too many wrong guesses: kill the code so a fresh one is required.
31
+ if @verification.locked?
32
+ @verification.update!(expires_at: Time.current)
33
+ Rails.logger.warn "[AUTH] Verification locked after #{@verification.attempts} attempts for #{@email} from #{request.remote_ip}"
34
+ flash.now[:alert] = t("flash.sessions.code_locked")
35
+ render :new, status: :unprocessable_entity
36
+ return
37
+ end
38
+
39
+ unless @verification.code_matches?(params[:code])
31
40
  @verification.increment!(:attempts)
32
41
  Rails.logger.warn "[AUTH] Invalid verification code attempt for #{@email} from #{request.remote_ip} (attempt #{@verification.attempts})"
33
42
  flash.now[:alert] = t("flash.sessions.invalid_code")
@@ -1,6 +1,7 @@
1
1
  class EmailVerification < ApplicationRecord
2
2
  VERIFICATION_TYPES = %w[signup login].freeze
3
3
  COOLDOWN_MINUTES = 15
4
+ MAX_ATTEMPTS = 5
4
5
 
5
6
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
6
7
  validates :code, presence: true
@@ -10,6 +11,10 @@ class EmailVerification < ApplicationRecord
10
11
  normalizes :email, with: ->(email) { email.strip.downcase }
11
12
  normalizes :code, with: ->(code) { code.strip.upcase }
12
13
 
14
+ # Encrypted at rest, not digested: the mail goes out through deliver_later,
15
+ # whose job reloads this row by GlobalID and must read the code back.
16
+ encrypts :code
17
+
13
18
  scope :pending, -> { where(verified_at: nil) }
14
19
  scope :expired, -> { where("expires_at < ?", Time.current) }
15
20
  scope :active, -> { pending.where("expires_at >= ?", Time.current) }
@@ -20,6 +25,19 @@ class EmailVerification < ApplicationRecord
20
25
  expires_at < Time.current
21
26
  end
22
27
 
28
+ # A code is single-use against a bounded number of guesses: once the wrong-code
29
+ # count hits the ceiling the record is dead regardless of how many IPs an
30
+ # attacker rotates through, since each code only ever maps to one row.
31
+ def locked?
32
+ attempts >= MAX_ATTEMPTS
33
+ end
34
+
35
+ # Constant-time, so response timing reveals nothing about how close a guess
36
+ # came. Normalized the same way the stored code is.
37
+ def code_matches?(candidate)
38
+ ActiveSupport::SecurityUtils.secure_compare(code, candidate.to_s.strip.upcase)
39
+ end
40
+
23
41
  def verified?
24
42
  verified_at.present?
25
43
  end
@@ -8,6 +8,7 @@ en:
8
8
  code_sent: "We sent you a verification code."
9
9
  invalid_code: "The code is incorrect. Please try again."
10
10
  code_expired: "The code has expired. Request a new one."
11
+ code_locked: "Too many incorrect tries. Request a new code to keep going."
11
12
  code_resent: "We sent you a new code."
12
13
  cooldown: "You must wait %{minutes} minutes before requesting another code."
13
14
  code_already_sent: "We already sent you a code. Check your email (including spam). You can request a new one in %{minutes} minutes."
@@ -19,6 +20,7 @@ en:
19
20
  email_taken: "An account with this email already exists. Would you like to sign in?"
20
21
  invalid_code: "The code is incorrect. Please try again."
21
22
  code_expired: "The code has expired. Request a new one."
23
+ code_locked: "Too many incorrect tries. Request a new code to keep going."
22
24
  code_resent: "We sent you a new code."
23
25
  cooldown: "You must wait %{minutes} minutes before requesting another code."
24
26
  code_already_sent: "We already sent you a code. Check your email (including spam). You can request a new one in %{minutes} minutes."
@@ -8,6 +8,7 @@ es:
8
8
  code_sent: "Te enviamos un codigo de verificacion."
9
9
  invalid_code: "El codigo es incorrecto. Por favor, intenta de nuevo."
10
10
  code_expired: "El codigo ha expirado. Solicita uno nuevo."
11
+ code_locked: "Demasiados intentos incorrectos. Solicita un codigo nuevo para continuar."
11
12
  code_resent: "Te enviamos un nuevo codigo."
12
13
  cooldown: "Debes esperar %{minutes} minutos antes de solicitar otro codigo."
13
14
  code_already_sent: "Ya te enviamos un codigo. Revisa tu correo (incluyendo spam). Podras solicitar uno nuevo en %{minutes} minutos."
@@ -19,6 +20,7 @@ es:
19
20
  email_taken: "Ya existe una cuenta con este correo. Quieres iniciar sesion?"
20
21
  invalid_code: "El codigo es incorrecto. Por favor, intenta de nuevo."
21
22
  code_expired: "El codigo ha expirado. Solicita uno nuevo."
23
+ code_locked: "Demasiados intentos incorrectos. Solicita un codigo nuevo para continuar."
22
24
  code_resent: "Te enviamos un nuevo codigo."
23
25
  cooldown: "Debes esperar %{minutes} minutos antes de solicitar otro codigo."
24
26
  code_already_sent: "Ya te enviamos un codigo. Revisa tu correo (incluyendo spam). Podras solicitar uno nuevo en %{minutes} minutos."
@@ -0,0 +1,18 @@
1
+ Description:
2
+ Adds a Content Security Policy violation report endpoint and shows the
3
+ reports on the backstage Security page. Needs maquina:security, whose
4
+ `security` database stores them.
5
+
6
+ Installs CspReportsController (POST <path>, no session or CSRF, both report
7
+ formats, 64 KB cap, browser-extension noise dropped), the
8
+ Security::CspViolation model and its migration in db/security_migrate, the
9
+ csp_violations table in db/security_schema.rb, a daily purge in
10
+ config/recurring.yml and a section on the Security page. It points the
11
+ policy's report-uri at the endpoint, gives anonymous requests a nonce
12
+ (Rails' session-id nonce is empty without a session), exempts the endpoint
13
+ from the general Rack::Attack throttle and caps it on its own. Anything it
14
+ cannot place safely is listed after the install instead of being forced.
15
+
16
+ Examples:
17
+ rails g maquina:csp_reports
18
+ rails g maquina:csp_reports --path /security/csp-reports