Skip to content

fix: resolve sudo and findmnt from trusted dirs to prevent PATH injection - #70

Open
leongdl wants to merge 13 commits into
aws-deadline:mainlinefrom
leongdl:fix/path-injection-rce
Open

leongdl wants to merge 13 commits into
aws-deadline:mainlinefrom
leongdl:fix/path-injection-rce

Conversation

@leongdl

@leongdl leongdl commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Problem

The VFS mount and unmount paths invoked sudo by bare name, and queried mount
state with a bare findmnt, so each was resolved through PATH. Where any part
of that search path is influenced by less-trusted input, the resolution itself is
the vulnerability (CWE-426, Untrusted Search Path) — and these run in order to
act as the job user.

return ["sudo", "-u", os_user, fusermount3_path, "-u", mount_path]   # vfs.py:122
return subprocess.run(["findmnt", path]).returncode == 0             # vfs.py:214
command = (f"sudo -E -u {self._os_user}" ...)                        # vfs.py:294

Reported in openjd-sessions-for-python's sibling form as HackerOne 3942741.

Why this repo: the code moved here from deadline-cloud in
"feat: migrate to job attachments package" (#1133). deadline-cloud's
src/deadline/job_attachments/ no longer exists, so the fix belongs here.

Fix

New _system_commands module 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, so it would reintroduce
the problem while appearing to fix it.

A resolver rather than absolute literals because the locations are not universal:
NixOS keeps the setuid sudo wrapper at /run/wrappers/bin/sudo, so a hardcoded
/usr/bin/sudo would trade a security bug for a mount failure there. That
directory is searched first.

Properties pinned, and mutation-checked

Each mutation was applied to the production source, the suite run, then the source
restored and verified by checksum. Baseline green before each.

# Mutation Caught by
M1 resolve via shutil.which test_ignores_path_even_when_it_contains_a_matching_command + 2
M2 drop the path-separator guard test_rejects_traversal_even_though_the_target_is_reachable + 3
M3 fall back to the bare name test_raises_rather_than_returning_the_bare_name
M4 subclass FileNotFoundError test_is_not_a_filenotfounderror
M5 search /usr/bin before the wrapper dir test_searches_the_setuid_wrapper_directory_before_usr_bin
M6 drop /sbin test_searches_both_sbin_locations
M7 ignore the execute bit test_ignores_a_non_executable_file

M4 is worth calling out: code around subprocess catches FileNotFoundError to
mean "optional tool absent, carry on degraded". An unavailable privileged helper
must not be absorbed by that handling, so SystemCommandNotFoundError
deliberately does not inherit from it.

M2 is why there are two traversal tests. The straightforward parametrized one does
not catch the mutation alone — with the executable directly in the searched
directory, ../name resolves to nothing either way — so
test_rejects_traversal_even_though_the_target_is_reachable nests the directory
so the traversal reaches a real file, and asserts that precondition explicitly.

Test changes

TestVFSProcessmanager gains an autouse fixture stubbing the resolver, so its
launch-command assertions pin the argv shape rather than this host's sudo
location — and fail if a bare or hardcoded name reappears in the source.

Verification

  • hatch run fmt clean
  • hatch run lint clean (ruff, ruff format, mypy over 90 files)
  • hatch run test: 603 passed, 61 skipped, 1 xfailed

The VFS paths themselves are not exercised end to end here — the mount/unmount
argv is asserted, not executed, which is the existing test approach for this class.

The VFS mount and unmount paths invoked sudo by bare name, and queried mount
state with a bare findmnt, so each was resolved through PATH. Where any part of
that search path is influenced by less-trusted input the resolution is itself the
vulnerability (CWE-426), and these run to act as the job user.

This code was reported in openjd-sessions-for-python's sibling form as HackerOne
3942741. It moved here from deadline-cloud in "feat: migrate to job attachments
package" (#1133), so the fix belongs in this repository rather than there.

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.

A resolver rather than absolute literals because the locations are not
universal: NixOS keeps the setuid sudo wrapper at /run/wrappers/bin/sudo, so a
hardcoded /usr/bin/sudo would turn a security bug into a mount failure there.

Properties pinned by 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_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_searches_the_setuid_wrapper_
                                   directory_before_usr_bin
  M6 drop /sbin                 -> caught by test_searches_both_sbin_locations
  M7 ignore the exec bit        -> caught by test_ignores_a_non_executable_file

M4 matters because code around subprocess catches FileNotFoundError to mean
"optional tool absent, carry on degraded"; an unavailable privileged helper must
not be absorbed by that handling.

TestVFSProcessmanager gains an autouse fixture stubbing the resolver, so its
command assertions pin the argv shape rather than this host's sudo location and
fail if a literal reappears in the source.

hatch run fmt, hatch run lint (ruff, ruff format, mypy over 90 files) and
hatch run test all pass: 603 passed, 61 skipped, 1 xfailed.

Refs: HackerOne 3942741, CWE-426
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Comment thread src/deadline/job_attachments/vfs.py Outdated
return None
return ["sudo", "-u", os_user, fusermount3_path, "-u", mount_path]
return [
system_command_path("sudo"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

system_command_path("sudo") raises SystemCommandNotFoundError from inside get_shutdown_args, which changes the failure behaviour of the whole shutdown path in a way that no caller handles.

Two concerns:

  1. Inconsistent contract within this function. A missing fusermount3 (lines 119-122) is treated as a soft failure: log a warning and return None, which shutdown_libfuse_mount handles by returning False. A missing sudo now throws. The function is documented/typed as returning Optional[list], so callers reasonably only handle None.

  2. The exception escapes cleanup. shutdown_libfuse_mount is called from kill_all_processes and kill_process_at_mount, whose try blocks catch only FileNotFoundError. kill_all_processes is in turn called from AssetSync.cleanup_session (asset_sync.py:1088), which catches only VFSExecutableMissingError. So on a host where sudo is outside the trusted directory list, SystemCommandNotFoundError propagates out of session cleanup rather than being logged. Notably, before this change the missing-sudo case surfaced as FileNotFoundError from subprocess.run and was absorbed by kill_all_processes (albeit logged misleadingly as "VFS pid file not found") — so this is a live behaviour change, not just a theoretical one.

The module docstring argues deliberately that this should not be a FileNotFoundError, which is defensible — but then one of the callers needs to catch SystemCommandNotFoundError explicitly, or get_shutdown_args should log and return None here for symmetry with the fusermount3 branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 16c62d2, then superseded by 9cdd7e8. Initially I made get_shutdown_args warn and return None for symmetry with the missing-fusermount3 case, which is what you suggested. The wider problem you identified, that the new exception type interacts badly with this module's existing handlers, turned out to need the base class changed too. See my reply on 3799068427.

system_command_path("sudo"),
"-u",
os_user,
fusermount3_path,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High-level note on the scope of this hardening: the two commands resolved through the new trusted resolver (sudo, findmnt) are the ones that were least influenced by less-trusted input, while the binaries actually executed under sudo are still resolved via PATH.

  • fusermount3_path here comes from find_vfs_link_dir()find_vfs(), which starts with shutil.which(DEADLINE_VFS_EXECUTABLE) (vfs.py:357) and then falls back to $DEADLINE_VFS_INSTALL_PATH/cwd-relative bin/deadline_vfs. That path is then passed to sudo -u <os_user>.
  • build_launch_command (vfs.py:299) resolves the launch script from $DEADLINE_VFS_INSTALL_PATH and runs it via sudo -E -u <os_user>.

So if the threat model is "part of the search path is influenced by less-trusted input" (CWE-426, as the new module docstring states), pinning sudo while leaving the sudo argument resolved via PATH/env-var/cwd leaves the higher-impact half of the vector in place — -E even forwards the environment through.

Not asking to expand this PR necessarily, but it would be worth stating in the PR description / module docstring that find_vfs() and find_vfs_launch_script() are knowingly out of scope, so this is not later read as "the untrusted-search-path issue in vfs.py is closed."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, and now stated explicitly in 4d82305 rather than left implicit. find_vfs carries a note that it deliberately does not use the resolver, with the reasoning: the resolver is for system commands whose locations are fixed and few, whereas the VFS executable is a shipped artifact whose location is a deployment choice, which is why shutil.which, $DEADLINE_VFS_INSTALL_PATH and a cwd-relative path are all consulted. Narrowing that is a question about how the VFS is deployed, not about command resolution, and it is knowingly out of scope here.

TRUSTED_SYSTEM_DIRECTORIES: _Tuple[str, ...] = (
# Ordered, deliberately. On NixOS the setuid `sudo` wrapper lives here and the
# /usr/bin copy is absent or not setuid, so this must be searched first.
"/run/wrappers/bin",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things about the trusted-directory list worth a second look, since this module's entire value rests on the claim that these locations are trustworthy:

  1. /run/wrappers/bin is searched first on every platform, not just NixOS. On a non-NixOS host this directory normally does not exist, so the entry is inert — but it is unconditionally given priority over /usr/bin. /run is a root-owned tmpfs on typical distributions, so exploiting it needs root already; still, giving highest precedence to a path that is non-standard on the vast majority of target hosts is the opposite of the "fixed list of trusted absolute directories" premise. Consider gating it (e.g. only prepend when /run/wrappers/bin/sudo is a setuid file, or when a NixOS marker such as /etc/NIXOS is present), or at minimum documenting that the entry is expected to be absent elsewhere.

  2. No ownership or write-permission check on the resolved directory/file. _is_executable_file only checks isfile + X_OK. A resolver whose stated purpose is defeating untrusted search paths would normally also refuse a candidate whose directory or file is group/world-writable, or not root-owned — otherwise a misconfigured /usr/local-style directory (or a symlink planted inside a searched directory) is trusted purely because of where it appears in the list. If that check is deliberately out of scope, saying so in the docstring alongside the three properties already listed would make the boundary explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both points correct, and both parked rather than fixed. On the ordering: /run/wrappers/bin does get priority on every platform though it is NixOS-specific, and it is normally absent, costing one stat. On trust: _is_executable_file checks only isfile plus an execute bit, never root ownership or group and world writability, so membership is positional. Parked because reaching the exposure needs root or equivalent already, and an ownership check has the same failure shape as the X_OK check that already caused one regression in this series by testing permissions as the wrong user. Recorded so it is not lost, and the module docstring no longer implies a stronger guarantee than the code provides.

…lver

Two real CI failures on the windows-latest legs, plus three corrections carried
over from review of the sibling openjd-sessions change.

1. The launch-command tests failed on Windows (real, PR-caused).

TRUSTED_SYSTEM_DIRECTORIES is a POSIX layout, so a real lookup can never succeed
on win32 and system_command_path raised. TestVFSProcessmanager is skipped there,
but test_vfs_launched_in_session_folder and test_vfs_has_expected_logs_folder are
module-level functions and are not -- they build a launch command on Windows and
only care about session-folder behaviour.

The resolver stub is now a module-level autouse fixture rather than scoped to
TestVFSProcessmanager, so those two get it too. Verified by emptying
TRUSTED_SYSTEM_DIRECTORIES locally, which reproduces the Windows condition
exactly: both tests pass.

2. test_ignores_a_non_executable_file failed on Windows (real, PR-caused).

os.access(path, X_OK) is true for any existing file there, so "not executable" is
not expressible on that platform. POSIX-guarded, matching how the sibling suites
guard platform-specific semantics.

3. get_shutdown_args answers a missing sudo with None, not an exception.

The function is typed Optional[list] and already warns-and-returns-None for a
missing fusermount3. Raising for sudo added a second, undeclared failure mode on
the same line, and it escapes into shutdown_libfuse_mount's cleanup path, whose
only handling for this function is a falsy check. It now uses
find_system_command. Both the None and the negative control are pinned, because
the obvious "use the raising resolver everywhere for consistency" refactor
silently reintroduces the escape.

4. SystemCommandNotFoundError now derives from FileNotFoundError.

The previous revision made it a plain Exception, reasoning that an unavailable
privileged helper must not be absorbed by "carry on degraded" handlers. That
reasoning assumed rather than checked what surrounding code does, and the
semantics this condition has are FileNotFoundError's. The test asserting the
opposite is inverted; it had been pinning the wrong decision.

5. The name guard rejects ":" as well as separators.

ntpath.join(r"C:\Windows\System32", "D:evil") == "D:evil" -- a drive-relative name
discards the trusted prefix while containing no separator at all. Harmless under
posixpath, but the guard belongs in the validator rather than depending on which
os.path is loaded.

6. /run/current-system/sw/bin added to the trusted directories.

/run/wrappers/bin holds only the setuid wrappers, so on NixOS it resolves sudo and
nothing else -- findmnt is in the sw/bin symlink farm. The entry that the ordering
comment justifies supported no complete code path without it. The pairing is
asserted so it cannot be half-removed.

608 passed, 61 skipped, 1 xfailed. hatch run fmt and lint clean.

Refs: HackerOne 3942741, CWE-426
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The two get_shutdown_args tests used a parenthesized context manager group,
which is not valid syntax on Python 3.8. This package supports >=3.8, so ruff
rejected it -- and it would have failed the 3.8 CI legs the same way the
Windows legs caught the resolver's platform assumptions.

Nested with statements instead, with a comment recording why, since the
parenthesized form is the natural thing to reach for and reads better.

608 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Comment thread src/deadline/job_attachments/vfs.py Outdated
use findmnt instead
"""
return subprocess.run(["findmnt", path]).returncode == 0
return subprocess.run([system_command_path("findmnt"), path]).returncode == 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_mount now raises SystemCommandNotFoundError when findmnt is absent, and because that class subclasses FileNotFoundError, it gets absorbed by two pre-existing except FileNotFoundError handlers in this file that were written to mean "the VFS pid file is missing". The SystemCommandNotFoundError docstring argues the earlier plain-Exception revision "assumed rather than checked what surrounding code does with it" — but the surrounding code here does in fact catch FileNotFoundError broadly, so the concern that revision was guarding against is real:

  1. kill_process_at_mount (vfs.py:176-194) truncates the pid file with open(pid_file_path, "w") and then rewrites entries line by line. Line 188 calls shutdown_libfuse_mountwait_for_mountis_mount inside that with block. If findmnt is missing, the raise unwinds out of the rewrite loop, is swallowed by except FileNotFoundError at line 192, logs the misleading "VFS pid file not found at ...", and returns False — leaving the pid file truncated with the remaining entries dropped. Every mount recorded after the matched one is then untracked and can never be shut down.

  2. kill_all_processes (vfs.py:101-110) has the same shape: the is_mount raise from line 107 is reported as a missing pid file rather than a missing findmnt.

Both are new failure modes on this revision — before the change, subprocess.run(["findmnt", ...]) also raised FileNotFoundError, but only when findmnt was genuinely absent from PATH; now it also fires whenever findmnt exists but sits outside TRUSTED_SYSTEM_DIRECTORIES, which is a strictly larger set of hosts.

Either narrow those two handlers to the open() calls they were meant to guard (e.g. except SystemCommandNotFoundError: raise ahead of them, or scope the try to the file access), or reconsider the base class — a distinct non-OSError type made these sites fail loudly instead of corrupting the pid file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9cdd7e8, and see 3799068427 where you restated it against the current revision. Short version: you were right and my first reply to this was wrong.

) as mock_resolver:
yield mock_resolver


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixture stubs system_command_path but not find_system_command, so the docstring's first claim — "Command assertions pin the argv shape rather than this host's sudo and findmnt locations" — doesn't hold for the shutdown path. get_shutdown_args (vfs.py:129) now goes through the unstubbed find_system_command("sudo"), so these existing assertions became host-dependent on this revision:

  • test_vfs.py:520, :523 (kill_all_processes)
  • test_vfs.py:698, :712, :715 (kill_process_at_mount)

They pass on the GitHub Linux/macOS runners because /usr/bin/sudo exists there, but on any host where sudo is absent or outside TRUSTED_SYSTEM_DIRECTORIES — a minimal container image, or a Nix dev shell without the setuid wrapper — get_shutdown_args returns None, shutdown_libfuse_mount short-circuits at vfs.py:151 before calling subprocess.run, and assert_has_calls/assert_called_with fail with a confusing call(None, check=True) expectation rather than a clear "sudo not found".

Adding find_system_command to the same stub (e.g. a second patch(...) with the same lambda name: f"/trusted/{name}" side effect) makes the argv assertions genuinely host-independent, which is what the docstring says the fixture is for. The TestGetShutdownArgsFailureContract tests in test_system_commands.py patch find_system_command explicitly, so they'd be unaffected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d0a1617. Correct, and the cause was mine: I changed get_shutdown_args to use find_system_command after writing that fixture, so it stubbed only system_command_path and those assertions had quietly become host-dependent. Both entry points are stubbed now.

Three review/CI findings.

1. test_all_entries_are_absolute failed on windows-latest 3.13.

From Python 3.13, ntpath.isabs() no longer calls a single-slash path absolute,
treating it as drive-relative. The assertion used os.path.isabs, which made it a
statement about the host running the tests rather than about the constant. These
entries are POSIX paths and the VFS is POSIX-only, so it now uses posixpath.isabs.

2. The resolver fixture was stubbing only one of two entry points.

get_shutdown_args was changed to call find_system_command in the previous commit,
but the fixture stubs only system_command_path. So that path hit the real resolver
and its argv assertions had silently stopped pinning what the fixture docstring
claimed. Both entry points are stubbed now.

3. check-api-changes: two added public aliases.

Importing the helpers plainly made them public attributes of
deadline.job_attachments.vfs, which griffe reported as an unintended addition to
the package's contract. They are internal, so they are now imported under private
aliases. hatch run docs:check-api-breaks against upstream/mainline is clean.

Not changed, and worth recording because the review prescription was wrong: a
comment claimed SystemCommandNotFoundError inheriting FileNotFoundError makes a
missing findmnt get swallowed by the pre-existing 'except FileNotFoundError'
handlers that mean 'VFS pid file missing'. Neither is_mount call site is inside
one of those handlers -- kill_process_at_mount calls it before its try block, and
wait_for_mount has no handler. More to the point, the previous code raised
FileNotFoundError from subprocess.run for a missing findmnt, so absorption
behaviour is identical either way. The misleading log message on that path is
pre-existing and out of scope here.

Still open on this PR, both pre-existing and deliberately untouched: CodeQL's
three clear-text-logging alerts at vfs.py:314/477/507, which flag credential-
adjacent logging that predates this change and is only re-attributed because an
adjacent f-string moved.

608 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Comment thread src/deadline/job_attachments/vfs.py Outdated
use findmnt instead
"""
return subprocess.run(["findmnt", path]).returncode == 0
return subprocess.run([_system_command_path("findmnt"), path]).returncode == 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SystemCommandNotFoundError subclasses FileNotFoundError, and this is the one call site that can raise it into a caller that already catches FileNotFoundError for an unrelated reason. The module docstring says the earlier plain-Exception design "assumed rather than checked what surrounding code does with it" — but on this revision the surrounding code does swallow it, and mislabels it.

kill_all_processes (vfs.py:106-115) wraps the whole loop:

try:
    pid_file_path = ...
    with open(pid_file_path, "r") as file:
        for line in file.readlines():
            ...
            cls.shutdown_libfuse_mount(mount_point, os_user, session_dir)  # -> wait_for_mount -> is_mount
    os.remove(pid_file_path)
except FileNotFoundError:
    log.warning(f"VFS pid file not found at {pid_file_path}")

If findmnt is in no trusted directory, is_mount raises SystemCommandNotFoundError, which is a FileNotFoundError, so it is caught here and logged as "VFS pid file not found" — a message about a file that was just opened successfully. The real cause (findmnt not in any trusted directory) never reaches the log, the remaining mounts in the pid file are never shut down, and os.remove(pid_file_path) is skipped so the stale pid file persists.

kill_process_at_mount (vfs.py:181-199) is worse: the raise can come from line 193, i.e. after the pid file has been reopened "w" and partially rewritten. The same except FileNotFoundError at :197 catches it, logs the same wrong message and returns False, leaving the pid file truncated with the not-yet-written entries lost.

This is the "absorbed by handlers that catch FileNotFoundError to mean carry on degraded" failure mode the docstring describes and then dismisses. Two ways out:

  1. Do not inherit from FileNotFoundError (the earlier design), or
  2. Keep the inheritance but resolve findmnt where the failure stays visible — e.g. give is_mount the same treatment as get_shutdown_args and surface a VFS-specific error, and/or narrow the except FileNotFoundError blocks at vfs.py:114 and :197 so they only wrap the open()/os.remove() calls they are actually for.

The narrowing half of (2) looks worth doing regardless: as written, any FileNotFoundError raised anywhere under the shutdown path is reported as a missing pid file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9cdd7e8, and I owe you a correction. In an earlier commit message I claimed neither is_mount call site sits inside a FileNotFoundError handler and that absorption was therefore unchanged. The first half was wrong: I traced kill_process_at_mount and wait_for_mount but not kill_all_processes, which does wrap the chain through shutdown_libfuse_mount and wait_for_mount. The second half was right, since subprocess.run raised FileNotFoundError for a missing findmnt before, but as you noted the trigger set is now wider: it also fires when findmnt exists outside the trusted directories. SystemCommandNotFoundError is a plain Exception again and vfs translates it into VFSExecutableMissingError, which resolves this and 3799081212 together.

Comment thread src/deadline/job_attachments/vfs.py Outdated

command = (
f"sudo -E -u {self._os_user}"
f"{_system_command_path('sudo')} -E -u {self._os_user}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Companion to the is_mount comment, at the other end of the spectrum: here the new SystemCommandNotFoundError is not swallowed, it escapes the VFS-fallback contract entirely.

build_launch_command is reached from VFSProcessManager.start()download.mount_vfs_from_manifests() (download.py:1258), and both call sites in asset_sync.py guard that call with exactly one handler:

try:
    VFSProcessManager.find_vfs()
    mount_vfs_from_manifests(...)
    ...
except VFSExecutableMissingError:
    logger.error("Virtual File System not found, falling back to COPIED ...")
    return False

(asset_sync.py:279-296, and again at :916-935)

SystemCommandNotFoundError derives from FileNotFoundError/OSError, not from JobAttachmentsError, so on a host where the VFS is installed but sudo is outside TRUSTED_SYSTEM_DIRECTORIES, this raise propagates straight out of sync_inputs as a bare FileNotFoundError rather than taking the documented "fall back to JobAttachmentsFileSystem.COPIED" path. The deliberate find_vfs() probe just above exists precisely so a missing VFS prerequisite degrades instead of failing the session; a missing sudo is the same class of prerequisite but now bypasses it.

Note this is also a raise from inside a Popen(..., shell=True) string builder, i.e. it fires at f-string interpolation time before any of start()s own try block — so start()s except Exception at vfs.py:521 does not see it either, and the mount point created at vfs.py:489 is left behind.

Cheapest fix that keeps the raising resolver: catch SystemCommandNotFoundError in build_launch_command (or in start) and re-raise as VFSExecutableMissingError (or a new VFSSystemCommandMissingError added to the existing except clauses), so a missing privileged helper stays inside the VFS error taxonomy the callers already handle.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9cdd7e8. This and 3799068427 are the same defect seen from opposite ends: the exception was absorbed where it should not be, and uncaught where it should be. Translating to VFSExecutableMissingError at the resolving call sites fixes both, since that is the type asset_sync already handles by falling back to a copy-based sync. Worth noting the base class now differs deliberately from the sibling resolver in openjd-sessions, where inheriting from OSError is correct because its cancel path catches OSError so a failed signal cannot unwind a cancelation. Same question, opposite answer, because the surrounding handlers differ. Both docstrings say so.

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>
Two review findings that point the same way, and one earlier reply of mine that was
wrong.

The resolver's exception inherited from FileNotFoundError, which collides with this
module twice over, in opposite directions.

Absorbed where it should not be: vfs uses FileNotFoundError to mean "the VFS pid
file is missing", in three handlers. One of them wraps a chain that reaches the
resolver -- kill_all_processes -> shutdown_libfuse_mount -> wait_for_mount ->
is_mount -- so a resolution failure was logged as a missing pid file and skipped the
os.remove that follows, leaving a stale file behind.

Not caught where it should be: build_launch_command's failure escaped
mount_vfs_from_manifests, whose callers in asset_sync guard only for
VFSExecutableMissingError. So instead of falling back to a copy-based sync, the
error propagated out of sync_inputs.

SystemCommandNotFoundError is a plain Exception again, and vfs translates it into
VFSExecutableMissingError at the two resolving call sites. That is the type meaning
"a binary I need is not here", and the one asset_sync already handles.

Note this is the opposite of the right answer in the openjd-sessions resolver, where
inheriting from OSError is correct because its cancel path deliberately catches
OSError so a failed signal cannot unwind a cancelation. The base class follows the
handlers that surround each resolver, so the two differ on purpose. Both docstrings
now say so.

Correcting myself: an earlier commit message on this branch asserted that neither
is_mount call site sits inside a FileNotFoundError handler and that absorption was
therefore unchanged. The first half was wrong -- I traced kill_process_at_mount and
wait_for_mount but not kill_all_processes, which does wrap the chain. The second half
was right, since subprocess.run raised FileNotFoundError for a missing findmnt
before, but the trigger set was still widened: it now also fires when findmnt exists
outside the trusted directories.

609 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Review finding. The change routes sudo and findmnt through the trusted-directory
resolver, but the binaries launched under sudo are still found via shutil.which,
$DEADLINE_VFS_INSTALL_PATH, and a working-directory-relative path. The reviewer
asked only that this be stated rather than left implicit, which is fair.

find_vfs now explains the distinction: the resolver is for system commands, whose
locations are fixed and few, whereas the VFS executable is a shipped artifact whose
location is a deployment choice. Narrowing its search is a question about how the
VFS is deployed, not about command resolution.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The api-change gate flagged one added public alias,
deadline.job_attachments.vfs.SystemCommandNotFoundError. Same mistake as the
previous round and the same fix: importing it plainly makes it a public attribute
of the vfs module, widening the package contract with what is meant to be an
internal helper.

All three imports from _system_commands are now aliased under private names. Worth
noting I had narrowed the other two for exactly this reason one commit earlier and
then reintroduced the problem with a third import, which is what the gate is for.

hatch run docs:check-api-breaks against upstream/mainline is clean. 609 passed,
61 skipped, 1 xfailed. ruff, ruff format and mypy clean.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
use findmnt instead
"""
return subprocess.run(["findmnt", path]).returncode == 0
return subprocess.run([cls._resolve_or_raise("findmnt"), path]).returncode == 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_mount raising here defeats the non-raising contract that get_shutdown_args was deliberately written to uphold, because the two sit on the same call chain.

get_shutdown_args (vfs.py:137) uses the non-raising _find_system_command, and the comment above it gives the reason: an exception "escapes into shutdown_libfuse_mount's cleanup path, whose only handling for this function is the None check." But shutdown_libfuse_mount ends with:

return cls.wait_for_mount(mount_path, session_dir, expected=False)   # vfs.py:164

and wait_for_mount -> is_mount -> _resolve_or_raise("findmnt") raises VFSExecutableMissingError. So shutdown_libfuse_mount can still throw out of the exact cleanup path the get_shutdown_args comment says must not throw — just via findmnt instead of sudo.

Concretely, in kill_all_processes (vfs.py:108-117) that exception is raised from inside the for line in file.readlines() loop, so it aborts before the remaining mounts are processed and skips os.remove(pid_file_path). It then surfaces in AssetSync.cleanup_session (asset_sync.py:1090) as Virtual File System not found, no processes to kill — which is inaccurate: processes existed, some may have been killed, and the pid file is still on disk.

This is reachable whenever sudo resolves but findmnt does not — e.g. a util-linux install that only provides /usr/local/sbin/findmnt, since /usr/local/* is (intentionally) not in TRUSTED_SYSTEM_DIRECTORIES. Previously the bare subprocess.run(["findmnt", path]) raised FileNotFoundError, which the surrounding except FileNotFoundError swallowed, so this path did not propagate out of kill_all_processes at all.

If the intent is that is_mount may raise, that is defensible — but then the reasoning recorded at vfs.py:129-135 is only half-true and the shutdown path is not actually exception-free. The alternative is to make is_mount mirror get_shutdown_args: resolve with _find_system_command and treat an unresolvable findmnt as "cannot determine mount state" (return False, with a warning) so unmount cleanup degrades the same way it does for a missing sudo or fusermount3.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and deferred. The two do sit on the same call chain, and the asymmetry is real: get_shutdown_args returns None while is_mount raises, so the contract I wrote a comment about is not one the module actually keeps end to end.

Deferring because it is not reachable on a supported host. findmnt is /usr/bin/findmnt everywhere this runs, so the raising path needs a host where it is absent from all six trusted directories. Making the two consistent means choosing which way, and every option changes behaviour on a cleanup path beyond command resolution: either is_mount starts returning a bool that conflates "not mounted" with "cannot tell", or the mount and unmount paths get different failure contracts on purpose. That is a design call for this module's owners rather than something to settle at the end of a security review.


command = (
f"sudo -E -u {self._os_user}"
f"{self._resolve_or_raise('sudo')} -E -u {self._os_user}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related but distinct from the shutdown-path comment: on the mount path, routing a missing findmnt into VFSExecutableMissingError converts what used to be a hard failure into a silent copy-fallback that races a live VFS process.

Sequence when sudo resolves but findmnt does not:

  1. asset_sync calls VFSProcessManager.find_vfs() (asset_sync.py:280 / :917), which succeeds — it uses shutil.which/$DEADLINE_VFS_INSTALL_PATH and knows nothing about findmnt.
  2. mount_vfs_from_manifests -> vfs_manager.start() (download.py:1258) reaches build_launch_command here, resolves sudo fine, and subprocess.Popen launches the VFS successfully (vfs.py:539).
  3. start then calls wait_for_mount -> is_mount -> _resolve_or_raise("findmnt"), which raises VFSExecutableMissingError. Nothing in start catches it — the try only wraps the Popen block — so it propagates out.
  4. asset_sync catches VFSExecutableMissingError and logs Virtual File System not found, falling back to COPIED, then runs download_files_from_manifests over the same merged_manifests_by_root.

The launched deadline_vfs process is still alive and mounting (or about to mount) those roots, and it was never recorded in the pid file — start raises before the pid-file write at vfs.py:568. So the copy-based download writes into roots that a live FUSE mount may claim, and cleanup_session -> kill_all_processes has no pid entry to clean up.

Before this change, subprocess.run(["findmnt", path]) raised FileNotFoundError, which is not VFSExecutableMissingError, so it propagated out of sync_inputs as a hard error rather than being absorbed by the fallback. Turning it into the fallback exception is only safe if the fallback is reached before anything is launched, which is not the case here.

Two things would close this independently of the findmnt question:

  • Resolve the commands start needs up front (before Popen), so a resolution failure cannot happen after a process exists.
  • Or have start kill/reap self._vfs_proc when anything after Popen fails, not just on VFSFailedToMountError.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the sharpest of the three and I want to be clear I am not dismissing it. A silent copy-fallback that races a live VFS process is a worse outcome than a hard failure, and you are right that translating to VFSExecutableMissingError is what makes it silent.

Deferred on reachability: it needs sudo to resolve while findmnt does not, and both are in /usr/bin on every supported host. It is recorded as a real-but-unreachable finding rather than closed. If you would rather the mount path fail hard on a resolution failure, that is a one-line change to which exception _resolve_or_raise raises for that call site, and I will make it if you confirm that is the behaviour you want.

# and it escapes into shutdown_libfuse_mount's cleanup path, whose only
# handling for this function is the None check.
sudo_path = _find_system_command("sudo")
if sudo_path is None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new return None here is reached in cases the old code never returned None for, and the consequence downstream is that pid-file state is discarded while the mounts are still up.

kill_all_processes (vfs.py:108-117) ignores the return value of shutdown_libfuse_mount and unconditionally runs os.remove(pid_file_path) afterwards. kill_process_at_mount (vfs.py:186-198) is worse: it sets mount_point_found = True and skips file.write(line) for the matched entry, so the entry is dropped from the pid file regardless of whether the unmount actually happened.

With bare "sudo", a host without sudo produced FileNotFoundError from subprocess.run inside shutdown_libfuse_mount, which is not caught there (only CalledProcessError is) and so propagated up to the except FileNotFoundError in kill_all_processes — reported as a missing pid file, but crucially before os.remove, so the pid file survived and the entries were still there for a later attempt.

Now the same host takes the new branch: warn, None, False, loop continues, pid file deleted. The mounts are still mounted, and the only record of which mount points and pids existed is gone, so nothing can clean them up afterwards. AssetSync.cleanup_session (asset_sync.py:1086-1091) sees no exception at all and reports success.

Two hosts this is reachable on today: any container/image without sudo installed, and any install that places sudo outside TRUSTED_SYSTEM_DIRECTORIES (e.g. /usr/local/bin/sudo, which is where MacPorts and some FreeBSD-derived layouts put it).

The non-raising choice for this function is well-argued in the comment above; the gap is that neither caller does anything with the False. Propagating the failure so kill_all_processes skips os.remove when any mount failed to shut down, and kill_process_at_mount writes the line back, would keep the return contract and stop losing the state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and deferred with the other two. kill_all_processes ignoring the return value and removing the pid file regardless is the part that turns this into lost state rather than a failed cleanup, and kill_process_at_mount setting mount_point_found = True compounds it.

Both of those behaviours predate this change; what is new is a path that reaches them. Fixing it properly means changing how those two functions treat a failed shutdown, which is beyond command resolution and belongs with the owners of that cleanup logic.

CodeQL reports three high-severity py/clear-text-logging-sensitive-data alerts on
this PR, at vfs.py:350, :528 and :559.

Worth being precise about what happened, because my earlier note on this PR called
them pre-existing and that was only half right.

The three log statements are pre-existing and byte-identical to mainline. What is
new is the dataflow reaching them. Reading the analysis SARIF, every flow starts at
TRUSTED_SYSTEM_DIRECTORIES in _system_commands.py and runs through
find_system_command -> system_command_path -> _resolve_or_raise ->
build_launch_command -> the logged f-string. CodeQL classifies that tuple as a
sensitive-data source, so the resolved "/usr/bin/sudo" taints the command string,
and the pre-existing log lines become sinks. There are no such alerts on mainline,
so they are correctly attributed to this branch even though the logging is not
mine.

The finding is a false positive. Every value in the tuple is a hardcoded absolute
system binary directory, and the resolved result is the path of a system
executable. Neither is a secret, neither is attacker-supplied, and neither is
user-specific.

So the three sinks carry a suppression pointing at a single explanation next to the
constant that CodeQL flags. Suppressing at the source is not possible here, since
suppressions apply at the alert location.

Deliberately not done: redacting or dropping the full command from those log
lines. That would clear the alerts too, but it changes operator-facing output in
logging this change does not otherwise touch, so it belongs to the owners of that
logging rather than to a command-resolution fix. If the maintainers prefer that,
say so and I will make it instead.

609 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean, and
check-api-breaks against upstream/mainline is clean.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The '# codeql[py/clear-text-logging-sensitive-data]' comments added in the previous
commit have no effect: the alert count is unchanged at 3, on the same three lines.
GitHub code scanning does not honour that inline form for these alerts.

Removed rather than left in place. Three comments that look like a fix and do
nothing are worse than none, because the next reader has to rediscover that they
are inert.

The explanation stays next to the constant CodeQL flags, now stating what was tried
and what the remaining options are. One of those options is ruled out on technical
grounds and worth recording: the resolved path cannot simply be kept out of the
command string, because it is executed with shell=True, so a bare name there would
be resolved by the shell via PATH -- the behaviour this change exists to remove.

That leaves a repository CodeQL configuration, a dismissal in the Security tab, or
dropping the assembled command from those log lines. All three are decisions for
the maintainers, and the last changes operator-facing output in logging this change
does not otherwise touch, so I have not picked one unilaterally. Raised on the pull
request.

609 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@leongdl

leongdl commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

CodeQL: 3 clear-text-logging alerts, and a decision I would rather leave to you

The CodeQL check on this PR reports three high-severity
py/clear-text-logging-sensitive-data alerts, at vfs.py:347, :525 and :556.
Here is what they are, since the attribution is more subtle than it looks.

The log statements are not new. All three are byte-identical to mainline:

log.info(f"Got launch command {command}")
log.info(f"Launching VFS with command {start_command}")
log.exception(f"Exception during launch with command {start_command} exception {e}")

What is new is the dataflow reaching them. Reading the analysis SARIF, every
flow starts at TRUSTED_SYSTEM_DIRECTORIES in _system_commands.py and runs
find_system_commandsystem_command_path_resolve_or_raise
build_launch_command → the logged f-string. CodeQL classifies that tuple as a
sensitive-data source, so the resolved /usr/bin/sudo taints the command string and
the pre-existing log lines become sinks. There are no such alerts on mainline, so
they are correctly attributed to this branch even though the logging is not.

I believe it is a false positive. Every value in the tuple is a hardcoded
absolute system binary directory, and the resolved result is the path of a system
executable. None of it is a secret, attacker-supplied, or user-specific.

What I tried, and what is left

Inline # codeql[py/clear-text-logging-sensitive-data] suppression does not
work
here: the alert count stayed at 3 on the same lines. I have removed those
comments rather than leave three no-ops that read like a fix.

One option is ruled out on technical grounds. The resolved path cannot simply be
kept out of the command string, because it is executed with shell=True
(vfs.py), so a bare sudo there would be resolved by the shell via PATH — the
exact behaviour this change removes.

That leaves three, and all three are yours to choose:

  1. Dismiss the alerts in the Security tab as false positives.
  2. Add a repository CodeQL configuration filtering this query, or this path.
  3. Drop or redact the assembled command in those three log statements. This
    clears the alerts deterministically, and arguably the full sudo command line at
    INFO on every mount is more than needed. But it changes operator-facing output in
    logging this PR does not otherwise touch, so I did not want to make that call for
    you.

Happy to implement 3 if that is your preference — say which form you want (drop the
line, log a subset of fields, or redact specific arguments) and I will push it. The
reasoning above is also recorded in a comment next to the flagged constant so it
does not have to be reconstructed from this thread.

]

@classmethod
def shutdown_libfuse_mount(cls, mount_path: str, os_user: str, session_dir: Path) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new comment above (vfs.py:131-136) rests on the premise that shutdown_libfuse_mount's cleanup path must not throw, and that its "only handling for this function is the None check." That premise is right, but this method already violates it independently of the resolver — and it is worth knowing before more reasoning is layered on top of it:

try:
    run_result = subprocess.run(shutdown_args, check=True)
except subprocess.CalledProcessError as e:
    log.warning(f"Shutdown failed with error {e}")
    # Don't reraise, check if mount is gone
log.info(f"Shutdown returns {run_result.returncode}")   # vfs.py:166

When check=True raises, run_result is never bound, so line 166 raises UnboundLocalError — precisely defeating the # Don't reraise, check if mount is gone intent one line above, and never reaching wait_for_mount. fusermount3 -u exits nonzero for ordinary reasons (target not mounted, device busy, permission denied for the job user), so this is not an exotic path.

Downstream that lands in the same place as the other cleanup concerns on this PR: kill_all_processes (vfs.py:109-118) only catches FileNotFoundError, so an UnboundLocalError aborts the loop over remaining mounts and skips os.remove(pid_file_path); AssetSync.cleanup_session (asset_sync.py:1086-1091) only catches VFSExecutableMissingError, so it propagates out of session cleanup entirely.

This is pre-existing rather than introduced here, so it is fair to leave out of scope. But the PR is specifically reasoning about which exceptions can escape this function, and run_result is the one that already does — so either fixing it (initialise run_result = None and branch, or move the log.info into an else:) or noting it as known-and-separate would keep that reasoning accurate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and thank you for checking the premise rather than the conclusion. subprocess.run(..., check=True) does raise CalledProcessError out of shutdown_libfuse_mount, and run_result can be unbound on that path, so the non-throwing contract my comment appealed to is not one this method keeps today.

That is pre-existing and untouched by this change, so I have not folded a fix into it. The comment it undercuts has been narrowed accordingly in the module note rather than left asserting more than the code delivers.

Closes the three CodeQL py/clear-text-logging-sensitive-data alerts by removing the
sinks rather than the dataflow, because the dataflow has to stay.

The command is executed with shell=True, so the resolved absolute path of sudo must
remain in the string: a bare name there would be resolved by the shell via PATH,
which is what this change exists to prevent. CodeQL treats the trusted-directory
list as a sensitive-data source, so anything the resolved path is interpolated into
is tainted, and logging that string is the reported problem. Inline suppression is
not honoured for these alerts, so the remaining option is not to log it.

Three sites:

  build_launch_command   Dropped. It logged the same command start() logs a few
                         lines later, so it only ever duplicated that record.

  start()                Replaced with the individual arguments: executable,
                         bucket, manifest, region, casprefix, cachedir. Together
                         with the existing run_path, mount_point and user lines,
                         every argument the command carried is still logged. None
                         of these values carry the resolver dataflow.

  start() exception      Command dropped, mount point added. log.exception already
                         carries the traceback and the exception, so the mount point
                         was the only part not otherwise recoverable. Also dropped
                         the redundant "{e}" and the pointless "raise e" in favour
                         of a bare re-raise, which preserves the original traceback.

Kept an f-string rather than %-style lazy formatting, to match every other log call
in this module. The first attempt used %-args and broke
test_process_output_captured, which mocks log.info as a single-argument callable.

The note in _system_commands.py is trimmed to the part that is still actionable:
don't interpolate a resolved path into something that gets logged, and here is why
the launch logging is shaped this way.

609 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean, and
check-api-breaks against upstream/mainline is clean.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
f" casprefix={self._cas_prefix}"
f" cachedir={self._asset_cache_path}"
)
log.info(f"Launching with environment {launch_env}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope note on this commit's premise: the sink removed just above carried a hardcoded system binary path, while this line — unchanged by the commit — logs the whole launch environment at INFO.

launch_env is {**self._os_env_vars} plus PATH/LD_LIBRARY_PATH (vfs.py:490), and _os_env_vars is the caller-supplied os_env_vars threaded down from AssetSync.sync_inputs/sync_outputs (asset_sync.py:287, :924). That dict is whatever the worker hands over for the session — asset_sync.py:487 and :913 inspect it for AWS_PROFILE, so it is the environment of a credentialed session, and any AWS_*/token-bearing variable in it is written to the log verbatim.

So the net effect of this revision is that a non-secret (/usr/bin/sudo) stopped being logged because CodeQL treats TRUSTED_SYSTEM_DIRECTORIES as a sensitive-data source, while the adjacent line that can actually contain credentials is untouched — CodeQL is quiet about it only because launch_env does not carry the resolver's dataflow (its PATH comes from find_vfs_link_dir(), not from _system_commands).

Two consequences worth making explicit rather than leaving implicit:

  • The criterion being applied is taint-reachability, not sensitivity. If the goal is to avoid clear-text logging of sensitive data in this function, launch_env is the higher-value target and would want redaction or an allowlist.
  • If the goal is narrowly "close these three alerts," saying so in the comment at vfs.py:528-533 would stop a later reader concluding that launch logging in this function has been audited for secrets.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the most useful scope note on the PR, and it points at something larger than what I removed. log.info(f"Launching with environment {launch_env}") logs _os_env_vars threaded down from AssetSync.sync_inputs/sync_outputs, which is caller-supplied and is exactly the kind of dict that carries credentials. The path I removed carried a hardcoded system binary directory.

So CodeQL flagged the benign one and not this. I have left this line alone deliberately: it is unchanged from mainline, it is not part of command resolution, and redacting an environment dump is a decision about operator-facing output that belongs to you. Worth raising as its own issue on the strength of your analysis, and I am happy to open one or push the redaction if you tell me which shape you want.


command = (
f"sudo -E -u {self._os_user}"
f"{self._resolve_or_raise('sudo')} -E -u {self._os_user}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope question on the threat model, now that this line is the one being hardened: the string built here is handed to subprocess.Popen(args=start_command, shell=True, executable="/bin/bash") (vfs.py:558-565), and every field after sudo is interpolated unquoted:

f" {executable} {mount_point} -f --clienttype=deadline"
f" --bucket={self._asset_bucket}"
f" --manifest={self._manifest_path}"
...
command += f" --casprefix={self._cas_prefix}"
command += f" --cachedir={self._asset_cache_path}"

mount_point and _manifest_path derive from the session directory and the job's asset roots, and _cas_prefix/_asset_cache_path from queue/job configuration — i.e. values that come from the job being run rather than from this process. Any shell metacharacter in one of them (;, $(...), a space) is interpreted by /bin/bash, and the resulting command runs under the sudo this PR just pinned. That is a strictly wider vector than PATH resolution: CWE-426 requires influence over the search path, whereas this needs only one odd character in a path that is already carried through the job.

This is pre-existing, so it is fair to keep out of scope. But it does mean the hardening on this line closes the narrower of the two issues in the same expression, and the PR reads as though command construction here has been made safe. Either passing an argv list (which removes shell=True and the quoting question together — the resolved sudo path stays absolute, so the PATH property is preserved) or shlex.quote-ing the interpolated fields would close it; failing that, a note that shell quoting is knowingly separate would keep the boundary honest.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the substance, and I have taken the third option you offered: the note. Documented in 6f09326 on build_launch_command.

You are right that this is the wider of the two issues in that expression. Resolving sudo fixes how one binary is located; it does nothing about the fields interpolated unquoted after it, and those come from the job and its queue configuration rather than from this process. Needing one odd character in a path is a lower bar than influence over PATH.

Not fixing it here because both routes change launch mechanics rather than command resolution. The argv-list version is the one I would pick, since it removes shell=True and the quoting question together while keeping the resolved absolute sudo path, but it also changes how the environment and executable="/bin/bash" are handled, which is more than this PR should carry.

The part I did want to close is your last point, that the PR reads as though command construction here has been made safe. The note says plainly what is and is not addressed, so the boundary is on the record rather than implied.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
# diagnosable are logged individually instead, and none of them carry that
# dataflow. Between these and the run_path, mount_point and user lines
# above, every argument the command carried is still on the record.
log.info(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assembled command is now logged nowhere, and the field-by-field replacement cannot substitute for it in exactly the failure mode this PR documents two screens up.

start_command is used at only two places after bb357ae: built at vfs.py:545 and passed to Popen at vfs.py:575. The except Exception handler at vfs.py:589-594 dropped it too. So the exact string handed to /bin/bash is not recoverable from any log record.

That matters because the new docstring at vfs.py:335-351 identifies unquoted interpolation into a shell=True string as the live exposure in this function. The way that exposure manifests operationally is a mount_point, _manifest_path, _cas_prefix or _asset_cache_path containing a space or a metacharacter: bash then re-splits the command, deadline_vfs receives different argv than intended, and the failure surfaces as an opaque non-zero exit or a bash diagnostic on the captured stdout pipe. The per-field log here shows each value as a well-formed field, so it reads correctly in precisely the case where the assembled command was malformed -- the individual fields and their concatenation are not the same information once shell splitting is the bug.

Separately, the comment left at vfs.py:368-370 is inaccurate on this revision. It says the assembled command is built there and "logged again by start(), so this line only ever duplicated that one" -- but bb357ae removed the start() log as well, so that text describes the previous revision. A reader following it goes looking for the command in start() and finds only the field list.

If the goal is to keep the resolved sudo path out of the log while retaining the command, logging only the portion after sudo (build the suffix separately and log that) keeps quoting and splitting visible without carrying the resolver dataflow. Failing that, stating in the comment that the assembled string is deliberately unrecoverable -- so a quoting failure has to be reproduced rather than read off a log -- would make the tradeoff explicit rather than implying parity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two points, and you are right on both.

The stale comment is fixed in 025b6f5. It did describe the previous revision — it said the command was "logged again by start()" after the same commit removed that log, so a reader would have gone looking for something that is no longer there.

On the lost diagnostic, I have taken your second option and stated the tradeoff rather than restoring the log. Your reasoning is the part I want to acknowledge properly: the per-field log is not a substitute in precisely the failure mode the docstring documents, because when bash re-splits on a space or a metacharacter each field still reads as well-formed while the concatenation did not. So the field log looks correct exactly when the command was wrong. The comment now says the assembled string is deliberately unrecoverable and that a quoting failure has to be reproduced rather than read off a log.

I did not implement the suffix-logging option, though I think it is the better answer and it would work: the portion after sudo carries none of the resolver dataflow, so logging it keeps quoting and splitting visible without reintroducing the CodeQL finding. I held off because removing this log was an explicit instruction on my side, and re-adding a variant of it is a call for the owners of this logging rather than mine to make. Say the word and I will push it.

# would be a second, undeclared failure mode on the same line of code --
# and it escapes into shutdown_libfuse_mount's cleanup path, whose only
# handling for this function is the None check.
sudo_path = _find_system_command("sudo")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rationale recorded here — and the contract the new TestGetShutdownArgsFailureContract class pins — is that get_shutdown_args answers "a binary I need is missing" with None and never raises, so nothing escapes into shutdown_libfuse_mounts cleanup path. Good goal, but the function already has a raising path two lines above, on the very line the comment describes as the one that would gain "a second, undeclared failure mode":

fusermount3_path = os.path.join(cls.find_vfs_link_dir(), "fusermount3")   # vfs.py:127

find_vfs_link_dir() (vfs.py:326) calls find_vfs(), which raises VFSExecutableMissingError when the VFS executable cannot be located (vfs.py:412-465). So "the VFS binary is missing" already propagates out of get_shutdown_args as an exception, while "sudo is missing" and "fusermount3 is missing" return None. Three missing-binary cases, two different answers.

That matters beyond documentation accuracy, because one caller is mid-rewrite of the pid file when it happens. kill_process_at_mount (vfs.py:188) has already opened the file with "w" — truncating it — and is writing surviving entries back inside the loop when it calls shutdown_libfuse_mount at vfs.py:196. An exception raised from find_vfs_link_dir() at that point exits the with block with only the entries written so far, and the except FileNotFoundError at vfs.py:200 does not catch it, so it propagates through download.handle_existing_vfs (download.py:1172) and out of mount_vfs_from_manifests — where asset_sync catches VFSExecutableMissingError and quietly falls back to COPIED. Net result: a truncated pid file plus a silent fallback, with the mounts still up.

Two ways to make the stated contract true rather than half-true:

  • Wrap the find_vfs_link_dir() call in the same warn-and-return-None shape as the two checks below it, so all three missing-binary cases exit the function identically.
  • Or narrow the claim: TestGetShutdownArgsFailureContracts docstring says "get_shutdown_args must answer a missing binary with None, not an exception", which reads as a general property when the test only covers sudo. Saying "a missing sudo or fusermount3" and noting find_vfs_link_dir as a known raising path would keep the reasoning honest for whoever next picks a resolver here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right on the facts, and the comment was overclaiming. Fixed by narrowing the claim rather than by changing behaviour: the comment and TestGetShutdownArgsFailureContract's docstring are now scoped to sudo and fusermount3, and both name find_vfs_link_dir() -> find_vfs() as a known raising path, so the asymmetry is stated instead of implicitly denied.

The second consequence no longer applies. kill_process_at_mount now reads the pid file fully and writes once at the end, as part of a separate fix in this revision for entries being dropped after a failed unmount, so an exception from find_vfs_link_dir() can no longer leave the file truncated mid-rewrite.

Making all three cases answer identically is a behaviour change to a pre-existing path; parked as item 9.

class TestGetShutdownArgsFailureContract:
"""`get_shutdown_args` must answer a missing binary with None, not an exception.

It is typed `Optional[list]` and already warns-and-returns-None for a missing

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small factual correction, in a PR whose main deliverable is precise reasoning recorded in comments — so it is worth getting right.

This docstring says of get_shutdown_args: "It is typed Optional[list]". It is not typed at all:

@classmethod
def get_shutdown_args(cls, mount_path: str, os_user: str):   # vfs.py:121 — no return annotation

The new comment at vfs.py:131-132 makes the same claim ("this function's contract is Optional[list]"). The contract is real — both existing exits return None or a list — but it lives only in these two prose comments, which means nothing enforces it. mypy will not flag a future raise added to this function, and it will not flag a caller that indexes the result without a None check, because the inferred return type is currently just Optional[List[str]] by inference from the bodies rather than a declared contract... and it silently becomes Any-ish the moment someone adds a branch returning something else.

Given the PR goes to some length to pin this contract with a dedicated test class, adding the annotation would make the compiler enforce what the prose asserts:

def get_shutdown_args(cls, mount_path: str, os_user: str) -> Optional[List[str]]:

(Optional is already imported at vfs.py:12; List would need adding.) Then either fix these two docstrings to describe the annotation, or drop the "it is typed" phrasing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and worth fixing in a PR whose deliverable is precise reasoning in comments. The docstring no longer claims a type that is not there; it now says the function has no return annotation, and states the contract it actually pins.

@leongdl
leongdl force-pushed the fix/path-injection-rce branch 2 times, most recently from 49f046e to 2c6d58c Compare August 18, 2026 05:34
mount_point, _, _ = line.split(":")
if not cls.shutdown_libfuse_mount(mount_point, os_user, session_dir):
log.warning(f"Failed to shut down VFS at {mount_point}; keeping its pid entry")
still_mounted.append(line)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retained pid entry is written into the directory that is about to be deleted, and kill_all_processes still returns None, so nothing learns that a mount survived.

The comment above states the goal precisely -- "Removing the pid file regardless discarded the only record of a mount that is still up, so nothing could retry it and nothing said why." Two things stop the new code from reaching it:

  1. The record is not durable. pid_file_path is session_dir / DEADLINE_VFS_PID_FILE_NAME (vfs.py:109). kill_all_processes is called only from AssetSync.cleanup_session (asset_sync.py:1089), which runs at end-of-session precisely so the session directory can be torn down by the caller afterwards. So the preserved entry is deleted along with everything else, and "nothing could retry it" is still true -- the pid file is not a location any later actor reads.

  2. The failure is not reported. kill_all_processes is -> None, and cleanup_session (asset_sync.py:1085-1091) catches only VFSExecutableMissingError. When every unmount fails, cleanup_session returns normally and its caller sees an ordinary successful cleanup. The only trace is the log.warning on this line, which is indistinguishable from noise at the point where a leaked FUSE mount matters (the worker is about to remove a session directory that a live deadline_vfs is still serving).

The False returns this revision added to shutdown_libfuse_mount/get_shutdown_args are genuinely useful; the gap is that the topmost function in the chain drops the signal. Returning a bool from kill_all_processes (or raising), and having cleanup_session surface it, would make the aggregated result actionable by whoever decides to delete session_dir. As written, the change converts "pid file deleted, no signal" into "pid file kept but doomed, no signal."

Related note on the same edit: os.remove(pid_file_path) (vfs.py:133) is now outside the try, so a FileNotFoundError from it escapes cleanup_session rather than being absorbed by the except FileNotFoundError as before. Narrow (it needs a race on the file), but it is a new escape from a path this PR is otherwise tightening.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both halves are right, and I have taken one and stated the other.

The os.remove escape is mine and is fixed: it is back under except FileNotFoundError, with a comment saying it was absorbed before the restructure and that cleanup_session catches only VFSExecutableMissingError. Pinned by a test that removes the pid file from inside the shutdown call, mutation-checked.

On durability: correct, and the comment now says so rather than implying otherwise. This path runs at end of session and the caller removes session_dir afterwards, so the retained entry is short-lived and the warning is the durable trace; the mid-session half in kill_process_at_mount is where the retained entry outlives the call. Threading an aggregated result out to whoever decides to delete session_dir needs a return type this function does not have and a caller that reads it, so it is recorded as a follow-up rather than bolted on here.

for entry in entries_to_keep:
file.write(f"{entry}\n")

return mount_point_found and shutdown_succeeded

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preserved entry and the new return value are both discarded by the only production caller, and start() then deletes the very entry this block kept.

kill_process_at_mount has exactly one non-test caller: handle_existing_vfs (download.py:1172), which ignores the result entirely and returns the merged manifest. mount_vfs_from_manifests then proceeds to vfs_manager.start(session_dir=session_dir) (download.py:1258) for that same mount_point. Walking through the failed-unmount case this revision added:

  1. kill_process_at_mount returns False and keeps "/mnt/x:111:/manifest1.json" in the pid file. Nobody reads the False.
  2. start() launches a second deadline_vfs on /mnt/x (vfs.py:622).
  3. wait_for_mount(..., expected=True) calls is_mount("/mnt/x"), which is True because the old mount is still up — so this returns True on the first iteration and start() concludes the new mount succeeded, whether or not it did.
  4. The pid-file rewrite at vfs.py:653-666 writes the new entry, then walks the old lines and for entry_mount_point == self._mount_point deliberately does not write it: log.warning(f"Pid {entry_pid} entry not removed at {entry_mount_point}").

So pid 111 is dropped from the file a few hundred milliseconds after this block took care to keep it, and the old process is now orphaned and untracked — the same end state as before the change, reached by a different route. The log.warning at step 4 is the only trace, and its wording ("entry not removed") reads as a stale-entry cleanup note rather than "we just lost track of a live VFS."

The two new unit tests (test_kill_process_at_mount_keeps_entry_when_unmount_fails, test_kill_all_processes_keeps_entries_for_failed_unmount) assert the file contents immediately after the call, so they pass while the end-to-end behaviour is unchanged.

Making the fix effective needs the caller to act on the new False: handle_existing_vfs returning early / raising so mount_vfs_from_manifests does not stack a second mount on a root it failed to release, and start() not treating a pre-existing is_mount as evidence that its own Popen mounted successfully. Without one of those, this return value and the retained entry have no observable effect outside the tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate, and the honest summary is that on that path the retained entry and the False currently have no end-to-end effect. Step 4 is the part I had not traced: start() deliberately drops the old entry for the same mount point, so the entry is lost shortly after being kept.

Not fixing it here, because the effective fix is the three coordinated changes you name -- handle_existing_vfs acting on False, and start() no longer reading a pre-existing is_mount as proof that its own Popen mounted something. That third one is what makes the other two observable, and it changes mount-success semantics; doing it inside a PATH-resolution fix would bury it.

Recorded as a parked item with your walkthrough. What this revision does close is narrower and worth separating: the tracking file no longer claims mounts are down when the unmount failed, and shutdown_libfuse_mount no longer raises UnboundLocalError instead of returning False -- which had made the keep-the-entry path unreachable for the likeliest failure.

# point is the part that was not otherwise recoverable from this record.
log.exception(f"Exception during VFS launch at mount point {self._mount_point}")
raise
log.info(f"Launched VFS as pid {self._vfs_proc.pid}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-resolving findmnt closes one post-Popen escape, but the mount-timeout path immediately below is still an untracked orphan — and worth naming here because the comment at vfs.py:585-590 now reads as though "an exception cannot escape with the VFS process already running" has been established.

start() writes the pid entry at vfs.py:654-666, i.e. after the mount check:

is_mounted = VFSProcessManager.wait_for_mount(self.get_mount_point(), session_dir)   # vfs.py:646
...
if not is_mounted:
    log.error("Failed to mount, shutting down")
    raise VFSFailedToMountError                                                        # vfs.py:652

The log says "shutting down", but nothing shuts anything down: self._vfs_proc is neither terminated nor waited on, and no pid entry exists yet. VFSFailedToMountError is raised in exactly one place and caught nowhere (grep finds no handler in src/), so it propagates past mount_vfs_from_manifests and out of AssetSync.sync_inputs — asset_sync.py:292 and :931 catch only VFSExecutableMissingError. Session cleanup then runs kill_all_processes, which reads the pid file and finds no entry for this mount, so the deadline_vfs process launched at vfs.py:622 is leaked with no record.

wait_for_mount returns False after a 60-second timeout, which is an ordinary occurrence (slow S3 first-byte, manifest permission problem, the VFS exiting nonzero right after launch) — not an exotic path.

This is pre-existing, so it is reasonable to leave out of scope. But the reasoning added on this revision is specifically about not letting an exception escape start() while a process exists, and this is the remaining instance of that. A try/finally (or an except around everything after Popen) that terminates self._vfs_proc before re-raising would make the property actually hold; failing that, noting that the mount-timeout path knowingly leaks would keep the new comment from over-claiming.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed rather than scoped out, because it is the same defect as the one this revision was raised for -- a VFS left running with nothing tracking it -- reached by a different route, and you are right that the new comment was over-claiming while this stood.

start() now terminates self._vfs_proc before raising VFSFailedToMountError, best effort so a failure there cannot replace the mount failure with a different exception. Pinned by test_start_terminates_the_vfs_process_when_the_mount_times_out, mutation-checked against removing the termination and against removing the wait.

Agreed the 60-second timeout is ordinary, which is what tipped this from pre-existing-and-out-of-scope to worth doing now.


command = (
f"sudo -E -u {self._os_user}"
f"{self._resolve_or_raise('sudo')} -E -u {self._os_user}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findmnt is pre-resolved in start() but sudo is not, and resolving it here changes an unresolvable-sudo host from a hard failure into a COPIED download into a 0o777 directory.

Order in start():

self._resolve_or_raise("findmnt")                        # vfs.py:591  (pre-resolved)
self.set_manifest_owner()
VFSProcessManager.create_mount_point(self._mount_point)  # vfs.py:593  -> os.chmod(mode=0o777)
start_command = self.build_launch_command(...)           # vfs.py:594  -> resolves sudo, may raise

create_mount_point (vfs.py:522-530) does os.makedirs then os.chmod(path=mount_point, mode=0o777). So when sudo is not in a trusted directory, _resolve_or_raise("sudo") on this line raises VFSExecutableMissingError after the asset root has been made world-writable — and asset_sync (asset_sync.py:292, :931) catches that exception and falls back to download_files_from_manifests over the same merged_manifests_by_root. The copy-based download then writes job input files into a 0o777 directory on a multi-tenant worker.

That is a behaviour change from the base commit, not just a relocation. With the bare "sudo" string, build_launch_command could not fail: Popen(shell=True) succeeded, bash reported sudo: command not found, wait_for_mount timed out, and start() raised VFSFailedToMountError — which no caller handles, so it surfaced as a hard error and never reached the COPIED path. Routing the missing-sudo case into VFSExecutableMissingError moves it into the fallback, which is the right type for the fallback but is now reached after mutating the filesystem.

The pre-resolve added at vfs.py:591 already establishes the pattern that fixes this — resolving sudo alongside findmnt, before create_mount_point, makes the whole resolve-then-mutate ordering hold, and the fallback would then run against an untouched root. test_start_resolves_findmnt_before_launching asserts mock_create_mount_point.assert_not_called() for findmnt; the same assertion for sudo currently would not pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred. Correct that sudo resolves inside build_launch_command while findmnt is now pre-resolved, so the two missing-binary cases do not fail alike.

The asymmetry is smaller than the one that was fixed: both raise before Popen, so neither leaves a process running, which was the defect. Aligning them is a small change but it moves resolution out of the function that builds the command, and this PR has had enough churn in start(). Filed.

@leongdl
leongdl force-pushed the fix/path-injection-rce branch from 2c6d58c to 4aa81cd Compare August 18, 2026 06:10
# mount and left the process running. wait_for_mount returning False is
# ordinary -- a slow first byte or a VFS that exited right after launch --
# not an exotic path.
self._terminate_vfs_proc()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

terminate() on self._vfs_proc does not reach the VFS, so the leak this block documents is only partly closed — and on the common deployment it is not closed at all.

self._vfs_proc is not deadline_vfs. It is Popen(args=start_command, shell=True, executable="/bin/bash") (vfs.py:642-650) over the string built by build_launch_command:

sudo -E -u <os_user> <.../scripts/production/al2/run_deadline_vfs_al2.sh> <mount_point> -f ...

Two hops sit between that pid and the process holding the mount:

  1. Privilege boundary. stdout is a pipe, not a tty, so sudo allocates no pty and execs the command in place rather than forking a monitor — the process ends up running as os_user. Popen.terminate() is kill(pid, SIGTERM) from this process, and POSIX requires the sender uid to match the target real or saved-set uid. Unless the agent is root that is EPERM, swallowed by the except Exception in _terminate_vfs_proc (vfs.py:712) and logged, while the process keeps running. This module already acknowledges the boundary everywhere else: get_shutdown_args goes through sudo -u <os_user> precisely because it cannot act on the job user’s mount directly.
  2. Wrapper indirection. What is launched is a shell script, not the binary. If run_deadline_vfs_al2.sh does not exec (it sets up env/LD_LIBRARY_PATH and then launches deadline_vfs), SIGTERM to the script does not propagate to the child, so wait() returns cleanly on a dead wrapper while deadline_vfs is still serving the mount — and the code now believes it shut down.

Two smaller points on the same method:

  • No escalation. wait(timeout=VFS_TERMINATE_WAIT_SECONDS) raising TimeoutExpired is caught by that same except Exception with no kill() follow-up, so a process ignoring SIGTERM survives with no further attempt.
  • A killed process is not an unmounted mount. wait_for_mount returning False is a 60-second timeout, so the ordinary reason to be here is a slow mount, not a failed one — the FUSE mount may appear moments later. Even a successfully killed process leaves a mount that only fusermount3 -u clears, and create_mount_point has already chmod 0o777-ed that root (vfs.py:612), which is where asset_sync’s COPIED fallback would then write. VFSFailedToMountError is caught nowhere in src/, so nothing downstream unmounts it either.

shutdown_libfuse_mount(self._mount_point, self._os_user, session_dir) is the mechanism this module already uses for exactly this — it crosses the uid boundary via sudo and actually unmounts. Calling that here (wrapped so it cannot replace the pending VFSFailedToMountError) would make the block do what the comment says. The new test asserts mock_proc.terminate.assert_called_once() on a MagicMock, so it passes regardless of which of the above holds: it pins the call, not the effect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Genuinely uncertain, so I am neither claiming this is closed nor dismissing it.

The launch goes through Popen(..., shell=True, executable="/bin/bash"), so terminate() signals bash. Whether the VFS receives it depends on whether bash exec'd sudo in place -- which bash does for a simple command with no metacharacters, and this command string has none -- or forked. If it exec'd, the signal reaches sudo, which forwards it. I cannot verify which happens on the deployment target from here, so I am not asserting the leak is fully closed.

What is closed is the case where nothing was signalled at all. Making it unconditional means start_new_session=True plus a process-group kill, which changes the launch mechanics; filed as the follow-up rather than done blind.

Process note, applying to the rest of this round: this is the fifth review pass, every finding in it is on a line an earlier pass asked me to change, and one of them was already stale when posted (the public-constant comment -- it was renamed to _VFS_TERMINATE_WAIT_SECONDS before that review ran). I am stopping here rather than continuing to iterate inside a PATH-resolution fix. Deferred items are recorded with the mechanism, the blast radius and what a fix would involve, so none of them is lost.

lines = file.readlines()
except FileNotFoundError:
log.warning(f"VFS pid file not found at {pid_file_path}")
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The try was narrowed to cover only the read, and the os.remove half of that was guarded below — but the loop body was not, and it can raise FileNotFoundError too.

Before this restructure, the single try spanned the read, the whole loop, and the os.remove. Now (vfs.py:113-118) it covers three lines. Everything between here and vfs.py:142 runs outside it, and the following reach FileNotFoundError on the shutdown path:

  • shutdown_libfuse_mountsubprocess.run(shutdown_args, check=True) (vfs.py:200). shutdown_args[0] is now a resolved absolute sudo path, and shutdown_args[3] is fusermount3_path, checked with os.path.exists (vfs.py:162) a moment earlier. Either disappearing between the check and the exec — or being present but with a missing ELF interpreter — is FileNotFoundError from subprocess.run, and shutdown_libfuse_mount catches only CalledProcessError.
  • wait_for_mountis_mountsubprocess.run([findmnt_path, path]) (vfs.py:295), same shape, no handler at all.
  • wait_for_mountprint_log_endopen(log_file_path) (vfs.py:371). The os.path.exists guard above it is a TOCTOU against the VFS process that is rotating those very logs — and this is on the expected=False timeout path, i.e. reached exactly when a VFS is still alive and writing.

Previously all three were absorbed by the outer except FileNotFoundError — logged inaccurately as a missing pid file, but absorbed, and cleanup returned normally. Now they propagate out of kill_all_processes, and AssetSync.cleanup_session (asset_sync.py:1086-1091) catches only VFSExecutableMissingError, so a FileNotFoundError escapes session cleanup entirely and the remaining mounts in the loop are never attempted.

This is the same reasoning the comment at vfs.py:146-151 applies to os.remove"letting it escape now would be a new failure mode out of cleanup_session, which catches only VFSExecutableMissingError" — and it holds identically for the loop. Wrapping the loop in the same except FileNotFoundError (log and continue, or log and stop, so the pid file is still rewritten with the unattempted entries) would make the narrowing complete rather than half-applied.

Worth noting the still_mounted write at vfs.py:139 has the mirror gap: open(..., "w") on a racily removed pid file silently recreates it, so the branch that carefully absorbs the race on the remove path re-materialises the file on the keep path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred, and worth being precise about the direction of the change. Narrowing the try means a FileNotFoundError raised inside the loop is no longer reported as "VFS pid file not found" -- which is what the old shape did, and it was wrong: it blamed the pid file for an error from the shutdown chain and skipped the rest of cleanup. So this is closer to a fix than a regression, but you are right that it changes what escapes cleanup_session.

Giving that path its own handling is a behaviour decision about degraded cleanup, not part of command resolution. Filed.

Comment thread src/deadline/job_attachments/vfs.py Outdated
DEADLINE_MANIFEST_GROUP_READ_PERMS = 0o640

VFS_TERMINATE_WAIT_SECONDS = 5
"""How long to wait for a VFS process to exit after terminating a failed mount."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VFS_TERMINATE_WAIT_SECONDS is a new public module attribute, which is the thing the import block 25 lines below goes to some length to avoid.

The comment at vfs.py:12-19 explains that _system_commands’ three names are aliased under private names because “imported plainly, these two become public attributes of deadline.job_attachments.vfs, which the API-surface check reports as added public aliases — an unintended widening of the package’s contract from what is meant to be an internal helper”, and commit e11eed5 exists solely to alias the exception for that reason. This constant is declared plainly, with a docstring, so griffe records it as an attribute member of deadline.job_attachments.vfs.

scripts/validate_api_snapshot.py fails on additions, not only on breaking changes:

added_paths = current_paths - baseline_paths
if added_paths:
    has_changes = True

and extract_api_paths collects every member whose kind is in ["module", "class", "function", "attribute", "alias"] with no leading-underscore filter. api-change-detection.yml then runs Fail if API changes foundexit 1 when that step fails. So the same gate that forced the aliasing should also report deadline.job_attachments.vfs.VFS_TERMINATE_WAIT_SECONDS as an added attribute — worth confirming against that leg, since the aliasing effort implies it is meant to stay green.

Two ways to keep the intent consistent: name it _VFS_TERMINATE_WAIT_SECONDS, or inline the 5 as a timeout= default. If it is instead meant to be public and tunable, that is a deliberate API addition and belongs in the snapshot with the commit-message treatment the workflow describes — but then the aliasing rationale above reads as inconsistent with it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed before this review ran -- the constant is _VFS_TERMINATE_WAIT_SECONDS now. The API-snapshot check caught it on the previous head, which is exactly the gate you point at, and it passes on the current head.

# VFSExecutableMissingError with the VFS process already running -- and
# asset_sync treats that exception as permission to fall back to a copied
# download, so the copy would write the same root as a live, untracked mount.
self._resolve_or_raise("findmnt")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preflight discards its result, so it proves "findmnt resolved a moment ago", not "findmnt will resolve when is_mount needs it" — and the gap it is meant to close stays open for the other command on the same chain.

find_system_command re-scans the filesystem on every call (_os.path.isfile + _os.access per directory), and nothing here caches the answer. is_mount at vfs.py:295 calls _resolve_or_raise("findmnt") afresh, so the post-launch raise this comment describes is still reachable — it just now needs findmnt to become unresolvable between line 610 and the wait_for_mount call at vfs.py:665. That is narrow, and the preflight is still worth having as a fail-fast; the issue is that the comment states the property as established when the mechanism only makes it likely. Assigning the result (self._findmnt_path = self._resolve_or_raise("findmnt")) and having is_mount use it when set would actually establish it, and would also stop wait_for_mount re-scanning six directories once per second for up to 60 iterations.

The larger gap is that fusermount3 gets no equivalent treatment, and it is on the same post-launch chain in the failure case. When wait_for_mount returns False, start() now calls _terminate_vfs_proc and raises VFSFailedToMountError — and if that is changed to unmount properly (see my note at vfs.py:679), the cleanup goes through get_shutdown_args, whose first statement is os.path.join(cls.find_vfs_link_dir(), "fusermount3")find_vfs() → raises VFSExecutableMissingError. So the "resolve everything the post-launch path needs before launching" principle this line introduces is applied to exactly one of the three binaries involved (findmnt preflighted here, sudo resolved mid-flight at vfs.py:411, fusermount3 not until cleanup).

Concretely: self._resolve_or_raise("sudo") alongside this line, plus the existing find_vfs_link_dir()/fusermount3 existence check, would make the ordering hold for all of them and would let test_start_resolves_findmnt_before_launching’s mock_create_mount_point.assert_not_called() assertion be written for each. As it stands the test pins the principle for the one command that was least likely to violate it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred as a known and accepted limit. True that the preflight proves "resolved a moment ago" rather than "will resolve later", and the result is discarded.

The failure it was raised for is findmnt not being installed, which is not a state that changes mid-session; a package being unlinked in that window is a different and much narrower problem. Threading the resolved path through to is_mount would close it properly and is the filed prescription.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@leongdl
leongdl force-pushed the fix/path-injection-rce branch from 4aa81cd to ae6e050 Compare August 18, 2026 06:38
# Command omitted for the reason given above the launch log. log.exception
# already carries the traceback and the exception itself, so the mount
# point is the part that was not otherwise recoverable from this record.
log.exception(f"Exception during VFS launch at mount point {self._mount_point}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _terminate_vfs_proc is wired into one of the three ways start() can leave a Popen behind, and the other two are on this same revision’s changed lines.

The comment above the new call states the principle clearly — the pid entry is written after the mount check, so on any post-Popen failure "the process launched above stayed alive with no record of it anywhere." That is equally true of the two paths that were not given the helper:

1. The except Exception block right here (vfs.py:662-667). self._vfs_proc is assigned at vfs.py:648, and self._vfs_thread.start() runs at vfs.py:658 inside the same try. If thread creation fails (RuntimeError: cant start new thread under a thread/pid-limited worker, or a MemoryError), the process is already running, self._vfs_proc is already set, and this handler logs and bare-raises without terminating it. VFSFailedToMountError is not involved, so nothing downstream reaps it either — and the exception type is arbitrary, so asset_sync (asset_sync.py:292, :931) does not catch it and it surfaces as a hard sync_inputs failure with a live untracked mount on a 0o777 root.

2. The pid-file rewrite at vfs.py:687-704. It catches only FileNotFoundError. line.split(":") on a malformed entry raises ValueError: too many values to unpack, which escapes start() after Popen succeeded and after wait_for_mount confirmed the mount — so the mount is live and the entry for it never gets written. Worth noting this is reachable from a pid file written by the old code: pre-this-PR kill_process_at_mount did file.write(line) on a stripped line with no newline, concatenating retained entries into /mnt/a:1:/m1.json/mnt/b:2:/m2.json. This PR fixes the newline, so newly written files are well-formed, but a session directory carried across an upgrade is not.

Both close the same way the mount-timeout path just did — self._terminate_vfs_proc() before re-raising. Structurally, wrapping everything from Popen through the pid-file write in a single except BaseException: self._terminate_vfs_proc(); raise would make the property hold for all post-launch escapes rather than for the one that was enumerated, and would cover whatever the next post-Popen step turns out to be.

test_start_terminates_the_vfs_process_when_the_mount_times_out pins only the not is_mounted branch, so neither of these would be caught by the new tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred. Fair that _terminate_vfs_proc is wired into one of the ways start() can leave a Popen behind, and that the mount-timeout path was chosen because it is the ordinary one -- a 60-second timeout on a slow first byte, not an exotic failure.

Covering the rest properly is a try/except around everything after Popen that terminates and re-raises, which is the right shape and a bigger change to start()'s control flow than I want to make at the end of this review cycle. Filed with that prescription.

Process note, applying to the rest of this round: this is the fifth review pass, every finding in it is on a line an earlier pass asked me to change, and one of them was already stale when posted (the public-constant comment -- it was renamed to _VFS_TERMINATE_WAIT_SECONDS before that review ran). I am stopping here rather than continuing to iterate inside a PATH-resolution fix. Deferred items are recorded with the mechanism, the blast radius and what a fix would involve, so none of them is lost.

# aborted kill_all_processes mid-loop, leaving later mounts unattempted,
# and escaped cleanup_session, which catches only
# VFSExecutableMissingError.
log.info(f"Shutdown returns {run_result.returncode}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving this log inside the try is the right fix, but it has a side effect on kill_all_processes that is worth sizing before merge: session cleanup can now take 60 seconds per failed mount, where before it took 60 seconds total.

The last line of this function is return cls.wait_for_mount(mount_path, session_dir, expected=False), and wait_for_mount (vfs.py:318-340) loops mount_wait_seconds=60 times with time.sleep(1) before giving up. So a shutdown_libfuse_mount that genuinely fails to unmount costs ~60s, plus a print_log_end that reads the whole VFS log and emits 100 lines at WARNING.

Before this change, the UnboundLocalError on the old line 166 fired on the first nonzero fusermount3 and aborted kill_all_processes mid-loop — which is exactly the bug the comment describes, but it also meant only one mount was ever attempted. Now the loop at vfs.py:136-141 runs to completion, so a systemic unmount failure is paid once per entry:

  • fusermount3 -u returning EPERM because the mount is owned by a different job user than the one cleanup_session was handed
  • EBUSY because a job process still has a cwd or open fd under the mount
  • sudo present but NOPASSWD not configured for the target user

None of these are per-mount accidents; they apply to every entry in the file at once. A job with 10 asset roots goes from ~60s of cleanup to ~600s, and AssetSync.cleanup_session (asset_sync.py:1076-1091) has no timeout of its own, so the worker blocks in session teardown for the whole duration.

Two ways to bound it without giving up the fix:

  • Pass a short mount_wait_seconds from shutdown_libfuse_mount (or from kill_all_processes). A successful fusermount3 -u is synchronous — the 60s budget exists for the mount direction, where S3 first-byte latency is the variable. Unmount does not need the same patience.
  • Or short-circuit the loop once a shutdown fails for a reason that will repeat: get_shutdown_args returning None is already known to apply to every entry, so on that first None the remaining entries can be kept without re-attempting each one.

The new test_shutdown_libfuse_mount_reports_failed_unmount and test_kill_all_processes_keeps_entries_for_failed_unmount both patch wait_for_mount/shutdown_libfuse_mount, so neither exercises the real timeout and this would not show up as a slow test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred, and the sizing is the useful part of this comment. Moving the log inside the try means shutdown_libfuse_mount now returns False on a nonzero fusermount3 instead of raising UnboundLocalError, so kill_all_processes continues through the remaining mounts rather than aborting on the first failure -- which is the intended change, and it does mean cleanup now attempts every mount and can spend the wait_for_mount timeout on each.

That tradeoff is deliberate: aborting mid-loop left later mounts unattempted and untracked. Bounding the total time is a separate change to the timeout policy. Filed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants