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.
- checksums.yaml +4 -4
- data/README.md +125 -15
- data/lib/generators/maquina/app/app_generator.rb +9 -4
- 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/clave/clave_generator.rb +48 -0
- data/lib/generators/maquina/clave/templates/app/controllers/registration/verifications_controller.rb.tt +10 -1
- data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt +10 -1
- data/lib/generators/maquina/clave/templates/app/models/email_verification.rb.tt +18 -0
- data/lib/generators/maquina/clave/templates/config/locales/clave.en.yml +2 -0
- data/lib/generators/maquina/clave/templates/config/locales/clave.es.yml +2 -0
- data/lib/generators/maquina/csp_reports/USAGE +18 -0
- data/lib/generators/maquina/csp_reports/csp_reports_generator.rb +231 -0
- data/lib/generators/maquina/csp_reports/templates/app/controllers/csp_reports_controller.rb +24 -0
- data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation.rb +89 -0
- data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation_report.rb +30 -0
- data/lib/generators/maquina/csp_reports/templates/app/views/backstage/security/_csp_violations.html.erb +42 -0
- data/lib/generators/maquina/csp_reports/templates/db/create_csp_violations.rb.tt +23 -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/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/{test/tmp/app/controllers/backstage_controller.rb → lib/generators/maquina/security/templates/app/controllers/backstage_controller.rb.tt} +0 -1
- 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/{test/tmp/app/views/layouts/solid_errors/application.html.erb → lib/generators/maquina/security/templates/app/views/layouts/backstage/security.html.erb} +1 -1
- 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/solid_errors_generator.rb +32 -1
- data/lib/generators/maquina/solid_errors/templates/app/controllers/backstage_dashboard_controller.rb +47 -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/config/initializers/solid_errors.rb.tt +12 -0
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/app_generator_test.rb +21 -0
- data/test/generators/maquina/clave_generator_test.rb +74 -0
- data/test/generators/maquina/csp_reports_generator_test.rb +151 -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 +59 -4
- data/test/tmp/.gitignore +5 -0
- data/test/tmp/.rubocop.yml +11 -0
- data/test/tmp/.standard.yml +1 -0
- data/test/tmp/Gemfile +12 -2
- data/test/tmp/Procfile.dev +3 -0
- data/test/tmp/README.md +89 -0
- data/test/tmp/app/controllers/home_controller.rb +6 -0
- data/test/tmp/app/javascript/application.js +1 -0
- data/test/tmp/app/views/home/index.html.erb +204 -0
- data/test/tmp/app/views/layouts/application.html.erb +10 -0
- data/test/tmp/config/application.rb +15 -0
- data/test/tmp/config/database.yml +90 -0
- data/test/tmp/config/database.yml.example +90 -0
- data/test/tmp/config/environments/development.rb +8 -0
- data/test/tmp/config/environments/production.rb +6 -0
- data/test/tmp/config/initializers/generators.rb +4 -0
- data/test/tmp/config/routes.rb +1 -1
- metadata +60 -32
- data/test/tmp/app/agents/error_redactor.rb +0 -34
- data/test/tmp/app/agents/errors_query.rb +0 -84
- data/test/tmp/app/agents/failure_triage.rb +0 -21
- data/test/tmp/app/agents/get_exception_tool.rb +0 -26
- data/test/tmp/app/agents/list_failures_tool.rb +0 -20
- data/test/tmp/app/agents/top_exceptions_tool.rb +0 -18
- data/test/tmp/app/helpers/solid_errors_helper.rb +0 -9
- data/test/tmp/app/javascript/controllers/backtrace_filter_controller.js +0 -27
- data/test/tmp/app/javascript/controllers/clipboard_controller.js +0 -36
- data/test/tmp/app/views/layouts/_admin_navigation.html.erb +0 -28
- data/test/tmp/app/views/solid_errors/errors/_actions.html.erb +0 -22
- data/test/tmp/app/views/solid_errors/errors/_delete_button.html.erb +0 -10
- data/test/tmp/app/views/solid_errors/errors/_error_card.html.erb +0 -63
- data/test/tmp/app/views/solid_errors/errors/_resolve_button.html.erb +0 -11
- data/test/tmp/app/views/solid_errors/errors/index.html.erb +0 -37
- data/test/tmp/app/views/solid_errors/errors/show/_actions.html.erb +0 -33
- data/test/tmp/app/views/solid_errors/errors/show/_error_details.html.erb +0 -95
- data/test/tmp/app/views/solid_errors/errors/show/_header.html.erb +0 -18
- data/test/tmp/app/views/solid_errors/errors/show/_properties.html.erb +0 -65
- data/test/tmp/app/views/solid_errors/errors/show.html.erb +0 -23
- data/test/tmp/app/views/solid_errors/occurrences/_backtrace_line.html.erb +0 -27
- data/test/tmp/app/views/solid_errors/occurrences/_collection.html.erb +0 -80
- data/test/tmp/app/views/solid_errors/occurrences/_occurrence.html.erb +0 -129
- data/test/tmp/bin/failures +0 -37
- data/test/tmp/bin/failures-mcp +0 -21
- 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]
|
|
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>
|
|
@@ -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
|
|
@@ -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
|
data/lib/generators/maquina/security/templates/app/controllers/backstage/security_controller.rb.tt
ADDED
|
@@ -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
|