# Security and data handling

Repodar is read-only by design. It never writes to a repository, never opens a pull request and
never changes anything on a hosting platform. This page lists what it reads, what it keeps and how
it protects the little it keeps.

## What is stored

Derived metadata only:

- repository names, ids, default branch and last push time;
- detected runtimes with versions, frameworks with versions, the deploy target;
- dependency names and versions (needed to match advisories);
- findings (EOL, vulnerability, major version behind) with their dates;
- what platform sources reported: site or project names, the repository they deploy, runtime
  version, last deploy time;
- what your servers reported about themselves (site, hostname, repository hint, runtime versions,
  the sending agent such as `report.sh/1`, time received), kept at most 30 days without a new
  report;
- for Forge sites, the version numbers printed by the one command Repodar asks Forge to run
  (never the rest of its output);
- server report tokens as a SHA-256 hash only, with a name and the times created, last used and
  revoked;
- your GitHub identity (login, name, avatar URL, verified email) and your sessions;
- a record of each digest sent.

## What is never stored

- **File contents.** Manifests, lockfiles and workflow files are fetched, parsed in memory and
  discarded. Repodar never clones a repository and never executes code from it.
- **Source code** of any kind, commit contents or diffs.
- **Plain-text tokens.** Every token is encrypted before it reaches the database (below).
- **Provider responses.** Errors keep a status code and a path, never a response body.
- **Secrets in logs.** Tokens are never logged, and scan logs are one summary line per scan, not
  one line per dependency or finding.

## GitHub App permissions

| Permission | Level | Why |
| --- | --- | --- |
| Contents | Repository, read-only | Read manifests, lockfiles and workflow files |
| Metadata | Repository, read-only | Repository names, default branch, archived flag, last push (mandatory for every App) |
| Email addresses | Account, read-only | Know the verified address to send the digest to |
| Deployments | Repository, read-only, optional | Last successful deployment per environment |

Events: `push` (to rescan after a push to the default branch); installation events arrive
automatically. You choose which repositories the App can see when you install it; that selection is
the watch list. Sign-in requests the OAuth scopes `read:user` and `user:email`, but for a GitHub
App these are informational: what a sign-in token can do is bounded by the App's permissions above.

## Token encryption

Provider tokens (Forge, Cloudflare, Vercel, DigitalOcean) and the GitHub token for URL-added
repositories are sealed with AES-256-GCM through WebCrypto:

- The key is derived per team with HKDF-SHA-256 from the server's `BETTER_AUTH_SECRET`, salted
  with the team id. A ciphertext copied into another team's row does not open.
- The additional authenticated data is the team id plus the provider, so a sealed Forge token
  cannot be replayed as another provider's credential.
- Every seal uses a fresh random 96-bit IV.
- After saving, the interface only ever shows a token's last four characters. Tokens are write-only.

Your GitHub sign-in token is stored by Better Auth with its OAuth token encryption turned on. It
is used only to read repositories you added by URL, and only when you have not set a dedicated
fine-grained token.

Rotating `BETTER_AUTH_SECRET` signs everyone out and makes stored tokens unreadable; Repodar then
asks for them again instead of failing silently.

## Server report tokens

Report tokens authenticate `POST /ingest/runtime`, where your servers post their own runtime
versions ([server reports](/docs/sources.md#server-reports)). They work the other way round from
provider tokens: Repodar creates them and never needs to read them back.

- A token is `rdr_` plus 32 bytes from the Workers CSPRNG, base64url. It is shown once, in the
  response to **Generate token** (never in a URL, a cookie or a log), and only its SHA-256 is
  stored. A database leak therefore exposes no usable token.
- A request's token is hashed and looked up by that hash, then compared again in constant time.
  Revoked tokens never authenticate. A token can only write runtime reports for its own
  workspace: it cannot read anything, sign in or reach any other route.
- The endpoint takes no session and no cookie, and it is exempt from the `Origin` check (a `curl`
  sends none); it is not a browser form, so there is nothing for a cross-site request to ride on.
  Responses are JSON with `Cache-Control: no-store`, `nosniff` and HSTS.
- Bodies are capped at 16 KB and 50 sites (checked against `Content-Length` and again while reading), every
  field is validated (versions are plain numbers, names a restricted character set) and stored
  as data, never rendered as markup.
- Logs carry one line per accepted report with the workspace id and counts (sites, matched,
  rejected); never the token, its hash, the site, the host or the body.

### The report script (`curl … | sh`)

The one-line setup runs a script fetched from Repodar (`sh -c "$(curl -fsS -m 10
https://repodar.dev/report.sh)"`). Piping a download into a shell is only as safe as its source,
so the script is kept boring on purpose:

- it is served over HTTPS from the app's own origin (HSTS), never from a CDN or another host;
- it is static: the same bytes for everyone, no per-user content, no token in it, cacheable for 5
  minutes. The only change on a self-hosted instance is the default URL line, set to its
  `APP_URL`;
- it is versioned (`report.sh/1`, sent as the agent) and reviewable at
  [apps/web/report/report.sh](https://github.com/gausejakub/repodar/blob/main/apps/web/report/report.sh),
  byte for byte what `/report.sh` serves; tests run it under `sh` and `dash` against fixtures;
- it never uses `eval`, never runs or parses anything it downloads, sends only values made of
  `A-Z a-z 0-9 . _ -`, passes the token to curl on stdin rather than on its command line, and
  always exits 0. `--dry-run` prints exactly what it would send.

If you prefer not to pipe, download it, read it, and run the copy from cron instead.

### The Forge version command

With the `site:manage-commands` scope, Repodar asks Forge to run one fixed command per site per
day, as the site's user ([details](/docs/sources.md#exact-versions-from-a-command-forge-runs)). It
prints the version of the site's PHP and of node, bun, deno, go, python, ruby, java, dotnet and
rust when they are found on `PATH`; it does nothing else (no writes, no network, no other
programs). Its text is built from
constants and the site's PHP version as Forge reports it (digits only); no site name, repository
content or user input is ever part of it. Only the version numbers in its output are stored.

## Request verification

- **GitHub webhooks** are verified against `X-Hub-Signature-256` (HMAC-SHA-256 with the webhook
  secret) before anything is parsed. A bad signature gets a 401. Delivery ids are remembered for 24
  hours, so a replayed delivery does no work.
- **The GitHub App install flow** carries a signed, short-lived `state` that ties the installation
  to the workspace that started it.
- **The App manifest flow** carries a `state` signed with the server's secret and valid for one
  hour. Its callback exists only on localhost or with `SELF_HOSTED=true`, and shows the new App's
  credentials once without storing them.
- **Form posts** that change state check that `Origin` matches `APP_URL`.
- **Server reports** (`POST /ingest/runtime`) carry a report token instead (below).

## Browser hardening

Every HTML response carries a strict Content Security Policy:

```text
default-src 'self'; script-src 'self'; style-src 'self';
img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self'; font-src 'self';
object-src 'none'; base-uri 'self'; form-action 'self' https://github.com; frame-ancestors 'none'
```

When billing is on (`BILLING_ENABLED=true`, hosted service only), `form-action` also allows
`https://checkout.stripe.com` and `https://billing.stripe.com`, because the Upgrade and Manage
buttons redirect there.

No inline scripts or styles, no `eval`, no third-party scripts, no CDNs: Vue and the fonts are
served from the Worker's own assets. HTML also gets `X-Frame-Options: DENY` and
`Referrer-Policy: strict-origin-when-cross-origin`; every response from the Worker gets `nosniff`,
and HTTPS responses get HSTS. Static files (CSS, scripts, fonts) are served by Cloudflare's assets
layer before the Worker runs, so these headers are not added to them.

## Rate limits

Fixed-window counters in KV protect the unauthenticated and costly routes. A blocked request gets
a 429 with `Retry-After`; the first block per window is logged once. KV counters are not atomic,
so a burst can overshoot by a few requests: this is abuse protection, not metering.

| Route | Limit | Per |
| --- | --- | --- |
| `/api/auth/*` (except get-session) | 30 per 10 minutes | IP |
| `POST /login/github` | 10 per 10 minutes | IP |
| Failed webhook signature checks (GitHub, Stripe separately) | 60 per 10 minutes | IP |
| `POST /app/repos` (add by URL) | 30 per 10 minutes | team |
| `GET /github/manifest/callback` | 5 per 10 minutes | IP |
| `GET /github/setup` | 20 per 10 minutes | IP |
| Any other authenticated `POST` under `/app` | 600 per minute | team |
| `POST /ingest/runtime` (server reports) | 10 per minute | report token |
| `POST /ingest/runtime` (server reports) | 120 per hour | team |
| Failed report token checks | 60 per 10 minutes | IP |
| Generating a report token | 20 per hour | team |

## Outbound requests

Repodar talks only to: the GitHub API, endoflife.date, OSV.dev, Packagist, the platform APIs you
gave a token for (Forge, Cloudflare, Vercel, DigitalOcean) and Lettr for email. Platform calls are
`GET` requests only, with one exception: the Forge version command above (`POST …/commands`, only
with the `site:manage-commands` scope).

## Reporting a vulnerability

Please email [SECURITY EMAIL] instead of opening a public issue. See
[SECURITY.md](https://github.com/gausejakub/repodar/blob/main/.github/SECURITY.md) for what to include and what to expect.
