Skip to content

docs(adr): niet-root sshd uitgewerkt en verworpen - #103

Merged
ericwout-overheid merged 17 commits into
experiment/kepler-ssh-sandboxfrom
experiment/kepler-sshd-zonder-root
Aug 7, 2026
Merged

docs(adr): niet-root sshd uitgewerkt en verworpen#103
ericwout-overheid merged 17 commits into
experiment/kepler-ssh-sandboxfrom
experiment/kepler-sshd-zonder-root

Conversation

@ericwout-overheid

@ericwout-overheid ericwout-overheid commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

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.

  • §3 Overwogen en verworpen krijgt de niet-root-sshd erbij, met de afweging die tot het verwerpen leidde.
  • §4.2 beschrijft de root-daemon niet langer als iets wat open blijft, maar als een keuze met de bijbehorende mitigatie, en verwijst naar §3.

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 — chroot naar /run/sshd, uid naar de rechtenloze user sshd. Zonder root gebeurt geen van beide, en komt pre-auth-code direct uit als claude, met de host-bindmount, de claude login-credentials en de container-env binnen bereik. De winst is dat bugs in de bevoorrechte listener claude opleveren in plaats van root; de prijs is dat bugs in de pre-auth-code claude opleveren in plaats van niets. In een sandbox die juist claude moet 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 zonder known_hosts-mismatch — inclusief AllowAgentForwarding, dus met bereik tot de SSH-agent aan de andere kant. Het auth-spoor komt in een stroom waar elk claude-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..HEAD raakt 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

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
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 ericwout-overheid changed the title experiment(kepler): sshd als claude op poort 2222, geen root-daemon docs(adr): niet-root sshd uitgewerkt en verworpen Aug 7, 2026
@ericwout-overheid
ericwout-overheid marked this pull request as ready for review August 7, 2026 07:38
@ericwout-overheid
ericwout-overheid merged commit 632cacb into experiment/kepler-ssh-sandbox Aug 7, 2026
@ericwout-overheid
ericwout-overheid deleted the experiment/kepler-sshd-zonder-root branch August 7, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant