diff --git a/docs/superpowers/specs/2026-05-15-hardening-refactor-design.md b/docs/superpowers/specs/2026-05-15-hardening-refactor-design.md index d92f94d..c4ae441 100644 --- a/docs/superpowers/specs/2026-05-15-hardening-refactor-design.md +++ b/docs/superpowers/specs/2026-05-15-hardening-refactor-design.md @@ -7,246 +7,219 @@ Companion: `2026-05-15-hardening-threat-model.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. +how they're factored, 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). +(commit `461b8d0`). Outcome: `left4me-server@1` 7.5→1.3 systemd-analyze, +`left4me-web` 8.7→4.1, all 8 Test 8 attack vectors blocked. Two +amendments to the spec's proposed composition required: `SystemCallArchitectures=native x86` +(srcds_linux is i386), `PrivatePIDs=true` (same-uid +`ProtectProc=invisible` can't hide gunicorn from srcds; PID namespace +fixes it at the kernel level). `MemoryDenyWriteExecute=true` permanently +excluded (Source engine i386 `.so` files have 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. +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 via +the ckn-bw bundle. -## Responsibility model +## Approach -**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. +Keep the current responsibility split for now: ckn-bw owns systemd unit +emission (base + hardening), left4me owns the educational reference +copies and the threat-model/test docs. Hardening directives land in +ckn-bw's `systemd/units` reactor at +`~/Projekte/ckn-bw/bundles/left4me/metadata.py:150+`, factored via +shared Python dicts so the two units (and the future +build-overlay-unit refactor) reuse the common base. -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. +The broader responsibility reshape — hardening as drop-in files +*living* in left4me with ckn-bw as a thin file-shipper — is a real +direction worth pursuing, but deserves its own session. Deferred. -### Concretely +## Factoring -- `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. +Three dict constants at the top of `metadata.py` (or in a sibling +`hardening.py` module if `metadata.py` grows past a comfortable read): -### What gets deleted +### `HARDENING_COMMON` -`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. +Directives both units take verbatim. ~17 keys: -## 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 +```python +HARDENING_COMMON = { + 'ProtectProc': 'invisible', + 'ProcSubset': 'pid', + 'ProtectKernelTunables': 'true', + 'ProtectKernelModules': 'true', + 'ProtectKernelLogs': 'true', + 'ProtectClock': 'true', + 'ProtectControlGroups': 'true', + 'ProtectHostname': 'true', + 'LockPersonality': 'true', + 'ProtectSystem': 'strict', + 'ProtectHome': 'true', + 'PrivateTmp': 'true', + 'RestrictNamespaces': 'true', + 'RestrictRealtime': 'true', + 'RemoveIPC': 'true', + 'KeyringMode': 'private', + 'UMask': '0027', + 'RestrictAddressFamilies': 'AF_INET AF_INET6 AF_UNIX', +} ``` +### `HARDENING_SERVER` + +`{**HARDENING_COMMON, ...server-specific}`. Adds sudo-incompatible +flags + filesystem virtualization + i386 amendment + per-instance PID +namespace + bound socket binds: + +- `NoNewPrivileges=true` +- `RestrictSUIDSGID=true` +- `PrivateUsers=true` +- **`PrivatePIDs=true`** *(Test amendment — D2.b / D5)* +- `PrivateIPC=true` +- `PrivateDevices=true` +- `CapabilityBoundingSet=` *(empty value → drop all)* +- `AmbientCapabilities=` +- `SystemCallArchitectures='native x86'` *(Test amendment — i386 srcds)* +- `SystemCallFilter=('@system-service', '~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged')` *(tuple → repeated key)* +- `TemporaryFileSystem='/var/lib /etc /opt /home /root /srv /mnt /media'` +- `BindReadOnlyPaths=('/var/lib/left4me/installation', '/var/lib/left4me/overlays', '/etc/left4me/host.env', '/etc/ssl', '/etc/ca-certificates', '/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/alternatives')` +- `BindPaths='/var/lib/left4me/runtime/%i'` +- `SocketBindAllow=('udp:27000-27999', 'tcp:27000-27999')` *(NEW — lock srcds bindable sockets to the game port range; not tested in Test 7 but cheap defense-in-depth. Concrete range pending verification of `LEFT4ME_PORT_RANGE_*` substitution support in systemd directives; hard-coded range as fallback.)* + +### `HARDENING_WEB` + +`{**HARDENING_COMMON, ...web-specific}`. Web inherits `ProtectSystem=strict` +from COMMON (was `=full` in the current base unit; this tightens). Adds +a syscall filter *without* `~@privileged` (sudo needs setuid). +**Excludes** `NoNewPrivileges`, `PrivateUsers`, `RestrictSUIDSGID`, +empty `CapabilityBoundingSet` — all sudo-incompatible. + +- `SystemCallArchitectures='native'` +- `SystemCallFilter=('@system-service', '~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete')` *(no `~@privileged`)* + +Web's existing `ReadWritePaths=/var/lib/left4me` stays in its unit's +inline `Service` dict (web-specific, not common). + +### Multi-value directives and empty values + +Tuples-of-strings → emitted as repeated `Key=Value` lines by ckn-bw's +systemd-bundle emitter. Existing precedent: `EnvironmentFile` at +`metadata.py:201-204`. Empty values (`CapabilityBoundingSet=`, +`AmbientCapabilities=`) need to emit as `Key=` with nothing after `=`. +Both behaviors verified as the first step of the implementation plan; +fallback approaches if the emitter doesn't handle them: inline-joined +strings where systemd accepts them, or extend the emitter. + +## Reference units + +Keep `deploy/files/usr/local/lib/systemd/system/left4me-server@.service` +and `deploy/files/usr/local/lib/systemd/system/left4me-web.service` as +**deliberately educational** copies of the deployed units. Each new +hardening directive in the reference gets a one-line comment +explaining the threat it addresses. A cold reader of the repo can open +the reference unit and read the threat model in code form, without +needing to read the ckn-bw bundle or systemd man pages. + +Source-of-truth: ckn-bw reactor is what's deployed. Reference units in +left4me are hand-synced. No CI drift test (would be brittle against +comment ordering and structural human-readable formatting); operator +discipline at edit time keeps them aligned. A top-of-file note in each +reference unit points readers at the reactor. + ## 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 +1. **Ckn-bw reactor edits.** Three constants + spread into the two + units. Verify tuple-multi-value emission. `metadata.py`. +2. **Sysctl drop-in via ckn-bw.** `kernel.yama.ptrace_scope=2`. Move + from host-only `/etc/sysctl.d/99-left4me-ptrace.conf` (applied by + hand in Test 9) into the bundle's file management. Find the existing + sysctl pattern in ckn-bw and follow it. +3. **Reference unit mirror with educational comments.** Update + `deploy/files/usr/local/lib/systemd/system/{left4me-server@,left4me-web}.service` + to match the reactor's emission, with per-directive comments + explaining each hardening directive's purpose. Top-of-file note + pointing to the reactor. +4. **Spec bug fixes in the test plan.** Four bugs flagged in + `2026-05-15-hardening-test-plan.md`'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. + `libseccomp-dev`. Doc-only. +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. + Reference this design + the refactor plan as the replacement. +6. **`SocketBindAllow=` for srcds** (in `HARDENING_SERVER`). Not tested + in Test 7; verify on deploy. Encoding pending — likely hard-coded + port range, since systemd directive variable substitution support + is uneven. +7. **Cleanup unmanaged packages on left4.me.** `apt remove gdb seccomp + libseccomp-dev` after the refactor lands. Test-only tooling; + reinstall on demand for future test sessions. -## Deploy sequencing +## Sequencing the deploy -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). +1. Land ckn-bw commit (reactor changes, sysctl drop-in entry). +2. Land left4me commit (reference units, spec bug fixes, uid-split + spec status update, this design doc, the refactor plan). 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. +4. `bw apply ovh.left4me` — applies reactor changes; systemd restarts + affected units automatically. 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`. + - `systemctl cat left4me-server@1` shows the new directives. + - Re-run a Test 8 subset (D1.a, D1.b, D2.b via PrivatePIDs, D5 with + the corrected pgrep) using the *corrected* probe pattern (per + spec bug fix in scope item 4). Test 8's full rerun is unnecessary + — composition is proven; only the *deployment* needs verifying. + - `sysctl kernel.yama.ptrace_scope` = 2. + - Smoke: server@1 + server@2 + web all active and stable for 10 + minutes. Web UI: login, server start/stop, log view, overlay + rebuild. +6. Rollback if needed: `git revert` the ckn-bw commit + `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) +## What's out of scope - **`MemoryDenyWriteExecute=true`** — permanently excluded. - **AppArmor profile** — deferred per defenses-survey. -- **`build-overlay-unit` refactor** (`2026-05-15-build-overlay-unit-design.md`) - — sequenced after. +- **`build-overlay-unit` refactor** + (`2026-05-15-build-overlay-unit-design.md`) — sequenced after this. + Will reuse `HARDENING_COMMON` (or a variant) when it lands. - **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. + superseded by this refactor (scope item 5). +- **Broader configmgmt-responsibility reshape** — hardening as + drop-ins living in left4me, ckn-bw becoming a thin file-shipper. + Real direction worth pursuing; deserves a dedicated session. + Out of scope here. +- **Stale RCON port app bug** — flagged in executor's handoff. 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). +- Does the systemd-bundle emitter handle `('a', 'b')` tuples as + repeated `Key=` lines, and `''` as `Key=` empty value? Verify as the + first step of the plan; fallback strategies if not. +- `SocketBindAllow=` value: hard-coded range vs. variable + substitution. Determined during emitter verification. ## Pointers - Threat model: `2026-05-15-hardening-threat-model.md` -- Defenses survey: `2026-05-15-hardening-defenses-survey.md` +- Defenses survey: `2026-05-15-hardening-defenses-survey.md` (§ 5 + candidate composition is the basis for the factoring above) - 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` +- Reference units: `deploy/files/usr/local/lib/systemd/system/` +- Deferred uid-split spec: `2026-05-15-user-uid-split-design.md` +- Adjacent (sequenced after): `2026-05-15-build-overlay-unit-design.md`