diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d73e6..8db0ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ replacing a module has to be a decision with visible consequences. ## Unreleased +- **`/processes` no longer returns command lines, and wrong keys no longer cost PBKDF2.** + `/processes` listed each host process's command line, which can carry a password or + a key; it now returns the same metadata as `/runtime` (pid, name, user, CPU, memory). + Every admin request ran 200,000 rounds of PBKDF2, so anyone who could reach the port + could tie up the service with wrong keys; after the first match (at startup, from the + configured key) a constant-time SHA-256 compare decides. Journal and package output + loses terminal control codes, and a command that cannot run reports only its kind of + error. + ## 0.4.0 - **Remove Docker daemon and host-root authority from the shipped container.** diff --git a/docs/security.md b/docs/security.md index 899ae9e..4c1518b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -4,7 +4,7 @@ SystemGate is intentionally narrow. It exists so a local dashboard or agent harn - **Read-only API**: there are no write, exec, restart, package-install, file-edit, or Docker mutation endpoints. Package and log collection shell out to a fixed, non-injectable command set; no caller input reaches a shell. - **Admin-key auth**: every endpoint except `/health` requires `X-SystemGate-Key`. -- **PBKDF2 key storage**: the first configured `SYSTEMGATE_ADMIN_KEY` is stored as a PBKDF2 hash under `data/admin-key.pbkdf2`; the raw key is not stored by SystemGate. +- **PBKDF2 key storage**: the first configured `SYSTEMGATE_ADMIN_KEY` is stored as a PBKDF2 hash under `data/admin-key.pbkdf2`; the raw key is not stored by SystemGate. Once a key has matched (at startup, from the configured key), later requests compare a SHA-256 of the presented key in constant time instead of running PBKDF2 again, so a caller sending wrong keys cannot tie up the service with slow hashing. A new key file is checked in full again. - **Server-side proxy friendly**: a dashboard can call SystemGate from its backend so the browser never receives the SystemGate key. - **Loopback binding**: compose publishes the API on `127.0.0.1:8040` only. - **Private Docker network**: the standalone compose file uses `systemgate_net` for local service-to-service traffic. @@ -13,6 +13,8 @@ SystemGate is intentionally narrow. It exists so a local dashboard or agent harn - **Container-scoped disk figures**: the host root is not visible to SystemGate. `/vitals` reports the disk path and scope so callers do not mistake container filesystem capacity for host capacity. - **Says which machine it measured**: `/vitals` reports `source.scope` as `host` or `container`, with the procfs and disk paths actually in use - read back from psutil, not from the configuration, so it reports where the numbers came from rather than where they were asked to come from. Host figures require `SYSTEMGATE_PROCFS_PATH=/host/proc`, the host root mount and `uts: host`, all set by the bundled compose file. psutil honours no environment variable for procfs redirection, so SystemGate assigns `psutil.PROCFS_PATH` itself at import; setting an environment variable alone silently does nothing. - **Bounded outputs**: process, log, package, backup, and container responses are capped so host telemetry cannot become an unbounded data leak. +- **No command lines**: `/processes`, `/services` and `/runtime` never collect a process's command line or environment, which often carry passwords and keys. +- **Clean text**: log and package output loses terminal control codes, and a command that cannot run is reported by its kind of error, not its message. - **No secret logging**: endpoints return system telemetry only; admin keys and upstream service secrets are not returned in responses. - **Telemetry only**: SystemGate can observe process lists, packages, journal errors, vitals, and backup timestamps, but it cannot change them. Container telemetry requires a future least-authority collector contract; it is not inferred from daemon access. diff --git a/systemgate/auth.py b/systemgate/auth.py index e2a3db0..c7c634f 100644 --- a/systemgate/auth.py +++ b/systemgate/auth.py @@ -14,9 +14,18 @@ def hash_key(key: str, salt: bytes | None = None) -> str: return f"{base64.b64encode(salt).decode()}${base64.b64encode(digest).decode()}" +# Stored hash -> SHA-256 of the key that matched it. PBKDF2 is slow on purpose, and paying it on +# every request let any caller that reaches the port burn CPU with wrong keys. Once a key has +# matched, a fast constant-time compare decides; a changed key file is a new entry. +_matched: dict[str, bytes] = {} + + def verify_key(key: str | None, encoded: str) -> bool: if not key or not encoded: return False + fast = hashlib.sha256(key.encode("utf-8")).digest() + if encoded in _matched: + return hmac.compare_digest(fast, _matched[encoded]) try: salt_b64, digest_b64 = encoded.split("$", 1) salt = base64.b64decode(salt_b64.encode()) @@ -24,5 +33,10 @@ def verify_key(key: str | None, encoded: str) -> bool: except Exception: return False actual = hashlib.pbkdf2_hmac("sha256", key.encode("utf-8"), salt, PBKDF2_ROUNDS) - return hmac.compare_digest(actual, expected) + if not hmac.compare_digest(actual, expected): + return False + if len(_matched) >= 8: + _matched.clear() + _matched[encoded] = fast + return True diff --git a/systemgate/main.py b/systemgate/main.py index 1200750..fc1a44a 100644 --- a/systemgate/main.py +++ b/systemgate/main.py @@ -2,6 +2,7 @@ import os import platform +import re import subprocess import time from contextlib import asynccontextmanager @@ -38,8 +39,13 @@ def procfs_path() -> str: return str(getattr(psutil, "PROCFS_PATH", "/proc")) +# Terminal control codes: C0 except tab and newline, DEL and C1. Journal lines carry whatever any +# host process printed, and an escape sequence must not reach a terminal-style view intact. +_CONTROL = re.compile("[\x00-\x08\x0b-\x1f\x7f-\x9f]") + + def _bounded(value: str, limit: int = 4000) -> str: - return value[-limit:] + return _CONTROL.sub("", value)[-limit:] def _secret_path(settings: Settings) -> Path: @@ -69,6 +75,9 @@ def require_admin(request: Request, x_systemgate_key: str | None = Header(None, async def lifespan(app: FastAPI): settings = get_settings() _ensure_key_hash(settings) + if settings.admin_key: + # One slow check now, so a wrong key never costs a slow check later (see verify_key). + verify_key(settings.admin_key, _secret_path(settings).read_text(encoding="utf-8").strip()) app.state.settings = settings app.state.package_cache = {"at": 0.0, "value": None} yield @@ -243,15 +252,17 @@ def containers(): } +# Metadata only, like /runtime: a command line can carry a password or a key. +_PROCESS_FIELDS = ["pid", "name", "username", "cpu_percent", "memory_percent"] + + @app.get("/processes", dependencies=[Depends(require_admin)]) def processes(limit: int = 20): limit = max(1, min(limit, 100)) rows = [] - for proc in psutil.process_iter(["pid", "name", "username", "cpu_percent", "memory_percent", "cmdline"]): + for proc in psutil.process_iter(_PROCESS_FIELDS): try: - item = proc.info - item["cmdline"] = " ".join(item.get("cmdline") or [])[:300] - rows.append(item) + rows.append({field: proc.info.get(field) for field in _PROCESS_FIELDS}) except (psutil.NoSuchProcess, psutil.AccessDenied): continue rows.sort(key=lambda item: (float(item.get("cpu_percent") or 0), float(item.get("memory_percent") or 0)), reverse=True) @@ -266,7 +277,7 @@ def _journal_errors() -> str: result = subprocess.run(command, check=False, capture_output=True, text=True, timeout=8) chunks.append(result.stdout or result.stderr) except Exception as exc: - chunks.append(f"{command[0]} unavailable: {exc}") + chunks.append(f"{command[0]} unavailable: {type(exc).__name__}") return _bounded("\n".join(chunks), 12000) @@ -280,7 +291,7 @@ def _run_summary(command: list[str]) -> dict[str, Any]: result = subprocess.run(command, check=False, capture_output=True, text=True, timeout=12) return {"ok": result.returncode == 0, "output": _bounded(result.stdout or result.stderr, 2000)} except Exception as exc: - return {"ok": False, "output": str(exc)} + return {"ok": False, "output": type(exc).__name__} @app.get("/packages", dependencies=[Depends(require_admin)]) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..0ffdd6a --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pytest + +from systemgate import auth + + +@pytest.fixture +def slow(monkeypatch): + """Every PBKDF2 run, with a fresh cache of matched keys.""" + monkeypatch.setattr(auth, "_matched", {}) + calls = [] + real = auth.hashlib.pbkdf2_hmac + monkeypatch.setattr(auth.hashlib, "pbkdf2_hmac", lambda *args: calls.append(args) or real(*args)) + return calls + + +def test_a_matched_key_is_not_hashed_again(slow): + encoded = auth.hash_key("right-key-1234") + slow.clear() + + assert auth.verify_key("right-key-1234", encoded) + assert len(slow) == 1 + assert auth.verify_key("right-key-1234", encoded) + assert not auth.verify_key("wrong-key-1234", encoded) + assert not auth.verify_key("right-key-12345", encoded) + assert len(slow) == 1 + + +def test_a_wrong_key_before_any_match_still_runs_the_full_check(slow): + encoded = auth.hash_key("right-key-1234") + slow.clear() + + assert not auth.verify_key("wrong-key-1234", encoded) + assert not auth.verify_key("wrong-key-1234", encoded) + assert len(slow) == 2 + + +def test_a_new_key_file_is_checked_in_full(slow): + first = auth.hash_key("right-key-1234") + assert auth.verify_key("right-key-1234", first) + rotated = auth.hash_key("other-key-5678") + slow.clear() + + assert not auth.verify_key("right-key-1234", rotated) + assert auth.verify_key("other-key-5678", rotated) + assert len(slow) == 2 + assert auth.verify_key("other-key-5678", rotated) + assert len(slow) == 2 diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index f57dd91..a27aeab 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -184,3 +184,61 @@ def test_processes_logs_packages_and_backups_are_bounded(tmp_path, monkeypatch): assert backup_state["latest"] is None assert backup_state["rejected"][0]["name"] == "20260101T000000Z" assert backup_state["rejected"][0]["status"] == "incomplete" + + +def test_processes_never_return_command_lines(tmp_path, monkeypatch): + import systemgate.main as main + asked = [] + info = {"pid": 7, "name": "app", "username": "me", "cpu_percent": 0.0, "memory_percent": 0.0, + "cmdline": ["app", "--password", "hunter2"], "environ": {"TOKEN": "hunter2"}} + + def process_iter(fields): + asked.extend(fields) + return [Mock(info=info)] + + monkeypatch.setattr(main.psutil, "process_iter", process_iter) + + with make_client(tmp_path, monkeypatch) as client: + response = client.get("/processes", headers=headers()) + + assert response.json()["results"] == [ + {"pid": 7, "name": "app", "username": "me", "cpu_percent": 0.0, "memory_percent": 0.0} + ] + assert "cmdline" not in asked + assert "hunter2" not in response.text + + +def test_host_output_loses_terminal_control_codes(monkeypatch): + import systemgate.main as main + + assert main._bounded("\x1b[31mred\x07\n\tok\x9b\x7f") == "[31mred\n\tok" + assert main._bounded("ab\x1bcd", 3) == "bcd" + output = Mock(stdout="\x1b]0;title\x07disk full\n", stderr="") + monkeypatch.setattr(main.subprocess, "run", lambda *args, **kwargs: output) + assert main._journal_errors() == "]0;titledisk full\n" + assert main._run_summary(["pip"])["output"] == "]0;titledisk full\n" + + +def test_a_failed_command_reports_only_the_kind_of_error(monkeypatch): + import systemgate.main as main + + def run(*args, **kwargs): + raise FileNotFoundError(2, "No such file or directory", "/srv/private/journalctl") + + monkeypatch.setattr(main.subprocess, "run", run) + + assert main._run_summary(["apt"]) == {"ok": False, "output": "FileNotFoundError"} + assert main._journal_errors() == "journalctl unavailable: FileNotFoundError" + + +def test_startup_primes_the_key_check_so_wrong_keys_stay_cheap(tmp_path, monkeypatch): + from systemgate import auth + monkeypatch.setattr(auth, "_matched", {}) + with make_client(tmp_path, monkeypatch) as client: + slow = [] + real = auth.hashlib.pbkdf2_hmac + monkeypatch.setattr(auth.hashlib, "pbkdf2_hmac", lambda *args: slow.append(args) or real(*args)) + assert client.get("/vitals", headers={"X-SystemGate-Key": "wrong-key-0000"}).status_code == 401 + assert client.get("/processes", headers=headers()).status_code == 200 + + assert slow == []