Compare commits
177 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 |
98 changed files with 25591 additions and 5743 deletions
|
|
@ -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 jq python3-jsonschema
|
||||
|
||||
- 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
|
||||
|
|
|
|||
51
CHANGELOG.md
51
CHANGELOG.md
|
|
@ -1,3 +1,54 @@
|
|||
# 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.
|
||||
|
|
|
|||
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 \
|
||||
|
|
|
|||
119
README.md
119
README.md
|
|
@ -4,17 +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 (Debian-like or RedHat-like) 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 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 and any .bashrc or .bash_aliases or .profile files that deviate from the skel defaults.
|
||||
- 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'
|
||||
- Ditto for /usr/local/bin (for non-binary files) and /usr/local/etc
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
|
@ -24,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:
|
||||
|
||||
|
|
@ -35,8 +38,6 @@ 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).
|
||||
|
||||
|
|
@ -69,12 +70,16 @@ 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
|
||||
- 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`
|
||||
- `--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
|
||||
|
|
@ -89,8 +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 sudo requires password?
|
||||
- `--ask-become-pass` (or `-K`) will prompt for the password. If you forget, and remote requires password for sudo, it'll still fall back to prompting for a password, but will be a bit slower to do so.
|
||||
* 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -102,11 +129,12 @@ 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:
|
||||
|
|
@ -119,6 +147,10 @@ Example:
|
|||
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.
|
||||
|
||||
---
|
||||
|
||||
### `enroll single-shot`
|
||||
|
|
@ -126,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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -144,24 +176,11 @@ Compare two harvest bundles and report what changed.
|
|||
- `--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)
|
||||
- `--enforce` to apply the **old** harvest state locally (requires `ansible-playbook` on `PATH`)
|
||||
|
||||
**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.
|
||||
|
||||
**Enforcement (`--enforce`)**
|
||||
If a diff exists and `ansible-playbook` is available, Enroll will:
|
||||
1) generate a manifest from the **old** harvest into a temporary directory
|
||||
2) run `ansible-playbook -i localhost, -c local <tmp>/playbook.yml` (often with `--tags role_<...>` to limit runtime)
|
||||
3) record in the diff report that the old harvest was enforced
|
||||
|
||||
Enforcement is intentionally “safe”:
|
||||
- reinstalls packages that were removed (`state: present`), but does **not** attempt downgrades/pinning
|
||||
- restores users, files (contents + permissions/ownership), and service enable/start state
|
||||
|
||||
If `ansible-playbook` is not on `PATH`, Enroll returns an error and does not enforce.
|
||||
|
||||
|
||||
**Output formats**
|
||||
- `--format json` (default for webhooks)
|
||||
|
|
@ -247,12 +266,21 @@ enroll validate ./harvest --fail-on-warnings
|
|||
|
||||
By default, `enroll` does **not** assume how you handle secrets in Ansible. It will attempt to avoid harvesting likely sensitive data (private keys, passwords, tokens, etc.). This can mean it skips some config files you may ultimately want to manage.
|
||||
|
||||
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.
|
||||
|
|
@ -261,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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -355,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)
|
||||
|
|
@ -402,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)
|
||||
|
|
@ -452,11 +496,6 @@ enroll diff --old /path/to/harvestA --new /path/to/harvestB --exclude-path /var/
|
|||
enroll diff --old /path/to/harvestA --new /path/to/harvestB --ignore-package-versions
|
||||
```
|
||||
|
||||
### Enforce the old harvest state when drift is detected (requires Ansible)
|
||||
```bash
|
||||
enroll diff --old /path/to/harvestA --new /path/to/harvestB --enforce --ignore-package-versions --exclude-path /var/anacron
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Explain
|
||||
|
|
@ -504,6 +543,7 @@ Roles collected
|
|||
- 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
|
||||
|
|
@ -549,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.
|
||||
|
||||
|
|
@ -585,13 +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
|
||||
# enforce = true # requires ansible-playbook on PATH
|
||||
|
||||
[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.
|
||||
64
debian/changelog
vendored
64
debian/changelog
vendored
|
|
@ -1,5 +1,67 @@
|
|||
enroll (0.4.0) 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
404
enroll/cli.py
404
enroll/cli.py
|
|
@ -4,6 +4,7 @@ import argparse
|
|||
import configparser
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
|
@ -13,16 +14,19 @@ from typing import Optional
|
|||
from .cache import new_harvest_cache_dir
|
||||
from .diff import (
|
||||
compare_harvests,
|
||||
enforce_old_harvest,
|
||||
format_report,
|
||||
has_enforceable_drift,
|
||||
post_webhook,
|
||||
send_email,
|
||||
)
|
||||
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, RemoteSudoPasswordRequired
|
||||
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
|
||||
|
|
@ -35,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.
|
||||
"""
|
||||
|
|
@ -62,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()
|
||||
|
|
@ -111,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 []:
|
||||
|
|
@ -134,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]:
|
||||
|
|
@ -275,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=".")
|
||||
|
|
@ -285,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(
|
||||
|
|
@ -306,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(
|
||||
|
|
@ -321,12 +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"
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _add_config_args(p: argparse.ArgumentParser) -> None:
|
||||
|
|
@ -334,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(
|
||||
|
|
@ -345,21 +505,53 @@ def _add_config_args(p: argparse.ArgumentParser) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _add_path_safety_args(
|
||||
p: argparse.ArgumentParser, *, default: object = False
|
||||
) -> None:
|
||||
p.add_argument(
|
||||
"--assume-safe-path",
|
||||
action="store_true",
|
||||
default=default,
|
||||
help=(
|
||||
"When running as root, continue without confirmation even if PATH "
|
||||
"contains '.', an empty/relative entry, or a group/world-writable "
|
||||
"directory. Intended for trusted non-interactive automation."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _add_remote_args(p: argparse.ArgumentParser) -> None:
|
||||
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.
|
||||
|
|
@ -373,6 +565,24 @@ def _add_remote_args(p: argparse.ArgumentParser) -> None:
|
|||
),
|
||||
)
|
||||
|
||||
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")
|
||||
|
|
@ -383,10 +593,12 @@ def main() -> None:
|
|||
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",
|
||||
|
|
@ -420,7 +632,6 @@ def main() -> None:
|
|||
"Excludes apply to all harvesting, including defaults."
|
||||
),
|
||||
)
|
||||
|
||||
h.add_argument(
|
||||
"--sops",
|
||||
nargs="+",
|
||||
|
|
@ -436,8 +647,11 @@ def main() -> None:
|
|||
help="Don't use sudo on the remote host (when using --remote options). This may result in a limited harvest due to permission restrictions.",
|
||||
)
|
||||
|
||||
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,
|
||||
|
|
@ -468,9 +682,11 @@ 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",
|
||||
|
|
@ -504,7 +720,6 @@ def main() -> None:
|
|||
"Excludes apply to all harvesting, including defaults."
|
||||
),
|
||||
)
|
||||
|
||||
s.add_argument(
|
||||
"--sops",
|
||||
nargs="+",
|
||||
|
|
@ -532,6 +747,7 @@ def main() -> None:
|
|||
|
||||
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,
|
||||
|
|
@ -575,15 +791,6 @@ def main() -> None:
|
|||
"Package additions/removals are still reported. Useful when routine upgrades would otherwise create noisy drift."
|
||||
),
|
||||
)
|
||||
d.add_argument(
|
||||
"--enforce",
|
||||
action="store_true",
|
||||
help=(
|
||||
"If differences are detected, attempt to enforce the old harvest state locally by generating a manifest and "
|
||||
"running ansible-playbook. Requires ansible-playbook on PATH. "
|
||||
"Enroll does not attempt to downgrade packages; if the only drift is package version upgrades (or newly installed packages), enforcement is skipped."
|
||||
),
|
||||
)
|
||||
d.add_argument(
|
||||
"--out",
|
||||
help="Write the report to this file instead of stdout.",
|
||||
|
|
@ -644,6 +851,7 @@ def main() -> None:
|
|||
|
||||
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=(
|
||||
|
|
@ -672,6 +880,7 @@ def main() -> None:
|
|||
"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=(
|
||||
|
|
@ -686,8 +895,17 @@ def main() -> None:
|
|||
v.add_argument(
|
||||
"--schema",
|
||||
help=(
|
||||
"Optional JSON schema source (file path or https:// URL). "
|
||||
"If omitted, uses the schema vendored in the enroll codebase."
|
||||
"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(
|
||||
|
|
@ -728,6 +946,24 @@ def main() -> None:
|
|||
)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
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)
|
||||
|
|
@ -743,14 +979,20 @@ def main() -> None:
|
|||
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=args.remote_host,
|
||||
remote_port=int(args.remote_port),
|
||||
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)
|
||||
|
|
@ -764,14 +1006,20 @@ def main() -> None:
|
|||
)
|
||||
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=args.remote_host,
|
||||
remote_port=int(args.remote_port),
|
||||
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:
|
||||
|
|
@ -789,6 +1037,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)
|
||||
|
|
@ -808,6 +1057,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=not bool(args.out),
|
||||
)
|
||||
print(path)
|
||||
elif args.cmd == "explain":
|
||||
|
|
@ -825,6 +1075,7 @@ def main() -> None:
|
|||
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"))
|
||||
|
|
@ -835,9 +1086,7 @@ def main() -> None:
|
|||
|
||||
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="validation report")
|
||||
else:
|
||||
sys.stdout.write(txt)
|
||||
|
||||
|
|
@ -853,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))
|
||||
|
|
@ -867,47 +1117,10 @@ def main() -> None:
|
|||
),
|
||||
)
|
||||
|
||||
# Optional enforcement: if drift is detected, attempt to restore the
|
||||
# system to the *old* (baseline) state using ansible-playbook.
|
||||
if bool(getattr(args, "enforce", False)):
|
||||
if has_changes:
|
||||
if not has_enforceable_drift(report):
|
||||
report["enforcement"] = {
|
||||
"requested": True,
|
||||
"status": "skipped",
|
||||
"reason": (
|
||||
"no enforceable drift detected (only additions and/or package version changes); "
|
||||
"enroll does not attempt to downgrade packages"
|
||||
),
|
||||
}
|
||||
else:
|
||||
try:
|
||||
info = enforce_old_harvest(
|
||||
args.old,
|
||||
sops_mode=bool(getattr(args, "sops", False)),
|
||||
report=report,
|
||||
)
|
||||
except Exception as e:
|
||||
raise SystemExit(
|
||||
f"error: could not enforce old harvest state: {e}"
|
||||
) from e
|
||||
report["enforcement"] = {
|
||||
"requested": True,
|
||||
**(info or {}),
|
||||
}
|
||||
else:
|
||||
report["enforcement"] = {
|
||||
"requested": True,
|
||||
"status": "skipped",
|
||||
"reason": "no differences detected",
|
||||
}
|
||||
|
||||
txt = format_report(report, fmt=str(getattr(args, "format", "text")))
|
||||
out_path = getattr(args, "out", None)
|
||||
if out_path:
|
||||
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")
|
||||
|
||||
|
|
@ -966,14 +1179,20 @@ def main() -> None:
|
|||
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=args.remote_host,
|
||||
remote_port=int(args.remote_port),
|
||||
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)
|
||||
|
|
@ -985,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))
|
||||
|
|
@ -996,20 +1216,27 @@ def main() -> None:
|
|||
)
|
||||
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=args.remote_host,
|
||||
remote_port=int(args.remote_port),
|
||||
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:
|
||||
|
|
@ -1029,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)
|
||||
|
|
@ -1040,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))
|
||||
|
|
@ -1059,11 +1288,18 @@ def main() -> None:
|
|||
args.out,
|
||||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
)
|
||||
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:
|
||||
|
|
|
|||
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"
|
||||
)
|
||||
|
|
@ -69,7 +69,7 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
|
|||
Uses dpkg-query and is expected to work on Debian/Ubuntu-like systems.
|
||||
|
||||
Output format:
|
||||
{"pkg": [{"version": "...", "arch": "..."}, ...], ...}
|
||||
{"pkg": [{"version": "...", "arch": "...", "section": "..."}, ...], ...}
|
||||
"""
|
||||
|
||||
try:
|
||||
|
|
@ -77,7 +77,7 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
|
|||
[
|
||||
"dpkg-query",
|
||||
"-W",
|
||||
"-f=${Package}\t${Version}\t${Architecture}\n",
|
||||
"-f=${Package}\t${Version}\t${Architecture}\t${Section}\n",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
|
|
@ -97,7 +97,10 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
|
|||
name, ver, arch = parts[0].strip(), parts[1].strip(), parts[2].strip()
|
||||
if not name:
|
||||
continue
|
||||
out.setdefault(name, []).append({"version": ver, "arch": arch})
|
||||
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()):
|
||||
|
|
@ -183,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:
|
||||
|
|
@ -220,6 +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
|
||||
|
|
|
|||
618
enroll/diff.py
618
enroll/diff.py
|
|
@ -3,11 +3,14 @@ from __future__ import annotations
|
|||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
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
|
||||
|
|
@ -17,8 +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:
|
||||
|
|
@ -49,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:
|
||||
|
|
@ -60,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()
|
||||
|
|
@ -69,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():
|
||||
|
|
@ -122,24 +230,10 @@ 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 _packages_inventory(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return (state.get("inventory") or {}).get("packages") or {}
|
||||
|
||||
|
||||
def _all_packages(state: Dict[str, Any]) -> List[str]:
|
||||
return sorted(_packages_inventory(state).keys())
|
||||
|
||||
|
||||
def _roles(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return state.get("roles") or {}
|
||||
|
||||
|
||||
def _pkg_version_key(entry: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return a stable string used for version comparison."""
|
||||
installs = entry.get("installations") or []
|
||||
|
|
@ -236,6 +330,12 @@ def _iter_managed_files(state: Dict[str, Any]) -> Iterable[Tuple[str, Dict[str,
|
|||
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 = _roles(state).get("etc_custom") or {}
|
||||
ec_role = ec.get("role_name") or "etc_custom"
|
||||
|
|
@ -299,13 +399,16 @@ def compare_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)
|
||||
|
||||
|
|
@ -539,276 +642,6 @@ def compare_harvests(
|
|||
return report, has_changes
|
||||
|
||||
|
||||
def has_enforceable_drift(report: Dict[str, Any]) -> bool:
|
||||
"""Return True if the diff report contains drift that is safe/meaningful to enforce.
|
||||
|
||||
Enforce mode is intended to restore *state* (files/users/services) and to
|
||||
reinstall packages that were removed.
|
||||
|
||||
It is deliberately conservative about package drift:
|
||||
- Package *version* changes alone are not enforced (no downgrades).
|
||||
- Newly installed packages are not removed.
|
||||
|
||||
This helper lets the CLI decide whether `--enforce` should actually run.
|
||||
"""
|
||||
|
||||
pk = report.get("packages", {}) or {}
|
||||
if pk.get("removed"):
|
||||
return True
|
||||
|
||||
sv = report.get("services", {}) or {}
|
||||
# We do not try to disable newly-enabled services; we only restore units
|
||||
# that were enabled in the baseline but are now missing.
|
||||
if sv.get("enabled_removed") or []:
|
||||
return True
|
||||
|
||||
for ch in sv.get("changed", []) or []:
|
||||
changes = ch.get("changes") or {}
|
||||
# Ignore package set drift for enforceability decisions; package
|
||||
# enforcement is handled via reinstalling removed packages, and we
|
||||
# avoid trying to "undo" upgrades/renames.
|
||||
for k in changes.keys():
|
||||
if k != "packages":
|
||||
return True
|
||||
|
||||
us = report.get("users", {}) or {}
|
||||
# We restore baseline users (missing/changed). We do not remove newly-added users.
|
||||
if (us.get("removed") or []) or (us.get("changed") or []):
|
||||
return True
|
||||
|
||||
fl = report.get("files", {}) or {}
|
||||
# We restore baseline files (missing/changed). We do not delete newly-managed files.
|
||||
if (fl.get("removed") or []) or (fl.get("changed") or []):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _role_tag(role: str) -> str:
|
||||
"""Return the Ansible tag name for a role (must match manifest generation)."""
|
||||
r = str(role or "").strip()
|
||||
safe = re.sub(r"[^A-Za-z0-9_-]+", "_", r).strip("_")
|
||||
if not safe:
|
||||
safe = "other"
|
||||
return f"role_{safe}"
|
||||
|
||||
|
||||
def _enforcement_plan(
|
||||
report: Dict[str, Any],
|
||||
old_state: Dict[str, Any],
|
||||
old_bundle_dir: Path,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a best-effort enforcement plan (roles/tags) for this diff report.
|
||||
|
||||
We only plan for drift that the baseline manifest can safely restore:
|
||||
- packages that were removed (reinstall, no downgrades)
|
||||
- baseline users that were removed/changed
|
||||
- baseline files that were removed/changed
|
||||
- baseline systemd units that were disabled/changed
|
||||
|
||||
We do NOT plan to remove newly-added packages/users/files/services.
|
||||
"""
|
||||
roles: set[str] = set()
|
||||
|
||||
# --- Packages (only removals)
|
||||
pk = report.get("packages", {}) or {}
|
||||
removed_pkgs = set(pk.get("removed") or [])
|
||||
if removed_pkgs:
|
||||
pkg_to_roles: Dict[str, set[str]] = {}
|
||||
|
||||
for svc in _roles(old_state).get("services") or []:
|
||||
r = str(svc.get("role_name") or "").strip()
|
||||
for p in svc.get("packages", []) or []:
|
||||
if p:
|
||||
pkg_to_roles.setdefault(str(p), set()).add(r)
|
||||
|
||||
for pr in _roles(old_state).get("packages") or []:
|
||||
r = str(pr.get("role_name") or "").strip()
|
||||
p = pr.get("package")
|
||||
if p:
|
||||
pkg_to_roles.setdefault(str(p), set()).add(r)
|
||||
|
||||
for p in removed_pkgs:
|
||||
for r in pkg_to_roles.get(str(p), set()):
|
||||
if r:
|
||||
roles.add(r)
|
||||
|
||||
# --- Users (removed/changed)
|
||||
us = report.get("users", {}) or {}
|
||||
if (us.get("removed") or []) or (us.get("changed") or []):
|
||||
u = _roles(old_state).get("users") or {}
|
||||
u_role = str(u.get("role_name") or "users")
|
||||
if u_role:
|
||||
roles.add(u_role)
|
||||
|
||||
# --- Files (removed/changed)
|
||||
fl = report.get("files", {}) or {}
|
||||
file_paths: List[str] = []
|
||||
for e in fl.get("removed", []) or []:
|
||||
if isinstance(e, dict):
|
||||
p = e.get("path")
|
||||
else:
|
||||
p = e
|
||||
if p:
|
||||
file_paths.append(str(p))
|
||||
for e in fl.get("changed", []) or []:
|
||||
if isinstance(e, dict):
|
||||
p = e.get("path")
|
||||
else:
|
||||
p = e
|
||||
if p:
|
||||
file_paths.append(str(p))
|
||||
|
||||
if file_paths:
|
||||
idx = _file_index(old_bundle_dir, old_state)
|
||||
for p in file_paths:
|
||||
rec = idx.get(p)
|
||||
if rec and rec.role:
|
||||
roles.add(str(rec.role))
|
||||
|
||||
# --- Services (enabled_removed + meaningful changes)
|
||||
sv = report.get("services", {}) or {}
|
||||
units: List[str] = []
|
||||
for u in sv.get("enabled_removed", []) or []:
|
||||
if u:
|
||||
units.append(str(u))
|
||||
for ch in sv.get("changed", []) or []:
|
||||
if not isinstance(ch, dict):
|
||||
continue
|
||||
unit = ch.get("unit")
|
||||
changes = ch.get("changes") or {}
|
||||
if unit and any(k != "packages" for k in changes.keys()):
|
||||
units.append(str(unit))
|
||||
|
||||
if units:
|
||||
old_units = _service_units(old_state)
|
||||
for u in units:
|
||||
snap = old_units.get(u)
|
||||
if snap and snap.get("role_name"):
|
||||
roles.add(str(snap.get("role_name")))
|
||||
|
||||
# Drop empty/unknown roles.
|
||||
roles = {r for r in roles if r and str(r).strip() and str(r).strip() != "unknown"}
|
||||
|
||||
tags = sorted({_role_tag(r) for r in roles})
|
||||
return {
|
||||
"roles": sorted(roles),
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
def enforce_old_harvest(
|
||||
old_path: str,
|
||||
*,
|
||||
sops_mode: bool = False,
|
||||
report: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Enforce the *old* (baseline) harvest state on the current machine.
|
||||
|
||||
When Ansible is available, this:
|
||||
1) renders a temporary manifest from the old harvest, and
|
||||
2) runs ansible-playbook locally to apply it.
|
||||
|
||||
Returns a dict suitable for attaching to the diff report under
|
||||
report['enforcement'].
|
||||
"""
|
||||
|
||||
ansible_playbook = shutil.which("ansible-playbook")
|
||||
if not ansible_playbook:
|
||||
raise RuntimeError(
|
||||
"ansible-playbook not found on PATH (cannot enforce; install Ansible)"
|
||||
)
|
||||
|
||||
# Import lazily to avoid heavy import cost and potential CLI cycles.
|
||||
from .manifest import manifest
|
||||
|
||||
started_at = _utc_now_iso()
|
||||
|
||||
with ExitStack() as stack:
|
||||
old_b = _bundle_from_input(old_path, sops_mode=sops_mode)
|
||||
if old_b.tempdir:
|
||||
stack.callback(old_b.tempdir.cleanup)
|
||||
|
||||
old_state = _load_state(old_b.dir)
|
||||
|
||||
plan: Optional[Dict[str, Any]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
roles: List[str] = []
|
||||
if report is not None:
|
||||
plan = _enforcement_plan(report, old_state, old_b.dir)
|
||||
roles = list(plan.get("roles") or [])
|
||||
t = list(plan.get("tags") or [])
|
||||
tags = t if t else None
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="enroll-enforce-") as td:
|
||||
td_path = Path(td)
|
||||
try:
|
||||
os.chmod(td_path, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 1) Generate a manifest in a temp directory.
|
||||
manifest(str(old_b.dir), str(td_path))
|
||||
|
||||
playbook = td_path / "playbook.yml"
|
||||
if not playbook.exists():
|
||||
raise RuntimeError(
|
||||
f"manifest did not produce expected playbook.yml at {playbook}"
|
||||
)
|
||||
|
||||
# 2) Apply it locally.
|
||||
env = dict(os.environ)
|
||||
cfg = td_path / "ansible.cfg"
|
||||
if cfg.exists():
|
||||
env["ANSIBLE_CONFIG"] = str(cfg)
|
||||
|
||||
cmd = [
|
||||
ansible_playbook,
|
||||
"-i",
|
||||
"localhost,",
|
||||
"-c",
|
||||
"local",
|
||||
str(playbook),
|
||||
]
|
||||
if tags:
|
||||
cmd.extend(["--tags", ",".join(tags)])
|
||||
p = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(td_path),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
) # nosec
|
||||
|
||||
finished_at = _utc_now_iso()
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"status": "applied" if p.returncode == 0 else "failed",
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"ansible_playbook": ansible_playbook,
|
||||
"command": cmd,
|
||||
"returncode": int(p.returncode),
|
||||
}
|
||||
|
||||
# Record tag selection (if we could attribute drift to specific roles).
|
||||
info["roles"] = roles
|
||||
info["tags"] = list(tags or [])
|
||||
if not tags:
|
||||
info["scope"] = "full_playbook"
|
||||
|
||||
if p.returncode != 0:
|
||||
err = (p.stderr or p.stdout or "").strip()
|
||||
raise RuntimeError(
|
||||
"ansible-playbook failed"
|
||||
+ (f" (rc={p.returncode})" if p.returncode is not None else "")
|
||||
+ (f": {err}" if err else "")
|
||||
)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def format_report(report: Dict[str, Any], *, fmt: str = "text") -> str:
|
||||
fmt = (fmt or "text").lower()
|
||||
if fmt == "json":
|
||||
|
|
@ -819,19 +652,26 @@ 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(str(p) for p in ex_paths)}")
|
||||
lines.append(f"file exclude patterns: {', '.join(s(p) for p in ex_paths)}")
|
||||
|
||||
if filt.get("ignore_package_versions"):
|
||||
ignored = int(
|
||||
|
|
@ -842,38 +682,6 @@ def _report_text(report: Dict[str, Any]) -> str:
|
|||
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
|
||||
lines.append(msg)
|
||||
|
||||
enf = report.get("enforcement") or {}
|
||||
if enf:
|
||||
lines.append("\nEnforcement")
|
||||
status = str(enf.get("status") or "").strip().lower()
|
||||
if status == "applied":
|
||||
extra = ""
|
||||
tags = enf.get("tags") or []
|
||||
scope = enf.get("scope")
|
||||
if tags:
|
||||
extra = f" (tags={','.join(str(t) for t in tags)})"
|
||||
elif scope:
|
||||
extra = f" ({scope})"
|
||||
lines.append(
|
||||
f" applied old harvest via ansible-playbook (rc={enf.get('returncode')})"
|
||||
+ extra
|
||||
+ (
|
||||
f" (finished {enf.get('finished_at')})"
|
||||
if enf.get("finished_at")
|
||||
else ""
|
||||
)
|
||||
)
|
||||
elif status == "failed":
|
||||
lines.append(
|
||||
f" attempted enforcement but ansible-playbook failed (rc={enf.get('returncode')})"
|
||||
)
|
||||
elif status == "skipped":
|
||||
r = enf.get("reason")
|
||||
lines.append(" skipped" + (f": {r}" if r else ""))
|
||||
else:
|
||||
# Best-effort formatting for future fields.
|
||||
lines.append(" " + json.dumps(enf, sort_keys=True))
|
||||
|
||||
pk = report.get("packages", {})
|
||||
lines.append("\nPackages")
|
||||
lines.append(f" added: {len(pk.get('added', []) or [])}")
|
||||
|
|
@ -883,73 +691,77 @@ def _report_text(report: Dict[str, Any]) -> str:
|
|||
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" ~ {ch.get('package')}: {ch.get('old')} -> {ch.get('new')}")
|
||||
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(
|
||||
[
|
||||
|
|
@ -973,14 +785,23 @@ 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 {}
|
||||
|
|
@ -988,7 +809,7 @@ def _report_markdown(report: Dict[str, Any]) -> str:
|
|||
if ex_paths:
|
||||
out.append(
|
||||
"- **File exclude patterns**: "
|
||||
+ ", ".join(f"`{p}`" for p in ex_paths)
|
||||
+ ", ".join(f"`{m(p)}`" for p in ex_paths)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
|
@ -1001,57 +822,14 @@ def _report_markdown(report: Dict[str, Any]) -> str:
|
|||
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
|
||||
out.append(msg + "\n")
|
||||
|
||||
enf = report.get("enforcement") or {}
|
||||
if enf:
|
||||
out.append("\n## Enforcement\n")
|
||||
status = str(enf.get("status") or "").strip().lower()
|
||||
if status == "applied":
|
||||
extra = ""
|
||||
tags = enf.get("tags") or []
|
||||
scope = enf.get("scope")
|
||||
if tags:
|
||||
extra = " (tags=" + ",".join(str(t) for t in tags) + ")"
|
||||
elif scope:
|
||||
extra = f" ({scope})"
|
||||
out.append(
|
||||
"- ✅ Applied old harvest via ansible-playbook"
|
||||
+ extra
|
||||
+ (
|
||||
f" (rc={enf.get('returncode')})"
|
||||
if enf.get("returncode") is not None
|
||||
else ""
|
||||
)
|
||||
+ (
|
||||
f" (finished `{enf.get('finished_at')}`)"
|
||||
if enf.get("finished_at")
|
||||
else ""
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
elif status == "failed":
|
||||
out.append(
|
||||
"- ⚠️ Attempted enforcement but ansible-playbook failed"
|
||||
+ (
|
||||
f" (rc={enf.get('returncode')})"
|
||||
if enf.get("returncode") is not None
|
||||
else ""
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
elif status == "skipped":
|
||||
r = enf.get("reason")
|
||||
out.append("- Skipped" + (f": {r}" if r else "") + "\n")
|
||||
else:
|
||||
out.append(f"- {json.dumps(enf, sort_keys=True)}\n")
|
||||
|
||||
pk = report.get("packages", {})
|
||||
out.append("## Packages\n")
|
||||
out.append(f"- Added: {len(pk.get('added', []) or [])}\n")
|
||||
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 [])
|
||||
|
|
@ -1059,7 +837,7 @@ def _report_markdown(report: Dict[str, Any]) -> str:
|
|||
out.append(f"- Version changed: {vc}{suffix}\n")
|
||||
for ch in pk.get("version_changed", []) or []:
|
||||
out.append(
|
||||
f" - `~ {ch.get('package')}`: `{ch.get('old')}` → `{ch.get('new')}`\n"
|
||||
f" - `~ {m(ch.get('package'))}`: `{m(ch.get('old'))}` → `{m(ch.get('new'))}`\n"
|
||||
)
|
||||
|
||||
sv = report.get("services", {})
|
||||
|
|
@ -1067,60 +845,64 @@ def _report_markdown(report: Dict[str, Any]) -> str:
|
|||
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")
|
||||
|
|
@ -1128,29 +910,31 @@ 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(
|
||||
[
|
||||
|
|
@ -1252,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 "")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ from collections import Counter, defaultdict
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from .diff import _bundle_from_input, _load_state # reuse existing bundle handling
|
||||
from .diff import _bundle_from_input # reuse existing bundle handling
|
||||
from .state import load_state
|
||||
from .cm import sanitize_report_text
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -72,7 +74,7 @@ _MANAGED_FILE_REASONS: Dict[str, ReasonInfo] = {
|
|||
),
|
||||
"system_firewall": ReasonInfo(
|
||||
"Firewall configuration",
|
||||
"Firewall rules/configuration (ufw, nftables, iptables, etc.).",
|
||||
"Firewall rules/configuration (ufw, nftables, iptables, ipset, etc.).",
|
||||
),
|
||||
"system_sysctl": ReasonInfo(
|
||||
"sysctl configuration",
|
||||
|
|
@ -188,6 +190,12 @@ _EXCLUDED_REASONS: Dict[str, 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).",
|
||||
|
|
@ -211,6 +219,10 @@ _OBSERVED_VIA: Dict[str, 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.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -285,7 +297,7 @@ def explain_state(
|
|||
- a SOPS-encrypted bundle (.sops)
|
||||
"""
|
||||
bundle = _bundle_from_input(harvest, sops_mode=sops_mode)
|
||||
state = _load_state(bundle.dir)
|
||||
state = load_state(bundle.dir)
|
||||
|
||||
host = state.get("host") or {}
|
||||
enroll = state.get("enroll") or {}
|
||||
|
|
@ -359,10 +371,27 @@ def explain_state(
|
|||
}
|
||||
)
|
||||
|
||||
# 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",
|
||||
|
|
@ -415,6 +444,7 @@ def explain_state(
|
|||
for rname in [
|
||||
"apt_config",
|
||||
"dnf_config",
|
||||
"sysctl",
|
||||
"etc_custom",
|
||||
"usr_local_custom",
|
||||
"extra_paths",
|
||||
|
|
@ -498,15 +528,25 @@ def explain_state(
|
|||
if fmt == "json":
|
||||
return json.dumps(report, indent=2, sort_keys=True)
|
||||
|
||||
# Text rendering
|
||||
# Text rendering.
|
||||
#
|
||||
# Harvested, attacker-influenceable values (host name, file paths used as
|
||||
# examples, include/exclude patterns, snapshot notes) are interpolated into
|
||||
# this human-readable text. Route each through ``sanitize_report_text`` (the
|
||||
# same helper the diff text report uses) so a value containing a raw newline
|
||||
# cannot forge an additional output line and a control byte cannot smuggle a
|
||||
# terminal escape sequence when the explanation is printed or written with
|
||||
# --out. The JSON branch above does not need this: json.dumps already escapes
|
||||
# control characters and cannot have its structure altered by a string value.
|
||||
s = sanitize_report_text
|
||||
out: List[str] = []
|
||||
out.append(f"Enroll explained: {harvest}")
|
||||
out.append(f"Enroll explained: {s(harvest)}")
|
||||
hn = host.get("hostname") or "(unknown host)"
|
||||
os_family = host.get("os") or "unknown"
|
||||
pkg_backend = host.get("pkg_backend") or "?"
|
||||
ver = enroll.get("version") or "?"
|
||||
out.append(f"Host: {hn} (os: {os_family}, pkg: {pkg_backend})")
|
||||
out.append(f"Enroll: {ver}")
|
||||
out.append(f"Host: {s(hn)} (os: {s(os_family)}, pkg: {s(pkg_backend)})")
|
||||
out.append(f"Enroll: {s(ver)}")
|
||||
out.append("")
|
||||
|
||||
out.append("Inventory")
|
||||
|
|
@ -516,30 +556,30 @@ def explain_state(
|
|||
for ov in observed_via_summary:
|
||||
extra = ""
|
||||
if ov.get("top_refs"):
|
||||
extra = f" (e.g. {', '.join(ov['top_refs'])})"
|
||||
out.append(f" - {ov['kind']}: {ov['count']} – {ov['why']}{extra}")
|
||||
extra = f" (e.g. {', '.join(s(x) for x in ov['top_refs'])})"
|
||||
out.append(f" - {s(ov['kind'])}: {ov['count']} – {s(ov['why'])}{extra}")
|
||||
out.append("")
|
||||
|
||||
out.append("Roles collected")
|
||||
for rs in role_summaries:
|
||||
out.append(f"- {rs['role']}: {rs['summary']}")
|
||||
out.append(f"- {s(rs['role'])}: {s(rs['summary'])}")
|
||||
if rs["role"] == "extra_paths":
|
||||
inc = rs.get("include_patterns") or []
|
||||
exc = rs.get("exclude_patterns") or []
|
||||
if inc:
|
||||
suffix = "…" if len(inc) > max_examples else ""
|
||||
out.append(
|
||||
f" include_patterns: {', '.join(map(str, inc[:max_examples]))}{suffix}"
|
||||
f" include_patterns: {', '.join(s(x) for x in inc[:max_examples])}{suffix}"
|
||||
)
|
||||
if exc:
|
||||
suffix = "…" if len(exc) > max_examples else ""
|
||||
out.append(
|
||||
f" exclude_patterns: {', '.join(map(str, exc[:max_examples]))}{suffix}"
|
||||
f" exclude_patterns: {', '.join(s(x) for x in exc[:max_examples])}{suffix}"
|
||||
)
|
||||
notes = rs.get("notes") or []
|
||||
if notes:
|
||||
for n in notes[:max_examples]:
|
||||
out.append(f" note: {n}")
|
||||
out.append(f" note: {s(n)}")
|
||||
if len(notes) > max_examples:
|
||||
out.append(
|
||||
f" note: (+{len(notes) - max_examples} more. Use --format json to see them all)"
|
||||
|
|
@ -550,8 +590,8 @@ def explain_state(
|
|||
if managed_file_reasons:
|
||||
for r in managed_file_reasons[:15]:
|
||||
exs = r.get("examples") or []
|
||||
ex_txt = f" Examples: {', '.join(exs)}" if exs else ""
|
||||
out.append(f"- {r['reason']} ({r['count']}): {r['why']}.{ex_txt}")
|
||||
ex_txt = f" Examples: {', '.join(s(x) for x in exs)}" if exs else ""
|
||||
out.append(f"- {s(r['reason'])} ({r['count']}): {s(r['why'])}.{ex_txt}")
|
||||
if len(managed_file_reasons) > 15:
|
||||
out.append(
|
||||
f"- (+{len(managed_file_reasons) - 15} more reasons. Use --format json to see them all)"
|
||||
|
|
@ -563,15 +603,15 @@ def explain_state(
|
|||
out.append("")
|
||||
out.append("Why directories were included (managed_dirs.reason)")
|
||||
for r in managed_dir_reasons:
|
||||
out.append(f"- {r['reason']} ({r['count']}): {r['why']}")
|
||||
out.append(f"- {s(r['reason'])} ({r['count']}): {s(r['why'])}")
|
||||
|
||||
out.append("")
|
||||
out.append("Why paths were excluded")
|
||||
if excluded_reasons:
|
||||
for r in excluded_reasons:
|
||||
exs = r.get("examples") or []
|
||||
ex_txt = f" Examples: {', '.join(exs)}" if exs else ""
|
||||
out.append(f"- {r['reason']} ({r['count']}): {r['why']}.{ex_txt}")
|
||||
ex_txt = f" Examples: {', '.join(s(x) for x in exs)}" if exs else ""
|
||||
out.append(f"- {s(r['reason'])} ({r['count']}): {s(r['why'])}.{ex_txt}")
|
||||
else:
|
||||
out.append("- (no excluded paths)")
|
||||
|
||||
|
|
|
|||
258
enroll/fsutil.py
258
enroll/fsutil.py
|
|
@ -1,10 +1,252 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def open_no_follow_path(
|
||||
path: str,
|
||||
*,
|
||||
write: bool = False,
|
||||
mode: int = 0o600,
|
||||
directory: bool = False,
|
||||
) -> int:
|
||||
"""Open ``path`` without following a symlink in *any* path component.
|
||||
|
||||
``O_NOFOLLOW`` only protects the final component of a path. A regular
|
||||
file reached through a symlinked *parent* directory (for example a user
|
||||
replacing ``~/.ssh`` with a link to a sensitive directory) would still be
|
||||
opened by a plain ``os.open(path, O_NOFOLLOW)``.
|
||||
|
||||
This helper resolves the path one component at a time with ``openat``
|
||||
semantics:
|
||||
|
||||
- each intermediate component is opened relative to its parent's
|
||||
descriptor without following symlinks;
|
||||
- the final component is opened with ``O_NOFOLLOW`` (read, or
|
||||
``O_WRONLY | O_CREAT | O_EXCL`` when ``write`` is True).
|
||||
|
||||
The important detail is that intermediate components are opened with
|
||||
``O_PATH | O_NOFOLLOW`` when ``O_PATH`` is available, and then verified
|
||||
with ``fstat()``. On Linux, ``O_RDONLY | O_DIRECTORY | O_NOFOLLOW`` is not
|
||||
sufficient for this job: a symlink whose target is a directory can still be
|
||||
opened as the target directory on some kernels. Opening with ``O_PATH`` and
|
||||
checking the resulting descriptor reliably exposes such a component as a
|
||||
symlink instead.
|
||||
|
||||
A symlink (or a ``..`` component) anywhere in the path raises
|
||||
``OSError(ELOOP)``. On platforms without ``openat``/``O_DIRECTORY``
|
||||
support, this falls back to a single ``O_NOFOLLOW`` open of the whole path,
|
||||
which is no worse than the historical behaviour.
|
||||
"""
|
||||
|
||||
cloexec = getattr(os, "O_CLOEXEC", 0)
|
||||
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
o_directory = getattr(os, "O_DIRECTORY", 0)
|
||||
o_path = getattr(os, "O_PATH", 0)
|
||||
|
||||
if write and directory:
|
||||
raise ValueError("directory=True cannot be combined with write=True")
|
||||
|
||||
if write:
|
||||
final_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | cloexec | nofollow
|
||||
elif directory and o_path:
|
||||
# O_PATH|O_NOFOLLOW opens a final symlink as the symlink object itself
|
||||
# on Linux, allowing inspect_dir_no_follow() to reject it via fstat().
|
||||
# O_RDONLY|O_DIRECTORY|O_NOFOLLOW can still follow a symlink-to-dir on
|
||||
# some kernels/filesystems.
|
||||
final_flags = o_path | cloexec | nofollow
|
||||
else:
|
||||
final_flags = os.O_RDONLY | cloexec | nofollow
|
||||
if directory:
|
||||
final_flags |= o_directory
|
||||
|
||||
supports_openat = bool(
|
||||
o_directory and nofollow and os.open in getattr(os, "supports_dir_fd", set())
|
||||
)
|
||||
if not supports_openat:
|
||||
return os.open(path, final_flags, mode)
|
||||
|
||||
absolute = path.startswith("/")
|
||||
parts = [p for p in path.split("/") if p not in ("", ".")]
|
||||
if not parts:
|
||||
return os.open(path, final_flags, mode)
|
||||
|
||||
*parent_parts, leaf = parts
|
||||
|
||||
# Use O_PATH for directory descriptors when available. O_PATH descriptors
|
||||
# can be used as dir_fd anchors for later openat-style calls, and with
|
||||
# O_NOFOLLOW they let us fstat() a symlink component instead of silently
|
||||
# following it. If O_PATH is unavailable, use O_RDONLY and an lstat()
|
||||
# pre-check for intermediate components as a best-effort fallback.
|
||||
dir_base_flags = (o_path if o_path else os.O_RDONLY) | cloexec | o_directory
|
||||
component_flags = (
|
||||
(o_path if o_path else os.O_RDONLY) | cloexec | o_directory | nofollow
|
||||
)
|
||||
|
||||
dir_fd = os.open("/" if absolute else ".", dir_base_flags)
|
||||
try:
|
||||
for component in parent_parts:
|
||||
if component == "..":
|
||||
raise OSError(errno.ELOOP, "unsafe '..' path component", path)
|
||||
|
||||
if not o_path:
|
||||
# Best-effort fallback for platforms without O_PATH. This is not
|
||||
# as race-resistant as the descriptor-only path, but it avoids
|
||||
# known symlink parents where we cannot open the component itself
|
||||
# as a non-followed O_PATH descriptor.
|
||||
try:
|
||||
st = os.lstat(component, dir_fd=dir_fd)
|
||||
except OSError:
|
||||
raise
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
raise OSError(errno.ELOOP, "symlinked path component", path)
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
raise OSError(errno.ENOTDIR, "non-directory path component", path)
|
||||
|
||||
try:
|
||||
next_fd = os.open(component, component_flags, dir_fd=dir_fd)
|
||||
except OSError as e:
|
||||
if e.errno in {errno.ELOOP, errno.ENOTDIR}:
|
||||
try:
|
||||
st = os.lstat(component, dir_fd=dir_fd)
|
||||
except OSError:
|
||||
raise
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
raise OSError(
|
||||
errno.ELOOP,
|
||||
"symlinked path component",
|
||||
path,
|
||||
) from e
|
||||
raise
|
||||
|
||||
try:
|
||||
st = os.fstat(next_fd)
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
raise OSError(errno.ELOOP, "symlinked path component", path)
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
raise OSError(errno.ENOTDIR, "non-directory path component", path)
|
||||
except Exception:
|
||||
os.close(next_fd)
|
||||
raise
|
||||
|
||||
os.close(dir_fd)
|
||||
dir_fd = next_fd
|
||||
|
||||
if leaf == "..":
|
||||
raise OSError(errno.ELOOP, "unsafe '..' path component", path)
|
||||
return os.open(leaf, final_flags, mode, dir_fd=dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
|
||||
|
||||
def inspect_dir_no_follow(path: str) -> os.stat_result:
|
||||
"""Return fstat() metadata for a directory opened without following symlinks.
|
||||
|
||||
Directory metadata capture must have the same TOCTOU properties as file
|
||||
capture: inspect the exact object reached through a no-follow descriptor,
|
||||
and reject symlink components anywhere in the path. Path-based
|
||||
``os.stat()`` / ``os.path.isdir()`` checks can be swapped between check and
|
||||
use when an include root is attacker-writable; this helper keeps the check
|
||||
bound to the opened descriptor.
|
||||
"""
|
||||
|
||||
fd = open_no_follow_path(path, directory=True)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
raise OSError(errno.ELOOP, "symlinked directory path", path)
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
raise OSError(errno.ENOTDIR, "not a directory", path)
|
||||
return st
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def stat_dir_triplet(path: str) -> Tuple[str, str, str]:
|
||||
"""Return (owner, group, mode) for a safely-opened directory path.
|
||||
|
||||
Unlike :func:`stat_triplet`, this refuses final symlinks and symlinked
|
||||
parent components, and derives metadata from the directory descriptor that
|
||||
passed those checks.
|
||||
"""
|
||||
|
||||
return stat_triplet_from_stat(inspect_dir_no_follow(path))
|
||||
|
||||
|
||||
def path_has_symlink_component(path: str) -> bool:
|
||||
"""Return True if any existing component of *path* is a symlink.
|
||||
|
||||
This is a lightweight discovery-time companion to ``open_no_follow_path``.
|
||||
It is intended for directory-walking code paths that must decide whether a
|
||||
candidate root is safe to enumerate before opening individual files. Missing
|
||||
trailing components are treated as non-symlinks; ``..`` is treated as unsafe
|
||||
and therefore reported as a symlink-like component.
|
||||
"""
|
||||
|
||||
norm = os.path.normpath(path)
|
||||
if norm in ("", "."):
|
||||
return False
|
||||
|
||||
if os.path.isabs(norm):
|
||||
cur = os.sep
|
||||
parts = [p for p in norm.split(os.sep) if p]
|
||||
else:
|
||||
cur = os.getcwd()
|
||||
parts = [p for p in norm.split(os.sep) if p]
|
||||
|
||||
for part in parts:
|
||||
if part in ("", "."):
|
||||
continue
|
||||
if part == "..":
|
||||
return True
|
||||
cur = os.path.join(cur, part)
|
||||
try:
|
||||
st = os.lstat(cur)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
# Fail closed for unreadable/racy paths used as discovery roots.
|
||||
return True
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_dir_no_symlink_components(path: str) -> bool:
|
||||
"""Return True only for directories reached without symlink components."""
|
||||
|
||||
if path_has_symlink_component(path):
|
||||
return False
|
||||
try:
|
||||
st = os.stat(path, follow_symlinks=False)
|
||||
except OSError:
|
||||
return False
|
||||
return stat.S_ISDIR(st.st_mode)
|
||||
|
||||
|
||||
def stat_triplet_from_stat(st: os.stat_result) -> Tuple[str, str, str]:
|
||||
"""Return (owner, group, mode) for an existing stat result."""
|
||||
|
||||
mode = oct(st.st_mode & 0o7777)[2:].zfill(4)
|
||||
|
||||
import grp
|
||||
import pwd
|
||||
|
||||
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
|
||||
|
||||
|
||||
def file_md5(path: str) -> str:
|
||||
"""Return hex MD5 of a file.
|
||||
|
||||
|
|
@ -23,18 +265,4 @@ def stat_triplet(path: str) -> Tuple[str, str, str]:
|
|||
owner/group are usernames/group names when resolvable, otherwise numeric ids.
|
||||
mode is a zero-padded octal string (e.g. "0644").
|
||||
"""
|
||||
st = os.stat(path, follow_symlinks=True)
|
||||
mode = oct(st.st_mode & 0o7777)[2:].zfill(4)
|
||||
|
||||
import grp
|
||||
import pwd
|
||||
|
||||
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
|
||||
return stat_triplet_from_stat(os.stat(path, follow_symlinks=True))
|
||||
|
|
|
|||
2231
enroll/harvest.py
2231
enroll/harvest.py
File diff suppressed because it is too large
Load diff
38
enroll/harvest_collectors/__init__.py
Normal file
38
enroll/harvest_collectors/__init__.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""Harvest collector package exports"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from .context import HarvestCollector, HarvestContext
|
||||
|
||||
_COLLECTOR_EXPORTS = {
|
||||
"CronLogrotateCollection": ".cron_logrotate",
|
||||
"CronLogrotateCollector": ".cron_logrotate",
|
||||
"ExtraPathsCollector": ".paths",
|
||||
"PackageManagerConfigCollection": ".package_manager",
|
||||
"PackageManagerConfigCollector": ".package_manager",
|
||||
"RuntimeStateCollection": ".runtime",
|
||||
"RuntimeStateCollector": ".runtime",
|
||||
"ServicePackageCollection": ".services",
|
||||
"ServicePackageCollector": ".services",
|
||||
"UsersCollection": ".users",
|
||||
"UsersCollector": ".users",
|
||||
"UsrLocalCustomCollector": ".paths",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"HarvestCollector",
|
||||
"HarvestContext",
|
||||
*_COLLECTOR_EXPORTS,
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
module_name = _COLLECTOR_EXPORTS.get(name)
|
||||
if module_name is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(module_name, __name__)
|
||||
value = getattr(module, name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
251
enroll/harvest_collectors/container_images.py
Normal file
251
enroll/harvest_collectors/container_images.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from collections.abc import (
|
||||
Iterable,
|
||||
) # nosec - executes fixed docker/podman command arguments only
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from ..harvest_types import ContainerImagesSnapshot
|
||||
from .context import HarvestCollector
|
||||
|
||||
_DIGEST_RE = re.compile(r"@sha256:[0-9A-Fa-f]{32,}")
|
||||
_SHA_ID_RE = re.compile(r"^(?:sha256:)?[0-9A-Fa-f]{64}$")
|
||||
|
||||
|
||||
def _normalise_image_id(value: Any) -> Optional[str]:
|
||||
s = str(value or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
if s.startswith("sha256:"):
|
||||
return s
|
||||
if _SHA_ID_RE.match(s):
|
||||
return "sha256:" + s
|
||||
return s
|
||||
|
||||
|
||||
def _as_string_list(value: Any) -> List[str]:
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
values = [value]
|
||||
elif isinstance(value, Iterable):
|
||||
values = list(value)
|
||||
else:
|
||||
values = [value]
|
||||
out: List[str] = []
|
||||
for item in values:
|
||||
s = str(item or "").strip()
|
||||
if not s or s in {"<none>", "<none>:<none>"}:
|
||||
continue
|
||||
if s not in out:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _pullable_digests(value: Any) -> List[str]:
|
||||
return [s for s in _as_string_list(value) if _DIGEST_RE.search(s)]
|
||||
|
||||
|
||||
def _split_tag_ref(ref: str) -> Optional[Dict[str, str]]:
|
||||
"""Split an image tag into repository/tag, preserving registry ports."""
|
||||
|
||||
s = str(ref or "").strip()
|
||||
if not s or "@" in s or s == "<none>:<none>":
|
||||
return None
|
||||
last_slash = s.rfind("/")
|
||||
last_colon = s.rfind(":")
|
||||
if last_colon > last_slash:
|
||||
repository = s[:last_colon]
|
||||
tag = s[last_colon + 1 :]
|
||||
else:
|
||||
repository = s
|
||||
tag = "latest"
|
||||
if not repository or not tag:
|
||||
return None
|
||||
return {"ref": s, "repository": repository, "tag": tag}
|
||||
|
||||
|
||||
def _tag_aliases(value: Any) -> List[Dict[str, str]]:
|
||||
out: List[Dict[str, str]] = []
|
||||
seen = set()
|
||||
for ref in _as_string_list(value):
|
||||
item = _split_tag_ref(ref)
|
||||
if not item:
|
||||
continue
|
||||
key = (item["repository"], item["tag"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _platform_from_inspect(
|
||||
item: Dict[str, Any],
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||
os_name = item.get("Os") or item.get("OS")
|
||||
arch = item.get("Architecture") or item.get("Arch")
|
||||
variant = item.get("Variant")
|
||||
os_s = str(os_name).strip() if os_name not in (None, "") else None
|
||||
arch_s = str(arch).strip() if arch not in (None, "") else None
|
||||
variant_s = str(variant).strip() if variant not in (None, "") else None
|
||||
platform = None
|
||||
if os_s and arch_s:
|
||||
platform = f"{os_s}/{arch_s}"
|
||||
if variant_s:
|
||||
platform = f"{platform}/{variant_s}"
|
||||
return os_s, arch_s, variant_s, platform
|
||||
|
||||
|
||||
def _run_command(
|
||||
argv: Sequence[str], *, timeout: int = 20
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # nosec - argv is constructed from fixed binary names and image ids
|
||||
list(argv),
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _chunks(items: Sequence[str], size: int) -> Iterable[List[str]]:
|
||||
for i in range(0, len(items), size):
|
||||
yield list(items[i : i + size])
|
||||
|
||||
|
||||
class ContainerImagesCollector(HarvestCollector):
|
||||
"""Collect local Docker and Podman image metadata.
|
||||
|
||||
The harvest records pullable registry digests where present. Local image IDs
|
||||
are kept as evidence but are not treated as pull references.
|
||||
"""
|
||||
|
||||
def collect(self) -> ContainerImagesSnapshot:
|
||||
images: List[Dict[str, Any]] = []
|
||||
notes: List[str] = []
|
||||
|
||||
images.extend(self._collect_engine("docker", notes=notes))
|
||||
images.extend(self._collect_engine("podman", notes=notes))
|
||||
|
||||
if images:
|
||||
digest_count = len([img for img in images if img.get("pull_ref")])
|
||||
notes.append(
|
||||
f"Detected {len(images)} container image(s); {digest_count} have registry digests usable for exact pulls."
|
||||
)
|
||||
|
||||
return ContainerImagesSnapshot(
|
||||
role_name="container_images",
|
||||
images=images,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
def _collect_engine(self, engine: str, *, notes: List[str]) -> List[Dict[str, Any]]:
|
||||
exe = shutil.which(engine)
|
||||
if not exe:
|
||||
return []
|
||||
|
||||
try:
|
||||
listed = _run_command([exe, "image", "ls", "-q", "--no-trunc"])
|
||||
except Exception as exc:
|
||||
notes.append(f"Failed to list {engine} images: {exc!r}")
|
||||
return []
|
||||
|
||||
if listed.returncode != 0:
|
||||
detail = (listed.stderr or listed.stdout or "").strip()
|
||||
if detail:
|
||||
notes.append(f"Failed to list {engine} images: {detail}")
|
||||
else:
|
||||
notes.append(
|
||||
f"Failed to list {engine} images: exit {listed.returncode}"
|
||||
)
|
||||
return []
|
||||
|
||||
image_ids = []
|
||||
seen_ids = set()
|
||||
for line in listed.stdout.splitlines():
|
||||
image_id = _normalise_image_id(line)
|
||||
if not image_id or image_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(image_id)
|
||||
image_ids.append(image_id)
|
||||
|
||||
if not image_ids:
|
||||
return []
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for chunk in _chunks(image_ids, 40):
|
||||
try:
|
||||
inspected = _run_command([exe, "image", "inspect", *chunk])
|
||||
except Exception as exc:
|
||||
notes.append(f"Failed to inspect {engine} images: {exc!r}")
|
||||
continue
|
||||
if inspected.returncode != 0:
|
||||
detail = (inspected.stderr or inspected.stdout or "").strip()
|
||||
notes.append(
|
||||
f"Failed to inspect {engine} images {', '.join(chunk[:3])}: {detail or inspected.returncode}"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
data = json.loads(inspected.stdout or "[]")
|
||||
except json.JSONDecodeError as exc:
|
||||
notes.append(f"Failed to parse {engine} image inspect JSON: {exc}")
|
||||
continue
|
||||
if not isinstance(data, list):
|
||||
notes.append(f"Unexpected {engine} image inspect JSON shape")
|
||||
continue
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
normalised = self._normalise_inspect(engine, item)
|
||||
if normalised is not None:
|
||||
out.append(normalised)
|
||||
return out
|
||||
|
||||
def _normalise_inspect(
|
||||
self, engine: str, item: Dict[str, Any]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
image_id = _normalise_image_id(item.get("Id") or item.get("ID"))
|
||||
repo_tags = _as_string_list(item.get("RepoTags"))
|
||||
repo_digests = _pullable_digests(item.get("RepoDigests"))
|
||||
pull_ref = sorted(repo_digests)[0] if repo_digests else None
|
||||
os_name, arch, variant, platform = _platform_from_inspect(item)
|
||||
|
||||
if not image_id and not repo_tags and not repo_digests:
|
||||
return None
|
||||
|
||||
notes: List[str] = []
|
||||
if not pull_ref:
|
||||
if repo_tags:
|
||||
notes.append(
|
||||
"Image has tag(s) but no RepoDigest; exact digest-pinned pull cannot be rendered."
|
||||
)
|
||||
else:
|
||||
notes.append(
|
||||
"Image has no tag or RepoDigest; local-only/dangling images cannot be pulled from a registry."
|
||||
)
|
||||
|
||||
out: Dict[str, Any] = {
|
||||
"engine": engine,
|
||||
"scope": "system",
|
||||
"user": None,
|
||||
"home": None,
|
||||
"image_id": image_id,
|
||||
"repo_tags": repo_tags,
|
||||
"repo_digests": repo_digests,
|
||||
"pull_ref": pull_ref,
|
||||
"tag_aliases": _tag_aliases(repo_tags),
|
||||
"os": os_name,
|
||||
"architecture": arch,
|
||||
"variant": variant,
|
||||
"platform": platform,
|
||||
"size": item.get("Size"),
|
||||
"created": item.get("Created"),
|
||||
"source": f"{engine} image inspect",
|
||||
"notes": notes,
|
||||
}
|
||||
return out
|
||||
32
enroll/harvest_collectors/context.py
Normal file
32
enroll/harvest_collectors/context.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
from ..ignore import IgnorePolicy
|
||||
from ..pathfilter import PathFilter
|
||||
|
||||
|
||||
@dataclass
|
||||
class HarvestContext:
|
||||
"""Shared context passed to feature collectors."""
|
||||
|
||||
bundle_dir: str
|
||||
policy: IgnorePolicy
|
||||
path_filter: PathFilter
|
||||
platform: Dict[str, Any]
|
||||
backend: Any
|
||||
installed_pkgs: Dict[str, Any]
|
||||
installed_names: Set[str]
|
||||
owned_etc: Set[str]
|
||||
etc_owner_map: Dict[str, str]
|
||||
topdir_to_pkgs: Dict[str, Set[str]]
|
||||
pkg_to_etc_paths: Dict[str, List[str]]
|
||||
captured_global: Set[str]
|
||||
|
||||
|
||||
class HarvestCollector:
|
||||
"""Base class for harvest feature collectors."""
|
||||
|
||||
def __init__(self, context: HarvestContext) -> None:
|
||||
self.context = context
|
||||
161
enroll/harvest_collectors/cron_logrotate.py
Normal file
161
enroll/harvest_collectors/cron_logrotate.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from ..capture import capture_file
|
||||
from ..harvest_types import ExcludedFile, ManagedFile, PackageSnapshot
|
||||
from ..package_hints import package_section_from_installations
|
||||
from ..system_paths import iter_matching_files
|
||||
from .context import HarvestCollector
|
||||
|
||||
|
||||
def _pick_installed(installed_names: Set[str], candidates: List[str]) -> Optional[str]:
|
||||
for candidate in candidates:
|
||||
if candidate in installed_names:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _is_cron_path(path: str) -> bool:
|
||||
return (
|
||||
path == "/etc/crontab"
|
||||
or path == "/etc/anacrontab"
|
||||
or path in ("/etc/cron.allow", "/etc/cron.deny")
|
||||
or path.startswith("/etc/cron.")
|
||||
or path.startswith("/etc/cron.d/")
|
||||
or path.startswith("/etc/anacron/")
|
||||
or path.startswith("/var/spool/cron/")
|
||||
or path.startswith("/var/spool/crontabs/")
|
||||
or path.startswith("/var/spool/anacron/")
|
||||
)
|
||||
|
||||
|
||||
def _is_logrotate_path(path: str) -> bool:
|
||||
return path == "/etc/logrotate.conf" or path.startswith("/etc/logrotate.d/")
|
||||
|
||||
|
||||
_CRON_CAPTURE_GLOBS = [
|
||||
"/etc/crontab",
|
||||
"/etc/cron.d/*",
|
||||
"/etc/cron.hourly/*",
|
||||
"/etc/cron.daily/*",
|
||||
"/etc/cron.weekly/*",
|
||||
"/etc/cron.monthly/*",
|
||||
"/etc/cron.allow",
|
||||
"/etc/cron.deny",
|
||||
"/etc/anacrontab",
|
||||
"/etc/anacron/*",
|
||||
# user crontabs / spool state
|
||||
"/var/spool/cron/*",
|
||||
"/var/spool/cron/crontabs/*",
|
||||
"/var/spool/crontabs/*",
|
||||
"/var/spool/anacron/*",
|
||||
]
|
||||
|
||||
_LOGROTATE_CAPTURE_GLOBS = [
|
||||
"/etc/logrotate.conf",
|
||||
"/etc/logrotate.d/*",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronLogrotateCollection:
|
||||
cron_pkg: Optional[str]
|
||||
logrotate_pkg: Optional[str]
|
||||
cron_snapshot: Optional[PackageSnapshot]
|
||||
logrotate_snapshot: Optional[PackageSnapshot]
|
||||
|
||||
|
||||
class CronLogrotateCollector(HarvestCollector):
|
||||
"""Collect dedicated cron/logrotate package roles before general packages."""
|
||||
|
||||
cron_role_name = "cron"
|
||||
logrotate_role_name = "logrotate"
|
||||
|
||||
def collect(self) -> CronLogrotateCollection:
|
||||
cron_pkg = _pick_installed(
|
||||
self.context.installed_names,
|
||||
["cron", "cronie", "cronie-anacron", "vixie-cron", "fcron"],
|
||||
)
|
||||
logrotate_pkg = _pick_installed(self.context.installed_names, ["logrotate"])
|
||||
|
||||
cron_snapshot = self._collect_cron_snapshot(cron_pkg) if cron_pkg else None
|
||||
logrotate_snapshot = (
|
||||
self._collect_logrotate_snapshot(logrotate_pkg) if logrotate_pkg else None
|
||||
)
|
||||
return CronLogrotateCollection(
|
||||
cron_pkg=cron_pkg,
|
||||
logrotate_pkg=logrotate_pkg,
|
||||
cron_snapshot=cron_snapshot,
|
||||
logrotate_snapshot=logrotate_snapshot,
|
||||
)
|
||||
|
||||
def _collect_cron_snapshot(self, cron_pkg: str) -> PackageSnapshot:
|
||||
managed: List[ManagedFile] = []
|
||||
excluded: List[ExcludedFile] = []
|
||||
notes: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
|
||||
for spec in _CRON_CAPTURE_GLOBS:
|
||||
for path in iter_matching_files(spec):
|
||||
if not os.path.isfile(path) or os.path.islink(path):
|
||||
continue
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=self.cron_role_name,
|
||||
abs_path=path,
|
||||
reason="system_cron",
|
||||
policy=self.context.policy,
|
||||
path_filter=self.context.path_filter,
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
seen_role=seen,
|
||||
seen_global=self.context.captured_global,
|
||||
)
|
||||
|
||||
return PackageSnapshot(
|
||||
package=cron_pkg,
|
||||
role_name=self.cron_role_name,
|
||||
section=package_section_from_installations(
|
||||
self.context.installed_pkgs.get(cron_pkg, [])
|
||||
),
|
||||
managed_files=managed,
|
||||
excluded=excluded,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
def _collect_logrotate_snapshot(self, logrotate_pkg: str) -> PackageSnapshot:
|
||||
managed: List[ManagedFile] = []
|
||||
excluded: List[ExcludedFile] = []
|
||||
notes: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
|
||||
for spec in _LOGROTATE_CAPTURE_GLOBS:
|
||||
for path in iter_matching_files(spec):
|
||||
if not os.path.isfile(path) or os.path.islink(path):
|
||||
continue
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=self.logrotate_role_name,
|
||||
abs_path=path,
|
||||
reason="system_logrotate",
|
||||
policy=self.context.policy,
|
||||
path_filter=self.context.path_filter,
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
seen_role=seen,
|
||||
seen_global=self.context.captured_global,
|
||||
)
|
||||
|
||||
return PackageSnapshot(
|
||||
package=logrotate_pkg,
|
||||
role_name=self.logrotate_role_name,
|
||||
section=package_section_from_installations(
|
||||
self.context.installed_pkgs.get(logrotate_pkg, [])
|
||||
),
|
||||
managed_files=managed,
|
||||
excluded=excluded,
|
||||
notes=notes,
|
||||
)
|
||||
87
enroll/harvest_collectors/package_manager.py
Normal file
87
enroll/harvest_collectors/package_manager.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Set
|
||||
|
||||
from ..capture import capture_file
|
||||
from ..harvest_types import (
|
||||
AptConfigSnapshot,
|
||||
DnfConfigSnapshot,
|
||||
ExcludedFile,
|
||||
ManagedFile,
|
||||
)
|
||||
from ..system_paths import iter_apt_capture_paths, iter_dnf_capture_paths
|
||||
from .context import HarvestCollector, HarvestContext
|
||||
|
||||
|
||||
@dataclass
|
||||
class PackageManagerConfigCollection:
|
||||
apt_config_snapshot: AptConfigSnapshot
|
||||
dnf_config_snapshot: DnfConfigSnapshot
|
||||
|
||||
|
||||
class PackageManagerConfigCollector(HarvestCollector):
|
||||
"""Collect package-manager configuration into existing role snapshots."""
|
||||
|
||||
def __init__(
|
||||
self, context: HarvestContext, seen_by_role: Dict[str, Set[str]]
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.seen_by_role = seen_by_role
|
||||
|
||||
def collect(self) -> PackageManagerConfigCollection:
|
||||
apt_notes: List[str] = []
|
||||
apt_excluded: List[ExcludedFile] = []
|
||||
apt_managed: List[ManagedFile] = []
|
||||
dnf_notes: List[str] = []
|
||||
dnf_excluded: List[ExcludedFile] = []
|
||||
dnf_managed: List[ManagedFile] = []
|
||||
|
||||
apt_role_name = "apt_config"
|
||||
dnf_role_name = "dnf_config"
|
||||
|
||||
if self.context.backend.name == "dpkg":
|
||||
apt_role_seen = self.seen_by_role.setdefault(apt_role_name, set())
|
||||
for path, reason in iter_apt_capture_paths():
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=apt_role_name,
|
||||
abs_path=path,
|
||||
reason=reason,
|
||||
policy=self.context.policy,
|
||||
path_filter=self.context.path_filter,
|
||||
managed_out=apt_managed,
|
||||
excluded_out=apt_excluded,
|
||||
seen_role=apt_role_seen,
|
||||
seen_global=self.context.captured_global,
|
||||
)
|
||||
elif self.context.backend.name == "rpm":
|
||||
dnf_role_seen = self.seen_by_role.setdefault(dnf_role_name, set())
|
||||
for path, reason in iter_dnf_capture_paths():
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=dnf_role_name,
|
||||
abs_path=path,
|
||||
reason=reason,
|
||||
policy=self.context.policy,
|
||||
path_filter=self.context.path_filter,
|
||||
managed_out=dnf_managed,
|
||||
excluded_out=dnf_excluded,
|
||||
seen_role=dnf_role_seen,
|
||||
seen_global=self.context.captured_global,
|
||||
)
|
||||
|
||||
return PackageManagerConfigCollection(
|
||||
apt_config_snapshot=AptConfigSnapshot(
|
||||
role_name=apt_role_name,
|
||||
managed_files=apt_managed,
|
||||
excluded=apt_excluded,
|
||||
notes=apt_notes,
|
||||
),
|
||||
dnf_config_snapshot=DnfConfigSnapshot(
|
||||
role_name=dnf_role_name,
|
||||
managed_files=dnf_managed,
|
||||
excluded=dnf_excluded,
|
||||
notes=dnf_notes,
|
||||
),
|
||||
)
|
||||
293
enroll/harvest_collectors/paths.py
Normal file
293
enroll/harvest_collectors/paths.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from ..capture import capture_file, capture_link
|
||||
from ..harvest_types import (
|
||||
ExcludedFile,
|
||||
ExtraPathsSnapshot,
|
||||
ManagedDir,
|
||||
ManagedFile,
|
||||
ManagedLink,
|
||||
UsrLocalCustomSnapshot,
|
||||
)
|
||||
from ..system_paths import MAX_FILES_CAP
|
||||
from ..pathfilter import expand_includes
|
||||
from ..fsutil import (
|
||||
is_dir_no_symlink_components,
|
||||
path_has_symlink_component,
|
||||
stat_dir_triplet,
|
||||
stat_triplet,
|
||||
)
|
||||
from .context import HarvestCollector, HarvestContext
|
||||
|
||||
|
||||
class UsrLocalCustomCollector(HarvestCollector):
|
||||
"""Collect selected /usr/local state into the usr_local_custom role."""
|
||||
|
||||
role_name = "usr_local_custom"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: HarvestContext,
|
||||
seen_by_role: Dict[str, Set[str]],
|
||||
already_all: Set[str],
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.seen_by_role = seen_by_role
|
||||
self.already_all = already_all
|
||||
self.notes: List[str] = []
|
||||
self.excluded: List[ExcludedFile] = []
|
||||
self.managed: List[ManagedFile] = []
|
||||
|
||||
def collect(self) -> UsrLocalCustomSnapshot:
|
||||
self._scan_tree(
|
||||
"/usr/local/etc",
|
||||
require_executable=False,
|
||||
cap=MAX_FILES_CAP,
|
||||
reason="usr_local_etc_custom",
|
||||
)
|
||||
self._scan_tree(
|
||||
"/usr/local/bin",
|
||||
require_executable=True,
|
||||
cap=MAX_FILES_CAP,
|
||||
reason="usr_local_bin_script",
|
||||
)
|
||||
return UsrLocalCustomSnapshot(
|
||||
role_name=self.role_name,
|
||||
managed_files=self.managed,
|
||||
excluded=self.excluded,
|
||||
notes=self.notes,
|
||||
)
|
||||
|
||||
def _scan_tree(
|
||||
self,
|
||||
root: str,
|
||||
*,
|
||||
require_executable: bool,
|
||||
cap: int,
|
||||
reason: str,
|
||||
) -> None:
|
||||
scanned = 0
|
||||
if not os.path.isdir(root):
|
||||
return
|
||||
role_seen = self.seen_by_role.setdefault(self.role_name, set())
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
for filename in filenames:
|
||||
path = os.path.join(dirpath, filename)
|
||||
if path in self.already_all:
|
||||
continue
|
||||
if not os.path.isfile(path) or os.path.islink(path):
|
||||
continue
|
||||
try:
|
||||
owner, group, mode = stat_triplet(path)
|
||||
except OSError:
|
||||
self.excluded.append(ExcludedFile(path=path, reason="unreadable"))
|
||||
continue
|
||||
|
||||
if require_executable:
|
||||
try:
|
||||
if (int(mode, 8) & 0o111) == 0:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=self.role_name,
|
||||
abs_path=path,
|
||||
reason=reason,
|
||||
policy=self.context.policy,
|
||||
path_filter=self.context.path_filter,
|
||||
managed_out=self.managed,
|
||||
excluded_out=self.excluded,
|
||||
seen_role=role_seen,
|
||||
seen_global=self.context.captured_global,
|
||||
metadata=(owner, group, mode),
|
||||
):
|
||||
self.already_all.add(path)
|
||||
scanned += 1
|
||||
if scanned >= cap:
|
||||
self.notes.append(
|
||||
f"Reached file cap ({cap}) while scanning {root}."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
class ExtraPathsCollector(HarvestCollector):
|
||||
"""Collect user-requested include/exclude paths into extra_paths."""
|
||||
|
||||
role_name = "extra_paths"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: HarvestContext,
|
||||
seen_by_role: Dict[str, Set[str]],
|
||||
already_all: Set[str],
|
||||
*,
|
||||
include_paths: Optional[List[str]] = None,
|
||||
exclude_paths: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.seen_by_role = seen_by_role
|
||||
self.already_all = already_all
|
||||
self.include_specs = list(include_paths or [])
|
||||
self.exclude_specs = list(exclude_paths or [])
|
||||
self.notes: List[str] = []
|
||||
self.excluded: List[ExcludedFile] = []
|
||||
self.managed: List[ManagedFile] = []
|
||||
self.managed_links: List[ManagedLink] = []
|
||||
self.managed_dirs: List[ManagedDir] = []
|
||||
self.dir_seen: Set[str] = set()
|
||||
|
||||
def collect(self) -> ExtraPathsSnapshot:
|
||||
self._collect_included_dirs()
|
||||
if self.include_specs:
|
||||
self.notes.append("User include patterns:")
|
||||
self.notes.extend([f"- {p}" for p in self.include_specs])
|
||||
if self.exclude_specs:
|
||||
self.notes.append("User exclude patterns:")
|
||||
self.notes.extend([f"- {p}" for p in self.exclude_specs])
|
||||
|
||||
included_files: List[str] = []
|
||||
if self.include_specs:
|
||||
files, inc_notes = expand_includes(
|
||||
self.context.path_filter.iter_include_patterns(),
|
||||
exclude=self.context.path_filter,
|
||||
max_files=MAX_FILES_CAP,
|
||||
)
|
||||
included_files = files
|
||||
self.notes.extend(inc_notes)
|
||||
|
||||
role_seen = self.seen_by_role.setdefault(self.role_name, set())
|
||||
for path in included_files:
|
||||
if path in self.already_all:
|
||||
continue
|
||||
if capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=self.role_name,
|
||||
abs_path=path,
|
||||
reason="user_include",
|
||||
policy=self.context.policy,
|
||||
path_filter=self.context.path_filter,
|
||||
managed_out=self.managed,
|
||||
excluded_out=self.excluded,
|
||||
seen_role=role_seen,
|
||||
seen_global=self.context.captured_global,
|
||||
):
|
||||
self.already_all.add(path)
|
||||
|
||||
return ExtraPathsSnapshot(
|
||||
role_name=self.role_name,
|
||||
include_patterns=self.include_specs,
|
||||
exclude_patterns=self.exclude_specs,
|
||||
managed_dirs=self.managed_dirs,
|
||||
managed_files=self.managed,
|
||||
managed_links=self.managed_links,
|
||||
excluded=self.excluded,
|
||||
notes=self.notes,
|
||||
)
|
||||
|
||||
def _collect_included_dirs(self) -> None:
|
||||
role_seen = self.seen_by_role.setdefault(self.role_name, set())
|
||||
for pat in self.context.path_filter.iter_include_patterns():
|
||||
if pat.kind == "prefix":
|
||||
path = pat.value
|
||||
if os.path.islink(path):
|
||||
self._capture_included_link(path, role_seen)
|
||||
elif is_dir_no_symlink_components(path):
|
||||
self._walk_and_capture_dirs(path, role_seen)
|
||||
elif pat.kind == "glob":
|
||||
for hit in glob.glob(pat.value, recursive=True):
|
||||
if os.path.islink(hit):
|
||||
self._capture_included_link(hit, role_seen)
|
||||
elif is_dir_no_symlink_components(hit):
|
||||
self._walk_and_capture_dirs(hit, role_seen)
|
||||
|
||||
def _capture_included_link(self, path: str, role_seen: Set[str]) -> None:
|
||||
path = os.path.normpath(path)
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if path in self.already_all:
|
||||
return
|
||||
if capture_link(
|
||||
role_name=self.role_name,
|
||||