# systemg — Security Review (2026-07-13)

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`), the IPC protocol, filesystem permissions, and every cross-privilege boundary (privilege dropping, capabilities, namespaces, cgroups, FD inheritance, signals, PID reuse, env sanitization).

Follow-up to `reports/2026-06-11.md`. Headline: **every prior finding (P0–P5, K0–K2) is fixed or an accepted risk** — the socket now enforces `SO_PEERCRED`/`getpeereid`, socket-supplied config paths are validated with `ensure_trusted_config`, runtime dirs/files/socket are `0700`/`0600`, the panic paths are gone, supplementary groups always reset on a uid switch, and activation FDs are `FD_CLOEXEC`.

The new findings all live **behind** that authentication boundary, so under the same-UID trust model none is a remote or cross-privilege break. They are reported as hardening / defense-in-depth: the socket is the trust boundary and its inputs (service names, control-line length) are still free-form. Ordered lowest → highest.

| Pri | Issue | Location |
|-----|-------|----------|
| P0 | Path traversal via socket-supplied service name in the log subsystem → arbitrary file **read** and **create** as the supervisor | `logs.rs:614,621,885,1124`; `supervisor.rs:1607` |
| P1 | `Spawn` children run with no privilege drop (always supervisor/root) | `supervisor.rs:1945` |
| P2 | Full supervisor environment inherited by dropped services (no `env_clear`) | `daemon.rs:2211,2244` |
| P3 | Config check→load TOCTOU in `ensure_trusted_config` | `runtime.rs:182`; `supervisor.rs:657` |
| P4 | Unbounded control-line read from the socket | `ipc.rs:410` |
| P5 | `serde_yaml` is unmaintained (RUSTSEC-2024-0320) | `Cargo.toml:41` |

---

### P5 — `serde_yaml` is an unmaintained dependency

**What.** All config parsing goes through `serde_yaml`, archived by its author and shipped as `0.9.34+deprecated`.

**Where.** `Cargo.toml:41` (`serde_yaml = "0.9"`); used in `config.rs` (`serde_yaml::from_str`).

**Why.** No security fixes upstream ([RUSTSEC-2024-0320](https://rustsec.org/advisories/RUSTSEC-2024-0320)). Config is operator-controlled, but a future parser bug (alias-expansion or deep-nesting exhaustion) would never be patched. Same class as `rustls-pemfile` in `reports/CVE.md`.

**Fix.** Migrate to `serde_yaml_ng`/`serde_yml` (no API break) and add `serde_yaml` to the RUSTSEC deny-list.

---

### P4 — Unbounded control-line read from the socket

**What.** `read_command` reads with `BufReader::read_line` into a `String` with no length cap; a connection streaming bytes without a newline grows the buffer until OOM.

**Where.** `src/ipc.rs:410` (`reader.read_line(&mut line)?`); same pattern client-side in `send_command`/`send_command_with_timeout`.

**Why.** Allocation without limits ([CWE-770](https://cwe.mitre.org/data/definitions/770.html)). Gated behind `authenticate_peer`, so only a same-UID/root peer can trigger it — a local DoS, not a privesc. Still, one framed message should never exhaust supervisor memory.

**Fix.** `.take(MAX_CONTROL_LINE)` (e.g. 1 MiB) and return a framing error past the cap.

---

### P3 — Config check→load TOCTOU in `ensure_trusted_config`

**What.** `ensure_trusted_config` `stat`s the path (rejecting group/other-writable or foreign-owned files), then the caller re-opens the same path by string in `load_config`. The file validated is not necessarily the file parsed and executed.

**Where.** `src/runtime.rs:182-210` (`fs::metadata`, follows symlinks); callers at `src/supervisor.rs:657-659`, `697-698`, `1801-1802`, `2111-2112`, `2213-2214`.

**Why.** Check/use race ([CWE-367](https://cwe.mitre.org/data/definitions/367.html)) plus symlink-following in the check ([CWE-59](https://cwe.mitre.org/data/definitions/59.html)). Exploiting it needs write access to a path component and same-UID auth, so it is a narrow authenticated race — but a validated config can be swapped for an attacker's before its `command:` entries launch as the supervisor. Cf. [CVE-2021-3560](https://nvd.nist.gov/vuln/detail/CVE-2021-3560).

**Fix.** Open once (`O_NOFOLLOW` on the final component), validate the `fstat` of the open descriptor, then parse from that descriptor — never re-resolve the path string.

---

### P2 — Dropped services inherit the full supervisor environment

**What.** systemg merges configured env vars onto the inherited process environment and strips only a small session denylist (`SSH_AUTH_SOCK`, `SSH_AGENT_PID`, `SSH_CLIENT`, `SSH_CONNECTION`, `SSH_TTY`). It never calls `Command::env_clear()`, so a service dropped to a lower-privileged user still gets the supervisor's (root's) entire environment.

**Where.** `src/daemon.rs:2211` (`collect_service_env` merged onto inherited env), `2235-2247` (strip-list); constants at `src/constants.rs:135`.

**Why.** Environment leak across a privilege boundary ([CWE-526](https://cwe.mitre.org/data/definitions/526.html) / [CWE-200](https://cwe.mitre.org/data/definitions/200.html)): any secret or loader var in root's env (`AWS_SECRET_ACCESS_KEY`, `VAULT_TOKEN`, a poisoned `PATH`/`LD_*`) is handed to every dropped child. systemd starts services from a clean environment for exactly this reason.

**Fix.** `env_clear()` then install only the resolved set (config + `PATH`/`HOME`/`USER`), with an opt-in `inherit_env`; at minimum extend the strip-list to well-known secret and `LD_*` vars.

---

### P1 — `Spawn` children run with no privilege drop

**What.** `handle_spawn` builds a child with a bare `Command::new(program)` and no `PrivilegeContext`/`pre_exec`, so — unlike configured services — spawned children get no uid/gid switch, capability trimming, rlimits, namespaces, or cgroup placement. In System mode they run as **root**.

**Where.** `src/supervisor.rs:1932-1960` (`handle_spawn`); contrast the configured path at `src/daemon.rs:2214-2252`.

**Why.** Execution with unnecessary privileges ([CWE-250](https://cwe.mitre.org/data/definitions/250.html)). `authorize_spawn` requires `parent_pid` to map to a tracked service and the socket is authenticated, so only a same-UID/root peer reaches it — not a cross-privilege break. But a child of a dropped service silently runs at maximum privilege, defeating the expectation that it is at most as privileged as its parent.

**Fix.** Resolve the parent service's `PrivilegeContext` (via the authorized `root_service`) and apply the same `pre_exec` drop, or at minimum inherit its user/caps.

---

### P0 — Path traversal via socket-supplied service name in the log subsystem

**What.** The `Logs` command carries a client-chosen `service` string. On no unit match, the supervisor still builds on-disk log paths from the raw string and both reads matching files back over the socket and **creates** files/dirs for it. The name is never checked for `..` or path separators, so `../../../../etc/cron.d/pwn` escapes the log directory.

**Where.**
- `src/supervisor.rs:1607-1621` — raw `service_name` flows into `get_service_log_path` / `resolve_log_path` → `stream_log_to_socket`.
- `src/logs.rs:613-624` — `canonical_log_path` builds `log_dir().push(format!("{service}_{kind}.log"))` with no sanitization (`normalize()` runs only in the `locate_existing_*` matchers).
- `src/logs.rs:1120-1136` — `resolve_tail_targets` → `touch_log_file` (`src/logs.rs:885-891`) does `create_dir_all(parent)` + `OpenOptions::create(true).open(path)`.

**Why.** Path traversal from untrusted socket input ([CWE-22](https://cwe.mitre.org/data/definitions/22.html); write side [CWE-73](https://cwe.mitre.org/data/definitions/73.html)). As the supervisor (root in System mode) this yields **arbitrary read** of any reachable `*.log` path streamed back to the caller, and **arbitrary file/dir creation** at attacker-influenced paths ending in `.log`. Gated behind `authenticate_peer`, so under the same-UID model it is not a privesc — but the service name is free-form input crossing the trust boundary, and a traversal-to-root-filesystem primitive should not exist. Cf. [CVE-2021-33503](https://nvd.nist.gov/vuln/detail/CVE-2021-33503).

**Fix.** Validate the service name at the socket boundary: reject any `/`, `\0`, or `.`/`..` component (or accept only `[A-Za-z0-9._-]`, bounded length), and assert every resolved path stays inside `runtime::log_dir()` before opening. The unmatched-service branch should not create files at all.
