* 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.
All checks were successful
CI / test (push) Successful in 41s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 9m2s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 13m39s
Lint / test (push) Successful in 39s

* 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: apply aggregate byte and total filesystem-entry limits when freezing directory harvest bundles, reject symlinked bundle roots, and abort when files or discovered directories change during the copy, so direct directory inputs remain bounded and fail closed under mutation.
This commit is contained in:
Miguel Jacq 2026-07-13 10:15:43 +10:00
parent da0d8851d3
commit 5bf247c485
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
9 changed files with 823 additions and 391 deletions

View file

@ -212,6 +212,59 @@ def test_freeze_directory_bundle_rejects_symlinked_subdir(tmp_path: Path):
freeze_directory_bundle(bundle)
def test_freeze_directory_bundle_rejects_symlinked_root(tmp_path: Path):
bundle = _write_bundle(tmp_path)
link = tmp_path / "bundle-link"
link.symlink_to(bundle, target_is_directory=True)
with pytest.raises(ArtifactSafetyError, match="root is a symlink"):
freeze_directory_bundle(link)
def test_freeze_directory_bundle_fails_if_discovered_dir_disappears(
tmp_path: Path, monkeypatch
):
import enroll.manifest_safety as ms
bundle = _write_bundle(tmp_path)
unstable = bundle / "unstable"
unstable.mkdir()
real_lstat = ms.Path.lstat
def fake_lstat(self):
if self == unstable:
raise FileNotFoundError(str(self))
return real_lstat(self)
monkeypatch.setattr(ms.Path, "lstat", fake_lstat)
with pytest.raises(ArtifactSafetyError, match="changed while being frozen"):
freeze_directory_bundle(bundle)
def test_freeze_directory_bundle_fails_if_file_changes_during_read(
tmp_path: Path, monkeypatch
):
import enroll.manifest_safety as ms
bundle = _write_bundle(tmp_path)
changing = bundle / "state.json"
real_read = ms.os.read
changed = False
def mutating_read(fd: int, size: int) -> bytes:
nonlocal changed
data = real_read(fd, size)
if not changed and data:
changed = True
# Same length: size-only checking would miss this in-place rewrite.
changing.write_bytes(b'{"tampered":1}')
return data
monkeypatch.setattr(ms.os, "read", mutating_read)
with pytest.raises(ArtifactSafetyError, match="changed while being frozen"):
freeze_directory_bundle(bundle)
def test_freeze_directory_bundle_rejects_hardlinked_file(tmp_path: Path):
bundle = _write_bundle(tmp_path)
secret = tmp_path / "secret"
@ -222,6 +275,37 @@ def test_freeze_directory_bundle_rejects_hardlinked_file(tmp_path: Path):
freeze_directory_bundle(bundle)
def test_freeze_directory_bundle_rejects_aggregate_size_bomb(
tmp_path: Path, monkeypatch
):
import enroll.manifest_safety as ms
bundle = _write_bundle(tmp_path)
extra = bundle / "artifacts" / "app" / "etc" / "app" / "extra.conf"
extra.write_bytes(b"x" * 32)
# Each file is individually small, but the aggregate must still be bounded.
monkeypatch.setattr(ms, "_FREEZE_MAX_TOTAL_BYTES", 32)
with pytest.raises(ArtifactSafetyError, match="total file size exceeds"):
freeze_directory_bundle(bundle)
def test_freeze_directory_bundle_counts_directories_against_entry_cap(
tmp_path: Path, monkeypatch
):
import enroll.manifest_safety as ms
bundle = _write_bundle(tmp_path)
(bundle / "empty-a").mkdir()
(bundle / "empty-b").mkdir()
# Directory-only trees consume inodes and traversal time too. The old
# file-only limit allowed an unbounded number of empty directories.
monkeypatch.setattr(ms, "_FREEZE_MAX_ENTRIES", 2)
with pytest.raises(ArtifactSafetyError, match="too many filesystem entries"):
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.