diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0527e6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cd8d51a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Lint code for consistent style + run: bin/rubocop -f github + + test: + runs-on: ubuntu-latest + + # services: + # redis: + # image: redis + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + # - name: Install packages + # run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config + + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run tests + env: + RAILS_ENV: test + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare && bin/rails test + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/tmp/screenshots + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7787bbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/.bundle/ +/doc/ +/log/*.log +/pkg/ +/tmp/ +/test/dummy/db/*.sqlite3 +/test/dummy/db/*.sqlite3-* +/test/dummy/log/*.log +/test/dummy/storage/ +/test/dummy/tmp/ +*.gem +.byebug_history diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..268b12b --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,3 @@ +inherit_gem: + rubocop-codeur: + - default.yml diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..ae5ecdb --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.4.2 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..0d9af87 --- /dev/null +++ b/Gemfile @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +# Specify your gem's dependencies in reported.gemspec +gemspec + +gem 'puma' + +gem 'sqlite3' + +gem 'propshaft' + +# Codeur Ruby styling [https://github.com/codeur/rubocop-codeur/] +gem 'rubocop-codeur', require: false + +# Start debugger with binding.b [https://github.com/ruby/debug] +# gem "debug", ">= 1.0.0" + +gem 'webmock', group: 'test' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..42226eb --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,279 @@ +PATH + remote: . + specs: + reported (0.1.0) + rails (>= 7.1, < 8) + +GEM + remote: https://rubygems.org/ + specs: + actioncable (7.2.2.2) + actionpack (= 7.2.2.2) + activesupport (= 7.2.2.2) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (7.2.2.2) + actionpack (= 7.2.2.2) + activejob (= 7.2.2.2) + activerecord (= 7.2.2.2) + activestorage (= 7.2.2.2) + activesupport (= 7.2.2.2) + mail (>= 2.8.0) + actionmailer (7.2.2.2) + actionpack (= 7.2.2.2) + actionview (= 7.2.2.2) + activejob (= 7.2.2.2) + activesupport (= 7.2.2.2) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (7.2.2.2) + actionview (= 7.2.2.2) + activesupport (= 7.2.2.2) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4, < 3.2) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (7.2.2.2) + actionpack (= 7.2.2.2) + activerecord (= 7.2.2.2) + activestorage (= 7.2.2.2) + activesupport (= 7.2.2.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.2.2.2) + activesupport (= 7.2.2.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.2.2.2) + activesupport (= 7.2.2.2) + globalid (>= 0.3.6) + activemodel (7.2.2.2) + activesupport (= 7.2.2.2) + activerecord (7.2.2.2) + activemodel (= 7.2.2.2) + activesupport (= 7.2.2.2) + timeout (>= 0.4.0) + activestorage (7.2.2.2) + actionpack (= 7.2.2.2) + activejob (= 7.2.2.2) + activerecord (= 7.2.2.2) + activesupport (= 7.2.2.2) + marcel (~> 1.0) + activesupport (7.2.2.2) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + base64 (0.3.0) + benchmark (0.4.1) + bigdecimal (3.2.3) + builder (3.3.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + crack (1.0.0) + bigdecimal + rexml + crass (1.0.6) + date (3.4.1) + drb (2.2.3) + erb (5.0.2) + erubi (1.13.1) + globalid (1.3.0) + activesupport (>= 6.1) + hashdiff (1.2.1) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + io-console (0.8.1) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.15.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.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + mini_mime (1.1.5) + mini_portile2 (2.8.9) + minitest (5.25.5) + net-imap (0.5.11) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.4) + nokogiri (1.18.10) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + parallel (1.27.0) + parser (3.3.9.0) + ast (~> 2.4.1) + racc + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prism (1.5.1) + 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.0.4) + nio4r (~> 2.0) + racc (1.8.1) + rack (3.1.16) + 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 (7.2.2.2) + actioncable (= 7.2.2.2) + actionmailbox (= 7.2.2.2) + actionmailer (= 7.2.2.2) + actionpack (= 7.2.2.2) + actiontext (= 7.2.2.2) + actionview (= 7.2.2.2) + activejob (= 7.2.2.2) + activemodel (= 7.2.2.2) + activerecord (= 7.2.2.2) + activestorage (= 7.2.2.2) + activesupport (= 7.2.2.2) + bundler (>= 1.15.0) + railties (= 7.2.2.2) + 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 (7.2.2.2) + actionpack (= 7.2.2.2) + activesupport (= 7.2.2.2) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.0) + rdoc (6.14.2) + erb + psych (>= 4.0.0) + regexp_parser (2.11.3) + reline (0.6.2) + io-console (~> 0.5) + rexml (3.4.4) + rubocop (1.81.1) + 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-capybara (2.22.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-codeur (0.11.6) + lint_roller (~> 1.1) + rubocop (>= 1.72.0, < 2.0) + rubocop-capybara (~> 2.21) + rubocop-factory_bot (~> 2.26) + rubocop-minitest (~> 0.37) + rubocop-performance (~> 1.24) + rubocop-rails (~> 2.30) + rubocop-factory_bot (2.27.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-minitest (0.38.2) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-performance (1.26.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 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) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + sqlite3 (2.7.4) + mini_portile2 (~> 2.8.0) + stringio (3.1.7) + thor (1.4.0) + timeout (0.4.3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + useragent (0.16.11) + webmock (3.25.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.7.3) + +PLATFORMS + ruby + +DEPENDENCIES + propshaft + puma + reported! + rubocop-codeur + sqlite3 + webmock + +BUNDLED WITH + 2.7.1 diff --git a/MIT-LICENSE b/MIT-LICENSE new file mode 100644 index 0000000..6f3fdfc --- /dev/null +++ b/MIT-LICENSE @@ -0,0 +1,20 @@ +Copyright 2024 Codeur + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 284217b..7c093d4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,141 @@ -# reported -CSP reports collection for Rails apps +# Reported + +A Rails engine that collects, stores and notifies on Slack about Content Security Policy (CSP) violation reports. + +## Features + +- Public `/csp-reports` endpoint for browsers to POST CSP violations +- Stores CSP reports in a database table +- Tracks notification status with `notified_at` column +- Optional Slack notifications for CSP violations +- Easy integration with Rails applications + +## Requirements + +- Ruby >= 3.2 +- Rails >= 7.1 +- PostgreSQL (for JSONB support) + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'reported' +``` + +And then execute: + +```bash +$ bundle install +``` + +Or install it yourself as: + +```bash +$ gem install reported +``` + +## Setup + +1. Run the install generator: + +```bash +$ rails generate reported:install +``` + +This will create an initializer at `config/initializers/reported.rb`. + +2. Run the migrations: + +```bash +$ rails reported:install:migrations +$ rails db:migrate +``` + +This creates the `reported_reports` table. + +The CSP reports endpoint is automatically available at `/csp-reports` (no mounting required). + +## Configuration + +### Content Security Policy + +Configure your application's CSP to send reports to the endpoint. In `config/initializers/content_security_policy.rb`: + +```ruby +Rails.application.config.content_security_policy do |policy| + policy.default_src :self, :https + policy.script_src :self, :https + # ... your other CSP directives ... + + # Configure the report URI + policy.report_uri "/csp-reports" +end +``` + +### Slack Notifications + +To enable Slack notifications, configure the initializer at `config/initializers/reported.rb`: + +```ruby +Reported.configuration do |config| + # Enable or disable Slack notifications + config.enabled = true + + # Slack webhook URL for notifications + config.slack_webhook_url = ENV['REPORTED_SLACK_WEBHOOK_URL'] +end +``` + +Get your Slack webhook URL from [Slack API](https://api.slack.com/messaging/webhooks). + +Set the webhook URL as an environment variable: + +```bash +REPORTED_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL +``` + +## Usage + +Once configured, the gem automatically: + +1. Receives CSP violation reports at `/reported/csp-reports` +2. Stores them in the `reported_reports` table +3. Sends notifications to Slack (if enabled) +4. Marks reports as notified with the `notified_at` timestamp + +### Accessing Reports + +You can access reports through the `Reported::Report` model: + +```ruby +# Get all reports +Reported::Report.all + +# Get unnotified reports +Reported::Report.not_notified + +# Get notified reports +Reported::Report.notified + +# Mark a report as notified manually +report = Reported::Report.first +report.mark_as_notified! +``` + +## Database Schema + +The `reported_reports` table includes: + +- `document_uri` - The URI of the document where the violation occurred +- `violated_directive` - The CSP directive that was violated +- `blocked_uri` - The URI that was blocked +- `original_policy` - The complete CSP policy +- `raw_report` - The complete JSON report from the browser +- `notified_at` - Timestamp of when the report was sent to Slack +- `created_at` / `updated_at` - Standard timestamps + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..5a624a1 --- /dev/null +++ b/Rakefile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'bundler/setup' + +APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__) +load 'rails/tasks/engine.rake' + +load 'rails/tasks/statistics.rake' + +require 'bundler/gem_tasks' diff --git a/app/assets/images/reported/.keep b/app/assets/images/reported/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/stylesheets/reported/application.css b/app/assets/stylesheets/reported/application.css new file mode 100644 index 0000000..0ebd7fe --- /dev/null +++ b/app/assets/stylesheets/reported/application.css @@ -0,0 +1,15 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require_tree . + *= require_self + */ diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/reported/application_controller.rb b/app/controllers/reported/application_controller.rb new file mode 100644 index 0000000..0464be5 --- /dev/null +++ b/app/controllers/reported/application_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Reported + class ApplicationController < ActionController::Base + end +end diff --git a/app/controllers/reported/csp_reports_controller.rb b/app/controllers/reported/csp_reports_controller.rb new file mode 100644 index 0000000..672da5a --- /dev/null +++ b/app/controllers/reported/csp_reports_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Reported + class CspReportsController < ApplicationController + # Skip CSRF token verification for CSP reports + skip_before_action :verify_authenticity_token + + def create + report_data = parse_report_data + + if report_data + create_report(report_data) + head :no_content + else + head :bad_request + end + rescue StandardError => e + Rails.logger.error("Error processing CSP report: #{e.message}") + head :internal_server_error + end + + private + + def create_report(report_data) + # Extract CSP report data, supporting both old and new formats + csp_data = extract_csp_data(report_data) + + report = Report.create!( + document_uri: csp_data[:document_uri], + violated_directive: csp_data[:violated_directive], + blocked_uri: csp_data[:blocked_uri], + original_policy: csp_data[:original_policy], + raw_report: report_data + ) + + # Send notification if enabled + NotificationJob.perform_later(report.id) if Reported.enabled + end + + def parse_report_data + body = request.body.read + return nil if body.blank? + + JSON.parse(body) + rescue JSON::ParserError => e + Rails.logger.error("Error parsing CSP report JSON: #{e.message}") + nil + end + + def extract_csp_data(report_data) + # Support both old format (csp-report) and new format (direct fields) + extract_properties(report_data['csp-report'] || report_data) + end + + def extract_properties(csp_report) + # Old format: {"csp-report": {...}} + { + document_uri: csp_report['document-uri'] || csp_report['documentURI'], + violated_directive: csp_report['violated-directive'] || csp_report['violatedDirective'] || + csp_report['effective-directive'] || csp_report['effectiveDirective'], + blocked_uri: csp_report['blocked-uri'] || csp_report['blockedURI'], + original_policy: csp_report['original-policy'] || csp_report['originalPolicy'] + } + end + end +end diff --git a/app/helpers/reported/application_helper.rb b/app/helpers/reported/application_helper.rb new file mode 100644 index 0000000..36d40d7 --- /dev/null +++ b/app/helpers/reported/application_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Reported + module ApplicationHelper + end +end diff --git a/app/jobs/reported/application_job.rb b/app/jobs/reported/application_job.rb new file mode 100644 index 0000000..ba4ef98 --- /dev/null +++ b/app/jobs/reported/application_job.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Reported + class ApplicationJob < ActiveJob::Base + end +end diff --git a/app/jobs/reported/notification_job.rb b/app/jobs/reported/notification_job.rb new file mode 100644 index 0000000..805f6f0 --- /dev/null +++ b/app/jobs/reported/notification_job.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' +require 'json' + +module Reported + class NotificationJob < ApplicationJob + queue_as :default + + def perform(report_id) + report = Report.find_by(id: report_id) + return unless report + return if report.notified? + return if Reported.slack_webhook_url.blank? + + send_slack_notification(report) + report.mark_as_notified! + rescue StandardError => e + Rails.logger.error("Error sending Slack notification for report #{report_id}: #{e.message}") + raise + end + + private + + def send_slack_notification(report) + uri = URI.parse(Reported.slack_webhook_url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true if uri.scheme == 'https' + + request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + request.body = notification_payload(report).to_json + + response = http.request(request) + + return if response.code.to_i == 200 + + raise "Slack API returned #{response.code}: #{response.body}" + end + + def notification_payload(report) + { + text: 'CSP Violation Report', + attachments: [ + { + color: 'danger', + fields: [ + { + title: 'Document URI', + value: report.document_uri || 'N/A', + short: false + }, + { + title: 'Violated Directive', + value: report.violated_directive || 'N/A', + short: true + }, + { + title: 'Blocked URI', + value: report.blocked_uri || 'N/A', + short: true + }, + { + title: 'Reported At', + value: report.created_at.to_s, + short: true + } + ] + } + ] + } + end + end +end diff --git a/app/mailers/reported/application_mailer.rb b/app/mailers/reported/application_mailer.rb new file mode 100644 index 0000000..08f301e --- /dev/null +++ b/app/mailers/reported/application_mailer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Reported + class ApplicationMailer < ActionMailer::Base + default from: 'from@example.com' + layout 'mailer' + end +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/models/reported/application_record.rb b/app/models/reported/application_record.rb new file mode 100644 index 0000000..3536510 --- /dev/null +++ b/app/models/reported/application_record.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Reported + class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true + end +end diff --git a/app/models/reported/report.rb b/app/models/reported/report.rb new file mode 100644 index 0000000..920b78a --- /dev/null +++ b/app/models/reported/report.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Reported + class Report < ApplicationRecord + scope :not_notified, -> { where(notified_at: nil) } + scope :notified, -> { where.not(notified_at: nil) } + + validates :raw_report, presence: true + + def mark_as_notified! + update!(notified_at: Time.current) + end + + def notified? + notified_at.present? + end + end +end diff --git a/app/views/layouts/reported/application.html.erb b/app/views/layouts/reported/application.html.erb new file mode 100644 index 0000000..790462b --- /dev/null +++ b/app/views/layouts/reported/application.html.erb @@ -0,0 +1,17 @@ + + + + Reported + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%= stylesheet_link_tag "reported/application", media: "all" %> + + + +<%= yield %> + + + diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..3e7e6f1 --- /dev/null +++ b/bin/rails @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails gems +# installed from the root of your application. + +ENGINE_ROOT = File.expand_path("..", __dir__) +ENGINE_PATH = File.expand_path("../lib/reported/engine", __dir__) +APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) + +# Set up gems listed in the Gemfile. +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) +require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) + +require "rails/all" +require "rails/engine/commands" diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 0000000..40330c0 --- /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/db/migrate/20240101000000_create_reported_reports.rb b/db/migrate/20240101000000_create_reported_reports.rb new file mode 100644 index 0000000..1aaf990 --- /dev/null +++ b/db/migrate/20240101000000_create_reported_reports.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CreateReportedReports < ActiveRecord::Migration[7.1] + def change + create_table :reported_reports do |t| + t.string :document_uri + t.string :violated_directive + t.string :blocked_uri + t.text :original_policy + if connection.adapter_name.downcase.include?('postgresql') + t.jsonb :raw_report, null: false, default: {} + else + t.json :raw_report, null: false, default: {} + end + t.datetime :notified_at + + t.timestamps + end + + add_index :reported_reports, :notified_at + add_index :reported_reports, :created_at + end +end diff --git a/lib/generators/reported/install/README b/lib/generators/reported/install/README new file mode 100644 index 0000000..a7a5f6a --- /dev/null +++ b/lib/generators/reported/install/README @@ -0,0 +1,29 @@ +=============================================================================== + +Reported has been installed! + +Next steps: + +1. Run the migration to create the reports table: + + rails reported:install:migrations + rails db:migrate + +2. The CSP reports endpoint is automatically available at /csp-reports + +3. Configure your Content Security Policy to send reports to this endpoint: + + In your config/initializers/content_security_policy.rb: + + Rails.application.config.content_security_policy do |policy| + # ... your CSP directives ... + policy.report_uri "/csp-reports" + end + +4. Configure Slack notifications in config/initializers/reported.rb + +5. Set your Slack webhook URL as an environment variable: + + REPORTED_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL + +=============================================================================== diff --git a/lib/generators/reported/install/install_generator.rb b/lib/generators/reported/install/install_generator.rb new file mode 100644 index 0000000..b022460 --- /dev/null +++ b/lib/generators/reported/install/install_generator.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails/generators' + +module Reported + module Generators + class InstallGenerator < Rails::Generators::Base + source_root File.expand_path('templates', __dir__) + + desc 'Creates Reported initializer for your application' + + def copy_initializer + template 'reported.rb', 'config/initializers/reported.rb' + + Rails.logger.debug 'Reported initializer created at config/initializers/reported.rb' + end + + def show_readme + readme 'README' if behavior == :invoke + end + end + end +end diff --git a/lib/generators/reported/install/templates/reported.rb b/lib/generators/reported/install/templates/reported.rb new file mode 100644 index 0000000..07c78af --- /dev/null +++ b/lib/generators/reported/install/templates/reported.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +Reported.configuration do |config| + # Enable or disable Slack notifications + config.enabled = true + + # Slack webhook URL for notifications + # Get your webhook URL from https://api.slack.com/messaging/webhooks + config.slack_webhook_url = ENV.fetch('REPORTED_SLACK_WEBHOOK_URL', nil) +end diff --git a/lib/reported.rb b/lib/reported.rb new file mode 100644 index 0000000..14a514a --- /dev/null +++ b/lib/reported.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'reported/version' +require 'reported/engine' + +module Reported + mattr_accessor :slack_webhook_url + mattr_accessor :enabled + + def self.configuration + yield self if block_given? + end +end diff --git a/lib/reported/engine.rb b/lib/reported/engine.rb new file mode 100644 index 0000000..9a543d0 --- /dev/null +++ b/lib/reported/engine.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Reported + class Engine < ::Rails::Engine + isolate_namespace Reported + + config.generators do |g| + g.test_framework :test_unit + g.fixture_replacement :factory_bot, dir: 'spec/factories' + end + + # Automatically add routes to the main application + initializer 'reported.add_routes' do |app| + app.routes.prepend do + post '/csp-reports', to: 'reported/csp_reports#create' + end + end + end +end diff --git a/lib/reported/version.rb b/lib/reported/version.rb new file mode 100644 index 0000000..83e5676 --- /dev/null +++ b/lib/reported/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Reported + VERSION = '0.1.0' +end diff --git a/lib/tasks/reported_tasks.rake b/lib/tasks/reported_tasks.rake new file mode 100644 index 0000000..ebf2ffa --- /dev/null +++ b/lib/tasks/reported_tasks.rake @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# desc "Explaining what the task does" +# task :reported do +# # Task goes here +# end diff --git a/reported.gemspec b/reported.gemspec new file mode 100644 index 0000000..008746b --- /dev/null +++ b/reported.gemspec @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative 'lib/reported/version' + +Gem::Specification.new do |spec| + spec.name = 'reported' + spec.version = Reported::VERSION + spec.authors = ['Brice TEXIER'] + spec.email = ['brice@codeur.com'] + spec.homepage = 'https://github.com/codeur/reported' + spec.summary = 'CSP reports collection for Rails apps' + spec.description = 'A Rails engine that collects, stores and notifies on Slack about CSP violation reports' + spec.license = 'MIT' + + # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host" + # to allow pushing to a single host or delete this section to allow pushing to any host. + # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = spec.homepage + spec.metadata['changelog_uri'] = 'https://github.com/codeur/reported/blob/main/CHANGELOG.md' + spec.metadata['rubygems_mfa_required'] = 'false' + + spec.files = Dir.chdir(File.expand_path(__dir__)) do + Dir['{app,config,db,lib}/**/*', 'MIT-LICENSE', 'Rakefile', 'README.md'] + end + + spec.required_ruby_version = '>= 3.4' + + spec.add_dependency 'rails', '>= 7.1', '< 8' +end diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/controllers/csp_reports_controller_test.rb b/test/controllers/csp_reports_controller_test.rb new file mode 100644 index 0000000..aedd3e4 --- /dev/null +++ b/test/controllers/csp_reports_controller_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Reported + class CspReportsControllerTest < ActionDispatch::IntegrationTest + setup do + @valid_csp_report_old_format = { + 'csp-report' => { + 'document-uri' => 'https://example.com/page', + 'violated-directive' => "script-src 'self'", + 'blocked-uri' => 'https://evil.com/script.js', + 'original-policy' => "default-src 'self'; script-src 'self'" + } + } + + @valid_csp_report_new_format = { + 'documentURI' => 'https://example.com/page', + 'violatedDirective' => "script-src 'self'", + 'blockedURI' => 'https://evil.com/script.js', + 'originalPolicy' => "default-src 'self'; script-src 'self'" + } + end + + test 'creates report with valid CSP data (old format)' do + assert_difference 'Report.count', 1 do + post '/csp-reports', + params: @valid_csp_report_old_format.to_json, + headers: { 'CONTENT_TYPE' => 'application/json' } + end + + assert_response :no_content + + report = Report.last + assert_equal 'https://example.com/page', report.document_uri + assert_equal "script-src 'self'", report.violated_directive + assert_equal 'https://evil.com/script.js', report.blocked_uri + end + + test 'creates report with valid CSP data (new format)' do + assert_difference 'Report.count', 1 do + post '/csp-reports', + params: @valid_csp_report_new_format.to_json, + headers: { 'CONTENT_TYPE' => 'application/json' } + end + + assert_response :no_content + + report = Report.last + assert_equal 'https://example.com/page', report.document_uri + assert_equal "script-src 'self'", report.violated_directive + assert_equal 'https://evil.com/script.js', report.blocked_uri + end + + test 'returns bad_request with invalid JSON' do + assert_no_difference 'Report.count' do + post '/csp-reports', + params: 'invalid json{', + headers: { 'CONTENT_TYPE' => 'application/json' } + end + + assert_response :bad_request + end + + test 'returns bad_request with empty body' do + assert_no_difference 'Report.count' do + post '/csp-reports', + params: '', + headers: { 'CONTENT_TYPE' => 'application/json' } + end + + assert_response :bad_request + end + + test 'stores raw_report as JSONB' do + post '/csp-reports', + params: @valid_csp_report_old_format.to_json, + headers: { 'CONTENT_TYPE' => 'application/json' } + + report = Report.last + # With JSONB, raw_report is stored as a hash directly + assert_equal @valid_csp_report_old_format.deep_stringify_keys, report.raw_report + end + + test 'does not require CSRF token' do + # This test verifies that external browsers can POST without CSRF token + post '/csp-reports', + params: @valid_csp_report_old_format.to_json, + headers: { 'CONTENT_TYPE' => 'application/json' } + + assert_response :no_content + end + end +end diff --git a/test/dummy/Rakefile b/test/dummy/Rakefile new file mode 100644 index 0000000..488c551 --- /dev/null +++ b/test/dummy/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# 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/test/dummy/app/assets/images/.keep b/test/dummy/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/dummy/app/assets/stylesheets/application.css b/test/dummy/app/assets/stylesheets/application.css new file mode 100644 index 0000000..0ebd7fe --- /dev/null +++ b/test/dummy/app/assets/stylesheets/application.css @@ -0,0 +1,15 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require_tree . + *= require_self + */ diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb new file mode 100644 index 0000000..9c1acb3 --- /dev/null +++ b/test/dummy/app/controllers/application_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +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 +end diff --git a/test/dummy/app/controllers/concerns/.keep b/test/dummy/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/dummy/app/helpers/application_helper.rb b/test/dummy/app/helpers/application_helper.rb new file mode 100644 index 0000000..15b06f0 --- /dev/null +++ b/test/dummy/app/helpers/application_helper.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +module ApplicationHelper +end diff --git a/test/dummy/app/jobs/application_job.rb b/test/dummy/app/jobs/application_job.rb new file mode 100644 index 0000000..bef3959 --- /dev/null +++ b/test/dummy/app/jobs/application_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +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/test/dummy/app/mailers/application_mailer.rb b/test/dummy/app/mailers/application_mailer.rb new file mode 100644 index 0000000..d84cb6e --- /dev/null +++ b/test/dummy/app/mailers/application_mailer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ApplicationMailer < ActionMailer::Base + default from: 'from@example.com' + layout 'mailer' +end diff --git a/test/dummy/app/models/application_record.rb b/test/dummy/app/models/application_record.rb new file mode 100644 index 0000000..08dc537 --- /dev/null +++ b/test/dummy/app/models/application_record.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/test/dummy/app/models/concerns/.keep b/test/dummy/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/dummy/app/views/layouts/application.html.erb b/test/dummy/app/views/layouts/application.html.erb new file mode 100644 index 0000000..f25ae92 --- /dev/null +++ b/test/dummy/app/views/layouts/application.html.erb @@ -0,0 +1,27 @@ + + + + <%= content_for(:title) || "Dummy" %> + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app %> + + + + <%= yield %> + + diff --git a/test/dummy/app/views/layouts/mailer.html.erb b/test/dummy/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..3aac900 --- /dev/null +++ b/test/dummy/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/test/dummy/app/views/layouts/mailer.text.erb b/test/dummy/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..37f0bdd --- /dev/null +++ b/test/dummy/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/test/dummy/app/views/pwa/manifest.json.erb b/test/dummy/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..a3c046e --- /dev/null +++ b/test/dummy/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "Dummy", + "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": "Dummy.", + "theme_color": "red", + "background_color": "red" +} diff --git a/test/dummy/app/views/pwa/service-worker.js b/test/dummy/app/views/pwa/service-worker.js new file mode 100644 index 0000000..b3a13fb --- /dev/null +++ b/test/dummy/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/test/dummy/bin/dev b/test/dummy/bin/dev new file mode 100755 index 0000000..3925783 --- /dev/null +++ b/test/dummy/bin/dev @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +exec './bin/rails', 'server', *ARGV diff --git a/test/dummy/bin/rails b/test/dummy/bin/rails new file mode 100755 index 0000000..a31728a --- /dev/null +++ b/test/dummy/bin/rails @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +APP_PATH = File.expand_path('../config/application', __dir__) +require_relative '../config/boot' +require 'rails/commands' diff --git a/test/dummy/bin/rake b/test/dummy/bin/rake new file mode 100755 index 0000000..c199955 --- /dev/null +++ b/test/dummy/bin/rake @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../config/boot' +require 'rake' +Rake.application.run diff --git a/test/dummy/bin/setup b/test/dummy/bin/setup new file mode 100755 index 0000000..5e6d2fe --- /dev/null +++ b/test/dummy/bin/setup @@ -0,0 +1,36 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'fileutils' + +APP_ROOT = File.expand_path('..', __dir__) + +def system!(*) + system(*, 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' + + 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/test/dummy/config.ru b/test/dummy/config.ru new file mode 100644 index 0000000..6dc8321 --- /dev/null +++ b/test/dummy/config.ru @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# 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/test/dummy/config/application.rb b/test/dummy/config/application.rb new file mode 100644 index 0000000..8dbd1cd --- /dev/null +++ b/test/dummy/config/application.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +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) + +module Dummy + class Application < Rails::Application + config.load_defaults Rails::VERSION::STRING.to_f + + # For compatibility with applications that use this config + config.action_controller.include_all_helpers = false + + # 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]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/test/dummy/config/boot.rb b/test/dummy/config/boot.rb new file mode 100644 index 0000000..6d2cba0 --- /dev/null +++ b/test/dummy/config/boot.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Set up gems listed in the Gemfile. +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) + +require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) +$LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) diff --git a/test/dummy/config/cable.yml b/test/dummy/config/cable.yml new file mode 100644 index 0000000..98367f8 --- /dev/null +++ b/test/dummy/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: dummy_production diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml new file mode 100644 index 0000000..01bebb5 --- /dev/null +++ b/test/dummy/config/database.yml @@ -0,0 +1,32 @@ +# 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 + pool: <%= 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 + + +# SQLite3 write its data on the local filesystem, as such it requires +# persistent disks. If you are deploying to a managed service, you should +# make sure it provides disk persistence, as many don't. +# +# Similarly, if you deploy your application as a Docker container, you must +# ensure the database is located in a persisted volume. +production: + <<: *default + # database: path/to/persistent/storage/production.sqlite3 diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb new file mode 100644 index 0000000..d5abe55 --- /dev/null +++ b/test/dummy/config/environment.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Load the Rails application. +require_relative 'application' + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/test/dummy/config/environments/development.rb b/test/dummy/config/environments/development.rb new file mode 100644 index 0000000..2270212 --- /dev/null +++ b/test/dummy/config/environments/development.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +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', '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 + + # 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 +end diff --git a/test/dummy/config/environments/production.rb b/test/dummy/config/environments/production.rb new file mode 100644 index 0000000..0d647bb --- /dev/null +++ b/test/dummy/config/environments/production.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +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 = :mem_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + # config.active_job.queue_adapter = :resque + + # 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 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/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb new file mode 100644 index 0000000..37fc1f7 --- /dev/null +++ b/test/dummy/config/environments/test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# 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/test/dummy/config/initializers/assets.rb b/test/dummy/config/initializers/assets.rb new file mode 100644 index 0000000..019d0bb --- /dev/null +++ b/test/dummy/config/initializers/assets.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# 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/test/dummy/config/initializers/content_security_policy.rb b/test/dummy/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..35ab3fd --- /dev/null +++ b/test/dummy/config/initializers/content_security_policy.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# 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) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/test/dummy/config/initializers/filter_parameter_logging.rb b/test/dummy/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..d81a902 --- /dev/null +++ b/test/dummy/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# 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 += %i[ + passw email secret token _key crypt salt certificate otp ssn cvv cvc +] diff --git a/test/dummy/config/initializers/inflections.rb b/test/dummy/config/initializers/inflections.rb new file mode 100644 index 0000000..9e049dc --- /dev/null +++ b/test/dummy/config/initializers/inflections.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# 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/test/dummy/config/locales/en.yml b/test/dummy/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/test/dummy/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/test/dummy/config/puma.rb b/test/dummy/config/puma.rb new file mode 100644 index 0000000..f4aa1fe --- /dev/null +++ b/test/dummy/config/puma.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# 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. +# +# 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 + +# 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/test/dummy/config/routes.rb b/test/dummy/config/routes.rb new file mode 100644 index 0000000..f9adc06 --- /dev/null +++ b/test/dummy/config/routes.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + mount Reported::Engine => '/reported' +end diff --git a/test/dummy/config/storage.yml b/test/dummy/config/storage.yml new file mode 100644 index 0000000..4942ab6 --- /dev/null +++ b/test/dummy/config/storage.yml @@ -0,0 +1,34 @@ +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 %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb new file mode 100644 index 0000000..f19c3c7 --- /dev/null +++ b/test/dummy/db/schema.rb @@ -0,0 +1,26 @@ +# 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[7.2].define(version: 2024_01_01_000000) do + create_table "reported_reports", force: :cascade do |t| + t.string "document_uri" + t.string "violated_directive" + t.string "blocked_uri" + t.text "original_policy" + t.json "raw_report", default: {}, null: false + t.datetime "notified_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at"], name: "index_reported_reports_on_created_at" + t.index ["notified_at"], name: "index_reported_reports_on_notified_at" + end +end diff --git a/test/dummy/log/.keep b/test/dummy/log/.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/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/integration/navigation_test.rb b/test/integration/navigation_test.rb new file mode 100644 index 0000000..91d920f --- /dev/null +++ b/test/integration/navigation_test.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'test_helper' + +class NavigationTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/jobs/notification_job_test.rb b/test/jobs/notification_job_test.rb new file mode 100644 index 0000000..e92224b --- /dev/null +++ b/test/jobs/notification_job_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Reported + class NotificationJobTest < ActiveJob::TestCase + setup do + @report = Report.create!( + document_uri: 'https://example.com/page', + violated_directive: "script-src 'self'", + blocked_uri: 'https://evil.com/script.js', + raw_report: { 'csp-report' => {} } + ) + Reported.slack_webhook_url = 'https://hooks.slack.com/services/TEST/WEBHOOK/URL' + end + + test 'sends notification to Slack' do + stub_request(:post, Reported.slack_webhook_url) + .to_return(status: 200, body: 'ok') + + NotificationJob.perform_now(@report.id) + + assert_requested :post, Reported.slack_webhook_url + end + + test 'marks report as notified after successful notification' do + stub_request(:post, Reported.slack_webhook_url) + .to_return(status: 200, body: 'ok') + + assert_nil @report.notified_at + + NotificationJob.perform_now(@report.id) + + @report.reload + assert_not_nil @report.notified_at + end + + test 'does not send notification if report already notified' do + @report.mark_as_notified! + + stub_request(:post, Reported.slack_webhook_url) + .to_return(status: 200, body: 'ok') + + NotificationJob.perform_now(@report.id) + + assert_not_requested :post, Reported.slack_webhook_url + end + + test 'does not send notification if webhook URL is not configured' do + Reported.slack_webhook_url = nil + + stub_request(:post, 'https://hooks.slack.com/services/TEST/WEBHOOK/URL') + .to_return(status: 200, body: 'ok') + + NotificationJob.perform_now(@report.id) + + assert_not_requested :post, 'https://hooks.slack.com/services/TEST/WEBHOOK/URL' + end + + test 'includes CSP violation details in Slack message' do + stub_request(:post, Reported.slack_webhook_url) + .with do |request| + body = JSON.parse(request.body) + body['text'] == 'CSP Violation Report' && + body['attachments'].any? do |a| + a['fields'].any? { |f| f['title'] == 'Document URI' && f['value'] == @report.document_uri } + end + end + .to_return(status: 200, body: 'ok') + + NotificationJob.perform_now(@report.id) + + assert_requested :post, Reported.slack_webhook_url + end + end +end 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/report_test.rb b/test/models/report_test.rb new file mode 100644 index 0000000..3c55439 --- /dev/null +++ b/test/models/report_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Reported + class ReportTest < ActiveSupport::TestCase + test 'requires raw_report' do + report = Report.new + assert_not report.valid? + assert_includes report.errors[:raw_report], "can't be blank" + end + + test 'can create report with raw_report' do + report = Report.new(raw_report: { 'csp-report' => {} }) + assert report.valid? + end + + test 'not_notified scope returns reports without notified_at' do + report1 = Report.create!(raw_report: { 'test' => 1 }) + report2 = Report.create!(raw_report: { 'test' => 2 }, notified_at: Time.current) + + assert_includes Report.not_notified, report1 + assert_not_includes Report.not_notified, report2 + end + + test 'notified scope returns reports with notified_at' do + report1 = Report.create!(raw_report: { 'test' => 1 }) + report2 = Report.create!(raw_report: { 'test' => 2 }, notified_at: Time.current) + + assert_not_includes Report.notified, report1 + assert_includes Report.notified, report2 + end + + test 'mark_as_notified! sets notified_at' do + report = Report.create!(raw_report: { 'test' => 1 }) + assert_nil report.notified_at + + report.mark_as_notified! + assert_not_nil report.notified_at + end + + test 'notified? returns true when notified_at is set' do + report = Report.create!(raw_report: { 'test' => 1 }) + assert_not report.notified? + + report.update!(notified_at: Time.current) + assert report.notified? + end + end +end diff --git a/test/reported_test.rb b/test/reported_test.rb new file mode 100644 index 0000000..4110a46 --- /dev/null +++ b/test/reported_test.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ReportedTest < ActiveSupport::TestCase + test 'it has a version number' do + assert Reported::VERSION + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..a6f2843 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Configure Rails Environment +ENV['RAILS_ENV'] = 'test' + +require_relative '../test/dummy/config/environment' +ActiveRecord::Migrator.migrations_paths = [File.expand_path('../test/dummy/db/migrate', __dir__)] +ActiveRecord::Migrator.migrations_paths << File.expand_path('../db/migrate', __dir__) +require 'rails/test_help' + +# Require webmock for testing HTTP requests +begin + require 'webmock/minitest' + WebMock.disable_net_connect!(allow_localhost: true) +rescue LoadError + # webmock not available +end + +# Load support files +Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } + +# Load fixtures from the engine +if ActiveSupport::TestCase.respond_to?(:fixture_paths=) + ActiveSupport::TestCase.fixture_paths = [File.expand_path('fixtures', __dir__)] + ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths + ActiveSupport::TestCase.file_fixture_path = "#{File.expand_path('fixtures', __dir__)}/files" + ActiveSupport::TestCase.fixtures :all +end