Skip to content

In plain words

  • Repodar only reads. It never writes to your repositories or your hosting.
  • The GitHub App asks for two read-only permissions: Contents and Metadata, on the repositories you pick.
  • Files are read, parsed in memory and discarded. What stays is derived metadata: versions, frameworks, deploy targets, dependency names and findings.
  • Every token you paste is encrypted before it reaches the database, and is never logged.
  • Found a problem? Report it privately; the steps are at the end of this page.

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 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, 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). 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:

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.

Last updated · View as Markdown