This documentation is available as Markdown. For the complete index, see llms.txt. Skip to content

Security model

For the complete documentation index, see 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 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); 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. 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.