From 098b9c3ac636457f0410d6f5d02e56d2ce8a1edc Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 13 Jul 2026 10:54:24 +1000 Subject: [PATCH 1/8] Correct remote host --- release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release.sh b/release.sh index 761c816..6b99fbd 100755 --- a/release.sh +++ b/release.sh @@ -46,7 +46,7 @@ sudo apt-get -y install createrepo-c rpm BUILD_OUTPUT="${HOME}/git/enroll/dist" KEYID="54A91143AE0AB4F7743B01FE888ED1B423A3BC99" REPO_ROOT="${HOME}/git/repo_rpm" -REMOTE="letessier.mig5.net:/opt/repo_rpm" +REMOTE="ashpool.mig5.net:/opt/repo_rpm" DISTS=( fedora:43 From 82db7a7d72e84716025fd9bc5f22b10a14fdb592 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 24 Jul 2026 10:57:46 +1000 Subject: [PATCH 2/8] Remove Fediverse link --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index ff70f4c..b72facf 100644 --- a/README.md +++ b/README.md @@ -364,9 +364,7 @@ poetry run enroll --help My Forgejo doesn't currently support federation, so I haven't opened registration/login for issues. -Instead, email me (see `pyproject.toml`) or contact me on the Fediverse: - -https://goto.mig5.net/@mig5 +Instead, email me (see `pyproject.toml`). --- From 1e9806d2dcc6440830f15bb37f9bb086e69cfb99 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 3 Aug 2026 15:29:17 +1000 Subject: [PATCH 3/8] Fix a TOCTOU in remote harvest zipapp Promote uploaded zipapps into a private root-owned directory before verification and execution. Copy and hash through the same pinned file descriptor, reject unsafe file types and metadata, publish atomically, and ensure sudo executes only the verified root-owned copy. --- CHANGELOG.md | 4 + debian/changelog | 6 + enroll/remote.py | 292 +++++++++++++++-- rpm/enroll.spec | 4 +- tests/test_remote.py | 57 +++- tests/test_remote_pyz_promotion.py | 508 +++++++++++++++++++++++++++++ 6 files changed, 831 insertions(+), 40 deletions(-) create mode 100644 tests/test_remote_pyz_promotion.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b8757e..ee0be80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.8.1 + + * Security: fix a TOCTOU in remote harvest. + # 0.8.0 * Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable. diff --git a/debian/changelog b/debian/changelog index 7afa14d..dbbc03c 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +enroll (0.8.1) unstable; urgency=medium + + * Security: fix a TOCTOU in remote harvest. + + -- Miguel Jacq Mon, 03 Aug 2026 15:20:00 +1000 + enroll (0.8.0) unstable; urgency=medium * Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable. diff --git a/enroll/remote.py b/enroll/remote.py index 4b936da..83006d9 100644 --- a/enroll/remote.py +++ b/enroll/remote.py @@ -300,8 +300,9 @@ def _build_enroll_pyz(tmpdir: Path) -> tuple[Path, str]: Returns ``(pyz_path, sha256_hex)``. The digest is computed on the exact bytes written locally so the caller can verify, on the remote side, that the - file that is about to be executed as root is byte-for-byte the one we built - (see ``_remote_verify_pyz_sha256``). This is transport/staging integrity + file that is executed is byte-for-byte the one we built (see + ``_remote_promote_verified_pyz`` and ``_remote_verify_pyz_sha256``). This is + transport/staging integrity defence-in-depth: it detects a swap of the staged file between upload and execution by anyone who gained write access to the staging directory. It is NOT a defence against a remote host that is already root-compromised -- such @@ -461,6 +462,243 @@ def _remote_file_sha256_sudo( return digest +_REMOTE_PROMOTE_PYZ_SCRIPT = r"""import hashlib +import os +import stat +import sys + +def _promote(argv): + ( + stage_dir, + source_name, + root_dir, + destination_name, + expected, + size_text, + owner_uid_text, + ) = argv + expected_size = int(size_text) + expected_owner_uid = int(owner_uid_text) + + for required_flag in ("O_DIRECTORY", "O_NOFOLLOW", "O_NONBLOCK"): + if not hasattr(os, required_flag): + raise RuntimeError( + "remote platform lacks required safe-open flag " + required_flag + ) + + if os.geteuid() != expected_owner_uid: + raise RuntimeError("promotion helper is not running as the expected user") + if not source_name or source_name in {".", ".."} or "/" in source_name: + raise RuntimeError("uploaded enroll.pyz name is not a single path component") + + no_follow = os.O_NOFOLLOW + cloexec = getattr(os, "O_CLOEXEC", 0) + dir_flags = os.O_RDONLY | os.O_DIRECTORY | no_follow | cloexec + stage_fd = None + root_fd = None + source_fd = None + destination_fd = None + temporary_name = ".enroll.pyz.tmp" + temporary_created = False + destination_published = False + promotion_complete = False + + try: + stage_fd = os.open(stage_dir, dir_flags) + root_fd = os.open(root_dir, dir_flags) + + root_stat = os.fstat(root_fd) + if not stat.S_ISDIR(root_stat.st_mode): + raise RuntimeError("destination path is not a directory") + if root_stat.st_uid != expected_owner_uid: + raise RuntimeError("destination directory has an unexpected owner") + if stat.S_IMODE(root_stat.st_mode) != 0o700: + raise RuntimeError("destination directory is not mode 0700") + + source_fd = os.open( + source_name, + os.O_RDONLY | no_follow | os.O_NONBLOCK | cloexec, + dir_fd=stage_fd, + ) + source_stat = os.fstat(source_fd) + if not stat.S_ISREG(source_stat.st_mode): + raise RuntimeError("uploaded enroll.pyz is not a regular file") + if source_stat.st_nlink != 1: + raise RuntimeError("uploaded enroll.pyz must not be hard-linked") + if source_stat.st_size != expected_size: + raise RuntimeError("uploaded enroll.pyz has an unexpected size") + + # The directory was freshly created by root, so neither name should exist. + # O_EXCL protects the temporary name. Publishing with link() instead of + # rename() also fails rather than replacing an unexpected final path. + destination_fd = os.open( + temporary_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | no_follow | cloexec, + 0o500, + dir_fd=root_fd, + ) + temporary_created = True + + digest = hashlib.sha256() + copied = 0 + while True: + # Read one byte beyond the expected length so concurrent growth cannot + # be hidden by stopping exactly at expected_size. + chunk = os.read(source_fd, min(1048576, expected_size - copied + 1)) + if not chunk: + break + copied += len(chunk) + if copied > expected_size: + raise RuntimeError("uploaded enroll.pyz grew while being copied") + digest.update(chunk) + + view = memoryview(chunk) + while view: + written = os.write(destination_fd, view) + if written <= 0: + raise RuntimeError("short write while promoting enroll.pyz") + view = view[written:] + + if copied != expected_size: + raise RuntimeError("uploaded enroll.pyz changed size while being copied") + + actual = digest.hexdigest() + if actual != expected: + raise RuntimeError( + "uploaded enroll.pyz SHA-256 mismatch: expected " + + expected + + ", received " + + actual + ) + + os.fchmod(destination_fd, 0o500) + os.fsync(destination_fd) + os.close(destination_fd) + destination_fd = None + + os.link( + temporary_name, + destination_name, + src_dir_fd=root_fd, + dst_dir_fd=root_fd, + follow_symlinks=False, + ) + destination_published = True + os.unlink(temporary_name, dir_fd=root_fd) + temporary_created = False + os.fsync(root_fd) + promotion_complete = True + finally: + for fd in (destination_fd, source_fd, stage_fd): + if fd is not None: + try: + os.close(fd) + except OSError: + pass + + # On every failed path, remove anything this invocation created before the + # directory descriptor is closed. This leaves no executable partial copy. + if root_fd is not None and not promotion_complete: + if destination_published: + try: + os.unlink(destination_name, dir_fd=root_fd) + except OSError: + pass + if temporary_created: + try: + os.unlink(temporary_name, dir_fd=root_fd) + except OSError: + pass + + if root_fd is not None: + try: + os.close(root_fd) + except OSError: + pass + + +if __name__ == "__main__": + _promote(sys.argv[1:]) +""" + + +def _remote_promote_verified_pyz( + ssh, + uploaded_pyz_path: str, + root_tmp_dir: str, + expected_sha256: str, + expected_size: int, + *, + remote_python: str, + sudo_password: Optional[str], +) -> str: + """Copy an uploaded zipapp into root-private storage and verify it there. + + The SFTP upload initially lives in a directory controlled by the + authenticated SSH account. Hashing that path and later executing it with + sudo leaves a verify/use race: the account can replace the path after the + hash check. Instead, one privileged process opens the source safely, copies + and hashes the bytes into a private root-owned directory, and atomically + publishes the destination only after the digest and size match. + + The returned root-owned path is the only path the caller may execute with + sudo. Racing or replacing the source can therefore cause only a verified + copy of the locally built payload to be published, or a closed failure. + """ + + expected = expected_sha256.strip().lower() + if len(expected) != 64 or any(c not in "0123456789abcdef" for c in expected): + raise ValueError("expected_sha256 must be a valid SHA-256 digest") + if expected_size < 0: + raise ValueError("expected_size must not be negative") + + uploaded = PurePosixPath(uploaded_pyz_path) + root_dir = PurePosixPath(root_tmp_dir) + if not uploaded.is_absolute() or not root_dir.is_absolute(): + raise ValueError("remote promotion paths must be absolute") + + stage_dir = str(uploaded.parent) + source_name = uploaded.name + if stage_dir in {"", ".", "/"} or source_name in {"", ".", ".."}: + raise ValueError("uploaded_pyz_path must name a file inside a directory") + + destination_name = "enroll.pyz" + destination_path = str(root_dir / destination_name) + + cmd = " ".join( + shlex.quote(tok) + for tok in ( + remote_python, + "-I", + "-c", + _REMOTE_PROMOTE_PYZ_SCRIPT, + stage_dir, + source_name, + str(root_dir), + destination_name, + expected, + str(expected_size), + "0", + ) + ) + rc, out, err = _ssh_run_sudo( + ssh, + cmd, + sudo_password=sudo_password, + get_pty=True, + ) + if rc != 0: + raise RuntimeError( + "Unable to promote and verify the uploaded enroll.pyz into " + "root-private storage. Refusing to execute it.\n" + f"Command: sudo {cmd}\n" + f"Exit code: {rc}\n" + f"Stdout: {out.strip()}\n" + f"Stderr: {err.strip()}" + ) + return destination_path + + def _remote_verify_pyz_sha256( ssh, remote_pyz_path: str, @@ -468,23 +706,13 @@ def _remote_verify_pyz_sha256( *, remote_python: str, ) -> None: - """Verify the uploaded zipapp's SHA-256 on the remote before executing it. + """Verify the uploaded zipapp before same-user ``--no-sudo`` execution. - This is transport/staging integrity defence-in-depth. The check runs on the - remote, immediately before the (root) execution of the zipapp, and fails - closed if the digest does not match the bytes we built locally. It shrinks - the window in which a *non-root* tamperer who somehow gained write access to - the staging directory could swap the file between upload and execution. - - It deliberately does NOT establish trust in a root-compromised remote: a - host that is already root can forge any check it runs about itself. Per - SECURITY.md, such a host is outside Enroll's threat model. The value here is - catching accidental corruption and unprivileged-local-user staging races, - not defeating a compromised root. - - The hashing is done with Python's hashlib (already required to run the - zipapp) rather than a ``sha256sum`` binary, so it does not depend on - coreutils being present or on PATH resolution of a hashing tool. + The privileged path uses :func:`_remote_promote_verified_pyz` instead, + because hashing a user-writable pathname and later executing that pathname + as root would leave a verify/use race. This simpler check remains useful in + ``--no-sudo`` mode for detecting transfer corruption; there is no privilege + transition in that mode. """ # Hash the staged file using the same interpreter that will execute it. @@ -850,16 +1078,6 @@ def _remote_harvest( rapp = f"{rtmp}/enroll.pyz" sftp.put(str(pyz), rapp) - # Before executing the uploaded zipapp (as root, under sudo), verify - # on the remote that the staged bytes match what we built locally. - # This is staging/transport integrity defence-in-depth: it fails - # closed if the file was swapped or corrupted between upload and - # execution. It does not (and cannot) defend against a remote that - # is already root-compromised; see _remote_verify_pyz_sha256. - _remote_verify_pyz_sha256( - ssh, rapp, pyz_sha256, remote_python=remote_python - ) - if not no_sudo: # The remote zipapp is staged as the SSH user, but the harvest # itself runs as root. Root must not write its bundle under the @@ -883,8 +1101,26 @@ def _remote_harvest( ) if rc != 0: raise RuntimeError(f"Remote sudo chmod failed: {err.strip()}") + # Promote the uploaded payload into the root-private directory + # and verify the bytes during that privileged copy. From this point + # onward, execute only the immutable root-owned copy; never return + # to the SSH-user-controlled staging path. + rapp = _remote_promote_verified_pyz( + ssh, + rapp, + remote_root_tmp, + pyz_sha256, + pyz.stat().st_size, + remote_python=remote_python, + sudo_password=sudo_password, + ) rbundle = f"{remote_root_tmp}/bundle" else: + # There is no privilege transition in --no-sudo mode, but retain + # the transport/corruption check before executing as the SSH user. + _remote_verify_pyz_sha256( + ssh, rapp, pyz_sha256, remote_python=remote_python + ) rbundle = f"{rtmp}/bundle" # Run remote harvest. diff --git a/rpm/enroll.spec b/rpm/enroll.spec index b026fee..5253c80 100644 --- a/rpm/enroll.spec +++ b/rpm/enroll.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.8.0 +%global upstream_version 0.8.1 Name: enroll Version: %{upstream_version} @@ -43,6 +43,8 @@ Enroll a server's running state retrospectively into Ansible. %{_bindir}/enroll %changelog +* Mon Aug 03 2026 Miguel Jacq - %{version}-%{release} +- Security: fix a TOCTOU in remote harvest. * Mon Jul 13 2026 Miguel Jacq - %{version}-%{release} - Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable. - Security: enforce tar member limits while lazily parsing untrusted archives rather than after `TarFile.getmembers()` has already indexed the entire archive; count repeated `.` entries and cap remote compressed downloads as well. diff --git a/tests/test_remote.py b/tests/test_remote.py index 255b00f..1e5ffcc 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib import io +import shlex import tarfile import warnings from pathlib import Path @@ -9,10 +10,9 @@ from pathlib import Path import pytest -# The remote harvest now SHA-256-verifies the uploaded zipapp on the remote -# before executing it. Tests mock _build_enroll_pyz to write these fixed bytes -# and return (path, _FAKE_PYZ_SHA256); the fake SSH routers below answer the -# verification command with the same digest so the happy paths proceed. +# Sudo harvests promote and verify the upload into a root-private directory; +# no-sudo harvests retain the direct remote digest check. Tests mock the local +# builder with these fixed bytes and digest so both paths can be exercised. _FAKE_PYZ_BYTES = b"PYZ" _FAKE_PYZ_SHA256 = "d6f4e1dbf7ba69af6c798c6f6f67383c978e68f4201bf31902275ef37e6263e1" @@ -28,9 +28,16 @@ def _fake_build_enroll_pyz(td) -> tuple[Path, str]: return p, _FAKE_PYZ_SHA256 +def _is_pyz_promote_cmd(cmd: str) -> bool: + """True if *cmd* is the privileged copy-and-verify operation.""" + return ".enroll.pyz.tmp" in cmd and "os.link" in cmd + + def _is_pyz_verify_cmd(cmd: str) -> bool: - """True if *cmd* is the remote pyz SHA-256 integrity check.""" - return "hashlib.sha256" in cmd and "enroll.pyz" in cmd + """True if *cmd* is the no-sudo remote pyz SHA-256 check.""" + return ( + "hashlib.sha256" in cmd and "enroll.pyz" in cmd and not _is_pyz_promote_cmd(cmd) + ) def _is_remote_uid_cmd(cmd: str) -> bool: @@ -193,7 +200,9 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch): return self._sftp def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs): - # Integrity check of the uploaded pyz (added with SHA-256 verify). + if _is_pyz_promote_cmd(cmd): + calls.append((cmd, bool(get_pty))) + return (None, _Stdout(b""), _Stderr()) if _is_pyz_verify_cmd(cmd): return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr()) calls.append((cmd, bool(get_pty))) @@ -286,6 +295,26 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch): assert "chown -- 1000 /tmp/enroll-root-123/bundle.tgz" in joined assert "chmod 0711 -- /tmp/enroll-root-123" in joined + # The upload is promoted and verified before harvest, and sudo executes + # only the root-owned copy. A later replacement of the SSH-user path can no + # longer alter the bytes that Python opens as root. + promote_i, promote_cmd = next( + (i, c) for i, (c, _pty) in enumerate(calls) if _is_pyz_promote_cmd(c) + ) + harvest_i, harvest_cmd = next( + (i, c) + for i, (c, _pty) in enumerate(calls) + if c.startswith("sudo -n") and " harvest " in c + ) + assert promote_i < harvest_i + promote_argv = shlex.split(promote_cmd) + assert "/tmp/enroll-remote-123" in promote_argv + assert "/tmp/enroll-root-123" in promote_argv + assert promote_argv.count("enroll.pyz") >= 2 + assert "/tmp/enroll-root-123/enroll.pyz" in harvest_cmd + assert "/tmp/enroll-remote-123/enroll.pyz" not in harvest_cmd + assert not any(_is_pyz_verify_cmd(c) for c, _pty in calls) + # The trusted digest must be obtained while the archive is still private, # before ownership/read access is handed to the SSH account. archive_hash_i = next( @@ -410,7 +439,9 @@ def test_remote_harvest_no_sudo_does_not_request_pty_or_chown( return self._sftp def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs): - # Integrity check of the uploaded pyz (added with SHA-256 verify). + if _is_pyz_promote_cmd(cmd): + calls.append((cmd, bool(get_pty))) + return (None, _Stdout(b""), _Stderr()) if _is_pyz_verify_cmd(cmd): return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr()) calls.append((cmd, bool(get_pty))) @@ -570,7 +601,9 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password( return self._sftp def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs): - # Integrity check of the uploaded pyz (added with SHA-256 verify). + if _is_pyz_promote_cmd(cmd): + calls.append((cmd, bool(get_pty))) + return (None, _Stdout(b""), _Stderr()) if _is_pyz_verify_cmd(cmd): return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr()) calls.append((cmd, bool(get_pty))) @@ -960,7 +993,8 @@ def test_remote_harvest_ssh_key_passphrase_retry(monkeypatch, tmp_path: Path): return self._sftp def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs): - # Integrity check of the uploaded pyz (added with SHA-256 verify). + if _is_pyz_promote_cmd(cmd): + return (None, _Stdout(b""), _Stderr()) if _is_pyz_verify_cmd(cmd): return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr()) if cmd.startswith("tar -cz -C"): @@ -1084,7 +1118,8 @@ def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive( return self._sftp def exec_command(self, cmd: str, **_kwargs): - # Integrity check of the uploaded pyz (added with SHA-256 verify). + if _is_pyz_promote_cmd(cmd): + return (None, _Stdout(b""), _Stderr()) if _is_pyz_verify_cmd(cmd): return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr()) return (_Stdout(), _Stdout(), _Stderr()) diff --git a/tests/test_remote_pyz_promotion.py b/tests/test_remote_pyz_promotion.py new file mode 100644 index 0000000..4ab6f38 --- /dev/null +++ b/tests/test_remote_pyz_promotion.py @@ -0,0 +1,508 @@ +from __future__ import annotations + +import hashlib +import os +import shlex +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +import enroll.remote as remote + + +_TRUSTED_PYZ = b"trusted enroll zipapp bytes\n" +_TRUSTED_SHA256 = hashlib.sha256(_TRUSTED_PYZ).hexdigest() + + +def _prepare_dirs(tmp_path: Path) -> tuple[Path, Path]: + stage_dir = tmp_path / "user-stage" + root_dir = tmp_path / "root-stage" + stage_dir.mkdir(mode=0o700) + root_dir.mkdir(mode=0o700) + # mkdir is affected by umask; the production flow explicitly chmods 0700. + root_dir.chmod(0o700) + return stage_dir, root_dir + + +def _load_promotion_function(): + namespace = {"__name__": "enroll_promotion_test"} + exec(remote._REMOTE_PROMOTE_PYZ_SCRIPT, namespace) + return namespace["_promote"] + + +def _promotion_args( + stage_dir: Path, + root_dir: Path, + *, + expected_sha256: str = _TRUSTED_SHA256, + expected_size: int = len(_TRUSTED_PYZ), + source_name: str = "enroll.pyz", + destination_name: str = "enroll.pyz", + expected_owner_uid: int | None = None, +) -> list[str]: + if expected_owner_uid is None: + expected_owner_uid = os.geteuid() + return [ + str(stage_dir), + source_name, + str(root_dir), + destination_name, + expected_sha256, + str(expected_size), + str(expected_owner_uid), + ] + + +def _promote_direct( + stage_dir: Path, + root_dir: Path, + **kwargs, +) -> None: + _load_promotion_function()(_promotion_args(stage_dir, root_dir, **kwargs)) + + +def _run_promotion_subprocess( + stage_dir: Path, + root_dir: Path, + *, + timeout: float = 5, + **kwargs, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + "-I", + "-c", + remote._REMOTE_PROMOTE_PYZ_SCRIPT, + *_promotion_args(stage_dir, root_dir, **kwargs), + ], + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def _assert_no_promoted_copy(root_dir: Path) -> None: + assert not (root_dir / "enroll.pyz").exists() + assert not (root_dir / ".enroll.pyz.tmp").exists() + + +def test_promotion_script_entrypoint_publishes_exact_private_copy(tmp_path: Path): + stage_dir, root_dir = _prepare_dirs(tmp_path) + (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) + + result = _run_promotion_subprocess(stage_dir, root_dir) + + assert result.returncode == 0, result.stderr + promoted = root_dir / "enroll.pyz" + assert promoted.read_bytes() == _TRUSTED_PYZ + promoted_stat = promoted.stat() + assert stat.S_IMODE(promoted_stat.st_mode) == 0o500 + assert promoted_stat.st_nlink == 1 + assert not (root_dir / ".enroll.pyz.tmp").exists() + + +def test_replacing_user_path_after_promotion_cannot_change_root_copy(tmp_path: Path): + stage_dir, root_dir = _prepare_dirs(tmp_path) + source = stage_dir / "enroll.pyz" + source.write_bytes(_TRUSTED_PYZ) + + _promote_direct(stage_dir, root_dir) + + replacement = stage_dir / "replacement.pyz" + replacement.write_bytes(b"print('attacker code')\n") + os.replace(replacement, source) + + assert source.read_bytes() != _TRUSTED_PYZ + assert (root_dir / "enroll.pyz").read_bytes() == _TRUSTED_PYZ + + +def test_digest_mismatch_fails_closed_and_removes_partial_copy(tmp_path: Path): + stage_dir, root_dir = _prepare_dirs(tmp_path) + payload = b"attacker-controlled bytes" + (stage_dir / "enroll.pyz").write_bytes(payload) + + with pytest.raises(RuntimeError, match="SHA-256 mismatch"): + _promote_direct(stage_dir, root_dir, expected_size=len(payload)) + + _assert_no_promoted_copy(root_dir) + + +@pytest.mark.parametrize( + "reported_size", [0, len(_TRUSTED_PYZ) - 1, len(_TRUSTED_PYZ) + 1] +) +def test_unexpected_source_size_fails_before_publication( + tmp_path: Path, reported_size: int +): + stage_dir, root_dir = _prepare_dirs(tmp_path) + (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) + + with pytest.raises(RuntimeError, match="unexpected size"): + _promote_direct(stage_dir, root_dir, expected_size=reported_size) + + _assert_no_promoted_copy(root_dir) + + +def test_source_symlink_is_rejected_without_following_it(tmp_path: Path): + stage_dir, root_dir = _prepare_dirs(tmp_path) + target = stage_dir / "payload" + target.write_bytes(_TRUSTED_PYZ) + (stage_dir / "enroll.pyz").symlink_to(target.name) + + with pytest.raises(OSError): + _promote_direct(stage_dir, root_dir) + + _assert_no_promoted_copy(root_dir) + + +def test_hardlinked_source_is_rejected(tmp_path: Path): + stage_dir, root_dir = _prepare_dirs(tmp_path) + source = stage_dir / "enroll.pyz" + source.write_bytes(_TRUSTED_PYZ) + os.link(source, stage_dir / "second-name") + + with pytest.raises(RuntimeError, match="must not be hard-linked"): + _promote_direct(stage_dir, root_dir) + + _assert_no_promoted_copy(root_dir) + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="FIFO test requires POSIX") +def test_fifo_source_is_rejected_without_blocking(tmp_path: Path): + stage_dir, root_dir = _prepare_dirs(tmp_path) + os.mkfifo(stage_dir / "enroll.pyz", mode=0o600) + + result = _run_promotion_subprocess( + stage_dir, + root_dir, + expected_sha256=hashlib.sha256(b"").hexdigest(), + expected_size=0, + timeout=2, + ) + + assert result.returncode != 0 + assert "not a regular file" in result.stderr + _assert_no_promoted_copy(root_dir) + + +def test_directory_source_is_rejected(tmp_path: Path): + stage_dir, root_dir = _prepare_dirs(tmp_path) + (stage_dir / "enroll.pyz").mkdir() + + with pytest.raises(RuntimeError, match="not a regular file"): + _promote_direct(stage_dir, root_dir) + + _assert_no_promoted_copy(root_dir) + + +def test_symlink_destination_directory_is_rejected(tmp_path: Path): + stage_dir = tmp_path / "user-stage" + stage_dir.mkdir() + (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) + real_root = tmp_path / "real-root" + real_root.mkdir(mode=0o700) + real_root.chmod(0o700) + root_link = tmp_path / "root-link" + root_link.symlink_to(real_root, target_is_directory=True) + + with pytest.raises(OSError): + _promote_direct(stage_dir, root_link) + + _assert_no_promoted_copy(real_root) + + +def test_non_private_destination_directory_is_rejected(tmp_path: Path): + stage_dir, root_dir = _prepare_dirs(tmp_path) + (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) + root_dir.chmod(0o755) + + with pytest.raises(RuntimeError, match="not mode 0700"): + _promote_direct(stage_dir, root_dir) + + _assert_no_promoted_copy(root_dir) + + +def test_promotion_requires_expected_effective_uid(tmp_path: Path): + stage_dir, root_dir = _prepare_dirs(tmp_path) + (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) + + with pytest.raises(RuntimeError, match="not running as the expected user"): + _promote_direct( + stage_dir, + root_dir, + expected_owner_uid=os.geteuid() + 1, + ) + + _assert_no_promoted_copy(root_dir) + + +def test_existing_temporary_name_is_not_overwritten(tmp_path: Path): + stage_dir, root_dir = _prepare_dirs(tmp_path) + (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) + temporary = root_dir / ".enroll.pyz.tmp" + temporary.write_bytes(b"pre-existing") + + with pytest.raises(FileExistsError): + _promote_direct(stage_dir, root_dir) + + assert temporary.read_bytes() == b"pre-existing" + assert not (root_dir / "enroll.pyz").exists() + + +def test_existing_final_name_is_not_replaced_and_partial_copy_is_removed( + tmp_path: Path, +): + stage_dir, root_dir = _prepare_dirs(tmp_path) + (stage_dir / "enroll.pyz").write_bytes(_TRUSTED_PYZ) + final = root_dir / "enroll.pyz" + final.write_bytes(b"pre-existing") + + with pytest.raises(FileExistsError): + _promote_direct(stage_dir, root_dir) + + assert final.read_bytes() == b"pre-existing" + assert not (root_dir / ".enroll.pyz.tmp").exists() + + +def test_path_replacement_after_open_cannot_change_promoted_inode( + tmp_path: Path, monkeypatch +): + stage_dir, root_dir = _prepare_dirs(tmp_path) + source = stage_dir / "enroll.pyz" + source.write_bytes(_TRUSTED_PYZ) + replacement = stage_dir / "attacker.pyz" + replacement.write_bytes(b"print('attacker code')\n") + + promote = _load_promotion_function() + real_read = os.read + replaced = False + + def replace_path_before_first_read(fd: int, count: int) -> bytes: + nonlocal replaced + if not replaced: + replaced = True + os.replace(replacement, source) + return real_read(fd, count) + + monkeypatch.setattr(os, "read", replace_path_before_first_read) + promote(_promotion_args(stage_dir, root_dir)) + + assert replaced is True + assert source.read_bytes() != _TRUSTED_PYZ + assert (root_dir / "enroll.pyz").read_bytes() == _TRUSTED_PYZ + + +def test_in_place_mutation_after_fstat_fails_digest_and_cleans_output( + tmp_path: Path, monkeypatch +): + stage_dir, root_dir = _prepare_dirs(tmp_path) + source = stage_dir / "enroll.pyz" + source.write_bytes(_TRUSTED_PYZ) + + promote = _load_promotion_function() + real_read = os.read + attacker_fd = os.open(source, os.O_WRONLY) + mutated = False + + def mutate_inode_before_first_read(fd: int, count: int) -> bytes: + nonlocal mutated + if not mutated: + mutated = True + os.lseek(attacker_fd, 0, os.SEEK_SET) + os.write(attacker_fd, b"X" * len(_TRUSTED_PYZ)) + os.fsync(attacker_fd) + return real_read(fd, count) + + monkeypatch.setattr(os, "read", mutate_inode_before_first_read) + try: + with pytest.raises(RuntimeError, match="SHA-256 mismatch"): + promote(_promotion_args(stage_dir, root_dir)) + finally: + os.close(attacker_fd) + + assert mutated is True + _assert_no_promoted_copy(root_dir) + + +def test_remote_promote_builds_one_privileged_atomic_command(monkeypatch): + captured: dict[str, object] = {} + + def fake_ssh_run_sudo(ssh, cmd, *, sudo_password, get_pty): + captured.update( + ssh=ssh, + cmd=cmd, + sudo_password=sudo_password, + get_pty=get_pty, + ) + return 0, "", "" + + monkeypatch.setattr(remote, "_ssh_run_sudo", fake_ssh_run_sudo) + marker = object() + + result = remote._remote_promote_verified_pyz( + marker, + "/tmp/user-stage/enroll.pyz", + "/tmp/root-stage", + _TRUSTED_SHA256, + len(_TRUSTED_PYZ), + remote_python="/usr/bin/python3", + sudo_password="secret", + ) + + assert result == "/tmp/root-stage/enroll.pyz" + assert captured["ssh"] is marker + assert captured["sudo_password"] == "secret" + assert captured["get_pty"] is True + argv = shlex.split(str(captured["cmd"])) + assert argv == [ + "/usr/bin/python3", + "-I", + "-c", + remote._REMOTE_PROMOTE_PYZ_SCRIPT, + "/tmp/user-stage", + "enroll.pyz", + "/tmp/root-stage", + "enroll.pyz", + _TRUSTED_SHA256, + str(len(_TRUSTED_PYZ)), + "0", + ] + + +@pytest.mark.parametrize( + ("uploaded", "root_dir", "digest", "size"), + [ + ("relative/enroll.pyz", "/tmp/root", _TRUSTED_SHA256, len(_TRUSTED_PYZ)), + ("/tmp/stage/enroll.pyz", "relative/root", _TRUSTED_SHA256, len(_TRUSTED_PYZ)), + ("/tmp/stage/enroll.pyz", "/tmp/root", "not-a-digest", len(_TRUSTED_PYZ)), + ("/tmp/stage/enroll.pyz", "/tmp/root", _TRUSTED_SHA256, -1), + ], +) +def test_remote_promote_rejects_invalid_local_parameters_before_sudo( + monkeypatch, uploaded: str, root_dir: str, digest: str, size: int +): + called = False + + def unexpected_sudo(*_args, **_kwargs): + nonlocal called + called = True + raise AssertionError("sudo must not run") + + monkeypatch.setattr(remote, "_ssh_run_sudo", unexpected_sudo) + + with pytest.raises(ValueError): + remote._remote_promote_verified_pyz( + object(), + uploaded, + root_dir, + digest, + size, + remote_python="python3", + sudo_password=None, + ) + assert called is False + + +def test_remote_promote_reports_privileged_failure(monkeypatch): + monkeypatch.setattr( + remote, + "_ssh_run_sudo", + lambda *_args, **_kwargs: (1, "", "digest mismatch"), + ) + + with pytest.raises(RuntimeError, match="Refusing to execute"): + remote._remote_promote_verified_pyz( + object(), + "/tmp/stage/enroll.pyz", + "/tmp/root", + _TRUSTED_SHA256, + len(_TRUSTED_PYZ), + remote_python="python3", + sudo_password=None, + ) + + +def test_sudo_harvest_stops_before_execution_when_promotion_fails( + tmp_path: Path, monkeypatch +): + import types + + # The fake builder must create the file because _remote_harvest reads its size. + def build_pyz(td): + pyz = Path(td) / "enroll.pyz" + pyz.write_bytes(_TRUSTED_PYZ) + return pyz, _TRUSTED_SHA256 + + monkeypatch.setattr(remote, "_build_enroll_pyz", build_pyz) + monkeypatch.setattr( + remote, + "_remote_promote_verified_pyz", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("promotion rejected attacker bytes") + ), + ) + + user_commands: list[str] = [] + sudo_commands: list[str] = [] + + def fake_run(_ssh, cmd, **_kwargs): + user_commands.append(cmd) + if cmd == "mktemp -d": + return 0, "/tmp/user-stage\n", "" + return 0, "", "" + + def fake_sudo(_ssh, cmd, **_kwargs): + sudo_commands.append(cmd) + if cmd == "mktemp -d": + return 0, "/tmp/root-stage\n", "" + return 0, "", "" + + monkeypatch.setattr(remote, "_ssh_run", fake_run) + monkeypatch.setattr(remote, "_ssh_run_sudo", fake_sudo) + + class FakeSFTP: + def put(self, _local, _remote): + return None + + def close(self): + return None + + class FakeSSH: + def load_system_host_keys(self): + return None + + def set_missing_host_key_policy(self, _policy): + return None + + def connect(self, **_kwargs): + return None + + def open_sftp(self): + return FakeSFTP() + + def close(self): + return None + + monkeypatch.setitem( + sys.modules, + "paramiko", + types.SimpleNamespace( + SSHClient=FakeSSH, + RejectPolicy=type("RejectPolicy", (), {}), + ), + ) + + with pytest.raises(RuntimeError, match="promotion rejected attacker bytes"): + remote._remote_harvest( + local_out_dir=tmp_path / "out", + remote_host="example.com", + no_sudo=False, + ) + + assert not any(" harvest " in command for command in sudo_commands) + assert any(command == "rm -rf -- /tmp/root-stage" for command in sudo_commands) + assert any(command == "rm -rf -- /tmp/user-stage" for command in user_commands) From 51668949926ac19ee20ba3789b64f7b5fa540601 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 3 Aug 2026 15:30:20 +1000 Subject: [PATCH 4/8] 0.8.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a039b1f..a7d7c3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "enroll" -version = "0.8.0" +version = "0.8.1" description = "Enroll a server's running state retrospectively into Ansible" readme = "README.md" requires-python = ">=3.10" From 70c90c62ae5a1d0aac4b51e0487ab1b5a06839ff Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 3 Aug 2026 15:53:51 +1000 Subject: [PATCH 5/8] Bump dependencies --- CHANGELOG.md | 4 + poetry.lock | 284 +++++++++++++++++++++++++-------------------------- 2 files changed, 146 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0be80..c4d97be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.8.2 + + * Bump dependencies + # 0.8.1 * Security: fix a TOCTOU in remote harvest. diff --git a/poetry.lock b/poetry.lock index 765762e..3e7a821 100644 --- a/poetry.lock +++ b/poetry.lock @@ -91,14 +91,14 @@ typecheck = ["mypy"] [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, - {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] [[package]] @@ -333,103 +333,103 @@ files = [ [[package]] name = "coverage" -version = "7.15.1" +version = "7.15.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "coverage-7.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:05d87c2a43373ad6b976d0a99ad58c48633633bcdeb896dc645a006472cc4a71"}, - {file = "coverage-7.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2afce82f2cf8f4c9002746a42755e1dc61baff33d9f7ab5569b4c9101f8f4d1d"}, - {file = "coverage-7.15.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a545ef5384d787d0fcac6c349afc2c5f99dcc39e13ed3c191b2c06305f64c04"}, - {file = "coverage-7.15.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:afaa144b8f5b3bc69fe0ce50d401c46b01ab264782553bfd05a3f98804524ecb"}, - {file = "coverage-7.15.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b6099490e5f88569c46b18605f556c3b30acc9a0a219cf7ef8fab8f7161ec4"}, - {file = "coverage-7.15.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb5f6c2cf1ffd0bf2bd925c7cdcae9b4f208e9696d453ed51eb1f5fa0cc5b45b"}, - {file = "coverage-7.15.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81572011fc1fc271317da35da593944daef7bfd507085e35751abbe702b74f69"}, - {file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f765d13c08497687d0780cca66115c6aa4ba6703ad43b61e94fab9db689e3a3"}, - {file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:771caba880fee96493d18dfc465c318e08ab74e3bc2a3e4089e52514be6a6e54"}, - {file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:433a73200848e80f27712fc113b6ff5311f29b479a7d3bd4b1106138a77f9674"}, - {file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5d6fa45079db9fbeba0a69e3d91189f05301d6ac918162a53179d32fc9ed4910"}, - {file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:00b6703c6640075cdce5124e9335dfdf9167272475301828acfdd09c0e5ee731"}, - {file = "coverage-7.15.1-cp310-cp310-win32.whl", hash = "sha256:3ad9a0eac4728327fd870d52f74d2e631d176c5f178eaea2d9983ab5b9755a55"}, - {file = "coverage-7.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:eb5fa75dc3d30e3a1b75da97973479b20ffa9b0641ff56d6e94b5f3e210daa54"}, - {file = "coverage-7.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6506330b4a8dcf53b95bd84d8d0e817107cdb3fc1438e835029cdf0bc6612eb0"}, - {file = "coverage-7.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8a51a8ec382c39d939cba0ab07ae949077ae4e842343bd4eed22d432358cea9"}, - {file = "coverage-7.15.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18ea20e3922d7f8ca9e0ef1084408d08c4ad62d5e531cb9c1f6896a99297ebea"}, - {file = "coverage-7.15.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aab9902a64b8390e3b56e539fddae1d79a267807fe5cb0c18d7d2f544ce867e2"}, - {file = "coverage-7.15.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cc316264317b07a9e90d7f2b4188a15e36e9b54e651081b791b0515fa612a29"}, - {file = "coverage-7.15.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d4e47e7eea81a8ccf060a07627654151d929da62c7b715738387c200905cec89"}, - {file = "coverage-7.15.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c6829d9a3b55ad2b73ef5fda8302e5be03683789e88b1a079dcf4a773229c21d"}, - {file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:764e045811f9c8cda436641f3f088283351d331a519b5807f19041cd0a68da1c"}, - {file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:09b3b088aa24489c4082bcc35fcc8224281ab94a653dfb6d3f0c8165b0d628ab"}, - {file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7911b02f57053adf8164ae63edb1c26574d24dfccabadc5268cf69310a69a358"}, - {file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:664e279ed40599b8ed16f4db18d92a7e212c73129672bec8f5d96d4da48d2404"}, - {file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:34fe7cf79d5f1f87f2e8ce7dc1c32950841f50e10d0120f263856acfad66de34"}, - {file = "coverage-7.15.1-cp311-cp311-win32.whl", hash = "sha256:5e2d2536d2f57a354aa382ed303ac0e2e5c9522a508c05b998d26181b94163a7"}, - {file = "coverage-7.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:c337da8fca7ea93ab43f3868cfcde6cf6dad32c3906b273cfbad5d7390bc423b"}, - {file = "coverage-7.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:db3403fdb7a94d5eb73e099befad8104d2a7d110a0f0d99df0de61c5d1fa756c"}, - {file = "coverage-7.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9476292594309db922cc841dd13b303b3c388f4c25d279884f7e2341c681f80"}, - {file = "coverage-7.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c579056b0de461b3a62318b63d0b6ce90aed7f8158d3f00da094df82f29d189"}, - {file = "coverage-7.15.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:23214bdbe226f2b0e9c66a7d6a1d59d4a88045dcf86e702cf0fe0d0935e3d615"}, - {file = "coverage-7.15.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df164be93b46b4825cc39339440a05edc54c4d1d865ba4a60fd43d151a2a1cd3"}, - {file = "coverage-7.15.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a524fca1a6f08927d9dc2d4c873cfb7bd7202c247f08b14bdc02424071b8b304"}, - {file = "coverage-7.15.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d70f3542cd38de85a9e257dcb1ac4c1ab4b6d7d2c2a645809207556628755d1c"}, - {file = "coverage-7.15.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d78aa537237212c4313aabe5e964b66acc86350ed19ebc56a3e202df33b6077b"}, - {file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a318112bb4f79d9d04766196d5a3388caa825908a6a9b052aa87de3d9aea7c61"}, - {file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e55d24cada901963eed5bc89fa562aa033f0d84b9d3de4ecf363737c13aed11e"}, - {file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c78f0cea7275342cf2adc2ad5fdd0aafa106ad91e66d573568f2fcf62c41df5"}, - {file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:86bd37eabe39977216f630a7fc1b698e7f5e81a191c7186013245c6c3d313f9d"}, - {file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6db15c217693bdc3ca0b84de1ba9afafe1c14c26a8a29d77f4ed0de2b6132e2"}, - {file = "coverage-7.15.1-cp312-cp312-win32.whl", hash = "sha256:359f3fbe09a51500c51966596ee4ee4070b356552c70b3b2420eb200d68e0f76"}, - {file = "coverage-7.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:fa75dc099c126e941a9c0baa8ebd2cbc78bd778687534fe410baf754f6d9e374"}, - {file = "coverage-7.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:26f89cf6d0634375f454fa71057945ad18edb0f1607a90fecf22c57dc3dc289a"}, - {file = "coverage-7.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71ac4ca1658ca99160fd58cc6967110e989c34b04627f24ed6ec9f70fb24571a"}, - {file = "coverage-7.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26a40cbf2b13bd94af53ee02a424cb3bb96a9edfac0d00834bd068512a62714b"}, - {file = "coverage-7.15.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4c5a5eff4ad4f9f7088fd3fc7a66d98d06566ee294b3b053309fb0a3b45be1e"}, - {file = "coverage-7.15.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:962aa56c1c9b016d681265880eb6acc9966029d2c4c559319cc43a1abbb9b59a"}, - {file = "coverage-7.15.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1678eb2dc57a8ce67601b029582ef6d41e9e6ca22692aaeccd4107e40f27386c"}, - {file = "coverage-7.15.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1174900a43f6f8c425fee10d7dbddc308adefcdc78aaced32357f5ab750a0e90"}, - {file = "coverage-7.15.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98847557a6859cadf693792ce89f440cb89692993f60dc6d3a7e35f3d340216f"}, - {file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8697b2edb57143546a24389efc11e1b000cd5800fc20d84f04edb601e4a7cfb8"}, - {file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6827ac0519be3fe91bf96b4060eb00d1d24f82649b29862cd75a3cfca248b02a"}, - {file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2de8ecbbc77c7e4d22572779920ed8979c69168675e96be3a548c996568c6c31"}, - {file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b25f0f0fa5260df9d7bb55d47c8bdc23fa3382c1a18f7c9cae122e6c320b1ad"}, - {file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a2effcbd93ae340a58db718fe4181d967f84d352c4cefeaab4ff82ce813901a"}, - {file = "coverage-7.15.1-cp313-cp313-win32.whl", hash = "sha256:895e65c96aef0cecea250f6e35e9a32f11375514e1a0cb5210e0fda128c04e8e"}, - {file = "coverage-7.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6d0a28b63a0d75f9ed5118105d1154fc3aa40a8605a30d5d87e3d043ad90fe7"}, - {file = "coverage-7.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:b4ee9818e8bae3544379ad2c09b851c4fb886aaa8860d57a1c1316ddcb16db49"}, - {file = "coverage-7.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a886af95f59edf67d5770fd3564d53f4a8af93f25f8c1d60d27e00d7f5674ee8"}, - {file = "coverage-7.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985657ebd707941de90d488d1cbb5efac20bdf81f7b91eba771624ccda4d36f4"}, - {file = "coverage-7.15.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5bbe2a06e0a5e1404d9ffbdb49b819bbd6a3bb198ebea4c8dfe7ad9f1e1c2e81"}, - {file = "coverage-7.15.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde0fe24083d0b7b3dbafa7a09f0796410af1afa2523f28f5f208d8340a4aaca"}, - {file = "coverage-7.15.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f89f7453d6d46db14cf233e2cd8edcd78de2b9c49d4f1dc109590b4e5dbfbb74"}, - {file = "coverage-7.15.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc3656c9ecc27b36bd0907455b77f83c0069ca9ad4a66dec892b76c696eb6047"}, - {file = "coverage-7.15.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:24d8e85a2a45e44883b488c2659f51fa761dad5353fdb319b672a93facbd2ca9"}, - {file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68931b5fe746ed4fdaa8892989cab9e6c35781eeb3b0ab2ded893d561e1b3652"}, - {file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ce6947e2a95534ecaa5a15e73c21e550514c980d80eda204d064d789a95f6a4"}, - {file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:841befdbc89b9c82435fc25b0f4f41858b6238693e45af758bec4cfc1968171c"}, - {file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5d3de58b837375e7f4c0e1a088ccab5f655efb2fd7427b729df02c862a559633"}, - {file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b1801963f9f44ae0c0f6d737bc7aeb2bbcde7d1fe7e3b43cddc1961af42d3b41"}, - {file = "coverage-7.15.1-cp314-cp314-win32.whl", hash = "sha256:8c7953c4128ef53b6ffb5f90d87c87d4ce26731df294760bb2314eb0e069e44b"}, - {file = "coverage-7.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:6f0bab60a582d415f0fb535ccff13ba334a47a1538f98913330a525d23bd535a"}, - {file = "coverage-7.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:0f410ee8f0ac4ec7db71bc0b7632a8b9994e1cad2755bd1566c17e6a162caa74"}, - {file = "coverage-7.15.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc868bab88e049d41fcd41766810d790a8b960053be2a45e060f5ce0d31d258b"}, - {file = "coverage-7.15.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:206d4ec6028f2773b40932d09f074539d6bcdd8f6b318d40cb04bdbd68ed0b49"}, - {file = "coverage-7.15.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:620482ef1c9f4e61f962e159325fe77dea59d16e39d9c9470d069053b244d864"}, - {file = "coverage-7.15.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d385fc9b054e309ad3cecdc77b586d2af0c98aeec2fdb3773544586f366e817c"}, - {file = "coverage-7.15.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1198bca9c0dd7c188aae1f185b0c0b5fc4f0a2b6909000858c29550320bdb07"}, - {file = "coverage-7.15.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d0297e6a070eadb49df7cddd0ab6f420b8b689dd8904c7dd815a323168fa57e"}, - {file = "coverage-7.15.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916fcf2214f56960e409561b37fc32a160a42b6e85483d0652d7b70fa55d707e"}, - {file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f837bae572c7869ffaa502e604c87e182543012831cf87aae4586ad090ac6dcf"}, - {file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3ea65e3ee6c7c32349fd00559927a9e577bdd72386087eeed1c42b62dfce9b82"}, - {file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:345034976f46a1c54bd17f4e43eb30bb92cb7082fcddff03250cff136cc4eb82"}, - {file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4f051a64eb8f8addb4661c2b41d6eea5b7ebc68ad4b2baea8d9bc54e1956e5f7"}, - {file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a7625770f7720b49bb30d194ad2f8d50fab3c5177874af3d2399676f95f9c594"}, - {file = "coverage-7.15.1-cp314-cp314t-win32.whl", hash = "sha256:81e503d130a472ad1bd38199ecd35116b40d92bcd31e27a2cacde035381f2070"}, - {file = "coverage-7.15.1-cp314-cp314t-win_amd64.whl", hash = "sha256:724e878b213b302ad46e9f2fc872d386613f20ebfc492a211482d917ea76c14f"}, - {file = "coverage-7.15.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ce2f05c14d077f406fefc4fa5e4f093ad0e0787549f6582535d6e28766f0361b"}, - {file = "coverage-7.15.1-py3-none-any.whl", hash = "sha256:717d01e6e00bed56ad13306f19e0dd2f4f645ee8159d2c72c72301d6cfc7090c"}, - {file = "coverage-7.15.1.tar.gz", hash = "sha256:165e9949eaf222ef1f018635d0d7f368a23bfe0212af558534c40d8c04686d67"}, + {file = "coverage-7.15.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3a82b2ceee91ba353e59fe2436d8a9eae799ff9825e5385423ea205d693e2949"}, + {file = "coverage-7.15.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3088cce65e54c2eefc08e7e1ca0b0acec1e95e8cf084ac848599103ed0367f74"}, + {file = "coverage-7.15.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a65e09efb0b5ab21fc54a8a65c5b2e533c0a4c0d064af0259a005dc656dc1b13"}, + {file = "coverage-7.15.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b51f279a2477b0e1f288b98f141fd227acfdd1d3f0370400e473788879b47871"}, + {file = "coverage-7.15.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7835176988cbcf1f014db683bc33aa15e0558e412bf08deaa99757335b88df15"}, + {file = "coverage-7.15.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f24896dc8863167f6732f4142f5d37e6195eccc8fe5fe528d35d49597d29fdb3"}, + {file = "coverage-7.15.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9490d43e5d041fdf376770a886a29722adb05f6b9c21a65c48c81fc8f1c33fd7"}, + {file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:225e359bd5dedaff6d68e36091af20555866c557d968167308b677379bf575c3"}, + {file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:22119e2e3b2ac5ac024d50131fdd4b22ab4c6cf8aa2fc792cce73c0d94c5812d"}, + {file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12d555badc462b0f6037ce8bec8b4af8d71f90eb55b57d0a358731f7ee7883e2"}, + {file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c4e2cf9cf774939b3dc581c6e31dfe7e8d7608b24f0f17524d6161f8235c3d2c"}, + {file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cea1b3e19d710f67e2ba9ce0b0b51032c2a9b4808a65ced48ddf336ef7e58058"}, + {file = "coverage-7.15.3-cp310-cp310-win32.whl", hash = "sha256:25c77560309f157e7b7ee8fe0bf78d047ba900b7ae42f0e50e559305b366fea2"}, + {file = "coverage-7.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:179fbf847e6c3d90ea71bfd570fe57f1ddb1c51474754894871c1e11099efaa0"}, + {file = "coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9"}, + {file = "coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f"}, + {file = "coverage-7.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4"}, + {file = "coverage-7.15.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11"}, + {file = "coverage-7.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f"}, + {file = "coverage-7.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743"}, + {file = "coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0"}, + {file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea"}, + {file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156"}, + {file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa"}, + {file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b"}, + {file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a"}, + {file = "coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715"}, + {file = "coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446"}, + {file = "coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965"}, + {file = "coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f"}, + {file = "coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60"}, + {file = "coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f"}, + {file = "coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088"}, + {file = "coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c"}, + {file = "coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851"}, + {file = "coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9"}, + {file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e"}, + {file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866"}, + {file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb"}, + {file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646"}, + {file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0"}, + {file = "coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d"}, + {file = "coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a"}, + {file = "coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235"}, + {file = "coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de"}, + {file = "coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c"}, + {file = "coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3"}, + {file = "coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30"}, + {file = "coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10"}, + {file = "coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049"}, + {file = "coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e"}, + {file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040"}, + {file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21"}, + {file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c"}, + {file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f"}, + {file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c"}, + {file = "coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93"}, + {file = "coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3"}, + {file = "coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767"}, + {file = "coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d"}, + {file = "coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef"}, + {file = "coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd"}, + {file = "coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731"}, + {file = "coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e"}, + {file = "coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c"}, + {file = "coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764"}, + {file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e"}, + {file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f"}, + {file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd"}, + {file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a"}, + {file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6"}, + {file = "coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8"}, + {file = "coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2"}, + {file = "coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926"}, + {file = "coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b"}, + {file = "coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95"}, + {file = "coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43"}, + {file = "coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8"}, + {file = "coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779"}, + {file = "coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77"}, + {file = "coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005"}, + {file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57"}, + {file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91"}, + {file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2"}, + {file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef"}, + {file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c"}, + {file = "coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42"}, + {file = "coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429"}, + {file = "coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e"}, + {file = "coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e"}, + {file = "coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d"}, ] [package.dependencies] @@ -440,58 +440,58 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.9" groups = ["main"] files = [ - {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, - {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, - {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, - {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, - {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, - {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, - {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, - {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, - {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, - {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, - {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, - {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, - {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, + {file = "cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f"}, + {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105"}, + {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef"}, + {file = "cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30"}, + {file = "cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c"}, + {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c"}, + {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95"}, + {file = "cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269"}, + {file = "cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47"}, + {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9"}, + {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7"}, + {file = "cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9"}, + {file = "cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9"}, ] [package.dependencies] From 5932d228012ca5ba7a02bd9e16a2fe8d3dcf7230 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 3 Aug 2026 15:58:14 +1000 Subject: [PATCH 6/8] Make remote harvest zipapp stdlib-only Lazy-load manifest, explain, and validation dependencies so the remote harvest zipapp does not require jsonschema, PyYAML, Paramiko, or other site-packages on the target host. Run the remote zipapp with Python isolated mode and site-packages disabled, while preserving existing CLI monkeypatch hooks. --- enroll/cli.py | 24 +++++++++++++-- enroll/remote.py | 2 ++ tests/test_remote.py | 71 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/enroll/cli.py b/enroll/cli.py index a123fd1..b0147d3 100644 --- a/enroll/cli.py +++ b/enroll/cli.py @@ -18,20 +18,38 @@ from .diff import ( post_webhook, send_email, ) -from .explain import explain_state from .harvest import harvest from .harvest_safety import ensure_safe_output_parent, write_text_output_file -from .manifest import manifest from .remote import ( remote_harvest, RemoteSudoPasswordRequired, RemoteSSHKeyPassphraseRequired, ) from .sopsutil import SopsError, encrypt_file_binary -from .validate import validate_harvest from .version import get_enroll_version +def explain_state(*args, **kwargs): + """Load the explain implementation only when that command is used.""" + from .explain import explain_state as _explain_state + + return _explain_state(*args, **kwargs) + + +def manifest(*args, **kwargs): + """Load manifest dependencies only when rendering a manifest.""" + from .manifest import manifest as _manifest + + return _manifest(*args, **kwargs) + + +def validate_harvest(*args, **kwargs): + """Load jsonschema only when validation is requested.""" + from .validate import validate_harvest as _validate_harvest + + return _validate_harvest(*args, **kwargs) + + def _discover_config_path(argv: list[str]) -> Optional[Path]: """Return the config path to use, if any. diff --git a/enroll/remote.py b/enroll/remote.py index 83006d9..60eb7a3 100644 --- a/enroll/remote.py +++ b/enroll/remote.py @@ -1126,6 +1126,8 @@ def _remote_harvest( # Run remote harvest. argv: list[str] = [ remote_python, + "-I", + "-S", rapp, "harvest", "--out", diff --git a/tests/test_remote.py b/tests/test_remote.py index 1e5ffcc..7e44316 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -313,6 +313,9 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch): assert promote_argv.count("enroll.pyz") >= 2 assert "/tmp/enroll-root-123/enroll.pyz" in harvest_cmd assert "/tmp/enroll-remote-123/enroll.pyz" not in harvest_cmd + harvest_argv = shlex.split(harvest_cmd) + pyz_i = harvest_argv.index("/tmp/enroll-root-123/enroll.pyz") + assert harvest_argv[pyz_i - 2 : pyz_i] == ["-I", "-S"] assert not any(_is_pyz_verify_cmd(c) for c, _pty in calls) # The trusted digest must be obtained while the archive is still private, @@ -1251,6 +1254,74 @@ def test_remote_verify_pyz_sha256_rejects_nonzero_rc(): ) +def test_build_enroll_pyz_harvest_starts_without_site_packages(tmp_path: Path): + """The remote payload must not depend on packages installed on the target. + + ``-S`` disables site-packages, reproducing a target that has Python but no + jsonschema/PyYAML. ``-I`` also ignores PYTHON* environment variables and + the current directory. The old eager CLI imports failed here before + argparse could dispatch to ``harvest``. + """ + import subprocess + import sys + + import enroll.remote as r + + pyz, _sha = r._build_enroll_pyz(tmp_path) + proc = subprocess.run( + [sys.executable, "-I", "-S", str(pyz), "harvest", "--help"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=15, + ) + + assert proc.returncode == 0, proc.stderr + assert "usage: enroll harvest" in proc.stdout + assert "ModuleNotFoundError" not in proc.stderr + assert "jsonschema" not in proc.stderr + assert "yaml" not in proc.stderr.lower() + + +def test_build_enroll_pyz_runs_harvest_without_site_packages(tmp_path: Path): + """Exercise command dispatch and the actual harvest implementation under -S.""" + import subprocess + import sys + + import enroll.remote as r + + build_dir = tmp_path / "build" + build_dir.mkdir() + pyz, _sha = r._build_enroll_pyz(build_dir) + out_dir = tmp_path / "bundle" + proc = subprocess.run( + [ + sys.executable, + "-I", + "-S", + str(pyz), + "harvest", + "--out", + str(out_dir), + "--exclude-path", + "/**", + "--assume-safe-path", + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=60, + ) + + assert proc.returncode == 0, proc.stderr + assert (out_dir / "state.json").is_file() + assert "ModuleNotFoundError" not in proc.stderr + assert "jsonschema" not in proc.stderr + assert "yaml" not in proc.stderr.lower() + + def test_build_enroll_pyz_excludes_tests_and_caches_and_returns_sha(tmp_path: Path): import zipfile From 4559aa6c70f57d963d093827ea076beef10c8e98 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 3 Aug 2026 15:59:43 +1000 Subject: [PATCH 7/8] 0.8.2 --- CHANGELOG.md | 1 + debian/changelog | 6 ++++++ rpm/enroll.spec | 4 +++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d97be..00a58b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 0.8.2 * Bump dependencies + * Make remote harvest zipapp stdlib-only # 0.8.1 diff --git a/debian/changelog b/debian/changelog index dbbc03c..d395d98 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +enroll (0.8.2) unstable; urgency=medium + + * Make remote harvest zipapp stdlib-only + + -- Miguel Jacq Mon, 03 Aug 2026 16:00:00 +1000 + enroll (0.8.1) unstable; urgency=medium * Security: fix a TOCTOU in remote harvest. diff --git a/rpm/enroll.spec b/rpm/enroll.spec index 5253c80..42399b9 100644 --- a/rpm/enroll.spec +++ b/rpm/enroll.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.8.1 +%global upstream_version 0.8.2 Name: enroll Version: %{upstream_version} @@ -44,6 +44,8 @@ Enroll a server's running state retrospectively into Ansible. %changelog * Mon Aug 03 2026 Miguel Jacq - %{version}-%{release} +- Make remote harvest zipapp stdlib-only +* Mon Aug 03 2026 Miguel Jacq - %{version}-%{release} - Security: fix a TOCTOU in remote harvest. * Mon Jul 13 2026 Miguel Jacq - %{version}-%{release} - Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable. From 32bf09887acfb03a3ad2f9ce79daa9674c43d157 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 3 Aug 2026 17:17:39 +1000 Subject: [PATCH 8/8] 0.8.2 --- pyproject.toml | 2 +- rpm/enroll.spec | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a7d7c3d..c8707ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "enroll" -version = "0.8.1" +version = "0.8.2" description = "Enroll a server's running state retrospectively into Ansible" readme = "README.md" requires-python = ">=3.10" diff --git a/rpm/enroll.spec b/rpm/enroll.spec index 42399b9..4d5f5ca 100644 --- a/rpm/enroll.spec +++ b/rpm/enroll.spec @@ -45,7 +45,6 @@ Enroll a server's running state retrospectively into Ansible. %changelog * Mon Aug 03 2026 Miguel Jacq - %{version}-%{release} - Make remote harvest zipapp stdlib-only -* Mon Aug 03 2026 Miguel Jacq - %{version}-%{release} - Security: fix a TOCTOU in remote harvest. * Mon Jul 13 2026 Miguel Jacq - %{version}-%{release} - Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable. @@ -71,7 +70,7 @@ Enroll a server's running state retrospectively into Ansible. - Add support for generating ipset and iptables configuration files from runtime, if the former weren't present ('firewall_runtime' role) * Tue May 12 2026 Miguel Jacq - %{version}-%{release} - Add ssh config support where JinjaTurtle is used -* Tue Feb 16 2026 Miguel Jacq - %{version}-%{release} +* Mon Feb 16 2026 Miguel Jacq - %{version}-%{release} - Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR` * Fri Jan 16 2026 Miguel Jacq - %{version}-%{release} - Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.