Fix mobile view menu popout. Add an option SENTRY_DSN support, which uses rails event reporting
This commit is contained in:
24
.env.example
24
.env.example
@@ -68,3 +68,27 @@ CLINCH_ALLOW_LOCALHOST=true
|
|||||||
|
|
||||||
# Optional: Set custom port
|
# Optional: Set custom port
|
||||||
# PORT=9000
|
# PORT=9000
|
||||||
|
|
||||||
|
# Sentry Configuration (Optional)
|
||||||
|
# Enable error tracking and performance monitoring
|
||||||
|
# Leave SENTRY_DSN empty to disable Sentry completely
|
||||||
|
#
|
||||||
|
# Production: Get your DSN from https://sentry.io/settings/projects/
|
||||||
|
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
|
||||||
|
#
|
||||||
|
# Optional: Override Sentry environment (defaults to Rails.env)
|
||||||
|
# SENTRY_ENVIRONMENT=production
|
||||||
|
#
|
||||||
|
# Optional: Override Sentry release (defaults to Git commit hash)
|
||||||
|
# SENTRY_RELEASE=v1.0.0
|
||||||
|
#
|
||||||
|
# Optional: Performance monitoring sample rate (0.0 to 1.0, default 0.2)
|
||||||
|
# Higher values provide more data but cost more
|
||||||
|
# SENTRY_TRACES_SAMPLE_RATE=0.2
|
||||||
|
#
|
||||||
|
# Optional: Continuous profiling sample rate (0.0 to 1.0, default 0.0)
|
||||||
|
# Very resource intensive, only enable for performance investigations
|
||||||
|
# SENTRY_PROFILES_SAMPLE_RATE=0.0
|
||||||
|
#
|
||||||
|
# Development: Enable Sentry in development for testing
|
||||||
|
# SENTRY_ENABLED_IN_DEVELOPMENT=true
|
||||||
|
|||||||
@@ -333,6 +333,12 @@ GEM
|
|||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
|
sentry-rails (5.28.0)
|
||||||
|
railties (>= 5.0)
|
||||||
|
sentry-ruby (~> 5.28.0)
|
||||||
|
sentry-ruby (5.28.0)
|
||||||
|
bigdecimal
|
||||||
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
solid_cable (3.0.12)
|
solid_cable (3.0.12)
|
||||||
actioncable (>= 7.2)
|
actioncable (>= 7.2)
|
||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
@@ -443,6 +449,8 @@ DEPENDENCIES
|
|||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
|
sentry-rails (~> 5.18)
|
||||||
|
sentry-ruby (~> 5.18)
|
||||||
solid_cable
|
solid_cable
|
||||||
solid_cache
|
solid_cache
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
|
|||||||
@@ -8,19 +8,38 @@ module Api
|
|||||||
def violation_report
|
def violation_report
|
||||||
# Parse CSP violation report
|
# Parse CSP violation report
|
||||||
report_data = JSON.parse(request.body.read)
|
report_data = JSON.parse(request.body.read)
|
||||||
|
csp_report = report_data['csp-report']
|
||||||
|
|
||||||
# Log the violation for security monitoring
|
# Log the violation for security monitoring
|
||||||
Rails.logger.warn "CSP Violation Report:"
|
Rails.logger.warn "CSP Violation Report:"
|
||||||
Rails.logger.warn " Blocked URI: #{report_data.dig('csp-report', 'blocked-uri')}"
|
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
||||||
Rails.logger.warn " Document URI: #{report_data.dig('csp-report', 'document-uri')}"
|
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
|
||||||
Rails.logger.warn " Referrer: #{report_data.dig('csp-report', 'referrer')}"
|
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
|
||||||
Rails.logger.warn " Violated Directive: #{report_data.dig('csp-report', 'violated-directive')}"
|
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
|
||||||
Rails.logger.warn " Original Policy: #{report_data.dig('csp-report', 'original-policy')}"
|
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
|
||||||
Rails.logger.warn " User Agent: #{request.user_agent}"
|
Rails.logger.warn " User Agent: #{request.user_agent}"
|
||||||
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
||||||
|
|
||||||
# In production, you might want to send this to a security monitoring service
|
# Emit structured event for CSP violation
|
||||||
# For now, we'll just log it and return a success response
|
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
|
||||||
|
Rails.event.notify("csp.violation", {
|
||||||
|
blocked_uri: csp_report['blocked-uri'],
|
||||||
|
document_uri: csp_report['document-uri'],
|
||||||
|
referrer: csp_report['referrer'],
|
||||||
|
violated_directive: csp_report['violated-directive'],
|
||||||
|
original_policy: csp_report['original-policy'],
|
||||||
|
disposition: csp_report['disposition'],
|
||||||
|
effective_directive: csp_report['effective-directive'],
|
||||||
|
source_file: csp_report['source-file'],
|
||||||
|
line_number: csp_report['line-number'],
|
||||||
|
column_number: csp_report['column-number'],
|
||||||
|
status_code: csp_report['status-code'],
|
||||||
|
user_agent: request.user_agent,
|
||||||
|
ip_address: request.remote_ip,
|
||||||
|
current_user_id: Current.user&.id,
|
||||||
|
timestamp: Time.current,
|
||||||
|
session_id: Current.session&.id
|
||||||
|
})
|
||||||
|
|
||||||
head :no_content
|
head :no_content
|
||||||
rescue JSON::ParserError => e
|
rescue JSON::ParserError => e
|
||||||
|
|||||||
@@ -1,21 +1,48 @@
|
|||||||
import { Controller } from "@hotwired/stimulus";
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ["sidebarOverlay", "button"];
|
static targets = ["sidebarOverlay"];
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
// Initialize mobile sidebar functionality
|
// Initialize mobile sidebar functionality
|
||||||
|
// Add escape key listener to close sidebar
|
||||||
|
this.boundHandleEscape = this.handleEscape.bind(this);
|
||||||
|
document.addEventListener('keydown', this.boundHandleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
// Clean up event listeners
|
||||||
|
document.removeEventListener('keydown', this.boundHandleEscape);
|
||||||
}
|
}
|
||||||
|
|
||||||
openSidebar() {
|
openSidebar() {
|
||||||
if (this.hasSidebarOverlayTarget) {
|
if (this.hasSidebarOverlayTarget) {
|
||||||
this.sidebarOverlayTarget.classList.remove('hidden');
|
this.sidebarOverlayTarget.classList.remove('hidden');
|
||||||
|
// Prevent body scroll when sidebar is open
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeSidebar() {
|
closeSidebar() {
|
||||||
if (this.hasSidebarOverlayTarget) {
|
if (this.hasSidebarOverlayTarget) {
|
||||||
this.sidebarOverlayTarget.classList.add('hidden');
|
this.sidebarOverlayTarget.classList.add('hidden');
|
||||||
|
// Restore body scroll
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sidebar when clicking on the overlay background
|
||||||
|
closeOnBackgroundClick(event) {
|
||||||
|
// Check if the click is on the overlay background (the semi-transparent layer)
|
||||||
|
if (event.target === this.sidebarOverlayTarget || event.target.classList.contains('bg-gray-900/80')) {
|
||||||
|
this.closeSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle escape key to close sidebar
|
||||||
|
handleEscape(event) {
|
||||||
|
if (event.key === 'Escape' && this.hasSidebarOverlayTarget && !this.sidebarOverlayTarget.classList.contains('hidden')) {
|
||||||
|
this.closeSidebar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,8 +25,9 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<% if authenticated? %>
|
<% if authenticated? %>
|
||||||
|
<div data-controller="mobile-sidebar">
|
||||||
<%= render "shared/sidebar" %>
|
<%= render "shared/sidebar" %>
|
||||||
<div class="lg:pl-64" data-controller="mobile-sidebar">
|
<div class="lg:pl-64">
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<!-- Public layout (signup/signin) -->
|
<!-- Public layout (signup/signin) -->
|
||||||
<main class="container mx-auto mt-28 px-5">
|
<main class="container mx-auto mt-28 px-5">
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
|
|
||||||
<!-- Sign Out -->
|
<!-- Sign Out -->
|
||||||
<li>
|
<li>
|
||||||
<%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -105,7 +105,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile sidebar overlay -->
|
<!-- Mobile sidebar overlay -->
|
||||||
<div class="relative z-50 lg:hidden hidden" data-mobile-sidebar-target="sidebarOverlay" id="mobile-sidebar-overlay">
|
<div class="relative z-50 lg:hidden hidden"
|
||||||
|
data-mobile-sidebar-target="sidebarOverlay"
|
||||||
|
id="mobile-sidebar-overlay"
|
||||||
|
data-action="click->mobile-sidebar#closeOnBackgroundClick">
|
||||||
<div class="fixed inset-0 bg-gray-900/80"></div>
|
<div class="fixed inset-0 bg-gray-900/80"></div>
|
||||||
<div class="fixed inset-0 flex">
|
<div class="fixed inset-0 flex">
|
||||||
<div class="relative mr-16 flex w-full max-w-xs flex-1">
|
<div class="relative mr-16 flex w-full max-w-xs flex-1">
|
||||||
@@ -141,7 +144,7 @@
|
|||||||
<!-- Same nav items as desktop -->
|
<!-- Same nav items as desktop -->
|
||||||
<ul role="list" class="-mx-2 space-y-1">
|
<ul role="list" class="-mx-2 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -150,7 +153,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<% if user.admin? %>
|
<% if user.admin? %>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -158,7 +161,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -166,7 +169,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -175,7 +178,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -183,7 +186,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -191,7 +194,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
102
config/initializers/csp_local_logger.rb
Normal file
102
config/initializers/csp_local_logger.rb
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Local file logger for CSP violations
|
||||||
|
# Provides local logging even when Sentry is not configured
|
||||||
|
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
# Create a dedicated logger for CSP violations
|
||||||
|
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||||
|
csp_logger = Logger.new(csp_log_path)
|
||||||
|
|
||||||
|
# Rotate logs daily, keep 30 days
|
||||||
|
csp_logger.keep = 30
|
||||||
|
csp_logger.level = Logger::INFO
|
||||||
|
|
||||||
|
# Format: [TIMESTAMP] LEVEL MESSAGE
|
||||||
|
csp_logger.formatter = proc do |severity, datetime, progname, msg|
|
||||||
|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
module CspViolationLocalLogger
|
||||||
|
def self.emit(event_data)
|
||||||
|
csp_data = event_data[:data] || {}
|
||||||
|
|
||||||
|
# Build a structured log message
|
||||||
|
violated_directive = csp_data[:violated_directive] || "unknown"
|
||||||
|
blocked_uri = csp_data[:blocked_uri] || "unknown"
|
||||||
|
document_uri = csp_data[:document_uri] || "unknown"
|
||||||
|
|
||||||
|
# Create a comprehensive log entry
|
||||||
|
log_message = "CSP VIOLATION DETECTED\n"
|
||||||
|
log_message += " Directive: #{violated_directive}\n"
|
||||||
|
log_message += " Blocked URI: #{blocked_uri}\n"
|
||||||
|
log_message += " Document URI: #{document_uri}\n"
|
||||||
|
log_message += " User Agent: #{csp_data[:user_agent]}\n"
|
||||||
|
log_message += " IP Address: #{csp_data[:ip_address]}\n"
|
||||||
|
log_message += " Timestamp: #{csp_data[:timestamp]}\n"
|
||||||
|
|
||||||
|
if csp_data[:current_user_id].present?
|
||||||
|
log_message += " Authenticated User ID: #{csp_data[:current_user_id]}\n"
|
||||||
|
log_message += " Session ID: #{csp_data[:session_id]}\n"
|
||||||
|
else
|
||||||
|
log_message += " User: Anonymous\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add additional details if available
|
||||||
|
if csp_data[:source_file].present?
|
||||||
|
log_message += " Source File: #{csp_data[:source_file]}"
|
||||||
|
log_message += ":#{csp_data[:line_number]}" if csp_data[:line_number].present?
|
||||||
|
log_message += ":#{csp_data[:column_number]}" if csp_data[:column_number].present?
|
||||||
|
log_message += "\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
if csp_data[:referrer].present?
|
||||||
|
log_message += " Referrer: #{csp_data[:referrer]}\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Determine severity for log level
|
||||||
|
level = determine_log_level(csp_data[:violated_directive])
|
||||||
|
|
||||||
|
csp_logger.log(level, log_message)
|
||||||
|
|
||||||
|
# Also log to main Rails logger for visibility
|
||||||
|
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
# Ensure logger errors don't break the CSP reporting flow
|
||||||
|
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def self.determine_log_level(violated_directive)
|
||||||
|
return Logger::INFO unless violated_directive.present?
|
||||||
|
|
||||||
|
case violated_directive.to_sym
|
||||||
|
when :script_src, :script_src_elem, :script_src_attr, :frame_src, :child_src
|
||||||
|
Logger::WARN # Higher priority violations
|
||||||
|
when :connect_src, :default_src, :style_src, :style_src_elem, :style_src_attr
|
||||||
|
Logger::INFO # Medium priority violations
|
||||||
|
else
|
||||||
|
Logger::DEBUG # Lower priority violations
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Register the local logger subscriber
|
||||||
|
Rails.event.subscribe("csp.violation", CspViolationLocalLogger)
|
||||||
|
|
||||||
|
Rails.logger.info "CSP violation local logger registered - logging to: #{csp_log_path}"
|
||||||
|
|
||||||
|
# Ensure the log file is created and writable
|
||||||
|
begin
|
||||||
|
# Create log file if it doesn't exist
|
||||||
|
FileUtils.touch(csp_log_path) unless File.exist?(csp_log_path)
|
||||||
|
|
||||||
|
# Test write to ensure permissions are correct
|
||||||
|
csp_logger.info "CSP Logger initialized at #{Time.current}"
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
|
||||||
|
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
|
||||||
|
end
|
||||||
|
end
|
||||||
140
config/initializers/sentry.rb
Normal file
140
config/initializers/sentry.rb
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Sentry configuration for error tracking and performance monitoring
|
||||||
|
# Only initializes if SENTRY_DSN environment variable is set
|
||||||
|
|
||||||
|
return unless ENV["SENTRY_DSN"].present?
|
||||||
|
|
||||||
|
Rails.application.configure do
|
||||||
|
config.sentry.dsn = ENV["SENTRY_DSN"]
|
||||||
|
|
||||||
|
# Set environment (defaults to Rails.env)
|
||||||
|
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
|
||||||
|
|
||||||
|
# Set release version from Git or environment variable
|
||||||
|
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil
|
||||||
|
|
||||||
|
# Sample rate for performance monitoring (0.0 to 1.0)
|
||||||
|
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
|
||||||
|
|
||||||
|
# Enable profiling in development/staging, disable in production unless explicitly enabled
|
||||||
|
config.sentry.profiles_sample_rate = if Rails.env.production?
|
||||||
|
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
|
||||||
|
else
|
||||||
|
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
|
||||||
|
end
|
||||||
|
|
||||||
|
# Include additional context
|
||||||
|
config.sentry.before_send = lambda do |event, hint|
|
||||||
|
# Filter out sensitive information
|
||||||
|
if event.context[:extra]
|
||||||
|
event.context[:extra].reject! { |key, value|
|
||||||
|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter sensitive parameters
|
||||||
|
if event.context[:request]
|
||||||
|
event.context[:request].reject! { |key, value|
|
||||||
|
key.to_s.match?(/password|secret|token|key|authorization/i)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
event
|
||||||
|
end
|
||||||
|
|
||||||
|
# Include breadcrumbs for debugging
|
||||||
|
config.sentry.breadcrumbs_logger = [:active_support_logger, :http_logger]
|
||||||
|
|
||||||
|
# Send session data for user context
|
||||||
|
config.sentry.user_context = lambda do
|
||||||
|
if Current.user.present?
|
||||||
|
{
|
||||||
|
id: Current.user.id,
|
||||||
|
email: Current.user.email_address,
|
||||||
|
admin: Current.user.admin?
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ignore common non-critical exceptions
|
||||||
|
config.sentry.excluded_exceptions += [
|
||||||
|
"ActionController::RoutingError",
|
||||||
|
"ActionController::InvalidAuthenticityToken",
|
||||||
|
"ActionController::UnknownFormat",
|
||||||
|
"ActionDispatch::Http::Parameters::ParseError",
|
||||||
|
"Rack::QueryParser::InvalidParameterError",
|
||||||
|
"Rack::Timeout::RequestTimeoutException",
|
||||||
|
"ActiveRecord::RecordNotFound"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add CSP-specific tags for security events
|
||||||
|
config.sentry.tags = lambda do
|
||||||
|
{
|
||||||
|
# Add application context
|
||||||
|
app_name: "clinch",
|
||||||
|
app_environment: Rails.env,
|
||||||
|
# Add CSP policy status
|
||||||
|
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
|
||||||
|
Rails.application.config.content_security_policy.present?
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Enhance before_send to handle CSP events properly
|
||||||
|
config.sentry.before_send = lambda do |event, hint|
|
||||||
|
# Filter out sensitive information
|
||||||
|
if event.context[:extra]
|
||||||
|
event.context[:extra].reject! { |key, value|
|
||||||
|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter sensitive parameters
|
||||||
|
if event.context[:request]
|
||||||
|
event.context[:request].reject! { |key, value|
|
||||||
|
key.to_s.match?(/password|secret|token|key|authorization/i)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Special handling for CSP violations
|
||||||
|
if event.tags&.dig(:csp_violation)
|
||||||
|
# Ensure CSP violations have proper security context
|
||||||
|
event.context[:server] = event.context[:server] || {}
|
||||||
|
event.context[:server][:name] = "clinch-auth-service"
|
||||||
|
event.context[:server][:environment] = Rails.env
|
||||||
|
|
||||||
|
# Add additional security context
|
||||||
|
event.context[:extra] ||= {}
|
||||||
|
event.context[:extra][:security_context] = {
|
||||||
|
csp_reporting: true,
|
||||||
|
user_authenticated: event.context[:user].present?,
|
||||||
|
request_origin: event.context[:request]&.dig(:headers, "Origin"),
|
||||||
|
request_referer: event.context[:request]&.dig(:headers, "Referer")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
event
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add CSP-specific breadcrumbs for security events
|
||||||
|
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint|
|
||||||
|
# Filter out sensitive breadcrumb data
|
||||||
|
if breadcrumb[:data]
|
||||||
|
breadcrumb[:data].reject! { |key, value|
|
||||||
|
key.to_s.match?(/password|secret|token|key|authorization/i) ||
|
||||||
|
value.to_s.match?(/password|secret/i)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark CSP-related events
|
||||||
|
if breadcrumb[:message]&.include?("CSP Violation") ||
|
||||||
|
breadcrumb[:category]&.include?("csp")
|
||||||
|
breadcrumb[:data] ||= {}
|
||||||
|
breadcrumb[:data][:security_event] = true
|
||||||
|
breadcrumb[:data][:csp_violation] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
breadcrumb
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only send errors in production unless explicitly enabled
|
||||||
|
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
|
||||||
|
end
|
||||||
120
config/initializers/sentry_subscriber.rb
Normal file
120
config/initializers/sentry_subscriber.rb
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Sentry subscriber for CSP violations via Structured Event Reporting
|
||||||
|
# This subscriber only sends events to Sentry if Sentry is properly initialized
|
||||||
|
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
# Only register the subscriber if Sentry is available and configured
|
||||||
|
if defined?(Sentry) && Sentry.initialized?
|
||||||
|
|
||||||
|
module CspViolationSentrySubscriber
|
||||||
|
def self.emit(event_data)
|
||||||
|
# Extract relevant CSP violation data
|
||||||
|
csp_data = event_data[:data] || {}
|
||||||
|
|
||||||
|
# Build a descriptive message for Sentry
|
||||||
|
violated_directive = csp_data[:violated_directive]
|
||||||
|
blocked_uri = csp_data[:blocked_uri]
|
||||||
|
document_uri = csp_data[:document_uri]
|
||||||
|
|
||||||
|
message = "CSP Violation: #{violated_directive}"
|
||||||
|
message += " - Blocked: #{blocked_uri}" if blocked_uri.present?
|
||||||
|
message += " - On: #{document_uri}" if document_uri.present?
|
||||||
|
|
||||||
|
# Extract domain from blocked_uri for better classification
|
||||||
|
blocked_domain = extract_domain(blocked_uri) if blocked_uri.present?
|
||||||
|
|
||||||
|
# Determine severity based on violation type
|
||||||
|
level = determine_severity(violated_directive, blocked_uri)
|
||||||
|
|
||||||
|
# Send to Sentry with rich context
|
||||||
|
Sentry.capture_message(
|
||||||
|
message,
|
||||||
|
level: level,
|
||||||
|
tags: {
|
||||||
|
csp_violation: true,
|
||||||
|
violated_directive: violated_directive,
|
||||||
|
blocked_domain: blocked_domain,
|
||||||
|
document_domain: extract_domain(document_uri),
|
||||||
|
user_authenticated: csp_data[:current_user_id].present?
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
# Full CSP report data
|
||||||
|
csp_violation_details: csp_data,
|
||||||
|
# Additional context for security analysis
|
||||||
|
request_context: {
|
||||||
|
user_agent: csp_data[:user_agent],
|
||||||
|
ip_address: csp_data[:ip_address],
|
||||||
|
session_id: csp_data[:session_id],
|
||||||
|
timestamp: csp_data[:timestamp]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log to Rails logger for redundancy
|
||||||
|
Rails.logger.info "CSP violation sent to Sentry: #{message}"
|
||||||
|
rescue => e
|
||||||
|
# Ensure subscriber errors don't break the CSP reporting flow
|
||||||
|
Rails.logger.error "Failed to send CSP violation to Sentry: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Extract domain from URI for better analysis
|
||||||
|
def self.extract_domain(uri)
|
||||||
|
return nil if uri.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
parsed = URI.parse(uri)
|
||||||
|
parsed.host
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
# Handle cases where URI might be malformed or just a path
|
||||||
|
if uri.start_with?('/')
|
||||||
|
nil # It's a relative path, no domain
|
||||||
|
else
|
||||||
|
uri.split('/').first # Best effort extraction
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Determine severity level based on violation type
|
||||||
|
def self.determine_severity(violated_directive, blocked_uri)
|
||||||
|
return :warning unless violated_directive.present?
|
||||||
|
|
||||||
|
case violated_directive.to_sym
|
||||||
|
when :script_src, :script_src_elem, :script_src_attr
|
||||||
|
# Script violations are highest priority (XSS risk)
|
||||||
|
:error
|
||||||
|
when :style_src, :style_src_elem, :style_src_attr
|
||||||
|
# Style violations are moderate risk
|
||||||
|
:warning
|
||||||
|
when :img_src
|
||||||
|
# Image violations are typically lower priority
|
||||||
|
:info
|
||||||
|
when :connect_src
|
||||||
|
# Network violations are important
|
||||||
|
:warning
|
||||||
|
when :font_src, :media_src
|
||||||
|
# Font/media violations are lower priority
|
||||||
|
:info
|
||||||
|
when :frame_src, :child_src
|
||||||
|
# Frame violations can be security critical
|
||||||
|
:error
|
||||||
|
when :default_src
|
||||||
|
# Default src violations are important
|
||||||
|
:warning
|
||||||
|
else
|
||||||
|
# Unknown or custom directives
|
||||||
|
:warning
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Register the subscriber for CSP violation events
|
||||||
|
Rails.event.subscribe("csp.violation", CspViolationSentrySubscriber)
|
||||||
|
|
||||||
|
Rails.logger.info "CSP violation Sentry subscriber registered"
|
||||||
|
else
|
||||||
|
Rails.logger.info "Sentry not initialized - CSP violations will only be logged locally"
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user