diff --git a/enroll/diff.py b/enroll/diff.py index 365d107..110ca9d 100644 --- a/enroll/diff.py +++ b/enroll/diff.py @@ -31,6 +31,27 @@ from .pathfilter import PathFilter from .sopsutil import decrypt_file_binary_to, require_sops_cmd +def _validate_diff_bundle(label: str, bundle_dir: Path) -> None: + """Validate a resolved harvest bundle before diff reads artifacts. + + `diff` intentionally compares older harvests, so keep schema validation out + of this internal safety pass. The important security property here is that + the bundle's artifact tree has the same path/symlink/hardlink/special-file + checks that `manifest` relies on before copying artifacts. + """ + + # Import lazily to avoid a module-level cycle: enroll.validate imports + # BundleRef/_bundle_from_input from this module. + from .validate import validate_harvest + + validation = validate_harvest(str(bundle_dir), no_schema=True) + if not validation.ok: + raise RuntimeError( + f"{label} harvest failed validation; refusing to diff unsafe bundle.\n" + + validation.to_text().strip() + ) + + def _progress_enabled() -> bool: """Return True if we should display interactive progress UI on the CLI. @@ -371,6 +392,9 @@ def compare_harvests( if new_b.tempdir: stack.callback(new_b.tempdir.cleanup) + _validate_diff_bundle("old", old_b.dir) + _validate_diff_bundle("new", new_b.dir) + old_state = _load_state(old_b.dir) new_state = _load_state(new_b.dir) diff --git a/tests/test_diff_bundle.py b/tests/test_diff_bundle.py index 2895484..f8c7a3b 100644 --- a/tests/test_diff_bundle.py +++ b/tests/test_diff_bundle.py @@ -701,6 +701,54 @@ def test_compare_harvests_with_exclude_paths(tmp_path: Path): assert "/etc/passwd" not in [f["path"] for f in report["files"]["changed"]] +def test_compare_harvests_rejects_unsafe_artifact_symlink(tmp_path: Path): + secret = tmp_path / "secret.txt" + secret.write_text("do not hash me", encoding="utf-8") + + old_bundle = tmp_path / "old" + old_artifacts = old_bundle / "artifacts" / "users" + old_artifacts.mkdir(parents=True) + (old_artifacts / "passwd").symlink_to(secret) + (old_bundle / "state.json").write_text( + json.dumps( + { + "inventory": {"packages": {}}, + "roles": { + "users": { + "managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}] + } + }, + } + ), + encoding="utf-8", + ) + + new_bundle = tmp_path / "new" + new_artifacts = new_bundle / "artifacts" / "users" + new_artifacts.mkdir(parents=True) + (new_artifacts / "passwd").write_text("safe", encoding="utf-8") + (new_bundle / "state.json").write_text( + json.dumps( + { + "inventory": {"packages": {}}, + "roles": { + "users": { + "managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}] + } + }, + } + ), + encoding="utf-8", + ) + + with pytest.raises(RuntimeError) as exc_info: + compare_harvests(str(old_bundle), str(new_bundle)) + + msg = str(exc_info.value) + assert "old harvest failed validation" in msg + assert "artifact is a symlink" in msg + + def test_utc_now_iso(): result = _utc_now_iso() assert "T" in result