diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f3a67b06 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "bundler" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/New_York" + open-pull-requests-limit: 10 + reviewers: + - "rsmoke" + assignees: + - "rsmoke" + labels: + - "dependencies" + - "security" + commit-message: + prefix: "chore" + include: "scope" + rebase-strategy: "auto" + versioning-strategy: "auto" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..c7fe911c --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,77 @@ +name: Security and Test Checks + +on: + push: + branches: [ main, staging ] + pull_request: + branches: [ main, staging ] + schedule: + - cron: '0 0 * * 1' # Run weekly on Monday at midnight UTC * + +jobs: + security_and_tests: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: lsa_evaluate_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + env: + RAILS_ENV: test + RACK_ENV: test + LOCAL_MYSQL_DATABASE_PASSWORD: password + DATABASE_USERNAME: root + DATABASE_URL: mysql2://root:password@127.0.0.1:3306/lsa_evaluate_test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3.4' + bundler-cache: true + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y mysql-client libmysqlclient-dev + bundle install + + - name: Wait for MySQL + run: | + while ! mysqladmin ping -h"127.0.0.1" -P"3306" --silent; do + sleep 1 + done + + - name: Set up database + env: + RAILS_ENV: test + DATABASE_URL: mysql2://root:password@127.0.0.1:3306/lsa_evaluate_test + DISABLE_DATABASE_ENVIRONMENT_CHECK: 1 + run: | + cp config/database.test.yml config/database.yml + bundle exec rails db:create + bundle exec rails db:schema:load + + - name: Run security audit + run: bundle exec rake security:audit + + - name: Run Brakeman + run: bundle exec brakeman -A + + - name: Run bundle audit + run: bundle exec bundle-audit check --update + + - name: Run tests + run: bundle exec rspec diff --git a/Gemfile b/Gemfile index 2cba4b62..cc36c67f 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem 'omniauth-saml', '~> 2.1' gem 'pagy', '~> 6.4' gem 'puma' gem 'pundit' +gem 'rack-attack' gem 'redis', '~> 5.0' gem 'sentry-ruby' gem 'sentry-rails' @@ -59,6 +60,8 @@ group :development, :staging do end group :development, :test do + gem 'brakeman' + gem 'bundler-audit' gem 'capybara' gem 'debug', platforms: %i[mri mingw x64_mingw] gem 'pry-byebug' @@ -72,6 +75,7 @@ group :development, :test do gem 'rubocop-rails-omakase', require: false gem 'rubocop-rspec', require: false gem 'rubocop-rspec_rails', require: false + gem 'safety_net' end group :development, :staging, :test do diff --git a/Gemfile.lock b/Gemfile.lock index c88c0ad5..be0c968b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,7 +93,12 @@ GEM debug_inspector (>= 1.2.0) bootsnap (1.18.4) msgpack (~> 1.2) + brakeman (7.0.2) + racc builder (3.3.0) + bundler-audit (0.9.2) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) byebug (11.1.3) capistrano (3.19.1) airbrussh (>= 1.0.0) @@ -318,6 +323,8 @@ GEM rack (3.1.12) rack-accept (0.4.5) rack (>= 0.4) + rack-attack (6.7.0) + rack (>= 1.0, < 4) rack-protection (4.1.1) base64 (>= 0.1.0) logger (>= 1.6.0) @@ -445,6 +452,7 @@ GEM ffi (~> 1.12) logger rubyzip (2.3.2) + safety_net (1.0.1) sassc (2.4.0) ffi (~> 1.9) sassc-rails (2.1.2) @@ -551,6 +559,8 @@ DEPENDENCIES better_errors binding_of_caller bootsnap + brakeman + bundler-audit capistrano (~> 3.17) capistrano-asdf capistrano-rails (~> 1.6, >= 1.6.1) @@ -578,6 +588,7 @@ DEPENDENCIES puma pundit pundit-matchers + rack-attack rails (~> 7.2) rails-controller-testing redis (~> 5.0) @@ -589,6 +600,7 @@ DEPENDENCIES rubocop-rails-omakase rubocop-rspec rubocop-rspec_rails + safety_net sassc-rails selenium-webdriver sentry-rails diff --git a/app/controllers/addresses_controller.rb b/app/controllers/addresses_controller.rb index af200654..6b1e92ef 100644 --- a/app/controllers/addresses_controller.rb +++ b/app/controllers/addresses_controller.rb @@ -60,7 +60,7 @@ def destroy private def set_address - @address = Address.find(params[:id]) + @address = Address.accessible_by(current_ability).find(params[:id]) end def address_params diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1432f245..130119f9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base include Pundit::Authorization include ApplicationHelper include Pagy::Backend + include SecurityProtection before_action :authenticate_user! # Custom flash logging (optional) @@ -39,15 +40,6 @@ def after_sign_out_path_for(resource_or_scope) def handle_exceptions yield rescue Pundit::NotAuthorizedError => exception - # Rails.logger.info('!!!! Handling Pundit::NotAuthorizedError in ApplicationController') - user_not_authorized(exception) - rescue ActiveRecord::RecordNotFound => exception - Rails.logger.info('!!!!!!! Handling ActiveRecord::RecordNotFound in ApplicationController') - redirect_to not_found_path - end - - # Private method for handling Pundit not authorized errors - def user_not_authorized(exception) logger.info('!!!!!!! Handling Pundit::NotAuthorizedError in ApplicationController') policy_name = exception.policy.class.to_s.underscore message = '!!! Not authorized !!!' @@ -55,8 +47,10 @@ def user_not_authorized(exception) flash[:alert] = message Rails.logger.error("#!#!#!# Pundit error: #{message} - User: #{current_user&.id}, Action: #{exception.query}, Policy: #{policy_name.humanize}") - # Redirect back or to root if referer is not available - redirect_to(request.referer || root_path) + redirect_to root_path + rescue ActiveRecord::RecordNotFound => exception + Rails.logger.info('!!!!!!! Handling ActiveRecord::RecordNotFound in ApplicationController') + redirect_to not_found_path end # Log exceptions in detail diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index 7d01c1c1..0d164b0a 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -52,7 +52,7 @@ def destroy private def set_container - @container = Container.find(params[:container_id]) + @container = policy_scope(Container).find(params[:container_id]) end def assignment_params diff --git a/app/controllers/bulk_contest_instances_controller.rb b/app/controllers/bulk_contest_instances_controller.rb index 7b4bd037..279ca270 100644 --- a/app/controllers/bulk_contest_instances_controller.rb +++ b/app/controllers/bulk_contest_instances_controller.rb @@ -38,7 +38,7 @@ def create private def set_container - @container = Container.find(params[:container_id]) + @container = policy_scope(Container).find(params[:container_id]) end def authorize_container_access diff --git a/app/controllers/concerns/security_protection.rb b/app/controllers/concerns/security_protection.rb new file mode 100644 index 00000000..9a69ea7e --- /dev/null +++ b/app/controllers/concerns/security_protection.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module SecurityProtection + extend ActiveSupport::Concern + + included do + before_action :check_request_security + end + + private + + def check_request_security + # Check for invalid UTF-8 sequences + unless valid_utf8_request? + Rails.logger.warn("[Security] Invalid UTF-8 sequence detected from IP: #{request.remote_ip}") + Sentry.capture_message("Invalid UTF-8 sequence detected", + level: 'warning', + extra: { + ip: request.remote_ip, + path: request.path, + user_agent: request.user_agent + } + ) + return render_404 + end + + # Check for PHP-style attacks + if php_attack_attempt? + Rails.logger.warn("[Security] PHP probe attempt detected from IP: #{request.remote_ip}") + Sentry.capture_message("PHP probe attempt detected", + level: 'warning', + extra: { + ip: request.remote_ip, + path: request.path, + user_agent: request.user_agent, + params: request.params.to_h + } + ) + return render_403 + end + end + + def valid_utf8_request? + # Validate path + path = request.path.dup + path.force_encoding('UTF-8') + return false unless path.valid_encoding? + + # Validate query parameters + request.query_parameters.each do |key, value| + next if value.nil? + str = value.to_s.dup + str.force_encoding('UTF-8') + return false unless str.valid_encoding? + end + + true + end + + def php_attack_attempt? + # Check for common PHP attack patterns + path = request.path.downcase + return true if path.end_with?('.php') + return true if path.include?('php') + return true if request.params.values.any? { |v| v.to_s.include?(' e render json: { errors: [ 'Contest instance not found' ] }, status: :unprocessable_entity diff --git a/app/controllers/judging_assignments_controller.rb b/app/controllers/judging_assignments_controller.rb index 13487f4e..d8f40010 100644 --- a/app/controllers/judging_assignments_controller.rb +++ b/app/controllers/judging_assignments_controller.rb @@ -138,9 +138,9 @@ def create_judge private def set_contest_instance - @container = Container.find(params[:container_id]) - @contest_description = @container.contest_descriptions.find(params[:contest_description_id]) - @contest_instance = @contest_description.contest_instances.find(params[:contest_instance_id]) + @container = policy_scope(Container).find(params[:container_id]) + @contest_description = policy_scope(ContestDescription).find(params[:contest_description_id]) + @contest_instance = policy_scope(ContestInstance).find(params[:contest_instance_id]) end def set_judging_assignment diff --git a/app/controllers/judging_rounds_controller.rb b/app/controllers/judging_rounds_controller.rb index 200310b4..33a01e8a 100644 --- a/app/controllers/judging_rounds_controller.rb +++ b/app/controllers/judging_rounds_controller.rb @@ -124,8 +124,8 @@ def update_rankings entry_id = ranking_data['entry_id'].presence || ranking_data[:entry_id].presence next unless entry_id - entry = Entry.find(entry_id) - entry_ranking = EntryRanking.find_or_initialize_by( + entry = policy_scope(Entry).find(entry_id) + entry_ranking = policy_scope(EntryRanking).find_or_initialize_by( entry: entry, judging_round: @judging_round, user: current_user @@ -144,8 +144,8 @@ def update_rankings entry_id = ranking_data['entry_id'].presence || ranking_data[:entry_id].presence if entry_id && ranking_data['rank'].present? - entry = Entry.find(entry_id) - entry_ranking = EntryRanking.find_or_initialize_by( + entry = policy_scope(Entry).find(entry_id) + entry_ranking = policy_scope(EntryRanking).find_or_initialize_by( entry: entry, judging_round: @judging_round, user: current_user @@ -273,7 +273,7 @@ def finalize_rankings private def set_contest_instance - @contest_instance = ContestInstance.find(params[:contest_instance_id]) + @contest_instance = policy_scope(ContestInstance).find(params[:contest_instance_id]) @contest_description = @contest_instance.contest_description @container = @contest_description.container end diff --git a/app/controllers/round_judge_assignments_controller.rb b/app/controllers/round_judge_assignments_controller.rb index c3927068..5fa113e3 100644 --- a/app/controllers/round_judge_assignments_controller.rb +++ b/app/controllers/round_judge_assignments_controller.rb @@ -11,7 +11,7 @@ def index def create @round_judge_assignment = @judging_round.round_judge_assignments.build(round_judge_assignment_params) - + if @round_judge_assignment.save redirect_to container_contest_description_contest_instance_judging_round_round_judge_assignments_path( @container, @contest_description, @contest_instance, @judging_round @@ -33,10 +33,10 @@ def destroy private def set_judging_round - @container = Container.find(params[:container_id]) - @contest_description = @container.contest_descriptions.find(params[:contest_description_id]) - @contest_instance = @contest_description.contest_instances.find(params[:contest_instance_id]) - @judging_round = @contest_instance.judging_rounds.find(params[:judging_round_id]) + @container = policy_scope(Container).find(params[:container_id]) + @contest_description = policy_scope(ContestDescription).find(params[:contest_description_id]) + @contest_instance = policy_scope(ContestInstance).find(params[:contest_instance_id]) + @judging_round = policy_scope(JudgingRound).find(params[:judging_round_id]) end def set_round_judge_assignment diff --git a/app/controllers/user_roles_controller.rb b/app/controllers/user_roles_controller.rb index 16613709..f98b3f8f 100644 --- a/app/controllers/user_roles_controller.rb +++ b/app/controllers/user_roles_controller.rb @@ -54,7 +54,7 @@ def destroy private def set_user_role - @user_role = UserRole.find(params[:id]) + @user_role = policy_scope(UserRole).find(params[:id]) end def user_role_params diff --git a/app/policies/contest_description_policy.rb b/app/policies/contest_description_policy.rb index f91282ff..c7ed8f54 100644 --- a/app/policies/contest_description_policy.rb +++ b/app/policies/contest_description_policy.rb @@ -1,4 +1,15 @@ class ContestDescriptionPolicy < ApplicationPolicy + class Scope < Scope + def resolve + if user&.axis_mundi? + scope.all + else + scope.joins(:container) + .where(containers: { id: user&.containers&.pluck(:id) || [] }) + end + end + end + def index? true end diff --git a/app/policies/entry_policy.rb b/app/policies/entry_policy.rb index 85e2cf43..cd32879d 100644 --- a/app/policies/entry_policy.rb +++ b/app/policies/entry_policy.rb @@ -1,4 +1,22 @@ class EntryPolicy < ApplicationPolicy + class Scope < Scope + def resolve + if user&.axis_mundi? + scope.all + else + # Start with a base scope that includes the necessary joins + base_scope = scope.joins(contest_instance: { contest_description: :container }) + + # Build the two conditions + own_entries = base_scope.where(profile: user&.profile) + container_entries = base_scope.where(containers: { id: user&.containers&.pluck(:id) || [] }) + + # Combine them with OR + own_entries.or(container_entries) + end + end + end + def create? (record.profile.user == user && record.contest_instance.open?) || axis_mundi? end diff --git a/app/policies/judging_assignment_policy.rb b/app/policies/judging_assignment_policy.rb index a401f305..efa1c992 100644 --- a/app/policies/judging_assignment_policy.rb +++ b/app/policies/judging_assignment_policy.rb @@ -1,4 +1,16 @@ class JudgingAssignmentPolicy < ApplicationPolicy + class Scope < Scope + def resolve + if user&.axis_mundi? + scope.all + else + scope.where(user: user) + .or(scope.joins(contest_instance: { contest_description: :container }) + .where(containers: { id: user&.containers&.pluck(:id) || [] })) + end + end + end + def create? user&.has_container_role?(record.contest_instance.contest_description.container) || axis_mundi? end @@ -8,8 +20,8 @@ def destroy? end def show? - record.user == user || - user&.has_container_role?(record.contest_instance.contest_description.container) || + record.user == user || + user&.has_container_role?(record.contest_instance.contest_description.container) || axis_mundi? end end diff --git a/app/policies/judging_round_policy.rb b/app/policies/judging_round_policy.rb index fe056eb4..36049f40 100644 --- a/app/policies/judging_round_policy.rb +++ b/app/policies/judging_round_policy.rb @@ -1,6 +1,19 @@ class JudgingRoundPolicy < ApplicationPolicy + class Scope < Scope + def resolve + if user&.axis_mundi? + scope.all + else + scope.joins(contest_instance: { contest_description: :container }) + .where(containers: { id: user&.containers&.pluck(:id) || [] }) + .or(scope.joins(:judging_assignments) + .where(judging_assignments: { user: user, active: true })) + end + end + end + def show? - user&.has_container_role?(record.contest_instance.contest_description.container) || + user&.has_container_role?(record.contest_instance.contest_description.container) || record.contest_instance.judges.include?(user) || axis_mundi? end diff --git a/app/policies/user_role_policy.rb b/app/policies/user_role_policy.rb index 2b9ce39f..672c4ae5 100644 --- a/app/policies/user_role_policy.rb +++ b/app/policies/user_role_policy.rb @@ -1,2 +1,27 @@ class UserRolePolicy < ApplicationPolicy + class Scope < Scope + def resolve + if user&.axis_mundi? + scope.all + else + scope.where(user: user) + end + end + end + + def show? + record.user == user || axis_mundi? + end + + def create? + axis_mundi? + end + + def update? + axis_mundi? + end + + def destroy? + axis_mundi? + end end diff --git a/app/views/applicant_dashboard/_available_contests.html.erb b/app/views/applicant_dashboard/_available_contests.html.erb index c61a665c..930d7505 100644 --- a/app/views/applicant_dashboard/_available_contests.html.erb +++ b/app/views/applicant_dashboard/_available_contests.html.erb @@ -3,9 +3,9 @@ <% if params[:filter] && (params[:filter][:department_id].present? || params[:filter][:container_id].present?) %>
- Showing contests for + Showing contests for <% if params[:filter][:department_id].present? %> - <% department = Department.find_by(id: params[:filter][:department_id]) %> + <% department = policy_scope(Department).find_by(id: params[:filter][:department_id]) %> <% if department %> Department: <%= department.name %> <% end %> @@ -14,7 +14,7 @@ and <% end %> <% if params[:filter][:container_id].present? %> - <% container = Container.find_by(id: params[:filter][:container_id]) %> + <% container = policy_scope(Container).find_by(id: params[:filter][:container_id]) %> <% if container %> Collection: <%= container.name %> <% end %> @@ -31,20 +31,20 @@ <% if params[:filter] && (params[:filter][:department_id].present? || params[:filter][:container_id].present?) %> Currently filtering by: <% if params[:filter][:department_id].present? %> - <% department = Department.find_by(id: params[:filter][:department_id]) %> + <% department = policy_scope(Department).find_by(id: params[:filter][:department_id]) %> <% if department %> Department: <%= department.name %> <% end %> <% end %> <% if params[:filter][:container_id].present? %> - <% container = Container.find_by(id: params[:filter][:container_id]) %> + <% container = policy_scope(Container).find_by(id: params[:filter][:container_id]) %> <% if container %> Collection: <%= container.name %> <% end %> <% end %> <% end %> - + <% if @active_contests_by_container.any? %> <% @active_contests_by_container.each do |container, contests| %> <%= render partial: 'applicant_dashboard/contest_table', locals: { container: container, contests: contests } %> @@ -52,4 +52,4 @@ <% else %>
No active contests available for your class level.
<% end %> - \ No newline at end of file + diff --git a/app/views/contest_descriptions/show.html.erb b/app/views/contest_descriptions/show.html.erb index 0e898964..202a765d 100644 --- a/app/views/contest_descriptions/show.html.erb +++ b/app/views/contest_descriptions/show.html.erb @@ -1,4 +1,4 @@ -<%= render @contest_description %> +<%= render partial: 'contest_description', locals: { contest_description: @contest_description } %><%= notice %>
-<%= render @entry %> +<%= render partial: 'entry', locals: { entry: @entry } %><%= notice %>
-<%= render @user_role %> +<%= render partial: 'user_role', locals: { user_role: @user_role } %>