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

@ -1275,21 +1275,6 @@ def _render_role_handlers(
return "---\n" + (body.rstrip() + "\n" if body else "")
# --- Ansible variable builders ---
def _normalise_flatpak_item(
item: Any,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> Dict[str, Any]:
return CMModule.normalise_flatpak_item(item, method=method, user=user, home=home)
def _normalise_flatpak_remote(item: Any) -> Dict[str, Any]:
return CMModule.normalise_flatpak_remote(item)
def _normalise_snap_item(item: Any) -> Dict[str, Any]:
out = CMModule.normalise_snap_item(item)

View file

@ -28,6 +28,7 @@ from .state import (
)
from .pathfilter import PathFilter
from .sopsutil import decrypt_file_binary_to, require_sops_cmd
from .manifest_safety import freeze_directory_bundle
def _validate_diff_bundle(label: str, bundle_dir: Path) -> None:
@ -145,7 +146,9 @@ class BundleRef:
return state_path(self.dir)
def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
def _bundle_from_input(
path: str, *, sops_mode: bool, freeze: bool = False
) -> BundleRef:
"""Resolve a user-supplied path to a harvest bundle directory.
Accepts:
@ -153,6 +156,14 @@ def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
- a path to state.json inside a bundle directory
- (sops mode or .sops) a SOPS-encrypted tar.gz bundle
- a plain tar.gz/tgz bundle
When ``freeze`` is True, a plain *directory* input is copied into a private
0700 temp directory (no-follow, regular-files-only) before being returned, so
a later consumer cannot be raced by an unprivileged owner mutating the source
directory after validation. Tar/SOPS inputs are always extracted into a
private temp directory and so are inherently frozen. ``freeze`` is left False
for purely diagnostic callers (e.g. ``validate``) that should report on the
exact directory the operator named rather than on a copy of it.
"""
p = Path(path).expanduser()
@ -162,6 +173,9 @@ def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
p = p.parent
if p.is_dir():
if freeze:
frozen_dir, td_frozen = freeze_directory_bundle(p, label="harvest bundle")
return BundleRef(dir=Path(frozen_dir), tempdir=td_frozen)
return BundleRef(dir=p)
if not p.exists():
@ -384,8 +398,8 @@ def compare_harvests(
Returns (report, has_changes).
"""
with ExitStack() as stack:
old_b = _bundle_from_input(old_path, sops_mode=sops_mode)
new_b = _bundle_from_input(new_path, sops_mode=sops_mode)
old_b = _bundle_from_input(old_path, sops_mode=sops_mode, freeze=True)
new_b = _bundle_from_input(new_path, sops_mode=sops_mode, freeze=True)
if old_b.tempdir:
stack.callback(old_b.tempdir.cleanup)
if new_b.tempdir:

View file

@ -191,7 +191,6 @@ class IgnorePolicy:
deny_globs: Optional[list[str]] = None
allow_binary_globs: Optional[list[str]] = None
max_file_bytes: int = 256_000
sample_bytes: int = 64_000
# If True, be much less conservative about collecting potentially
# sensitive files. This disables deny globs (e.g. /etc/shadow,
# /etc/ssl/private/*) and skips heuristic content scanning.

View file

@ -9,7 +9,7 @@ from typing import List, Optional
from .ansible import manifest_from_bundle_dir as manifest_ansible_from_bundle_dir
from .harvest_safety import ensure_safe_output_parent
from .manifest_safety import validate_site_fqdn
from .manifest_safety import freeze_directory_bundle, validate_site_fqdn
from .remote import _safe_extract_tar
from .sopsutil import (
decrypt_file_binary_to,
@ -34,7 +34,15 @@ def _prepare_bundle_dir(
p = Path(bundle).expanduser()
if p.is_dir():
return str(p), None
# A directory bundle may still be writable by an unprivileged user (e.g.
# root running `manifest` against /tmp/some-harvest). Validation is a
# point-in-time check, but the renderer/JinjaTurtle re-open artifacts by
# path afterwards, so a mutable source could be raced. Freeze the bundle
# into a private 0700 temp copy (no-follow, regular-files-only) and
# consume that immutable copy instead. Tar/SOPS inputs already get an
# equivalent private extraction below.
frozen_dir, td_frozen = freeze_directory_bundle(p, label="harvest bundle")
return frozen_dir, td_frozen
if not sops_mode:
raise RuntimeError(f"Harvest path is not a directory: {p}")

View file

@ -1,12 +1,15 @@
from __future__ import annotations
import errno
import os
import re
import shutil
import stat
import tempfile
from pathlib import Path
from typing import Iterator, Tuple
from typing import Iterator, Optional, Tuple
from .fsutil import open_no_follow_path
from .harvest_safety import (
OutputSafetyError,
ensure_safe_output_parent,
@ -256,3 +259,141 @@ def copy_safe_artifact_file(src: str | Path, dst: str | Path) -> None:
"""Copy an already validated artifact file without following symlinks."""
shutil.copy2(src, dst, follow_symlinks=False)
_FREEZE_MAX_FILE_BYTES = 64 * 1024 * 1024
_FREEZE_MAX_FILES = 200_000
def _read_all_no_follow(abs_path: str) -> bytes:
"""Read a regular file's bytes via a no-follow, non-hardlinked open.
Mirrors the harvest-side capture discipline: open every path component
without following symlinks, fstat the resulting descriptor, and refuse
anything that is not a single-linked regular file. This is what makes the
frozen copy independent of later mutation of the source tree -- the bytes
are taken from the descriptor we validated, not re-resolved by path.
"""
fd: Optional[int] = None
try:
try:
fd = open_no_follow_path(abs_path)
except OSError as e:
if e.errno == errno.ELOOP:
raise ArtifactSafetyError(
f"bundle path contains a symlink component: {abs_path}"
) from e
if e.errno == errno.ENOTDIR:
raise ArtifactSafetyError(
f"bundle path has a non-directory component: {abs_path}"
) from e
raise ArtifactSafetyError(f"unable to read bundle file: {abs_path}") from e
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
raise ArtifactSafetyError(f"bundle file is not a regular file: {abs_path}")
if st.st_nlink > 1:
raise ArtifactSafetyError(f"bundle file is hardlinked: {abs_path}")
if st.st_size > _FREEZE_MAX_FILE_BYTES:
raise ArtifactSafetyError(f"bundle file is too large to freeze: {abs_path}")
chunks: list[bytes] = []
remaining = int(st.st_size)
while remaining > 0:
chunk = os.read(fd, min(1024 * 1024, remaining))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
finally:
if fd is not None:
try:
os.close(fd)
except OSError:
pass
def freeze_directory_bundle(
bundle_dir: str | Path, *, label: str = "harvest bundle"
) -> Tuple[str, tempfile.TemporaryDirectory]:
"""Copy a directory harvest bundle into a private, immutable-by-attacker tree.
Validation of a harvest bundle (schema + artifact safety) is point-in-time:
it proves the tree is safe *at the moment it is checked*. When the bundle
directory is still writable by an unprivileged user, an attacker can race the
later consumers (``manifest``/``diff``/``explain`` re-open artifacts by path
to copy, hash, or feed to JinjaTurtle) and swap a validated regular file for a
symlink or different file after the check.
Tar and SOPS inputs already avoid this: Enroll extracts them into a private
``0700`` temp directory before validating. Plain *directory* inputs were used
in place. This helper closes that gap by giving directory inputs the same
treatment: it copies the bundle into a fresh ``0700`` temp directory using
no-follow traversal, taking each file's bytes from a validated descriptor
(no symlinks, no hardlinks, regular files only). The returned copy is what the
caller should validate and render from, so a subsequent mutation of the
original directory cannot affect the consumed bundle.
Returns ``(frozen_bundle_dir, tempdir)``. The caller must keep ``tempdir``
alive for the lifetime of the bundle use; it cleans up on close.
"""
src_root = Path(bundle_dir).expanduser()
if not src_root.is_dir():
raise ArtifactSafetyError(f"{label} is not a directory: {src_root}")
td = tempfile.TemporaryDirectory(prefix="enroll-frozen-bundle-")
try:
dst_root = Path(td.name) / "bundle"
dst_root.mkdir(mode=0o700, parents=True, exist_ok=False)
try:
os.chmod(dst_root, 0o700)
except OSError:
pass
file_count = 0
# followlinks=False: do not descend into symlinked directories. Each
# discovered file is independently re-opened no-follow before copying, so
# a symlinked directory cannot smuggle content into the frozen tree even
# if it is swapped in mid-walk.
for cur, dirs, files in os.walk(src_root, followlinks=False):
cur_p = Path(cur)
# Refuse symlinked subdirectories rather than silently skipping them,
# so a tampered bundle fails loudly instead of producing a partial
# frozen copy that later diverges from the operator's expectation.
for dname in list(dirs):
dp = cur_p / dname
try:
dst = dp.lstat()
except FileNotFoundError:
dirs.remove(dname)
continue
if stat.S_ISLNK(dst.st_mode):
raise ArtifactSafetyError(
f"{label} contains a symlinked directory: {dp}"
)
rel_dir = cur_p.relative_to(src_root)
target_dir = dst_root / rel_dir
target_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
for fname in files:
file_count += 1
if file_count > _FREEZE_MAX_FILES:
raise ArtifactSafetyError(
f"{label} has too many files to freeze safely"
)
src_file = cur_p / fname
data = _read_all_no_follow(str(src_file))
dst_file = target_dir / fname
fd = open_no_follow_path(str(dst_file), write=True, mode=0o600)
with os.fdopen(fd, "wb") as fh:
fh.write(data)
return str(dst_root), td
except BaseException:
td.cleanup()
raise