mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
Plan 75 §G7. The suite's hand-rolled stubs were entrenching the very duplication the plan removes: _FakeProc pasted byte-identical in four modules, ~25 fake_run closures re-implementing argv dispatch, and zero tests patching the wrappers — every stub targeted raw subprocess inside one service module, so each new service test recreated the raw seam. tests/subprocess_stub.py is that seam. Tests script argv → result; when G1 moves services onto run_checked() the retarget happens here once instead of in twelve files. Two properties are deliberate: - An unscripted command raises instead of inventing returncode=0. It is a BaseException for the same reason SbinPathError is — services wrap subprocess in `except Exception`, so an AssertionError would be eaten by exactly the handlers the kit exists to test. - Stubbed execs are still sbin-guarded. Patching subprocess removes the §B2 runtime guard, so the kit re-applies it; popen_guard grows a public guard_argv() as the one door for that check. Matching normalises away a sudo prefix and argv[0] absolutisation, so one scripted rule matches on a root box, a sudo box and a dev box alike. Mutation-checked: removing the guard call fails the guard test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
128 lines
5.4 KiB
Python
128 lines
5.4 KiB
Python
"""Runtime layer of the sbin guard (plan 75 §B2).
|
|
|
|
The static scan in ``test_sbin_command_guard.py`` only sees literal argv lists —
|
|
164 of the 288 raw ``subprocess.*`` sites in ``backend/app``. The other 43%
|
|
(variables, f-strings, concatenation) are opaque to AST, and they are where a
|
|
bare-name exec of an sbin-resident binary can still hide.
|
|
|
|
This module closes that gap at runtime, during tests. ``GuardedPopen`` wraps
|
|
``subprocess.Popen`` — the one primitive ``run``/``call``/``check_output``/
|
|
``check_call`` all funnel through — and inspects the *actual* argv at call
|
|
time. A bare-name head that resolves under the current ``$PATH`` but NOT under
|
|
a ``$PATH`` with the sbin dirs removed fails loudly:
|
|
|
|
works in dev (PATH has sbin), dies under the panel's systemd unit
|
|
(PATH has none) — plan 74's outage class, caught before it ships.
|
|
|
|
Its coverage is exactly "whatever the suite exercises" — honest, measurable,
|
|
and improving for free as tests are added. A call the suite never makes is a
|
|
call no test can vouch for either way; this guard makes no claim about it.
|
|
|
|
Deliberate non-failures:
|
|
|
|
* a head that resolves under NEITHER PATH is simply absent on this machine.
|
|
The real ``Popen`` will raise ``FileNotFoundError`` — the honest result, and
|
|
one probe-honesty suites exercise deliberately. The guard does not turn a
|
|
missing-on-this-box binary into a guard violation.
|
|
* string-form argv (``shell=True``) and heads containing a path separator are
|
|
not PATH searches and are left alone.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import traceback
|
|
|
|
|
|
class SbinPathError(BaseException):
|
|
"""A bare-name exec that only resolves through an sbin dir.
|
|
|
|
``BaseException`` on purpose: the bug class this catches ships BECAUSE a
|
|
blanket ``except Exception`` converts ``FileNotFoundError`` into a false
|
|
fact ("No Firewall Installed"). A plain ``AssertionError`` raised here
|
|
would be swallowed by exactly the handlers it exists to catch.
|
|
"""
|
|
|
|
|
|
def sanitized_path(env=None):
|
|
"""The current ``$PATH`` with every sbin dir removed — the shape of the
|
|
panel unit's ``Environment="PATH=..."`` that caused the plan 74 outage."""
|
|
env = os.environ if env is None else env
|
|
parts = env.get('PATH', '').split(os.pathsep)
|
|
kept = [p for p in parts
|
|
if p and os.path.basename(p.rstrip('/\\')) not in ('sbin',)]
|
|
return os.pathsep.join(kept)
|
|
|
|
|
|
def offending_head(argv, env=None):
|
|
"""argv[0] if this argv is a bare-name exec that only resolves through an
|
|
sbin dir, else ``None``. Pure and env-injectable so it can be tested
|
|
without spawning anything."""
|
|
if os.name != 'posix' or isinstance(argv, (str, bytes)) or not argv:
|
|
return None
|
|
head = argv[0]
|
|
if not isinstance(head, str) or os.path.sep in head:
|
|
return None # absolute, or explicitly relative — not a PATH search
|
|
if shutil.which(head, path=sanitized_path(env)):
|
|
return None # resolves without sbin: equally safe under the unit
|
|
if not shutil.which(head):
|
|
return None # absent on this machine — FileNotFoundError is honest
|
|
return head
|
|
|
|
|
|
def _callsite():
|
|
"""First stack frame outside this module and subprocess internals."""
|
|
for frame in reversed(traceback.extract_stack()):
|
|
base = os.path.basename(frame.filename)
|
|
if base != 'popen_guard.py' and base != 'subprocess.py':
|
|
return f'{frame.filename}:{frame.lineno}'
|
|
return '<unknown>'
|
|
|
|
|
|
def _routed_through_helper():
|
|
"""True when the exec came through the one-door helper module.
|
|
|
|
``run_privileged``/``run_unprivileged`` absolutize argv[0] exactly when
|
|
$PATH cannot — under the panel unit's sbin-less PATH the very call this
|
|
guard is looking at resolves to an absolute path. Firing on their
|
|
pass-through in an sbin-ful environment (every real-exec integration
|
|
test) would condemn the door itself; the guard hunts *bypasses* of it.
|
|
"""
|
|
for frame in traceback.extract_stack():
|
|
if frame.filename.replace('\\', '/').endswith('app/utils/system.py'):
|
|
return True
|
|
return False
|
|
|
|
|
|
def guard_argv(argv):
|
|
"""Raise :class:`SbinPathError` when *argv* is a bare-name sbin-only exec.
|
|
|
|
One door for the check itself. ``GuardedPopen`` calls it before a real
|
|
exec; ``tests/subprocess_stub.py`` calls it before returning a *scripted*
|
|
result, so stubbing subprocess does not punch a hole in this guard — a
|
|
test that fakes ``subprocess.run`` still cannot smuggle a bare-name
|
|
``ufw`` past it. That matters more than it sounds: 44 of the suite's
|
|
subprocess stubs mean 44 execs the runtime guard never sees.
|
|
"""
|
|
head = offending_head(argv)
|
|
if head is None or _routed_through_helper():
|
|
return
|
|
raise SbinPathError(
|
|
f'{head!r} is exec\'d by bare name but only resolves through '
|
|
f'an sbin dir. The panel\'s systemd unit ships a PATH with no '
|
|
f'sbin, so this call raises FileNotFoundError in production '
|
|
f'while passing every dev run — plan 74\'s outage class.\n'
|
|
f' called from: {_callsite()}\n'
|
|
f'Use run_privileged() (or run_unprivileged() when no root is '
|
|
f'needed); both resolve argv[0] to an absolute path when '
|
|
f'$PATH cannot.'
|
|
)
|
|
|
|
|
|
class GuardedPopen(subprocess.Popen):
|
|
"""``subprocess.Popen`` that refuses bare-name sbin-only execs (test-time)."""
|
|
|
|
def __init__(self, args, *a, **kw):
|
|
guard_argv(args)
|
|
super().__init__(args, *a, **kw)
|