12 tasks across left4me + ckn-bw: emitter verification, three Python constants in the systemd_units reactor, spread into both managed units, sysctl drop-in, annotated reference units, four spec bug fixes, mark uid-split spec superseded, cross-repo push, bw apply + verify on host, apt-remove test tooling. Each task has bite-sized steps with exact commands and expected output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1321 lines
48 KiB
Markdown
1321 lines
48 KiB
Markdown
# Hardening refactor — implementation plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Land the proven Test 7 hardening composition into ckn-bw's `systemd/units` reactor for `left4me-server@.service` and `left4me-web.service`, ship the `kernel.yama.ptrace_scope=2` sysctl via the bundle, mirror the reference units with per-directive educational comments, fix four spec bugs in the test plan, mark the uid-split spec superseded, and verify the deploy on `left4.me`.
|
|
|
|
**Architecture:** Three shared Python dicts in ckn-bw's `metadata.py` (`HARDENING_COMMON`, `HARDENING_SERVER`, `HARDENING_WEB`) spread via `**` into the existing `systemd/units` reactor entries. Multi-value directives encoded as Python tuples (existing precedent: `EnvironmentFile` at `metadata.py:201-204`). Reference units in `left4me/deploy/files/usr/local/lib/systemd/system/` hand-synced to match the emission, with per-directive comments explaining each threat addressed. Broader configmgmt responsibility reshape (drop-ins owned by left4me) deliberately deferred.
|
|
|
|
**Tech Stack:** Python 3, bundlewrap (ckn-bw), systemd 257 (Debian 13 Trixie on `left4.me`), pytest (for `deploy/tests/`), bash (for verification on host).
|
|
|
|
**Repos:** This plan touches two repos. Each task header notes which:
|
|
- `~/Projekte/left4me` (project repo — this file lives here)
|
|
- `~/Projekte/ckn-bw` (config-management bundle repo)
|
|
|
|
---
|
|
|
|
## Task 1: Verify ckn-bw systemd-bundle emitter handles tuples and empty values
|
|
|
|
**Repo:** `~/Projekte/ckn-bw`
|
|
|
|
**Why:** The factoring depends on two emitter behaviors:
|
|
1. A tuple of strings → emitted as repeated `Key=Value` lines (needed for `SystemCallFilter`, `BindReadOnlyPaths`, `SocketBindAllow`).
|
|
2. An empty string → emitted as `Key=` with no value (needed for `CapabilityBoundingSet=`, `AmbientCapabilities=`).
|
|
If either doesn't work, the factoring needs to change shape (inline-join strings, or use a sentinel).
|
|
|
|
**Files:**
|
|
- Read: `~/Projekte/ckn-bw/bundles/systemd/` (the upstream bundlewrap systemd bundle; locate where unit emission happens)
|
|
- Read: `~/Projekte/ckn-bw/bundles/left4me/metadata.py:201-204` (existing tuple-emission precedent for `EnvironmentFile`)
|
|
|
|
- [ ] **Step 1: Inspect the systemd bundle's emitter for multi-value handling**
|
|
|
|
Run:
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
find bundles/systemd -name '*.py' -type f
|
|
# Expected: items.py, possibly templates/
|
|
grep -rn 'tuple\|list\|EnvironmentFile' bundles/systemd/ 2>/dev/null
|
|
# Look for how multi-value emission works
|
|
```
|
|
|
|
Then read the emitter source. Expected behavior: iterates the dict; if the value is a tuple/list, emits one `Key=Value` line per element; if the value is a string, emits one line.
|
|
|
|
- [ ] **Step 2: Inspect handling of empty values**
|
|
|
|
Run:
|
|
```bash
|
|
grep -rn "''\\|None\\|if value" bundles/systemd/ 2>/dev/null
|
|
```
|
|
|
|
Read the emitter to see if it strips empty strings or emits `Key=`. If unclear from reading, write a tiny test:
|
|
|
|
```python
|
|
# Save as /tmp/test-emitter.py — adapt to actual emitter import path
|
|
from bundles.systemd.items import render_unit # or whatever the real entry point is
|
|
|
|
unit = {
|
|
'Service': {
|
|
'Type': 'simple',
|
|
'CapabilityBoundingSet': '',
|
|
'SystemCallFilter': ('@system-service', '~@debug @mount'),
|
|
}
|
|
}
|
|
print(render_unit('test.service', unit))
|
|
```
|
|
|
|
Run it (adapting paths to ckn-bw's actual layout). Expected: a unit body containing `CapabilityBoundingSet=` (empty value) and two `SystemCallFilter=` lines.
|
|
|
|
- [ ] **Step 3: Record findings in the design doc's "Open items resolved in implementation"**
|
|
|
|
If both behaviors work as expected:
|
|
|
|
Edit `~/Projekte/left4me/docs/superpowers/specs/2026-05-15-hardening-refactor-design.md`. Replace the "Open items resolved in implementation, not design" section with:
|
|
|
|
```markdown
|
|
## Implementation notes (resolved during plan execution)
|
|
|
|
- The ckn-bw systemd-bundle emitter renders Python tuples as repeated
|
|
`Key=Value` lines and renders empty strings as `Key=` with no
|
|
value. The factoring uses both. Verified on <date> against
|
|
bundles/systemd at <commit>.
|
|
- `SocketBindAllow=` value: hard-coded port range `27000-27999`
|
|
(matches the `LEFT4ME_PORT_RANGE_*` metadata values). Variable
|
|
substitution in this directive is not supported.
|
|
```
|
|
|
|
If either behavior is broken, document the fallback and adjust later tasks accordingly. The most common fallbacks:
|
|
- Tuple not supported → inline-join with space for `SystemCallFilter` (it accepts space-separated within a line, though semantics differ — read systemd.exec(5) carefully).
|
|
- Empty value not supported → patch the emitter or open a ckn-bw issue.
|
|
|
|
- [ ] **Step 4: Commit the design-doc update**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git add docs/superpowers/specs/2026-05-15-hardening-refactor-design.md
|
|
git commit -m "$(cat <<'EOF'
|
|
spec(hardening-refactor): resolve emitter open items
|
|
|
|
Verified during plan execution that the ckn-bw systemd-bundle emitter
|
|
handles tuples and empty values as expected. SocketBindAllow port
|
|
range hard-coded since systemd directive variable substitution is not
|
|
universal.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Add `HARDENING_COMMON`, `HARDENING_SERVER`, `HARDENING_WEB` constants
|
|
|
|
**Repo:** `~/Projekte/ckn-bw`
|
|
|
|
**Files:**
|
|
- Modify: `~/Projekte/ckn-bw/bundles/left4me/metadata.py` (add the three constants near the top of the file, after imports, before the first `@metadata_reactor.provides` decorator)
|
|
|
|
- [ ] **Step 1: Read the current file head to find insertion point**
|
|
|
|
Run:
|
|
```bash
|
|
head -30 ~/Projekte/ckn-bw/bundles/left4me/metadata.py
|
|
```
|
|
|
|
Identify where the imports end and the first `@metadata_reactor.provides` starts. The new constants go between.
|
|
|
|
- [ ] **Step 2: Add the three constants**
|
|
|
|
Insert into `~/Projekte/ckn-bw/bundles/left4me/metadata.py` between imports and the first reactor:
|
|
|
|
```python
|
|
# Hardening composition — proven via the hardening test plan (left4me
|
|
# commit 461b8d0). See:
|
|
# docs/superpowers/specs/2026-05-15-hardening-threat-model.md
|
|
# docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
|
|
# docs/superpowers/specs/2026-05-15-hardening-test-plan.md
|
|
# docs/superpowers/specs/2026-05-15-hardening-refactor-design.md
|
|
# (paths in the left4me repo)
|
|
|
|
# Directives both managed units take verbatim.
|
|
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',
|
|
}
|
|
|
|
# Gameserver unit: COMMON + sudo-incompatible flags + filesystem
|
|
# virtualization + i386 amendment + per-instance PID namespace + bound
|
|
# socket binds.
|
|
HARDENING_SERVER = {
|
|
**HARDENING_COMMON,
|
|
'NoNewPrivileges': 'true',
|
|
'RestrictSUIDSGID': 'true',
|
|
'PrivateUsers': 'true',
|
|
# PrivatePIDs is the test-plan amendment that closes D2.b: same-uid
|
|
# ProtectProc=invisible cannot hide gunicorn from srcds (both run
|
|
# as uid 980); a private PID namespace does.
|
|
'PrivatePIDs': 'true',
|
|
'PrivateIPC': 'true',
|
|
'PrivateDevices': 'true',
|
|
'CapabilityBoundingSet': '',
|
|
'AmbientCapabilities': '',
|
|
# srcds_linux is i386 (Source 2007 engine). Bare 'native' kills
|
|
# every 32-bit syscall and traps srcds_run in a respawn loop.
|
|
'SystemCallArchitectures': 'native x86',
|
|
'SystemCallFilter': (
|
|
'@system-service',
|
|
'~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged',
|
|
),
|
|
'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',
|
|
# Lock srcds bindable sockets to the game port range. Hard-coded
|
|
# range because systemd directive variable substitution is uneven.
|
|
'SocketBindAllow': (
|
|
'udp:27000-27999',
|
|
'tcp:27000-27999',
|
|
),
|
|
# MemoryDenyWriteExecute=true permanently excluded — Source engine
|
|
# i386 .so files have text relocations that need mprotect(W+X)
|
|
# during the dynamic linker's relocation pass.
|
|
}
|
|
|
|
# Web unit: COMMON + sudo-compatible additions. EXCLUDES
|
|
# NoNewPrivileges, PrivateUsers, RestrictSUIDSGID, empty
|
|
# CapabilityBoundingSet, and ~@privileged in the syscall filter — all
|
|
# sudo-incompatible until a future refactor replaces sudo with
|
|
# systemctl-managed transient units.
|
|
HARDENING_WEB = {
|
|
**HARDENING_COMMON,
|
|
'SystemCallArchitectures': 'native',
|
|
'SystemCallFilter': (
|
|
'@system-service',
|
|
'~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete',
|
|
),
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify the file still parses**
|
|
|
|
Run:
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
python3 -c "import ast; ast.parse(open('bundles/left4me/metadata.py').read())"
|
|
# Expected: no output (no syntax error)
|
|
```
|
|
|
|
- [ ] **Step 4: Run `bw test` if available**
|
|
|
|
Run:
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw test ovh.left4me 2>&1 | head -50
|
|
# Expected: passes, or fails only on already-known issues unrelated
|
|
# to these constants
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
git add bundles/left4me/metadata.py
|
|
git commit -m "$(cat <<'EOF'
|
|
bundles/left4me: add HARDENING_{COMMON,SERVER,WEB} constants
|
|
|
|
Three shared dicts capturing the proven hardening composition from the
|
|
left4me hardening test plan (left4me commit 461b8d0). HARDENING_COMMON
|
|
is the directive set both managed units take verbatim; HARDENING_SERVER
|
|
adds the sudo-incompatible flags + filesystem virtualization + i386
|
|
amendment + PrivatePIDs + SocketBindAllow; HARDENING_WEB adds the
|
|
sudo-compatible syscall filter.
|
|
|
|
Not yet spread into the unit emission — that's the next commit.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Spread `HARDENING_SERVER` into `left4me-server@.service` reactor entry
|
|
|
|
**Repo:** `~/Projekte/ckn-bw`
|
|
|
|
**Files:**
|
|
- Modify: `~/Projekte/ckn-bw/bundles/left4me/metadata.py` (the `left4me-server@.service` entry inside the `systemd/units` reactor, currently at ~line 221+)
|
|
|
|
- [ ] **Step 1: Read the current emission**
|
|
|
|
Run:
|
|
```bash
|
|
sed -n '221,276p' ~/Projekte/ckn-bw/bundles/left4me/metadata.py
|
|
```
|
|
|
|
Identify the `Service` dict inside the `left4me-server@.service` entry. Note any existing hardening keys (`NoNewPrivileges`, `PrivateTmp`, `ProtectSystem`, `ReadOnlyPaths`, `ReadWritePaths`, etc.) — they will be removed in favor of the spread.
|
|
|
|
- [ ] **Step 2: Edit the entry to spread `HARDENING_SERVER` and remove duplicated keys**
|
|
|
|
Modify the `Service` dict to look like:
|
|
|
|
```python
|
|
'left4me-server@.service': {
|
|
'Unit': {
|
|
'Description': 'left4me server instance %i',
|
|
'After': 'network-online.target',
|
|
'Wants': 'network-online.target',
|
|
'StartLimitBurst': '5',
|
|
'StartLimitIntervalSec': '60s',
|
|
},
|
|
'Service': {
|
|
'Type': 'simple',
|
|
'User': 'left4me',
|
|
'Group': 'left4me',
|
|
'EnvironmentFile': (
|
|
'/etc/left4me/host.env',
|
|
'/var/lib/left4me/instances/%i/instance.env',
|
|
),
|
|
'WorkingDirectory': '-/var/lib/left4me/runtime/%i/merged/left4dead2',
|
|
'ExecStartPre': '+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay mount %i',
|
|
'ExecStart': '/var/lib/left4me/runtime/%i/merged/srcds_run -game left4dead2 +hostport ${L4D2_PORT} $L4D2_ARGS',
|
|
'ExecStopPost': '+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay umount %i',
|
|
'Restart': 'on-failure',
|
|
'RestartSec': '5',
|
|
|
|
# Resource control (baseline from prior performance work).
|
|
'Slice': 'l4d2-game.slice',
|
|
'Nice': '-5',
|
|
'IOSchedulingClass': 'best-effort',
|
|
'IOSchedulingPriority': '4',
|
|
'OOMScoreAdjust': '-200',
|
|
'MemoryHigh': '1.5G',
|
|
'MemoryMax': '2G',
|
|
'TasksMax': '256',
|
|
'LimitNOFILE': '65536',
|
|
'KillSignal': 'SIGINT',
|
|
'TimeoutStopSec': '15s',
|
|
'LogRateLimitIntervalSec': '0',
|
|
|
|
# Hardening profile — see HARDENING_SERVER constant near top
|
|
# of this file for per-directive rationale.
|
|
**HARDENING_SERVER,
|
|
},
|
|
'Install': {
|
|
'WantedBy': {'multi-user.target'},
|
|
},
|
|
},
|
|
```
|
|
|
|
Removed from the prior emission (now provided by `HARDENING_SERVER`):
|
|
`NoNewPrivileges`, `PrivateTmp`, `PrivateDevices`, `ProtectHome`,
|
|
`ProtectSystem`, `RestrictSUIDSGID`, `LockPersonality`, `ReadOnlyPaths`,
|
|
`ReadWritePaths`.
|
|
|
|
(`ReadOnlyPaths` and `ReadWritePaths` are superseded by
|
|
`TemporaryFileSystem` + `BindReadOnlyPaths` + `BindPaths` in
|
|
`HARDENING_SERVER`.)
|
|
|
|
- [ ] **Step 3: Verify the file still parses and `bw test` passes**
|
|
|
|
Run:
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
python3 -c "import ast; ast.parse(open('bundles/left4me/metadata.py').read())"
|
|
bw test ovh.left4me 2>&1 | head -50
|
|
```
|
|
|
|
- [ ] **Step 4: Render the unit to a temp file and visually diff against the proven Test 7 composition**
|
|
|
|
If `bw` has a render-without-apply mode (`bw apply --interactive` or `bw items` etc.), use it. Otherwise:
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw items ovh.left4me 'svc_systemd:left4me-server@.service' 2>/dev/null || \
|
|
bw debug -n ovh.left4me -c "from bundlewrap.utils import get_file_contents; print(repo.get_node('ovh.left4me').metadata.get('systemd/units')['left4me-server@.service'])"
|
|
```
|
|
|
|
Adapt to whatever inspection command ckn-bw supports. Compare visually
|
|
to the Test 7 drop-in (recorded inline in left4me's
|
|
`docs/superpowers/specs/2026-05-15-hardening-test-plan.md` under
|
|
"## Test 7" section): every directive that was in the proven Test 7
|
|
drop-in should now be present in the rendered unit.
|
|
|
|
Note specifically: `PrivatePIDs=true` and `SystemCallArchitectures=native x86`
|
|
must appear (the test amendments). `MemoryDenyWriteExecute=true` must
|
|
NOT appear (permanently excluded).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
git add bundles/left4me/metadata.py
|
|
git commit -m "$(cat <<'EOF'
|
|
bundles/left4me: spread HARDENING_SERVER into left4me-server@.service
|
|
|
|
Replaces the inline hardening directives on the gameserver unit with
|
|
the shared HARDENING_SERVER dict. Removes legacy ReadOnlyPaths /
|
|
ReadWritePaths (superseded by TemporaryFileSystem + BindReadOnlyPaths
|
|
+ BindPaths in the dict). Brings the unit to the proven Test 7
|
|
composition with the i386 amendment (SystemCallArchitectures=native x86)
|
|
and PrivatePIDs=true.
|
|
|
|
Not deployed until bw apply.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Spread `HARDENING_WEB` into `left4me-web.service` reactor entry
|
|
|
|
**Repo:** `~/Projekte/ckn-bw`
|
|
|
|
**Files:**
|
|
- Modify: `~/Projekte/ckn-bw/bundles/left4me/metadata.py` (the `left4me-web.service` entry, ~line 186)
|
|
|
|
- [ ] **Step 1: Read the current emission**
|
|
|
|
Run:
|
|
```bash
|
|
sed -n '186,220p' ~/Projekte/ckn-bw/bundles/left4me/metadata.py
|
|
```
|
|
|
|
The current entry has `ProtectSystem='full'` and `PrivateTmp='true'` and a comment about `NoNewPrivileges` intentionally not being set.
|
|
|
|
- [ ] **Step 2: Edit the entry to spread `HARDENING_WEB`**
|
|
|
|
```python
|
|
'left4me-web.service': {
|
|
'Unit': {
|
|
'Description': 'left4me web application',
|
|
'After': 'network-online.target',
|
|
'Wants': 'network-online.target',
|
|
},
|
|
'Service': {
|
|
'Type': 'simple',
|
|
'User': 'left4me',
|
|
'Group': 'left4me',
|
|
'WorkingDirectory': '/opt/left4me/src',
|
|
'Environment': {
|
|
'HOME=/var/lib/left4me',
|
|
'PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
|
},
|
|
'EnvironmentFile': (
|
|
'/etc/left4me/host.env',
|
|
'/etc/left4me/web.env',
|
|
),
|
|
'ExecStart': (
|
|
'/opt/left4me/.venv/bin/gunicorn '
|
|
f'--workers {workers} --threads {threads} '
|
|
"--bind 127.0.0.1:8000 'l4d2web.app:create_app()'"
|
|
),
|
|
'Restart': 'on-failure',
|
|
'RestartSec': '3',
|
|
|
|
# Web app writes broadly under /var/lib/left4me. Kept inline
|
|
# because it's web-specific (server@ uses BindPaths to bind
|
|
# only its instance dir).
|
|
'ReadWritePaths': '/var/lib/left4me',
|
|
|
|
# Hardening profile — see HARDENING_WEB constant near top of
|
|
# this file. NoNewPrivileges intentionally NOT set: workers
|
|
# sudo to the helpers. PrivateUsers and RestrictSUIDSGID also
|
|
# absent for the same reason. ProtectSystem tightens from
|
|
# 'full' to 'strict' via HARDENING_COMMON.
|
|
**HARDENING_WEB,
|
|
},
|
|
'Install': {
|
|
'WantedBy': {'multi-user.target'},
|
|
},
|
|
},
|
|
```
|
|
|
|
Removed from the prior emission (now provided by `HARDENING_WEB`):
|
|
`PrivateTmp`, `ProtectSystem` (tightened from `full` → `strict`).
|
|
|
|
- [ ] **Step 3: Verify the file still parses and `bw test` passes**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
python3 -c "import ast; ast.parse(open('bundles/left4me/metadata.py').read())"
|
|
bw test ovh.left4me 2>&1 | head -50
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
git add bundles/left4me/metadata.py
|
|
git commit -m "$(cat <<'EOF'
|
|
bundles/left4me: spread HARDENING_WEB into left4me-web.service
|
|
|
|
Adds the sudo-compatible hardening subset to the web unit. Tightens
|
|
ProtectSystem=full → strict. NoNewPrivileges, PrivateUsers,
|
|
RestrictSUIDSGID, empty CapabilityBoundingSet, and ~@privileged in the
|
|
syscall filter intentionally absent (sudo-incompatible until a future
|
|
refactor replaces the helper sudo with systemctl-managed transient
|
|
units).
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Manage `kernel.yama.ptrace_scope=2` sysctl via the bundle
|
|
|
|
**Repo:** `~/Projekte/ckn-bw`
|
|
|
|
**Files:**
|
|
- Locate or create: a sysctl drop-in. Path depends on the convention discovered in Step 1; candidates:
|
|
- `~/Projekte/ckn-bw/bundles/left4me/files/etc/sysctl.d/99-left4me-ptrace.conf` for the file-based pattern
|
|
- A `sysctl/options` reactor entry inline in `bundles/left4me/metadata.py` for the reactor-based pattern
|
|
|
|
- [ ] **Step 1: Find the existing sysctl pattern in ckn-bw**
|
|
|
|
Run:
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
grep -rn 'sysctl\.d\|kernel\.' bundles/ 2>/dev/null | grep -v '^bundles/left4me/' | head -10
|
|
```
|
|
|
|
Look for an existing `pkg_files:` or `files:` entry that targets `/etc/sysctl.d/*`, or a `metadata.get('sysctl/...')` reactor pattern. Read the relevant bundle's `items.py` or `metadata.py` to understand the convention.
|
|
|
|
If ckn-bw has a `sysctl` bundle with a reactor for `sysctl/*` keys, use that. Otherwise, fall back to a direct `files:` entry in the left4me bundle.
|
|
|
|
- [ ] **Step 2: Add the sysctl drop-in**
|
|
|
|
If ckn-bw has a sysctl reactor:
|
|
|
|
Add to `~/Projekte/ckn-bw/bundles/left4me/metadata.py`, in a new
|
|
`@metadata_reactor.provides('sysctl/...')` reactor or by appending to an existing one:
|
|
|
|
```python
|
|
@metadata_reactor.provides('sysctl/options')
|
|
def sysctl_left4me(metadata):
|
|
return {
|
|
'sysctl': {
|
|
'options': {
|
|
# Block ptrace except from CAP_SYS_PTRACE holders. Belt-and-
|
|
# braces with SystemCallFilter=~@debug + PrivateUsers=true
|
|
# in the gameserver unit. See:
|
|
# left4me docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
|
|
'kernel.yama.ptrace_scope': '2',
|
|
},
|
|
},
|
|
}
|
|
```
|
|
|
|
If ckn-bw uses a direct `files:` entry pattern:
|
|
|
|
Create `~/Projekte/ckn-bw/bundles/left4me/files/etc/sysctl.d/99-left4me-ptrace.conf` with:
|
|
|
|
```
|
|
# Block ptrace except from CAP_SYS_PTRACE holders. Belt-and-braces with
|
|
# SystemCallFilter=~@debug + PrivateUsers=true in the gameserver unit.
|
|
# See left4me docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
|
|
kernel.yama.ptrace_scope = 2
|
|
```
|
|
|
|
And add to `~/Projekte/ckn-bw/bundles/left4me/items.py` in the `files = {...}` dict:
|
|
|
|
```python
|
|
'/etc/sysctl.d/99-left4me-ptrace.conf': {
|
|
'mode': '0644',
|
|
'owner': 'root',
|
|
'group': 'root',
|
|
'triggers': {
|
|
'action:sysctl_reload', # adapt to whatever the sysctl reload action is called in ckn-bw
|
|
},
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 3: Verify with `bw test`**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw test ovh.left4me 2>&1 | head -50
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
git add bundles/left4me/
|
|
git commit -m "$(cat <<'EOF'
|
|
bundles/left4me: ship kernel.yama.ptrace_scope=2 sysctl drop-in
|
|
|
|
Belt-and-braces with the gameserver unit's SystemCallFilter=~@debug +
|
|
PrivateUsers=true. Currently applied by hand on left4.me (left over
|
|
from the hardening test plan's Test 9); landing in the bundle so it
|
|
survives bw apply and is reproducible on any future host.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Update reference unit `deploy/files/usr/local/lib/systemd/system/left4me-server@.service` with per-directive comments
|
|
|
|
**Repo:** `~/Projekte/left4me`
|
|
|
|
**Files:**
|
|
- Modify: `~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service`
|
|
|
|
- [ ] **Step 1: Read the current reference**
|
|
|
|
Run:
|
|
```bash
|
|
cat ~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service
|
|
```
|
|
|
|
- [ ] **Step 2: Replace with the new annotated content**
|
|
|
|
Overwrite the file with:
|
|
|
|
```ini
|
|
# left4me gameserver — system unit, one instance per gameserver.
|
|
#
|
|
# This is the REFERENCE COPY of the deployed unit. The live source is
|
|
# the systemd/units reactor at ~/Projekte/ckn-bw/bundles/left4me/metadata.py
|
|
# (look for 'left4me-server@.service'). Hardening directives live in
|
|
# the HARDENING_SERVER constant near the top of the same file.
|
|
# This file is hand-synced; edit both together.
|
|
#
|
|
# Threat model: docs/superpowers/specs/2026-05-15-hardening-threat-model.md
|
|
# Defenses survey: docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
|
|
# Test plan + results: docs/superpowers/specs/2026-05-15-hardening-test-plan.md
|
|
|
|
[Unit]
|
|
Description=left4me server instance %i
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
# Bound the restart loop. Without these, a persistent ExecStartPre or
|
|
# ExecStart failure spins indefinitely.
|
|
StartLimitBurst=5
|
|
StartLimitIntervalSec=60s
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=left4me
|
|
Group=left4me
|
|
EnvironmentFile=/etc/left4me/host.env
|
|
EnvironmentFile=/var/lib/left4me/instances/%i/instance.env
|
|
# `-` prefix: chdir failure is non-fatal. The merged dir only exists
|
|
# once ExecStartPre's overlay mount succeeds.
|
|
WorkingDirectory=-/var/lib/left4me/runtime/%i/merged/left4dead2
|
|
# `+` prefix runs the helper as PID 1 (root, all caps, host
|
|
# namespaces) — required because the unit has NoNewPrivileges=true
|
|
# AND PrivateUsers=true; both block sudo's setuid path. nsenter into
|
|
# PID 1's mount namespace ensures the umount in ExecStopPost succeeds
|
|
# without EBUSY from the unit's own slave-mount tree.
|
|
ExecStartPre=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay mount %i
|
|
# Run from the merged overlay, NOT installation/. srcds_run cds to its
|
|
# own dirname before exec'ing srcds_linux; the binary's path determines
|
|
# gameinfo + addons lookup.
|
|
ExecStart=/var/lib/left4me/runtime/%i/merged/srcds_run -game left4dead2 +hostport ${L4D2_PORT} $L4D2_ARGS
|
|
ExecStopPost=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay umount %i
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
|
|
# === Resource control baseline ===
|
|
# See docs/superpowers/specs/2026-05-09-l4d2-server-host-perf-baseline-design.md
|
|
Slice=l4d2-game.slice
|
|
Nice=-5
|
|
IOSchedulingClass=best-effort
|
|
IOSchedulingPriority=4
|
|
OOMScoreAdjust=-200
|
|
MemoryHigh=1.5G
|
|
MemoryMax=2G
|
|
TasksMax=256
|
|
LimitNOFILE=65536
|
|
KillSignal=SIGINT
|
|
TimeoutStopSec=15s
|
|
LogRateLimitIntervalSec=0
|
|
|
|
# === Identity / privilege drop ===
|
|
NoNewPrivileges=true # block setuid escalation (defense: D3)
|
|
RestrictSUIDSGID=true # block setuid()/setgid() syscalls
|
|
CapabilityBoundingSet= # drop all caps — no privilege to escalate
|
|
AmbientCapabilities=
|
|
|
|
# === Filesystem virtualization ===
|
|
# Mask /var/lib, /etc, /opt, etc. with empty tmpfs; bind back only
|
|
# what srcds needs. The DB (/var/lib/left4me/left4me.db) and web.env
|
|
# (/etc/left4me/web.env) are intentionally not bound — they don't
|
|
# exist in this unit's filesystem view (defenses: D1.a, D1.b).
|
|
TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media
|
|
BindReadOnlyPaths=/var/lib/left4me/installation
|
|
BindReadOnlyPaths=/var/lib/left4me/overlays
|
|
BindReadOnlyPaths=/etc/left4me/host.env
|
|
BindReadOnlyPaths=/etc/ssl
|
|
BindReadOnlyPaths=/etc/ca-certificates
|
|
BindReadOnlyPaths=/etc/resolv.conf
|
|
BindReadOnlyPaths=/etc/nsswitch.conf
|
|
BindReadOnlyPaths=/etc/alternatives
|
|
BindPaths=/var/lib/left4me/runtime/%i
|
|
ProtectSystem=strict # belt-and-braces with TemporaryFileSystem
|
|
ProtectHome=true
|
|
|
|
# === Process namespacing ===
|
|
PrivateUsers=true # own user namespace; cross-uid ptrace blocked (D2)
|
|
PrivatePIDs=true # own PID namespace; hides peer-srcds + gunicorn (D2.b, D5)
|
|
PrivateTmp=true
|
|
PrivateDevices=true
|
|
PrivateIPC=true
|
|
RestrictNamespaces=true # block unshare()/clone(CLONE_NEW*)
|
|
|
|
# === /proc and /sys ===
|
|
ProtectProc=invisible # foreign-uid /proc hidden (paired with PrivatePIDs for full hide)
|
|
ProcSubset=pid # /proc shows only PID dirs, no kallsyms/cpuinfo
|
|
ProtectKernelTunables=true # /proc/sys, /sys read-only
|
|
ProtectKernelModules=true # no module load/unload
|
|
ProtectKernelLogs=true # no /dev/kmsg or syslog()
|
|
ProtectClock=true # no settimeofday()
|
|
ProtectControlGroups=true # /sys/fs/cgroup read-only
|
|
ProtectHostname=true # no sethostname()
|
|
LockPersonality=true # no personality() switches
|
|
|
|
# === Syscall filter ===
|
|
# srcds_linux is i386 (Source 2007 engine). 'native x86' allows both
|
|
# x86_64 (from srcds_run + the dynamic linker) and i386 (from srcds_linux).
|
|
# Bare 'native' traps srcds_run in a respawn loop.
|
|
SystemCallArchitectures=native x86
|
|
SystemCallFilter=@system-service
|
|
SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged
|
|
# ~@debug is the load-bearing block for D2.a: drops ptrace(), process_vm_readv/writev().
|
|
# ~@privileged blocks anything requiring CAP_*, redundant with empty bounding set.
|
|
# MemoryDenyWriteExecute=true is NOT set — Source engine i386 .so files
|
|
# have text relocations that need mprotect(W+X) during dynamic-linker pass.
|
|
|
|
# === Network ===
|
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX # AF_UNIX needed for journald
|
|
# Lock srcds bindable sockets to the game port range.
|
|
SocketBindAllow=udp:27000-27999
|
|
SocketBindAllow=tcp:27000-27999
|
|
|
|
# === Misc hygiene ===
|
|
RestrictRealtime=true # no real-time scheduling
|
|
RemoveIPC=true # clean up SysV IPC on unit stop
|
|
KeyringMode=private # private kernel keyring
|
|
UMask=0027
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
- [ ] **Step 3: Verify content visually**
|
|
|
|
Run:
|
|
```bash
|
|
diff <(cat ~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service | grep -vE '^#|^\s*$') \
|
|
<(echo "expected directive set" | tr ' ' '\n' | sort)
|
|
```
|
|
|
|
(Or just read the file and confirm each directive matches the reactor's emission.)
|
|
|
|
- [ ] **Step 4: Commit (deferred — commit with Task 7's reference for one logical change)**
|
|
|
|
Don't commit yet; the next task updates the web reference. Commit both together at the end of Task 7.
|
|
|
|
---
|
|
|
|
## Task 7: Update reference unit `deploy/files/usr/local/lib/systemd/system/left4me-web.service` with per-directive comments
|
|
|
|
**Repo:** `~/Projekte/left4me`
|
|
|
|
**Files:**
|
|
- Modify: `~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service`
|
|
|
|
- [ ] **Step 1: Read the current reference**
|
|
|
|
```bash
|
|
cat ~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service
|
|
```
|
|
|
|
- [ ] **Step 2: Replace with the new annotated content**
|
|
|
|
```ini
|
|
# left4me web application — system unit.
|
|
#
|
|
# This is the REFERENCE COPY of the deployed unit. The live source is
|
|
# the systemd/units reactor at ~/Projekte/ckn-bw/bundles/left4me/metadata.py
|
|
# (look for 'left4me-web.service'). Hardening directives live in
|
|
# the HARDENING_WEB constant near the top of the same file.
|
|
# This file is hand-synced; edit both together.
|
|
#
|
|
# Several directives that the gameserver uses are intentionally absent
|
|
# from this unit:
|
|
# NoNewPrivileges — blocks sudo's setuid escalation
|
|
# PrivateUsers — breaks sudo's host-root mapping
|
|
# RestrictSUIDSGID — blocks setuid()/setgid()
|
|
# CapabilityBoundingSet= — empty value would deny sudo's caps
|
|
# ~@privileged in SystemCallFilter — blocks sudo's setuid syscall
|
|
# The web app invokes privileged helpers (left4me-systemctl,
|
|
# left4me-overlay, left4me-script-sandbox) via sudo, so these
|
|
# directives can't be applied here. A future refactor replacing sudo
|
|
# with systemctl-managed transient units would unlock them.
|
|
#
|
|
# Threat model + defenses + tests: see docs/superpowers/specs/2026-05-15-hardening-*
|
|
|
|
[Unit]
|
|
Description=left4me web application
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=left4me
|
|
Group=left4me
|
|
WorkingDirectory=/opt/left4me/src
|
|
Environment=HOME=/var/lib/left4me PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
EnvironmentFile=/etc/left4me/host.env
|
|
EnvironmentFile=/etc/left4me/web.env
|
|
ExecStart=/opt/left4me/.venv/bin/gunicorn --workers 4 --threads 4 --bind 127.0.0.1:8000 'l4d2web.app:create_app()'
|
|
Restart=on-failure
|
|
RestartSec=3
|
|
|
|
# Web writes broadly under /var/lib/left4me (DB, instance configs,
|
|
# overlays, runtime). Kept inline because it's web-specific
|
|
# (server@ uses BindPaths to bind only its instance dir).
|
|
ReadWritePaths=/var/lib/left4me
|
|
|
|
# === Filesystem ===
|
|
ProtectSystem=strict # tightened from prior 'full'; via HARDENING_COMMON
|
|
ProtectHome=true
|
|
PrivateTmp=true
|
|
|
|
# === /proc + kernel ===
|
|
ProtectProc=invisible # foreign-uid /proc hidden (defense: D4)
|
|
ProcSubset=pid
|
|
ProtectKernelTunables=true
|
|
ProtectKernelModules=true
|
|
ProtectKernelLogs=true
|
|
ProtectClock=true
|
|
ProtectControlGroups=true
|
|
ProtectHostname=true
|
|
LockPersonality=true
|
|
|
|
# === Syscall filter (sudo-compatible — note absence of ~@privileged) ===
|
|
SystemCallArchitectures=native
|
|
SystemCallFilter=@system-service
|
|
SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete
|
|
# ~@debug blocks ptrace + process_vm_readv/writev (D4).
|
|
# ~@privileged intentionally omitted — sudo needs setuid().
|
|
|
|
# === Network ===
|
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
|
|
# === Misc hygiene ===
|
|
RestrictNamespaces=true
|
|
RestrictRealtime=true
|
|
RemoveIPC=true
|
|
KeyringMode=private
|
|
UMask=0027
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
(Note: the `--workers 4 --threads 4` values are placeholders; the live emission uses metadata-derived values. The reference doc can use a fixed example — operator note in commit message will clarify.)
|
|
|
|
- [ ] **Step 3: Commit both reference units together**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git add deploy/files/usr/local/lib/systemd/system/left4me-server@.service \
|
|
deploy/files/usr/local/lib/systemd/system/left4me-web.service
|
|
git commit -m "$(cat <<'EOF'
|
|
deploy/files: annotate reference units with per-directive hardening comments
|
|
|
|
Update the educational reference copies of left4me-server@.service and
|
|
left4me-web.service to match the new hardening composition from the
|
|
ckn-bw reactor (HARDENING_COMMON + HARDENING_SERVER / HARDENING_WEB).
|
|
Per-directive comments explain each defense's purpose and the threat
|
|
it addresses, so a cold reader of this repo can understand the threat
|
|
model from the unit file alone.
|
|
|
|
Top-of-file note in each reference points at the ckn-bw reactor as
|
|
the live source; reference is hand-synced.
|
|
|
|
gunicorn ExecStart in the web reference uses placeholder
|
|
'--workers 4 --threads 4' values; live emission interpolates from
|
|
metadata. This is the documented divergence between the reference
|
|
and the deployed unit.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Fix four spec bugs in the hardening test plan
|
|
|
|
**Repo:** `~/Projekte/left4me`
|
|
|
|
**Files:**
|
|
- Modify: `~/Projekte/left4me/docs/superpowers/specs/2026-05-15-hardening-test-plan.md`
|
|
|
|
The four bugs (per the test plan's existing "Spec bugs surfaced" / "Output" section, recorded by the test executor in commit `461b8d0`):
|
|
|
|
A. **PID-lookup race.** `pgrep -f 'srcds_linux.*left4dead2' | head -1` picks whichever instance's PID is lowest — often `@2` not `@1`. Replace with `systemctl show -p MainPID --value left4me-server@1.service`.
|
|
|
|
B. **gdb-from-host ptrace verification flaw.** `sudo nsenter --target $PID --mount -- gdb -p $TARGET` runs gdb as root with full caps in only the mount namespace; the unit's SECCOMP filter doesn't apply. Replace with a probe that runs *inside the same hardening profile* via `systemd-run` with the same directives, or inspect the compiled `SystemCallFilter` directly.
|
|
|
|
C. **D5 pgrep pattern won't match.** `pgrep -f 'srcds_linux.*\@2'` doesn't match because the `@N` lives in the systemd unit name, not in argv. Use `systemctl show -p MainPID --value left4me-server@2.service` or look up the instance by game port (`27021` for @2 in current deployments).
|
|
|
|
D. **`scmp_sys_resolver` package name.** It's in `seccomp` on Debian 13, not `libseccomp-dev` as the spec said.
|
|
|
|
- [ ] **Step 1: Locate the four references in the test plan**
|
|
|
|
```bash
|
|
grep -n "pgrep -f 'srcds_linux" ~/Projekte/left4me/docs/superpowers/specs/2026-05-15-hardening-test-plan.md
|
|
grep -n "nsenter.*gdb\|gdb.*--batch -p" ~/Projekte/left4me/docs/superpowers/specs/2026-05-15-hardening-test-plan.md
|
|
grep -n "libseccomp-dev" ~/Projekte/left4me/docs/superpowers/specs/2026-05-15-hardening-test-plan.md
|
|
```
|
|
|
|
Each occurrence is a fix site.
|
|
|
|
- [ ] **Step 2: Apply fix A — PID-lookup race**
|
|
|
|
Find and replace, across the test plan:
|
|
|
|
```
|
|
PID=$(pgrep -f 'srcds_linux.*left4dead2' | head -1)
|
|
```
|
|
|
|
With:
|
|
|
|
```
|
|
PID=$(systemctl show -p MainPID --value left4me-server@1.service)
|
|
```
|
|
|
|
(For tests targeting `@2`, use `@2.service` instead.)
|
|
|
|
- [ ] **Step 3: Apply fix B — gdb verification flaw**
|
|
|
|
Find every block like:
|
|
|
|
```bash
|
|
sudo nsenter --target $PID --mount -- /usr/bin/gdb --batch -p $GUNICORN_PID 2>&1 | tail -3
|
|
# Expect: ptrace: Operation not permitted
|
|
```
|
|
|
|
Replace with a note that this verification is unreliable, and offer two replacement approaches:
|
|
|
|
```bash
|
|
# This nsenter-based gdb runs as root with full caps in only the unit's
|
|
# mount namespace; the SECCOMP filter doesn't apply, so the result is
|
|
# not meaningful. Use one of these instead:
|
|
#
|
|
# Option A: probe inside the same hardening profile.
|
|
sudo systemd-run --pty --uid=left4me --gid=left4me \
|
|
-p NoNewPrivileges=true \
|
|
-p PrivateUsers=true \
|
|
-p CapabilityBoundingSet= \
|
|
-p AmbientCapabilities= \
|
|
-p SystemCallArchitectures='native x86' \
|
|
-p SystemCallFilter='@system-service' \
|
|
-p SystemCallFilter='~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged' \
|
|
-- /usr/bin/gdb --batch -p $GUNICORN_PID 2>&1 | tail -3
|
|
# Expect: ptrace: Operation not permitted (or seccomp-related kill)
|
|
#
|
|
# Option B: inspect the compiled SystemCallFilter directly.
|
|
sudo systemd-analyze syscall-filter left4me-server@1.service 2>&1 | grep -E '^(ptrace|process_vm)'
|
|
# Expect: empty (these syscalls are not in the allow list)
|
|
```
|
|
|
|
(Note: `systemd-analyze syscall-filter` may not accept a unit name; if not, parse the unit's `SystemCallFilter=` lines and resolve via `systemd-analyze syscall-filter @system-service`.)
|
|
|
|
- [ ] **Step 4: Apply fix C — D5 pgrep pattern**
|
|
|
|
Find Test 8's D5 verification:
|
|
|
|
```bash
|
|
PID2=$(pgrep -f 'srcds_linux.*\@2' | head -1)
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```bash
|
|
PID2=$(systemctl show -p MainPID --value left4me-server@2.service)
|
|
```
|
|
|
|
- [ ] **Step 5: Apply fix D — package name**
|
|
|
|
Find and replace:
|
|
|
|
```
|
|
scmp_sys_resolver (in libseccomp-dev; install on demand for Test 3/4 if filters need analysis)
|
|
```
|
|
|
|
With:
|
|
|
|
```
|
|
scmp_sys_resolver (in `seccomp` package on Debian 13; install on demand for Test 3/4 if filters need analysis)
|
|
```
|
|
|
|
- [ ] **Step 6: Mark the "Spec bugs surfaced" subsection as resolved**
|
|
|
|
If the test plan has a section titled "Spec bugs surfaced" or "Output", append a resolution line:
|
|
|
|
```markdown
|
|
**Resolved 2026-05-15 via the hardening-refactor plan** (commit
|
|
will be filled in after step 7 of Task 8). The four bugs are
|
|
fixed in-place in the test commands above.
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git add docs/superpowers/specs/2026-05-15-hardening-test-plan.md
|
|
git commit -m "$(cat <<'EOF'
|
|
spec(hardening-test-plan): fix four bugs surfaced by executor
|
|
|
|
Four corrections noted by the test plan's executor in commit 461b8d0:
|
|
|
|
- PID-lookup race: pgrep+head can pick the wrong instance. Replace
|
|
with systemctl show -p MainPID --value left4me-server@N.service.
|
|
- gdb-from-host ptrace check: nsenter into only the mount namespace
|
|
with root caps bypasses the SECCOMP filter, so the test is a false
|
|
positive. Replace with systemd-run-with-same-directives probe, or
|
|
syscall-filter inspection.
|
|
- D5 pgrep pattern: 'srcds_linux.*\@2' doesn't match because @N is
|
|
in the unit name, not argv. Use systemctl show -p MainPID.
|
|
- scmp_sys_resolver is in the seccomp package on Debian 13, not
|
|
libseccomp-dev.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Mark `2026-05-15-user-uid-split-design.md` superseded
|
|
|
|
**Repo:** `~/Projekte/left4me`
|
|
|
|
**Files:**
|
|
- Modify: `~/Projekte/left4me/docs/superpowers/specs/2026-05-15-user-uid-split-design.md` (front-matter)
|
|
|
|
- [ ] **Step 1: Read the current top of the file**
|
|
|
|
```bash
|
|
head -10 ~/Projekte/left4me/docs/superpowers/specs/2026-05-15-user-uid-split-design.md
|
|
```
|
|
|
|
- [ ] **Step 2: Add a "Status: superseded" header**
|
|
|
|
Insert at the top of the file, immediately after the H1 title line:
|
|
|
|
```markdown
|
|
**Status: SUPERSEDED 2026-05-15 by the hardening refactor.**
|
|
|
|
The original question — should left4me have 1, 2, or 3 system users — is
|
|
now answered: **2 users (current state) is correct.** The
|
|
defenses that motivated a 3-user split (DB readability from srcds,
|
|
cross-server ptrace, same-uid /proc visibility, web-side reach into
|
|
gameserver state) are closed by the systemd hardening composition
|
|
landed in commit `<refactor commit>`:
|
|
- `PrivateUsers=true` blocks cross-uid ptrace at the kernel level.
|
|
- `PrivatePIDs=true` hides peer processes even when uids match.
|
|
- `TemporaryFileSystem=` + minimal binds hide the DB and web.env from
|
|
srcds entirely.
|
|
- `SystemCallFilter=~@debug` + empty `CapabilityBoundingSet=` block
|
|
ptrace at the syscall layer.
|
|
|
|
The residual filesystem-ACL surface (DB at `0640 root:left4me`,
|
|
web.env same) is a separate concern: a uid split would close it via
|
|
kernel ACLs, but for the current deployment shape it's covered by the
|
|
systemd-imposed FS view. If the deployment shape changes (multi-tenant
|
|
host, shell logins as the service uids, additional services running
|
|
as `left4me` outside these units) the uid split should be revisited.
|
|
|
|
The original content of this spec is preserved below for context.
|
|
|
|
---
|
|
```
|
|
|
|
(Replace `<refactor commit>` with the actual hash once the commits land — Task 10 will sweep this.)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git add docs/superpowers/specs/2026-05-15-user-uid-split-design.md
|
|
git commit -m "$(cat <<'EOF'
|
|
spec(user-uid-split): mark superseded by the hardening refactor
|
|
|
|
The 1/2/3-user question is answered: stay at 2 (left4me + l4d2-sandbox).
|
|
The defenses that motivated a 3-user split (cross-uid ptrace,
|
|
cross-server contamination, web-side reach into gameserver state,
|
|
DB/env exposure to srcds) are closed by the systemd hardening
|
|
composition: PrivateUsers + PrivatePIDs + TemporaryFileSystem +
|
|
SystemCallFilter=~@debug + empty CapabilityBoundingSet.
|
|
|
|
The residual filesystem-ACL surface (mode 0640 root:left4me on DB and
|
|
web.env) is noted as a separate concern — covered for the current
|
|
deployment shape, revisit if shape changes.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Cross-repo push (operator-gated)
|
|
|
|
**Repos:** both
|
|
|
|
**Pre-condition:** all prior tasks committed locally in both repos.
|
|
|
|
- [ ] **Step 1: Verify clean state in both repos**
|
|
|
|
Run:
|
|
```bash
|
|
cd ~/Projekte/left4me && git status && git log --oneline origin/master..HEAD
|
|
echo "---"
|
|
cd ~/Projekte/ckn-bw && git status && git log --oneline origin/master..HEAD
|
|
```
|
|
|
|
Expected: both repos clean working trees; left4me has 4 new commits (Tasks 7, 8, 9, + possibly the Task 1 emitter-doc update), ckn-bw has 4 new commits (Tasks 2, 3, 4, 5).
|
|
|
|
- [ ] **Step 2: Confirm with operator before push**
|
|
|
|
Pause and confirm with the operator that pushing is the right move. Don't push autonomously — the operator's pattern in earlier sessions has been to commit locally and push manually.
|
|
|
|
- [ ] **Step 3: If approved, push both repos**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me && git push origin master
|
|
cd ~/Projekte/ckn-bw && git push origin master
|
|
```
|
|
|
|
- [ ] **Step 4: Sweep the placeholder `<refactor commit>` in `2026-05-15-user-uid-split-design.md`**
|
|
|
|
After the push, look up the actual refactor commit hash (the most recent left4me commit) and replace `<refactor commit>` in the uid-split spec's superseded header.
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
REFACTOR_HASH=$(git log --oneline -1 --format='%h')
|
|
sed -i.bak "s/<refactor commit>/$REFACTOR_HASH/" docs/superpowers/specs/2026-05-15-user-uid-split-design.md
|
|
rm docs/superpowers/specs/2026-05-15-user-uid-split-design.md.bak
|
|
git add docs/superpowers/specs/2026-05-15-user-uid-split-design.md
|
|
git commit --amend --no-edit
|
|
git push --force-with-lease origin master # only if not yet pulled elsewhere
|
|
```
|
|
|
|
(Force-push is OK here because the placeholder fill is part of the same commit; only acceptable if no other operator has pulled.)
|
|
|
|
Alternative: leave `<refactor commit>` as a follow-up sweep; the spec is human-readable either way.
|
|
|
|
---
|
|
|
|
## Task 11: `bw apply ovh.left4me` and verify on the host
|
|
|
|
**Repo:** `~/Projekte/ckn-bw` (executes against `left4.me`)
|
|
|
|
**Pre-condition:** Task 10 completed.
|
|
|
|
- [ ] **Step 1: Apply**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw apply ovh.left4me 2>&1 | tee /tmp/bw-apply.log
|
|
```
|
|
|
|
Watch for errors. Expected outcome: the systemd units `left4me-server@1`, `left4me-server@2`, `left4me-web` are reloaded and restarted by `bw`. The sysctl drop-in is installed and reloaded.
|
|
|
|
- [ ] **Step 2: Verify directives present on the host**
|
|
|
|
SSH to `left4.me`:
|
|
|
|
```bash
|
|
ssh root@left4.me # or whatever the operator's ssh alias is
|
|
sudo systemctl cat left4me-server@1.service
|
|
# Expect: contains every directive from HARDENING_SERVER. Specifically
|
|
# verify PrivatePIDs=true and SystemCallArchitectures=native x86 appear.
|
|
sudo systemctl cat left4me-web.service
|
|
# Expect: ProtectSystem=strict (not full), and the new hardening
|
|
# directives present (no NoNewPrivileges, no PrivateUsers).
|
|
sysctl kernel.yama.ptrace_scope
|
|
# Expect: kernel.yama.ptrace_scope = 2
|
|
```
|
|
|
|
- [ ] **Step 3: Functional smoke**
|
|
|
|
```bash
|
|
# All services active
|
|
sudo systemctl is-active left4me-server@1 left4me-server@2 left4me-web
|
|
# Expect: active active active
|
|
|
|
# 30 seconds later, still active (catches respawn-loop regressions)
|
|
sleep 30
|
|
sudo systemctl is-active left4me-server@1 left4me-server@2 left4me-web
|
|
|
|
# systemd-analyze score
|
|
sudo systemd-analyze security left4me-server@1.service | tail -3
|
|
# Expect: a low score (≤ 2.0); recall baseline was 7.5, Test 7 was 1.3
|
|
sudo systemd-analyze security left4me-web.service | tail -3
|
|
# Expect: ≤ 5.0; baseline 8.7, Test 10 was 4.1
|
|
```
|
|
|
|
- [ ] **Step 4: Attack-vector re-verification (subset of Test 8, with the corrected probes)**
|
|
|
|
```bash
|
|
PID1=$(systemctl show -p MainPID --value left4me-server@1.service)
|
|
GUNICORN_PID=$(systemctl show -p MainPID --value left4me-web.service)
|
|
PID2=$(systemctl show -p MainPID --value left4me-server@2.service)
|
|
|
|
# D1.a — DB invisible to srcds
|
|
sudo nsenter --target $PID1 --mount -- cat /var/lib/left4me/left4me.db 2>&1 | head -1
|
|
# Expect: No such file or directory
|
|
|
|
# D1.b — web.env invisible
|
|
sudo nsenter --target $PID1 --mount -- cat /etc/left4me/web.env 2>&1 | head -1
|
|
# Expect: No such file or directory
|
|
|
|
# D2.b — gunicorn invisible via /proc
|
|
sudo nsenter --target $PID1 --mount --pid -- ls /proc/$GUNICORN_PID 2>&1 | head -1
|
|
# Expect: No such file or directory (PrivatePIDs makes the host PID not exist in the namespace)
|
|
|
|
# D5 — cross-instance: server@1 cannot see server@2's PID
|
|
sudo nsenter --target $PID1 --mount --pid -- ls /proc/$PID2 2>&1 | head -1
|
|
# Expect: No such file or directory
|
|
|
|
# Syscall filter compiled correctly (option B from Task 8/step 3 fix)
|
|
sudo systemd-analyze syscall-filter left4me-server@1.service 2>/dev/null \
|
|
| grep -E '^\s*(ptrace|process_vm_)' || echo "blocked (not in allow list)"
|
|
# Expect: "blocked (not in allow list)"
|
|
```
|
|
|
|
- [ ] **Step 5: Smoke against the web UI**
|
|
|
|
Manually:
|
|
- Open the web UI (whatever URL the operator uses for left4.me).
|
|
- Log in.
|
|
- Start/stop a server (exercises the sudo path).
|
|
- View live logs for a server.
|
|
- Trigger an overlay rebuild for a script overlay (exercises the sandbox).
|
|
|
|
Confirm everything works. If anything breaks, the fix is to identify which directive caused it via journalctl, narrow the filter, and iterate.
|
|
|
|
- [ ] **Step 6: Record on-host state**
|
|
|
|
If something breaks: file a follow-up note in the test plan's Results section. If clean: just proceed.
|
|
|
|
- [ ] **Step 7: No commit needed for this task** (host verification only)
|
|
|
|
---
|
|
|
|
## Task 12: Apt-remove test tooling from `left4.me`
|
|
|
|
**Repo:** none (host change)
|
|
|
|
**Pre-condition:** Task 11 verified.
|
|
|
|
- [ ] **Step 1: Confirm tooling is unused**
|
|
|
|
SSH to `left4.me` and confirm no live processes need gdb/libseccomp/seccomp:
|
|
|
|
```bash
|
|
which gdb
|
|
# Expect: /usr/bin/gdb (still installed)
|
|
sudo apt list --installed 2>/dev/null | grep -E '^(gdb|libseccomp-dev|seccomp)/'
|
|
# Expect: list of those packages
|
|
```
|
|
|
|
- [ ] **Step 2: Remove**
|
|
|
|
```bash
|
|
sudo apt remove --purge -y gdb libseccomp-dev seccomp
|
|
sudo apt autoremove -y
|
|
```
|
|
|
|
- [ ] **Step 3: Verify clean**
|
|
|
|
```bash
|
|
which gdb 2>&1
|
|
# Expect: empty (gdb not in path)
|
|
sudo apt list --installed 2>/dev/null | grep -E '^(gdb|libseccomp-dev|seccomp)/'
|
|
# Expect: empty
|
|
```
|
|
|
|
- [ ] **Step 4: Verify services still happy**
|
|
|
|
```bash
|
|
sudo systemctl is-active left4me-server@1 left4me-server@2 left4me-web
|
|
# Expect: active active active
|
|
```
|
|
|
|
- [ ] **Step 5: No commit needed** (host change only; if future operator wants reproducibility, add the removal to ckn-bw's `pkg_apt` exclusion list — but defer that decision)
|
|
|
|
---
|
|
|
|
## Plan complete
|
|
|
|
After Task 12, write a final session-handoff at
|
|
`docs/superpowers/specs/2026-05-15-session-handoff.md` (overwrite the
|
|
existing executor handoff) summarizing:
|
|
|
|
- Refactor landed (commit hashes both repos)
|
|
- All attack vectors still blocked post-deploy
|
|
- uid-split spec marked superseded
|
|
- Next session: build-overlay-unit refactor or the deferred drop-in
|
|
reshape, operator's call
|
|
|
|
Commit + push the handoff.
|
|
|
|
## Self-review checklist
|
|
|
|
After completing all tasks, verify against the design spec:
|
|
|
|
- [ ] All directives from HARDENING_SERVER appear in the deployed
|
|
`left4me-server@.service` (per Task 11/Step 2).
|
|
- [ ] All directives from HARDENING_WEB appear in the deployed
|
|
`left4me-web.service`.
|
|
- [ ] PrivatePIDs=true and SystemCallArchitectures=native x86 present
|
|
on server@. (Test amendments.)
|
|
- [ ] MemoryDenyWriteExecute=true absent everywhere.
|
|
- [ ] SocketBindAllow=udp:27000-27999 + tcp:27000-27999 on server@.
|
|
- [ ] `kernel.yama.ptrace_scope=2` on the host.
|
|
- [ ] Reference units in deploy/files annotated with per-directive
|
|
comments.
|
|
- [ ] Test plan's four bugs fixed.
|
|
- [ ] uid-split spec marked superseded.
|
|
- [ ] gdb + seccomp + libseccomp-dev removed from left4.me.
|
|
- [ ] All three units active and stable.
|
|
- [ ] Test 8 D1.a, D1.b, D2.b, D5 vectors all blocked.
|