Remove 'enroll diff --enforce' option. Tighten yaml data re: handlers - use listen: instead of notify:
This commit is contained in:
parent
e9d7d74445
commit
903125976d
15 changed files with 851 additions and 1288 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
391
enroll/diff.py
391
enroll/diff.py
|
|
@ -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")
|
||||
|
|
|
|||
131
enroll/ignore.py
131
enroll/ignore.py
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue