Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
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']
Copy link
Contributor

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

Copy link
Contributor Author

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.


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
11 changes: 11 additions & 0 deletions .gitignore
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
11 changes: 11 additions & 0 deletions Gemfile
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
20 changes: 20 additions & 0 deletions MIT-LICENSE
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.
137 changes: 135 additions & 2 deletions README.md
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).
8 changes: 8 additions & 0 deletions Rakefile
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"
67 changes: 67 additions & 0 deletions app/controllers/reported/csp_reports_controller.rb
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
72 changes: 72 additions & 0 deletions app/jobs/reported/notification_job.rb
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
16 changes: 16 additions & 0 deletions app/models/reported/report.rb
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
17 changes: 17 additions & 0 deletions db/migrate/20240101000000_create_reported_reports.rb
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use JSONB instead of TEXT type for raw_report.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed in commit d82ec31. Migration now uses t.jsonb :raw_report, null: false, default: {}. Controller updated to store hash directly instead of JSON string. All tests updated accordingly.

t.datetime :notified_at

t.timestamps
end

add_index :reported_reports, :notified_at
add_index :reported_reports, :created_at
end
end
Loading
Loading