# systemg — Security Review (2026-06-11)

Process supervisor / init system. Trust model: the **config file is operator-controlled** (commands, users, health-check URLs there are intended execution, not vulns). Real attack surface is the **control socket** (`control.sock`) and cross-privilege boundaries. This review has two parts: a **user-space** audit (control plane, IPC, config) and a **kernel-space** audit (the privileged paths systemg runs as root — privilege dropping, capabilities, namespaces, cgroups, FD inheritance). Findings are ordered lowest → highest within each part.

## User-space audit

| Pri | Issue | Location |
|-----|-------|----------|
| P0 | Unauthenticated socket + trusted `parent_pid` → spawn as supervisor; empty-command panic | `supervisor.rs:1383,1868`; `ipc.rs:126` |
| P1 | `Restart`/`AddProject` execute arbitrary client-named config | `ipc.rs:52,78` |
| P2 | Control socket has no restrictive perms | `supervisor.rs:868` |
| P3 | World-accessible state/log dirs & files | `ipc.rs:19`; `runtime.rs:75` |
| P4 | `expand_env_vars` panics on missing var | `config.rs:1116` |
| P5 | `parse_memory_limit` unchecked multiply | `spawn.rs:558` |

Root cause for P0–P2: the control socket has no authentication and no filesystem hardening. Peer-credential checks + socket/dir permissions neutralize most impact.

---

### P5 — Integer-overflow panic in `parse_memory_limit`

**What.** A `total_memory` like `"99999999999999999999G"` overflows an unchecked multiply.

**Where.** `src/spawn.rs:558` — `number_part.parse::<u64>().ok().map(|v| v * factor)`.

**Why.** Panics in debug; silently wraps in release ([CWE-190](https://cwe.mitre.org/data/definitions/190.html)). Operator-config-only, so impact is minimal.

**Fix.** Use `v.checked_mul(factor)`, return `None` on overflow.

---

### P4 — `expand_env_vars` panics on a missing variable

**What.** Any config containing `${UNSET_VAR}` aborts the process instead of returning an error.

**Where.** `src/config.rs:1116` — `Err(_) => panic!("Missing environment variable: {var_name}")`.

**Why.** Uncaught panic in a long-running daemon ([CWE-248](https://cwe.mitre.org/data/definitions/248.html)). Reachable via the client-supplied config path on `Restart`/`AddProject` (see P1), turning a config typo or hostile config into supervisor downtime.

**Fix.** Return `Err(ProcessManagerError::…)`; let the caller reject the config and keep running.

---

### P3 — World-accessible runtime/log directories

**What.** State/log dirs and the PID, state, and `config_hint` files are created with default umask, ending up world-readable with world-traversable parents.

**Where.** `src/ipc.rs:19` (`runtime_dir`) and `src/runtime.rs:75-88` (System mode `/var/lib/systemg`, `/var/log/systemg`).

**Why.** Local information disclosure of service topology, PIDs, and config paths ([CWE-732](https://cwe.mitre.org/data/definitions/732.html)). Also exposes the control socket to other local users (enables P2). Compare [CVE-2021-3997](https://nvd.nist.gov/vuln/detail/CVE-2021-3997) (systemd runtime-dir handling).

**Fix.** `set_permissions` state/log dirs to `0700`/`0750` and sensitive files to `0600` after creation; set umask early in daemon startup.

---

### P2 — Control socket created with no restrictive permissions

**What.** `control.sock` is bound with default umask and never `chmod`ed, so on many systems other local users can `connect()`.

**Where.** `src/supervisor.rs:868` — `UnixListener::bind(&socket_path)?` (no `set_permissions` anywhere).

**Why.** The socket is the **sole control channel** ([CWE-732](https://cwe.mitre.org/data/definitions/732.html)). Anyone who can connect issues `Shutdown`/`Stop`/`Restart`/`Spawn`. Root-run, this is a local-privesc primitive (see P0/P1). Cf. [CVE-2020-15709](https://nvd.nist.gov/vuln/detail/CVE-2020-15709) (unix-socket privesc class).

**Fix.** After bind, `fs::set_permissions(&socket_path, Permissions::from_mode(0o600))` inside a `0700` dir; ideally enforce peer credentials (P0).

---

### P1 — `Restart` / `AddProject` accept an arbitrary client-supplied config path

**What.** A client names any filesystem path as a config; the supervisor (root in System mode) reads it, expands env vars, and launches its `command:` entries.

**Where.** `src/ipc.rs:52-88` — `AddProject { config: String }`, `Restart { config: Option<String> }`; handled via `reload_config` / project-add → `load_config`.

**Why.** Arbitrary command execution with the supervisor's privileges ([CWE-94](https://cwe.mitre.org/data/definitions/94.html) / [CWE-862](https://cwe.mitre.org/data/definitions/862.html)). With P2, any local user escalates to the supervisor UID by pointing it at a config they wrote. No path validation against a trusted root.

**Fix.** Gate behind peer-credential checks (P0); restrict accepted config paths to a trusted directory; require the same UID as the supervisor owner.

---

### P0 — Unauthenticated control socket; trusted client `parent_pid`; empty-command panic

**What.** Every socket command is processed with zero authentication, yielding (1) arbitrary process spawn as the supervisor and (2) a one-packet DoS. `Spawn` runs `Command::new(params.command[0]).args(…)` directly; authorization keys off a **client-supplied `parent_pid`** (and a single-tree fallback that authorizes *any* spawn); an empty `command` array panics on `command[0]`.

**Where.** Accept loop `src/supervisor.rs:1383` (`Ok((mut stream, _addr))`, no `SO_PEERCRED`/`getpeereid`); `Spawn` path `ipc.rs:126-141` + `supervisor.rs:1821-1868`; single-tree fallback `spawn.rs:481-485`.

**Why.** Both consequences are unauthenticated ([CWE-306](https://cwe.mitre.org/data/definitions/306.html) + [CWE-862](https://cwe.mitre.org/data/definitions/862.html)):
1. **Arbitrary spawn as supervisor** — send a `parent_pid` matching a managed PID (or rely on the fallback) → run any binary as the supervisor (root in System mode). Trusting a PID off the wire ([CWE-807](https://cwe.mitre.org/data/definitions/807.html)) makes the spawn-tree check meaningless. Cf. the Docker [CVE-2019-5736](https://nvd.nist.gov/vuln/detail/CVE-2019-5736) class of "control channel → host exec."
2. **One-packet DoS** — a `Spawn` with an empty `command` panics on `command[0]` ([CWE-1284](https://cwe.mitre.org/data/definitions/1284.html)); the deserializer enforces no minimum length.

**Fix.**
- Authenticate the peer on accept (`SO_PEERCRED` / `getpeereid`); reject UIDs other than the supervisor owner (or an explicit allowlist).
- Derive `parent_pid` from the authenticated peer, or verify the claimed PID belongs to a service the peer owns — never trust it from the wire.
- Reject empty `command` with `ControlResponse::Error` before indexing.
- Tighten the single-tree fallback in `find_spawn_tree` so it can't auto-authorize arbitrary spawns.

---

## Kernel-space audit

The privileged paths systemg runs as root in System mode: privilege dropping (`setuid`/`setgid`/`setgroups`), Linux capabilities, namespace `unshare`, cgroup writes, rlimits, and inherited descriptors. These are hard to UAT from user space, so the focus is on correctness of the drop sequence and what leaks across the privilege boundary into spawned services. Findings ordered lowest → highest.

| Pri | Issue | Location |
|-----|-------|----------|
| K0 | Supplementary groups not reset on user switch (privilege retention) | `privilege.rs:287` |
| K1 | Socket-activation FDs leak into children (no `FD_CLOEXEC`) | `runtime.rs:295` |
| K2 | PID/PGID reuse race in signal delivery (accepted risk) | `daemon.rs:1820` |

The drop sequence itself (`unshare` → limits → nice → affinity → caps-pre → uid/gid switch → caps-post) and the keepcaps/ambient handling are correct; the gaps are in residual group membership and descriptor inheritance.

---

### K2 — PID/PGID reuse race in signal delivery

**What.** Between enumerating a service's descendants/process group and delivering `SIGTERM`/`SIGKILL`, a tracked PID can exit and be recycled by the kernel; systemg then signals an unrelated process as root.

**Where.** `src/daemon.rs:1820` (`terminate_process_tree`) and the `killpg` paths at `daemon.rs:1847,4059,4069`.

**Why.** Signaling a recycled PID/PGID is a time-of-check/time-of-use race ([CWE-367](https://cwe.mitre.org/data/definitions/367.html)); as root the worst case is killing an innocent process. The existing `target_pgid >= 0 && target_pgid != supervisor_pgid` guard already prevents the most dangerous cases (`killpg(0)` broadcast, hitting the supervisor's own group). A complete fix needs `pidfd_open`/`pidfd_send_signal` (Linux 5.6+) to bind signals to a specific process instance.

**Fix.** Accepted risk for now — documented, not changed. A half-measure (extra liveness check) narrows but does not close the window and adds complexity; migrating tree teardown to pidfds is the real fix and is tracked separately.

---

### K1 — Socket-activation descriptors leak into spawned services

**What.** Activation sockets inherited via `LISTEN_FDS` were recorded but never marked close-on-exec, so the raw listening descriptors survived `exec` into every spawned service — including services dropped to a lower-privileged user.

**Where.** `src/runtime.rs:295` (`capture_socket_activation`) — captured `fds` were stored without touching their descriptor flags.

**Why.** A lower-privileged child holding the supervisor's listening sockets can accept connections or interfere with the activation protocol it was never meant to see ([CWE-403](https://cwe.mitre.org/data/definitions/403.html), file-descriptor leak across a privilege boundary). systemd's own `sd_listen_fds` sets `FD_CLOEXEC` for exactly this reason.

**Fix.** Set `FD_CLOEXEC` on each captured activation FD immediately after capture so they do not survive `exec` into children.

---

### K0 — Supplementary groups not reset when dropping privileges

**What.** When a service is dropped to a non-root user, `setgroups` was only called if the config explicitly listed supplementary groups. With none configured, the child kept the supervisor's (root's) supplementary groups — e.g. `0`/`root`, `wheel`, `docker`, `sudo`.

**Where.** `src/privilege.rs:287` — `apply_user_switch` gated `setgroups` behind `if !self.user.supplementary.is_empty()`, then ran `setgid`/`setuid`.

**Why.** This is the classic privilege-drop omission ([CWE-273](https://cwe.mitre.org/data/definitions/273.html)): a process that switched to `nobody` still carried root's group memberships and could read/write group-owned resources (e.g. the Docker socket → root) it was never granted. It is the same bug behind a long line of `setuid`-without-`initgroups`/`setgroups` advisories.

**Fix.** Always call `setgroups` on a uid/gid switch, installing exactly the configured supplementary groups plus the target gid — collapsing to just the target gid when none are configured, which drops every inherited root group. Validated by a root-only forked test that asserts gid 0 is absent from `getgroups` after the switch.
