An open-source privacy scanner that evaluates websites against the Seglamater Privacy Specification (SPS) v1.0. Scores sites from 0 to 100 across six categories and assigns a letter grade.
Available as a CLI tool for one-off scans and an HTTP API server with badge generation, scheduled scanning, and pluggable storage backends.
Try it live: seglamater.app/privacy — scan any website for free, no account required.
Requires Rust 1.85+ (edition 2024).
git clone https://github.com/purpleneutral/sps.git
cd sps
cargo build --releaseThe binary is at target/release/seglamater-scan.
seglamater-scan scan example.comOutput:
Seglamater Privacy Scan — example.com
Specification: SPS v1.0
Score: 78/100 (Grade: B)
TRANSPORT SECURITY 16/20
PASS [8] TLS 1.3 supported
PASS [4] TLS 1.0/1.1 disabled
PASS [4] HSTS enabled
FAIL [0] HSTS max-age >= 1 year
...
seglamater-scan serveThe server starts on http://0.0.0.0:8080 with a SQLite database by default.
SPS evaluates 24 checks across 6 categories. Every check is binary — pass or fail. No partial credit.
| Category | Points | What It Measures |
|---|---|---|
| Transport Security | 20 | TLS 1.3, legacy protocol rejection, HSTS configuration |
| Security Headers | 20 | CSP, Referrer-Policy, Permissions-Policy, X-Content-Type-Options, X-Frame-Options |
| Tracking & Third Parties | 30 | Analytics scripts, ad trackers, fingerprinting, third-party CDNs, mixed content |
| Cookie Behavior | 15 | Third-party cookies, Secure/HttpOnly/SameSite flags, expiration |
| Email & DNS Security | 10 | SPF, DKIM, DMARC, DNSSEC, CAA records |
| Best Practices | 5 | security.txt, privacy.json, JavaScript-free accessibility |
Everything checked is publicly observable — no cooperation required from the site being scanned. The full methodology is documented in the SPS v1.0 specification.
| Grade | Score |
|---|---|
| A+ | 95-100 |
| A | 90-94 |
| B | 75-89 |
| C | 60-74 |
| D | 40-59 |
| F | 0-39 |
seglamater-scan <COMMAND>
seglamater-scan scan <DOMAIN> [OPTIONS]| Argument/Option | Description | Default |
|---|---|---|
<DOMAIN> |
Domain to scan (e.g., mozilla.org) |
Required |
--format |
Output format: text or json |
text |
Examples:
# Human-readable output
seglamater-scan scan mozilla.org
# JSON for automation
seglamater-scan scan duckduckgo.com --format jsonseglamater-scan serve [OPTIONS]| Option | Description | Default |
|---|---|---|
--host |
Address to bind to | 0.0.0.0 |
--port |
Port to listen on | 8080 |
--database-url |
Database connection string | sqlite://./scanner.db |
The --database-url can also be set via the DATABASE_URL environment variable.
Examples:
# Default (SQLite, port 8080)
seglamater-scan serve
# Custom port with PostgreSQL
seglamater-scan serve --port 3000 \
--database-url "postgres://user:pass@localhost/seglamater"
# Using environment variable
DATABASE_URL="postgres://user:pass@db:5432/scanner" seglamater-scan serveAll endpoints are available when running seglamater-scan serve.
Write endpoints (POST, PUT, DELETE) require an API key when SPS_API_KEY is set. If the variable is unset or empty, the server runs in open mode and all requests pass through.
Provide the key via either header:
X-API-Key: <your-key>
Authorization: Bearer <your-key>
Read endpoints (GET) are always public and never require authentication.
Response (401): { "error": "Unauthorized — provide a valid API key" }
All endpoints are rate-limited per client IP:
| Request type | Limit |
|---|---|
Write (POST/PUT/DELETE) |
5 requests/minute |
Read (GET) |
60 requests/minute |
When behind a reverse proxy (Traefik, Caddy, nginx), the client IP is extracted from the X-Forwarded-For header.
Response (429): { "error": "Too many requests — please try again later" }
Trigger a scan, store the result, and return it.
Request:
{ "domain": "example.com" }Response (200):
{
"domain": "example.com",
"spec_version": { "major": 1, "minor": 0 },
"scanned_at": "2026-02-25T14:30:00Z",
"categories": [ ... ],
"total_score": 78,
"grade": "B",
"recommendations": [ ... ]
}Get the latest scan result for a domain.
Response (200): Full scan result (same shape as /api/scan response).
Response (404): { "error": "No scan found for this domain" }
Get scan history for a domain, most recent first.
| Query Parameter | Description | Default |
|---|---|---|
limit |
Max records to return | 50 |
Response (200):
[
{ "id": 42, "domain": "example.com", "score": 78, "grade": "B", "scanned_at": "2026-02-25T14:30:00Z" },
{ "id": 41, "domain": "example.com", "score": 76, "grade": "B", "scanned_at": "2026-02-24T14:30:00Z" }
]List all scanned domains with their latest score.
| Query Parameter | Description | Default |
|---|---|---|
limit |
Records per page | 50 |
offset |
Pagination offset | 0 |
Register a domain for automatic scheduled re-scanning.
Request:
{ "domain": "example.com", "interval_hours": 24 }interval_hours defaults to 24 if omitted.
Search domains by prefix.
| Query Parameter | Description | Default |
|---|---|---|
q |
Search prefix | Required |
limit |
Max results | 50 |
Aggregate statistics across all scans.
Response (200):
{
"total_domains": 150,
"total_scans": 847,
"average_score": 72.5,
"grade_distribution": { "a_plus": 12, "a": 30, "b": 68, "c": 25, "d": 10, "f": 5 }
}Dynamic SVG badge (shields.io style). Returns image/svg+xml with 1-hour cache.
Returns an "unknown" badge if no scan exists for the domain.
Embed in HTML:
<a href="https://seglamater.app/privacy/scan/example.com">
<img src="https://seglamater.app/api/privacy/badge/example.com.svg" alt="SPS Score" height="20">
</a>Embed in Markdown:
[](https://seglamater.app/privacy/scan/example.com)Circular score dial SVG showing the numeric score, letter grade, and SPS branding. Returns image/svg+xml with 1-hour cache.
| Query Parameter | Description | Default |
|---|---|---|
size |
Width and height in pixels (clamped to 60-300) | 120 |
Returns a "no scan" placeholder if no scan exists for the domain.
Embed in HTML:
<a href="https://seglamater.app/privacy/scan/example.com">
<img src="https://seglamater.app/api/privacy/dial/example.com.svg" alt="SPS Score" width="120" height="120">
</a>Custom size (80px):
<a href="https://seglamater.app/privacy/scan/example.com">
<img src="https://seglamater.app/api/privacy/dial/example.com.svg?size=80" alt="SPS Score" width="80" height="80">
</a>| Check | Points | Pass Criteria |
|---|---|---|
| TLS 1.3 supported | 8 | Server negotiates TLS 1.3 |
| TLS 1.0/1.1 disabled | 4 | Server rejects legacy TLS |
| HSTS enabled | 4 | Strict-Transport-Security header present |
| HSTS max-age >= 1 year | 2 | max-age >= 31536000 |
| HSTS includeSubDomains | 1 | Directive present |
| HSTS preload | 1 | Directive present |
| Check | Points | Pass Criteria |
|---|---|---|
| Content-Security-Policy present | 6 | Header exists |
| CSP blocks unsafe-inline | 3 | No 'unsafe-inline' in script-src |
| CSP blocks unsafe-eval | 3 | No 'unsafe-eval' in script-src |
| Referrer-Policy set | 3 | Restrictive value (no-referrer, same-origin, strict-origin, strict-origin-when-cross-origin) |
| Permissions-Policy set | 3 | Restricts at least 1 sensitive API |
| X-Content-Type-Options | 1 | Set to nosniff |
| X-Frame-Options | 1 | Set to DENY or SAMEORIGIN |
| Check | Points | Pass Criteria |
|---|---|---|
| No third-party analytics | 10 | No scripts from known analytics domains |
| No advertising/tracking scripts | 10 | No resources from known tracker domains |
| No fingerprinting patterns | 5 | No Canvas, WebGL, AudioContext, or FingerprintJS signatures |
| No third-party CDNs | 3 | All resources from first-party domain |
| All resources over HTTPS | 2 | No mixed content |
| Check | Points | Pass Criteria |
|---|---|---|
| No third-party cookies | 5 | No Set-Cookie from third parties |
| Secure flag on all cookies | 3 | Every cookie has Secure |
| HttpOnly flag on all cookies | 3 | Every cookie has HttpOnly |
| SameSite on all cookies | 2 | SameSite=Strict or SameSite=Lax |
| Reasonable expiration | 2 | No cookie expires beyond 1 year |
If no cookies are set, all checks pass (ideal behavior).
| Check | Points | Pass Criteria |
|---|---|---|
| SPF record strict | 3 | v=spf1 ... -all (hard fail) |
| DKIM discoverable | 2 | DKIM TXT record found via common selectors |
| DMARC policy enforced | 3 | p=quarantine or p=reject |
| DNSSEC enabled | 1 | DNSKEY records present |
| CAA record present | 1 | At least 1 CAA record |
| Check | Points | Pass Criteria |
|---|---|---|
| security.txt present | 2 | /.well-known/security.txt returns 200 |
| privacy.json present | 2 | /.well-known/privacy.json returns valid JSON |
| Accessible without JS | 1 | HTML contains 20+ words without JavaScript |
The server supports pluggable storage backends via Cargo feature flags. Tables are created automatically on startup.
Zero-configuration file-based database. Enabled by default.
cargo build --release
seglamater-scan serve --database-url "sqlite://./scanner.db"For production deployments. Requires the postgres feature flag.
cargo build --release --features postgres
seglamater-scan serve --database-url "postgres://user:pass@localhost:5432/seglamater"Implement the Storage trait from scanner_server::storage:
use scanner_server::storage::{Storage, ScanRecord, AggregateStats};
impl Storage for MyStorage {
async fn store_scan(&self, domain: &str, score: u32, grade: &str, scan_data: &str) -> Result<i64> { ... }
async fn get_latest_scan(&self, domain: &str) -> Result<Option<ScanRecord>> { ... }
async fn get_history(&self, domain: &str, limit: i64) -> Result<Vec<ScanRecord>> { ... }
// ... see storage/traits.rs for the full trait
}docker build -t seglamater-scan .
docker run -p 8080:8080 -v scanner-data:/data seglamater-scanThe browser feature enables headless Chromium for JavaScript-rendered page analysis. Pass it via the FEATURES build arg — the Dockerfile automatically installs Chromium and its dependencies in the runtime image when this feature is active.
docker build --build-arg FEATURES=browser -t seglamater-scan .
docker run -p 8080:8080 -v scanner-data:/data \
--security-opt seccomp=unconfined \
seglamater-scanThe seccomp=unconfined flag is required for Chromium's sandbox inside Docker. The container runs as a non-root scanner user.
docker compose up -dThe default docker-compose.yml runs the server on port 8080 with a SQLite database persisted to a Docker volume. Set SPS_FEATURES=browser in your environment to enable headless browser support.
Set DATABASE_URL and build with the postgres feature:
services:
scanner:
build:
context: .
args:
FEATURES: "postgres"
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://scanner:<your-secure-password>@db:5432/scanner
- RUST_LOG=info
depends_on:
- db
db:
image: postgres:17
environment:
- POSTGRES_USER=scanner
- POSTGRES_PASSWORD=<your-secure-password>
- POSTGRES_DB=scanner
volumes:
- pg-data:/var/lib/postgresql/data
volumes:
pg-data:| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
Database connection string | sqlite://./scanner.db |
RUST_LOG |
Log level (debug, info, warn, error) |
info |
SPS_API_KEY |
API key for write endpoints (unset = open mode) | (unset) |
SPS_CORS_ORIGINS |
Comma-separated allowed origins | https://seglamater.app,https://seglamater.com |
CHROME_BIN |
Path to Chromium binary (browser feature) | /usr/bin/chromium |
SPS_MAX_BROWSER_SESSIONS |
Max concurrent headless browser sessions | 2 |
When running in server mode, a background scheduler automatically re-scans registered domains.
- Checks for due domains every 5 minutes
- Waits 2 seconds between scans to be respectful to target servers
- Register domains via
POST /api/domainswith a custominterval_hours
Use the SPS GitHub Action to scan your domain in CI and fail the build if the privacy score drops below a threshold.
- uses: purpleneutral/sps@v1
with:
domain: example.com
threshold: 75name: Privacy Check
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Weekly Monday 6am
jobs:
sps:
runs-on: ubuntu-latest
steps:
- uses: purpleneutral/sps@v1
id: scan
with:
domain: example.com
threshold: 75
min-grade: B
- run: echo "Score ${{ steps.scan.outputs.score }}/100 (${{ steps.scan.outputs.grade }})"| Input | Required | Default | Description |
|---|---|---|---|
domain |
Yes | — | Domain to scan |
threshold |
No | 0 |
Minimum score (0-100) to pass |
min-grade |
No | — | Minimum grade (A+, A, B, C, D, F) |
api-url |
No | https://seglamater.app/api/privacy |
API base URL (override for self-hosted) |
trigger-scan |
No | true |
Trigger a fresh scan or read the latest existing result |
fail-on-error |
No | true |
Fail the build if the API is unreachable |
| Output | Description |
|---|---|
score |
Numeric score (0-100) |
grade |
Letter grade |
passed |
true or false |
domain |
Normalized domain |
scan-url |
Link to full results |
The action calls the public SPS API. Fresh scans (trigger-scan: true) take 10-60 seconds depending on the target site. The public API is rate-limited to 3 scans/minute per IP — for high-frequency CI, use trigger-scan: false to read existing results.
scanner-core Core types, scoring, report formatting
^
scanner-{transport, headers, tracking, cookies, dns, bestpractices}
^ Individual check implementations
scanner-browser Headless Chromium page loading
scanner-engine Scan orchestration, page fetching, recommendations
^
scanner-server HTTP API, badge/dial generation, storage, scheduler
scanner-cli CLI interface (scan + serve subcommands)
| Crate | Purpose |
|---|---|
scanner-core |
Specification types, grade thresholds, check/category result types, text/JSON report formatting |
scanner-transport |
TLS version checks, HSTS header parsing |
scanner-headers |
CSP, Referrer-Policy, Permissions-Policy, X-Content-Type-Options, X-Frame-Options |
scanner-tracking |
HTML parsing, tracker/analytics domain matching, fingerprinting detection, CDN detection |
scanner-cookies |
Set-Cookie header parsing, attribute validation |
scanner-dns |
SPF, DKIM, DMARC, DNSSEC, CAA record checks |
scanner-bestpractices |
security.txt, privacy.json, JavaScript-free accessibility |
scanner-browser |
Headless Chromium via CDP: page loading, network interception, cookie/HTML collection |
scanner-engine |
Scan orchestration: run_scan(), fetch_page(), normalize_domain(), recommendation generation |
scanner-server |
Axum HTTP server, Storage trait + SQLite/PostgreSQL backends, SVG badge/dial generation, background scheduler |
scanner-cli |
Binary entry point with scan and serve subcommands |
- User-Agent:
Mozilla/5.0 (compatible; SeglamaterScan/0.1; +https://seglamater.app/privacy) - HTTP timeout: 30 seconds per request
- Browser timeout: 45 seconds for the headless Chromium page load (includes navigation, network idle wait, and data collection)
- Redirects: Up to 10 followed
- TLS: Valid certificates required (no insecure connections)
- Parallelism: Transport and DNS checks run concurrently; header, tracking, cookie, and best practice checks run after the page is fetched
- Browser integration: A headless Chromium instance loads the page to capture JavaScript-rendered content, runtime cookies, and network requests. DNS is pinned to the resolved IP to prevent SSRF. The browser runs in a sandboxed, no-GPU environment with a single-use profile discarded after each scan.
Input domains are automatically normalized:
https://Example.COM/pathbecomesexample.comhttp://site.org:8080/becomessite.org- Leading/trailing whitespace is trimmed
- Browser extension — Available at purpleneutral/sps-extension. Shows the SPS grade for every site in your toolbar. Chrome and Firefox, Manifest V3.
- CI/CD integration — Available. See CI/CD Integration for usage.
- Spec v1.1 — Additional checks based on community feedback
- Blocklist updates — Automated tracker/analytics list refresh from upstream sources
Contributions are welcome. If you find a false positive, a missing tracker, or a check that should be scored differently, open an issue with details.
For code contributions:
- Fork the repository
- Create a feature branch
- Run
cargo testandcargo clippybefore submitting - Open a pull request with a clear description of the change
If you think the specification itself should change, open a discussion issue first — spec changes affect every scan.
If you find this project useful, you can buy me a coffee.
GPL-3.0-only. See LICENSE for details.
The SPS v1.0 specification is licensed under CC BY 4.0.