3 Commits
Author SHA1 Message Date
Juan Denis 122cf3c9c1 docs(tests): stop the sbin guard reading as coverage
The static scan sees literal argv lists only — 164 of 288 raw subprocess
sites (57%), and only the hardcoded names in SBIN_COMMANDS. A green run
was easy to read as 'no bare sbin calls exist'; it never meant that. Say
so in the module header and in the failure message itself, and point at
the runtime layer (popen_guard) that watches actual argv.
2026-08-17 21:23:00 -04:00
Juan DenisandClaude Opus 5 8c81617179 refactor(utils): rename run_command -> run_unprivileged
plan 75 §B1. Two helpers did overlapping jobs with different semantics, and the
one that says nothing about privilege sat one autocomplete from the one that
does. nginx_advanced_service reached for it, so `nginx -t` ran unprivileged and
by bare name — it could neither read root-owned config nor find /usr/sbin/nginx,
and reported every config as invalid.

Renamed rather than folded. Folding into run_privileged(privileged=False) would
change sudo semantics at all six call sites — `sudo -n dig` fails where plain
dig worked, if sudoers does not permit it — and the failure this fixes was
choosing the wrong helper, not the helper's behaviour. The rename removes the
ambiguity with zero behavioural risk; the return type (dict, not
CompletedProcess) stays as callers expect.

`run_unprivileged(['nginx', '-t'])` reads as obviously wrong. `run_command` did
not. The old name was ambiguous in a second way: PythonService has an unrelated
method of the same name, which keeps it.

Six call sites, three test modules. Two patch targets were written as strings
('app.utils.system.run_command', setattr(mod, 'run_command')) so a word-boundary
rename skipped them and they would have failed at runtime, not import.

No alias: an alias leaves the second door open, which is the thing being closed.
run_command was never exported through plugins_sdk, so there is no public SDK
surface to break; a third-party extension importing it gets a loud ImportError
rather than silent misbehaviour. A guard test asserts the name stays gone.

417 tests green across monitor, ssl, dns, nginx, firewall and the guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 20:07:39 -04:00
Juan DenisandClaude Opus 5 b51d9c2018 fix(services): close the rest of the bare-name sbin exec class
The ufw fix repaired every run_privileged caller centrally, but three sites
reached subprocess by other routes and were broken the same way. All are
"$PATH omits sbin + the panel runs as root, so nothing prepends sudo to
resolve it".

- run_command() got the same argv[0] resolution as privileged_cmd. It does no
  privilege escalation, so nothing else would ever find these commands.
- nginx_advanced_service.test_config() ran `nginx -t` (/usr/sbin/nginx) via
  run_command, so it raised FileNotFoundError and reported every config
  invalid. It also returned valid=True unconditionally, ignoring the exit code
  entirely — so when it DID run, a broken config passed. Now run_privileged and
  the returncode is checked. reload_nginx() likewise reports a failed reload
  instead of swallowing it.
- dovecot_service.get_status() detected dovecot with `which`, which searches
  $PATH only and false-negatives on /usr/sbin/dovecot, then read the version by
  bare name under a shared except. Now is_command_available() + a separate try,
  so a version read that fails cannot unsay "installed". Patched in both the
  builtin-extensions source and the app/plugins live copy.
- SecurityService.get_lynis_status() inferred presence solely from exec'ing
  `lynis --version`; presence and version are now separate questions.

Adds test_sbin_command_guard.py so this cannot come back: an AST scan fails CI
on any raw subprocess call using a known sbin-resident command by bare name
(it caught the dovecot site), plus per-command proof that both helpers resolve
under the exact PATH the panel unit ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:37:59 -04:00