Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
# Security model
|
||||
|
||||
This document states what `gitea-macos-runner` protects, what it does not, and the operational
|
||||
choices that follow. Read it before pointing the runner at an instance where untrusted code can
|
||||
open a pull request.
|
||||
|
||||
## Summary
|
||||
|
||||
Workflow code runs **as the guest admin user, with passwordless sudo, and with no container
|
||||
isolation**. The only isolation boundary is the virtual machine, and that VM is destroyed after a
|
||||
single job. This is the same trust posture as GitHub's hosted macOS runners: the job owns the
|
||||
machine, and the machine is thrown away.
|
||||
|
||||
## Trust boundary
|
||||
|
||||
```
|
||||
┌─ host Mac ─────────────────────────────────────────────┐
|
||||
│ daemon, admin PAT, registration token, base images │ ← trusted
|
||||
│ ┌─ ephemeral VM ─────────────────────────────────┐ │
|
||||
│ │ gitea-runner + workflow code (root-capable) │ │ ← untrusted
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**The VM is the boundary.** Everything inside it is treated as untrusted and disposable;
|
||||
everything outside it is trusted. A guest that escapes Virtualization.framework and compromises the
|
||||
host is **out of scope** — the design assumes Apple's hypervisor holds. If your threat model
|
||||
includes hypervisor escape, this tool is not sufficient on its own; isolate the host at the network
|
||||
level and treat it as a machine that may be compromised.
|
||||
|
||||
## What runs where
|
||||
|
||||
- **In the guest:** the `gitea-runner` binary and every step of the workflow. The runner registers
|
||||
with `--labels "macos-arm64:host"` — the `:host` execution mode means steps run directly on the
|
||||
guest OS rather than inside a container. There is no second layer of isolation, by design;
|
||||
container isolation is not meaningfully available for macOS builds, and it would defeat the point
|
||||
of a real macOS environment.
|
||||
- **On the host:** the daemon, the Gitea admin PAT, the registration token, base images, and the
|
||||
clone/boot/destroy lifecycle. **The admin PAT is never copied into a guest.**
|
||||
|
||||
Because the guest admin has passwordless sudo, a job can install software, load kernel extensions
|
||||
the guest permits, read every file in the image, and reconfigure the guest arbitrarily. None of
|
||||
that persists: the clone is deleted when the job ends and the next job starts from the untouched
|
||||
base image. Nothing a job writes is visible to any later job.
|
||||
|
||||
## Ephemeral registration is server-enforced
|
||||
|
||||
Each VM registers a fresh runner with `--ephemeral`. Ephemerality is enforced **by the Gitea
|
||||
server**, not by the runner or by this daemon:
|
||||
|
||||
- After the runner accepts one job, Gitea **refuses to dispatch a second job** to that
|
||||
registration.
|
||||
- Gitea **deletes the registration** once that job completes.
|
||||
|
||||
The security consequence matters: a runner token exfiltrated from inside a running job cannot be
|
||||
used to fetch additional jobs, because the server has already spent that registration. The worst an
|
||||
attacker can do with it is nothing. This is why a compromised job does not become a persistent
|
||||
foothold in your CI queue — the compromised VM is destroyed and its credential is already dead.
|
||||
|
||||
## The shared registration token and its blast radius
|
||||
|
||||
The registration token is a different matter, and it is the sharpest edge in this design.
|
||||
|
||||
Gitea's registration tokens are **reusable by construction**, and creating a new token for a scope
|
||||
**invalidates the previous one**. There is no API for minting a single-use, per-VM registration
|
||||
token. A per-VM token is therefore impossible — not merely unimplemented.
|
||||
|
||||
So every VM presents the same registration token, and that token is necessarily present inside an
|
||||
untrusted guest for the duration of registration.
|
||||
|
||||
**Blast radius if the token leaks:** an attacker can register arbitrary runners against the scope
|
||||
the token covers (instance-wide, if you seeded `GITEA_RUNNER_REGISTRATION_TOKEN` on the server).
|
||||
Such a runner can advertise your labels and thereby **receive and execute jobs**, which means it
|
||||
can read whatever secrets those jobs are given and return forged results. It does not by itself
|
||||
grant API access to Gitea, read repositories the runner is not assigned jobs from, or confer admin
|
||||
rights.
|
||||
|
||||
Mitigations, in order of effectiveness:
|
||||
|
||||
1. **Constrain who can dispatch jobs to the label** (below) so a rogue runner has a small pool of
|
||||
jobs to intercept.
|
||||
2. **Register at org or repo scope** rather than instance-wide when only a few repos need macOS.
|
||||
The token's reach is then limited to that scope.
|
||||
3. **Rotate the token** by changing `GITEA_RUNNER_REGISTRATION_TOKEN` and restarting Gitea. Update
|
||||
`gitea.registrationTokenFile` on the host at the same time; in-flight VMs that have already
|
||||
registered are unaffected.
|
||||
4. **Keep secrets out of macOS jobs where possible.** Prefer short-lived, narrowly scoped
|
||||
credentials injected per job over long-lived org-level secrets.
|
||||
|
||||
## Limiting who can use the runner
|
||||
|
||||
By default an instance-wide runner will execute any job from any repo that writes
|
||||
`runs-on: macos-arm64`. On an instance where untrusted users can push branches or open PRs that
|
||||
trigger workflows, that is effectively arbitrary code execution on your CI Mac's VM.
|
||||
|
||||
Restrict it:
|
||||
|
||||
- **Register at repo or org scope** instead of instance-wide — the runner is then only offered jobs
|
||||
from that repo or org.
|
||||
- Use Gitea's per-repo and per-org **Actions runner settings** to control which repositories may
|
||||
use a shared runner.
|
||||
- Configure Gitea so that **workflows from forked-repository pull requests require approval** before
|
||||
running. This is the single most important setting if your instance accepts outside
|
||||
contributions.
|
||||
|
||||
Decide this deliberately. Nothing in the daemon restricts job origin; that control lives entirely
|
||||
in Gitea.
|
||||
|
||||
## Secrets hygiene
|
||||
|
||||
- **Prefer the file forms of every credential:** `gitea.adminTokenFile` and
|
||||
`gitea.registrationTokenFile` rather than `adminToken`/`registrationToken` inline in
|
||||
`config.json`. For the admin token this is not merely a preference in one direction: config
|
||||
validation requires **exactly one** of `adminToken` and `adminTokenFile`, so a stale inline token
|
||||
cannot sit unnoticed beside a live token file.
|
||||
- **`chmod 600` every token file** and keep it owned by the runner user:
|
||||
|
||||
```sh
|
||||
chmod 600 ~/.config/gitea-macos-runner/admin-token \
|
||||
~/.config/gitea-macos-runner/registration-token
|
||||
```
|
||||
|
||||
`gitea-macos-runner doctor` checks this: its `token file permissions` check warns, and prints the
|
||||
exact `chmod` to run, if any configured token file is group- or world-readable.
|
||||
|
||||
- **Token *files* keep secrets off `ps`.** Any local user can read another process's argument
|
||||
vector; a token passed as `--token …` is visible there for the life of the process, and often in
|
||||
shell history and logs too. The `--token-file` form passes a path instead, so the secret never
|
||||
enters the command line.
|
||||
- **Do not commit `config.json`** or any token file. If your config lives in a dotfiles repo, keep
|
||||
the token files outside it.
|
||||
- **The `guest.password`** is a credential for a throwaway machine, but it appears in the host's
|
||||
config file and grants SSH into running guests. Treat the config file itself as sensitive
|
||||
(`chmod 600`) and do not reuse a password from anywhere else.
|
||||
- **Rotate the admin PAT** on the normal schedule you use for admin credentials. Its scope
|
||||
(`read:admin` + `write:admin`) is genuinely powerful — it can enumerate and delete runner
|
||||
registrations instance-wide — so it is the highest-value secret on the host. It lives only on the
|
||||
host and never enters a VM; keep it that way.
|
||||
|
||||
## Host hardening notes
|
||||
|
||||
- Use a **dedicated Mac** for CI. The auto-login recommendation in
|
||||
[setup.md](setup.md#25-install-the-service) means the disk is unlocked at boot, which is
|
||||
acceptable for a purpose-built CI machine and not for a workstation holding other data.
|
||||
- Run the daemon as a **non-administrator user** where practical. It needs a GUI session and
|
||||
virtualization entitlements, not host root.
|
||||
- Place the Mac on a **network segment that cannot reach production**. Guests get NAT'd egress
|
||||
through the host; anything the host can reach, a job can reach.
|
||||
- Keep the base image current. It is rebuilt from an IPSW, so refreshing macOS and the toolchain is
|
||||
an `image build` away — and every job automatically picks up the new image.
|
||||
Reference in New Issue
Block a user