maquina-generators 0.4.1 → 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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +96 -17
  3. data/lib/generators/maquina/app/app_generator.rb +75 -19
  4. data/lib/generators/maquina/app/templates/.rubocop.yml +6 -0
  5. data/lib/generators/maquina/app/templates/README.md.erb +6 -4
  6. data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +18 -5
  7. data/lib/generators/maquina/app/templates/config/database.yml.tt +12 -0
  8. data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +32 -1
  9. data/lib/generators/maquina/mission_control_jobs/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  10. data/lib/generators/maquina/mission_control_jobs/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  11. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  12. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/admin.html.erb +21 -0
  13. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/_general_information.html.erb +1 -1
  14. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/failed/_job.html.erb +2 -2
  15. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/shared/_job.html.erb +1 -1
  16. data/lib/generators/maquina/rack_attack/USAGE +8 -6
  17. data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +6 -2
  18. data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +139 -57
  19. data/lib/generators/maquina/security/USAGE +15 -0
  20. data/lib/generators/maquina/security/security_generator.rb +278 -0
  21. data/lib/generators/maquina/security/templates/app/controllers/backstage/security_controller.rb.tt +45 -0
  22. data/lib/generators/maquina/security/templates/app/controllers/backstage_controller.rb.tt +7 -0
  23. data/lib/generators/maquina/security/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  24. data/lib/generators/maquina/security/templates/app/models/security/abuse_event.rb +36 -0
  25. data/lib/generators/maquina/security/templates/app/models/security/abuse_report.rb +112 -0
  26. data/lib/generators/maquina/security/templates/app/models/security/record.rb +6 -0
  27. data/lib/generators/maquina/security/templates/app/views/backstage/security/_addresses.html.erb +37 -0
  28. data/lib/generators/maquina/security/templates/app/views/backstage/security/_blocked.html.erb +53 -0
  29. data/lib/generators/maquina/security/templates/app/views/backstage/security/_by_day.html.erb +33 -0
  30. data/lib/generators/maquina/security/templates/app/views/backstage/security/_hosts.html.erb +31 -0
  31. data/lib/generators/maquina/security/templates/app/views/backstage/security/_paths.html.erb +33 -0
  32. data/lib/generators/maquina/security/templates/app/views/backstage/security/_recent.html.erb +37 -0
  33. data/lib/generators/maquina/security/templates/app/views/backstage/security/_utc_time.html.erb +2 -0
  34. data/lib/generators/maquina/security/templates/app/views/backstage/security/show.html.erb +47 -0
  35. data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  36. data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt +40 -0
  37. data/lib/generators/maquina/security/templates/app/views/layouts/admin.html.erb +21 -0
  38. data/lib/generators/maquina/security/templates/app/views/layouts/backstage/security.html.erb +23 -0
  39. data/lib/generators/maquina/security/templates/config/initializers/rack_attack_events.rb +34 -0
  40. data/lib/generators/maquina/security/templates/db/security_schema.rb.tt +27 -0
  41. data/lib/generators/maquina/solid_errors/USAGE +24 -0
  42. data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +171 -14
  43. data/lib/generators/maquina/solid_errors/templates/app/agents/error_redactor.rb +34 -0
  44. data/lib/generators/maquina/solid_errors/templates/app/agents/errors_query.rb +84 -0
  45. data/lib/generators/maquina/solid_errors/templates/app/agents/failure_triage.rb +21 -0
  46. data/lib/generators/maquina/solid_errors/templates/app/agents/get_exception_tool.rb +26 -0
  47. data/lib/generators/maquina/solid_errors/templates/app/agents/list_failures_tool.rb +20 -0
  48. data/lib/generators/maquina/solid_errors/templates/app/agents/top_exceptions_tool.rb +18 -0
  49. data/lib/generators/maquina/solid_errors/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  50. data/lib/generators/maquina/solid_errors/templates/app/controllers/failures_mcp_controller.rb.tt +65 -0
  51. data/lib/generators/maquina/solid_errors/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  52. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  53. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/admin.html.erb +21 -0
  54. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.html.erb +65 -0
  55. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.text.erb +30 -0
  56. data/lib/generators/maquina/solid_errors/templates/bin/failures +37 -0
  57. data/lib/generators/maquina/solid_errors/templates/bin/failures-mcp +21 -0
  58. data/lib/generators/maquina/solid_errors/templates/config/initializers/solid_errors.rb.tt +12 -0
  59. data/lib/maquina_generators/version.rb +1 -1
  60. data/test/generators/maquina/app_generator_test.rb +142 -5
  61. data/test/generators/maquina/failures_mcp_tools_test.rb +37 -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 +199 -4
  66. data/test/tmp/Gemfile +1 -1
  67. data/test/tmp/app/controllers/application_controller.rb +2 -0
  68. data/test/tmp/app/controllers/registrations_controller.rb +37 -0
  69. data/test/tmp/app/models/account.rb +5 -0
  70. data/test/tmp/app/models/current.rb +5 -0
  71. data/test/tmp/app/models/user.rb +12 -0
  72. data/test/tmp/app/views/registrations/new.html.erb +72 -0
  73. data/test/tmp/app/views/sessions/new.html.erb +46 -0
  74. data/test/tmp/config/locales/registration.en.yml +44 -0
  75. data/test/tmp/config/locales/registration.es.yml +44 -0
  76. data/test/tmp/config/routes.rb +4 -0
  77. data/test/tmp/db/migrate/20260927024163_create_accounts.rb +9 -0
  78. data/test/tmp/db/migrate/20260927024164_add_account_fields_to_users.rb +7 -0
  79. metadata +72 -7
  80. data/test/tmp/Procfile.dev +0 -3
  81. data/test/tmp/config/application.rb +0 -9
  82. data/test/tmp/config/solid_queue.yml +0 -21
@@ -0,0 +1,93 @@
1
+ <div class="py-8">
2
+ <!-- Header -->
3
+ <div class="mb-6 flex items-end justify-between border-b border-border pb-4">
4
+ <h1 class="text-2xl font-bold text-foreground">Overview</h1>
5
+ <%% if @generated_at %>
6
+ <p class="font-mono text-[11px] tabular-nums text-muted-foreground">as of <%%= @generated_at.strftime("%H:%M %Z") %></p>
7
+ <%% end %>
8
+ </div>
9
+
10
+ <!-- Metrics: populate @metrics in BackstageDashboardController#index. Aggregate-only. -->
11
+ <%% if @metrics.present? %>
12
+ <div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
13
+ <%% @metrics.each do |metric| %>
14
+ <%%= render "components/card" do %>
15
+ <div class="px-5 py-4">
16
+ <div class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
17
+ <%% if metric[:icon] %><%%= icon_for(metric[:icon], class: "size-3.5") %><%% end %>
18
+ <%%= metric[:label] %>
19
+ </div>
20
+ <div class="font-display text-4xl font-semibold tabular-nums text-foreground">
21
+ <%%= number_with_delimiter(metric[:value]) %><%% if metric[:total] %><span class="text-xl text-muted-foreground">/<%%= number_with_delimiter(metric[:total]) %></span><%% end %>
22
+ </div>
23
+ <%% if metric[:hint] %>
24
+ <div class="mt-1 text-[11px] tabular-nums text-muted-foreground"><%%= metric[:hint] %></div>
25
+ <%% end %>
26
+ </div>
27
+ <%% end %>
28
+ <%% end %>
29
+ </div>
30
+ <%% else %>
31
+ <%%= empty_state(
32
+ title: "No metrics yet",
33
+ description: "Add aggregate counts in BackstageDashboardController#index to populate this overview.",
34
+ icon: :layout_dashboard,
35
+ size: :default
36
+ ) %>
37
+ <%% end %>
38
+
39
+ <!-- Operations: entry points to the installed engines -->
40
+ <h2 class="mt-10 mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Operations</h2>
41
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
42
+ <%% if defined?(SolidErrors::Engine) %>
43
+ <%%= link_to "<%= options[:prefix] %>/errors", class: "group block" do %>
44
+ <%%= render "components/card", css_classes: "transition-shadow hover:shadow-md" do %>
45
+ <div class="flex items-center gap-4 px-5 py-4">
46
+ <span class="inline-flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
47
+ <%%= icon_for(:triangle_alert, class: "size-5") %>
48
+ </span>
49
+ <div class="min-w-0 flex-1">
50
+ <div class="font-semibold text-foreground">Errors</div>
51
+ <div class="text-sm text-muted-foreground">Exceptions, backtraces, and resolution</div>
52
+ </div>
53
+ <%%= icon_for(:arrow_right, class: "size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5") %>
54
+ </div>
55
+ <%% end %>
56
+ <%% end %>
57
+ <%% end %>
58
+
59
+ <%% if defined?(MissionControl::Jobs::Engine) %>
60
+ <%%= link_to "<%= options[:prefix] %>/jobs", class: "group block" do %>
61
+ <%%= render "components/card", css_classes: "transition-shadow hover:shadow-md" do %>
62
+ <div class="flex items-center gap-4 px-5 py-4">
63
+ <span class="inline-flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
64
+ <%%= icon_for(:inbox, class: "size-5") %>
65
+ </span>
66
+ <div class="min-w-0 flex-1">
67
+ <div class="font-semibold text-foreground">Background jobs</div>
68
+ <div class="text-sm text-muted-foreground">Queues, workers, and recurring tasks</div>
69
+ </div>
70
+ <%%= icon_for(:arrow_right, class: "size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5") %>
71
+ </div>
72
+ <%% end %>
73
+ <%% end %>
74
+ <%% end %>
75
+
76
+ <%% if defined?(Security::AbuseEvent) %>
77
+ <%%= link_to "<%= options[:prefix] %>/security", class: "group block" do %>
78
+ <%%= render "components/card", css_classes: "transition-shadow hover:shadow-md" do %>
79
+ <div class="flex items-center gap-4 px-5 py-4">
80
+ <span class="inline-flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
81
+ <%%= icon_for(:slash, class: "size-5") %>
82
+ </span>
83
+ <div class="min-w-0 flex-1">
84
+ <div class="font-semibold text-foreground">Security</div>
85
+ <div class="text-sm text-muted-foreground">Throttled, blocked, and banned requests</div>
86
+ </div>
87
+ <%%= icon_for(:arrow_right, class: "size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5") %>
88
+ </div>
89
+ <%% end %>
90
+ <%% end %>
91
+ <%% end %>
92
+ </div>
93
+ </div>
@@ -4,17 +4,29 @@
4
4
  <div class="flex items-center gap-6">
5
5
  <span class="text-sm font-semibold text-foreground">Admin</span>
6
6
  <div class="-mb-px flex items-center space-x-4">
7
- <%%= link_to "<%= options[:prefix] %>/solid_errors",
8
- class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium #{request.path.start_with?('<%= options[:prefix] %>/solid_errors') ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'}" do %>
9
- <%%= icon_for(:alert_triangle, class: "size-4") %>
7
+ <%%= link_to "<%= options[:prefix] %>",
8
+ class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium #{request.path == '<%= options[:prefix] %>' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'}" do %>
9
+ <%%= icon_for(:layout_dashboard, class: "size-4") %>
10
+ <span>Overview</span>
11
+ <%% end %>
12
+
13
+ <%%= link_to "<%= options[:prefix] %>/errors",
14
+ class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium #{request.path.start_with?('<%= options[:prefix] %>/errors') ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'}" do %>
15
+ <%%= icon_for(:triangle_alert, class: "size-4") %>
10
16
  <span>Errors</span>
11
17
  <%% end %>
12
18
 
13
- <%%= link_to "<%= options[:prefix] %>/mission_control_jobs",
14
- class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium #{request.path.start_with?('<%= options[:prefix] %>/mission_control_jobs') ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'}" do %>
15
- <%%= icon_for(:briefcase, class: "size-4") %>
19
+ <%%= link_to "<%= options[:prefix] %>/jobs",
20
+ class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium #{request.path.start_with?('<%= options[:prefix] %>/jobs') ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'}" do %>
21
+ <%%= icon_for(:inbox, class: "size-4") %>
16
22
  <span>Jobs</span>
17
23
  <%% end %>
24
+
25
+ <%%= link_to "<%= options[:prefix] %>/security",
26
+ 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 %>
27
+ <%%= icon_for(:slash, class: "size-4") %>
28
+ <span>Security</span>
29
+ <%% end %>
18
30
  </div>
19
31
  </div>
20
32
 
@@ -0,0 +1,21 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Admin</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
9
+ <%= javascript_importmap_tags %>
10
+ <%= yield :head %>
11
+ <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
12
+ </head>
13
+ <body class="min-h-screen bg-background text-foreground">
14
+ <%= render "layouts/admin_navigation" %>
15
+ <main class="pt-4 pb-8">
16
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
17
+ <%= yield %>
18
+ </div>
19
+ </main>
20
+ </body>
21
+ </html>
@@ -7,7 +7,7 @@
7
7
  <div class="space-y-4">
8
8
  <div>
9
9
  <span class="text-sm text-muted-foreground">Arguments</span>
10
- <div class="mt-1 text-sm font-mono text-foreground break-words"><%= job_arguments(job) %></div>
10
+ <div class="mt-1 text-sm font-mono text-foreground break-words overflow-wrap-anywhere"><%= job_arguments(job) %></div>
11
11
  </div>
12
12
 
13
13
  <div>
@@ -1,7 +1,7 @@
1
1
  <%= render "components/table/cell" do %>
2
- <div>
2
+ <div class="min-w-0">
3
3
  <%= link_to failed_job_error(job), application_job_path(@application, job.job_id, anchor: "error"),
4
- class: "text-sm text-foreground hover:text-primary transition-colors" %>
4
+ class: "block text-sm text-foreground hover:text-primary transition-colors break-words overflow-wrap-anywhere line-clamp-2" %>
5
5
  <div class="text-xs text-muted-foreground mt-1"><%= time_distance_in_words_with_title(job.failed_at) %> ago</div>
6
6
  </div>
7
7
  <% end %>
@@ -12,7 +12,7 @@
12
12
  <% end %>
13
13
  <%= render "components/table/cell" do %>
14
14
  <% if job.serialized_arguments.present? %>
15
- <span class="text-xs font-mono text-muted-foreground"><%= job_arguments(job) %></span>
15
+ <span class="block min-w-0 text-xs font-mono text-muted-foreground break-words overflow-wrap-anywhere line-clamp-2"><%= job_arguments(job) %></span>
16
16
  <% end %>
17
17
  <% end %>
18
18
  <%= render "components/table/cell" do %>
@@ -3,13 +3,15 @@ Description:
3
3
  vulnerability scans and throttle abusive requests.
4
4
 
5
5
  Default protections:
6
- - Blocks PHP file requests (*.php)
7
- - Blocks WordPress paths (wp-admin, wp-login, wp-content, etc.)
8
- - Blocks sensitive file access (.env, .git, /etc/passwd, etc.)
9
- - Blocks common scanner targets (phpmyadmin, cgi-bin, etc.)
6
+ - Bans an IP for 7 days after 3 scanner paths in 10 minutes: PHP files,
7
+ WordPress paths, sensitive files (.env, .git, /etc/passwd), backup
8
+ archives (.zip, .sql, .tar.gz) and common scanner targets
9
+ - Bans an IP for 1 day after 600 requests in 5 minutes
10
+ - Throttles general requests (300/5min per IP; assets and Active Storage exempt)
11
+ - Throttles login attempts (5/20s per IP on POST --login-path)
10
12
  - Safelists localhost
11
- - Throttles general requests (300/5min per IP)
12
- - Throttles login attempts (5/20s per IP)
13
+ - Logs every refusal as an [ATTACK] line
13
14
 
14
15
  Examples:
15
16
  rails g maquina:rack_attack
17
+ rails g maquina:rack_attack --login-path /sign_in
@@ -5,6 +5,8 @@ module Maquina
5
5
  class RackAttackGenerator < Rails::Generators::Base
6
6
  source_root File.expand_path("templates", __dir__)
7
7
 
8
+ class_option :login_path, type: :string, default: "/session",
9
+ desc: "Path whose POSTs the login throttle counts"
8
10
  class_option :quiet, type: :boolean, default: false,
9
11
  desc: "Suppress post-install instructions"
10
12
 
@@ -42,9 +44,11 @@ module Maquina
42
44
  say "Rack::Attack has been installed!", :green
43
45
  say ""
44
46
  say "Default protections enabled:", :yellow
45
- say " - Blocklist: PHP files, WordPress paths, sensitive files, scanner targets"
47
+ say " - Scanner ban: 3 scanner paths (PHP, WordPress, dotfiles, backup archives) in 10 min bans the IP for 7 days"
48
+ say " - Flood ban: 600 requests in 5 min bans the IP for 1 day"
49
+ say " - Throttle: 300 req/5min per IP, 5 POST #{options[:login_path]}/20s per IP"
46
50
  say " - Safelist: localhost (127.0.0.1, ::1)"
47
- say " - Throttle: 300 req/5min per IP, 5 login attempts/20s per IP"
51
+ say " - Every refusal logged as an [ATTACK] line"
48
52
  say ""
49
53
  say "Customize rules in config/initializers/rack_attack.rb"
50
54
  say ""
@@ -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