docs(adr): niet-root sshd uitgewerkt en verworpen - #103
Merged
ericwout-overheid merged 17 commits intoAug 7, 2026
Merged
Conversation
Poort 2222 vereist geen root, dus sshd start na de privilege-drop als claude. Er draait daarmee geen root-daemon in de container: een pre-auth-lek in OpenSSH levert claude op in plaats van container-root, en de setpriv-bounding-set is niet langer nodig omdat het proces sowieso geen capabilities heeft. De prijs is dat OpenSSHs eigen privilege separation vervalt; die vereist root om het pre-auth-proces af te splitsen. De host-key blijft van root, nu 640 root:claude: sshd moet hem kunnen lezen maar de ingesloten partij mag hem niet vervangen. Pidfile verhuist mee naar een pad dat claude kan schrijven. Smoke-test keert de root-assertie om en toetst een lege effectieve capability-set; de tunneltest wijst naar de nieuwe poort.
ericwout-overheid
requested review from
jonrust-minbzk,
loek-rijksoverheid and
mreuvekamp
as code owners
August 6, 2026 09:45
ericwout-overheid
marked this pull request as draft
August 6, 2026 09:48
ericwout-overheid
added a commit
that referenced
this pull request
Aug 6, 2026
…n bron De regel pinde poort 22 hard. Verplaatst een latere wijziging sshd naar een andere poort — zoals #103 doet — dan beschermt de DROP een dichte poort en staat de nieuwe open voor het hele bridge-subnet, zonder dat de smoke-test daarover valt. SSHD_PORT is nu de enige plek waar het nummer staat; de kepler-override geeft hem door. De gate op ENABLE_SSHD vervalt: een DROP op een poort waar niets luistert kost niets, en default-deny hoort niet van een runtime-vlag af te hangen. De smoke-test toetste alleen of er ergens een DROP-regel stond. Een regel ná de subnet-ACCEPT doet niets en een regel zonder bronuitzondering sluit juist iedereen buiten; beide bleven groen. Nu worden positie en bronbeperking apart getoetst, met een eigen melding per geval, en de fout van het uitleescommando wordt niet meer weggegooid. README en ADR zeggen niet langer dat een buurcontainer er niet bij kan: bron-IP op een gedeelde bridge is een drempel, geen authenticatie — een container met NET_RAW kan de gateway spoofen.
… kost UsePAM stond nog op de Debian-default yes. sshd_config(5) stelt dat je sshd met UsePAM aan niet als niet-root kunt draaien: de sessiemodules verwachten root. Dat is de dragende aanname van deze PR, dus de drop-in zet hem nu expliciet uit en de build asserteert dat. De pidfile-directory was 750 root:claude. Groep krijgt daarmee r-x, geen schrijfrecht, terwijl mijn eigen comment eronder zei dat sshd er zijn pidfile moest schrijven. Gevolg: sshd luistert, de wachtlus loopt vol, en de operator leest starten mislukt. De pidfile staat nu in /run/sshd-claude, dat claude bezit; .ssh-host blijft 700 root:root, want schrijfrecht daar zou betekenen dat de host-key te unlinken en vervangen is. De opruiming bij een mislukte start is terug. Smoke-test: de poortbinding-check keek nog naar container-poort 22, die niet meer bestaat — de belangrijkste security-assertie faalde dus altijd. Ook de assertie op het inmiddels dode /var/log/sshd.log is weg, de capability-check toetst weer permitted naast effective, en een ontbrekend sshd-proces krijgt een eigen melding in plaats van draait hij toch als root. README en ADR benoemen nu de tweede prijs van deze opzet: claude kan de host-key lezen. Dat is genoeg om sshd te doden, zelf op dezelfde poort te binden met diezelfde sleutel en zonder known_hosts-mismatch door te gaan — waarmee ook PermitOpen en AllowTcpForwarding onder controle staan van de partij die ze zouden moeten beperken. Dode chgrp/chmod op de wegwerpsleutel in de Dockerfile zijn weg.
Ik had de directory bij het oplossen van het pidfile-probleem teruggezet naar 700 root:root. Zonder x-bit voor de groep kan claude er niet doorheen en dus de host-key niet openen, ongeacht de 640 op het bestand zelf — sshd zou niet starten. 750 root:claude is de juiste stand: doorlopen en lezen mag, aanmaken en unlinken niet. Dat laatste is waarom het geen 770 wordt; de pidfile staat daarom in /run.
De README beschreef de host-key-directory als 700 root:root. Dat is precies de stand waarin sshd als claude niet start — nagemeten op debian 13 met OpenSSH 10.0p2: no hostkeys available, geen pidfile. De code zet 750 root:claude; de README zei het tegenovergestelde en nodigde uit om terug te hardenen naar een opzet die stil breekt. authorized_keys wordt nu geschreven vóór sshd start. Beide stappen staan in hetzelfde script, dus het venster waarin sshd luistert zonder dat er een sleutel staat is te vermijden in plaats van te documenteren. Verder: - De smoke-test toetste eigenaar en mode maar niet de groep, terwijl de groep juist het dragende mechanisme is: 750 root:root zou geslaagd zijn en sshd alsnog breken. Nu %U %G %a. - usepam no staat in de runtime-directive-lijst. Met UsePAM aan bindt sshd de poort gewoon en breekt hij pas bij de sessie-opzet, dus dit is de enige plek waar zo n regressie zichtbaar wordt. - De pass-tekst zei nog 0700 waar de assertie 750 toetst. - Vier verwijzingen naar de root-fase-opzet rechtgezet, en het comment over /run als verse tmpfs: dat geldt onder podman, niet onder Docker — de rm in entrypoint.sh doet het werk. - De diagnose bij een mislukte start noemt ook de directory-rechten. - ADR-sectie staat niet langer onder de rootfase, en de PR-coördinatie is uit de README gehaald: die beschrijft de eindtoestand.
Door het verplaatsen van het authorized_keys-blok naar vóór de sshd-start is de status op dat punt altijd ready; running wordt pas verderop gezet. De INFO-tak was daarmee onbereikbaar en elke geslaagde start printte een waarschuwing die verwees naar een eerdere melding die niet bestaat. Het onderscheid met een echt mislukte start was daarmee ook weg. Verder: - SSHD_PORT=2222 in de kepler-override, gelijk aan de Port-directive. Zonder dat beschermt de firewallregel uit #101 na samenvoegen een dode poort terwijl 2222 open staat voor het hele bridge-subnet. - De build eist precies één port-regel: Port is cumulatief, dus een tweede regel zou sshd op twee poorten laten luisteren terwijl beide controles groen blijven. - Er is weer een assertie dat een geslaagde login gelogd wordt, met een offset zodat een event van een vorige start niet meetelt. Die was met het oude logbestand verdwenen, terwijl dat juist de reden voor -e is. - Het comment over het venster claimt niet meer dat er geen pad is waarop sshd zonder sleutel luistert; dat pad bestaat, het is alleen blijvend in plaats van een venster. - ADR noemt de derde prijs: het auth-spoor wordt vervalsbaar, want sshd schrijft als claude naar dezelfde stroom als claude zelf.
De guard rond de offsetmeting hing aan de exitcode van een pipeline, en die is die van wc — altijd 0. De fail-tak en de lege-offset-afhandeling waren daarmee dood, precies de klasse fout die de vorige commit in de INFO-tak repareerde. De logs-aanroep staat nu buiten de pipe. running is op dit punt onbereikbaar: de root-fase geeft alleen disabled, absent, invalid, failed of ready door, en running wordt pas verderop gezet. Uit de case en de conditie gehaald zodat er geen tak overblijft die niet kan vuren. Twee ADR-verwijzingen stonden nog op 2.3.4, en het comment in de override verwees naar een PR-nummer in plaats van naar het script dat het gedrag levert.
tail op een herestring faalt niet, dus de fail-tak eromheen was onbereikbaar.
SSHD_STATUS wordt na de start nergens meer gelezen; de twee toekenningen in dat blok waren dode stores. Het comment in de override beschreef een firewallregel die op deze branch niet bestaat, terwijl README en ADR in dezelfde branch het tegenovergestelde zeggen. De variabele stuurt hier alleen de poortkeuze. UsePAM no ontbrak in de hardening-opsomming, terwijl die directive de voorwaarde is voor de hele niet-root-opzet en de smoke-test hem bewaakt.
De prijs van het wegvallen van privilege separation stond als "de scheiding tussen pre- en post-auth vervalt". Concreet betekent het dat pre-auth-code ongechroot als claude draait, met de host-bindmount, de login-credentials en de container-env binnen bereik, in plaats van in een lege chroot als de user sshd. De ruil is tweezijdig en de verliesrichting stond er niet. Het auth-spoor staat niet alleen onder controle van sshd zelf: elk proces van claude kan via /proc/<pid>/fd/1 in dezelfde stroom schrijven. Het ADR noemde beide restrisico's niet, en claimde bij de host-key een controle die alleen rotatie afdekt, geen geheimhouding. rm -f op het pidfile faalt als daar een directory staat, en claude mag in die tmpfs-loze directory schrijven; met errexit werd dat een herstartlus.
De build-guard op meerdere Port-regels motiveerde zich met een firewallregel die op deze branch niet bestaat. De guard blijft terecht: Port is cumulatief, dus een tweede regel laat sshd ook op de niet-bedoelde poort luisteren. De pidfile-route is niet de enige faalroute die de container meeneemt: een fifo op het publieke host-key-pad blokkeert de root-fase net zo goed. De host-key-garantie zat toegeschreven aan de directory-mode. Wie schrijfrecht op /home/claude heeft kan de hele directory hernoemen; wat dat afvangt is de controle op type en eigenaar bij elke start. Het comment over de taakverdeling tussen de fasen beschreef nog de opzet waarin de root-fase sshd zelf startte.
…enoemd De pre-auth-code draait hier ongechroot als claude; zonder env-scrub staat de API-sleutel in de omgeving van precies dat proces. De capability-assertie toetste alleen permitted en effective. In de root-variant verkleint setpriv de bounding set van sshd; hier gebeurt dat niet, en sinds de assertie CapBnd losliet bewaakte niets meer wat sshd daaraan meekrijgt. Hij wordt nu vergeleken met die van PID 1, zodat een verruiming opvalt zonder een vaste waarde vast te leggen. Twee dingen die in de beperkingen ontbraken: de root-variant weigert sshd te starten als het auth-logbestand niet aan te maken is, en die eis bestaat hier niet. En het overnemen van de host-identiteit reikt verder dan de eigen container: in een zelf opgezette sshd staat AllowAgentForwarding aan, dus met ForwardAgent aan de andere kant komt de ingesloten partij bij de SSH-agent op de host.
De CapBnd-vergelijking met PID 1 kon niet falen zoals bedoeld: een bounding set kan niet groeien, hij wordt bij fork geërfd en blijft over execve staan, en sshd start hier als plain child zonder setpriv ertussen. De enige manier om hem te laten afwijken is sshd juist verkleinen — precies de hardening die de root-variant heeft. Een guard die de verbetering afstraft is erger dan geen guard. De controle op permitted en effective blijft; die vangt wel iets. De API-sleutel-bullet zei dat de sleutel een sshd-sessie niet bereikt. In deze opzet draait de pre-auth-code ongechroot als claude, dus de env-scrub verplaatst het gat één /proc-lezing verderop in plaats van het te sluiten.
Een bounding set kan wel degelijk groeien, namelijk in een nieuwe user-namespace — en dat is precies wat rootless podman in deze image doet. Voor sshd geldt het niet, want die unshared er geen; de conclusie blijft dus staan, de onderbouwing niet.
# Conflicts: # claude-sandbox/README.md # claude-sandbox/entrypoint-root.sh
Deze branch bevatte de uitwerking van een sshd die ná de privilege-drop als claude op poort 2222 draait, om het punt 'de daemon draait als root' uit ADR 0001 par. 4.2 te adresseren. Bij review viel de ruil de verkeerde kant op: OpenSSH splitst zijn pre-auth-proces alleen af als het als root start, dus zonder root komt pre-auth-code direct uit als claude — met de host-bindmount en de credentials binnen bereik, in plaats van in een lege chroot. In een sandbox die juist claude moet insluiten weegt dat zwaarder dan geen root-daemon hebben. De zorg achter het oorspronkelijke punt was bovendien al afgedekt: de bounding set van de daemon is verkleind, dus een lek levert geen container-root op die de firewall kan flushen. Wat overblijft is de afweging zelf, vastgelegd bij de verworpen alternatieven, zodat navolgbaar blijft waarom de daemon als root draait.
ericwout-overheid
marked this pull request as ready for review
August 7, 2026 07:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Gestapeld op #92. Sluit het punt "de daemon draait als root" uit ADR 0001 §4.2 — door het als bewuste keuze vast te leggen in plaats van als openstaand risico.
Wat er wijzigt
Alleen
docs/adr/0001-maven-testcontainers-sandbox-isolatie.md. Geen code.Waarom verworpen
Deze branch bevatte eerst de uitwerking: sshd ná de privilege-drop, als
claude, op poort 2222. Bij review viel de ruil de verkeerde kant op.OpenSSH splitst zijn pre-auth-proces alleen af als het als root start —
chrootnaar/run/sshd, uid naar de rechtenloze usersshd. Zonder root gebeurt geen van beide, en komt pre-auth-code direct uit alsclaude, met de host-bindmount, declaude login-credentials en de container-env binnen bereik. De winst is dat bugs in de bevoorrechte listenerclaudeopleveren in plaats van root; de prijs is dat bugs in de pre-auth-codeclaudeopleveren in plaats van niets. In een sandbox die juistclaudemoet insluiten, weegt die prijs zwaarder.De zorg achter het oorspronkelijke punt is bovendien al afgedekt: sshd start via
setpriv --bounding-set=-net_admin,-net_raw, dus een lek in de daemon levert geen container-root met de capabilities waarmee de firewall te flushen is.Daar kwamen bij review gevolgen bij die geen van beide kanten van de ruil zijn: de host-key wordt leesbaar voor
claude, waarmee hij de daemon kan doden en dezelfde identiteit kan afleveren zonderknown_hosts-mismatch — inclusiefAllowAgentForwarding, dus met bereik tot de SSH-agent aan de andere kant. Het auth-spoor komt in een stroom waar elkclaude-proces in kan schrijven, en de eis "geen auth-log dan geen sshd" vervalt.Een niet-root sshd kan alleen sessies leveren als zichzelf. Dat is geen detail van deze uitwerking maar een eigenschap van de opzet, dus er is geen variant die de bezwaren wegneemt.
Geverifieerd
De boom is regel voor regel gelijk aan die van #92, op dit ene bestand na:
git diff experiment/kepler-ssh-sandbox..HEADraakt alleen het ADR.Expliciet NIET geverifieerd
Niets uit te voeren — de PR bevat geen code. De uitspraken over OpenSSH's privilege separation komen uit de upstream-broncode (
sshd-auth.c), niet uit een draaiende sshd.🤖 Generated with Claude Code