More hardening
All checks were successful
CI / test (push) Successful in 51s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m8s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 16m23s
Lint / test (push) Successful in 43s

This commit is contained in:
Miguel Jacq 2026-07-03 12:24:08 +10:00
parent 40bff49815
commit d2a46394fe
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
5 changed files with 314 additions and 18 deletions

View file

@ -348,29 +348,42 @@ class IgnorePolicy:
return None
def _content_deny_reason(self, path: str, data: bytes) -> Optional[str]:
if b"\x00" in data:
match_path = normalize_for_match(path)
for g in self.allow_binary_globs or []:
if fnmatch.fnmatch(match_path, g):
# Binary is acceptable for explicitly-allowed paths.
return None
return "binary_like"
# High-confidence secret *material* (private keys, age secret keys,
# populated credential assignments, credential URIs, Authorization
# headers) is scanned against the raw bytes FIRST, before any
# binary/allowlist decision. This runs even for binary payloads on the
# allow_binary_globs list: those globs exist to let genuinely-binary
# *public* keyring formats (e.g. APT/RPM GPG keyrings) through the
# "binary_like" denial, but they must NOT become a hole through which a
# keybox/keyring carrying *private* key material is captured unscanned
# in safe mode. The private-key markers are byte-oriented and match
# inside binary containers, so running them here closes that asymmetry
# while still allowing ordinary public keyrings.
if not self.dangerous:
# High-confidence secret *material* (private keys, age secret keys)
# is scanned against the raw bytes and is NOT subject to comment
# stripping. A private key embedded in a file is sensitive regardless
# of comment framing, and this closes the bypass where opening a block
# comment (e.g. a leading "/*" line) hid key material from the
# line-oriented scanner.
for pat in HIGH_CONFIDENCE_SECRET_PATTERNS:
if pat.search(data):
return "sensitive_content"
if b"\x00" in data:
match_path = normalize_for_match(path)
for g in self.allow_binary_globs or []:
if fnmatch.fnmatch(match_path, g):
# Binary is acceptable for explicitly-allowed paths. The
# high-confidence secret-material scan above has already run
# on the raw bytes, so an allowed binary keyring that
# actually embeds private-key material was refused before
# reaching here; only genuinely non-secret binary keyrings
# get this far.
return None
return "binary_like"
if not self.dangerous:
# Softer assignment/keyword/URI heuristics stay comment-aware so a
# genuinely commented-out example does not make Enroll useless for
# ordinary config files. iter_effective_lines() is hardened so an
# unterminated/inline block comment cannot mask later real content.
# These line-oriented heuristics only make sense on text, so they
# run only after the NUL/binary check above has passed.
for line in self.iter_effective_lines(data):
for pat in SENSITIVE_CONTENT_PATTERNS:
if pat.search(line):

View file

@ -15,6 +15,44 @@ from .manifest_safety import ArtifactSafetyError, safe_artifact_file
from .yamlutil import yaml_dump_mapping, yaml_load_mapping
# Bound the external JinjaTurtle subprocess. These are defence-in-depth limits
# for running a separate binary over harvested (possibly attacker-influenced)
# config: a hang or a runaway-size output must not stall or exhaust the manifest
# process. Both limits convert to an ordinary failure that jinjify_artifact()
# turns into a safe raw-file-copy fallback. The timeout can be overridden via
# ENROLL_JINJATURTLE_TIMEOUT (seconds) for unusually large legitimate configs.
_DEFAULT_JINJATURTLE_TIMEOUT_S = 30
_MAX_JT_OUTPUT_BYTES = 16 * 1024 * 1024
def _resolve_jt_timeout() -> float:
raw = os.environ.get("ENROLL_JINJATURTLE_TIMEOUT")
if raw:
try:
value = float(raw)
if value > 0:
return value
except ValueError:
pass
return float(_DEFAULT_JINJATURTLE_TIMEOUT_S)
JINJATURTLE_SUBPROCESS_TIMEOUT_S = _resolve_jt_timeout()
def _reject_oversized_jt_output(path: Path, label: str) -> None:
"""Raise if a JinjaTurtle output file exceeds the ingest size cap."""
try:
size = path.stat().st_size
except OSError as e:
raise RuntimeError(f"jinjaturtle {label} output is unreadable: {path}") from e
if size > _MAX_JT_OUTPUT_BYTES:
raise RuntimeError(
"jinjaturtle %s output is too large to ingest (%d bytes > %d)"
% (label, size, _MAX_JT_OUTPUT_BYTES)
)
SYSTEMD_SUFFIXES = {
".service",
".socket",
@ -479,13 +517,37 @@ def run_jinjaturtle(
if force_format:
cmd.extend(["-f", force_format])
p = subprocess.run(cmd, text=True, capture_output=True) # nosec
# JinjaTurtle is an external binary run over harvested, potentially
# attacker-influenced config. Bound its runtime so a pathological or
# hostile input (e.g. a config that makes the converter loop or hang)
# cannot stall a manifest run indefinitely. A timeout is surfaced as an
# ordinary failure; jinjify_artifact() catches it and falls back to
# copying the raw file, which is the safe default.
try:
p = subprocess.run(
cmd,
text=True,
capture_output=True,
timeout=JINJATURTLE_SUBPROCESS_TIMEOUT_S,
) # nosec
except subprocess.TimeoutExpired as e:
raise RuntimeError(
"jinjaturtle timed out after %ss for %s (role=%s)"
% (JINJATURTLE_SUBPROCESS_TIMEOUT_S, src_path, role_name)
) from e
if p.returncode != 0:
raise RuntimeError(
"jinjaturtle failed for %s (role=%s)\ncmd=%r\nstdout=%s\nstderr=%s"
% (src_path, role_name, cmd, p.stdout, p.stderr)
)
# Cap how much generated output Enroll will ingest. A converter that
# emits an enormous template/vars file (accidentally or maliciously)
# should not be able to exhaust memory in the manifest process; refuse
# oversized output and fall back to a raw copy.
_reject_oversized_jt_output(defaults_out, "defaults")
_reject_oversized_jt_output(template_out, "template")
vars_text = defaults_out.read_text(encoding="utf-8").strip()
template_text = template_out.read_text(encoding="utf-8")

View file

@ -12,6 +12,7 @@ from typing import Iterator, Optional, Tuple
from .fsutil import open_no_follow_path
from .harvest_safety import (
OutputSafetyError,
_effective_uid,
ensure_safe_output_parent,
prepare_new_private_dir,
)
@ -55,17 +56,66 @@ def validate_site_fqdn(value: str | None) -> str | None:
return text
def _assert_root_safe_output_dir(path: Path, st: os.stat_result) -> None:
"""Reject an existing output directory that is unsafe for a root-run merge.
Only enforced when Enroll runs as root. Site/FQDN mode intentionally merges
generated files into an existing tree, and the individual writers re-open
files by path. A directory inside that tree that is owned by an unprivileged
user, or writable by group/other, lets that user pre-create or swap files
(including planting a symlink after this scan) that a later root-run write
would follow or clobber. A symlink-only scan cannot catch that race, so the
interior of a root-run output tree must additionally be root-owned and not
group/world-writable.
The sticky-bit exception that ``harvest_safety`` allows for a shared *parent*
boundary such as ``/tmp`` is deliberately NOT honoured here: this is the
interior of Enroll's own output tree, not a shared staging root, so a
world-writable directory is never acceptable even if sticky.
"""
if _effective_uid() != 0:
return
if st.st_uid != 0:
raise ManifestOutputError(
"manifest output tree contains a directory not owned by root; "
f"refusing root-run merge: {path}"
)
if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
raise ManifestOutputError(
"manifest output tree contains a group/other-writable directory; "
f"refusing root-run merge: {path}"
)
def _assert_no_output_symlinks(root: Path) -> None:
"""Reject pre-existing symlinks in an output tree we are about to merge into.
"""Reject unsafe pre-existing entries in an output tree we merge into.
Non-site mode refuses existing output directories entirely. Site/FQDN modes
intentionally accumulate multiple nodes into one tree, so reject symlinks in
the tree before merging to avoid writes being redirected outside *root*.
intentionally accumulate multiple nodes into one tree, so before merging we
reject:
* symlinks anywhere in the tree (a write could be redirected outside
*root*), and
* when running as root, any directory in the tree that is not root-owned
or is group/other-writable (an unprivileged owner could race the merge
by planting files/symlinks after this scan -- see
:func:`_assert_root_safe_output_dir`).
Version-control metadata can contain implementation-specific entries and is
not part of Enroll's generated layout, so it is pruned from this check.
"""
skip_dirs = {".git", ".hg", ".svn"}
# Check the root of the merge target itself, not only its descendants.
try:
root_st = root.lstat()
except FileNotFoundError:
root_st = None
if root_st is not None and not stat.S_ISLNK(root_st.st_mode):
_assert_root_safe_output_dir(root, root_st)
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
dirpath_p = Path(dirpath)
@ -82,6 +132,7 @@ def _assert_no_output_symlinks(root: Path) -> None:
raise ManifestOutputError(
f"manifest output tree contains a symlink; refusing to merge: {p}"
)
_assert_root_safe_output_dir(p, st)
for filename in filenames:
if filename in skip_dirs: