fix(kepler): SSH-poort alleen bereikbaar vanaf de gateway - #101
Open
ericwout-overheid wants to merge 14 commits into
Open
fix(kepler): SSH-poort alleen bereikbaar vanaf de gateway#101ericwout-overheid wants to merge 14 commits into
ericwout-overheid wants to merge 14 commits into
Conversation
init-firewall.sh accepteerde inbound vanaf het hele bridge-subnet zonder poortfilter, waardoor elke andere container op datzelfde compose-netwerk poort 22 rechtstreeks bereikte — langs de 127.0.0.1-publish om. Een gepubliceerde poort komt binnen vanaf de gateway, dus die blijft werken. De regel staat er alleen bij ENABLE_SSHD=true; zonder sshd luistert er niets. Smoke-test asserteert de regel, en README en ADR beschrijven het restrisico niet langer als open.
ericwout-overheid
requested review from
jonrust-minbzk,
loek-rijksoverheid and
mreuvekamp
as code owners
August 6, 2026 09:41
ericwout-overheid
marked this pull request as draft
August 6, 2026 09:48
…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.
De assertie las SSHD_PORT uit dezelfde env die init-firewall.sh gebruikt en vergeleek de firewall dus met zichzelf. Belangrijker: kepler.conf bevat geen Port-directive, dus sshd luistert op de OpenSSH-default en SSHD_PORT is nergens aan gekoppeld. Verplaatst een latere wijziging sshd naar een andere poort, dan beschermt de regel een dichte poort terwijl de echte openstaat — en de test meldt groen. De poort komt nu uit sshd -T, en de test vergelijkt die met SSHD_PORT. Een verschil is precies het scenario waarvoor de variabele bestaat. Verder: - De bronbeperking wordt tegen het echte gateway-adres gehouden. ! -s met een heel subnet of een verkeerd adres slaagde eerder ook. - Een gefaalde exec valt niet meer stil terug op poort 22. - SSHD_PORT wordt gevalideerd zoals de andere externe waarden in het script; een range als 1:65535 zou anders alle inbound TCP dichtzetten. - De grep koppelt --dport en -j DROP niet meer aan elkaar in één patroon, zodat een extra match in de regel de check niet stil laat missen.
De match op de DROP-regel eiste geen -p tcp, dus een UDP-DROP op dezelfde poort zou geslaagd zijn terwijl TCP open bleef voor het hele bridge-subnet. Een gefaalde sshd -T gooide zijn foutmelding weg, waardoor de assertie de reden nooit kon noemen. De README benoemt dat deze regel Kepler zelf blokkeert als de port-forward met een ander bronadres binnenkomt — een hangende verbinding is dan het enige symptoom.
ericwout-overheid
added a commit
that referenced
this pull request
Aug 6, 2026
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.
Met `sshd -T 2>&1 | awk` gaan de foutregels de awk in, die alleen regels doorlaat die op ^port matchen — de melding bleef dus leeg terwijl de vorige commit-boodschap beweerde dat de reden zichtbaar werd. De exitcode van een pipeline is bovendien die van awk en dus altijd 0; alleen de regex ving het. De ruwe uitvoer wordt nu eerst opgevangen en pas daarna gefilterd.
De exitcode van een commando-substitutie met een pipe of een awk is die van het laatste onderdeel, en awk faalt hier nooit — de guard eromheen kon dus niet vuren. Het filteren staat nu los van het ophalen, en de regex-check heeft een eigen tak met de ruwe uitvoer erin. printenv geeft een lege string en exitcode 1 als de variabele niet gezet is, en een lege string bij een gefaalde exec; die twee waren niet te onderscheiden. De fallback op 22 gebeurt nu in de container, gelijk aan wat init-firewall.sh doet, zodat een lege uitkomst alleen nog een echte fout kan zijn.
…irewall
De README noemde rootless podman met pasta als runtime die de port-forward met
een ander bronadres doorzet. Dat is niet nagemeten, en pasta zet host-loopback
standaard juist om naar het gateway-adres. De voorwaardelijke formulering
blijft, de runtime-naam gaat eruit.
De smoke-test gebruikte ${SSHD_PORT-22} waar init-firewall.sh ${SSHD_PORT:-22}
gebruikt: bij een lege waarde draaide de firewall op 22 terwijl de test een
afwijking meldde.
SSHD_PORT en de Port-directive waren twee bronnen voor hetzelfde nummer. Liepen ze uit elkaar — een eigen override, een .env, een afgeleide compose die ENABLE_SSHD wel zet en SSHD_PORT niet — dan viel de firewall terug op 22, stond de echte poort open voor het hele bridge-subnet, en meldde het log dat SSH tot de gateway beperkt was. Fail-open met een succesmelding. init-firewall.sh leest nu de Port-directive uit dezelfde drop-in die sshd leest, met sshd's eigen default 22 als die er niet staat. De env-variabele en de vergelijking ertegen in de smoke-test vervallen; de vergelijking met `sshd -T` blijft. De README suggereerde dat NET_RAW de enige spoofing-route is en dat de bronuitzondering alleen de port-forward toelaat.
Port is cumulatief in sshd_config: elke regel voegt een luisterpoort toe. De extractie nam alleen de eerste, dus bij twee Port-regels beschermde de firewall er één en stond de andere open voor het hele bridge-subnet. De smoke-test had dezelfde beperking en werd daar groen. Beide lopen nu over alle poorten. sshd accepteert Port 22 en Port=22, hoofdletterongevoelig; de = -vorm werd niet herkend en viel stil terug op 22. Waarden worden nu ook met 10# genormaliseerd, zodat Port 0022 net als bij sshd 22 oplevert in plaats van een harde fout die de container niet laat starten. De toewijzing van drop_line en subnet_line hing aan een grep die bij afwezigheid rc 1 geeft; met pipefail legde dat het script neer op precies de regressie die de takken eronder moesten melden, vóór de resterende secties en zonder dump_logs. De INFO-regel bij een geslaagde start noemde poort 22 als vaste waarde, wat weer een tweede bron voor hetzelfde nummer was.
… SSH sshd luistert ook op poorten die uit een ListenAddress met expliciete poort komen, en doet Include over alle drop-ins in sshd_config.d. De extractie keek alleen naar Port in kepler.conf, dus een ListenAddress 0.0.0.0:2200 leverde een open poort zonder DROP op — met een groene smoke-test erbij, want die had dezelfde beperking. Beide lezen nu ook listenaddress; een kaal IPv6-adres telt niet mee, want dat is een adres en geen poort. De parse zat buiten elke voorwaarde en liet bij een ongeldige waarde de hele container niet meer starten, ook met ENABLE_SSHD uit. Hij draait nu alleen als sshd daadwerkelijk aangezet is. De README beschreef het restrisico verkeerd: blind spoofen krijgt de TCP- handshake niet rond en opent dus geen sessie. De aanval die wel werkt is ARP-poisoning door een buurcontainer met NET_RAW, en die is niet blind.
… ENABLE_SSHD af Het comment beloofde dat de regels onvoorwaardelijk gezet worden en de code zette er twee alinea's verderop een ENABLE_SSHD-gate omheen. Beide punten zijn geldig: default-deny hoort niet van een runtime-vlag af te hangen, en een parse-fout in een config die niemand gebruikt hoort de container niet onstartbaar te maken. De regels worden nu altijd gezet. Alleen een onbruikbare poortwaarde valt anders uit: fataal zodra sshd echt aanstaat, want dan is niet vast te stellen welke poort beschermd moet worden; een waarschuwing met overslaan als sshd uit staat. sshd_config zelf wordt meegelezen: Port is cumulatief over bestandsgrenzen heen, dus een directive daar telt op bij die uit de drop-ins.
…wall-gateway # Conflicts: # claude-sandbox/entrypoint-root.sh # claude-sandbox/kepler/smoke-test.sh
ericwout-overheid
added a commit
that referenced
this pull request
Aug 7, 2026
* experiment(kepler): sshd als claude op poort 2222, geen root-daemon 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. * fix(kepler): UsePAM uit, pidfile naar /run, eerlijk over wat de opzet 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. * fix(kepler): host-key-directory doorloopbaar houden voor claude 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. * fix(kepler): sleutel vóór de sshd-start, rechten kloppend gedocumenteerd 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. * fix(kepler): geslaagde start meldde ten onrechte dat sshd niet luistert 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. * fix(kepler): offset-guard kon niet vuren, dode statuswaarde opgeruimd 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. * fix(kepler): tail-tak weghalen die niet kon vuren tail op een herestring faalt niet, dus de fail-tak eromheen was onbereikbaar. * fix(kepler): dode statustoekenningen weg, comments gelijk aan de branch 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. * fix(kepler): prijs van de niet-root-opzet volledig opschrijven 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. * docs(kepler): claims gelijktrekken met wat de code waarmaakt 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. * fix(kepler): API-sleutel uit sshd, bounding set weer bewaakt, gaten benoemd 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. * fix(kepler): assertie weghalen die alleen op hardening kan vuren 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. * docs(kepler): premisse bij de capability-assertie kloppend maken 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. * docs(adr): niet-root sshd uitgewerkt en verworpen, implementatie eruit 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 09:44
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 eerste openstaande punt uit ADR 0001 §4.2 over de sshd.
Wat er wijzigt
init-firewall.shaccepteerde inbound vanaf het hele bridge-subnet zonder poortfilter:Daardoor bereikt elke andere container op hetzelfde compose-netwerk de SSH-poort rechtstreeks, langs de
127.0.0.1-publish om. Er staat nu een DROP vóór die regel die inbound naar die poort beperkt tot de gateway, alleen bijENABLE_SSHD=true.Het poortnummer komt uit de
Port-directive in/etc/ssh/sshd_config.d/kepler.conf— dezelfde drop-in die sshd zelf leest — met sshd's eigen default 22 als die directive er niet staat. Eén bron dus, en geen aparte environment-variabele die stil uit de pas kan lopen: viel de firewall terug op 22 terwijl sshd elders bindt, dan stond de echte poort open voor het hele subnet, mét een geruststellende regel in het log.De smoke-test asserteert positie,
-p tcp, de exacte bronbeperking en de overeenkomst metsshd -T; README en ADR beschrijven dit niet langer als open restrisico.Waarom gestapeld en niet in #92
Dit raakt
init-firewall.sh, dat álle sandbox-gebruikers deelt, en de aanname "een gepubliceerde poort komt binnen vanaf de gateway" is platformafhankelijk. Los te testen en los terug te draaien is hier meer waard dan het meenemen in de grote PR.Wat dit níet dekt
Bron-IP-filtering op een gedeelde bridge is geen authenticatie. Een container met
NET_RAWkan de gateway spoofen, een metNET_ADMINbereikt hetzelfde met een SNAT-regel; beide blijven blind, maar voor het openen van een sessie is dat genoeg. De uitzondering laat bovendien élk pakket toe met het bridge-adres als bron, niet alleen de port-forward. De drempel gaat omhoog, de poort gaat niet dicht — zo staat het ook in de README.Geverifieerd
shellcheck --severity=warningopinit-firewall.shenkepler/smoke-test.sh: schoonunshare --map-root-user --net) en de assertielogica tegen de gerenderdeiptables -S-uitvoer gedraaid: de bron staat vóór-p tcp, de positiecontrole klopt, en een DROP zonder! -sof op UDP wordt roodawk-extractie van dePort-directive tegen een drop-in mét en zonder die regelExpliciet NIET geverifieerd
./kepler/smoke-test.sh -i ~/.ssh/keplermoet groen blijven, inclusief de nieuwe assertie.🤖 Generated with Claude Code