Commit Graph
9 Commits
Author SHA1 Message Date
branderson 6796ff1cd1 Name both halves of the reason an SSH push has no transport
The paragraph on how strongly the GitHub push block is enforced rests on two
properties of dejima and named one. The allowlist decides IPv4; what makes
port 22 unreachable at all is that dejima also blocks IPv6 in the container.
An allowlist naming one address family says nothing about the other, so
without the second property a session reaches GitHub's v6 ranges on any port
it likes and the argument collapses without a line of it changing.

Both are dejima's to keep true, which is why they are written down here. A
claim about another repository's behaviour fails by that behaviour changing
while the paragraph depending on it does not - the same shape as the node
templates inheriting an IPv4-only rule list from reasoning that had been
made correctly twice elsewhere.

Found by the agent working in dejima, who read the equivalent claim there,
found it did not make an IPv4-only assumption, and pointed out that the
IPv4-shaped version might be on this side instead. It was.
2026-08-26 02:54:10 -07:00
branderson afbe827e0f Describe the boundary as it is, not as it changed
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m12s
tests / compose-schema (pull_request_target) Successful in 47s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
The threat model and the runbook are descriptions of the current tool, not
a record of what moved. The IPv6 sections read as a changelog: what the rule
list used to name, and what was reachable before. That belongs in the pull
request, which has it.

Also drops 'already' where it described this repository's own history rather
than the relationship between two layers, which is what the sentence was
about.

Same rules, same reasoning, stated in the present tense.
2026-08-25 16:38:49 -07:00
branderson ec521afd84 Bound the LAN over IPv6, and over the range a tailnet uses
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / tests (pull_request_target) Successful in 1m11s
tests / compose-schema (pull_request_target) Successful in 46s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
The node firewall's DROPs named IPv4 ranges only, so the boundary this
runbook calls "the boundary" did not apply to IPv6 at all. That is open by
default on every network including ones with no IPv6 in them: every Linux
host brings up an fe80:: address on every interface whether or not anything
routes v6, and a daemon bound to :: answers on it. Neighbours were reachable
across the segment with all four DROPs in force and `ip -4 route` showing
nothing unusual.

bin/dejima-ci-firewall already blocked v6, and it is a different position. It
covers a confined job reaching off its bridge. It is not in front of the VM's
own traffic, and on the privileged tier it is not in front of the jobs
either, since those run in the host's network namespace. The node rule is the
one covering root on the VM, which on that tier is the ordinary case rather
than the worst one.

Dropped whole rather than enumerated, because IPv6 has no RFC 1918. A v6
network is numbered out of a globally routable delegated prefix, so no
equivalent range list can be written here - fc00::/7 catches ULAs most
networks do not use and misses the prefix a neighbour is actually on. On the
node rather than in the guest, for the reason the whole file is on the node.

Also drops 100.64.0.0/10, where a tailnet is numbered.

Rule 10 gets a by-hand check in proxmox/README.md rather than an automated
one: it asks ff02::1 who is on the link and counts the replies. Nothing in
this repository runs inside the VM - test/ci-runner.bats runs inside a job,
which is the wrong vantage point, since a confined job's traffic is stopped
by dejima-ci-firewall before it reaches the tap. That asymmetry with the
sister project is noted where the test is.

Docs: the threat model gains a section on the two address families and where
each one's rule sits, a "Not bounded" list collecting the negative space in
one place, two new entries under "Rejected, with reasons", and a quarterly
review cadence with conditions to check rather than re-argue.

Tested with `bats test/` (100 passing).
2026-08-25 14:50:52 -07:00
branderson e7360de7ad Restate how the push block is enforced, not just that it exists
tests / runner-env (confined) (pull_request_target) Successful in 1m40s
tests / tests (pull_request_target) Successful in 2m13s
tests / compose-schema (pull_request_target) Successful in 1m54s
tests / runner-env (privileged) (pull_request_target) Successful in 1m35s
The entry linked out for the whole of it, which left a reader unable to judge the control without leaving the page. Whether not granting push access counts as a control at all depends on how it is enforced, and that is a fact rather than an argument: GitHub's ranges are allowlisted on 443 only, so a session clones and calls the API normally while port 22 is unreachable and an SSH push has no transport, whatever git-push-guard.sh catches.

The argument stays behind the link and the mechanism comes back. Arguments drift when they are copied - this repository proved that within a day - and a port number does not.
2026-08-10 20:47:26 -07:00
branderson 1faf85be5c Point at dejima's push reasoning, and pin the maturity disclaimer
The push-access entry carried its own copy of the argument, and one line of it had already drifted from dejima's: it said the eager agent's pull request is something the operator should have decided rather than discovered, which puts the cost on the operator and implies approving it would be fine. dejima's version puts the cost where it lands - on maintainers already triaging a flood of unsolicited AI-generated pull requests - and says the block exists so it cannot happen, not so you get to approve it first.

That is the drift a second copy produces, found within a day of writing it. The entry now keeps only what this document needs, states that closing the CI chain follows from the principle rather than being the reason for it, and links to dejima's section for the argument so there is one copy to keep right.

The maturity disclaimer is carried in the same three places dejima and the sister project use, with test/disclaimer.bats pinning it. Duplicated rather than linked, which is the opposite of the decision above and deliberate: a disclaimer behind a link is one the reader does not see, and its whole value is being on the page where the security argument is being made.
2026-08-10 20:47:26 -07:00
branderson 2af7b15fe6 Give push-blocking its own reason rather than borrowing this one
The entry said not granting push access is a real answer and that it is why dejima blocks GitHub push by default. The second half was a wrong attribution: it read the default as a mitigation for the CI chain, which is this document's concern and not the reason the default exists.

The reason is narrower and holds whether or not CI exists anywhere: a session not being used to push to a forge should not be able to push to it. The case that makes it concrete is an eager agent rather than a compromised one - it finds a real bug in an unrelated project and helpfully pushes a fix branch to somebody else's repository. Nothing about that is an attack and the change might even be good, and it is still something the operator should have decided rather than discovered. Closing the CI chain falls out of that principle rather than being the point of it.

dejima's default is stated as the observable behaviour it is - GitHub push blocked at the network layer, port 22 unreachable without --allow-github-push, so there is no transport regardless of what any guard catches - rather than as evidence for a motive its own documentation does not claim.
2026-08-10 20:47:04 -07:00
branderson e7bffbcbbc State that reviewing main is a dependency, not an aside
The approval-gate entry explained that review cannot gate a CI run, and stopped there. Read on its own that invites the conclusion the tool is meant to replace reviewing what you merge, which is the opposite of dejima's stated position and of what this repository actually relies on.

dejima-ci-allowlist reads .dejima/allowed-domains from origin/main, never from a branch or a local checkout, and treats what it finds there as already reviewed. That is the entire mechanism by which a branch cannot widen its own egress: the answer comes from somewhere a branch cannot write. The only way content reaches main is a pull request somebody merged, so the allowlist is exactly as trustworthy as the merges are, and merging a diff unread is not a lapse this tool compensates for - it is how a widened allowlist arrives, with the tool then serving it faithfully.

A What this tool assumes section says that, in the same place the sister project states its own precondition, and links to dejima's responsible-use.md for the safe-against-responsible distinction. The approval-gate entry now says explicitly that it is about the run and nothing else: review still gates the merge, that is still the operator's job, and neither control substitutes for the other.
2026-08-10 20:47:04 -07:00
branderson 9e3367b927 Give the real reason there is no approval gate
The entry said a human approving a diff they have not read is a control in name only, and that reviewers who do read it are the bottleneck the agent workflow removes. That is wrong twice: it is not the reason, and it reads as a judgement that code review is too weak to gate CI, which implies CI could be gated on review if you were willing to wait.

It cannot. dejima's docs/ci.md works through every version of an approval gate and each fails mechanically rather than through inattention. Changing the trigger does not help because the workflow file lives in the tree the branch controls. Dispatching by hand does not, because a token that can open a pull request generally covers the dispatch endpoint. Leaving the runner stopped does not, because a job that cannot land on your runner lands on someone else's. Gitea's own approval requirement comes closest and applies to pull requests from forks, which is exactly what a session pushing a branch to the same repository is not.

Review is downstream of the run by construction. The chain from a session writing code to a runner executing it needs no operator action at any step, so by the time there is a diff to read it has already run.

The entry now also names the control that does work and that the old wording obscured: not granting the session push access at all. No push, no branch, no pull request, no run. It is why dejima blocks GitHub push by default and it is a legitimate answer for some projects. This repository exists so it is not the only one - granting push access should be a decision about how you want to work rather than a decision to accept unbounded code execution on your network.
2026-08-10 20:47:04 -07:00
branderson 9656b8c6ea Bring across what the sister project grew, and write down the threat model
A parity check against dejima-devbox-hardener found gaps in one direction only. This closes them.

The runbook's verification step asked the reader to probe a named host on their own LAN, which means writing down something worth attacking on a machine the same page says to treat as already lost. It now probes the default gateway: already in the routing table so it reveals nothing, guaranteed alive where an arbitrary host is not, and inside the ranges the DROPs cover so it must be unreachable as a destination while traffic through it is untouched. The note that a blocked first probe means nothing when the other three also fail comes with it.

test/firewall.bats is new. This repository's own runbook calls rule ordering the one thing here that is easy to get wrong and nothing checked it. Every exception is to an address inside a range the same file drops, the console appends new rules at the bottom, and a dead rule is silent. It also pins policy_out: ACCEPT, since inverting it is the change the template argues against at length, and the DHCP pair as both-or-neither.

test/links.bats is new, and this repository has more documentation than the sister one.

units.bats had a test that passes when systemd/ is empty or absent, because grep failing to open a file gives the same non-zero status the assertion wanted. In a file whose subject is a check that silently asserted nothing, that was worth fixing.

docs/threat-model.md is new and written for this project. The adversary is a pull request. The tiers have different boundaries and the table says which. A privileged job reaches root by design, with no exploit, which is why that tier's containment is entirely external. Rollback is named as the distinctive control and as explicitly not a detection mechanism, since it destroys evidence along with the intruder.

On escape it says the accurate thing: an escape grants more than the container had, because root is more than a job container has, and it does not leave the model, since root on the runner VM is already a row in the table reached on the privileged tier with no exploit at all.
2026-08-10 20:47:04 -07:00