fail loudly on an empty/truncated frozen harvest bundle. Tighten as much as we can the remote harvesting

This commit is contained in:
Miguel Jacq 2026-06-29 08:56:06 +10:00
parent a2dc054882
commit ddb18403c8
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
4 changed files with 358 additions and 40 deletions

View file

@ -213,3 +213,25 @@ def test_freeze_directory_bundle_rejects_hardlinked_file(tmp_path: Path):
os.link(secret, hard)
with pytest.raises(ArtifactSafetyError, match="hardlink"):
freeze_directory_bundle(bundle)
def test_freeze_directory_bundle_fails_loudly_on_unreadable_subdir(tmp_path: Path):
"""An unreadable subtree must abort the freeze, not silently truncate it.
Regression test: os.walk() defaults to swallowing directory-listing errors,
which previously produced a partial frozen bundle with no error. The freeze
now passes onerror= so a PermissionError aborts with ArtifactSafetyError.
"""
if os.geteuid() == 0:
pytest.skip("root can read 0000 directories; cannot simulate the case")
bundle = _write_bundle(tmp_path)
locked = bundle / "artifacts" / "app" / "etc" / "app"
assert locked.is_dir()
os.chmod(locked, 0o000)
try:
with pytest.raises(ArtifactSafetyError, match="could not be fully read"):
freeze_directory_bundle(bundle)
finally:
# Restore perms so tmp_path cleanup can remove the tree.
os.chmod(locked, 0o755)

View file

@ -8,6 +8,30 @@ from pathlib import Path
import pytest
# The remote harvest now SHA-256-verifies the uploaded zipapp on the remote
# before executing it. Tests mock _build_enroll_pyz to write these fixed bytes
# and return (path, _FAKE_PYZ_SHA256); the fake SSH routers below answer the
# verification command with the same digest so the happy paths proceed.
_FAKE_PYZ_BYTES = b"PYZ"
_FAKE_PYZ_SHA256 = "d6f4e1dbf7ba69af6c798c6f6f67383c978e68f4201bf31902275ef37e6263e1"
def _fake_build_enroll_pyz(td) -> tuple[Path, str]:
"""Stand-in for remote._build_enroll_pyz used across remote tests.
Writes a tiny fixed payload instead of a real zipapp and returns the
(path, sha256) tuple the production builder now returns.
"""
p = Path(td) / "enroll.pyz"
p.write_bytes(_FAKE_PYZ_BYTES)
return p, _FAKE_PYZ_SHA256
def _is_pyz_verify_cmd(cmd: str) -> bool:
"""True if *cmd* is the remote pyz SHA-256 integrity check."""
return "hashlib.sha256" in cmd and "enroll.pyz" in cmd
def _make_tgz_bytes(files: dict[str, bytes]) -> bytes:
bio = io.BytesIO()
with tarfile.open(fileobj=bio, mode="w:gz") as tf:
@ -56,12 +80,7 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch):
import enroll.remote as r
# Avoid building a real zipapp; just create a file.
def fake_build(_td: Path) -> Path:
p = _td / "enroll.pyz"
p.write_bytes(b"PYZ")
return p
monkeypatch.setattr(r, "_build_enroll_pyz", fake_build)
monkeypatch.setattr(r, "_build_enroll_pyz", _fake_build_enroll_pyz)
# Prepare a tiny harvest bundle tar stream from the "remote".
tgz = _make_tgz_bytes({"state.json": b'{"ok": true}\n'})
@ -156,6 +175,9 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch):
return self._sftp
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
# Integrity check of the uploaded pyz (added with SHA-256 verify).
if _is_pyz_verify_cmd(cmd):
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
calls.append((cmd, bool(get_pty)))
# The tar stream uses exec_command directly.
if cmd.startswith("tar -cz -C"):
@ -264,12 +286,7 @@ def test_remote_harvest_no_sudo_does_not_request_pty_or_chown(
import enroll.remote as r
monkeypatch.setattr(
r,
"_build_enroll_pyz",
lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
or (Path(td) / "enroll.pyz"),
)
monkeypatch.setattr(r, "_build_enroll_pyz", _fake_build_enroll_pyz)
tgz = _make_tgz_bytes({"state.json": b"{}"})
calls: list[tuple[str, bool]] = []
@ -357,6 +374,9 @@ def test_remote_harvest_no_sudo_does_not_request_pty_or_chown(
return self._sftp
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
# Integrity check of the uploaded pyz (added with SHA-256 verify).
if _is_pyz_verify_cmd(cmd):
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
calls.append((cmd, bool(get_pty)))
if cmd == "mktemp -d":
return (None, _Stdout(b"/tmp/enroll-remote-456\n"), _Stderr())
@ -409,12 +429,7 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password(
import enroll.remote as r
# Avoid building a real zipapp; just create a file.
monkeypatch.setattr(
r,
"_build_enroll_pyz",
lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
or (Path(td) / "enroll.pyz"),
)
monkeypatch.setattr(r, "_build_enroll_pyz", _fake_build_enroll_pyz)
tgz = _make_tgz_bytes({"state.json": b'{"ok": true}\n'})
calls: list[tuple[str, bool]] = []
@ -514,6 +529,9 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password(
return self._sftp
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
# Integrity check of the uploaded pyz (added with SHA-256 verify).
if _is_pyz_verify_cmd(cmd):
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
calls.append((cmd, bool(get_pty)))
# Tar stream
@ -800,12 +818,7 @@ def test_remote_harvest_ssh_key_passphrase_retry(monkeypatch, tmp_path: Path):
import enroll.remote as r
monkeypatch.setattr(
r,
"_build_enroll_pyz",
lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
or (Path(td) / "enroll.pyz"),
)
monkeypatch.setattr(r, "_build_enroll_pyz", _fake_build_enroll_pyz)
tgz = _make_tgz_bytes({"state.json": b'{"ok": true}\n'})
@ -901,6 +914,9 @@ def test_remote_harvest_ssh_key_passphrase_retry(monkeypatch, tmp_path: Path):
return self._sftp
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
# Integrity check of the uploaded pyz (added with SHA-256 verify).
if _is_pyz_verify_cmd(cmd):
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
if cmd.startswith("tar -cz -C"):
return (_Stdin(cmd), _Stdout(tgz, rc=0), _Stderr(b""))
if cmd == "mktemp -d":
@ -952,12 +968,7 @@ def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive(
import enroll.remote as r
monkeypatch.setattr(
r,
"_build_enroll_pyz",
lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
or (Path(td) / "enroll.pyz"),
)
monkeypatch.setattr(r, "_build_enroll_pyz", _fake_build_enroll_pyz)
class _Chan:
def __init__(self):
@ -1022,6 +1033,9 @@ def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive(
return self._sftp
def exec_command(self, cmd: str, **_kwargs):
# Integrity check of the uploaded pyz (added with SHA-256 verify).
if _is_pyz_verify_cmd(cmd):
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
return (_Stdout(), _Stdout(), _Stderr())
def close(self):
@ -1048,3 +1062,124 @@ def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive(
remote_host="example.com",
stdin=io.StringIO(),
)
# --------------------------------------------------------------------------- #
# Integrity of the uploaded zipapp before remote (root) execution.
# --------------------------------------------------------------------------- #
class _VerifyChan:
"""Minimal Paramiko-channel stand-in for _ssh_run consumption."""
def __init__(self, out: bytes = b"", err: bytes = b"", rc: int = 0):
self._out, self._err, self._rc = out, err, rc
self._oi = self._ei = 0
self._closed = False
def recv_ready(self) -> bool:
return (not self._closed) and self._oi < len(self._out)
def recv(self, n: int) -> bytes:
chunk = self._out[self._oi : self._oi + n]
self._oi += len(chunk)
return chunk
def recv_stderr_ready(self) -> bool:
return (not self._closed) and self._ei < len(self._err)
def recv_stderr(self, n: int) -> bytes:
chunk = self._err[self._ei : self._ei + n]
self._ei += len(chunk)
return chunk
def exit_status_ready(self) -> bool:
return self._closed or (
self._oi >= len(self._out) and self._ei >= len(self._err)
)
def recv_exit_status(self) -> int:
return self._rc
def shutdown_write(self) -> None:
return
def close(self) -> None:
self._closed = True
class _VerifyStdio:
def __init__(self, chan: _VerifyChan):
self.channel = chan
def _verify_ssh(out: bytes, *, rc: int = 0):
chan = _VerifyChan(out=out, rc=rc)
class FakeSSH:
def exec_command(self, cmd, *, get_pty=False, **_kw):
assert "hashlib.sha256" in cmd # this is the verify command
return (io.StringIO(), _VerifyStdio(chan), _VerifyStdio(chan))
return FakeSSH()
def test_remote_verify_pyz_sha256_rejects_mismatch():
import enroll.remote as r
ssh = _verify_ssh(b"0" * 64)
with pytest.raises(RuntimeError, match="Integrity check failed"):
r._remote_verify_pyz_sha256(
ssh, "/tmp/x/enroll.pyz", "a" * 64, remote_python="python3"
)
def test_remote_verify_pyz_sha256_accepts_match():
import enroll.remote as r
expected = "a" * 64
ssh = _verify_ssh(expected.encode())
# Should not raise.
r._remote_verify_pyz_sha256(
ssh, "/tmp/x/enroll.pyz", expected, remote_python="python3"
)
def test_remote_verify_pyz_sha256_rejects_empty():
import enroll.remote as r
ssh = _verify_ssh(b"")
with pytest.raises(RuntimeError, match="empty SHA-256"):
r._remote_verify_pyz_sha256(
ssh, "/tmp/x/enroll.pyz", "a" * 64, remote_python="python3"
)
def test_remote_verify_pyz_sha256_rejects_nonzero_rc():
import enroll.remote as r
ssh = _verify_ssh(b"", rc=2)
with pytest.raises(RuntimeError, match="verify the integrity"):
r._remote_verify_pyz_sha256(
ssh, "/tmp/x/enroll.pyz", "a" * 64, remote_python="python3"
)
def test_build_enroll_pyz_excludes_tests_and_caches_and_returns_sha(tmp_path: Path):
import zipfile
import enroll.remote as r
pyz, sha = r._build_enroll_pyz(tmp_path)
assert isinstance(sha, str) and len(sha) == 64
assert sha == r._sha256_file(pyz)
names = zipfile.ZipFile(pyz).namelist()
assert any(n.endswith("schema/state.schema.json") for n in names)
for n in names:
leaf = n.rsplit("/", 1)[-1]
assert "__pycache__" not in n
assert not n.endswith((".pyc", ".pyo"))
assert "/tests/" not in n
assert not leaf.startswith("test_")
assert leaf != "conftest.py"