Close TOCTOU gap by freezing directory harvest bundles into a private 0700 copy before the two consumers that actually read artifacts (manifest and diff) touch them.

This commit is contained in:
Miguel Jacq 2026-06-28 21:07:49 +10:00
parent 1bfbfd90f6
commit a2dc054882
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
7 changed files with 347 additions and 24 deletions

View file

@ -7,6 +7,7 @@ from pathlib import Path
import pytest
from enroll.ansible import _role_tag
from enroll.manifest_safety import freeze_directory_bundle
from enroll.diff import (
_Spinner,
_utc_now_iso,
@ -34,6 +35,8 @@ def test_bundle_from_directory_and_statejson_path(tmp_path: Path):
b = _make_bundle_dir(tmp_path)
# Plain resolution (no freeze) returns the directory in place. Freezing is
# opt-in and exercised by the consumer-level tests below.
br1 = d._bundle_from_input(str(b), sops_mode=False)
assert br1.dir == b
assert br1.state_path.exists()
@ -42,6 +45,31 @@ def test_bundle_from_directory_and_statejson_path(tmp_path: Path):
assert br2.dir == b
def test_bundle_from_input_directory_freeze_copies_and_protects(tmp_path: Path):
import enroll.diff as d
b = _make_bundle_dir(tmp_path)
art = b / "artifacts" / "app"
art.mkdir(parents=True)
(art / "f.conf").write_text("orig\n", encoding="utf-8")
# With freeze=True the bundle is copied into a private temp dir, and mutating
# the source afterwards does not change the frozen copy.
result = d._bundle_from_input(str(b), sops_mode=False, freeze=True)
try:
assert result.dir != b
assert result.tempdir is not None
frozen_f = result.dir / "artifacts" / "app" / "f.conf"
assert frozen_f.read_text(encoding="utf-8") == "orig\n"
# Attacker rewrites the source after the freeze; frozen copy is unaffected.
(art / "f.conf").write_text("tampered\n", encoding="utf-8")
assert frozen_f.read_text(encoding="utf-8") == "orig\n"
finally:
if result.tempdir:
result.tempdir.cleanup()
def test_bundle_from_tarball_extracts(tmp_path: Path):
import enroll.diff as d
@ -742,9 +770,68 @@ def test_compare_harvests_rejects_unsafe_artifact_symlink(tmp_path: Path):
with pytest.raises(RuntimeError) as exc_info:
compare_harvests(str(old_bundle), str(new_bundle))
# The unsafe symlinked artifact is now rejected when the directory bundle is
# frozen into a private copy (no-follow traversal), which happens before
# validation. The diff is still refused; the rejection just fires earlier.
msg = str(exc_info.value)
assert "old harvest failed validation" in msg
assert "artifact is a symlink" in msg
assert "symlink" in msg.lower()
def test_compare_harvests_freezes_directory_bundles_against_source_mutation(
tmp_path: Path,
):
"""End-to-end: a directory diff must read frozen copies, so mutating a source
bundle's artifact after compare_harvests has frozen it cannot change the
result or redirect a read to a secret."""
def _bundle(name: str, content: str) -> Path:
b = tmp_path / name
art = b / "artifacts" / "users"
art.mkdir(parents=True)
(art / "passwd").write_text(content, encoding="utf-8")
(b / "state.json").write_text(
json.dumps(
{
"inventory": {"packages": {}},
"roles": {
"users": {
"managed_files": [
{"path": "/etc/passwd", "src_rel": "passwd"}
]
}
},
}
),
encoding="utf-8",
)
return b
old_bundle = _bundle("old", "same\n")
new_bundle = _bundle("new", "same\n")
# Identical content -> no file content drift.
report, _ = compare_harvests(str(old_bundle), str(new_bundle))
changed_paths = [f["path"] for f in report["files"]["changed"]]
assert "/etc/passwd" not in changed_paths
# The freeze already happened inside compare_harvests above and was torn down,
# so to prove immunity during a single call we instead confirm that the frozen
# copy is what gets hashed: rewrite the source between freeze and hash is not
# observable to the operator because the consumed tree is a private copy.
# Confirm at the unit level that a post-freeze swap to a secret symlink is not
# followed by the hashing path.
secret = tmp_path / "secret"
secret.write_text("TOP-SECRET\n", encoding="utf-8")
frozen_dir, td = freeze_directory_bundle(old_bundle)
try:
src = old_bundle / "artifacts" / "users" / "passwd"
src.unlink()
src.symlink_to(secret)
frozen = Path(frozen_dir) / "artifacts" / "users" / "passwd"
assert not frozen.is_symlink()
assert frozen.read_text(encoding="utf-8") == "same\n"
finally:
td.cleanup()
def test_utc_now_iso():

View file

@ -9,6 +9,7 @@ from enroll.manifest_safety import (
ArtifactSafetyError,
ManifestOutputError,
copy_safe_artifact_file,
freeze_directory_bundle,
iter_safe_artifact_files,
prepare_manifest_output_dir,
safe_artifact_file,
@ -124,3 +125,91 @@ def test_iter_safe_artifact_files_rejects_symlink_subdir(tmp_path: Path):
with pytest.raises(ArtifactSafetyError, match="directory is a symlink"):
list(iter_safe_artifact_files(bundle, "role"))
def _write_bundle(tmp_path: Path) -> Path:
bundle = tmp_path / "bundle"
art = bundle / "artifacts" / "app" / "etc" / "app"
art.mkdir(parents=True)
(art / "app.conf").write_text("original-content\n", encoding="utf-8")
(bundle / "state.json").write_text("{}\n", encoding="utf-8")
return bundle
def test_freeze_directory_bundle_copies_content(tmp_path: Path):
bundle = _write_bundle(tmp_path)
frozen_dir, td = freeze_directory_bundle(bundle)
try:
frozen = Path(frozen_dir)
assert frozen != bundle
assert (frozen / "state.json").read_text(encoding="utf-8") == "{}\n"
assert (frozen / "artifacts" / "app" / "etc" / "app" / "app.conf").read_text(
encoding="utf-8"
) == "original-content\n"
# The frozen copy is a private directory.
mode = (Path(frozen_dir)).stat().st_mode & 0o777
assert mode == 0o700
finally:
td.cleanup()
def test_freeze_directory_bundle_is_immutable_after_source_swap(tmp_path: Path):
"""The core TOCTOU regression: mutating the source after freezing must not
change what a consumer reads, and must not let a post-freeze symlink redirect
reads to a secret."""
bundle = _write_bundle(tmp_path)
art_file = bundle / "artifacts" / "app" / "etc" / "app" / "app.conf"
frozen_dir, td = freeze_directory_bundle(bundle)
try:
secret = tmp_path / "secret"
secret.write_text("TOP-SECRET\n", encoding="utf-8")
# Attacker swaps the validated regular file for a symlink to a secret and
# also rewrites another path's content. None of this may affect the frozen
# copy the consumer actually uses.
art_file.unlink()
art_file.symlink_to(secret)
frozen_file = (
Path(frozen_dir) / "artifacts" / "app" / "etc" / "app" / "app.conf"
)
assert not frozen_file.is_symlink()
assert frozen_file.read_text(encoding="utf-8") == "original-content\n"
assert "TOP-SECRET" not in frozen_file.read_text(encoding="utf-8")
finally:
td.cleanup()
def test_freeze_directory_bundle_rejects_symlinked_file(tmp_path: Path):
bundle = _write_bundle(tmp_path)
secret = tmp_path / "secret"
secret.write_text("secret\n", encoding="utf-8")
# A bundle that already contains a symlinked artifact at freeze time is
# rejected outright rather than silently following it.
link = bundle / "artifacts" / "app" / "etc" / "app" / "link.conf"
link.symlink_to(secret)
with pytest.raises(ArtifactSafetyError, match="symlink"):
freeze_directory_bundle(bundle)
def test_freeze_directory_bundle_rejects_symlinked_subdir(tmp_path: Path):
bundle = _write_bundle(tmp_path)
outside = tmp_path / "outside"
outside.mkdir()
(outside / "x.conf").write_text("x\n", encoding="utf-8")
(bundle / "artifacts" / "app" / "linkdir").symlink_to(
outside, target_is_directory=True
)
with pytest.raises(ArtifactSafetyError, match="symlink"):
freeze_directory_bundle(bundle)
def test_freeze_directory_bundle_rejects_hardlinked_file(tmp_path: Path):
bundle = _write_bundle(tmp_path)
secret = tmp_path / "secret"
secret.write_text("secret\n", encoding="utf-8")
hard = bundle / "artifacts" / "app" / "etc" / "app" / "hard.conf"
os.link(secret, hard)
with pytest.raises(ArtifactSafetyError, match="hardlink"):
freeze_directory_bundle(bundle)