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.
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:
- Do not register general-purpose runners at instance level on an instance that hosts repositories receiving agent-authored branches.
- Harden every runner that repository can reach, so landing elsewhere changes nothing that matters.
- 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 --testis 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.