馃寪 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

馃悰 fix(seed): contain metadata cleanup - #3428

Merged
gaborbernat merged 3 commits into
pypa:mainfrom
gaborbernat:security/seed-metadata-containment
Oct 17, 2026
Merged

gaborbernat merged 3 commits into
pypa:mainfrom
gaborbernat:security/seed-metadata-containment

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

The app-data seeder, the default, reseeds a path that already holds an environment: before it copies or links a seed package into site-packages, _uninstall_previous_version finds that package's existing .dist-info directory and removes what its metadata lists. The paths came from top_level.txt and RECORD inside the environment and were used as found. A blank line, a . or an absolute path in top_level.txt named site-packages or the environment root. A RECORD entry with .., or one below a symlinked directory, named a path outside the environment. Running virtualenv again over such an environment deleted all of site-packages, the whole environment including its interpreter, or files outside it, and reported success. 馃悰 Cleanup now removes an entry only when it resolves to a strict descendant of the environment root that is not an ancestor of the metadata directory, and removes a symlink as a link. Everything else is skipped with a warning. This addresses draft advisory ANT-2026-SXOEHWP7.

Package metadata supplied the removal paths

_uninstall_dist, added in #2186 so that a reseeded wheel does not leave the previous version's entry points behind, built the removal set from each stripped line of top_level.txt joined to site-packages, the .dist-info directory itself, and the first field of each RECORD line joined to the same base. It deleted every path that existed, a directory with shutil.rmtree through safe_delete and the rest with unlink. pathlib drops an empty or . segment and takes an absolute one as a new anchor, so a blank line or . named site-packages, and the environment's own path named its root. RECORD paths are relative to site-packages, and .. segments walked out of it.

Each entry is now resolved and checked before removal. Path.resolve follows symlinks and folds .., and the entry is kept only when the resolved environment root is among its parents, which excludes the root itself, and when it is not among the resolved parents of the .dist-info directory, which excludes site-packages and every directory between it and the root. An entry that fails either test is logged at warning level with the metadata file that listed it and left in place. The rest is removed as before. A RECORD entry that leaves site-packages but stays inside the environment, such as the ../../bin/pip launchers, still passes.

flowchart LR
    E[entry from top_level.txt or RECORD] --> B[before: exists, so rmtree or unlink]
    E --> L{is a symlink?}
    L -- yes --> RL[resolve the parent, keep the link name]
    L -- no --> RF[resolve the whole path]
    RL --> C{root among parents and not above the dist-info?}
    RF --> C
    C -- no --> W[warn and keep]
    C -- yes --> D[rmtree a directory, unlink the rest]
    classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000;
    classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332;
    class B bad;
    class W,D ok;
Loading

A symlink above the entry was followed

The old loop tested exists(), which follows symlinks, and then is_dir() and not is_symlink(). An entry that was itself a symlink was unlinked and its target kept, while a dangling link failed exists() and stayed. An entry below a symlinked directory, linked/keep.txt with linked pointing outside the environment, counted as a regular file, and unlink removed the file inside the link's target; a directory below such a link went to rmtree the same way.

The loop now takes is_symlink() beside exists(), so a dangling link is an entry too, and resolves a link through its parent only, entry.parent.resolve() / entry.name. The link's own location decides containment, and Path.unlink removes the link and not its target. Any other entry resolves in full, so a symlinked directory above it moves the resolved path outside the root and the entry is skipped.

sequenceDiagram
    participant C as cleanup
    participant L as site-packages/linked (symlink)
    participant O as outside directory
    rect rgb(255, 214, 214)
    C->>L: RECORD lists linked/keep.txt: exists, not a directory
    L->>O: unlink through the link: outside/keep.txt removed
    end
    rect rgb(216, 243, 220)
    C->>C: resolve: outside is not under the root, warn and keep
    C->>L: RECORD lists linked itself: unlink the link
    Note over O: target untouched
    end
Loading

Behavior changes

Cleanup skips entries that resolve outside the environment, to the environment root, to site-packages or to a directory between the two, with a warning; before, they were deleted. Launcher records inside the environment remain supported. A final symbolic link is removed without following its target, as before, and a dangling one listed by RECORD is now removed as well.

Reported by Anthropic's OSS Scanner: ANT-2026-SXOEHWP7.

@gaborbernat gaborbernat added bug security Fixes a vulnerability or hardens the supply chain, CI or release labels Oct 16, 2026
Reported by Anthropic's OSS Scanner: ANT-2026-SXOEHWP7.
A blank top_level.txt line (or a "." entry) resolves to site-packages,
which is inside the environment, so cleanup removed the whole directory
before reseeding. Entries that resolve to a parent of the dist-info
directory are now skipped with the same warning as entries outside the
environment.

The tests now also cover a RECORD entry that escapes the environment
through "..", and a RECORD-listed symlink that points outside: the link
is removed, its target is kept.
@gaborbernat
gaborbernat force-pushed the security/seed-metadata-containment branch from d0c89ee to b35b807 Compare October 17, 2026 10:16
@gaborbernat
gaborbernat marked this pull request as ready for review October 17, 2026 10:19
@gaborbernat
gaborbernat merged commit e3cea6f into pypa:main Oct 17, 2026
74 checks passed
@gaborbernat
gaborbernat deleted the security/seed-metadata-containment branch October 17, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug security Fixes a vulnerability or hardens the supply chain, CI or release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant