-
Notifications
You must be signed in to change notification settings - Fork 0
Implement CSP reports collection Rails engine with Slack notifications #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
976df1b
b3ac340
8e48e55
cdf1573
d82ec31
4e09c83
e6b7069
aa32cac
37d0ca1
f66f587
5f30689
83dee91
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| name: Test | ||
|
|
||
| on: | ||
| push: | ||
| branches: [ main ] | ||
| pull_request: | ||
| branches: [ main ] | ||
|
|
||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
|
|
||
| strategy: | ||
| matrix: | ||
| ruby-version: ['3.0', '3.1', '3.2'] | ||
| rails-version: ['6.1', '7.0', '7.1'] | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v3 | ||
|
|
||
| - name: Set up Ruby | ||
| uses: ruby/setup-ruby@v1 | ||
| with: | ||
| ruby-version: ${{ matrix.ruby-version }} | ||
| bundler-cache: true | ||
|
|
||
| - name: Install dependencies | ||
| run: bundle install | ||
|
|
||
| - name: Run tests | ||
| run: bundle exec rake test | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| .bundle/ | ||
| log/*.log | ||
| pkg/ | ||
| test/dummy/db/*.sqlite3 | ||
| test/dummy/db/*.sqlite3-journal | ||
| test/dummy/log/*.log | ||
| test/dummy/storage/ | ||
| test/dummy/tmp/ | ||
| *.gem | ||
| .byebug_history | ||
| Gemfile.lock |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| source "https://rubygems.org" | ||
|
|
||
| # Specify your gem's dependencies in reported.gemspec | ||
| gemspec | ||
|
|
||
| gem "sqlite3" | ||
| gem "puma" | ||
|
|
||
| group :development, :test do | ||
| gem "rails", "~> 7.0" | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,135 @@ | ||
| # 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 | ||
|
|
||
| ## 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). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| 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" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| module Reported | ||
| class CspReportsController < ActionController::Base | ||
| # Skip CSRF token verification for CSP reports | ||
| skip_before_action :verify_authenticity_token | ||
|
|
||
| def create | ||
| report_data = parse_report_data | ||
|
|
||
| if 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.to_json | ||
| ) | ||
|
|
||
| # Send notification if enabled | ||
| NotificationJob.perform_later(report.id) if Reported.enabled | ||
|
|
||
| head :no_content | ||
| else | ||
| head :bad_request | ||
| end | ||
| rescue => e | ||
| Rails.logger.error("Error processing CSP report: #{e.message}") | ||
| head :internal_server_error | ||
| end | ||
|
|
||
| private | ||
|
|
||
| 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) | ||
| if report_data['csp-report'] | ||
| # Old format: {"csp-report": {...}} | ||
| csp_report = report_data['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'] | ||
| } | ||
| else | ||
| # New format: direct fields or camelCase | ||
| { | ||
| document_uri: report_data['document-uri'] || report_data['documentURI'] || report_data['document_uri'], | ||
| violated_directive: report_data['violated-directive'] || report_data['violatedDirective'] || report_data['effective-directive'] || report_data['effectiveDirective'] || report_data['violated_directive'], | ||
| blocked_uri: report_data['blocked-uri'] || report_data['blockedURI'] || report_data['blocked_uri'], | ||
| original_policy: report_data['original-policy'] || report_data['originalPolicy'] || report_data['original_policy'] | ||
| } | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| require 'net/http' | ||
| require 'uri' | ||
| require 'json' | ||
|
|
||
| module Reported | ||
| class NotificationJob < ActiveJob::Base | ||
| queue_as :default | ||
|
|
||
| def perform(report_id) | ||
| report = Report.find_by(id: report_id) | ||
| return unless report | ||
| return if report.notified? | ||
| return unless Reported.slack_webhook_url.present? | ||
|
|
||
| send_slack_notification(report) | ||
| report.mark_as_notified! | ||
| rescue => 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) | ||
|
|
||
| unless response.code.to_i == 200 | ||
| raise "Slack API returned #{response.code}: #{response.body}" | ||
| end | ||
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| module Reported | ||
| class Report < ApplicationRecord | ||
| validates :raw_report, presence: true | ||
|
|
||
| scope :not_notified, -> { where(notified_at: nil) } | ||
| scope :notified, -> { where.not(notified_at: nil) } | ||
|
|
||
| def mark_as_notified! | ||
| update!(notified_at: Time.current) | ||
| end | ||
|
|
||
| def notified? | ||
| notified_at.present? | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| class CreateReportedReports < ActiveRecord::Migration[6.0] | ||
| def change | ||
| create_table :reported_reports do |t| | ||
| t.string :document_uri | ||
| t.string :violated_directive | ||
| t.string :blocked_uri | ||
| t.text :original_policy | ||
| t.text :raw_report, null: false | ||
|
||
| t.datetime :notified_at | ||
|
|
||
| t.timestamps | ||
| end | ||
|
|
||
| add_index :reported_reports, :notified_at | ||
| add_index :reported_reports, :created_at | ||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adjust versions to support Ruby >= 3.2 and Rails >= 7.1
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated in commit d82ec31. GitHub workflow now tests Ruby 3.2-3.3 and Rails 7.1-7.2. Also updated gemspec to require Ruby >= 3.2.0 and Rails >= 7.1.