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:
parent
1bfbfd90f6
commit
a2dc054882
7 changed files with 347 additions and 24 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue