- <%= render "shared/flash" %>
- <%= yield %>
+
- <%= 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 %>
@@ -105,7 +105,10 @@
-
+ <%= render "shared/sidebar" %>
+
<% else %>
diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb
index de1697b..92a6231 100644
--- a/app/views/shared/_sidebar.html.erb
+++ b/app/views/shared/_sidebar.html.erb
@@ -90,7 +90,7 @@
+
+
+
+
+
-
+
+
+ <%= render "shared/flash" %>
+ <%= yield %>
+
+
+
@@ -141,7 +144,7 @@
- - <%= 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 %> @@ -150,7 +153,7 @@ <% if user.admin? %>
- - <%= 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 %> @@ -158,7 +161,7 @@ <% end %>
- - <%= 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 %> @@ -166,7 +169,7 @@ <% end %>
- - <%= 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 %> @@ -175,7 +178,7 @@ <% end %>
- - <%= 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 %> @@ -183,7 +186,7 @@ <% end %>
- - <%= 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 %> @@ -191,7 +194,7 @@ <% end %>
- - <%= 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 %> diff --git a/config/initializers/csp_local_logger.rb b/config/initializers/csp_local_logger.rb new file mode 100644 index 0000000..aca2bd1 --- /dev/null +++ b/config/initializers/csp_local_logger.rb @@ -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 \ No newline at end of file diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb new file mode 100644 index 0000000..19e50ac --- /dev/null +++ b/config/initializers/sentry.rb @@ -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 \ No newline at end of file diff --git a/config/initializers/sentry_subscriber.rb b/config/initializers/sentry_subscriber.rb new file mode 100644 index 0000000..018777b --- /dev/null +++ b/config/initializers/sentry_subscriber.rb @@ -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 \ No newline at end of file