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.
| Directive | Blocked | Count | |
|---|---|---|---|
| script-src-elem | inline · (function(){var s=document.createEl…unattributed | 3 | |
| img-src | https://images.old-cdn.example | 1,208 | |
| script-src | https://widgets.example/embed.js | 412 | |
| font-src | https://fonts.example | 87 | |
| connect-src | https://api.examplereport-only | 54 |
- Received
- 1,764
- Dropped as noise
- 0
- Keys
- 5
- Emails sent
- 0
How it works
Real signal repeats. Noise is endless, but recognisable.
-
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.
-
Count, don't store
Everything else is deduped into keys with counts. Millions of reports stay thousands of rows in one SQLite file.
-
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.
-
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
- NEL, deprecation and crash reportsAny Reporting API type lands in the same table.
- Report-Only rolloutsSee what a stricter policy would break before you enforce it.
- Prometheus metricsOpt-in, for alerting on volume changes the digest ignores.
- Security modelHow a public, unauthenticated endpoint treats every byte as hostile.