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). 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
Origincheck (acurlsends none); it is not a browser form, so there is nothing for a cross-site request to ride on. Responses are JSON withCache-Control: no-store,nosniffand HSTS. - Bodies are capped at 16 KB and 50 sites (checked against
Content-Lengthand 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, byte for byte what/report.shserves; tests run it undershanddashagainst fixtures; - it never uses
eval, never runs or parses anything it downloads, sends only values made ofA-Z a-z 0-9 . _ -, passes the token to curl on stdin rather than on its command line, and always exits 0.--dry-runprints 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). 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
statethat ties the installation to the workspace that started it. - The App manifest flow carries a
statesigned with the server's secret and valid for one hour. Its callback exists only on localhost or withSELF_HOSTED=true, and shows the new App's credentials once without storing them. - Form posts that change state check that
OriginmatchesAPP_URL. - Server reports (
POST /ingest/runtime) carry a report token instead (below).
Browser hardening
Every HTML response carries a strict Content Security Policy:
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 for what to include and what to expect.