Compare commits

..

3 commits

Author SHA1 Message Date
5757bf4275
Update DEVELOPMENT.md
All checks were successful
CI / test (push) Successful in 51s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 12m41s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 22m25s
Lint / test (push) Successful in 1m19s
2026-06-22 17:23:31 +10:00
992b8060a5
validation of artifact dir 2026-06-22 17:23:25 +10:00
efb6d7cc15
Be strict about XDG_CACHE_DIR ownership etc 2026-06-22 17:22:27 +10:00
6 changed files with 217 additions and 78 deletions

View file

@ -376,14 +376,23 @@ Puppet and Salt also run `cm.resolve_catalog_conflicts()` after renderer role co
capture_file(abs_path, role_name, reason, policy, path_filter, ...) capture_file(abs_path, role_name, reason, policy, path_filter, ...)
-> skip if already seen globally or in this role -> skip if already seen globally or in this role
-> skip if --exclude-path matches -> skip if --exclude-path matches
-> ask IgnorePolicy.deny_reason(abs_path) -> ask IgnorePolicy.inspect_file(abs_path)
-> stat owner/group/mode with fsutil.stat_triplet() -> open the source through fsutil.open_no_follow_path()
-> copy to artifacts/<role_name>/<abs_path without leading slash> -> reject symlinks in any path component, not only the leaf
-> fstat() the opened descriptor
-> reject non-regular or over-size files
-> read the exact bytes from that descriptor
-> scan those bytes for binary/secret content unless dangerous
-> use the inspected fstat() for owner/group/mode metadata
-> write the inspected bytes to artifacts/<role_name>/<abs_path without leading slash>
with no-follow destination creation
-> append ManagedFile -> append ManagedFile
-> mark seen in role/global -> mark seen in role/global
``` ```
`fsutil.stat_triplet()` returns owner, group, and a zero-padded octal mode string. It falls back to numeric uid/gid strings if user/group names cannot be resolved. This ordering is intentional. Enroll should not scan one file and later copy a different file after a race. When `IgnorePolicy.inspect_file()` succeeds, `capture_file()` writes the exact bytes that were inspected and uses the same descriptor's stat metadata.
`fsutil.stat_triplet()` and `stat_triplet_from_stat()` return owner, group, and a zero-padded octal mode string. They fall back to numeric uid/gid strings if user/group names cannot be resolved.
### 7.2 `capture_link()` ### 7.2 `capture_link()`
@ -450,6 +459,20 @@ regex:^/path/...$ regex
`expand_includes()` is conservative: it ignores symlinks, respects excludes, caps file counts, and returns notes for unmatched patterns or caps. `expand_includes()` is conservative: it ignores symlinks, respects excludes, caps file counts, and returns notes for unmatched patterns or caps.
### 7.6 Output, artifact, and cache safety helpers
Several safety helpers protect privileged runs from following attacker-controlled paths:
- `fsutil.open_no_follow_path()` opens source and artifact paths component-by-component, rejecting symlinked parent directories as well as symlinked leaf files.
- `harvest_safety.prepare_new_private_dir()` is used for user-facing plaintext output directories such as `harvest --out` and default manifest output; it refuses existing final paths and creates `0700` directories.
- `harvest_safety.ensure_safe_output_parent()` is used when writing output files such as reports or encrypted SOPS bundles. It validates parents before staging a temporary file and atomically replacing the final path.
- `harvest_safety.ensure_private_dir()` is used for persistent internal directories such as Enroll's cache root. Existing directories are allowed, but symlink components and unsafe root-run parents are refused.
- `cache.new_harvest_cache_dir()` creates unpredictable per-harvest cache directories beneath the hardened cache root with `mkdtemp()` and private permissions.
- `manifest_safety.safe_artifact_file()` validates referenced harvested artifacts before renderers copy them. It rejects absolute or `..` paths, symlinks, non-regular files, hardlinks, and paths that resolve outside the artifact root.
- `manifest_safety.prepare_manifest_output_dir()` refuses unsafe manifest output paths. In `--fqdn` site mode, where an existing tree is intentionally reused, it walks the existing output tree and refuses symlinks before merging generated files.
When adding a new code path that writes plaintext host state, prefer these helpers over raw `mkdir(parents=True)`, `open()`, `shutil.copy*()`, or `tar.extract*()`.
--- ---
## 8. Platform and package backends ## 8. Platform and package backends
@ -788,8 +811,7 @@ SOPS mode:
The renderers do not know about SOPS. The renderers do not know about SOPS.
Note: Manifest deliberately hooks into validate() to make sure the harvest meets the schema and Before dispatching to a renderer, `manifest.manifest()` calls `validate.validate_harvest()` with normal schema validation enabled. That means generated configuration-management code is only rendered after Enroll has checked the bundle schema, referenced artifact existence, and artifact safety. If validation fails, manifest generation stops rather than trying to produce best-effort output from a malformed or tampered bundle.
doesn't contain dangerous tamperings before turning it into config management code.
--- ---
@ -1380,17 +1402,24 @@ This is intended to answer “what did Enroll collect and why?”
1. `state.json` exists, 1. `state.json` exists,
2. it parses as JSON, 2. it parses as JSON,
3. it validates against the vendored schema unless `--no-schema` is set, 3. it validates against the vendored schema unless `--no-schema` is set,
4. every `managed_file.src_rel` points to an artifact file, 4. every `managed_file.src_rel` is relative and points to a safe artifact file,
5. firewall runtime generated artifacts exist, 5. firewall runtime generated artifacts exist and are safe,
6. there are no unreferenced artifact files, reported as warnings. 6. the top-level `artifacts/` path is a real directory rather than a symlink or file,
7. there are no malicious or unsafe bits such as symlinks/hardlinks etc traversing out of the artifact tree 7. the whole artifact tree contains no symlink directories, symlink files, hardlinks, special files, or paths that escape the artifact root,
8. unreferenced artifact files are reported as warnings.
`validate_harvest()` is used in three important contexts:
- `enroll validate` exposes the checks directly to users.
- `manifest.manifest()` validates before rendering Ansible/Puppet/Salt output.
- `diff.compare_harvests()` validates both input bundles before comparing them, using `no_schema=True` so older harvests can still be inspected while artifact safety checks remain active.
`diff --enforce` renders the old harvest through `manifest.manifest()`, so enforcement also passes through manifest-time validation before a local apply tool is invoked.
It returns a `ValidationResult` with `errors`, `warnings`, `ok()`, `to_dict()`, and `to_text()`. It returns a `ValidationResult` with `errors`, `warnings`, `ok()`, `to_dict()`, and `to_text()`.
The CLI supports local schema override with `--schema`, warning failure with `--fail-on-warnings`, JSON/text output, and `--out`. The CLI supports local schema override with `--schema`, warning failure with `--fail-on-warnings`, JSON/text output, and `--out`.
Note that manifest() hooks into validate() to make sure the harvest is safe before rendering it into config management code.
--- ---
## 19. Remote harvesting ## 19. Remote harvesting
@ -1457,7 +1486,7 @@ Unknown host keys are rejected by default through Paramiko's reject policy. User
- device nodes, - device nodes,
- anything resolving outside the destination. - anything resolving outside the destination.
This helper is reused by remote harvest, manifest SOPS extraction, and diff bundle resolution. This helper is reused by remote harvest, manifest SOPS extraction, validate/diff bundle resolution, and any code path that needs to unpack a harvest tarball. Do not use raw `tar.extractall()` for user- or remote-provided bundles.
--- ---

View file

@ -8,6 +8,8 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from .harvest_safety import OutputSafetyError, ensure_private_dir
def _safe_component(s: str) -> str: def _safe_component(s: str) -> str:
s = s.strip() s = s.strip()
@ -44,16 +46,17 @@ class HarvestCache:
def _ensure_dir_secure(path: Path) -> None: def _ensure_dir_secure(path: Path) -> None:
"""Create a directory with restrictive permissions; refuse symlinks.""" """Create a private cache directory with output-path safety checks.
# Refuse a symlink at the leaf.
if path.exists() and path.is_symlink(): Cache roots are persistent, so existing directories are allowed, but they
raise RuntimeError(f"Refusing to use symlink path: {path}") still need the same symlink-component and root-parent trust checks as
path.mkdir(parents=True, exist_ok=True, mode=0o700) plaintext harvest/manifest output paths.
"""
try: try:
os.chmod(path, 0o700) ensure_private_dir(path, label="cache directory")
except OSError: except OutputSafetyError as e:
# Best-effort; on some FS types chmod may fail. raise RuntimeError(str(e)) from e
pass
def new_harvest_cache_dir(*, hint: Optional[str] = None) -> HarvestCache: def new_harvest_cache_dir(*, hint: Optional[str] = None) -> HarvestCache:

View file

@ -69,6 +69,8 @@ def _assert_existing_output_dir_component(path: Path, *, label: str) -> None:
raise OutputSafetyError( raise OutputSafetyError(
f"{label} parent path contains a symlink; refusing: {path}" f"{label} parent path contains a symlink; refusing: {path}"
) )
if not stat.S_ISDIR(st.st_mode):
raise OutputSafetyError(f"{label} parent is not a directory: {path}")
_assert_trusted_root_parent(path, st, label=label) _assert_trusted_root_parent(path, st, label=label)
@ -214,6 +216,24 @@ def write_text_output_file(
return out return out
def ensure_private_dir(path: str | Path, *, label: str = "output") -> Path:
"""Create or validate a private directory without requiring it to be empty.
This is for persistent internal directories such as Enroll's cache root,
where existing contents are expected across runs. It uses the same
component-by-component symlink and root-parent trust checks as user-facing
plaintext output directories, but permits an existing final directory.
"""
out = Path(path).expanduser()
sentinel = out / ".enroll-private-dir-check"
_assert_no_existing_symlink_components(sentinel, label=label)
out = _mkdir_private_dir_tree(out, label=label, final_must_be_new=False)
_assert_no_existing_symlink_components(sentinel, label=label)
_chmod_private(out)
return out
def prepare_new_private_dir(path: str | Path, *, label: str = "output") -> Path: def prepare_new_private_dir(path: str | Path, *, label: str = "output") -> Path:
"""Create a brand-new private output directory. """Create a brand-new private output directory.

View file

@ -232,65 +232,79 @@ def validate_harvest(
# Validate the whole artifact tree too, so unreferenced symlinks, # Validate the whole artifact tree too, so unreferenced symlinks,
# hardlinks, special files, and path-shaping tricks do not survive # hardlinks, special files, and path-shaping tricks do not survive
# validation simply because no managed_file currently references them. # validation simply because no managed_file currently references them.
if artifacts_dir.exists() and artifacts_dir.is_dir(): if artifacts_dir.exists():
for root, dirs, files in os.walk(artifacts_dir, followlinks=False): try:
root_p = Path(root) artifacts_st = artifacts_dir.lstat()
for name in list(dirs): except OSError as e:
fp = root_p / name errors.append(f"unable to inspect artifacts directory: {e}")
try: else:
st = fp.lstat() if stat.S_ISLNK(artifacts_st.st_mode):
except FileNotFoundError: errors.append(f"artifacts directory is a symlink: {artifacts_dir}")
continue elif not stat.S_ISDIR(artifacts_st.st_mode):
if stat.S_ISLNK(st.st_mode): errors.append(f"artifacts path is not a directory: {artifacts_dir}")
errors.append(f"artifact directory is a symlink: {fp}") else:
elif not stat.S_ISDIR(st.st_mode): for root, dirs, files in os.walk(artifacts_dir, followlinks=False):
errors.append(f"artifact directory is not a directory: {fp}") root_p = Path(root)
for name in list(dirs):
fp = root_p / name
try:
st = fp.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(st.st_mode):
errors.append(f"artifact directory is a symlink: {fp}")
elif not stat.S_ISDIR(st.st_mode):
errors.append(
f"artifact directory is not a directory: {fp}"
)
for name in files: for name in files:
fp = root_p / name fp = root_p / name
try: try:
st = fp.lstat() st = fp.lstat()
except FileNotFoundError: except FileNotFoundError:
continue continue
try: try:
rel = fp.relative_to(artifacts_dir) rel = fp.relative_to(artifacts_dir)
except ValueError: except ValueError:
errors.append(f"artifact escapes artifact root: {fp}") errors.append(f"artifact escapes artifact root: {fp}")
continue continue
parts = rel.parts parts = rel.parts
if len(parts) < 2: if len(parts) < 2:
errors.append(f"artifact is not under a role directory: {fp}") errors.append(
continue f"artifact is not under a role directory: {fp}"
role_name = parts[0] )
src_rel = "/".join(parts[1:]) continue
role_name = parts[0]
src_rel = "/".join(parts[1:])
if stat.S_ISLNK(st.st_mode): if stat.S_ISLNK(st.st_mode):
errors.append( errors.append(
f"artifact is a symlink: artifacts/{role_name}/{src_rel}" f"artifact is a symlink: artifacts/{role_name}/{src_rel}"
) )
continue continue
if not stat.S_ISREG(st.st_mode): if not stat.S_ISREG(st.st_mode):
errors.append( errors.append(
f"artifact is not a regular file: artifacts/{role_name}/{src_rel}" f"artifact is not a regular file: artifacts/{role_name}/{src_rel}"
) )
continue continue
if st.st_nlink > 1: if st.st_nlink > 1:
errors.append( errors.append(
f"artifact is hardlinked: artifacts/{role_name}/{src_rel}" f"artifact is hardlinked: artifacts/{role_name}/{src_rel}"
) )
continue continue
try: try:
safe_artifact_file(bundle.dir, role_name, src_rel) safe_artifact_file(bundle.dir, role_name, src_rel)
except (FileNotFoundError, ArtifactSafetyError) as e: except (FileNotFoundError, ArtifactSafetyError) as e:
errors.append( errors.append(
f"unsafe artifact: artifacts/{role_name}/{src_rel}: {e}" f"unsafe artifact: artifacts/{role_name}/{src_rel}: {e}"
) )
continue continue
if (role_name, src_rel) not in referenced: if (role_name, src_rel) not in referenced:
warnings.append( warnings.append(
f"unreferenced artifact present: artifacts/{role_name}/{src_rel}" f"unreferenced artifact present: artifacts/{role_name}/{src_rel}"
) )
return ValidationResult(errors=errors, warnings=warnings) return ValidationResult(errors=errors, warnings=warnings)
finally: finally:

View file

@ -95,3 +95,44 @@ def test_enroll_cache_dir_uses_default_when_xdg_not_set(monkeypatch):
monkeypatch.delenv("XDG_CACHE_HOME", raising=False) monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
result = enroll_cache_dir() result = enroll_cache_dir()
assert str(result).endswith("/.local/cache/enroll") assert str(result).endswith("/.local/cache/enroll")
def test_ensure_dir_secure_refuses_symlink_parent(tmp_path: Path):
from enroll.cache import _ensure_dir_secure
target = tmp_path / "target"
target.mkdir()
link = tmp_path / "link"
link.symlink_to(target, target_is_directory=True)
with pytest.raises(RuntimeError, match="symlink"):
_ensure_dir_secure(link / "enroll" / "harvest")
assert not (target / "enroll" / "harvest").exists()
def test_ensure_dir_secure_rejects_unsafe_root_parent(tmp_path: Path, monkeypatch):
from enroll.cache import _ensure_dir_secure
import enroll.harvest_safety as hs
untrusted = tmp_path / "untrusted"
untrusted.mkdir()
untrusted.chmod(0o777)
monkeypatch.setattr(hs, "_effective_uid", lambda: 0)
with pytest.raises(RuntimeError, match="not owned by root|writable by group/other"):
_ensure_dir_secure(untrusted / "cache")
def test_ensure_dir_secure_rejects_existing_file_when_not_root(
tmp_path: Path, monkeypatch
):
from enroll.cache import _ensure_dir_secure
import enroll.harvest_safety as hs
path = tmp_path / "cache"
path.write_text("not a dir", encoding="utf-8")
monkeypatch.setattr(hs, "_effective_uid", lambda: 1000)
with pytest.raises(RuntimeError, match="not a directory"):
_ensure_dir_secure(path)

View file

@ -453,3 +453,35 @@ def test_validate_harvest_rejects_unreferenced_artifact_symlink(tmp_path: Path):
assert result.ok is False assert result.ok is False
assert any("symlink" in e for e in result.errors) assert any("symlink" in e for e in result.errors)
def test_validate_harvest_rejects_top_level_artifacts_symlink(tmp_path: Path):
bundle_dir = tmp_path / "bundle"
bundle_dir.mkdir()
target = tmp_path / "artifact-target"
target.mkdir()
(bundle_dir / "artifacts").symlink_to(target, target_is_directory=True)
(bundle_dir / "state.json").write_text(
json.dumps({"roles": {"users": {"managed_files": []}}}),
encoding="utf-8",
)
result = validate_harvest(str(bundle_dir), no_schema=True)
assert result.ok is False
assert any("artifacts directory is a symlink" in e for e in result.errors)
def test_validate_harvest_rejects_top_level_artifacts_file(tmp_path: Path):
bundle_dir = tmp_path / "bundle"
bundle_dir.mkdir()
(bundle_dir / "artifacts").write_text("not a directory", encoding="utf-8")
(bundle_dir / "state.json").write_text(
json.dumps({"roles": {"users": {"managed_files": []}}}),
encoding="utf-8",
)
result = validate_harvest(str(bundle_dir), no_schema=True)
assert result.ok is False
assert any("artifacts path is not a directory" in e for e in result.errors)