Strict validation of PATH when running as root in case it could contain potentially unsafe binaries

This commit is contained in:
Miguel Jacq 2026-06-22 11:06:01 +10:00
parent 205c419a7a
commit a0914e1369
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
3 changed files with 109 additions and 0 deletions

View file

@ -13,6 +13,8 @@
* Add support for detecting Flatpaks and Snaps. * Add support for detecting Flatpaks and Snaps.
* Stricter validation of harvests to ensure that they meet the schema and don't contain unsafe artifacts (e.g symlinks pointing outside the artifact tree) * Stricter validation of harvests to ensure that they meet the schema and don't contain unsafe artifacts (e.g symlinks pointing outside the artifact tree)
* Perform harvest validation before trying to manifest from it. * Perform harvest validation before trying to manifest from it.
* Stricter validation on FQDN name in multisite mode.
* Strict check of `$PATH` when running harvest as root, in case it could lead to execution of unsafe binaries during harvest. Override with `--assume-safe-path` for non-interactive or CI purposes.
# 0.6.0 # 0.6.0

View file

@ -106,6 +106,9 @@ Harvest state about a host and write a harvest bundle.
* If neither is provided, and Enroll detects an encrypted key in an interactive session, it will still fall back to prompting on-demand. * If neither is provided, and Enroll detects an encrypted key in an interactive session, it will still fall back to prompting on-demand.
* In non-interactive sessions, pass `--ask-key-passphrase` or `--ssh-key-passphrase-env ENV_VAR` when using encrypted private keys. * In non-interactive sessions, pass `--ask-key-passphrase` or `--ssh-key-passphrase-env ENV_VAR` when using encrypted private keys.
* Note: `--ask-key-passphrase` and `--ssh-key-passphrase-env` are mutually exclusive. * Note: `--ask-key-passphrase` and `--ssh-key-passphrase-env` are mutually exclusive.
- Root PATH safety:
- when run as root, Enroll warns and asks for confirmation if `PATH` contains `.`, an empty/relative entry, or a group/world-writable directory.
- use `--assume-safe-path` for trusted non-interactive automation where that `PATH` is intentional.
Examples (encrypted SSH key) Examples (encrypted SSH key)
@ -278,6 +281,8 @@ enroll validate ./harvest --fail-on-warnings
By default, `enroll` does **not** assume how you handle secrets in Ansible. It will attempt to avoid harvesting likely sensitive data (private keys, passwords, tokens, etc.). This can mean it skips some config files you may ultimately want to manage. By default, `enroll` does **not** assume how you handle secrets in Ansible. It will attempt to avoid harvesting likely sensitive data (private keys, passwords, tokens, etc.). This can mean it skips some config files you may ultimately want to manage.
Safe-mode content scanning is intentionally conservative. It treats common assignment-style credential keys as sensitive, including names such as `password`, `client_secret`, `secret_key`, `auth_token`, `api_key`, `aws_access_key_id`, `aws_secret_access_key`, `azure_client_secret`, `GOOGLE_APPLICATION_CREDENTIALS`, and service-account key names.
Automatic harvesting of per-user shell dotfiles is also disabled by default, even when those files differ from `/etc/skel`, because `.bashrc`, `.profile`, `.bash_aliases`, and similar files commonly contain exported tokens, credentials, or aliases/functions with embedded secrets. Use `--dangerous` for automatic shell-dotfile capture, or use targeted `--include-path` patterns for narrower safe-mode review. Automatic harvesting of per-user shell dotfiles is also disabled by default, even when those files differ from `/etc/skel`, because `.bashrc`, `.profile`, `.bash_aliases`, and similar files commonly contain exported tokens, credentials, or aliases/functions with embedded secrets. Use `--dangerous` for automatic shell-dotfile capture, or use targeted `--include-path` patterns for narrower safe-mode review.
If you opt in to collecting everything: If you opt in to collecting everything:

View file

@ -4,6 +4,7 @@ import argparse
import configparser import configparser
import json import json
import os import os
import stat
import sys import sys
import tarfile import tarfile
import tempfile import tempfile
@ -134,6 +135,83 @@ def _split_list_value(v: str) -> list[str]:
return [raw] if raw else [] return [raw] if raw else []
def _path_entry_is_unsafe(entry: str) -> Optional[str]:
"""Return a human-readable reason if a PATH entry is unsafe for root.
Empty PATH entries and relative entries resolve via the current working
directory, which is equivalent to trusting whatever directory the operator
happens to be in. Existing group/world-writable directories are also risky
when Enroll is run as root because Enroll deliberately invokes host tools
from PATH while harvesting and enforcing state.
"""
if entry == "":
return "empty PATH entry resolves to the current directory"
if entry == ".":
return "'.' resolves to the current directory"
if not os.path.isabs(entry):
return "relative PATH entry resolves from the current directory"
try:
st = os.stat(entry)
except OSError:
return None
if not stat.S_ISDIR(st.st_mode):
return None
if st.st_mode & stat.S_IWOTH:
return "directory is world-writable"
if st.st_mode & stat.S_IWGRP:
return "directory is group-writable"
return None
def _unsafe_root_path_reasons(path_value: Optional[str] = None) -> list[str]:
"""Return unsafe PATH entries that should make root execution interactive."""
raw = os.environ.get("PATH", "") if path_value is None else str(path_value)
out: list[str] = []
for entry in raw.split(os.pathsep):
reason = _path_entry_is_unsafe(entry)
if reason:
label = entry if entry else "<empty>"
out.append(f"{label}: {reason}")
return out
def _is_effective_root() -> bool:
geteuid = getattr(os, "geteuid", None)
return bool(geteuid is not None and geteuid() == 0)
def _confirm_root_path_safety(*, force: bool = False) -> None:
"""Prompt before running as root with a PATH that trusts writable entries."""
if force or not _is_effective_root():
return
reasons = _unsafe_root_path_reasons()
if not reasons:
return
details = "\n".join(f" - {r}" for r in reasons)
msg = (
"warning: enroll is running as root and PATH contains entries that "
"could allow an untrusted binary to be executed:\n"
f"{details}\n"
)
if not sys.stdin.isatty():
raise SystemExit(
msg + "error: refusing to continue non-interactively. Re-run with "
"--assume-safe-path if you intentionally trust this PATH."
)
print(msg, file=sys.stderr, end="")
answer = input("Are you sure you want to continue? [y/N] ")
if answer.strip().lower() not in {"y", "yes"}:
raise SystemExit("aborted: unsafe root PATH was not confirmed")
def _section_to_argv( def _section_to_argv(
p: argparse.ArgumentParser, cfg: configparser.ConfigParser, section: str p: argparse.ArgumentParser, cfg: configparser.ConfigParser, section: str
) -> list[str]: ) -> list[str]:
@ -359,6 +437,21 @@ def _add_config_args(p: argparse.ArgumentParser) -> None:
) )
def _add_path_safety_args(
p: argparse.ArgumentParser, *, default: object = False
) -> None:
p.add_argument(
"--assume-safe-path",
action="store_true",
default=default,
help=(
"When running as root, continue without confirmation even if PATH "
"contains '.', an empty/relative entry, or a group/world-writable "
"directory. Intended for trusted non-interactive automation."
),
)
def _add_remote_args(p: argparse.ArgumentParser) -> None: def _add_remote_args(p: argparse.ArgumentParser) -> None:
p.add_argument( p.add_argument(
"--remote-host", "--remote-host",
@ -432,10 +525,12 @@ def main() -> None:
version=f"{get_enroll_version()}", version=f"{get_enroll_version()}",
) )
_add_config_args(ap) _add_config_args(ap)
_add_path_safety_args(ap)
sub = ap.add_subparsers(dest="cmd", required=True) sub = ap.add_subparsers(dest="cmd", required=True)
h = sub.add_parser("harvest", help="Harvest service/package/config state") h = sub.add_parser("harvest", help="Harvest service/package/config state")
_add_config_args(h) _add_config_args(h)
_add_path_safety_args(h, default=argparse.SUPPRESS)
_add_remote_args(h) _add_remote_args(h)
h.add_argument( h.add_argument(
"--out", "--out",
@ -488,6 +583,7 @@ def main() -> None:
"manifest", help="Render configuration-management code from a harvest" "manifest", help="Render configuration-management code from a harvest"
) )
_add_config_args(m) _add_config_args(m)
_add_path_safety_args(m, default=argparse.SUPPRESS)
m.add_argument( m.add_argument(
"--harvest", "--harvest",
required=True, required=True,
@ -522,6 +618,7 @@ def main() -> None:
help="Harvest state, then manifest configuration-management code, in one shot", help="Harvest state, then manifest configuration-management code, in one shot",
) )
_add_config_args(s) _add_config_args(s)
_add_path_safety_args(s, default=argparse.SUPPRESS)
_add_remote_args(s) _add_remote_args(s)
s.add_argument( s.add_argument(
"--harvest", "--harvest",
@ -582,6 +679,7 @@ def main() -> None:
d = sub.add_parser("diff", help="Compare two harvests and report differences") d = sub.add_parser("diff", help="Compare two harvests and report differences")
_add_config_args(d) _add_config_args(d)
_add_path_safety_args(d, default=argparse.SUPPRESS)
d.add_argument( d.add_argument(
"--old", "--old",
required=True, required=True,
@ -703,6 +801,7 @@ def main() -> None:
e = sub.add_parser("explain", help="Explain a harvest state.json") e = sub.add_parser("explain", help="Explain a harvest state.json")
_add_config_args(e) _add_config_args(e)
_add_path_safety_args(e, default=argparse.SUPPRESS)
e.add_argument( e.add_argument(
"harvest", "harvest",
help=( help=(
@ -731,6 +830,7 @@ def main() -> None:
"validate", help="Validate a harvest bundle (state.json + artifacts)" "validate", help="Validate a harvest bundle (state.json + artifacts)"
) )
_add_config_args(v) _add_config_args(v)
_add_path_safety_args(v, default=argparse.SUPPRESS)
v.add_argument( v.add_argument(
"harvest", "harvest",
help=( help=(
@ -787,6 +887,8 @@ def main() -> None:
) )
args = ap.parse_args(argv) args = ap.parse_args(argv)
_confirm_root_path_safety(force=bool(getattr(args, "assume_safe_path", False)))
# Preserve historical defaults for remote harvesting unless ssh_config lookup is enabled. # Preserve historical defaults for remote harvesting unless ssh_config lookup is enabled.
# This lets ssh_config values take effect when the user did not explicitly set # This lets ssh_config values take effect when the user did not explicitly set
# --remote-user / --remote-port. # --remote-user / --remote-port.