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.
- checksums.yaml +4 -4
- data/README.md +96 -17
- data/lib/generators/maquina/app/app_generator.rb +75 -19
- data/lib/generators/maquina/app/templates/.rubocop.yml +6 -0
- data/lib/generators/maquina/app/templates/README.md.erb +6 -4
- data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +18 -5
- data/lib/generators/maquina/app/templates/config/database.yml.tt +12 -0
- data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +32 -1
- data/lib/generators/maquina/mission_control_jobs/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/admin.html.erb +21 -0
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/_general_information.html.erb +1 -1
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/failed/_job.html.erb +2 -2
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/shared/_job.html.erb +1 -1
- data/lib/generators/maquina/rack_attack/USAGE +8 -6
- data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +6 -2
- data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +139 -57
- data/lib/generators/maquina/security/USAGE +15 -0
- data/lib/generators/maquina/security/security_generator.rb +278 -0
- data/lib/generators/maquina/security/templates/app/controllers/backstage/security_controller.rb.tt +45 -0
- data/lib/generators/maquina/security/templates/app/controllers/backstage_controller.rb.tt +7 -0
- data/lib/generators/maquina/security/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
- data/lib/generators/maquina/security/templates/app/models/security/abuse_event.rb +36 -0
- data/lib/generators/maquina/security/templates/app/models/security/abuse_report.rb +112 -0
- data/lib/generators/maquina/security/templates/app/models/security/record.rb +6 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_addresses.html.erb +37 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_blocked.html.erb +53 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_by_day.html.erb +33 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_hosts.html.erb +31 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_paths.html.erb +33 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_recent.html.erb +37 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_utc_time.html.erb +2 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/show.html.erb +47 -0
- data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
- data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt +40 -0
- data/lib/generators/maquina/security/templates/app/views/layouts/admin.html.erb +21 -0
- data/lib/generators/maquina/security/templates/app/views/layouts/backstage/security.html.erb +23 -0
- data/lib/generators/maquina/security/templates/config/initializers/rack_attack_events.rb +34 -0
- data/lib/generators/maquina/security/templates/db/security_schema.rb.tt +27 -0
- data/lib/generators/maquina/solid_errors/USAGE +24 -0
- data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +171 -14
- data/lib/generators/maquina/solid_errors/templates/app/agents/error_redactor.rb +34 -0
- data/lib/generators/maquina/solid_errors/templates/app/agents/errors_query.rb +84 -0
- data/lib/generators/maquina/solid_errors/templates/app/agents/failure_triage.rb +21 -0
- data/lib/generators/maquina/solid_errors/templates/app/agents/get_exception_tool.rb +26 -0
- data/lib/generators/maquina/solid_errors/templates/app/agents/list_failures_tool.rb +20 -0
- data/lib/generators/maquina/solid_errors/templates/app/agents/top_exceptions_tool.rb +18 -0
- data/lib/generators/maquina/solid_errors/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
- data/lib/generators/maquina/solid_errors/templates/app/controllers/failures_mcp_controller.rb.tt +65 -0
- data/lib/generators/maquina/solid_errors/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
- data/lib/generators/maquina/solid_errors/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
- data/lib/generators/maquina/solid_errors/templates/app/views/layouts/admin.html.erb +21 -0
- data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.html.erb +65 -0
- data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.text.erb +30 -0
- data/lib/generators/maquina/solid_errors/templates/bin/failures +37 -0
- data/lib/generators/maquina/solid_errors/templates/bin/failures-mcp +21 -0
- data/lib/generators/maquina/solid_errors/templates/config/initializers/solid_errors.rb.tt +12 -0
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/app_generator_test.rb +142 -5
- data/test/generators/maquina/failures_mcp_tools_test.rb +37 -0
- data/test/generators/maquina/mission_control_jobs_generator_test.rb +28 -4
- data/test/generators/maquina/rack_attack_generator_test.rb +41 -19
- data/test/generators/maquina/security_generator_test.rb +380 -0
- data/test/generators/maquina/solid_errors_generator_test.rb +199 -4
- data/test/tmp/Gemfile +1 -1
- data/test/tmp/app/controllers/application_controller.rb +2 -0
- data/test/tmp/app/controllers/registrations_controller.rb +37 -0
- data/test/tmp/app/models/account.rb +5 -0
- data/test/tmp/app/models/current.rb +5 -0
- data/test/tmp/app/models/user.rb +12 -0
- data/test/tmp/app/views/registrations/new.html.erb +72 -0
- data/test/tmp/app/views/sessions/new.html.erb +46 -0
- data/test/tmp/config/locales/registration.en.yml +44 -0
- data/test/tmp/config/locales/registration.es.yml +44 -0
- data/test/tmp/config/routes.rb +4 -0
- data/test/tmp/db/migrate/20260927024163_create_accounts.rb +9 -0
- data/test/tmp/db/migrate/20260927024164_add_account_fields_to_users.rb +7 -0
- metadata +72 -7
- data/test/tmp/Procfile.dev +0 -3
- data/test/tmp/config/application.rb +0 -9
- 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]
|
|
8
|
-
class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium #{request.path
|
|
9
|
-
<%%= icon_for(:
|
|
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] %>/
|
|
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] %>/
|
|
15
|
-
<%%= icon_for(:
|
|
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
|
-
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
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
|
-
-
|
|
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 " -
|
|
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 " -
|
|
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
|
-
#
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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:
|
|
85
|
-
req.ip if req.path ==
|
|
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
|