Skip to content

bug(deploy): the husk predicate follows symlinks, and #17339 makes it run as root on every host #17362

Closed

Description

@mrveiss

Problem

venv_provenance.is_provenance_husk classifies by Path.is_dir() and Path.iterdir(), both of which follow symlinks:

if not dist_info.is_dir():
    return False
...
contents = [entry.name for entry in dist_info.iterdir()]
return contents == [PROVENANCE_MARKER_FILENAME]

So a symlink named *.dist-info pointing at a directory whose sole entry is a file called AUTOBOT_PROVENANCE is classified as a husk, and clear_provenance_husks then runs:

(dist_info / PROVENANCE_MARKER_FILENAME).unlink()   # unlinks THROUGH the link, in the target dir
dist_info.rmdir()                                    # fails on a symlink, leaving the link behind

The rmdir fails (a symlink is not an empty directory), so the link survives — but the file inside the target directory is already gone. The predicate's whole promise is that it "cannot reach a real installation, an operator's files, or another tool's"; through a link, it can.

Why this is worth recording now rather than when it was written

It is inherited from #17332 and it is theoretical — nothing in this codebase creates such a symlink, and an attacker who can write into site-packages has easier paths. What changed is the blast radius:

Same defect, three orders of magnitude more occasions to meet one.

Acceptance criteria

  • is_provenance_husk refuses a dist-info that is a symlink — Path.is_dir(follow_symlinks=False) on 3.13+, or os.path.islink first
  • The marker unlink refuses to follow a link out of the directory it is clearing
  • A test plants a *.dist-info symlink whose target holds a lone AUTOBOT_PROVENANCE file and asserts the target file survives
  • The narrow-deletion docstring says so, since it currently promises a property the code does not have

Notes

Found during #17339's review (comment 5807391532). Not fixed there because widening a PR that makes a deletion reachable into one that also changes what the deletion matches is two different risks in one review — and the second wants its own test, not a line in someone else's.

Activity

  1. mrveiss commented on Sep 24, 2026

    @mrveiss
    OwnerAuthor

    Delivered in PR #17398, squash-merged as 5dcdb18cec. Verified against merged main via git show origin/main:<path>, criterion by criterion.

    AC Status Evidence on main @ 5dcdb18cec
    is_provenance_husk refuses a dist-info that is a symlink met venv_provenance.py:167 — if dist_info.is_symlink() or not dist_info.is_dir(): return False. The symlink test comes first, so it is decided before is_dir() can follow the link and answer True.
    the marker unlink refuses to follow a link out of the directory it is clearing met the refusal above is upstream of the clearer, so a symlinked dist-info is never classified as a husk and the unlink is never reached for one.
    a test plants a *.dist-info symlink whose target holds a lone AUTOBOT_PROVENANCE file and asserts the target file survives met test_a_symlinked_dist_info_is_not_a_husk (test_venv_provenance_husk_17332.py:326) — link.symlink_to(outside, target_is_directory=True), then asserts the predicate is False, that the target file still exists ("the husk clearer deleted a file through a symlink, outside the directory it was clearing"), and that the link itself is left alone rather than half-removed.
    the narrow-deletion docstring says so met the docstring now states the property and why it is checked rather than assumed, naming the escalation that made it load-bearing: #17339 made this predicate run as root on every provisioned host, in three venvs.

    The detail worth keeping

    The third assertion — assert link.is_symlink() — is the one that would be easy to leave out, and it tests a different failure from the other two. Without it, an implementation that refused the husk and removed the link would still pass: the target file survives, the predicate returns False, and a half-removed link is left behind. Three assertions, three distinct ways to get this wrong.

    And the docstring criterion is not cosmetic here. The old text promised narrow deletion, the code did not deliver it, and the gap only became dangerous when #17339 changed who runs it and how often — root, every host, three venvs. A promise that was merely optimistic became a promise that could delete files outside site-packages.

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions