From 3256ed2ab17792a64ebbed9cb87db6612c6cd196 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 15 May 2026 14:05:38 +0200 Subject: [PATCH] =?UTF-8?q?spec(hardening-refactor):=20design=20=E2=80=94?= =?UTF-8?q?=20drop-ins=20owned=20by=20left4me,=20ckn-bw=20deploys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardening composition is application knowledge (which paths to bind, that srcds is i386, what breaks sudo). It belongs in the left4me repo as drop-in .conf files under deploy/files/etc/systemd/system/.d/. ckn-bw shrinks: keeps the base units in its reactor, removes the hardening keys, ships the drop-ins to /etc/systemd/system/. Existing educational reference units in deploy/files/.../*.service are deleted in favor of the drop-ins, which carry per-directive comments. Broader configmgmt-responsibility reshape (base units leaving the reactor) deliberately deferred to a future session. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-15-hardening-refactor-design.md | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-hardening-refactor-design.md diff --git a/docs/superpowers/specs/2026-05-15-hardening-refactor-design.md b/docs/superpowers/specs/2026-05-15-hardening-refactor-design.md new file mode 100644 index 0000000..d92f94d --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-hardening-refactor-design.md @@ -0,0 +1,252 @@ +# Hardening refactor — design + +**Status:** approved design; implementation plan to follow at +`docs/superpowers/plans/2026-05-15-hardening-refactor.md`. +Companion: `2026-05-15-hardening-threat-model.md`, +`2026-05-15-hardening-defenses-survey.md`, +`2026-05-15-hardening-test-plan.md` (executed 2026-05-15, results inline). + +This doc records the *shape* of the refactor — where the artifacts live, +who owns what, what's in scope. The implementation plan lays out the +steps. + +## Context + +The hardening test plan ran end-to-end on `left4.me` on 2026-05-15 +(commit `461b8d0`). The composition is proven (server@1 7.5→1.3 +systemd-analyze, web 8.7→4.1, all 8 Test 8 attack vectors blocked) with +two amendments to the spec's proposed directives: `SystemCallArchitectures=native x86` +(srcds_linux is i386), `PrivatePIDs=true` (same-uid `ProtectProc=invisible` +can't hide gunicorn from srcds). `MemoryDenyWriteExecute=true` permanently +excluded (Source engine i386 text relocations). + +The composition is *not currently deployed* — Test 7's drop-in was +cleaned up at session end; only the Test 9 sysctl +(`kernel.yama.ptrace_scope=2`) persists. This refactor lands the proven +composition permanently. + +## Responsibility model + +**Hardening is application knowledge, not infrastructure knowledge.** +Which paths to bind, which syscall groups srcds tolerates (i386 archs, +text relocations), which directives break sudo on the web app — all of +this is dictated by the left4me threat model and codebase, not by the +host's configuration. The hardening therefore lives **in the left4me +repo**; ckn-bw is responsible for *deploying* it, not *defining* it. + +The base units (what runs, as which uid, with which env files, with +which restart policy) stay in ckn-bw's reactor for now. The broader +"thin ckn-bw" question — should base units also leave the reactor? — +is a deferred follow-up, deliberately out of scope for this refactor. + +### Concretely + +- `deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` + — drop-in file, owned by left4me, deployed by ckn-bw. Contains the + proven Test 7 directive set with the two amendments and the + `SocketBindAllow=` addition. Per-directive comments document the + threat each addresses. +- `deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf` + — drop-in file, owned by left4me, deployed by ckn-bw. Contains the + Test 10 sudo-compatible subset. +- `deploy/files/etc/sysctl.d/99-left4me-ptrace.conf` + — `kernel.yama.ptrace_scope = 2`. Same pattern: lives here, + ckn-bw ships it. +- `~/Projekte/ckn-bw/bundles/left4me/metadata.py` `systemd/units` + reactor — hardening directives **removed** from the + `left4me-server@.service` and `left4me-web.service` entries. Reactor + keeps the base shape only: `Type`, `User`, `Group`, `EnvironmentFile`, + `ExecStart` + `ExecStartPre`/`ExecStopPost` (the `+`-prefixed nsenter + overlay-mount), `Restart`, `Slice`, resource limits (`MemoryMax`, + `LimitNOFILE`, `OOMScoreAdjust`, etc.). +- `~/Projekte/ckn-bw/bundles/left4me/items.py` — small addition: ship + the three drop-in files from `/opt/left4me/src/deploy/files/etc/...` + to `/etc/...`. Mechanism (symlink vs. file-copy via `files` entry) is + a small implementation decision, deferred to the plan. + +### What gets deleted + +`deploy/files/usr/local/lib/systemd/system/left4me-server@.service` and +`deploy/files/usr/local/lib/systemd/system/left4me-web.service` (the +existing reference samples). These were intended as educational +copies of the ckn-bw-emitted units, but with hardening now living as +drop-ins in this repo, the educational value moves to those drop-ins. +The base unit definition lives only in the ckn-bw reactor; a top-level +`deploy/README.md` (or addition to an existing README) points readers +at `~/Projekte/ckn-bw/bundles/left4me/metadata.py` for the base shape +and at `deploy/files/etc/systemd/system/.d/` for the hardening. + +## Drop-in content sketches + +Full content lives in the actual `.conf` files; this section sketches +the shape so the implementation plan has a target. + +### `left4me-server@.service.d/10-hardening.conf` + +Header comment: links to threat model + defenses survey + commit +reference. Then `[Service]` block grouped by concern: + +- **Identity / privilege drop**: `NoNewPrivileges`, `RestrictSUIDSGID`, + `CapabilityBoundingSet=` (empty), `AmbientCapabilities=` (empty) +- **Filesystem virtualization**: `TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media`, + `BindReadOnlyPaths=` for installation/overlays/host.env/SSL/DNS plumbing, + `BindPaths=/var/lib/left4me/runtime/%i`, `ProtectSystem=strict`, + `ProtectHome=true` +- **Process namespacing**: `PrivateUsers=true`, **`PrivatePIDs=true`**, + `PrivateTmp=true`, `PrivateDevices=true`, `PrivateIPC=true`, + `RestrictNamespaces=true` +- **/proc + kernel**: `ProtectProc=invisible`, `ProcSubset=pid`, + all six `Protect*=true` (kernel tunables/modules/logs/clock/control-groups/hostname), + `LockPersonality=true` +- **Syscall filter**: `SystemCallArchitectures=native x86` (the i386 + amendment, with comment explaining srcds is 32-bit Source 2007), + `SystemCallFilter=@system-service` + `~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged` +- **Network**: `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`, + `SocketBindAllow=` for the gameserver port range (encoding TBD by + the plan — may need to hard-code the range, since systemd directive + variable substitution isn't universal) +- **Hygiene**: `RestrictRealtime=true`, `RemoveIPC=true`, + `KeyringMode=private`, `UMask=0027` + +Plus an inline comment block explaining `MemoryDenyWriteExecute=true` +is permanently excluded and why. + +### `left4me-web.service.d/10-hardening.conf` + +Header comment: explicit list of directives *omitted* and why (sudo). +Then `[Service]` block: + +- **Filesystem**: `ProtectSystem=strict` (was `=full` in the base unit; + this tightens), `ProtectHome=true` +- **/proc + kernel**: same set as the server drop-in +- **Syscall filter**: `SystemCallArchitectures=native`, + `SystemCallFilter=@system-service` + `~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete` + **without** `~@privileged` (sudo needs setuid) +- **Network**: `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX` +- **Hygiene**: `RestrictNamespaces=true`, `RestrictRealtime=true`, + `RemoveIPC=true`, `UMask=0027` + +Omitted (with header-comment rationale): `NoNewPrivileges`, +`PrivateUsers`, `RestrictSUIDSGID`, `CapabilityBoundingSet=`, +`~@privileged`. All sudo-incompatible. A future "replace sudo with +systemctl-managed transient units" refactor (the build-overlay-unit +spec moves in that direction) will let us tighten this drop-in. + +### `etc/sysctl.d/99-left4me-ptrace.conf` + +One-line file: +``` +# Block ptrace unless tracer has CAP_SYS_PTRACE. Belt-and-braces with +# SystemCallFilter=~@debug + PrivateUsers=true in the gameserver unit. +kernel.yama.ptrace_scope = 2 +``` + +## Scope of the refactor + +1. **Three new files in `deploy/files/etc/...`** with the content + sketched above. Per-directive comments included. +2. **`ckn-bw/bundles/left4me/metadata.py`** — remove hardening keys + from `left4me-server@.service` and `left4me-web.service` reactor + entries. Reactor shrinks to base unit definition only. +3. **`ckn-bw/bundles/left4me/items.py`** — add deploy step for the + three drop-in files (mechanism TBD by implementation plan; + candidates: symlinks, `files` entries with `source=` pointing at + the post-git_deploy path, or a small dedicated action). +4. **Deletion of `deploy/files/usr/local/lib/systemd/system/*.service`** + reference files. Replace with a brief `deploy/README.md` pointer + (or add a section to the existing project README). +5. **Mark `2026-05-15-user-uid-split-design.md` superseded.** Front-matter + status note + brief explanation that `PrivateUsers` + `PrivatePIDs` + + `TemporaryFileSystem` close D1, D2, D3, D5 at the kernel level. + This design + the refactor plan are the replacement. +6. **Spec bug fixes in `2026-05-15-hardening-test-plan.md`.** The four + bugs documented in the test plan's "Output" section: PID-lookup race + (use `systemctl show -p MainPID --value`), gdb-from-host + verification flaw (probe via `systemd-run` inside the same + hardening profile, not via `nsenter` that bypasses it), D5 pgrep + pattern, `scmp_sys_resolver` package is `seccomp` not + `libseccomp-dev`. +7. **Apt-remove `gdb` + `seccomp` + `libseccomp-dev`** from + `left4.me` after the refactor lands. Test-only tooling; reinstall + on demand for future test sessions. + +## Deploy sequencing + +The composition is verified in transient drop-ins. Landing it via +`bw apply` does: + +1. Land left4me commit (new drop-ins + reference cleanup + spec bug + fixes + uid-split spec status update + this design doc + the + refactor plan). +2. Land ckn-bw commit (reactor shrink + drop-in deploy mechanism). +3. Push both repos. +4. `bw apply ovh.left4me`. ckn-bw `git_deploy`s the left4me tree to + `/opt/left4me/src/`; the new items step installs the drop-ins to + `/etc/systemd/system/.d/`; the reactor's now-shrunken unit + emission triggers systemd reload; affected units restart. +5. Verify on the host: + - `systemctl cat left4me-server@1` shows the base unit *plus* the + hardening drop-in merged. + - Re-run a Test 8 subset (D1.a DB invisibility, D1.b web.env + invisibility, D2.b same-uid /proc hidden, D5 cross-instance + ptrace blocked) using the *corrected* probe pattern (per spec bug + fix in step 6 of "Scope"). + - Smoke: server@1 active, server@2 active, web responds, RCON + works, overlay build succeeds. +6. Rollback if needed: `git revert` the ckn-bw commit (or the left4me + commit — base + drop-ins are decoupled now, so either revert is + bounded) + `bw apply`. + +## What this approach buys + +- **Test artifact = production artifact.** The Test 7 drop-in is + literally what gets deployed. No translation, no factoring step + between proof and prod. +- **Application-level clarity.** A cold reader of left4me opens + `deploy/files/etc/systemd/system/.d/10-hardening.conf` and + reads the threat model in code form, per-directive comments + explaining each defense. +- **Easy rollback.** Drop-ins are removable as a unit. Base unit + is unaffected if hardening misfires. +- **Pattern for the build-overlay-unit refactor.** When that work + lands (queued), its unit ships its own drop-in the same way. +- **Smaller ckn-bw reactor.** Removing the hardening from the reactor + makes the reactor a clearer "this service exists" definition. + +## What's out of scope (explicit, deferred) + +- **`MemoryDenyWriteExecute=true`** — permanently excluded. +- **AppArmor profile** — deferred per defenses-survey. +- **`build-overlay-unit` refactor** (`2026-05-15-build-overlay-unit-design.md`) + — sequenced after. +- **3-user uid split** — `2026-05-15-user-uid-split-design.md` + superseded by this refactor (item 5 above). +- **Broader configmgmt-responsibility reshape** — base units leaving + the ckn-bw reactor, ckn-bw becoming a thin file-shipper, etc. Real + refactor; deserves its own session. Out of scope here. +- **Stale RCON port app bug** — flagged in executor's handoff + (`2026-05-15-session-handoff.md`). Separate scope. +- **Pushing the branch** — operator decides when. + +## Open items resolved in implementation, not design + +- Mechanism for ckn-bw to ship the drop-ins from + `/opt/left4me/src/deploy/files/etc/systemd/...` to `/etc/systemd/...`. + Pick during implementation; candidates: symlink, `files` entry with + `source=` of post-git_deploy path, dedicated action. Decision driven + by what ckn-bw idioms already exist. +- `SocketBindAllow=` value encoding (variable substitution support in + the directive is uncertain — may need hard-coded range or templated + via a sysctl-style approach). + +## Pointers + +- Threat model: `2026-05-15-hardening-threat-model.md` +- Defenses survey: `2026-05-15-hardening-defenses-survey.md` +- Test plan + results: `2026-05-15-hardening-test-plan.md` + (commit `461b8d0`) +- Executor's handoff: `2026-05-15-session-handoff.md` + (commit `152c313`) +- Live reactor: `~/Projekte/ckn-bw/bundles/left4me/metadata.py:150+` +- Deferred uid-split spec (to be marked superseded): `2026-05-15-user-uid-split-design.md` +- Adjacent (sequenced after this): `2026-05-15-build-overlay-unit-design.md`