# Security model

> For the complete documentation index, see [llms.txt](/llms.txt)

This page collects the guarantees the other Box pages rely on, in one
place, for a security review.

## Code reaches the box one way

A box holds **no credential to your repository** — no deploy key, no
token — and never pulls. Suites arrive only through
[`bundle push`](/box/pushing-suites/) with a project token you control. The
GitHub App a box may use is granted **checks: write** and **pull requests:
read** and nothing else; it reports results, it does not read code.

## Where code runs

- **One container per job.** Every run, every bundle's `npm ci` and every
  environment's viewer executes in a container of its own. A run
  sidecar is the only component holding the docker socket; the daemon
  that holds tokens, values and secrets never loads a bundle's code, it
  names four strings and asks the sidecar for a container. Nothing in
  that request can choose an image, a mount or a flag.
- **A run sees its environment and nothing else.** Mounted: the bundle's
  tree read-only and that environment's run history read-write. In the
  environment: the target, the variables and the secrets of that one
  environment. Not there: the daemon's state, other projects and
  environments, tokens, channel credentials, the docker socket. The
  container runs unprivileged with a read-only root filesystem, every
  capability dropped, memory and CPU limits, and is stopped — gracefully
  first, so the run closes its own journal — when its timeout is up.
- **A viewer is a container too**, on a network of its own with exactly
  two neighbours: the guard, which proxies people at it, and the daemon,
  which it orders runs from. It has no route to the sidecar, to another
  environment's viewer, or out of the box. Its credential lets it order a
  run in its own environment and read that run's status, nothing more.
- **A run ordered from the UI names the person.** The guard mints a
  single-use, short-lived ticket for the click; the viewer forwards it;
  the daemon accepts an actor only from the guard's own signed request.
  The viewer — code from a bundle — cannot impersonate anyone or reach
  another environment.
- **A run keeps outbound access** — it drives your application — and a
  box does not filter where it connects. Treat a suite's dependencies as
  code you run.

## Secrets

- **Write-only on the wire.** `env push` and `env set` put a value in; no
  answer on that door ever carries one back — not on success, not in
  `--json`, not in `--dry-run`.
- **Never in a bundle.** `.env*` and `.secrets*` files are excluded from
  `bundle push` even when committed, and a suite that still contains one is
  refused before upload.
- **Never in the viewer.** The browser receives the *names* of an
  environment's secrets, never values.
- **Never in the audit trail or logs.** Every value-related event records
  names only.
- **Reveal is possible, and audited.** An administrator can show or copy a
  secret's value on the values page. Each look is one `secret.revealed`
  event naming who, which secret(s) and `how: show | copy`; the response
  is `Cache-Control: no-store`.
- **On disk**, secrets live in the box's own state directory, outside the
  configuration file and outside any bundle; `env push` from your
  repository's files remains the single description of an environment.

## Tokens

- Project tokens (`unob_…`) are random, stored as **SHA-256 hashes**, and
  compared in constant time. A copy of the box's state is not a set of
  working credentials.
- A token is bound to **one project**; nothing in a request can name
  another.
- Two scopes: `bundles` (push, order runs) and `bundles+values` (also write
  environment values). Give CI the narrow one.
- Revocation is soft — the record stays for the trail, the token stops
  working — and takes effect without a restart.

## Authentication and sessions

- OpenID Connect with PKCE, `state` and `nonce`; ID tokens verified against
  the issuer's published keys.
- Login attempts are single-use and time-limited; a replayed callback finds
  nothing. The same property makes the break-glass password form
  CSRF-proof.
- Sessions are **held on the box**; the cookie carries an opaque, signed id
  (`HttpOnly`, `SameSite=Lax`, `Secure` on HTTPS). A session can therefore
  be ended at any moment — by an administrator, by a directory re-check,
  by a disable.
- Login routes are rate-limited per client address; the client address is
  taken from `X-Forwarded-For` only from an explicit list of trusted
  proxies.
- Break-glass passwords are stored as scrypt hashes, and an unknown
  username costs the same work as a known one.

## Authorization

- Group → role mapping is **deny by default**; an unmapped group grants
  nothing. Roles are re-checked against the directory on a schedule; a
  directory outage never signs anyone out, and a login during an outage is
  refused rather than admitted on trust.
- The role is **enforced at the proxy**, not in the UI. Read-only sessions
  may only `GET`/`HEAD` the viewer; the terminal is admin-only; the
  server-wide file browser is denied to every role; a suite's files are
  read-only for every role, a push is what changes them. The viewer greys out
  what a role may not do, but the proxy is what refuses.
- The policy decides on a **canonical path** — query stripped,
  percent-decoded, slashes collapsed, `.`/`..` resolved — so no spelling of
  a denied path reaches the viewer. An ambiguous target is denied, not
  guessed.
- Every state-changing request is checked for its `Origin`, on HTTP and on
  WebSocket upgrades.
- Every administrative action requires an administrator session; the JSON
  routes behind the values page additionally require the `X-Unotest-Guard`
  request header, which no other origin can attach without a CORS
  preflight the box never answers.

## The guard's pages

Every answer under `/_guard/` carries:

```
Content-Security-Policy: default-src 'self'; script-src 'nonce-…';
  style-src 'nonce-…'; img-src 'self' data:; form-action 'self' https://github.com;
  base-uri 'none'; frame-ancestors 'none'
```

with a nonce minted per request. Only the page's own stylesheet and script
run; nothing frames the pages; forms post only to the box and — for the
GitHub App manifest — to `github.com`. JSON routes under `/_guard/admin/…`
are `Cache-Control: no-store`. The viewer behind the guard sets its own
headers.

## Licence and seats

- The licence is a signed document naming your organisation, a seat count
  and an expiry. The box verifies the signature locally.
- **Expired** licences get a grace period (14 days by default): the box
  keeps working and the Environments page shows a banner with the days
  left. After grace — or with a missing or invalid file — every request
  outside the sign-in pages is answered with a *License required* page,
  and the daemon's upload and run endpoints are refused too.
- `GET /_guard/health` reports `{ ok, license }` for your monitoring.
- Seats are enforced at login (see [Sign-in](/box/sign-in/#seats)); the
  refusal is audited.

## Updates

The box updates itself from the `stable` release channel. Every request to
the release service is authenticated by the box's licence and a key the box
generated itself; downloaded artifacts are checked against the announced
SHA-256 before anything is installed; if the stack does not come up healthy
after an update, the previous version is restored. Your licence, your
configuration and your `.env` are never part of a release and never
touched by one.

## What is logged

- **Audit trail** — one JSONL file per UTC day, append-only, every event
  listed in [Administration](/box/administration/#the-audit-trail).
  Written for your SIEM; the box never reads it back.
- **Process log** — operational lines: bundle stored, run queued, values
  changed (names), reveals (names), rate limits hit, refusals.
- Neither ever carries a secret's value.

## If you put a reverse proxy in front

The box terminates TLS itself. If you add your own proxy:

- pass `Content-Security-Policy` through unchanged. A proxy that strips or
  rewrites it turns the guard's pages into static forms (they keep working,
  without reveal / copy / JSON); one that injects its own script breaks
  them;
- pass `Cache-Control: no-store` through on `/_guard/admin/…`;
- forward WebSocket upgrades (`/ws`, `/ws/terminal`) and the `Origin`
  header as the browser sent it;
- do not rewrite request bodies — bundle uploads are verified against
  their content hash and would be refused as `id-mismatch`;
- tell unotest the proxy's address so it can be added to the trusted hop
  list; otherwise every visitor shares one login rate-limit bucket.
