{"id":49899339,"url":"https://github.com/purpleneutral/sps","last_synced_at":"2026-05-16T02:14:31.930Z","repository":{"id":340881603,"uuid":"1167999119","full_name":"purpleneutral/sps","owner":"purpleneutral","description":"Seglamater Privacy Standard — open-source privacy scanner for the web. 24 checks, 6 categories, 100 points.","archived":false,"fork":false,"pushed_at":"2026-02-27T03:28:38.000Z","size":125,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-02-27T05:49:41.442Z","etag":null,"topics":["open-source","privacy","privacy-tools","rust","scanner","security"],"latest_commit_sha":null,"homepage":"https://seglamater.app/privacy","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/purpleneutral.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-26T22:57:49.000Z","updated_at":"2026-02-27T03:28:41.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/purpleneutral/sps","commit_stats":null,"previous_names":["purpleneutral/sps"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/purpleneutral/sps","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/purpleneutral%2Fsps","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/purpleneutral%2Fsps/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/purpleneutral%2Fsps/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/purpleneutral%2Fsps/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/purpleneutral","download_url":"https://codeload.github.com/purpleneutral/sps/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/purpleneutral%2Fsps/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33087648,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-15T20:25:35.270Z","status":"online","status_checked_at":"2026-05-16T02:00:07.515Z","response_time":115,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["open-source","privacy","privacy-tools","rust","scanner","security"],"created_at":"2026-05-16T02:14:31.081Z","updated_at":"2026-05-16T02:14:31.923Z","avatar_url":"https://github.com/purpleneutral.png","language":"Rust","funding_links":["https://buymeacoffee.com/uniqueuserg"],"categories":[],"sub_categories":[],"readme":"# Seglamater Privacy Standard (SPS)\n\n[![SPS Score](https://seglamater.app/api/privacy/badge/seglamater.app.svg)](https://seglamater.app/privacy/scan/seglamater.app)\n[![CI](https://github.com/purpleneutral/sps/actions/workflows/ci.yml/badge.svg)](https://github.com/purpleneutral/sps/actions/workflows/ci.yml)\n\nAn open-source privacy scanner that evaluates websites against the [Seglamater Privacy Specification (SPS) v1.0](spec/v1.0.md). Scores sites from 0 to 100 across six categories and assigns a letter grade.\n\nAvailable as a CLI tool for one-off scans and an HTTP API server with badge generation, scheduled scanning, and pluggable storage backends.\n\n**Try it live:** [seglamater.app/privacy](https://seglamater.app/privacy) — scan any website for free, no account required.\n\n## Quick Start\n\n### Install from source\n\nRequires **Rust 1.85+** (edition 2024).\n\n```bash\ngit clone https://github.com/purpleneutral/sps.git\ncd sps\ncargo build --release\n```\n\nThe binary is at `target/release/seglamater-scan`.\n\n### Scan a site\n\n```bash\nseglamater-scan scan example.com\n```\n\nOutput:\n\n```\nSeglamater Privacy Scan — example.com\nSpecification: SPS v1.0\n\nScore: 78/100 (Grade: B)\n\nTRANSPORT SECURITY                           16/20\n  PASS  [8] TLS 1.3 supported\n  PASS  [4] TLS 1.0/1.1 disabled\n  PASS  [4] HSTS enabled\n  FAIL  [0] HSTS max-age \u003e= 1 year\n  ...\n```\n\n### Start the API server\n\n```bash\nseglamater-scan serve\n```\n\nThe server starts on `http://0.0.0.0:8080` with a SQLite database by default.\n\n## What It Checks\n\nSPS evaluates 24 checks across 6 categories. Every check is binary — pass or fail. No partial credit.\n\n| Category | Points | What It Measures |\n|----------|--------|------------------|\n| Transport Security | 20 | TLS 1.3, legacy protocol rejection, HSTS configuration |\n| Security Headers | 20 | CSP, Referrer-Policy, Permissions-Policy, X-Content-Type-Options, X-Frame-Options |\n| Tracking \u0026 Third Parties | 30 | Analytics scripts, ad trackers, fingerprinting, third-party CDNs, mixed content |\n| Cookie Behavior | 15 | Third-party cookies, Secure/HttpOnly/SameSite flags, expiration |\n| Email \u0026 DNS Security | 10 | SPF, DKIM, DMARC, DNSSEC, CAA records |\n| Best Practices | 5 | security.txt, privacy.json, JavaScript-free accessibility |\n\nEverything checked is publicly observable — no cooperation required from the site being scanned. The full methodology is documented in the [SPS v1.0 specification](spec/v1.0.md).\n\n### Grade Thresholds\n\n| Grade | Score |\n|-------|-------|\n| A+ | 95-100 |\n| A | 90-94 |\n| B | 75-89 |\n| C | 60-74 |\n| D | 40-59 |\n| F | 0-39 |\n\n## CLI Reference\n\n```\nseglamater-scan \u003cCOMMAND\u003e\n```\n\n### `scan` — Run a privacy scan\n\n```bash\nseglamater-scan scan \u003cDOMAIN\u003e [OPTIONS]\n```\n\n| Argument/Option | Description | Default |\n|----------------|-------------|---------|\n| `\u003cDOMAIN\u003e` | Domain to scan (e.g., `mozilla.org`) | Required |\n| `--format` | Output format: `text` or `json` | `text` |\n\n**Examples:**\n\n```bash\n# Human-readable output\nseglamater-scan scan mozilla.org\n\n# JSON for automation\nseglamater-scan scan duckduckgo.com --format json\n```\n\n### `serve` — Start the HTTP API server\n\n```bash\nseglamater-scan serve [OPTIONS]\n```\n\n| Option | Description | Default |\n|--------|-------------|---------|\n| `--host` | Address to bind to | `0.0.0.0` |\n| `--port` | Port to listen on | `8080` |\n| `--database-url` | Database connection string | `sqlite://./scanner.db` |\n\nThe `--database-url` can also be set via the `DATABASE_URL` environment variable.\n\n**Examples:**\n\n```bash\n# Default (SQLite, port 8080)\nseglamater-scan serve\n\n# Custom port with PostgreSQL\nseglamater-scan serve --port 3000 \\\n  --database-url \"postgres://user:pass@localhost/seglamater\"\n\n# Using environment variable\nDATABASE_URL=\"postgres://user:pass@db:5432/scanner\" seglamater-scan serve\n```\n\n## API Reference\n\nAll endpoints are available when running `seglamater-scan serve`.\n\n### Authentication\n\nWrite 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.\n\nProvide the key via either header:\n\n```\nX-API-Key: \u003cyour-key\u003e\nAuthorization: Bearer \u003cyour-key\u003e\n```\n\nRead endpoints (`GET`) are always public and never require authentication.\n\n**Response (401):** `{ \"error\": \"Unauthorized — provide a valid API key\" }`\n\n### Rate Limiting\n\nAll endpoints are rate-limited per client IP:\n\n| Request type | Limit |\n|-------------|-------|\n| Write (`POST`/`PUT`/`DELETE`) | 5 requests/minute |\n| Read (`GET`) | 60 requests/minute |\n\nWhen behind a reverse proxy (Traefik, Caddy, nginx), the client IP is extracted from the `X-Forwarded-For` header.\n\n**Response (429):** `{ \"error\": \"Too many requests — please try again later\" }`\n\n### POST /api/scan\n\nTrigger a scan, store the result, and return it.\n\n**Request:**\n\n```json\n{ \"domain\": \"example.com\" }\n```\n\n**Response (200):**\n\n```json\n{\n  \"domain\": \"example.com\",\n  \"spec_version\": { \"major\": 1, \"minor\": 0 },\n  \"scanned_at\": \"2026-02-25T14:30:00Z\",\n  \"categories\": [ ... ],\n  \"total_score\": 78,\n  \"grade\": \"B\",\n  \"recommendations\": [ ... ]\n}\n```\n\n### GET /api/verify/:domain\n\nGet the latest scan result for a domain.\n\n**Response (200):** Full scan result (same shape as `/api/scan` response).\n\n**Response (404):** `{ \"error\": \"No scan found for this domain\" }`\n\n### GET /api/history/:domain\n\nGet scan history for a domain, most recent first.\n\n| Query Parameter | Description | Default |\n|----------------|-------------|---------|\n| `limit` | Max records to return | `50` |\n\n**Response (200):**\n\n```json\n[\n  { \"id\": 42, \"domain\": \"example.com\", \"score\": 78, \"grade\": \"B\", \"scanned_at\": \"2026-02-25T14:30:00Z\" },\n  { \"id\": 41, \"domain\": \"example.com\", \"score\": 76, \"grade\": \"B\", \"scanned_at\": \"2026-02-24T14:30:00Z\" }\n]\n```\n\n### GET /api/domains\n\nList all scanned domains with their latest score.\n\n| Query Parameter | Description | Default |\n|----------------|-------------|---------|\n| `limit` | Records per page | `50` |\n| `offset` | Pagination offset | `0` |\n\n### POST /api/domains\n\nRegister a domain for automatic scheduled re-scanning.\n\n**Request:**\n\n```json\n{ \"domain\": \"example.com\", \"interval_hours\": 24 }\n```\n\n`interval_hours` defaults to `24` if omitted.\n\n### GET /api/domains/search\n\nSearch domains by prefix.\n\n| Query Parameter | Description | Default |\n|----------------|-------------|---------|\n| `q` | Search prefix | Required |\n| `limit` | Max results | `50` |\n\n### GET /api/stats\n\nAggregate statistics across all scans.\n\n**Response (200):**\n\n```json\n{\n  \"total_domains\": 150,\n  \"total_scans\": 847,\n  \"average_score\": 72.5,\n  \"grade_distribution\": { \"a_plus\": 12, \"a\": 30, \"b\": 68, \"c\": 25, \"d\": 10, \"f\": 5 }\n}\n```\n\n### GET /badge/:domain.svg\n\nDynamic SVG badge ([shields.io](https://shields.io) style). Returns `image/svg+xml` with 1-hour cache.\n\nReturns an \"unknown\" badge if no scan exists for the domain.\n\n**Embed in HTML:**\n\n```html\n\u003ca href=\"https://seglamater.app/privacy/scan/example.com\"\u003e\n  \u003cimg src=\"https://seglamater.app/api/privacy/badge/example.com.svg\" alt=\"SPS Score\" height=\"20\"\u003e\n\u003c/a\u003e\n```\n\n**Embed in Markdown:**\n\n```markdown\n[![SPS Score](https://seglamater.app/api/privacy/badge/example.com.svg)](https://seglamater.app/privacy/scan/example.com)\n```\n\n### GET /dial/:domain.svg\n\nCircular score dial SVG showing the numeric score, letter grade, and SPS branding. Returns `image/svg+xml` with 1-hour cache.\n\n| Query Parameter | Description | Default |\n|----------------|-------------|---------|\n| `size` | Width and height in pixels (clamped to 60-300) | `120` |\n\nReturns a \"no scan\" placeholder if no scan exists for the domain.\n\n**Embed in HTML:**\n\n```html\n\u003ca href=\"https://seglamater.app/privacy/scan/example.com\"\u003e\n  \u003cimg src=\"https://seglamater.app/api/privacy/dial/example.com.svg\" alt=\"SPS Score\" width=\"120\" height=\"120\"\u003e\n\u003c/a\u003e\n```\n\n**Custom size (80px):**\n\n```html\n\u003ca href=\"https://seglamater.app/privacy/scan/example.com\"\u003e\n  \u003cimg src=\"https://seglamater.app/api/privacy/dial/example.com.svg?size=80\" alt=\"SPS Score\" width=\"80\" height=\"80\"\u003e\n\u003c/a\u003e\n```\n\n## Scoring Details\n\n### Categories and Checks\n\n#### Transport Security (20 points)\n\n| Check | Points | Pass Criteria |\n|-------|--------|---------------|\n| TLS 1.3 supported | 8 | Server negotiates TLS 1.3 |\n| TLS 1.0/1.1 disabled | 4 | Server rejects legacy TLS |\n| HSTS enabled | 4 | `Strict-Transport-Security` header present |\n| HSTS max-age \u003e= 1 year | 2 | `max-age` \u003e= 31536000 |\n| HSTS includeSubDomains | 1 | Directive present |\n| HSTS preload | 1 | Directive present |\n\n#### Security Headers (20 points)\n\n| Check | Points | Pass Criteria |\n|-------|--------|---------------|\n| Content-Security-Policy present | 6 | Header exists |\n| CSP blocks unsafe-inline | 3 | No `'unsafe-inline'` in script-src |\n| CSP blocks unsafe-eval | 3 | No `'unsafe-eval'` in script-src |\n| Referrer-Policy set | 3 | Restrictive value (`no-referrer`, `same-origin`, `strict-origin`, `strict-origin-when-cross-origin`) |\n| Permissions-Policy set | 3 | Restricts at least 1 sensitive API |\n| X-Content-Type-Options | 1 | Set to `nosniff` |\n| X-Frame-Options | 1 | Set to `DENY` or `SAMEORIGIN` |\n\n#### Tracking \u0026 Third Parties (30 points)\n\n| Check | Points | Pass Criteria |\n|-------|--------|---------------|\n| No third-party analytics | 10 | No scripts from known analytics domains |\n| No advertising/tracking scripts | 10 | No resources from known tracker domains |\n| No fingerprinting patterns | 5 | No Canvas, WebGL, AudioContext, or FingerprintJS signatures |\n| No third-party CDNs | 3 | All resources from first-party domain |\n| All resources over HTTPS | 2 | No mixed content |\n\n#### Cookie Behavior (15 points)\n\n| Check | Points | Pass Criteria |\n|-------|--------|---------------|\n| No third-party cookies | 5 | No `Set-Cookie` from third parties |\n| Secure flag on all cookies | 3 | Every cookie has `Secure` |\n| HttpOnly flag on all cookies | 3 | Every cookie has `HttpOnly` |\n| SameSite on all cookies | 2 | `SameSite=Strict` or `SameSite=Lax` |\n| Reasonable expiration | 2 | No cookie expires beyond 1 year |\n\nIf no cookies are set, all checks pass (ideal behavior).\n\n#### Email \u0026 DNS Security (10 points)\n\n| Check | Points | Pass Criteria |\n|-------|--------|---------------|\n| SPF record strict | 3 | `v=spf1 ... -all` (hard fail) |\n| DKIM discoverable | 2 | DKIM TXT record found via common selectors |\n| DMARC policy enforced | 3 | `p=quarantine` or `p=reject` |\n| DNSSEC enabled | 1 | DNSKEY records present |\n| CAA record present | 1 | At least 1 CAA record |\n\n#### Best Practices (5 points)\n\n| Check | Points | Pass Criteria |\n|-------|--------|---------------|\n| security.txt present | 2 | `/.well-known/security.txt` returns 200 |\n| privacy.json present | 2 | `/.well-known/privacy.json` returns valid JSON |\n| Accessible without JS | 1 | HTML contains 20+ words without JavaScript |\n\n## Storage Backends\n\nThe server supports pluggable storage backends via Cargo feature flags. Tables are created automatically on startup.\n\n### SQLite (default)\n\nZero-configuration file-based database. Enabled by default.\n\n```bash\ncargo build --release\nseglamater-scan serve --database-url \"sqlite://./scanner.db\"\n```\n\n### PostgreSQL\n\nFor production deployments. Requires the `postgres` feature flag.\n\n```bash\ncargo build --release --features postgres\nseglamater-scan serve --database-url \"postgres://user:pass@localhost:5432/seglamater\"\n```\n\n### Custom Storage Backend\n\nImplement the `Storage` trait from `scanner_server::storage`:\n\n```rust\nuse scanner_server::storage::{Storage, ScanRecord, AggregateStats};\n\nimpl Storage for MyStorage {\n    async fn store_scan(\u0026self, domain: \u0026str, score: u32, grade: \u0026str, scan_data: \u0026str) -\u003e Result\u003ci64\u003e { ... }\n    async fn get_latest_scan(\u0026self, domain: \u0026str) -\u003e Result\u003cOption\u003cScanRecord\u003e\u003e { ... }\n    async fn get_history(\u0026self, domain: \u0026str, limit: i64) -\u003e Result\u003cVec\u003cScanRecord\u003e\u003e { ... }\n    // ... see storage/traits.rs for the full trait\n}\n```\n\n## Docker\n\n### Build and run\n\n```bash\ndocker build -t seglamater-scan .\ndocker run -p 8080:8080 -v scanner-data:/data seglamater-scan\n```\n\n### With headless browser\n\nThe `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.\n\n```bash\ndocker build --build-arg FEATURES=browser -t seglamater-scan .\ndocker run -p 8080:8080 -v scanner-data:/data \\\n  --security-opt seccomp=unconfined \\\n  seglamater-scan\n```\n\nThe `seccomp=unconfined` flag is required for Chromium's sandbox inside Docker. The container runs as a non-root `scanner` user.\n\n### docker-compose\n\n```bash\ndocker compose up -d\n```\n\nThe 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.\n\n### With PostgreSQL\n\nSet `DATABASE_URL` and build with the `postgres` feature:\n\n```yaml\nservices:\n  scanner:\n    build:\n      context: .\n      args:\n        FEATURES: \"postgres\"\n    ports:\n      - \"8080:8080\"\n    environment:\n      - DATABASE_URL=postgres://scanner:\u003cyour-secure-password\u003e@db:5432/scanner\n      - RUST_LOG=info\n    depends_on:\n      - db\n\n  db:\n    image: postgres:17\n    environment:\n      - POSTGRES_USER=scanner\n      - POSTGRES_PASSWORD=\u003cyour-secure-password\u003e\n      - POSTGRES_DB=scanner\n    volumes:\n      - pg-data:/var/lib/postgresql/data\n\nvolumes:\n  pg-data:\n```\n\n## Environment Variables\n\n| Variable | Description | Default |\n|----------|-------------|---------|\n| `DATABASE_URL` | Database connection string | `sqlite://./scanner.db` |\n| `RUST_LOG` | Log level (`debug`, `info`, `warn`, `error`) | `info` |\n| `SPS_API_KEY` | API key for write endpoints (unset = open mode) | *(unset)* |\n| `SPS_CORS_ORIGINS` | Comma-separated allowed origins | `https://seglamater.app,https://seglamater.com` |\n| `CHROME_BIN` | Path to Chromium binary (browser feature) | `/usr/bin/chromium` |\n| `SPS_MAX_BROWSER_SESSIONS` | Max concurrent headless browser sessions | `2` |\n\n## Background Scheduler\n\nWhen running in server mode, a background scheduler automatically re-scans registered domains.\n\n- Checks for due domains every 5 minutes\n- Waits 2 seconds between scans to be respectful to target servers\n- Register domains via `POST /api/domains` with a custom `interval_hours`\n\n## CI/CD Integration\n\nUse the SPS GitHub Action to scan your domain in CI and fail the build if the privacy score drops below a threshold.\n\n### Basic usage\n\n```yaml\n- uses: purpleneutral/sps@v1\n  with:\n    domain: example.com\n    threshold: 75\n```\n\n### Full example\n\n```yaml\nname: Privacy Check\non:\n  push:\n    branches: [main]\n  schedule:\n    - cron: '0 6 * * 1'  # Weekly Monday 6am\n\njobs:\n  sps:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: purpleneutral/sps@v1\n        id: scan\n        with:\n          domain: example.com\n          threshold: 75\n          min-grade: B\n\n      - run: echo \"Score ${{ steps.scan.outputs.score }}/100 (${{ steps.scan.outputs.grade }})\"\n```\n\n### Inputs\n\n| Input | Required | Default | Description |\n|-------|----------|---------|-------------|\n| `domain` | Yes | — | Domain to scan |\n| `threshold` | No | `0` | Minimum score (0-100) to pass |\n| `min-grade` | No | — | Minimum grade (`A+`, `A`, `B`, `C`, `D`, `F`) |\n| `api-url` | No | `https://seglamater.app/api/privacy` | API base URL (override for self-hosted) |\n| `trigger-scan` | No | `true` | Trigger a fresh scan or read the latest existing result |\n| `fail-on-error` | No | `true` | Fail the build if the API is unreachable |\n\n### Outputs\n\n| Output | Description |\n|--------|-------------|\n| `score` | Numeric score (0-100) |\n| `grade` | Letter grade |\n| `passed` | `true` or `false` |\n| `domain` | Normalized domain |\n| `scan-url` | Link to full results |\n\nThe 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.\n\n## Architecture\n\n```\nscanner-core           Core types, scoring, report formatting\n  ^\nscanner-{transport, headers, tracking, cookies, dns, bestpractices}\n  ^                    Individual check implementations\nscanner-browser        Headless Chromium page loading\nscanner-engine         Scan orchestration, page fetching, recommendations\n  ^\nscanner-server         HTTP API, badge/dial generation, storage, scheduler\nscanner-cli            CLI interface (scan + serve subcommands)\n```\n\n| Crate | Purpose |\n|-------|---------|\n| `scanner-core` | Specification types, grade thresholds, check/category result types, text/JSON report formatting |\n| `scanner-transport` | TLS version checks, HSTS header parsing |\n| `scanner-headers` | CSP, Referrer-Policy, Permissions-Policy, X-Content-Type-Options, X-Frame-Options |\n| `scanner-tracking` | HTML parsing, tracker/analytics domain matching, fingerprinting detection, CDN detection |\n| `scanner-cookies` | Set-Cookie header parsing, attribute validation |\n| `scanner-dns` | SPF, DKIM, DMARC, DNSSEC, CAA record checks |\n| `scanner-bestpractices` | security.txt, privacy.json, JavaScript-free accessibility |\n| `scanner-browser` | Headless Chromium via CDP: page loading, network interception, cookie/HTML collection |\n| `scanner-engine` | Scan orchestration: `run_scan()`, `fetch_page()`, `normalize_domain()`, recommendation generation |\n| `scanner-server` | Axum HTTP server, Storage trait + SQLite/PostgreSQL backends, SVG badge/dial generation, background scheduler |\n| `scanner-cli` | Binary entry point with `scan` and `serve` subcommands |\n\n## Scan Behavior\n\n- **User-Agent:** `Mozilla/5.0 (compatible; SeglamaterScan/0.1; +https://seglamater.app/privacy)`\n- **HTTP timeout:** 30 seconds per request\n- **Browser timeout:** 45 seconds for the headless Chromium page load (includes navigation, network idle wait, and data collection)\n- **Redirects:** Up to 10 followed\n- **TLS:** Valid certificates required (no insecure connections)\n- **Parallelism:** Transport and DNS checks run concurrently; header, tracking, cookie, and best practice checks run after the page is fetched\n- **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.\n\n### Domain Normalization\n\nInput domains are automatically normalized:\n\n- `https://Example.COM/path` becomes `example.com`\n- `http://site.org:8080/` becomes `site.org`\n- Leading/trailing whitespace is trimmed\n\n## Roadmap\n\n- **Browser extension** — Available at [purpleneutral/sps-extension](https://github.com/purpleneutral/sps-extension). Shows the SPS grade for every site in your toolbar. Chrome and Firefox, Manifest V3.\n- **CI/CD integration** — Available. See [CI/CD Integration](#cicd-integration) for usage.\n- **Spec v1.1** — Additional checks based on community feedback\n- **Blocklist updates** — Automated tracker/analytics list refresh from upstream sources\n\n## Contributing\n\nContributions are welcome. If you find a false positive, a missing tracker, or a check that should be scored differently, open an issue with details.\n\nFor code contributions:\n\n1. Fork the repository\n2. Create a feature branch\n3. Run `cargo test` and `cargo clippy` before submitting\n4. Open a pull request with a clear description of the change\n\nIf you think the specification itself should change, open a discussion issue first — spec changes affect every scan.\n\n## Support\n\nIf you find this project useful, you can [buy me a coffee](https://buymeacoffee.com/uniqueuserg).\n\n## License\n\nGPL-3.0-only. See [LICENSE](LICENSE) for details.\n\nThe [SPS v1.0 specification](spec/v1.0.md) is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpurpleneutral%2Fsps","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpurpleneutral%2Fsps","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpurpleneutral%2Fsps/lists"}