Compare commits
225 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 098b9c3ac6 | |||
| 5bf247c485 | |||
| da0d8851d3 | |||
| bcaf660349 | |||
| 9d24ea99e9 | |||
| 7d54696402 | |||
| 1148b34bce | |||
| d2a46394fe | |||
| 40bff49815 | |||
| 65cbe18819 | |||
| 47c2c8accc | |||
| c6e171e0f0 | |||
| c468bc221e | |||
| 5ffad10665 | |||
| 17f771cadd | |||
| a34813e788 | |||
| 85345083a3 | |||
| e72044b610 | |||
| 43fca6b3da | |||
| 3b9cfea404 | |||
| f3e8387a10 | |||
| a98d3372fb | |||
| 3781d602a1 | |||
| 4146aa997b | |||
| 737316d507 | |||
| 6d55ac6cc5 | |||
| 0fd722281e | |||
| bb17e57f93 | |||
| b546bb9bf2 | |||
| 3c1e08bdde | |||
| 56ae883948 | |||
| 76aafcc08d | |||
| 78b52eae71 | |||
| 1c20dddf52 | |||
| 7519adc705 | |||
| b3d4adf7d4 | |||
| ddb18403c8 | |||
| a2dc054882 | |||
| 1bfbfd90f6 | |||
| 44d1a22db4 | |||
| f56f076765 | |||
| f5b85d29d3 | |||
| 903125976d | |||
| e9d7d74445 | |||
| 88e9dba39a | |||
| 680d286a20 | |||
| 8a9fe82461 | |||
| 1d42b2bfb9 | |||
| d99ba66951 | |||
| 958f8e3aa7 | |||
| d96ad3dc02 | |||
| c3c3608049 | |||
| 5757bf4275 | |||
| 992b8060a5 | |||
| efb6d7cc15 | |||
| 4277e029d0 | |||
| 5930758398 | |||
| 952687e15d | |||
| 07b07e60c5 | |||
| e10a3f62b0 | |||
| c4448226c0 | |||
| 00f960d01e | |||
| 70525e52d8 | |||
| ad019f6b09 | |||
| cec6023a40 | |||
| 1312b7eac2 | |||
| a1d7a9e4e6 | |||
| bf1c72c542 | |||
| d93de8a8a2 | |||
| 21a3ef3447 | |||
| 3feba9a9f2 | |||
| d1e99db2df | |||
| def1c2bbc7 | |||
| e78f61c5ed | |||
| c7a6bfe979 | |||
| a0914e1369 | |||
| 205c419a7a | |||
| 3e8ad600e2 | |||
| 0a0f067111 | |||
| e2b61bcdf1 | |||
| 03dc467e32 | |||
| 1e61ae2ff9 | |||
| 67b92731f6 | |||
| 0384f8817b | |||
| 5ffd4ee755 | |||
| 706604df74 | |||
| a85e8265f4 | |||
| 6ee8c60e64 | |||
| ce2652a3b3 | |||
| b704a6c80b | |||
| b3a9cd3fb9 | |||
| 429da3f4c1 | |||
| f21bac7d1c | |||
| fc120f02a5 | |||
| 528176ad82 | |||
| 90e863df40 | |||
| a0ac28f213 | |||
| 5b0e945c99 | |||
| d81c32ab7f | |||
| c7c8b93e09 | |||
| 5bb22afefd | |||
| 582679a523 | |||
| 97b64522c6 | |||
| eeb37be567 | |||
| f335077e59 | |||
| 8cbde1423a | |||
| 4fd0facaf8 | |||
| 5845ff58e4 | |||
| 097022f782 | |||
| 08066595f1 | |||
| eb286b1db0 | |||
| ceb86c513c | |||
| 899724097e | |||
| 7379587a28 | |||
| d6371ccccd | |||
| 5644062040 | |||
| de42e16510 | |||
| b8926f9a5f | |||
| 05b2875c17 | |||
| adfeb21d4b | |||
| 0d111caf62 | |||
| 02feff014f | |||
| 37523514b0 | |||
| 79e73584e9 | |||
| bf0228a76a | |||
| 22723678bd | |||
| a4b0ef0544 | |||
| b149b2e5d7 | |||
| ebc27e1111 | |||
| e2be9a6239 | |||
| e448994470 | |||
| 845f8d9ad1 | |||
| c7e3b94355 | |||
| ee08bf43ba | |||
| ceca3df83c | |||
| 20cc48e1ce | |||
| ed9ec6893a | |||
| de7531424d | |||
| 5e6c8e6455 | |||
| 3c84b3c070 | |||
| 380a0b8ca2 | |||
| 33b9d44c55 | |||
| f9e93cd6fd | |||
| e682aae41e | |||
| 9546e1b8ed | |||
| 3c19ae54b2 | |||
| 8774d019d3 | |||
| 1e996f4a43 | |||
| e2339616fb | |||
| 00329cdd33 | |||
| 9dfbd411de | |||
| 8f425b595b | |||
| eb1d096c90 | |||
| 11351cce87 | |||
| bbfc338734 | |||
| 76df10ee92 | |||
| a0fbed5ca5 | |||
| 6c58beddfe | |||
| fbb06f1177 | |||
| 62b2f2ffe6 | |||
| bf735c8328 | |||
| 1544dc0295 | |||
| b25dd1e314 | |||
| 3fcfefe644 | |||
| 618dd20e7c | |||
| 5695f4258e | |||
| 5c686d27cc | |||
| 4ea7267b92 | |||
| d403dcb918 | |||
| 778237740a | |||
| 87ddf52e81 | |||
| 5f6b0f49d9 | |||
| 1856e3a79d | |||
| 478b0e1b9d | |||
| f5eaac9f75 | |||
| 5754ef1aad | |||
| d172d848c4 | |||
| f84d795c49 | |||
| 95b784c1a0 | |||
| ebd30247d1 | |||
| 9a249cc973 | |||
| 9749190cd8 | |||
| ca3d958a96 | |||
| 8be821c494 | |||
| 8daed96b7c | |||
| e0ef5ede98 | |||
| 025f00f924 | |||
| 66d032d981 | |||
| 45e0d9bb16 | |||
| 9f30c56e8a | |||
| 7a9a0abcd1 | |||
| aea58c8684 | |||
| ca4cf00e84 | |||
| d3fdfc9ef7 | |||
| bcf3dd7422 | |||
| 91ec1b8791 | |||
| b5e32770a3 | |||
| e04b158c39 | |||
| a1433d645f | |||
| e68ec0bffc | |||
| 24cedc8c8d | |||
| c9003d589d | |||
| 59674d4660 | |||
| 56d0148614 | |||
| 04234e296f | |||
| a2be708a31 | |||
| 9df4dc862d | |||
| fd55bcde9b | |||
| 1d3ce6191e | |||
| 626d76c755 | |||
| f82fd894ca | |||
| 9a2516d858 | |||
| 6c3275b44a | |||
| 824010b2ab | |||
| 29b52d451d | |||
| c88405ef01 | |||
| 781efef467 | |||
| 09438246ae | |||
| e4887b7add | |||
| e44e4aaf3a | |||
| f01603dac4 | |||
| 081739fd19 | |||
| 043802e800 | |||
| 984b0fa81b | |||
| ad2abed612 |
109 changed files with 33186 additions and 4141 deletions
|
|
@ -21,6 +21,7 @@ jobs:
|
|||
python3-poetry-core \
|
||||
python3-yaml \
|
||||
python3-paramiko \
|
||||
python3-jsonschema \
|
||||
rsync \
|
||||
ca-certificates
|
||||
|
||||
|
|
|
|||
|
|
@ -7,27 +7,96 @@ jobs:
|
|||
test:
|
||||
runs-on: docker
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- distro: debian
|
||||
image: docker.io/library/debian:13
|
||||
python: python3
|
||||
- distro: almalinux
|
||||
image: docker.io/library/almalinux:9
|
||||
python: python3.11
|
||||
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
env:
|
||||
DISTRO: ${{ matrix.distro }}
|
||||
PYTHON_BIN: ${{ matrix.python }}
|
||||
run: |
|
||||
set -eux
|
||||
|
||||
case "${DISTRO}" in
|
||||
debian)
|
||||
mkdir -m 755 -p /etc/apt/keyrings
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gnupg git tar gzip findutils bash nodejs procps \
|
||||
ansible ansible-lint python3 python3-venv python3-pip pipx systemctl python3-apt jq python3-jsonschema
|
||||
;;
|
||||
almalinux)
|
||||
dnf -y upgrade --refresh
|
||||
dnf -y install \
|
||||
ca-certificates curl-minimal gnupg2 git tar gzip findutils bash which jq nodejs procps-ng \
|
||||
dnf-plugins-core epel-release
|
||||
dnf -y config-manager --set-enabled crb || true
|
||||
dnf -y makecache
|
||||
dnf -y install \
|
||||
python3.11 python3.11-devel python3.11-pip gcc make \
|
||||
ansible-core ansible-lint systemd rpm httpd
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported CI distro: ${DISTRO}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- 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 ansible-lint python3-venv pipx systemctl python3-apt
|
||||
|
||||
- name: Install Poetry
|
||||
env:
|
||||
PYTHON_BIN: ${{ matrix.python }}
|
||||
POETRY_VERSION: "2.4.1"
|
||||
run: |
|
||||
pipx install poetry==1.8.3
|
||||
/root/.local/bin/poetry --version
|
||||
set -eux
|
||||
if ! command -v pipx >/dev/null 2>&1; then
|
||||
"${PYTHON_BIN}" -m pip install --user pipx
|
||||
fi
|
||||
PIPX_BIN="$(command -v pipx || true)"
|
||||
if [ -z "${PIPX_BIN}" ]; then
|
||||
PIPX_BIN="${HOME}/.local/bin/pipx"
|
||||
fi
|
||||
"${PIPX_BIN}" install --python "${PYTHON_BIN}" "poetry==${POETRY_VERSION}"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
poetry --version
|
||||
poetry --version | grep -E "Poetry \(version 2\."
|
||||
|
||||
- name: Install project deps (including test extras)
|
||||
env:
|
||||
PYTHON_BIN: ${{ matrix.python }}
|
||||
run: |
|
||||
poetry env use "${PYTHON_BIN}"
|
||||
poetry install --with dev
|
||||
|
||||
- name: Install sops
|
||||
run: |
|
||||
set -eux
|
||||
case "$(uname -m)" in
|
||||
x86_64) sops_arch=amd64 ;;
|
||||
aarch64|arm64) sops_arch=arm64 ;;
|
||||
*) echo "Unsupported architecture for sops: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -L -o /usr/local/bin/sops "https://github.com/getsops/sops/releases/download/v3.13.1/sops-v3.13.1.linux.${sops_arch}"
|
||||
chmod +x /usr/local/bin/sops
|
||||
|
||||
- name: Run test script
|
||||
env:
|
||||
PYTHON_BIN: ${{ matrix.python }}
|
||||
run: |
|
||||
./tests.sh
|
||||
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
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 --skip-version-check --exit-code 1 .
|
||||
|
||||
# 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"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,3 +8,6 @@ dist
|
|||
*.pdf
|
||||
*.csv
|
||||
*.html
|
||||
coverage.xml
|
||||
*.orig
|
||||
*.rej
|
||||
|
|
|
|||
93
CHANGELOG.md
93
CHANGELOG.md
|
|
@ -1,3 +1,92 @@
|
|||
# 0.8.0
|
||||
|
||||
* Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable.
|
||||
* Security: enforce tar member limits while lazily parsing untrusted archives rather than after `TarFile.getmembers()` has already indexed the entire archive; count repeated `.` entries and cap remote compressed downloads as well.
|
||||
* Security: apply aggregate byte and total filesystem-entry limits when freezing directory harvest bundles, reject symlinked bundle roots, and abort when files or discovered directories change during the copy, so direct directory inputs remain bounded and fail closed under mutation.
|
||||
|
||||
# 0.7.0
|
||||
|
||||
* BREAKING CHANGE: Remove the `enroll diff --enforce` option. Enroll no longer applies the old harvest state locally to repair drift; this avoids the risk of enforcing a potentially malicious or tampered harvest. To restore baseline state, regenerate a manifest from the trusted harvest and apply it yourself, or compare two `enroll diff` runs and act on the result.
|
||||
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
|
||||
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
|
||||
* BREAKING CHANGE: Don't allow reading `.enroll.ini` in the CWD. Use only the ENROLL_CONFIG env var, an explicit `--config` path or else the XDG default location (or `~/.config/enroll/enroll.ini` if `XDG_CONFIG_HOME` is not set).
|
||||
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
|
||||
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
|
||||
* Support for detecting Docker and Podman images and enforcing their presence (by SHA256 hash).
|
||||
* Add support for detecting Flatpaks and Snaps.
|
||||
* Stricter validation of harvests to ensure that they meet the schema and don't contain unsafe artifacts (e.g symlinks pointing outside the artifact tree)
|
||||
* Perform harvest validation before trying to manifest from it.
|
||||
* Stricter validation on FQDN name in multisite mode.
|
||||
* Strict check of `$PATH` when running harvest as root, in case it could lead to execution of unsafe binaries during harvest. Override with `--assume-safe-path` for non-interactive or CI purposes.
|
||||
* Stricter validation of the destination dirs that harvest or manifest write to, to prevent writing to a different user-controlled area. Stricter permissions on the output dirs too.
|
||||
|
||||
# 0.6.0
|
||||
|
||||
* Add support for capturing ipset and iptables configuration files
|
||||
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present (`firewall_runtime` role)
|
||||
* Dependency updates
|
||||
|
||||
# 0.5.0
|
||||
|
||||
* Add support for templating `sshd_config`, if a compatible version of JinjaTurtle is also present.
|
||||
* Dependency updates
|
||||
|
||||
# 0.4.4
|
||||
|
||||
* Update cryptography dependency
|
||||
* Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
|
||||
|
||||
# 0.4.3
|
||||
|
||||
* Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
|
||||
* Update dependencies
|
||||
|
||||
# 0.4.2
|
||||
|
||||
* Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
|
||||
|
||||
# 0.4.1
|
||||
|
||||
* Add interactive output when 'enroll diff --enforce' is invoking Ansible.
|
||||
|
||||
# 0.4.0
|
||||
|
||||
* Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
|
||||
* Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
|
||||
* Update pynacl dependency to resolve CVE-2025-69277
|
||||
* Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
|
||||
* Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
|
||||
* Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
|
||||
* Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible. Only the specific roles that had diffed will be applied (via the new tags capability)
|
||||
|
||||
# 0.3.0
|
||||
|
||||
* Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.
|
||||
* Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery.
|
||||
* Capture other files in the user's home directory such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
|
||||
* Ignore files that end with a tilde or - (probably backup files generated by editors or shadow file changes)
|
||||
* Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
|
||||
|
||||
# 0.2.3
|
||||
|
||||
* Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible. It will also fall back to this prompt if a password is required but the arg wasn't passed in.
|
||||
|
||||
# 0.2.2
|
||||
|
||||
* Fix stat() of parent directory so that we set directory perms correct on --include paths.
|
||||
* Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
|
||||
|
||||
# 0.2.1
|
||||
|
||||
* Don't accidentally add `extra_paths` role to `usr_local_custom` list, resulting in `extra_paths` appearing twice in manifested playbook
|
||||
* Ensure directories in the tree of anything included with --include are defined in the state and manifest so we make dirs before we try to create files
|
||||
|
||||
# 0.2.0
|
||||
|
||||
* Add version CLI arg
|
||||
* Add ability to enroll RH-style systems (DNF5/DNF/RPM)
|
||||
* Refactor harvest state to track package versions
|
||||
|
||||
# 0.1.7
|
||||
|
||||
* Fix an attribution bug for certain files ending up in the wrong package/role.
|
||||
|
|
@ -10,8 +99,8 @@
|
|||
# 0.1.5
|
||||
|
||||
* Consolidate logrotate and cron files into their main service/package roles if they exist.
|
||||
* Standardise on MAX_FILES_CAP in one place
|
||||
* Manage apt stuff in its own role, not in etc_custom
|
||||
* Standardise on `MAX_FILES_CAP` in one place
|
||||
* Manage apt stuff in its own role, not in `etc_custom`
|
||||
|
||||
# 0.1.4
|
||||
|
||||
|
|
|
|||
5
CONTRIBUTORS.md
Normal file
5
CONTRIBUTORS.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## Contributors
|
||||
|
||||
mig5 would like to thank the following people for their contributions to Enroll.
|
||||
|
||||
* [slhck](https://slhck.info/)
|
||||
1317
DEVELOPMENT.md
Normal file
1317
DEVELOPMENT.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -19,6 +19,7 @@ RUN set -eux; \
|
|||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
devscripts \
|
||||
libdistro-info-perl \
|
||||
debhelper \
|
||||
dh-python \
|
||||
pybuild-plugin-pyproject \
|
||||
|
|
@ -26,6 +27,7 @@ RUN set -eux; \
|
|||
python3-poetry-core \
|
||||
python3-yaml \
|
||||
python3-paramiko \
|
||||
python3-jsonschema \
|
||||
rsync \
|
||||
ca-certificates \
|
||||
; \
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM fedora:42
|
||||
ARG BASE_IMAGE=fedora:42
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
dnf -y update; \
|
||||
|
|
@ -21,6 +22,7 @@ RUN set -eux; \
|
|||
python3-rpm-macros \
|
||||
python3-yaml \
|
||||
python3-paramiko \
|
||||
python3-jsonschema \
|
||||
openssl-devel \
|
||||
python3-poetry-core ; \
|
||||
dnf -y clean all
|
||||
|
|
@ -33,24 +35,8 @@ set -euo pipefail
|
|||
SRC="${SRC:-/src}"
|
||||
WORKROOT="${WORKROOT:-/work}"
|
||||
OUT="${OUT:-/out}"
|
||||
DEPS_DIR="${DEPS_DIR:-/deps}"
|
||||
|
||||
# Install jinjaturtle from local rpm
|
||||
# Filter out .src.rpm and debug* subpackages if present.
|
||||
if [ -d "${DEPS_DIR}" ] && compgen -G "${DEPS_DIR}/*.rpm" > /dev/null; then
|
||||
mapfile -t rpms < <(ls -1 "${DEPS_DIR}"/*.rpm | grep -vE '(\.src\.rpm$|-(debuginfo|debugsource)-)')
|
||||
if [ "${#rpms[@]}" -gt 0 ]; then
|
||||
echo "Installing dependency RPMs from ${DEPS_DIR}:"
|
||||
printf ' - %s\n' "${rpms[@]}"
|
||||
dnf -y install "${rpms[@]}"
|
||||
dnf -y clean all
|
||||
else
|
||||
echo "NOTE: Only src/debug RPMs found in ${DEPS_DIR}; nothing installed." >&2
|
||||
fi
|
||||
else
|
||||
echo "NOTE: No RPMs found in ${DEPS_DIR}. If the build fails with missing python3dist(jinjaturtle)," >&2
|
||||
echo " mount your jinjaturtle RPM directory as -v <dir>:/deps" >&2
|
||||
fi
|
||||
VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)"
|
||||
echo "Version ID is ${VERSION_ID}"
|
||||
|
||||
mkdir -p "${WORKROOT}" "${OUT}"
|
||||
WORK="${WORKROOT}/src"
|
||||
|
|
|
|||
287
README.md
287
README.md
|
|
@ -4,16 +4,20 @@
|
|||
<img src="https://git.mig5.net/mig5/enroll/raw/branch/main/enroll.svg" alt="Enroll logo" width="240" />
|
||||
</div>
|
||||
|
||||
**enroll** inspects a Linux machine (currently Debian-only) and generates Ansible roles/playbooks (and optionally inventory) for what it finds.
|
||||
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates Ansible configuration-management code from it.
|
||||
|
||||
- Detects packages that have been installed.
|
||||
- Detects Debian package ownership of `/etc` files using dpkg’s local database.
|
||||
- Captures config that has **changed from packaged defaults** (dpkg conffile hashes + package md5sums when available).
|
||||
- Detects package ownership of `/etc` files where possible
|
||||
- Captures config that has **changed from packaged defaults** where possible (e.g dpkg conffile hashes + package md5sums when available).
|
||||
- Also captures **service-relevant custom/unowned files** under `/etc/<service>/...` (e.g. drop-in config includes).
|
||||
- Defensively excludes likely secrets (path denylist + content sniff + size caps).
|
||||
- Captures non-system users and their SSH public keys.
|
||||
- Captures miscellaneous `/etc` files it can’t attribute to a package and installs them in an `etc_custom` role.
|
||||
- Ditto for /usr/local/bin (for non-binary files) and /usr/local/etc
|
||||
- Captures non-system users and their SSH public keys. In `--dangerous` mode, it also auto-harvests common shell dotfiles such as `.bashrc`, `.profile`, `.bash_logout`, and `.bash_aliases` when appropriate.
|
||||
- Captures miscellaneous `/etc` files it can't attribute to a package and installs them in an `etc_custom` role.
|
||||
- When running as root/sudo, captures live writable sysctl state into a `sysctl` role that manages `/etc/sysctl.d/99-enroll.conf`.
|
||||
- Captures live ipset and iptables runtime state, when active ipsets/iptables rules are present *and* no corresponding persistent ipset/iptables *files* were found.
|
||||
- Captures symlinks in common applications that rely on them, e.g apache2/nginx 'sites-enabled'
|
||||
- Tries to capture Flatpak, Snap, Docker image presence
|
||||
- Captures snowflake-y things found in /usr/local/bin (for non-binary files) and /usr/local/etc
|
||||
- Avoids trying to start systemd services that were detected as inactive during harvest.
|
||||
|
||||
---
|
||||
|
|
@ -23,7 +27,7 @@
|
|||
`enroll` works in two phases:
|
||||
|
||||
1) **Harvest**: collect host facts + relevant files into a harvest bundle (`state.json` + harvested artifacts)
|
||||
2) **Manifest**: turn that harvest into Ansible roles/playbooks (and optionally inventory)
|
||||
2) **Manifest**: turn that harvest into Ansible configuration-management code.
|
||||
|
||||
Additionally, some other functionalities exist:
|
||||
|
||||
|
|
@ -34,15 +38,13 @@ Additionally, some other functionalities exist:
|
|||
|
||||
## Output modes: single-site vs multi-site (`--fqdn`)
|
||||
|
||||
`enroll manifest` (and `enroll single-shot`) support two distinct output styles.
|
||||
|
||||
### Single-site mode (default: *no* `--fqdn`)
|
||||
Use when enrolling **one server** (or generating a “golden” role set you intend to reuse).
|
||||
|
||||
**Characteristics**
|
||||
- Roles are more self-contained.
|
||||
- Raw config files live in the role’s `files/`.
|
||||
- Template variables live in the role’s `defaults/main.yml`.
|
||||
- Raw config files live in the role's `files/`.
|
||||
- Template variables live in the role's `defaults/main.yml`.
|
||||
|
||||
### Multi-site mode (`--fqdn`)
|
||||
Use when enrolling **several existing servers** quickly, especially if they differ.
|
||||
|
|
@ -68,13 +70,17 @@ Harvest state about a host and write a harvest bundle.
|
|||
- “Manual” packages
|
||||
- Changed-from-default config (plus related custom/unowned files under service dirs)
|
||||
- Non-system users + SSH public keys
|
||||
- Misc `/etc` that can’t be attributed to a package (`etc_custom` role)
|
||||
- In `--dangerous` mode: common per-user shell dotfiles that are likely to represent deliberate account customisation
|
||||
- Misc `/etc` that can't be attributed to a package (`etc_custom` role)
|
||||
- Static firewall config files such as nftables, UFW, firewalld, `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, and `/etc/ipset*`
|
||||
- Live writable sysctl state via `sysctl -a`, emitted as `/etc/sysctl.d/99-enroll.conf` at manifest time when running as root/sudo (`sysctl` role)
|
||||
- Live kernel ipset/iptables state via `ipset save`, `iptables-save`, and `ip6tables-save` as a fallback, but only when the corresponding persistent config was not found (`firewall_runtime` role at manifest time)
|
||||
- Optional user-specified extra files/dirs via `--include-path` (emitted as an `extra_paths` role at manifest time)
|
||||
|
||||
**Common flags**
|
||||
- Remote harvesting:
|
||||
- `--remote-host`, `--remote-user`, `--remote-port`
|
||||
- `--no-sudo` (if you don’t want/need sudo)
|
||||
- `--remote-host`, `--remote-user`, `--remote-port`, `--remote-ssh-config`
|
||||
- `--no-sudo` (if you don't want/need sudo)
|
||||
- Sensitive-data behaviour:
|
||||
- default: tries to avoid likely secrets
|
||||
- `--dangerous`: disables secret-safety checks (see “Sensitive data” below)
|
||||
|
|
@ -88,6 +94,30 @@ Harvest state about a host and write a harvest bundle.
|
|||
- glob (default): supports `*` and `**` (prefix with `glob:` to force)
|
||||
- regex: prefix with `re:` or `regex:`
|
||||
- Precedence: excludes win over includes.
|
||||
* Using remote mode and auth requires secrets?
|
||||
* sudo password:
|
||||
* `--ask-become-pass` (or `-K`) prompts for the sudo password.
|
||||
* If you forget, and remote sudo requires a password, Enroll will still fall back to prompting in interactive mode (slightly slower due to retry).
|
||||
* SSH private-key passphrase:
|
||||
* `--ask-key-passphrase` prompts for the SSH key passphrase.
|
||||
* `--ssh-key-passphrase-env ENV_VAR` reads the SSH key passphrase from an environment variable (useful for CI/non-interactive runs).
|
||||
* If neither is provided, and Enroll detects an encrypted key in an interactive session, it will still fall back to prompting on-demand.
|
||||
* In non-interactive sessions, pass `--ask-key-passphrase` or `--ssh-key-passphrase-env ENV_VAR` when using encrypted private keys.
|
||||
* Note: `--ask-key-passphrase` and `--ssh-key-passphrase-env` are mutually exclusive.
|
||||
- Root PATH safety:
|
||||
- when run as root, Enroll warns and asks for confirmation if `PATH` contains `.`, an empty/relative entry, or a group/world-writable directory.
|
||||
- use `--assume-safe-path` for trusted non-interactive automation where that `PATH` is intentional.
|
||||
|
||||
Examples (encrypted SSH key)
|
||||
|
||||
```bash
|
||||
# Interactive
|
||||
enroll harvest --remote-host myhost.example.com --remote-user myuser --ask-key-passphrase --out /tmp/enroll-harvest
|
||||
|
||||
# Non-interactive / CI
|
||||
export ENROLL_SSH_KEY_PASSPHRASE='correct horse battery staple'
|
||||
enroll single-shot --remote-host myhost.example.com --remote-user myuser --ssh-key-passphrase-env ENROLL_SSH_KEY_PASSPHRASE --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn myhost.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -99,11 +129,27 @@ Generate Ansible output from an existing harvest bundle.
|
|||
or `--harvest /path/to/harvest.tar.gz.sops` (if using `--sops`)
|
||||
|
||||
**Output**
|
||||
- In plaintext mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
|
||||
- In plaintext Ansible mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
|
||||
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.
|
||||
|
||||
**Common flags**
|
||||
- `--fqdn <host>`: enables **multi-site** output style
|
||||
- `--fqdn <host>`: enables **multi-site** output style for Ansible (host-specific state lives in inventory `host_vars`).
|
||||
- `--no-common-roles`: disables the default grouping of package and systemd-unit roles into Debian Section/RPM Group roles, preserving one generated role per package/unit. `--fqdn` implies this behaviour.
|
||||
|
||||
**Role tags**
|
||||
Generated playbooks tag each role so you can target just the parts you need:
|
||||
|
||||
- Tag format: `role_<role_name>` (e.g. `role_services`, `role_users`)
|
||||
- Fallback/safe tag: `role_other`
|
||||
|
||||
Example:
|
||||
```bash
|
||||
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_services,role_users
|
||||
```
|
||||
|
||||
**IMPORTANT**: Always make sure that you take adequate precautions to prevent a malicious actor from tampering with your harvest. Enroll tries to set the permissions of it to something your running user has access to, but environments and situations can vary. A malicious actor could change your harvest contents in a way that doesn't violate the schema but results in sensitive exposure or dangerous execution once you apply the 'manifested' configuration management version of it.
|
||||
|
||||
Whenever in doubt, add `--sops` (with SOPS installed on your PATH) and encrypt the harvest so that only you can decrypt it.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -112,7 +158,7 @@ Convenience wrapper that runs **harvest → manifest** in one command.
|
|||
|
||||
Use this when you want “get me something workable ASAP”.
|
||||
|
||||
Supports the same general flags as harvest/manifest, including `--fqdn`, remote harvest flags, and `--sops`.
|
||||
Supports the same general flags as harvest/manifest, including `--fqdn`, `--no-common-roles`, remote harvest flags, and `--sops`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -128,6 +174,13 @@ Compare two harvest bundles and report what changed.
|
|||
**Inputs**
|
||||
- `--old <harvest>` and `--new <harvest>` (directories or `state.json` paths)
|
||||
- `--sops` when comparing SOPS-encrypted harvest bundles
|
||||
- `--exclude-path <PATTERN>` (repeatable) to ignore file/dir drift under matching paths (same pattern syntax as harvest)
|
||||
- `--ignore-package-versions` to ignore package version-only drift (upgrades/downgrades)
|
||||
|
||||
**Noise suppression**
|
||||
- `--exclude-path` is useful for things that change often but you still want in the harvest baseline (e.g. `/var/anacron`).
|
||||
- `--ignore-package-versions` keeps routine upgrades from alerting; package add/remove drift is still reported.
|
||||
|
||||
|
||||
**Output formats**
|
||||
- `--format json` (default for webhooks)
|
||||
|
|
@ -143,16 +196,91 @@ Compare two harvest bundles and report what changed.
|
|||
|
||||
---
|
||||
|
||||
### `enroll explain`
|
||||
Analyze a harvest and provide user-friendly explanations for what's in it and why.
|
||||
|
||||
This may also explain why something *wasn't* included (e.g a binary file, a file that was too large, unreadable due to permissions, or looked like a log file/secret.
|
||||
|
||||
Provide either the path to the harvest or the path to its state.json. It can also handle SOPS-encrypted harvests.
|
||||
|
||||
Output can be provided in plaintext or json.
|
||||
|
||||
---
|
||||
|
||||
### `enroll validate`
|
||||
|
||||
Validates a harvest by checking:
|
||||
|
||||
* state.json exists and is valid JSON
|
||||
* state.json validates against a JSON Schema (by default the vendored one)
|
||||
* Every `managed_file` entry has a corresponding artifact at: `artifacts/<role_name>/<src_rel>`
|
||||
* That there are no **unreferenced files** sitting in `artifacts/` that aren't in the state.
|
||||
|
||||
#### Schema location + overrides
|
||||
|
||||
The master schema lives at: `enroll/schema/state.schema.json`.
|
||||
|
||||
You can override with a local file or URL:
|
||||
|
||||
```
|
||||
enroll validate /path/to/harvest --schema ./state.schema.json
|
||||
enroll validate /path/to/harvest --schema https://enroll.sh/schema/state.schema.json
|
||||
```
|
||||
|
||||
Or skip schema checks (still does artifact consistency checks):
|
||||
|
||||
```
|
||||
enroll validate /path/to/harvest --no-schema
|
||||
```
|
||||
|
||||
#### CLI usage examples
|
||||
|
||||
Validate a local harvest:
|
||||
|
||||
```
|
||||
enroll validate ./harvest
|
||||
```
|
||||
|
||||
Validate a harvest tarball or a sops bundle:
|
||||
|
||||
```
|
||||
enroll validate ./harvest.tar.gz
|
||||
enroll validate ./harvest.sops --sops
|
||||
```
|
||||
|
||||
JSON output + write to file:
|
||||
|
||||
```
|
||||
enroll validate ./harvest --format json --out validate.json
|
||||
```
|
||||
|
||||
Return exit code 1 for any warnings, not just errors (useful for CI):
|
||||
|
||||
```
|
||||
enroll validate ./harvest --fail-on-warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sensitive data
|
||||
|
||||
By default, `enroll` does **not** assume how you handle secrets in Ansible. It will attempt to avoid harvesting likely sensitive data (private keys, passwords, tokens, etc.). This can mean it skips some config files you may ultimately want to manage.
|
||||
|
||||
If you opt in to collecting everything:
|
||||
Safe-mode content scanning is intentionally conservative. It treats common assignment-style credential keys as sensitive, including names such as `password` (and abbreviations like `passwd`, `pwd`, and `pw`, e.g. `db_pw`), `client_secret`, `secret_key`, `auth_token`, `api_key`, `aws_access_key_id`, `aws_secret_access_key`, `azure_client_secret`, `GOOGLE_APPLICATION_CREDENTIALS`, and service-account key names.
|
||||
|
||||
**IMPORTANT**: Enroll tolerates value-less credential keyword mentions in comments, such as `# token`, so ordinary stock configuration files do not become unusable. However, commented-out credential values are still treated as sensitive. A populated credential assignment, credential-bearing URI, `Authorization` header, or private-key material is refused in default safe mode even when it appears inside a comment. Use `--dangerous` only when you intentionally want to collect such material, and prefer `--sops` or another appropriate form of at-rest encryption whenever in doubt.
|
||||
|
||||
Automatic harvesting of per-user shell dotfiles is also disabled by default, even when those files differ from `/etc/skel`, because `.bashrc`, `.profile`, `.bash_aliases`, and similar files commonly contain exported tokens, credentials, or aliases/functions with embedded secrets. Use `--dangerous` for automatic shell-dotfile capture, or use targeted `--include-path` patterns for narrower safe-mode review.
|
||||
|
||||
If you wish to opt in to collecting everything, use `--dangerous` mode, but be aware of what it means:
|
||||
|
||||
### `--dangerous`
|
||||
**WARNING:** disables “likely secret” safety checks. This can copy private keys, TLS key material, API tokens, database passwords, and other credentials into the harvest output **in plaintext**.
|
||||
|
||||
If you intend to keep harvests/manifests long-term (especially in git), strongly consider encrypting them at rest.
|
||||
**IMPORTANT:** 'dangerous' mode is exactly that: it disables “likely secret” safety checks when harvesting system data.
|
||||
|
||||
This means it can copy private keys, TLS key material, API tokens, database passwords, and other credentials into the harvest output **in plaintext**, including paths that would normally be considered very secret.
|
||||
|
||||
If you intend to keep harvests/manifests long-term on disk away from the host or its usual protected paths, strongly consider encrypting them at rest!
|
||||
|
||||
### Encrypt bundles at rest with `--sops`
|
||||
`--sops` encrypts the harvest and/or manifest outputs into a single `.tar.gz.sops` file (GPG). This is for **storage-at-rest**, not for direct “Ansible SOPS inventory” workflows.
|
||||
|
|
@ -161,16 +289,17 @@ If you intend to keep harvests/manifests long-term (especially in git), strongly
|
|||
|
||||
---
|
||||
|
||||
## JinjaTurtle integration (both modes)
|
||||
## JinjaTurtle integration
|
||||
|
||||
If [JinjaTurtle](https://git.mig5.net/mig5/jinjaturtle) is installed, `enroll` can generate Jinja2 templates for ini/json/xml/toml-style config.
|
||||
If [JinjaTurtle](https://git.mig5.net/mig5/jinjaturtle) is installed, `enroll` can generate templates for ini/json/xml/toml-style config in renderers.
|
||||
|
||||
For Ansible:
|
||||
- Templates live in `roles/<role>/templates/...`
|
||||
- Variables live in:
|
||||
- single-site: `roles/<role>/defaults/main.yml`
|
||||
- multi-site: `inventory/host_vars/<fqdn>/<role>.yml`
|
||||
|
||||
You can force it on with `--jinjaturtle` or disable with `--no-jinjaturtle`.
|
||||
You can force template generation on with `--jinjaturtle` or disable it with `--no-jinjaturtle`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -191,7 +320,7 @@ sudo apt update
|
|||
sudo apt install enroll
|
||||
```
|
||||
|
||||
### Fedora 42
|
||||
## Fedora
|
||||
|
||||
```bash
|
||||
sudo rpm --import https://mig5.net/static/mig5.asc
|
||||
|
|
@ -199,7 +328,7 @@ sudo rpm --import https://mig5.net/static/mig5.asc
|
|||
sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF'
|
||||
[mig5]
|
||||
name=mig5 Repository
|
||||
baseurl=https://rpm.mig5.net/rpm/$basearch
|
||||
baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
repo_gpgcheck=1
|
||||
|
|
@ -233,7 +362,7 @@ poetry run enroll --help
|
|||
|
||||
## Found a bug / have a suggestion?
|
||||
|
||||
My Forgejo doesn’t currently support federation, so I haven’t opened registration/login for issues.
|
||||
My Forgejo doesn't currently support federation, so I haven't opened registration/login for issues.
|
||||
|
||||
Instead, email me (see `pyproject.toml`) or contact me on the Fediverse:
|
||||
|
||||
|
|
@ -255,6 +384,14 @@ enroll harvest --out /tmp/enroll-harvest
|
|||
enroll harvest --remote-host myhost.example.com --remote-user myuser --out /tmp/enroll-harvest
|
||||
```
|
||||
|
||||
### Remote harvest over SSH, where the SSH configuration is in ~/.ssh/config (e.g a different SSH key)
|
||||
|
||||
Note: you must still pass `--remote-host`, but in this case, its value can be the 'Host' alias of an entry in your `~/.ssh/config`.
|
||||
|
||||
```bash
|
||||
enroll harvest --remote-host myhostalias --remote-ssh-config ~/.ssh/config --out /tmp/enroll-harvest
|
||||
```
|
||||
|
||||
### Include paths (`--include-path`)
|
||||
```bash
|
||||
# Add a few dotfiles from /home (still secret-safe unless --dangerous)
|
||||
|
|
@ -302,6 +439,13 @@ enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
|
|||
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
|
||||
```
|
||||
|
||||
|
||||
### Container image caches
|
||||
|
||||
If Docker or Podman is available during harvest, Enroll records local image-cache metadata from `image ls` and `image inspect`. Images that expose registry `RepoDigest` values are reproducible by digest, for example `registry.example.net/app@sha256:...`; those are the references rendered into manifests. Local image IDs and tag-only images are preserved as evidence and notes, but are not treated as exact registry pull references.
|
||||
|
||||
For Ansible, digest-pinned Docker images are pulled with `community.docker.docker_image_pull` and digest-pinned Podman images are pulled with `containers.podman.podman_image`; harvested tag aliases are re-applied where possible. The generated `requirements.yml` includes `community.docker` and `containers.podman` alongside any other required collections. In `--fqdn` mode the image list is host-specific inventory data.
|
||||
|
||||
### Manifest with `--sops`
|
||||
```bash
|
||||
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)
|
||||
|
|
@ -330,7 +474,7 @@ enroll single-shot --remote-host myhost.example.com --remote-user myuser --har
|
|||
|
||||
## Diff
|
||||
|
||||
### Compare two harvest directories
|
||||
### Compare two harvest directories, output in json
|
||||
```bash
|
||||
enroll diff --old /path/to/harvestA --new /path/to/harvestB --format json
|
||||
```
|
||||
|
|
@ -342,6 +486,78 @@ enroll diff --old /path/to/golden/harvest --new /path/to/new/harvest --web
|
|||
|
||||
`diff` mode also supports email sending and text or markdown format, as well as `--exit-code` mode to trigger a return code of 2 (useful for crons or CI)
|
||||
|
||||
### Ignore a specific directory or file from the diff
|
||||
```bash
|
||||
enroll diff --old /path/to/harvestA --new /path/to/harvestB --exclude-path /var/anacron
|
||||
```
|
||||
|
||||
### Ignore package version drift (routine upgrades) but still alert on add/remove
|
||||
```bash
|
||||
enroll diff --old /path/to/harvestA --new /path/to/harvestB --ignore-package-versions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Explain
|
||||
|
||||
### Explain a harvest
|
||||
|
||||
All of these do the same thing:
|
||||
|
||||
```bash
|
||||
enroll explain /path/to/state.json
|
||||
enroll explain /path/to/bundle_dir
|
||||
enroll explain /path/to/harvest.tar.gz
|
||||
```
|
||||
|
||||
### Explain a SOPS-encrypted harvest
|
||||
|
||||
```bash
|
||||
enroll explain /path/to/harvest.tar.gz.sops --sops
|
||||
```
|
||||
|
||||
### Explain with JSON output and more examples
|
||||
|
||||
```bash
|
||||
enroll explain /path/to/state.json --format json --max-examples 25
|
||||
```
|
||||
|
||||
### Example output
|
||||
|
||||
```
|
||||
❯ enroll explain /tmp/syrah.harvest
|
||||
Enroll explain: /tmp/syrah.harvest
|
||||
Host: syrah.mig5.net (os: debian, pkg: dpkg)
|
||||
Enroll: 0.2.3
|
||||
|
||||
Inventory
|
||||
- Packages: 254
|
||||
- Why packages were included (observed_via):
|
||||
- user_installed: 248 – Package appears explicitly installed (as opposed to only pulled in as a dependency).
|
||||
- package_role: 232 – Package was referenced by an enroll packages snapshot/role. (e.g. acl, acpid, adduser)
|
||||
- systemd_unit: 22 – Package is associated with a systemd unit that was harvested. (e.g. postfix.service, tor.service, apparmor.service)
|
||||
|
||||
Roles collected
|
||||
- users: 1 user(s), 1 file(s), 0 excluded
|
||||
- services: 19 unit(s), 111 file(s), 6 excluded
|
||||
- packages: 232 package snapshot(s), 41 file(s), 0 excluded
|
||||
- apt_config: 26 file(s), 7 dir(s), 10 excluded
|
||||
- dnf_config: 0 file(s), 0 dir(s), 0 excluded
|
||||
- firewall_runtime: 2 snapshot(s), 1 ipset(s)
|
||||
- etc_custom: 70 file(s), 20 dir(s), 0 excluded
|
||||
- usr_local_custom: 35 file(s), 1 dir(s), 0 excluded
|
||||
- extra_paths: 0 file(s), 0 dir(s), 0 excluded
|
||||
|
||||
Why files were included (managed_files.reason)
|
||||
- custom_unowned (179): A file not owned by any package (often custom/operator-managed).. Examples: /etc/apparmor.d/local/lsb_release, /etc/apparmor.d/local/nvidia_modprobe, /etc/apparmor.d/local/sbin.dhclient
|
||||
- usr_local_bin_script (35): Executable scripts under /usr/local/bin (often operator-installed).. Examples: /usr/local/bin/check_firewall, /usr/local/bin/awslogs
|
||||
- apt_keyring (13): Repository signing key material used by APT.. Examples: /etc/apt/keyrings/openvpn-repo-public.asc, /etc/apt/trusted.gpg, /etc/apt/trusted.gpg.d/deb.torproject.org-keyring.gpg
|
||||
- modified_conffile (10): A package-managed conffile differs from the packaged/default version.. Examples: /etc/dnsmasq.conf, /etc/ssh/moduli, /etc/tor/torrc
|
||||
- logrotate_snippet (9): logrotate snippets/configs referenced in system configuration.. Examples: /etc/logrotate.d/rsyslog, /etc/logrotate.d/tor, /etc/logrotate.d/apt
|
||||
- apt_config (7): APT configuration affecting package installation and repository behavior.. Examples: /etc/apt/apt.conf.d/01autoremove, /etc/apt/apt.conf.d/20listchanges, /etc/apt/apt.conf.d/70debconf
|
||||
[...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run Ansible
|
||||
|
|
@ -356,6 +572,12 @@ ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml
|
|||
ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml
|
||||
```
|
||||
|
||||
### Run only specific roles (tags)
|
||||
Generated playbooks tag each role as `role_<name>` (e.g. `role_users`, `role_services`), so you can speed up targeted runs:
|
||||
```bash
|
||||
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_users
|
||||
```
|
||||
|
||||
## Configuration file
|
||||
|
||||
As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands.
|
||||
|
|
@ -367,8 +589,8 @@ Enroll supports reading an ini-style file of all the arguments for each subcomma
|
|||
### Location of the config file
|
||||
|
||||
The path the config file can be specified with `-c` or `--config` on the command-line. Otherwise,
|
||||
Enroll will look for `./enroll.ini`, `./.enroll.ini` (in the current working directory),
|
||||
`~/.config/enroll/enroll.ini` (or `$XDG_CONFIG_HOME/enroll/enroll.ini`).
|
||||
Enroll will look for the `ENROLL_CONFIG` environment variable, `$XDG_CONFIG_HOME/enroll/enroll.ini`,
|
||||
or `~/.config/enroll/enroll.ini`.
|
||||
|
||||
You may also pass `--no-config` if you deliberately want to ignore the config file even if it existed.
|
||||
|
||||
|
|
@ -403,7 +625,12 @@ exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
|
|||
[manifest]
|
||||
# you can set defaults here too, e.g.
|
||||
no_jinjaturtle = true
|
||||
sops = 00AE817C24A10C2540461A9C1D7CDE0234DB458D
|
||||
sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
|
||||
|
||||
[diff]
|
||||
# ignore noisy drift
|
||||
exclude_path = /var/anacron
|
||||
ignore_package_versions = true
|
||||
|
||||
[single-shot]
|
||||
# if you use single-shot, put its defaults here.
|
||||
|
|
|
|||
97
SECURITY.md
Normal file
97
SECURITY.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Enroll Threat Model and Security Scope
|
||||
|
||||
Enroll is a command-line systems administration tool. It is designed to be executed intentionally by a system administrator, often with elevated privileges, in order to inspect a host, harvest selected system state, and optionally generate or apply configuration-management output.
|
||||
|
||||
Because of that design, Enroll’s security model is different from that of a network service, web application, daemon, or setuid program. Enroll does not attempt to defend against arbitrary local compromise of the account executing it. If an attacker can control the command line, environment, configuration file, working directory, `PATH`, harvested input bundle, or configuration-management tools used by the administrator, they may be able to influence what Enroll does. That situation is considered a local trust-boundary failure outside Enroll’s intended security model.
|
||||
|
||||
## Core assumptions
|
||||
|
||||
Enroll assumes that the person running the tool understands what they are asking it to do.
|
||||
|
||||
In particular:
|
||||
|
||||
* If Enroll is run as root, the root user is assumed to control and understand the command line, environment, configuration file, and output location being used.
|
||||
* If an `enroll.ini` configuration file is loaded, its location and contents are assumed to be owned, selected, and understood by the operator.
|
||||
* The operator is expected to understand the implications of options such as `--dangerous`, `--assume-safe-path`, `--sops`, `--remote-host`, and `--remote-ssh-config`.
|
||||
* Harvest bundles used for `manifest` or `diff` are assumed to come from a trusted source unless the operator is deliberately inspecting untrusted input without applying it.
|
||||
* Configuration-management tools invoked by Enroll, such as Ansible, SOPS, SSH, `sudo`, Docker, Podman, Flatpak, Snap, package managers, and system utilities, are assumed to be the trusted tools the operator intended to use.
|
||||
|
||||
## What is in scope
|
||||
|
||||
Enroll tries to protect careful administrators from common and serious mistakes that can occur when a privileged CLI tool reads and writes host state.
|
||||
|
||||
In-scope security concerns include:
|
||||
|
||||
* Avoiding accidental capture of obvious secrets in default safe mode.
|
||||
* Refusing known sensitive paths such as shadow files, SSH host keys, private key material, and common certificate/private-key locations unless the operator explicitly opts into dangerous collection.
|
||||
* Warning when `--dangerous` is used, especially without encrypted output.
|
||||
* Supporting encrypted harvest bundles via `--sops`.
|
||||
* Avoiding symlink traversal and time-of-check/time-of-use mistakes when copying harvested files.
|
||||
* Refusing unsafe artifact paths, symlinks, hardlinks, device nodes, and tar path traversal in harvest bundles.
|
||||
* Treating a harvest bundle as point-in-time validated: before `manifest` or `diff` re-open artifacts to render or hash them, a plain *directory* bundle is copied into a private, attacker-immutable temp tree (regular files only, no symlinks or hardlinks, opened without following links) so the bundle that is consumed cannot be raced and swapped after validation. Tar and SOPS inputs get the equivalent treatment by being extracted into a private temp directory.
|
||||
* Keeping harvested values in Ansible *data* rather than playbook *structure*, so a harvested path, owner, group, username, or link target cannot inject YAML structure or be re-evaluated as a Jinja/template expression at apply time.
|
||||
* Writing plaintext harvest outputs into private directories by default.
|
||||
* Hardening root-run output path handling so Enroll does not accidentally write through attacker-prepared symlinks or unsafe parent directories.
|
||||
* Refusing to continue non-interactively when run as root with an unsafe `PATH`, unless the operator explicitly confirms with `--assume-safe-path`.
|
||||
* Avoiding injection in generated manifests where harvested values are embedded into Ansible output — not only shell injection, but YAML-structure injection and runtime Jinja/template re-evaluation — by serializing harvested data through a safe YAML dumper, tagging template-looking values as Ansible `!unsafe`, and allowlisting the only identifiers ever spliced into raw task YAML.
|
||||
* Rejecting unknown SSH host keys by default during remote harvests.
|
||||
|
||||
These measures are defense-in-depth. They are intended to reduce the chance of accidental exposure, unsafe filesystem writes, path traversal, command injection, or dangerous behavior when Enroll is used normally by an administrator.
|
||||
|
||||
## What is out of scope
|
||||
|
||||
The following are generally out of scope and should not be reported as Enroll vulnerabilities unless they also bypass one of Enroll’s explicit hardening mechanisms:
|
||||
|
||||
* A malicious local user who can already control the root user’s command line, shell environment, config file, `PATH`, `XDG_CONFIG_HOME`, SSH config, working directory, or invoked binaries.
|
||||
* A root user loading an `enroll.ini` file whose contents intentionally request dangerous behavior.
|
||||
* A root user passing `--dangerous` and then observing that Enroll may collect sensitive information.
|
||||
* A root user passing `--assume-safe-path` and then observing that Enroll does not prompt about `PATH` safety.
|
||||
* A user applying generated Ansible manifests from an untrusted harvest.
|
||||
* A user configuring a webhook, email target, SSH proxy command, SOPS binary, package manager, or configuration-management tool that they do not trust.
|
||||
* A compromised system where an attacker already controls root-owned files, root’s shell, root’s configuration, or the privileged tools Enroll invokes.
|
||||
* Reports that amount to “if root runs this tool with malicious options, root can make the system do dangerous things.”
|
||||
* Enroll harvesting a file that merely *mentions* a credential-related word in a comment with no assigned value (for example a commented-out `# token` hint in a stock config). Enroll tolerates value-less keyword mentions in comments so it is not useless for harvesting ordinary configuration files. However, a commented-out credential *value* — a populated `key = value` assignment, a URI with embedded credentials, an `Authorization` header, or private-key material — is treated as sensitive even inside a comment, because a "commented out" secret is very often a real secret that was merely disabled. Such a file is refused in default safe mode and requires `--dangerous` (ideally with `--sops`) to collect. It remains the responsibility of the user to use `--sops` or appropriate at-rest encryption if in the slightest doubt about what might get harvested.
|
||||
|
||||
Enroll is a tool for administrators, not a sandbox for hostile local users. It cannot make unsafe local trust decisions safe if the operator’s own execution environment is already attacker-controlled.
|
||||
|
||||
## Trusted harvests
|
||||
|
||||
Harvest bundles should be treated as sensitive and trusted administrative artifacts.
|
||||
|
||||
A harvest may contain hostnames, usernames, package lists, service state, filesystem metadata, configuration files, firewall snapshots, container image references, Flatpak/Snap state, and other operational details. In `--dangerous` mode it may contain substantially more sensitive material.
|
||||
|
||||
Before running `manifest` or `diff`, or applying a generated manifest, the operator should be confident that the harvest bundle came from a trusted source and has not been tampered with.
|
||||
|
||||
Enroll validates harvest structure and artifact safety. Validation can detect many unsafe filesystem constructs, such as path traversal, missing artifacts, symlinks, hardlinks, and schema mismatches. Validation does not and cannot prove that the desired state represented by a harvest is safe to apply.
|
||||
|
||||
## Local compromise
|
||||
|
||||
Enroll includes hardening against some local filesystem attack patterns because it is often run with high privileges. For example, it tries to avoid symlink races, unsafe output directories, path traversal, and accidental secret capture.
|
||||
|
||||
However, local compromise cannot be ruled out completely for a privileged CLI tool. If an attacker can influence the administrator’s shell, environment, config file, binaries, SSH configuration, SOPS binary, configuration-management tools, or harvest inputs, they may be able to influence Enroll’s behavior.
|
||||
|
||||
Such scenarios are treated as local compromise or operator trust failures, not as vulnerabilities in Enroll by themselves.
|
||||
|
||||
## Security report guidance
|
||||
|
||||
Useful vulnerability reports include issues where Enroll behaves unsafely despite the documented trust model. Examples include:
|
||||
|
||||
* Enroll captures a clearly sensitive default-denied file without `--dangerous`.
|
||||
* Enroll follows a symlink or hardlink in a way that causes privileged file disclosure or overwrite.
|
||||
* Enroll extracts a tar member outside the intended harvest directory.
|
||||
* Enroll accepts a malicious harvest artifact that escapes the artifact root.
|
||||
* Enroll generates an Ansible manifest where ordinary harvested data can cause command injection.
|
||||
* Enroll writes root-run output into an unsafe attacker-controlled path despite its safety checks.
|
||||
* Enroll silently ignores a failed safety check and proceeds anyway.
|
||||
* Enroll accepts an unknown SSH host key unexpectedly.
|
||||
* Enroll exposes secrets in logs, errors, reports, or generated output when not explicitly requested by the operator.
|
||||
|
||||
Less useful reports, and normally out of scope, include:
|
||||
|
||||
* “Root can configure Enroll to collect sensitive files.”
|
||||
* “Root can pass `--dangerous` and collect dangerous data.”
|
||||
* “Root can pass `--assume-safe-path` and bypass the root `PATH` warning.”
|
||||
* “Root can point Enroll at a malicious config file.”
|
||||
* “A malicious local user can compromise Enroll after already controlling root’s environment or binaries.”
|
||||
|
||||
Reports about concrete bypasses of Enroll's hardening are welcomed (see https://enroll.sh/security.html), but the project does not treat intentional administrator-controlled execution as a vulnerability.
|
||||
114
debian/changelog
vendored
114
debian/changelog
vendored
|
|
@ -1,5 +1,117 @@
|
|||
enroll (0.1.7) unstable; urgency=medium
|
||||
enroll (0.8.0) unstable; urgency=medium
|
||||
|
||||
* Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable.
|
||||
* Security: enforce tar member limits while lazily parsing untrusted archives rather than after `TarFile.getmembers()` has already indexed the entire archive; count repeated `.` entries and cap remote compressed downloads as well.
|
||||
* Security: apply aggregate byte and total filesystem-entry limits when freezing directory harvest bundles, reject symlinked bundle roots, and abort when files or discovered directories change during the copy, so direct directory inputs remain bounded and fail closed under mutation.
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Mon, 13 Jul 2026 10:00:00 +1000
|
||||
|
||||
enroll (0.7.0) unstable; urgency=medium
|
||||
|
||||
* BREAKING CHANGE: Remove the `enroll diff --enforce` option. Enroll no longer applies the old harvest state locally to repair drift; this avoids the risk of enforcing a potentially malicious or tampered harvest. To restore baseline state, regenerate a manifest from the trusted harvest and apply it yourself, or compare two `enroll diff` runs and act on the result.
|
||||
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
|
||||
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
|
||||
* BREAKING CHANGE: Don't allow reading `.enroll.ini` in the CWD. Use only the ENROLL_CONFIG env var, an explicit `--config` path or else the XDG default location (or `~/.config/enroll/enroll.ini` if `XDG_CONFIG_HOME` is not set).
|
||||
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
|
||||
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
|
||||
* Support for detecting Docker and Podman images and enforcing their presence (by SHA256 hash).
|
||||
* Add support for detecting Flatpaks and Snaps.
|
||||
* Stricter validation of harvests to ensure that they meet the schema and don't contain unsafe artifacts (e.g symlinks pointing outside the artifact tree)
|
||||
* Perform harvest validation before trying to manifest from it.
|
||||
* Stricter validation on FQDN name in multisite mode.
|
||||
* Strict check of `$PATH` when running harvest as root, in case it could lead to execution of unsafe binaries during harvest. Override with `--assume-safe-path` for non-interactive or CI purposes.
|
||||
* Stricter validation of the destination dirs that harvest or manifest write to, to prevent writing to a different user-controlled area. Stricter permissions on the output dirs too.
|
||||
* Lots of hardening across the whole codebase and integration with Jinjaturtle.
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Sun, 5 Jul 2026 11:00:00 +1000
|
||||
|
||||
enroll (0.6.0) unstable; urgency=medium
|
||||
|
||||
* Add support for capturing ipset and iptables configuration files
|
||||
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present ('firewall_runtime' role)
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Thu, 14 May 2026 15:00:00 +1000
|
||||
|
||||
enroll (0.5.0) unstable; urgency=medium
|
||||
|
||||
* Add ssh config support where JinjaTurtle is used
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Tue, 12 May 2026 12:00 +1000
|
||||
|
||||
enroll (0.4.4) unstable; urgency=medium
|
||||
|
||||
* Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Tue, 17 Feb 2026 11:00 +1100
|
||||
|
||||
enroll (0.4.3) unstable; urgency=medium
|
||||
|
||||
* Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Fri, 16 Jan 2026 11:00 +1100
|
||||
|
||||
enroll (0.4.2) unstable; urgency=medium
|
||||
|
||||
* Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Tue, 13 Jan 2026 21:55:00 +1100
|
||||
|
||||
enroll (0.4.1) unstable; urgency=medium
|
||||
* Add interactive output when 'enroll diff --enforce' is invoking Ansible.
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Sun, 11 Jan 2026 10:00:00 +1100
|
||||
|
||||
enroll (0.4.0) unstable; urgency=medium
|
||||
* Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
|
||||
* Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
|
||||
* Update pynacl dependency to resolve CVE-2025-69277
|
||||
* Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
|
||||
* Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
|
||||
* Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
|
||||
* Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible.
|
||||
Only the specific roles that had diffed will be applied (via the new tags capability)
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Sat, 10 Jan 2026 10:30:00 +1100
|
||||
|
||||
enroll (0.3.0) unstable; urgency=medium
|
||||
|
||||
* Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.
|
||||
* Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery.
|
||||
* Capture other files in the user's home directory such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
|
||||
* Ignore files that end with a tilde or - (probably backup files generated by editors or shadow file changes)
|
||||
* Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Mon, 05 Jan 2026 17:00:00 +1100
|
||||
|
||||
enroll (0.2.3) unstable; urgency=medium
|
||||
|
||||
* Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible. It will also fall back to this prompt if a password is required but the arg wasn't passed in.
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Sun, 04 Jan 2026 20:38:00 +1100
|
||||
|
||||
enroll (0.2.2) unstable; urgency=medium
|
||||
|
||||
* Fix stat() of parent directory so that we set directory perms correct on --include paths.
|
||||
* Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Sat, 03 Jan 2026 09:56:00 +1100
|
||||
|
||||
enroll (0.2.1) unstable; urgency=medium
|
||||
|
||||
* Don't accidentally add extra_paths role to usr_local_custom list, resulting in extra_paths appearing twice in manifested playbook
|
||||
* Ensure directories in the tree of anything included with --include are defined in the state and manifest so we make dirs before we try to create files
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Fri, 02 Jan 2026 21:30:00 +1100
|
||||
|
||||
enroll (0.2.0) unstable; urgency=medium
|
||||
|
||||
* Add version CLI arg
|
||||
* Add ability to enroll RH-style systems (DNF5/DNF/RPM)
|
||||
* Refactor harvest state to track package versions
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Mon, 29 Dec 2025 17:30:00 +1100
|
||||
|
||||
enroll (0.1.7) unstable; urgency=medium
|
||||
* Fix an attribution bug for certain files ending up in the wrong package/role.
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Sun, 28 Dec 2025 18:30:00 +1100
|
||||
|
|
|
|||
5
debian/control
vendored
5
debian/control
vendored
|
|
@ -10,12 +10,13 @@ Build-Depends:
|
|||
python3-all,
|
||||
python3-yaml,
|
||||
python3-poetry-core,
|
||||
python3-paramiko
|
||||
python3-paramiko,
|
||||
python3-jsonschema
|
||||
Standards-Version: 4.6.2
|
||||
Homepage: https://git.mig5.net/mig5/enroll
|
||||
|
||||
Package: enroll
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, ${python3:Depends}, python3-yaml, python3-paramiko
|
||||
Depends: ${misc:Depends}, ${python3:Depends}, python3-yaml, python3-paramiko, python3-jsonschema
|
||||
Description: Harvest a host into Ansible roles
|
||||
A tool that inspects a system and emits Ansible roles/playbooks to reproduce it.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,55 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Set, Tuple
|
||||
import re
|
||||
import stat
|
||||
import shutil
|
||||
import subprocess # nosec
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from .fsutil import (
|
||||
is_dir_no_symlink_components,
|
||||
open_no_follow_path,
|
||||
path_has_symlink_component,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlatpakInstall:
|
||||
name: str
|
||||
method: str
|
||||
remote: Optional[str] = None
|
||||
branch: Optional[str] = None
|
||||
arch: Optional[str] = None
|
||||
kind: Optional[str] = None
|
||||
ref: Optional[str] = None
|
||||
user: Optional[str] = None
|
||||
home: Optional[str] = None
|
||||
source: str = "filesystem"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlatpakRemote:
|
||||
name: str
|
||||
method: str
|
||||
url: str
|
||||
user: Optional[str] = None
|
||||
home: Optional[str] = None
|
||||
source: str = "filesystem"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapInstall:
|
||||
name: str
|
||||
channel: Optional[str] = None
|
||||
revision: Optional[int] = None
|
||||
classic: bool = False
|
||||
devmode: bool = False
|
||||
dangerous: bool = False
|
||||
notes: List[str] = field(default_factory=list)
|
||||
source: str = "snap-list"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -16,6 +63,7 @@ class UserRecord:
|
|||
primary_group: str
|
||||
supplementary_groups: List[str]
|
||||
ssh_files: List[str]
|
||||
flatpaks: List[FlatpakInstall] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]:
|
||||
|
|
@ -105,7 +153,12 @@ def is_human_user(uid: int, shell: str, uid_min: int) -> bool:
|
|||
def find_user_ssh_files(home: str) -> List[str]:
|
||||
sshdir = os.path.join(home, ".ssh")
|
||||
out: List[str] = []
|
||||
if not os.path.isdir(sshdir):
|
||||
# ``os.path.isdir`` follows symlinks, so a user who replaces ``~/.ssh``
|
||||
# with a link to a sensitive directory (e.g. /etc/ssl/private) could
|
||||
# otherwise have a regular file inside it harvested through the symlinked
|
||||
# parent. Refuse a symlinked .ssh outright; capture_file() applies the
|
||||
# same parent-symlink protection at copy time as defense in depth.
|
||||
if os.path.islink(sshdir) or not os.path.isdir(sshdir):
|
||||
return out
|
||||
|
||||
ak = os.path.join(sshdir, "authorized_keys")
|
||||
|
|
@ -115,6 +168,642 @@ def find_user_ssh_files(home: str) -> List[str]:
|
|||
return sorted(set(out))
|
||||
|
||||
|
||||
def _read_first_existing_text(
|
||||
paths: List[str], *, max_bytes: int = 8192
|
||||
) -> Optional[str]:
|
||||
"""Read the first small regular text file without following symlinks.
|
||||
|
||||
Per-user Flatpak metadata lives under user-controlled home directories.
|
||||
When Enroll is run as root, plain ``open()`` would let a user replace
|
||||
``active/origin`` or ``repo/config`` with a symlink to a privileged file and
|
||||
have its contents copied into state.json. Use the same no-symlink component
|
||||
invariant as the normal harvester, require a regular file, and cap reads to
|
||||
avoid device/large-file DoS.
|
||||
"""
|
||||
|
||||
for path in paths:
|
||||
fd: Optional[int] = None
|
||||
try:
|
||||
fd = open_no_follow_path(path)
|
||||
st = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(st.st_mode)
|
||||
or st.st_nlink > 1
|
||||
or st.st_size > max_bytes
|
||||
):
|
||||
continue
|
||||
data = os.read(fd, max_bytes + 1)
|
||||
if len(data) > max_bytes:
|
||||
continue
|
||||
value = data.decode("utf-8", errors="replace").strip()
|
||||
if value:
|
||||
return value
|
||||
except OSError:
|
||||
continue
|
||||
finally:
|
||||
if fd is not None:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_flatpak_ref(
|
||||
ref: str,
|
||||
) -> Tuple[Optional[str], str, Optional[str], Optional[str]]:
|
||||
"""Return (kind, name, arch, branch) for a Flatpak ref.
|
||||
|
||||
refs look like app/org.example.App/x86_64/stable or
|
||||
runtime/org.example.Platform/x86_64/23.08. If the value is already just an
|
||||
application/runtime ID, keep it as the name and leave the other fields empty.
|
||||
"""
|
||||
parts = [p for p in (ref or "").strip().split("/") if p]
|
||||
if len(parts) >= 4 and parts[0] in {"app", "runtime"}:
|
||||
return parts[0], parts[1], parts[2], parts[3]
|
||||
return None, (ref or "").strip(), None, None
|
||||
|
||||
|
||||
def _parse_plain_flatpak_list_output(
|
||||
output: str,
|
||||
*,
|
||||
method: str,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> List[FlatpakInstall]:
|
||||
"""Parse default `flatpak list` table output.
|
||||
|
||||
Example:
|
||||
Name Application ID Version Branch Installation
|
||||
OnionShare org.onionshare.OnionShare 2.6.4 stable system
|
||||
"""
|
||||
out: List[FlatpakInstall] = []
|
||||
seen: Set[
|
||||
Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]
|
||||
] = set()
|
||||
id_re = re.compile(r"\b(?:[A-Za-z0-9_-]+\.)+[A-Za-z0-9_-]+\b")
|
||||
for line in output.splitlines():
|
||||
line = line.rstrip()
|
||||
if not line.strip():
|
||||
continue
|
||||
if "Application ID" in line and "Installation" in line:
|
||||
continue
|
||||
match = id_re.search(line)
|
||||
if not match:
|
||||
continue
|
||||
name = match.group(0)
|
||||
tail = line[match.end() :].split()
|
||||
installation = tail[-1] if tail else ""
|
||||
if installation in {"system", "user"} and installation != method:
|
||||
continue
|
||||
branch = None
|
||||
if len(tail) >= 2 and tail[-1] in {"system", "user"}:
|
||||
branch = tail[-2]
|
||||
elif tail:
|
||||
branch = tail[-1]
|
||||
|
||||
key = (name, None, branch, None, None)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(
|
||||
FlatpakInstall(
|
||||
name=name,
|
||||
method=method,
|
||||
remote=None,
|
||||
branch=branch,
|
||||
arch=None,
|
||||
kind=None,
|
||||
ref=None,
|
||||
user=user,
|
||||
home=home,
|
||||
source="flatpak-list",
|
||||
)
|
||||
)
|
||||
return sorted(out, key=lambda f: (f.name, f.branch or ""))
|
||||
|
||||
|
||||
def _parse_flatpak_list_output(
|
||||
output: str,
|
||||
*,
|
||||
method: str,
|
||||
columns: Optional[Tuple[str, ...]] = None,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> List[FlatpakInstall]:
|
||||
"""Parse Flatpak list output.
|
||||
|
||||
If columns is None, parse the default table. Otherwise columns names must
|
||||
match the order passed to `flatpak list --columns=...`.
|
||||
"""
|
||||
if columns is None:
|
||||
return _parse_plain_flatpak_list_output(
|
||||
output, method=method, user=user, home=home
|
||||
)
|
||||
|
||||
out: List[FlatpakInstall] = []
|
||||
seen: Set[
|
||||
Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]
|
||||
] = set()
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
lower = line.lower()
|
||||
if lower.startswith("ref") or lower.startswith("application id"):
|
||||
continue
|
||||
|
||||
parts = line.split("\t")
|
||||
if len(parts) < len(columns):
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
fields = {
|
||||
name: parts[idx].strip()
|
||||
for idx, name in enumerate(columns)
|
||||
if idx < len(parts)
|
||||
}
|
||||
ref = fields.get("ref") or fields.get("application") or ""
|
||||
kind, name, ref_arch, ref_branch = _parse_flatpak_ref(ref)
|
||||
if not name:
|
||||
continue
|
||||
|
||||
remote = fields.get("origin") or None
|
||||
branch = fields.get("branch") or ref_branch
|
||||
arch = fields.get("arch") or ref_arch
|
||||
if remote in {"", "-"}:
|
||||
remote = None
|
||||
if branch in {"", "-"}:
|
||||
branch = None
|
||||
if arch in {"", "-"}:
|
||||
arch = None
|
||||
|
||||
key = (name, remote, branch, arch, kind)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(
|
||||
FlatpakInstall(
|
||||
name=name,
|
||||
method=method,
|
||||
remote=remote,
|
||||
branch=branch,
|
||||
arch=arch,
|
||||
kind=kind,
|
||||
ref=ref if "/" in ref else None,
|
||||
user=user,
|
||||
home=home,
|
||||
source="flatpak-list",
|
||||
)
|
||||
)
|
||||
return sorted(
|
||||
out,
|
||||
key=lambda f: (
|
||||
f.kind or "",
|
||||
f.name,
|
||||
f.remote or "",
|
||||
f.branch or "",
|
||||
f.arch or "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_KNOWN_FLATPAK_LIST_COLUMNS = {
|
||||
"name",
|
||||
"description",
|
||||
"application",
|
||||
"version",
|
||||
"branch",
|
||||
"arch",
|
||||
"origin",
|
||||
"installation",
|
||||
"ref",
|
||||
"active",
|
||||
"latest",
|
||||
"size",
|
||||
"options",
|
||||
}
|
||||
|
||||
|
||||
def _parse_flatpak_columns_help(output: str) -> Set[str]:
|
||||
"""Parse `flatpak list --columns=help` output into supported fields."""
|
||||
supported: Set[str] = set()
|
||||
for line in output.splitlines():
|
||||
# Help output varies a bit between Flatpak versions. Treat any known
|
||||
# token as a supported field, whether it appears alone or in a
|
||||
# description table.
|
||||
for token in re.findall(r"[A-Za-z_][A-Za-z0-9_-]*", line.lower()):
|
||||
if token in _KNOWN_FLATPAK_LIST_COLUMNS:
|
||||
supported.add(token)
|
||||
return supported
|
||||
|
||||
|
||||
def _run_flatpak_columns_help() -> Optional[Set[str]]:
|
||||
if shutil.which("flatpak") is None:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run( # nosec
|
||||
["flatpak", "list", "--columns=help"],
|
||||
shell=False,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
supported = _parse_flatpak_columns_help(proc.stdout or "")
|
||||
return supported or None
|
||||
|
||||
|
||||
def _flatpak_list_attempts(
|
||||
scope: str, supported: Optional[Set[str]]
|
||||
) -> List[Tuple[List[str], Optional[Tuple[str, ...]]]]:
|
||||
def supported_columns(*wanted: str) -> Optional[Tuple[str, ...]]:
|
||||
if supported is not None and not set(wanted).issubset(supported):
|
||||
return None
|
||||
return tuple(wanted)
|
||||
|
||||
column_sets: List[Tuple[str, ...]] = []
|
||||
for wanted in (
|
||||
("application", "origin", "branch", "arch"),
|
||||
("application", "branch", "arch"),
|
||||
("application", "branch"),
|
||||
("application",),
|
||||
("ref", "origin", "branch", "arch"),
|
||||
("ref", "branch", "arch"),
|
||||
("ref", "branch"),
|
||||
("ref",),
|
||||
):
|
||||
cols = supported_columns(*wanted)
|
||||
if cols is not None and cols not in column_sets:
|
||||
column_sets.append(cols)
|
||||
|
||||
attempts: List[Tuple[List[str], Optional[Tuple[str, ...]]]] = [
|
||||
(
|
||||
["flatpak", "list", scope, "--columns=" + ",".join(cols)],
|
||||
cols,
|
||||
)
|
||||
for cols in column_sets
|
||||
]
|
||||
attempts.append((["flatpak", "list", scope], None))
|
||||
return attempts
|
||||
|
||||
|
||||
def _run_flatpak_list(method: str) -> Optional[Tuple[str, Optional[Tuple[str, ...]]]]:
|
||||
if shutil.which("flatpak") is None:
|
||||
return None
|
||||
|
||||
scope = "--system" if method == "system" else "--user"
|
||||
supported = _run_flatpak_columns_help()
|
||||
for args, columns in _flatpak_list_attempts(scope, supported):
|
||||
try:
|
||||
proc = subprocess.run( # nosec
|
||||
args,
|
||||
shell=False,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception: # nosec B112
|
||||
continue
|
||||
if proc.returncode == 0:
|
||||
return proc.stdout or "", columns
|
||||
return None
|
||||
|
||||
|
||||
def _flatpak_remote_from_ref(
|
||||
flatpak_root: str, app_id: str, arch: str, branch: str, remote_names: List[str]
|
||||
) -> Optional[str]:
|
||||
for remote_name in remote_names:
|
||||
ref = os.path.join(
|
||||
flatpak_root,
|
||||
"repo",
|
||||
"refs",
|
||||
"remotes",
|
||||
remote_name,
|
||||
"app",
|
||||
app_id,
|
||||
arch,
|
||||
branch,
|
||||
)
|
||||
if not path_has_symlink_component(ref) and os.path.exists(ref):
|
||||
return remote_name
|
||||
return None
|
||||
|
||||
|
||||
def _parse_flatpak_deploy_origin(branch_dir: str) -> Optional[str]:
|
||||
active_dir = os.path.join(branch_dir, "active")
|
||||
candidates = [
|
||||
os.path.join(active_dir, "origin"),
|
||||
os.path.join(active_dir, "metadata"),
|
||||
]
|
||||
|
||||
origin = _read_first_existing_text([candidates[0]])
|
||||
if origin:
|
||||
return origin
|
||||
|
||||
metadata = _read_first_existing_text([candidates[1]])
|
||||
if metadata:
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
try:
|
||||
parser.read_string(metadata)
|
||||
except Exception:
|
||||
return None
|
||||
for section in ("Application", "Runtime"):
|
||||
if parser.has_option(section, "origin"):
|
||||
value = parser.get(section, "origin", fallback="").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _find_flatpaks_in_root(
|
||||
flatpak_root: str,
|
||||
*,
|
||||
method: str,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> List[FlatpakInstall]:
|
||||
apps_dir = os.path.join(flatpak_root, "app")
|
||||
if not is_dir_no_symlink_components(apps_dir):
|
||||
return []
|
||||
|
||||
remote_names = [
|
||||
r.name
|
||||
for r in find_flatpak_remotes(flatpak_root, method=method, user=user, home=home)
|
||||
]
|
||||
out: List[FlatpakInstall] = []
|
||||
|
||||
try:
|
||||
app_ids = sorted(os.listdir(apps_dir))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
seen: Set[Tuple[str, Optional[str], Optional[str], Optional[str]]] = set()
|
||||
for app_id in app_ids:
|
||||
app_path = os.path.join(apps_dir, app_id)
|
||||
if not is_dir_no_symlink_components(app_path):
|
||||
continue
|
||||
try:
|
||||
arches = sorted(os.listdir(app_path))
|
||||
except OSError:
|
||||
continue
|
||||
for arch in arches:
|
||||
arch_path = os.path.join(app_path, arch)
|
||||
if not is_dir_no_symlink_components(arch_path):
|
||||
continue
|
||||
try:
|
||||
branches = sorted(os.listdir(arch_path))
|
||||
except OSError:
|
||||
continue
|
||||
for branch in branches:
|
||||
branch_path = os.path.join(arch_path, branch)
|
||||
if not is_dir_no_symlink_components(branch_path):
|
||||
continue
|
||||
active_dir = os.path.join(branch_path, "active")
|
||||
if not is_dir_no_symlink_components(active_dir):
|
||||
continue
|
||||
remote = _parse_flatpak_deploy_origin(branch_path)
|
||||
if not remote:
|
||||
remote = _flatpak_remote_from_ref(
|
||||
flatpak_root, app_id, arch, branch, remote_names
|
||||
)
|
||||
key = (app_id, remote, branch, arch)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(
|
||||
FlatpakInstall(
|
||||
name=app_id,
|
||||
method=method,
|
||||
remote=remote,
|
||||
branch=branch or None,
|
||||
arch=arch or None,
|
||||
kind="app",
|
||||
ref=f"app/{app_id}/{arch}/{branch}",
|
||||
user=user,
|
||||
home=home,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(
|
||||
out, key=lambda f: (f.name, f.remote or "", f.branch or "", f.arch or "")
|
||||
)
|
||||
|
||||
|
||||
def find_flatpak_remotes(
|
||||
flatpak_root: str,
|
||||
*,
|
||||
method: str,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> List[FlatpakRemote]:
|
||||
"""Return configured Flatpak remotes for a Flatpak installation root.
|
||||
|
||||
Flatpak stores remotes in the OSTree repo config. This gives us the remote
|
||||
names and repository URLs. It does not reliably preserve the original
|
||||
.flatpakref/.flatpakrepo URL that was used during installation.
|
||||
"""
|
||||
config_path = os.path.join(flatpak_root, "repo", "config")
|
||||
config_text = _read_first_existing_text([config_path])
|
||||
if not config_text:
|
||||
return []
|
||||
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
try:
|
||||
parser.read_string(config_text)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
out: List[FlatpakRemote] = []
|
||||
for section in parser.sections():
|
||||
match = re.fullmatch(r'remote\s+"(.+)"', section)
|
||||
if not match:
|
||||
continue
|
||||
name = match.group(1).strip()
|
||||
url = parser.get(section, "url", fallback="").strip()
|
||||
if not name or not url:
|
||||
continue
|
||||
out.append(
|
||||
FlatpakRemote(
|
||||
name=name,
|
||||
method=method,
|
||||
url=url,
|
||||
user=user,
|
||||
home=home,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(out, key=lambda r: (r.method, r.user or "", r.name))
|
||||
|
||||
|
||||
def find_user_flatpaks(home: str, user: Optional[str] = None) -> List[FlatpakInstall]:
|
||||
"""Return per-user Flatpak applications installed under a home directory."""
|
||||
flatpak_root = os.path.join(home, ".local", "share", "flatpak")
|
||||
return _find_flatpaks_in_root(flatpak_root, method="user", user=user, home=home)
|
||||
|
||||
|
||||
def find_user_flatpak_remotes(
|
||||
home: str, user: Optional[str] = None
|
||||
) -> List[FlatpakRemote]:
|
||||
flatpak_root = os.path.join(home, ".local", "share", "flatpak")
|
||||
return find_flatpak_remotes(flatpak_root, method="user", user=user, home=home)
|
||||
|
||||
|
||||
def find_system_flatpaks() -> List[FlatpakInstall]:
|
||||
"""Return Flatpak refs installed system-wide.
|
||||
|
||||
Prefer `flatpak list --system` because it is Flatpak's own view of
|
||||
installed refs and includes layouts the filesystem scanner might miss.
|
||||
Fall back to the on-disk app deployment tree when the command is
|
||||
unavailable or produces unparsable output.
|
||||
"""
|
||||
listing = _run_flatpak_list("system")
|
||||
if listing is not None:
|
||||
output, columns = listing
|
||||
parsed = _parse_flatpak_list_output(output, method="system", columns=columns)
|
||||
if parsed or not output.strip():
|
||||
return parsed
|
||||
return _find_flatpaks_in_root("/var/lib/flatpak", method="system")
|
||||
|
||||
|
||||
def find_system_flatpak_remotes() -> List[FlatpakRemote]:
|
||||
return find_flatpak_remotes("/var/lib/flatpak", method="system")
|
||||
|
||||
|
||||
def _parse_snap_notes(notes: str) -> List[str]:
|
||||
if not notes or notes == "-":
|
||||
return []
|
||||
cleaned = notes.replace(",", " ").replace(";", " ")
|
||||
return sorted(
|
||||
{n.strip().lower() for n in cleaned.split() if n.strip() and n.strip() != "-"}
|
||||
)
|
||||
|
||||
|
||||
def _parse_snap_list_output(output: str) -> List[SnapInstall]:
|
||||
out: List[SnapInstall] = []
|
||||
for idx, line in enumerate(output.splitlines()):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if idx == 0 and line.lower().startswith("name"):
|
||||
continue
|
||||
parts = line.split(maxsplit=5)
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
name = parts[0]
|
||||
revision: Optional[int]
|
||||
try:
|
||||
revision = int(parts[2])
|
||||
except ValueError:
|
||||
revision = None
|
||||
tracking = parts[3]
|
||||
channel = None if tracking in {"-", ""} else tracking
|
||||
notes = _parse_snap_notes(parts[5] if len(parts) > 5 else "")
|
||||
out.append(
|
||||
SnapInstall(
|
||||
name=name,
|
||||
channel=channel,
|
||||
revision=revision,
|
||||
classic="classic" in notes,
|
||||
devmode="devmode" in notes,
|
||||
dangerous="dangerous" in notes,
|
||||
notes=notes,
|
||||
source="snap-list",
|
||||
)
|
||||
)
|
||||
return sorted(out, key=lambda s: s.name)
|
||||
|
||||
|
||||
def _run_snap_list() -> Optional[str]:
|
||||
if shutil.which("snap") is None:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run( # nosec
|
||||
["snap", "list"],
|
||||
shell=False,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout or ""
|
||||
|
||||
|
||||
def _find_system_snaps_from_filesystem() -> List[SnapInstall]:
|
||||
snapd_snaps = "/var/lib/snapd/snaps"
|
||||
if not os.path.isdir(snapd_snaps):
|
||||
return []
|
||||
|
||||
current_revisions: Dict[str, int] = {}
|
||||
snap_mounts = "/snap"
|
||||
if os.path.isdir(snap_mounts):
|
||||
try:
|
||||
mount_names = os.listdir(snap_mounts)
|
||||
except OSError:
|
||||
mount_names = []
|
||||
for name in mount_names:
|
||||
current = os.path.join(snap_mounts, name, "current")
|
||||
try:
|
||||
target = os.readlink(current)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
current_revisions[name] = int(os.path.basename(target.rstrip("/")))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
candidates: Dict[str, List[int]] = {}
|
||||
try:
|
||||
entries = os.listdir(snapd_snaps)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
for entry in entries:
|
||||
if not entry.endswith(".snap") or "_" not in entry:
|
||||
continue
|
||||
name, rev_text = entry[:-5].rsplit("_", 1)
|
||||
try:
|
||||
revision = int(rev_text)
|
||||
except ValueError:
|
||||
continue
|
||||
candidates.setdefault(name, []).append(revision)
|
||||
|
||||
out: List[SnapInstall] = []
|
||||
for name, revisions in candidates.items():
|
||||
revision = current_revisions.get(name)
|
||||
if revision is None:
|
||||
revision = max(revisions)
|
||||
out.append(SnapInstall(name=name, revision=revision, source="filesystem"))
|
||||
return sorted(out, key=lambda s: s.name)
|
||||
|
||||
|
||||
def find_system_snaps() -> List[SnapInstall]:
|
||||
"""Return system-wide snap packages.
|
||||
|
||||
Prefer `snap list` because it exposes channel tracking and confinement notes.
|
||||
Fall back to snapd's on-disk snap filenames when the command is unavailable.
|
||||
"""
|
||||
output = _run_snap_list()
|
||||
if output is not None:
|
||||
parsed = _parse_snap_list_output(output)
|
||||
if parsed:
|
||||
return parsed
|
||||
return _find_system_snaps_from_filesystem()
|
||||
|
||||
|
||||
def collect_non_system_users() -> List[UserRecord]:
|
||||
defs = parse_login_defs()
|
||||
uid_min = defs.get("UID_MIN", 1000)
|
||||
|
|
@ -139,6 +828,10 @@ def collect_non_system_users() -> List[UserRecord]:
|
|||
|
||||
ssh_files = find_user_ssh_files(home) if home and home.startswith("/") else []
|
||||
|
||||
flatpaks: List[FlatpakInstall] = []
|
||||
if home and home.startswith("/"):
|
||||
flatpaks = find_user_flatpaks(home, user=name)
|
||||
|
||||
users.append(
|
||||
UserRecord(
|
||||
name=name,
|
||||
|
|
@ -150,6 +843,7 @@ def collect_non_system_users() -> List[UserRecord]:
|
|||
primary_group=primary_group,
|
||||
supplementary_groups=supp,
|
||||
ssh_files=ssh_files,
|
||||
flatpaks=flatpaks,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
2511
enroll/ansible.py
Normal file
2511
enroll/ansible.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,8 @@ from datetime import datetime
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .harvest_safety import OutputSafetyError, ensure_private_dir
|
||||
|
||||
|
||||
def _safe_component(s: str) -> str:
|
||||
s = s.strip()
|
||||
|
|
@ -44,16 +46,17 @@ class HarvestCache:
|
|||
|
||||
|
||||
def _ensure_dir_secure(path: Path) -> None:
|
||||
"""Create a directory with restrictive permissions; refuse symlinks."""
|
||||
# Refuse a symlink at the leaf.
|
||||
if path.exists() and path.is_symlink():
|
||||
raise RuntimeError(f"Refusing to use symlink path: {path}")
|
||||
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
"""Create a private cache directory with output-path safety checks.
|
||||
|
||||
Cache roots are persistent, so existing directories are allowed, but they
|
||||
still need the same symlink-component and root-parent trust checks as
|
||||
plaintext harvest/manifest output paths.
|
||||
"""
|
||||
|
||||
try:
|
||||
os.chmod(path, 0o700)
|
||||
except OSError:
|
||||
# Best-effort; on some FS types chmod may fail.
|
||||
pass
|
||||
ensure_private_dir(path, label="cache directory")
|
||||
except OutputSafetyError as e:
|
||||
raise RuntimeError(str(e)) from e
|
||||
|
||||
|
||||
def new_harvest_cache_dir(*, hint: Optional[str] = None) -> HarvestCache:
|
||||
|
|
|
|||
357
enroll/capture.py
Normal file
357
enroll/capture.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import errno
|
||||
import stat
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from .fsutil import open_no_follow_path, stat_triplet, stat_triplet_from_stat
|
||||
from .harvest_types import ExcludedFile, ManagedFile, ManagedLink
|
||||
from .ignore import IgnorePolicy
|
||||
from .pathfilter import PathFilter
|
||||
|
||||
|
||||
def files_differ(a: str, b: str, *, max_bytes: int = 2_000_000) -> bool:
|
||||
"""Return True if file ``a`` differs from file ``b``.
|
||||
|
||||
Best-effort and conservative: unreadable/missing baselines, non-regular
|
||||
files, and unexpectedly large files are treated as different so callers err
|
||||
on the side of preserving user state.
|
||||
"""
|
||||
|
||||
try:
|
||||
st_a = os.stat(a, follow_symlinks=True)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
if not stat.S_ISREG(st_a.st_mode):
|
||||
return True
|
||||
|
||||
try:
|
||||
st_b = os.stat(b, follow_symlinks=True)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
if not stat.S_ISREG(st_b.st_mode):
|
||||
return True
|
||||
|
||||
if st_a.st_size != st_b.st_size:
|
||||
return True
|
||||
|
||||
if st_a.st_size > max_bytes:
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(a, "rb") as fa, open(b, "rb") as fb:
|
||||
while True:
|
||||
ca = fa.read(1024 * 64)
|
||||
cb = fb.read(1024 * 64)
|
||||
if ca != cb:
|
||||
return True
|
||||
if not ca:
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def _open_no_follow_write(path: str, mode: int = 0o600) -> int:
|
||||
return open_no_follow_path(path, write=True, mode=mode)
|
||||
|
||||
|
||||
def write_bytes_into_bundle(
|
||||
bundle_dir: str, role_name: str, src_rel: str, data: bytes
|
||||
) -> None:
|
||||
dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
|
||||
fd = -1
|
||||
try:
|
||||
fd = _open_no_follow_write(dst, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
fd = -1
|
||||
f.write(data)
|
||||
try:
|
||||
os.chmod(dst, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if fd >= 0:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def copy_into_bundle(
|
||||
bundle_dir: str, role_name: str, abs_path: str, src_rel: str
|
||||
) -> None:
|
||||
"""Legacy safe copy helper used by tests and non-IgnorePolicy callers.
|
||||
|
||||
Real harvests using IgnorePolicy copy the exact bytes read from the safely
|
||||
opened source file in capture_file(). This helper still refuses source
|
||||
symlinks at copy time and refuses destination symlink overwrites.
|
||||
"""
|
||||
|
||||
fd = -1
|
||||
try:
|
||||
try:
|
||||
fd = open_no_follow_path(abs_path)
|
||||
except OSError as e:
|
||||
if e.errno in {errno.ELOOP, errno.ENOTDIR}:
|
||||
raise OSError("refusing to copy symlink source") from e
|
||||
raise
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise OSError("refusing to copy non-regular source")
|
||||
if st.st_nlink > 1:
|
||||
raise OSError("refusing to copy hardlinked source")
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = os.read(fd, 1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
write_bytes_into_bundle(bundle_dir, role_name, src_rel, b"".join(chunks))
|
||||
finally:
|
||||
if fd >= 0:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def capture_file(
|
||||
*,
|
||||
bundle_dir: str,
|
||||
role_name: str,
|
||||
abs_path: str,
|
||||
reason: str,
|
||||
policy: IgnorePolicy,
|
||||
path_filter: PathFilter,
|
||||
managed_out: List[ManagedFile],
|
||||
excluded_out: List[ExcludedFile],
|
||||
seen_role: Optional[Set[str]] = None,
|
||||
seen_global: Optional[Set[str]] = None,
|
||||
metadata: Optional[tuple[str, str, str]] = None,
|
||||
) -> bool:
|
||||
"""Try to capture a single file into the bundle.
|
||||
|
||||
Returns True if the file was copied and appended to ``managed_out``.
|
||||
``seen_role`` de-duplicates within a role; ``seen_global`` de-duplicates
|
||||
across harvest stages so multiple generated roles do not manage one path.
|
||||
"""
|
||||
|
||||
if seen_global is not None and abs_path in seen_global:
|
||||
return False
|
||||
if seen_role is not None and abs_path in seen_role:
|
||||
return False
|
||||
|
||||
def _mark_seen() -> None:
|
||||
if seen_role is not None:
|
||||
seen_role.add(abs_path)
|
||||
if seen_global is not None:
|
||||
seen_global.add(abs_path)
|
||||
|
||||
if path_filter.is_excluded(abs_path):
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
inspection = None
|
||||
inspect_file = getattr(policy, "inspect_file", None)
|
||||
if callable(inspect_file):
|
||||
inspected = inspect_file(abs_path)
|
||||
if isinstance(inspected, tuple) and len(inspected) == 2:
|
||||
deny, inspection = inspected
|
||||
else:
|
||||
# Some tests and third-party callers use MagicMock/spec policies that
|
||||
# expose inspect_file but have not configured it. Fall back to the
|
||||
# legacy deny_reason/copy path for those non-real policies.
|
||||
deny = policy.deny_reason(abs_path)
|
||||
else:
|
||||
deny = policy.deny_reason(abs_path)
|
||||
if deny:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
try:
|
||||
if inspection is not None:
|
||||
# Prefer the stat taken from the no-follow descriptor that was
|
||||
# actually inspected and whose bytes are about to be written. A
|
||||
# caller-supplied ``metadata`` triplet (see below) may have been
|
||||
# derived from a separate, symlink-following stat with its own
|
||||
# time-of-check/time-of-use window, so it must not override the
|
||||
# authoritative descriptor stat when we have one.
|
||||
owner, group, mode = stat_triplet_from_stat(inspection.stat_result)
|
||||
elif metadata is not None:
|
||||
# Fallback for callers that pre-computed metadata and are not using a
|
||||
# real IgnorePolicy that returns a FileInspection (e.g. tests, or the
|
||||
# /usr/local scanner when inspection is unavailable). This value is a
|
||||
# convenience/perf optimisation only; it never widens what gets
|
||||
# captured, since inspection (secret scan, no-follow, size/hardlink
|
||||
# checks) has already run above.
|
||||
owner, group, mode = metadata
|
||||
else:
|
||||
owner, group, mode = stat_triplet(abs_path)
|
||||
except OSError:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
src_rel = abs_path.lstrip("/")
|
||||
try:
|
||||
if inspection is not None:
|
||||
write_bytes_into_bundle(bundle_dir, role_name, src_rel, inspection.data)
|
||||
else:
|
||||
copy_into_bundle(bundle_dir, role_name, abs_path, src_rel)
|
||||
except OSError:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
managed_out.append(
|
||||
ManagedFile(
|
||||
path=abs_path,
|
||||
src_rel=src_rel,
|
||||
owner=owner,
|
||||
group=group,
|
||||
mode=mode,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
_mark_seen()
|
||||
return True
|
||||
|
||||
|
||||
USER_SHELL_DOTFILES_WITH_SKEL_BASELINE = [
|
||||
(".bashrc", "user_shell_rc"),
|
||||
(".profile", "user_profile"),
|
||||
(".bash_logout", "user_shell_logout"),
|
||||
]
|
||||
|
||||
USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE = [
|
||||
(".bash_aliases", "user_shell_aliases"),
|
||||
]
|
||||
|
||||
|
||||
def capture_user_shell_dotfiles(
|
||||
*,
|
||||
bundle_dir: str,
|
||||
role_name: str,
|
||||
home: str,
|
||||
skel_dir: str,
|
||||
enabled: bool,
|
||||
policy: IgnorePolicy,
|
||||
path_filter: PathFilter,
|
||||
managed_out: List[ManagedFile],
|
||||
excluded_out: List[ExcludedFile],
|
||||
seen_role: Optional[Set[str]],
|
||||
seen_global: Optional[Set[str]],
|
||||
) -> int:
|
||||
"""Capture selected per-user shell dotfiles when explicitly enabled."""
|
||||
|
||||
if not enabled:
|
||||
return 0
|
||||
|
||||
home = (home or "").rstrip("/")
|
||||
if not home or not home.startswith("/"):
|
||||
return 0
|
||||
|
||||
captured = 0
|
||||
max_compare_bytes = int(getattr(policy, "max_file_bytes", 256_000))
|
||||
|
||||
for rel, reason in USER_SHELL_DOTFILES_WITH_SKEL_BASELINE:
|
||||
upath = os.path.join(home, rel)
|
||||
if not os.path.isfile(upath) or os.path.islink(upath):
|
||||
continue
|
||||
skel_path = os.path.join(skel_dir, rel)
|
||||
if not files_differ(upath, skel_path, max_bytes=max_compare_bytes):
|
||||
continue
|
||||
if capture_file(
|
||||
bundle_dir=bundle_dir,
|
||||
role_name=role_name,
|
||||
abs_path=upath,
|
||||
reason=reason,
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed_out,
|
||||
excluded_out=excluded_out,
|
||||
seen_role=seen_role,
|
||||
seen_global=seen_global,
|
||||
):
|
||||
captured += 1
|
||||
|
||||
for rel, reason in USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE:
|
||||
upath = os.path.join(home, rel)
|
||||
if not os.path.isfile(upath) or os.path.islink(upath):
|
||||
continue
|
||||
if capture_file(
|
||||
bundle_dir=bundle_dir,
|
||||
role_name=role_name,
|
||||
abs_path=upath,
|
||||
reason=reason,
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed_out,
|
||||
excluded_out=excluded_out,
|
||||
seen_role=seen_role,
|
||||
seen_global=seen_global,
|
||||
):
|
||||
captured += 1
|
||||
|
||||
return captured
|
||||
|
||||
|
||||
def capture_link(
|
||||
*,
|
||||
role_name: str,
|
||||
abs_path: str,
|
||||
reason: str,
|
||||
policy: IgnorePolicy,
|
||||
path_filter: PathFilter,
|
||||
managed_out: List[ManagedLink],
|
||||
excluded_out: List[ExcludedFile],
|
||||
seen_role: Optional[Set[str]] = None,
|
||||
seen_global: Optional[Set[str]] = None,
|
||||
) -> bool:
|
||||
"""Record a symlink for later materialisation by the manifest renderer."""
|
||||
|
||||
if seen_global is not None and abs_path in seen_global:
|
||||
return False
|
||||
if seen_role is not None and abs_path in seen_role:
|
||||
return False
|
||||
|
||||
def _mark_seen() -> None:
|
||||
if seen_role is not None:
|
||||
seen_role.add(abs_path)
|
||||
if seen_global is not None:
|
||||
seen_global.add(abs_path)
|
||||
|
||||
if path_filter.is_excluded(abs_path):
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
deny_link = getattr(policy, "deny_reason_link", None)
|
||||
if callable(deny_link):
|
||||
deny = deny_link(abs_path)
|
||||
else:
|
||||
deny = policy.deny_reason(abs_path)
|
||||
if deny in ("not_regular_file", "not_file", "not_regular"):
|
||||
deny = None
|
||||
|
||||
if deny:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
if not os.path.islink(abs_path):
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="not_symlink"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
try:
|
||||
target = os.readlink(abs_path)
|
||||
except OSError:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
managed_out.append(ManagedLink(path=abs_path, target=target, reason=reason))
|
||||
_mark_seen()
|
||||
return True
|
||||
619
enroll/cli.py
619
enroll/cli.py
|
|
@ -2,7 +2,9 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
|
@ -10,11 +12,24 @@ from pathlib import Path
|
|||
from typing import Optional
|
||||
|
||||
from .cache import new_harvest_cache_dir
|
||||
from .diff import compare_harvests, format_report, post_webhook, send_email
|
||||
from .diff import (
|
||||
compare_harvests,
|
||||
format_report,
|
||||
post_webhook,
|
||||
send_email,
|
||||
)
|
||||
from .explain import explain_state
|
||||
from .harvest import harvest
|
||||
from .harvest_safety import ensure_safe_output_parent, write_text_output_file
|
||||
from .manifest import manifest
|
||||
from .remote import remote_harvest
|
||||
from .remote import (
|
||||
remote_harvest,
|
||||
RemoteSudoPasswordRequired,
|
||||
RemoteSSHKeyPassphraseRequired,
|
||||
)
|
||||
from .sopsutil import SopsError, encrypt_file_binary
|
||||
from .validate import validate_harvest
|
||||
from .version import get_enroll_version
|
||||
|
||||
|
||||
def _discover_config_path(argv: list[str]) -> Optional[Path]:
|
||||
|
|
@ -24,8 +39,10 @@ def _discover_config_path(argv: list[str]) -> Optional[Path]:
|
|||
1) --no-config disables loading.
|
||||
2) --config PATH (or -c PATH)
|
||||
3) $ENROLL_CONFIG
|
||||
4) ./enroll.ini, ./.enroll.ini
|
||||
5) $XDG_CONFIG_HOME/enroll/enroll.ini (or ~/.config/enroll/enroll.ini)
|
||||
4) $XDG_CONFIG_HOME/enroll/enroll.ini (or ~/.config/enroll/enroll.ini)
|
||||
|
||||
Current-directory config files are deliberately not auto-loaded; use
|
||||
--config ./enroll.ini if that behaviour is desired.
|
||||
|
||||
The config file is optional; if no file is found, returns None.
|
||||
"""
|
||||
|
|
@ -51,12 +68,6 @@ def _discover_config_path(argv: list[str]) -> Optional[Path]:
|
|||
if envp:
|
||||
return Path(envp).expanduser()
|
||||
|
||||
cwd = Path.cwd()
|
||||
for name in ("enroll.ini", ".enroll.ini"):
|
||||
cp = cwd / name
|
||||
if cp.exists() and cp.is_file():
|
||||
return cp
|
||||
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
if xdg:
|
||||
base = Path(xdg).expanduser()
|
||||
|
|
@ -100,6 +111,15 @@ def _action_lookup(p: argparse.ArgumentParser) -> dict[str, argparse.Action]:
|
|||
return m
|
||||
|
||||
|
||||
def _warn_dangerous_harvest(*, sops_enabled: bool) -> None:
|
||||
if not sops_enabled:
|
||||
print(
|
||||
"warning: --dangerous is enabled. The harvest may contain sensitive "
|
||||
"files, credentials, private keys, tokens, or application secrets. "
|
||||
"Consider using --sops to encrypt the harvest at rest."
|
||||
)
|
||||
|
||||
|
||||
def _choose_flag(a: argparse.Action) -> Optional[str]:
|
||||
# Prefer a long flag if available (e.g. --dangerous over -d)
|
||||
for s in getattr(a, "option_strings", []) or []:
|
||||
|
|
@ -123,6 +143,149 @@ def _split_list_value(v: str) -> list[str]:
|
|||
return [raw] if raw else []
|
||||
|
||||
|
||||
def _root_trust_reason(path: Path, *, final: bool) -> Optional[str]:
|
||||
"""Return why a PATH directory/ancestor is unsafe for root execution."""
|
||||
|
||||
running_as_root = _is_effective_root()
|
||||
if not final and not running_as_root:
|
||||
return None
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
return None
|
||||
|
||||
subject = "directory" if final else "parent directory"
|
||||
if running_as_root and st.st_uid != 0:
|
||||
return f"{subject} is not owned by root"
|
||||
|
||||
writable_by_group = bool(st.st_mode & stat.S_IWGRP)
|
||||
writable_by_other = bool(st.st_mode & stat.S_IWOTH)
|
||||
sticky = bool(st.st_mode & stat.S_ISVTX)
|
||||
|
||||
# A sticky shared ancestor such as /tmp may contain a root-owned PATH
|
||||
# directory safely enough for this check, but the PATH entry itself must
|
||||
# never be writable by group/other because that permits command planting.
|
||||
if final or not sticky:
|
||||
if writable_by_other:
|
||||
return f"{subject} is world-writable"
|
||||
if writable_by_group:
|
||||
return f"{subject} is group-writable"
|
||||
return None
|
||||
|
||||
|
||||
def _root_parent_trust_reason(path: Path) -> Optional[str]:
|
||||
"""Check original and resolved PATH ancestors for root trust."""
|
||||
|
||||
if not _is_effective_root():
|
||||
return None
|
||||
|
||||
candidates: list[Path] = []
|
||||
candidates.extend(reversed(path.parents))
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError:
|
||||
resolved = None
|
||||
if resolved is not None and resolved != path:
|
||||
candidates.extend(reversed(resolved.parents))
|
||||
|
||||
seen: set[str] = set()
|
||||
for parent in candidates:
|
||||
key = str(parent)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
reason = _root_trust_reason(parent, final=False)
|
||||
if reason:
|
||||
return f"{reason}: {parent}"
|
||||
return None
|
||||
|
||||
|
||||
def _path_entry_is_unsafe(entry: str) -> Optional[str]:
|
||||
"""Return a human-readable reason if a PATH entry is unsafe for root.
|
||||
|
||||
Empty PATH entries and relative entries resolve via the current working
|
||||
directory, which is equivalent to trusting whatever directory the operator
|
||||
happens to be in. Existing group/world-writable directories are also risky
|
||||
when Enroll is run as root because Enroll deliberately invokes host tools
|
||||
from PATH while harvesting and enforcing state. When running as root, an
|
||||
existing PATH directory must also be root-owned; a non-root-owned 0755
|
||||
directory is still attacker-controlled by its owner.
|
||||
"""
|
||||
|
||||
if entry == "":
|
||||
return "empty PATH entry resolves to the current directory"
|
||||
if entry == ".":
|
||||
return "'.' resolves to the current directory"
|
||||
if not os.path.isabs(entry):
|
||||
return "relative PATH entry resolves from the current directory"
|
||||
|
||||
p = Path(entry)
|
||||
parent_reason = _root_parent_trust_reason(p)
|
||||
if parent_reason:
|
||||
return parent_reason
|
||||
|
||||
try:
|
||||
st = os.stat(entry)
|
||||
except OSError:
|
||||
return None
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
return None
|
||||
|
||||
final_reason = _root_trust_reason(p, final=True)
|
||||
if final_reason:
|
||||
return final_reason
|
||||
return None
|
||||
|
||||
|
||||
def _unsafe_root_path_reasons(path_value: Optional[str] = None) -> list[str]:
|
||||
"""Return unsafe PATH entries that should make root execution interactive."""
|
||||
|
||||
raw = os.environ.get("PATH", "") if path_value is None else str(path_value)
|
||||
out: list[str] = []
|
||||
for entry in raw.split(os.pathsep):
|
||||
reason = _path_entry_is_unsafe(entry)
|
||||
if reason:
|
||||
label = entry if entry else "<empty>"
|
||||
out.append(f"{label}: {reason}")
|
||||
return out
|
||||
|
||||
|
||||
def _is_effective_root() -> bool:
|
||||
geteuid = getattr(os, "geteuid", None)
|
||||
return bool(geteuid is not None and geteuid() == 0)
|
||||
|
||||
|
||||
def _confirm_root_path_safety(*, force: bool = False) -> None:
|
||||
"""Prompt before running as root with a PATH that trusts writable entries."""
|
||||
|
||||
if force or not _is_effective_root():
|
||||
return
|
||||
|
||||
reasons = _unsafe_root_path_reasons()
|
||||
if not reasons:
|
||||
return
|
||||
|
||||
details = "\n".join(f" - {r}" for r in reasons)
|
||||
msg = (
|
||||
"warning: enroll is running as root and PATH contains entries that "
|
||||
"could allow an untrusted binary to be executed:\n"
|
||||
f"{details}\n"
|
||||
)
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
raise SystemExit(
|
||||
msg + "error: refusing to continue non-interactively. Re-run with "
|
||||
"--assume-safe-path if you intentionally trust this PATH."
|
||||
)
|
||||
|
||||
print(msg, file=sys.stderr, end="")
|
||||
answer = input("Are you sure you want to continue? [y/N] ")
|
||||
if answer.strip().lower() not in {"y", "yes"}:
|
||||
raise SystemExit("aborted: unsafe root PATH was not confirmed")
|
||||
|
||||
|
||||
def _section_to_argv(
|
||||
p: argparse.ArgumentParser, cfg: configparser.ConfigParser, section: str
|
||||
) -> list[str]:
|
||||
|
|
@ -264,7 +427,7 @@ def _resolve_sops_out_file(out: Optional[str], *, hint: str) -> Path:
|
|||
|
||||
|
||||
def _tar_dir_to(path_dir: Path, tar_path: Path) -> None:
|
||||
tar_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ensure_safe_output_parent(tar_path, label="harvest tar output")
|
||||
with tarfile.open(tar_path, mode="w:gz") as tf:
|
||||
# Keep a stable on-disk layout when extracted: state.json + artifacts/
|
||||
tf.add(str(path_dir), arcname=".")
|
||||
|
|
@ -274,7 +437,7 @@ def _encrypt_harvest_dir_to_sops(
|
|||
bundle_dir: Path, out_file: Path, fps: list[str]
|
||||
) -> Path:
|
||||
out_file = Path(out_file)
|
||||
out_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
ensure_safe_output_parent(out_file, label="encrypted harvest output")
|
||||
|
||||
# Create the tarball alongside the output file (keeps filesystem permissions/locality sane).
|
||||
fd, tmp_tgz = tempfile.mkstemp(
|
||||
|
|
@ -295,7 +458,15 @@ def _encrypt_harvest_dir_to_sops(
|
|||
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--fqdn",
|
||||
help="Host FQDN/name for site-mode output (creates inventory/, inventory/host_vars/, playbooks/).",
|
||||
help="Host FQDN/name for site-mode output (creates Ansible host_vars for that host).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--no-common-roles",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Do not group package and systemd-unit roles into common section/group roles. "
|
||||
"This preserves one generated role per package/unit. --fqdn implies this."
|
||||
),
|
||||
)
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument(
|
||||
|
|
@ -310,19 +481,12 @@ def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _jt_mode(args: argparse.Namespace) -> str:
|
||||
def _jt_mode(args: argparse.Namespace) -> Optional[bool]:
|
||||
if getattr(args, "jinjaturtle", False):
|
||||
return "on"
|
||||
return True
|
||||
if getattr(args, "no_jinjaturtle", False):
|
||||
return "off"
|
||||
return "auto"
|
||||
|
||||
|
||||
def _add_remote_args(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--remote-host",
|
||||
help="SSH host to run harvesting on (if set, harvest runs remotely and is pulled locally).",
|
||||
)
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _add_config_args(p: argparse.ArgumentParser) -> None:
|
||||
|
|
@ -330,8 +494,8 @@ def _add_config_args(p: argparse.ArgumentParser) -> None:
|
|||
"-c",
|
||||
"--config",
|
||||
help=(
|
||||
"Path to an INI config file for default options. If omitted, enroll will look for "
|
||||
"./enroll.ini, ./.enroll.ini, or ~/.config/enroll/enroll.ini (or $XDG_CONFIG_HOME/enroll/enroll.ini)."
|
||||
"Path to an INI config file for default options. If omitted, enroll will look for a path defined by the "
|
||||
"ENROLL_CONFIG environment variable , ~/.config/enroll/enroll.ini (or $XDG_CONFIG_HOME/enroll/enroll.ini)."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
|
|
@ -339,26 +503,103 @@ def _add_config_args(p: argparse.ArgumentParser) -> None:
|
|||
action="store_true",
|
||||
help="Do not load any INI config file (even if one would be auto-discovered).",
|
||||
)
|
||||
|
||||
|
||||
def _add_path_safety_args(
|
||||
p: argparse.ArgumentParser, *, default: object = False
|
||||
) -> None:
|
||||
p.add_argument(
|
||||
"--assume-safe-path",
|
||||
action="store_true",
|
||||
default=default,
|
||||
help=(
|
||||
"When running as root, continue without confirmation even if PATH "
|
||||
"contains '.', an empty/relative entry, or a group/world-writable "
|
||||
"directory. Intended for trusted non-interactive automation."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _add_remote_args(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--remote-host",
|
||||
help="SSH host to run harvesting on (if set, harvest runs remotely and is pulled locally).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--remote-ssh-config",
|
||||
nargs="?",
|
||||
const=str(Path.home() / ".ssh" / "config"),
|
||||
default=None,
|
||||
help=(
|
||||
"Use OpenSSH-style ssh_config settings for --remote-host. "
|
||||
"If provided without a value, defaults to ~/.ssh/config. "
|
||||
"(Applies HostName/User/Port/IdentityFile/ProxyCommand/HostKeyAlias when supported.)"
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--remote-port",
|
||||
type=int,
|
||||
default=22,
|
||||
help="SSH port for --remote-host (default: 22).",
|
||||
default=None,
|
||||
help=(
|
||||
"SSH port for --remote-host. If omitted, defaults to 22, or a value from ssh_config when "
|
||||
"--remote-ssh-config is set."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--remote-user",
|
||||
default=os.environ.get("USER") or None,
|
||||
help="SSH username for --remote-host (default: local $USER).",
|
||||
default=None,
|
||||
help=(
|
||||
"SSH username for --remote-host. If omitted, defaults to local $USER, or a value from ssh_config when "
|
||||
"--remote-ssh-config is set."
|
||||
),
|
||||
)
|
||||
|
||||
# Align terminology with Ansible: "become" == sudo.
|
||||
p.add_argument(
|
||||
"--ask-become-pass",
|
||||
"-K",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Prompt for the remote sudo (become) password when using --remote-host "
|
||||
"(similar to ansible --ask-become-pass)."
|
||||
),
|
||||
)
|
||||
|
||||
keyp = p.add_mutually_exclusive_group()
|
||||
keyp.add_argument(
|
||||
"--ask-key-passphrase",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Prompt for the SSH private key passphrase when using --remote-host. "
|
||||
"If not set, enroll will still prompt on-demand if it detects an encrypted key in an interactive session."
|
||||
),
|
||||
)
|
||||
keyp.add_argument(
|
||||
"--ssh-key-passphrase-env",
|
||||
metavar="ENV_VAR",
|
||||
help=(
|
||||
"Read the SSH private key passphrase from environment variable ENV_VAR "
|
||||
"(useful for non-interactive runs/CI)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(prog="enroll")
|
||||
ap.add_argument(
|
||||
"-v",
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"{get_enroll_version()}",
|
||||
)
|
||||
_add_config_args(ap)
|
||||
_add_path_safety_args(ap)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
h = sub.add_parser("harvest", help="Harvest service/package/config state")
|
||||
_add_config_args(h)
|
||||
_add_path_safety_args(h, default=argparse.SUPPRESS)
|
||||
_add_remote_args(h)
|
||||
h.add_argument(
|
||||
"--out",
|
||||
help=(
|
||||
|
|
@ -391,7 +632,6 @@ def main() -> None:
|
|||
"Excludes apply to all harvesting, including defaults."
|
||||
),
|
||||
)
|
||||
|
||||
h.add_argument(
|
||||
"--sops",
|
||||
nargs="+",
|
||||
|
|
@ -406,10 +646,12 @@ def main() -> None:
|
|||
action="store_true",
|
||||
help="Don't use sudo on the remote host (when using --remote options). This may result in a limited harvest due to permission restrictions.",
|
||||
)
|
||||
_add_remote_args(h)
|
||||
|
||||
m = sub.add_parser("manifest", help="Render Ansible roles from a harvest")
|
||||
m = sub.add_parser(
|
||||
"manifest", help="Render configuration-management code from a harvest"
|
||||
)
|
||||
_add_config_args(m)
|
||||
_add_path_safety_args(m, default=argparse.SUPPRESS)
|
||||
m.add_argument(
|
||||
"--harvest",
|
||||
required=True,
|
||||
|
|
@ -440,9 +682,12 @@ def main() -> None:
|
|||
_add_common_manifest_args(m)
|
||||
|
||||
s = sub.add_parser(
|
||||
"single-shot", help="Harvest state, then manifest Ansible code, in one shot"
|
||||
"single-shot",
|
||||
help="Harvest state, then manifest configuration-management code, in one shot",
|
||||
)
|
||||
_add_config_args(s)
|
||||
_add_path_safety_args(s, default=argparse.SUPPRESS)
|
||||
_add_remote_args(s)
|
||||
s.add_argument(
|
||||
"--harvest",
|
||||
help=(
|
||||
|
|
@ -475,7 +720,6 @@ def main() -> None:
|
|||
"Excludes apply to all harvesting, including defaults."
|
||||
),
|
||||
)
|
||||
|
||||
s.add_argument(
|
||||
"--sops",
|
||||
nargs="+",
|
||||
|
|
@ -500,10 +744,10 @@ def main() -> None:
|
|||
),
|
||||
)
|
||||
_add_common_manifest_args(s)
|
||||
_add_remote_args(s)
|
||||
|
||||
d = sub.add_parser("diff", help="Compare two harvests and report differences")
|
||||
_add_config_args(d)
|
||||
_add_path_safety_args(d, default=argparse.SUPPRESS)
|
||||
d.add_argument(
|
||||
"--old",
|
||||
required=True,
|
||||
|
|
@ -529,6 +773,24 @@ def main() -> None:
|
|||
default="text",
|
||||
help="Report output format (default: text).",
|
||||
)
|
||||
d.add_argument(
|
||||
"--exclude-path",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="PATTERN",
|
||||
help=(
|
||||
"Exclude file paths from the diff report (repeatable). Supports globs (including '**') and regex via 're:<regex>'. "
|
||||
"This affects file drift reporting only (added/removed/changed files), not package/service/user diffs."
|
||||
),
|
||||
)
|
||||
d.add_argument(
|
||||
"--ignore-package-versions",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Ignore package version changes in the diff report and exit status. "
|
||||
"Package additions/removals are still reported. Useful when routine upgrades would otherwise create noisy drift."
|
||||
),
|
||||
)
|
||||
d.add_argument(
|
||||
"--out",
|
||||
help="Write the report to this file instead of stdout.",
|
||||
|
|
@ -587,6 +849,86 @@ def main() -> None:
|
|||
help="Environment variable containing SMTP password (optional).",
|
||||
)
|
||||
|
||||
e = sub.add_parser("explain", help="Explain a harvest state.json")
|
||||
_add_config_args(e)
|
||||
_add_path_safety_args(e, default=argparse.SUPPRESS)
|
||||
e.add_argument(
|
||||
"harvest",
|
||||
help=(
|
||||
"Harvest input (directory, a path to state.json, a tarball, or a SOPS-encrypted bundle)."
|
||||
),
|
||||
)
|
||||
e.add_argument(
|
||||
"--sops",
|
||||
action="store_true",
|
||||
help="Treat the input as a SOPS-encrypted bundle (auto-detected if the filename ends with .sops).",
|
||||
)
|
||||
e.add_argument(
|
||||
"--format",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format.",
|
||||
)
|
||||
e.add_argument(
|
||||
"--max-examples",
|
||||
type=int,
|
||||
default=3,
|
||||
help="How many example paths/refs to show per reason.",
|
||||
)
|
||||
|
||||
v = sub.add_parser(
|
||||
"validate", help="Validate a harvest bundle (state.json + artifacts)"
|
||||
)
|
||||
_add_config_args(v)
|
||||
_add_path_safety_args(v, default=argparse.SUPPRESS)
|
||||
v.add_argument(
|
||||
"harvest",
|
||||
help=(
|
||||
"Harvest input (directory, a path to state.json, a tarball, or a SOPS-encrypted bundle)."
|
||||
),
|
||||
)
|
||||
v.add_argument(
|
||||
"--sops",
|
||||
action="store_true",
|
||||
help="Treat the input as a SOPS-encrypted bundle (auto-detected if the filename ends with .sops).",
|
||||
)
|
||||
v.add_argument(
|
||||
"--schema",
|
||||
help=(
|
||||
"Optional JSON schema source. File paths are loaded by default; "
|
||||
"http(s) URLs require --allow-remote-schema. If omitted, uses "
|
||||
"the schema vendored in the enroll codebase."
|
||||
),
|
||||
)
|
||||
v.add_argument(
|
||||
"--allow-remote-schema",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Allow --schema to fetch an http(s) URL. Disabled by default so "
|
||||
"validation never makes network requests unless explicitly requested."
|
||||
),
|
||||
)
|
||||
v.add_argument(
|
||||
"--no-schema",
|
||||
action="store_true",
|
||||
help="Skip JSON schema validation and only perform bundle consistency checks.",
|
||||
)
|
||||
v.add_argument(
|
||||
"--fail-on-warnings",
|
||||
action="store_true",
|
||||
help="Exit non-zero if validation produces warnings.",
|
||||
)
|
||||
v.add_argument(
|
||||
"--format",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format.",
|
||||
)
|
||||
v.add_argument(
|
||||
"--out",
|
||||
help="Write the report to this file instead of stdout.",
|
||||
)
|
||||
|
||||
argv = sys.argv[1:]
|
||||
cfg_path = _discover_config_path(argv)
|
||||
argv = _inject_config_argv(
|
||||
|
|
@ -598,18 +940,36 @@ def main() -> None:
|
|||
"manifest": m,
|
||||
"single-shot": s,
|
||||
"diff": d,
|
||||
"explain": e,
|
||||
"validate": v,
|
||||
},
|
||||
)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
remote_host: Optional[str] = getattr(args, "remote_host", None)
|
||||
if args.cmd in {"harvest", "single-shot"} and bool(
|
||||
getattr(args, "dangerous", False)
|
||||
):
|
||||
_warn_dangerous_harvest(sops_enabled=bool(getattr(args, "sops", None)))
|
||||
|
||||
_confirm_root_path_safety(force=bool(getattr(args, "assume_safe_path", False)))
|
||||
|
||||
# Preserve historical defaults for remote harvesting unless ssh_config lookup is enabled.
|
||||
# This lets ssh_config values take effect when the user did not explicitly set
|
||||
# --remote-user / --remote-port.
|
||||
if hasattr(args, "remote_host"):
|
||||
rsc = getattr(args, "remote_ssh_config", None)
|
||||
if not rsc:
|
||||
if getattr(args, "remote_port", None) is None:
|
||||
setattr(args, "remote_port", 22)
|
||||
if getattr(args, "remote_user", None) is None:
|
||||
setattr(args, "remote_user", os.environ.get("USER") or None)
|
||||
|
||||
try:
|
||||
if args.cmd == "harvest":
|
||||
sops_fps = getattr(args, "sops", None)
|
||||
if remote_host:
|
||||
if args.remote_host:
|
||||
if sops_fps:
|
||||
out_file = _resolve_sops_out_file(args.out, hint=remote_host)
|
||||
out_file = _resolve_sops_out_file(args.out, hint=args.remote_host)
|
||||
with tempfile.TemporaryDirectory(prefix="enroll-harvest-") as td:
|
||||
tmp_bundle = Path(td) / "bundle"
|
||||
tmp_bundle.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -618,14 +978,21 @@ def main() -> None:
|
|||
except OSError:
|
||||
pass
|
||||
remote_harvest(
|
||||
ask_become_pass=args.ask_become_pass,
|
||||
ask_key_passphrase=bool(args.ask_key_passphrase),
|
||||
ssh_key_passphrase_env=getattr(
|
||||
args, "ssh_key_passphrase_env", None
|
||||
),
|
||||
local_out_dir=tmp_bundle,
|
||||
remote_host=remote_host,
|
||||
remote_port=int(args.remote_port),
|
||||
remote_host=args.remote_host,
|
||||
remote_port=args.remote_port,
|
||||
remote_user=args.remote_user,
|
||||
remote_ssh_config=args.remote_ssh_config,
|
||||
dangerous=bool(args.dangerous),
|
||||
no_sudo=bool(args.no_sudo),
|
||||
include_paths=list(getattr(args, "include_path", []) or []),
|
||||
exclude_paths=list(getattr(args, "exclude_path", []) or []),
|
||||
allow_existing_output=True,
|
||||
)
|
||||
_encrypt_harvest_dir_to_sops(
|
||||
tmp_bundle, out_file, list(sops_fps)
|
||||
|
|
@ -635,17 +1002,24 @@ def main() -> None:
|
|||
out_dir = (
|
||||
Path(args.out)
|
||||
if args.out
|
||||
else new_harvest_cache_dir(hint=remote_host).dir
|
||||
else new_harvest_cache_dir(hint=args.remote_host).dir
|
||||
)
|
||||
state = remote_harvest(
|
||||
ask_become_pass=args.ask_become_pass,
|
||||
ask_key_passphrase=bool(args.ask_key_passphrase),
|
||||
ssh_key_passphrase_env=getattr(
|
||||
args, "ssh_key_passphrase_env", None
|
||||
),
|
||||
local_out_dir=out_dir,
|
||||
remote_host=remote_host,
|
||||
remote_port=int(args.remote_port),
|
||||
remote_host=args.remote_host,
|
||||
remote_port=args.remote_port,
|
||||
remote_user=args.remote_user,
|
||||
remote_ssh_config=args.remote_ssh_config,
|
||||
dangerous=bool(args.dangerous),
|
||||
no_sudo=bool(args.no_sudo),
|
||||
include_paths=list(getattr(args, "include_path", []) or []),
|
||||
exclude_paths=list(getattr(args, "exclude_path", []) or []),
|
||||
allow_existing_output=not bool(args.out),
|
||||
)
|
||||
print(str(state))
|
||||
else:
|
||||
|
|
@ -663,23 +1037,64 @@ def main() -> None:
|
|||
dangerous=bool(args.dangerous),
|
||||
include_paths=list(getattr(args, "include_path", []) or []),
|
||||
exclude_paths=list(getattr(args, "exclude_path", []) or []),
|
||||
allow_existing_output=True,
|
||||
)
|
||||
_encrypt_harvest_dir_to_sops(
|
||||
tmp_bundle, out_file, list(sops_fps)
|
||||
)
|
||||
print(str(out_file))
|
||||
else:
|
||||
if not args.out:
|
||||
raise SystemExit(
|
||||
"error: --out is required unless --remote-host is set"
|
||||
if args.out:
|
||||
out_dir = args.out
|
||||
else:
|
||||
out_dir = (
|
||||
Path(args.out)
|
||||
if args.out
|
||||
else new_harvest_cache_dir(hint=args.remote_host).dir
|
||||
)
|
||||
path = harvest(
|
||||
args.out,
|
||||
out_dir,
|
||||
dangerous=bool(args.dangerous),
|
||||
include_paths=list(getattr(args, "include_path", []) or []),
|
||||
exclude_paths=list(getattr(args, "exclude_path", []) or []),
|
||||
allow_existing_output=not bool(args.out),
|
||||
)
|
||||
print(path)
|
||||
elif args.cmd == "explain":
|
||||
out = explain_state(
|
||||
args.harvest,
|
||||
sops_mode=bool(getattr(args, "sops", False)),
|
||||
fmt=str(getattr(args, "format", "text")),
|
||||
max_examples=int(getattr(args, "max_examples", 3)),
|
||||
)
|
||||
sys.stdout.write(out)
|
||||
|
||||
elif args.cmd == "validate":
|
||||
res = validate_harvest(
|
||||
args.harvest,
|
||||
sops_mode=bool(getattr(args, "sops", False)),
|
||||
schema=getattr(args, "schema", None),
|
||||
no_schema=bool(getattr(args, "no_schema", False)),
|
||||
allow_remote_schema=bool(getattr(args, "allow_remote_schema", False)),
|
||||
)
|
||||
|
||||
fmt = str(getattr(args, "format", "text"))
|
||||
if fmt == "json":
|
||||
txt = json.dumps(res.to_dict(), indent=2, sort_keys=True) + "\n"
|
||||
else:
|
||||
txt = res.to_text()
|
||||
|
||||
out_path = getattr(args, "out", None)
|
||||
if out_path:
|
||||
write_text_output_file(out_path, txt, label="validation report")
|
||||
else:
|
||||
sys.stdout.write(txt)
|
||||
|
||||
if res.errors:
|
||||
raise SystemExit(1)
|
||||
if res.warnings and bool(getattr(args, "fail_on_warnings", False)):
|
||||
raise SystemExit(1)
|
||||
|
||||
elif args.cmd == "manifest":
|
||||
out_enc = manifest(
|
||||
args.harvest,
|
||||
|
|
@ -687,6 +1102,7 @@ def main() -> None:
|
|||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
sops_fingerprints=getattr(args, "sops", None),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
)
|
||||
if getattr(args, "sops", None) and out_enc:
|
||||
print(str(out_enc))
|
||||
|
|
@ -695,14 +1111,16 @@ def main() -> None:
|
|||
args.old,
|
||||
args.new,
|
||||
sops_mode=bool(getattr(args, "sops", False)),
|
||||
exclude_paths=list(getattr(args, "exclude_path", []) or []),
|
||||
ignore_package_versions=bool(
|
||||
getattr(args, "ignore_package_versions", False)
|
||||
),
|
||||
)
|
||||
|
||||
txt = format_report(report, fmt=str(getattr(args, "format", "text")))
|
||||
out_path = getattr(args, "out", None)
|
||||
if out_path:
|
||||
p = Path(out_path).expanduser()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(txt, encoding="utf-8")
|
||||
write_text_output_file(out_path, txt, label="diff report")
|
||||
else:
|
||||
print(txt, end="" if txt.endswith("\n") else "\n")
|
||||
|
||||
|
|
@ -747,9 +1165,11 @@ def main() -> None:
|
|||
raise SystemExit(2)
|
||||
elif args.cmd == "single-shot":
|
||||
sops_fps = getattr(args, "sops", None)
|
||||
if remote_host:
|
||||
if args.remote_host:
|
||||
if sops_fps:
|
||||
out_file = _resolve_sops_out_file(args.harvest, hint=remote_host)
|
||||
out_file = _resolve_sops_out_file(
|
||||
args.harvest, hint=args.remote_host
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="enroll-harvest-") as td:
|
||||
tmp_bundle = Path(td) / "bundle"
|
||||
tmp_bundle.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -758,14 +1178,21 @@ def main() -> None:
|
|||
except OSError:
|
||||
pass
|
||||
remote_harvest(
|
||||
ask_become_pass=args.ask_become_pass,
|
||||
ask_key_passphrase=bool(args.ask_key_passphrase),
|
||||
ssh_key_passphrase_env=getattr(
|
||||
args, "ssh_key_passphrase_env", None
|
||||
),
|
||||
local_out_dir=tmp_bundle,
|
||||
remote_host=remote_host,
|
||||
remote_port=int(args.remote_port),
|
||||
remote_host=args.remote_host,
|
||||
remote_port=args.remote_port,
|
||||
remote_user=args.remote_user,
|
||||
remote_ssh_config=args.remote_ssh_config,
|
||||
dangerous=bool(args.dangerous),
|
||||
no_sudo=bool(args.no_sudo),
|
||||
include_paths=list(getattr(args, "include_path", []) or []),
|
||||
exclude_paths=list(getattr(args, "exclude_path", []) or []),
|
||||
allow_existing_output=True,
|
||||
)
|
||||
_encrypt_harvest_dir_to_sops(
|
||||
tmp_bundle, out_file, list(sops_fps)
|
||||
|
|
@ -777,6 +1204,7 @@ def main() -> None:
|
|||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
sops_fingerprints=list(sops_fps),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
)
|
||||
if not args.harvest:
|
||||
print(str(out_file))
|
||||
|
|
@ -784,23 +1212,31 @@ def main() -> None:
|
|||
harvest_dir = (
|
||||
Path(args.harvest)
|
||||
if args.harvest
|
||||
else new_harvest_cache_dir(hint=remote_host).dir
|
||||
else new_harvest_cache_dir(hint=args.remote_host).dir
|
||||
)
|
||||
remote_harvest(
|
||||
ask_become_pass=args.ask_become_pass,
|
||||
ask_key_passphrase=bool(args.ask_key_passphrase),
|
||||
ssh_key_passphrase_env=getattr(
|
||||
args, "ssh_key_passphrase_env", None
|
||||
),
|
||||
local_out_dir=harvest_dir,
|
||||
remote_host=remote_host,
|
||||
remote_port=int(args.remote_port),
|
||||
remote_host=args.remote_host,
|
||||
remote_port=args.remote_port,
|
||||
remote_user=args.remote_user,
|
||||
remote_ssh_config=args.remote_ssh_config,
|
||||
dangerous=bool(args.dangerous),
|
||||
no_sudo=bool(args.no_sudo),
|
||||
include_paths=list(getattr(args, "include_path", []) or []),
|
||||
exclude_paths=list(getattr(args, "exclude_path", []) or []),
|
||||
allow_existing_output=not bool(args.harvest),
|
||||
)
|
||||
manifest(
|
||||
str(harvest_dir),
|
||||
args.out,
|
||||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
)
|
||||
# For usability (when --harvest wasn't provided), print the harvest path.
|
||||
if not args.harvest:
|
||||
|
|
@ -820,6 +1256,7 @@ def main() -> None:
|
|||
dangerous=bool(args.dangerous),
|
||||
include_paths=list(getattr(args, "include_path", []) or []),
|
||||
exclude_paths=list(getattr(args, "exclude_path", []) or []),
|
||||
allow_existing_output=True,
|
||||
)
|
||||
_encrypt_harvest_dir_to_sops(
|
||||
tmp_bundle, out_file, list(sops_fps)
|
||||
|
|
@ -831,6 +1268,7 @@ def main() -> None:
|
|||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
sops_fingerprints=list(sops_fps),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
)
|
||||
if not args.harvest:
|
||||
print(str(out_file))
|
||||
|
|
@ -850,56 +1288,19 @@ def main() -> None:
|
|||
args.out,
|
||||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
)
|
||||
elif args.cmd == "diff":
|
||||
report, has_changes = compare_harvests(
|
||||
args.old, args.new, sops_mode=bool(getattr(args, "sops", False))
|
||||
)
|
||||
|
||||
rendered = format_report(report, fmt=str(args.format))
|
||||
if args.out:
|
||||
Path(args.out).expanduser().write_text(rendered, encoding="utf-8")
|
||||
else:
|
||||
print(rendered, end="")
|
||||
|
||||
do_notify = bool(has_changes or getattr(args, "notify_always", False))
|
||||
|
||||
if do_notify and getattr(args, "webhook", None):
|
||||
wf = str(getattr(args, "webhook_format", "json"))
|
||||
body = format_report(report, fmt=wf).encode("utf-8")
|
||||
headers = {"User-Agent": "enroll"}
|
||||
if wf == "json":
|
||||
headers["Content-Type"] = "application/json"
|
||||
else:
|
||||
headers["Content-Type"] = "text/plain; charset=utf-8"
|
||||
for hv in getattr(args, "webhook_header", []) or []:
|
||||
if ":" not in hv:
|
||||
raise SystemExit(
|
||||
"error: --webhook-header must be in the form 'K:V'"
|
||||
)
|
||||
k, v = hv.split(":", 1)
|
||||
headers[k.strip()] = v.strip()
|
||||
status, _ = post_webhook(str(args.webhook), body, headers=headers)
|
||||
if status and status >= 400:
|
||||
raise SystemExit(f"error: webhook returned HTTP {status}")
|
||||
|
||||
if do_notify and (getattr(args, "email_to", []) or []):
|
||||
subject = getattr(args, "email_subject", None) or "enroll diff report"
|
||||
smtp_password = None
|
||||
pw_env = getattr(args, "smtp_password_env", None)
|
||||
if pw_env:
|
||||
smtp_password = os.environ.get(str(pw_env))
|
||||
send_email(
|
||||
to_addrs=list(getattr(args, "email_to", []) or []),
|
||||
subject=str(subject),
|
||||
body=rendered,
|
||||
from_addr=getattr(args, "email_from", None),
|
||||
smtp=getattr(args, "smtp", None),
|
||||
smtp_user=getattr(args, "smtp_user", None),
|
||||
smtp_password=smtp_password,
|
||||
)
|
||||
|
||||
if getattr(args, "exit_code", False) and has_changes:
|
||||
raise SystemExit(2)
|
||||
except RemoteSudoPasswordRequired:
|
||||
raise SystemExit(
|
||||
"error: remote sudo requires a password. Re-run with --ask-become-pass."
|
||||
) from None
|
||||
except RemoteSSHKeyPassphraseRequired as e:
|
||||
msg = str(e).strip() or (
|
||||
"SSH private key passphrase is required. "
|
||||
"Re-run with --ask-key-passphrase or --ssh-key-passphrase-env VAR."
|
||||
)
|
||||
raise SystemExit(f"error: {msg}") from None
|
||||
except RuntimeError as e:
|
||||
raise SystemExit(f"error: {e}") from None
|
||||
except SopsError as e:
|
||||
raise SystemExit(f"error: {e}")
|
||||
raise SystemExit(f"error: {e}") from None
|
||||
|
|
|
|||
933
enroll/cm.py
Normal file
933
enroll/cm.py
Normal file
|
|
@ -0,0 +1,933 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Dict,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
Mapping,
|
||||
Set,
|
||||
)
|
||||
|
||||
from .state import load_state, state_path, write_state
|
||||
|
||||
|
||||
@dataclass
|
||||
class CMModule:
|
||||
"""Renderer-neutral configuration-management resource group.
|
||||
|
||||
A CMModule is intentionally small: it captures the resources that the
|
||||
renderer turns into Ansible tasks. The renderer may still decide how to
|
||||
name/include/order the group.
|
||||
"""
|
||||
|
||||
role_name: str
|
||||
module_name: str
|
||||
packages: Set[str] = field(default_factory=set)
|
||||
groups: Set[str] = field(default_factory=set)
|
||||
users: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
dirs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
files: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
links: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
services: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
firewall_runtime: Dict[str, Any] = field(default_factory=dict)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
managed_owner_attr: ClassVar[str] = "owner"
|
||||
firewall_runtime_dir: ClassVar[str] = "/etc/enroll/firewall"
|
||||
firewall_runtime_artifacts: ClassVar[tuple[tuple[str, str, str], ...]] = (
|
||||
("ipset_save", "ipset.save", "0600"),
|
||||
("iptables_v4_save", "iptables.v4", "0600"),
|
||||
("iptables_v6_save", "iptables.v6", "0600"),
|
||||
)
|
||||
|
||||
def has_core_resources(self) -> bool:
|
||||
return bool(
|
||||
self.packages
|
||||
or self.groups
|
||||
or self.users
|
||||
or self.dirs
|
||||
or self.files
|
||||
or self.links
|
||||
or self.services
|
||||
or self.firewall_runtime
|
||||
or self.notes
|
||||
)
|
||||
|
||||
def has_resources(self) -> bool:
|
||||
return self.has_core_resources()
|
||||
|
||||
def has_resources_or_attrs(self, *attrs: str) -> bool:
|
||||
"""Return true if core resources or named renderer extras are present."""
|
||||
|
||||
return self.has_core_resources() or any(
|
||||
bool(getattr(self, attr, None)) for attr in attrs
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def state_path(bundle_dir: str | Path) -> Path:
|
||||
"""Return the canonical state.json path for a harvest bundle."""
|
||||
|
||||
return state_path(bundle_dir)
|
||||
|
||||
@classmethod
|
||||
def load_state(cls, bundle_dir: str | Path) -> Dict[str, Any]:
|
||||
"""Load state.json for a renderer using the shared bundle state loader."""
|
||||
|
||||
return load_state(bundle_dir)
|
||||
|
||||
@classmethod
|
||||
def _load_state(cls, bundle_dir: str | Path) -> Dict[str, Any]:
|
||||
"""Backward-compatible alias for renderer subclasses."""
|
||||
|
||||
return cls.load_state(bundle_dir)
|
||||
|
||||
@classmethod
|
||||
def write_state(
|
||||
cls,
|
||||
bundle_dir: str | Path,
|
||||
state: Mapping[str, Any],
|
||||
*,
|
||||
indent: int = 2,
|
||||
sort_keys: bool = True,
|
||||
) -> Path:
|
||||
"""Write state.json using the shared bundle state writer."""
|
||||
|
||||
return write_state(bundle_dir, state, indent=indent, sort_keys=sort_keys)
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_items(snap: Dict[str, Any], key: str) -> Iterator[Dict[str, Any]]:
|
||||
values = snap.get(key) or []
|
||||
if not isinstance(values, list):
|
||||
return
|
||||
for item in values:
|
||||
if isinstance(item, dict):
|
||||
yield item
|
||||
|
||||
@classmethod
|
||||
def managed_dirs_from_snapshot(
|
||||
cls, snap: Dict[str, Any]
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
return cls._snapshot_items(snap, "managed_dirs")
|
||||
|
||||
@classmethod
|
||||
def managed_files_from_snapshot(
|
||||
cls, snap: Dict[str, Any]
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
return cls._snapshot_items(snap, "managed_files")
|
||||
|
||||
@classmethod
|
||||
def managed_links_from_snapshot(
|
||||
cls, snap: Dict[str, Any]
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
return cls._snapshot_items(snap, "managed_links")
|
||||
|
||||
def add_managed_dir(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
owner: Any = "root",
|
||||
group: Any = "root",
|
||||
mode: Any = "0755",
|
||||
**attrs: Any,
|
||||
) -> None:
|
||||
if not path:
|
||||
return
|
||||
data: Dict[str, Any] = {
|
||||
"owner": owner or "root",
|
||||
"group": group or "root",
|
||||
"mode": mode or "0755",
|
||||
}
|
||||
data.update(attrs)
|
||||
self.dirs.setdefault(path, data)
|
||||
|
||||
def add_managed_file(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
owner: Any = "root",
|
||||
group: Any = "root",
|
||||
mode: Any = "0644",
|
||||
**attrs: Any,
|
||||
) -> None:
|
||||
if not path:
|
||||
return
|
||||
data: Dict[str, Any] = {
|
||||
"owner": owner or "root",
|
||||
"group": group or "root",
|
||||
"mode": mode or "0644",
|
||||
}
|
||||
data.update(attrs)
|
||||
self.files.setdefault(path, data)
|
||||
|
||||
def add_managed_link(self, path: str, **attrs: Any) -> None:
|
||||
if path:
|
||||
self.links.setdefault(path, attrs)
|
||||
|
||||
def add_snapshot_notes(self, snap: Dict[str, Any]) -> None:
|
||||
self.notes.extend(str(n) for n in (snap.get("notes", []) or []))
|
||||
|
||||
@staticmethod
|
||||
def package_name_from_snapshot(snap: Dict[str, Any]) -> str:
|
||||
return str(snap.get("package") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def package_names_from_snapshot(snap: Dict[str, Any]) -> Iterator[str]:
|
||||
for pkg in snap.get("packages", []) or []:
|
||||
pkg_s = str(pkg or "").strip()
|
||||
if pkg_s:
|
||||
yield pkg_s
|
||||
|
||||
def add_package_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
pkg = self.package_name_from_snapshot(snap)
|
||||
if pkg:
|
||||
self.packages.add(pkg)
|
||||
|
||||
def add_service_packages_from_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
self.packages.update(self.package_names_from_snapshot(snap))
|
||||
|
||||
def service_unit_from_snapshot(self, snap: Dict[str, Any]) -> str:
|
||||
return str(snap.get("unit") or "").strip()
|
||||
|
||||
def service_enabled_from_snapshot(self, snap: Dict[str, Any]) -> bool:
|
||||
unit_file_state = str(snap.get("unit_file_state") or "")
|
||||
return unit_file_state in ("enabled", "enabled-runtime")
|
||||
|
||||
def service_state_from_snapshot(
|
||||
self,
|
||||
snap: Dict[str, Any],
|
||||
*,
|
||||
running: str,
|
||||
stopped: str,
|
||||
) -> str:
|
||||
return running if snap.get("active_state") == "active" else stopped
|
||||
|
||||
def add_service_snapshot_state(
|
||||
self,
|
||||
snap: Dict[str, Any],
|
||||
*,
|
||||
state_key: str,
|
||||
running: str,
|
||||
stopped: str,
|
||||
include_manage: bool = False,
|
||||
) -> None:
|
||||
"""Add the common systemd service parts, parameterised per renderer."""
|
||||
|
||||
self.add_service_packages_from_snapshot(snap)
|
||||
unit = self.service_unit_from_snapshot(snap)
|
||||
if not unit:
|
||||
return
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"name": unit,
|
||||
state_key: self.service_state_from_snapshot(
|
||||
snap, running=running, stopped=stopped
|
||||
),
|
||||
"enable": self.service_enabled_from_snapshot(snap),
|
||||
}
|
||||
if include_manage:
|
||||
data["manage"] = True
|
||||
self.services[unit] = data
|
||||
|
||||
@staticmethod
|
||||
def normalise_flatpak_item(
|
||||
item: Any,
|
||||
*,
|
||||
method: str,
|
||||
user: str | None = None,
|
||||
home: str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
if isinstance(item, dict):
|
||||
out = dict(item)
|
||||
elif isinstance(item, str):
|
||||
out = {"name": item}
|
||||
else:
|
||||
out = {"name": str(item)}
|
||||
|
||||
out["method"] = str(out.get("method") or method or "system").strip() or "system"
|
||||
if user and not out.get("user"):
|
||||
out["user"] = user
|
||||
if home and not out.get("home"):
|
||||
out["home"] = home
|
||||
ref = str(out.get("ref") or "").strip()
|
||||
if ref and not out.get("name"):
|
||||
out["name"] = ref.rsplit("/", 1)[-1]
|
||||
name = str(out.get("name") or out.get("app_id") or "").strip()
|
||||
if name:
|
||||
out["name"] = name
|
||||
remote = str(out.get("remote") or "").strip()
|
||||
if remote:
|
||||
out["remote"] = remote
|
||||
branch = str(out.get("branch") or out.get("origin") or "").strip()
|
||||
if branch:
|
||||
out["branch"] = branch
|
||||
if ref:
|
||||
out["ref"] = ref
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def normalise_flatpak_remote(item: Any) -> Dict[str, Any]:
|
||||
if isinstance(item, dict):
|
||||
out = dict(item)
|
||||
else:
|
||||
out = {"name": str(item)}
|
||||
name = str(out.get("name") or out.get("remote") or "").strip()
|
||||
url = str(out.get("url") or out.get("from_url") or "").strip()
|
||||
method = (
|
||||
str(out.get("method") or out.get("scope") or "system").strip() or "system"
|
||||
)
|
||||
if name:
|
||||
out["name"] = name
|
||||
if url:
|
||||
out["url"] = url
|
||||
out["method"] = "user" if method == "user" else "system"
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def normalise_snap_item(item: Any) -> Dict[str, Any]:
|
||||
if isinstance(item, dict):
|
||||
out = dict(item)
|
||||
elif isinstance(item, str):
|
||||
out = {"name": item}
|
||||
else:
|
||||
out = {"name": str(item)}
|
||||
|
||||
name = str(out.get("name") or "").strip()
|
||||
if name:
|
||||
out["name"] = name
|
||||
channel = str(out.get("tracking") or out.get("channel") or "").strip()
|
||||
if channel:
|
||||
out["channel"] = channel
|
||||
raw_notes = out.get("notes") or []
|
||||
if isinstance(raw_notes, str):
|
||||
raw_notes = [raw_notes]
|
||||
notes = [str(note).lower() for note in raw_notes]
|
||||
confinement = str(out.get("confinement") or "").strip().lower()
|
||||
out["classic"] = bool(
|
||||
out.get("classic")
|
||||
or confinement == "classic"
|
||||
or any("classic" in note for note in notes)
|
||||
)
|
||||
out["devmode"] = bool(
|
||||
out.get("devmode")
|
||||
or any("devmode" in note or "dev mode" in note for note in notes)
|
||||
)
|
||||
out["dangerous"] = bool(
|
||||
out.get("dangerous") or any("dangerous" in note for note in notes)
|
||||
)
|
||||
revision = str(out.get("revision") or "").strip()
|
||||
if revision and not channel:
|
||||
out["revision"] = revision
|
||||
return out
|
||||
|
||||
def prepare_flatpak_remote(self, item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
def prepare_flatpak_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
def prepare_snap_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def user_records_from_snapshot(snap: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
records: List[Dict[str, Any]] = []
|
||||
for raw in snap.get("users", []) or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
name = str(raw.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
primary_group = str(raw.get("primary_group") or name).strip()
|
||||
supplementary = sorted(
|
||||
{
|
||||
str(group).strip()
|
||||
for group in (raw.get("supplementary_groups") or [])
|
||||
if str(group).strip()
|
||||
}
|
||||
)
|
||||
records.append(
|
||||
{
|
||||
"name": name,
|
||||
"uid": raw.get("uid"),
|
||||
"gid": raw.get("gid"),
|
||||
"primary_group": primary_group,
|
||||
"home": raw.get("home") or f"/home/{name}",
|
||||
"shell": raw.get("shell"),
|
||||
"gecos": raw.get("gecos"),
|
||||
"supplementary_groups": supplementary,
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
@staticmethod
|
||||
def user_group_names_from_records(records: Iterable[Mapping[str, Any]]) -> Set[str]:
|
||||
groups: Set[str] = set()
|
||||
for record in records:
|
||||
primary_group = str(record.get("primary_group") or "").strip()
|
||||
if primary_group:
|
||||
groups.add(primary_group)
|
||||
groups.update(
|
||||
str(group).strip()
|
||||
for group in (record.get("supplementary_groups") or [])
|
||||
if str(group).strip()
|
||||
)
|
||||
return groups
|
||||
|
||||
@staticmethod
|
||||
def package_service_entries(
|
||||
roles: Mapping[str, Any],
|
||||
inventory_packages: Mapping[str, Any],
|
||||
*,
|
||||
use_common_roles: bool,
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
for svc in roles.get("services", []) or []:
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
own_label = str(svc.get("role_name") or svc.get("unit") or "service")
|
||||
role_label = (
|
||||
section_label_for_packages(
|
||||
svc.get("packages", []) or [], inventory_packages
|
||||
)
|
||||
if use_common_roles
|
||||
else own_label
|
||||
)
|
||||
yield {"kind": "service", "snapshot": svc, "role_label": role_label}
|
||||
|
||||
for pkg in roles.get("packages", []) or []:
|
||||
if not isinstance(pkg, dict):
|
||||
continue
|
||||
own_label = str(pkg.get("role_name") or pkg.get("package") or "package")
|
||||
role_label = (
|
||||
package_section_label(pkg, inventory_packages)
|
||||
if use_common_roles
|
||||
else own_label
|
||||
)
|
||||
yield {"kind": "package", "snapshot": pkg, "role_label": role_label}
|
||||
|
||||
@staticmethod
|
||||
def active_service_units_by_package(
|
||||
entries: Iterable[Mapping[str, Any]],
|
||||
) -> Dict[str, List[Dict[str, str]]]:
|
||||
"""Return active service units keyed by the packages that produced them.
|
||||
|
||||
Renderers use this when a package-owned managed file should refresh the
|
||||
service that package provides. The helper is deliberately conservative:
|
||||
stopped/inactive services are not included, and ambiguous package->many
|
||||
service mappings are left to the renderer/caller to resolve.
|
||||
"""
|
||||
|
||||
by_package: Dict[str, List[Dict[str, str]]] = {}
|
||||
for entry in entries:
|
||||
if str(entry.get("kind") or "package") != "service":
|
||||
continue
|
||||
snap = entry.get("snapshot") or {}
|
||||
if not isinstance(snap, Mapping):
|
||||
continue
|
||||
unit = str(snap.get("unit") or "").strip()
|
||||
if not unit or str(snap.get("active_state") or "") != "active":
|
||||
continue
|
||||
role_name = str(snap.get("role_name") or unit).strip()
|
||||
for pkg in snap.get("packages", []) or []:
|
||||
package = str(pkg or "").strip()
|
||||
if package:
|
||||
by_package.setdefault(package, []).append(
|
||||
{"unit": unit, "role_name": role_name}
|
||||
)
|
||||
for package, services in list(by_package.items()):
|
||||
seen: Set[str] = set()
|
||||
unique: List[Dict[str, str]] = []
|
||||
for svc in services:
|
||||
unit = svc.get("unit") or ""
|
||||
if unit and unit not in seen:
|
||||
seen.add(unit)
|
||||
unique.append(svc)
|
||||
by_package[package] = sorted(unique, key=lambda svc: svc.get("unit", ""))
|
||||
return by_package
|
||||
|
||||
@staticmethod
|
||||
def active_service_units_for_package_snapshot(
|
||||
package_snapshot: Mapping[str, Any],
|
||||
service_units_by_package: Mapping[str, List[Dict[str, str]]],
|
||||
) -> List[str]:
|
||||
"""Return active service units that a package snapshot can safely refresh.
|
||||
|
||||
If one active service is associated with the package, return it. If
|
||||
several are associated, only return a role-name match; otherwise avoid
|
||||
guessing and return no services. This prevents package-level config from
|
||||
recreating the old broad-restart problem.
|
||||
"""
|
||||
|
||||
package = str(package_snapshot.get("package") or "").strip()
|
||||
if not package:
|
||||
return []
|
||||
services = list(service_units_by_package.get(package) or [])
|
||||
if len(services) == 1:
|
||||
unit = services[0].get("unit") or ""
|
||||
return [unit] if unit else []
|
||||
|
||||
role_name = str(package_snapshot.get("role_name") or "").strip()
|
||||
if role_name:
|
||||
matched = [
|
||||
svc.get("unit") or ""
|
||||
for svc in services
|
||||
if svc.get("role_name") == role_name and svc.get("unit")
|
||||
]
|
||||
if matched:
|
||||
return sorted(set(matched))
|
||||
return []
|
||||
|
||||
def add_user_flatpaks_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
home_by_user = {
|
||||
str(u.get("name")): str(u.get("home") or "")
|
||||
for u in (snap.get("users", []) or [])
|
||||
if isinstance(u, dict) and u.get("name")
|
||||
}
|
||||
for remote in snap.get("user_flatpak_remotes", []) or []:
|
||||
item = self.normalise_flatpak_remote(remote)
|
||||
user = str(item.get("user") or "").strip()
|
||||
if user and not item.get("home"):
|
||||
item["home"] = home_by_user.get(user) or f"/home/{user}"
|
||||
if item.get("method") == "user" and item.get("name") and item.get("url"):
|
||||
self.flatpak_remotes.append( # type: ignore[attr-defined]
|
||||
self.prepare_flatpak_remote(item)
|
||||
)
|
||||
for uname, flatpaks in (snap.get("user_flatpaks", {}) or {}).items():
|
||||
user = str(uname)
|
||||
for fp in flatpaks or []:
|
||||
item = self.normalise_flatpak_item(
|
||||
fp, method="user", user=user, home=home_by_user.get(user) or None
|
||||
)
|
||||
if item.get("name"):
|
||||
self.flatpaks.append( # type: ignore[attr-defined]
|
||||
self.prepare_flatpak_item(item)
|
||||
)
|
||||
|
||||
def add_flatpak_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
for remote in snap.get("remotes", []) or []:
|
||||
item = self.normalise_flatpak_remote(remote)
|
||||
if item.get("name") and item.get("url"):
|
||||
self.flatpak_remotes.append( # type: ignore[attr-defined]
|
||||
self.prepare_flatpak_remote(item)
|
||||
)
|
||||
for fp in snap.get("system_flatpaks", []) or []:
|
||||
item = self.normalise_flatpak_item(fp, method="system")
|
||||
if item.get("name"):
|
||||
self.flatpaks.append( # type: ignore[attr-defined]
|
||||
self.prepare_flatpak_item(item)
|
||||
)
|
||||
self.add_snapshot_notes(snap)
|
||||
|
||||
def add_snap_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
for raw in snap.get("system_snaps", []) or []:
|
||||
item = self.normalise_snap_item(raw)
|
||||
if item.get("name"):
|
||||
self.snaps.append( # type: ignore[attr-defined]
|
||||
self.prepare_snap_item(item)
|
||||
)
|
||||
self.add_snapshot_notes(snap)
|
||||
|
||||
def firewall_runtime_snapshot_has_artifacts(self, snap: Mapping[str, Any]) -> bool:
|
||||
return any(
|
||||
str(snap.get(key) or "").strip()
|
||||
for key, _dest, _mode in self.firewall_runtime_artifacts
|
||||
)
|
||||
|
||||
def firewall_runtime_source_refs(self, snap: Mapping[str, Any]) -> Dict[str, str]:
|
||||
return {
|
||||
key: str(snap.get(key) or "").strip()
|
||||
for key, _dest, _mode in self.firewall_runtime_artifacts
|
||||
if str(snap.get(key) or "").strip()
|
||||
}
|
||||
|
||||
def firewall_runtime_dest_path(self, dest_name: str) -> str:
|
||||
return f"{self.firewall_runtime_dir}/{dest_name}"
|
||||
|
||||
def firewall_runtime_ipset_sets(self, snap: Mapping[str, Any]) -> List[str]:
|
||||
return [
|
||||
str(x).strip() for x in (snap.get("ipset_sets") or []) if str(x).strip()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def shell_quote(value: Any) -> str:
|
||||
return shlex.quote(str(value or ""))
|
||||
|
||||
def firewall_ipset_restore_cmd(self, path: str, sets: List[str]) -> str:
|
||||
flush_parts = [f"ipset flush {self.shell_quote(name)} || true" for name in sets]
|
||||
flush = "; ".join(flush_parts)
|
||||
restore = f"ipset restore -exist < {self.shell_quote(path)}"
|
||||
if flush:
|
||||
return f"/bin/sh -c {self.shell_quote(flush + '; ' + restore)}"
|
||||
return f"/bin/sh -c {self.shell_quote(restore)}"
|
||||
|
||||
def firewall_runtime_commands(self, runtime: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {}
|
||||
ipset_path = str(runtime.get("ipset_save") or "")
|
||||
if ipset_path:
|
||||
sets = [str(x) for x in (runtime.get("ipset_sets") or []) if str(x)]
|
||||
out["ipset_restore_cmd"] = self.firewall_ipset_restore_cmd(ipset_path, sets)
|
||||
ipt4_path = str(runtime.get("iptables_v4_save") or "")
|
||||
if ipt4_path:
|
||||
out["iptables_v4_restore_cmd"] = (
|
||||
f"iptables-restore {self.shell_quote(ipt4_path)}"
|
||||
)
|
||||
ipt6_path = str(runtime.get("iptables_v6_save") or "")
|
||||
if ipt6_path:
|
||||
out["iptables_v6_restore_cmd"] = (
|
||||
f"ip6tables-restore {self.shell_quote(ipt6_path)}"
|
||||
)
|
||||
return out
|
||||
|
||||
def _managed_owner_attrs(self, owner: Any) -> Dict[str, Any]:
|
||||
return {self.managed_owner_attr: owner or "root"}
|
||||
|
||||
def add_firewall_runtime_snapshot(
|
||||
self,
|
||||
snap: Dict[str, Any],
|
||||
*,
|
||||
bundle_dir: str,
|
||||
artifact_role: str,
|
||||
files_dir: Path,
|
||||
copy_artifact: Callable[..., str | None],
|
||||
source_uri: Callable[[str, str], str],
|
||||
file_prefix: str | None = None,
|
||||
dir_attrs: Mapping[str, Any] | None = None,
|
||||
file_attrs: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Add captured live firewall state using renderer-supplied file hooks."""
|
||||
|
||||
self.add_service_packages_from_snapshot(snap)
|
||||
attrs: Dict[str, Any] = {
|
||||
**self._managed_owner_attrs("root"),
|
||||
"group": "root",
|
||||
"mode": "0750",
|
||||
"reason": "firewall_runtime",
|
||||
}
|
||||
if dir_attrs:
|
||||
attrs.update(dir_attrs)
|
||||
self.add_managed_dir(self.firewall_runtime_dir, **attrs)
|
||||
|
||||
runtime: Dict[str, Any] = {}
|
||||
for key, dest_name, mode in self.firewall_runtime_artifacts:
|
||||
src_rel = str(snap.get(key) or "").strip()
|
||||
if not src_rel:
|
||||
continue
|
||||
role_rel = copy_artifact(
|
||||
bundle_dir,
|
||||
artifact_role,
|
||||
src_rel,
|
||||
files_dir,
|
||||
dst_prefix=file_prefix,
|
||||
)
|
||||
if not role_rel:
|
||||
self.notes.append(
|
||||
f"Firewall runtime artifact {src_rel!r} was referenced but not found."
|
||||
)
|
||||
continue
|
||||
file_data: Dict[str, Any] = {
|
||||
**self._managed_owner_attrs("root"),
|
||||
"group": "root",
|
||||
"mode": mode,
|
||||
"source": source_uri(self.module_name, role_rel),
|
||||
"reason": "firewall_runtime",
|
||||
}
|
||||
if file_attrs:
|
||||
file_data.update(file_attrs)
|
||||
dest = self.firewall_runtime_dest_path(dest_name)
|
||||
self.add_managed_file(dest, **file_data)
|
||||
runtime[key] = dest
|
||||
|
||||
ipset_sets = self.firewall_runtime_ipset_sets(snap)
|
||||
if ipset_sets:
|
||||
runtime["ipset_sets"] = ipset_sets
|
||||
if runtime:
|
||||
runtime.update(self.firewall_runtime_commands(runtime))
|
||||
self.firewall_runtime.update(runtime)
|
||||
self.add_snapshot_notes(snap)
|
||||
|
||||
def remove_directory_resource_conflicts(self) -> None:
|
||||
for path in set(self.files) | set(self.links):
|
||||
self.dirs.pop(path, None)
|
||||
|
||||
|
||||
def package_section_label(
|
||||
package_role: Dict[str, Any], inventory_packages: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Return the Debian Section/RPM Group label for a package role."""
|
||||
|
||||
pkg = str(package_role.get("package") or "").strip()
|
||||
inv = inventory_packages.get(pkg) or {}
|
||||
candidates: List[str] = []
|
||||
|
||||
for value in (package_role.get("section"), inv.get("section"), inv.get("group")):
|
||||
if isinstance(value, str) and value.strip():
|
||||
candidates.append(value.strip())
|
||||
|
||||
for inst in inv.get("installations", []) or []:
|
||||
if not isinstance(inst, dict):
|
||||
continue
|
||||
for key in ("section", "group"):
|
||||
value = inst.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
candidates.append(value.strip())
|
||||
|
||||
for value in candidates:
|
||||
if value.lower() not in {"(none)", "none", "unspecified"}:
|
||||
return value
|
||||
return "misc"
|
||||
|
||||
|
||||
def section_label_for_packages(
|
||||
packages: List[str], inventory_packages: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Return a stable section/group label for a set of packages."""
|
||||
|
||||
for pkg in packages or []:
|
||||
label = package_section_label({"package": pkg}, inventory_packages)
|
||||
if label and label.lower() != "misc":
|
||||
return label
|
||||
return "misc"
|
||||
|
||||
|
||||
def role_order_key(role: str) -> tuple[int, str]:
|
||||
# Keep broadly similar ordering to generated Ansible playbooks: package/config
|
||||
# scaffolding first, then services/users, then host-specific runtime state.
|
||||
priority = {
|
||||
"apt_config": 10,
|
||||
"dnf_config": 11,
|
||||
"etc_custom": 80,
|
||||
"usr_local_custom": 81,
|
||||
"extra_paths": 82,
|
||||
"container_images": 88,
|
||||
"users": 90,
|
||||
"enroll_runtime": 94,
|
||||
"sysctl": 95,
|
||||
"firewall_runtime": 99,
|
||||
}
|
||||
return (priority.get(role, 50), role)
|
||||
|
||||
|
||||
# Control characters (excluding ordinary tab) that must never reach generated
|
||||
# documentation. A raw newline/carriage return in a harvested value would let it
|
||||
# break out of a Markdown list item or code span and inject new document
|
||||
# structure (a fake heading, a misleading link/command block); other C0/C1
|
||||
# control bytes can smuggle terminal escape sequences when the README is printed.
|
||||
_MARKDOWN_CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
||||
|
||||
|
||||
def sanitize_markdown_text(value: Any) -> str:
|
||||
"""Neutralise harvested text before it is spliced into generated Markdown.
|
||||
|
||||
Generated docs (the Ansible ``README.md``) embed harvested, attacker-
|
||||
influenceable values such as the host name and captured file paths. These
|
||||
are not executed by Ansible, but a value containing a newline, carriage
|
||||
return, backtick, or control byte could otherwise break out of its
|
||||
surrounding list item / code span and inject misleading Markdown structure
|
||||
(a forged heading, a deceptive ``[link](...)``/command block) or a terminal
|
||||
escape sequence when the file is viewed. This collapses any whitespace run
|
||||
(including newlines and tabs) to a single space, drops other control bytes,
|
||||
and replaces backticks with a similar-looking single quote so a value can
|
||||
never escape an inline code span. It is deliberately lossy: the README is a
|
||||
human-readable summary, and faithful representation of hostile bytes there
|
||||
is not a goal.
|
||||
"""
|
||||
|
||||
text = str(value)
|
||||
# Collapse any run of whitespace (newlines, CR, tabs, spaces) to one space so
|
||||
# a harvested value stays on a single Markdown line / inside one code span.
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
# Drop remaining control characters that survived the whitespace collapse.
|
||||
text = _MARKDOWN_CONTROL_RE.sub("", text)
|
||||
# A backtick would close an inline code span and let following characters be
|
||||
# interpreted as Markdown; swap it for a visually-similar acute accent.
|
||||
text = text.replace("`", "\u00b4")
|
||||
return text.strip()
|
||||
|
||||
|
||||
def sanitize_report_text(value: Any) -> str:
|
||||
"""Neutralise harvested text before it is spliced into a plaintext report.
|
||||
|
||||
The ``enroll diff`` text/markdown reports embed harvested, attacker-
|
||||
influenceable values (file paths, owners, groups, link targets, host names,
|
||||
metadata old/new values). Even in the non-Markdown text report a raw
|
||||
newline or carriage return in such a value would let it forge additional
|
||||
report lines (e.g. a fake "No differences detected." line or a spoofed
|
||||
package/file entry), and other C0/C1 control bytes could smuggle terminal
|
||||
escape sequences when the report is printed or piped to a notification
|
||||
channel.
|
||||
|
||||
This collapses any whitespace run (including newlines and tabs) to a single
|
||||
space and drops other control bytes. Unlike :func:`sanitize_markdown_text`
|
||||
it does not rewrite backticks, since the plaintext report does not use
|
||||
Markdown code spans. It is deliberately lossy: the report is a
|
||||
human-readable summary, not a faithful byte-for-byte rendering of hostile
|
||||
input.
|
||||
"""
|
||||
|
||||
text = str(value)
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
text = _MARKDOWN_CONTROL_RE.sub("", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def markdown_list(items: Iterable[Any], *, empty: str = "None.") -> str:
|
||||
"""Render already-composed Markdown list lines.
|
||||
|
||||
Callers that embed harvested values (``snapshot_note_lines``,
|
||||
``snapshot_excluded_lines``, ``path_reason_lines``) sanitise those values
|
||||
with :func:`sanitize_markdown_text` before composing each line, so this
|
||||
helper only joins lines it is given. It still drops empty entries.
|
||||
"""
|
||||
|
||||
values = [str(item) for item in items if str(item).strip()]
|
||||
return "\n".join(f"- {item}" for item in values) or f"- {empty}"
|
||||
|
||||
|
||||
def path_reason_lines(
|
||||
items: Iterable[Mapping[str, Any]], *, source_key: str = "path"
|
||||
) -> List[str]:
|
||||
lines: List[str] = []
|
||||
for item in items or []:
|
||||
path = sanitize_markdown_text(item.get(source_key) or "")
|
||||
if not path:
|
||||
continue
|
||||
reason = sanitize_markdown_text(item.get("reason") or "")
|
||||
lines.append(f"{path} ({reason})" if reason else path)
|
||||
return lines
|
||||
|
||||
|
||||
def iter_role_snapshots(roles: Mapping[str, Any]) -> Iterator[Mapping[str, Any]]:
|
||||
for value in roles.values():
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, Mapping):
|
||||
yield item
|
||||
elif isinstance(value, Mapping):
|
||||
yield value
|
||||
|
||||
|
||||
def snapshot_note_lines(roles: Mapping[str, Any]) -> List[str]:
|
||||
notes: List[str] = []
|
||||
for snap in iter_role_snapshots(roles):
|
||||
source = sanitize_markdown_text(
|
||||
snap.get("role_name") or snap.get("unit") or snap.get("package") or "role"
|
||||
)
|
||||
notes.extend(
|
||||
f"`{source}`: {sanitize_markdown_text(note)}"
|
||||
for note in snap.get("notes", []) or []
|
||||
)
|
||||
return notes
|
||||
|
||||
|
||||
def snapshot_excluded_lines(roles: Mapping[str, Any]) -> List[str]:
|
||||
excluded: List[str] = []
|
||||
for snap in iter_role_snapshots(roles):
|
||||
source = sanitize_markdown_text(
|
||||
snap.get("role_name") or snap.get("unit") or snap.get("package") or "role"
|
||||
)
|
||||
for line in path_reason_lines(snap.get("excluded", []) or []):
|
||||
excluded.append(f"`{source}`: {line}")
|
||||
return excluded
|
||||
|
||||
|
||||
def _drop_duplicate_set_items(
|
||||
module: CMModule,
|
||||
values: Set[str],
|
||||
seen: Set[str],
|
||||
resource_type: str,
|
||||
) -> Set[str]:
|
||||
kept: Set[str] = set()
|
||||
for value in sorted(values):
|
||||
if value in seen:
|
||||
module.notes.append(
|
||||
f"Skipped duplicate {resource_type}[{value}] already emitted earlier in this catalog."
|
||||
)
|
||||
continue
|
||||
kept.add(value)
|
||||
seen.add(value)
|
||||
return kept
|
||||
|
||||
|
||||
def _drop_duplicate_mapping_items(
|
||||
module: CMModule,
|
||||
values: Dict[str, Dict[str, Any]],
|
||||
seen: Set[str],
|
||||
resource_type: str,
|
||||
*,
|
||||
excluded_titles: Set[str] | None = None,
|
||||
excluded_reason: str = "conflicts with another resource",
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
kept: Dict[str, Dict[str, Any]] = {}
|
||||
excluded_titles = excluded_titles or set()
|
||||
for title, attrs in values.items():
|
||||
if title in excluded_titles:
|
||||
module.notes.append(f"Skipped {resource_type}[{title}]: {excluded_reason}.")
|
||||
continue
|
||||
if title in seen:
|
||||
module.notes.append(
|
||||
f"Skipped duplicate {resource_type}[{title}] already emitted earlier in this catalog."
|
||||
)
|
||||
continue
|
||||
kept[title] = attrs
|
||||
seen.add(title)
|
||||
return kept
|
||||
|
||||
|
||||
def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None:
|
||||
"""Resolve global catalog conflicts in the shared model.
|
||||
|
||||
Deduplicates the same package, service, or parent directory appearing in
|
||||
more than one role. The Ansible renderer tolerates such duplicates, but this
|
||||
helper remains available for any catalog-style consumer of the shared model.
|
||||
"""
|
||||
|
||||
ordered = list(modules)
|
||||
concrete_file_paths: Set[str] = set()
|
||||
for module in ordered:
|
||||
concrete_file_paths.update(module.files)
|
||||
concrete_file_paths.update(module.links)
|
||||
|
||||
seen_packages: Set[str] = set()
|
||||
seen_groups: Set[str] = set()
|
||||
seen_users: Set[str] = set()
|
||||
seen_dirs: Set[str] = set()
|
||||
seen_files: Set[str] = set()
|
||||
seen_links: Set[str] = set()
|
||||
seen_services: Set[str] = set()
|
||||
|
||||
for module in ordered:
|
||||
module.packages = _drop_duplicate_set_items(
|
||||
module, module.packages, seen_packages, "Package"
|
||||
)
|
||||
module.groups = _drop_duplicate_set_items(
|
||||
module, module.groups, seen_groups, "Group"
|
||||
)
|
||||
module.users = _drop_duplicate_mapping_items(
|
||||
module, module.users, seen_users, "User"
|
||||
)
|
||||
module.dirs = _drop_duplicate_mapping_items(
|
||||
module,
|
||||
module.dirs,
|
||||
seen_dirs,
|
||||
"File",
|
||||
excluded_titles=concrete_file_paths,
|
||||
excluded_reason="a file or link with the same path is emitted in this catalog",
|
||||
)
|
||||
module.files = _drop_duplicate_mapping_items(
|
||||
module, module.files, seen_files | seen_links, "File"
|
||||
)
|
||||
seen_files.update(module.files)
|
||||
module.links = _drop_duplicate_mapping_items(
|
||||
module, module.links, seen_links | seen_files, "File"
|
||||
)
|
||||
seen_links.update(module.links)
|
||||
module.services = _drop_duplicate_mapping_items(
|
||||
module, module.services, seen_services, "Service"
|
||||
)
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess # nosec
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
|
@ -64,6 +63,53 @@ def list_manual_packages() -> List[str]:
|
|||
return sorted(set(pkgs))
|
||||
|
||||
|
||||
def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
|
||||
"""Return mapping of installed package name -> installed instances.
|
||||
|
||||
Uses dpkg-query and is expected to work on Debian/Ubuntu-like systems.
|
||||
|
||||
Output format:
|
||||
{"pkg": [{"version": "...", "arch": "...", "section": "..."}, ...], ...}
|
||||
"""
|
||||
|
||||
try:
|
||||
p = subprocess.run(
|
||||
[
|
||||
"dpkg-query",
|
||||
"-W",
|
||||
"-f=${Package}\t${Version}\t${Architecture}\t${Section}\n",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
) # nosec
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
out: Dict[str, List[Dict[str, str]]] = {}
|
||||
for raw in (p.stdout or "").splitlines():
|
||||
line = raw.strip("\n")
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
name, ver, arch = parts[0].strip(), parts[1].strip(), parts[2].strip()
|
||||
if not name:
|
||||
continue
|
||||
instance = {"version": ver, "arch": arch}
|
||||
if len(parts) >= 4 and parts[3].strip():
|
||||
instance["section"] = parts[3].strip()
|
||||
out.setdefault(name, []).append(instance)
|
||||
|
||||
# Stable ordering for deterministic JSON dumps.
|
||||
for k in list(out.keys()):
|
||||
out[k] = sorted(
|
||||
out[k], key=lambda x: (x.get("arch") or "", x.get("version") or "")
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
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]]]:
|
||||
|
|
@ -140,7 +186,12 @@ def parse_status_conffiles(
|
|||
if m:
|
||||
out[pkg] = m
|
||||
|
||||
with open(status_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
try:
|
||||
f = open(status_path, "r", encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return out
|
||||
|
||||
with f:
|
||||
for line in f:
|
||||
if line.strip() == "":
|
||||
if cur:
|
||||
|
|
@ -177,31 +228,10 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]:
|
|||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
md5, rel = line.split(None, 1)
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
# Skip malformed/truncated lines instead of aborting the harvest.
|
||||
continue
|
||||
md5, rel = parts
|
||||
m[rel.strip()] = md5.strip()
|
||||
return m
|
||||
|
||||
|
||||
def file_md5(path: str) -> str:
|
||||
h = hashlib.md5() # nosec
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def stat_triplet(path: str) -> Tuple[str, str, str]:
|
||||
st = os.stat(path, follow_symlinks=True)
|
||||
mode = oct(st.st_mode & 0o777)[2:].zfill(4)
|
||||
|
||||
import pwd, grp
|
||||
|
||||
try:
|
||||
owner = pwd.getpwuid(st.st_uid).pw_name
|
||||
except KeyError:
|
||||
owner = str(st.st_uid)
|
||||
try:
|
||||
group = grp.getgrgid(st.st_gid).gr_name
|
||||
except KeyError:
|
||||
group = str(st.st_gid)
|
||||
return owner, group, mode
|
||||
|
|
|
|||
423
enroll/diff.py
423
enroll/diff.py
|
|
@ -7,6 +7,10 @@ import shutil
|
|||
import subprocess # nosec
|
||||
import tarfile
|
||||
import tempfile
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import itertools
|
||||
import urllib.request
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -16,7 +20,100 @@ from pathlib import Path
|
|||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from .remote import _safe_extract_tar
|
||||
from .state import (
|
||||
inventory_packages_from_state as _packages_inventory,
|
||||
load_state as _load_state,
|
||||
roles_from_state as _roles,
|
||||
state_path,
|
||||
)
|
||||
from .pathfilter import PathFilter
|
||||
from .sopsutil import decrypt_file_binary_to, require_sops_cmd
|
||||
from .manifest_safety import freeze_directory_bundle
|
||||
from .cm import sanitize_markdown_text, sanitize_report_text
|
||||
|
||||
|
||||
def _validate_diff_bundle(label: str, bundle_dir: Path) -> None:
|
||||
"""Validate a resolved harvest bundle before diff reads artifacts.
|
||||
|
||||
`diff` intentionally compares older harvests, so keep schema validation out
|
||||
of this internal safety pass. The important security property here is that
|
||||
the bundle's artifact tree has the same path/symlink/hardlink/special-file
|
||||
checks that `manifest` relies on before copying artifacts.
|
||||
"""
|
||||
|
||||
# Import lazily to avoid a module-level cycle: enroll.validate imports
|
||||
# BundleRef/_bundle_from_input from this module.
|
||||
from .validate import validate_harvest
|
||||
|
||||
validation = validate_harvest(str(bundle_dir), no_schema=True)
|
||||
if not validation.ok:
|
||||
raise RuntimeError(
|
||||
f"{label} harvest failed validation; refusing to diff unsafe bundle.\n"
|
||||
+ validation.to_text().strip()
|
||||
)
|
||||
|
||||
|
||||
def _progress_enabled() -> bool:
|
||||
"""Return True if we should display interactive progress UI on the CLI.
|
||||
|
||||
We only emit progress when stderr is a TTY, so it won't pollute JSON/text reports
|
||||
captured by systemd, CI, webhooks, etc. Users can also disable this explicitly via
|
||||
ENROLL_NO_PROGRESS=1.
|
||||
"""
|
||||
if os.environ.get("ENROLL_NO_PROGRESS", "").strip() in {"1", "true", "yes"}:
|
||||
return False
|
||||
try:
|
||||
return sys.stderr.isatty()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class _Spinner:
|
||||
"""A tiny terminal spinner with an elapsed-time counter (stderr-only)."""
|
||||
|
||||
def __init__(self, message: str, *, interval: float = 0.12) -> None:
|
||||
self.message = message.rstrip()
|
||||
self.interval = interval
|
||||
self._stop = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._last_len = 0
|
||||
self._start = 0.0
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._start = time.monotonic()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, name="enroll-spinner", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, final_line: Optional[str] = None) -> None:
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
# Clear spinner line.
|
||||
try:
|
||||
sys.stderr.write("\r" + (" " * max(self._last_len, 0)) + "\r")
|
||||
if final_line:
|
||||
sys.stderr.write(final_line.rstrip() + "\n")
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass # nosec
|
||||
|
||||
def _run(self) -> None:
|
||||
frames = itertools.cycle("|/-\\")
|
||||
while not self._stop.is_set():
|
||||
elapsed = time.monotonic() - self._start
|
||||
line = f"{self.message} {next(frames)} {elapsed:0.1f}s"
|
||||
try:
|
||||
sys.stderr.write("\r" + line)
|
||||
sys.stderr.flush()
|
||||
self._last_len = max(self._last_len, len(line))
|
||||
except Exception:
|
||||
return
|
||||
self._stop.wait(self.interval)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
|
|
@ -47,10 +144,12 @@ class BundleRef:
|
|||
|
||||
@property
|
||||
def state_path(self) -> Path:
|
||||
return self.dir / "state.json"
|
||||
return state_path(self.dir)
|
||||
|
||||
|
||||
def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
|
||||
def _bundle_from_input(
|
||||
path: str, *, sops_mode: bool, freeze: bool = False
|
||||
) -> BundleRef:
|
||||
"""Resolve a user-supplied path to a harvest bundle directory.
|
||||
|
||||
Accepts:
|
||||
|
|
@ -58,6 +157,14 @@ def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
|
|||
- a path to state.json inside a bundle directory
|
||||
- (sops mode or .sops) a SOPS-encrypted tar.gz bundle
|
||||
- a plain tar.gz/tgz bundle
|
||||
|
||||
When ``freeze`` is True, a plain *directory* input is copied into a private
|
||||
0700 temp directory (no-follow, regular-files-only) before being returned, so
|
||||
a later consumer cannot be raced by an unprivileged owner mutating the source
|
||||
directory after validation. Tar/SOPS inputs are always extracted into a
|
||||
private temp directory and so are inherently frozen. ``freeze`` is left False
|
||||
for purely diagnostic callers (e.g. ``validate``) that should report on the
|
||||
exact directory the operator named rather than on a copy of it.
|
||||
"""
|
||||
|
||||
p = Path(path).expanduser()
|
||||
|
|
@ -67,6 +174,9 @@ def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
|
|||
p = p.parent
|
||||
|
||||
if p.is_dir():
|
||||
if freeze:
|
||||
frozen_dir, td_frozen = freeze_directory_bundle(p, label="harvest bundle")
|
||||
return BundleRef(dir=Path(frozen_dir), tempdir=td_frozen)
|
||||
return BundleRef(dir=p)
|
||||
|
||||
if not p.exists():
|
||||
|
|
@ -120,24 +230,54 @@ def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
|
|||
)
|
||||
|
||||
|
||||
def _load_state(bundle_dir: Path) -> Dict[str, Any]:
|
||||
sp = bundle_dir / "state.json"
|
||||
with open(sp, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _all_packages(state: Dict[str, Any]) -> List[str]:
|
||||
pkgs = set(state.get("manual_packages", []) or [])
|
||||
pkgs |= set(state.get("manual_packages_skipped", []) or [])
|
||||
for s in state.get("services", []) or []:
|
||||
for p in s.get("packages", []) or []:
|
||||
pkgs.add(p)
|
||||
return sorted(pkgs)
|
||||
return sorted(_packages_inventory(state).keys())
|
||||
|
||||
|
||||
def _pkg_version_key(entry: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return a stable string used for version comparison."""
|
||||
installs = entry.get("installations") or []
|
||||
if isinstance(installs, list) and installs:
|
||||
parts: List[str] = []
|
||||
for inst in installs:
|
||||
if not isinstance(inst, dict):
|
||||
continue
|
||||
arch = str(inst.get("arch") or "")
|
||||
ver = str(inst.get("version") or "")
|
||||
if not ver:
|
||||
continue
|
||||
parts.append(f"{arch}:{ver}" if arch else ver)
|
||||
if parts:
|
||||
return "|".join(sorted(parts))
|
||||
v = entry.get("version")
|
||||
if v:
|
||||
return str(v)
|
||||
return None
|
||||
|
||||
|
||||
def _pkg_version_display(entry: Dict[str, Any]) -> Optional[str]:
|
||||
v = entry.get("version")
|
||||
if v:
|
||||
return str(v)
|
||||
installs = entry.get("installations") or []
|
||||
if isinstance(installs, list) and installs:
|
||||
parts: List[str] = []
|
||||
for inst in installs:
|
||||
if not isinstance(inst, dict):
|
||||
continue
|
||||
arch = str(inst.get("arch") or "")
|
||||
ver = str(inst.get("version") or "")
|
||||
if not ver:
|
||||
continue
|
||||
parts.append(f"{ver} ({arch})" if arch else ver)
|
||||
if parts:
|
||||
return ", ".join(sorted(parts))
|
||||
return None
|
||||
|
||||
|
||||
def _service_units(state: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for s in state.get("services", []) or []:
|
||||
for s in _roles(state).get("services") or []:
|
||||
unit = s.get("unit")
|
||||
if unit:
|
||||
out[str(unit)] = s
|
||||
|
|
@ -145,7 +285,7 @@ def _service_units(state: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
|||
|
||||
|
||||
def _users_by_name(state: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
users = (state.get("users") or {}).get("users") or []
|
||||
users = (_roles(state).get("users") or {}).get("users") or []
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for u in users:
|
||||
name = u.get("name")
|
||||
|
|
@ -167,43 +307,49 @@ class FileRec:
|
|||
|
||||
def _iter_managed_files(state: Dict[str, Any]) -> Iterable[Tuple[str, Dict[str, Any]]]:
|
||||
# Services
|
||||
for s in state.get("services", []) or []:
|
||||
for s in _roles(state).get("services") or []:
|
||||
role = s.get("role_name") or "unknown"
|
||||
for mf in s.get("managed_files", []) or []:
|
||||
yield str(role), mf
|
||||
|
||||
# Package roles
|
||||
for p in state.get("package_roles", []) or []:
|
||||
for p in _roles(state).get("packages") or []:
|
||||
role = p.get("role_name") or "unknown"
|
||||
for mf in p.get("managed_files", []) or []:
|
||||
yield str(role), mf
|
||||
|
||||
# Users
|
||||
u = state.get("users") or {}
|
||||
u = _roles(state).get("users") or {}
|
||||
u_role = u.get("role_name") or "users"
|
||||
for mf in u.get("managed_files", []) or []:
|
||||
yield str(u_role), mf
|
||||
|
||||
# apt_config
|
||||
ac = state.get("apt_config") or {}
|
||||
ac = _roles(state).get("apt_config") or {}
|
||||
ac_role = ac.get("role_name") or "apt_config"
|
||||
for mf in ac.get("managed_files", []) or []:
|
||||
yield str(ac_role), mf
|
||||
|
||||
# sysctl
|
||||
sc = _roles(state).get("sysctl") or {}
|
||||
sc_role = sc.get("role_name") or "sysctl"
|
||||
for mf in sc.get("managed_files", []) or []:
|
||||
yield str(sc_role), mf
|
||||
|
||||
# etc_custom
|
||||
ec = state.get("etc_custom") or {}
|
||||
ec = _roles(state).get("etc_custom") or {}
|
||||
ec_role = ec.get("role_name") or "etc_custom"
|
||||
for mf in ec.get("managed_files", []) or []:
|
||||
yield str(ec_role), mf
|
||||
|
||||
# usr_local_custom
|
||||
ul = state.get("usr_local_custom") or {}
|
||||
ul = _roles(state).get("usr_local_custom") or {}
|
||||
ul_role = ul.get("role_name") or "usr_local_custom"
|
||||
for mf in ul.get("managed_files", []) or []:
|
||||
yield str(ul_role), mf
|
||||
|
||||
# extra_paths
|
||||
xp = state.get("extra_paths") or {}
|
||||
xp = _roles(state).get("extra_paths") or {}
|
||||
xp_role = xp.get("role_name") or "extra_paths"
|
||||
for mf in xp.get("managed_files", []) or []:
|
||||
yield str(xp_role), mf
|
||||
|
|
@ -245,28 +391,53 @@ def compare_harvests(
|
|||
new_path: str,
|
||||
*,
|
||||
sops_mode: bool = False,
|
||||
exclude_paths: Optional[List[str]] = None,
|
||||
ignore_package_versions: bool = False,
|
||||
) -> Tuple[Dict[str, Any], bool]:
|
||||
"""Compare two harvests.
|
||||
|
||||
Returns (report, has_changes).
|
||||
"""
|
||||
with ExitStack() as stack:
|
||||
old_b = _bundle_from_input(old_path, sops_mode=sops_mode)
|
||||
new_b = _bundle_from_input(new_path, sops_mode=sops_mode)
|
||||
old_b = _bundle_from_input(old_path, sops_mode=sops_mode, freeze=True)
|
||||
new_b = _bundle_from_input(new_path, sops_mode=sops_mode, freeze=True)
|
||||
if old_b.tempdir:
|
||||
stack.callback(old_b.tempdir.cleanup)
|
||||
if new_b.tempdir:
|
||||
stack.callback(new_b.tempdir.cleanup)
|
||||
|
||||
_validate_diff_bundle("old", old_b.dir)
|
||||
_validate_diff_bundle("new", new_b.dir)
|
||||
|
||||
old_state = _load_state(old_b.dir)
|
||||
new_state = _load_state(new_b.dir)
|
||||
|
||||
old_pkgs = set(_all_packages(old_state))
|
||||
new_pkgs = set(_all_packages(new_state))
|
||||
old_inv = _packages_inventory(old_state)
|
||||
new_inv = _packages_inventory(new_state)
|
||||
|
||||
old_pkgs = set(old_inv.keys())
|
||||
new_pkgs = set(new_inv.keys())
|
||||
|
||||
pkgs_added = sorted(new_pkgs - old_pkgs)
|
||||
pkgs_removed = sorted(old_pkgs - new_pkgs)
|
||||
|
||||
pkgs_version_changed: List[Dict[str, Any]] = []
|
||||
pkgs_version_changed_ignored_count = 0
|
||||
for pkg in sorted(old_pkgs & new_pkgs):
|
||||
a = old_inv.get(pkg) or {}
|
||||
b = new_inv.get(pkg) or {}
|
||||
if _pkg_version_key(a) != _pkg_version_key(b):
|
||||
if ignore_package_versions:
|
||||
pkgs_version_changed_ignored_count += 1
|
||||
else:
|
||||
pkgs_version_changed.append(
|
||||
{
|
||||
"package": pkg,
|
||||
"old": _pkg_version_display(a),
|
||||
"new": _pkg_version_display(b),
|
||||
}
|
||||
)
|
||||
|
||||
old_units = _service_units(old_state)
|
||||
new_units = _service_units(new_state)
|
||||
units_added = sorted(set(new_units) - set(old_units))
|
||||
|
|
@ -327,6 +498,17 @@ def compare_harvests(
|
|||
|
||||
old_files = _file_index(old_b.dir, old_state)
|
||||
new_files = _file_index(new_b.dir, new_state)
|
||||
|
||||
# Optional user-supplied path exclusions (same semantics as harvest --exclude-path),
|
||||
# applied only to file drift reporting.
|
||||
diff_filter = PathFilter(include=(), exclude=exclude_paths or ())
|
||||
if exclude_paths:
|
||||
old_files = {
|
||||
p: r for p, r in old_files.items() if not diff_filter.is_excluded(p)
|
||||
}
|
||||
new_files = {
|
||||
p: r for p, r in new_files.items() if not diff_filter.is_excluded(p)
|
||||
}
|
||||
old_paths_set = set(old_files)
|
||||
new_paths_set = set(new_files)
|
||||
|
||||
|
|
@ -380,6 +562,7 @@ def compare_harvests(
|
|||
[
|
||||
pkgs_added,
|
||||
pkgs_removed,
|
||||
pkgs_version_changed,
|
||||
units_added,
|
||||
units_removed,
|
||||
units_changed,
|
||||
|
|
@ -401,6 +584,10 @@ def compare_harvests(
|
|||
|
||||
report: Dict[str, Any] = {
|
||||
"generated_at": _utc_now_iso(),
|
||||
"filters": {
|
||||
"exclude_paths": list(exclude_paths or []),
|
||||
"ignore_package_versions": bool(ignore_package_versions),
|
||||
},
|
||||
"old": {
|
||||
"input": old_path,
|
||||
"bundle_dir": str(old_b.dir),
|
||||
|
|
@ -413,7 +600,14 @@ def compare_harvests(
|
|||
"state_mtime": _mtime_iso(new_b.state_path),
|
||||
"host": (new_state.get("host") or {}).get("hostname"),
|
||||
},
|
||||
"packages": {"added": pkgs_added, "removed": pkgs_removed},
|
||||
"packages": {
|
||||
"added": pkgs_added,
|
||||
"removed": pkgs_removed,
|
||||
"version_changed": pkgs_version_changed,
|
||||
"version_changed_ignored_count": int(
|
||||
pkgs_version_changed_ignored_count
|
||||
),
|
||||
},
|
||||
"services": {
|
||||
"enabled_added": units_added,
|
||||
"enabled_removed": units_removed,
|
||||
|
|
@ -458,90 +652,122 @@ def format_report(report: Dict[str, Any], *, fmt: str = "text") -> str:
|
|||
|
||||
|
||||
def _report_text(report: Dict[str, Any]) -> str:
|
||||
# Harvested, attacker-influenceable values (host names, file paths, owners,
|
||||
# groups, link targets, metadata old/new values) must be neutralised before
|
||||
# being spliced into the report. A raw newline/CR could otherwise forge
|
||||
# additional report lines, and other control bytes could smuggle terminal
|
||||
# escape sequences into a printed or piped report. ``s`` is the text-report
|
||||
# sanitiser (collapses whitespace, drops control bytes).
|
||||
s = sanitize_report_text
|
||||
lines: List[str] = []
|
||||
old = report.get("old", {})
|
||||
new = report.get("new", {})
|
||||
lines.append(
|
||||
f"enroll diff report (generated {report.get('generated_at')})\n"
|
||||
f"old: {old.get('input')} (host={old.get('host')}, state_mtime={old.get('state_mtime')})\n"
|
||||
f"new: {new.get('input')} (host={new.get('host')}, state_mtime={new.get('state_mtime')})"
|
||||
f"enroll diff report (generated {s(report.get('generated_at'))})\n"
|
||||
f"old: {s(old.get('input'))} (host={s(old.get('host'))}, state_mtime={s(old.get('state_mtime'))})\n"
|
||||
f"new: {s(new.get('input'))} (host={s(new.get('host'))}, state_mtime={s(new.get('state_mtime'))})"
|
||||
)
|
||||
|
||||
filt = report.get("filters", {}) or {}
|
||||
ex_paths = filt.get("exclude_paths", []) or []
|
||||
if ex_paths:
|
||||
lines.append(f"file exclude patterns: {', '.join(s(p) for p in ex_paths)}")
|
||||
|
||||
if filt.get("ignore_package_versions"):
|
||||
ignored = int(
|
||||
(report.get("packages", {}) or {}).get("version_changed_ignored_count") or 0
|
||||
)
|
||||
msg = "package version drift: ignored (--ignore-package-versions)"
|
||||
if ignored:
|
||||
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
|
||||
lines.append(msg)
|
||||
|
||||
pk = report.get("packages", {})
|
||||
lines.append("\nPackages")
|
||||
lines.append(f" added: {len(pk.get('added', []) or [])}")
|
||||
lines.append(f" removed: {len(pk.get('removed', []) or [])}")
|
||||
ignored_v = int(pk.get("version_changed_ignored_count") or 0)
|
||||
vc = len(pk.get("version_changed", []) or [])
|
||||
suffix = f" (ignored {ignored_v})" if ignored_v else ""
|
||||
lines.append(f" version_changed: {vc}{suffix}")
|
||||
for p in pk.get("added", []) or []:
|
||||
lines.append(f" + {p}")
|
||||
lines.append(f" + {s(p)}")
|
||||
for p in pk.get("removed", []) or []:
|
||||
lines.append(f" - {p}")
|
||||
lines.append(f" - {s(p)}")
|
||||
for ch in pk.get("version_changed", []) or []:
|
||||
lines.append(
|
||||
f" ~ {s(ch.get('package'))}: {s(ch.get('old'))} -> {s(ch.get('new'))}"
|
||||
)
|
||||
|
||||
sv = report.get("services", {})
|
||||
lines.append("\nServices (enabled systemd units)")
|
||||
for u in sv.get("enabled_added", []) or []:
|
||||
lines.append(f" + {u}")
|
||||
lines.append(f" + {s(u)}")
|
||||
for u in sv.get("enabled_removed", []) or []:
|
||||
lines.append(f" - {u}")
|
||||
lines.append(f" - {s(u)}")
|
||||
for ch in sv.get("changed", []) or []:
|
||||
unit = ch.get("unit")
|
||||
lines.append(f" * {unit} changed")
|
||||
lines.append(f" * {s(unit)} changed")
|
||||
for k, v in (ch.get("changes") or {}).items():
|
||||
if k == "packages":
|
||||
a = (v or {}).get("added", [])
|
||||
r = (v or {}).get("removed", [])
|
||||
if a:
|
||||
lines.append(f" packages +: {', '.join(a)}")
|
||||
lines.append(f" packages +: {', '.join(s(x) for x in a)}")
|
||||
if r:
|
||||
lines.append(f" packages -: {', '.join(r)}")
|
||||
lines.append(f" packages -: {', '.join(s(x) for x in r)}")
|
||||
else:
|
||||
lines.append(f" {k}: {v.get('old')} -> {v.get('new')}")
|
||||
lines.append(f" {s(k)}: {s(v.get('old'))} -> {s(v.get('new'))}")
|
||||
|
||||
us = report.get("users", {})
|
||||
lines.append("\nUsers")
|
||||
for u in us.get("added", []) or []:
|
||||
lines.append(f" + {u}")
|
||||
lines.append(f" + {s(u)}")
|
||||
for u in us.get("removed", []) or []:
|
||||
lines.append(f" - {u}")
|
||||
lines.append(f" - {s(u)}")
|
||||
for ch in us.get("changed", []) or []:
|
||||
name = ch.get("name")
|
||||
lines.append(f" * {name} changed")
|
||||
lines.append(f" * {s(name)} changed")
|
||||
for k, v in (ch.get("changes") or {}).items():
|
||||
if k == "supplementary_groups":
|
||||
a = (v or {}).get("added", [])
|
||||
r = (v or {}).get("removed", [])
|
||||
if a:
|
||||
lines.append(f" groups +: {', '.join(a)}")
|
||||
lines.append(f" groups +: {', '.join(s(x) for x in a)}")
|
||||
if r:
|
||||
lines.append(f" groups -: {', '.join(r)}")
|
||||
lines.append(f" groups -: {', '.join(s(x) for x in r)}")
|
||||
else:
|
||||
lines.append(f" {k}: {v.get('old')} -> {v.get('new')}")
|
||||
lines.append(f" {s(k)}: {s(v.get('old'))} -> {s(v.get('new'))}")
|
||||
|
||||
fl = report.get("files", {})
|
||||
lines.append("\nFiles")
|
||||
for e in fl.get("added", []) or []:
|
||||
lines.append(
|
||||
f" + {e.get('path')} (role={e.get('role')}, reason={e.get('reason')})"
|
||||
f" + {s(e.get('path'))} (role={s(e.get('role'))}, reason={s(e.get('reason'))})"
|
||||
)
|
||||
for e in fl.get("removed", []) or []:
|
||||
lines.append(
|
||||
f" - {e.get('path')} (role={e.get('role')}, reason={e.get('reason')})"
|
||||
f" - {s(e.get('path'))} (role={s(e.get('role'))}, reason={s(e.get('reason'))})"
|
||||
)
|
||||
for ch in fl.get("changed", []) or []:
|
||||
p = ch.get("path")
|
||||
lines.append(f" * {p} changed")
|
||||
lines.append(f" * {s(p)} changed")
|
||||
for k, v in (ch.get("changes") or {}).items():
|
||||
if k == "content":
|
||||
if "old_sha256" in (v or {}):
|
||||
lines.append(" content: sha256 changed")
|
||||
else:
|
||||
lines.append(f" content: {v.get('old')} -> {v.get('new')}")
|
||||
lines.append(
|
||||
f" content: {s(v.get('old'))} -> {s(v.get('new'))}"
|
||||
)
|
||||
else:
|
||||
lines.append(f" {k}: {v.get('old')} -> {v.get('new')}")
|
||||
lines.append(f" {s(k)}: {s(v.get('old'))} -> {s(v.get('new'))}")
|
||||
|
||||
if not any(
|
||||
[
|
||||
(pk.get("added") or []),
|
||||
(pk.get("removed") or []),
|
||||
(pk.get("version_changed") or []),
|
||||
(sv.get("enabled_added") or []),
|
||||
(sv.get("enabled_removed") or []),
|
||||
(sv.get("changed") or []),
|
||||
|
|
@ -559,84 +785,124 @@ def _report_text(report: Dict[str, Any]) -> str:
|
|||
|
||||
|
||||
def _report_markdown(report: Dict[str, Any]) -> str:
|
||||
# Harvested, attacker-influenceable values (host names, file paths, owners,
|
||||
# groups, link targets, metadata old/new values) are embedded in Markdown
|
||||
# code spans below. Without neutralisation a value containing a backtick,
|
||||
# newline, or control byte could break out of its code span / list item and
|
||||
# inject misleading Markdown structure (a forged heading, a deceptive
|
||||
# ``[link](...)``), exactly as the generated README guards against. ``m`` is
|
||||
# the Markdown sanitiser used for the README; reuse it here so both
|
||||
# documentation surfaces share one policy.
|
||||
m = sanitize_markdown_text
|
||||
old = report.get("old", {})
|
||||
new = report.get("new", {})
|
||||
out: List[str] = []
|
||||
out.append("# enroll diff report\n")
|
||||
out.append(f"Generated: `{report.get('generated_at')}`\n")
|
||||
out.append(f"Generated: `{m(report.get('generated_at'))}`\n")
|
||||
out.append(
|
||||
f"- **Old**: `{old.get('input')}` (host={old.get('host')}, state_mtime={old.get('state_mtime')})\n"
|
||||
f"- **New**: `{new.get('input')}` (host={new.get('host')}, state_mtime={new.get('state_mtime')})\n"
|
||||
f"- **Old**: `{m(old.get('input'))}` (host={m(old.get('host'))}, state_mtime={m(old.get('state_mtime'))})\n"
|
||||
f"- **New**: `{m(new.get('input'))}` (host={m(new.get('host'))}, state_mtime={m(new.get('state_mtime'))})\n"
|
||||
)
|
||||
|
||||
filt = report.get("filters", {}) or {}
|
||||
ex_paths = filt.get("exclude_paths", []) or []
|
||||
if ex_paths:
|
||||
out.append(
|
||||
"- **File exclude patterns**: "
|
||||
+ ", ".join(f"`{m(p)}`" for p in ex_paths)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
if filt.get("ignore_package_versions"):
|
||||
ignored = int(
|
||||
(report.get("packages", {}) or {}).get("version_changed_ignored_count") or 0
|
||||
)
|
||||
msg = "- **Package version drift**: ignored (`--ignore-package-versions`)"
|
||||
if ignored:
|
||||
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
|
||||
out.append(msg + "\n")
|
||||
|
||||
pk = report.get("packages", {})
|
||||
out.append("## Packages\n")
|
||||
out.append(f"- Added: {len(pk.get('added', []) or [])}\n")
|
||||
for p in pk.get("added", []) or []:
|
||||
out.append(f" - `+ {p}`\n")
|
||||
out.append(f" - `+ {m(p)}`\n")
|
||||
out.append(f"- Removed: {len(pk.get('removed', []) or [])}\n")
|
||||
for p in pk.get("removed", []) or []:
|
||||
out.append(f" - `- {p}`\n")
|
||||
out.append(f" - `- {m(p)}`\n")
|
||||
|
||||
ignored_v = int(pk.get("version_changed_ignored_count") or 0)
|
||||
vc = len(pk.get("version_changed", []) or [])
|
||||
suffix = f" (ignored {ignored_v})" if ignored_v else ""
|
||||
out.append(f"- Version changed: {vc}{suffix}\n")
|
||||
for ch in pk.get("version_changed", []) or []:
|
||||
out.append(
|
||||
f" - `~ {m(ch.get('package'))}`: `{m(ch.get('old'))}` → `{m(ch.get('new'))}`\n"
|
||||
)
|
||||
|
||||
sv = report.get("services", {})
|
||||
out.append("## Services (enabled systemd units)\n")
|
||||
if sv.get("enabled_added"):
|
||||
out.append("- Enabled added\n")
|
||||
for u in sv.get("enabled_added", []) or []:
|
||||
out.append(f" - `+ {u}`\n")
|
||||
out.append(f" - `+ {m(u)}`\n")
|
||||
if sv.get("enabled_removed"):
|
||||
out.append("- Enabled removed\n")
|
||||
for u in sv.get("enabled_removed", []) or []:
|
||||
out.append(f" - `- {u}`\n")
|
||||
out.append(f" - `- {m(u)}`\n")
|
||||
if sv.get("changed"):
|
||||
out.append("- Changed\n")
|
||||
for ch in sv.get("changed", []) or []:
|
||||
unit = ch.get("unit")
|
||||
out.append(f" - `{unit}`\n")
|
||||
out.append(f" - `{m(unit)}`\n")
|
||||
for k, v in (ch.get("changes") or {}).items():
|
||||
if k == "packages":
|
||||
a = (v or {}).get("added", [])
|
||||
r = (v or {}).get("removed", [])
|
||||
if a:
|
||||
out.append(
|
||||
f" - packages added: {', '.join('`'+x+'`' for x in a)}\n"
|
||||
f" - packages added: {', '.join('`'+m(x)+'`' for x in a)}\n"
|
||||
)
|
||||
if r:
|
||||
out.append(
|
||||
f" - packages removed: {', '.join('`'+x+'`' for x in r)}\n"
|
||||
f" - packages removed: {', '.join('`'+m(x)+'`' for x in r)}\n"
|
||||
)
|
||||
else:
|
||||
out.append(f" - {k}: `{v.get('old')}` → `{v.get('new')}`\n")
|
||||
out.append(
|
||||
f" - {m(k)}: `{m(v.get('old'))}` → `{m(v.get('new'))}`\n"
|
||||
)
|
||||
|
||||
us = report.get("users", {})
|
||||
out.append("## Users\n")
|
||||
if us.get("added"):
|
||||
out.append("- Added\n")
|
||||
for u in us.get("added", []) or []:
|
||||
out.append(f" - `+ {u}`\n")
|
||||
out.append(f" - `+ {m(u)}`\n")
|
||||
if us.get("removed"):
|
||||
out.append("- Removed\n")
|
||||
for u in us.get("removed", []) or []:
|
||||
out.append(f" - `- {u}`\n")
|
||||
out.append(f" - `- {m(u)}`\n")
|
||||
if us.get("changed"):
|
||||
out.append("- Changed\n")
|
||||
for ch in us.get("changed", []) or []:
|
||||
name = ch.get("name")
|
||||
out.append(f" - `{name}`\n")
|
||||
out.append(f" - `{m(name)}`\n")
|
||||
for k, v in (ch.get("changes") or {}).items():
|
||||
if k == "supplementary_groups":
|
||||
a = (v or {}).get("added", [])
|
||||
r = (v or {}).get("removed", [])
|
||||
if a:
|
||||
out.append(
|
||||
f" - groups added: {', '.join('`'+x+'`' for x in a)}\n"
|
||||
f" - groups added: {', '.join('`'+m(x)+'`' for x in a)}\n"
|
||||
)
|
||||
if r:
|
||||
out.append(
|
||||
f" - groups removed: {', '.join('`'+x+'`' for x in r)}\n"
|
||||
f" - groups removed: {', '.join('`'+m(x)+'`' for x in r)}\n"
|
||||
)
|
||||
else:
|
||||
out.append(f" - {k}: `{v.get('old')}` → `{v.get('new')}`\n")
|
||||
out.append(
|
||||
f" - {m(k)}: `{m(v.get('old'))}` → `{m(v.get('new'))}`\n"
|
||||
)
|
||||
|
||||
fl = report.get("files", {})
|
||||
out.append("## Files\n")
|
||||
|
|
@ -644,34 +910,37 @@ def _report_markdown(report: Dict[str, Any]) -> str:
|
|||
out.append("- Added\n")
|
||||
for e in fl.get("added", []) or []:
|
||||
out.append(
|
||||
f" - `+ {e.get('path')}` (role={e.get('role')}, reason={e.get('reason')})\n"
|
||||
f" - `+ {m(e.get('path'))}` (role={m(e.get('role'))}, reason={m(e.get('reason'))})\n"
|
||||
)
|
||||
if fl.get("removed"):
|
||||
out.append("- Removed\n")
|
||||
for e in fl.get("removed", []) or []:
|
||||
out.append(
|
||||
f" - `- {e.get('path')}` (role={e.get('role')}, reason={e.get('reason')})\n"
|
||||
f" - `- {m(e.get('path'))}` (role={m(e.get('role'))}, reason={m(e.get('reason'))})\n"
|
||||
)
|
||||
if fl.get("changed"):
|
||||
out.append("- Changed\n")
|
||||
for ch in fl.get("changed", []) or []:
|
||||
p = ch.get("path")
|
||||
out.append(f" - `{p}`\n")
|
||||
out.append(f" - `{m(p)}`\n")
|
||||
for k, v in (ch.get("changes") or {}).items():
|
||||
if k == "content":
|
||||
if "old_sha256" in (v or {}):
|
||||
out.append(" - content: sha256 changed\n")
|
||||
else:
|
||||
out.append(
|
||||
f" - content: `{v.get('old')}` → `{v.get('new')}`\n"
|
||||
f" - content: `{m(v.get('old'))}` → `{m(v.get('new'))}`\n"
|
||||
)
|
||||
else:
|
||||
out.append(f" - {k}: `{v.get('old')}` → `{v.get('new')}`\n")
|
||||
out.append(
|
||||
f" - {m(k)}: `{m(v.get('old'))}` → `{m(v.get('new'))}`\n"
|
||||
)
|
||||
|
||||
if not any(
|
||||
[
|
||||
(pk.get("added") or []),
|
||||
(pk.get("removed") or []),
|
||||
(pk.get("version_changed") or []),
|
||||
(sv.get("enabled_added") or []),
|
||||
(sv.get("enabled_removed") or []),
|
||||
(sv.get("changed") or []),
|
||||
|
|
@ -767,8 +1036,14 @@ def send_email(
|
|||
try:
|
||||
s.starttls()
|
||||
s.ehlo()
|
||||
except Exception:
|
||||
# STARTTLS is optional; ignore if unsupported.
|
||||
except Exception as e:
|
||||
if smtp_user or smtp_password:
|
||||
raise RuntimeError(
|
||||
"email: SMTP STARTTLS failed; refusing to send credentials "
|
||||
"without TLS"
|
||||
) from e
|
||||
# Without credentials, keep STARTTLS opportunistic so localhost or
|
||||
# unauthenticated relay setups continue to work.
|
||||
pass # nosec
|
||||
if smtp_user:
|
||||
s.login(smtp_user, smtp_password or "")
|
||||
|
|
|
|||
618
enroll/explain.py
Normal file
618
enroll/explain.py
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from .diff import _bundle_from_input # reuse existing bundle handling
|
||||
from .state import load_state
|
||||
from .cm import sanitize_report_text
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReasonInfo:
|
||||
title: str
|
||||
why: str
|
||||
|
||||
|
||||
_MANAGED_FILE_REASONS: Dict[str, ReasonInfo] = {
|
||||
# Package manager / repo config
|
||||
"apt_config": ReasonInfo(
|
||||
"APT configuration",
|
||||
"APT configuration affecting package installation and repository behavior.",
|
||||
),
|
||||
"apt_source": ReasonInfo(
|
||||
"APT repository source",
|
||||
"APT source list entries (e.g. sources.list or sources.list.d).",
|
||||
),
|
||||
"apt_keyring": ReasonInfo(
|
||||
"APT keyring",
|
||||
"Repository signing key material used by APT.",
|
||||
),
|
||||
"apt_signed_by_keyring": ReasonInfo(
|
||||
"APT Signed-By keyring",
|
||||
"Keyring referenced via a Signed-By directive in an APT source.",
|
||||
),
|
||||
"yum_conf": ReasonInfo(
|
||||
"YUM/DNF main config",
|
||||
"Primary YUM configuration (often /etc/yum.conf).",
|
||||
),
|
||||
"yum_config": ReasonInfo(
|
||||
"YUM/DNF config",
|
||||
"YUM/DNF configuration files (including conf.d).",
|
||||
),
|
||||
"yum_repo": ReasonInfo(
|
||||
"YUM/DNF repository",
|
||||
"YUM/DNF repository definitions (e.g. yum.repos.d).",
|
||||
),
|
||||
"dnf_config": ReasonInfo(
|
||||
"DNF configuration",
|
||||
"DNF configuration affecting package installation and repositories.",
|
||||
),
|
||||
"rpm_gpg_key": ReasonInfo(
|
||||
"RPM GPG key",
|
||||
"Repository signing keys used by RPM/YUM/DNF.",
|
||||
),
|
||||
# SSH
|
||||
"authorized_keys": ReasonInfo(
|
||||
"SSH authorized keys",
|
||||
"User authorized_keys files (controls who can log in with SSH keys).",
|
||||
),
|
||||
"ssh_public_key": ReasonInfo(
|
||||
"SSH public key",
|
||||
"SSH host/user public keys relevant to authentication.",
|
||||
),
|
||||
# System config / security
|
||||
"system_security": ReasonInfo(
|
||||
"Security configuration",
|
||||
"Security-sensitive configuration (SSH, sudoers, PAM, auth, etc.).",
|
||||
),
|
||||
"system_network": ReasonInfo(
|
||||
"Network configuration",
|
||||
"Network configuration (interfaces, resolv.conf, network managers, etc.).",
|
||||
),
|
||||
"system_firewall": ReasonInfo(
|
||||
"Firewall configuration",
|
||||
"Firewall rules/configuration (ufw, nftables, iptables, ipset, etc.).",
|
||||
),
|
||||
"system_sysctl": ReasonInfo(
|
||||
"sysctl configuration",
|
||||
"Kernel sysctl tuning (sysctl.conf / sysctl.d).",
|
||||
),
|
||||
"system_modprobe": ReasonInfo(
|
||||
"modprobe configuration",
|
||||
"Kernel module configuration (modprobe.d).",
|
||||
),
|
||||
"system_mounts": ReasonInfo(
|
||||
"Mount configuration",
|
||||
"Mount configuration (e.g. /etc/fstab and related).",
|
||||
),
|
||||
"system_rc": ReasonInfo(
|
||||
"Startup/rc configuration",
|
||||
"Startup scripts / rc configuration that can affect boot behavior.",
|
||||
),
|
||||
# systemd + timers
|
||||
"systemd_dropin": ReasonInfo(
|
||||
"systemd drop-in",
|
||||
"systemd override/drop-in files that modify a unit's behavior.",
|
||||
),
|
||||
"systemd_envfile": ReasonInfo(
|
||||
"systemd EnvironmentFile",
|
||||
"Files referenced by systemd units via EnvironmentFile.",
|
||||
),
|
||||
"related_timer": ReasonInfo(
|
||||
"Related systemd timer",
|
||||
"A systemd timer captured because it is related to a unit/service.",
|
||||
),
|
||||
# cron / logrotate
|
||||
"system_cron": ReasonInfo(
|
||||
"System cron",
|
||||
"System cron configuration (crontab, cron.d, etc.).",
|
||||
),
|
||||
"cron_snippet": ReasonInfo(
|
||||
"Cron snippet",
|
||||
"Cron snippets referenced/used by harvested services or configs.",
|
||||
),
|
||||
"system_logrotate": ReasonInfo(
|
||||
"System logrotate",
|
||||
"System logrotate configuration.",
|
||||
),
|
||||
"logrotate_snippet": ReasonInfo(
|
||||
"logrotate snippet",
|
||||
"logrotate snippets/configs referenced in system configuration.",
|
||||
),
|
||||
# Custom paths / drift signals
|
||||
"modified_conffile": ReasonInfo(
|
||||
"Modified package conffile",
|
||||
"A package-managed conffile differs from the packaged/default version.",
|
||||
),
|
||||
"modified_packaged_file": ReasonInfo(
|
||||
"Modified packaged file",
|
||||
"A file owned by a package differs from the packaged version.",
|
||||
),
|
||||
"custom_unowned": ReasonInfo(
|
||||
"Unowned custom file",
|
||||
"A file not owned by any package (often custom/operator-managed).",
|
||||
),
|
||||
"custom_specific_path": ReasonInfo(
|
||||
"Custom specific path",
|
||||
"A specific path included by a custom rule or snapshot.",
|
||||
),
|
||||
"usr_local_bin_script": ReasonInfo(
|
||||
"/usr/local/bin script",
|
||||
"Executable scripts under /usr/local/bin (often operator-installed).",
|
||||
),
|
||||
"usr_local_etc_custom": ReasonInfo(
|
||||
"/usr/local/etc custom",
|
||||
"Custom configuration under /usr/local/etc.",
|
||||
),
|
||||
# User includes
|
||||
"user_include": ReasonInfo(
|
||||
"User-included path",
|
||||
"Included because you specified it via --include-path / include patterns.",
|
||||
),
|
||||
}
|
||||
|
||||
_MANAGED_DIR_REASONS: Dict[str, ReasonInfo] = {
|
||||
"parent_of_managed_file": ReasonInfo(
|
||||
"Parent directory",
|
||||
"Included so permissions/ownership can be recreated for managed files.",
|
||||
),
|
||||
"user_include_dir": ReasonInfo(
|
||||
"User-included directory",
|
||||
"Included because you specified it via --include-path / include patterns.",
|
||||
),
|
||||
}
|
||||
|
||||
_EXCLUDED_REASONS: Dict[str, ReasonInfo] = {
|
||||
"user_excluded": ReasonInfo(
|
||||
"User excluded",
|
||||
"Excluded because you explicitly excluded it (e.g. --exclude-path / patterns).",
|
||||
),
|
||||
"unreadable": ReasonInfo(
|
||||
"Unreadable",
|
||||
"Enroll could not read this path with the permissions it had.",
|
||||
),
|
||||
"log_file": ReasonInfo(
|
||||
"Log file",
|
||||
"Excluded because it appears to be a log file (usually noisy/large).",
|
||||
),
|
||||
"denied_path": ReasonInfo(
|
||||
"Denied path",
|
||||
"Excluded because the path is in a denylist for safety.",
|
||||
),
|
||||
"too_large": ReasonInfo(
|
||||
"Too large",
|
||||
"Excluded because it exceeded the size limit for harvested files.",
|
||||
),
|
||||
"not_regular_file": ReasonInfo(
|
||||
"Not a regular file",
|
||||
"Excluded because it was not a regular file (device, socket, etc.).",
|
||||
),
|
||||
"symlink_component": ReasonInfo(
|
||||
"Unsafe symlinked path",
|
||||
"Excluded because a directory in the path was a symlink, which could "
|
||||
"redirect capture into a sensitive location; Enroll refuses to follow "
|
||||
"symlinked parents when harvesting files.",
|
||||
),
|
||||
"binary_like": ReasonInfo(
|
||||
"Binary-like",
|
||||
"Excluded because it looked like binary content (not useful for config management).",
|
||||
),
|
||||
"sensitive_content": ReasonInfo(
|
||||
"Sensitive content",
|
||||
"Excluded because it likely contains secrets (e.g. shadow, private keys).",
|
||||
),
|
||||
}
|
||||
|
||||
_OBSERVED_VIA: Dict[str, ReasonInfo] = {
|
||||
"user_installed": ReasonInfo(
|
||||
"User-installed",
|
||||
"Package appears explicitly installed (as opposed to only pulled in as a dependency).",
|
||||
),
|
||||
"systemd_unit": ReasonInfo(
|
||||
"Referenced by systemd unit",
|
||||
"Package is associated with a systemd unit that was harvested.",
|
||||
),
|
||||
"package_role": ReasonInfo(
|
||||
"Referenced by package role",
|
||||
"Package was referenced by an enroll packages snapshot/role.",
|
||||
),
|
||||
"firewall_runtime": ReasonInfo(
|
||||
"Referenced by firewall runtime role",
|
||||
"Package was referenced by captured live ipset/iptables runtime state.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _ri(mapping: Dict[str, ReasonInfo], key: str) -> ReasonInfo:
|
||||
return mapping.get(key) or ReasonInfo(key, f"Captured with reason '{key}'")
|
||||
|
||||
|
||||
def _role_common_counts(role_obj: Dict[str, Any]) -> Tuple[int, int, int, int]:
|
||||
"""Return (managed_files, managed_dirs, excluded, notes) counts for a RoleCommon object."""
|
||||
mf = len(role_obj.get("managed_files") or [])
|
||||
md = len(role_obj.get("managed_dirs") or [])
|
||||
ex = len(role_obj.get("excluded") or [])
|
||||
nt = len(role_obj.get("notes") or [])
|
||||
return mf, md, ex, nt
|
||||
|
||||
|
||||
def _summarize_reasons(
|
||||
items: Iterable[Dict[str, Any]],
|
||||
reason_key: str,
|
||||
*,
|
||||
mapping: Dict[str, ReasonInfo],
|
||||
max_examples: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
by_reason: Dict[str, List[str]] = defaultdict(list)
|
||||
counts: Counter[str] = Counter()
|
||||
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
r = it.get(reason_key)
|
||||
if not r:
|
||||
continue
|
||||
r = str(r)
|
||||
counts[r] += 1
|
||||
p = it.get("path")
|
||||
if (
|
||||
max_examples > 0
|
||||
and isinstance(p, str)
|
||||
and p
|
||||
and len(by_reason[r]) < max_examples
|
||||
):
|
||||
by_reason[r].append(p)
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for reason, count in counts.most_common():
|
||||
info = _ri(mapping, reason)
|
||||
out.append(
|
||||
{
|
||||
"reason": reason,
|
||||
"count": count,
|
||||
"title": info.title,
|
||||
"why": info.why,
|
||||
"examples": by_reason.get(reason, []),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def explain_state(
|
||||
harvest: str,
|
||||
*,
|
||||
sops_mode: bool = False,
|
||||
fmt: str = "text",
|
||||
max_examples: int = 3,
|
||||
) -> str:
|
||||
"""Explain a harvest bundle's state.json.
|
||||
|
||||
`harvest` may be:
|
||||
- a bundle directory
|
||||
- a path to state.json
|
||||
- a tarball (.tar.gz/.tgz)
|
||||
- a SOPS-encrypted bundle (.sops)
|
||||
"""
|
||||
bundle = _bundle_from_input(harvest, sops_mode=sops_mode)
|
||||
state = load_state(bundle.dir)
|
||||
|
||||
host = state.get("host") or {}
|
||||
enroll = state.get("enroll") or {}
|
||||
roles = state.get("roles") or {}
|
||||
inv = state.get("inventory") or {}
|
||||
inv_pkgs = (inv.get("packages") or {}) if isinstance(inv, dict) else {}
|
||||
|
||||
role_summaries: List[Dict[str, Any]] = []
|
||||
|
||||
# Users
|
||||
users_obj = roles.get("users") or {}
|
||||
user_entries = users_obj.get("users") or []
|
||||
mf, md, ex, _nt = (
|
||||
_role_common_counts(users_obj) if isinstance(users_obj, dict) else (0, 0, 0, 0)
|
||||
)
|
||||
role_summaries.append(
|
||||
{
|
||||
"role": "users",
|
||||
"summary": f"{len(user_entries)} user(s), {mf} file(s), {ex} excluded",
|
||||
"notes": users_obj.get("notes") or [],
|
||||
}
|
||||
)
|
||||
|
||||
# Services
|
||||
services_list = roles.get("services") or []
|
||||
if isinstance(services_list, list):
|
||||
total_mf = sum(
|
||||
len((s.get("managed_files") or []))
|
||||
for s in services_list
|
||||
if isinstance(s, dict)
|
||||
)
|
||||
total_ex = sum(
|
||||
len((s.get("excluded") or [])) for s in services_list if isinstance(s, dict)
|
||||
)
|
||||
role_summaries.append(
|
||||
{
|
||||
"role": "services",
|
||||
"summary": f"{len(services_list)} unit(s), {total_mf} file(s), {total_ex} excluded",
|
||||
"units": [
|
||||
{
|
||||
"unit": s.get("unit"),
|
||||
"active_state": s.get("active_state"),
|
||||
"sub_state": s.get("sub_state"),
|
||||
"unit_file_state": s.get("unit_file_state"),
|
||||
"condition_result": s.get("condition_result"),
|
||||
}
|
||||
for s in services_list
|
||||
if isinstance(s, dict)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Package snapshots
|
||||
pkgs_list = roles.get("packages") or []
|
||||
if isinstance(pkgs_list, list):
|
||||
total_mf = sum(
|
||||
len((p.get("managed_files") or []))
|
||||
for p in pkgs_list
|
||||
if isinstance(p, dict)
|
||||
)
|
||||
total_ex = sum(
|
||||
len((p.get("excluded") or [])) for p in pkgs_list if isinstance(p, dict)
|
||||
)
|
||||
role_summaries.append(
|
||||
{
|
||||
"role": "packages",
|
||||
"summary": f"{len(pkgs_list)} package snapshot(s), {total_mf} file(s), {total_ex} excluded",
|
||||
"packages": [
|
||||
p.get("package") for p in pkgs_list if isinstance(p, dict)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Runtime firewall snapshot
|
||||
firewall_obj = roles.get("firewall_runtime") or {}
|
||||
if isinstance(firewall_obj, dict) and firewall_obj:
|
||||
captures = [
|
||||
key
|
||||
for key in ("ipset_save", "iptables_v4_save", "iptables_v6_save")
|
||||
if firewall_obj.get(key)
|
||||
]
|
||||
role_summaries.append(
|
||||
{
|
||||
"role": "firewall_runtime",
|
||||
"summary": f"{len(captures)} snapshot(s), {len(firewall_obj.get('ipset_sets') or [])} ipset(s)",
|
||||
"notes": firewall_obj.get("notes") or [],
|
||||
}
|
||||
)
|
||||
|
||||
# Single snapshots
|
||||
for rname in [
|
||||
"apt_config",
|
||||
"dnf_config",
|
||||
"sysctl",
|
||||
"etc_custom",
|
||||
"usr_local_custom",
|
||||
"extra_paths",
|
||||
]:
|
||||
robj = roles.get(rname) or {}
|
||||
if not isinstance(robj, dict):
|
||||
continue
|
||||
mf, md, ex, _nt = _role_common_counts(robj)
|
||||
extra: Dict[str, Any] = {}
|
||||
if rname == "extra_paths":
|
||||
extra = {
|
||||
"include_patterns": robj.get("include_patterns") or [],
|
||||
"exclude_patterns": robj.get("exclude_patterns") or [],
|
||||
}
|
||||
role_summaries.append(
|
||||
{
|
||||
"role": rname,
|
||||
"summary": f"{mf} file(s), {md} dir(s), {ex} excluded",
|
||||
"notes": robj.get("notes") or [],
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
|
||||
# Flatten managed/excluded across roles
|
||||
all_managed_files: List[Dict[str, Any]] = []
|
||||
all_managed_dirs: List[Dict[str, Any]] = []
|
||||
all_excluded: List[Dict[str, Any]] = []
|
||||
|
||||
def _consume_role(role_obj: Dict[str, Any]) -> None:
|
||||
for f in role_obj.get("managed_files") or []:
|
||||
if isinstance(f, dict):
|
||||
all_managed_files.append(f)
|
||||
for d in role_obj.get("managed_dirs") or []:
|
||||
if isinstance(d, dict):
|
||||
all_managed_dirs.append(d)
|
||||
for e in role_obj.get("excluded") or []:
|
||||
if isinstance(e, dict):
|
||||
all_excluded.append(e)
|
||||
|
||||
if isinstance(users_obj, dict):
|
||||
_consume_role(users_obj)
|
||||
if isinstance(services_list, list):
|
||||
for s in services_list:
|
||||
if isinstance(s, dict):
|
||||
_consume_role(s)
|
||||
if isinstance(pkgs_list, list):
|
||||
for p in pkgs_list:
|
||||
if isinstance(p, dict):
|
||||
_consume_role(p)
|
||||
for rname in [
|
||||
"apt_config",
|
||||
"dnf_config",
|
||||
"sysctl",
|
||||
"etc_custom",
|
||||
"usr_local_custom",
|
||||
"extra_paths",
|
||||
]:
|
||||
robj = roles.get(rname)
|
||||
if isinstance(robj, dict):
|
||||
_consume_role(robj)
|
||||
|
||||
managed_file_reasons = _summarize_reasons(
|
||||
all_managed_files,
|
||||
"reason",
|
||||
mapping=_MANAGED_FILE_REASONS,
|
||||
max_examples=max_examples,
|
||||
)
|
||||
managed_dir_reasons = _summarize_reasons(
|
||||
all_managed_dirs,
|
||||
"reason",
|
||||
mapping=_MANAGED_DIR_REASONS,
|
||||
max_examples=max_examples,
|
||||
)
|
||||
excluded_reasons = _summarize_reasons(
|
||||
all_excluded,
|
||||
"reason",
|
||||
mapping=_EXCLUDED_REASONS,
|
||||
max_examples=max_examples,
|
||||
)
|
||||
|
||||
# Inventory observed_via breakdown (count packages that contain at least one entry for that kind)
|
||||
observed_kinds: Counter[str] = Counter()
|
||||
observed_refs: Dict[str, Counter[str]] = defaultdict(Counter)
|
||||
for _pkg, entry in inv_pkgs.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
seen_kinds = set()
|
||||
for ov in entry.get("observed_via") or []:
|
||||
if not isinstance(ov, dict):
|
||||
continue
|
||||
kind = ov.get("kind")
|
||||
if not kind:
|
||||
continue
|
||||
kind = str(kind)
|
||||
seen_kinds.add(kind)
|
||||
ref = ov.get("ref")
|
||||
if isinstance(ref, str) and ref:
|
||||
observed_refs[kind][ref] += 1
|
||||
for k in seen_kinds:
|
||||
observed_kinds[k] += 1
|
||||
|
||||
observed_via_summary: List[Dict[str, Any]] = []
|
||||
for kind, cnt in observed_kinds.most_common():
|
||||
info = _ri(_OBSERVED_VIA, kind)
|
||||
top_refs = [
|
||||
r for r, _ in observed_refs.get(kind, Counter()).most_common(max_examples)
|
||||
]
|
||||
observed_via_summary.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"count": cnt,
|
||||
"title": info.title,
|
||||
"why": info.why,
|
||||
"top_refs": top_refs,
|
||||
}
|
||||