fix(security): pin icacls to its absolute System32 path at both ACL sites (BACKLOG #1769) - #1144
wshallwshall wants to merge 1 commit into
Conversation
…ites (BACKLOG #1769)
Two subprocess.run calls invoked icacls by unqualified name. Windows resolves an
unqualified program name through a search path that reaches the caller's working
directory, so an icacls.exe planted in the directory an operator happened to
launch from would be run instead. Neither call elevates, but both are
security-relevant, and both carried a comment asserting "icacls is a fixed
system tool" -- true only once the path is pinned.
store/store.py's _secure_file WRITES the ACL that restricts the store DB and the
DPAPI key file. It logs only on a non-zero exit, so a planted icacls.exe exiting
0 would leave that PHI-adjacent file its inherited (possibly broad) ACL and
report nothing: the hardening becomes a silent no-op.
auth/trust_anchors.py's dacl_is_owner_only parses icacls OUTPUT to decide whether
a TLS trust anchor is owner-only. A planted icacls printing a clean DACL would
turn a group-writable anchor into an accepted one.
Both now call _system_exe("icacls.exe") from service_status, the stdlib-only
neutral leaf, rather than growing a third resolver. That helper asks the OS via
GetSystemDirectoryW instead of reading %SystemRoot%, because an environment block
is inherited from whoever launched the process -- the same party this defends
against. store already reached service_status transitively through
config.settings, and tests/test_dependency_boundaries.py forbids only fastapi,
pyside6, messagefoundry.api and messagefoundry.console to the engine packages, so
neither import crosses a boundary.
Both comments now state the pin rather than asserting a property the code did not
yet have.
Tests: one per site, asserting argv[0] is absolute, is still icacls.exe, and sits
in the directory _system_dir reports. Compared against _system_dir rather than a
literal "System32" because GetSystemDirectoryW answers "SysWOW64" to a 32-bit
process, where that is the correct answer. Each test also asserts the call was
recorded at all, so it cannot pass over nothing. Both were run against the
unqualified-name source first and failed there with "got 'icacls'". They run
where os.name == "nt" is real rather than monkeypatched, matching the existing
note in tests/test_store.py. The two pre-existing assertions that pinned the bare
name now check the basename instead, leaving the pin to its own test.
d8603f5 to
d38c20e
Compare
|
LANDER: THE DEPENDENCY IS CLEARED. PR 1132 is merged, so Recording this here rather than in a message, because the session that authored this PR has ended and a cross-session note dies with the session that received it. That was this PR author's own point and it turned out to apply to them. MARK IT READY. DO NOT RE-RUN ITS CHECKS. This is the author's instruction and it is correct: a re-run replays the recorded merge SHA, which predates 1132, so the red would come straight back. Marking the PR ready fires a fresh DO NOT REBASE EITHER. The author measured it with TWO THINGS FROM MY READ OF 1132 THAT BEAR ON THIS PR, since it imports from the same module:
The pin deliberately lives in ON I have not touched this PR. Whoever picks it up: mark ready, and I will read and enqueue it in turn. |
What
Two
subprocess.runcalls invokedicaclsby unqualified name. Windows resolves an unqualifiedprogram name through a search path that reaches the caller's working directory, so an
icacls.exeplanted in the directory an operator happened to launch from would be run instead. Neither call
elevates, but both are security-relevant, and both carried a comment asserting "icacls is a fixed
system tool" -- true only once the path is pinned.
icacls.exewould domessagefoundry/store/store.py_secure_filemessagefoundry/auth/trust_anchors.pydacl_is_owner_onlyicaclsoutput to decide whether a TLS trust anchor is owner-onlyBoth now call
_system_exe("icacls.exe")fromservice_status, the stdlib-only neutral leaf, ratherthan growing a third resolver. Both comments now state the pin instead of asserting a property the
code did not yet have.
BACKLOG #1769, allocated and claimed in the maintainer-internal vault.
docs/BACKLOG.mdis a stubhere and is deliberately untouched.
Layering, verified rather than assumed
service_status.pyimports only stdlib (asyncio,ctypes,os,re,subprocess,sys), soneither new import can create a cycle.
store/store.pyalready reached it transitively throughconfig.settings.tests/test_dependency_boundaries.pyis the only import-graph gate in the repo;it forbids the engine packages only
fastapi,pyside6,messagefoundry.apiandmessagefoundry.console, andauth/is not in its engine-package list at all. It passes here.Importing a private name across packages already ships in this codebase --
api/app.pyimports_secure_filefromstore.store, andtransports/file.pyimports_content_matches_declaredfromparsing.sniff-- so_system_exeis left private.Tests
One per site. Each asserts
argv[0]is absolute, is stillicacls.exe, and sits in the directory_system_dir()reports.The third assertion compares against
_system_dir()rather than a literalSystem32becauseGetSystemDirectoryWanswersSysWOW64to a 32-bit process, and that is the correct systemdirectory there; a literal would red while the pin was perfectly correct.
_system_dir's ownbehaviour is tested beside it in
tests/test_service_control.py(#1132), so this does not duplicateit.
Fail-first was observed, not derived. With only the two source hunks reverted to
origin/mainand the tests unchanged, both fail:
Two further details:
that guard, a run where
subprocess.runis never reached passes over nothing, which isindistinguishable from passing.
os.name == "nt"is real, not monkeypatched, matching the existing note intests/test_store.py(forcing it makespathlibinstantiateWindowsPathand crash pytest onLinux). So they are exercised on the
windows-2022/windows-2025legs and skip on ubuntu.os.path.isabsis correct only because of that gate:posixpath.isabs("C:\\Windows\\System32\\icacls.exe")isFalse, so a version of this test thatfaked the platform on Linux would fail after the fix. Measured.
The two pre-existing assertions that pinned the bare name (
argv[0] == "icacls"and the exact-argvlist) now check the basename, leaving the pin to its own dedicated test.
Checks run
ruff check(passed),ruff format --check(passed),mypy messagefoundrystrict (no issues, 274files),
pytest tests/test_store.py tests/test_trust_anchors.py(86 passed, 1 skipped -- thePOSIX-only test, correct on Windows), and
tests/test_dependency_boundaries.py tests/test_threat_model_doc_drift.py tests/test_phi_at_rest_inventory.py tests/test_tooling_partition.py(62 passed, 89 skipped). All pre-commit hooks passed at commit,bandit included.
Because
_system_exeis not onmain, the local run neededmessagefoundry/service_status.pytemporarily overlaid from #1132's branch. That overlay is notpart of this commit -- the committed diff is exactly the four files above. It is self-contained:
main's
_sc_pathhas no consumer outsideservice_status.pyitself, and main'sservice.pydoesnot import
service_status.Legs a local run cannot report, which must be read here after the fresh run:
test (windows-2022, py3.14)andtest (windows-2025, py3.14)-- the new tests are Windows-gated and skipon ubuntu, so those two legs are the only ones that execute them. Also
repo-scan(bandit, semgrep,crypto-inventory, forbidden-content), CodeQL, and
diff-coverage.A full-suite run was attempted and is not being reported as a result: the tree was rebased
underneath it mid-run, which produced one unrelated
ImportErrorfailure intests/test_audit_offbox_tee.py. That is an artifact of the rebase, not a finding. The CI legs arethe authority.
Three deliberate non-changes
# nosec B603 B607is left intact at both sites, thoughB607is now redundant. Measuredwith a positive control: with both codes removed, bandit reports exactly
B603(
subprocess_without_shell_equals_true) and noB607, so pinning the path does eliminate thepartial-path finding. It is left because the vault's
docs/security/THREAT-MODEL.md:263statesthe annotation verbatim as
# nosec B603 B607, and the doc-drift test is inert in publiccheckouts (
docs/security/**is withheld), so nothing in CI would catch that doc going stale.Trading dead text for silent drift in the security record is the worse outcome.
service_status.pyis not touched at all, including its module docstring, which enumeratesits importers as
messagefoundry.configandmessagefoundry.api. There are now six, and fix(service): pin every elevated program to an absolute system path (BACKLOG #1680) #1132's_system_exedocstring likewise enumerates its callers as "this module andmessagefoundry.service" when this change adds two more. Both are completeness claims thatSDS-3.6 would rather see as "at least". They are left alone because any edit to those lines
conflicts with fix(service): pin every elevated program to an absolute system path (BACKLOG #1680) #1132 (measured), and because a four-file diff cannot accidentally land another
session's work. Widening them belongs to fix(service): pin every elevated program to an absolute system path (BACKLOG #1680) #1132's author or a follow-up.
The vault
THREAT-MODEL.mdrow is not updated here -- separate repository. Its subprocessinventory already lists both
icaclssites, and its control column ("list-form argv withshell=Falseand fixed/validated arguments") stays true; this change makes it understated, notfalse. That update and the B607 removal belong together as one vault change covering these two
sites and fix(service): pin every elevated program to an absolute system path (BACKLOG #1680) #1132's
sc/cmd/netsites, which carry the same now-redundant annotation.