# Security & trust
Source: https://docs.privacytracker.privacykey.org/security

What privacytracker protects against, what data leaves your device, the access-control layers, and how to report a vulnerability.

This page is for self-hosters making real deployment decisions, contributors keeping the security posture intact, and security researchers running tests under our coordinated-disclosure policy. Wherever the docs-site version diverges from the canonical Security card on the [privacytracker Plane board](https://sites.plane.so/issues/39b6604351894f09a5e903acce37d265), **the Plane board is the source of truth** and this page mirrors it for discoverability.

> **Note**
>
> privacytracker is designed as a **single-user, local-first** tool. The threat model assumes one trusted operator running it on hardware they control. If you're putting this in front of multiple untrusted users, you're outside the supported model — see [Runtime and database boundaries](#runtime-and-database-boundaries) below.

## Threat model

What privacytracker protects against:

- **Silent privacy-label changes.** The whole point. Snapshots + diffs + notifications surface when an app's disclosed data collection changes without you noticing.
- **Cross-origin abuse of the local API.** Same-origin CSRF check on every mutating verb. A malicious tab can't trigger `POST /api/scrape` against your local install.
- **Casual access to destructive routes when the API is exposed beyond localhost.** The optional `AUDITOR_ADMIN_TOKEN` adds a shared-secret layer on top of CSRF.
- **Brute-forcing the admin token.** Token login is capped at 5 attempts per minute, plus an absolute backstop that trips after 100 failed attempts in 15 minutes no matter where they come from. Comparison is constant-time. See [Rate limiting](#rate-limiting) for what the per-caller bucket is actually keyed on.
- **DNS rebinding.** Every request — including `GET` — is rejected with `400 Host not allowed` unless its effective `Host` is on the allowlist, which defaults to loopback only.
- **Silent ed25519 signature stripping during desktop auto-update.** The Tauri updater verifies every patch's signature before applying it.
- **Force-include of private annotations in audit-bundle exports.** SQL-level filter at build time; no URL parameter or escape hatch can override it.
- **Unwitting AI provider exfiltration.** AI is opt-in and disabled by default. When enabled, only privacy-policy text is sent — never app names, your annotations, or telemetry of any kind.

What privacytracker does *not* protect against:

- **A malicious or compromised host machine.** If your laptop is rooted, privacytracker can't protect data inside it. Use full-disk encryption.
- **A compromised AI provider.** When you enable an AI provider and send privacy-policy text to it, that text leaves your machine. We hash policies and skip re-summarisation when the hash is unchanged, but the content of policies is still visible to whichever provider you chose.
- **Volumetric DoS from many source IPs.** privacytracker does rate-limit its own routes ([Rate limiting](#rate-limiting)), but that limiter is defence-in-depth: the thresholds are modest, and unless you set `PRIVACYTRACKER_TRUST_PROXY` every caller lands in one shared bucket per route. It will not absorb a distributed flood. If you expose the instance to the public internet, put it behind a rate-limiting reverse proxy.
- **Multi-user separation.** There are no user accounts. Anyone with browser access to your instance has full access to its data.
- **Physical attacks against your machine.** Out of scope.

## What data leaves your device

Five things, all opt-in or transparent:

**App Store HTML — fetched from apps.apple.com**

privacytracker fetches each app's App Store page to read its public privacy labels. This is the same network call your browser makes when you visit `apps.apple.com/.../id<digits>`. The request goes directly from your machine to Apple — no intermediary. No personal data is sent.

Apple sees the request as coming from your IP address. They presumably log it like any other web request. There is no Apple developer account or API key involved.

**Privacy-policy text — fetched from the developer's domain**

When AI summaries are enabled, privacytracker follows the *Developer's Privacy Policy* link from each app's App Store page and fetches the policy text. The fetcher follows redirects, respects standard `Cache-Control` headers, and does not auto-fill any form fields. The developer's host sees a request from your IP for the URL Apple already publishes on the App Store page.

The text is hashed (SHA-256) and re-summarisation only happens when the hash changes — so cosmetic edits to a policy don't burn AI calls or re-leak anything. A fetch keeps at most 300,000 characters of the text, and each app keeps its newest 20 captured versions (no more than 8 MiB between them), so a page that changes on every visit cannot grow the database or its backup without bound.

**Internet Archive — when you import history or fetch a policy**

Two features talk to archive.org, and both send it a URL Apple or the developer already publishes, never anything about you beyond the IP the request comes from.

The Wayback importer (Settings → Historical Import, or *Check the archive* on an app's Change History tab) lists the archived copies of the app's App Store page, fetches the ones it needs, and at most once per app asks Save Page Now to capture today's page, only when the Archive holds no copy from the last 45 days. After each successful policy fetch, privacytracker also looks up the newest archived copy of that policy so the AI Policy and Change History tabs can link to one, keeps whichever known copy is newest, and asks for a fresh capture only when no copy from the last 45 days is known, from that lookup or from an earlier save. If a developer's site refuses the policy twice, the text is read from the newest archived copy instead. None of this happens while policy scraping is disabled.

**AI provider calls — only when configured**

If you've configured an AI provider (OpenAI, Anthropic, or your own local OpenAI-compatible endpoint), privacytracker sends the policy text to that provider as part of summarisation prompts. The provider receives:

- The policy text (chunked if the provider's `providerLikelyNeedsChunking` flag is set)
- The lens prompts (`POLICY_TOPIC_GUIDES` in `lib/privacy-policy.ts`)
- Provider-required headers (your API key in the case of OpenAI / Anthropic)

The provider does *not* receive: app names, bundle IDs, your annotations, your IP-on-the-Internet (assuming standard egress), telemetry, or anything else.

Local providers (Ollama, llama.cpp, LM Studio, vLLM) keep all of this on your machine.

**Notification webhooks — only when you configure a URL**

If you paste an incoming-webhook URL into the desktop app's *Keep privacytracker running in the background* wizard (or write `notification_webhook_url` through `POST /api/settings` on any build), privacytracker POSTs notifications to that URL. Off by default — an empty URL, or `notification_webhook_frequency = off`, disables it entirely.

The receiving service sees **app names and change summaries**, such as *"Instagram: 2 new categories under Data Used to Track You"*. With [Privacy policy updates](https://docs.privacytracker.privacykey.org/configuration#notifications) on, which it isn't by default, it also learns which apps' privacy policies changed and the host name in each policy's link, as in *"Spotify: Privacy policy text changed at spotify.com."* It does not see your annotations, privacy profile, AI keys, or the underlying policy text. Four payload shapes are supported (`slack`, `discord`, `teams`, `generic`); The `generic` payload also carries the structured rows under `notifications`.

Delivery is either immediate or a daily or weekly batch posted from the 30-minute tick. Immediate delivery posts each App Store change (privacy labels, accessibility labels or age rating) as soon as a sync has saved it, and each privacy-policy change as it's raised, during quiet hours too. The batches carry the notifications raised since the previous one. Neither applies the bell's per-type filters, so a change the bell leaves out is still sent. Requests go out through the same `safeFetch` SSRF, size, and timeout guards as `/api/scrape`. Since this is a third-party chat service, treat it as you would any other outbound integration: pick a private channel.

privacytracker has **no analytics, no telemetry, no crash reporting** of its own. Beyond the four paths above, the SQLite database, all settings, all your annotations stay on your machine.

## Authentication & access control

Three layers. The host allowlist applies to every request; the CSRF check applies to mutations; the admin token applies to mutations and — on a network-exposed instance — to a short list of sensitive reads too.

### Host allowlist (always on)

Before anything else, `proxy.ts` rejects any request — `GET` included — whose effective `Host` isn't allowlisted:

```bash
curl -H "Host: evil.example" http://localhost:3000/api/apps
# 400 { "error": "Host not allowed" }
```

The default allowlist is loopback only. Add LAN hostnames with `PRIVACYTRACKER_ALLOWED_HOSTS` — see [Configuration → Network exposure](https://docs.privacytracker.privacykey.org/configuration#network-exposure). This is the DNS-rebinding defence: browsers can't spoof `Host`, so a page that rebinds DNS to your loopback instance still arrives under its own hostname and is bounced.

### Same-origin CSRF check (always on)

Every mutating verb (`POST`, `PUT`, `PATCH`, `DELETE`) on `/api/` checks that the `Origin` header matches the request host, unless the caller supplies the admin token instead. The check lives in `proxy.ts` and runs before any route handler.

```bash
curl -X POST http://localhost:3000/api/reset
# 403 { "error": "Cross-origin mutation rejected" }

curl -X POST http://localhost:3000/api/reset \
  -H "Origin: http://localhost:3000"
# 200
```

The body is human-readable prose, not a stable machine code — match on the status, not the string.

Browser requests from your installed UI satisfy this automatically. Cross-origin or curl-from-a-different-host calls won't unless they set the header explicitly. If you're behind a reverse proxy, the proxy must forward the original `Host` header — see [Troubleshooting → Reverse-proxy CSRF rejection](https://docs.privacytracker.privacykey.org/troubleshooting#reverse-proxy-csrf-rejection).

### Admin token (optional, opt-in)

When you set `AUDITOR_ADMIN_TOKEN` in the environment, *destructive* routes (`POST /api/reset`, `DELETE /api/apps`, `POST /api/settings`, `DELETE /api/wayback/import-all`, etc.) require the token on top of the CSRF check. Calls without it get **401** `{ "error": "Admin token required" }`.

```bash
export AUDITOR_ADMIN_TOKEN=$(openssl rand -hex 32)
# ... start privacytracker with this in the environment ...

curl -X POST http://localhost:3000/api/reset \
  -H "Origin: http://localhost:3000" \
  -H "X-Auditor-Admin-Token: $AUDITOR_ADMIN_TOKEN"
```

Verification uses **`crypto.timingSafeEqual`** so token comparison runs in constant time regardless of where the first byte differs. This rules out timing oracles even on a fast LAN.

The token is purely opt-in — there's no built-in user/account system in privacytracker. Use it when the app is exposed beyond a single-user trusted boundary (e.g., self-hosted on a LAN where multiple devices can reach it).

> **Tip**
>
> Generate a token with `openssl rand -hex 32`. Store it like any other secret — never commit it, never paste it into a public log.

#### Sensitive reads are gated too

Once the instance is network-exposed — any non-loopback entry in `PRIVACYTRACKER_ALLOWED_HOSTS`, `PRIVACYTRACKER_NETWORK_EXPOSED` set, or a specific non-loopback `PRIVACYTRACKER_BIND_HOST` — the token is also required on `GET` requests whose path starts with any of:

```
/api/ai/debug-log
/api/backup/
/api/deployment/
/api/desktop/diagnostics
/api/diagnostics/
/api/export
/api/import/
```

Missing or wrong token gets 401 `{ "error": "Admin token required for non-local API access" }`. This proxy gate never fires on a loopback-only install — but it isn't the only gate. `GET /api/backup/export` and `GET /api/ai/debug-log` check `adminTokenRequiredForRequest()` themselves, which is `adminTokenConfigured() || isNetworkExposed()` (`lib/security.ts`), so they return 401 `{ "error": "Admin token required" }` as soon as `AUDITOR_ADMIN_TOKEN` is set, loopback or not. This is why "read-only means unauthenticated" doesn't hold for the LAN deployments the token exists for.

#### Browser sessions use a cookie, not the header

The Settings panel can't put a long-lived secret in JavaScript, so it exchanges the token once and works from a cookie afterwards:

| Endpoint | Does |
|---|---|
| `POST /api/auth/admin-token/login` | Takes `{ "token": "..." }`, constant-time compares, sets `pt_admin_token` — HttpOnly, `SameSite=strict`, `Secure` over HTTPS, 8-hour max-age. Its value is not the token: it is an HMAC-SHA256 of the token under a 32-byte secret the server mints once per process. |
| `POST /api/auth/admin-token/logout` | Clears the cookie server-side (JS can't, it's HttpOnly). |
| `GET /api/auth/admin-token/status` | Returns `{ configured, unlocked }`. Never returns the token. |

All three are exempt from the non-local admin gate (`ADMIN_AUTH_BYPASS_PREFIX` in `proxy.ts`) — the login endpoint is how you obtain the cookie in the first place, so gating it would be circular. Same-origin is the narrower claim: login and logout call `isSameOriginRequest()` themselves, but `GET .../status` doesn't, and the proxy's CSRF check only covers `POST`/`PUT`/`PATCH`/`DELETE` — so a cross-origin `GET` reaches that handler. What it gets back is two booleans; the token itself is never returned. Everywhere else `proxy.ts` accepts either credential, with one asymmetry: the header takes the raw token only, while the cookie takes the derived session value or the raw token. Every `pt_admin_token` cookie on the request is tried, so a cookie planted by a sibling port or a parent domain cannot shadow the real one. Because the secret lives in the server process, a restart or a token rotation signs every browser out.

Keeping the token out of `sessionStorage` is the point: an injected script can't read an HttpOnly cookie, so an XSS bug can't exfiltrate the token and replay it later from somewhere else, and a session value that does leak is worthless once the server restarts.

### Rate limiting

privacytracker rate-limits its own routes. `checkRateLimit()` in `lib/security.ts` is a per-key sliding window, keyed on route prefix plus client IP, applied to 68 of the 122 API routes — 41 calling it directly, 27 more through `requireMutationGuard()`, which also writes a `<action>.rate_limited` audit row. Every denial is a 429, but only some carry `Retry-After`: `requireMutationGuard()` always sets it (`lib/api-guards.ts`), as do a handful of direct callers such as `/api/scrape`, `/api/search`, and the admin-token login. Most routes that call `checkRateLimit()` directly — `/api/settings`, `/api/reset`, `/api/backup/export`, `/api/backup/restore`, `/api/shortlist`, the `/api/manual-apps` family — return a bare 429 with just the error body, so a client that backs off on the header needs a fallback for those. None of it is behind an env var or build mode.

| Route | Limit |
|---|---|
| `POST /api/scrape` | 30 / minute |
| `POST /api/search` | 60 / minute |
| `POST /api/reset` | 30 / 10 minutes |
| `POST /api/backup/restore` | 3 / 10 minutes |
| `POST /api/auth/admin-token/login` | 5 / minute |
| Failed admin-token logins, all sources | 100 / 15 minutes |

Read the boundary carefully. Without `PRIVACYTRACKER_TRUST_PROXY`, `X-Forwarded-For` is attacker-controlled and deliberately ignored, so the IP part of every key collapses to the constant `local` — one shared bucket per route rather than one per caller. That's what the last row is for: an absolute, IP-independent backstop on failed logins, counting failures only, so an attacker who trips it inflicts a self-healing cooldown rather than locking you out.

The practical consequence: these limits stop a runaway client loop and make token brute-force impractical. They are not a substitute for a rate-limiting reverse proxy in front of an internet-facing instance.

> **Note**
>
> A 429 from privacytracker is not the same thing as a 429 from Apple. The internal limiter logs its denials with the marker `This is our INTERNAL limiter (lib/security.ts), not Apple's 429 cooldown.` and clears within its own window; Apple's cooldown is longer and shows up as `partial: rateLimited` in the sync runner.

### Audit log

Every failed admin-token attempt (and a handful of other security-relevant events) is appended to the `audit_log` table:

| Column | Contents |
|---|---|
| `id` | UUID (`crypto.randomUUID()`), TEXT primary key — not an autoincrement integer |
| `created_at` | Unix timestamp (ms) |
| `action` | dot-namespaced action code — `admin_token.login`, `admin_token.login.invalid`, `reset.success`, `backup.export.success`, `app.delete.success`, … |
| `actor_ip` | requester IP — from `x-forwarded-for` only when `PRIVACYTRACKER_TRUST_PROXY` is set, otherwise the literal `local` |
| `user_agent` | the `User-Agent` header, truncated to 256 chars |
| `detail` | free-text context, truncated to 1024 chars |
| `success` | `1` or `0` |

Action codes are namespaced by facility, then outcome. A failed admin-token login is `admin_token.login.invalid` (with `admin_token.login.rate_limited` and `admin_token.login.global_throttled` for the two throttle paths); a successful one is `admin_token.login`. A reset is `reset.success`, `reset.unauthorised`, `reset.rate_limited`, or `reset.failed`. Bundle export is `backup.export.success`; restore is `backup.restore.*`.

Rows are only ever appended — the schema has no `deleted_at` column and nothing deletes or rewrites an individual entry. The table as a whole is not protected, though: `audit_log` is listed in `START_OVER_TABLES_TO_TRUNCATE` (`lib/reset-tables.ts`), so `POST /api/admin/start-over` runs `DELETE FROM audit_log`, and `POST /api/backup/restore` clears it too before replacing it with whatever the bundle carried (`TABLES_IN_INSERT_ORDER` in `lib/backup.ts`). `POST /api/reset` is the destructive route that deliberately preserves it. Both wipe paths need the admin token — which means an actor who has the token can erase the record of their own access, so copy the log off-host if you need it to survive that. If you want to prune it for storage, drop and recreate manually with the app stopped.

There is no audit-log UI. Nothing in the app reads this table — no route SELECTs from it, and there's no Settings panel for it. It's a forensic trail, not a screen. Read it with the sqlite3 CLI:

```bash
sqlite3 data/privacy.db "SELECT datetime(created_at/1000, 'unixepoch'), action, actor_ip, success FROM audit_log ORDER BY created_at DESC LIMIT 50;"
```

The separate `activity_log` table — scrapes, re-syncs, scheduled runs, migrations, backup/restore — *is* surfaced in the UI, under **Settings → Admin → Developer Options → Activity log**. Don't confuse the two: `activity_log` is the operational timeline, `audit_log` is the privileged-request trail.

## Cryptographic primitives

| Use | Primitive | Where |
|---|---|---|
| Admin-token verification | `crypto.timingSafeEqual` (Node native) | `proxy.ts`, `lib/security.ts` |
| Privacy-policy version hashing | SHA-256 | `lib/privacy-policy.ts` |
| Desktop-app auto-update verification | ed25519 signature on every patch | Tauri updater |
| Backup-bundle authenticity | HMAC-SHA256 over the canonicalised envelope, verified in constant time | `lib/backup.ts` |
| Audit-bundle integrity | none today (versioned JSON only) | `lib/audit-bundle.ts` |

Backup bundles are signed, not encrypted. The key is 32 random bytes at `<data dir>/backup-signing.key`, mode `0600`, generated on first use and never leaving the machine — so a signature proves *"this bundle came from this install"*, nothing more. A bundle exported anywhere else fails verification and needs the explicit untrusted opt-in to restore; see [Backup & restore](https://docs.privacytracker.privacykey.org/backup-and-restore#restoring-a-bundle-from-another-install). Anyone who can read that key file can forge envelopes targeting your install.

Both bundle kinds are unencrypted JSON so they're inspectable and recoverable without tooling. Treat them as sensitive data and share via channels you'd use for any other personal file — see [Audit-bundle export threat model](#audit-bundle-export-threat-model) below.

## Verifying signed binaries

The desktop app builds we publish on GitHub releases are:

1. Signed with a Developer ID certificate
2. Notarized by Apple
3. Verified at install time by Gatekeeper
4. Re-verified on every auto-update by Tauri's ed25519 check before the patch is applied

You can verify the signature manually:

```bash
codesign --verify --deep --strict --verbose=2 /Applications/privacytracker.app
spctl --assess --verbose=4 --type execute /Applications/privacytracker.app
```

To verify the build matches source, [build from source](https://docs.privacytracker.privacykey.org/develop/build-from-source) and compare the resulting bundle. The build is reproducible up to timestamp metadata; the unsigned `.app` bundle should match byte-for-byte modulo the signing payload.

## Audit-bundle export threat model

An audit bundle is a JSON file describing tracked apps, their privacy labels, AI summaries, and exportable annotations. Bundles can also include the privacy profile you assessed against, with an opt-out checkbox in the export dialog.

`POST /api/export/audit-bundle` allows the export when the `flag.settings.admin.export.audit_bundle` flag resolves to `on`, **or** when your focus workflow (`flag.focus.workflow`) is `other_handoff` — the "I'm preparing a bundle for someone else" answer. Either arm is sufficient, and the first one is what a recommender trips: `AUDIENCE_RULES.loved_one` in `lib/feature-flag-rules.ts` sets that flag to `on`, so picking the **Loved one** audience unlocks the export by itself, with the workflow still sitting at `custom`. For `self` and `guardian` the hard default is `off`, and those audiences need the `other_handoff` answer or an explicit flag override before the route stops returning 403. The API is the authoritative gate; client surfaces just hide the button.

**What's included** (when you click Export):

- Tracked apps + their full privacy disclosure (categories, identifiers, policy text excerpt, AI summary, accessibility features)
- Annotations marked `visibility = 'export'` only
- Optionally: your privacy profile (the tolerances you set for data collection, sharing, retention, etc.)

**What's never included:**

- Notes flagged `visibility = 'private'` — unconditionally excluded by SQL filter at build time. There is no force-include URL parameter or escape hatch.
- Your audience or goal selections — the recipient picks their own.
- Your flag overrides, AI provider configuration, or AI keys.
- Notification preferences, sync schedule, or any other personal settings.

**Threat model.** Bundles are unencrypted JSON. Treat them like a personal email: once you share the file, the recipient can open it indefinitely, and there is no revocation mechanism. We deliberately avoided shipping any centralised revocation list — that would contradict the local-first design — so the recommended sharing channels are ones you already trust for personal data: encrypted messaging (Signal, iMessage), end-to-end-encrypted email, or direct file transfer between devices on your local network. **Avoid pasting the contents into public-facing tools** (issue trackers, chat rooms, AI assistants).

When a recipient imports a bundle, they see your annotations attributed to your name (the free-text field captured at export time, or *"your friend"* if blank). They can edit or delete their own copy of those notes after import; this does not affect the original on your device. The bundle's `version` field guards against schema mismatches — newer bundles refuse to import into older app versions with a clear error rather than silently misparsing.

> **Warning**
>
> If you find a way to surface private (`visibility = 'private'`) notes in an exported bundle, that's a security issue and we want to hear about it through the [reporting process](#reporting-a-vulnerability) below.

## Runtime and database boundaries

privacytracker is designed as a single-user, local-first tool. **Run only one runtime against a given SQLite database at a time**: a Docker/Node process *or* the desktop app, not both sharing the same `privacy.db`. SQLite WAL mode handles ordinary contention inside one process, but two app processes writing the same database is outside the supported security and data-integrity model.

If you want to run multiple instances (e.g., one per family member), use **separate `data/` directories** per instance. The in-app [device menu](https://docs.privacytracker.privacykey.org/devices#the-device-menu) separates family members' devices within one install, but it's a filter, not an access boundary — use separate instances when their data must stay apart.

## Supported versions

Security fixes are applied to the **`main` branch only**. There is no long-term-support branch. If you're running privacytracker in production, track the latest Docker image (or the latest commit on `main`) so you receive fixes as soon as they ship.

| Version | Supported |
|---|---|
| `main` (latest) | Yes |
| Older commits / tagged releases | No |

## Reporting a vulnerability

**Please do not open a public GitHub issue, Discussion, or pull request for anything security-sensitive.** Once a vulnerability is visible in a public thread it's effectively disclosed to attackers too. Use one of the two private channels below.

**[GitHub Private Vulnerability Reporting](https://github.com/privacykey/privacytracker/security/advisories/new)**

**Preferred.** Keeps the entire conversation inside the repo but hidden from the public until we publish an advisory. Threads directly into our triage workflow; when a fix ships, GitHub can request a CVE on your behalf.

**Encrypted email (once the key is published)**

The PGP key for `security@privacykey.org` isn't published yet. Until `.github/security-pgp.asc` and its fingerprint land in the repo, please use GitHub Private Vulnerability Reporting. If you can't use GitHub, open an advisory with minimal detail and ask us to coordinate another secure channel from there.

**Do not** send sensitive vulnerability details by plaintext email.

### What to include

The more of the following you can share up front, the faster we can reproduce the issue and ship a fix:

- The affected version or commit (`git rev-parse --short HEAD`, or your Docker image tag / digest).
- A clear description of the vulnerability and the impact if exploited (who gets what access, what data leaks, what can be modified).
- Reliable reproduction steps — ideally a minimal proof-of-concept (`curl` command, repro script, or short written walkthrough).
- Any logs, HTTP captures, or screenshots that show the issue.
- Whether you've disclosed the issue anywhere else (and to whom).
- How you'd like to be credited — name, handle, affiliation, or *anonymous*.

### Our response

privacytracker is maintained by a small team, so we **can't commit to a strict SLA**, but we aim to:

- **Acknowledge** every report as soon as practical after receiving it.
- **Keep you updated** as we triage, reproduce, and work on a fix — even if that update is *"still investigating, no news yet"*.
- **Coordinate the disclosure timeline** with you. Ninety days is a reasonable default; we're happy to move faster on clear-cut issues and ask for more time on ones that need a deeper refactor. Active exploitation in the wild changes the math — tell us if you see it.
- **Credit you** in the fix commit, release notes, and the GitHub advisory, unless you ask to stay anonymous.

If you haven't heard back from us and feel like you're being ignored, escalate by pinging the same channel — replies sometimes end up in spam, and advisory notifications can get lost behind a busy inbox.

## Safe harbor

We consider security research conducted under this policy to be **authorised activity**. If you act in good faith and follow the rules below, we will not:

- Pursue or support any legal action against you;
- Cooperate with law enforcement requests targeting you for the research;
- Ask your hosting provider or network to take action against you.

To stay within safe harbor:

**Step 1: Report privately first**

Report the vulnerability through one of the channels above **before** disclosing it publicly or to any third party.

**Step 2: Test only your own deployments**

Test against deployments you own or have explicit permission to test. Don't probe another user's self-hosted privacytracker instance without consent.

**Step 3: Avoid collateral damage**

Avoid privacy violations (don't access data you don't need to prove the issue), destruction of data, and disruption of service. If a proof-of-concept would require any of these, stop and describe the issue to us instead.

**Step 4: Give us a fix window**

Give us a reasonable opportunity to fix the issue before you publish.

This policy follows the spirit of [disclose.io](https://disclose.io). If another policy we agree to in writing conflicts with this one, the written agreement takes precedence.

## Scope

privacytracker ships as source in the main repository and as a Docker image. **In scope** for security reports:

- The Next.js web application (`app/`, `lib/`, `components/`).
- The HTTP API surface (`app/api/**`).
- The companion Python CLI (`scripts/ios-app-import/`).
- The official Docker image and the reference `docker-compose.yml`.
- The default configuration shipped in this repo — including the database schema in `lib/db.ts`, the scraping logic in `lib/scraper.ts`, and the AI-provider request flow in `lib/ai-config.ts`.
- Exposure of locally-stored secrets (AI provider keys, session data) via the web UI or API.

**Out of scope:**

**Third-party services we integrate with**

Vulnerabilities in Apple's App Store, `archive.org`, the iTunes Search API, or your chosen AI provider (OpenAI, Anthropic, Ollama, etc.) — please report those to the relevant vendor.

We're still interested in cases where our *handling* of a third-party response is unsafe — e.g., a parser bug that lets a malicious response execute code on the host.

**Social engineering, physical attacks, DoS**

Social engineering of maintainers or contributors, physical attacks against a user's own machine, and volumetric DoS or brute-force against a user's own local deployment are out of scope.

**User-introduced misconfiguration**

Issues that require the user to install a malicious browser extension, run arbitrary untrusted code on their own machine, or intentionally misconfigure their own deployment.

**Best-practice findings without concrete impact**

Missing HSTS header on localhost, verbose error messages in dev mode, outdated non-exploitable dependencies flagged by a scanner, etc. You're welcome to open a normal GitHub issue for these.

If you're unsure whether something is in scope, **report it anyway** — we'd rather hear about it and tell you it's out of scope than miss something real.

## Known transitive advisories with no shipping impact

Some Dependabot advisories surface against transitive Rust dependencies that sit deep in Tauri's gtk stack on Linux. We don't ship a Linux desktop build (the only Tauri targets compiled in CI are `aarch64-apple-darwin` and `x86_64-apple-darwin`), so the vulnerable code paths never compile into released binaries. We track these here so reviewers can confirm the *no actionable fix* status without re-deriving it each time Dependabot re-flags them.

**glib 0.18.5 — VariantStrIter Iterator/DoubleEndedIterator unsoundness**

**Advisory:** RUSTSEC-2025-* / GHSA-h22v-69rx-mc8w (severity: Moderate).

**Vulnerable code path:** Linux gtk3 webview only. `glib` is a transitive dependency of `wry` → `webkit2gtk-rs` → `gtk-rs`. Our `Cargo.toml` does not depend on `glib` directly; the macOS / Windows builds compile a different webview backend (WKWebView / WebView2) that doesn't link against `glib`.

**Why no in-tree fix:** The `glib` 0.18.x line was abandoned at `0.18.5` with no `0.18.6` patch release. The fix exists in `0.20.10+` and `0.21.x`, but `wry` still pins `gtk = "^0.18"`, which transitively forces `glib 0.18.x`. Bumping requires `wry` (and through it `tauri`) to advance its gtk stack — outside our control.

**Mitigation in our build:** None needed. The macOS bundles compiled by `.github/workflows/macos-release.yml` don't link `glib`; the Linux Docker image builds the Next.js web app only, which is a pure JavaScript runtime with no Rust components.

**rand 0.9.4 — unsoundness when a custom logger uses rand::rng()**

**Advisory:** RUSTSEC-2025-* / GHSA-* (severity: Low).

**Vulnerable code path:** Triggered only when application code calls `rand::rng()` while a user-supplied global logger is installed. We don't depend on `rand` directly; it's pulled in transitively by Tauri internals that don't take application-level loggers.

**Why no in-tree fix:** `rand 0.9.4` is the latest published version (next semver-compatible release would be `0.9.5+`, not yet on crates.io). Once a patched `0.9.5` ships, Dependabot will open a `Cargo.lock`-only PR; merging that PR is the fix.

**Mitigation in our build:** We don't install a custom `log` subscriber that uses `rand::rng()` from inside its `log` callback. `tauri-plugin-log` is the single owner of the `log` facade subscriber and uses its own filesystem appender — no rand involvement.

When the upstream patches above land, Dependabot's auto-PRs to `Cargo.lock` are the canonical fix; merge them on the next maintenance cycle. Do **not** fork `Cargo.lock` to edit version strings by hand — the checksums are content-addressed and any drift will fail `cargo verify`.

## Hall of fame

Thanks to the researchers who help improve privacytracker's security. With each researcher's permission, names are listed here alongside the report that earned them a place.

*No entries yet.*
