Skip to content

fix(kepler): auth-log naar het volume, niet naar de container-laag - #102

Merged
ericwout-overheid merged 19 commits into
experiment/kepler-ssh-sandboxfrom
fix/kepler-sshd-log-naar-containerlog
Aug 7, 2026
Merged

fix(kepler): auth-log naar het volume, niet naar de container-laag#102
ericwout-overheid merged 19 commits into
experiment/kepler-ssh-sandboxfrom
fix/kepler-sshd-log-naar-containerlog

Conversation

@ericwout-overheid

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

Copy link
Copy Markdown
Collaborator

Gestapeld op #92.

Wat er wijzigt

sshd logde met -E /var/log/sshd.log naar de container-laag, en dat bestand is bij een recreate weg. Het auth-spoor staat nu op een eigen volume: /var/log/sshd/sshd.log als 640 root:claude in een 750 root:claude-directory.

Waarom daar, en niet in de containerlog of in /home/claude

  • Containerlog: die bestaat per container, dus --force-recreate geeft een nieuwe — precies het probleem dat deze PR wegneemt. En na de privilege-drop schrijft claude zelf in die stroom: een verzonnen Accepted publickey for claude … is er niet van een echte te onderscheiden, en oudere records zijn weg te duwen door de log vol te schrijven.
  • Een pad in /home/claude: het bestand is root-eigen te maken, maar hernoemen vereist alleen schrijfrecht op de ouder, en die is van claude. Hij kan de directory verplaatsen en er een eigen sshd.log voor in de plaats zetten; sshd schrijft dan door op de open fd, terwijl iedereen die het gedocumenteerde pad leest — inclusief de smoke-test — de vervalsing ziet. Een spoor wegschuiven valt op, een spoor vervangen niet.
  • /var/log op een eigen volume: die ouder is niet schrijfbaar voor claude, dus die omlegging kan daar niet, en het volume zorgt dat het spoor een recreate overleeft. Het staat los van claude-home, dus het blijft ook staan als je dat volume weggooit om een environment-variabele te wijzigen.

Geen tijdstempel

OpenSSH zet zelf geen tijd in een logregel. De normale weg loopt via openlog()/syslog(), waarbij alleen de kale boodschap wordt doorgegeven en de syslog-daemon tijd, hostnaam en tag toevoegt; het pad achter -e/-E schrijft dezelfde boodschap met write(), zonder die laag. Er draait hier geen logdaemon, dus de regels dragen geen datum.

Een logdaemon toevoegen is geen kwestie van een pakket installeren: een syslog-socket is per ontwerp voor elk lokaal proces schrijfbaar, dus de ingesloten partij zou er zelf regels in kunnen zetten met een zelfgekozen tijd — precies wat deze opzet moet uitsluiten. Dat vraagt een eigen afweging over het rechtenmodel en staat als #110.

Smoke-test

Toetst het auth-event zelf — Accepted publickey for claude na de geslaagde login uit sectie 3 — en dat de regel een key-fingerprint draagt, want dat is de enige identificatie in het spoor: het bron-IP is door NAT altijd de gateway. Alleen regels die ná de login zijn bijgekomen tellen mee, want het bestand overleeft een recreate.

De rechten-assertie dekt bestand, directory én /var/log zelf, en toetst daar de mode en niet de eigenaar: is die ouder ooit groep- of other-schrijfbaar, dan is het hele pad om te leggen terwijl stat op de eerste twee klopt.

LogLevel VERBOSE wordt getoetst waar dat kan — in de build tegen sshd -T en in de config-sectie van de smoke-test. Niet via de fingerprint: OpenSSH verhoogt het niveau naar INFO zodra de authenticatie slaagt en plakt de fingerprint hoe dan ook aan die regel.

Wat dit níet dekt

  • Geen tijdstempel — zie hierboven en Auth-spoor van de SSH-server bevat geen datum en tijd #110.
  • Geen rotatie. Het bestand groeit onbegrensd; verwijder je het sshd-log volume, dan is het weg. Let op de asymmetrie met claude-home: dat volume is external, sshd-log niet, dus docker compose down -v wist juist het spoor en laat de data staan.
  • Een vol filesystem legt het spoor stil. sshd controleert het resultaat van zijn schrijfactie niet, dus logins gaan door zonder dat er iets bijkomt. claude kan dat uitlokken via zijn eigen home, die hetzelfde filesystem deelt.
  • Geen bewijsmateriaal. Wie root in de container heeft kan het spoor alsnog herschrijven, en het verlaat de sandbox niet. Voor een forensisch spoor moet het naar een bestemming buiten de container.
  • Eén sleutel, geen persoon. Herleidbaarheid gaat tot de fingerprint.

Geverifieerd

  • bash -n en shellcheck --severity=warning op alle gewijzigde scripts: schoon
  • Het ontbreken van een tijdstempel achter -E, en dat de fingerprint los staat van LogLevel, nagelezen in de upstream-bron (log.c, auth.c) — niet uit documentatie afgeleid
  • De prepare_auth_log-logica los nagespeeld: verse aanmaak (640), bestaande log blijft behouden, te ruime rechten worden hersteld, en een symlink op de plek van de directory of het bestand wordt geweigerd
  • YAML-geldigheid van compose.override.kepler.yml met de volume-toevoeging, en dat de mount niet botst met de bestaande volumes of met de podman-override

Expliciet NIET geverifieerd

  • Geen image gebouwd en geen container gedraaid. Of het bestand met de verwachte rechten op het volume ontstaat en of sshd erheen schrijft, blijkt pas uit een echte run.
  • De smoke-test is niet gedraaid; de assertie op Accepted publickey is niet tegen echte sshd-output getoetst.
  • Of claude in de praktijk niet bij /var/log/sshd kan, is beredeneerd uit de rechten en niet in een draaiende container geprobeerd.

🤖 Generated with Claude Code

/var/log/sshd.log stond op de container-laag: een recreate wiste hem en er was
geen rotatie. Met -D -e gaan de auth-events naar de containerlog, waar de
rotatie van de logdriver geldt en de operator toch al kijkt.

-D betekent dat sshd niet daemoniseert, dus hij gaat met & naar de achtergrond.
De exit-code zegt daarmee niets meer over succes; de bestaande wachtlus op
/run/sshd.pid blijft het signaal, aangevuld met een kill -0 op het proces.

Smoke-test toetst nu op sshd-regels in de containerlog in plaats van op de
rechten van een bestand dat niet meer bestaat.
De containerlog was de verkeerde bestemming, om twee redenen die bij het
reviewen naar boven kwamen.

De aanleiding klopte niet: een containerlog bestaat per container, dus een
recreate wist hem net zo goed als een bestand op de container-laag. Het probleem
dat deze wijziging moest oplossen bleef dus bestaan.

En het maakte het spoor onbetrouwbaar. Na de privilege-drop schrijft claude zelf
naar diezelfde stroom, dus een verzonnen Accepted publickey-regel is in docker
logs niet te onderscheiden van een echte, en oudere records zijn weg te duwen.
De gelogde partij kon daarmee zijn eigen auth-spoor vervalsen.

De log staat nu op het claude-home volume als 640 root:claude: overleeft een
recreate, en alleen root schrijft. De smoke-test toetst het echte auth-event
(Accepted publickey na de login uit sectie 3) in plaats van de startbanner, plus
de rechten van het bestand.

De README is eerlijk over wat er niet gedekt is: geen rotatie, en een log die de
gelogde partij kan lezen is geen bewijsmateriaal zolang het het enige exemplaar
in de sandbox is.
@ericwout-overheid ericwout-overheid changed the title fix(kepler): auth-events naar de containerlog i.p.v. een eigen bestand fix(kepler): auth-log naar het volume, niet naar de container-laag Aug 6, 2026
De assertie op Accepted publickey stond in sectie 1b, terwijl de eerste login pas
in sectie 3 gebeurt. Op een vers volume gaf dat een vals-rood met een misleidende
diagnose — en dat is het standaardpad, want ENABLE_SSHD aanzetten vereist een
volume-recreate. Op een hergebruikt volume was het omgekeerde erger: het bestand
overleeft een recreate en wordt nooit getrunceerd, dus één geslaagde run hield de
check voorgoed groen, ook als de logging daarna gesloopt werd.

De assertie staat nu ná de login en telt alleen de regels die sinds een offset
van vóór die login zijn bijgekomen.

prepare_auth_log had een type-guard op de directory maar niet op het bestand.
[[ -f ]] dereferencet, dus een symlink op die plek liet het auth-spoor ergens
anders belanden en zette root een bestand naar keuze op root:claude 640. claude
bezit /home/claude en kan die directory aanmaken voordat sshd voor het eerst
start. Nu dezelfde guard als bij de host-key.

De faalmelding noemt het pad en de waarschijnlijke oorzaken, en de README claimt
niet langer dat claude het spoor niet kan wissen: aanpassen en verwijderen van
regels kan hij niet, de directory wegschuiven wel.
Een mislukte meting viel op 0 terug, en 0 betekent tel het hele bestand.
Daarmee zou een Accepted publickey van een vorige run de assertie voorgoed
groen houden — precies het gat dat de offset moest dichten. Alleen bestand
bestaat nog niet levert nu 0 op; een onleesbare meting is een bevinding.

De symlink-guard vermeldt dat hij geen hardlink dekt.
De smoke-test toetste alleen het bestand. De 750 op de directory is juist wat
claude belet regels te verwijderen, want het bestand staat in zijn eigen home;
zakt die ooit terug naar 770 of naar eigenaar claude, dan bleef de check groen
terwijl de premisse van deze PR weg is.

De oorzakenlijst bij een mislukte start verwees naar de containerlog, een
restant van de tussenliggende opzet. sshd schrijft naar SSHD_LOG en daar staat
de reden.

Het comment claimde dat het spoor niet te verdringen is; de README zegt terecht
dat de directory wel weg te schuiven is.
Een pad in /home/claude is root-eigen te maken, maar de ouder is van claude:
hij kan de directory hernoemen en er een eigen sshd.log voor in de plaats
zetten. sshd schrijft dan door op de open fd, terwijl wie het
gedocumenteerde pad leest — inclusief de smoke-test — de vervalsing ziet.
Een spoor wegschuiven valt op, een spoor vervangen niet. Het bestand staat
nu op een eigen volume onder /var/log, waar alleen root de directory kan
hernoemen.

OpenSSH schrijft achter -E en -e het formaat "%s%s%.*s\\r\\n": geen datum, geen
tijd, geen pid. Een auth-spoor zonder tijd beantwoordt niet wanneer iemand
binnenkwam. sshd schrijft daarom naar een fifo en een root-leesluis zet de
tijd ervoor; valt die luis weg, dan blokkeert sshd op een volle fifo en is
er geen login zonder spoor.
De rechten-assertie toetst nu ook de ouder van de logdirectory: is die van
claude, dan kan hij het hele pad omleggen en leest iedereen die het
opvraagt zijn versie, terwijl stat op directory en bestand groen blijft.

De login-assertie eist een ISO-8601-tijdstempel voor de regel. Valt de
leesluis weg, dan komt de kale OpenSSH-regel in het bestand en zou een
grep op 'Accepted publickey' groen blijven zonder dat er nog te herleiden
is wanneer de login plaatsvond.
Het comment en de README beweerden dat een weggevallen leesluis de fifo laat
vollopen en sshd blokkeert. Dat gebeurt niet: schrijven naar een fifo zonder
lezer geeft EPIPE, sshd zet SIGPIPE op SIG_IGN en gooit de returnwaarde van
write() weg, dus regels van de daemon verdwijnen geruisloos. Nieuwe verbindingen
heropenen het logpad en blokkeren wel, vóór authenticatie — SSH valt dan stil
zonder melding, en de ingesloten partij kan dat uitlokken door het volume vol te
schrijven. Dat staat nu bij de beperkingen in plaats van als garantie.

claude-home is external en sshd-log niet, dus docker compose down -v wist het
auditspoor en laat de data staan. De README benadrukte alleen de andere kant.

Verder: /run is onder Docker geen tmpfs, en een laatste regel zonder afsluitende
newline viel uit de leesluis weg.
… rechtzetten

De bullet zei dat de sleutel een sshd-sessie niet bereikt. Dat is waar over
sshd's env-opbouw, maar elk proces van claude draagt de variabele en een sessie
is dezelfde uid.

De beschreven trigger klopte niet: claude heeft geen schrijfrecht op de
logdirectory en kan het volume niet rechtstreeks vullen. Wat wel geldt en
scherper is: één mislukte schrijfactie beëindigt de luis definitief, want hij
erft errexit.
Niet elk proces van claude draagt de variabele: de SSH-sessie juist niet, en dat
is precies waarom een aanvaller een ander proces moet uitlezen.
# Conflicts:
#	claude-sandbox/README.md
#	claude-sandbox/entrypoint-root.sh
sshd schreef met -E naar een fifo waar een bash-leesluis de tijdstempel bijzette.
Dat leverde een tijd op, maar met een prijs: één mislukte schrijfactie doodde de
luis definitief, waarna regels van de daemon geruisloos verdwenen en nieuwe
verbindingen blokkeerden op het openen van de fifo — vóór authenticatie, dus SSH
viel stil zonder melding. De keten kon dat bovendien niet afkeuren, want de
functie eindigde op een achtergrondjob en gaf altijd 0 terug.

busybox syslogd doet waar hij voor bestaat: hij zet de tijd erbij en roteert op
grootte, en als hij wegvalt blokkeert hij sshd niet, want syslog() schrijft naar
een socket en is best-effort. De start controleert dat /dev/log er is voordat
sshd begint, anders zouden de eerste regels alsnog verdwijnen.

De padbescherming blijft ongewijzigd: eigen volume onder een root-eigen
/var/log, 640 root:claude in een 750 root:claude-directory.

Twee dingen die deze opzet niet oplost en die nu bij de beperkingen staan: een
weggevallen syslogd is stil, en het syslog-formaat draagt geen jaartal.
De syslog-variant liep op twee dingen vast die los staan van de tijdstempel.
busybox syslogd leest zonder -f zijn eigen /etc/syslog.conf, en dat bestand komt
mee met het pakket; matcht daar een regel, dan schrijft hij daarheen en slaat de
-O-bestemming over, waarmee het spoor op de container-laag belandt in plaats van
op het volume. En de socket wordt bij het binden op 0666 gezet, dus elk lokaal
proces kan er regels in schrijven met een zelfgekozen tijdstempel — precies wat
deze opzet moet uitsluiten.

Beide zijn te ondervangen, maar niet zonder het rechtenmodel van syslog te
verbouwen, en dat hoort niet in een wijziging over de logbestemming. sshd
schrijft daarom rechtstreeks met -E naar het root-eigen bestand: dat houdt de
invariant die deze wijziging beoogt, zonder daemon, socket of routeringsconfig.

De prijs is dat de regels geen tijd dragen. Dat staat nu expliciet bij de
beperkingen, met de reden erbij: OpenSSH laat de tijd aan syslog over.

De smoke-test toetst nu de key-fingerprint in plaats van een tijdstempel — dat
is de enige identificatie die het spoor draagt, want het bron-IP is door NAT
altijd de gateway.
De controle op de key-fingerprint pretendeerde te bewaken dat LogLevel VERBOSE
in de drop-in staat. Dat kan hij niet: OpenSSH verhoogt het niveau naar INFO
zodra de authenticatie slaagt en plakt de fingerprint hoe dan ook aan die regel,
dus de tak vuurt nooit voor het scenario in zijn eigen foutmelding. De directive
wordt nu getoetst waar dat wel kan — in de build tegen sshd -T, en in de sectie
van de smoke-test die de effectieve config nagaat. De fingerprint-controle
blijft, met een melding die zegt wat hij echt aantoont.

De ouder van de logdirectory werd op eigenaar getoetst terwijl hernoemen aan de
mode hangt: een groep- of other-schrijfbare /var/log laat het hele pad omleggen
terwijl stat op directory en bestand klopt.

Verder aan de beperkingen toegevoegd: een vol filesystem legt het spoor stil
zonder dat sshd dat merkt, en de regels eindigen op CRLF. De ankering van
regels via de containerlog gold alleen binnen één containerleven, terwijl het
spoor juist een recreate overleeft.
De rechten-assertie toetste alles behalve de eigenschap waar deze wijziging om
draait: dat het spoor op een eigen mount staat. Zonder de volume-regel maakt de
entrypoint dezelfde directory met dezelfde rechten aan op de container-laag en
blijft de hele sectie groen, terwijl het spoor geen recreate meer overleeft.

Wat LogLevel VERBOSE toevoegt was te ruim opgeschreven: geweigerde gebruikers en
herhaalde mislukkingen staan al op het standaardniveau, en afgebroken
verbindingen loggen als fout. Wat er echt bijkomt is de eerste mislukte poging
per verbinding van een toegelaten gebruiker, plus de key-probe.

De fingerprint-controle is geen guard op de drop-in maar op het formaat; de
melding zei het eerste. Het comment bij de ouder-assertie ontkende de helft van
wat de code eronder doet — mode en eigenaar tellen allebei.

De claim dat /run onder podman een tmpfs is en onder Docker niet, is niet
nagemeten en is vervangen door wat wel vaststaat.
@ericwout-overheid
ericwout-overheid marked this pull request as ready for review August 7, 2026 09:25
@ericwout-overheid
ericwout-overheid merged commit aee2c38 into experiment/kepler-ssh-sandbox Aug 7, 2026
@ericwout-overheid
ericwout-overheid deleted the fix/kepler-sshd-log-naar-containerlog branch August 7, 2026 09:25
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