Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32bf09887a | |||
| 4559aa6c70 | |||
| 5932d22801 | |||
| 70c90c62ae | |||
| 5166894992 | |||
| 1e9806d2dc | |||
| 82db7a7d72 | |||
| 098b9c3ac6 |
11 changed files with 1083 additions and 191 deletions
|
|
@ -1,3 +1,12 @@
|
||||||
|
# 0.8.2
|
||||||
|
|
||||||
|
* Bump dependencies
|
||||||
|
* Make remote harvest zipapp stdlib-only
|
||||||
|
|
||||||
|
# 0.8.1
|
||||||
|
|
||||||
|
* Security: fix a TOCTOU in remote harvest.
|
||||||
|
|
||||||
# 0.8.0
|
# 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.
|
* 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.
|
||||||
|
|
|
||||||
|
|
@ -364,9 +364,7 @@ poetry run enroll --help
|
||||||
|
|
||||||
My Forgejo doesn't currently support federation, so I haven't opened registration/login for issues.
|
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:
|
Instead, email me (see `pyproject.toml`).
|
||||||
|
|
||||||
https://goto.mig5.net/@mig5
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
12
debian/changelog
vendored
12
debian/changelog
vendored
|
|
@ -1,3 +1,15 @@
|
||||||
|
enroll (0.8.2) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Make remote harvest zipapp stdlib-only
|
||||||
|
|
||||||
|
-- Miguel Jacq <mig@mig5.net> Mon, 03 Aug 2026 16:00:00 +1000
|
||||||
|
|
||||||
|
enroll (0.8.1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Security: fix a TOCTOU in remote harvest.
|
||||||
|
|
||||||
|
-- Miguel Jacq <mig@mig5.net> Mon, 03 Aug 2026 15:20:00 +1000
|
||||||
|
|
||||||
enroll (0.8.0) unstable; urgency=medium
|
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.
|
* 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.
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,38 @@ from .diff import (
|
||||||
post_webhook,
|
post_webhook,
|
||||||
send_email,
|
send_email,
|
||||||
)
|
)
|
||||||
from .explain import explain_state
|
|
||||||
from .harvest import harvest
|
from .harvest import harvest
|
||||||
from .harvest_safety import ensure_safe_output_parent, write_text_output_file
|
from .harvest_safety import ensure_safe_output_parent, write_text_output_file
|
||||||
from .manifest import manifest
|
|
||||||
from .remote import (
|
from .remote import (
|
||||||
remote_harvest,
|
remote_harvest,
|
||||||
RemoteSudoPasswordRequired,
|
RemoteSudoPasswordRequired,
|
||||||
RemoteSSHKeyPassphraseRequired,
|
RemoteSSHKeyPassphraseRequired,
|
||||||
)
|
)
|
||||||
from .sopsutil import SopsError, encrypt_file_binary
|
from .sopsutil import SopsError, encrypt_file_binary
|
||||||
from .validate import validate_harvest
|
|
||||||
from .version import get_enroll_version
|
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]:
|
def _discover_config_path(argv: list[str]) -> Optional[Path]:
|
||||||
"""Return the config path to use, if any.
|
"""Return the config path to use, if any.
|
||||||
|
|
||||||
|
|
|
||||||
294
enroll/remote.py
294
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
|
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
|
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
|
file that is executed is byte-for-byte the one we built (see
|
||||||
(see ``_remote_verify_pyz_sha256``). This is transport/staging integrity
|
``_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
|
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
|
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
|
NOT a defence against a remote host that is already root-compromised -- such
|
||||||
|
|
@ -461,6 +462,243 @@ def _remote_file_sha256_sudo(
|
||||||
return digest
|
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(
|
def _remote_verify_pyz_sha256(
|
||||||
ssh,
|
ssh,
|
||||||
remote_pyz_path: str,
|
remote_pyz_path: str,
|
||||||
|
|
@ -468,23 +706,13 @@ def _remote_verify_pyz_sha256(
|
||||||
*,
|
*,
|
||||||
remote_python: str,
|
remote_python: str,
|
||||||
) -> None:
|
) -> 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
|
The privileged path uses :func:`_remote_promote_verified_pyz` instead,
|
||||||
remote, immediately before the (root) execution of the zipapp, and fails
|
because hashing a user-writable pathname and later executing that pathname
|
||||||
closed if the digest does not match the bytes we built locally. It shrinks
|
as root would leave a verify/use race. This simpler check remains useful in
|
||||||
the window in which a *non-root* tamperer who somehow gained write access to
|
``--no-sudo`` mode for detecting transfer corruption; there is no privilege
|
||||||
the staging directory could swap the file between upload and execution.
|
transition in that mode.
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Hash the staged file using the same interpreter that will execute it.
|
# Hash the staged file using the same interpreter that will execute it.
|
||||||
|
|
@ -850,16 +1078,6 @@ def _remote_harvest(
|
||||||
rapp = f"{rtmp}/enroll.pyz"
|
rapp = f"{rtmp}/enroll.pyz"
|
||||||
sftp.put(str(pyz), rapp)
|
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:
|
if not no_sudo:
|
||||||
# The remote zipapp is staged as the SSH user, but the harvest
|
# The remote zipapp is staged as the SSH user, but the harvest
|
||||||
# itself runs as root. Root must not write its bundle under the
|
# itself runs as root. Root must not write its bundle under the
|
||||||
|
|
@ -883,13 +1101,33 @@ def _remote_harvest(
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
raise RuntimeError(f"Remote sudo chmod failed: {err.strip()}")
|
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"
|
rbundle = f"{remote_root_tmp}/bundle"
|
||||||
else:
|
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"
|
rbundle = f"{rtmp}/bundle"
|
||||||
|
|
||||||
# Run remote harvest.
|
# Run remote harvest.
|
||||||
argv: list[str] = [
|
argv: list[str] = [
|
||||||
remote_python,
|
remote_python,
|
||||||
|
"-I",
|
||||||
|
"-S",
|
||||||
rapp,
|
rapp,
|
||||||
"harvest",
|
"harvest",
|
||||||
"--out",
|
"--out",
|
||||||
|
|
|
||||||
284
poetry.lock
generated
284
poetry.lock
generated
|
|
@ -91,14 +91,14 @@ typecheck = ["mypy"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.6.17"
|
version = "2026.7.22"
|
||||||
description = "Python package for providing Mozilla's CA Bundle."
|
description = "Python package for providing Mozilla's CA Bundle."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
groups = ["dev"]
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"},
|
{file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"},
|
||||||
{file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"},
|
{file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -333,103 +333,103 @@ files = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "7.15.1"
|
version = "7.15.3"
|
||||||
description = "Code coverage measurement for Python"
|
description = "Code coverage measurement for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
groups = ["dev"]
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "coverage-7.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:05d87c2a43373ad6b976d0a99ad58c48633633bcdeb896dc645a006472cc4a71"},
|
{file = "coverage-7.15.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3a82b2ceee91ba353e59fe2436d8a9eae799ff9825e5385423ea205d693e2949"},
|
||||||
{file = "coverage-7.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2afce82f2cf8f4c9002746a42755e1dc61baff33d9f7ab5569b4c9101f8f4d1d"},
|
{file = "coverage-7.15.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3088cce65e54c2eefc08e7e1ca0b0acec1e95e8cf084ac848599103ed0367f74"},
|
||||||
{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.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a65e09efb0b5ab21fc54a8a65c5b2e533c0a4c0d064af0259a005dc656dc1b13"},
|
||||||
{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.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b51f279a2477b0e1f288b98f141fd227acfdd1d3f0370400e473788879b47871"},
|
||||||
{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.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7835176988cbcf1f014db683bc33aa15e0558e412bf08deaa99757335b88df15"},
|
||||||
{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.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f24896dc8863167f6732f4142f5d37e6195eccc8fe5fe528d35d49597d29fdb3"},
|
||||||
{file = "coverage-7.15.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81572011fc1fc271317da35da593944daef7bfd507085e35751abbe702b74f69"},
|
{file = "coverage-7.15.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9490d43e5d041fdf376770a886a29722adb05f6b9c21a65c48c81fc8f1c33fd7"},
|
||||||
{file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f765d13c08497687d0780cca66115c6aa4ba6703ad43b61e94fab9db689e3a3"},
|
{file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:225e359bd5dedaff6d68e36091af20555866c557d968167308b677379bf575c3"},
|
||||||
{file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:771caba880fee96493d18dfc465c318e08ab74e3bc2a3e4089e52514be6a6e54"},
|
{file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:22119e2e3b2ac5ac024d50131fdd4b22ab4c6cf8aa2fc792cce73c0d94c5812d"},
|
||||||
{file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:433a73200848e80f27712fc113b6ff5311f29b479a7d3bd4b1106138a77f9674"},
|
{file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12d555badc462b0f6037ce8bec8b4af8d71f90eb55b57d0a358731f7ee7883e2"},
|
||||||
{file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5d6fa45079db9fbeba0a69e3d91189f05301d6ac918162a53179d32fc9ed4910"},
|
{file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c4e2cf9cf774939b3dc581c6e31dfe7e8d7608b24f0f17524d6161f8235c3d2c"},
|
||||||
{file = "coverage-7.15.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:00b6703c6640075cdce5124e9335dfdf9167272475301828acfdd09c0e5ee731"},
|
{file = "coverage-7.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cea1b3e19d710f67e2ba9ce0b0b51032c2a9b4808a65ced48ddf336ef7e58058"},
|
||||||
{file = "coverage-7.15.1-cp310-cp310-win32.whl", hash = "sha256:3ad9a0eac4728327fd870d52f74d2e631d176c5f178eaea2d9983ab5b9755a55"},
|
{file = "coverage-7.15.3-cp310-cp310-win32.whl", hash = "sha256:25c77560309f157e7b7ee8fe0bf78d047ba900b7ae42f0e50e559305b366fea2"},
|
||||||
{file = "coverage-7.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:eb5fa75dc3d30e3a1b75da97973479b20ffa9b0641ff56d6e94b5f3e210daa54"},
|
{file = "coverage-7.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:179fbf847e6c3d90ea71bfd570fe57f1ddb1c51474754894871c1e11099efaa0"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6506330b4a8dcf53b95bd84d8d0e817107cdb3fc1438e835029cdf0bc6612eb0"},
|
{file = "coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8a51a8ec382c39d939cba0ab07ae949077ae4e842343bd4eed22d432358cea9"},
|
{file = "coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f"},
|
||||||
{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.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4"},
|
||||||
{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.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11"},
|
||||||
{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.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f"},
|
||||||
{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.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c6829d9a3b55ad2b73ef5fda8302e5be03683789e88b1a079dcf4a773229c21d"},
|
{file = "coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:764e045811f9c8cda436641f3f088283351d331a519b5807f19041cd0a68da1c"},
|
{file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:09b3b088aa24489c4082bcc35fcc8224281ab94a653dfb6d3f0c8165b0d628ab"},
|
{file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7911b02f57053adf8164ae63edb1c26574d24dfccabadc5268cf69310a69a358"},
|
{file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:664e279ed40599b8ed16f4db18d92a7e212c73129672bec8f5d96d4da48d2404"},
|
{file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:34fe7cf79d5f1f87f2e8ce7dc1c32950841f50e10d0120f263856acfad66de34"},
|
{file = "coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-win32.whl", hash = "sha256:5e2d2536d2f57a354aa382ed303ac0e2e5c9522a508c05b998d26181b94163a7"},
|
{file = "coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:c337da8fca7ea93ab43f3868cfcde6cf6dad32c3906b273cfbad5d7390bc423b"},
|
{file = "coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446"},
|
||||||
{file = "coverage-7.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:db3403fdb7a94d5eb73e099befad8104d2a7d110a0f0d99df0de61c5d1fa756c"},
|
{file = "coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9476292594309db922cc841dd13b303b3c388f4c25d279884f7e2341c681f80"},
|
{file = "coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c579056b0de461b3a62318b63d0b6ce90aed7f8158d3f00da094df82f29d189"},
|
{file = "coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60"},
|
||||||
{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.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f"},
|
||||||
{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.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088"},
|
||||||
{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.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c"},
|
||||||
{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.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d78aa537237212c4313aabe5e964b66acc86350ed19ebc56a3e202df33b6077b"},
|
{file = "coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a318112bb4f79d9d04766196d5a3388caa825908a6a9b052aa87de3d9aea7c61"},
|
{file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e55d24cada901963eed5bc89fa562aa033f0d84b9d3de4ecf363737c13aed11e"},
|
{file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c78f0cea7275342cf2adc2ad5fdd0aafa106ad91e66d573568f2fcf62c41df5"},
|
{file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:86bd37eabe39977216f630a7fc1b698e7f5e81a191c7186013245c6c3d313f9d"},
|
{file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6db15c217693bdc3ca0b84de1ba9afafe1c14c26a8a29d77f4ed0de2b6132e2"},
|
{file = "coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-win32.whl", hash = "sha256:359f3fbe09a51500c51966596ee4ee4070b356552c70b3b2420eb200d68e0f76"},
|
{file = "coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:fa75dc099c126e941a9c0baa8ebd2cbc78bd778687534fe410baf754f6d9e374"},
|
{file = "coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a"},
|
||||||
{file = "coverage-7.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:26f89cf6d0634375f454fa71057945ad18edb0f1607a90fecf22c57dc3dc289a"},
|
{file = "coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71ac4ca1658ca99160fd58cc6967110e989c34b04627f24ed6ec9f70fb24571a"},
|
{file = "coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26a40cbf2b13bd94af53ee02a424cb3bb96a9edfac0d00834bd068512a62714b"},
|
{file = "coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c"},
|
||||||
{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.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3"},
|
||||||
{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.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30"},
|
||||||
{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.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10"},
|
||||||
{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.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98847557a6859cadf693792ce89f440cb89692993f60dc6d3a7e35f3d340216f"},
|
{file = "coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8697b2edb57143546a24389efc11e1b000cd5800fc20d84f04edb601e4a7cfb8"},
|
{file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6827ac0519be3fe91bf96b4060eb00d1d24f82649b29862cd75a3cfca248b02a"},
|
{file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2de8ecbbc77c7e4d22572779920ed8979c69168675e96be3a548c996568c6c31"},
|
{file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b25f0f0fa5260df9d7bb55d47c8bdc23fa3382c1a18f7c9cae122e6c320b1ad"},
|
{file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a2effcbd93ae340a58db718fe4181d967f84d352c4cefeaab4ff82ce813901a"},
|
{file = "coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-win32.whl", hash = "sha256:895e65c96aef0cecea250f6e35e9a32f11375514e1a0cb5210e0fda128c04e8e"},
|
{file = "coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6d0a28b63a0d75f9ed5118105d1154fc3aa40a8605a30d5d87e3d043ad90fe7"},
|
{file = "coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3"},
|
||||||
{file = "coverage-7.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:b4ee9818e8bae3544379ad2c09b851c4fb886aaa8860d57a1c1316ddcb16db49"},
|
{file = "coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a886af95f59edf67d5770fd3564d53f4a8af93f25f8c1d60d27e00d7f5674ee8"},
|
{file = "coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985657ebd707941de90d488d1cbb5efac20bdf81f7b91eba771624ccda4d36f4"},
|
{file = "coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef"},
|
||||||
{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.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd"},
|
||||||
{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.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731"},
|
||||||
{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.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e"},
|
||||||
{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.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:24d8e85a2a45e44883b488c2659f51fa761dad5353fdb319b672a93facbd2ca9"},
|
{file = "coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68931b5fe746ed4fdaa8892989cab9e6c35781eeb3b0ab2ded893d561e1b3652"},
|
{file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ce6947e2a95534ecaa5a15e73c21e550514c980d80eda204d064d789a95f6a4"},
|
{file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:841befdbc89b9c82435fc25b0f4f41858b6238693e45af758bec4cfc1968171c"},
|
{file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5d3de58b837375e7f4c0e1a088ccab5f655efb2fd7427b729df02c862a559633"},
|
{file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b1801963f9f44ae0c0f6d737bc7aeb2bbcde7d1fe7e3b43cddc1961af42d3b41"},
|
{file = "coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-win32.whl", hash = "sha256:8c7953c4128ef53b6ffb5f90d87c87d4ce26731df294760bb2314eb0e069e44b"},
|
{file = "coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:6f0bab60a582d415f0fb535ccff13ba334a47a1538f98913330a525d23bd535a"},
|
{file = "coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:0f410ee8f0ac4ec7db71bc0b7632a8b9994e1cad2755bd1566c17e6a162caa74"},
|
{file = "coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc868bab88e049d41fcd41766810d790a8b960053be2a45e060f5ce0d31d258b"},
|
{file = "coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:206d4ec6028f2773b40932d09f074539d6bcdd8f6b318d40cb04bdbd68ed0b49"},
|
{file = "coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95"},
|
||||||
{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.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43"},
|
||||||
{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.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8"},
|
||||||
{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.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779"},
|
||||||
{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.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916fcf2214f56960e409561b37fc32a160a42b6e85483d0652d7b70fa55d707e"},
|
{file = "coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f837bae572c7869ffaa502e604c87e182543012831cf87aae4586ad090ac6dcf"},
|
{file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3ea65e3ee6c7c32349fd00559927a9e577bdd72386087eeed1c42b62dfce9b82"},
|
{file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:345034976f46a1c54bd17f4e43eb30bb92cb7082fcddff03250cff136cc4eb82"},
|
{file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4f051a64eb8f8addb4661c2b41d6eea5b7ebc68ad4b2baea8d9bc54e1956e5f7"},
|
{file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a7625770f7720b49bb30d194ad2f8d50fab3c5177874af3d2399676f95f9c594"},
|
{file = "coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-win32.whl", hash = "sha256:81e503d130a472ad1bd38199ecd35116b40d92bcd31e27a2cacde035381f2070"},
|
{file = "coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-win_amd64.whl", hash = "sha256:724e878b213b302ad46e9f2fc872d386613f20ebfc492a211482d917ea76c14f"},
|
{file = "coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429"},
|
||||||
{file = "coverage-7.15.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ce2f05c14d077f406fefc4fa5e4f093ad0e0787549f6582535d6e28766f0361b"},
|
{file = "coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e"},
|
||||||
{file = "coverage-7.15.1-py3-none-any.whl", hash = "sha256:717d01e6e00bed56ad13306f19e0dd2f4f645ee8159d2c72c72301d6cfc7090c"},
|
{file = "coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e"},
|
||||||
{file = "coverage-7.15.1.tar.gz", hash = "sha256:165e9949eaf222ef1f018635d0d7f368a23bfe0212af558534c40d8c04686d67"},
|
{file = "coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
|
|
@ -440,58 +440,58 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "49.0.0"
|
version = "50.0.0"
|
||||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.9.0,!=3.9.1,>=3.9"
|
python-versions = "!=3.9.0,!=3.9.1,>=3.9"
|
||||||
groups = ["main"]
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"},
|
{file = "cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"},
|
{file = "cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"},
|
{file = "cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"},
|
{file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"},
|
{file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"},
|
{file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"},
|
{file = "cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"},
|
{file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"},
|
{file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"},
|
{file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"},
|
{file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"},
|
{file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef"},
|
||||||
{file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"},
|
{file = "cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"},
|
{file = "cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"},
|
{file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"},
|
{file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"},
|
{file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"},
|
{file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"},
|
{file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"},
|
{file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"},
|
{file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"},
|
{file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"},
|
{file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"},
|
{file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"},
|
{file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95"},
|
||||||
{file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"},
|
{file = "cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"},
|
{file = "cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"},
|
{file = "cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"},
|
{file = "cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"},
|
{file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"},
|
{file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"},
|
{file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"},
|
{file = "cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"},
|
{file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"},
|
{file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"},
|
{file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"},
|
{file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"},
|
{file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7"},
|
||||||
{file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"},
|
{file = "cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba"},
|
||||||
{file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"},
|
{file = "cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c"},
|
||||||
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"},
|
{file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a"},
|
||||||
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"},
|
{file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e"},
|
||||||
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"},
|
{file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d"},
|
||||||
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"},
|
{file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437"},
|
||||||
{file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"},
|
{file = "cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9"},
|
||||||
{file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"},
|
{file = "cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "enroll"
|
name = "enroll"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
description = "Enroll a server's running state retrospectively into Ansible"
|
description = "Enroll a server's running state retrospectively into Ansible"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ sudo apt-get -y install createrepo-c rpm
|
||||||
BUILD_OUTPUT="${HOME}/git/enroll/dist"
|
BUILD_OUTPUT="${HOME}/git/enroll/dist"
|
||||||
KEYID="54A91143AE0AB4F7743B01FE888ED1B423A3BC99"
|
KEYID="54A91143AE0AB4F7743B01FE888ED1B423A3BC99"
|
||||||
REPO_ROOT="${HOME}/git/repo_rpm"
|
REPO_ROOT="${HOME}/git/repo_rpm"
|
||||||
REMOTE="letessier.mig5.net:/opt/repo_rpm"
|
REMOTE="ashpool.mig5.net:/opt/repo_rpm"
|
||||||
|
|
||||||
DISTS=(
|
DISTS=(
|
||||||
fedora:43
|
fedora:43
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
%global upstream_version 0.8.0
|
%global upstream_version 0.8.2
|
||||||
|
|
||||||
Name: enroll
|
Name: enroll
|
||||||
Version: %{upstream_version}
|
Version: %{upstream_version}
|
||||||
|
|
@ -43,6 +43,9 @@ Enroll a server's running state retrospectively into Ansible.
|
||||||
%{_bindir}/enroll
|
%{_bindir}/enroll
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Mon Aug 03 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
|
- Make remote harvest zipapp stdlib-only
|
||||||
|
- Security: fix a TOCTOU in remote harvest.
|
||||||
* Mon Jul 13 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Mon Jul 13 2026 Miguel Jacq <mig@mig5.net> - %{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: 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.
|
- 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.
|
||||||
|
|
@ -67,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)
|
- 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 <mig@mig5.net> - %{version}-%{release}
|
* Tue May 12 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Add ssh config support where JinjaTurtle is used
|
- Add ssh config support where JinjaTurtle is used
|
||||||
* Tue Feb 16 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Mon Feb 16 2026 Miguel Jacq <mig@mig5.net> - %{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`
|
- 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 <mig@mig5.net> - %{version}-%{release}
|
* Fri Jan 16 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
|
- Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import shlex
|
||||||
import tarfile
|
import tarfile
|
||||||
import warnings
|
import warnings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -9,10 +10,9 @@ from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
# The remote harvest now SHA-256-verifies the uploaded zipapp on the remote
|
# Sudo harvests promote and verify the upload into a root-private directory;
|
||||||
# before executing it. Tests mock _build_enroll_pyz to write these fixed bytes
|
# no-sudo harvests retain the direct remote digest check. Tests mock the local
|
||||||
# and return (path, _FAKE_PYZ_SHA256); the fake SSH routers below answer the
|
# builder with these fixed bytes and digest so both paths can be exercised.
|
||||||
# verification command with the same digest so the happy paths proceed.
|
|
||||||
_FAKE_PYZ_BYTES = b"PYZ"
|
_FAKE_PYZ_BYTES = b"PYZ"
|
||||||
_FAKE_PYZ_SHA256 = "d6f4e1dbf7ba69af6c798c6f6f67383c978e68f4201bf31902275ef37e6263e1"
|
_FAKE_PYZ_SHA256 = "d6f4e1dbf7ba69af6c798c6f6f67383c978e68f4201bf31902275ef37e6263e1"
|
||||||
|
|
||||||
|
|
@ -28,9 +28,16 @@ def _fake_build_enroll_pyz(td) -> tuple[Path, str]:
|
||||||
return p, _FAKE_PYZ_SHA256
|
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:
|
def _is_pyz_verify_cmd(cmd: str) -> bool:
|
||||||
"""True if *cmd* is the remote pyz SHA-256 integrity check."""
|
"""True if *cmd* is the no-sudo remote pyz SHA-256 check."""
|
||||||
return "hashlib.sha256" in cmd and "enroll.pyz" in cmd
|
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:
|
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
|
return self._sftp
|
||||||
|
|
||||||
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
|
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):
|
if _is_pyz_verify_cmd(cmd):
|
||||||
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
||||||
calls.append((cmd, bool(get_pty)))
|
calls.append((cmd, bool(get_pty)))
|
||||||
|
|
@ -286,6 +295,29 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch):
|
||||||
assert "chown -- 1000 /tmp/enroll-root-123/bundle.tgz" in joined
|
assert "chown -- 1000 /tmp/enroll-root-123/bundle.tgz" in joined
|
||||||
assert "chmod 0711 -- /tmp/enroll-root-123" 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
|
||||||
|
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,
|
# The trusted digest must be obtained while the archive is still private,
|
||||||
# before ownership/read access is handed to the SSH account.
|
# before ownership/read access is handed to the SSH account.
|
||||||
archive_hash_i = next(
|
archive_hash_i = next(
|
||||||
|
|
@ -410,7 +442,9 @@ def test_remote_harvest_no_sudo_does_not_request_pty_or_chown(
|
||||||
return self._sftp
|
return self._sftp
|
||||||
|
|
||||||
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
|
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):
|
if _is_pyz_verify_cmd(cmd):
|
||||||
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
||||||
calls.append((cmd, bool(get_pty)))
|
calls.append((cmd, bool(get_pty)))
|
||||||
|
|
@ -570,7 +604,9 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password(
|
||||||
return self._sftp
|
return self._sftp
|
||||||
|
|
||||||
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
|
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):
|
if _is_pyz_verify_cmd(cmd):
|
||||||
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
||||||
calls.append((cmd, bool(get_pty)))
|
calls.append((cmd, bool(get_pty)))
|
||||||
|
|
@ -960,7 +996,8 @@ def test_remote_harvest_ssh_key_passphrase_retry(monkeypatch, tmp_path: Path):
|
||||||
return self._sftp
|
return self._sftp
|
||||||
|
|
||||||
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
|
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):
|
if _is_pyz_verify_cmd(cmd):
|
||||||
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
||||||
if cmd.startswith("tar -cz -C"):
|
if cmd.startswith("tar -cz -C"):
|
||||||
|
|
@ -1084,7 +1121,8 @@ def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive(
|
||||||
return self._sftp
|
return self._sftp
|
||||||
|
|
||||||
def exec_command(self, cmd: str, **_kwargs):
|
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):
|
if _is_pyz_verify_cmd(cmd):
|
||||||
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
||||||
return (_Stdout(), _Stdout(), _Stderr())
|
return (_Stdout(), _Stdout(), _Stderr())
|
||||||
|
|
@ -1216,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):
|
def test_build_enroll_pyz_excludes_tests_and_caches_and_returns_sha(tmp_path: Path):
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
|
|
||||||
508
tests/test_remote_pyz_promotion.py
Normal file
508
tests/test_remote_pyz_promotion.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue