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