Remove 'enroll diff --enforce' option. Tighten yaml data re: handlers - use listen: instead of notify:

This commit is contained in:
Miguel Jacq 2026-06-28 16:01:11 +10:00
parent e9d7d74445
commit 903125976d
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
15 changed files with 851 additions and 1288 deletions

View file

@ -18,7 +18,11 @@ from .manifest_safety import (
iter_safe_artifact_files,
prepare_manifest_output_dir,
)
from .render_safety import ansible_unsafe_data
from .render_safety import (
ansible_unsafe_data,
assert_generated_yaml_safe,
scaffold_token,
)
from .role_names import avoid_reserved_role_name
from .state import inventory_packages_from_state, roles_from_state
from .yamlutil import yaml_dump_mapping, yaml_load_mapping
@ -236,7 +240,7 @@ class AnsibleRole(CMModule):
self.add_snapshot_notes(snap)
def render_firewall_runtime_tasks(self) -> str:
var_prefix = self.role_name
var_prefix = scaffold_token(self.role_name, field="role var_prefix")
return f"""- name: Ensure firewall runtime snapshot directory exists
ansible.builtin.file:
path: {self.firewall_runtime_dir}
@ -292,7 +296,7 @@ class AnsibleRole(CMModule):
"""
def render_firewall_runtime_handlers(self) -> str:
var_prefix = self.role_name
var_prefix = scaffold_token(self.role_name, field="role var_prefix")
return f"""---
- name: Flush captured ipsets before restoring members
ansible.builtin.command:
@ -512,7 +516,7 @@ def _write_role_scaffold(role_dir: str) -> None:
def _role_tag(role: str) -> str:
"""Return a stable Ansible tag name for a role.
Used by `enroll diff --enforce` to run only the roles needed to repair drift.
Lets operators run only selected roles via `--tags` when applying a manifest.
"""
r = str(role or "").strip()
# Ansible tag charset is fairly permissive, but keep it portable and consistent.
@ -532,13 +536,17 @@ def _write_playbook_all(path: str, roles: List[str]) -> None:
" roles:",
]
for r in roles:
pb_lines.append(f" - role: {r}")
pb_lines.append(f" tags: [{_role_tag(r)}]")
safe = scaffold_token(r, field="role name")
pb_lines.append(f" - role: {safe}")
pb_lines.append(f" tags: [{_role_tag(safe)}]")
text = "\n".join(pb_lines) + "\n"
assert_generated_yaml_safe(text, label="playbook.yml")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(pb_lines) + "\n")
f.write(text)
def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
fqdn = scaffold_token(fqdn, field="site fqdn")
pb_lines = [
"---",
f"- name: Apply all roles on {fqdn}",
@ -548,10 +556,13 @@ def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
" roles:",
]
for r in roles:
pb_lines.append(f" - role: {r}")
pb_lines.append(f" tags: [{_role_tag(r)}]")
safe = scaffold_token(r, field="role name")
pb_lines.append(f" - role: {safe}")
pb_lines.append(f" tags: [{_role_tag(safe)}]")
text = "\n".join(pb_lines) + "\n"
assert_generated_yaml_safe(text, label="host playbook")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(pb_lines) + "\n")
f.write(text)
def _ensure_ansible_cfg(cfg_path: str) -> None:
@ -756,6 +767,14 @@ def _write_ansible_role(
ctx, role_dir, role, vars_map or {}, site_defaults=site_defaults
)
# Backstop guardrail: never write a tasks/handlers document whose *structure*
# was altered by a harvested value. Enroll authors this YAML as scaffolding
# and keeps all harvested data in variable files; if any harvested value ever
# leaked into this text and changed its shape, fail closed here rather than
# emit a poisoned playbook.
assert_generated_yaml_safe(tasks, label=f"role '{role}' tasks/main.yml")
assert_generated_yaml_safe(handlers, label=f"role '{role}' handlers/main.yml")
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
f.write(tasks.rstrip() + "\n")
@ -981,6 +1000,8 @@ def _render_generic_files_tasks(var_prefix: str) -> str:
def _render_install_packages_tasks(role: str, var_prefix: str) -> str:
"""Render package installation through Ansible's generic package provider."""
role = scaffold_token(role, field="role name")
var_prefix = scaffold_token(var_prefix, field="role var_prefix")
return f"""- name: Install packages for {role}
ansible.builtin.package:
name: "{{{{ {var_prefix}_packages | default([]) }}}}"
@ -1145,6 +1166,7 @@ def _render_role_tasks(
def _single_service_restart_handler_body(var_prefix: str) -> str:
var_prefix = scaffold_token(var_prefix, field="role var_prefix")
return f"""- name: Restart service
ansible.builtin.service:
name: "{{{{ {var_prefix}_unit_name }}}}"
@ -1156,25 +1178,66 @@ def _single_service_restart_handler_body(var_prefix: str) -> str:
"""
def _service_restart_handler_name(unit: str) -> str:
return f"Restart managed service {unit}"
def _service_restart_listen_topic(var_prefix: str) -> str:
"""Return the fixed handler ``listen:`` topic for a grouped role.
The topic is derived solely from the (already sanitized) role var_prefix and
is validated as a scaffold token. It deliberately contains NO harvested data
such as a unit name, so the same string can be embedded in both the notify
side (data) and the handler ``listen:`` (scaffolding) without ever splicing a
harvested value into YAML structure. The specific units to restart travel as
the ``<var_prefix>_restart_units`` Ansible *variable*.
"""
var_prefix = scaffold_token(var_prefix, field="role var_prefix")
return f"enroll_restart_grouped_services_{var_prefix}"
def _grouped_service_restart_handlers_body(role: AnsibleRole) -> str:
handlers: List[str] = []
"""Render the grouped-service restart handler.
Harvested unit names never appear in this YAML text. The handler listens on
a fixed, role-scoped topic and restarts each unit from the
``<var_prefix>_restart_units`` variable (written to the role's defaults /
host_vars through ``ansible_unsafe_data``). If no unit in the role is in a
"started" state, no restart handler is emitted.
"""
has_restartable = any(
str(svc.get("state") or "stopped") == "started"
for svc in role.services.values()
)
if not has_restartable:
return ""
var_prefix = scaffold_token(role.var_prefix, field="role var_prefix")
topic = _service_restart_listen_topic(var_prefix)
return f"""- name: Restart managed services for {var_prefix}
ansible.builtin.service:
name: "{{{{ item }}}}"
state: restarted
loop: "{{{{ {var_prefix}_restart_units | default([]) }}}}"
listen: {topic}
when: enroll_manage_systemd_runtime | default(true) | bool
"""
def restart_units_for_role(role: AnsibleRole) -> List[str]:
"""Return the harvested unit names a grouped role should restart, as data.
These are emitted into the role's ``<var_prefix>_restart_units`` variable and
consumed by the restart handler's loop. They are ordinary harvested values
and are protected by ``ansible_unsafe_data`` when the variable file is
written -- they never touch YAML scaffolding.
"""
units: List[str] = []
for unit, svc in sorted(role.services.items()):
name = str(svc.get("name") or unit).strip()
if not name or str(svc.get("state") or "stopped") != "started":
continue
handlers.append(
f"""- name: {_service_restart_handler_name(name)}
ansible.builtin.service:
name: {name}
state: restarted
when: enroll_manage_systemd_runtime | default(true) | bool
"""
)
return "\n".join(_task_body(handler) for handler in handlers if _task_body(handler))
units.append(name)
return units
def _render_role_handlers(
@ -1819,16 +1882,19 @@ def _role_managed_content_vars(
if notify_service_handlers and kind == "service":
unit = str(snap.get("unit") or "").strip()
if unit and str(snap.get("active_state") or "") == "active":
notify_other = _service_restart_handler_name(unit)
# Notify the role's fixed restart topic (scaffold-safe). The
# specific unit travels as data in <var_prefix>_restart_units;
# it is never spliced into the handler/notify YAML text.
notify_other = _service_restart_listen_topic(role)
else:
notify_other = None
elif notify_service_handlers and kind == "package":
notify_other = [
_service_restart_handler_name(unit)
for unit in CMModule.active_service_units_for_package_snapshot(
snap, service_units_by_package
)
]
if CMModule.active_service_units_for_package_snapshot(
snap, service_units_by_package
):
notify_other = _service_restart_listen_topic(role)
else:
notify_other = None
for item in _build_managed_files_var(
managed_files,
@ -2045,7 +2111,10 @@ def _render_common_ansible_roles(
role,
notify_by_kind={"service": None},
overwrite_templates=True,
extra_vars={f"{role.var_prefix}_systemd_units": systemd_units},
extra_vars={
f"{role.var_prefix}_systemd_units": systemd_units,
f"{role.var_prefix}_restart_units": restart_units_for_role(role),
},
grouped_services=True,
restart_grouped_services=True,
notify_service_handlers=True,

View file

@ -14,9 +14,7 @@ from typing import Optional
from .cache import new_harvest_cache_dir
from .diff import (
compare_harvests,
enforce_old_harvest,
format_report,
has_enforceable_drift,
post_webhook,
send_email,
)
@ -793,15 +791,6 @@ def main() -> None:
"Package additions/removals are still reported. Useful when routine upgrades would otherwise create noisy drift."
),
)
d.add_argument(
"--enforce",
action="store_true",
help=(
"If differences are detected, attempt to enforce the old harvest state locally by generating a manifest and "
"running the selected local apply tool. "
"Enroll does not attempt to downgrade packages; if the only drift is package version upgrades (or newly installed packages), enforcement is skipped."
),
)
d.add_argument(
"--out",
help="Write the report to this file instead of stdout.",
@ -1118,41 +1107,6 @@ def main() -> None:
),
)
# Optional enforcement: if drift is detected, attempt to restore the
# system to the *old* (baseline) state using ansible.
if bool(getattr(args, "enforce", False)):
if has_changes:
if not has_enforceable_drift(report):
report["enforcement"] = {
"requested": True,
"status": "skipped",
"reason": (
"no enforceable drift detected (only additions and/or package version changes); "
"enroll does not attempt to downgrade packages"
),
}
else:
try:
info = enforce_old_harvest(
args.old,
sops_mode=bool(getattr(args, "sops", False)),
report=report,
)
except Exception as e:
raise SystemExit(
f"error: could not enforce old harvest state: {e}"
) from e
report["enforcement"] = {
"requested": True,
**(info or {}),
}
else:
report["enforcement"] = {
"requested": True,
"status": "skipped",
"reason": "no differences detected",
}
txt = format_report(report, fmt=str(getattr(args, "format", "text")))
out_path = getattr(args, "out", None)
if out_path:

View file

@ -3,7 +3,6 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess # nosec
import tarfile
@ -628,321 +627,6 @@ def compare_harvests(
return report, has_changes
def has_enforceable_drift(report: Dict[str, Any]) -> bool:
"""Return True if the diff report contains drift that is safe/meaningful to enforce.
Enforce mode is intended to restore *state* (files/users/services) and to
reinstall packages that were removed.
It is deliberately conservative about package drift:
- Package *version* changes alone are not enforced (no downgrades).
- Newly installed packages are not removed.
This helper lets the CLI decide whether `--enforce` should actually run.
"""
pk = report.get("packages", {}) or {}
if pk.get("removed"):
return True
sv = report.get("services", {}) or {}
# We do not try to disable newly-enabled services; we only restore units
# that were enabled in the baseline but are now missing.
if sv.get("enabled_removed") or []:
return True
for ch in sv.get("changed", []) or []:
changes = ch.get("changes") or {}
# Ignore package set drift for enforceability decisions; package
# enforcement is handled via reinstalling removed packages, and we
# avoid trying to "undo" upgrades/renames.
for k in changes.keys():
if k != "packages":
return True
us = report.get("users", {}) or {}
# We restore baseline users (missing/changed). We do not remove newly-added users.
if (us.get("removed") or []) or (us.get("changed") or []):
return True
fl = report.get("files", {}) or {}
# We restore baseline files (missing/changed). We do not delete newly-managed files.
if (fl.get("removed") or []) or (fl.get("changed") or []):
return True
return False
def _role_tag(role: str) -> str:
"""Return the Ansible tag name for a role (must match manifest generation)."""
r = str(role or "").strip()
safe = re.sub(r"[^A-Za-z0-9_-]+", "_", r).strip("_")
if not safe:
safe = "other"
return f"role_{safe}"
def _enforcement_command(
exe: str,
manifest_dir: Path,
*,
tags: Optional[List[str]] = None,
) -> Tuple[List[str], Dict[str, str]]:
"""Return the local apply command and environment for a rendered manifest."""
env = dict(os.environ)
playbook = manifest_dir / "playbook.yml"
if not playbook.exists():
raise RuntimeError(
f"manifest did not produce expected playbook.yml at {playbook}"
)
cfg = manifest_dir / "ansible.cfg"
if cfg.exists():
env["ANSIBLE_CONFIG"] = str(cfg)
cmd = [
exe,
"-i",
"localhost,",
"-c",
"local",
str(playbook),
]
if tags:
cmd.extend(["--tags", ",".join(tags)])
return cmd, env
def _enforcement_plan(
report: Dict[str, Any],
old_state: Dict[str, Any],
old_bundle_dir: Path,
) -> Dict[str, Any]:
"""Return a best-effort enforcement plan (roles/tags) for this diff report.
We only plan for drift that the baseline manifest can safely restore:
- packages that were removed (reinstall, no downgrades)
- baseline users that were removed/changed
- baseline files that were removed/changed
- baseline systemd units that were disabled/changed
We do NOT plan to remove newly-added packages/users/files/services.
"""
roles: set[str] = set()
# --- Packages (only removals)
pk = report.get("packages", {}) or {}
removed_pkgs = set(pk.get("removed") or [])
if removed_pkgs:
pkg_to_roles: Dict[str, set[str]] = {}
for svc in _roles(old_state).get("services") or []:
r = str(svc.get("role_name") or "").strip()
for p in svc.get("packages", []) or []:
if p:
pkg_to_roles.setdefault(str(p), set()).add(r)
for pr in _roles(old_state).get("packages") or []:
r = str(pr.get("role_name") or "").strip()
p = pr.get("package")
if p:
pkg_to_roles.setdefault(str(p), set()).add(r)
for p in removed_pkgs:
for r in pkg_to_roles.get(str(p), set()):
if r:
roles.add(r)
# --- Users (removed/changed)
us = report.get("users", {}) or {}
if (us.get("removed") or []) or (us.get("changed") or []):
u = _roles(old_state).get("users") or {}
u_role = str(u.get("role_name") or "users")
if u_role:
roles.add(u_role)
# --- Files (removed/changed)
fl = report.get("files", {}) or {}
file_paths: List[str] = []
for e in fl.get("removed", []) or []:
if isinstance(e, dict):
p = e.get("path")
else:
p = e
if p:
file_paths.append(str(p))
for e in fl.get("changed", []) or []:
if isinstance(e, dict):
p = e.get("path")
else:
p = e
if p:
file_paths.append(str(p))
if file_paths:
idx = _file_index(old_bundle_dir, old_state)
for p in file_paths:
rec = idx.get(p)
if rec and rec.role:
roles.add(str(rec.role))
# --- Services (enabled_removed + meaningful changes)
sv = report.get("services", {}) or {}
units: List[str] = []
for u in sv.get("enabled_removed", []) or []:
if u:
units.append(str(u))
for ch in sv.get("changed", []) or []:
if not isinstance(ch, dict):
continue
unit = ch.get("unit")
changes = ch.get("changes") or {}
if unit and any(k != "packages" for k in changes.keys()):
units.append(str(unit))
if units:
old_units = _service_units(old_state)
for u in units:
snap = old_units.get(u)
if snap and snap.get("role_name"):
roles.add(str(snap.get("role_name")))
# Drop empty/unknown roles.
roles = {r for r in roles if r and str(r).strip() and str(r).strip() != "unknown"}
tags = sorted({_role_tag(r) for r in roles})
return {
"roles": sorted(roles),
"tags": tags,
}
def enforce_old_harvest(
old_path: str,
*,
sops_mode: bool = False,
report: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Enforce the *old* (baseline) harvest state on the current machine.
This renders a temporary Ansible manifest from the old harvest, then runs
the local apply command:
- ansible-playbook -i localhost, -c local playbook.yml
Returns a dict suitable for attaching to the diff report under
report['enforcement'].
"""
tool_exe = "ansible-playbook"
# Import lazily to avoid heavy import cost and potential CLI cycles.
from .manifest import manifest
started_at = _utc_now_iso()
with ExitStack() as stack:
old_b = _bundle_from_input(old_path, sops_mode=sops_mode)
if old_b.tempdir:
stack.callback(old_b.tempdir.cleanup)
old_state = _load_state(old_b.dir)
plan: Optional[Dict[str, Any]] = None
tags: Optional[List[str]] = None
roles: List[str] = []
if report is not None:
plan = _enforcement_plan(report, old_state, old_b.dir)
roles = list(plan.get("roles") or [])
# Ansible has generated per-role tags that can safely narrow the
# apply scope.
t = list(plan.get("tags") or [])
tags = t if t else None
with tempfile.TemporaryDirectory(prefix="enroll-enforce-") as td:
td_path = Path(td)
try:
os.chmod(td_path, 0o700)
except OSError:
pass
# 1) Generate a manifest in a temp directory. The renderer now
# refuses to write into an existing destination, so use a fresh
# child path under the secure temporary directory.
manifest_dir = td_path / "manifest"
manifest(str(old_b.dir), str(manifest_dir))
# 2) Apply it locally.
cmd, env = _enforcement_command(
tool_exe,
manifest_dir,
tags=tags,
)
spinner: Optional[_Spinner] = None
p: Optional[subprocess.CompletedProcess[str]] = None
t0 = time.monotonic()
if _progress_enabled():
if tags:
sys.stderr.write(
f"Enforce: running {tool_exe} tags: {','.join(tags)})\n",
)
else:
sys.stderr.write(f"Enforce: running {tool_exe}\n")
sys.stderr.flush()
spinner = _Spinner(f" {tool_exe}")
spinner.start()
try:
p = subprocess.run(
cmd,
cwd=str(td_path),
env=env,
capture_output=True,
text=True,
check=False,
) # nosec
finally:
if spinner:
elapsed = time.monotonic() - t0
rc = p.returncode if p is not None else None
spinner.stop(
final_line=(
f"Enforce: {tool_exe} finished in {elapsed:0.1f}s"
+ (f" (rc={rc})" if rc is not None else "")
),
)
finished_at = _utc_now_iso()
info: Dict[str, Any] = {
"status": "applied" if p.returncode == 0 else "failed",
"executable": tool_exe,
"started_at": started_at,
"finished_at": finished_at,
"command": cmd,
"returncode": int(p.returncode),
}
# Keep the Ansible-specific field for compatibility with existing
# consumers of the JSON report.
info["ansible_playbook"] = tool_exe
info["roles"] = roles
info["tags"] = list(tags or [])
if not tags:
info["scope"] = "full_manifest"
if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip()
raise RuntimeError(
f"{tool_exe} failed"
+ (f" (rc={p.returncode})" if p.returncode is not None else "")
+ (f": {err}" if err else "")
)
return info
def format_report(report: Dict[str, Any], *, fmt: str = "text") -> str:
fmt = (fmt or "text").lower()
if fmt == "json":
@ -976,38 +660,6 @@ def _report_text(report: Dict[str, Any]) -> str:
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
lines.append(msg)
enf = report.get("enforcement") or {}
if enf:
lines.append("\nEnforcement")
status = str(enf.get("status") or "").strip().lower()
if status == "applied":
extra = ""
tags = enf.get("tags") or []
scope = enf.get("scope")
if tags:
extra = f" (tags={','.join(str(t) for t in tags)})"
elif scope:
extra = f" ({scope})"
lines.append(
f" applied old harvest (rc={enf.get('returncode')})"
+ extra
+ (
f" (finished {enf.get('finished_at')})"
if enf.get("finished_at")
else ""
)
)
elif status == "failed":
lines.append(
f" attempted enforcement but failed (rc={enf.get('returncode')})"
)
elif status == "skipped":
r = enf.get("reason")
lines.append(" skipped" + (f": {r}" if r else ""))
else:
# Best-effort formatting for future fields.
lines.append(" " + json.dumps(enf, sort_keys=True))
pk = report.get("packages", {})
lines.append("\nPackages")
lines.append(f" added: {len(pk.get('added', []) or [])}")
@ -1135,49 +787,6 @@ def _report_markdown(report: Dict[str, Any]) -> str:
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
out.append(msg + "\n")
enf = report.get("enforcement") or {}
if enf:
out.append("\n## Enforcement\n")
status = str(enf.get("status") or "").strip().lower()
if status == "applied":
extra = ""
tags = enf.get("tags") or []
scope = enf.get("scope")
if tags:
extra = " (tags=" + ",".join(str(t) for t in tags) + ")"
elif scope:
extra = f" ({scope})"
out.append(
"- ✅ Applied old harvest"
+ extra
+ (
f" (rc={enf.get('returncode')})"
if enf.get("returncode") is not None
else ""
)
+ (
f" (finished `{enf.get('finished_at')}`)"
if enf.get("finished_at")
else ""
)
+ "\n"
)
elif status == "failed":
out.append(
"- ⚠️ Attempted enforcement but failed"
+ (
f" (rc={enf.get('returncode')})"
if enf.get("returncode") is not None
else ""
)
+ "\n"
)
elif status == "skipped":
r = enf.get("reason")
out.append("- Skipped" + (f": {r}" if r else "") + "\n")
else:
out.append(f"- {json.dumps(enf, sort_keys=True)}\n")
pk = report.get("packages", {})
out.append("## Packages\n")
out.append(f"- Added: {len(pk.get('added', []) or [])}\n")

View file

@ -58,12 +58,35 @@ DEFAULT_ALLOW_BINARY_GLOBS = [
# --dangerous or targeted include/exclude review when a file is genuinely
# needed.
#
# The assignment pattern catches INI/YAML/JSON/TOML-ish keys such as:
# password: hunter2
# "client_secret": "..."
# aws_secret_access_key = ...
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
SENSITIVE_CONTENT_PATTERNS = [
# Patterns are split into two tiers:
#
# HIGH_CONFIDENCE_SECRET_PATTERNS
# Unambiguous secret *material* (private-key blocks, age secret keys).
# A file containing one of these should never be harvested in safe mode
# regardless of how it is framed. These are scanned against the raw bytes
# and are deliberately NOT subject to comment stripping: a private key is
# a private key whether or not the surrounding format treats some line as
# a comment, and (critically) an attacker must not be able to hide key
# material from the scanner by opening a block comment (e.g. a leading
# "/*" line) that the line-oriented scanner would otherwise honour.
#
# SENSITIVE_CONTENT_PATTERNS
# The single remaining *soft* heuristic: a bare mention of a credential
# word (e.g. the literal token "password" with no assigned value). This is
# the only pattern prone to false positives on stock config files that
# ship commented-out examples, so it -- and only it -- stays comment-aware
# via iter_effective_lines().
#
# IMPORTANT (conservative-by-default): anything that looks like an actual
# credential *value* -- a populated "key = value" assignment, a URI with
# embedded credentials, or an Authorization header -- is treated as
# high-confidence and is scanned against the RAW bytes, including inside
# comments. A "commented out" secret is very often a real secret that someone
# disabled, so Enroll deliberately refuses such a file in safe mode and requires
# --dangerous (ideally with --sops) to collect it. Only genuinely value-less
# keyword mentions remain tolerated in comments, which is what keeps Enroll
# useful for ordinary config files without risking a real disabled credential.
HIGH_CONFIDENCE_SECRET_PATTERNS = [
re.compile(
rb"-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----"
),
@ -71,6 +94,13 @@ SENSITIVE_CONTENT_PATTERNS = [
re.compile(rb"(?i)AGE-SECRET-KEY-[A-Z0-9]+"),
re.compile(rb"(?i)OPENSSH PRIVATE KEY"),
re.compile(rb"(?i)PGP PRIVATE KEY BLOCK"),
# Assignment-style credential keys with a value, e.g.
# password: hunter2
# "client_secret": "..."
# aws_secret_access_key = ...
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
# A populated assignment is a real (if disabled) secret regardless of comment
# framing, so it is scanned on raw bytes.
re.compile(
rb"""(?ix)
(^|[^A-Za-z0-9])
@ -94,13 +124,8 @@ SENSITIVE_CONTENT_PATTERNS = [
\s*[:=]
"""
),
re.compile(
rb"(?i)\b(pass|passwd|password|passphrase|token|secret|"
rb"credentials?|api[_-]?key)\b"
),
# Credentials embedded in connection-string URIs, e.g.
# postgres://user:pass@host, redis://:pass@host, amqp://u:p@host
# The keyword regex above keys on assignment-style names and misses these.
re.compile(rb"(?i)[a-z][a-z0-9+.-]*://[^/\s:@]*:[^/\s@]+@[^/\s]"),
# HTTP(S) Authorization / Proxy-Authorization header values carrying a
# bearer/basic/digest credential.
@ -110,6 +135,16 @@ SENSITIVE_CONTENT_PATTERNS = [
),
]
SENSITIVE_CONTENT_PATTERNS = [
# Bare credential-word mention with no assigned value. This is the only soft
# heuristic and the only one tolerated inside comments, because stock config
# files legitimately ship value-less commented hints (e.g. "# token").
re.compile(
rb"(?i)\b(pass|passwd|password|passphrase|token|secret|"
rb"credentials?|api[_-]?key)\b"
),
]
COMMENT_PREFIXES = (b"#", b";", b"//")
BLOCK_START = b"/*"
BLOCK_END = b"*/"
@ -168,23 +203,61 @@ class IgnorePolicy:
if self.allow_binary_globs is None:
self.allow_binary_globs = list(DEFAULT_ALLOW_BINARY_GLOBS)
def _strip_balanced_block_comments(self, content: bytes) -> bytes:
"""Remove only *properly closed* ``/* ... */`` regions from *content*.
The previous line-oriented scanner entered "block comment mode" the moment
a line began with ``/*`` and then skipped every subsequent line until it
saw ``*/``. That had two problems an attacker (or just an unusual config)
could exploit to hide secrets from the content scan:
* an *unterminated* ``/*`` (no closing ``*/`` anywhere after it) masked
the entire remainder of the file, including real key material; and
* content appearing *after* a ``*/`` on the same line, or a one-line
``/* ... */ secret``, was dropped.
Operating on the whole byte stream and removing only *balanced* comment
regions fixes both: an opening ``/*`` with no matching ``*/`` is left in
place (so its contents are still scanned), and bytes after a closing
``*/`` are preserved. Each removed region is replaced with a single
space so tokens on either side cannot be accidentally joined.
"""
out = bytearray()
i = 0
n = len(content)
while i < n:
start = content.find(BLOCK_START, i)
if start == -1:
out += content[i:]
break
end = content.find(BLOCK_END, start + len(BLOCK_START))
if end == -1:
# Unterminated block comment: do NOT treat the rest of the file as
# commented out. Keep it verbatim so secret scanning still runs.
out += content[i:]
break
out += content[i:start]
out += b" "
i = end + len(BLOCK_END)
return bytes(out)
def iter_effective_lines(self, content: bytes):
in_block = False
for raw in content.splitlines():
"""Yield non-comment lines for the soft content heuristics.
Block comments are removed first (only when properly closed), then
single-line comment prefixes are skipped. Genuinely commented-out
secrets remain ignored -- that is deliberate, documented behaviour -- but
a block-comment open can no longer suppress scanning of later, real
content.
"""
for raw in self._strip_balanced_block_comments(content).splitlines():
line = raw.lstrip()
if in_block:
if BLOCK_END in line:
in_block = False
continue
if not line:
continue
if line.startswith(BLOCK_START):
in_block = True
continue
if line.startswith(COMMENT_PREFIXES) or line.startswith(b"*"):
continue
@ -221,6 +294,20 @@ class IgnorePolicy:
return "binary_like"
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"
# 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.
for line in self.iter_effective_lines(data):
for pat in SENSITIVE_CONTENT_PATTERNS:
if pat.search(line):

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import re
from collections.abc import Mapping, Set as AbstractSet
from typing import Any
@ -7,6 +8,109 @@ from typing import Any
ANSIBLE_JINJA_STARTS = ("{{", "{%", "{#")
class RenderSafetyError(RuntimeError):
"""Raised when generated configuration-management text is unsafe.
This is a *generation-time* guardrail. It fires when Enroll would otherwise
emit raw scaffolding (task/handler YAML) built from a value that is not a
known-safe Enroll-controlled token, or when a generated task/handler file
does not round-trip to the structure Enroll intended. Either case means a
harvested value has leaked into playbook *structure* instead of staying in
Ansible *data*, so Enroll fails closed rather than writing a poisoned
manifest.
"""
# The only characters Enroll ever needs inside raw YAML scaffolding are those
# that make up sanitized role/module identifiers and a handful of fixed English
# words in task names. Anything outside this set must travel as Ansible *data*
# (a variable consumed via ``{{ ... }}``), never as scaffolding text.
#
# This is deliberately strict: it matches the charset produced by
# ``ansible._role_id`` / ``package_hints.role_id`` plus spaces (for the fixed
# human-readable portions of task names that Enroll itself authors). It does NOT
# permit quotes, colons, newlines, braces, or any YAML metacharacter, so a value
# that passes this gate cannot alter document structure.
_SCAFFOLD_TOKEN_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_ .-]*$")
def scaffold_token(value: str, *, field: str = "scaffold token") -> str:
"""Return *value* only if it is safe to splice into raw YAML scaffolding.
Use this for the *only* legitimate reason to interpolate a dynamic value
into hand-written YAML text: an Enroll-generated, already-sanitized
identifier such as a role name or module name. Harvested free-text (unit
names, file paths, package descriptions, user fields, ...) must never be
passed here -- it belongs in a variable file via :func:`ansible_unsafe_data`
and must be referenced from tasks through ``{{ ... }}`` indirection.
Raising rather than escaping is intentional. Escaping invites a long tail of
"did we cover every YAML metacharacter / Jinja delimiter / indentation
trick" bugs. A hard allowlist makes structural injection impossible by
construction: if a caller ever tries to splice harvested data into
scaffolding, generation aborts loudly instead of silently producing unsafe
output.
"""
text = "" if value is None else str(value)
if not _SCAFFOLD_TOKEN_RE.fullmatch(text):
raise RenderSafetyError(
f"refusing to interpolate unsafe {field} into generated YAML "
f"scaffolding: {text!r}. Harvested values must be passed as Ansible "
f"data (a variable rendered through ansible_unsafe_data), not spliced "
f"into task/handler text."
)
return text
def assert_generated_yaml_safe(text: str, *, label: str) -> None:
"""Verify a block of Enroll-generated task/handler YAML is well-formed.
This is the backstop for the scaffold-token rule. Even if a future change
reintroduces raw interpolation of a harvested value, this check parses the
generated YAML and confirms it is a list of mappings (Ansible task/handler
shape) with only string keys -- the structure Enroll intends. A harvested
value that injects an extra list item, a non-mapping entry, a non-string
key, or breaks parsing entirely will trip this and abort generation.
It is intentionally structural, not value-level: it does not try to judge
whether a *value* is dangerous (that is what ``ansible_unsafe_data`` handles
for variable files). It ensures harvested data cannot change the *shape* of
a generated tasks/handlers document.
"""
import yaml
try:
doc = yaml.safe_load(text)
except yaml.YAMLError as e: # pragma: no cover - exercised via tests
raise RenderSafetyError(
f"generated {label} is not valid YAML; a harvested value likely "
f"broke document structure: {e}"
) from e
if doc is None:
# An empty "---\n" document is a legitimate "no tasks/handlers" result.
return
if not isinstance(doc, list):
raise RenderSafetyError(
f"generated {label} is not a YAML list of tasks/handlers; a "
f"harvested value likely altered document structure"
)
for entry in doc:
if not isinstance(entry, Mapping):
raise RenderSafetyError(
f"generated {label} contains a non-mapping entry; a harvested "
f"value likely injected list structure: {entry!r}"
)
for key in entry.keys():
if not isinstance(key, str):
raise RenderSafetyError(
f"generated {label} contains a non-string task key; a "
f"harvested value likely injected mapping structure: {key!r}"
)
class AnsibleUnsafeText(str):
"""String subclass dumped as Ansible's ``!unsafe`` YAML scalar.