Files
dejima-ci-hardener/README.md
T
Brad Anderson f6156c2888 Name the tiers after what a job gets, and put the reader first
The README grew alongside the tool and ended up in the order the work
happened rather than the order a reader needs. Three things were wrong with
it. The two-tier split was implicit, so a reader had to sort out which
paragraphs applied to the host they were building. The opening defined the
whole tool as "no Docker socket in job containers, and deny-by-default
egress", which describes one tier only - someone building the other was told
it does things it deliberately will not do. And long-form justification sat
between the reader and the steps.

So the README is now tier-first: pick a tier from a comparison table, then
follow only that tier's half. Everything that is background rather than
instruction moved to docs/reference.md, with docs/README.md indexing it and
the Proxmox runbook. The runbook stays in proxmox/ beside the firewall
template it explains.

The tier names changed with it. `ci` and `runtime` were dejima's words for
these hosts: `ci` distinguishes nothing on a tool whose whole subject is CI,
and `runtime` only means something if you already know dejima's runtime tests
are the workload that forced the tier to exist. They are `confined` and
`privileged` now, after what a job on each is granted. The distinction that
generalises is where the security boundary sits - inside the host for one,
on the hypervisor for the other - and the table leads with it.

`unprivileged`/`privileged` would have been the more standard pairing and was
rejected: two characters apart on a flag where a misread silently produces a
host that looks hardened and is not.

The old --role values still work and say what they are now called rather than
translating silently. examples/ is renamed to match on both sides, so no
file's tier is implied by the absence of a suffix, and the config mounts in
the compose files follow.

Written down explicitly for the first time: the privileged tier has no egress
allowlist by design. Its jobs hold the socket and run in the host's network
namespace, so any filter installed there is one they can flush. Installing it
anyway would leave a host that looks like it has an allowlist, which is worse
than one that plainly does not.

Also adds GPL-3.0-or-later to match dejima: LICENSE plus SPDX headers on the
scripts.

bats test/ passes, 21/21. All four --role values and an invalid one behave as
intended; the compose and act_runner examples still parse.
2026-08-06 19:31:55 -07:00

15 KiB

dejima-ci-hardener

Hardens a CI runner host that executes agent-authored code on every pull request, and gives its jobs as little as possible to execute it with.

This is the only thing you clone onto a CI box. It is self-contained - there is no step that copies files out of a dejima checkout, and dejima itself never needs to be present here.

There are two kinds of runner host, and which one you are building changes almost everything below. Pick the tier first, then follow only its half.

For why any of this is necessary - what a CI run adds over a sandbox, and why no approval gate is offered - see dejima's docs/ci.md. You do not need it to follow this.

Do not install this next to dejima

This runs on a machine whose job is to execute untrusted code. dejima runs on a machine you care about. Putting both on one host defeats the separation that makes either worth anything, and this program holds a forge token and rewrites a resolver's configuration - neither of which belongs within reach of a sandbox session.

Two tiers, and you must pick one

The difference is not how much a job is granted. It is where the security boundary sits, and therefore what you have to build to have one at all.

confined privileged
Built by --role confined --role privileged
Docker socket in job containers no yes
Job networking its own bridge the host's namespace
Egress allowlist in front of jobs yes, from each repo's main none, by design
The boundary is in the host on the hypervisor
Treat this host as expected to hold already lost
Runs install.sh yes no, deliberately
For tests, lint, anything needing no daemon image builds, tests needing a real daemon or host networking

Confined is where everything belongs that can go there. Its jobs get no Docker socket, run on a bridge this tool creates, and reach only the domains the served repositories declare in .dejima/allowed-domains on their main. install.sh builds all of that, and it is reasonable to run on a machine you already have.

Privileged exists for the jobs that cannot work otherwise: building a Docker image, or dejima's own runtime tests, which assert things about a real daemon and a real host network. Both grants are root-equivalent on the host, and neither can be reduced without breaking the job they exist for. That tier needs a machine built to be lost, not a machine you own.

The privileged tier has no egress allowlist, on purpose

This is the split's most important consequence and the easiest to read as an omission, so plainly: it is not one.

The confined tier's allowlist works because its jobs cannot reach it. The rules sit on a bridge their traffic has to cross, in a network namespace they are not in, enforced by a kernel they hold no privileged access to.

A privileged job has all three. It holds the Docker socket, which is root-equivalent on that host, and it runs in the host's own network namespace. Any filter installed there is one it can flush in a single command. Installing it anyway would cost an evening and leave a host that looks like it has an egress allowlist, which is worse than one that plainly does not: the next person to reason about that machine reasons from the label.

So dejima-ci-bootstrap --role privileged deliberately stops short of running install.sh, and the boundary moves outward - to the hypervisor's firewall, applied to the VM's tap device, where the guest cannot reach it.

The guarantee that survives is narrower and honest, and it is the one worth making: an attacker who owns that VM reaches the internet, and not your network.


The confined tier

Build or harden the host

Either works. On a machine you already have:

sudo apt-get install ipset iptables dnsmasq
git clone https://gitbox.branderson.io/branderson/dejima-ci-hardener.git
cd dejima-ci-hardener
sudo ./install.sh

On a fresh VM, proxmox/README.md builds one and dejima-ci-bootstrap finishes it - packages, sshd, unattended upgrades, the host's own egress policy, and then install.sh for the job allowlist:

sudo ./bin/dejima-ci-bootstrap --role confined --mgmt <your-ip>

What install.sh does

It prompts for each value, offering whatever is already configured as the default, and does the whole job: creates the Docker network with a fixed bridge name, writes the resolver config, installs dejima-ci-allowlist and dejima-ci-firewall, installs and enables the systemd units, and renders the first allowlist.

Idempotent - re-running and pressing Enter at every prompt changes nothing, so it is also how to change one setting later. --non-interactive re-installs from the existing configuration.

What it asks for:

Job network name, bridge, gateway and subnet - a private range
Forge URL https://gitea.example.com - the API root is derived
Upstream resolver see below; it offers what this host already uses
Forge token read access only, never echoed, kept if one is already installed
Repositories owner/repo per line - adding one trusts that repository's main
Base domains allowed regardless of any project declaration

The upstream resolver

The one setting that is genuinely ambiguous, so: it is the resolver dnsmasq forwards allowed queries to - the one that actually knows the answers.

It is not the dnsmasq this configures (that would loop), and not the job bridge gateway. It is whatever this host already resolves with:

grep nameserver /etc/resolv.conf

On a host running systemd-resolved that gives you 127.0.0.53, the local stub, which is not an answer - resolvectl status | grep 'DNS Servers' is, and names the servers behind it. Anywhere else resolvectl is simply absent, including on a VM built by the runbook above. Do not install it to find this out; /etc/resolv.conf is the answer on that machine.

It has to resolve everything on the allowlist, including your forge - so if that name is internal, this has to be your internal DNS. no-resolv in the dnsmasq config means it will not read /etc/resolv.conf itself, which is why it must be named explicitly.

The runner

install.sh does not register runners - that needs a token from your forge and is the one step it cannot do for you.

File Install as
examples/act-runner-confined.yaml the runner's config.yaml
examples/docker-compose-confined.yml several runners on one host
examples/docker-daemon.json /etc/docker/daemon.json - merge, do not overwrite

Set --dns=<your gateway> in container.options, and register at repository or organization scope - see Register scope is the boundary.


The privileged tier

Build the VM. Do not harden an existing one.

install.sh hardens the runners on a machine you already have. For this tier that is the wrong starting point - its jobs get the Docker socket, so the assumption has to be that a compromised job gets root on that host, and handing an existing machine to that assumption is not a decision many people would make deliberately.

proxmox/README.md builds one instead: a runbook for the Proxmox web console, then one script inside the VM.

# inside the new VM, once the console steps are done
sudo ./bin/dejima-ci-bootstrap --role privileged --mgmt <your-ip>

The node half is a runbook rather than a script on purpose. Something that runs qm create and writes into /etc/pve/firewall/ is exactly what gets pasted into a root shell unread, and the blast radius is every VM on the node. The in-VM half is a script, because running that one blind costs you a machine built to be lost.

The boundary is on the node, not in the VM

Everything installed inside the VM is defence in depth: real against a compromised job that has not escaped its container, which is the ordinary case, and worth nothing against one that has.

What holds either way is the Proxmox firewall, applied to the VM's tap device and unreachable from the guest. It denies the VM every RFC1918 destination except two pinholes - the forge on 443 and a resolver on 53 - and those two are the entire remaining LAN exposure. Public egress is deliberately not restricted there; docs/reference.md says why, and why a port allowlist there bought nothing.

One thing to check rather than assume, because its absence looks exactly like it working: the datacenter firewall must be enabled (Datacenter → Firewall → Options → Firewall: Yes), and the VM's network device must have its own Firewall checkbox ticked. Without either, the per-VM rules apply to nothing. pve-firewall compile on the node is the check, and proxmox/README.md has a from-inside-the-VM check that does not rely on reading configuration at all.

Snapshot, and roll back on a cadence

The VM is worth losing, which is only true if losing it is cheap. Register the runner, then snapshot - VM → Snapshots → Take Snapshot, named clean, before the first job runs and with Include RAM unticked. One taken later preserves whatever that job left; one taken before registration drops the runner off the forge on every rollback; one with RAM restores the guest's clock along with it, and a VM that thinks it is last month cannot verify a certificate.

Rolling back after anything suspicious is not optional. Rolling back on a schedule is the more valuable of the two, because it does not depend on your having noticed - and it is the one that stops happening once doing it by hand gets old. Proxmox schedules backups, not rollbacks, so it is a cron entry on the node; proxmox/README.md §6 has the line, why each part of it is what it is, how to confirm it is still running, and why the baseline needs refreshing every month or two.

The runner

File Install as
examples/act-runner-privileged.yaml the runner's config.yaml, on its own host
examples/docker-compose-privileged.yml the runner itself

Give it one label that nothing else carries, and grant it to as few repositories as you can stand. There is no --dns to set and no job network to join: its jobs are in the host's namespace, which is the grant the tier exists for.


Both tiers

Register scope is the boundary; the label is not

A dedicated label routes honest jobs to the runner you prepared. It stops nothing else, because runs-on: lives in the workflow file and the workflow file lives in the branch. A branch can name any label it likes, so a session that changes runs-on: dejima-ci to runs-on: ubuntu-latest and opens a pull request has stepped outside everything this tool installs - provided some runner answering that label will take the job.

Whether one will depends on where your runners are registered:

Registered at Which repositories it serves
Instance every repository on the instance
Organization that organization's repositories
Repository that repository

Gitea has no per-repository runner ACL; its FAQ points at registration scope as the control instead, and gitea.com registers its own runners for a single organization so they will not run other repositories' jobs.

A repository's CI posture is that of the weakest runner it can reach, not the one its workflow names. So if the instance hosting these repositories also offers general-purpose instance-level runners, hardening this one buys less than it appears to. In rough order of preference:

  1. Do not register general-purpose runners at instance level on an instance that hosts repositories receiving agent-authored branches.
  2. Harden every runner that repository can reach, so landing elsewhere changes nothing that matters.
  3. Move the repository to an instance or organization whose reachable runners are all hardened.

Verify

Two failures matter here and both look exactly like success from inside a job: a job container that was handed the Docker socket, and a job that can reach an off-list address. Neither shows up as a red run.

dejima ships test/ci-runner.bats for exactly this, run by its own CI as runner-env (sandbox) and runner-env (host-docker). Point it at your runner and it will tell you which half is wrong - if the DNS checks pass while the egress checks fail, the resolver is fine and the packet filter is not.

Use

Confined-tier commands. A privileged host installs none of them, which is the point of that tier rather than a gap in it.

dejima-ci-allowlist check    # what would change, without changing it
dejima-ci-allowlist render   # fetch, validate, install, reload if changed
dejima-ci-allowlist show     # what is installed right now
dejima-ci-firewall           # reapply the egress rules

Exit codes are distinguishable on purpose, because a timer's alerting has to tell "the allowlist moved" from "the forge is unreachable":

0 nothing to do
1 check only - there is a pending change
2 a fault: bad configuration, an unreachable forge, a malformed entry

A configuration problem lists every problem at once, each naming the setting, the path it points at, and what belongs there.

How it fails

Every failure leaves the previous allowlist in place. That direction is deliberate, and it is the thing to preserve in any change:

  • A forge that does not answer, or answers with anything other than 200 or 404, aborts the run. A 404 is a repository with no declaration, which is ordinary and skipped.
  • A collection that comes out empty is refused rather than installed. An allowlist of nothing looks exactly like a working one until a job needs something.
  • An entry that is not a plain domain name aborts the run rather than being skipped. A silently dropped entry reads as "that domain was never declared" and sends whoever debugs it to the wrong repository.
  • A rendered file that fails dnsmasq --test is not installed. A resolver that will not start is a job network with no DNS at all.
  • An unchanged allowlist is not reinstalled, so the log stays useful for spotting when it actually changed.

The firewall refuses to run without ipset, iptables and ip6tables, and says what their absence costs. Without that check its first command fails and the state it leaves behind resembles a working one: DNS still refuses unlisted names, because that is the resolver's doing rather than the firewall's, while every job has unrestricted egress.

Tests

bats test/

Runs against a stubbed curl and dnsmasq.

Further reading

docs/ Index of everything below
docs/reference.md The two docker.sock settings, CONFIG_FILE, the /data volume, compose networks against job networks, sharing a job network between runners, why the privileged runner is separate, why the resolver is per-runner, the cache server
proxmox/README.md Building the sacrificial VM: node firewall rules, bootstrap, snapshot, scheduled rollback
dejima's docs/ci.md Why a CI runner needs any of this, and why there is no approval gate

License

GPL-3.0-or-later. See LICENSE.