Blotter

A quiet log of your site's CSP violations.

Blotter is a small self-hosted collector for CSP reports, and any other Reporting API type. It drops the noise it recognises, counts everything else, and emails you only when something new appears.

Quickstart Source on GitHub

DirectiveBlockedCount
script-src-eleminline · (function(){var s=document.createEl…unattributed3
img-srchttps://images.old-cdn.example1,208
script-srchttps://widgets.example/embed.js412
font-srchttps://fonts.example87
connect-srchttps://api.examplereport-only54
Received
1,764
Dropped as noise
0
Keys
5
Emails sent
0
A simulation of the idea, not live data. Extensions and scanners are dropped at the door, repeats only bump a count, and a new key is the one thing that earns an email.

How it works

Real signal repeats. Noise is endless, but recognisable.

  1. Filter at the door

    Extension schemes, known injector hosts, reports about documents that aren't yours, and directives your policy never emits are dropped at ingest. Every rule keeps a drop counter, so you can see what it eats.

  2. Count, don't store

    Everything else is deduped into keys with counts. Millions of reports stay thousands of rows in one SQLite file.

  3. Keep what looks like an attack

    Inline or eval violations with no source file are exactly what an injected script looks like. They are never dropped: they get their own bucket, deduped per script sample, and go first in the digest.

  4. Email on new, and only on new

    A daily plain-text digest goes out when a key appears that you haven't seen before. Otherwise it stays silent.

Most days

No email. Nothing to do.

An illustration of a month: thirty digest runs, one email.

Ignore rules

A click can't hide an attack.

Ignore rules are exact-match only: one host, one scheme, one sample. They can only be created from a report that's already stored, never typed in, so the GUI can't produce a rule broad enough to swallow something real. The broader kinds, host suffixes and URL prefixes, are edited by hand in one YAML file. There is no regex anywhere.

Small on purpose

~1,000 lines
Tests included. One person can hold the whole thing in their head.
Plain Ruby
A Rack ingest endpoint, a Sinatra GUI, SQLite, and one YAML rules file.
Nothing external
No JS build step, no framework, no third-party requests. Fonts are self-hosted.
Private by default
Query strings and fragments are stripped, client IPs are never stored, and everything prunes after 90 days.
Flood-safe
A 256KB body cap, a per-POST report cap, a daily cap on new keys, and a hard cap on table size.
Can't hurt your site
Reports are fire-and-forget from the browser. If the collector is down, nobody notices but you.

Quickstart

Requires Ruby 3.2 or newer.

git clone https://github.com/espen/blotter.git && cd blotter
bin/setup                   # bundle install, config.yml with a fresh secret
$EDITOR config.yml
bundle exec puma -p 9292    # POST /csp is public, the GUI is at /admin

Then point your site at it:

Content-Security-Policy: …; report-uri https://csp.example.com/csp
Reporting-Endpoints: csp-endpoint="https://csp.example.com/csp"
Content-Security-Policy: …; report-to csp-endpoint

Add 'report-sample' to script-src so inline violations carry the first 40 characters of the script. Prefer containers? A Dockerfile and compose.yml are included, alongside systemd units and a Caddy example.

Also