From 429d41eeadda1e6f4edba359214241827108d3a7 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Mon, 3 Nov 2025 17:37:28 +1100 Subject: [PATCH] First commit! --- Dockerfile | 76 +++ Gemfile | 71 +++ Gemfile.lock | 427 +++++++++++++++ Procfile.dev | 2 + README.md | 24 + Rakefile | 6 + app/assets/builds/.keep | 0 app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 10 + app/assets/tailwind/application.css | 1 + app/controllers/api/events_controller.rb | 75 +++ app/controllers/application_controller.rb | 9 + app/controllers/concerns/.keep | 0 app/controllers/events_controller.rb | 33 ++ app/controllers/projects_controller.rb | 99 ++++ app/controllers/rule_sets_controller.rb | 53 ++ app/helpers/application_helper.rb | 3 + app/javascript/application.js | 3 + app/javascript/controllers/application.js | 9 + .../controllers/hello_controller.js | 7 + app/javascript/controllers/index.js | 4 + app/jobs/application_job.rb | 7 + app/jobs/event_normalization_job.rb | 87 +++ app/jobs/generate_waf_rules_job.rb | 171 ++++++ app/jobs/process_waf_analytics_job.rb | 126 +++++ app/jobs/process_waf_event_job.rb | 52 ++ app/mailers/application_mailer.rb | 4 + app/models/application_record.rb | 3 + app/models/concerns/.keep | 0 app/models/current.rb | 16 + app/models/event.rb | 287 ++++++++++ app/models/issue.rb | 97 ++++ app/models/network_range.rb | 63 +++ app/models/path_segment.rb | 17 + app/models/project.rb | 211 ++++++++ app/models/request_host.rb | 19 + app/models/rule.rb | 126 +++++ app/models/rule_set.rb | 108 ++++ app/services/dsn_authentication_service.rb | 81 +++ app/services/event_normalizer.rb | 122 +++++ app/views/events/index.html.erb | 112 ++++ app/views/layouts/application.html.erb | 85 +++ app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/projects/analytics.html.erb | 200 +++++++ app/views/projects/index.html.erb | 49 ++ app/views/projects/new.html.erb | 32 ++ app/views/projects/show.html.erb | 118 ++++ app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + bin/brakeman | 7 + bin/bundler-audit | 6 + bin/ci | 6 + bin/dev | 16 + bin/docker-entrypoint | 8 + bin/importmap | 4 + bin/jobs | 6 + bin/kamal | 27 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 35 ++ bin/thrust | 5 + config.ru | 6 + config/application.rb | 35 ++ config/boot.rb | 4 + config/bundler-audit.yml | 5 + config/cable.yml | 17 + config/cache.yml | 16 + config/ci.rb | 23 + config/database.yml | 41 ++ config/deploy.yml | 120 ++++ config/environment.rb | 5 + config/environments/development.rb | 78 +++ config/environments/production.rb | 90 +++ config/environments/test.rb | 53 ++ config/importmap.rb | 7 + config/initializers/assets.rb | 7 + .../initializers/content_security_policy.rb | 29 + .../initializers/filter_parameter_logging.rb | 8 + config/initializers/inflections.rb | 16 + config/initializers/pagy.rb | 6 + config/locales/en.yml | 31 ++ config/puma.rb | 42 ++ config/queue.yml | 18 + config/recurring.yml | 15 + config/routes.rb | 30 + config/storage.yml | 27 + db/cable_schema.rb | 11 + db/cache_schema.rb | 14 + .../20251102030111_create_network_ranges.rb | 30 + db/migrate/20251102044000_create_projects.rb | 21 + db/migrate/20251102044052_create_events.rb | 37 ++ db/migrate/20251102080959_create_rule_sets.rb | 13 + db/migrate/20251102081014_create_rules.rb | 17 + .../20251102081043_add_fields_to_rule_sets.rb | 11 + ...02234055_add_simple_event_normalization.rb | 15 + ...9_rename_action_to_waf_action_in_events.rb | 5 + db/queue_schema.rb | 129 +++++ db/schema.rb | 161 ++++++ db/seeds.rb | 9 + docs/path-segment-architecture.md | 512 ++++++++++++++++++ lib/tasks/.keep | 0 log/.keep | 0 public/400.html | 135 +++++ public/404.html | 135 +++++ public/406-unsupported-browser.html | 135 +++++ public/422.html | 135 +++++ public/500.html | 135 +++++ public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + script/.keep | 0 test/application_system_test_case.rb | 5 + test/controllers/.keep | 0 test/fixtures/files/.keep | 0 test/fixtures/network_ranges.yml | 37 ++ test/fixtures/path_segments.yml | 11 + test/fixtures/request_actions.yml | 7 + test/fixtures/request_hosts.yml | 11 + test/fixtures/request_methods.yml | 7 + test/fixtures/request_protocols.yml | 7 + test/fixtures/rule_sets.yml | 15 + test/fixtures/rules.yml | 23 + test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 test/models/network_range_test.rb | 7 + test/models/path_segment_test.rb | 7 + test/models/request_action_test.rb | 7 + test/models/request_host_test.rb | 7 + test/models/request_method_test.rb | 7 + test/models/request_protocol_test.rb | 7 + test/models/rule_set_test.rb | 7 + test/models/rule_test.rb | 7 + test/system/.keep | 0 test/test_helper.rb | 15 + tmp/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 141 files changed, 5890 insertions(+) create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Procfile.dev create mode 100644 README.md create mode 100644 Rakefile create mode 100644 app/assets/builds/.keep create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/tailwind/application.css create mode 100644 app/controllers/api/events_controller.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/events_controller.rb create mode 100644 app/controllers/projects_controller.rb create mode 100644 app/controllers/rule_sets_controller.rb create mode 100644 app/helpers/application_helper.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/hello_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/event_normalization_job.rb create mode 100644 app/jobs/generate_waf_rules_job.rb create mode 100644 app/jobs/process_waf_analytics_job.rb create mode 100644 app/jobs/process_waf_event_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/current.rb create mode 100644 app/models/event.rb create mode 100644 app/models/issue.rb create mode 100644 app/models/network_range.rb create mode 100644 app/models/path_segment.rb create mode 100644 app/models/project.rb create mode 100644 app/models/request_host.rb create mode 100644 app/models/rule.rb create mode 100644 app/models/rule_set.rb create mode 100644 app/services/dsn_authentication_service.rb create mode 100644 app/services/event_normalizer.rb create mode 100644 app/views/events/index.html.erb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/projects/analytics.html.erb create mode 100644 app/views/projects/index.html.erb create mode 100644 app/views/projects/new.html.erb create mode 100644 app/views/projects/show.html.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100755 bin/brakeman create mode 100755 bin/bundler-audit create mode 100755 bin/ci create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/importmap create mode 100755 bin/jobs create mode 100755 bin/kamal create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/thrust create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/bundler-audit.yml create mode 100644 config/cable.yml create mode 100644 config/cache.yml create mode 100644 config/ci.rb create mode 100644 config/database.yml create mode 100644 config/deploy.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/importmap.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/pagy.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/migrate/20251102030111_create_network_ranges.rb create mode 100644 db/migrate/20251102044000_create_projects.rb create mode 100644 db/migrate/20251102044052_create_events.rb create mode 100644 db/migrate/20251102080959_create_rule_sets.rb create mode 100644 db/migrate/20251102081014_create_rules.rb create mode 100644 db/migrate/20251102081043_add_fields_to_rule_sets.rb create mode 100644 db/migrate/20251102234055_add_simple_event_normalization.rb create mode 100644 db/migrate/20251103035249_rename_action_to_waf_action_in_events.rb create mode 100644 db/queue_schema.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 docs/path-segment-architecture.md create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep create mode 100644 test/application_system_test_case.rb create mode 100644 test/controllers/.keep create mode 100644 test/fixtures/files/.keep create mode 100644 test/fixtures/network_ranges.yml create mode 100644 test/fixtures/path_segments.yml create mode 100644 test/fixtures/request_actions.yml create mode 100644 test/fixtures/request_hosts.yml create mode 100644 test/fixtures/request_methods.yml create mode 100644 test/fixtures/request_protocols.yml create mode 100644 test/fixtures/rule_sets.yml create mode 100644 test/fixtures/rules.yml create mode 100644 test/helpers/.keep create mode 100644 test/integration/.keep create mode 100644 test/mailers/.keep create mode 100644 test/models/.keep create mode 100644 test/models/network_range_test.rb create mode 100644 test/models/path_segment_test.rb create mode 100644 test/models/request_action_test.rb create mode 100644 test/models/request_host_test.rb create mode 100644 test/models/request_method_test.rb create mode 100644 test/models/request_protocol_test.rb create mode 100644 test/models/rule_set_test.rb create mode 100644 test/models/rule_test.rb create mode 100644 test/system/.keep create mode 100644 test/test_helper.rb create mode 100644 tmp/.keep create mode 100644 vendor/.keep create mode 100644 vendor/javascript/.keep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f531722 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,76 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t baffle_hub . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name baffle_hub baffle_hub + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.4.7 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency. +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY Gemfile Gemfile.lock vendor ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + + + +# Final stage for app image +FROM base + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..7329c15 --- /dev/null +++ b/Gemfile @@ -0,0 +1,71 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.1" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" +# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +gem "importmap-rails" +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] +gem "turbo-rails" +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] +gem "stimulus-rails" +# Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] +gem "tailwindcss-rails" +# Build JSON APIs with ease [https://github.com/rails/jbuilder] +gem "jbuilder" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +# gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +gem "image_processing", "~> 1.2" + +# Pagination +gem "pagy" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..154b512 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,427 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.15) + railties + actioncable (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + mail (>= 2.8.0) + actionmailer (8.1.1) + actionpack (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activesupport (= 8.1.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.1) + actionview (= 8.1.1) + activesupport (= 8.1.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.1) + activesupport (= 8.1.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.1) + activesupport (= 8.1.1) + globalid (>= 0.3.6) + activemodel (8.1.1) + activesupport (= 8.1.1) + activerecord (8.1.1) + activemodel (= 8.1.1) + activesupport (= 8.1.1) + timeout (>= 0.4.0) + activestorage (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activesupport (= 8.1.1) + marcel (~> 1.0) + activesupport (8.1.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + base64 (0.3.0) + bcrypt_pbkdf (1.1.1) + bigdecimal (3.3.1) + bindex (0.8.1) + bootsnap (1.18.6) + msgpack (~> 1.2) + brakeman (7.1.0) + racc + builder (3.3.0) + bundler-audit (0.9.2) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + crass (1.0.6) + date (3.5.0) + debug (1.11.0) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.1.8) + drb (2.2.3) + ed25519 (1.4.0) + erb (5.1.3) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) + fugit (1.12.1) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.3.0) + activesupport (>= 6.1) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) + ruby-vips (>= 2.0.17, < 3) + importmap-rails (2.2.2) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.1) + irb (1.15.3) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.15.2) + kamal (2.8.2) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + matrix (0.4.3) + mini_magick (5.3.1) + logger + mini_mime (1.1.5) + minitest (5.26.0) + msgpack (1.8.0) + net-imap (0.5.12) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.0) + nio4r (2.7.5) + nokogiri (1.18.10-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + pagy (9.4.0) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.6.0) + propshaft (1.3.1) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + psych (5.2.6) + date + stringio + public_suffix (6.0.2) + puma (7.1.0) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.3) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails (8.1.1) + actioncable (= 8.1.1) + actionmailbox (= 8.1.1) + actionmailer (= 8.1.1) + actionpack (= 8.1.1) + actiontext (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activemodel (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + bundler (>= 1.15.0) + railties (= 8.1.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.1) + rdoc (6.15.1) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.2) + io-console (~> 0.5) + rexml (3.4.4) + rubocop (1.81.7) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.47.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.33.4) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + ruby-vips (2.2.5) + ffi (~> 1.12) + logger + rubyzip (3.2.1) + securerandom (0.4.1) + selenium-webdriver (4.38.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + solid_cable (3.0.12) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.8) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.2.4) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlite3 (2.7.4-aarch64-linux-gnu) + sqlite3 (2.7.4-aarch64-linux-musl) + sqlite3 (2.7.4-arm-linux-gnu) + sqlite3 (2.7.4-arm-linux-musl) + sqlite3 (2.7.4-arm64-darwin) + sqlite3 (2.7.4-x86_64-linux-gnu) + sqlite3 (2.7.4-x86_64-linux-musl) + sshkit (1.24.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stimulus-rails (1.3.4) + railties (>= 6.0.0) + stringio (3.1.7) + tailwindcss-rails (4.4.0) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.1.16) + tailwindcss-ruby (4.1.16-aarch64-linux-gnu) + tailwindcss-ruby (4.1.16-aarch64-linux-musl) + tailwindcss-ruby (4.1.16-arm64-darwin) + tailwindcss-ruby (4.1.16-x86_64-linux-gnu) + tailwindcss-ruby (4.1.16-x86_64-linux-musl) + thor (1.4.0) + thruster (0.1.16) + thruster (0.1.16-aarch64-linux) + thruster (0.1.16-arm64-darwin) + thruster (0.1.16-x86_64-linux) + timeout (0.4.4) + tsort (0.2.0) + turbo-rails (2.0.20) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.1.0) + useragent (0.16.11) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin-24 + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bootsnap + brakeman + bundler-audit + capybara + debug + image_processing (~> 1.2) + importmap-rails + jbuilder + kamal + pagy + propshaft + puma (>= 5.0) + rails (~> 8.1.1) + rubocop-rails-omakase + selenium-webdriver + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + tailwindcss-rails + thruster + turbo-rails + tzinfo-data + web-console + +BUNDLED WITH + 2.6.9 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..0ac4b20 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,2 @@ +web: bin/rails server -b 0.0.0.0 -p 3041 +css: bin/rails tailwindcss:watch diff --git a/README.md b/README.md new file mode 100644 index 0000000..7db80e4 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# README + +This README would normally document whatever steps are necessary to get the +application up and running. + +Things you may want to cover: + +* Ruby version + +* System dependencies + +* Configuration + +* Database creation + +* Database initialization + +* How to run the test suite + +* Services (job queues, cache servers, search engines, etc.) + +* Deployment instructions + +* ... diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..fe93333 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/app/controllers/api/events_controller.rb b/app/controllers/api/events_controller.rb new file mode 100644 index 0000000..7d0ddf7 --- /dev/null +++ b/app/controllers/api/events_controller.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class Api::EventsController < ApplicationController + skip_before_action :verify_authenticity_token + + # POST /api/:project_id/events + def create + project = authenticate_project! + return head :not_found unless project + + # Parse the incoming WAF event data + event_data = parse_event_data(request) + + # Create event asynchronously + ProcessWafEventJob.perform_later( + project_id: project.id, + event_data: event_data, + headers: extract_serializable_headers(request) + ) + + # Always return 200 OK to avoid agent retries + head :ok + rescue DsnAuthenticationService::AuthenticationError => e + Rails.logger.warn "DSN authentication failed: #{e.message}" + head :unauthorized + rescue JSON::ParserError => e + Rails.logger.error "Invalid JSON in event data: #{e.message}" + head :bad_request + end + + private + + def authenticate_project! + DsnAuthenticationService.authenticate(request, params[:project_id]) + end + + def parse_event_data(request) + # Handle different content types + content_type = request.content_type || "application/json" + + case content_type + when /application\/json/ + JSON.parse(request.body.read) + when /application\/x-www-form-urlencoded/ + # Convert form data to JSON-like hash + request.request_parameters + else + # Try to parse as JSON anyway + JSON.parse(request.body.read) + end + rescue => e + Rails.logger.error "Failed to parse event data: #{e.message}" + {} + ensure + request.body.rewind if request.body.respond_to?(:rewind) + end + + def extract_serializable_headers(request) + # Only extract the headers we need for analytics, avoiding IO objects + important_headers = %w[ + User-Agent Content-Type Content-Length Accept + X-Forwarded-For X-Real-IP X-Forwarded-Proto + Authorization X-Baffle-Auth X-Sentry-Auth + Referer Accept-Language Accept-Encoding + ] + + headers = {} + important_headers.each do |header| + value = request.headers[header] + headers[header] = value if value.present? + end + + headers + end +end \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..d0bc7e8 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,9 @@ +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + # Changes to the importmap will invalidate the etag for HTML responses + stale_when_importmap_changes + + include Pagy::Backend +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb new file mode 100644 index 0000000..bf7b229 --- /dev/null +++ b/app/controllers/events_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class EventsController < ApplicationController + before_action :set_project + + def index + @events = @project.events.order(timestamp: :desc) + Rails.logger.debug "Found project? #{@project.name} / #{@project.events.count} / #{@events.count}" + Rails.logger.debug "Action: #{params[:waf_action]}" + # Apply filters + @events = @events.by_ip(params[:ip]) if params[:ip].present? + @events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].present? + @events = @events.where(country_code: params[:country]) if params[:country].present? + + Rails.logger.debug "after filter #{@project.name} / #{@project.events.count} / #{@events.count}" + # Debug info + Rails.logger.debug "Events count before pagination: #{@events.count}" + Rails.logger.debug "Project: #{@project&.name} (ID: #{@project&.id})" + + # Paginate + @pagy, @events = pagy(@events, items: 50) + + Rails.logger.debug "Events count after pagination: #{@events.count}" + Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages" + end + + private + + def set_project + @project = Project.find(params[:project_id]) || Project.find_by(slug: params[:project_id]) + redirect_to projects_path, alert: "Project not found" unless @project + end +end \ No newline at end of file diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb new file mode 100644 index 0000000..62f992b --- /dev/null +++ b/app/controllers/projects_controller.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class ProjectsController < ApplicationController + before_action :set_project, only: [:show, :edit, :update, :events, :analytics] + + def index + @projects = Project.order(created_at: :desc) + end + + def show + @recent_events = @project.recent_events(limit: 10) + @event_count = @project.event_count(24.hours.ago) + @blocked_count = @project.blocked_count(24.hours.ago) + @waf_status = @project.waf_status + end + + def new + @project = Project.new + end + + def create + @project = Project.new(project_params) + + if @project.save + redirect_to @project, notice: "Project was successfully created. Use this DSN for your baffle-agent: #{@project.dsn}" + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @project.update(project_params) + redirect_to @project, notice: "Project was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def events + @events = @project.events.recent.includes(:project) + + # Apply filters + @events = @events.by_ip(params[:ip]) if params[:ip].present? + @events = @events.by_action(params[:action]) if params[:action].present? + @events = @events.where(country_code: params[:country]) if params[:country].present? + + # Debug info + Rails.logger.debug "Events count before pagination: #{@events.count}" + Rails.logger.debug "Project: #{@project&.name} (ID: #{@project&.id})" + + # Paginate + @pagy, @events = pagy(@events, items: 50) + + Rails.logger.debug "Events count after pagination: #{@events.count}" + Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages" + end + + def analytics + @time_range = params[:time_range]&.to_i || 24 # hours + + # Basic analytics + @total_events = @project.event_count(@time_range.hours.ago) + @blocked_events = @project.blocked_count(@time_range.hours.ago) + @allowed_events = @project.allowed_count(@time_range.hours.ago) + + # Top blocked IPs + @top_blocked_ips = @project.top_blocked_ips(limit: 10, time_range: @time_range.hours.ago) + + # Country distribution + @country_stats = @project.events + .where(timestamp: @time_range.hours.ago..Time.current) + .where.not(country_code: nil) + .group(:country_code) + .select('country_code, COUNT(*) as count') + .order('count DESC') + .limit(10) + + # Action distribution + @action_stats = @project.events + .where(timestamp: @time_range.hours.ago..Time.current) + .group(:action) + .select('action, COUNT(*) as count') + .order('count DESC') + end + + private + + def set_project + @project = Project.find_by(slug: params[:id]) || Project.find_by(id: params[:id]) + redirect_to projects_path, alert: "Project not found" unless @project + end + + def project_params + params.require(:project).permit(:name, :enabled, settings: {}) + end +end \ No newline at end of file diff --git a/app/controllers/rule_sets_controller.rb b/app/controllers/rule_sets_controller.rb new file mode 100644 index 0000000..6d573c1 --- /dev/null +++ b/app/controllers/rule_sets_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class RuleSetsController < ApplicationController + before_action :set_rule_set, only: [:show, :edit, :update, :push_to_agents] + + def index + @rule_sets = RuleSet.includes(:rules).by_priority + end + + def show + @rules = @rule_set.rules.includes(:rule_set).by_priority + end + + def new + @rule_set = RuleSet.new + end + + def create + @rule_set = RuleSet.new(rule_set_params) + + if @rule_set.save + redirect_to @rule_set, notice: "Rule set was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @rule_set.update(rule_set_params) + redirect_to @rule_set, notice: "Rule set was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def push_to_agents + @rule_set.push_to_agents! + redirect_to @rule_set, notice: "Rule set pushed to agents successfully." + end + + private + + def set_rule_set + @rule_set = RuleSet.find_by(slug: params[:id]) || RuleSet.find(params[:id]) + end + + def rule_set_params + params.require(:rule_set).permit(:name, :description, :enabled, :priority) + end +end \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..76e3d63 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,3 @@ +module ApplicationHelper + include Pagy::Frontend if defined?(Pagy) +end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 0000000..0d7b494 --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,3 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 0000000..1213e85 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000..5975c07 --- /dev/null +++ b/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 0000000..1156bf8 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/jobs/event_normalization_job.rb b/app/jobs/event_normalization_job.rb new file mode 100644 index 0000000..1f83dd2 --- /dev/null +++ b/app/jobs/event_normalization_job.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +class EventNormalizationJob < ApplicationJob + queue_as :default + + # Normalize all existing events + def perform_all_events(batch_size: 1000) + total_events = Event.where(request_host_id: nil).count + Rails.logger.info "Starting normalization of #{total_events} events" + + offset = 0 + processed = 0 + + loop do + events = Event.where(request_host_id: nil) + .limit(batch_size) + .offset(offset) + .includes(:project) + + break if events.empty? + + events.each do |event| + begin + EventNormalizer.normalize_event!(event) + event.save! + processed += 1 + rescue => e + Rails.logger.error "Failed to normalize event #{event.id}: #{e.message}" + end + end + + Rails.logger.info "Processed #{processed}/#{total_events} events" + offset += batch_size + end + + Rails.logger.info "Completed normalization of #{processed} events" + end + + # Normalize a specific event + def perform(event_id) + event = Event.find(event_id) + + EventNormalizer.normalize_event!(event) + event.save! + + Rails.logger.info "Successfully normalized event #{event_id}" + rescue ActiveRecord::RecordNotFound + Rails.logger.error "Event #{event_id} not found for normalization" + rescue => e + Rails.logger.error "Failed to normalize event #{event_id}: #{e.message}" + raise + end + + # Normalize events for a specific project + def perform_for_project(project_id, batch_size: 1000) + project = Project.find(project_id) + total_events = project.events.where(request_host_id: nil).count + Rails.logger.info "Starting normalization of #{total_events} events for project #{project.name}" + + offset = 0 + processed = 0 + + loop do + events = project.events + .where(request_host_id: nil) + .limit(batch_size) + .offset(offset) + + break if events.empty? + + events.each do |event| + begin + EventNormalizer.normalize_event!(event) + event.save! + processed += 1 + rescue => e + Rails.logger.error "Failed to normalize event #{event.id}: #{e.message}" + end + end + + Rails.logger.info "Processed #{processed}/#{total_events} events for project #{project.name}" + offset += batch_size + end + + Rails.logger.info "Completed normalization of #{processed} events for project #{project.name}" + end +end \ No newline at end of file diff --git a/app/jobs/generate_waf_rules_job.rb b/app/jobs/generate_waf_rules_job.rb new file mode 100644 index 0000000..07eec5a --- /dev/null +++ b/app/jobs/generate_waf_rules_job.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +class GenerateWafRulesJob < ApplicationJob + queue_as :waf_rules + + def perform(project_id:, event_id:) + project = Project.find(project_id) + event = Event.find(event_id) + + # Only analyze blocked events for rule generation + return unless event.blocked? + + # Generate different types of rules based on patterns + generate_ip_rules(project, event) + generate_path_rules(project, event) + generate_user_agent_rules(project, event) + generate_parameter_rules(project, event) + + # Notify project of new rules + project.broadcast_rules_refresh + + rescue => e + Rails.logger.error "Error generating WAF rules: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + end + + private + + def generate_ip_rules(project, event) + return unless event.ip_address.present? + + # Check if this IP has multiple violations + violation_count = project.events + .by_ip(event.ip_address) + .blocked + .where(timestamp: 24.hours.ago..Time.current) + .count + + # Auto-block IPs with 10+ violations in 24 hours + if violation_count >= 10 && !project.blocked_ips.include?(event.ip_address) + project.add_ip_rule( + event.ip_address, + 'block', + expires_at: 7.days.from_now, + reason: "Auto-generated: #{violation_count} violations in 24 hours" + ) + + Rails.logger.info "Auto-blocked IP #{event.ip_address} for project #{project.slug}" + end + end + + def generate_path_rules(project, event) + return unless event.request_path.present? + + # Look for repeated attack patterns on specific paths + path_violations = project.events + .where(request_path: event.request_path) + .blocked + .where(timestamp: 1.hour.ago..Time.current) + .count + + # Suggest path rules if 20+ violations on same path + if path_violations >= 20 + suggest_path_rule(project, event.request_path, path_violations) + end + end + + def generate_user_agent_rules(project, event) + return unless event.user_agent.present? + + # Look for malicious user agents + ua_violations = project.events + .by_user_agent(event.user_agent) + .blocked + .where(timestamp: 1.hour.ago..Time.current) + .count + + # Suggest user agent rules if 15+ violations from same UA + if ua_violations >= 15 + suggest_user_agent_rule(project, event.user_agent, ua_violations) + end + end + + def generate_parameter_rules(project, event) + params = event.query_params + return unless params.present? + + # Look for suspicious parameter patterns + params.each do |key, value| + next unless value.is_a?(String) + + # Check for common attack patterns in parameter values + if contains_attack_pattern?(value) + param_violations = project.events + .where("payload LIKE ?", "%#{key}%#{value}%") + .blocked + .where(timestamp: 6.hours.ago..Time.current) + .count + + if param_violations >= 5 + suggest_parameter_rule(project, key, value, param_violations) + end + end + end + end + + def suggest_path_rule(project, path, violation_count) + # Create an issue for manual review + Issue.create!( + project: project, + title: "Suggested Path Rule", + description: "Path '#{path}' has #{violation_count} violations in 1 hour", + severity: "low", + metadata: { + type: "path_rule", + path: path, + violation_count: violation_count, + suggested_action: "block" + } + ) + end + + def suggest_user_agent_rule(project, user_agent, violation_count) + # Create an issue for manual review + Issue.create!( + project: project, + title: "Suggested User Agent Rule", + description: "User Agent '#{user_agent}' has #{violation_count} violations in 1 hour", + severity: "low", + metadata: { + type: "user_agent_rule", + user_agent: user_agent, + violation_count: violation_count, + suggested_action: "block" + } + ) + end + + def suggest_parameter_rule(project, param_name, param_value, violation_count) + # Create an issue for manual review + Issue.create!( + project: project, + title: "Suggested Parameter Rule", + description: "Parameter '#{param_name}' with suspicious values has #{violation_count} violations", + severity: "medium", + metadata: { + type: "parameter_rule", + param_name: param_name, + param_value: param_value, + violation_count: violation_count, + suggested_action: "block" + } + ) + end + + def contains_attack_pattern?(value) + # Common attack patterns + attack_patterns = [ + / + + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..3aac900 --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..37f0bdd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/projects/analytics.html.erb b/app/views/projects/analytics.html.erb new file mode 100644 index 0000000..66fcf6d --- /dev/null +++ b/app/views/projects/analytics.html.erb @@ -0,0 +1,200 @@ +
+

<%= @project.name %> - Analytics

+
+ <%= link_to "← Back to Project", project_path(@project), class: "btn btn-secondary" %> +
+
+ + +
+
+
Time Range
+
+
+ <%= form_with url: analytics_project_path(@project), method: :get, local: true do |form| %> +
+
+ <%= form.label :time_range, "Time Range", class: "form-label" %> + <%= form.select :time_range, + options_for_select([ + ["Last Hour", 1], + ["Last 6 Hours", 6], + ["Last 24 Hours", 24], + ["Last 7 Days", 168], + ["Last 30 Days", 720] + ], @time_range), + {}, class: "form-select" %> +
+
+ <%= form.submit "Update", class: "btn btn-primary" %> +
+
+ <% end %> +
+
+ + +
+
+
+
+

<%= number_with_delimiter(@total_events) %>

+

Total Events

+
+
+
+
+
+
+

<%= number_with_delimiter(@allowed_events) %>

+

Allowed

+
+
+
+
+
+
+

<%= number_with_delimiter(@blocked_events) %>

+

Blocked

+
+
+
+
+ +
+ +
+
+
+
Top Blocked IPs
+
+
+ <% if @top_blocked_ips.any? %> +
+ + + + + + + + + <% @top_blocked_ips.each do |stat| %> + + + + + <% end %> + +
IP AddressBlocked Count
<%= stat.ip_address %><%= number_with_delimiter(stat.count) %>
+
+ <% else %> +

No blocked events in this time range.

+ <% end %> +
+
+
+ + +
+
+
+
Top Countries
+
+
+ <% if @country_stats.any? %> +
+ + + + + + + + + <% @country_stats.each do |stat| %> + + + + + <% end %> + +
CountryEvents
<%= stat.country_code || 'Unknown' %><%= number_with_delimiter(stat.count) %>
+
+ <% else %> +

No country data available.

+ <% end %> +
+
+
+
+ + +
+
+
+
+
Action Distribution
+
+
+ <% if @action_stats.any? %> +
+ <% @action_stats.each do |stat| %> +
+
+
+

<%= stat.action.upcase %>

+

+ + <%= number_with_delimiter(stat.count) %> + +

+
+
+
+ <% end %> +
+ <% else %> +

No action data available.

+ <% end %> +
+
+
+
+ +<% if @total_events > 0 %> +
+
+
+
+
Block Rate
+
+
+
+ <% blocked_percentage = (@blocked_events.to_f / @total_events * 100).round(1) %> + <% allowed_percentage = (@allowed_events.to_f / @total_events * 100).round(1) %> + +
+ <%= allowed_percentage %>% Allowed +
+
+ <%= blocked_percentage %>% Blocked +
+
+
+
+
+
+<% end %> + +
+ <%= link_to "View Events", events_project_path(@project), class: "btn btn-primary" %> + <%= link_to "Export Data", "#", class: "btn btn-secondary", onclick: "alert('Export feature coming soon!')" %> +
\ No newline at end of file diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb new file mode 100644 index 0000000..bd53ccd --- /dev/null +++ b/app/views/projects/index.html.erb @@ -0,0 +1,49 @@ +

Projects

+ +<%= link_to "New Project", new_project_path, class: "btn btn-primary mb-3" %> + +
+ <% @projects.each do |project| %> +
+
+
+
<%= project.name %>
+ + <%= project.enabled? ? 'Active' : 'Disabled' %> + +
+
+

+ Status: + + <%= project.waf_status %> + +

+

+ Events (24h): <%= project.event_count(24.hours.ago) %> +

+

+ Blocked (24h): <%= project.blocked_count(24.hours.ago) %> +

+ + DSN:
+ <%= project.dsn %> +
+
+ +
+
+ <% end %> +
+ +<% if @projects.empty? %> +
+

No projects yet

+

Create your first project to start monitoring WAF events.

+ <%= link_to "Create Project", new_project_path, class: "btn btn-primary" %> +
+<% end %> \ No newline at end of file diff --git a/app/views/projects/new.html.erb b/app/views/projects/new.html.erb new file mode 100644 index 0000000..c9a0171 --- /dev/null +++ b/app/views/projects/new.html.erb @@ -0,0 +1,32 @@ +

New Project

+ +<%= form_with(model: @project, local: true) do |form| %> + <% if @project.errors.any? %> +
+

<%= pluralize(@project.errors.count, "error") %> prohibited this project from being saved:

+
    + <% @project.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :name, class: "form-label" %> + <%= form.text_field :name, class: "form-control" %> +
+ +
+ <%= form.label :enabled, class: "form-label" %> +
+ <%= form.check_box :enabled, class: "form-check-input" %> + <%= form.label :enabled, "Enable this project", class: "form-check-label" %> +
+
+ +
+ <%= form.submit "Create Project", class: "btn btn-primary" %> + <%= link_to "Cancel", projects_path, class: "btn btn-secondary" %> +
+<% end %> \ No newline at end of file diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb new file mode 100644 index 0000000..84df6e2 --- /dev/null +++ b/app/views/projects/show.html.erb @@ -0,0 +1,118 @@ +
+

<%= @project.name %>

+
+ <%= link_to "Edit", edit_project_path(@project), class: "btn btn-secondary" %> + <%= link_to "Events", events_project_path(@project), class: "btn btn-primary" %> + <%= link_to "Analytics", analytics_project_path(@project), class: "btn btn-info" %> +
+
+ +
+
+
+
+
Project Status
+
+
+

Status: + + <%= @waf_status %> + +

+

Enabled: + + <%= @project.enabled? ? 'Yes' : 'No' %> + +

+

Events (24h): <%= @event_count %>

+

Blocked (24h): <%= @blocked_count %>

+
+
+
+ +
+
+
+
DSN Configuration
+
+
+

DSN:

+ <%= @project.dsn %> + + + <% if @project.internal_dsn.present? %> +
+

Internal DSN:

+ <%= @project.internal_dsn %> + <% end %> +
+
+
+
+ +
+
+
+
Recent Events
+
+
+ <% if @recent_events.any? %> +
+ + + + + + + + + + + + <% @recent_events.limit(5).each do |event| %> + + + + + + + + <% end %> + +
TimeIPActionPathStatus
<%= event.timestamp.strftime("%H:%M:%S") %><%= event.ip_address %> + + <%= event.action %> + + <%= event.request_path %><%= event.response_status %>
+
+
+ <%= link_to "View All Events", events_project_path(@project), class: "btn btn-primary btn-sm" %> +
+ <% else %> +

No events received yet.

+ <% end %> +
+
+
+ + \ No newline at end of file diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..fbf3e3e --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "BaffleHub", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "BaffleHub.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 0000000..b3a13fb --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundler-audit b/bin/bundler-audit new file mode 100755 index 0000000..e2ef226 --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/bin/ci b/bin/ci new file mode 100755 index 0000000..4137ad5 --- /dev/null +++ b/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..ad72c7d --- /dev/null +++ b/bin/dev @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +if ! gem list foreman -i --silent; then + echo "Installing foreman..." + gem install foreman +fi + +# Default to port 3000 if not specified +export PORT="${PORT:-3000}" + +# Let the debug gem allow remote connections, +# but avoid loading until `debugger` is called +export RUBY_DEBUG_OPEN="true" +export RUBY_DEBUG_LAZY="true" + +exec foreman start -f Procfile.dev "$@" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000..ed31659 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 0000000..36502ab --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 0000000..cbe59b9 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 0000000..5a20504 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..81be011 --- /dev/null +++ b/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 0000000..36bde2d --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..4a3c09a --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 0000000..b43120f --- /dev/null +++ b/config/application.rb @@ -0,0 +1,35 @@ +require_relative "boot" + +require "rails/all" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +# Ensure pagy is loaded +require 'pagy' + +module BaffleHub + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configure trusted proxies for proper client IP detection + # This allows Rails to properly detect real client IPs behind reverse proxies + config.action_dispatch.trusted_proxies = [ + # Docker network ranges where your reverse proxy might be + IPAddr.new("172.16.0.0/12"), # Docker default bridge network range + IPAddr.new("10.0.0.0/8"), # Internal networks + IPAddr.new("192.168.0.0/16"), # Private networks + IPAddr.new("172.64.66.1") # Your specific Caddy container IP + ] + + # Enable IP spoofing check for security + config.action_dispatch.ip_spoofing_check = true + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..988a5dd --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/bundler-audit.yml b/config/bundler-audit.yml new file mode 100644 index 0000000..e74b3af --- /dev/null +++ b/config/bundler-audit.yml @@ -0,0 +1,5 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: + - CVE-THAT-DOES-NOT-APPLY diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..b9adc5a --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,17 @@ +# Async adapter only works within the same process, so for manually triggering cable updates from a console, +# and seeing results in the browser, you must do so from the web console (running inside the dev process), +# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view +# to make the web console appear. +development: + adapter: async + +test: + adapter: test + +production: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 0000000..19d4908 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,16 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +development: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 0000000..e56a92e --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,23 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + step "Tests: Rails", "bin/rails test" + step "Tests: System", "bin/rails test:system" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..693252b --- /dev/null +++ b/config/database.yml @@ -0,0 +1,41 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + + +# Store production database in the storage/ directory, which by default +# is mounted as a persistent Docker volume in config/deploy.yml. +production: + primary: + <<: *default + database: storage/production.sqlite3 + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..fdfcda2 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,120 @@ +# Name of your application. Used to uniquely configure containers. +service: baffle_hub + +# Name of the container image (use your-user/app-name on external registries). +image: baffle_hub + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +# +# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb! +# +# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer). +# +# proxy: +# ssl: true +# host: app.example.com + +# Where you keep your container images. +registry: + # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ... + server: localhost:5555 + + # Needed for authenticated registries. + # username: your-user + + # Always use an access token rather than real password when possible. + # password: + # - KAMAL_REGISTRY_PASSWORD + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + clear: + # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. + # When you start using multiple servers, you should split out job processing to a dedicated machine. + SOLID_QUEUE_IN_PUMA: true + + # Set number of processes dedicated to Solid Queue (default: 1) + # JOB_CONCURRENCY: 3 + + # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + # Match this to any external database server to configure Active Record correctly + # Use baffle_hub-db for a db accessory server on same machine via local kamal docker network. + # DB_HOST: 192.168.0.2 + + # Log everything from Rails + # RAILS_LOG_LEVEL: debug + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password" + +# Use a persistent storage volume for sqlite database files and local Active Storage files. +# Recommended to change this to a mounted volume path that is backed up off server. +volumes: + - "baffle_hub_storage:/rails/storage" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + + +# Configure the image builder. +builder: + arch: amd64 + + # # Build image via remote server (useful for faster amd64 builds on arm64 computers) + # remote: ssh://docker@docker-builder-server + # + # # Pass arguments and secrets to the Docker build process + # args: + # RUBY_VERSION: 3.4.7 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: valkey/valkey:8 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..75243c3 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,78 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..f5763e0 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,90 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..c2095b1 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,53 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 0000000..909dfc5 --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,7 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/turbo-rails", to: "turbo.min.js" +pin "@hotwired/stimulus", to: "stimulus.min.js" +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 0000000..4873244 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..d51d713 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,29 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` +# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. +# # config.content_security_policy_nonce_auto = true +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..c0b717f --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..3860f65 --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb new file mode 100644 index 0000000..86391e3 --- /dev/null +++ b/config/initializers/pagy.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Pagy configuration +# require 'pagy' + +# Pagy::VARS[:items] = 50 # default items per page \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..38c4b86 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,42 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments. +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 0000000..9eace59 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 0000000..b4207f9 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,15 @@ +# examples: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_cleanup_with_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day + +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..f5d101b --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,30 @@ +Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # WAF Event Ingestion API + namespace :api, defaults: { format: :json } do + post ":project_id/events", to: "events#create" + end + + # Root path - projects dashboard + root "projects#index" + + # Project management + resources :projects, only: [:index, :new, :create, :show, :edit, :update] do + resources :events, only: [:index] + member do + get :analytics + end + end + + # Rule management + resources :rule_sets, only: [:index, :new, :create, :show, :edit, :update] do + member do + post :push_to_agents + end + end +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..927dc53 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,27 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 0000000..2366660 --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 0000000..6005a29 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20251102030111_create_network_ranges.rb b/db/migrate/20251102030111_create_network_ranges.rb new file mode 100644 index 0000000..666c94e --- /dev/null +++ b/db/migrate/20251102030111_create_network_ranges.rb @@ -0,0 +1,30 @@ +class CreateNetworkRanges < ActiveRecord::Migration[8.1] + def change + create_table :network_ranges do |t| + t.binary :ip_address, null: false + t.integer :network_prefix, null: false + t.integer :ip_version, null: false + t.string :company + t.integer :asn + t.string :asn_org + t.boolean :is_datacenter, default: false + t.boolean :is_proxy, default: false + t.boolean :is_vpn, default: false + t.string :ip_api_country + t.string :geo2_country + t.text :abuser_scores + t.text :additional_data + t.timestamp :last_api_fetch + + t.timestamps + end + + # Indexes for common queries + add_index :network_ranges, [:ip_address, :network_prefix], name: 'idx_network_ranges_ip_range' + add_index :network_ranges, :asn, name: 'idx_network_ranges_asn' + add_index :network_ranges, :company, name: 'idx_network_ranges_company' + add_index :network_ranges, :ip_api_country, name: 'idx_network_ranges_country' + add_index :network_ranges, [:is_datacenter, :is_proxy, :is_vpn], name: 'idx_network_ranges_flags' + add_index :network_ranges, :ip_version, name: 'idx_network_ranges_version' + end +end diff --git a/db/migrate/20251102044000_create_projects.rb b/db/migrate/20251102044000_create_projects.rb new file mode 100644 index 0000000..9cc077e --- /dev/null +++ b/db/migrate/20251102044000_create_projects.rb @@ -0,0 +1,21 @@ +class CreateProjects < ActiveRecord::Migration[8.1] + def change + create_table :projects do |t| + t.string :name, null: false + t.string :slug, null: false + t.string :public_key, null: false + t.boolean :enabled, default: true, null: false + t.integer :rate_limit_threshold, default: 100, null: false + t.integer :blocked_ip_count, default: 0, null: false + t.text :custom_rules, default: "{}", null: false + t.text :settings, default: "{}", null: false + + t.timestamps + end + + add_index :projects, :slug, unique: true + add_index :projects, :public_key, unique: true + add_index :projects, :enabled + add_index :projects, :name + end +end diff --git a/db/migrate/20251102044052_create_events.rb b/db/migrate/20251102044052_create_events.rb new file mode 100644 index 0000000..dff24a7 --- /dev/null +++ b/db/migrate/20251102044052_create_events.rb @@ -0,0 +1,37 @@ +class CreateEvents < ActiveRecord::Migration[8.1] + def change + create_table :events do |t| + t.references :project, null: false, foreign_key: true + t.string :event_id, null: false + t.datetime :timestamp, null: false + t.string :action + t.string :ip_address + t.text :user_agent + t.string :request_method + t.string :request_path + t.string :request_url + t.string :request_protocol + t.integer :response_status + t.integer :response_time_ms + t.string :rule_matched + t.text :blocked_reason + t.string :server_name + t.string :environment + t.string :country_code + t.string :city + t.string :agent_version + t.string :agent_name + t.json :payload + + t.timestamps + end + + add_index :events, :event_id, unique: true + add_index :events, :timestamp + add_index :events, [:project_id, :timestamp] + add_index :events, [:project_id, :action] + add_index :events, [:project_id, :ip_address] + add_index :events, :ip_address + add_index :events, :action + end +end diff --git a/db/migrate/20251102080959_create_rule_sets.rb b/db/migrate/20251102080959_create_rule_sets.rb new file mode 100644 index 0000000..86b839a --- /dev/null +++ b/db/migrate/20251102080959_create_rule_sets.rb @@ -0,0 +1,13 @@ +class CreateRuleSets < ActiveRecord::Migration[8.1] + def change + create_table :rule_sets do |t| + t.string :name + t.text :description + t.boolean :enabled + t.json :projects + t.json :rules + + t.timestamps + end + end +end diff --git a/db/migrate/20251102081014_create_rules.rb b/db/migrate/20251102081014_create_rules.rb new file mode 100644 index 0000000..7116306 --- /dev/null +++ b/db/migrate/20251102081014_create_rules.rb @@ -0,0 +1,17 @@ +class CreateRules < ActiveRecord::Migration[8.1] + def change + create_table :rules do |t| + t.references :rule_set, null: false, foreign_key: true + t.string :rule_type + t.string :target + t.string :action + t.boolean :enabled + t.datetime :expires_at + t.integer :priority + t.json :conditions + t.json :metadata + + t.timestamps + end + end +end diff --git a/db/migrate/20251102081043_add_fields_to_rule_sets.rb b/db/migrate/20251102081043_add_fields_to_rule_sets.rb new file mode 100644 index 0000000..a88be0d --- /dev/null +++ b/db/migrate/20251102081043_add_fields_to_rule_sets.rb @@ -0,0 +1,11 @@ +class AddFieldsToRuleSets < ActiveRecord::Migration[8.1] + def change + add_column :rule_sets, :slug, :string + add_column :rule_sets, :priority, :integer + add_column :rule_sets, :projects_subscription, :json + + add_index :rule_sets, :slug, unique: true + add_index :rule_sets, :enabled + add_index :rule_sets, :priority + end +end diff --git a/db/migrate/20251102234055_add_simple_event_normalization.rb b/db/migrate/20251102234055_add_simple_event_normalization.rb new file mode 100644 index 0000000..94dc9eb --- /dev/null +++ b/db/migrate/20251102234055_add_simple_event_normalization.rb @@ -0,0 +1,15 @@ +class AddSimpleEventNormalization < ActiveRecord::Migration[8.1] + def change + # Add foreign key for hosts (most valuable normalization) + add_column :events, :request_host_id, :integer + add_foreign_key :events, :request_hosts + add_index :events, :request_host_id + + # Add path segment storage as string for LIKE queries + add_column :events, :request_segment_ids, :string + add_index :events, :request_segment_ids + + # Add composite index for common WAF queries using enums + add_index :events, [:request_host_id, :request_method, :request_segment_ids], name: 'idx_events_host_method_path' + end +end diff --git a/db/migrate/20251103035249_rename_action_to_waf_action_in_events.rb b/db/migrate/20251103035249_rename_action_to_waf_action_in_events.rb new file mode 100644 index 0000000..6d44595 --- /dev/null +++ b/db/migrate/20251103035249_rename_action_to_waf_action_in_events.rb @@ -0,0 +1,5 @@ +class RenameActionToWafActionInEvents < ActiveRecord::Migration[8.1] + def change + rename_column :events, :action, :waf_action + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 0000000..85194b6 --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,129 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..d4a847c --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,161 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2025_11_03_035249) do + create_table "events", force: :cascade do |t| + t.string "agent_name" + t.string "agent_version" + t.text "blocked_reason" + t.string "city" + t.string "country_code" + t.datetime "created_at", null: false + t.string "environment" + t.string "event_id", null: false + t.string "ip_address" + t.json "payload" + t.integer "project_id", null: false + t.integer "request_host_id" + t.string "request_method" + t.string "request_path" + t.string "request_protocol" + t.string "request_segment_ids" + t.string "request_url" + t.integer "response_status" + t.integer "response_time_ms" + t.string "rule_matched" + t.string "server_name" + t.datetime "timestamp", null: false + t.datetime "updated_at", null: false + t.text "user_agent" + t.string "waf_action" + t.index ["event_id"], name: "index_events_on_event_id", unique: true + t.index ["ip_address"], name: "index_events_on_ip_address" + t.index ["project_id", "ip_address"], name: "index_events_on_project_id_and_ip_address" + t.index ["project_id", "timestamp"], name: "index_events_on_project_id_and_timestamp" + t.index ["project_id", "waf_action"], name: "index_events_on_project_id_and_waf_action" + t.index ["project_id"], name: "index_events_on_project_id" + t.index ["request_host_id", "request_method", "request_segment_ids"], name: "idx_events_host_method_path" + t.index ["request_host_id"], name: "index_events_on_request_host_id" + t.index ["request_segment_ids"], name: "index_events_on_request_segment_ids" + t.index ["timestamp"], name: "index_events_on_timestamp" + t.index ["waf_action"], name: "index_events_on_waf_action" + end + + create_table "network_ranges", force: :cascade do |t| + t.text "abuser_scores" + t.text "additional_data" + t.integer "asn" + t.string "asn_org" + t.string "company" + t.datetime "created_at", null: false + t.string "geo2_country" + t.binary "ip_address", null: false + t.string "ip_api_country" + t.integer "ip_version", null: false + t.boolean "is_datacenter", default: false + t.boolean "is_proxy", default: false + t.boolean "is_vpn", default: false + t.datetime "last_api_fetch" + t.integer "network_prefix", null: false + t.datetime "updated_at", null: false + t.index ["asn"], name: "idx_network_ranges_asn" + t.index ["company"], name: "idx_network_ranges_company" + t.index ["ip_address", "network_prefix"], name: "idx_network_ranges_ip_range" + t.index ["ip_api_country"], name: "idx_network_ranges_country" + t.index ["ip_version"], name: "idx_network_ranges_version" + t.index ["is_datacenter", "is_proxy", "is_vpn"], name: "idx_network_ranges_flags" + end + + create_table "path_segments", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "first_seen_at", null: false + t.string "segment", null: false + t.datetime "updated_at", null: false + t.integer "usage_count", default: 1, null: false + t.index ["segment"], name: "index_path_segments_on_segment", unique: true + end + + create_table "projects", force: :cascade do |t| + t.integer "blocked_ip_count", default: 0, null: false + t.datetime "created_at", null: false + t.text "custom_rules", default: "{}", null: false + t.boolean "enabled", default: true, null: false + t.string "name", null: false + t.string "public_key", null: false + t.integer "rate_limit_threshold", default: 100, null: false + t.text "settings", default: "{}", null: false + t.string "slug", null: false + t.datetime "updated_at", null: false + t.index ["enabled"], name: "index_projects_on_enabled" + t.index ["name"], name: "index_projects_on_name" + t.index ["public_key"], name: "index_projects_on_public_key", unique: true + t.index ["slug"], name: "index_projects_on_slug", unique: true + end + + create_table "request_hosts", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "first_seen_at", null: false + t.string "hostname", null: false + t.datetime "updated_at", null: false + t.integer "usage_count", default: 1, null: false + t.index ["hostname"], name: "index_request_hosts_on_hostname", unique: true + end + + create_table "request_methods", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "method", null: false + t.datetime "updated_at", null: false + t.index ["method"], name: "index_request_methods_on_method", unique: true + end + + create_table "request_protocols", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "protocol", null: false + t.datetime "updated_at", null: false + t.index ["protocol"], name: "index_request_protocols_on_protocol", unique: true + end + + create_table "rule_sets", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.boolean "enabled" + t.string "name" + t.integer "priority" + t.json "projects" + t.json "projects_subscription" + t.json "rules" + t.string "slug" + t.datetime "updated_at", null: false + t.index ["enabled"], name: "index_rule_sets_on_enabled" + t.index ["priority"], name: "index_rule_sets_on_priority" + t.index ["slug"], name: "index_rule_sets_on_slug", unique: true + end + + create_table "rules", force: :cascade do |t| + t.string "action" + t.json "conditions" + t.datetime "created_at", null: false + t.boolean "enabled" + t.datetime "expires_at" + t.json "metadata" + t.integer "priority" + t.integer "rule_set_id", null: false + t.string "rule_type" + t.string "target" + t.datetime "updated_at", null: false + t.index ["rule_set_id"], name: "index_rules_on_rule_set_id" + end + + add_foreign_key "events", "projects" + add_foreign_key "events", "request_hosts" + add_foreign_key "rules", "rule_sets" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..4fbd6ed --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/docs/path-segment-architecture.md b/docs/path-segment-architecture.md new file mode 100644 index 0000000..2671a57 --- /dev/null +++ b/docs/path-segment-architecture.md @@ -0,0 +1,512 @@ +# Path Segment Architecture + +## Overview + +Baffle Hub uses a path segment decomposition strategy to efficiently store and query URL paths in WAF event logs. This architecture provides significant storage compression while enabling fast prefix-based path searches using SQLite's B-tree indexes. + +## The Problem + +WAF systems generate millions of request events. Storing full URL paths like `/api/v1/users/123/posts` repeatedly wastes storage and makes pattern-based queries inefficient. + +Traditional approaches: +- **Full path storage**: High redundancy, large database size +- **String pattern matching with LIKE**: No index support, slow queries +- **Full-Text Search (FTS)**: Complex setup, overkill for structured paths + +## Our Solution: Path Segment Normalization + +### Architecture Components + +``` +Request: /api/v1/users/123/posts + ↓ +Decompose into segments: ["api", "v1", "users", "123", "posts"] + ↓ +Normalize to IDs: [1, 2, 3, 4, 5] + ↓ +Store as JSON array: "[1,2,3,4,5]" +``` + +### Database Schema + +```ruby +# path_segments table - deduplicated segment dictionary +create_table :path_segments do |t| + t.string :segment, null: false, index: { unique: true } + t.integer :usage_count, default: 1, null: false + t.datetime :first_seen_at, null: false + t.timestamps +end + +# events table - references segments by ID +create_table :events do |t| + t.string :request_segment_ids # JSON array: "[1,2,3]" + t.string :request_path # Original path for display + # ... other fields +end + +# Critical index for fast lookups +add_index :events, :request_segment_ids +``` + +### Models + +**PathSegment** - The segment dictionary: +```ruby +class PathSegment < ApplicationRecord + validates :segment, presence: true, uniqueness: true + validates :usage_count, presence: true, numericality: { greater_than: 0 } + + def self.find_or_create_segment(segment) + find_or_create_by(segment: segment) do |path_segment| + path_segment.usage_count = 1 + path_segment.first_seen_at = Time.current + end + end + + def increment_usage! + increment!(:usage_count) + end +end +``` + +**Event** - Stores segment IDs as JSON array: +```ruby +class Event < ApplicationRecord + serialize :request_segment_ids, type: Array, coder: JSON + + # Path reconstruction helper + def reconstructed_path + return request_path if request_segment_ids.blank? + + segments = PathSegment.where(id: request_segment_ids).index_by(&:id) + '/' + request_segment_ids.map { |id| segments[id]&.segment }.compact.join('/') + end + + def path_depth + request_segment_ids&.length || 0 + end +end +``` + +## The Indexing Strategy + +### Why Standard LIKE Doesn't Work + +SQLite's B-tree indexes only work with LIKE when the pattern is a simple alphanumeric prefix: + +```sql +-- ✅ Uses index (alphanumeric prefix) +WHERE column LIKE 'api%' + +-- ❌ Full table scan (starts with '[') +WHERE request_segment_ids LIKE '[1,2,%' +``` + +### The Solution: Range Queries on Lexicographic Sort + +JSON arrays sort lexicographically in SQLite: + +``` +"[1,2]" (exact match) +"[1,2,3]" (prefix match - has [1,2] as start) +"[1,2,4]" (prefix match - has [1,2] as start) +"[1,2,99]" (prefix match - has [1,2] as start) +"[1,3]" (out of range - different prefix) +``` + +To find all paths starting with `[1,2]`: +```sql +-- Exact match OR prefix range +WHERE request_segment_ids = '[1,2]' + OR (request_segment_ids >= '[1,2,' AND request_segment_ids < '[1,3]') +``` + +The range `>= '[1,2,' AND < '[1,3]'` captures all arrays starting with `[1,2,...]`. + +### Query Performance + +``` +EXPLAIN QUERY PLAN: + MULTI-INDEX OR + ├─ INDEX 1: SEARCH events USING INDEX index_events_on_request_segment_ids (request_segment_ids=?) + └─ INDEX 2: SEARCH events USING INDEX index_events_on_request_segment_ids (request_segment_ids>? AND request_segment_ids(prefix_segment_ids) { + return none if prefix_segment_ids.blank? + + # Convert [1, 2] to JSON string "[1,2]" + prefix_str = prefix_segment_ids.to_json + + # Build upper bound by incrementing last segment + # [1, 2] + 1 = [1, 3] + upper_prefix = prefix_segment_ids[0..-2] + [prefix_segment_ids.last + 1] + upper_str = upper_prefix.to_json + + # Lower bound for prefix matches: "[1,2," + lower_prefix_str = "#{prefix_str[0..-2]}," + + # Range query that uses B-tree index + where("request_segment_ids = ? OR (request_segment_ids >= ? AND request_segment_ids < ?)", + prefix_str, lower_prefix_str, upper_str) +} +``` + +## Usage Examples + +### Basic Prefix Search + +```ruby +# Find all /api/v1/* paths +api_seg = PathSegment.find_by(segment: 'api') +v1_seg = PathSegment.find_by(segment: 'v1') + +events = Event.with_path_prefix([api_seg.id, v1_seg.id]) +# Matches: /api/v1, /api/v1/users, /api/v1/users/123, etc. +``` + +### Combined with Other Filters + +```ruby +# Blocked requests to /admin/* from specific IP +admin_seg = PathSegment.find_by(segment: 'admin') + +Event.where(ip_address: '192.168.1.100') + .where(waf_action: :deny) + .with_path_prefix([admin_seg.id]) +``` + +### Using Composite Index + +```ruby +# POST requests to /api/* on specific host +# Uses: idx_events_host_method_path +host = RequestHost.find_by(hostname: 'api.example.com') +api_seg = PathSegment.find_by(segment: 'api') + +Event.where(request_host_id: host.id, request_method: :post) + .with_path_prefix([api_seg.id]) +``` + +### Exact Path Match + +```ruby +# Find exact path /api/v1 (not /api/v1/users) +api_seg = PathSegment.find_by(segment: 'api') +v1_seg = PathSegment.find_by(segment: 'v1') + +Event.where(request_segment_ids: [api_seg.id, v1_seg.id].to_json) +``` + +### Path Reconstruction for Display + +```ruby +events = Event.with_path_prefix([api_seg.id]).limit(10) + +events.each do |event| + puts "#{event.reconstructed_path} - #{event.waf_action}" + # => /api/v1/users - allow + # => /api/v1/posts - deny +end +``` + +## Performance Characteristics + +| Operation | Index Used | Complexity | Notes | +|-----------|-----------|------------|-------| +| Exact path match | ✅ B-tree | O(log n) | Single index lookup | +| Prefix path match | ✅ B-tree range | O(log n + k) | k = number of matches | +| Path depth filter | ❌ None | O(n) | Full table scan - use sparingly | +| Host+method+path | ✅ Composite | O(log n + k) | Optimal for WAF queries | + +### Indexes in Schema + +```ruby +# Single-column index for path queries +add_index :events, :request_segment_ids + +# Composite index for common WAF query patterns +add_index :events, [:request_host_id, :request_method, :request_segment_ids], + name: 'idx_events_host_method_path' +``` + +## Storage Efficiency + +### Compression Benefits + +Example: `/api/v1/users` appears in 100,000 events + +**Without normalization:** +``` +100,000 events × 15 bytes = 1,500,000 bytes (1.5 MB) +``` + +**With normalization:** +``` +3 segments × 10 bytes (avg) = 30 bytes +100,000 events × 7 bytes ("[1,2,3]") = 700,000 bytes (700 KB) +Total: 700,030 bytes (700 KB) + +Savings: 53% reduction +``` + +Plus benefits: +- **Usage tracking**: `usage_count` shows hot paths +- **Analytics**: Easy to identify common path patterns +- **Flexibility**: Can query at segment level + +## Normalization Process + +### Event Creation Flow + +```ruby +# 1. Event arrives with full path +payload = { + "request" => { "path" => "/api/v1/users/123" } +} + +# 2. Event model extracts path +event = Event.create_from_waf_payload!(event_id, payload, project) +# Sets: request_path = "/api/v1/users/123" + +# 3. After validation, EventNormalizer runs +EventNormalizer.normalize_event!(event) + +# 4. Path is decomposed into segments +segments = ["/api/v1/users/123"].split('/').reject(&:blank?) +# => ["api", "v1", "users", "123"] + +# 5. Each segment is normalized to ID +segment_ids = segments.map do |segment| + path_segment = PathSegment.find_or_create_segment(segment) + path_segment.increment_usage! unless path_segment.new_record? + path_segment.id +end +# => [1, 2, 3, 4] + +# 6. IDs stored as JSON array +event.request_segment_ids = segment_ids +# Stored in DB as: "[1,2,3,4]" +``` + +### EventNormalizer Service + +```ruby +class EventNormalizer + def normalize_path_segments + segments = @event.path_segments_array + return if segments.empty? + + segment_ids = segments.map do |segment| + path_segment = PathSegment.find_or_create_segment(segment) + path_segment.increment_usage! unless path_segment.new_record? + path_segment.id + end + + # Store as array - serialize will handle JSON encoding + @event.request_segment_ids = segment_ids + end +end +``` + +## Important: JSON Functions and Performance + +### ❌ Avoid in WHERE Clauses + +JSON functions like `json_array_length()` cannot use indexes: + +```ruby +# ❌ SLOW - Full table scan +Event.where("json_array_length(request_segment_ids) = ?", 3) + +# ✅ FAST - Filter in Ruby after indexed query +Event.with_path_prefix([api_id]).select { |e| e.path_depth == 3 } +``` + +### ✅ Use for Analytics (Async) + +JSON functions are fine for analytics queries run in background jobs: + +```ruby +# Background job for analytics +class PathDepthAnalysisJob < ApplicationJob + def perform(project_id) + # This is OK in async context + stats = Event.where(project_id: project_id) + .select("json_array_length(request_segment_ids) as depth, COUNT(*) as count") + .group("depth") + .order(:depth) + + # Store results for dashboard + PathDepthStats.create!(project_id: project_id, data: stats) + end +end +``` + +## Edge Cases and Considerations + +### Empty Paths + +```ruby +request_path = "/" +segments = [] # Empty after split and reject +request_segment_ids = [] # Empty array +# Stored as: "[]" +``` + +### Trailing Slashes + +```ruby +"/api/v1/" == "/api/v1" # Both normalize to ["api", "v1"] +``` + +### Special Characters in Segments + +```ruby +# URL-encoded segments are stored as-is +"/search?q=hello%20world" +# Segments: ["search?q=hello%20world"] +``` + +Consider normalizing query params separately if needed. + +### Very Deep Paths + +Paths with 10+ segments work fine but consider: +- Are they legitimate? (Could indicate attack) +- Impact on JSON array size +- Consider truncating for analytics + +## Analytics Use Cases + +### Most Common Paths + +```ruby +# Top 10 most accessed paths +Event.group(:request_segment_ids) + .order('COUNT(*) DESC') + .limit(10) + .count + .map { |seg_ids, count| + path = PathSegment.where(id: JSON.parse(seg_ids)) + .pluck(:segment) + .join('/') + ["/#{path}", count] + } +``` + +### Hot Path Segments + +```ruby +# Most frequently used segments (indicates common endpoints) +PathSegment.order(usage_count: :desc).limit(20) +``` + +### Attack Pattern Detection + +```ruby +# Paths with unusual depth (possible directory traversal) +Event.where(waf_action: :deny) + .select { |e| e.path_depth > 10 } + .group_by { |e| e.request_segment_ids.first } +``` + +### Path-Based Rule Generation + +```ruby +# Auto-block paths that are frequently denied +suspicious_paths = Event.where(waf_action: :deny) + .where('created_at > ?', 1.hour.ago) + .group(:request_segment_ids) + .having('COUNT(*) > ?', 100) + .pluck(:request_segment_ids) + +suspicious_paths.each do |seg_ids| + RuleSet.global.block_path_segments(seg_ids) +end +``` + +## Future Optimizations + +### Phase 2 Considerations + +If performance becomes critical: + +1. **Materialized Path Column**: Pre-compute common prefix patterns +2. **Trie Data Structure**: In-memory trie for ultra-fast prefix matching +3. **Redis Cache**: Cache hot path lookups +4. **Partial Indexes**: Index only blocked/challenged events + +```ruby +# Example: Partial index for security-relevant events +add_index :events, :request_segment_ids, + where: "waf_action IN ('deny', 'challenge')", + name: 'idx_events_blocked_paths' +``` + +### Storage Considerations + +For very large deployments (100M+ events): + +- **Archive old events**: Move to separate table +- **Aggregate path stats**: Pre-compute daily/hourly summaries +- **Compress JSON**: SQLite JSON1 extension supports compression + +## Testing + +### Test Index Usage + +```ruby +# Verify B-tree index is being used +sql = Event.with_path_prefix([1, 2]).to_sql +plan = ActiveRecord::Base.connection.execute("EXPLAIN QUERY PLAN #{sql}") + +# Should see: "SEARCH events USING INDEX index_events_on_request_segment_ids" +puts plan.to_a +``` + +### Benchmark Queries + +```ruby +require 'benchmark' + +prefix_ids = [1, 2] + +# Test indexed range query +Benchmark.bm do |x| + x.report("Indexed range:") { + Event.with_path_prefix(prefix_ids).count + } + + x.report("LIKE query:") { + Event.where("request_segment_ids LIKE ?", "[1,2,%").count + } +end + +# Range query should be 10-100x faster +``` + +## Conclusion + +Path segment normalization with JSON array storage provides: + +✅ **Significant storage savings** (50%+ compression) +✅ **Fast prefix queries** using standard B-tree indexes +✅ **Analytics-friendly** with usage tracking and pattern detection +✅ **Rails-native** using built-in serialization +✅ **Scalable** to millions of events with O(log n) lookups + +The key insight: **Range queries on lexicographically-sorted JSON strings use B-tree indexes efficiently**, avoiding the need for complex full-text search or custom indexing strategies. + +--- + +**Related Documentation:** +- [Event Ingestion](./event-ingestion.md) (TODO) +- [WAF Rule Engine](./rule-engine.md) (TODO) +- [Analytics Architecture](./analytics.md) (TODO) diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/public/400.html b/public/400.html new file mode 100644 index 0000000..640de03 --- /dev/null +++ b/public/400.html @@ -0,0 +1,135 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..d7f0f14 --- /dev/null +++ b/public/404.html @@ -0,0 +1,135 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 0000000..43d2811 --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,135 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..f12fb4a --- /dev/null +++ b/public/422.html @@ -0,0 +1,135 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..e4eb18a --- /dev/null +++ b/public/500.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..04b34bf --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/script/.keep b/script/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 0000000..cee29fd --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,5 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] +end diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/network_ranges.yml b/test/fixtures/network_ranges.yml new file mode 100644 index 0000000..bdbf89a --- /dev/null +++ b/test/fixtures/network_ranges.yml @@ -0,0 +1,37 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + ip_address: + network_prefix: 1 + ip_version: 1 + company: MyString + asn: 1 + asn_org: MyString + is_datacenter: false + is_proxy: false + is_vpn: false + ip_api_country: MyString + geo2_country: MyString + abuser_scores: MyText + additional_data: MyText + created_at: 2025-11-02 14:01:11 + updated_at: 2025-11-02 14:01:11 + last_api_fetch: 2025-11-02 14:01:11 + +two: + ip_address: + network_prefix: 1 + ip_version: 1 + company: MyString + asn: 1 + asn_org: MyString + is_datacenter: false + is_proxy: false + is_vpn: false + ip_api_country: MyString + geo2_country: MyString + abuser_scores: MyText + additional_data: MyText + created_at: 2025-11-02 14:01:11 + updated_at: 2025-11-02 14:01:11 + last_api_fetch: 2025-11-02 14:01:11 diff --git a/test/fixtures/path_segments.yml b/test/fixtures/path_segments.yml new file mode 100644 index 0000000..17bfefc --- /dev/null +++ b/test/fixtures/path_segments.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + segment: MyString + usage_count: 1 + first_seen_at: 2025-11-03 10:24:38 + +two: + segment: MyString + usage_count: 1 + first_seen_at: 2025-11-03 10:24:38 diff --git a/test/fixtures/request_actions.yml b/test/fixtures/request_actions.yml new file mode 100644 index 0000000..efc5b2f --- /dev/null +++ b/test/fixtures/request_actions.yml @@ -0,0 +1,7 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + action: MyString + +two: + action: MyString diff --git a/test/fixtures/request_hosts.yml b/test/fixtures/request_hosts.yml new file mode 100644 index 0000000..7129c20 --- /dev/null +++ b/test/fixtures/request_hosts.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + hostname: MyString + usage_count: 1 + first_seen_at: 2025-11-03 10:24:29 + +two: + hostname: MyString + usage_count: 1 + first_seen_at: 2025-11-03 10:24:29 diff --git a/test/fixtures/request_methods.yml b/test/fixtures/request_methods.yml new file mode 100644 index 0000000..0e4acc9 --- /dev/null +++ b/test/fixtures/request_methods.yml @@ -0,0 +1,7 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + method: MyString + +two: + method: MyString diff --git a/test/fixtures/request_protocols.yml b/test/fixtures/request_protocols.yml new file mode 100644 index 0000000..1ac6663 --- /dev/null +++ b/test/fixtures/request_protocols.yml @@ -0,0 +1,7 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + protocol: MyString + +two: + protocol: MyString diff --git a/test/fixtures/rule_sets.yml b/test/fixtures/rule_sets.yml new file mode 100644 index 0000000..2a6e0b9 --- /dev/null +++ b/test/fixtures/rule_sets.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + description: MyText + enabled: false + projects: + rules: + +two: + name: MyString + description: MyText + enabled: false + projects: + rules: diff --git a/test/fixtures/rules.yml b/test/fixtures/rules.yml new file mode 100644 index 0000000..7984efd --- /dev/null +++ b/test/fixtures/rules.yml @@ -0,0 +1,23 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + rule_set: one + rule_type: MyString + target: MyString + action: MyString + enabled: false + expires_at: 2025-11-02 19:10:14 + priority: 1 + conditions: + metadata: + +two: + rule_set: two + rule_type: MyString + target: MyString + action: MyString + enabled: false + expires_at: 2025-11-02 19:10:14 + priority: 1 + conditions: + metadata: diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/mailers/.keep b/test/mailers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/models/network_range_test.rb b/test/models/network_range_test.rb new file mode 100644 index 0000000..4c344c6 --- /dev/null +++ b/test/models/network_range_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class NetworkRangeTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/path_segment_test.rb b/test/models/path_segment_test.rb new file mode 100644 index 0000000..0d0b3fa --- /dev/null +++ b/test/models/path_segment_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PathSegmentTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/request_action_test.rb b/test/models/request_action_test.rb new file mode 100644 index 0000000..a825c73 --- /dev/null +++ b/test/models/request_action_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class RequestActionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/request_host_test.rb b/test/models/request_host_test.rb new file mode 100644 index 0000000..2a23afd --- /dev/null +++ b/test/models/request_host_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class RequestHostTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/request_method_test.rb b/test/models/request_method_test.rb new file mode 100644 index 0000000..0d6e846 --- /dev/null +++ b/test/models/request_method_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class RequestMethodTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/request_protocol_test.rb b/test/models/request_protocol_test.rb new file mode 100644 index 0000000..790998f --- /dev/null +++ b/test/models/request_protocol_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class RequestProtocolTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/rule_set_test.rb b/test/models/rule_set_test.rb new file mode 100644 index 0000000..616301d --- /dev/null +++ b/test/models/rule_set_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class RuleSetTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb new file mode 100644 index 0000000..ac1654f --- /dev/null +++ b/test/models/rule_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class RuleTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/system/.keep b/test/system/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..0c22470 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,15 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + + # Add more helper methods to be used by all tests here... + end +end diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 0000000..e69de29