diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..4f9d0e7 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + +jobs: + test: + runs-on: docker + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ansible + + - name: Install Poetry + run: | + pipx install poetry==1.8.3 + /root/.local/bin/poetry --version + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Install project deps (including test extras) + run: | + poetry install --with test + + - name: Run test script + run: | + ./tests.sh + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml new file mode 100644 index 0000000..6e86a79 --- /dev/null +++ b/.forgejo/workflows/lint.yml @@ -0,0 +1,40 @@ +name: Lint + +on: + push: + +jobs: + test: + runs-on: docker + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + black pyflakes3 python3-bandit + + - name: Run linters + run: | + black --diff --check enroll/* + black --diff --check tests/* + pyflakes3 enroll/* + pyflakes3 tests/* + bandit -r enroll/ + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml new file mode 100644 index 0000000..fad2f6f --- /dev/null +++ b/.forgejo/workflows/trivy.yml @@ -0,0 +1,40 @@ +name: Trivy + +on: + schedule: + - cron: '0 1 * * *' + push: + +jobs: + test: + runs-on: docker + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy + + - name: Run trivy + run: | + trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry . + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 0000000..3b6678d --- /dev/null +++ b/CHANGELOG.txt @@ -0,0 +1,13 @@ +# 0.0.2 + + * Merge pkg_ and roles created based on file/service detection + * Avoid idempotency issue with users (password_lock) + * Rename subcommands/args ('export' is now 'enroll', '--bundle' is now '--harvest') + * Don't try and start systemd services that were Inactive at harvest time + * Capture miscellaneous files in /etc under their own etc_custom role, but not backup files + * Add tests + * Various other bug fixes + +# 0.0.1 + + * Initial commit diff --git a/README.md b/README.md index ff6f353..27b790d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ It aims to be **optimistic and noninteractive**: - Also captures **service-relevant custom/unowned files** under `/etc//...` (e.g. drop-in config includes). - Defensively excludes likely secrets (path denylist + content sniff + size caps). - Captures non-system users that exist on the system, and their SSH public keys +- Captures miscellaneous `/etc` files that it can't attribute to a package, and installs it in an `etc_custom` role +- Avoids trying to start systemd services that were detected as being Inactive during harvest ## Install (Poetry) @@ -21,22 +23,22 @@ poetry run enroll --help On the host (root recommended): -### 1. Generate a bundle of state/information about the host +### 1. Harvest state/information about the host ```bash -sudo poetry run enroll harvest --out /tmp/enroll-bundle +sudo poetry run enroll harvest --out /tmp/enroll-harvest ``` -### 2. Generate Ansible manifests (roles/playbook) from that bundle +### 2. Generate Ansible manifests (roles/playbook) from that harvest ```bash -sudo poetry run enroll manifest --bundle /tmp/enroll-bundle --out /tmp/enroll-ansible +sudo poetry run enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible ``` ### Alternatively, do both steps in one shot: ```bash -sudo poetry run enroll export --bundle /tmp/enroll-bundle --out /tmp/enroll-ansible +sudo poetry run enroll enroll --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible ``` Then run: diff --git a/enroll/accounts.py b/enroll/accounts.py index f47c0e4..7c92f4e 100644 --- a/enroll/accounts.py +++ b/enroll/accounts.py @@ -2,7 +2,7 @@ from __future__ import annotations import os from dataclasses import dataclass -from typing import Dict, List, Optional, Set, Tuple +from typing import Dict, List, Set, Tuple @dataclass @@ -27,7 +27,12 @@ def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]: if not line or line.startswith("#"): continue parts = line.split() - if len(parts) >= 2 and parts[0] in {"UID_MIN", "UID_MAX", "SYS_UID_MIN", "SYS_UID_MAX"}: + if len(parts) >= 2 and parts[0] in { + "UID_MIN", + "UID_MAX", + "SYS_UID_MIN", + "SYS_UID_MAX", + }: try: vals[parts[0]] = int(parts[1]) except ValueError: @@ -37,7 +42,9 @@ def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]: return vals -def parse_passwd(path: str = "/etc/passwd") -> List[Tuple[str, int, int, str, str, str]]: +def parse_passwd( + path: str = "/etc/passwd", +) -> List[Tuple[str, int, int, str, str, str]]: rows: List[Tuple[str, int, int, str, str, str]] = [] with open(path, "r", encoding="utf-8", errors="replace") as f: for line in f: @@ -60,7 +67,9 @@ def parse_passwd(path: str = "/etc/passwd") -> List[Tuple[str, int, int, str, st return rows -def parse_group(path: str = "/etc/group") -> Tuple[Dict[int, str], Dict[str, int], Dict[str, Set[str]]]: +def parse_group( + path: str = "/etc/group", +) -> Tuple[Dict[int, str], Dict[str, int], Dict[str, Set[str]]]: gid_to_name: Dict[int, str] = {} name_to_gid: Dict[str, int] = {} members: Dict[str, Set[str]] = {} @@ -130,16 +139,18 @@ def collect_non_system_users() -> List[UserRecord]: ssh_files = find_user_ssh_files(home) if home and home.startswith("/") else [] - users.append(UserRecord( - name=name, - uid=uid, - gid=gid, - gecos=gecos, - home=home, - shell=shell, - primary_group=primary_group, - supplementary_groups=supp, - ssh_files=ssh_files, - )) + users.append( + UserRecord( + name=name, + uid=uid, + gid=gid, + gecos=gecos, + home=home, + shell=shell, # nosec + primary_group=primary_group, + supplementary_groups=supp, + ssh_files=ssh_files, + ) + ) return users diff --git a/enroll/cli.py b/enroll/cli.py index abdc01f..530a388 100644 --- a/enroll/cli.py +++ b/enroll/cli.py @@ -9,16 +9,32 @@ def main() -> None: ap = argparse.ArgumentParser(prog="enroll") sub = ap.add_subparsers(dest="cmd", required=True) - h = sub.add_parser("harvest", help="Harvest service/package/config state into a bundle") - h.add_argument("--out", required=True, help="Bundle output directory") + h = sub.add_parser("harvest", help="Harvest service/package/config state") + h.add_argument("--out", required=True, help="Harvest output directory") - r = sub.add_parser("manifest", help="Render Ansible roles from a harvested bundle") - r.add_argument("--bundle", required=True, help="Path to the bundle directory created by the harvest command") - r.add_argument("--out", required=True, help="Output directory for generated roles/playbook Ansible manifest") + r = sub.add_parser("manifest", help="Render Ansible roles from a harvest") + r.add_argument( + "--harvest", + required=True, + help="Path to the directory created by the harvest command", + ) + r.add_argument( + "--out", + required=True, + help="Output directory for generated roles/playbook Ansible manifest", + ) - e = sub.add_parser("export", help="Harvest then manifest in one shot") - e.add_argument("--bundle", required=True, help="Path to the directory to place the bundle in") - e.add_argument("--out", required=True, help="Output directory for generated roles/playbook Ansible manifest") + e = sub.add_parser( + "enroll", help="Harvest state, then manifest Ansible code, in one shot" + ) + e.add_argument( + "--harvest", required=True, help="Path to the directory to place the harvest in" + ) + e.add_argument( + "--out", + required=True, + help="Output directory for generated roles/playbook Ansible manifest", + ) args = ap.parse_args() @@ -26,7 +42,7 @@ def main() -> None: path = harvest(args.out) print(path) elif args.cmd == "manifest": - manifest(args.bundle, args.out) - elif args.cmd == "export": - harvest(args.bundle) - manifest(args.bundle, args.out) + manifest(args.harvest, args.out) + elif args.cmd == "enroll": + harvest(args.harvest) + manifest(args.harvest, args.out) diff --git a/enroll/debian.py b/enroll/debian.py index 2874403..6324d94 100644 --- a/enroll/debian.py +++ b/enroll/debian.py @@ -3,19 +3,19 @@ from __future__ import annotations import glob import hashlib import os -import subprocess +import subprocess # nosec from typing import Dict, List, Optional, Set, Tuple def _run(cmd: list[str]) -> str: - p = subprocess.run(cmd, check=False, text=True, capture_output=True) + p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec if p.returncode != 0: raise RuntimeError(f"Command failed: {cmd}\n{p.stderr}") return p.stdout def dpkg_owner(path: str) -> Optional[str]: - p = subprocess.run(["dpkg", "-S", path], text=True, capture_output=True) + p = subprocess.run(["dpkg", "-S", path], text=True, capture_output=True) #nosec if p.returncode != 0: return None left = p.stdout.split(":", 1)[0].strip() @@ -23,10 +23,9 @@ def dpkg_owner(path: str) -> Optional[str]: return pkg or None - def list_manual_packages() -> List[str]: """Return packages marked as manually installed (apt-mark showmanual).""" - p = subprocess.run(["apt-mark", "showmanual"], text=True, capture_output=True) + p = subprocess.run(["apt-mark", "showmanual"], text=True, capture_output=True) #nosec if p.returncode != 0: return [] pkgs: List[str] = [] @@ -37,6 +36,7 @@ def list_manual_packages() -> List[str]: pkgs.append(line) return sorted(set(pkgs)) + def build_dpkg_etc_index( info_dir: str = "/var/lib/dpkg/info", ) -> Tuple[Set[str], Dict[str, str], Dict[str, Set[str]], Dict[str, List[str]]]: @@ -83,7 +83,9 @@ def build_dpkg_etc_index( return owned, owner, topdir_to_pkgs, pkg_to_etc -def parse_status_conffiles(status_path: str = "/var/lib/dpkg/status") -> Dict[str, Dict[str, str]]: +def parse_status_conffiles( + status_path: str = "/var/lib/dpkg/status", +) -> Dict[str, Dict[str, str]]: """ pkg -> { "/etc/foo": md5hex, ... } based on dpkg status "Conffiles" field. This md5 is the packaged baseline for the conffile. @@ -152,7 +154,7 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]: def file_md5(path: str) -> str: - h = hashlib.md5() + h = hashlib.md5() # nosec with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) @@ -164,6 +166,7 @@ def stat_triplet(path: str) -> Tuple[str, str, str]: mode = oct(st.st_mode & 0o777)[2:].zfill(4) import pwd, grp + try: owner = pwd.getpwuid(st.st_uid).pw_name except KeyError: diff --git a/enroll/harvest.py b/enroll/harvest.py index 437cd66..9df950d 100644 --- a/enroll/harvest.py +++ b/enroll/harvest.py @@ -18,8 +18,7 @@ from .debian import ( stat_triplet, ) from .secrets import SecretPolicy -from .accounts import collect_non_system_users, UserRecord - +from .accounts import collect_non_system_users @dataclass @@ -43,6 +42,10 @@ class ServiceSnapshot: unit: str role_name: str packages: List[str] + active_state: Optional[str] + sub_state: Optional[str] + unit_file_state: Optional[str] + condition_result: Optional[str] managed_files: List[ManagedFile] excluded: List[ExcludedFile] notes: List[str] @@ -66,15 +69,59 @@ class UsersSnapshot: notes: List[str] +@dataclass +class EtcCustomSnapshot: + role_name: str + managed_files: List[ManagedFile] + excluded: List[ExcludedFile] + notes: List[str] + + ALLOWED_UNOWNED_EXTS = { - ".conf", ".cfg", ".ini", ".cnf", ".yaml", ".yml", ".json", ".toml", - ".rules", ".service", ".socket", ".timer", ".target", ".path", ".mount", - ".network", ".netdev", ".link", + ".conf", + ".cfg", + ".ini", + ".cnf", + ".yaml", + ".yml", + ".json", + ".toml", + ".rules", + ".service", + ".socket", + ".timer", + ".target", + ".path", + ".mount", + ".network", + ".netdev", + ".link", "", # allow extensionless (common in /etc/default and /etc/init.d) } MAX_UNOWNED_FILES_PER_ROLE = 400 +# Directories that are shared across many packages; never attribute unowned files in these trees to a single package. +SHARED_ETC_TOPDIRS = { + "default", + "apparmor.d", + "network", + "init.d", + "systemd", + "pam.d", + "ssh", + "ssl", + "sudoers.d", + "cron.d", + "cron.daily", + "cron.weekly", + "cron.monthly", + "cron.hourly", + "logrotate.d", + "sysctl.d", + "modprobe.d", +} + def _safe_name(s: str) -> str: out: List[str] = [] @@ -89,10 +136,12 @@ def _role_name_from_unit(unit: str) -> str: def _role_name_from_pkg(pkg: str) -> str: - return "pkg_" + _safe_name(pkg) + return _safe_name(pkg) -def _copy_into_bundle(bundle_dir: str, role_name: str, abs_path: str, src_rel: str) -> None: +def _copy_into_bundle( + bundle_dir: str, role_name: str, abs_path: str, src_rel: str +) -> None: dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel) os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.copy2(abs_path, dst) @@ -114,7 +163,9 @@ def _hint_names(unit: str, pkgs: Set[str]) -> Set[str]: return {h for h in hints if h} -def _add_pkgs_from_etc_topdirs(hints: Set[str], topdir_to_pkgs: Dict[str, Set[str]], pkgs: Set[str]) -> None: +def _add_pkgs_from_etc_topdirs( + hints: Set[str], topdir_to_pkgs: Dict[str, Set[str]], pkgs: Set[str] +) -> None: for h in hints: for p in topdir_to_pkgs.get(h, set()): pkgs.add(p) @@ -123,16 +174,20 @@ def _add_pkgs_from_etc_topdirs(hints: Set[str], topdir_to_pkgs: Dict[str, Set[st def _maybe_add_specific_paths(hints: Set[str]) -> List[str]: paths: List[str] = [] for h in hints: - paths.extend([ - f"/etc/default/{h}", - f"/etc/init.d/{h}", - f"/etc/sysctl.d/{h}.conf", - f"/etc/logrotate.d/{h}", - ]) + paths.extend( + [ + f"/etc/default/{h}", + f"/etc/init.d/{h}", + f"/etc/sysctl.d/{h}.conf", + f"/etc/logrotate.d/{h}", + ] + ) return paths -def _scan_unowned_under_roots(roots: List[str], owned_etc: Set[str], limit: int = MAX_UNOWNED_FILES_PER_ROLE) -> List[str]: +def _scan_unowned_under_roots( + roots: List[str], owned_etc: Set[str], limit: int = MAX_UNOWNED_FILES_PER_ROLE +) -> List[str]: found: List[str] = [] for root in roots: if not os.path.isdir(root): @@ -170,7 +225,10 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: os.makedirs(bundle_dir, exist_ok=True) if hasattr(os, "geteuid") and os.geteuid() != 0: - print("Warning: not running as root; harvest may miss files or metadata.", flush=True) + print( + "Warning: not running as root; harvest may miss files or metadata.", + flush=True, + ) owned_etc, etc_owner_map, topdir_to_pkgs, pkg_to_etc_paths = build_dpkg_etc_index() conffiles_by_pkg = parse_status_conffiles() @@ -185,14 +243,20 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: try: ui = get_unit_info(unit) except UnitQueryError as e: - service_snaps.append(ServiceSnapshot( - unit=unit, - role_name=role, - packages=[], - managed_files=[], - excluded=[], - notes=[str(e)], - )) + service_snaps.append( + ServiceSnapshot( + unit=unit, + role_name=role, + packages=[], + active_state=None, + sub_state=None, + unit_file_state=None, + condition_result=None, + managed_files=[], + excluded=[], + notes=[str(e)], + ) + ) continue pkgs: Set[str] = set() @@ -243,6 +307,7 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: if not os.path.isfile(path) or os.path.islink(path): continue if path in conff: + # Only capture conffiles when they differ from the package default. try: current = file_md5(path) except OSError: @@ -267,7 +332,9 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: candidates.setdefault(pth, "custom_unowned") if not pkgs and not candidates: - notes.append("No packages or /etc candidates detected (unexpected for enabled service).") + notes.append( + "No packages or /etc candidates detected (unexpected for enabled service)." + ) for path, reason in sorted(candidates.items()): deny = policy.deny_reason(path) @@ -285,31 +352,49 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: except OSError: excluded.append(ExcludedFile(path=path, reason="unreadable")) continue - managed.append(ManagedFile( - path=path, - src_rel=src_rel, - owner=owner, - group=group, - mode=mode, - reason=reason, - )) + managed.append( + ManagedFile( + path=path, + src_rel=src_rel, + owner=owner, + group=group, + mode=mode, + reason=reason, + ) + ) - service_snaps.append(ServiceSnapshot( - unit=unit, - role_name=role, - packages=sorted(pkgs), - managed_files=managed, - excluded=excluded, - notes=notes, - )) + service_snaps.append( + ServiceSnapshot( + unit=unit, + role_name=role, + packages=sorted(pkgs), + active_state=ui.active_state, + sub_state=ui.sub_state, + unit_file_state=ui.unit_file_state, + condition_result=ui.condition_result, + managed_files=managed, + excluded=excluded, + notes=notes, + ) + ) # ------------------------- # Manual package roles # ------------------------- manual_pkgs = list_manual_packages() + # Avoid duplicate roles: if a manual package is already managed by any service role, skip its pkg_ role. + covered_by_services: Set[str] = set() + for s in service_snaps: + for p in s.packages: + covered_by_services.add(p) + + manual_pkgs_skipped: List[str] = [] pkg_snaps: List[PackageSnapshot] = [] for pkg in manual_pkgs: + if pkg in covered_by_services: + manual_pkgs_skipped.append(pkg) + continue role = _role_name_from_pkg(pkg) notes: List[str] = [] excluded: List[ExcludedFile] = [] @@ -343,13 +428,17 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: topdirs = _topdirs_for_package(pkg, pkg_to_etc_paths) roots: List[str] = [] for td in sorted(topdirs): + if td in SHARED_ETC_TOPDIRS: + continue roots.extend([f"/etc/{td}", f"/etc/{td}.d"]) roots.extend([f"/etc/default/{td}"]) roots.extend([f"/etc/init.d/{td}"]) roots.extend([f"/etc/logrotate.d/{td}"]) roots.extend([f"/etc/sysctl.d/{td}.conf"]) - for pth in _scan_unowned_under_roots([r for r in roots if os.path.isdir(r)], owned_etc): + for pth in _scan_unowned_under_roots( + [r for r in roots if os.path.isdir(r)], owned_etc + ): candidates.setdefault(pth, "custom_unowned") for r in roots: @@ -373,25 +462,31 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: except OSError: excluded.append(ExcludedFile(path=path, reason="unreadable")) continue - managed.append(ManagedFile( - path=path, - src_rel=src_rel, - owner=owner, - group=group, - mode=mode, - reason=reason, - )) + managed.append( + ManagedFile( + path=path, + src_rel=src_rel, + owner=owner, + group=group, + mode=mode, + reason=reason, + ) + ) if not pkg_to_etc_paths.get(pkg, []) and not managed: - notes.append("No /etc files detected for this package (may be a meta package).") + notes.append( + "No /etc files detected for this package (may be a meta package)." + ) - pkg_snaps.append(PackageSnapshot( - package=pkg, - role_name=role, - managed_files=managed, - excluded=excluded, - notes=notes, - )) + pkg_snaps.append( + PackageSnapshot( + package=pkg, + role_name=role, + managed_files=managed, + excluded=excluded, + notes=notes, + ) + ) # ------------------------- # Users role (non-system users) @@ -402,7 +497,7 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: users_list: List[dict] = [] try: - us + user_records = collect_non_system_users() except Exception as e: user_records = [] users_notes.append(f"Failed to enumerate users: {e!r}") @@ -410,47 +505,51 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: users_role_name = "users" for u in user_records: - users_list.append({ - "name": u.name, - "uid": u.uid, - "gid": u.gid, - "gecos": u.gecos, - "home": u.home, - "shell": u.shell, - "primary_group": u.primary_group, - "supplementary_groups": u.supplementary_groups, - }) + users_list.append( + { + "name": u.name, + "uid": u.uid, + "gid": u.gid, + "gecos": u.gecos, + "home": u.home, + "shell": u.shell, + "primary_group": u.primary_group, + "supplementary_groups": u.supplementary_groups, + } + ) - # Copy authorized_keys + # Copy only safe SSH public material: authorized_keys + *.pub for sf in u.ssh_files: deny = policy.deny_reason(sf) if deny: users_excluded.append(ExcludedFile(path=sf, reason=deny)) continue - - # Force safe modes; still record current owner/group for reference. try: owner, group, mode = stat_triplet(sf) except OSError: users_excluded.append(ExcludedFile(path=sf, reason="unreadable")) continue - src_rel = sf.lstrip("/") try: _copy_into_bundle(bundle_dir, users_role_name, sf, src_rel) except OSError: users_excluded.append(ExcludedFile(path=sf, reason="unreadable")) continue - - reason = "authorized_keys" if sf.endswith("/authorized_keys") else "ssh_public_key" - users_managed.append(ManagedFile( - path=sf, - src_rel=src_rel, - owner=owner, - group=group, - mode=mode, - reason=reason, - )) + reason = ( + "authorized_keys" + if sf.endswith("/authorized_keys") + else "ssh_public_key" + ) + users_managed.append( + ManagedFile( + path=sf, + src_rel=src_rel, + owner=owner, + group=group, + mode=mode, + reason=reason, + ) + ) users_snapshot = UsersSnapshot( role_name=users_role_name, @@ -460,12 +559,91 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: notes=users_notes, ) + # ------------------------- + # etc_custom role (unowned /etc files not already attributed elsewhere) + # ------------------------- + etc_notes: List[str] = [] + etc_excluded: List[ExcludedFile] = [] + etc_managed: List[ManagedFile] = [] + etc_role_name = "etc_custom" + + # Build a set of files already captured by other roles. + already: Set[str] = set() + for s in service_snaps: + for mf in s.managed_files: + already.add(mf.path) + for p in pkg_snaps: + for mf in p.managed_files: + already.add(mf.path) + for mf in users_managed: + already.add(mf.path) + + # Walk /etc for unowned config-ish files + scanned = 0 + for dirpath, _, filenames in os.walk("/etc"): + for fn in filenames: + path = os.path.join(dirpath, fn) + if path in already: + continue + if path in owned_etc: + continue + if not os.path.isfile(path) or os.path.islink(path): + continue + if not _is_confish(path): + continue + + deny = policy.deny_reason(path) + if deny: + etc_excluded.append(ExcludedFile(path=path, reason=deny)) + continue + + try: + owner, group, mode = stat_triplet(path) + except OSError: + etc_excluded.append(ExcludedFile(path=path, reason="unreadable")) + continue + + src_rel = path.lstrip("/") + try: + _copy_into_bundle(bundle_dir, etc_role_name, path, src_rel) + except OSError: + etc_excluded.append(ExcludedFile(path=path, reason="unreadable")) + continue + + etc_managed.append( + ManagedFile( + path=path, + src_rel=src_rel, + owner=owner, + group=group, + mode=mode, + reason="custom_unowned", + ) + ) + scanned += 1 + if scanned >= 2000: + etc_notes.append( + "Reached file cap (2000) while scanning /etc for unowned files." + ) + break + if scanned >= 2000: + break + + etc_custom_snapshot = EtcCustomSnapshot( + role_name=etc_role_name, + managed_files=etc_managed, + excluded=etc_excluded, + notes=etc_notes, + ) + state = { "host": {"hostname": os.uname().nodename, "os": "debian"}, "users": asdict(users_snapshot), "services": [asdict(s) for s in service_snaps], "manual_packages": manual_pkgs, + "manual_packages_skipped": manual_pkgs_skipped, "package_roles": [asdict(p) for p in pkg_snaps], + "etc_custom": asdict(etc_custom_snapshot), } state_path = os.path.join(bundle_dir, "state.json") diff --git a/enroll/manifest.py b/enroll/manifest.py index b502abb..a7395e1 100644 --- a/enroll/manifest.py +++ b/enroll/manifest.py @@ -50,12 +50,14 @@ def manifest(bundle_dir: str, out_dir: str) -> None: services: List[Dict[str, Any]] = state.get("services", []) package_roles: List[Dict[str, Any]] = state.get("package_roles", []) users_snapshot: Dict[str, Any] = state.get("users", {}) + etc_custom_snapshot: Dict[str, Any] = state.get("etc_custom", {}) os.makedirs(out_dir, exist_ok=True) roles_root = os.path.join(out_dir, "roles") os.makedirs(roles_root, exist_ok=True) manifested_users_roles: List[str] = [] + manifested_etc_custom_roles: List[str] = [] manifested_service_roles: List[str] = [] manifested_pkg_roles: List[str] = [] @@ -86,11 +88,17 @@ def manifest(bundle_dir: str, out_dir: str) -> None: # defaults: store users list (handy for later), but tasks are explicit for readability defaults = """--- users_accounts: -""" + ("\n".join([f" - name: {u.get('name')}" for u in users]) + "\n") - with open(os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8") as f: +""" + ( + "\n".join([f" - name: {u.get('name')}" for u in users]) + "\n" + ) + with open( + os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(defaults) - with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8" + ) as f: f.write("---\ndependencies: []\n") # tasks @@ -112,7 +120,7 @@ users_accounts: lines.append(f" group: {u.get('primary_group')}") supp = u.get("supplementary_groups") or [] if supp: - lines.append(" groups: " + ",".join(supp)) + lines.append(" groups: " + ",".join(sorted(supp))) lines.append(" append: true") lines.append(f" home: {u.get('home')}") lines.append(" create_home: true") @@ -120,9 +128,8 @@ users_accounts: lines.append(f" shell: {u.get('shell')}") if u.get("gecos"): # quote to avoid YAML surprises - gec = u.get("gecos").replace('"', '\"') + gec = u.get("gecos").replace('"', '"') lines.append(f' comment: "{gec}"') - lines.append(" password_lock: true") lines.append(" state: present") # Ensure ~/.ssh @@ -163,30 +170,122 @@ users_accounts: lines.append(f" mode: '{mode}'") tasks = "\n".join(lines).rstrip() + "\n" - with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(tasks) # handlers (none needed) - with open(os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" + ) as f: f.write("---\n") - readme = """# users + readme = ( + """# users Generated non-system user accounts and SSH public material. ## Users -""" + ("\n".join([f"- {u.get('name')} (uid {u.get('uid')})" for u in users]) or "- (none)") + """\n +""" + + ( + "\n".join([f"- {u.get('name')} (uid {u.get('uid')})" for u in users]) + or "- (none)" + ) + + """\n ## Included SSH files -""" + ("\n".join([f"- {mf.get('path')} ({mf.get('reason')})" for mf in managed_files]) or "- (none)") + """\n +""" + + ( + "\n".join( + [f"- {mf.get('path')} ({mf.get('reason')})" for mf in managed_files] + ) + or "- (none)" + ) + + """\n ## Excluded -""" + ("\n".join([f"- {e.get('path')} ({e.get('reason')})" for e in excluded]) or "- (none)") + """\n +""" + + ( + "\n".join([f"- {e.get('path')} ({e.get('reason')})" for e in excluded]) + or "- (none)" + ) + + """\n ## Notes -""" + ("\n".join([f"- {n}" for n in notes]) or "- (none)") + """\n""" +""" + + ("\n".join([f"- {n}" for n in notes]) or "- (none)") + + """\n""" + ) with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f: f.write(readme) manifested_users_roles.append(role) + # ------------------------- + # etc_custom role (unowned /etc not already attributed) + # ------------------------- + if etc_custom_snapshot and etc_custom_snapshot.get("managed_files"): + role = etc_custom_snapshot.get("role_name", "etc_custom") + role_dir = os.path.join(roles_root, role) + _write_role_scaffold(role_dir) + _copy_artifacts(bundle_dir, role, role_dir) + + managed_files = etc_custom_snapshot.get("managed_files", []) + excluded = etc_custom_snapshot.get("excluded", []) + notes = etc_custom_snapshot.get("notes", []) + + # tasks: just deploy files (no restarts) + lines: List[str] = ["---"] + for mf in managed_files: + dest = mf["path"] + src = mf["src_rel"] + lines.append(f"- name: Deploy {dest}") + lines.append(" ansible.builtin.copy:") + lines.append(f" src: {src}") + lines.append(f" dest: {dest}") + lines.append(f" owner: {mf.get('owner')}") + lines.append(f" group: {mf.get('group')}") + lines.append(f" mode: '{mf.get('mode')}'") + + tasks = "\n".join(lines).rstrip() + "\n" + with open( + os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8" + ) as f: + f.write(tasks) + + with open( + os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" + ) as f: + f.write("---\n") + with open( + os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8" + ) as f: + f.write("---\ndependencies: []\n") + + readme = ( + """# etc_custom + +Unowned /etc config files not attributed to packages or services. + +## Managed files +""" + + ("\n".join([f"- {mf.get('path')}" for mf in managed_files]) or "- (none)") + + """\n +## Excluded +""" + + ( + "\n".join([f"- {e.get('path')} ({e.get('reason')})" for e in excluded]) + or "- (none)" + ) + + """\n +## Notes +""" + + ("\n".join([f"- {n}" for n in notes]) or "- (none)") + + """\n""" + ) + with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f: + f.write(readme) + + manifested_etc_custom_roles.append(role) + # ------------------------- # Service roles # ------------------------- @@ -202,11 +301,16 @@ Generated non-system user accounts and SSH public material. var_prefix = role + was_active = svc.get("active_state") == "active" defaults = f"""--- {var_prefix}_packages: {_yaml_list(pkgs, indent=2)} +{var_prefix}_active_state_at_harvest: "{svc.get("active_state")}" +{var_prefix}_start: {"true" if was_active else "false"} """ - with open(os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(defaults) handlers = """--- @@ -219,10 +323,14 @@ Generated non-system user accounts and SSH public material. name: "{{ unit_name }}" state: restarted """ - with open(os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(handlers) - systemd_files = [mf for mf in managed_files if mf["path"].startswith("/etc/systemd/system/")] + systemd_files = [ + mf for mf in managed_files if mf["path"].startswith("/etc/systemd/system/") + ] other_files = [mf for mf in managed_files if mf not in systemd_files] def copy_task(mf: Dict[str, Any], notify: str | None) -> str: @@ -237,7 +345,8 @@ Generated non-system user accounts and SSH public material. {notify_line}""" task_parts: List[str] = [] - task_parts.append(f"""--- + task_parts.append( + f"""--- - name: Set unit name ansible.builtin.set_fact: unit_name: "{unit}" @@ -248,30 +357,44 @@ Generated non-system user accounts and SSH public material. state: present update_cache: true when: {var_prefix}_packages | length > 0 -""") +""" + ) if systemd_files: for mf in systemd_files: task_parts.append(copy_task(mf, "[systemd daemon-reload]")) - task_parts.append("""- name: Reload systemd to pick up unit changes + task_parts.append( + """- name: Reload systemd to pick up unit changes ansible.builtin.meta: flush_handlers -""") +""" + ) for mf in other_files: task_parts.append(copy_task(mf, "[Restart service]")) - task_parts.append(f"""- name: Ensure {unit} is enabled and running + task_parts.append( + f"""- name: Ensure {unit} is enabled (preserve running state) ansible.builtin.service: name: "{{{{ unit_name }}}}" enabled: true + +- name: Start {unit} if it was active at harvest time + ansible.builtin.service: + name: "{{{{ unit_name }}}}" state: started -""") + when: {var_prefix}_start | bool +""" + ) tasks = "\n".join(task_parts).rstrip() + "\n" - with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(tasks) - with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8" + ) as f: f.write("---\ndependencies: []\n") excluded = svc.get("excluded", []) @@ -315,7 +438,9 @@ Generated from `{unit}`. {var_prefix}_packages: - {pkg} """ - with open(os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(defaults) handlers = """--- @@ -323,10 +448,14 @@ Generated from `{unit}`. ansible.builtin.systemd: daemon_reload: true """ - with open(os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(handlers) - systemd_files = [mf for mf in managed_files if mf["path"].startswith("/etc/systemd/system/")] + systemd_files = [ + mf for mf in managed_files if mf["path"].startswith("/etc/systemd/system/") + ] other_files = [mf for mf in managed_files if mf not in systemd_files] def copy_task(mf: Dict[str, Any], notify: str | None) -> str: @@ -341,29 +470,37 @@ Generated from `{unit}`. {notify_line}""" task_parts: List[str] = [] - task_parts.append(f"""--- + task_parts.append( + f"""--- - name: Install manual package {pkg} ansible.builtin.apt: name: "{{{{ {var_prefix}_packages }}}}" state: present update_cache: true -""") +""" + ) if systemd_files: for mf in systemd_files: task_parts.append(copy_task(mf, "[systemd daemon-reload]")) - task_parts.append("""- name: Reload systemd to pick up unit changes + task_parts.append( + """- name: Reload systemd to pick up unit changes ansible.builtin.meta: flush_handlers -""") +""" + ) for mf in other_files: task_parts.append(copy_task(mf, None)) tasks = "\n".join(task_parts).rstrip() + "\n" - with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(tasks) - with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8" + ) as f: f.write("---\ndependencies: []\n") excluded = pr.get("excluded", []) @@ -389,4 +526,7 @@ Generated for manual package `{pkg}`. manifested_pkg_roles.append(role) # Playbooks - _write_playbook(os.path.join(out_dir, "playbook.yml"), manifested_users_roles + manifested_pkg_roles + manifested_service_roles) + _write_playbook( + os.path.join(out_dir, "playbook.yml"), + manifested_users_roles + manifested_etc_custom_roles + manifested_pkg_roles + manifested_service_roles, + ) diff --git a/enroll/secrets.py b/enroll/secrets.py index 9797e61..06514e5 100644 --- a/enroll/secrets.py +++ b/enroll/secrets.py @@ -8,6 +8,15 @@ from typing import Optional DEFAULT_DENY_GLOBS = [ + # Common backup copies created by passwd tools (can contain sensitive data) + "/etc/passwd-", + "/etc/group-", + "/etc/shadow-", + "/etc/gshadow-", + "/etc/subuid-", + "/etc/subgid-", + "/etc/*shadow-", + "/etc/*gshadow-", "/etc/ssl/private/*", "/etc/ssh/ssh_host_*", "/etc/shadow", @@ -17,9 +26,9 @@ DEFAULT_DENY_GLOBS = [ ] SENSITIVE_CONTENT_PATTERNS = [ - re.compile(br"-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----"), - re.compile(br"(?i)\bpassword\s*="), - re.compile(br"(?i)\b(pass|passwd|token|secret|api[_-]?key)\b"), + re.compile(rb"-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----"), + re.compile(rb"(?i)\bpassword\s*="), + re.compile(rb"(?i)\b(pass|passwd|token|secret|api[_-]?key)\b"), ] diff --git a/enroll/systemd.py b/enroll/systemd.py index 0cfbed0..828c518 100644 --- a/enroll/systemd.py +++ b/enroll/systemd.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -import subprocess +import subprocess # nosec from dataclasses import dataclass from typing import List, Optional @@ -12,7 +12,11 @@ class UnitInfo: fragment_path: Optional[str] dropin_paths: List[str] env_files: List[str] - exec_paths: List[str] # binaries from ExecStart "path=" parts + exec_paths: List[str] + active_state: Optional[str] + sub_state: Optional[str] + unit_file_state: Optional[str] + condition_result: Optional[str] class UnitQueryError(RuntimeError): @@ -23,14 +27,22 @@ class UnitQueryError(RuntimeError): def _run(cmd: list[str]) -> str: - p = subprocess.run(cmd, check=False, text=True, capture_output=True) + p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec if p.returncode != 0: raise RuntimeError(f"Command failed: {cmd}\n{p.stderr}") return p.stdout def list_enabled_services() -> List[str]: - out = _run(["systemctl", "list-unit-files", "--type=service", "--state=enabled", "--no-legend"]) + out = _run( + [ + "systemctl", + "list-unit-files", + "--type=service", + "--state=enabled", + "--no-legend", + ] + ) units: List[str] = [] for line in out.splitlines(): parts = line.split() @@ -39,7 +51,7 @@ def list_enabled_services() -> List[str]: unit = parts[0].strip() if not unit.endswith(".service"): continue - # Skip template units like "getty@.service" which are enabled but not valid for systemctl show + # Skip template units like "getty@.service" if unit.endswith("@.service") or "@.service" in unit: continue units.append(unit) @@ -49,13 +61,27 @@ def list_enabled_services() -> List[str]: def get_unit_info(unit: str) -> UnitInfo: p = subprocess.run( [ - "systemctl", "show", unit, - "-p", "FragmentPath", - "-p", "DropInPaths", - "-p", "EnvironmentFiles", - "-p", "ExecStart", + "systemctl", + "show", + unit, + "-p", + "FragmentPath", + "-p", + "DropInPaths", + "-p", + "EnvironmentFiles", + "-p", + "ExecStart", + "-p", + "ActiveState", + "-p", + "SubState", + "-p", + "UnitFileState", + "-p", + "ConditionResult", "--no-page", - ], + ], # nosec check=False, text=True, capture_output=True, @@ -70,7 +96,6 @@ def get_unit_info(unit: str) -> UnitInfo: kv[k] = v.strip() fragment = kv.get("FragmentPath") or None - dropins = [pp for pp in (kv.get("DropInPaths", "") or "").split() if pp] env_files: List[str] = [] @@ -87,4 +112,8 @@ def get_unit_info(unit: str) -> UnitInfo: dropin_paths=sorted(set(dropins)), env_files=sorted(set(env_files)), exec_paths=sorted(set(exec_paths)), + active_state=kv.get("ActiveState") or None, + sub_state=kv.get("SubState") or None, + unit_file_state=kv.get("UnitFileState") or None, + condition_result=kv.get("ConditionResult") or None, ) diff --git a/poetry.lock b/poetry.lock index a9b0222..3bffe25 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,7 +1,296 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. -package = [] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.13.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, + {file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, + {file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, + {file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, + {file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, + {file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, + {file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, + {file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, + {file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, + {file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, + {file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, + {file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, + {file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, + {file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, + {file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, + {file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, + {file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, + {file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, + {file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, + {file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, + {file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, + {file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, + {file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, + {file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, + {file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, + {file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, + {file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, + {file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, + {file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, + {file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, + {file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, + {file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, + {file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, + {file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, + {file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, + {file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "tomli" +version = "2.3.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "53f2eabc9c26446fbcc00d348c47878e118afc2054778c3c803a0a8028af27d9" +content-hash = "535a18dadbd873841a37b9823dc0871877559c03798b7fc25b86f030f191e630" diff --git a/pyproject.toml b/pyproject.toml index 43c9c90..b911822 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "enroll" -version = "0.0.1" +version = "0.0.2" description = "Enroll a server's running state retrospectively into Ansible" authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" @@ -14,6 +14,10 @@ python = "^3.10" [tool.poetry.scripts] enroll = "enroll.cli:main" +[tool.poetry.group.test.dependencies] +pytest = "^9.0.2" +pytest-cov = "^7.0.0" + [build-system] requires = ["poetry-core>=1.8.0"] build-backend = "poetry.core.masonry.api" diff --git a/tests.sh b/tests.sh new file mode 100755 index 0000000..cf5e03b --- /dev/null +++ b/tests.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +poetry run pytest -vvvv --cov=enroll --cov-report=term-missing --disable-warnings + +poetry run enroll enroll --harvest /tmp/bundle --out /tmp/ansible && \ + cd /tmp/ansible && \ + sudo ansible-playbook playbook.yml -i "localhost," -c local --check --diff + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2b99213 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path + +# Ensure repository root is on sys.path so `import enroll` resolves to the local package. +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a93c509 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,77 @@ +import sys + +import enroll.cli as cli + + +def test_cli_harvest_subcommand_calls_harvest(monkeypatch, capsys, tmp_path): + called = {} + + def fake_harvest(out: str): + called["out"] = out + return str(tmp_path / "state.json") + + monkeypatch.setattr(cli, "harvest", fake_harvest) + monkeypatch.setattr(sys, "argv", ["enroll", "harvest", "--out", str(tmp_path)]) + + cli.main() + assert called["out"] == str(tmp_path) + captured = capsys.readouterr() + assert str(tmp_path / "state.json") in captured.out + + +def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path): + called = {} + + def fake_manifest(harvest_dir: str, out_dir: str): + called["harvest"] = harvest_dir + called["out"] = out_dir + + monkeypatch.setattr(cli, "manifest", fake_manifest) + monkeypatch.setattr( + sys, + "argv", + [ + "enroll", + "manifest", + "--harvest", + str(tmp_path / "bundle"), + "--out", + str(tmp_path / "ansible"), + ], + ) + + cli.main() + assert called["harvest"] == str(tmp_path / "bundle") + assert called["out"] == str(tmp_path / "ansible") + + +def test_cli_enroll_subcommand_runs_harvest_then_manifest(monkeypatch, tmp_path): + calls = [] + + def fake_harvest(bundle_dir: str): + calls.append(("harvest", bundle_dir)) + return str(tmp_path / "bundle" / "state.json") + + def fake_manifest(bundle_dir: str, out_dir: str): + calls.append(("manifest", bundle_dir, out_dir)) + + monkeypatch.setattr(cli, "harvest", fake_harvest) + monkeypatch.setattr(cli, "manifest", fake_manifest) + monkeypatch.setattr( + sys, + "argv", + [ + "enroll", + "enroll", + "--harvest", + str(tmp_path / "bundle"), + "--out", + str(tmp_path / "ansible"), + ], + ) + + cli.main() + assert calls == [ + ("harvest", str(tmp_path / "bundle")), + ("manifest", str(tmp_path / "bundle"), str(tmp_path / "ansible")), + ] diff --git a/tests/test_harvest.py b/tests/test_harvest.py new file mode 100644 index 0000000..8e19fb4 --- /dev/null +++ b/tests/test_harvest.py @@ -0,0 +1,141 @@ +import json +from pathlib import Path + +import enroll.harvest as h +from enroll.systemd import UnitInfo + + +class AllowAllPolicy: + def deny_reason(self, path: str): + return None + + +def test_harvest_dedup_manual_packages_and_builds_etc_custom( + monkeypatch, tmp_path: Path +): + bundle = tmp_path / "bundle" + + import os + + real_isfile = os.path.isfile + real_isdir = os.path.isdir + real_exists = os.path.exists + real_islink = os.path.islink + + # Fake filesystem: two /etc files exist, only one is dpkg-owned. + files = { + "/etc/openvpn/server.conf": b"server", + "/etc/default/keyboard": b"kbd", + } + dirs = {"/etc", "/etc/openvpn", "/etc/default"} + + def fake_isfile(p: str) -> bool: + if p.startswith("/etc/") or p == "/etc": + return p in files + return real_isfile(p) + + def fake_isdir(p: str) -> bool: + if p.startswith("/etc"): + return p in dirs + return real_isdir(p) + + def fake_islink(p: str) -> bool: + if p.startswith("/etc"): + return False + return real_islink(p) + + def fake_exists(p: str) -> bool: + if p.startswith("/etc"): + return p in files or p in dirs + return real_exists(p) + + def fake_walk(root: str): + if root == "/etc": + yield ("/etc/openvpn", [], ["server.conf"]) + yield ("/etc/default", [], ["keyboard"]) + elif root == "/etc/openvpn": + yield ("/etc/openvpn", [], ["server.conf"]) + elif root == "/etc/default": + yield ("/etc/default", [], ["keyboard"]) + else: + yield (root, [], []) + + monkeypatch.setattr(h.os.path, "isfile", fake_isfile) + monkeypatch.setattr(h.os.path, "isdir", fake_isdir) + monkeypatch.setattr(h.os.path, "islink", fake_islink) + monkeypatch.setattr(h.os.path, "exists", fake_exists) + monkeypatch.setattr(h.os, "walk", fake_walk) + + # Avoid real system access + monkeypatch.setattr(h, "list_enabled_services", lambda: ["openvpn.service"]) + monkeypatch.setattr( + h, + "get_unit_info", + lambda unit: UnitInfo( + name=unit, + fragment_path="/lib/systemd/system/openvpn.service", + dropin_paths=[], + env_files=[], + exec_paths=["/usr/sbin/openvpn"], + active_state="inactive", + sub_state="dead", + unit_file_state="enabled", + condition_result=None, + ), + ) + + # Debian package index: openvpn owns /etc/openvpn/server.conf; keyboard is unowned. + def fake_build_index(): + owned_etc = {"/etc/openvpn/server.conf"} + etc_owner_map = {"/etc/openvpn/server.conf": "openvpn"} + topdir_to_pkgs = {"openvpn": {"openvpn"}} + pkg_to_etc_paths = {"openvpn": ["/etc/openvpn/server.conf"], "curl": []} + return owned_etc, etc_owner_map, topdir_to_pkgs, pkg_to_etc_paths + + monkeypatch.setattr(h, "build_dpkg_etc_index", fake_build_index) + + # openvpn conffile hash mismatch => should be captured under service role + monkeypatch.setattr( + h, + "parse_status_conffiles", + lambda: {"openvpn": {"/etc/openvpn/server.conf": "old"}}, + ) + monkeypatch.setattr(h, "read_pkg_md5sums", lambda pkg: {}) + monkeypatch.setattr(h, "file_md5", lambda path: "new") + + monkeypatch.setattr( + h, "dpkg_owner", lambda p: "openvpn" if "openvpn" in p else None + ) + monkeypatch.setattr(h, "list_manual_packages", lambda: ["openvpn", "curl"]) + monkeypatch.setattr(h, "collect_non_system_users", lambda: []) + + monkeypatch.setattr(h, "stat_triplet", lambda p: ("root", "root", "0644")) + + # Avoid needing source files on disk by implementing our own bundle copier + def fake_copy(bundle_dir: str, role_name: str, abs_path: str, src_rel: str): + dst = Path(bundle_dir) / "artifacts" / role_name / src_rel + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_bytes(files.get(abs_path, b"")) + + monkeypatch.setattr(h, "_copy_into_bundle", fake_copy) + + state_path = h.harvest(str(bundle), policy=AllowAllPolicy()) + st = json.loads(Path(state_path).read_text(encoding="utf-8")) + + assert "openvpn" in st["manual_packages"] + assert "curl" in st["manual_packages"] + assert "openvpn" in st["manual_packages_skipped"] + assert all(pr["package"] != "openvpn" for pr in st["package_roles"]) + assert any(pr["package"] == "curl" for pr in st["package_roles"]) + + # Service role captured modified conffile + svc = st["services"][0] + assert svc["unit"] == "openvpn.service" + assert "openvpn" in svc["packages"] + assert any(mf["path"] == "/etc/openvpn/server.conf" for mf in svc["managed_files"]) + + # Unowned /etc/default/keyboard is attributed to etc_custom only + etc_custom = st["etc_custom"] + assert any( + mf["path"] == "/etc/default/keyboard" for mf in etc_custom["managed_files"] + ) diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..71f2030 --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,115 @@ +import json +from pathlib import Path + +from enroll.manifest import manifest + + +def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path): + bundle = tmp_path / "bundle" + out = tmp_path / "ansible" + (bundle / "artifacts" / "foo" / "etc").mkdir(parents=True, exist_ok=True) + (bundle / "artifacts" / "foo" / "etc" / "foo.conf").write_text( + "x", encoding="utf-8" + ) + + state = { + "host": {"hostname": "test", "os": "debian"}, + "users": { + "role_name": "users", + "users": [ + { + "name": "alice", + "uid": 1000, + "gid": 1000, + "gecos": "Alice", + "home": "/home/alice", + "shell": "/bin/bash", + "primary_group": "alice", + "supplementary_groups": ["docker", "qubes"], + } + ], + "managed_files": [], + "excluded": [], + "notes": [], + }, + "etc_custom": { + "role_name": "etc_custom", + "managed_files": [ + { + "path": "/etc/default/keyboard", + "src_rel": "etc/default/keyboard", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "custom_unowned", + } + ], + "excluded": [], + "notes": [], + }, + "services": [ + { + "unit": "foo.service", + "role_name": "foo", + "packages": ["foo"], + "active_state": "inactive", + "sub_state": "dead", + "unit_file_state": "enabled", + "condition_result": "no", + "managed_files": [ + { + "path": "/etc/foo.conf", + "src_rel": "etc/foo.conf", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "modified_conffile", + } + ], + "excluded": [], + "notes": [], + } + ], + "package_roles": [ + { + "package": "curl", + "role_name": "curl", + "managed_files": [], + "excluded": [], + "notes": [], + } + ], + } + + bundle.mkdir(parents=True, exist_ok=True) + (bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8") + + # Create artifact for etc_custom file so copy works + (bundle / "artifacts" / "etc_custom" / "etc" / "default").mkdir( + parents=True, exist_ok=True + ) + (bundle / "artifacts" / "etc_custom" / "etc" / "default" / "keyboard").write_text( + "kbd", encoding="utf-8" + ) + + manifest(str(bundle), str(out)) + + # Service role: conditional start must be a clean Ansible expression + tasks = (out / "roles" / "foo" / "tasks" / "main.yml").read_text(encoding="utf-8") + assert "when: foo_start | bool" in tasks + # Ensure we didn't emit deprecated/broken '{{ }}' delimiters in when: + for line in tasks.splitlines(): + if line.lstrip().startswith("when:"): + assert "{{" not in line and "}}" not in line + + defaults = (out / "roles" / "foo" / "defaults" / "main.yml").read_text( + encoding="utf-8" + ) + assert "foo_start: false" in defaults + + # Playbook should include users, etc_custom, packages, and services + pb = (out / "playbook.yml").read_text(encoding="utf-8") + assert "- users" in pb + assert "- etc_custom" in pb + assert "- curl" in pb + assert "- foo" in pb diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 0000000..f66d4cb --- /dev/null +++ b/tests/test_secrets.py @@ -0,0 +1,8 @@ +from enroll.secrets import SecretPolicy + + +def test_secret_policy_denies_common_backup_files(): + pol = SecretPolicy() + assert pol.deny_reason("/etc/shadow-") == "denied_path" + assert pol.deny_reason("/etc/passwd-") == "denied_path" + assert pol.deny_reason("/etc/group-") == "denied_path"