fix: resolve privileged commands from trusted dirs to prevent PATH injection - #1058
fix: resolve privileged commands from trusted dirs to prevent PATH injection#1058leongdl wants to merge 5 commits into
Conversation
The agent invoked sudo, pkill and shutdown by bare name, resolving them through
PATH. Where any part of that search path is influenced by less-trusted input the
resolution is itself the vulnerability (CWE-426), and the agent runs these as a
privileged user.
Add _system_commands, which resolves a bare command name against a fixed,
ordered list of trusted absolute directories. PATH is never consulted, and
neither is shutil.which -- it resolves through PATH and so would reintroduce the
problem while appearing to fix it.
Also fixes two defects in the previous absolute-path-literal approach:
* /usr/sbin/shutdown is wrong on non-usr-merged Debian, where shutdown exists
only at /sbin/shutdown. A literal there converts a security bug into a host
that will not shut down on stop. Both sbin directories are now searched.
* The Windows branch was still invoking a bare "shutdown" and is now resolved
from System32 under SystemRoot.
Properties pinned by test/unit/test_system_commands.py, each mutation-checked
against a green baseline with the source restored and verified by checksum:
M1 resolve via shutil.which -> caught by test_ignores_path_even_when_it_
contains_a_matching_command (+2 others)
M2 drop the separator guard -> caught by test_rejects_traversal_even_
though_the_target_is_reachable (+3 others)
M3 fall back to the bare name -> caught by test_system_command_path_raises_
rather_than_returning_the_bare_name
M4 subclass FileNotFoundError -> caught by test_is_not_a_filenotfounderror
M5 reorder the wrapper dir -> caught by test_posix_searches_the_setuid_
wrapper_directory_before_usr_bin
M6 drop /sbin -> caught by test_posix_searches_both_sbin_
locations
M4 matters because callers around subprocess catch FileNotFoundError to mean
"optional tool absent, carry on degraded"; an unavailable privileged helper must
not be absorbed by that handling.
Existing tests that asserted command locations now stub the resolver instead.
Those cases simulate a platform via sys.platform, so a real lookup would resolve
against whatever platform the suite is running on -- and stubbing means a
literal reappearing in the source fails the assertion.
hatch run fmt, hatch run lint (ruff, ruff format, mypy) and hatch run test all
pass: 3077 passed, 48 skipped.
Refs: HackerOne 3942741, CWE-426
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| ] | ||
| else: | ||
| shutdown_command = ["sudo", "shutdown", "now"] | ||
| shutdown_command = [system_command_path("sudo"), system_command_path("shutdown"), "now"] |
There was a problem hiding this comment.
Passing an agent-resolved absolute path to sudo couples this resolver to the NOPASSWD sudoers rule the installer writes — worth an end-to-end check on --allow-shutdown hosts.
Previously the argv was sudo shutdown now, and sudo resolved shutdown itself using secure_path, which is what matched the rule install.sh:342 writes:
${wa_user} ALL=(root) NOPASSWD: /usr/sbin/shutdown now
Now the agent picks the path, using the order /run/wrappers/bin, /usr/bin, /bin, /usr/sbin, /sbin. On usr-merged distributions where sbin has been folded into bin (Fedora 42+, Arch) /usr/bin/shutdown is found first, so the argv becomes sudo /usr/bin/shutdown now rather than the /usr/sbin/shutdown now in the rule.
To be fair to the change: sudo compares the device and inode of the submitted command against the sudoers command spec, so a /usr/bin vs /usr/sbin difference that is the same file via a symlinked directory should still match. The risk is narrower than the string difference suggests — but it is now a property of two independently maintained lists (this resolver's search order, and the literal in each installer script) rather than of sudo alone, and a silent non-match means sudo prompts for a password and the shutdown fails. On an autoscaled fleet that surfaces as a host that never terminates and keeps heartbeating in STOPPING.
Concretely worth doing:
- Verify a shutdown end-to-end on a host installed with
--allow-shutdown, on at least one usr-merged and one non-usr-merged layout, since unit tests stub the resolver and cannot catch a sudoers mismatch. install_macos.sh:604-608now documents the old contract and is stale: it states "sudo resolvesshutdownto /sbin/shutdown via PATH ... The sudoers command MUST continue to match that argv exactly." That comment is the natural place for a future reader to check this invariant, so it should describe the new mechanism.
There was a problem hiding this comment.
Fixed in 10b6772, by removing the coupling rather than testing around it. shutdown is no longer resolved at all: its path is a contract with the sudoers rule the installer writes, so it is now the module constant LINUX_SHUTDOWN_PATH, and only sudo is resolved. Your point that a silent non-match leaves the host heartbeating in STOPPING is what made this a must-fix rather than a nit. The end-to-end check across both distribution layouts is parked, since I have no non-usr-merged host to run it on and an unexecuted test would be worse than none. The static half is covered: a unit test reads the rule and compares the full granted argv against what the agent builds.
| raise ValueError( | ||
| f"A system command name must not contain a path separator, but got {name!r}." | ||
| ) | ||
|
|
There was a problem hiding this comment.
_validate_command_name blocks path separators but not a Windows drive-relative prefix, so the "only paths under the trusted directories are returned" invariant can be broken on win32.
os.path.join discards the left operand when the right operand carries a drive letter:
ntpath.join(r"C:\Windows\System32", "D:evil") # -> "D:evil"
ntpath.join(r"C:\Windows\System32", "C:evil") # -> "C:evil"Neither D:evil nor C:evil contains / or \\, so both pass validation, and the resulting candidate resolves against that drive's per-drive current directory — outside every trusted directory. find_system_command would then return it.
Not currently reachable (every call site passes a literal), but this module's stated contract is that the returned path is always under a trusted directory, and the docstring calls that property "load-bearing". A cheap way to enforce it regardless of name shape is to verify the result after joining, e.g. reject when os.path.dirname(os.path.abspath(candidate)) is not the trusted directory, or reject names for which os.path.splitdrive(name)[0] is non-empty.
There was a problem hiding this comment.
Fixed in 10b6772. Verified your example before changing anything: ntpath.join(r"C:\Windows\System32", "D:evil") really does return "D:evil", so a drive-relative name escaped every trusted directory while containing no separator. The validator now rejects : as well, with D:evil and C:evil in the parametrized rejection cases.
|
|
||
| def _is_executable_file(path: str) -> bool: | ||
| return os.path.isfile(path) and os.access(path, os.X_OK) | ||
|
|
There was a problem hiding this comment.
_is_executable_file checks X_OK as the agent user, but the commands resolved for sudo are executed as root — so a binary that is deliberately not agent-executable now resolves to None and the operation fails outright.
shutdown is the case that matters. On hardened hosts /sbin/shutdown (or /usr/sbin/shutdown) is not uncommonly mode 0750 root:root, and /usr/sbin itself is sometimes not traversable by non-root users. Before this change the agent never needed to see the binary: it passed the bare name and sudo — already root — did the resolution. Now find_system_command("shutdown") returns None for the unprivileged agent user, system_command_path raises SystemCommandNotFoundError, and _host_shutdown raises before reaching Popen.
That exception is not caught. Both call sites in startup/entrypoint.py (lines 275-276 and 560-561) invoke _host_shutdown inside a while _repeatedly_attempt_host_shutdown(): loop with no handler, so the error unwinds to the top-level except Exception in entrypoint, which logs and sys.exit(1)s. For a service-requested shutdown that means the agent exits instead of retrying, the host never powers off, and the loop's explicit design goal — keep heartbeating in STOPPING until the host goes down — is lost.
Two things worth separating here:
- For a command that will be run via
sudo, existence (os.path.exists) is the appropriate check, not the calling user'sX_OK. - Regardless of the check,
_host_shutdownshould probably catchSystemCommandNotFoundErrorand log rather than let it escape the retry loop, since raising is strictly worse than the previous behaviour of lettingsudotry.
There was a problem hiding this comment.
Partly fixed in 9f4a61b, and the part I did not fix is now moot. _host_shutdown no longer lets the error escape: it logs and returns, because callers treat it as best-effort and retry until the host goes down, so unwinding to the top-level handler ended the retrying and left the host up. The X_OK-as-agent-user concern was specifically about shutdown, which is no longer resolved, so the remaining resolved commands are sudo, pkill and shutdown.exe, all 0755 and exec'd by the agent itself.
| system_command_path("sudo"), | ||
| "-u", | ||
| user.user, | ||
| system_command_path("pkill"), |
There was a problem hiding this comment.
This one was already immune to the PATH problem — it was the absolute literal /usr/bin/pkill — so replacing it with the resolver does not remove a CWE-426 exposure, it only changes which absolute path is submitted.
The change is not neutral, though: the resolver searches /run/wrappers/bin first, so on NixOS this now yields /run/wrappers/bin/pkill where it previously yielded /usr/bin/pkill. Since this runs under sudo -u <job-user>, any operator sudoers rule that permits the old literal would stop matching, and sudo would prompt for a password — leaving job-user processes alive after a session ends.
Note also that system_command_path is evaluated inside the try at line 107, whose only handler is except subprocess.CalledProcessError. A SystemCommandNotFoundError therefore propagates past it. It is ultimately swallowed by the broad except Exception in _cleanup_session_user (line 81), which downgrades a "cannot clean up job-user processes at all" condition to a single logger.warn — the same treatment a transient failure gets.
If the goal is uniformity of style rather than a fix, that is reasonable, but it would be worth calling out in the PR description that pkill was not part of the vulnerability, so a reviewer can weigh the NixOS argv change against a benefit that is cosmetic here.
There was a problem hiding this comment.
Correct on both counts about scope, and now stated in the PR description: pkill was already the absolute literal /usr/bin/pkill, so routing it through the resolver removes no exposure. One part of the comment is wrong, though, and I want it on the record rather than silently ignored: the resolver will not yield /run/wrappers/bin/pkill on NixOS. That directory holds only setuid and setcap wrappers, and pkill is not among them. With /run/current-system/sw/bin in the list it resolves from the symlink farm as expected.
| else: | ||
| cmd = [ | ||
| "sudo", | ||
| system_command_path("sudo"), |
There was a problem hiding this comment.
system_command_path can raise SystemCommandNotFoundError, and nothing in install() catches it — the surrounding try (line 162) only wraps run(...) and only handles CalledProcessError. So on a host where sudo is not in a trusted directory, the installer dies with a raw traceback rather than the actionable message the rest of this function is careful to produce (print(f"ERROR: {e}") / sys.exit(1) on the Windows path).
install.sh already handles this case with a clear message ("ERROR: sudo is not installed but is a required dependency of the worker agent.", line 263-264), but that check runs inside the script this line is trying to launch, so it can no longer be reached. Catching the error here and printing the same style of message would keep the diagnostic.
There was a problem hiding this comment.
Fixed in 10b6772. Resolution happens before the argv is built and is now caught there, printing the actionable message and exiting 1, which matches what the Windows branch already does a few lines up. Your observation that install.sh's own "sudo is not installed" message is unreachable because it lives inside the script this line launches is what made the fix worth doing rather than deferring to the script.
|
|
||
| def find_system_command(name: str) -> Optional[str]: | ||
| """Return the absolute path to ``name``, or ``None`` if it is not installed. | ||
|
|
There was a problem hiding this comment.
find_system_command and SystemCommandNotFoundError are exported in __all__ but no production code calls or catches either — the only callers of find_system_command are in test/unit/test_system_commands.py, and every call site in this PR uses system_command_path.
That leaves a public "the absence is tolerable" entry point with no user, and the carefully argued design decision in SystemCommandNotFoundError's docstring (deliberately not a FileNotFoundError so degraded-mode handlers cannot absorb it) has nothing to protect, because nothing catches it anywhere. Given the module is private (_system_commands), it may be simpler to keep only system_command_path and add find_system_command when a tolerant caller actually appears.
There was a problem hiding this comment.
Fixed in 9f4a61b by dropping find_system_command from __all__. You were right that every caller in this package needs the command it asks for, so none can do anything useful with None. It stays defined because the tests use it to exercise the search without asserting on an exception, but it no longer advertises a tolerate-absence entry point that nothing here wants.
Six review findings. Two are defects introduced by this branch, one is a hole in
the security fix itself, and three are corrections shared with the sibling repos.
1. `shutdown` is no longer resolved. Its path is a sudoers contract.
installer/install.sh writes:
${wa_user} ALL=(root) NOPASSWD: /usr/sbin/shutdown now
sudoers matches one exact path. A trusted-directory search can legitimately
return a different one for the same binary -- /usr/bin is searched before
/usr/sbin, and on a usr-merged distribution both exist -- at which point the rule
stops matching, sudo prompts for a password, and shutdown-on-stop fails. Nor was
this position ever a PATH lookup: sudo resolves its own argument, as root.
So this branch had converted a security bug into an availability bug, which is
the same mistake the earlier absolute-path literals made in the other direction.
The path is now the module constant LINUX_SHUTDOWN_PATH (MACOS_SHUTDOWN_PATH for
darwin, since macOS has no /usr/sbin/shutdown), and `sudo` alone is resolved.
TestShutdownPathIsASudoersContract pins the pairing by *reading install.sh* and
comparing, rather than restating the path. A constant asserted against a copy of
itself would pin nothing.
2. The name guard rejects ":" -- a real bypass on the one resolver with a
Windows branch.
ntpath.join(r"C:\Windows\System32", "D:evil") == "D:evil"
A drive-relative name discards the trusted prefix entirely and resolves against
that drive's own current directory, while containing no path separator at all. The
separator-only guard let it through, which made this module the injection point it
exists to remove.
3. installer no longer dies with a traceback when sudo is unresolvable.
The resolution happened while building the argv, outside the try that covers only
run()/CalledProcessError. It now prints the actionable message, matching what the
Windows branch already does for its equivalent failure.
4. SystemCommandNotFoundError derives from FileNotFoundError.
The earlier plain-Exception choice was justified by a claim about what surrounding
handlers do that was never checked. The semantics are FileNotFoundError's, and
that is how capabilities.py and metrics.py already treat a missing nvidia-smi. The
test asserting the opposite is inverted -- it had been pinning the wrong decision.
5. /run/current-system/sw/bin added. /run/wrappers/bin holds only the setuid
wrappers, so on NixOS it resolved sudo and nothing else; pkill is in the sw/bin
symlink farm. The wrapper entry supported no complete code path without it.
Mutation-checked, all five caught against a green baseline with sources restored
and verified by checksum:
W1 resolve shutdown again -> test_host_shutdown[linux],
test_host_shutdown_failure[linux]
W2 drift the constant off the rule -> test_shutdown_path_matches_the_installer_
sudoers_rule
W3 drop the colon guard -> test_rejects_name_with_a_path_component
[drive-relative]
W4 revert to plain Exception -> test_is_a_filenotfounderror
W5 drop the NixOS sw/bin entry -> test_posix_has_the_two_nixos_entries_as_
a_pair
One note on that harness: under xdist it over-reported which tests caught each
mutant, listing unrelated names alongside the real ones. Re-run serially the
attribution is exact and the two shutdown tests pass under W3/W4/W5 as they
should. The caught/survived verdicts were unaffected.
Not changed, with reasons: the review also suggested relaxing the X_OK check
because resolved commands run as root under sudo, and flagged the /usr/bin-before-
/usr/sbin order as risky for sudoers coupling. Both concerns were specifically
about `shutdown`, which is no longer resolved at all, so the remaining resolved
commands (sudo, pkill, shutdown.exe) are 0755 and exec'd by the agent itself. The
claim that pkill would resolve to /run/wrappers/bin/pkill on NixOS is also wrong:
that directory holds only setuid wrappers, and with the sw/bin entry added pkill
now resolves correctly there.
3085 passed, 48 skipped. ruff, ruff format and mypy clean.
Refs: HackerOne 3942741, CWE-426
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| """ | ||
| directories = trusted_directories() | ||
|
|
||
| assert directories.index("/usr/bin") < directories.index("/usr/sbin") |
There was a problem hiding this comment.
test_shutdown_is_not_looked_up_in_the_trusted_directories has no skipif guard, but it asserts on POSIX-only entries — so it raises ValueError on Windows, where the unit-test matrix runs.
trusted_directories() returns a one-element tuple on win32 (C:\Windows\System32), so:
directories.index("/usr/bin") # ValueError: "/usr/bin" is not in listEvery other POSIX-layout assertion in this file is guarded (test_posix_searches_the_setuid_wrapper_directory_before_usr_bin, test_posix_has_the_two_nixos_entries_as_a_pair, test_posix_searches_both_sbin_locations all carry @pytest.mark.skipif(sys.platform == "win32", ...)), and this one makes the same kind of claim about the same list but is missing the marker. code_quality.yml runs UnitTest on windows-latest across all five Python versions, so this fails there rather than being a latent nit.
The second assertion on the next line (LINUX_SHUTDOWN_PATH.startswith("/usr/sbin/")) is platform-independent and would be fine to keep unguarded, so the minimal fix is either the same skipif on the whole test, or splitting the directories.index half out under a guard.
There was a problem hiding this comment.
Fixed in a37ad17. Correct that this was a real failure rather than a nit, and it did fail the windows-latest 3.13 leg before I got to it. Guarded with the same win32 skipif the sibling POSIX-layout tests carry.
| producing a path the rule does not cover -- see ``_host_shutdown``. | ||
| """ | ||
|
|
||
| MACOS_SHUTDOWN_PATH = "/sbin/shutdown" |
There was a problem hiding this comment.
MACOS_SHUTDOWN_PATH is the same sudoers contract as LINUX_SHUTDOWN_PATH, but nothing pins it to the rule install_macos.sh writes.
install_macos.sh:618 grants:
${wa_user} ALL=(root) NOPASSWD: /sbin/shutdown -h now
and the agent now invokes [sudo, /sbin/shutdown, -h, now], so today they agree. But TestShutdownPathIsASudoersContract._granted_shutdown_path reads only installer/install.sh, and its regex (NOPASSWD:\s*(\S+)\s+now) is Linux-shaped — it would not match the macOS rule's -h now even if pointed at that file. The only macOS assertion is test_macos_shutdown_path_exists_on_this_host, which checks the file exists on the runner, not that it matches the grant.
So the class docstring's claim that the pairing "cannot drift apart silently" holds for Linux only. If someone edits the macOS rule (or the -h flag) the constant here silently stops matching, and the failure mode is the one the docstring describes: sudo prompts for a password and shutdown-on-stop fails — on the platform where the argv has two parts to keep in sync rather than one.
Extending the helper to take a script path plus an expected-args suffix, and adding a darwin case against install_macos.sh, would cover both with the same mechanism.
Separately, install_macos.sh:606-607 is now stale as a result of this change — it says the agent invokes sudo shutdown -h now and that "sudo resolves shutdown to /sbin/shutdown via PATH". After this PR the agent passes the absolute path itself, which is worth correcting since that comment is the thing a future reader would consult before editing the rule.
There was a problem hiding this comment.
Fixed in a37ad17 and 9f4a61b. You were right that the docstring's "cannot drift apart silently" held for Linux only: the regex was Linux-shaped and would not have matched install_macos.sh's -h now. The contract test is now parametrized over both installers and reads each rule from its own script. It also compares the full argv rather than only the path, since a rule losing or gaining -h would otherwise pass, which was raised separately in 3799155191. The stale comment at install_macos.sh:606-607 is corrected too, since it still described sudo resolving the name via PATH.
| An earlier revision made this a plain ``Exception``, reasoning that an | ||
| unavailable privileged helper must not be absorbed by handlers that catch | ||
| ``FileNotFoundError`` to mean "carry on degraded". That reasoning assumed rather | ||
| than checked what surrounding code does with it, and the semantics this |
There was a problem hiding this comment.
Now that shutdown is no longer resolved, /usr/sbin and /sbin have no remaining user — and the test that pins them justifies them with exactly the case that was removed.
The only names passed to this resolver in production are sudo (entrypoint.py, session_cleanup.py, installer/init.py), pkill (session_cleanup.py), and shutdown.exe (Windows). None of those live under sbin on any supported platform, and the comment here says so outright: "this list is no longer used to locate shutdown". The "other commands can still live only under /sbin" hedge is about hypothetical future callers.
Meanwhile test_posix_searches_both_sbin_locations reads:
"""shutdown is at /usr/sbin/shutdown on usr-merged distributions but only
at /sbin/shutdown on some Debian releases. Hardcoding either one broke a
host; both must be searched."""
That rationale is now false — shutdown is deliberately hardcoded (LINUX_SHUTDOWN_PATH) and is not searched. A future reader trying to work out whether these two entries are load-bearing will find a test asserting they are, for a reason that no longer applies. Either drop the two entries (smallest search surface is the conservative default for a module whose whole purpose is constraining resolution) or restate the test's justification so it does not point at the one command that bypasses the resolver.
Related, the module docstring still argues against hardcoded literals on the same grounds — "on non-usr-merged Debian shutdown exists only at /sbin/shutdown. A hardcoded literal turns a security bug into an availability bug on those hosts" — while this revision hardcodes /usr/sbin/shutdown. That is defensible, because the sudoers rule hardcodes the same path so such hosts were already broken before this PR, but as written the docstring reads as an argument against what the code now does.
There was a problem hiding this comment.
Fixed in 46386e2. Both the module comment and the test now say the honest thing: no production caller resolves an sbin command any more, because shutdown is hardcoded and sudo and pkill are both in /usr/bin. The entries stay, since a system command can be sbin-only on a non-usr-merged distribution, but "no current caller" replaced the justification that cited the case I had removed.
Two CI failures on windows-latest 3.13, both in tests added by this branch, plus
two review findings.
1. test_the_granted_path_is_absolute failed on Windows 3.13.
From Python 3.13, ntpath.isabs() treats a single-slash path as drive-relative
rather than absolute. The granted path is a POSIX path read out of a shell script,
so os.path.isabs made the assertion a statement about the host running the tests.
It uses posixpath.isabs now.
2. test_shutdown_is_not_looked_up_in_the_trusted_directories failed on Windows.
trusted_directories() returns the System32 layout there, so .index('/usr/bin')
raised ValueError. POSIX-guarded, since the ordering it asserts only exists on
POSIX.
3. MACOS_SHUTDOWN_PATH was unpinned.
install_macos.sh grants '/sbin/shutdown -h now' and its own comment says the
sudoers command MUST continue to match that argv exactly. The constant agreed with
it only by coincidence. The contract test is now parametrized over both installers
and reads each rule out of the script, so neither platform can drift.
4. The sbin entries carried a stale justification.
They were justified by , which is no longer resolved here at all. No
production caller resolves an sbin command today: sudo and pkill are both
/usr/bin, shutdown.exe is System32. The entries stay, because the split is real on
non-usr-merged distributions, but the comment and test now say 'no current caller'
instead of citing a case that was removed.
3087 passed, 48 skipped. ruff, ruff format and mypy clean.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| ) | ||
| content = script.read_text() | ||
| # install.sh grants "<path> now"; install_macos.sh grants "<path> -h now". | ||
| match = re.search(r"NOPASSWD:\s*(\S+)(?:\s+-h)?\s+now", content) |
There was a problem hiding this comment.
The new contract test pins the sudoers path but not the sudoers argv, and (?:\s+-h)? is what opens the hole.
sudo matches a NOPASSWD: rule against the whole command line, not just the program. The two installers grant different argv:
install.sh:342 ${wa_user} ALL=(root) NOPASSWD: /usr/sbin/shutdown now
install_macos.sh:618 ${wa_user} ALL=(root) NOPASSWD: /sbin/shutdown -h now
and _host_shutdown builds argv to match each ([sudo, LINUX_SHUTDOWN_PATH, "now"] / [sudo, MACOS_SHUTDOWN_PATH, "-h", "now"]). But _granted_shutdown_path returns match.group(1) only, so the flag is read and thrown away.
Because the -h group is optional, the regex matches either shape for either installer, and two silent drifts get through:
- macOS rule edited to
/sbin/shutdown now(dropping-h) — regex matches,group(1)is unchanged, test passes; the agent still sends-h, so the rule no longer matches and sudo prompts for a password. - Linux rule edited to
/usr/sbin/shutdown -h now(someone normalising the two installers) — same thing in the other direction: test passes, the agent sends barenow, grant no longer matches.
Either one produces exactly the failure the class docstring says it exists to prevent, on the platform where the argv has two parts to keep in sync.
Since the helper is already parameterised over the installer, the cheap fix is to capture the tail as well and assert against the argv the module actually builds — e.g. return (group(1), flags) and compare to the platform's expected [path, *flags, "now"], or drop the (?:\s+-h)? optionality and parametrise the expected suffix per installer so an unexpected shape trips the assert match is not None message rather than passing quietly.
There was a problem hiding this comment.
Fixed in 9f4a61b. Correct: (?:\s+-h)? was optional and only group(1) was returned, so both drifts you describe passed. The test now drives _host_shutdown, strips the sudo prefix, and compares the whole remaining argv against the rule's full command line. Mutation-checked by removing -h from the macOS rule, which fails the macOS case and nothing else.
Two changes to comments only. No behaviour change. Dropped the vulnerability-classification references. They named a taxonomy without telling a reader anything actionable about this code, and the comment reads better stating what goes wrong and what the module does about it. Dropped the "each is pinned by a test in <path>" bookkeeping. It told the reader where tests live rather than why the code is shaped this way, and it goes stale the moment a test file moves. The properties themselves are still listed, now with the reason each one is easy to undo, which is the part that helps someone editing this later. The module docstrings now open with the problem, then the approach, then the three properties and what breaks if each is lost. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Four review findings.
1. _host_shutdown no longer raises on a resolution failure.
system_command_path("sudo") could raise, and neither call site catches it, so it
unwound to the top-level handler and exited the agent. Callers treat _host_shutdown
as best-effort and retry until the host goes down, so raising ended the retrying
and left the host up with no further attempt. It now logs and returns.
2. The sudoers contract test pins the whole argv, not just the path.
sudo matches the entire command line. Capturing only the path let two silent drifts
through: the macOS rule losing its -h while the agent still sends it, or the Linux
rule gaining one while the agent sends a bare "now". Either produces a password
prompt at shutdown, which is the failure the test exists to catch. It now drives
_host_shutdown and compares the argv after the sudo prefix.
Mutation-checked: removing -h from install_macos.sh's rule fails
test_shutdown_argv_matches_the_installer_sudoers_rule[macOS] and nothing else.
3. install_macos.sh's comment was stale.
It still described the agent running `sudo shutdown -h now` with sudo resolving the
name via PATH. The agent passes the absolute path itself now, and a test compares it
against this rule, so the comment says that instead.
4. find_system_command dropped from __all__.
Every caller in this package needs the command it asks for, so none can do anything
with None. It stays defined for the tests, but exporting it advertised a
"tolerate absence" entry point that nothing here wants.
3087 passed, 48 skipped. ruff, ruff format and mypy clean.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Problem
The agent invoked
sudo,pkillandshutdownby bare name, so each wasresolved through
PATH. Where any part of that search path is influenced byless-trusted input, the resolution itself is the vulnerability (CWE-426,
Untrusted Search Path) — and the agent runs these as a privileged user.
Companion to HackerOne 3942741, reported against
openjd-sessions-for-python.Fix
New
_system_commandsmodule resolves a bare command name against a fixed,ordered list of trusted absolute directories.
PATHis never consulted, andneither is
shutil.which— it resolves throughPATH, so it would reintroducethe problem while appearing to fix it.
This also fixes two defects in the obvious alternative
Hardcoding absolute literals closes the hole but breaks hosts:
/usr/sbin/shutdownis wrong on non-usr-merged Debian, whereshutdownexists only at
/sbin/shutdown. A literal there trades a security bug for ahost that will not shut down on stop. Both
sbindirectories are now searched.shutdownand is now resolved fromSystem32underSystemRoot./usr/bin/sudois likewise not universal — NixOS keeps the setuid wrapper at/run/wrappers/bin/sudo, so that directory is searched first.Properties pinned, and mutation-checked
Each mutation was applied to the production source, the suite run, and the source
restored and verified by checksum. Baseline green before each.
shutil.whichtest_ignores_path_even_when_it_contains_a_matching_command+ 2test_rejects_traversal_even_though_the_target_is_reachable+ 3test_system_command_path_raises_rather_than_returning_the_bare_nameFileNotFoundErrortest_is_not_a_filenotfounderror/usr/binbefore the wrapper dirtest_posix_searches_the_setuid_wrapper_directory_before_usr_bin/sbintest_posix_searches_both_sbin_locationsM4 is worth calling out: callers around
subprocesscatchFileNotFoundErrortomean "optional tool absent, carry on degraded". An unavailable privileged helper
must not be absorbed by that handling, so
SystemCommandNotFoundErrordeliberately does not inherit from it.
M2 is why there are two traversal tests. The straightforward one does not catch
the mutation on its own — with the executable directly in the searched directory,
../nameresolves to nothing either way — sotest_rejects_traversal_even_though_the_target_is_reachablenests the directoryso the traversal reaches a real file, and asserts that precondition.
Test changes
Existing tests that asserted command locations now stub the resolver. Those cases
simulate a platform via
sys.platform, so a real lookup would resolve againstwhatever platform the suite is actually running on. Stubbing also means a literal
reappearing in the source fails the assertion.
Verification
hatch run fmtcleanhatch run lintclean (ruff, ruff format, mypy — 200 source files)hatch run test: 3077 passed, 48 skippedOne caveat, stated plainly: across four full-suite runs, one run had
test/unit/test_session_events.py::test_log_before_call[DeleteWorkerBeforeCallTest]fail. It passes in isolation (27/27, repeatedly) and the other three full runs
were clean. The suite uses
pytest-randomly, and that test has no relationshipto the files changed here, so I believe it is a pre-existing order-dependent
flake — but I was not able to prove it pre-existing, so I am flagging rather than
asserting it.
The Windows shutdown path is unverified. I have no Windows host; that branch
is covered by a unit test of the directory layout only, not by execution.
Scope note: pkill and shutdown were not part of the reported defect
Raised in review and worth stating plainly.
session_cleanup.pyalready used the absolute literal/usr/bin/pkill, so routingit through the resolver removes no exposure. It is here for consistency, so that one
mechanism owns every privileged command name in the package, and so a later edit
cannot reintroduce a bare name unnoticed.
shutdownis the opposite case: it is now deliberately not resolved. Its path isa contract with the sudoers rule the installer writes, and a trusted-directory search
can legitimately return a path that rule does not grant. A unit test reads the rule
out of each installer and compares the full granted argv against what the agent
builds, so drift on either side fails the build instead of producing a password
prompt at shutdown time.
The only site in this package where a bare name was resolved through
PATHatprivilege is
sudo.