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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/migration"
|
|
3
|
+
|
|
4
|
+
module Maquina
|
|
5
|
+
module Generators
|
|
6
|
+
class CspReportsGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
THROTTLE_LIMIT = 100
|
|
12
|
+
THROTTLE_PERIOD = "5.minutes"
|
|
13
|
+
# Rails' default nonce generator, empty for a request without a session.
|
|
14
|
+
SESSION_NONCE = "->(request) { request.session.id.to_s }"
|
|
15
|
+
POLICY_BLOCK = /^ config\.content_security_policy do \|policy\|\n/
|
|
16
|
+
# The general throttle's exemptions, in the two shapes maquina:rack_attack
|
|
17
|
+
# has shipped: a start_with? list, and a STATIC_FILE_PREFIXES word array.
|
|
18
|
+
COUNTED_IP_EXEMPTIONS = /req\.path\.start_with\?\("\/assets", "\/rails\/active_storage"/
|
|
19
|
+
STATIC_FILE_PREFIXES = /(STATIC_FILE_PREFIXES = %w\[)([^\]]*)(\])/
|
|
20
|
+
SECURITY_PAGE_END = /\n<\/div>\n\z/
|
|
21
|
+
|
|
22
|
+
class_option :path, type: :string, default: "/csp-reports",
|
|
23
|
+
desc: "Path browsers post violation reports to"
|
|
24
|
+
class_option :quiet, type: :boolean, default: false,
|
|
25
|
+
desc: "Suppress post-install instructions"
|
|
26
|
+
|
|
27
|
+
def self.next_migration_number(dirname)
|
|
28
|
+
current = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
29
|
+
existing = Dir.glob("#{dirname}/[0-9]*_*.rb").map { |f| File.basename(f).split("_", 2).first }.max
|
|
30
|
+
(existing && existing >= current) ? existing.succ : current
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# 1. maquina:security owns the database the reports go in.
|
|
34
|
+
def require_security_database
|
|
35
|
+
return if File.exist?(File.join(destination_root, "db/security_schema.rb"))
|
|
36
|
+
|
|
37
|
+
raise Thor::Error, "maquina:csp_reports stores reports in the `security` database. Run `rails g maquina:security --prefix /admin` first."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 2. Models
|
|
41
|
+
def create_models
|
|
42
|
+
copy_file "app/models/security/csp_violation.rb", "app/models/security/csp_violation.rb"
|
|
43
|
+
copy_file "app/models/security/csp_violation_report.rb", "app/models/security/csp_violation_report.rb"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 3. Controller and route
|
|
47
|
+
def create_controller
|
|
48
|
+
copy_file "app/controllers/csp_reports_controller.rb", "app/controllers/csp_reports_controller.rb"
|
|
49
|
+
route %(post "#{options[:path]}", to: "csp_reports#create", as: :csp_reports)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 4. The table: a migration for an existing security database, the schema
|
|
53
|
+
# for a fresh one (db:prepare loads the schema only on create).
|
|
54
|
+
def create_table
|
|
55
|
+
return if File.read(schema_path).include?(%("csp_violations"))
|
|
56
|
+
|
|
57
|
+
migration_template "db/create_csp_violations.rb.tt", "db/security_migrate/create_csp_violations.rb"
|
|
58
|
+
version = Dir.glob(File.join(destination_root, "db/security_migrate/*_create_csp_violations.rb"))
|
|
59
|
+
.map { |f| File.basename(f).split("_", 2).first }.max
|
|
60
|
+
gsub_file "db/security_schema.rb", /define\(version: [\d_]+\)/, "define(version: #{version_with_underscores(version)})"
|
|
61
|
+
inject_into_file "db/security_schema.rb", csp_violations_table, before: /^end\s*\z/
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 5. The policy reports to the endpoint, and anonymous requests get a nonce.
|
|
65
|
+
def configure_content_security_policy
|
|
66
|
+
path = "config/initializers/content_security_policy.rb"
|
|
67
|
+
content = File.exist?(File.join(destination_root, path)) ? File.read(File.join(destination_root, path)) : ""
|
|
68
|
+
|
|
69
|
+
if content.include?("report_uri")
|
|
70
|
+
@report_uri_pending = !content.include?(%(report_uri "#{options[:path]}"))
|
|
71
|
+
elsif content.match?(POLICY_BLOCK)
|
|
72
|
+
inject_into_file path, report_uri_line, after: POLICY_BLOCK
|
|
73
|
+
else
|
|
74
|
+
@report_uri_pending = true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if content.include?(SESSION_NONCE)
|
|
78
|
+
gsub_file path, SESSION_NONCE, "->(request) { request.session.id.to_s.presence || SecureRandom.base64(16) }"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 6. Rack::Attack: out of the general throttle, capped on its own.
|
|
83
|
+
def configure_rack_attack
|
|
84
|
+
path = "config/initializers/rack_attack.rb"
|
|
85
|
+
full_path = File.join(destination_root, path)
|
|
86
|
+
return @rack_attack_pending = true unless File.exist?(full_path)
|
|
87
|
+
|
|
88
|
+
content = File.read(full_path)
|
|
89
|
+
return if content.include?(%("csp/ip"))
|
|
90
|
+
|
|
91
|
+
if content.match?(COUNTED_IP_EXEMPTIONS)
|
|
92
|
+
# The match stops before the call's closing paren, which stays in place.
|
|
93
|
+
gsub_file path, COUNTED_IP_EXEMPTIONS, 'req.path.start_with?("/assets", "/rails/active_storage", ' + %("#{options[:path]}")
|
|
94
|
+
elsif content.match?(STATIC_FILE_PREFIXES)
|
|
95
|
+
gsub_file path, STATIC_FILE_PREFIXES, "\\1\\2 #{options[:path]}\\3"
|
|
96
|
+
else
|
|
97
|
+
@rack_attack_pending = true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
append_to_file path, csp_throttle
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 7. Daily purge
|
|
104
|
+
def add_recurring_purge
|
|
105
|
+
path = File.join(destination_root, "config/recurring.yml")
|
|
106
|
+
content = File.exist?(path) ? File.read(path) : ""
|
|
107
|
+
return if content.include?("purge_csp_violations")
|
|
108
|
+
|
|
109
|
+
if content.match?(/^production:\n/)
|
|
110
|
+
inject_into_file "config/recurring.yml", recurring_entry, after: /^production:\n/
|
|
111
|
+
else
|
|
112
|
+
@recurring_pending = true
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# 8. Security page section
|
|
117
|
+
def add_security_page_section
|
|
118
|
+
copy_file "app/views/backstage/security/_csp_violations.html.erb",
|
|
119
|
+
"app/views/backstage/security/_csp_violations.html.erb"
|
|
120
|
+
|
|
121
|
+
page = "app/views/backstage/security/show.html.erb"
|
|
122
|
+
full_path = File.join(destination_root, page)
|
|
123
|
+
content = File.exist?(full_path) ? File.read(full_path) : ""
|
|
124
|
+
return if content.include?("backstage/security/csp_violations")
|
|
125
|
+
|
|
126
|
+
if content.match?(SECURITY_PAGE_END)
|
|
127
|
+
inject_into_file page, security_page_section, before: SECURITY_PAGE_END
|
|
128
|
+
else
|
|
129
|
+
@page_pending = true
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# 9. Post-install message
|
|
134
|
+
def show_post_install
|
|
135
|
+
return if options[:quiet]
|
|
136
|
+
|
|
137
|
+
say ""
|
|
138
|
+
say "Content Security Policy reports are set up at POST #{options[:path]}.", :green
|
|
139
|
+
say ""
|
|
140
|
+
say "Next steps:", :yellow
|
|
141
|
+
say " - Add the table: bin/rails db:migrate (db:prepare on deploy)"
|
|
142
|
+
say " - Watch the Security page while the policy is report-only, then set"
|
|
143
|
+
say " content_security_policy_report_only = false to enforce it."
|
|
144
|
+
if @report_uri_pending
|
|
145
|
+
say ""
|
|
146
|
+
say "Point the policy at the endpoint in config/initializers/content_security_policy.rb:", :yellow
|
|
147
|
+
say " #{report_uri_line.strip}"
|
|
148
|
+
end
|
|
149
|
+
if @rack_attack_pending
|
|
150
|
+
say ""
|
|
151
|
+
say "Exempt #{options[:path]} from the general throttle in config/initializers/rack_attack.rb and add:", :yellow
|
|
152
|
+
say csp_throttle
|
|
153
|
+
end
|
|
154
|
+
if @recurring_pending
|
|
155
|
+
say ""
|
|
156
|
+
say "Add to config/recurring.yml under your production (or shared) key:", :yellow
|
|
157
|
+
say recurring_entry
|
|
158
|
+
end
|
|
159
|
+
if @page_pending
|
|
160
|
+
say ""
|
|
161
|
+
say "Render the section on the Security page:", :yellow
|
|
162
|
+
say security_page_section
|
|
163
|
+
end
|
|
164
|
+
say ""
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def schema_path = File.join(destination_root, "db/security_schema.rb")
|
|
170
|
+
|
|
171
|
+
def migration_version
|
|
172
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def version_with_underscores(version)
|
|
176
|
+
version.sub(/\A(\d{4})(\d{2})(\d{2})(\d{6})\z/, '\1_\2_\3_\4')
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def report_uri_line
|
|
180
|
+
%( # Violations go to CspReportsController (maquina:csp_reports). report-uri\n) +
|
|
181
|
+
%( # alone: adding report-to switches Chrome to the batched Reporting API.\n) +
|
|
182
|
+
%( policy.report_uri "#{options[:path]}"\n)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def csp_throttle
|
|
186
|
+
<<~RUBY
|
|
187
|
+
|
|
188
|
+
# Content Security Policy reports (maquina:csp_reports): exempt from the
|
|
189
|
+
# general throttle above — one page with a dozen violations would spend a
|
|
190
|
+
# reader's budget — and capped on their own.
|
|
191
|
+
Rack::Attack.throttle("csp/ip", limit: #{THROTTLE_LIMIT}, period: #{THROTTLE_PERIOD}) do |req|
|
|
192
|
+
(req.env["action_dispatch.remote_ip"]&.to_s || req.ip) if req.path == "#{options[:path]}" && req.post?
|
|
193
|
+
end
|
|
194
|
+
RUBY
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def recurring_entry
|
|
198
|
+
<<~YAML.gsub(/^/, " ")
|
|
199
|
+
purge_csp_violations:
|
|
200
|
+
command: "Security::CspViolation.expired.delete_all"
|
|
201
|
+
schedule: every day at 4:30am
|
|
202
|
+
YAML
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def security_page_section
|
|
206
|
+
%(\n\n <%= render "backstage/security/csp_violations", report: Security::CspViolationReport.new %>)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def csp_violations_table
|
|
210
|
+
<<~RUBY.gsub(/^(?=.)/, " ")
|
|
211
|
+
|
|
212
|
+
create_table "csp_violations", force: :cascade do |t|
|
|
213
|
+
t.integer "column_number"
|
|
214
|
+
t.datetime "created_at", null: false
|
|
215
|
+
t.string "blocked_uri"
|
|
216
|
+
t.string "disposition"
|
|
217
|
+
t.string "document_uri"
|
|
218
|
+
t.string "effective_directive", null: false
|
|
219
|
+
t.string "ip"
|
|
220
|
+
t.integer "line_number"
|
|
221
|
+
t.string "sample"
|
|
222
|
+
t.string "source_file"
|
|
223
|
+
t.string "user_agent"
|
|
224
|
+
t.index ["created_at"], name: "index_csp_violations_on_created_at"
|
|
225
|
+
t.index ["effective_directive", "created_at"], name: "index_csp_violations_on_effective_directive_and_created_at"
|
|
226
|
+
end
|
|
227
|
+
RUBY
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Receives Content Security Policy violation reports. Browsers post them with no
|
|
2
|
+
# cookie and no CSRF token, in either the legacy report-uri shape
|
|
3
|
+
# (application/csp-report) or the Reporting API's (application/reports+json),
|
|
4
|
+
# so this is an API controller that reads the raw body itself.
|
|
5
|
+
#
|
|
6
|
+
# Always 204 for a report it could read — a browser does nothing with the
|
|
7
|
+
# answer — and best effort: a locked security database must never turn a
|
|
8
|
+
# report into a 500 that the browser retries.
|
|
9
|
+
class CspReportsController < ActionController::API
|
|
10
|
+
MAX_BYTES = 64.kilobytes
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
body = request.body.read(MAX_BYTES + 1).to_s
|
|
14
|
+
return head 413 if body.bytesize > MAX_BYTES
|
|
15
|
+
|
|
16
|
+
Security::CspViolation.record_report!(body, ip: request.remote_ip, user_agent: request.user_agent)
|
|
17
|
+
head :no_content
|
|
18
|
+
rescue JSON::ParserError
|
|
19
|
+
head :bad_request
|
|
20
|
+
rescue => error
|
|
21
|
+
Rails.logger.error "[CSP] report not recorded: #{error.class}: #{error.message}"
|
|
22
|
+
head :no_content
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# One Content Security Policy violation a browser reported to CspReportsController.
|
|
2
|
+
# Rows are written once and never updated; they are what tells you whether the
|
|
3
|
+
# policy is ready to enforce (report-only) or what it is breaking (enforced).
|
|
4
|
+
#
|
|
5
|
+
# Browsers send two shapes. The legacy `report-uri` one is a single object,
|
|
6
|
+
# `{"csp-report": {"blocked-uri": ..., ...}}` (Content-Type application/csp-report);
|
|
7
|
+
# the Reporting API one is an array of `{"type": "csp-violation", "body": {...}}`
|
|
8
|
+
# (application/reports+json) with camelCase keys. Both normalize to the same row.
|
|
9
|
+
class Security::CspViolation < Security::Record
|
|
10
|
+
RETENTION = 30.days
|
|
11
|
+
|
|
12
|
+
# Violations caused by the reader's own browser extensions say nothing about
|
|
13
|
+
# the app, and they are most of the volume.
|
|
14
|
+
NOISE_SCHEMES = %w[chrome-extension moz-extension safari-extension safari-web-extension ms-browser-extension].freeze
|
|
15
|
+
|
|
16
|
+
FIELD_LIMIT = 1024
|
|
17
|
+
SAMPLE_LIMIT = 256
|
|
18
|
+
|
|
19
|
+
LEGACY_KEYS = {
|
|
20
|
+
"document-uri" => :document_uri, "blocked-uri" => :blocked_uri,
|
|
21
|
+
"effective-directive" => :effective_directive, "violated-directive" => :effective_directive,
|
|
22
|
+
"disposition" => :disposition, "source-file" => :source_file,
|
|
23
|
+
"line-number" => :line_number, "column-number" => :column_number, "script-sample" => :sample
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
REPORTING_API_KEYS = {
|
|
27
|
+
"documentURL" => :document_uri, "blockedURL" => :blocked_uri,
|
|
28
|
+
"effectiveDirective" => :effective_directive, "disposition" => :disposition,
|
|
29
|
+
"sourceFile" => :source_file, "lineNumber" => :line_number,
|
|
30
|
+
"columnNumber" => :column_number, "sample" => :sample
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
validates :effective_directive, presence: true
|
|
34
|
+
|
|
35
|
+
scope :recent, -> { order(created_at: :desc, id: :desc) }
|
|
36
|
+
scope :within, ->(window) { where(created_at: window.ago..) }
|
|
37
|
+
scope :expired, -> { where(created_at: ...RETENTION.ago) }
|
|
38
|
+
|
|
39
|
+
# Records every violation in a raw report body; returns how many were kept.
|
|
40
|
+
# Raises JSON::ParserError on a body that isn't JSON.
|
|
41
|
+
def self.record_report!(raw, ip:, user_agent:)
|
|
42
|
+
violations = parse(raw).reject { |attrs| noise?(attrs) }
|
|
43
|
+
violations.each do |attrs|
|
|
44
|
+
create!(attrs.merge(ip:, user_agent: user_agent&.truncate(FIELD_LIMIT, omission: "")))
|
|
45
|
+
end
|
|
46
|
+
violations.size
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.parse(raw)
|
|
50
|
+
payload = JSON.parse(raw)
|
|
51
|
+
reports = payload.is_a?(Array) ? payload : [payload]
|
|
52
|
+
|
|
53
|
+
reports.filter_map do |report|
|
|
54
|
+
next unless report.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
if report.key?("csp-report")
|
|
57
|
+
normalize(report["csp-report"], LEGACY_KEYS)
|
|
58
|
+
elsif report["type"] == "csp-violation"
|
|
59
|
+
normalize(report["body"], REPORTING_API_KEYS)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.noise?(attrs)
|
|
65
|
+
[attrs[:blocked_uri], attrs[:source_file]].compact.any? do |uri|
|
|
66
|
+
NOISE_SCHEMES.any? { |scheme| uri.start_with?("#{scheme}:") }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.normalize(body, keys)
|
|
71
|
+
return unless body.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
attrs = keys.each_with_object({}) do |(key, attribute), acc|
|
|
74
|
+
acc[attribute] ||= body[key] if body.key?(key)
|
|
75
|
+
end
|
|
76
|
+
# The directive alone (`script-src`), not the whole violated policy line.
|
|
77
|
+
attrs[:effective_directive] = attrs[:effective_directive].to_s.split.first
|
|
78
|
+
# URLs lose their query and fragment: a reported URL can carry a token.
|
|
79
|
+
%i[document_uri blocked_uri source_file].each { |key| attrs[key] = strip_url(attrs[key]) }
|
|
80
|
+
attrs[:sample] = attrs[:sample]&.to_s&.truncate(SAMPLE_LIMIT, omission: "")
|
|
81
|
+
%i[line_number column_number].each { |key| attrs[key] = attrs[key]&.to_i }
|
|
82
|
+
attrs.transform_values { |value| value.is_a?(String) ? value.truncate(FIELD_LIMIT, omission: "") : value }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.strip_url(value)
|
|
86
|
+
value&.to_s&.split(/[?#]/, 2)&.first
|
|
87
|
+
end
|
|
88
|
+
private_class_method :normalize, :strip_url
|
|
89
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# What the Security page shows about Content Security Policy: the last `window`
|
|
2
|
+
# of reported violations, grouped by what was blocked, where and why.
|
|
3
|
+
class Security::CspViolationReport
|
|
4
|
+
Group = Data.define(:effective_directive, :blocked_uri, :document_uri, :count, :last_seen)
|
|
5
|
+
|
|
6
|
+
attr_reader :window, :limit
|
|
7
|
+
|
|
8
|
+
def initialize(window: 7.days, limit: 10)
|
|
9
|
+
@window = window
|
|
10
|
+
@limit = limit
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def violations = Security::CspViolation.within(window)
|
|
14
|
+
|
|
15
|
+
def empty? = violations.none?
|
|
16
|
+
|
|
17
|
+
def total = violations.count
|
|
18
|
+
|
|
19
|
+
# The same blocked resource on the same page under the same directive, most
|
|
20
|
+
# frequent first — one row per thing to fix, however many readers hit it.
|
|
21
|
+
def top
|
|
22
|
+
violations.group(:effective_directive, :blocked_uri, :document_uri)
|
|
23
|
+
.order(Arel.sql("COUNT(*) DESC, MAX(created_at) DESC")).limit(limit)
|
|
24
|
+
.pluck(:effective_directive, :blocked_uri, :document_uri, Arel.sql("COUNT(*)"), Arel.sql("MAX(created_at)"))
|
|
25
|
+
.map do |directive, blocked, document, count, last|
|
|
26
|
+
Group.new(effective_directive: directive, blocked_uri: blocked, document_uri: document,
|
|
27
|
+
count:, last_seen: last.is_a?(String) ? ActiveSupport::TimeZone["UTC"].parse(last) : last)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%# locals: (report:) %>
|
|
2
|
+
<%# Content Security Policy violations browsers reported to /csp-reports
|
|
3
|
+
(maquina:csp_reports), grouped so each row is one thing to fix. %>
|
|
4
|
+
<%= render "components/card" do %>
|
|
5
|
+
<%= render "components/card/header" do %>
|
|
6
|
+
<%= render "components/card/title", text: "Content Security Policy violations" %>
|
|
7
|
+
<%= render "components/card/description",
|
|
8
|
+
text: "#{number_with_delimiter(report.total)} reported in the last #{report.window.inspect}, kept for #{Security::CspViolation::RETENTION.inspect}. Most frequent first." %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<%= render "components/card/content", spacing: :full do %>
|
|
11
|
+
<% if report.empty? %>
|
|
12
|
+
<%= empty_state(title: "No violation was reported.", icon: :check_circle, size: :compact) %>
|
|
13
|
+
<% else %>
|
|
14
|
+
<div class="overflow-x-auto">
|
|
15
|
+
<%= render "components/table", container: false, "aria-label": "Content Security Policy violations" do %>
|
|
16
|
+
<%= render "components/table/header" do %>
|
|
17
|
+
<%= render "components/table/row" do %>
|
|
18
|
+
<%= render "components/table/head", text: "Directive" %>
|
|
19
|
+
<%= render "components/table/head", text: "Blocked" %>
|
|
20
|
+
<%= render "components/table/head", text: "Page" %>
|
|
21
|
+
<%= render "components/table/head", text: "Reports", css_classes: "text-right" %>
|
|
22
|
+
<%= render "components/table/head", text: "Last seen" %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% end %>
|
|
25
|
+
<%= render "components/table/body" do %>
|
|
26
|
+
<% report.top.each do |group| %>
|
|
27
|
+
<%= render "components/table/row" do %>
|
|
28
|
+
<%= render "components/table/cell", css_classes: "font-mono", text: group.effective_directive %>
|
|
29
|
+
<%= render "components/table/cell", css_classes: "font-mono whitespace-normal break-all", text: group.blocked_uri.presence || "—" %>
|
|
30
|
+
<%= render "components/table/cell", css_classes: "font-mono whitespace-normal break-all", text: group.document_uri.presence || "—" %>
|
|
31
|
+
<%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(group.count) %>
|
|
32
|
+
<%= render "components/table/cell" do %>
|
|
33
|
+
<%= render "backstage/security/utc_time", time: group.last_seen %>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% end %>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
<% end %>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% end %>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Content Security Policy violation reports (maquina:csp_reports). Lives in the
|
|
2
|
+
# `security` database next to abuse_events; db/security_schema.rb carries the same
|
|
3
|
+
# table for a fresh database, this migration adds it to an existing one.
|
|
4
|
+
class CreateCspViolations < ActiveRecord::Migration<%= migration_version %>
|
|
5
|
+
def change
|
|
6
|
+
create_table :csp_violations do |t|
|
|
7
|
+
t.string :effective_directive, null: false
|
|
8
|
+
t.string :blocked_uri
|
|
9
|
+
t.string :document_uri
|
|
10
|
+
t.string :source_file
|
|
11
|
+
t.integer :line_number
|
|
12
|
+
t.integer :column_number
|
|
13
|
+
t.string :disposition
|
|
14
|
+
t.string :sample
|
|
15
|
+
t.string :ip
|
|
16
|
+
t.string :user_agent
|
|
17
|
+
t.datetime :created_at, null: false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
add_index :csp_violations, :created_at
|
|
21
|
+
add_index :csp_violations, [:effective_directive, :created_at]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -49,7 +49,7 @@ module Maquina
|
|
|
49
49
|
|
|
50
50
|
# 5. Routes
|
|
51
51
|
def add_route
|
|
52
|
-
mount_path = "#{options[:prefix]}/
|
|
52
|
+
mount_path = "#{options[:prefix]}/jobs"
|
|
53
53
|
|
|
54
54
|
route "mount MissionControl::Jobs::Engine, at: \"#{mount_path}\""
|
|
55
55
|
end
|
|
@@ -67,6 +67,13 @@ module Maquina
|
|
|
67
67
|
layout_files.each { |f| copy_file f, f }
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# 7b. Backstage dashboard — admin overview at the prefix root, linking to
|
|
71
|
+
# the installed engines. Shared with the solid_errors generator, so
|
|
72
|
+
# guard every step for idempotency when both run.
|
|
73
|
+
def create_backstage_dashboard
|
|
74
|
+
install_backstage_dashboard
|
|
75
|
+
end
|
|
76
|
+
|
|
70
77
|
# 8. Custom views
|
|
71
78
|
def copy_views
|
|
72
79
|
return unless options[:copy_views]
|
|
@@ -105,6 +112,30 @@ module Maquina
|
|
|
105
112
|
File.exist?(File.join(destination_root, "bin/rails"))
|
|
106
113
|
end
|
|
107
114
|
|
|
115
|
+
# Idempotent install of the shared backstage dashboard (controller, layout,
|
|
116
|
+
# prefix-aware view, and the prefix-root route). Safe to run from both this
|
|
117
|
+
# generator and solid_errors.
|
|
118
|
+
def install_backstage_dashboard
|
|
119
|
+
unless File.exist?(File.join(destination_root, "app/controllers/backstage_dashboard_controller.rb"))
|
|
120
|
+
copy_file "app/controllers/backstage_dashboard_controller.rb",
|
|
121
|
+
"app/controllers/backstage_dashboard_controller.rb"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
unless File.exist?(File.join(destination_root, "app/views/layouts/admin.html.erb"))
|
|
125
|
+
copy_file "app/views/layouts/admin.html.erb", "app/views/layouts/admin.html.erb"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
unless File.exist?(File.join(destination_root, "app/views/backstage_dashboard/index.html.erb"))
|
|
129
|
+
template "app/views/backstage_dashboard/index.html.erb.tt",
|
|
130
|
+
"app/views/backstage_dashboard/index.html.erb"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
routes_path = File.join(destination_root, "config/routes.rb")
|
|
134
|
+
if File.exist?(routes_path) && !File.read(routes_path).include?("backstage_dashboard#index")
|
|
135
|
+
route %(get "#{options[:prefix]}" => "backstage_dashboard#index", as: :backstage_dashboard)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
108
139
|
def layout_files
|
|
109
140
|
layouts_dir = File.join(self.class.source_root, "app/views/layouts/mission_control")
|
|
110
141
|
Dir.glob("**/*.erb", base: layouts_dir).map do |file|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Admin overview — a backstage surface alongside the SolidErrors and Mission
|
|
4
|
+
# Control Jobs engines, under the same backstage Basic auth.
|
|
5
|
+
#
|
|
6
|
+
# Out of the box it shows the installed engine entry points and an empty metrics
|
|
7
|
+
# area. Populate @metrics in #index with aggregate, non-sensitive counts for
|
|
8
|
+
# your app — counts, not names or per-record detail.
|
|
9
|
+
class BackstageDashboardController < BackstageController
|
|
10
|
+
layout "admin"
|
|
11
|
+
|
|
12
|
+
before_action :require_backstage_credentials
|
|
13
|
+
before_action :authenticate_backstage
|
|
14
|
+
|
|
15
|
+
def index
|
|
16
|
+
@generated_at = Time.current
|
|
17
|
+
|
|
18
|
+
# Add overview metrics here. Each entry renders as a card:
|
|
19
|
+
#
|
|
20
|
+
# @metrics = [
|
|
21
|
+
# {label: "Accounts", icon: :folder, value: Account.count, hint: "tenants"},
|
|
22
|
+
# {label: "Users", icon: :user, value: User.count, hint: "members"}
|
|
23
|
+
# ]
|
|
24
|
+
#
|
|
25
|
+
# Keep them aggregate-only. :total and :hint are optional.
|
|
26
|
+
@metrics = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Closed (503) until credentials.backstage is set. Without this an unset
|
|
32
|
+
# username and password are both "", and an empty Basic header matches them.
|
|
33
|
+
def require_backstage_credentials
|
|
34
|
+
backstage = Rails.application.credentials.backstage || {}
|
|
35
|
+
head :service_unavailable if backstage[:username].blank? || backstage[:password].blank?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Same backstage credentials the engines use.
|
|
39
|
+
def authenticate_backstage
|
|
40
|
+
authenticate_or_request_with_http_basic("Admin") do |username, password|
|
|
41
|
+
backstage = Rails.application.credentials.backstage || {}
|
|
42
|
+
|
|
43
|
+
ActiveSupport::SecurityUtils.secure_compare(username.to_s, backstage[:username].to_s) &
|
|
44
|
+
ActiveSupport::SecurityUtils.secure_compare(password.to_s, backstage[:password].to_s)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -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>
|