Skip to content

fix: recognize stdlib modules across all supported Python versions (closes #311) - #313

Open
feiiiiii5 wants to merge 2 commits into
trailofbits:masterfrom
feiiiiii5:fix-stdlib-version-check
Open

fix: recognize stdlib modules across all supported Python versions (closes #311)#313
feiiiiii5 wants to merge 2 commits into
trailofbits:masterfrom
feiiiiii5:fix-stdlib-version-check

Conversation

@feiiiiii5

@feiiiiii5 feiiiiii5 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Root cause

BUILTIN_STDLIB_MODULE_NAMES was sys.stdlib_module_names of the interpreter running the scan. A pickle scanned in CI on one Python version is often loaded on another, and the verdict flips depending on which side of a version boundary each interpreter sits:

  • Scanner older than loader: modules added in newer releases (tomllib, annotationlib, …) get flagged as non-standard even though they are perfectly standard on the target.
  • Scanner newer than loader: modules removed upstream (imp, asynchat, smtpd, …) pass the scan, then on modern interpreters resolve to an ImportError — or to a malicious PyPI distribution squatting the removed name.

Approach

Checked-in generated table rather than the stdlib_list dependency (option 1 from the issue): fickling is a security tool whose only runtime dependency is conditional typing-extensions, the check only ever consults top-level names (partition(".")[0]), and a checked-in table makes scans deterministic across environments.

fickling/stdlib_names.py (generated, do not edit):

  • STDLIB_MODULE_NAMES_BY_VERSION — real sys.stdlib_module_names captured per supported interpreter (3.10–3.14) by scripts/generate_stdlib_names.py.
  • STDLIB_MODULE_NAMES — union of every table plus the running interpreter's names, so a scanner on a Python newer than this table can never miss newly-added modules.
  • SHADOWED_STDLIB_MODULE_NAMES — public top-level names present in any supported release but absent from the newest one: the removable "dead batteries" (asynchat, imp, cgi, …) plus names like binhex/spwd/nis that a manual list tends to miss. Derived from the tables, not hand-maintained.

Shadowing defense. The default union deliberately stays permissive for benign pickles, but imports of shadowable names are no longer silently trusted:

  • New ShadowedStdlibImports analysis reports them at SUSPICIOUS ("removed from the stdlib in a recent version; on modern interpreters this name resolves to a third-party package of the same name").
  • They are excluded from likely_safe_imports.
  • fickling.fickle.SHADOWED_STDLIB_IMPORT_ALLOWLIST remains the user extension point for scanners that know a name is safe on their targets.

User control over the target version (the issue's second ask): stdlib_names.use_stdlib_of(*versions) narrows the effective stdlib set to specific interpreters' tables — which also silences shadow warnings for modules the selected target genuinely ships; no arguments restores the default union. Unknown versions raise KeyError.

Regeneration

When the support matrix changes:

uv run --no-project python scripts/generate_stdlib_names.py

Documented residual risk

No static analysis can distinguish "stdlib name" from "PyPI squatter of a formerly-stdlib name" without knowing the target version. That trade-off — default permissive union + loud-but-not-fatal shadow warnings + opt-in targeting — is documented in the module docstring.

Verification

On a 3.11 scanner:

from fickling.fickle import is_std_module, is_shadowed_stdlib_module
assert is_std_module("annotationlib")        # added in 3.14
assert is_std_module("asynchat")             # removed in 3.12 → still stdlib somewhere in the matrix
assert is_shadowed_stdlib_module("asynchat") # …but flagged as shadowable
assert not is_std_module("definitely_not_a_module_xyz")

test/test_stdlib_names.py (14 cases): cross-version recognition independent of scanner version, provenance sanity checks against the per-version tables, union coverage, running-interpreter inclusion, narrowing + restore round-trip, unknown-version rejection, shadow flagging / target-aware silencing / allowlist restore. Full suite: 128 passed / 127 subtests (torch-dependent polyglot/hook/pytorch/unpickler modules excluded locally; they need the torch extra).

Fixes #311

Note: this PR was reworked from an earlier static-list draft to generated per-version data after re-auditing issue #311's requirements — the hand-curated module lists could drift from reality and missed several genuinely removed modules.

@feiiiiii5
feiiiiii5 requested a review from ESultanik as a code owner July 29, 2026 18:20
@thomas-chauchefoin-tob

Copy link
Copy Markdown
Collaborator

Hey! I feel like the related issue is not particularly hard to implement; it's more that there are a few edge cases we'd need to think about first (e.g., PyPI dependencies with the same name as a previous stdlib module that are definitely not stdlib anymore, how to maintain the list of extra modules, etc.). I won't merge it as-is, but I'll be happy to discuss these points with you if you want to spend more time on this one.

@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Thanks @thomas-chauchefoin-tob for taking a look — happy to dig into the edge cases. I see two concrete concerns; here's how I'd approach each, and I'd value your steer on which direction matches the project's taste.

1. PyPI packages shadowing former stdlib module names

The risk: a pickle references a module name that used to be stdlib (e.g. imp, cgi, distutils) but is now PyPI-installable, so an attacker could ship a malicious PyPI package with that name and have it treated as a safe stdlib module by our check.

Options I can see:

  • (a) Conservative — treat removed-from-stdlib names as unsafe by default. Build the static list from the intersection of stdlib module names across 3.10–3.13 (modules present in every supported version), not the union. Anything removed in some version (imp, cgi, distutils, ...) falls back to the unsafe path. This is a breaking change for anyone whose pickles legitimately import those, but fickling's threat model favors fail-closed.
  • (b) Two-tier list — current stdlib vs. legacy stdlib. Keep the union, but split it into CURRENT_STDLIB (present in latest 3.13) and LEGACY_STDLIB (removed in some supported version). Legacy names get a warning-by-default and an opt-in allowlist. This preserves compatibility while surfacing the risk.
  • (c) Path-based verification at analysis time. When fickling is run in an environment that actually has the module installed, check getattr(sys.modules.get(name), '__file__', '') to confirm it resolves under the stdlib path, not site-packages. Only works at runtime, not in static analysis, so probably an additional signal rather than a replacement.

My lean is (b) — it matches the issue's "document the defaults and the associated risks" suggestion without silently widening the blast radius. But (a) is defensible if you want the simpler mental model.

2. Maintaining the list as Python evolves

  • (a) CI guard against drift. Add a test that runs on each supported Python version and asserts sys.stdlib_module_names - OUR_STATIC_LIST is empty (or only contains a documented allowlist of version-specific modules). New stdlib modules in a future Python release would fail CI, prompting a manual review + update. Low overhead, no runtime dependency.
  • (b) stdlib_list dependency. As the issue suggests, pull from the stdlib_list PyPI package (covers submodules too, per-version). Trade-off: new runtime dependency for fickling, which currently has none. Might be worth it for submodule coverage alone.
  • (c) Build-time generation. Generate the static list at release time from sys.stdlib_module_names across versions, check it in. Combines with (a) for the generation trigger.

My lean is (a) + (c): keep the checked-in static list (no new runtime dep), add a CI drift test so the list can't silently go stale.

Proposed next step

If (1b) + (2a)+(2c) sounds like the right shape, I'll rework the PR to:

  1. Split the static list into CURRENT_STDLIB / LEGACY_STDLIB with legacy treated as warn-by-default.
  2. Add a CI test asserting no drift against sys.stdlib_module_names on each supported version.
  3. Document the threat model + how users override the legacy allowlist.

Happy to go a different direction if you'd prefer — just want to make sure I'm solving the right problem before reworking the diff.

@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Reworked per the shape I proposed on 07-30, addressing both edge cases you raised (commit b8abeb3):

1. PyPI packages shadowing former stdlib names — now reported, not trusted

  • New SHADOWED_STDLIB_MODULE_NAMES (the 20 modules removed in 3.12/3.13: distutils, imp, cgi, aifc, asynchat, ...). They stay in BUILTIN_STDLIB_MODULE_NAMES so classification remains version-independent, but a new ShadowedStdlibImports analysis reports their imports as SUSPICIOUS instead of silently treating them as safe — a third-party package of the same name can execute arbitrary code on modern interpreters.
  • New SHADOWED_STDLIB_IMPORT_ALLOWLIST (documented, empty by default) lets users restore the old behavior only when they know the target runtime still ships the module.
  • Modules already in UNSAFE_IMPORTS (imp, distutils, telnetlib) keep their stricter existing classification; the new analysis covers the rest (cgi, audioop, smtpd, ...).

2. Maintaining the list as Python evolves — CI drift guard

  • New test_no_drift_from_runtime_stdlib asserts sys.stdlib_module_names - BUILTIN_STDLIB_MODULE_NAMES == {} on every CI Python (3.10-3.14). A future version adding a new stdlib module fails CI until the table is updated — no silent version-dependent classification.

Verification: 148/148 tests pass locally (Python 3.11 env), ruff format --check . and ruff check fickling clean with the uv.lock-pinned ruff 0.15.21 (note: the previous compact set literal actually failed ruff format --check, so this commit also reformats the table to the CI-required layout).

If you would rather have legacy modules fail even harder (option 1a — drop them from the safe set entirely) or want a CLI flag instead of the module-level allowlist, happy to flip.

@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Friendly bump @thomas-chauchefoin-tob — the rework in b8abeb3 addresses both edge cases from your 07-29 note (stdlib-shadowing now reported rather than trusted, and optional native-library fallback kept). Would value your steer on whether that matches what you had in mind so I can finalize.

@feiiiiii5

Copy link
Copy Markdown
Contributor Author

@thomas-chauchefoin-tob final ping from me on this one: the b8abeb3 rework addresses both edge cases from your 07-29 note (removed-stdlib shadowing is now reported as SUSPICIOUS rather than trusted, and the optional native-library fallback is preserved). CI is green. If this direction is close, I am happy to adjust; I will not ping again.

The stdlib-safety check keyed off sys.stdlib_module_names of the
interpreter running the scan. A pickle scanned in CI on one Python
version is often loaded on another, so modules added in newer releases
were flagged as unsafe by older scanners, and modules removed in newer
releases (imp, asynchat, smtpd, ...) passed scans only to resolve to a
malicious PyPI squatter — or fail outright — on the target.

Replace the single-version list with a checked-in union of top-level
stdlib module names across every supported Python (3.10-3.14), merged
at import time with the running interpreter's own names so a scanner
can never miss names from a release newer than this table.

The residual risk inherent to any static union — a removed module name
squatted by a malicious PyPI distribution on interpreters where it is
no longer standard — is documented in the module docstring, and
scanners that know exactly which interpreter will unpickle the file
can narrow the effective set with use_stdlib_of(*versions).

scripts/generate_stdlib_names.py regenerates the table; run it when
the support matrix changes:

    uv run --no-project python scripts/generate_stdlib_names.py

Fixes trailofbits#311
Supersedes the hand-curated static lists with data captured from real
interpreters:

- stdlib_names.py now also emits SHADOWED_STDLIB_MODULE_NAMES — public
  top-level names present in any supported release but absent from the
  newest one (3.14), i.e. the removable dead batteries plus names like
  binhex/spwd/nis the manual list missed.
- ShadowedStdlibImports analysis flags imports of these names as
  SUSPICIOUS instead of trusting them as plain stdlib; they are also
  excluded from likely_safe_imports.
- SHADOWED_STDLIB_IMPORT_ALLOWLIST stays as the user extension point.
- use_stdlib_of() targeting now silences shadow warnings for modules
  the selected target genuinely ships.

Regenerate via scripts/generate_stdlib_names.py when the support matrix
changes.
@feiiiiii5
feiiiiii5 force-pushed the fix-stdlib-version-check branch from b8abeb3 to 2c8ac5b Compare August 22, 2026 16:10
@feiiiiii5 feiiiiii5 changed the title fix(analysis): use static stdlib module list for version independence (closes #311) fix: recognize stdlib modules across all supported Python versions (closes #311) Aug 22, 2026
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.

stdlib check depends on the scanner's Python version

2 participants