feat(kepler): geharde SSH-server in de sandbox voor GitKraken Kepler-remote - #92
Draft
jonrust-minbzk wants to merge 109 commits into
Draft
feat(kepler): geharde SSH-server in de sandbox voor GitKraken Kepler-remote#92jonrust-minbzk wants to merge 109 commits into
jonrust-minbzk wants to merge 109 commits into
Conversation
Maven+Testcontainers in de sandbox via nested rootless Podman i.p.v. de host-agent-bridge. Sluit de container->host code-execution van #44: geen bridge, geen Docker-socket, geen --privileged. Cross-platform, lichter dan sysbox/microVM. PoC moet seccomp/fuse/subuid/egress uitwijzen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#44) INSTALL_PODMAN build-ARG (default false) installeert podman + fuse-overlayfs + uidmap + passt/slirp4netns en zet subuid/subgid + rootless storage.conf. compose.override.podman.yml.example levert /dev/fuse + seccomp-stand. PoC-map met sample Testcontainers-project, smoke-test.sh en README (run-stappen, ALLOWED_DOMAINS-egress, fallbacks). Niet live testbaar in deze omgeving (geen runtime); gebruiker draait smoke-test op de host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
newuidmap faalde met "write to uid_map failed: Operation not permitted". Root cause: AppArmor docker-default-profiel medieert writes naar /proc/<pid>/uid_map op Debian/Ubuntu-hosts. Userns (initieel), CAP_SETUID/SETGID (bounding set), setuid-root newuidmap en NoNewPrivs=0 waren allemaal in orde; AppArmor was de enige overgebleven blokkade. seccomp stond al op unconfined, label=disable dekt alleen SELinux. README-fallbacktabel bijgewerkt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nde hosts (#44) PoC wees uit dat naïeve multi-uid rootless podman niet werkt op gehardende Ubuntu/Tuxedo (apparmor_restrict_unprivileged_userns=1): host blokkeert élke userns-map, en de privileged newuidmap-range faalt apart. Herziene aanpak: - Single-uid modus: geen subuid/subgid voor claude -> podman mapt alleen eigen uid als root, gebruikt newuidmap niet. ignore_chown_errors=true in storage.conf. - Custom AppArmor-profiel (flags=(unconfined) { userns, }) + setup-host.sh laadt het op de host. Restrictie blijft systeembreed aan; alleen deze container krijgt userns. Override verwijst naar het profiel i.p.v. apparmor=unconfined. - Spec uitgebreid met PoC-bevindingen, per-setup matrix en security-trade-off. - README herzien: per-setup flow, setup-host.sh, fallbacks. Nog te verifieren op een gehardende host (kan niet in deze werkomgeving). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…44) PoC-voortgang op gehardende host: AppArmor-userns-profiel lost userns op met sysctl=1 (self-map OK), single-uid podman info werkt, en met ignore_chown_errors slaagt image-extractie. Resterend: pasta-netwerk faalde op ontbrekend /dev/net/tun. - compose-override: /dev/net/tun device erbij (NET_ADMIN had de sandbox al). - entrypoint: schrijft rootless storage.conf (single-uid + fuse-overlayfs + ignore_chown_errors) idempotent bij start, want een bestaand named volume schaduwt de baked-in image-versie. - README-fallbacks: pasta/tun + storage.conf-shadow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iner) (#44) Nested container start faalde op crun "open /proc/sys/net/ipv4/ping_group_range: Read-only file system": podman zet die sysctl default, maar /proc/sys is RO in de outer container. Entrypoint schrijft nu ook containers.conf met default_sysctls=[] zodat crun geen sysctls probeert te zetten. README-fallback bijgewerkt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nested container start faalde op crun "mount proc: Operation not permitted": Docker maskeert /proc-paden, waardoor de kernel (mount_too_revealing) een nieuwe procfs in de geneste mount-namespace weigert. systempaths=unconfined heft de masked/RO /proc-paden op de outer container op. Peelt outer-sandbox-hardening verder af — security-trade-off staat in de override-comment en spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mvn test (surefire) sloeg *IT stil over -> groene build zonder dat de Testcontainers-test ooit liep. Hernoemd naar *Test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#44) PoC GESLAAGD (geverifieerd: Tests run: 1, Failures: 0, Errors: 0 — Testcontainers alpine startte via podman). Testcontainers maakt een bridge-netwerk → netavark riep default `nft` aan, niet in de image. iptables-driver gebruikt iptables-nft (al aanwezig). Entrypoint zet dit nu in containers.conf. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…44) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ild-bevestiging (#44) Andere sessie draaide een echt Quarkus-project (Redis-stack Dev-Services): 289+46 tests groen via podman in de sandbox. Gap: containers met port-wait (Postgres/Redis) time-outten omdat Testcontainers de netavark bridge-gateway (10.88.0.1) als host resolvet terwijl rootless podman op localhost publisht. Fix: TESTCONTAINERS_HOST_OVERRIDE=localhost in smoke-test + README + spec. De alpine-GenericContainer miste dit (geen port-wait). Host-agent hiermee niet meer nodig in de sandbox (blijft fallback). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…allback) (#44) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… nodig (#44) Geverifieerd: Docker-default seccomp faalt op 'cannot clone: Operation not permitted' (podman re-exec gebruikt clone(CLONE_NEW*)). seccomp=unconfined is dus nodig, geen gold-plating. Spec: hardening-verfijning-sectie met het tailored-seccomp-vervolgpad + waarom apparmor/systempaths nauwelijks te versmallen zijn. De veiligheid zit in de scoping, niet in het wegpoetsen van de inherente relaxaties. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
defaultAction=ALLOW (zodat podman's clone/unshare/mount/setns werken — Docker-default brak hierop) + ERRNO-deny op de gevaarlijke kernel-escape-syscalls die Docker-default ook blokkeert (module-load, kexec, reboot, iopl/ioperm, swap, klok-zetten, bpf, perf_event_open, open_by_handle_at, acct, _sysctl, vm86). Strikt veiliger dan unconfined zonder podman te breken. Override verwijst naar seccomp/podman-sandbox.json (pad relatief t.o.v. compose-bestand). Nog te verifieren op de host (recreate + smoke). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ smoke groen) (#44) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#44) #44-DoD: afweging vastgelegd als ADR (docs/adr/0001-...): podman-in-docker als voorkeur, host-agent als fallback, security-balans, goedkope hardening, C/D uitgesteld. maven-mcp-agent.md verwijst nu naar het podman-alternatief en documenteert de host-agent expliciet als fallback + de goedkope hardening (dedicated least-priv user, projecten buiten gedeelde map, bind 127.0.0.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oc-podman→podman (#44) Niet langer een PoC maar een voorstel tot echt gebruik dat de Maven host-agent beoogt te vervangen (PR-review + collega-tests; bij succes kan de host-agent weg, mits objectief beter). Teksten ontdaan van PoC-framing in README, spec, ADR, maven-mcp-agent, override- en script-comments. Map host-agents/maven/poc-podman → podman (+ seccomp-pad in override). smoke-test eindmelding "PoC GESLAAGD" → "OK — Testcontainers werkt". Plan-doc blijft als historisch record. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
De host-agent is een DEKKINGS-fallback (hosts waar podman-in-docker nog niet kan + Mac/Win te verifieren), geen security-upgrade. De eerdere claim "voor wie de outer-relaxaties niet wil" klopt niet: die relaxaties verbreden het kernel-oppervlak van de container (escape vereist nog een exploit), terwijl de host-agent code direct op de host draait — voor een op container-escape beduchte gebruiker juist zwakker. ADR + maven-mcp-agent bijgewerkt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…44) Issue-comment merkt terecht op: /dev/fuse is een (klein) kernel-risico en hoort pas toegevoegd bij traagheid, niet als default. Tot nu toe was fuse-overlayfs + /dev/fuse de default. Nu: - Default storage = vfs (geverifieerd: single-uid + [storage.options.vfs] ignore_chown_errors=true, smoke groen, geen /dev/fuse). - Configureerbaar via .env: PODMAN_STORAGE_DRIVER=vfs|overlay + PODMAN_FUSE_DEVICE=/dev/null|/dev/fuse. Entrypoint genereert storage.conf uit de driver; valt terug op vfs + waarschuwing als overlay gevraagd is maar /dev/fuse ontbreekt (footgun-guard). - compose-override: /dev/fuse vervangen door ${PODMAN_FUSE_DEVICE:-/dev/null} (no-op default) + PODMAN_STORAGE_DRIVER env-passthrough. - Dockerfile baked storage.conf → vfs; fuse-overlayfs blijft geïnstalleerd zodat opt-in geen rebuild vergt (inert zonder /dev/fuse). - Spec/ADR/README/.env.sample bijgewerkt: vfs default, fuse opt-in + security-noot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e sourcet sdkman (#44) - Dockerfile: inline #-comments uit de \-gecontinueerde case-RUN gehaald (werkte op buildkit, maar fragiel op classic builder) → naar comment-blok erboven. - smoke-test.sh: sourcet nu zelf sdkman + faalt met duidelijke melding als mvn ontbreekt, zodat het standalone werkt (niet alleen via de README-wrapper). Standalone geverifieerd: smoke groen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ebreid (#44) Security-review: de blocklist miste escape-relevante, cap-gated syscalls die Docker-default wél blokkeert en die podman/JVM-Testcontainers niet nodig hebben. Toegevoegd: userfaultfd, io_uring_{setup,enter,register}, NUMA (mbind/set_mempolicy/migrate_pages/move_pages), process_vm_{readv,writev}, process_madvise, fanotify_init, kcmp, pidfd_getfd, en module-load-rest (create_module/query_module/get_kernel_syms). ptrace bewust NIET geblokkeerd (Docker-default laat het toe). Docs: vergelijkingstabel-rij geactualiseerd, seccomp-omschrijving + ptrace-noot, SELinux label=disable in de security-balans. Seccomp is create-time → uitgebreide blocklist nog op een recreate te bevestigen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…abel + .example (#44) Drift gevonden in de spec "Artefacten"-tabel (stond buiten de ontwerp-disclaimer): beschreef nog subuid baked + fuse-overlayfs storage + devices:[/dev/fuse]. Nu in lijn met de werkelijke config (single-uid, vfs-default, /dev/net/tun + ${PODMAN_FUSE_DEVICE}). ADR/spec verwijzen nu naar compose.override.podman.yml.example (met .example) i.p.v. de losse naam. Rename poc-podman→podman volledig schoon, geen PoC-refs in living docs (alleen in het historische plan-doc), links resolven. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
compose.override.podman.yml.example → compose.override.podman.yml. Het bestand wordt direct met -f meegegeven (geen kopieer-template), dus .example was misleidend. Wordt niet auto-geladen (Compose auto-merget alleen compose.override.yml), dus blijft opt-in. Refs in README/spec/ADR/plan/Dockerfile/.env.sample/entrypoint bijgewerkt; header-noot "kopieer naar compose.override.yml" vervangen door expliciete -f-instructie + waarschuwing tegen hernoemen. Linux-override blijft .example (dat is wél een kopieer-template). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bevestigd op een Mac met Podman-machine (applehv → Fedora CoreOS):
rootless podman-in-podman draait en de Maven+Testcontainers-smoke-test
slaagt end-to-end.
- Nieuwe compose.override.podman-macos.yml: apparmor=unconfined (Fedora
CoreOS heeft geen AppArmor/userns-hardening), label=disable voor SELinux,
absoluut seccomp-pad via ${PWD} (podman leest het profiel client-side).
- Hernoem compose.override.podman.yml -> compose.override.podman-linux.yml
en werk alle verwijzingen bij (README, setup-host.sh, entrypoint.sh, ADR,
spec, plan).
- README: macOS-sectie met de drie afwijkingen (podman-compose i.p.v.
podman compose, BUILDAH_FORMAT=docker, absoluut seccomp-pad), fallback-
rijen voor de macOS-fouten, en per-setup matrix bijgewerkt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent-opt-in (#44) PODMAN_FUSE_DEVICE was redundant naast PODMAN_STORAGE_DRIVER. Compose kan een devices-entry niet uit de waarde van een var afleiden (geen conditionals in interpolatie), dus i.p.v. een tweede env-var is /dev/fuse nu een uitgecommentarieerde regel in de overrides die je uncomment voor overlay. PODMAN_STORAGE_DRIVER blijft de enige .env-knop; entrypoint waarschuwt + valt terug op vfs als overlay gekozen is zonder het device (geen broken start). Linux- + macOS-override, entrypoint, .env.sample, README en spec bijgewerkt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…44) Nieuwe gids docs/kata-linux-maximale-isolatie.md: Kata Containers (eigen guest-kernel per container) als de kernel-escape-laag op Linux-native — de plek waar die grens by default ontbreekt. Inclusief: voorwaarden (KVM/nested virt), eerlijke noot over Docker-Engine↔Kata-integratiefrictie (shim-v2 vs containerd/ nerdctl), interactie met de podman-hardening (host-AppArmor-profiel waarschijnlijk overbodig onder Kata), en caveats. Mac/Windows: kernel-grens al aanwezig (VM via Docker Desktop/Rancher/podman machine) → Kata niet nodig, met WSL2/projects-mount kanttekeningen. Niet getest in deze omgeving; gemarkeerd als te verifiëren. Gelinkt vanuit ADR (Optie D) + podman-README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Een niet-numerieke -p of --tunnel-port kwam pas veel later naar boven als een ssh-fout over een bad forwarding specification, of als een mislukte connect in de pre-flight.
…le weg - Een RSA-sleutel onder 3072 bits werd met een geslaagd-melding weggeschreven en daarna door sshd geweigerd (RequiredRSASize). De enige aanwijzing was Permission denied (publickey), en de smoke-test wees naar de verkeerde oorzaak. De entrypoint weigert de sleutel nu vooraf, de README noemt de eis en de smoke-test noemt hem als diagnose. - ADR 0001 kende de sshd niet, terwijl de code naar §2.3 verwijst en het isolatieverhaal daar hoort te staan. Nieuwe §2.3.4 voor de rootfase-start en een punt in §4.2 over het inbound-oppervlak dat dit toevoegt. - Dezelfde onderbouwing stond drie tot vier keer uitgeschreven. De ADR is nu de plek; op de aanroeplocaties staat de regel met een verwijzing. - Poortvalidatie las 08 als octaal en lekte een rauwe bash-fout voor de nette melding.
…evangen De reden waarom authorized_keys in de gebruikersfase geschreven wordt was op drie plekken onjuist opgeschreven: StrictModes accepteert root-eigendom van de padcomponenten. De werkelijke reden is dat ~/.ssh van claude moet blijven, zodat wie KEPLER_SSH_PUBKEY leeg laat het bestand zelf kan beheren op het volume. - Poortvalidatie begrensde de lengte niet, waardoor een waarde boven 64 bits de rekenkundige vergelijking liet wrappen en alsnog doorging. - RequiredRSASize 3072 staat in kepler.conf en werd hard herhaald in de entrypoint. De smoke-test toetst die directive nu, zodat de twee niet stil uiteen kunnen lopen. - ADR 2.3.4 benoemt weer waarom de eigendomscontrole op de host-key bestaat.
- Een mislukte login stapte uit voor het blok dat de sshd-auth-log dumpt, terwijl daar met LogLevel VERBOSE juist de reden staat. De dump is nu een functie die ook voor de harde exits draait. - De rechtencheck op authorized_keys eiste exact 600/700 en stond achter de logintest. Daardoor was hij onbereikbaar precies wanneer hij ertoe deed, en gaf hij vals rood op het zelfbeheer-pad dat de README zelf aanraadt. Nu op de invariant die sshd afdwingt: niet schrijfbaar voor groep of anderen. - De root-fase geeft zijn uitkomst door via SSHD_STATUS. De gebruikersfase schreef anders een sleutel weg met een geslaagd-melding terwijl er geen sshd luisterde, en gaf bij een ongeldige ENABLE_SSHD advies over een var die wel gezet was. - Een lege of onbruikbare host-key op het volume passeerde de controle; sshd startte dan niet en geen van de gemelde oorzaken klopte. - ONGEWIJZIGD gelaten stond ook in de melding als er niets te bewaren viel. - Smoke-test meldt het als de entrypoint de sleutel uit .env geweigerd heeft; de run was anders groen terwijl een oude sleutel van het volume het werk deed. - De build noemt de ontbrekende directive bij naam in plaats van alleen een exit-code, en de CI-comment claimt niet meer dan een gecachte laag waarmaakt.
De vorige commit beweerde twee dingen die niet in de code stonden: de gebruikersfase ging nog steeds op ENABLE_SSHD af in plaats van op de doorgegeven SSHD_STATUS, en de conditionele keep-tekst bestond niet. Beide alsnog toegepast. Daardoor schreef de gebruikersfase bij een mislukte sshd-start nog altijd een sleutel weg met een INFO-regel, en las een gebruiker op een vers volume dat zijn authorized_keys ONGEWIJZIGD was gelaten terwijl er niets stond. Verder uit dezelfde ronde: - De rechtencheck in de smoke-test liet test -O vallen en werd daarmee blind voor precies de regressie die hij bewaakte: een root-geschreven authorized_keys met mode 644 in een 755-directory kwam er groen doorheen. Eigendom staat er weer bij, naast de rechten-invariant. - De zelfbeheer-waarschuwing controleert ook de home-directory; sshd loopt de hele keten na en weigert elk niveau dat voor anderen schrijfbaar is. - dump_logs toont de reden als de container gestopt is, in plaats van die met een generieke tekst te vervangen. - De sleutelcheck grept niet meer op de afwezigheid van een waarschuwing binnen --tail 200, maar positief op de INFO-regel in het volledige log, met dezelfde canary als de expect-no-sshd-modus. Dat dekt ook de weigerpaden die de naam van de variabele niet noemen, en is ongevoelig voor een stale waarschuwing. - grep -q achter een pipe kan onder pipefail een treffer als misser rapporteren wanneer de producer SIGPIPE krijgt; omgezet naar een here-string.
…eren - De sectie die controleert of de sleutel uit .env geaccepteerd is, gate-de op een host-shell-variabele. Het script leest .env niet, dus onder de gedocumenteerde aanroep was die var leeg en deed de hele sectie niets. Nu wordt de container-env gelezen — dezelfde waarde die de entrypoint bij de laatste start zag — en worden vingerafdrukken vergeleken. Dat is ook ongevoelig voor logrotatie, logdriver en een herstart. - dump_logs verving de foutregel nog steeds door een eigen diagnose die onwaar kan zijn: een onbereikbare daemon of een verkeerde containernaam werd gemeld als container draait niet meer. Beide bronnen tonen nu hun eigen stderr. - Sectie 0 duidde elke fout van ps als container draait niet; een daemon die niet draait of ontbrekende socket-rechten krijgen nu hun eigen melding. - De failed-tak in entrypoint.sh was onbereikbaar en beweerde het tegendeel van wat de code doet. Weg, met de rationale erbij waarom failed als actief telt. - Het schrijffaalpad claimte nog steeds een bestaande sleutel; gebruikt nu keep. - De rechtencheck op de home-directory geldt voor beide paden in plaats van alleen het zelfbeheerpad, en de remediatie noemt chmod 755 op de home-dir. - Twee tegengestelde defaults voor SSHD_STATUS in hetzelfde bestand opgeruimd.
…lde de sleutelvergelijking - find geeft exit 1 zodra een van de opgegeven paden ontbreekt, ook als het voor de andere wel een treffer print. De rechtencheck stond achter die exit-code en zweeg daardoor juist op een vers volume, waar authorized_keys nog niet bestaat — een van de gevallen waarvoor hij bedoeld was. - De vingerafdruk van authorized_keys werd met 2>&1 opgehaald. Een WARN-regel van de CLI (podman doet dat routinematig) belandt dan voor de vingerafdruk in de waarde, waarna de vergelijking vals-rood geeft met een stellige onjuiste diagnose. Stromen gescheiden via een tempbestand. - Een mislukte exec bij het lezen van KEPLER_SSH_PUBKEY werd gerapporteerd als zelfbeheerde authorized_keys, waarna de sectie stil groen bleef. printenv geeft 1 voor niet-gezet; alles daarboven is nu een fout met de stderr erbij. - De vergelijking eiste gelijkheid terwijl de melding bevat zegt; bij meerdere sleutels in het bestand gaf dat vals-rood. - OPEN_HTTPS viel bij een exec-fout terug op een verzonnen waarde waarop vervolgens een lek gemeld werd.
grep -q sluit af zodra hij matcht; de producent krijgt dan SIGPIPE en dat telt onder pipefail als mislukt. Bij veel sleutels in authorized_keys gaf juist een vroege treffer daardoor vals-rood (rc 141). De omsluitende spaties in het patroon voorkomen dat een langere vingerafdruk met dezelfde prefix meematcht.
- OPEN_HTTPS: niet gezet en onleesbaar vielen samen op leeg. Compose injecteert de var niet als hij ontbreekt en init-firewall valt dan terug op false, dus een bereikbare example.com is in dat geval wel degelijk een lek — dat werd afgezwakt tot niet vast te stellen. De drie uitkomsten staan nu uit elkaar, en bij een CLI-fout komt de reden mee. - De KEPLER_SSH_PUBKEY-check leunde op de aanname dat alleen exit-codes boven 1 een CLI-fout zijn. Welke code een implementatie voor een daemon-fout kiest verschilt; lege stderr is het betrouwbare onderscheid met niet-gezet. - Een falende sshd -T gooide zijn eigen foutregel weg, terwijl daar meestal Bad configuration option in staat — precies waar de entrypoint naar verwijst. - mktemp overal geguard; zonder schrijfbare tempdir eindigde de run anders vlak voor de samenvatting.
De OpenSSH-default zoekt ook .ssh/authorized_keys2. Dat bestand werd nooit gevalideerd en ook niet overschreven als KEPLER_SSH_PUBKEY gezet is, terwijl het wel meetelt bij de autorisatie. Nu expliciet op .ssh/authorized_keys, geasserteerd in de build en in de smoke-test.
De bestaande Trivy-runs zijn scan-type fs en zien daarmee geen enkel apt-pakket. openssh-server komt ongepind uit apt en valt buiten Dependabot, dus er was geen enkel signaal op een kwetsbare sshd. De job die de variant toch al bouwt scant hem nu; ignore-unfixed houdt het actionable.
This was referenced Aug 6, 2026
feat/maven-podman-poc is squash-gemerged in main (f6558ee), waardoor GitHub PR 92 omhing naar main en de merge-base terugviel naar van voor het podman-werk. Elk gedeeld bestand botste daardoor, terwijl de inhoud grotendeels dezelfde is. Onze kant behouden voor alle Kepler-toevoegingen; van main overgenomen wat daar nieuwer is: Node.js v24.18.1, de postgres-bump in de sample-pom, en de tools-tabel met Node.js 24 LTS en bredere kolommen. De openssh-server-vermelding staat weer in de Netwerk-rij.
De scan blokkeerde de PR op npm-modules die met de Node.js-tarball meekomen (tar, brace-expansion, ip-address, undici). Die zitten net zo goed in de default-image, en die wordt hier niet gescand — alleen deze PR erop laten vallen is willekeurig. De job bestaat om apt-pakketten te dekken, want openssh-server komt ongepind uit apt en valt buiten Dependabot.
This was referenced Aug 6, 2026
ssh-keygen schrijft zowel de private als de publieke sleutel, maar de guard toetste het type alleen op de private. Op het .pub-pad werd alleen een symlink afgevangen. Een fifo daar laat ssh-keygen blokkeren op open(), in de root-fase en vóór de privilege-drop, zonder melding en zonder exit — restart-beleid grijpt dan niet in, want het proces eindigt nooit. /home/claude is van claude, en schrijfrecht op de parent is genoeg om de hele .ssh-host-directory te vervangen, dus de ingesloten partij kan dat pad zetten.
…ttrekken sshd werd gestart zonder env-scrub en erfde dus de volledige container-env; zijn pre-auth-child erft die over de fork heen. Een pre-auth-lek in OpenSSH leest de sleutel daarmee uit /proc/self/environ, zonder authenticatie en zonder verdere escalatie. Voor een sessie maakt het niets uit: sshd bouwt daar toch een verse omgeving op. De README zette dat sshd de env reset onder 'niet omzeild'. Dat is waar over sshd, maar het leest als 'de sleutel is buiten bereik' en dat klopt niet: PID 1 draait na de drop als claude met die variabele, en een sessie is dezelfde uid. Ook toegevoegd bij de beperkingen: claude kan de host-identiteit laten roteren door .ssh-host te hernoemen. Hij kiest de nieuwe sleutel niet, maar dwingt wel bij elke start een known_hosts-mismatch af.
Het README-bewijs voor de leesbare API-sleutel wees naar /proc/1/environ, maar compose.yml zet init: true — PID 1 is tini als root, en dat pad is voor claude juist niet leesbaar. Wie de claim natrok kreeg Permission denied en zou het omgekeerde concluderen. De conclusie zelf klopt en is breder: elk proces van claude dat de container-env draagt, levert de sleutel. De sshd -T-asserties vergeleken hoofdlettergevoelig met lowercase sleutels. Upstream OpenSSH schrijft die dump in mixed case; bij een base-image-bump zou de build falen met de misleidende melding dat de drop-in niet toegepast is. De env-scrub is een denylist van één variabele; een sync-comment koppelt hem aan de secrets in compose.yml.
De image zet /home/claude/.local/bin vooraan in PATH voor de claude-CLI. Dat pad ligt op het claude-home volume en is van claude, terwijl entrypoint-root.sh en init-firewall.sh als root draaien en hun binaries op naam aanroepen — iptables, setpriv, ssh-keygen, install, stat. Wie in de container als claude kan schrijven, bepaalt daarmee welke van die commando's root uitvoert bij de eerstvolgende start; het restart-beleid en een gewone docker restart zijn genoeg om dat te laten gebeuren, vóór de privilege-drop en met NET_ADMIN en NET_RAW. De root-fase draait nu met een vaste PATH naar de systeemdirectories. De gebruikersfase krijgt de PATH van de image terug vlak vóór de drop, want daar is /home/claude/.local/bin juist nodig.
Het formaat van de sleutelnamen in 'sshd -T' is niet toegezegd; vandaag is het lowercase. Wijzigt dat ooit, dan faalt de build met een melding die naar de drop-in wijst in plaats van naar de dump.
# Conflicts: # claude-sandbox/entrypoint-root.sh
De directive-lus gebruikte grep -qix, dus de waarde werd als reguliere expressie gelezen. Bij 'permitopen localhost:* 127.0.0.1:* [::1]:*' is [::1] een bracket-expressie en :* nul of meer dubbele punten, waardoor die regel zichzelf niet kan matchen en de sectie per definitie rood is. Een assertie die altijd faalt leert lezers de sectie te negeren.
) * fix(kepler): auth-events naar de containerlog i.p.v. een eigen bestand /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. * fix(kepler): auth-log naar het volume in plaats van naar de containerlog 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. * fix(kepler): auth-assertie ná de login, symlink-guard op het logbestand 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. * fix(kepler): offsetmeting mag niet stil op nul terugvallen 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. * fix(kepler): logdirectory meetoetsen, faaldiagnose naar het juiste pad 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. * fix(kepler): auth-spoor buiten bereik van claude en met een tijdstempel 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. * fix(kepler): CR van OpenSSH strippen in het auth-spoor * fix(kepler): smoke-test en README mee naar het nieuwe logpad 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. * docs(kepler): het aparte volume voor het auth-spoor benoemen * fix(kepler): fail-closed-claim rechtzetten, down -v-gedrag benoemen 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. * fix(kepler): API-sleutel uit de omgeving van sshd * docs(kepler): API-sleutel-bullet en de trigger voor een dode leesluis 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. * docs(kepler): preciezer waar de API-sleutel wel en niet staat 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. * fix(kepler): auth-spoor via syslogd in plaats van een eigen fifo 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. * fix(kepler): auth-spoor rechtstreeks naar het bestand, zonder logdaemon 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. * fix(kepler): LogLevel bewaken waar dat kan, ouder-assertie op de mode 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. * fix(kepler): mount meetoetsen, claims over logniveau en /run rechtzetten 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.
* 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.
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.
Optionele opt-in (
INSTALL_SSHD=true) die een gehard sshd ín de sandbox draait, zodat GitKraken Kepler via SSH een Claude-agent-sessie in de gecureerde, gefirewallde image kan draaien i.p.v. op de kale host/VM.Wat het toevoegt
INSTALL_SSHD=true(default uit) installeert + hardt OpenSSH-server: pubkey-only, geen root, alleen userclaude, geen X11.compose.override.kepler.ymlpubliceert de poort loopback-only (127.0.0.1:2222); pubkey komt runtime viaKEPLER_SSH_PUBKEY(geen baked keys).claude-sandbox/kepler/smoke-test.sh: host-side verificatie van poortbinding, login, PATH, hardening-weigeringen, firewall + regressie-guards.Twee niet-triviale problemen opgelost (met smoke-test geborgd)
1.
PerSourcePenalties no— OpenSSH ≥9.8 straft per bron-IP. Achter de NAT/port-forward (gvproxy op macOS, Docker's portpublish) deelt elke client één bron-IP, dus één mislukte/afgebroken connectie straft alle volgende — zelf-versterkend (~15-600s). Op een loopback-only, pubkey-only poort levert per-bron-throttling nauwelijks beveiliging op. Burst-guard in de smoke-test (sectie 6).2. Kepler tunnel-race workaround (
/etc/zsh/zprofile) — Kepler zet een ControlMaster op en draait de tunnel alsssh -N -Lmux-slave; die draagt de forward over aan de master en exit binnen ~10-30 ms. De poort is dan al klaar (curl door de tunnel → HTTP 200), maar Keplers readiness-poll behandelt het child-exit als fataal vóór de poort-check →ssh exited before the tunnel ... (code 0). Server-onafhankelijk (idem tegen een kale sshd) → Kepler-bug, geen sandbox-fout. Workaround: rek de fantoom-login-shell die de mux-slave opent met een kortesleep(alleen non-interactieve login-shells; dezsh -cprobe en interactieve shells blijven ongemoeid) zodat Keplers eerste poll de al-klare poort pakt. Regressie-guard in de smoke-test (sectie 7). De bug is upstream aan het Kepler-team gemeld; workaround kan eruit zodra dat gefixt is.Beveiliging
De sshd is gehard (pubkey-only,
AllowUsers claude,PermitRootLogin no, geen agent-/X11-/stream-local-forwarding,PermitOpenop de loopback-vormen) en de build faalt als de drop-in niet effectief blijkt. De daemon start metenv -u ANTHROPIC_API_KEY, zodat een pre-auth-lek in OpenSSH de sleutel niet uit zijn eigen omgeving leest.Wat het níet dekt staat in de README-sectie 'Kepler (SSH-remote)' en is bewust uitgeschreven: een geslaagde login is een volledige shell als
claude, de API-sleutel blijft binnen de sessie leesbaar via/proc/1/environ, binnen het container-netwerk luistert sshd op alle adressen (#101 beperkt dat tot de gateway), het auth-spoor draagt geen tijdstempel (#102 lost dat op), enclaudekan host-key-rotatie afdwingen door.ssh-hostte hernoemen.Geverifieerd
smoke-test.shvolledig groen op een podman-machine op macOS, inclusief beide regressie-guards, en Kepler verbindt end-to-end met de sandbox als remote. Die run is gedaan op een eerdere stand van deze branch.shellcheck --severity=warningop alle gewijzigde scripts: schoon.Expliciet NIET geverifieerd
build-image.yml. Die stand is niet opnieuw gedraaid; herhaal de smoke-test vóór merge.linux/amd64; de arm64-variant komt pas bij de push naarmain.🤖 Generated with Claude Code