Skip to content

Proposal: a global hook for recording per-wheel crypto (FIPS) evidence while the wheel is unpacked #1339

Description

@EmilienM

Problem

Teams publishing a wheel index for FIPS-enabled hosts need a per-wheel answer to one
question: does this wheel use the host's OpenSSL, or does it carry its own? A wheel that
resolves libcrypto.so.3 from the host inherits the host's FIPS provider and crypto
policy. A wheel that ships or statically links its own copy does not, and no amount of
host configuration changes that.

That question is answerable only from the wheel's bytes, and fromager is the one place
those bytes are already open. add_extra_metadata_to_wheels() unpacks every wheel it
builds and already writes analysis results into .dist-info: fromager-elf-requires.txt,
fromager-elf-provides.txt, and the SPDX SBOM. Answering the question later, index-side,
means downloading and unpacking everything a second time and losing the link back to the
build settings that produced the posture in the first place.

I have been working on a scanner for this and would like to wire it up to fromager. Below
is what I measured: what works today with no fromager change, and the one hook I think is
missing.

The tool

wheel-crypto-scan, Apache-2.0. Static
analysis over a wheel. No network, no LLM, no dataflow analysis. Deterministic: same wheel
in, byte-identical JSONL out, and records carry no timestamps, host paths or hostnames, so
embedding one does not break a reproducible build.

It reports evidence, not compliance. There is no passing verdict class and there will not
be one. openssl_linkage resolves to system, bundled, static, mixed, none or
unknown, findings say what matched and where, and a human or a policy step decides what
that means.

Dependencies are pyelftools and packaging. Fromager already has both (elfdeps pulls
in pyelftools, packaging is direct), so a plugin adds no new transitive dependency.

What it finds on real wheels

Scanned today, PyPI manylinux x86_64, 7 wheels in 1.1s at --jobs 4:

Wheel What the wheel carries Why a builder cares
grpcio 1.84.0 BoringSSL, statically linked (third_party/boringssl-with-bazel/... paths plus the BoringSSL banner in read-only data) Host FIPS provider is not in the picture at all
pyzmq 27.2.0 pyzmq.libs/libsodium-1c6bac97.so.26.4.0, hash-renamed by auditwheel Bundled crypto that a vendor-directory check finds, but a dependency check alone does not
hf-xet 1.6.0 rustls, aws-lc-rs, ring, blake3 A complete alternative TLS stack in Rust. Never uses the host OpenSSL
pycryptodome 3.23.0 Its own AES, MD5 and curve25519 implementations Primitives implemented in-tree, outside any validated module
cryptography 50.0.1 OpenSSL 4.0.2 compiled into _rust.abi3.so, no DT_NEEDED on libcrypto The same version rebuilt against the system OpenSSL has the opposite posture
requests 2.34.2 hashlib.md5(..., usedforsecurity=False) Marked, so it does not raise under FIPS. Worth telling apart from the call that does

A trimmed record:

{
  "wheel": {"filename": "pyzmq-27.2.0-cp312-abi3-manylinux_2_28_x86_64.whl", "...": "..."},
  "verdict": {
    "class": "NON_APPROVED_CRYPTO",
    "conditions": {"libsodium_linkage": "bundled", "openssl_linkage": "none"},
    "reasons": ["BIN_BUNDLED_CRYPTO_LIB: libsodium", "BIN_LIBSODIUM: libsodium"]
  },
  "artifacts": {"bundled_libs": ["pyzmq.libs/libsodium-1c6bac97.so.26.4.0", "..."]}
}

The interesting part for fromager is the contrast rather than any single verdict. A
package rebuilt from source against the system OpenSSL has a different posture from the
same version on PyPI, and a scan at build time is what proves the rebuild did what the
build settings intended. That makes it a regression check (did our override quietly stop
working?) rather than a compliance stamp. Prebuilt wheels (pre_built: true) are the
other end of it: fromager never builds them, so a scan is the only thing that says what
they carry.

What works today, with no change to fromager

A plugin of about 40 lines, registered on the existing global hooks:

[project.entry-points."fromager.hooks"]
post_build = "fromager_crypto_scan:post_build"
prebuilt_wheel = "fromager_crypto_scan:prebuilt_wheel"
post_bootstrap = "fromager_crypto_scan:post_bootstrap"
def _scan(ctx, wheel_filename):
    record = scan_wheel(wheel_filename, _context())
    out_dir = pathlib.Path(ctx.work_dir) / "crypto-scan"
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / f"{wheel_filename.name}.json").write_text(to_json_line(record))
    logger.info("crypto scan %s: %s openssl_linkage=%s", wheel_filename.name,
                record["verdict"]["class"],
                record["verdict"]["conditions"].get("openssl_linkage"))

Against fromager 0.96.0, fromager bootstrap six==1.17.0:

INFO setuptools-84.0.0: crypto scan setuptools-84.0.0-0-py3-none-any.whl: CONDITIONAL openssl_linkage=none reasons=PY_INSECURE_RNG: random; PY_TLS_POLICY_OVERRIDE: ...
INFO six-1.17.0: crypto scan six-1.17.0-0-py2.py3-none-any.whl: NO_CRYPTO_DETECTED openssl_linkage=none reasons=none

One JSON record per wheel lands in work-dir/crypto-scan/. Cost is 0.26s to 0.57s per
wheel including interpreter startup (grpcio, 6.8 MiB, is the 0.57s), and wheels are read
from the zip in memory rather than extracted.

So the sidecar case needs nothing from you. Four things I ran into:

  1. No global hook fires while the wheel is unpacked. add_extra_metadata_to_wheels()
    is the only place with the tree open, and its extension point is a per-package
    override: at most one module per package, replacing the default implementation. A
    cross-cutting analyzer cannot write into .dist-info there. The alternative is
    unpacking and repacking the wheel a second time after the build, which is exactly what
    you pushed back on in Generate SBOM for wheels #908 for large wheels, and rightly.
  2. post_build does not fire for a reused wheel (_is_wheel_built() short-circuits
    it), and bootstrap runs only post_bootstrap. A plugin has to register all three hooks
    to cover the paths, and a wheel served from a previous build is still never seen.
  3. Gating from a hook means raising, which aborts the whole run rather than marking one
    package. That is fine as stop-the-line policy and wrong for "record everything, decide
    at release". Not asking for anything here, hooks are fire-and-forget by design.
  4. Package settings are extra="forbid", so per-package plugin config (say, the
    posture expected for one package) cannot live in the settings YAML. An env var covers
    it for now.

Proposal

1. Keep the scanner out of fromager. What counts as risky crypto is policy, it moves
faster than fromager releases, and you already said in #908 that you do not want fromager
working out what is inside a wheel. Agreed. Fromager only needs to offer the hook.

2. Add one global hook that runs while the wheel is unpacked, after the elfdeps and
SBOM steps and before wheel pack:

GLOBAL_HOOK_NAMES = ("post_bootstrap", "post_build", "prebuilt_wheel", "wheel_contents")

def wheel_contents(*, ctx, req, version, wheel_file, wheel_root_dir, dist_info_dir) -> None:
    ...

Plugins get the unpacked tree, may write files into .dist-info, and the wheel is still
packed exactly once. Nothing about this is crypto specific: a license scanner, the
metadata consistency checks in #1147, or the Maturin SBOM merge in #1327 would use the
same point. Happy to send the PR with docs and tests.

3. Later, a standard format. Once CycloneDX support lands (#987), the evidence can be
expressed as a CBOM in .dist-info/sboms/ and ride inside the wheel, instead of a
tool-specific JSON file.

Two things to settle if evidence gets embedded

Both measured rather than guessed.

An embedded record describes the pre-pack wheel. I simulated the flow on pyzmq: scan,
write the record into .dist-info, wheel pack --build-number 0, rescan. binaries,
findings and verdict come back identical. What changes is identity: filename (build
tag), sha256, size_bytes, record_entries, total_uncompressed_bytes. So an embedded
document cannot carry the final wheel's digest. It either names its input explicitly or
leaves identity out, and a sidecar written after packing is what carries the digest. That
is the same split #1238 proposes for attestations, and I would keep it: evidence inside
the wheel, digest-bearing record beside it.

An SBOM directory is a statement, not a scratchpad. My scanner treats a component
declared in .dist-info/sboms/ as the strongest evidence available, on the grounds that a
build tool wrote it rather than anyone inferring it. Write inferred evidence there in
CycloneDX form and the next scan reads its own guess back as a declaration. Anything
embedded needs a tool marker that readers skip. Related, and my bug to fix rather than
yours: fromager's SPDX document uses packages, which CycloneDX-only readers (mine
included, today) skip in silence. Worth knowing for anyone who assumes an SBOM is an SBOM.

Next steps

  • If wheel_contents sounds right, I will open a PR with the hook, docs and tests. Tell me
    what you want it called and whether the signature above is the shape you would take.
  • I will publish the plugin (the three existing hooks, sidecar JSON per wheel) as a
    separate package. Happy to put it under python-wheel-build instead if you would rather
    it lived near fromager.
  • Open question: sidecar only, embedded only, or both? My preference is both, for the
    reasons above, but the sidecar alone is genuinely useful and costs you nothing.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions