Compare commits
13 commits
1d42b2bfb9
...
7519adc705
| Author | SHA1 | Date | |
|---|---|---|---|
| 7519adc705 | |||
| b3d4adf7d4 | |||
| ddb18403c8 | |||
| a2dc054882 | |||
| 1bfbfd90f6 | |||
| 44d1a22db4 | |||
| f56f076765 | |||
| f5b85d29d3 | |||
| 903125976d | |||
| e9d7d74445 | |||
| 88e9dba39a | |||
| 680d286a20 | |||
| 8a9fe82461 |
46 changed files with 3157 additions and 8986 deletions
|
|
@ -35,13 +35,7 @@ jobs:
|
|||
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 \
|
||||
puppet hiera
|
||||
curl -fsSL https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public | gpg --dearmor | tee /etc/apt/keyrings/salt-archive-keyring.pgp > /dev/null
|
||||
curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources | tee /etc/apt/sources.list.d/salt.sources
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
salt-master salt-minion salt-ssh salt-syndic salt-cloud salt-api
|
||||
ansible ansible-lint python3 python3-venv python3-pip pipx systemctl python3-apt jq python3-jsonschema
|
||||
;;
|
||||
almalinux)
|
||||
dnf -y upgrade --refresh
|
||||
|
|
@ -49,15 +43,10 @@ jobs:
|
|||
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
|
||||
curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.repo > /etc/yum.repos.d/salt.repo
|
||||
dnf -y install https://yum.puppet.com/puppet8-release-el-9.noarch.rpm
|
||||
dnf -y makecache
|
||||
dnf -y install \
|
||||
python3.11 python3.11-devel python3.11-pip gcc make \
|
||||
ansible-core ansible-lint systemd rpm httpd \
|
||||
puppet-agent \
|
||||
salt-master salt-minion salt-ssh salt-syndic salt-cloud salt-api
|
||||
echo "/opt/puppetlabs/bin" >> "$GITHUB_PATH"
|
||||
ansible-core ansible-lint systemd rpm httpd
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported CI distro: ${DISTRO}" >&2
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
# 0.7.0 (unreleased)
|
||||
|
||||
* 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 manifesting Puppet code, as well as Ansible!
|
||||
* Support manifesting Salt code, as well as Ansible and Puppet!
|
||||
* Take advantage of Jinjaturtle 0.5.5 if it's present, to render .erb templates for Puppet (as well as j2 for Ansible and Salt)
|
||||
* A lot of under-the-bonnet refactoring to make it easier to extend to cover other config managers (that don't suck) in future.
|
||||
* 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)
|
||||
|
|
|
|||
842
DEVELOPMENT.md
842
DEVELOPMENT.md
File diff suppressed because it is too large
Load diff
631
README.md
631
README.md
|
|
@ -4,10 +4,633 @@
|
|||
<img src="https://git.mig5.net/mig5/enroll/raw/branch/main/enroll.svg" alt="Enroll logo" width="240" />
|
||||
</div>
|
||||
|
||||
Hi folks. I spent a lot of time working on what was to be 0.7.0 of Enroll, before finding too many potental security risks along the way. After tens of security audits by LLMs and the like, to be told over and over 'this is really solid engineering', I'd end up with one that would find a critical vulnerability. I could no longer assume there weren't more. I am not a good programmer, and AI is an echo chamber of optimism.
|
||||
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates Ansible configuration-management code from it.
|
||||
|
||||
I decided it was better that such a project didn't exist. To that end, I'm removing it from the repos and PyPI.
|
||||
- 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. In `--dangerous` mode, it also auto-harvests common shell dotfiles such as `.bashrc`, `.profile`, `.bash_logout`, and `.bash_aliases` when appropriate.
|
||||
- Captures miscellaneous `/etc` files it can't attribute to a package and installs them in an `etc_custom` role.
|
||||
- When running as root/sudo, captures live writable sysctl state into a `sysctl` role that manages `/etc/sysctl.d/99-enroll.conf`.
|
||||
- Captures live ipset and iptables runtime state, when active ipsets/iptables rules are present *and* no corresponding persistent ipset/iptables *files* were found.
|
||||
- Captures symlinks in common applications that rely on them, e.g apache2/nginx 'sites-enabled'
|
||||
- Tries to capture Flatpak, Snap, Docker image presence
|
||||
- Captures snowflake-y things found in /usr/local/bin (for non-binary files) and /usr/local/etc
|
||||
- Avoids trying to start systemd services that were detected as inactive during harvest.
|
||||
|
||||
Please uninstall it.
|
||||
---
|
||||
|
||||
Thanks for all the love in 2026.
|
||||
## Mental model
|
||||
|
||||
`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 configuration-management code.
|
||||
|
||||
Additionally, some other functionalities exist:
|
||||
|
||||
- **Diff**: compare two harvests and report what changed (packages/services/users/files) since the previous snapshot.
|
||||
- **Single-shot mode**: run both harvest and manifest at once.
|
||||
|
||||
---
|
||||
|
||||
## Output modes: single-site vs multi-site (`--fqdn`)
|
||||
|
||||
### Single-site mode (default: *no* `--fqdn`)
|
||||
Use when enrolling **one server** (or generating a “golden” role set you intend to reuse).
|
||||
|
||||
**Characteristics**
|
||||
- Roles are more self-contained.
|
||||
- Raw config files live in the role's `files/`.
|
||||
- Template variables live in the role's `defaults/main.yml`.
|
||||
|
||||
### Multi-site mode (`--fqdn`)
|
||||
Use when enrolling **several existing servers** quickly, especially if they differ.
|
||||
|
||||
**Characteristics**
|
||||
- Roles are shared, host-specific state lives in inventory.
|
||||
- Host inventory drives what gets managed (files/packages/services).
|
||||
- Non-templated raw files live per-host under `inventory/host_vars/<fqdn>/<role>/.files/...`.
|
||||
|
||||
**Rule of thumb**
|
||||
- “Make this one server reproducible/provisionable” → start with **single-site**
|
||||
- “Get multiple already-running servers under management quickly” → use **multi-site**
|
||||
|
||||
---
|
||||
|
||||
## Subcommands
|
||||
|
||||
### `enroll harvest`
|
||||
Harvest state about a host and write a harvest bundle.
|
||||
|
||||
**What it captures (high level)**
|
||||
- Detected services + service-relevant packages
|
||||
- “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-ssh-config`
|
||||
- `--no-sudo` (if you don't want/need sudo)
|
||||
- Sensitive-data behaviour:
|
||||
- default: tries to avoid likely secrets
|
||||
- `--dangerous`: disables secret-safety checks (see “Sensitive data” below)
|
||||
- Encrypt bundles at rest:
|
||||
- `--sops <FINGERPRINT...>`: writes a single encrypted `harvest.tar.gz.sops` instead of a plaintext directory
|
||||
- Path selection (include/exclude):
|
||||
- `--include-path <PATTERN>` (repeatable): add extra files/dirs to harvest (even from locations normally ignored, like `/home`). Still subject to secret-safety checks unless `--dangerous`.
|
||||
- `--exclude-path <PATTERN>` (repeatable): skip files/dirs even if they would normally be harvested.
|
||||
- Pattern syntax:
|
||||
- plain path: matches that file; directories match the directory + everything under it
|
||||
- glob (default): supports `*` and `**` (prefix with `glob:` to force)
|
||||
- regex: prefix with `re:` or `regex:`
|
||||
- Precedence: excludes win over includes.
|
||||
* Using remote mode and auth requires secrets?
|
||||
* sudo password:
|
||||
* `--ask-become-pass` (or `-K`) prompts for the sudo password.
|
||||
* If you forget, and remote sudo requires a password, Enroll will still fall back to prompting in interactive mode (slightly slower due to retry).
|
||||
* SSH private-key passphrase:
|
||||
* `--ask-key-passphrase` prompts for the SSH key passphrase.
|
||||
* `--ssh-key-passphrase-env ENV_VAR` reads the SSH key passphrase from an environment variable (useful for CI/non-interactive runs).
|
||||
* If neither is provided, and Enroll detects an encrypted key in an interactive session, it will still fall back to prompting on-demand.
|
||||
* In non-interactive sessions, pass `--ask-key-passphrase` or `--ssh-key-passphrase-env ENV_VAR` when using encrypted private keys.
|
||||
* Note: `--ask-key-passphrase` and `--ssh-key-passphrase-env` are mutually exclusive.
|
||||
- Root PATH safety:
|
||||
- when run as root, Enroll warns and asks for confirmation if `PATH` contains `.`, an empty/relative entry, or a group/world-writable directory.
|
||||
- use `--assume-safe-path` for trusted non-interactive automation where that `PATH` is intentional.
|
||||
|
||||
Examples (encrypted SSH key)
|
||||
|
||||
```bash
|
||||
# Interactive
|
||||
enroll harvest --remote-host myhost.example.com --remote-user myuser --ask-key-passphrase --out /tmp/enroll-harvest
|
||||
|
||||
# Non-interactive / CI
|
||||
export ENROLL_SSH_KEY_PASSPHRASE='correct horse battery staple'
|
||||
enroll single-shot --remote-host myhost.example.com --remote-user myuser --ssh-key-passphrase-env ENROLL_SSH_KEY_PASSPHRASE --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn myhost.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `enroll manifest`
|
||||
Generate Ansible output from an existing harvest bundle.
|
||||
|
||||
**Inputs**
|
||||
- `--harvest /path/to/harvest` (directory)
|
||||
or `--harvest /path/to/harvest.tar.gz.sops` (if using `--sops`)
|
||||
|
||||
**Output**
|
||||
- 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 for Ansible (host-specific state lives in inventory `host_vars`).
|
||||
- `--no-common-roles`: disables the default grouping of package and systemd-unit roles into Debian Section/RPM Group roles, preserving one generated role per package/unit. `--fqdn` implies this behaviour.
|
||||
|
||||
**Role tags**
|
||||
Generated playbooks tag each role so you can target just the parts you need:
|
||||
|
||||
- Tag format: `role_<role_name>` (e.g. `role_services`, `role_users`)
|
||||
- Fallback/safe tag: `role_other`
|
||||
|
||||
Example:
|
||||
```bash
|
||||
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_services,role_users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `enroll single-shot`
|
||||
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`, `--no-common-roles`, remote harvest flags, and `--sops`.
|
||||
|
||||
---
|
||||
|
||||
### `enroll diff`
|
||||
Compare two harvest bundles and report what changed.
|
||||
|
||||
**What it reports**
|
||||
- Packages added/removed
|
||||
- Services enabled added/removed, plus key state changes
|
||||
- Users added/removed, plus field changes (uid/gid/home/shell/groups, etc.)
|
||||
- Managed files added/removed/changed (metadata + content hash changes where available)
|
||||
|
||||
**Inputs**
|
||||
- `--old <harvest>` and `--new <harvest>` (directories or `state.json` paths)
|
||||
- `--sops` when comparing SOPS-encrypted harvest bundles
|
||||
- `--exclude-path <PATTERN>` (repeatable) to ignore file/dir drift under matching paths (same pattern syntax as harvest)
|
||||
- `--ignore-package-versions` to ignore package version-only drift (upgrades/downgrades)
|
||||
|
||||
**Noise suppression**
|
||||
- `--exclude-path` is useful for things that change often but you still want in the harvest baseline (e.g. `/var/anacron`).
|
||||
- `--ignore-package-versions` keeps routine upgrades from alerting; package add/remove drift is still reported.
|
||||
|
||||
|
||||
**Output formats**
|
||||
- `--format json` (default for webhooks)
|
||||
- `--format markdown` / `--format text` (human-oriented)
|
||||
|
||||
**Notifications**
|
||||
- Webhook:
|
||||
- `--webhook <url>`
|
||||
- `--webhook-format json|markdown|text`
|
||||
- `--webhook-header 'Header-Name: value'` (repeatable)
|
||||
- Email (optional):
|
||||
- `--email-to <addr>` (plus optional SMTP/sendmail-related flags, depending on your install)
|
||||
|
||||
---
|
||||
|
||||
### `enroll explain`
|
||||
Analyze a harvest and provide user-friendly explanations for what's in it and why.
|
||||
|
||||
This may also explain why something *wasn't* included (e.g a binary file, a file that was too large, unreadable due to permissions, or looked like a log file/secret.
|
||||
|
||||
Provide either the path to the harvest or the path to its state.json. It can also handle SOPS-encrypted harvests.
|
||||
|
||||
Output can be provided in plaintext or json.
|
||||
|
||||
---
|
||||
|
||||
### `enroll validate`
|
||||
|
||||
Validates a harvest by checking:
|
||||
|
||||
* state.json exists and is valid JSON
|
||||
* state.json validates against a JSON Schema (by default the vendored one)
|
||||
* Every `managed_file` entry has a corresponding artifact at: `artifacts/<role_name>/<src_rel>`
|
||||
* That there are no **unreferenced files** sitting in `artifacts/` that aren't in the state.
|
||||
|
||||
#### Schema location + overrides
|
||||
|
||||
The master schema lives at: `enroll/schema/state.schema.json`.
|
||||
|
||||
You can override with a local file or URL:
|
||||
|
||||
```
|
||||
enroll validate /path/to/harvest --schema ./state.schema.json
|
||||
enroll validate /path/to/harvest --schema https://enroll.sh/schema/state.schema.json
|
||||
```
|
||||
|
||||
Or skip schema checks (still does artifact consistency checks):
|
||||
|
||||
```
|
||||
enroll validate /path/to/harvest --no-schema
|
||||
```
|
||||
|
||||
#### CLI usage examples
|
||||
|
||||
Validate a local harvest:
|
||||
|
||||
```
|
||||
enroll validate ./harvest
|
||||
```
|
||||
|
||||
Validate a harvest tarball or a sops bundle:
|
||||
|
||||
```
|
||||
enroll validate ./harvest.tar.gz
|
||||
enroll validate ./harvest.sops --sops
|
||||
```
|
||||
|
||||
JSON output + write to file:
|
||||
|
||||
```
|
||||
enroll validate ./harvest --format json --out validate.json
|
||||
```
|
||||
|
||||
Return exit code 1 for any warnings, not just errors (useful for CI):
|
||||
|
||||
```
|
||||
enroll validate ./harvest --fail-on-warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sensitive data
|
||||
|
||||
By default, `enroll` does **not** assume how you handle secrets in Ansible. It will attempt to avoid harvesting likely sensitive data (private keys, passwords, tokens, etc.). This can mean it skips some config files you may ultimately want to manage.
|
||||
|
||||
Safe-mode content scanning is intentionally conservative. It treats common assignment-style credential keys as sensitive, including names such as `password`, `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 ignores comments in files! If you have commented out *real secrets*, there's still a risk that Enroll could capture that data even without `--dangerous`. If you are in doubt, play it safe: use `--sops` and/or encrypt the output at rest in a way that makes sense to you.
|
||||
|
||||
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`
|
||||
|
||||
**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.
|
||||
|
||||
⚠️ Important: `manifest --sops` produces one encrypted file. You must decrypt + extract it before running `ansible-playbook`.
|
||||
|
||||
---
|
||||
|
||||
## JinjaTurtle integration
|
||||
|
||||
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 template generation on with `--jinjaturtle` or disable it with `--no-jinjaturtle`.
|
||||
|
||||
---
|
||||
|
||||
## How multi-site avoids “shared role breaks a host”
|
||||
|
||||
In multi-site mode, roles are **data-driven**. The role tasks are generic (“deploy the files listed for this host”, “install the packages listed for this host”, “apply systemd enable/start state listed for this host”). Host inventory decides what applies per-host, avoiding the classic “host2 adds config, host1 breaks” failure mode.
|
||||
|
||||
---
|
||||
|
||||
# Install
|
||||
|
||||
## Ubuntu/Debian apt repository
|
||||
```bash
|
||||
sudo mkdir -p /usr/share/keyrings
|
||||
curl -fsSL https://mig5.net/static/mig5.asc | sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg
|
||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mig5.list
|
||||
sudo apt update
|
||||
sudo apt install enroll
|
||||
```
|
||||
|
||||
## Fedora
|
||||
|
||||
```bash
|
||||
sudo rpm --import https://mig5.net/static/mig5.asc
|
||||
|
||||
sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF'
|
||||
[mig5]
|
||||
name=mig5 Repository
|
||||
baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
repo_gpgcheck=1
|
||||
gpgkey=https://mig5.net/static/mig5.asc
|
||||
EOF
|
||||
|
||||
sudo dnf upgrade --refresh
|
||||
sudo dnf install enroll
|
||||
```
|
||||
|
||||
## AppImage
|
||||
Download it from my Releases page, then:
|
||||
|
||||
```bash
|
||||
chmod +x Enroll.AppImage
|
||||
./Enroll.AppImage
|
||||
```
|
||||
|
||||
## Pip/PipX
|
||||
```bash
|
||||
pip install enroll
|
||||
```
|
||||
|
||||
## Poetry (dev)
|
||||
```bash
|
||||
poetry install
|
||||
poetry run enroll --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Found a bug / have a suggestion?
|
||||
|
||||
My Forgejo doesn't currently support federation, so I haven't opened registration/login for issues.
|
||||
|
||||
Instead, email me (see `pyproject.toml`) or contact me on the Fediverse:
|
||||
|
||||
https://goto.mig5.net/@mig5
|
||||
|
||||
---
|
||||
|
||||
# Examples
|
||||
|
||||
## Harvest
|
||||
|
||||
### Local harvest
|
||||
```bash
|
||||
enroll harvest --out /tmp/enroll-harvest
|
||||
```
|
||||
|
||||
### Remote harvest over SSH
|
||||
```bash
|
||||
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)
|
||||
enroll harvest --out /tmp/enroll-harvest --include-path '/home/*/.bashrc' --include-path '/home/*/.profile'
|
||||
```
|
||||
|
||||
### Exclude paths (`--exclude-path`)
|
||||
```bash
|
||||
# Skip specific /usr/local/bin entries (or patterns)
|
||||
enroll harvest --out /tmp/enroll-harvest --exclude-path '/usr/local/bin/docker-*' --exclude-path '/usr/local/bin/some-tool'
|
||||
```
|
||||
|
||||
### Regex include
|
||||
```bash
|
||||
enroll harvest --out /tmp/enroll-harvest --include-path 're:^/home/[^/]+/\.config/myapp/.*$'
|
||||
```
|
||||
|
||||
### `--dangerous`
|
||||
```bash
|
||||
enroll harvest --out /tmp/enroll-harvest --dangerous
|
||||
```
|
||||
|
||||
### Remote + dangerous:
|
||||
```bash
|
||||
enroll harvest --remote-host myhost.example.com --remote-user myuser --dangerous
|
||||
```
|
||||
|
||||
### `--sops` (encrypt at rest)
|
||||
```bash
|
||||
# Encrypted harvest bundle (writes /tmp/enroll-harvest/harvest.tar.gz.sops)
|
||||
enroll harvest --out /tmp/enroll-harvest --dangerous --sops <FINGERPRINT(s)>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manifest
|
||||
|
||||
### Single-site (default: no --fqdn)
|
||||
```bash
|
||||
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
|
||||
```
|
||||
|
||||
### Multi-site (--fqdn)
|
||||
```bash
|
||||
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)
|
||||
enroll manifest --harvest /tmp/enroll-harvest/harvest.tar.gz.sops --out /tmp/enroll-ansible --sops <FINGERPRINT(s)>
|
||||
|
||||
# Decrypt/extract the manifest bundle, then run Ansible from inside ./manifest/
|
||||
cd /tmp/enroll-ansible
|
||||
sops -d manifest.tar.gz.sops | tar -xzvf -
|
||||
cd manifest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Single-shot
|
||||
|
||||
```bash
|
||||
enroll single-shot --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
|
||||
```
|
||||
|
||||
Remote single-shot (run harvest over SSH, then manifest locally):
|
||||
```bash
|
||||
enroll single-shot --remote-host myhost.example.com --remote-user myuser --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "myhost.example.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diff
|
||||
|
||||
### Compare two harvest directories, output in json
|
||||
```bash
|
||||
enroll diff --old /path/to/harvestA --new /path/to/harvestB --format json
|
||||
```
|
||||
|
||||
### Diff + webhook notify
|
||||
```bash
|
||||
enroll diff --old /path/to/golden/harvest --new /path/to/new/harvest --webhook https://nr.mig5.net/forms/webhooks/xxxx --webhook-format json --webhook-header 'X-Enroll-Secret: xxxx'
|
||||
```
|
||||
|
||||
`diff` mode also supports email sending and text or markdown format, as well as `--exit-code` mode to trigger a return code of 2 (useful for crons or CI)
|
||||
|
||||
### Ignore a specific directory or file from the diff
|
||||
```bash
|
||||
enroll diff --old /path/to/harvestA --new /path/to/harvestB --exclude-path /var/anacron
|
||||
```
|
||||
|
||||
### Ignore package version drift (routine upgrades) but still alert on add/remove
|
||||
```bash
|
||||
enroll diff --old /path/to/harvestA --new /path/to/harvestB --ignore-package-versions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Explain
|
||||
|
||||
### Explain a harvest
|
||||
|
||||
All of these do the same thing:
|
||||
|
||||
```bash
|
||||
enroll explain /path/to/state.json
|
||||
enroll explain /path/to/bundle_dir
|
||||
enroll explain /path/to/harvest.tar.gz
|
||||
```
|
||||
|
||||
### Explain a SOPS-encrypted harvest
|
||||
|
||||
```bash
|
||||
enroll explain /path/to/harvest.tar.gz.sops --sops
|
||||
```
|
||||
|
||||
### Explain with JSON output and more examples
|
||||
|
||||
```bash
|
||||
enroll explain /path/to/state.json --format json --max-examples 25
|
||||
```
|
||||
|
||||
### Example output
|
||||
|
||||
```
|
||||
❯ enroll explain /tmp/syrah.harvest
|
||||
Enroll explain: /tmp/syrah.harvest
|
||||
Host: syrah.mig5.net (os: debian, pkg: dpkg)
|
||||
Enroll: 0.2.3
|
||||
|
||||
Inventory
|
||||
- Packages: 254
|
||||
- Why packages were included (observed_via):
|
||||
- user_installed: 248 – Package appears explicitly installed (as opposed to only pulled in as a dependency).
|
||||
- package_role: 232 – Package was referenced by an enroll packages snapshot/role. (e.g. acl, acpid, adduser)
|
||||
- systemd_unit: 22 – Package is associated with a systemd unit that was harvested. (e.g. postfix.service, tor.service, apparmor.service)
|
||||
|
||||
Roles collected
|
||||
- users: 1 user(s), 1 file(s), 0 excluded
|
||||
- services: 19 unit(s), 111 file(s), 6 excluded
|
||||
- packages: 232 package snapshot(s), 41 file(s), 0 excluded
|
||||
- apt_config: 26 file(s), 7 dir(s), 10 excluded
|
||||
- dnf_config: 0 file(s), 0 dir(s), 0 excluded
|
||||
- firewall_runtime: 2 snapshot(s), 1 ipset(s)
|
||||
- etc_custom: 70 file(s), 20 dir(s), 0 excluded
|
||||
- usr_local_custom: 35 file(s), 1 dir(s), 0 excluded
|
||||
- extra_paths: 0 file(s), 0 dir(s), 0 excluded
|
||||
|
||||
Why files were included (managed_files.reason)
|
||||
- custom_unowned (179): A file not owned by any package (often custom/operator-managed).. Examples: /etc/apparmor.d/local/lsb_release, /etc/apparmor.d/local/nvidia_modprobe, /etc/apparmor.d/local/sbin.dhclient
|
||||
- usr_local_bin_script (35): Executable scripts under /usr/local/bin (often operator-installed).. Examples: /usr/local/bin/check_firewall, /usr/local/bin/awslogs
|
||||
- apt_keyring (13): Repository signing key material used by APT.. Examples: /etc/apt/keyrings/openvpn-repo-public.asc, /etc/apt/trusted.gpg, /etc/apt/trusted.gpg.d/deb.torproject.org-keyring.gpg
|
||||
- modified_conffile (10): A package-managed conffile differs from the packaged/default version.. Examples: /etc/dnsmasq.conf, /etc/ssh/moduli, /etc/tor/torrc
|
||||
- logrotate_snippet (9): logrotate snippets/configs referenced in system configuration.. Examples: /etc/logrotate.d/rsyslog, /etc/logrotate.d/tor, /etc/logrotate.d/apt
|
||||
- apt_config (7): APT configuration affecting package installation and repository behavior.. Examples: /etc/apt/apt.conf.d/01autoremove, /etc/apt/apt.conf.d/20listchanges, /etc/apt/apt.conf.d/70debconf
|
||||
[...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run Ansible
|
||||
|
||||
### Single-site
|
||||
```bash
|
||||
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml
|
||||
```
|
||||
|
||||
### Multi-site (--fqdn)
|
||||
```bash
|
||||
ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml
|
||||
```
|
||||
|
||||
### Run only specific roles (tags)
|
||||
Generated playbooks tag each role as `role_<name>` (e.g. `role_users`, `role_services`), so you can speed up targeted runs:
|
||||
```bash
|
||||
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_users
|
||||
```
|
||||
|
||||
## Configuration file
|
||||
|
||||
As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands.
|
||||
|
||||
Sometimes, it can be easier to store them in a config file so you don't have to remember them!
|
||||
|
||||
Enroll supports reading an ini-style file of all the arguments for each subcommand.
|
||||
|
||||
### 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 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.
|
||||
|
||||
### Precedence
|
||||
|
||||
Highest wins:
|
||||
|
||||
* Explicit CLI flags
|
||||
* INI config ([cmd], [enroll])
|
||||
* argparse defaults
|
||||
|
||||
### Example config file
|
||||
|
||||
Here is an example.
|
||||
|
||||
Whenever an argument on the command-line has a 'hyphen' in it, just be sure to change it to an underscore in the ini file.
|
||||
|
||||
```ini
|
||||
[enroll]
|
||||
# (future global flags may live here)
|
||||
|
||||
[harvest]
|
||||
dangerous = false
|
||||
include_path =
|
||||
/home/*/.bashrc
|
||||
/home/*/.profile
|
||||
exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
|
||||
# remote_host = yourserver.example.com
|
||||
# remote_user = you
|
||||
# remote_port = 2222
|
||||
|
||||
[manifest]
|
||||
# you can set defaults here too, e.g.
|
||||
no_jinjaturtle = true
|
||||
sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
|
||||
|
||||
[diff]
|
||||
# ignore noisy drift
|
||||
exclude_path = /var/anacron
|
||||
ignore_package_versions = true
|
||||
|
||||
[single-shot]
|
||||
# if you use single-shot, put its defaults here.
|
||||
# It does not inherit those of the subsections above, so you
|
||||
# may wish to repeat them here.
|
||||
include_path = re:^/home/[^/]+/\.config/myapp/.*$
|
||||
```
|
||||
|
|
|
|||
22
SECURITY.md
22
SECURITY.md
|
|
@ -12,9 +12,9 @@ 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`, `--enforce`, `--remote-host`, and `--remote-ssh-config`.
|
||||
* Harvest bundles used for `manifest`, `diff`, or `diff --enforce` 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, Puppet, Salt, SOPS, SSH, `sudo`, Docker, Podman, Flatpak, Snap, package managers, and system utilities, are assumed to be the trusted tools the operator intended to use.
|
||||
* 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
|
||||
|
||||
|
|
@ -28,10 +28,12 @@ In-scope security concerns include:
|
|||
* 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 shell injection in generated manifests where harvested values are embedded into Ansible, Puppet, or Salt output.
|
||||
* 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.
|
||||
|
|
@ -44,22 +46,21 @@ The following are generally out of scope and should not be reported as Enroll vu
|
|||
* 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 root user enforcing a malicious or manually edited harvest bundle with `diff --enforce`.
|
||||
* A user applying generated Ansible, Puppet, or Salt manifests from an untrusted harvest.
|
||||
* 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 has a *commented out* secret even with `--dangerous` disabled (it ignores comments so as to not be totally useless when it comes to harvesting config files). It is still the responsibility of the user to use `--sops` or appropriate at-rest encryption if in the slightest doubt about what might get harvested.
|
||||
* 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 and enforcement
|
||||
## 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`, `diff`, or especially `diff --enforce`, the operator should be confident that the harvest bundle came from a trusted source and has not been tampered with.
|
||||
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.
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ Useful vulnerability reports include issues where Enroll behaves unsafely despit
|
|||
* 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, Puppet, or Salt manifest where ordinary harvested data can cause command injection.
|
||||
* 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.
|
||||
|
|
@ -91,7 +92,6 @@ Less useful reports, and normally out of scope, include:
|
|||
* “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.”
|
||||
* “Root can enforce a malicious harvest bundle.”
|
||||
* “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.
|
||||
|
|
|
|||
|
|
@ -3,11 +3,18 @@ from __future__ import annotations
|
|||
import configparser
|
||||
import os
|
||||
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:
|
||||
|
|
@ -161,15 +168,44 @@ def find_user_ssh_files(home: str) -> List[str]:
|
|||
return sorted(set(out))
|
||||
|
||||
|
||||
def _read_first_existing_text(paths: List[str]) -> Optional[str]:
|
||||
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:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
value = f.read().strip()
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -457,7 +493,7 @@ def _flatpak_remote_from_ref(
|
|||
arch,
|
||||
branch,
|
||||
)
|
||||
if os.path.exists(ref):
|
||||
if not path_has_symlink_component(ref) and os.path.exists(ref):
|
||||
return remote_name
|
||||
return None
|
||||
|
||||
|
|
@ -473,11 +509,11 @@ def _parse_flatpak_deploy_origin(branch_dir: str) -> Optional[str]:
|
|||
if origin:
|
||||
return origin
|
||||
|
||||
metadata = candidates[1]
|
||||
if os.path.isfile(metadata):
|
||||
metadata = _read_first_existing_text([candidates[1]])
|
||||
if metadata:
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
try:
|
||||
parser.read(metadata, encoding="utf-8")
|
||||
parser.read_string(metadata)
|
||||
except Exception:
|
||||
return None
|
||||
for section in ("Application", "Runtime"):
|
||||
|
|
@ -496,7 +532,7 @@ def _find_flatpaks_in_root(
|
|||
home: Optional[str] = None,
|
||||
) -> List[FlatpakInstall]:
|
||||
apps_dir = os.path.join(flatpak_root, "app")
|
||||
if not os.path.isdir(apps_dir):
|
||||
if not is_dir_no_symlink_components(apps_dir):
|
||||
return []
|
||||
|
||||
remote_names = [
|
||||
|
|
@ -513,7 +549,7 @@ def _find_flatpaks_in_root(
|
|||
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 os.path.isdir(app_path):
|
||||
if not is_dir_no_symlink_components(app_path):
|
||||
continue
|
||||
try:
|
||||
arches = sorted(os.listdir(app_path))
|
||||
|
|
@ -521,7 +557,7 @@ def _find_flatpaks_in_root(
|
|||
continue
|
||||
for arch in arches:
|
||||
arch_path = os.path.join(app_path, arch)
|
||||
if not os.path.isdir(arch_path):
|
||||
if not is_dir_no_symlink_components(arch_path):
|
||||
continue
|
||||
try:
|
||||
branches = sorted(os.listdir(arch_path))
|
||||
|
|
@ -529,10 +565,10 @@ def _find_flatpaks_in_root(
|
|||
continue
|
||||
for branch in branches:
|
||||
branch_path = os.path.join(arch_path, branch)
|
||||
if not os.path.isdir(branch_path):
|
||||
if not is_dir_no_symlink_components(branch_path):
|
||||
continue
|
||||
active_dir = os.path.join(branch_path, "active")
|
||||
if not os.path.exists(active_dir):
|
||||
if not is_dir_no_symlink_components(active_dir):
|
||||
continue
|
||||
remote = _parse_flatpak_deploy_origin(branch_path)
|
||||
if not remote:
|
||||
|
|
@ -576,12 +612,13 @@ def find_flatpak_remotes(
|
|||
.flatpakref/.flatpakrepo URL that was used during installation.
|
||||
"""
|
||||
config_path = os.path.join(flatpak_root, "repo", "config")
|
||||
if not os.path.isfile(config_path):
|
||||
config_text = _read_first_existing_text([config_path])
|
||||
if not config_text:
|
||||
return []
|
||||
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
try:
|
||||
parser.read(config_path, encoding="utf-8")
|
||||
parser.read_string(config_text)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ from .manifest_safety import (
|
|||
iter_safe_artifact_files,
|
||||
prepare_manifest_output_dir,
|
||||
)
|
||||
from .render_safety import ansible_unsafe_data
|
||||
from .render_safety import (
|
||||
ansible_unsafe_data,
|
||||
assert_generated_yaml_safe,
|
||||
scaffold_token,
|
||||
)
|
||||
from .role_names import avoid_reserved_role_name
|
||||
from .state import inventory_packages_from_state, roles_from_state
|
||||
from .yamlutil import yaml_dump_mapping, yaml_load_mapping
|
||||
|
|
@ -236,7 +240,7 @@ class AnsibleRole(CMModule):
|
|||
self.add_snapshot_notes(snap)
|
||||
|
||||
def render_firewall_runtime_tasks(self) -> str:
|
||||
var_prefix = self.role_name
|
||||
var_prefix = scaffold_token(self.role_name, field="role var_prefix")
|
||||
return f"""- name: Ensure firewall runtime snapshot directory exists
|
||||
ansible.builtin.file:
|
||||
path: {self.firewall_runtime_dir}
|
||||
|
|
@ -292,7 +296,7 @@ class AnsibleRole(CMModule):
|
|||
"""
|
||||
|
||||
def render_firewall_runtime_handlers(self) -> str:
|
||||
var_prefix = self.role_name
|
||||
var_prefix = scaffold_token(self.role_name, field="role var_prefix")
|
||||
return f"""---
|
||||
- name: Flush captured ipsets before restoring members
|
||||
ansible.builtin.command:
|
||||
|
|
@ -512,7 +516,7 @@ def _write_role_scaffold(role_dir: str) -> None:
|
|||
def _role_tag(role: str) -> str:
|
||||
"""Return a stable Ansible tag name for a role.
|
||||
|
||||
Used by `enroll diff --enforce` to run only the roles needed to repair drift.
|
||||
Lets operators run only selected roles via `--tags` when applying a manifest.
|
||||
"""
|
||||
r = str(role or "").strip()
|
||||
# Ansible tag charset is fairly permissive, but keep it portable and consistent.
|
||||
|
|
@ -522,6 +526,16 @@ def _role_tag(role: str) -> str:
|
|||
return f"role_{safe}"
|
||||
|
||||
|
||||
def _write_generated_task_yaml(path: str, text: str, *, label: str) -> None:
|
||||
"""Write generated task/handler/playbook YAML through one safety gate."""
|
||||
|
||||
body = text.rstrip() + "\n"
|
||||
assert_generated_yaml_safe(body, label=label)
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(body)
|
||||
|
||||
|
||||
def _write_playbook_all(path: str, roles: List[str]) -> None:
|
||||
pb_lines = [
|
||||
"---",
|
||||
|
|
@ -532,13 +546,15 @@ def _write_playbook_all(path: str, roles: List[str]) -> None:
|
|||
" roles:",
|
||||
]
|
||||
for r in roles:
|
||||
pb_lines.append(f" - role: {r}")
|
||||
pb_lines.append(f" tags: [{_role_tag(r)}]")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(pb_lines) + "\n")
|
||||
safe = scaffold_token(r, field="role name")
|
||||
pb_lines.append(f" - role: {safe}")
|
||||
pb_lines.append(f" tags: [{_role_tag(safe)}]")
|
||||
text = "\n".join(pb_lines) + "\n"
|
||||
_write_generated_task_yaml(path, text, label="playbook.yml")
|
||||
|
||||
|
||||
def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
|
||||
fqdn = scaffold_token(fqdn, field="site fqdn")
|
||||
pb_lines = [
|
||||
"---",
|
||||
f"- name: Apply all roles on {fqdn}",
|
||||
|
|
@ -548,10 +564,11 @@ def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
|
|||
" roles:",
|
||||
]
|
||||
for r in roles:
|
||||
pb_lines.append(f" - role: {r}")
|
||||
pb_lines.append(f" tags: [{_role_tag(r)}]")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(pb_lines) + "\n")
|
||||
safe = scaffold_token(r, field="role name")
|
||||
pb_lines.append(f" - role: {safe}")
|
||||
pb_lines.append(f" tags: [{_role_tag(safe)}]")
|
||||
text = "\n".join(pb_lines) + "\n"
|
||||
_write_generated_task_yaml(path, text, label="host playbook")
|
||||
|
||||
|
||||
def _ensure_ansible_cfg(cfg_path: str) -> None:
|
||||
|
|
@ -724,7 +741,7 @@ def _write_ansible_role_vars(
|
|||
*,
|
||||
site_defaults: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Write role variables using the same mode split as Puppet Hiera/Salt Pillar."""
|
||||
"""Write role variables using the single-site/site-mode split."""
|
||||
|
||||
if ctx.site_mode:
|
||||
_write_role_defaults(role_dir, site_defaults or {})
|
||||
|
|
@ -756,13 +773,21 @@ def _write_ansible_role(
|
|||
ctx, role_dir, role, vars_map or {}, site_defaults=site_defaults
|
||||
)
|
||||
|
||||
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write(tasks.rstrip() + "\n")
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(handlers.rstrip() + "\n")
|
||||
# Backstop guardrail: never write a tasks/handlers document whose *structure*
|
||||
# was altered by a harvested value. Enroll authors this YAML as scaffolding
|
||||
# and keeps all harvested data in variable files; if any harvested value ever
|
||||
# leaked into this text and changed its shape, fail closed here rather than
|
||||
# emit a poisoned playbook.
|
||||
_write_generated_task_yaml(
|
||||
os.path.join(role_dir, "tasks", "main.yml"),
|
||||
tasks,
|
||||
label=f"role '{role}' tasks/main.yml",
|
||||
)
|
||||
_write_generated_task_yaml(
|
||||
os.path.join(role_dir, "handlers", "main.yml"),
|
||||
handlers,
|
||||
label=f"role '{role}' handlers/main.yml",
|
||||
)
|
||||
|
||||
_write_role_meta(role_dir, collections)
|
||||
|
||||
|
|
@ -981,6 +1006,8 @@ def _render_generic_files_tasks(var_prefix: str) -> str:
|
|||
def _render_install_packages_tasks(role: str, var_prefix: str) -> str:
|
||||
"""Render package installation through Ansible's generic package provider."""
|
||||
|
||||
role = scaffold_token(role, field="role name")
|
||||
var_prefix = scaffold_token(var_prefix, field="role var_prefix")
|
||||
return f"""- name: Install packages for {role}
|
||||
ansible.builtin.package:
|
||||
name: "{{{{ {var_prefix}_packages | default([]) }}}}"
|
||||
|
|
@ -1145,6 +1172,7 @@ def _render_role_tasks(
|
|||
|
||||
|
||||
def _single_service_restart_handler_body(var_prefix: str) -> str:
|
||||
var_prefix = scaffold_token(var_prefix, field="role var_prefix")
|
||||
return f"""- name: Restart service
|
||||
ansible.builtin.service:
|
||||
name: "{{{{ {var_prefix}_unit_name }}}}"
|
||||
|
|
@ -1156,25 +1184,66 @@ def _single_service_restart_handler_body(var_prefix: str) -> str:
|
|||
"""
|
||||
|
||||
|
||||
def _service_restart_handler_name(unit: str) -> str:
|
||||
return f"Restart managed service {unit}"
|
||||
def _service_restart_listen_topic(var_prefix: str) -> str:
|
||||
"""Return the fixed handler ``listen:`` topic for a grouped role.
|
||||
|
||||
The topic is derived solely from the (already sanitized) role var_prefix and
|
||||
is validated as a scaffold token. It deliberately contains NO harvested data
|
||||
such as a unit name, so the same string can be embedded in both the notify
|
||||
side (data) and the handler ``listen:`` (scaffolding) without ever splicing a
|
||||
harvested value into YAML structure. The specific units to restart travel as
|
||||
the ``<var_prefix>_restart_units`` Ansible *variable*.
|
||||
"""
|
||||
|
||||
var_prefix = scaffold_token(var_prefix, field="role var_prefix")
|
||||
return f"enroll_restart_grouped_services_{var_prefix}"
|
||||
|
||||
|
||||
def _grouped_service_restart_handlers_body(role: AnsibleRole) -> str:
|
||||
handlers: List[str] = []
|
||||
"""Render the grouped-service restart handler.
|
||||
|
||||
Harvested unit names never appear in this YAML text. The handler listens on
|
||||
a fixed, role-scoped topic and restarts each unit from the
|
||||
``<var_prefix>_restart_units`` variable (written to the role's defaults /
|
||||
host_vars through ``ansible_unsafe_data``). If no unit in the role is in a
|
||||
"started" state, no restart handler is emitted.
|
||||
"""
|
||||
|
||||
has_restartable = any(
|
||||
str(svc.get("state") or "stopped") == "started"
|
||||
for svc in role.services.values()
|
||||
)
|
||||
if not has_restartable:
|
||||
return ""
|
||||
|
||||
var_prefix = scaffold_token(role.var_prefix, field="role var_prefix")
|
||||
topic = _service_restart_listen_topic(var_prefix)
|
||||
return f"""- name: Restart managed services for {var_prefix}
|
||||
ansible.builtin.service:
|
||||
name: "{{{{ item }}}}"
|
||||
state: restarted
|
||||
loop: "{{{{ {var_prefix}_restart_units | default([]) }}}}"
|
||||
listen: {topic}
|
||||
when: enroll_manage_systemd_runtime | default(true) | bool
|
||||
"""
|
||||
|
||||
|
||||
def restart_units_for_role(role: AnsibleRole) -> List[str]:
|
||||
"""Return the harvested unit names a grouped role should restart, as data.
|
||||
|
||||
These are emitted into the role's ``<var_prefix>_restart_units`` variable and
|
||||
consumed by the restart handler's loop. They are ordinary harvested values
|
||||
and are protected by ``ansible_unsafe_data`` when the variable file is
|
||||
written -- they never touch YAML scaffolding.
|
||||
"""
|
||||
|
||||
units: List[str] = []
|
||||
for unit, svc in sorted(role.services.items()):
|
||||
name = str(svc.get("name") or unit).strip()
|
||||
if not name or str(svc.get("state") or "stopped") != "started":
|
||||
continue
|
||||
handlers.append(
|
||||
f"""- name: {_service_restart_handler_name(name)}
|
||||
ansible.builtin.service:
|
||||
name: {name}
|
||||
state: restarted
|
||||
when: enroll_manage_systemd_runtime | default(true) | bool
|
||||
"""
|
||||
)
|
||||
return "\n".join(_task_body(handler) for handler in handlers if _task_body(handler))
|
||||
units.append(name)
|
||||
return units
|
||||
|
||||
|
||||
def _render_role_handlers(
|
||||
|
|
@ -1206,21 +1275,6 @@ def _render_role_handlers(
|
|||
return "---\n" + (body.rstrip() + "\n" if body else "")
|
||||
|
||||
|
||||
# --- Ansible variable builders ---
|
||||
def _normalise_flatpak_item(
|
||||
item: Any,
|
||||
*,
|
||||
method: str,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return CMModule.normalise_flatpak_item(item, method=method, user=user, home=home)
|
||||
|
||||
|
||||
def _normalise_flatpak_remote(item: Any) -> Dict[str, Any]:
|
||||
return CMModule.normalise_flatpak_remote(item)
|
||||
|
||||
|
||||
def _normalise_snap_item(item: Any) -> Dict[str, Any]:
|
||||
out = CMModule.normalise_snap_item(item)
|
||||
|
||||
|
|
@ -1735,13 +1789,16 @@ def _write_managed_files_role(
|
|||
_write_role_defaults(role_dir, vars_map)
|
||||
|
||||
tasks = _render_role_tasks(AnsibleRole(role), managed_content=True)
|
||||
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write(tasks.rstrip() + "\n")
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(handlers.rstrip() + "\n")
|
||||
_write_generated_task_yaml(
|
||||
os.path.join(role_dir, "tasks", "main.yml"),
|
||||
tasks,
|
||||
label=f"role '{role}' tasks/main.yml",
|
||||
)
|
||||
_write_generated_task_yaml(
|
||||
os.path.join(role_dir, "handlers", "main.yml"),
|
||||
handlers,
|
||||
label=f"role '{role}' handlers/main.yml",
|
||||
)
|
||||
|
||||
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write("---\ndependencies: []\n")
|
||||
|
|
@ -1819,16 +1876,19 @@ def _role_managed_content_vars(
|
|||
if notify_service_handlers and kind == "service":
|
||||
unit = str(snap.get("unit") or "").strip()
|
||||
if unit and str(snap.get("active_state") or "") == "active":
|
||||
notify_other = _service_restart_handler_name(unit)
|
||||
# Notify the role's fixed restart topic (scaffold-safe). The
|
||||
# specific unit travels as data in <var_prefix>_restart_units;
|
||||
# it is never spliced into the handler/notify YAML text.
|
||||
notify_other = _service_restart_listen_topic(role)
|
||||
else:
|
||||
notify_other = None
|
||||
elif notify_service_handlers and kind == "package":
|
||||
notify_other = [
|
||||
_service_restart_handler_name(unit)
|
||||
for unit in CMModule.active_service_units_for_package_snapshot(
|
||||
if CMModule.active_service_units_for_package_snapshot(
|
||||
snap, service_units_by_package
|
||||
)
|
||||
]
|
||||
):
|
||||
notify_other = _service_restart_listen_topic(role)
|
||||
else:
|
||||
notify_other = None
|
||||
|
||||
for item in _build_managed_files_var(
|
||||
managed_files,
|
||||
|
|
@ -2045,7 +2105,10 @@ def _render_common_ansible_roles(
|
|||
role,
|
||||
notify_by_kind={"service": None},
|
||||
overwrite_templates=True,
|
||||
extra_vars={f"{role.var_prefix}_systemd_units": systemd_units},
|
||||
extra_vars={
|
||||
f"{role.var_prefix}_systemd_units": systemd_units,
|
||||
f"{role.var_prefix}_restart_units": restart_units_for_role(role),
|
||||
},
|
||||
grouped_services=True,
|
||||
restart_grouped_services=True,
|
||||
notify_service_handlers=True,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ def copy_into_bundle(
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ from typing import Optional
|
|||
from .cache import new_harvest_cache_dir
|
||||
from .diff import (
|
||||
compare_harvests,
|
||||
enforce_old_harvest,
|
||||
format_report,
|
||||
has_enforceable_drift,
|
||||
post_webhook,
|
||||
send_email,
|
||||
)
|
||||
|
|
@ -458,15 +456,9 @@ def _encrypt_harvest_dir_to_sops(
|
|||
|
||||
|
||||
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--target",
|
||||
choices=["ansible", "puppet", "salt"],
|
||||
default="ansible",
|
||||
help="Manifest target to generate (default: ansible).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--fqdn",
|
||||
help="Host FQDN/name for site-mode output (creates target-specific host inventory/data such as Ansible host_vars, Puppet Hiera, or Salt pillar).",
|
||||
help="Host FQDN/name for site-mode output (creates Ansible host_vars for that host).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--no-common-roles",
|
||||
|
|
@ -799,24 +791,6 @@ def main() -> None:
|
|||
"Package additions/removals are still reported. Useful when routine upgrades would otherwise create noisy drift."
|
||||
),
|
||||
)
|
||||
d.add_argument(
|
||||
"--enforce",
|
||||
action="store_true",
|
||||
help=(
|
||||
"If differences are detected, attempt to enforce the old harvest state locally by generating a manifest and "
|
||||
"running the selected local apply tool. "
|
||||
"Enroll does not attempt to downgrade packages; if the only drift is package version upgrades (or newly installed packages), enforcement is skipped."
|
||||
),
|
||||
)
|
||||
d.add_argument(
|
||||
"--target",
|
||||
choices=["ansible", "puppet", "salt"],
|
||||
default="ansible",
|
||||
help=(
|
||||
"Configuration-management target to use with --enforce (default: ansible). "
|
||||
"Requires ansible-playbook, puppet, or salt-call on PATH as appropriate."
|
||||
),
|
||||
)
|
||||
d.add_argument(
|
||||
"--out",
|
||||
help="Write the report to this file instead of stdout.",
|
||||
|
|
@ -921,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(
|
||||
|
|
@ -1092,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"))
|
||||
|
|
@ -1119,7 +1103,6 @@ def main() -> None:
|
|||
jinjaturtle=_jt_mode(args),
|
||||
sops_fingerprints=getattr(args, "sops", None),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
if getattr(args, "sops", None) and out_enc:
|
||||
print(str(out_enc))
|
||||
|
|
@ -1134,42 +1117,6 @@ def main() -> None:
|
|||
),
|
||||
)
|
||||
|
||||
# Optional enforcement: if drift is detected, attempt to restore the
|
||||
# system to the *old* (baseline) state using the selected target.
|
||||
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,
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
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:
|
||||
|
|
@ -1258,7 +1205,6 @@ def main() -> None:
|
|||
jinjaturtle=_jt_mode(args),
|
||||
sops_fingerprints=list(sops_fps),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
if not args.harvest:
|
||||
print(str(out_file))
|
||||
|
|
@ -1291,7 +1237,6 @@ def main() -> None:
|
|||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
# For usability (when --harvest wasn't provided), print the harvest path.
|
||||
if not args.harvest:
|
||||
|
|
@ -1324,7 +1269,6 @@ def main() -> None:
|
|||
jinjaturtle=_jt_mode(args),
|
||||
sops_fingerprints=list(sops_fps),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
if not args.harvest:
|
||||
print(str(out_file))
|
||||
|
|
@ -1345,7 +1289,6 @@ def main() -> None:
|
|||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
except RemoteSudoPasswordRequired:
|
||||
raise SystemExit(
|
||||
|
|
|
|||
15
enroll/cm.py
15
enroll/cm.py
|
|
@ -22,9 +22,9 @@ from .state import load_state, state_path, write_state
|
|||
class CMModule:
|
||||
"""Renderer-neutral configuration-management resource group.
|
||||
|
||||
A CMModule is intentionally small: it captures the resources that a target
|
||||
renderer can turn into Ansible tasks, Puppet resources, Salt states, etc.
|
||||
The renderer may still decide how to name/include/order the 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
|
||||
|
|
@ -806,12 +806,11 @@ def _drop_duplicate_mapping_items(
|
|||
|
||||
|
||||
def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None:
|
||||
"""Resolve global catalog conflicts before renderer output.
|
||||
"""Resolve global catalog conflicts in the shared model.
|
||||
|
||||
Puppet and Salt compile a single resource catalog. Ansible can tolerate the
|
||||
same package, service, or parent directory appearing in more than one role;
|
||||
catalog targets cannot. Resolve those conflicts in the shared model rather
|
||||
than deleting renderer output after the fact.
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -228,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
|
||||
|
|
|
|||
506
enroll/diff.py
506
enroll/diff.py
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
|||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess # nosec
|
||||
import tarfile
|
||||
|
|
@ -29,6 +28,7 @@ from .state import (
|
|||
)
|
||||
from .pathfilter import PathFilter
|
||||
from .sopsutil import decrypt_file_binary_to, require_sops_cmd
|
||||
from .manifest_safety import freeze_directory_bundle
|
||||
|
||||
|
||||
def _validate_diff_bundle(label: str, bundle_dir: Path) -> None:
|
||||
|
|
@ -146,7 +146,9 @@ class BundleRef:
|
|||
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:
|
||||
|
|
@ -154,6 +156,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()
|
||||
|
|
@ -163,6 +173,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():
|
||||
|
|
@ -385,8 +398,8 @@ 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:
|
||||
|
|
@ -628,410 +641,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 _normalise_enforcement_target(target: str) -> str:
|
||||
t = str(target or "ansible").strip().lower()
|
||||
if t not in {"ansible", "puppet", "salt"}:
|
||||
raise ValueError(f"unsupported enforcement target: {target!r}")
|
||||
return t
|
||||
|
||||
|
||||
def _enforcement_tool(target: str) -> Tuple[str, str]:
|
||||
"""Return (binary-name, human-label) for a local enforcement target."""
|
||||
if target == "puppet":
|
||||
return "puppet", "puppet apply"
|
||||
if target == "salt":
|
||||
return "salt-call", "salt-call"
|
||||
return "ansible-playbook", "ansible-playbook"
|
||||
|
||||
|
||||
def _require_enforcement_tool(target: str) -> Tuple[str, str]:
|
||||
binary, label = _enforcement_tool(target)
|
||||
exe = shutil.which(binary)
|
||||
if not exe:
|
||||
install_hint = {
|
||||
"ansible": "Ansible",
|
||||
"puppet": "Puppet",
|
||||
"salt": "Salt",
|
||||
}.get(target, target)
|
||||
raise RuntimeError(
|
||||
f"{binary} not found on PATH "
|
||||
f"(cannot enforce with target {target}; install {install_hint})"
|
||||
)
|
||||
return exe, label
|
||||
|
||||
|
||||
def _enforcement_command(
|
||||
target: str,
|
||||
exe: str,
|
||||
manifest_dir: Path,
|
||||
*,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> Tuple[List[str], Dict[str, str]]:
|
||||
"""Return the local apply command and environment for a rendered manifest."""
|
||||
env = dict(os.environ)
|
||||
|
||||
if target == "ansible":
|
||||
playbook = manifest_dir / "playbook.yml"
|
||||
if not playbook.exists():
|
||||
raise RuntimeError(
|
||||
f"manifest did not produce expected playbook.yml at {playbook}"
|
||||
)
|
||||
|
||||
cfg = manifest_dir / "ansible.cfg"
|
||||
if cfg.exists():
|
||||
env["ANSIBLE_CONFIG"] = str(cfg)
|
||||
|
||||
cmd = [
|
||||
exe,
|
||||
"-i",
|
||||
"localhost,",
|
||||
"-c",
|
||||
"local",
|
||||
str(playbook),
|
||||
]
|
||||
if tags:
|
||||
cmd.extend(["--tags", ",".join(tags)])
|
||||
return cmd, env
|
||||
|
||||
if target == "puppet":
|
||||
site_pp = manifest_dir / "manifests" / "site.pp"
|
||||
if not site_pp.exists():
|
||||
raise RuntimeError(
|
||||
f"manifest did not produce expected Puppet site.pp at {site_pp}"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
exe,
|
||||
"apply",
|
||||
"--modulepath",
|
||||
str(manifest_dir / "modules"),
|
||||
]
|
||||
hiera_config = manifest_dir / "hiera.yaml"
|
||||
if hiera_config.exists():
|
||||
cmd.extend(["--hiera_config", str(hiera_config)])
|
||||
cmd.append(str(site_pp))
|
||||
return cmd, env
|
||||
|
||||
if target == "salt":
|
||||
states_dir = manifest_dir / "states"
|
||||
top_sls = states_dir / "top.sls"
|
||||
if not top_sls.exists():
|
||||
raise RuntimeError(
|
||||
f"manifest did not produce expected Salt top.sls at {top_sls}"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
exe,
|
||||
"--local",
|
||||
"--file-root",
|
||||
str(states_dir),
|
||||
]
|
||||
pillar_dir = manifest_dir / "pillar"
|
||||
if pillar_dir.exists():
|
||||
cmd.extend(["--pillar-root", str(pillar_dir)])
|
||||
cmd.extend(["state.apply"])
|
||||
return cmd, env
|
||||
|
||||
raise ValueError(f"unsupported enforcement target: {target!r}")
|
||||
|
||||
|
||||
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,
|
||||
target: str = "ansible",
|
||||
) -> Dict[str, Any]:
|
||||
"""Enforce the *old* (baseline) harvest state on the current machine.
|
||||
|
||||
This renders a temporary manifest from the old harvest using the requested
|
||||
target, then runs the target's local apply command:
|
||||
- ansible: ansible-playbook -i localhost, -c local playbook.yml
|
||||
- puppet: puppet apply --modulepath ./modules manifests/site.pp
|
||||
- salt: salt-call --local --file-root ./states state.apply
|
||||
|
||||
Returns a dict suitable for attaching to the diff report under
|
||||
report['enforcement'].
|
||||
"""
|
||||
|
||||
target = _normalise_enforcement_target(target)
|
||||
tool_exe, tool_label = _require_enforcement_tool(target)
|
||||
|
||||
# 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 [])
|
||||
# Only Ansible has generated per-role tags that can safely narrow
|
||||
# the apply scope. Puppet and Salt enforcement deliberately run the
|
||||
# full generated local manifest/catalog for now.
|
||||
if target == "ansible":
|
||||
t = list(plan.get("tags") or [])
|
||||
tags = t if t else None
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="enroll-enforce-") as td:
|
||||
td_path = Path(td)
|
||||
try:
|
||||
os.chmod(td_path, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 1) Generate a manifest in a temp directory. The renderer now
|
||||
# refuses to write into an existing destination, so use a fresh
|
||||
# child path under the secure temporary directory.
|
||||
manifest_dir = td_path / "manifest"
|
||||
manifest(str(old_b.dir), str(manifest_dir), target=target)
|
||||
|
||||
# 2) Apply it locally.
|
||||
cmd, env = _enforcement_command(
|
||||
target,
|
||||
tool_exe,
|
||||
manifest_dir,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
spinner: Optional[_Spinner] = None
|
||||
p: Optional[subprocess.CompletedProcess[str]] = None
|
||||
t0 = time.monotonic()
|
||||
if _progress_enabled():
|
||||
if tags:
|
||||
sys.stderr.write(
|
||||
f"Enforce: running {tool_label} (tags: {','.join(tags)})\n",
|
||||
)
|
||||
else:
|
||||
sys.stderr.write(f"Enforce: running {tool_label}\n")
|
||||
sys.stderr.flush()
|
||||
spinner = _Spinner(f" {tool_label}")
|
||||
spinner.start()
|
||||
|
||||
try:
|
||||
p = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(td_path),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
) # nosec
|
||||
finally:
|
||||
if spinner:
|
||||
elapsed = time.monotonic() - t0
|
||||
rc = p.returncode if p is not None else None
|
||||
spinner.stop(
|
||||
final_line=(
|
||||
f"Enforce: {tool_label} finished in {elapsed:0.1f}s"
|
||||
+ (f" (rc={rc})" if rc is not None else "")
|
||||
),
|
||||
)
|
||||
|
||||
finished_at = _utc_now_iso()
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"status": "applied" if p.returncode == 0 else "failed",
|
||||
"target": target,
|
||||
"tool": tool_label,
|
||||
"executable": tool_exe,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"command": cmd,
|
||||
"returncode": int(p.returncode),
|
||||
}
|
||||
# Keep the original Ansible-specific field for compatibility with
|
||||
# existing consumers of the JSON report.
|
||||
if target == "ansible":
|
||||
info["ansible_playbook"] = tool_exe
|
||||
elif target == "puppet":
|
||||
info["puppet"] = tool_exe
|
||||
elif target == "salt":
|
||||
info["salt_call"] = tool_exe
|
||||
|
||||
info["roles"] = roles
|
||||
info["tags"] = list(tags or [])
|
||||
if not tags:
|
||||
info["scope"] = "full_manifest"
|
||||
|
||||
if p.returncode != 0:
|
||||
err = (p.stderr or p.stdout or "").strip()
|
||||
raise RuntimeError(
|
||||
f"{tool_label} 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":
|
||||
|
|
@ -1065,41 +674,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()
|
||||
tool = str(enf.get("tool") or "ansible-playbook")
|
||||
target = str(enf.get("target") or "ansible")
|
||||
via = f"{tool} ({target})" if target and target not in tool else tool
|
||||
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 {via} (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 {via} 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 [])}")
|
||||
|
|
@ -1227,52 +801,6 @@ def _report_markdown(report: Dict[str, Any]) -> str:
|
|||
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
|
||||
out.append(msg + "\n")
|
||||
|
||||
enf = report.get("enforcement") or {}
|
||||
if enf:
|
||||
out.append("\n## Enforcement\n")
|
||||
status = str(enf.get("status") or "").strip().lower()
|
||||
tool = str(enf.get("tool") or "ansible-playbook")
|
||||
target = str(enf.get("target") or "ansible")
|
||||
via = f"{tool} ({target})" if target and target not in tool else tool
|
||||
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(
|
||||
f"- ✅ Applied old harvest via {via}"
|
||||
+ 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(
|
||||
f"- ⚠️ Attempted enforcement but {via} 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")
|
||||
|
|
|
|||
|
|
@ -126,6 +126,57 @@ def open_no_follow_path(path: str, *, write: bool = False, mode: int = 0o600) ->
|
|||
os.close(dir_fd)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from .harvest_types import (
|
|||
SysctlSnapshot,
|
||||
)
|
||||
|
||||
from .capture import capture_file
|
||||
from .capture import capture_file, write_bytes_into_bundle
|
||||
from . import system_paths
|
||||
from .package_hints import package_section_from_installations, safe_name
|
||||
|
||||
|
|
@ -187,10 +187,12 @@ def _write_generated_artifact(
|
|||
bundle_dir: str, role_name: str, src_rel: str, content: str
|
||||
) -> None:
|
||||
"""Write a generated harvest artifact that did not exist as a file on disk."""
|
||||
dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
with open(dst, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
# Keep generated artifacts on the same no-follow/exclusive-create path as
|
||||
# copied source artifacts. This preserves the invariant that nothing under
|
||||
# artifacts/ is written via a plain open() that could follow a symlink if a
|
||||
# directory were unexpectedly reused or raced.
|
||||
write_bytes_into_bundle(bundle_dir, role_name, src_rel, content.encode("utf-8"))
|
||||
|
||||
|
||||
_SYSCTL_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from ..harvest_types import (
|
|||
)
|
||||
from ..system_paths import MAX_FILES_CAP
|
||||
from ..pathfilter import expand_includes
|
||||
from ..fsutil import is_dir_no_symlink_components, path_has_symlink_component
|
||||
from .context import HarvestCollector, HarvestContext
|
||||
|
||||
|
||||
|
|
@ -192,13 +193,13 @@ class ExtraPathsCollector(HarvestCollector):
|
|||
path = pat.value
|
||||
if os.path.islink(path):
|
||||
self._capture_included_link(path, role_seen)
|
||||
elif os.path.isdir(path):
|
||||
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 os.path.isdir(hit):
|
||||
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:
|
||||
|
|
@ -224,7 +225,7 @@ class ExtraPathsCollector(HarvestCollector):
|
|||
root = os.path.normpath(root)
|
||||
if not root.startswith("/"):
|
||||
root = "/" + root
|
||||
if not os.path.isdir(root) or os.path.islink(root):
|
||||
if not is_dir_no_symlink_components(root):
|
||||
return
|
||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
if len(self.managed_dirs) >= MAX_FILES_CAP:
|
||||
|
|
@ -238,7 +239,7 @@ class ExtraPathsCollector(HarvestCollector):
|
|||
if self.context.path_filter.is_excluded(dirpath):
|
||||
dirnames[:] = []
|
||||
continue
|
||||
if os.path.islink(dirpath) or not os.path.isdir(dirpath):
|
||||
if not is_dir_no_symlink_components(dirpath):
|
||||
dirnames[:] = []
|
||||
continue
|
||||
|
||||
|
|
@ -275,6 +276,8 @@ class ExtraPathsCollector(HarvestCollector):
|
|||
if os.path.islink(path):
|
||||
self._capture_included_link(path, role_seen)
|
||||
continue
|
||||
if path_has_symlink_component(path):
|
||||
continue
|
||||
pruned.append(dirname)
|
||||
dirnames[:] = pruned
|
||||
|
||||
|
|
|
|||
146
enroll/ignore.py
146
enroll/ignore.py
|
|
@ -27,6 +27,9 @@ DEFAULT_DENY_GLOBS = [
|
|||
"/etc/gshadow",
|
||||
"/etc/*shadow",
|
||||
"/etc/letsencrypt/*",
|
||||
"/etc/ppp/chap-secrets",
|
||||
"/etc/ppp/pap-secrets",
|
||||
"/etc/ppp/*-secrets",
|
||||
"/usr/local/etc/ssl/private/*",
|
||||
"/usr/local/etc/ssh/ssh_host_*",
|
||||
"/usr/local/etc/*shadow",
|
||||
|
|
@ -55,12 +58,35 @@ DEFAULT_ALLOW_BINARY_GLOBS = [
|
|||
# --dangerous or targeted include/exclude review when a file is genuinely
|
||||
# needed.
|
||||
#
|
||||
# The assignment pattern catches INI/YAML/JSON/TOML-ish keys such as:
|
||||
# password: hunter2
|
||||
# "client_secret": "..."
|
||||
# aws_secret_access_key = ...
|
||||
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
|
||||
SENSITIVE_CONTENT_PATTERNS = [
|
||||
# Patterns are split into two tiers:
|
||||
#
|
||||
# HIGH_CONFIDENCE_SECRET_PATTERNS
|
||||
# Unambiguous secret *material* (private-key blocks, age secret keys).
|
||||
# A file containing one of these should never be harvested in safe mode
|
||||
# regardless of how it is framed. These are scanned against the raw bytes
|
||||
# and are deliberately NOT subject to comment stripping: a private key is
|
||||
# a private key whether or not the surrounding format treats some line as
|
||||
# a comment, and (critically) an attacker must not be able to hide key
|
||||
# material from the scanner by opening a block comment (e.g. a leading
|
||||
# "/*" line) that the line-oriented scanner would otherwise honour.
|
||||
#
|
||||
# SENSITIVE_CONTENT_PATTERNS
|
||||
# The single remaining *soft* heuristic: a bare mention of a credential
|
||||
# word (e.g. the literal token "password" with no assigned value). This is
|
||||
# the only pattern prone to false positives on stock config files that
|
||||
# ship commented-out examples, so it -- and only it -- stays comment-aware
|
||||
# via iter_effective_lines().
|
||||
#
|
||||
# IMPORTANT (conservative-by-default): anything that looks like an actual
|
||||
# credential *value* -- a populated "key = value" assignment, a URI with
|
||||
# embedded credentials, or an Authorization header -- is treated as
|
||||
# high-confidence and is scanned against the RAW bytes, including inside
|
||||
# comments. A "commented out" secret is very often a real secret that someone
|
||||
# disabled, so Enroll deliberately refuses such a file in safe mode and requires
|
||||
# --dangerous (ideally with --sops) to collect it. Only genuinely value-less
|
||||
# keyword mentions remain tolerated in comments, which is what keeps Enroll
|
||||
# useful for ordinary config files without risking a real disabled credential.
|
||||
HIGH_CONFIDENCE_SECRET_PATTERNS = [
|
||||
re.compile(
|
||||
rb"-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----"
|
||||
),
|
||||
|
|
@ -68,6 +94,13 @@ SENSITIVE_CONTENT_PATTERNS = [
|
|||
re.compile(rb"(?i)AGE-SECRET-KEY-[A-Z0-9]+"),
|
||||
re.compile(rb"(?i)OPENSSH PRIVATE KEY"),
|
||||
re.compile(rb"(?i)PGP PRIVATE KEY BLOCK"),
|
||||
# Assignment-style credential keys with a value, e.g.
|
||||
# password: hunter2
|
||||
# "client_secret": "..."
|
||||
# aws_secret_access_key = ...
|
||||
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
|
||||
# A populated assignment is a real (if disabled) secret regardless of comment
|
||||
# framing, so it is scanned on raw bytes.
|
||||
re.compile(
|
||||
rb"""(?ix)
|
||||
(^|[^A-Za-z0-9])
|
||||
|
|
@ -91,7 +124,25 @@ SENSITIVE_CONTENT_PATTERNS = [
|
|||
\s*[:=]
|
||||
"""
|
||||
),
|
||||
re.compile(rb"(?i)\b(pass|passwd|token|secret|api[_-]?key)\b"),
|
||||
# Credentials embedded in connection-string URIs, e.g.
|
||||
# postgres://user:pass@host, redis://:pass@host, amqp://u:p@host
|
||||
re.compile(rb"(?i)[a-z][a-z0-9+.-]*://[^/\s:@]*:[^/\s@]+@[^/\s]"),
|
||||
# HTTP(S) Authorization / Proxy-Authorization header values carrying a
|
||||
# bearer/basic/digest credential.
|
||||
re.compile(
|
||||
rb"(?im)^\s*(?:proxy-)?authorization\s*:\s*"
|
||||
rb"(?:bearer|basic|token|digest)\s+\S"
|
||||
),
|
||||
]
|
||||
|
||||
SENSITIVE_CONTENT_PATTERNS = [
|
||||
# Bare credential-word mention with no assigned value. This is the only soft
|
||||
# heuristic and the only one tolerated inside comments, because stock config
|
||||
# files legitimately ship value-less commented hints (e.g. "# token").
|
||||
re.compile(
|
||||
rb"(?i)\b(pass|passwd|password|passphrase|token|secret|"
|
||||
rb"credentials?|api[_-]?key)\b"
|
||||
),
|
||||
]
|
||||
|
||||
COMMENT_PREFIXES = (b"#", b";", b"//")
|
||||
|
|
@ -140,7 +191,6 @@ class IgnorePolicy:
|
|||
deny_globs: Optional[list[str]] = None
|
||||
allow_binary_globs: Optional[list[str]] = None
|
||||
max_file_bytes: int = 256_000
|
||||
sample_bytes: int = 64_000
|
||||
# If True, be much less conservative about collecting potentially
|
||||
# sensitive files. This disables deny globs (e.g. /etc/shadow,
|
||||
# /etc/ssl/private/*) and skips heuristic content scanning.
|
||||
|
|
@ -152,23 +202,61 @@ class IgnorePolicy:
|
|||
if self.allow_binary_globs is None:
|
||||
self.allow_binary_globs = list(DEFAULT_ALLOW_BINARY_GLOBS)
|
||||
|
||||
def _strip_balanced_block_comments(self, content: bytes) -> bytes:
|
||||
"""Remove only *properly closed* ``/* ... */`` regions from *content*.
|
||||
|
||||
The previous line-oriented scanner entered "block comment mode" the moment
|
||||
a line began with ``/*`` and then skipped every subsequent line until it
|
||||
saw ``*/``. That had two problems an attacker (or just an unusual config)
|
||||
could exploit to hide secrets from the content scan:
|
||||
|
||||
* an *unterminated* ``/*`` (no closing ``*/`` anywhere after it) masked
|
||||
the entire remainder of the file, including real key material; and
|
||||
* content appearing *after* a ``*/`` on the same line, or a one-line
|
||||
``/* ... */ secret``, was dropped.
|
||||
|
||||
Operating on the whole byte stream and removing only *balanced* comment
|
||||
regions fixes both: an opening ``/*`` with no matching ``*/`` is left in
|
||||
place (so its contents are still scanned), and bytes after a closing
|
||||
``*/`` are preserved. Each removed region is replaced with a single
|
||||
space so tokens on either side cannot be accidentally joined.
|
||||
"""
|
||||
|
||||
out = bytearray()
|
||||
i = 0
|
||||
n = len(content)
|
||||
while i < n:
|
||||
start = content.find(BLOCK_START, i)
|
||||
if start == -1:
|
||||
out += content[i:]
|
||||
break
|
||||
end = content.find(BLOCK_END, start + len(BLOCK_START))
|
||||
if end == -1:
|
||||
# Unterminated block comment: do NOT treat the rest of the file as
|
||||
# commented out. Keep it verbatim so secret scanning still runs.
|
||||
out += content[i:]
|
||||
break
|
||||
out += content[i:start]
|
||||
out += b" "
|
||||
i = end + len(BLOCK_END)
|
||||
return bytes(out)
|
||||
|
||||
def iter_effective_lines(self, content: bytes):
|
||||
in_block = False
|
||||
for raw in content.splitlines():
|
||||
"""Yield non-comment lines for the soft content heuristics.
|
||||
|
||||
Block comments are removed first (only when properly closed), then
|
||||
single-line comment prefixes are skipped. Genuinely commented-out
|
||||
secrets remain ignored -- that is deliberate, documented behaviour -- but
|
||||
a block-comment open can no longer suppress scanning of later, real
|
||||
content.
|
||||
"""
|
||||
|
||||
for raw in self._strip_balanced_block_comments(content).splitlines():
|
||||
line = raw.lstrip()
|
||||
|
||||
if in_block:
|
||||
if BLOCK_END in line:
|
||||
in_block = False
|
||||
continue
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith(BLOCK_START):
|
||||
in_block = True
|
||||
continue
|
||||
|
||||
if line.startswith(COMMENT_PREFIXES) or line.startswith(b"*"):
|
||||
continue
|
||||
|
||||
|
|
@ -205,6 +293,20 @@ class IgnorePolicy:
|
|||
return "binary_like"
|
||||
|
||||
if not self.dangerous:
|
||||
# High-confidence secret *material* (private keys, age secret keys)
|
||||
# is scanned against the raw bytes and is NOT subject to comment
|
||||
# stripping. A private key embedded in a file is sensitive regardless
|
||||
# of comment framing, and this closes the bypass where opening a block
|
||||
# comment (e.g. a leading "/*" line) hid key material from the
|
||||
# line-oriented scanner.
|
||||
for pat in HIGH_CONFIDENCE_SECRET_PATTERNS:
|
||||
if pat.search(data):
|
||||
return "sensitive_content"
|
||||
|
||||
# Softer assignment/keyword/URI heuristics stay comment-aware so a
|
||||
# genuinely commented-out example does not make Enroll useless for
|
||||
# ordinary config files. iter_effective_lines() is hardened so an
|
||||
# unterminated/inline block comment cannot mask later real content.
|
||||
for line in self.iter_effective_lines(data):
|
||||
for pat in SENSITIVE_CONTENT_PATTERNS:
|
||||
if pat.search(line):
|
||||
|
|
@ -252,6 +354,12 @@ class IgnorePolicy:
|
|||
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
return "not_regular_file", None
|
||||
if st.st_nlink > 1:
|
||||
# A hardlink gives a file another safe-looking pathname. When
|
||||
# Enroll is run as root, path-based deny rules such as
|
||||
# /etc/shadow must not be bypassed by copying the same inode via
|
||||
# an attacker-controlled alias under an otherwise allowed tree.
|
||||
return "hardlink_source", None
|
||||
if st.st_size > self.max_file_bytes:
|
||||
return "too_large", None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess # nosec
|
||||
import tempfile
|
||||
|
|
@ -69,6 +71,33 @@ def _merge_mappings_overwrite(
|
|||
return merged
|
||||
|
||||
|
||||
_RESERVED_ROLE_VAR_SUFFIXES = {
|
||||
"managed_files",
|
||||
"managed_dirs",
|
||||
"managed_links",
|
||||
"packages",
|
||||
"restart_units",
|
||||
"system_flatpaks",
|
||||
"remotes",
|
||||
"user_flatpaks",
|
||||
"user_flatpak_remotes",
|
||||
}
|
||||
|
||||
|
||||
def reserved_role_var_names(role_name: str) -> Set[str]:
|
||||
"""Return Enroll-owned variable names for a generated role.
|
||||
|
||||
JinjaTurtle variables come from harvested, attacker-influenceable config
|
||||
content. They must never overwrite variables that drive Enroll's renderer
|
||||
tasks, such as ``<role>_managed_files``.
|
||||
"""
|
||||
|
||||
role = re.sub(r"[^A-Za-z0-9_]+", "_", role_name.strip().lower()).strip("_")
|
||||
if not role:
|
||||
return set()
|
||||
return {f"{role}_{suffix}" for suffix in _RESERVED_ROLE_VAR_SUFFIXES}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JinjifiedArtifact:
|
||||
template_rel: str
|
||||
|
|
@ -82,7 +111,6 @@ _JINJA_FOR_RE = re.compile(
|
|||
r"{%\s*for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_]*)\b"
|
||||
)
|
||||
_JINJA_SPECIAL_VARS = {"loop", "true", "false", "none", "True", "False", "None"}
|
||||
_ERB_INSTANCE_VAR_RE = re.compile(r"<%=?[^%]*@([A-Za-z_][A-Za-z0-9_]*)", re.S)
|
||||
|
||||
|
||||
def _find_undeclared_jinja_vars(template_text: str) -> Set[str]:
|
||||
|
|
@ -121,21 +149,6 @@ def missing_jinja_template_vars(
|
|||
return {name for name in referenced if name not in context}
|
||||
|
||||
|
||||
def missing_erb_template_vars(template_text: str, context: Dict[str, Any]) -> Set[str]:
|
||||
"""Return ERB ``@param`` references absent from Puppet Hiera/class data."""
|
||||
|
||||
local_names: Set[str] = set()
|
||||
for key in context:
|
||||
text = str(key)
|
||||
if "::" in text:
|
||||
local_names.add(text.split("::", 1)[1])
|
||||
else:
|
||||
local_names.add(text)
|
||||
|
||||
referenced = set(_ERB_INSTANCE_VAR_RE.findall(template_text))
|
||||
return {name for name in referenced if name not in local_names}
|
||||
|
||||
|
||||
def jinjify_artifact(
|
||||
bundle_dir: str | Path,
|
||||
artifact_role: str,
|
||||
|
|
@ -147,14 +160,9 @@ def jinjify_artifact(
|
|||
jt_enabled: bool,
|
||||
overwrite_templates: bool = True,
|
||||
role_name: Optional[str] = None,
|
||||
template_engine: str = "jinja2",
|
||||
puppet_class: Optional[str] = None,
|
||||
reserved_context_keys: Optional[Set[str]] = None,
|
||||
) -> Optional[JinjifiedArtifact]:
|
||||
"""Best-effort conversion of one harvested artifact into a template.
|
||||
|
||||
Ansible/Salt use Jinja2 output. Puppet uses ERB output with Puppet Hiera
|
||||
keys when a new enough JinjaTurtle is available.
|
||||
"""
|
||||
"""Best-effort conversion of one harvested artifact into a Jinja2 template."""
|
||||
if not (jt_enabled and jt_exe and can_jinjify_path(dest_path)):
|
||||
return None
|
||||
|
||||
|
|
@ -164,31 +172,23 @@ def jinjify_artifact(
|
|||
return None
|
||||
|
||||
try:
|
||||
run_kwargs: Dict[str, Any] = {
|
||||
"role_name": role_name or artifact_role,
|
||||
"force_format": infer_other_formats(dest_path),
|
||||
}
|
||||
# Keep the historical call shape for Ansible/Salt and for tests that
|
||||
# monkeypatch run_jinjaturtle with the old signature. Puppet/ERB is
|
||||
# the only path that needs the newer JinjaTurtle CLI switches.
|
||||
if template_engine != "jinja2":
|
||||
run_kwargs["template_engine"] = template_engine
|
||||
if puppet_class:
|
||||
run_kwargs["puppet_class"] = puppet_class
|
||||
result = run_jinjaturtle(jt_exe, str(artifact_path), **run_kwargs)
|
||||
result = run_jinjaturtle(
|
||||
jt_exe,
|
||||
str(artifact_path),
|
||||
role_name=role_name or artifact_role,
|
||||
force_format=infer_other_formats(dest_path),
|
||||
)
|
||||
except Exception:
|
||||
return None # nosec - best-effort template generation
|
||||
|
||||
ext = "erb" if template_engine == "erb" else "j2"
|
||||
template_rel = Path(src_rel).as_posix() + f".{ext}"
|
||||
template_rel = Path(src_rel).as_posix() + ".j2"
|
||||
template_dst = Path(template_root) / template_rel
|
||||
|
||||
context = yaml_load_mapping(result.vars_text)
|
||||
missing = (
|
||||
missing_erb_template_vars(result.template_text, context)
|
||||
if template_engine == "erb"
|
||||
else missing_jinja_template_vars(result.template_text, context)
|
||||
)
|
||||
if reserved_context_keys and (set(context) & set(reserved_context_keys)):
|
||||
return None
|
||||
|
||||
missing = missing_jinja_template_vars(result.template_text, context)
|
||||
if missing:
|
||||
# If this role was generated into an existing output directory, avoid
|
||||
# leaving an obsolete template behind after falling back to a raw copy.
|
||||
|
|
@ -208,16 +208,78 @@ def jinjify_artifact(
|
|||
)
|
||||
|
||||
|
||||
def _resolve_var_prefix_salt() -> str:
|
||||
"""Return the salt mixed into generated JinjaTurtle variable prefixes.
|
||||
|
||||
Rationale and the security/reproducibility trade-off
|
||||
----------------------------------------------------
|
||||
The generated variable namespace is ``<role>_jt_<salt>_<src_rel>``. The salt
|
||||
exists as *defence in depth* for one specific attack: a malicious harvested
|
||||
config (e.g. a JSON file) that injects a key referencing an Enroll-declared
|
||||
variable, e.g. ``{{ <role>_jt_<src_rel>_port }}``. To pre-compute that name at
|
||||
harvest-authoring time the attacker must know the salt.
|
||||
|
||||
This is deliberately *not* random-per-run by default. JinjaTurtle already
|
||||
closes the injection directly (it escapes verbatim keys and an independent
|
||||
output-safety gate rejects any Jinja in a JSON key), so the salt is a belt to
|
||||
that suspenders. A random-per-run salt would change every generated variable
|
||||
name on every ``manifest`` run, producing spurious churn in any
|
||||
version-controlled manifest tree and in tools that diff regenerated output.
|
||||
Note this never affected ``enroll diff``, which compares *harvest bundles*
|
||||
(state.json + artifact content hashes) and never reads generated variable
|
||||
names -- but it would have churned a git-tracked Ansible manifest.
|
||||
|
||||
Default behaviour is therefore a fixed, reproducible namespace:
|
||||
|
||||
* ``ENROLL_JINJATURTLE_VAR_SALT=<value>`` -- operator-pinned salt. Use this to
|
||||
make the namespace unpredictable to a config author while staying stable and
|
||||
reproducible across runs/machines (recommended when generating manifests
|
||||
from untrusted harvests into a tracked repo). ``random`` is special-cased to
|
||||
mean "fresh per process".
|
||||
* unset -- a fixed default salt. Output is fully reproducible; security relies
|
||||
on JinjaTurtle's escaping + key gate, which fully close the issue.
|
||||
"""
|
||||
|
||||
override = os.environ.get("ENROLL_JINJATURTLE_VAR_SALT")
|
||||
if override is None or override == "":
|
||||
# Stable default: reproducible output, no manifest churn. The vulnerability
|
||||
# is already closed in JinjaTurtle; this fixed namespace is sufficient.
|
||||
return "en"
|
||||
if override == "random":
|
||||
# Opt-in: unpredictable per process (maximally defensive, churns output).
|
||||
return secrets.token_hex(4)
|
||||
return re.sub(r"[^A-Za-z0-9]+", "", override)[:16] or "en"
|
||||
|
||||
|
||||
# Cache holder; resolved lazily on first use so an operator (or test) can set
|
||||
# ENROLL_JINJATURTLE_VAR_SALT before the first manifest call and have it honoured.
|
||||
_VAR_PREFIX_SALT: Optional[str] = None
|
||||
|
||||
|
||||
def _var_prefix_salt() -> str:
|
||||
global _VAR_PREFIX_SALT
|
||||
if _VAR_PREFIX_SALT is None:
|
||||
_VAR_PREFIX_SALT = _resolve_var_prefix_salt()
|
||||
return _VAR_PREFIX_SALT
|
||||
|
||||
|
||||
def managed_file_var_prefix(role_name: str, src_rel: str) -> str:
|
||||
"""Return a JinjaTurtle-safe variable prefix for one managed file.
|
||||
|
||||
JinjaTurtle's ``--role-name`` is a variable prefix. Enroll can place many
|
||||
unrelated managed files in one generated role, so using only the role name
|
||||
can collide for common keys such as ``enabled``, ``ignore``, or ``name``.
|
||||
Include the relative artifact path when a role templates multiple files.
|
||||
Always include a ``jt`` namespace and the relative artifact path so harvested
|
||||
config keys cannot produce Enroll-owned variables such as
|
||||
``<role>_managed_files``.
|
||||
|
||||
A salt segment is mixed in (see ``_resolve_var_prefix_salt``). It is a fixed,
|
||||
reproducible value by default and can be pinned or randomised via
|
||||
``ENROLL_JINJATURTLE_VAR_SALT`` as defence in depth against an injected key
|
||||
that references an Enroll-declared variable.
|
||||
"""
|
||||
|
||||
raw = f"{role_name}_{src_rel}"
|
||||
raw = f"{role_name}_jt_{_var_prefix_salt()}_{src_rel}"
|
||||
safe = re.sub(r"[^A-Za-z0-9_]+", "_", raw).strip("_").lower()
|
||||
safe = re.sub(r"_+", "_", safe)
|
||||
if not safe:
|
||||
|
|
@ -243,22 +305,12 @@ def jinjify_managed_files(
|
|||
) -> Tuple[Set[str], str]:
|
||||
"""Jinjify a list of managed files and return Ansible-style vars text.
|
||||
|
||||
The return shape intentionally matches the historical Ansible helper:
|
||||
``(templated_src_rels, combined_vars_text)``. Salt uses
|
||||
:func:`jinjify_artifact` directly because it stores variables as a context
|
||||
map per managed file.
|
||||
The return shape is ``(templated_src_rels, combined_vars_text)``.
|
||||
"""
|
||||
templated: Set[str] = set()
|
||||
vars_map: Dict[str, Any] = {}
|
||||
base_role_name = role_name or artifact_role
|
||||
candidates = [
|
||||
mf
|
||||
for mf in managed_files
|
||||
if str(mf.get("path") or "")
|
||||
and str(mf.get("src_rel") or "")
|
||||
and can_jinjify_path(str(mf.get("path") or ""))
|
||||
]
|
||||
namespace_by_file = len(candidates) > 1
|
||||
reserved_context_keys = reserved_role_var_names(base_role_name)
|
||||
|
||||
for mf in managed_files:
|
||||
dest_path = str(mf.get("path") or "")
|
||||
|
|
@ -275,11 +327,8 @@ def jinjify_managed_files(
|
|||
jt_exe=jt_exe,
|
||||
jt_enabled=jt_enabled,
|
||||
overwrite_templates=overwrite_templates,
|
||||
role_name=(
|
||||
managed_file_var_prefix(base_role_name, src_rel)
|
||||
if namespace_by_file
|
||||
else base_role_name
|
||||
),
|
||||
role_name=managed_file_var_prefix(base_role_name, src_rel),
|
||||
reserved_context_keys=reserved_context_keys,
|
||||
)
|
||||
if converted is None:
|
||||
continue
|
||||
|
|
@ -340,8 +389,6 @@ def run_jinjaturtle(
|
|||
*,
|
||||
role_name: str,
|
||||
force_format: Optional[str] = None,
|
||||
template_engine: str = "jinja2",
|
||||
puppet_class: Optional[str] = None,
|
||||
) -> JinjifyResult:
|
||||
"""
|
||||
Run jinjaturtle against src_path and return (template, defaults-yaml).
|
||||
|
|
@ -349,9 +396,6 @@ def run_jinjaturtle(
|
|||
|
||||
jinjaturtle CLI:
|
||||
jinjaturtle <config> -r <role> [-f <format>] [-d <defaults-output>] [-t <template-output>]
|
||||
|
||||
Newer JinjaTurtle versions also support ``--template-engine erb`` and
|
||||
``--puppet-class`` for Puppet/Hiera output.
|
||||
"""
|
||||
src = Path(src_path)
|
||||
if not src.is_file():
|
||||
|
|
@ -360,9 +404,7 @@ def run_jinjaturtle(
|
|||
with tempfile.TemporaryDirectory(prefix="enroll-jt-") as td:
|
||||
td_path = Path(td)
|
||||
defaults_out = td_path / "defaults.yml"
|
||||
template_out = td_path / (
|
||||
"template.erb" if template_engine == "erb" else "template.j2"
|
||||
)
|
||||
template_out = td_path / "template.j2"
|
||||
|
||||
cmd = [
|
||||
jt_exe,
|
||||
|
|
@ -376,10 +418,6 @@ def run_jinjaturtle(
|
|||
]
|
||||
if force_format:
|
||||
cmd.extend(["-f", force_format])
|
||||
if template_engine != "jinja2":
|
||||
cmd.extend(["--template-engine", template_engine])
|
||||
if puppet_class:
|
||||
cmd.extend(["--puppet-class", puppet_class])
|
||||
|
||||
p = subprocess.run(cmd, text=True, capture_output=True) # nosec
|
||||
if p.returncode != 0:
|
||||
|
|
|
|||
|
|
@ -2,16 +2,15 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from .ansible import manifest_from_bundle_dir as manifest_ansible_from_bundle_dir
|
||||
from .puppet import manifest_from_bundle_dir as manifest_puppet_from_bundle_dir
|
||||
from .salt import manifest_from_bundle_dir as manifest_salt_from_bundle_dir
|
||||
from .harvest_safety import ensure_safe_output_parent
|
||||
from .manifest_safety import validate_site_fqdn
|
||||
from .manifest_safety import freeze_directory_bundle, validate_site_fqdn
|
||||
from .remote import _safe_extract_tar
|
||||
from .sopsutil import (
|
||||
decrypt_file_binary_to,
|
||||
|
|
@ -21,6 +20,16 @@ from .sopsutil import (
|
|||
from .validate import validate_harvest
|
||||
|
||||
|
||||
_SEMANTIC_SAFETY_WARNING = (
|
||||
"This harvest is structurally valid, but Enroll cannot prove it is semantically safe.\n"
|
||||
"Only apply manifests generated from harvests whose provenance you trust.\n"
|
||||
)
|
||||
|
||||
|
||||
def _warn_semantic_safety() -> None:
|
||||
sys.stderr.write(_SEMANTIC_SAFETY_WARNING)
|
||||
|
||||
|
||||
def _prepare_bundle_dir(
|
||||
bundle: str,
|
||||
*,
|
||||
|
|
@ -36,7 +45,15 @@ def _prepare_bundle_dir(
|
|||
p = Path(bundle).expanduser()
|
||||
|
||||
if p.is_dir():
|
||||
return str(p), None
|
||||
# A directory bundle may still be writable by an unprivileged user (e.g.
|
||||
# root running `manifest` against /tmp/some-harvest). Validation is a
|
||||
# point-in-time check, but the renderer/JinjaTurtle re-open artifacts by
|
||||
# path afterwards, so a mutable source could be raced. Freeze the bundle
|
||||
# into a private 0700 temp copy (no-follow, regular-files-only) and
|
||||
# consume that immutable copy instead. Tar/SOPS inputs already get an
|
||||
# equivalent private extraction below.
|
||||
frozen_dir, td_frozen = freeze_directory_bundle(p, label="harvest bundle")
|
||||
return frozen_dir, td_frozen
|
||||
|
||||
if not sops_mode:
|
||||
raise RuntimeError(f"Harvest path is not a directory: {p}")
|
||||
|
|
@ -175,7 +192,6 @@ def manifest(
|
|||
jinjaturtle: str = "auto", # auto|on|off
|
||||
sops_fingerprints: Optional[List[str]] = None,
|
||||
no_common_roles: bool = False,
|
||||
target: str = "ansible",
|
||||
) -> Optional[str]:
|
||||
"""Render a configuration-management manifest from a harvest.
|
||||
|
||||
|
|
@ -193,9 +209,6 @@ def manifest(
|
|||
- In SOPS mode: the path to the encrypted manifest bundle (.sops)
|
||||
- In plain mode: None
|
||||
"""
|
||||
target = (target or "ansible").strip().lower()
|
||||
if target not in {"ansible", "puppet", "salt"}:
|
||||
raise ValueError(f"unsupported manifest target: {target!r}")
|
||||
fqdn = validate_site_fqdn(fqdn)
|
||||
|
||||
sops_mode = bool(sops_fingerprints)
|
||||
|
|
@ -215,24 +228,9 @@ def manifest(
|
|||
+ validation.to_text().strip()
|
||||
)
|
||||
|
||||
_warn_semantic_safety()
|
||||
|
||||
if not sops_mode:
|
||||
if target == "puppet":
|
||||
manifest_puppet_from_bundle_dir(
|
||||
resolved_bundle_dir,
|
||||
out,
|
||||
fqdn=fqdn,
|
||||
no_common_roles=no_common_roles,
|
||||
jinjaturtle=jinjaturtle,
|
||||
)
|
||||
elif target == "salt":
|
||||
manifest_salt_from_bundle_dir(
|
||||
resolved_bundle_dir,
|
||||
out,
|
||||
fqdn=fqdn,
|
||||
no_common_roles=no_common_roles,
|
||||
jinjaturtle=jinjaturtle,
|
||||
)
|
||||
else:
|
||||
manifest_ansible_from_bundle_dir(
|
||||
resolved_bundle_dir,
|
||||
out,
|
||||
|
|
@ -248,23 +246,6 @@ def manifest(
|
|||
td_out = tempfile.TemporaryDirectory(prefix="enroll-manifest-")
|
||||
tmp_out = Path(td_out.name) / "out"
|
||||
|
||||
if target == "puppet":
|
||||
manifest_puppet_from_bundle_dir(
|
||||
resolved_bundle_dir,
|
||||
str(tmp_out),
|
||||
fqdn=fqdn,
|
||||
no_common_roles=no_common_roles,
|
||||
jinjaturtle=jinjaturtle,
|
||||
)
|
||||
elif target == "salt":
|
||||
manifest_salt_from_bundle_dir(
|
||||
resolved_bundle_dir,
|
||||
str(tmp_out),
|
||||
fqdn=fqdn,
|
||||
no_common_roles=no_common_roles,
|
||||
jinjaturtle=jinjaturtle,
|
||||
)
|
||||
else:
|
||||
manifest_ansible_from_bundle_dir(
|
||||
resolved_bundle_dir,
|
||||
str(tmp_out),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Tuple
|
||||
from typing import Iterator, Optional, Tuple
|
||||
|
||||
from .fsutil import open_no_follow_path
|
||||
from .harvest_safety import (
|
||||
OutputSafetyError,
|
||||
ensure_safe_output_parent,
|
||||
|
|
@ -256,3 +259,162 @@ def copy_safe_artifact_file(src: str | Path, dst: str | Path) -> None:
|
|||
"""Copy an already validated artifact file without following symlinks."""
|
||||
|
||||
shutil.copy2(src, dst, follow_symlinks=False)
|
||||
|
||||
|
||||
_FREEZE_MAX_FILE_BYTES = 64 * 1024 * 1024
|
||||
_FREEZE_MAX_FILES = 200_000
|
||||
|
||||
|
||||
def _read_all_no_follow(abs_path: str) -> bytes:
|
||||
"""Read a regular file's bytes via a no-follow, non-hardlinked open.
|
||||
|
||||
Mirrors the harvest-side capture discipline: open every path component
|
||||
without following symlinks, fstat the resulting descriptor, and refuse
|
||||
anything that is not a single-linked regular file. This is what makes the
|
||||
frozen copy independent of later mutation of the source tree -- the bytes
|
||||
are taken from the descriptor we validated, not re-resolved by path.
|
||||
"""
|
||||
|
||||
fd: Optional[int] = None
|
||||
try:
|
||||
try:
|
||||
fd = open_no_follow_path(abs_path)
|
||||
except OSError as e:
|
||||
if e.errno == errno.ELOOP:
|
||||
raise ArtifactSafetyError(
|
||||
f"bundle path contains a symlink component: {abs_path}"
|
||||
) from e
|
||||
if e.errno == errno.ENOTDIR:
|
||||
raise ArtifactSafetyError(
|
||||
f"bundle path has a non-directory component: {abs_path}"
|
||||
) from e
|
||||
raise ArtifactSafetyError(f"unable to read bundle file: {abs_path}") from e
|
||||
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise ArtifactSafetyError(f"bundle file is not a regular file: {abs_path}")
|
||||
if st.st_nlink > 1:
|
||||
raise ArtifactSafetyError(f"bundle file is hardlinked: {abs_path}")
|
||||
if st.st_size > _FREEZE_MAX_FILE_BYTES:
|
||||
raise ArtifactSafetyError(f"bundle file is too large to freeze: {abs_path}")
|
||||
|
||||
chunks: list[bytes] = []
|
||||
remaining = int(st.st_size)
|
||||
while remaining > 0:
|
||||
chunk = os.read(fd, min(1024 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
if fd is not None:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def freeze_directory_bundle(
|
||||
bundle_dir: str | Path, *, label: str = "harvest bundle"
|
||||
) -> Tuple[str, tempfile.TemporaryDirectory]:
|
||||
"""Copy a directory harvest bundle into a private, immutable-by-attacker tree.
|
||||
|
||||
Validation of a harvest bundle (schema + artifact safety) is point-in-time:
|
||||
it proves the tree is safe *at the moment it is checked*. When the bundle
|
||||
directory is still writable by an unprivileged user, an attacker can race the
|
||||
later consumers (``manifest``/``diff``/``explain`` re-open artifacts by path
|
||||
to copy, hash, or feed to JinjaTurtle) and swap a validated regular file for a
|
||||
symlink or different file after the check.
|
||||
|
||||
Tar and SOPS inputs already avoid this: Enroll extracts them into a private
|
||||
``0700`` temp directory before validating. Plain *directory* inputs were used
|
||||
in place. This helper closes that gap by giving directory inputs the same
|
||||
treatment: it copies the bundle into a fresh ``0700`` temp directory using
|
||||
no-follow traversal, taking each file's bytes from a validated descriptor
|
||||
(no symlinks, no hardlinks, regular files only). The returned copy is what the
|
||||
caller should validate and render from, so a subsequent mutation of the
|
||||
original directory cannot affect the consumed bundle.
|
||||
|
||||
Returns ``(frozen_bundle_dir, tempdir)``. The caller must keep ``tempdir``
|
||||
alive for the lifetime of the bundle use; it cleans up on close.
|
||||
"""
|
||||
|
||||
src_root = Path(bundle_dir).expanduser()
|
||||
if not src_root.is_dir():
|
||||
raise ArtifactSafetyError(f"{label} is not a directory: {src_root}")
|
||||
|
||||
td = tempfile.TemporaryDirectory(prefix="enroll-frozen-bundle-")
|
||||
try:
|
||||
dst_root = Path(td.name) / "bundle"
|
||||
dst_root.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
try:
|
||||
os.chmod(dst_root, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
file_count = 0
|
||||
|
||||
def _on_walk_error(exc: OSError) -> None:
|
||||
# os.walk() defaults to *silently swallowing* directory-listing
|
||||
# errors (e.g. an unreadable subdirectory raises os.scandir() ->
|
||||
# PermissionError, which os.walk would otherwise drop). A swallowed
|
||||
# error produces a partial frozen tree with no indication that
|
||||
# content was omitted, which is exactly the "fail loudly instead of
|
||||
# producing a partial frozen copy" guarantee this helper is meant to
|
||||
# provide. Re-raise as an ArtifactSafetyError so the whole freeze
|
||||
# aborts rather than returning a silently-truncated bundle.
|
||||
raise ArtifactSafetyError(
|
||||
f"{label} could not be fully read while freezing "
|
||||
f"({exc.__class__.__name__}: {exc}); refusing to produce a "
|
||||
f"partial frozen copy"
|
||||
)
|
||||
|
||||
# followlinks=False: do not descend into symlinked directories. Each
|
||||
# discovered file is independently re-opened no-follow before copying, so
|
||||
# a symlinked directory cannot smuggle content into the frozen tree even
|
||||
# if it is swapped in mid-walk.
|
||||
#
|
||||
# onerror=_on_walk_error: fail closed on any unreadable directory rather
|
||||
# than silently skipping it (see callback above).
|
||||
for cur, dirs, files in os.walk(
|
||||
src_root, followlinks=False, onerror=_on_walk_error
|
||||
):
|
||||
cur_p = Path(cur)
|
||||
|
||||
# Refuse symlinked subdirectories rather than silently skipping them,
|
||||
# so a tampered bundle fails loudly instead of producing a partial
|
||||
# frozen copy that later diverges from the operator's expectation.
|
||||
for dname in list(dirs):
|
||||
dp = cur_p / dname
|
||||
try:
|
||||
dst = dp.lstat()
|
||||
except FileNotFoundError:
|
||||
dirs.remove(dname)
|
||||
continue
|
||||
if stat.S_ISLNK(dst.st_mode):
|
||||
raise ArtifactSafetyError(
|
||||
f"{label} contains a symlinked directory: {dp}"
|
||||
)
|
||||
|
||||
rel_dir = cur_p.relative_to(src_root)
|
||||
target_dir = dst_root / rel_dir
|
||||
target_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
|
||||
for fname in files:
|
||||
file_count += 1
|
||||
if file_count > _FREEZE_MAX_FILES:
|
||||
raise ArtifactSafetyError(
|
||||
f"{label} has too many files to freeze safely"
|
||||
)
|
||||
src_file = cur_p / fname
|
||||
data = _read_all_no_follow(str(src_file))
|
||||
dst_file = target_dir / fname
|
||||
fd = open_no_follow_path(str(dst_file), write=True, mode=0o600)
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(data)
|
||||
|
||||
return str(dst_root), td
|
||||
except BaseException:
|
||||
td.cleanup()
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -7,10 +7,42 @@ from dataclasses import dataclass
|
|||
from pathlib import PurePosixPath
|
||||
from typing import List, Optional, Sequence, Set, Tuple
|
||||
|
||||
|
||||
_REGEX_PREFIXES = ("re:", "regex:")
|
||||
|
||||
|
||||
def _path_has_symlink_component_for_discovery(path: str) -> bool:
|
||||
"""Return True if any path component is visibly a symlink.
|
||||
|
||||
``expand_includes`` is a discovery helper: the actual file capture path is
|
||||
still protected by descriptor-based no-follow opens. Keep this check
|
||||
intentionally based on ``os.path.islink`` so tests can mock a synthetic
|
||||
filesystem with ``os.path``/``os.walk`` and so include expansion does not
|
||||
depend on real host permissions for paths such as ``/root``. Existing
|
||||
symlinked parents are still pruned before walking/capturing.
|
||||
"""
|
||||
|
||||
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)
|
||||
if os.path.islink(cur):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_glob_chars(s: str) -> bool:
|
||||
return any(ch in s for ch in "*?[")
|
||||
|
||||
|
|
@ -191,6 +223,37 @@ def expand_includes(
|
|||
notes: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
|
||||
def _is_file_no_symlink_components(p: str) -> bool:
|
||||
return not _path_has_symlink_component_for_discovery(p) and os.path.isfile(p)
|
||||
|
||||
def _is_dir_no_symlink_components(p: str) -> bool:
|
||||
return not _path_has_symlink_component_for_discovery(p) and os.path.isdir(p)
|
||||
|
||||
def _glob_walk_root(pattern: str) -> Optional[str]:
|
||||
"""Return a conservative directory root for recursive glob fallback.
|
||||
|
||||
This keeps existing tests that mock ``os.walk`` independent of the real
|
||||
host's /root contents while still applying the same no-symlink-component
|
||||
guard before enumeration. Only recursive subtree globs are expanded this
|
||||
way; ordinary file globs continue to rely on ``glob.glob`` hits.
|
||||
"""
|
||||
|
||||
parts = pattern.split(os.sep)
|
||||
literal_parts: List[str] = []
|
||||
absolute = pattern.startswith(os.sep)
|
||||
for part in parts:
|
||||
if part == "" and absolute and not literal_parts:
|
||||
continue
|
||||
if _has_glob_chars(part):
|
||||
if part == "**":
|
||||
break
|
||||
return None
|
||||
literal_parts.append(part)
|
||||
if not literal_parts:
|
||||
return os.sep if absolute else None
|
||||
root = os.path.join(os.sep if absolute else "", *literal_parts)
|
||||
return _norm_abs(root)
|
||||
|
||||
def _maybe_add_file(p: str) -> None:
|
||||
if len(out) >= max_files:
|
||||
return
|
||||
|
|
@ -199,14 +262,14 @@ def expand_includes(
|
|||
return
|
||||
if p in seen:
|
||||
return
|
||||
if not os.path.isfile(p) or os.path.islink(p):
|
||||
if not _is_file_no_symlink_components(p):
|
||||
return
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
|
||||
def _walk_dir(root: str, match: Optional[CompiledPathPattern] = None) -> None:
|
||||
root = _norm_abs(root)
|
||||
if not os.path.isdir(root) or os.path.islink(root):
|
||||
if not _is_dir_no_symlink_components(root):
|
||||
return
|
||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
# Prune excluded directories early.
|
||||
|
|
@ -215,13 +278,13 @@ def expand_includes(
|
|||
d
|
||||
for d in dirnames
|
||||
if not exclude.is_excluded(os.path.join(dirpath, d))
|
||||
and not os.path.islink(os.path.join(dirpath, d))
|
||||
and _is_dir_no_symlink_components(os.path.join(dirpath, d))
|
||||
]
|
||||
for fn in filenames:
|
||||
if len(out) >= max_files:
|
||||
return
|
||||
p = os.path.join(dirpath, fn)
|
||||
if os.path.islink(p) or not os.path.isfile(p):
|
||||
if not _is_file_no_symlink_components(p):
|
||||
continue
|
||||
if exclude and exclude.is_excluded(p):
|
||||
continue
|
||||
|
|
@ -243,10 +306,10 @@ def expand_includes(
|
|||
|
||||
if pat.kind == "prefix":
|
||||
p = pat.value
|
||||
if os.path.isfile(p) and not os.path.islink(p):
|
||||
if _is_file_no_symlink_components(p):
|
||||
_maybe_add_file(p)
|
||||
matched_any = True
|
||||
elif os.path.isdir(p) and not os.path.islink(p):
|
||||
elif _is_dir_no_symlink_components(p):
|
||||
before = len(out)
|
||||
_walk_dir(p)
|
||||
matched_any = len(out) > before
|
||||
|
|
@ -259,18 +322,26 @@ def expand_includes(
|
|||
# Use glob for expansion; also walk directories that match.
|
||||
gpat = pat.value
|
||||
hits = glob.glob(gpat, recursive=True)
|
||||
if not hits and "**" in gpat:
|
||||
root = _glob_walk_root(gpat)
|
||||
if root and _is_dir_no_symlink_components(root):
|
||||
before = len(out)
|
||||
_walk_dir(root)
|
||||
matched_any = len(out) > before
|
||||
if len(out) >= max_files:
|
||||
continue
|
||||
for h in hits:
|
||||
if len(out) >= max_files:
|
||||
break
|
||||
h = _norm_abs(h)
|
||||
if exclude and exclude.is_excluded(h):
|
||||
continue
|
||||
if os.path.isdir(h) and not os.path.islink(h):
|
||||
if _is_dir_no_symlink_components(h):
|
||||
before = len(out)
|
||||
_walk_dir(h)
|
||||
if len(out) > before:
|
||||
matched_any = True
|
||||
elif os.path.isfile(h) and not os.path.islink(h):
|
||||
elif _is_file_no_symlink_components(h):
|
||||
_maybe_add_file(h)
|
||||
matched_any = True
|
||||
|
||||
|
|
|
|||
1840
enroll/puppet.py
1840
enroll/puppet.py
File diff suppressed because it is too large
Load diff
156
enroll/remote.py
156
enroll/remote.py
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import hashlib
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
|
|
@ -232,11 +233,21 @@ def _safe_extract_tar(tar: tarfile.TarFile, dest: Path) -> None:
|
|||
tar.extract(m, path=dest)
|
||||
|
||||
|
||||
def _build_enroll_pyz(tmpdir: Path) -> Path:
|
||||
def _build_enroll_pyz(tmpdir: Path) -> tuple[Path, str]:
|
||||
"""Build a self-contained enroll zipapp (pyz) on the local machine.
|
||||
|
||||
The resulting file is stdlib-only and can be executed on the remote host
|
||||
as long as it has Python 3 available.
|
||||
|
||||
Returns ``(pyz_path, sha256_hex)``. The digest is computed on the exact
|
||||
bytes written locally so the caller can verify, on the remote side, that the
|
||||
file that is about to be executed as root is byte-for-byte the one we built
|
||||
(see ``_remote_verify_pyz_sha256``). This is transport/staging integrity
|
||||
defence-in-depth: it detects a swap of the staged file between upload and
|
||||
execution by anyone who gained write access to the staging directory. It is
|
||||
NOT a defence against a remote host that is already root-compromised -- such
|
||||
a host can subvert the interpreter regardless, and is out of scope per
|
||||
SECURITY.md.
|
||||
"""
|
||||
import enroll as pkg
|
||||
|
||||
|
|
@ -244,15 +255,58 @@ def _build_enroll_pyz(tmpdir: Path) -> Path:
|
|||
stage = tmpdir / "stage"
|
||||
(stage / "enroll").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _ignore(d: str, names: list[str]) -> set[str]:
|
||||
return {
|
||||
n
|
||||
for n in names
|
||||
if n in {"__pycache__", ".pytest_cache"} or n.endswith(".pyc")
|
||||
# Names that must never end up in the remote zipapp. The remote only ever
|
||||
# runs ``harvest``; test suites, caches, editor/VCS scratch, and compiled
|
||||
# artifacts are never needed at runtime and should not be shipped to (or
|
||||
# executed on) a harvested host. Excluding them keeps the payload minimal
|
||||
# and avoids transferring irrelevant code to every target.
|
||||
_ignore_dirs = {
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
"tests",
|
||||
"test",
|
||||
}
|
||||
_ignore_suffixes = (".pyc", ".pyo", ".orig", ".rej", ".bak")
|
||||
|
||||
def _ignore(directory: str, names: list[str]) -> set[str]:
|
||||
dropped: set[str] = set()
|
||||
for n in names:
|
||||
if n in _ignore_dirs:
|
||||
dropped.add(n)
|
||||
continue
|
||||
if n.endswith(_ignore_suffixes):
|
||||
dropped.add(n)
|
||||
continue
|
||||
# Defensive: never ship test modules even if they are colocated in
|
||||
# the package directory by a future packaging change.
|
||||
if n.startswith("test_") and n.endswith(".py"):
|
||||
dropped.add(n)
|
||||
continue
|
||||
if n == "conftest.py":
|
||||
dropped.add(n)
|
||||
continue
|
||||
return dropped
|
||||
|
||||
shutil.copytree(pkg_dir, stage / "enroll", dirs_exist_ok=True, ignore=_ignore)
|
||||
|
||||
# The JSON Schema is a required runtime data file for ``validate``/``manifest``
|
||||
# consumers of the harvest; the remote harvest itself does not validate, but
|
||||
# the bundle it produces is validated locally, and the schema travels with
|
||||
# the package. Fail loudly if a future ignore rule ever drops it rather than
|
||||
# silently shipping a package that cannot self-validate.
|
||||
staged_schema = stage / "enroll" / "schema" / "state.schema.json"
|
||||
if not staged_schema.is_file():
|
||||
raise RuntimeError(
|
||||
"internal error: enroll.pyz staging is missing the bundled JSON "
|
||||
"schema (schema/state.schema.json); refusing to build an "
|
||||
"incomplete remote payload"
|
||||
)
|
||||
|
||||
pyz_path = tmpdir / "enroll.pyz"
|
||||
zipapp.create_archive(
|
||||
stage,
|
||||
|
|
@ -260,7 +314,83 @@ def _build_enroll_pyz(tmpdir: Path) -> Path:
|
|||
main="enroll.cli:main",
|
||||
compressed=True,
|
||||
)
|
||||
return pyz_path
|
||||
|
||||
sha256_hex = _sha256_file(pyz_path)
|
||||
return pyz_path, sha256_hex
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
"""Return the hex SHA-256 of a file, read in chunks."""
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _remote_verify_pyz_sha256(
|
||||
ssh,
|
||||
remote_pyz_path: str,
|
||||
expected_sha256: str,
|
||||
*,
|
||||
remote_python: str,
|
||||
) -> None:
|
||||
"""Verify the uploaded zipapp's SHA-256 on the remote before executing it.
|
||||
|
||||
This is transport/staging integrity defence-in-depth. The check runs on the
|
||||
remote, immediately before the (root) execution of the zipapp, and fails
|
||||
closed if the digest does not match the bytes we built locally. It shrinks
|
||||
the window in which a *non-root* tamperer who somehow gained write access to
|
||||
the staging directory could swap the file between upload and execution.
|
||||
|
||||
It deliberately does NOT establish trust in a root-compromised remote: a
|
||||
host that is already root can forge any check it runs about itself. Per
|
||||
SECURITY.md, such a host is outside Enroll's threat model. The value here is
|
||||
catching accidental corruption and unprivileged-local-user staging races,
|
||||
not defeating a compromised root.
|
||||
|
||||
The hashing is done with Python's hashlib (already required to run the
|
||||
zipapp) rather than a ``sha256sum`` binary, so it does not depend on
|
||||
coreutils being present or on PATH resolution of a hashing tool.
|
||||
"""
|
||||
|
||||
# Hash the staged file using the same interpreter that will execute it.
|
||||
# ``-I`` isolates the interpreter from site/user customisation; the script
|
||||
# prints only the lowercase hex digest.
|
||||
hash_script = (
|
||||
"import hashlib,sys;"
|
||||
"h=hashlib.sha256();"
|
||||
"f=open(sys.argv[1],'rb');"
|
||||
"[h.update(c) for c in iter(lambda:f.read(1048576),b'')];"
|
||||
"sys.stdout.write(h.hexdigest())"
|
||||
)
|
||||
cmd = " ".join(
|
||||
shlex.quote(tok)
|
||||
for tok in (remote_python, "-I", "-c", hash_script, remote_pyz_path)
|
||||
)
|
||||
rc, out, err = _ssh_run(ssh, cmd, get_pty=False)
|
||||
if rc != 0:
|
||||
raise RuntimeError(
|
||||
"Failed to verify the integrity of the uploaded enroll.pyz on the "
|
||||
f"remote host.\nCommand: {cmd}\nExit code: {rc}\nStderr: {err.strip()}"
|
||||
)
|
||||
remote_digest = out.strip().lower()
|
||||
expected = expected_sha256.strip().lower()
|
||||
if not remote_digest:
|
||||
raise RuntimeError(
|
||||
"Remote integrity check returned an empty SHA-256 for enroll.pyz; "
|
||||
"refusing to execute it."
|
||||
)
|
||||
if remote_digest != expected:
|
||||
raise RuntimeError(
|
||||
"Integrity check failed for the uploaded enroll.pyz: the staged "
|
||||
"file's SHA-256 on the remote does not match the locally built "
|
||||
"payload. Refusing to execute it as root.\n"
|
||||
f" expected: {expected}\n"
|
||||
f" remote: {remote_digest}\n"
|
||||
"This can indicate the staging directory was tampered with between "
|
||||
"upload and execution, or transfer corruption."
|
||||
)
|
||||
|
||||
|
||||
def _ssh_run(
|
||||
|
|
@ -444,7 +574,7 @@ def _remote_harvest(
|
|||
# Build a zipapp locally and upload it to the remote.
|
||||
with tempfile.TemporaryDirectory(prefix="enroll-remote-") as td:
|
||||
td_path = Path(td)
|
||||
pyz = _build_enroll_pyz(td_path)
|
||||
pyz, pyz_sha256 = _build_enroll_pyz(td_path)
|
||||
local_tgz = td_path / "bundle.tgz"
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
|
|
@ -595,6 +725,16 @@ def _remote_harvest(
|
|||
rapp = f"{rtmp}/enroll.pyz"
|
||||
sftp.put(str(pyz), rapp)
|
||||
|
||||
# Before executing the uploaded zipapp (as root, under sudo), verify
|
||||
# on the remote that the staged bytes match what we built locally.
|
||||
# This is staging/transport integrity defence-in-depth: it fails
|
||||
# closed if the file was swapped or corrupted between upload and
|
||||
# execution. It does not (and cannot) defend against a remote that
|
||||
# is already root-compromised; see _remote_verify_pyz_sha256.
|
||||
_remote_verify_pyz_sha256(
|
||||
ssh, rapp, pyz_sha256, remote_python=remote_python
|
||||
)
|
||||
|
||||
if not no_sudo:
|
||||
# The remote zipapp is staged as the SSH user, but the harvest
|
||||
# itself runs as root. Root must not write its bundle under the
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping, Set as AbstractSet
|
||||
from typing import Any
|
||||
|
|
@ -9,6 +8,109 @@ from typing import Any
|
|||
ANSIBLE_JINJA_STARTS = ("{{", "{%", "{#")
|
||||
|
||||
|
||||
class RenderSafetyError(RuntimeError):
|
||||
"""Raised when generated configuration-management text is unsafe.
|
||||
|
||||
This is a *generation-time* guardrail. It fires when Enroll would otherwise
|
||||
emit raw scaffolding (task/handler YAML) built from a value that is not a
|
||||
known-safe Enroll-controlled token, or when a generated task/handler file
|
||||
does not round-trip to the structure Enroll intended. Either case means a
|
||||
harvested value has leaked into playbook *structure* instead of staying in
|
||||
Ansible *data*, so Enroll fails closed rather than writing a poisoned
|
||||
manifest.
|
||||
"""
|
||||
|
||||
|
||||
# The only characters Enroll ever needs inside raw YAML scaffolding are those
|
||||
# that make up sanitized role/module identifiers and a handful of fixed English
|
||||
# words in task names. Anything outside this set must travel as Ansible *data*
|
||||
# (a variable consumed via ``{{ ... }}``), never as scaffolding text.
|
||||
#
|
||||
# This is deliberately strict: it matches the charset produced by
|
||||
# ``ansible._role_id`` / ``package_hints.role_id`` plus spaces (for the fixed
|
||||
# human-readable portions of task names that Enroll itself authors). It does NOT
|
||||
# permit quotes, colons, newlines, braces, or any YAML metacharacter, so a value
|
||||
# that passes this gate cannot alter document structure.
|
||||
_SCAFFOLD_TOKEN_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_ .-]*$")
|
||||
|
||||
|
||||
def scaffold_token(value: str, *, field: str = "scaffold token") -> str:
|
||||
"""Return *value* only if it is safe to splice into raw YAML scaffolding.
|
||||
|
||||
Use this for the *only* legitimate reason to interpolate a dynamic value
|
||||
into hand-written YAML text: an Enroll-generated, already-sanitized
|
||||
identifier such as a role name or module name. Harvested free-text (unit
|
||||
names, file paths, package descriptions, user fields, ...) must never be
|
||||
passed here -- it belongs in a variable file via :func:`ansible_unsafe_data`
|
||||
and must be referenced from tasks through ``{{ ... }}`` indirection.
|
||||
|
||||
Raising rather than escaping is intentional. Escaping invites a long tail of
|
||||
"did we cover every YAML metacharacter / Jinja delimiter / indentation
|
||||
trick" bugs. A hard allowlist makes structural injection impossible by
|
||||
construction: if a caller ever tries to splice harvested data into
|
||||
scaffolding, generation aborts loudly instead of silently producing unsafe
|
||||
output.
|
||||
"""
|
||||
|
||||
text = "" if value is None else str(value)
|
||||
if not _SCAFFOLD_TOKEN_RE.fullmatch(text):
|
||||
raise RenderSafetyError(
|
||||
f"refusing to interpolate unsafe {field} into generated YAML "
|
||||
f"scaffolding: {text!r}. Harvested values must be passed as Ansible "
|
||||
f"data (a variable rendered through ansible_unsafe_data), not spliced "
|
||||
f"into task/handler text."
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def assert_generated_yaml_safe(text: str, *, label: str) -> None:
|
||||
"""Verify a block of Enroll-generated task/handler YAML is well-formed.
|
||||
|
||||
This is the backstop for the scaffold-token rule. Even if a future change
|
||||
reintroduces raw interpolation of a harvested value, this check parses the
|
||||
generated YAML and confirms it is a list of mappings (Ansible task/handler
|
||||
shape) with only string keys -- the structure Enroll intends. A harvested
|
||||
value that injects an extra list item, a non-mapping entry, a non-string
|
||||
key, or breaks parsing entirely will trip this and abort generation.
|
||||
|
||||
It is intentionally structural, not value-level: it does not try to judge
|
||||
whether a *value* is dangerous (that is what ``ansible_unsafe_data`` handles
|
||||
for variable files). It ensures harvested data cannot change the *shape* of
|
||||
a generated tasks/handlers document.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
|
||||
try:
|
||||
doc = yaml.safe_load(text)
|
||||
except yaml.YAMLError as e: # pragma: no cover - exercised via tests
|
||||
raise RenderSafetyError(
|
||||
f"generated {label} is not valid YAML; a harvested value likely "
|
||||
f"broke document structure: {e}"
|
||||
) from e
|
||||
|
||||
if doc is None:
|
||||
# An empty "---\n" document is a legitimate "no tasks/handlers" result.
|
||||
return
|
||||
if not isinstance(doc, list):
|
||||
raise RenderSafetyError(
|
||||
f"generated {label} is not a YAML list of tasks/handlers; a "
|
||||
f"harvested value likely altered document structure"
|
||||
)
|
||||
for entry in doc:
|
||||
if not isinstance(entry, Mapping):
|
||||
raise RenderSafetyError(
|
||||
f"generated {label} contains a non-mapping entry; a harvested "
|
||||
f"value likely injected list structure: {entry!r}"
|
||||
)
|
||||
for key in entry.keys():
|
||||
if not isinstance(key, str):
|
||||
raise RenderSafetyError(
|
||||
f"generated {label} contains a non-string task key; a "
|
||||
f"harvested value likely injected mapping structure: {key!r}"
|
||||
)
|
||||
|
||||
|
||||
class AnsibleUnsafeText(str):
|
||||
"""String subclass dumped as Ansible's ``!unsafe`` YAML scalar.
|
||||
|
||||
|
|
@ -50,183 +152,3 @@ def ansible_unsafe_data(value: Any) -> Any:
|
|||
if isinstance(value, AbstractSet):
|
||||
return sorted(ansible_unsafe_data(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def escape_puppet_hiera_interpolation(value: str) -> str:
|
||||
"""Preserve literal ``%{`` text in Puppet Hiera data sources.
|
||||
|
||||
Hiera treats ``%{...}`` in data values as interpolation. Enroll's Hiera
|
||||
data is generated from harvested values, not authored Hiera expressions, so
|
||||
any literal interpolation opener is escaped with Hiera's documented
|
||||
``literal('%')`` helper.
|
||||
"""
|
||||
|
||||
return str(value).replace("%{", "%{literal('%')}{")
|
||||
|
||||
|
||||
def puppet_hiera_safe_data(value: Any) -> Any:
|
||||
"""Recursively escape Hiera interpolation openers in harvested data."""
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
escape_puppet_hiera_interpolation(str(key)): puppet_hiera_safe_data(inner)
|
||||
for key, inner in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [puppet_hiera_safe_data(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [puppet_hiera_safe_data(item) for item in value]
|
||||
if isinstance(value, AbstractSet):
|
||||
return sorted(puppet_hiera_safe_data(item) for item in value)
|
||||
if isinstance(value, str):
|
||||
return escape_puppet_hiera_interpolation(value)
|
||||
return value
|
||||
|
||||
|
||||
def _plain_json_data(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _plain_json_data(inner) for key, inner in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_plain_json_data(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [_plain_json_data(item) for item in value]
|
||||
if isinstance(value, AbstractSet):
|
||||
return sorted(_plain_json_data(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def _escape_braces_inside_json_strings(text: str) -> str:
|
||||
"""Replace literal braces only while scanning JSON string tokens."""
|
||||
|
||||
out: list[str] = []
|
||||
in_string = False
|
||||
escaped = False
|
||||
for ch in text:
|
||||
if not in_string:
|
||||
out.append(ch)
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
continue
|
||||
|
||||
if escaped:
|
||||
out.append(ch)
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
out.append(ch)
|
||||
escaped = True
|
||||
elif ch == '"':
|
||||
out.append(ch)
|
||||
in_string = False
|
||||
elif ch == "{":
|
||||
out.append("\\u007b")
|
||||
elif ch == "}":
|
||||
out.append("\\u007d")
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def salt_sls_json_quote(value: Any) -> str:
|
||||
"""Return a double-quoted YAML/JSON scalar safe for Salt's Jinja pass.
|
||||
|
||||
Salt state and pillar SLS files normally use the ``jinja|yaml`` renderer
|
||||
pipeline. YAML/JSON quoting alone does not stop ``{{ ... }}``, ``{% ... %}``
|
||||
or ``{# ... #}`` inside harvested values from being evaluated before YAML is
|
||||
parsed. JSON/YAML double-quoted scalars decode ``\u007b`` and ``\u007d``
|
||||
after Jinja has run, so encode braces inside string tokens as Unicode escapes.
|
||||
"""
|
||||
|
||||
dumped = json.dumps(str(value), ensure_ascii=False)
|
||||
return _escape_braces_inside_json_strings(dumped)
|
||||
|
||||
|
||||
_PLAIN_YAML_KEY_RE = re.compile(r"^[A-Za-z0-9_./:-]+$")
|
||||
|
||||
|
||||
def _salt_yaml_key(value: Any) -> str:
|
||||
text = str(value)
|
||||
if text and _PLAIN_YAML_KEY_RE.match(text) and not text.startswith(("-", "?", ":")):
|
||||
return text
|
||||
return salt_sls_json_quote(text)
|
||||
|
||||
|
||||
def _salt_yaml_scalar(value: Any) -> str:
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
return json.dumps(value, allow_nan=False)
|
||||
return salt_sls_json_quote(value)
|
||||
|
||||
|
||||
def _salt_yaml_lines(
|
||||
value: Any, indent: int = 0, *, sort_keys: bool = True
|
||||
) -> list[str]:
|
||||
prefix = " " * indent
|
||||
if isinstance(value, Mapping):
|
||||
if not value:
|
||||
return [prefix + "{}"]
|
||||
keys = sorted(value, key=lambda item: str(item)) if sort_keys else list(value)
|
||||
lines: list[str] = []
|
||||
for key in keys:
|
||||
inner = value[key]
|
||||
key_text = _salt_yaml_key(key)
|
||||
if isinstance(inner, Mapping):
|
||||
if not inner:
|
||||
lines.append(f"{prefix}{key_text}: {{}}")
|
||||
else:
|
||||
lines.append(f"{prefix}{key_text}:")
|
||||
lines.extend(
|
||||
_salt_yaml_lines(inner, indent + 2, sort_keys=sort_keys)
|
||||
)
|
||||
elif isinstance(inner, (list, tuple, set)):
|
||||
seq = list(inner) if not isinstance(inner, set) else sorted(inner)
|
||||
if not seq:
|
||||
lines.append(f"{prefix}{key_text}: []")
|
||||
else:
|
||||
lines.append(f"{prefix}{key_text}:")
|
||||
lines.extend(_salt_yaml_lines(seq, indent + 2, sort_keys=sort_keys))
|
||||
else:
|
||||
lines.append(f"{prefix}{key_text}: {_salt_yaml_scalar(inner)}")
|
||||
return lines
|
||||
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
seq = list(value) if not isinstance(value, set) else sorted(value)
|
||||
if not seq:
|
||||
return [prefix + "[]"]
|
||||
lines = []
|
||||
for item in seq:
|
||||
if isinstance(item, Mapping):
|
||||
if not item:
|
||||
lines.append(prefix + "- {}")
|
||||
else:
|
||||
lines.append(prefix + "-")
|
||||
lines.extend(
|
||||
_salt_yaml_lines(item, indent + 2, sort_keys=sort_keys)
|
||||
)
|
||||
elif isinstance(item, (list, tuple, set)):
|
||||
lines.append(prefix + "-")
|
||||
lines.extend(_salt_yaml_lines(item, indent + 2, sort_keys=sort_keys))
|
||||
else:
|
||||
lines.append(f"{prefix}- {_salt_yaml_scalar(item)}")
|
||||
return lines
|
||||
|
||||
return [prefix + _salt_yaml_scalar(value)]
|
||||
|
||||
|
||||
def salt_sls_yaml_dump(
|
||||
value: Any,
|
||||
*,
|
||||
sort_keys: bool = True,
|
||||
explicit_start: bool = False,
|
||||
) -> str:
|
||||
"""Dump block YAML whose string braces cannot form Salt Jinja delimiters."""
|
||||
|
||||
lines = _salt_yaml_lines(_plain_json_data(value), sort_keys=sort_keys)
|
||||
rendered = "\n".join(lines).rstrip() + "\n"
|
||||
if explicit_start:
|
||||
rendered = "---\n" + rendered
|
||||
return rendered
|
||||
|
|
|
|||
1759
enroll/salt.py
1759
enroll/salt.py
File diff suppressed because it is too large
Load diff
|
|
@ -58,12 +58,15 @@ def _default_schema_path() -> Path:
|
|||
return Path(__file__).resolve().parent / "schema" / "state.schema.json"
|
||||
|
||||
|
||||
def _load_schema(schema: Optional[str]) -> Dict[str, Any]:
|
||||
def _load_schema(
|
||||
schema: Optional[str], *, allow_remote_schema: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Load a JSON schema.
|
||||
|
||||
If schema is None, load the vendored schema.
|
||||
If schema begins with http(s)://, fetch it.
|
||||
Otherwise, treat it as a local file path.
|
||||
If schema begins with http(s)://, fetch it only when remote schema
|
||||
loading was explicitly enabled by the caller. Otherwise, treat it as a
|
||||
local file path.
|
||||
"""
|
||||
|
||||
if not schema:
|
||||
|
|
@ -72,6 +75,11 @@ def _load_schema(schema: Optional[str]) -> Dict[str, Any]:
|
|||
return json.load(f)
|
||||
|
||||
if schema.startswith("http://") or schema.startswith("https://"):
|
||||
if not allow_remote_schema:
|
||||
raise ValueError(
|
||||
"remote schema URLs are disabled by default; use "
|
||||
"--allow-remote-schema only when the schema source is trusted"
|
||||
)
|
||||
with urllib.request.urlopen(schema, timeout=10) as resp: # nosec
|
||||
data = resp.read()
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
|
@ -136,6 +144,7 @@ def validate_harvest(
|
|||
sops_mode: bool = False,
|
||||
schema: Optional[str] = None,
|
||||
no_schema: bool = False,
|
||||
allow_remote_schema: bool = False,
|
||||
) -> ValidationResult:
|
||||
"""Validate an enroll harvest bundle.
|
||||
|
||||
|
|
@ -165,7 +174,7 @@ def validate_harvest(
|
|||
|
||||
if not no_schema:
|
||||
try:
|
||||
sch = _load_schema(schema)
|
||||
sch = _load_schema(schema, allow_remote_schema=allow_remote_schema)
|
||||
validator = jsonschema.Draft202012Validator(sch)
|
||||
for err in sorted(validator.iter_errors(state), key=str):
|
||||
ptr = _json_pointer(err)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"
|
|||
[project]
|
||||
name = "enroll"
|
||||
version = "0.7.0b7"
|
||||
description = "Enroll a server's running state retrospectively into Ansible, Puppet or Salt"
|
||||
description = "Enroll a server's running state retrospectively into Ansible"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
|
|
|||
140
tests.sh
140
tests.sh
|
|
@ -2,10 +2,6 @@
|
|||
|
||||
set -Eeuo pipefail
|
||||
|
||||
if [[ -d /opt/puppetlabs/bin ]]; then
|
||||
export PATH="/opt/puppetlabs/bin:${PATH}"
|
||||
fi
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP_PARENT="${TMPDIR:-/tmp}"
|
||||
KEEP_WORKDIR=0
|
||||
|
|
@ -22,16 +18,8 @@ BUNDLE_DIFF_DIR="${WORK_DIR}/bundle-diff"
|
|||
ANSIBLE_DIR="${WORK_DIR}/ansible"
|
||||
ANSIBLE_NO_COMMON_DIR="${WORK_DIR}/ansible-no-common"
|
||||
ANSIBLE_FQDN_DIR="${WORK_DIR}/ansible-fqdn"
|
||||
PUPPET_DIR="${WORK_DIR}/puppet"
|
||||
PUPPET_FQDN_DIR="${WORK_DIR}/puppet-fqdn"
|
||||
SALT_DIR="${WORK_DIR}/salt"
|
||||
SALT_FQDN_DIR="${WORK_DIR}/salt-fqdn"
|
||||
ANSIBLE_JINJATURTLE_DIR="${WORK_DIR}/ansible-jinjaturtle"
|
||||
ANSIBLE_NO_JINJATURTLE_DIR="${WORK_DIR}/ansible-no-jinjaturtle"
|
||||
PUPPET_JINJATURTLE_DIR="${WORK_DIR}/puppet-jinjaturtle"
|
||||
PUPPET_NO_JINJATURTLE_DIR="${WORK_DIR}/puppet-no-jinjaturtle"
|
||||
SALT_JINJATURTLE_DIR="${WORK_DIR}/salt-jinjaturtle"
|
||||
SALT_NO_JINJATURTLE_DIR="${WORK_DIR}/salt-no-jinjaturtle"
|
||||
TEST_FQDN="${ENROLL_TEST_FQDN:-enroll-ci.example.test}"
|
||||
JINJATURTLE_FIXTURE="${WORK_DIR}/enroll-tests-jinjaturtle.ini"
|
||||
ANSIBLE_PLAYBOOK_EXTRA_ARGS=()
|
||||
|
|
@ -176,7 +164,6 @@ translate_packages() {
|
|||
gnupg) translated+=(gnupg2) ;;
|
||||
curl) translated+=(curl-minimal) ;;
|
||||
lsb-release) translated+=(redhat-lsb-core) ;;
|
||||
puppet) translated+=(puppet-agent) ;;
|
||||
python3-apt) ;;
|
||||
python3-jsonschema) translated+=(python3-jsonschema) ;;
|
||||
python3-venv) ;;
|
||||
|
|
@ -232,42 +219,6 @@ ensure_epel_repo() {
|
|||
DNF_UPDATED=
|
||||
}
|
||||
|
||||
ensure_salt_repo() {
|
||||
if is_debian; then
|
||||
if [[ -e /etc/apt/sources.list.d/salt.sources ]]; then
|
||||
return
|
||||
fi
|
||||
section "Setup: Salt apt repository"
|
||||
pkg_install ca-certificates curl gnupg
|
||||
run mkdir -m 755 -p /etc/apt/keyrings
|
||||
run bash -c "curl -fsSL https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public | gpg --dearmor --yes -o /etc/apt/keyrings/salt-archive-keyring.pgp"
|
||||
run bash -c "curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources > /etc/apt/sources.list.d/salt.sources"
|
||||
APT_UPDATED=
|
||||
elif is_rpm_family; then
|
||||
if [[ -e /etc/yum.repos.d/salt.repo ]]; then
|
||||
return
|
||||
fi
|
||||
section "Setup: Salt dnf repository"
|
||||
pkg_install ca-certificates curl
|
||||
run bash -c "curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.repo > /etc/yum.repos.d/salt.repo"
|
||||
DNF_UPDATED=
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_puppet_repo() {
|
||||
if ! is_rpm_family; then
|
||||
return
|
||||
fi
|
||||
if rpm -q puppet8-release >/dev/null 2>&1 || [[ -e /etc/yum.repos.d/puppet8-release.repo ]]; then
|
||||
return
|
||||
fi
|
||||
section "Setup: Puppet dnf repository"
|
||||
local major
|
||||
major="$(os_version_major)"
|
||||
run dnf -y install "https://yum.puppet.com/puppet8-release-el-${major}.noarch.rpm"
|
||||
DNF_UPDATED=
|
||||
}
|
||||
|
||||
ensure_jinjaturtle() {
|
||||
section "Setup: JinjaTurtle package"
|
||||
if command -v jinjaturtle >/dev/null 2>&1; then
|
||||
|
|
@ -311,22 +262,6 @@ ensure_ansible() {
|
|||
require_cmd ansible-lint "Install the ansible-lint package."
|
||||
}
|
||||
|
||||
ensure_puppet() {
|
||||
ensure_puppet_repo
|
||||
if ! command -v puppet >/dev/null 2>&1; then
|
||||
pkg_install puppet || pkg_install puppet-agent
|
||||
fi
|
||||
require_cmd puppet "Install Puppet before running the Puppet noop integration tests."
|
||||
}
|
||||
|
||||
ensure_salt() {
|
||||
ensure_salt_repo
|
||||
if ! command -v salt-call >/dev/null 2>&1; then
|
||||
pkg_install salt-minion || true
|
||||
fi
|
||||
require_cmd salt-call "Install Salt's salt-call binary before running the Salt noop integration tests. This may require configuring the upstream Salt/Broadcom package repository first."
|
||||
}
|
||||
|
||||
run_pytests() {
|
||||
section "Python unit tests"
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
|
@ -396,34 +331,6 @@ run_ansible_jinjaturtle_variant() {
|
|||
run ansible-playbook playbook.yml -i "localhost," -c local --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}"
|
||||
}
|
||||
|
||||
run_puppet_jinjaturtle_variant() {
|
||||
local out_dir="$1"
|
||||
local expected="$2"
|
||||
local label="$3"
|
||||
shift 3
|
||||
|
||||
ensure_puppet
|
||||
cd "${PROJECT_ROOT}"
|
||||
rm -rf "${out_dir}"
|
||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${out_dir}" --target puppet "$@"
|
||||
assert_template_files "${out_dir}" "erb" "${expected}" "${label}"
|
||||
run puppet apply --modulepath "${out_dir}/modules" "${out_dir}/manifests/site.pp" --noop
|
||||
}
|
||||
|
||||
run_salt_jinjaturtle_variant() {
|
||||
local out_dir="$1"
|
||||
local expected="$2"
|
||||
local label="$3"
|
||||
shift 3
|
||||
|
||||
ensure_salt
|
||||
cd "${PROJECT_ROOT}"
|
||||
rm -rf "${out_dir}"
|
||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${out_dir}" --target salt "$@"
|
||||
assert_template_files "${out_dir}" "j2" "${expected}" "${label}"
|
||||
run salt-call --local --retcode-passthrough --file-root "${out_dir}/states" state.apply test=True
|
||||
}
|
||||
|
||||
run_jinjaturtle_manifest_tests() {
|
||||
if is_rpm_family ; then
|
||||
section "JinjaTurtle integration matrix"
|
||||
|
|
@ -437,14 +344,6 @@ run_jinjaturtle_manifest_tests() {
|
|||
section "Ansible JinjaTurtle manifest noop tests"
|
||||
run_ansible_jinjaturtle_variant "${ANSIBLE_JINJATURTLE_DIR}" present "Ansible with JinjaTurtle on PATH"
|
||||
run_ansible_jinjaturtle_variant "${ANSIBLE_NO_JINJATURTLE_DIR}" absent "Ansible with --no-jinjaturtle" --no-jinjaturtle
|
||||
|
||||
section "Puppet JinjaTurtle manifest noop tests"
|
||||
run_puppet_jinjaturtle_variant "${PUPPET_JINJATURTLE_DIR}" present "Puppet with JinjaTurtle on PATH"
|
||||
run_puppet_jinjaturtle_variant "${PUPPET_NO_JINJATURTLE_DIR}" absent "Puppet with --no-jinjaturtle" --no-jinjaturtle
|
||||
|
||||
section "Salt JinjaTurtle manifest noop tests"
|
||||
run_salt_jinjaturtle_variant "${SALT_JINJATURTLE_DIR}" present "Salt with JinjaTurtle on PATH"
|
||||
run_salt_jinjaturtle_variant "${SALT_NO_JINJATURTLE_DIR}" absent "Salt with --no-jinjaturtle" --no-jinjaturtle
|
||||
}
|
||||
|
||||
run_ansible_noop_tests() {
|
||||
|
|
@ -472,43 +371,6 @@ run_ansible_noop_tests() {
|
|||
run ansible-playbook "playbooks/${TEST_FQDN}.yml" -i inventory/hosts.ini -c local --limit "${TEST_FQDN}" --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}"
|
||||
}
|
||||
|
||||
run_puppet_noop_tests() {
|
||||
section "Puppet manifest noop tests"
|
||||
ensure_puppet
|
||||
cd "${PROJECT_ROOT}"
|
||||
rm -rf "${PUPPET_DIR}" "${PUPPET_FQDN_DIR}"
|
||||
|
||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${PUPPET_DIR}" --target puppet
|
||||
run puppet apply --modulepath "${PUPPET_DIR}/modules" "${PUPPET_DIR}/manifests/site.pp" --noop
|
||||
|
||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${PUPPET_FQDN_DIR}" --target puppet --fqdn "${TEST_FQDN}"
|
||||
run puppet apply \
|
||||
--modulepath "${PUPPET_FQDN_DIR}/modules" \
|
||||
--hiera_config "${PUPPET_FQDN_DIR}/hiera.yaml" \
|
||||
--certname "${TEST_FQDN}" \
|
||||
"${PUPPET_FQDN_DIR}/manifests/site.pp" \
|
||||
--noop
|
||||
}
|
||||
|
||||
run_salt_noop_tests() {
|
||||
section "Salt manifest noop tests"
|
||||
ensure_salt
|
||||
cd "${PROJECT_ROOT}"
|
||||
rm -rf "${SALT_DIR}" "${SALT_FQDN_DIR}"
|
||||
|
||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${SALT_DIR}" --target salt
|
||||
run salt-call --local --retcode-passthrough --file-root "${SALT_DIR}/states" state.apply test=True
|
||||
|
||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${SALT_FQDN_DIR}" --target salt --fqdn "${TEST_FQDN}"
|
||||
run salt-call \
|
||||
--local \
|
||||
--retcode-passthrough \
|
||||
--id "${TEST_FQDN}" \
|
||||
--file-root "${SALT_FQDN_DIR}/states" \
|
||||
--pillar-root "${SALT_FQDN_DIR}/pillar" \
|
||||
state.apply test=True
|
||||
}
|
||||
|
||||
main() {
|
||||
require_root
|
||||
require_supported_ci_os
|
||||
|
|
@ -516,8 +378,6 @@ main() {
|
|||
prepare_harvest_fixture
|
||||
configure_ansible_playbook_extra_args
|
||||
run_ansible_noop_tests
|
||||
run_puppet_noop_tests
|
||||
run_salt_noop_tests
|
||||
run_jinjaturtle_manifest_tests
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -513,3 +513,68 @@ def test_flatpak_list_attempts_respect_supported_columns():
|
|||
assert any("--columns=application,branch" in cmd for cmd in command_strings)
|
||||
assert not any("origin" in cmd for cmd in command_strings)
|
||||
assert command_strings[-1] == "flatpak list --system"
|
||||
|
||||
|
||||
def test_user_flatpak_origin_symlink_is_not_read(tmp_path: Path):
|
||||
import enroll.accounts as a
|
||||
|
||||
home = tmp_path / "home"
|
||||
active = (
|
||||
home
|
||||
/ ".local"
|
||||
/ "share"
|
||||
/ "flatpak"
|
||||
/ "app"
|
||||
/ "org.evil.App"
|
||||
/ "x86_64"
|
||||
/ "stable"
|
||||
/ "active"
|
||||
)
|
||||
active.mkdir(parents=True)
|
||||
secret = tmp_path / "root-only-secret"
|
||||
secret.write_text("ROOTSECRET\n", encoding="utf-8")
|
||||
(active / "origin").symlink_to(secret)
|
||||
|
||||
apps = a.find_user_flatpaks(str(home), user="alice")
|
||||
|
||||
assert len(apps) == 1
|
||||
assert apps[0].name == "org.evil.App"
|
||||
assert apps[0].remote is None
|
||||
|
||||
|
||||
def test_user_flatpak_repo_config_symlink_is_not_read(tmp_path: Path):
|
||||
import enroll.accounts as a
|
||||
|
||||
home = tmp_path / "home"
|
||||
flatpak_root = home / ".local" / "share" / "flatpak"
|
||||
(flatpak_root / "repo").mkdir(parents=True)
|
||||
secret = tmp_path / "root-only-secret"
|
||||
secret.write_text(
|
||||
'[remote "ROOTSECRET"]\nurl=https://evil.example/\n', encoding="utf-8"
|
||||
)
|
||||
(flatpak_root / "repo" / "config").symlink_to(secret)
|
||||
|
||||
assert a.find_user_flatpak_remotes(str(home), user="alice") == []
|
||||
|
||||
|
||||
def test_user_flatpak_tree_with_symlinked_parent_is_not_walked(tmp_path: Path):
|
||||
import enroll.accounts as a
|
||||
|
||||
home = tmp_path / "home"
|
||||
real = tmp_path / "real-flatpak"
|
||||
active = (
|
||||
real
|
||||
/ "share"
|
||||
/ "flatpak"
|
||||
/ "app"
|
||||
/ "org.evil.App"
|
||||
/ "x86_64"
|
||||
/ "stable"
|
||||
/ "active"
|
||||
)
|
||||
active.mkdir(parents=True)
|
||||
(active / "origin").write_text("evil\n", encoding="utf-8")
|
||||
home.mkdir()
|
||||
(home / ".local").symlink_to(real, target_is_directory=True)
|
||||
|
||||
assert a.find_user_flatpaks(str(home), user="alice") == []
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path):
|
|||
called["fqdn"] = kwargs.get("fqdn")
|
||||
called["jinjaturtle"] = kwargs.get("jinjaturtle")
|
||||
called["no_common_roles"] = kwargs.get("no_common_roles")
|
||||
called["target"] = kwargs.get("target")
|
||||
|
||||
monkeypatch.setattr(cli, "manifest", fake_manifest)
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -70,7 +69,6 @@ def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path):
|
|||
assert called["fqdn"] is None
|
||||
assert called["jinjaturtle"] == "auto"
|
||||
assert called["no_common_roles"] is False
|
||||
assert called["target"] == "ansible"
|
||||
|
||||
|
||||
def test_cli_force_unsafe_path_before_subcommand_reaches_guard(monkeypatch, tmp_path):
|
||||
|
|
@ -131,36 +129,6 @@ def test_cli_force_unsafe_path_after_subcommand_reaches_guard(monkeypatch, tmp_p
|
|||
assert seen["force"] is True
|
||||
|
||||
|
||||
def test_cli_manifest_target_puppet_is_forwarded(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
|
||||
def fake_manifest(harvest_dir: str, out_dir: str, **kwargs):
|
||||
called["harvest"] = harvest_dir
|
||||
called["out"] = out_dir
|
||||
called["target"] = kwargs.get("target")
|
||||
|
||||
monkeypatch.setattr(cli, "manifest", fake_manifest)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"enroll",
|
||||
"manifest",
|
||||
"--harvest",
|
||||
str(tmp_path / "bundle"),
|
||||
"--out",
|
||||
str(tmp_path / "puppet"),
|
||||
"--target",
|
||||
"puppet",
|
||||
],
|
||||
)
|
||||
|
||||
cli.main()
|
||||
assert called["harvest"] == str(tmp_path / "bundle")
|
||||
assert called["out"] == str(tmp_path / "puppet")
|
||||
assert called["target"] == "puppet"
|
||||
|
||||
|
||||
def test_cli_manifest_no_common_roles_is_forwarded(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
|
||||
from enroll.ansible import _role_tag
|
||||
from enroll.manifest_safety import freeze_directory_bundle
|
||||
from enroll.diff import (
|
||||
_Spinner,
|
||||
_enforcement_plan,
|
||||
has_enforceable_drift,
|
||||
_role_tag,
|
||||
_utc_now_iso,
|
||||
_report_markdown,
|
||||
)
|
||||
|
|
@ -36,6 +35,8 @@ def test_bundle_from_directory_and_statejson_path(tmp_path: Path):
|
|||
|
||||
b = _make_bundle_dir(tmp_path)
|
||||
|
||||
# Plain resolution (no freeze) returns the directory in place. Freezing is
|
||||
# opt-in and exercised by the consumer-level tests below.
|
||||
br1 = d._bundle_from_input(str(b), sops_mode=False)
|
||||
assert br1.dir == b
|
||||
assert br1.state_path.exists()
|
||||
|
|
@ -44,6 +45,31 @@ def test_bundle_from_directory_and_statejson_path(tmp_path: Path):
|
|||
assert br2.dir == b
|
||||
|
||||
|
||||
def test_bundle_from_input_directory_freeze_copies_and_protects(tmp_path: Path):
|
||||
import enroll.diff as d
|
||||
|
||||
b = _make_bundle_dir(tmp_path)
|
||||
art = b / "artifacts" / "app"
|
||||
art.mkdir(parents=True)
|
||||
(art / "f.conf").write_text("orig\n", encoding="utf-8")
|
||||
|
||||
# With freeze=True the bundle is copied into a private temp dir, and mutating
|
||||
# the source afterwards does not change the frozen copy.
|
||||
result = d._bundle_from_input(str(b), sops_mode=False, freeze=True)
|
||||
try:
|
||||
assert result.dir != b
|
||||
assert result.tempdir is not None
|
||||
frozen_f = result.dir / "artifacts" / "app" / "f.conf"
|
||||
assert frozen_f.read_text(encoding="utf-8") == "orig\n"
|
||||
|
||||
# Attacker rewrites the source after the freeze; frozen copy is unaffected.
|
||||
(art / "f.conf").write_text("tampered\n", encoding="utf-8")
|
||||
assert frozen_f.read_text(encoding="utf-8") == "orig\n"
|
||||
finally:
|
||||
if result.tempdir:
|
||||
result.tempdir.cleanup()
|
||||
|
||||
|
||||
def test_bundle_from_tarball_extracts(tmp_path: Path):
|
||||
import enroll.diff as d
|
||||
|
||||
|
|
@ -744,9 +770,68 @@ def test_compare_harvests_rejects_unsafe_artifact_symlink(tmp_path: Path):
|
|||
with pytest.raises(RuntimeError) as exc_info:
|
||||
compare_harvests(str(old_bundle), str(new_bundle))
|
||||
|
||||
# The unsafe symlinked artifact is now rejected when the directory bundle is
|
||||
# frozen into a private copy (no-follow traversal), which happens before
|
||||
# validation. The diff is still refused; the rejection just fires earlier.
|
||||
msg = str(exc_info.value)
|
||||
assert "old harvest failed validation" in msg
|
||||
assert "artifact is a symlink" in msg
|
||||
assert "symlink" in msg.lower()
|
||||
|
||||
|
||||
def test_compare_harvests_freezes_directory_bundles_against_source_mutation(
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""End-to-end: a directory diff must read frozen copies, so mutating a source
|
||||
bundle's artifact after compare_harvests has frozen it cannot change the
|
||||
result or redirect a read to a secret."""
|
||||
|
||||
def _bundle(name: str, content: str) -> Path:
|
||||
b = tmp_path / name
|
||||
art = b / "artifacts" / "users"
|
||||
art.mkdir(parents=True)
|
||||
(art / "passwd").write_text(content, encoding="utf-8")
|
||||
(b / "state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"inventory": {"packages": {}},
|
||||
"roles": {
|
||||
"users": {
|
||||
"managed_files": [
|
||||
{"path": "/etc/passwd", "src_rel": "passwd"}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return b
|
||||
|
||||
old_bundle = _bundle("old", "same\n")
|
||||
new_bundle = _bundle("new", "same\n")
|
||||
|
||||
# Identical content -> no file content drift.
|
||||
report, _ = compare_harvests(str(old_bundle), str(new_bundle))
|
||||
changed_paths = [f["path"] for f in report["files"]["changed"]]
|
||||
assert "/etc/passwd" not in changed_paths
|
||||
|
||||
# The freeze already happened inside compare_harvests above and was torn down,
|
||||
# so to prove immunity during a single call we instead confirm that the frozen
|
||||
# copy is what gets hashed: rewrite the source between freeze and hash is not
|
||||
# observable to the operator because the consumed tree is a private copy.
|
||||
# Confirm at the unit level that a post-freeze swap to a secret symlink is not
|
||||
# followed by the hashing path.
|
||||
secret = tmp_path / "secret"
|
||||
secret.write_text("TOP-SECRET\n", encoding="utf-8")
|
||||
frozen_dir, td = freeze_directory_bundle(old_bundle)
|
||||
try:
|
||||
src = old_bundle / "artifacts" / "users" / "passwd"
|
||||
src.unlink()
|
||||
src.symlink_to(secret)
|
||||
frozen = Path(frozen_dir) / "artifacts" / "users" / "passwd"
|
||||
assert not frozen.is_symlink()
|
||||
assert frozen.read_text(encoding="utf-8") == "same\n"
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
|
||||
def test_utc_now_iso():
|
||||
|
|
@ -798,150 +883,6 @@ def test_role_tag_empty():
|
|||
assert _role_tag(" ") == "role_other"
|
||||
|
||||
|
||||
def test_has_enforceable_drift_packages_removed():
|
||||
report = {"packages": {"removed": ["vim"]}}
|
||||
assert has_enforceable_drift(report) is True
|
||||
|
||||
|
||||
def test_has_enforceable_drift_services_removed():
|
||||
report = {"services": {"enabled_removed": ["nginx.service"]}}
|
||||
assert has_enforceable_drift(report) is True
|
||||
|
||||
|
||||
def test_has_enforceable_drift_service_changed():
|
||||
report = {
|
||||
"services": {
|
||||
"changed": [
|
||||
{
|
||||
"unit": "nginx.service",
|
||||
"changes": {"active_state": {"old": "active", "new": "inactive"}},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
assert has_enforceable_drift(report) is True
|
||||
|
||||
|
||||
def test_has_enforceable_drift_service_package_only_changed():
|
||||
# Service changed only in packages - should NOT be enforceable
|
||||
report = {
|
||||
"services": {
|
||||
"changed": [
|
||||
{
|
||||
"unit": "nginx.service",
|
||||
"changes": {"packages": {"added": ["nginx-extra"]}},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
assert has_enforceable_drift(report) is False
|
||||
|
||||
|
||||
def test_has_enforceable_drift_users_removed():
|
||||
report = {"users": {"removed": ["alice"]}}
|
||||
assert has_enforceable_drift(report) is True
|
||||
|
||||
|
||||
def test_has_enforceable_drift_users_changed():
|
||||
report = {
|
||||
"users": {
|
||||
"changed": [
|
||||
{"name": "alice", "changes": {"uid": {"old": 1000, "new": 1001}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
assert has_enforceable_drift(report) is True
|
||||
|
||||
|
||||
def test_has_enforceable_drift_files_removed():
|
||||
report = {
|
||||
"files": {
|
||||
"removed": [{"path": "/etc/passwd", "role": "users", "reason": "conffile"}]
|
||||
}
|
||||
}
|
||||
assert has_enforceable_drift(report) is True
|
||||
|
||||
|
||||
def test_has_enforceable_drift_files_changed():
|
||||
report = {
|
||||
"files": {
|
||||
"changed": [
|
||||
{
|
||||
"path": "/etc/passwd",
|
||||
"changes": {"content": {"old": "sha1", "new": "sha2"}},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
assert has_enforceable_drift(report) is True
|
||||
|
||||
|
||||
def test_has_enforceable_drift_no_drift():
|
||||
report = {
|
||||
"packages": {"added": ["newpkg"]},
|
||||
"services": {"enabled_added": ["new.service"]},
|
||||
"users": {"added": ["bob"]},
|
||||
"files": {"added": ["/opt/newfile"]},
|
||||
}
|
||||
assert has_enforceable_drift(report) is False
|
||||
|
||||
|
||||
def test_enforcement_plan_packages_removed(monkeypatch, tmp_path: Path):
|
||||
old_state = {
|
||||
"roles": {
|
||||
"services": [{"role_name": "nginx", "packages": ["nginx"]}],
|
||||
"packages": [{"role_name": "vim", "package": "vim"}],
|
||||
}
|
||||
}
|
||||
report = {"packages": {"removed": ["nginx", "vim"]}}
|
||||
|
||||
result = _enforcement_plan(report, old_state, tmp_path)
|
||||
assert "nginx" in result.get("roles", [])
|
||||
assert "vim" in result.get("roles", [])
|
||||
assert "role_nginx" in result.get("tags", [])
|
||||
|
||||
|
||||
def test_enforcement_plan_users_changed():
|
||||
old_state = {
|
||||
"roles": {"users": {"role_name": "users", "users": [{"name": "alice"}]}}
|
||||
}
|
||||
report = {"users": {"changed": [{"name": "alice", "changes": {"uid": {}}}]}}
|
||||
|
||||
result = _enforcement_plan(report, old_state, Path("/tmp"))
|
||||
assert "users" in result.get("roles", [])
|
||||
|
||||
|
||||
def test_enforcement_plan_files_removed(tmp_path: Path):
|
||||
# Create the artifacts directory structure that _file_index expects
|
||||
artifacts_dir = tmp_path / "artifacts" / "etc_custom"
|
||||
artifacts_dir.mkdir(parents=True)
|
||||
|
||||
old_state = {
|
||||
"roles": {
|
||||
"etc_custom": {
|
||||
"role_name": "etc_custom",
|
||||
"managed_files": [
|
||||
{"path": "/etc/custom.conf", "src_rel": "custom.conf"}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
report = {
|
||||
"files": {"removed": [{"path": "/etc/custom.conf", "role": "etc_custom"}]}
|
||||
}
|
||||
|
||||
result = _enforcement_plan(report, old_state, tmp_path)
|
||||
assert "etc_custom" in result.get("roles", [])
|
||||
|
||||
|
||||
def test_enforcement_plan_no_drift():
|
||||
old_state = {"roles": {}}
|
||||
report = {"packages": {"added": ["newpkg"]}}
|
||||
|
||||
result = _enforcement_plan(report, old_state, Path("/tmp"))
|
||||
assert result.get("roles", []) == []
|
||||
|
||||
|
||||
def test_bundle_from_input_tgz(monkeypatch, tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
|
|
@ -989,64 +930,6 @@ def test_report_markdown_basic():
|
|||
assert "+ vim" in result
|
||||
|
||||
|
||||
def test_report_markdown_with_enforcement_applied():
|
||||
report = {
|
||||
"generated_at": "2024-01-01T00:00:00Z",
|
||||
"old": {"input": "old.tar.gz"},
|
||||
"new": {"input": "new.tar.gz"},
|
||||
"packages": {"added": [], "removed": [], "version_changed": []},
|
||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
||||
"users": {"added": [], "removed": [], "changed": []},
|
||||
"files": {"added": [], "removed": [], "changed": []},
|
||||
"enforcement": {
|
||||
"status": "applied",
|
||||
"tags": ["role_users"],
|
||||
"returncode": 0,
|
||||
"finished_at": "2024-01-01T00:01:00Z",
|
||||
},
|
||||
}
|
||||
result = _report_markdown(report)
|
||||
assert "Applied old harvest" in result
|
||||
assert "role_users" in result
|
||||
|
||||
|
||||
def test_report_markdown_with_enforcement_failed():
|
||||
report = {
|
||||
"generated_at": "2024-01-01T00:00:00Z",
|
||||
"old": {"input": "old.tar.gz"},
|
||||
"new": {"input": "new.tar.gz"},
|
||||
"packages": {"added": [], "removed": [], "version_changed": []},
|
||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
||||
"users": {"added": [], "removed": [], "changed": []},
|
||||
"files": {"added": [], "removed": [], "changed": []},
|
||||
"enforcement": {
|
||||
"status": "failed",
|
||||
"returncode": 1,
|
||||
},
|
||||
}
|
||||
result = _report_markdown(report)
|
||||
assert "ansible-playbook failed" in result
|
||||
|
||||
|
||||
def test_report_markdown_with_enforcement_skipped():
|
||||
report = {
|
||||
"generated_at": "2024-01-01T00:00:00Z",
|
||||
"old": {"input": "old.tar.gz"},
|
||||
"new": {"input": "new.tar.gz"},
|
||||
"packages": {"added": [], "removed": [], "version_changed": []},
|
||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
||||
"users": {"added": [], "removed": [], "changed": []},
|
||||
"files": {"added": [], "removed": [], "changed": []},
|
||||
"enforcement": {
|
||||
"status": "skipped",
|
||||
"reason": "no drift",
|
||||
},
|
||||
}
|
||||
result = _report_markdown(report)
|
||||
assert "Skipped" in result
|
||||
assert "no drift" in result
|
||||
|
||||
|
||||
def test_report_markdown_with_version_ignored():
|
||||
report = {
|
||||
"generated_at": "2024-01-01T00:00:00Z",
|
||||
|
|
@ -1355,67 +1238,3 @@ def test_report_text_with_ignore_package_versions():
|
|||
result = d._report_text(report)
|
||||
assert "package version drift: ignored" in result
|
||||
assert "ignored 5 changes" in result
|
||||
|
||||
|
||||
def test_report_text_with_enforcement_applied():
|
||||
"""Test _report_text includes enforcement applied status."""
|
||||
import enroll.diff as d
|
||||
|
||||
report = {
|
||||
"generated_at": "2024-01-01T00:00:00Z",
|
||||
"old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
|
||||
"new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
|
||||
"packages": {"added": [], "removed": [], "version_changed": []},
|
||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
||||
"users": {"added": [], "removed": [], "changed": []},
|
||||
"files": {"added": [], "removed": [], "changed": []},
|
||||
"enforcement": {
|
||||
"status": "applied",
|
||||
"returncode": 0,
|
||||
"tags": ["test"],
|
||||
"finished_at": "2024-01-01T01:00:00Z",
|
||||
},
|
||||
}
|
||||
result = d._report_text(report)
|
||||
assert "Enforcement" in result
|
||||
assert "applied old harvest via ansible-playbook" in result
|
||||
assert "tags=test" in result
|
||||
|
||||
|
||||
def test_report_text_with_enforcement_failed():
|
||||
"""Test _report_text includes enforcement failed status."""
|
||||
import enroll.diff as d
|
||||
|
||||
report = {
|
||||
"generated_at": "2024-01-01T00:00:00Z",
|
||||
"old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
|
||||
"new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
|
||||
"packages": {"added": [], "removed": [], "version_changed": []},
|
||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
||||
"users": {"added": [], "removed": [], "changed": []},
|
||||
"files": {"added": [], "removed": [], "changed": []},
|
||||
"enforcement": {"status": "failed", "returncode": 1},
|
||||
}
|
||||
result = d._report_text(report)
|
||||
assert "Enforcement" in result
|
||||
assert "ansible-playbook failed" in result
|
||||
|
||||
|
||||
def test_report_text_with_enforcement_skipped():
|
||||
"""Test _report_text includes enforcement skipped status."""
|
||||
import enroll.diff as d
|
||||
|
||||
report = {
|
||||
"generated_at": "2024-01-01T00:00:00Z",
|
||||
"old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
|
||||
"new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
|
||||
"packages": {"added": [], "removed": [], "version_changed": []},
|
||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
||||
"users": {"added": [], "removed": [], "changed": []},
|
||||
"files": {"added": [], "removed": [], "changed": []},
|
||||
"enforcement": {"status": "skipped", "reason": "no changes"},
|
||||
}
|
||||
result = d._report_text(report)
|
||||
assert "Enforcement" in result
|
||||
assert "skipped" in result
|
||||
assert "no changes" in result
|
||||
|
|
|
|||
214
tests/test_diff_ignore_versions_exclude.py
Normal file
214
tests/test_diff_ignore_versions_exclude.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_bundle(
|
||||
root: Path, state: dict, artifacts: dict[str, bytes] | None = None
|
||||
) -> None:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||
artifacts = artifacts or {}
|
||||
for rel, data in artifacts.items():
|
||||
p = root / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
|
||||
|
||||
def _minimal_roles() -> dict:
|
||||
"""A small roles structure that's sufficient for enroll.diff file indexing."""
|
||||
return {
|
||||
"users": {
|
||||
"role_name": "users",
|
||||
"users": [],
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"services": [],
|
||||
"packages": [],
|
||||
"apt_config": {
|
||||
"role_name": "apt_config",
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"etc_custom": {
|
||||
"role_name": "etc_custom",
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"usr_local_custom": {
|
||||
"role_name": "usr_local_custom",
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"extra_paths": {
|
||||
"role_name": "extra_paths",
|
||||
"include_patterns": [],
|
||||
"exclude_patterns": [],
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_diff_ignore_package_versions_suppresses_version_drift(tmp_path: Path):
|
||||
from enroll.diff import compare_harvests
|
||||
|
||||
old = tmp_path / "old"
|
||||
new = tmp_path / "new"
|
||||
|
||||
old_state = {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "h1"},
|
||||
"inventory": {
|
||||
"packages": {
|
||||
"curl": {
|
||||
"version": "1.0",
|
||||
"installations": [{"version": "1.0", "arch": "amd64"}],
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": _minimal_roles(),
|
||||
}
|
||||
new_state = {
|
||||
**old_state,
|
||||
"inventory": {
|
||||
"packages": {
|
||||
"curl": {
|
||||
"version": "1.1",
|
||||
"installations": [{"version": "1.1", "arch": "amd64"}],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_write_bundle(old, old_state)
|
||||
_write_bundle(new, new_state)
|
||||
|
||||
# Without ignore flag, version drift is reported and counts as changes.
|
||||
report, has_changes = compare_harvests(str(old), str(new))
|
||||
assert has_changes is True
|
||||
assert report["packages"]["version_changed"]
|
||||
|
||||
# With ignore flag, version drift is suppressed and does not count as changes.
|
||||
report2, has_changes2 = compare_harvests(
|
||||
str(old), str(new), ignore_package_versions=True
|
||||
)
|
||||
assert has_changes2 is False
|
||||
assert report2["packages"]["version_changed"] == []
|
||||
assert report2["packages"]["version_changed_ignored_count"] == 1
|
||||
assert report2["filters"]["ignore_package_versions"] is True
|
||||
|
||||
|
||||
def test_diff_exclude_path_filters_file_drift_and_affects_has_changes(tmp_path: Path):
|
||||
from enroll.diff import compare_harvests
|
||||
|
||||
old = tmp_path / "old"
|
||||
new = tmp_path / "new"
|
||||
|
||||
# Only file drift is under /var/anacron, which is excluded.
|
||||
old_state = {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "h1"},
|
||||
"inventory": {"packages": {}},
|
||||
"roles": {
|
||||
**_minimal_roles(),
|
||||
"extra_paths": {
|
||||
**_minimal_roles()["extra_paths"],
|
||||
"managed_files": [
|
||||
{
|
||||
"path": "/var/anacron/daily.stamp",
|
||||
"src_rel": "var/anacron/daily.stamp",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0644",
|
||||
"reason": "extra_path",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
new_state = json.loads(json.dumps(old_state))
|
||||
|
||||
_write_bundle(
|
||||
old,
|
||||
old_state,
|
||||
{"artifacts/extra_paths/var/anacron/daily.stamp": b"yesterday\n"},
|
||||
)
|
||||
_write_bundle(
|
||||
new,
|
||||
new_state,
|
||||
{"artifacts/extra_paths/var/anacron/daily.stamp": b"today\n"},
|
||||
)
|
||||
|
||||
report, has_changes = compare_harvests(
|
||||
str(old), str(new), exclude_paths=["/var/anacron"]
|
||||
)
|
||||
assert has_changes is False
|
||||
assert report["files"]["changed"] == []
|
||||
assert report["filters"]["exclude_paths"] == ["/var/anacron"]
|
||||
|
||||
|
||||
def test_diff_exclude_path_only_filters_files_not_packages(tmp_path: Path):
|
||||
from enroll.diff import compare_harvests
|
||||
|
||||
old = tmp_path / "old"
|
||||
new = tmp_path / "new"
|
||||
|
||||
old_state = {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "h1"},
|
||||
"inventory": {"packages": {"curl": {"version": "1.0"}}},
|
||||
"roles": {
|
||||
**_minimal_roles(),
|
||||
"extra_paths": {
|
||||
**_minimal_roles()["extra_paths"],
|
||||
"managed_files": [
|
||||
{
|
||||
"path": "/var/anacron/daily.stamp",
|
||||
"src_rel": "var/anacron/daily.stamp",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0644",
|
||||
"reason": "extra_path",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
new_state = {
|
||||
**old_state,
|
||||
"inventory": {
|
||||
"packages": {
|
||||
"curl": {"version": "1.0"},
|
||||
"htop": {"version": "3.0"},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_write_bundle(
|
||||
old,
|
||||
old_state,
|
||||
{"artifacts/extra_paths/var/anacron/daily.stamp": b"yesterday\n"},
|
||||
)
|
||||
_write_bundle(
|
||||
new,
|
||||
new_state,
|
||||
{
|
||||
"artifacts/extra_paths/var/anacron/daily.stamp": b"today\n",
|
||||
},
|
||||
)
|
||||
|
||||
report, has_changes = compare_harvests(
|
||||
str(old), str(new), exclude_paths=["/var/anacron"]
|
||||
)
|
||||
assert has_changes is True
|
||||
# File drift is filtered, but package drift remains.
|
||||
assert report["files"]["changed"] == []
|
||||
assert report["packages"]["added"] == ["htop"]
|
||||
|
|
@ -1,560 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _write_bundle(
|
||||
root: Path, state: dict, artifacts: dict[str, bytes] | None = None
|
||||
) -> None:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||
artifacts = artifacts or {}
|
||||
for rel, data in artifacts.items():
|
||||
p = root / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
|
||||
|
||||
def _minimal_roles() -> dict:
|
||||
"""A small roles structure that's sufficient for enroll.diff file indexing."""
|
||||
return {
|
||||
"users": {
|
||||
"role_name": "users",
|
||||
"users": [],
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"services": [],
|
||||
"packages": [],
|
||||
"apt_config": {
|
||||
"role_name": "apt_config",
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"etc_custom": {
|
||||
"role_name": "etc_custom",
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"usr_local_custom": {
|
||||
"role_name": "usr_local_custom",
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"extra_paths": {
|
||||
"role_name": "extra_paths",
|
||||
"include_patterns": [],
|
||||
"exclude_patterns": [],
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_diff_ignore_package_versions_suppresses_version_drift(tmp_path: Path):
|
||||
from enroll.diff import compare_harvests
|
||||
|
||||
old = tmp_path / "old"
|
||||
new = tmp_path / "new"
|
||||
|
||||
old_state = {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "h1"},
|
||||
"inventory": {
|
||||
"packages": {
|
||||
"curl": {
|
||||
"version": "1.0",
|
||||
"installations": [{"version": "1.0", "arch": "amd64"}],
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": _minimal_roles(),
|
||||
}
|
||||
new_state = {
|
||||
**old_state,
|
||||
"inventory": {
|
||||
"packages": {
|
||||
"curl": {
|
||||
"version": "1.1",
|
||||
"installations": [{"version": "1.1", "arch": "amd64"}],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_write_bundle(old, old_state)
|
||||
_write_bundle(new, new_state)
|
||||
|
||||
# Without ignore flag, version drift is reported and counts as changes.
|
||||
report, has_changes = compare_harvests(str(old), str(new))
|
||||
assert has_changes is True
|
||||
assert report["packages"]["version_changed"]
|
||||
|
||||
# With ignore flag, version drift is suppressed and does not count as changes.
|
||||
report2, has_changes2 = compare_harvests(
|
||||
str(old), str(new), ignore_package_versions=True
|
||||
)
|
||||
assert has_changes2 is False
|
||||
assert report2["packages"]["version_changed"] == []
|
||||
assert report2["packages"]["version_changed_ignored_count"] == 1
|
||||
assert report2["filters"]["ignore_package_versions"] is True
|
||||
|
||||
|
||||
def test_diff_exclude_path_filters_file_drift_and_affects_has_changes(tmp_path: Path):
|
||||
from enroll.diff import compare_harvests
|
||||
|
||||
old = tmp_path / "old"
|
||||
new = tmp_path / "new"
|
||||
|
||||
# Only file drift is under /var/anacron, which is excluded.
|
||||
old_state = {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "h1"},
|
||||
"inventory": {"packages": {}},
|
||||
"roles": {
|
||||
**_minimal_roles(),
|
||||
"extra_paths": {
|
||||
**_minimal_roles()["extra_paths"],
|
||||
"managed_files": [
|
||||
{
|
||||
"path": "/var/anacron/daily.stamp",
|
||||
"src_rel": "var/anacron/daily.stamp",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0644",
|
||||
"reason": "extra_path",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
new_state = json.loads(json.dumps(old_state))
|
||||
|
||||
_write_bundle(
|
||||
old,
|
||||
old_state,
|
||||
{"artifacts/extra_paths/var/anacron/daily.stamp": b"yesterday\n"},
|
||||
)
|
||||
_write_bundle(
|
||||
new,
|
||||
new_state,
|
||||
{"artifacts/extra_paths/var/anacron/daily.stamp": b"today\n"},
|
||||
)
|
||||
|
||||
report, has_changes = compare_harvests(
|
||||
str(old), str(new), exclude_paths=["/var/anacron"]
|
||||
)
|
||||
assert has_changes is False
|
||||
assert report["files"]["changed"] == []
|
||||
assert report["filters"]["exclude_paths"] == ["/var/anacron"]
|
||||
|
||||
|
||||
def test_diff_exclude_path_only_filters_files_not_packages(tmp_path: Path):
|
||||
from enroll.diff import compare_harvests
|
||||
|
||||
old = tmp_path / "old"
|
||||
new = tmp_path / "new"
|
||||
|
||||
old_state = {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "h1"},
|
||||
"inventory": {"packages": {"curl": {"version": "1.0"}}},
|
||||
"roles": {
|
||||
**_minimal_roles(),
|
||||
"extra_paths": {
|
||||
**_minimal_roles()["extra_paths"],
|
||||
"managed_files": [
|
||||
{
|
||||
"path": "/var/anacron/daily.stamp",
|
||||
"src_rel": "var/anacron/daily.stamp",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0644",
|
||||
"reason": "extra_path",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
new_state = {
|
||||
**old_state,
|
||||
"inventory": {
|
||||
"packages": {
|
||||
"curl": {"version": "1.0"},
|
||||
"htop": {"version": "3.0"},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_write_bundle(
|
||||
old,
|
||||
old_state,
|
||||
{"artifacts/extra_paths/var/anacron/daily.stamp": b"yesterday\n"},
|
||||
)
|
||||
_write_bundle(
|
||||
new,
|
||||
new_state,
|
||||
{
|
||||
"artifacts/extra_paths/var/anacron/daily.stamp": b"today\n",
|
||||
},
|
||||
)
|
||||
|
||||
report, has_changes = compare_harvests(
|
||||
str(old), str(new), exclude_paths=["/var/anacron"]
|
||||
)
|
||||
assert has_changes is True
|
||||
# File drift is filtered, but package drift remains.
|
||||
assert report["files"]["changed"] == []
|
||||
assert report["packages"]["added"] == ["htop"]
|
||||
|
||||
|
||||
def test_enforce_old_harvest_requires_ansible_playbook(monkeypatch, tmp_path: Path):
|
||||
import enroll.diff as d
|
||||
|
||||
monkeypatch.setattr(d.shutil, "which", lambda name: None)
|
||||
|
||||
old = tmp_path / "old"
|
||||
_write_bundle(old, {"inventory": {"packages": {}}, "roles": _minimal_roles()})
|
||||
|
||||
with pytest.raises(RuntimeError, match="ansible-playbook not found"):
|
||||
d.enforce_old_harvest(str(old))
|
||||
|
||||
|
||||
def test_enforce_old_harvest_runs_ansible_with_tags_from_file_drift(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
import enroll.diff as d
|
||||
import enroll.manifest as mf
|
||||
|
||||
# Pretend ansible-playbook is installed.
|
||||
monkeypatch.setattr(d.shutil, "which", lambda name: "/usr/bin/ansible-playbook")
|
||||
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
# Stub manifest generation to only create playbook.yml (fast, no real roles needed).
|
||||
def fake_manifest(_harvest_dir: str, out_dir: str, **_kwargs):
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
(out / "playbook.yml").write_text(
|
||||
"---\n- hosts: all\n gather_facts: false\n roles: []\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mf, "manifest", fake_manifest)
|
||||
|
||||
def fake_run(
|
||||
argv, cwd=None, env=None, capture_output=False, text=False, check=False
|
||||
):
|
||||
calls["argv"] = list(argv)
|
||||
calls["cwd"] = cwd
|
||||
return types.SimpleNamespace(returncode=0, stdout="ok", stderr="")
|
||||
|
||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
||||
|
||||
old = tmp_path / "old"
|
||||
old_state = {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "h1"},
|
||||
"inventory": {"packages": {}},
|
||||
"roles": {
|
||||
**_minimal_roles(),
|
||||
"usr_local_custom": {
|
||||
**_minimal_roles()["usr_local_custom"],
|
||||
"managed_files": [
|
||||
{
|
||||
"path": "/etc/myapp.conf",
|
||||
"src_rel": "etc/myapp.conf",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0644",
|
||||
"reason": "custom",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
_write_bundle(old, old_state)
|
||||
|
||||
# Minimal report containing enforceable drift: a baseline file is "removed".
|
||||
report = {
|
||||
"packages": {"added": [], "removed": [], "version_changed": []},
|
||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
||||
"users": {"added": [], "removed": [], "changed": []},
|
||||
"files": {
|
||||
"added": [],
|
||||
"removed": [{"path": "/etc/myapp.conf", "role": "usr_local_custom"}],
|
||||
"changed": [],
|
||||
},
|
||||
}
|
||||
|
||||
info = d.enforce_old_harvest(str(old), report=report)
|
||||
assert info["status"] == "applied"
|
||||
assert "--tags" in info["command"]
|
||||
assert "role_usr_local_custom" in ",".join(info.get("tags") or [])
|
||||
|
||||
argv = calls.get("argv")
|
||||
assert argv and argv[0].endswith("ansible-playbook")
|
||||
assert "--tags" in argv
|
||||
# Ensure we pass the computed tag.
|
||||
i = argv.index("--tags")
|
||||
assert "role_usr_local_custom" in str(argv[i + 1])
|
||||
|
||||
|
||||
def test_enforce_old_harvest_runs_puppet_target(monkeypatch, tmp_path: Path):
|
||||
import enroll.diff as d
|
||||
import enroll.manifest as mf
|
||||
|
||||
monkeypatch.setattr(
|
||||
d.shutil,
|
||||
"which",
|
||||
lambda name: "/usr/bin/puppet" if name == "puppet" else None,
|
||||
)
|
||||
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_manifest(_harvest_dir: str, out_dir: str, **kwargs):
|
||||
calls["manifest_target"] = kwargs.get("target")
|
||||
out = Path(out_dir)
|
||||
(out / "manifests").mkdir(parents=True)
|
||||
(out / "modules").mkdir(parents=True)
|
||||
(out / "manifests" / "site.pp").write_text(
|
||||
"node default { }\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mf, "manifest", fake_manifest)
|
||||
|
||||
def fake_run(
|
||||
argv, cwd=None, env=None, capture_output=False, text=False, check=False
|
||||
):
|
||||
calls["argv"] = list(argv)
|
||||
calls["cwd"] = cwd
|
||||
return types.SimpleNamespace(returncode=0, stdout="ok", stderr="")
|
||||
|
||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
||||
|
||||
old = tmp_path / "old"
|
||||
_write_bundle(old, {"inventory": {"packages": {}}, "roles": _minimal_roles()})
|
||||
|
||||
report = {
|
||||
"packages": {"added": [], "removed": ["curl"], "version_changed": []},
|
||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
||||
"users": {"added": [], "removed": [], "changed": []},
|
||||
"files": {"added": [], "removed": [], "changed": []},
|
||||
}
|
||||
|
||||
info = d.enforce_old_harvest(str(old), report=report, target="puppet")
|
||||
|
||||
assert info["status"] == "applied"
|
||||
assert info["target"] == "puppet"
|
||||
assert info["tool"] == "puppet apply"
|
||||
assert info["scope"] == "full_manifest"
|
||||
assert info["tags"] == []
|
||||
assert calls["manifest_target"] == "puppet"
|
||||
|
||||
argv = calls.get("argv")
|
||||
assert argv and argv[:2] == ["/usr/bin/puppet", "apply"]
|
||||
assert "--modulepath" in argv
|
||||
assert any(
|
||||
str(Path(calls["cwd"]) / "manifest" / "manifests" / "site.pp") == str(a)
|
||||
for a in argv
|
||||
)
|
||||
|
||||
|
||||
def test_enforce_old_harvest_runs_salt_target(monkeypatch, tmp_path: Path):
|
||||
import enroll.diff as d
|
||||
import enroll.manifest as mf
|
||||
|
||||
monkeypatch.setattr(
|
||||
d.shutil,
|
||||
"which",
|
||||
lambda name: "/usr/bin/salt-call" if name == "salt-call" else None,
|
||||
)
|
||||
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_manifest(_harvest_dir: str, out_dir: str, **kwargs):
|
||||
calls["manifest_target"] = kwargs.get("target")
|
||||
out = Path(out_dir)
|
||||
(out / "states").mkdir(parents=True)
|
||||
(out / "states" / "top.sls").write_text("base:\n '*': []\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(mf, "manifest", fake_manifest)
|
||||
|
||||
def fake_run(
|
||||
argv, cwd=None, env=None, capture_output=False, text=False, check=False
|
||||
):
|
||||
calls["argv"] = list(argv)
|
||||
calls["cwd"] = cwd
|
||||
return types.SimpleNamespace(returncode=0, stdout="ok", stderr="")
|
||||
|
||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
||||
|
||||
old = tmp_path / "old"
|
||||
_write_bundle(old, {"inventory": {"packages": {}}, "roles": _minimal_roles()})
|
||||
|
||||
report = {
|
||||
"packages": {"added": [], "removed": ["curl"], "version_changed": []},
|
||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
||||
"users": {"added": [], "removed": [], "changed": []},
|
||||
"files": {"added": [], "removed": [], "changed": []},
|
||||
}
|
||||
|
||||
info = d.enforce_old_harvest(str(old), report=report, target="salt")
|
||||
|
||||
assert info["status"] == "applied"
|
||||
assert info["target"] == "salt"
|
||||
assert info["tool"] == "salt-call"
|
||||
assert info["scope"] == "full_manifest"
|
||||
assert calls["manifest_target"] == "salt"
|
||||
|
||||
argv = calls.get("argv")
|
||||
assert argv and argv[0] == "/usr/bin/salt-call"
|
||||
assert "--local" in argv
|
||||
assert "--file-root" in argv
|
||||
assert "state.apply" in argv
|
||||
assert str(Path(calls["cwd"]) / "manifest" / "states") in argv
|
||||
|
||||
|
||||
def test_cli_diff_enforce_forwards_target(monkeypatch):
|
||||
import enroll.cli as cli
|
||||
|
||||
report = {
|
||||
"packages": {"added": [], "removed": ["curl"], "version_changed": []},
|
||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
||||
"users": {"added": [], "removed": [], "changed": []},
|
||||
"files": {"added": [], "removed": [], "changed": []},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cli, "compare_harvests", lambda *a, **k: (report, True))
|
||||
monkeypatch.setattr(cli, "has_enforceable_drift", lambda r: True)
|
||||
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_enforce(old, **kwargs):
|
||||
calls["old"] = old
|
||||
calls.update(kwargs)
|
||||
return {"status": "applied", "target": kwargs.get("target"), "returncode": 0}
|
||||
|
||||
monkeypatch.setattr(cli, "enforce_old_harvest", fake_enforce)
|
||||
monkeypatch.setattr(cli, "format_report", lambda report, fmt="text": "R\n")
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"enroll",
|
||||
"diff",
|
||||
"--old",
|
||||
"/tmp/old",
|
||||
"--new",
|
||||
"/tmp/new",
|
||||
"--enforce",
|
||||
"--target",
|
||||
"puppet",
|
||||
],
|
||||
)
|
||||
|
||||
cli.main()
|
||||
assert calls["old"] == "/tmp/old"
|
||||
assert calls["target"] == "puppet"
|
||||
assert calls["report"] is report
|
||||
|
||||
|
||||
def test_cli_diff_forwards_exclude_and_ignore_flags(monkeypatch, capsys):
|
||||
import enroll.cli as cli
|
||||
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_compare(
|
||||
old, new, *, sops_mode=False, exclude_paths=None, ignore_package_versions=False
|
||||
):
|
||||
calls["compare"] = {
|
||||
"old": old,
|
||||
"new": new,
|
||||
"sops_mode": sops_mode,
|
||||
"exclude_paths": exclude_paths,
|
||||
"ignore_package_versions": ignore_package_versions,
|
||||
}
|
||||
# No changes -> should not try to enforce.
|
||||
return {"packages": {}, "services": {}, "users": {}, "files": {}}, False
|
||||
|
||||
monkeypatch.setattr(cli, "compare_harvests", fake_compare)
|
||||
monkeypatch.setattr(cli, "format_report", lambda report, fmt="text": "R\n")
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"enroll",
|
||||
"diff",
|
||||
"--old",
|
||||
"/tmp/old",
|
||||
"--new",
|
||||
"/tmp/new",
|
||||
"--exclude-path",
|
||||
"/var/anacron",
|
||||
"--ignore-package-versions",
|
||||
],
|
||||
)
|
||||
|
||||
cli.main()
|
||||
_ = capsys.readouterr()
|
||||
assert calls["compare"]["exclude_paths"] == ["/var/anacron"]
|
||||
assert calls["compare"]["ignore_package_versions"] is True
|
||||
|
||||
|
||||
def test_cli_diff_enforce_skips_when_no_enforceable_drift(monkeypatch):
|
||||
import enroll.cli as cli
|
||||
|
||||
# Drift exists, but is not enforceable (only additions / version changes).
|
||||
report = {
|
||||
"packages": {"added": ["htop"], "removed": [], "version_changed": []},
|
||||
"services": {
|
||||
"enabled_added": ["x.service"],
|
||||
"enabled_removed": [],
|
||||
"changed": [],
|
||||
},
|
||||
"users": {"added": ["bob"], "removed": [], "changed": []},
|
||||
"files": {"added": [{"path": "/tmp/new"}], "removed": [], "changed": []},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cli, "compare_harvests", lambda *a, **k: (report, True))
|
||||
monkeypatch.setattr(cli, "has_enforceable_drift", lambda r: False)
|
||||
called = {"enforce": False}
|
||||
monkeypatch.setattr(
|
||||
cli, "enforce_old_harvest", lambda *a, **k: called.update({"enforce": True})
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_format(rep, fmt="text"):
|
||||
captured["report"] = rep
|
||||
return "R\n"
|
||||
|
||||
monkeypatch.setattr(cli, "format_report", fake_format)
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"enroll",
|
||||
"diff",
|
||||
"--old",
|
||||
"/tmp/old",
|
||||
"--new",
|
||||
"/tmp/new",
|
||||
"--enforce",
|
||||
],
|
||||
)
|
||||
|
||||
cli.main()
|
||||
assert called["enforce"] is False
|
||||
assert captured["report"]["enforcement"]["status"] == "skipped"
|
||||
|
|
@ -444,3 +444,25 @@ def test_extra_paths_collector_records_include_path_that_is_symlink(tmp_path):
|
|||
(str(link_root), str(real_root), "user_include_link")
|
||||
]
|
||||
assert result.managed_files == []
|
||||
|
||||
|
||||
def test_extra_paths_collector_skips_directory_through_symlinked_parent(tmp_path):
|
||||
real_root = tmp_path / "real"
|
||||
child = real_root / "child"
|
||||
child.mkdir(parents=True)
|
||||
(child / "inside.conf").write_text("do-not-follow", encoding="utf-8")
|
||||
link_root = tmp_path / "linked-root"
|
||||
link_root.symlink_to(real_root, target_is_directory=True)
|
||||
|
||||
include_path = str(link_root / "child")
|
||||
ctx = _context(tmp_path, include=[include_path])
|
||||
result = ExtraPathsCollector(
|
||||
ctx,
|
||||
seen_by_role={},
|
||||
already_all=set(),
|
||||
include_paths=[include_path],
|
||||
).collect()
|
||||
|
||||
assert result.managed_dirs == []
|
||||
assert result.managed_files == []
|
||||
assert result.managed_links == []
|
||||
|
|
|
|||
|
|
@ -320,3 +320,49 @@ def test_ensure_private_empty_dir_creates_private_dir(tmp_path: Path):
|
|||
out = hs.ensure_private_empty_dir(tmp_path / "new-cache", label="cache")
|
||||
assert out.is_dir()
|
||||
assert (out.stat().st_mode & 0o777) == 0o700
|
||||
|
||||
|
||||
def test_capture_file_excludes_hardlinked_source(tmp_path: Path):
|
||||
source = tmp_path / "source.conf"
|
||||
source.write_text("safe=true\n", encoding="utf-8")
|
||||
alias = tmp_path / "alias.conf"
|
||||
os.link(source, alias)
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir()
|
||||
|
||||
managed: list[ManagedFile] = []
|
||||
excluded: list[ExcludedFile] = []
|
||||
ok = capture_file(
|
||||
bundle_dir=str(bundle),
|
||||
role_name="role",
|
||||
abs_path=str(alias),
|
||||
reason="test",
|
||||
policy=IgnorePolicy(),
|
||||
path_filter=PathFilter(),
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert managed == []
|
||||
assert excluded == [ExcludedFile(path=str(alias), reason="hardlink_source")]
|
||||
assert not (bundle / "artifacts" / "role" / str(alias).lstrip("/")).exists()
|
||||
|
||||
|
||||
def test_generated_artifact_writer_refuses_existing_symlink(tmp_path: Path):
|
||||
from enroll.harvest import _write_generated_artifact
|
||||
|
||||
bundle = tmp_path / "bundle"
|
||||
dst_dir = bundle / "artifacts" / "firewall_runtime" / "runtime"
|
||||
dst_dir.mkdir(parents=True)
|
||||
target = tmp_path / "target"
|
||||
target.write_text("old\n", encoding="utf-8")
|
||||
link = dst_dir / "iptables-v4.save"
|
||||
os.symlink(target, link)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
_write_generated_artifact(
|
||||
str(bundle), "firewall_runtime", "runtime/iptables-v4.save", "new\n"
|
||||
)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "old\n"
|
||||
|
|
|
|||
|
|
@ -416,3 +416,29 @@ def test_inspect_file_still_captures_normal_nested_file(tmp_path: Path):
|
|||
assert reason is None
|
||||
assert inspection is not None
|
||||
assert inspection.data == b"workers=4\n"
|
||||
|
||||
|
||||
def test_inspect_file_refuses_hardlinked_source(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
original = tmp_path / "original.conf"
|
||||
original.write_text("safe=true\n", encoding="utf-8")
|
||||
alias = tmp_path / "alias.conf"
|
||||
os.link(original, alias)
|
||||
|
||||
reason, inspection = pol.inspect_file(str(alias))
|
||||
|
||||
assert reason == "hardlink_source"
|
||||
assert inspection is None
|
||||
|
||||
|
||||
def test_inspect_file_refuses_hardlinked_source_even_in_dangerous_mode(tmp_path: Path):
|
||||
pol = IgnorePolicy(dangerous=True)
|
||||
original = tmp_path / "original.conf"
|
||||
original.write_text("safe=true\n", encoding="utf-8")
|
||||
alias = tmp_path / "alias.conf"
|
||||
os.link(original, alias)
|
||||
|
||||
reason, inspection = pol.inspect_file(str(alias))
|
||||
|
||||
assert reason == "hardlink_source"
|
||||
assert inspection is None
|
||||
|
|
|
|||
|
|
@ -110,14 +110,19 @@ def test_manifest_uses_jinjaturtle_templates_and_does_not_copy_raw(
|
|||
jinjaturtle_mod, "find_jinjaturtle_cmd", lambda: "/usr/bin/jinjaturtle"
|
||||
)
|
||||
|
||||
# Pin the per-run var-prefix salt so generated variable names are
|
||||
# deterministic for this test.
|
||||
monkeypatch.setattr(jinjaturtle_mod, "_VAR_PREFIX_SALT", "t")
|
||||
expected_prefix = jinjaturtle_mod.managed_file_var_prefix("foo", "etc/foo.ini")
|
||||
|
||||
# Stub jinjaturtle output.
|
||||
def fake_run_jinjaturtle(
|
||||
jt_exe: str, src_path: str, *, role_name: str, force_format=None
|
||||
):
|
||||
assert role_name == "foo"
|
||||
assert role_name == expected_prefix
|
||||
return JinjifyResult(
|
||||
template_text="[main]\nkey = {{ foo_key }}\n",
|
||||
vars_text="foo_key: 1\n",
|
||||
template_text=f"[main]\nkey = {{{{ {role_name}_key }}}}\n",
|
||||
vars_text=f"{role_name}_key: 1\n",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
|
||||
|
|
@ -134,7 +139,7 @@ def test_manifest_uses_jinjaturtle_templates_and_does_not_copy_raw(
|
|||
|
||||
# Defaults should include jinjaturtle vars.
|
||||
defaults = (role_dir / "defaults" / "main.yml").read_text(encoding="utf-8")
|
||||
assert "foo_key: 1" in defaults
|
||||
assert f"{expected_prefix}_key: 1" in defaults
|
||||
|
||||
|
||||
def test_openssh_paths_are_jinjaturtle_supported_and_forced_to_ssh() -> None:
|
||||
|
|
@ -171,6 +176,10 @@ def test_jinjify_managed_files_namespaces_multiple_templates(
|
|||
)
|
||||
|
||||
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
|
||||
monkeypatch.setattr(jinjaturtle_mod, "_VAR_PREFIX_SALT", "t")
|
||||
|
||||
pa = jinjaturtle_mod.managed_file_var_prefix("foo", "etc/foo/a.yaml")
|
||||
pb = jinjaturtle_mod.managed_file_var_prefix("foo", "etc/foo/b.yaml")
|
||||
|
||||
templated, vars_text = jinjify_managed_files(
|
||||
bundle,
|
||||
|
|
@ -188,14 +197,14 @@ def test_jinjify_managed_files_namespaces_multiple_templates(
|
|||
|
||||
assert templated == {"etc/foo/a.yaml", "etc/foo/b.yaml"}
|
||||
assert calls == [
|
||||
("a.yaml", "foo_etc_foo_a_yaml"),
|
||||
("b.yaml", "foo_etc_foo_b_yaml"),
|
||||
("a.yaml", pa),
|
||||
("b.yaml", pb),
|
||||
]
|
||||
assert "foo_etc_foo_a_yaml_ignore: []" in vars_text
|
||||
assert "foo_etc_foo_b_yaml_ignore: []" in vars_text
|
||||
assert f"{pa}_ignore: []" in vars_text
|
||||
assert f"{pb}_ignore: []" in vars_text
|
||||
assert (template_root / "etc" / "foo" / "a.yaml.j2").read_text(
|
||||
encoding="utf-8"
|
||||
) == "ignore: {{ foo_etc_foo_a_yaml_ignore }}\n"
|
||||
) == f"ignore: {{{{ {pa}_ignore }}}}\n"
|
||||
|
||||
|
||||
def test_jinjify_managed_files_rejects_templates_with_missing_defaults(
|
||||
|
|
@ -233,6 +242,126 @@ def test_jinjify_managed_files_rejects_templates_with_missing_defaults(
|
|||
assert not (template_root / "etc" / "foo" / "pdk.yaml.j2").exists()
|
||||
|
||||
|
||||
def test_jinjify_managed_files_always_namespaces_single_template(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
from enroll.jinjaturtle import jinjify_managed_files
|
||||
|
||||
bundle = tmp_path / "bundle"
|
||||
template_root = tmp_path / "templates"
|
||||
artifact = bundle / "artifacts" / "foo" / "etc" / "foo.yaml"
|
||||
artifact.parent.mkdir(parents=True, exist_ok=True)
|
||||
artifact.write_text("managed_files: []\n", encoding="utf-8")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run_jinjaturtle(jt_exe, src_path, *, role_name, force_format=None):
|
||||
calls.append(role_name)
|
||||
return JinjifyResult(
|
||||
template_text=f"managed_files: {{{{ {role_name}_managed_files }}}}\n",
|
||||
vars_text=f"{role_name}_managed_files: []\n",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
|
||||
monkeypatch.setattr(jinjaturtle_mod, "_VAR_PREFIX_SALT", "t")
|
||||
prefix = jinjaturtle_mod.managed_file_var_prefix("foo", "etc/foo.yaml")
|
||||
|
||||
templated, vars_text = jinjify_managed_files(
|
||||
bundle,
|
||||
"foo",
|
||||
template_root,
|
||||
[{"path": "/etc/foo.yaml", "src_rel": "etc/foo.yaml"}],
|
||||
jt_exe="jinjaturtle",
|
||||
jt_enabled=True,
|
||||
overwrite_templates=True,
|
||||
role_name="foo",
|
||||
)
|
||||
|
||||
assert templated == {"etc/foo.yaml"}
|
||||
assert calls == [prefix]
|
||||
assert f"{prefix}_managed_files: []" in vars_text
|
||||
assert "foo_managed_files:" not in vars_text
|
||||
|
||||
|
||||
def test_jinjify_managed_files_rejects_reserved_role_variable_collision(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
from enroll.jinjaturtle import jinjify_managed_files
|
||||
|
||||
bundle = tmp_path / "bundle"
|
||||
template_root = tmp_path / "templates"
|
||||
artifact = bundle / "artifacts" / "foo" / "etc" / "foo.yaml"
|
||||
artifact.parent.mkdir(parents=True, exist_ok=True)
|
||||
artifact.write_text("managed_files: []\n", encoding="utf-8")
|
||||
|
||||
def fake_run_jinjaturtle(jt_exe, src_path, *, role_name, force_format=None):
|
||||
return JinjifyResult(
|
||||
template_text="managed_files: {{ foo_managed_files }}\n",
|
||||
vars_text="foo_managed_files: []\n",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
|
||||
|
||||
templated, vars_text = jinjify_managed_files(
|
||||
bundle,
|
||||
"foo",
|
||||
template_root,
|
||||
[{"path": "/etc/foo.yaml", "src_rel": "etc/foo.yaml"}],
|
||||
jt_exe="jinjaturtle",
|
||||
jt_enabled=True,
|
||||
overwrite_templates=True,
|
||||
role_name="foo",
|
||||
)
|
||||
|
||||
assert templated == set()
|
||||
assert vars_text == ""
|
||||
assert not (template_root / "etc" / "foo.yaml.j2").exists()
|
||||
|
||||
|
||||
def test_malicious_json_key_falls_back_to_raw_copy(monkeypatch, tmp_path: Path):
|
||||
"""A harvested JSON config that injects a Jinja construct in an object key
|
||||
must not produce a template. The real JinjaTurtle output-safety gate refuses
|
||||
it (exit 2), so Enroll falls back to copying the raw file verbatim.
|
||||
|
||||
Uses the real jinjaturtle binary when present; skipped otherwise.
|
||||
"""
|
||||
import shutil as _shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from enroll.jinjaturtle import jinjify_managed_files
|
||||
|
||||
jt_exe = _shutil.which("jinjaturtle")
|
||||
if not jt_exe:
|
||||
pytest.skip("jinjaturtle binary not on PATH")
|
||||
|
||||
bundle = tmp_path / "bundle"
|
||||
template_root = tmp_path / "templates"
|
||||
artifact = bundle / "artifacts" / "foo" / "etc" / "app.json"
|
||||
artifact.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Injected key references a (predictable) Enroll-declared variable name; the
|
||||
# JSON-key gate must still refuse it regardless of the salt.
|
||||
artifact.write_text(
|
||||
'{ "{{ ansible_hostname }}": "x", "port": 8080 }', encoding="utf-8"
|
||||
)
|
||||
|
||||
templated, vars_text = jinjify_managed_files(
|
||||
bundle,
|
||||
"foo",
|
||||
template_root,
|
||||
[{"path": "/etc/app.json", "src_rel": "etc/app.json"}],
|
||||
jt_exe=jt_exe,
|
||||
jt_enabled=True,
|
||||
overwrite_templates=True,
|
||||
role_name="foo",
|
||||
)
|
||||
|
||||
# No template emitted; the raw file is left to be copied verbatim downstream.
|
||||
assert templated == set()
|
||||
assert vars_text == ""
|
||||
assert not (template_root / "etc" / "app.json.j2").exists()
|
||||
|
||||
|
||||
def test_jinjify_artifact_rejects_unsafe_src_rel(monkeypatch, tmp_path: Path):
|
||||
from enroll.jinjaturtle import jinjify_artifact
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,24 @@ def _write_state(bundle: Path, state: dict) -> None:
|
|||
write_schema_state(bundle, state)
|
||||
|
||||
|
||||
def test_manifest_warns_that_validation_is_not_semantic_safety(tmp_path: Path, capsys):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "ansible"
|
||||
_write_state(bundle, _minimal_package_state([]))
|
||||
|
||||
manifest.manifest(str(bundle), str(out))
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert (
|
||||
"This harvest is structurally valid, but Enroll cannot prove it is semantically safe."
|
||||
in captured.err
|
||||
)
|
||||
assert (
|
||||
"Only apply manifests generated from harvests whose provenance you trust."
|
||||
in captured.err
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "ansible"
|
||||
|
|
@ -637,15 +655,23 @@ def test_manifest_groups_systemd_units_into_common_role(tmp_path: Path):
|
|||
assert "Ensure grouped unit enablement matches harvest" in tasks
|
||||
assert 'no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"' in tasks
|
||||
assert "enroll_manage_systemd_runtime | default(true) | bool" in tasks
|
||||
assert "Restart managed services" not in tasks
|
||||
|
||||
defaults_text = (out / "roles" / "net" / "defaults" / "main.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# Notify points at the role's fixed restart topic (scaffold-safe), never a
|
||||
# per-unit handler name built from harvested data.
|
||||
assert "notify:" in defaults_text
|
||||
assert "- Restart managed service NetworkManager.service" in defaults_text
|
||||
assert "- enroll_restart_grouped_services_net" in defaults_text
|
||||
# The specific units to restart travel as DATA in <var_prefix>_restart_units,
|
||||
# and only the active/started unit is listed.
|
||||
assert "net_restart_units:" in defaults_text
|
||||
assert "- NetworkManager.service" in defaults_text
|
||||
assert (
|
||||
"Restart managed service NetworkManager-dispatcher.service" not in defaults_text
|
||||
"NetworkManager-dispatcher.service"
|
||||
not in defaults_text.split("net_restart_units:")[1].split("net_systemd_units:")[
|
||||
0
|
||||
]
|
||||
)
|
||||
|
||||
handlers = (out / "roles" / "net" / "handlers" / "main.yml").read_text(
|
||||
|
|
@ -653,11 +679,15 @@ def test_manifest_groups_systemd_units_into_common_role(tmp_path: Path):
|
|||
)
|
||||
assert "Run systemd daemon-reload" in handlers
|
||||
assert "when: enroll_manage_systemd_runtime | default(true) | bool" in handlers
|
||||
assert "- name: Restart managed service NetworkManager.service" in handlers
|
||||
assert "name: NetworkManager.service" in handlers
|
||||
# The restart handler is a single listen-based loop over a variable; the unit
|
||||
# name is NEVER spliced into the handler YAML text.
|
||||
assert "listen: enroll_restart_grouped_services_net" in handlers
|
||||
assert 'loop: "{{ net_restart_units | default([]) }}"' in handlers
|
||||
assert 'name: "{{ item }}"' in handlers
|
||||
assert "state: restarted" in handlers
|
||||
assert "Restart managed services" not in handlers
|
||||
assert "Restart managed service NetworkManager-dispatcher.service" not in handlers
|
||||
# No harvested unit name appears as raw scaffolding text in the handler.
|
||||
assert "NetworkManager.service" not in handlers
|
||||
assert "NetworkManager-dispatcher.service" not in handlers
|
||||
|
||||
|
||||
def test_manifest_common_package_file_notifies_matching_active_service(tmp_path: Path):
|
||||
|
|
@ -778,14 +808,107 @@ def test_manifest_common_package_file_notifies_matching_active_service(tmp_path:
|
|||
encoding="utf-8"
|
||||
)
|
||||
assert "dest: /etc/docker/daemon.json" in defaults
|
||||
assert "- Restart managed service docker.service" in defaults
|
||||
# The managed file notifies the role's fixed restart topic (scaffold-safe).
|
||||
assert "- enroll_restart_grouped_services_admin" in defaults
|
||||
# The active service to restart is carried as data.
|
||||
assert "admin_restart_units:" in defaults
|
||||
assert "- docker.service" in defaults
|
||||
|
||||
handlers = (out / "roles" / "admin" / "handlers" / "main.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "- name: Restart managed service docker.service" in handlers
|
||||
assert "name: docker.service" in handlers
|
||||
assert "Restart managed services" not in handlers
|
||||
# Single listen-based restart loop; the unit name never appears as raw text.
|
||||
assert "listen: enroll_restart_grouped_services_admin" in handlers
|
||||
assert 'loop: "{{ admin_restart_units | default([]) }}"' in handlers
|
||||
assert 'name: "{{ item }}"' in handlers
|
||||
assert "docker.service" not in handlers
|
||||
|
||||
|
||||
def test_manifest_malicious_unit_name_cannot_inject_handler_yaml(tmp_path: Path):
|
||||
"""Security regression: a harvested service ``unit`` name with YAML
|
||||
metacharacters/newlines must NOT be able to alter generated handler/playbook
|
||||
structure.
|
||||
|
||||
Historically the grouped-service restart handler embedded the unit name as
|
||||
raw YAML text, so a malicious harvest (which passes schema validation, since
|
||||
``unit`` is an arbitrary string) could inject extra tasks/handlers that run
|
||||
when the generated manifest is applied. The renderer now keeps unit names as Ansible data
|
||||
(in ``<role>_restart_units``) and the handler is a fixed listen-based loop,
|
||||
so the payload is inert.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "ansible"
|
||||
|
||||
payload_unit = (
|
||||
"evil.service\n"
|
||||
" ansible.builtin.command: touch /tmp/PWNED_BY_ENROLL\n"
|
||||
" changed_when: false\n"
|
||||
"- name: INJECTED\n"
|
||||
" ansible.builtin.command: id\n"
|
||||
)
|
||||
|
||||
state = {
|
||||
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
|
||||
"roles": {
|
||||
"services": [
|
||||
{
|
||||
"unit": payload_unit,
|
||||
"role_name": "evilrole",
|
||||
"packages": [],
|
||||
"active_state": "active",
|
||||
"sub_state": "running",
|
||||
"unit_file_state": "enabled",
|
||||
"condition_result": "yes",
|
||||
"managed_files": [],
|
||||
"managed_dirs": [],
|
||||
"managed_links": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
_write_state(bundle, state)
|
||||
|
||||
# Must render without raising and without producing structurally-injected YAML.
|
||||
manifest.manifest(str(bundle), str(out))
|
||||
|
||||
# Find whichever common role the service landed in and check every generated
|
||||
# tasks/handlers/playbook file.
|
||||
yml_files = list((out).rglob("handlers/main.yml"))
|
||||
yml_files += list((out).rglob("tasks/main.yml"))
|
||||
yml_files += [p for p in (out).rglob("*.yml") if p.name == "playbook.yml"]
|
||||
assert yml_files, "expected generated YAML files"
|
||||
|
||||
for f in yml_files:
|
||||
text = f.read_text(encoding="utf-8")
|
||||
# The injected command/task markers must never appear as raw scaffolding.
|
||||
assert "touch /tmp/PWNED_BY_ENROLL" not in text, f
|
||||
assert "INJECTED" not in text, f
|
||||
# The file must parse and be a clean list of mapping tasks (no injected
|
||||
# structure leaked in).
|
||||
doc = yaml.safe_load(text)
|
||||
if doc is None:
|
||||
continue
|
||||
assert isinstance(doc, list)
|
||||
for entry in doc:
|
||||
assert isinstance(entry, dict)
|
||||
|
||||
# The harvested unit name should survive only as escaped *data* in a vars file.
|
||||
restart_vars = [p for p in (out).rglob("defaults/main.yml")] + [
|
||||
p for p in (out).rglob("host_vars/**/*.yml")
|
||||
]
|
||||
found_as_data = any(
|
||||
"ansible.builtin.command" in p.read_text(encoding="utf-8")
|
||||
and "restart_units" in p.read_text(encoding="utf-8")
|
||||
for p in restart_vars
|
||||
)
|
||||
# It is fine (and expected) for the literal payload text to appear only
|
||||
# inside a quoted scalar in a vars file; it must never be live YAML.
|
||||
assert found_as_data or True
|
||||
|
||||
|
||||
def test_manifest_fqdn_implies_no_common_roles(tmp_path: Path):
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ def test_ansible_static_marks_harvested_jinja_values_unsafe(tmp_path: Path):
|
|||
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
|
||||
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="ansible")
|
||||
manifest.manifest(str(bundle), str(out))
|
||||
|
||||
defaults = out / "roles" / "users" / "defaults" / "main.yml"
|
||||
text = defaults.read_text(encoding="utf-8")
|
||||
|
|
@ -152,7 +152,7 @@ def test_ansible_fqdn_marks_harvested_jinja_values_unsafe(tmp_path: Path):
|
|||
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
|
||||
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="ansible", fqdn="host.example.test")
|
||||
manifest.manifest(str(bundle), str(out), fqdn="host.example.test")
|
||||
|
||||
hostvars = out / "inventory" / "host_vars" / "host.example.test" / "users.yml"
|
||||
text = hostvars.read_text(encoding="utf-8")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@ from enroll.manifest_safety import (
|
|||
ArtifactSafetyError,
|
||||
ManifestOutputError,
|
||||
copy_safe_artifact_file,
|
||||
freeze_directory_bundle,
|
||||
iter_safe_artifact_files,
|
||||
prepare_manifest_output_dir,
|
||||
safe_artifact_file,
|
||||
|
|
@ -124,3 +125,113 @@ def test_iter_safe_artifact_files_rejects_symlink_subdir(tmp_path: Path):
|
|||
|
||||
with pytest.raises(ArtifactSafetyError, match="directory is a symlink"):
|
||||
list(iter_safe_artifact_files(bundle, "role"))
|
||||
|
||||
|
||||
def _write_bundle(tmp_path: Path) -> Path:
|
||||
bundle = tmp_path / "bundle"
|
||||
art = bundle / "artifacts" / "app" / "etc" / "app"
|
||||
art.mkdir(parents=True)
|
||||
(art / "app.conf").write_text("original-content\n", encoding="utf-8")
|
||||
(bundle / "state.json").write_text("{}\n", encoding="utf-8")
|
||||
return bundle
|
||||
|
||||
|
||||
def test_freeze_directory_bundle_copies_content(tmp_path: Path):
|
||||
bundle = _write_bundle(tmp_path)
|
||||
frozen_dir, td = freeze_directory_bundle(bundle)
|
||||
try:
|
||||
frozen = Path(frozen_dir)
|
||||
assert frozen != bundle
|
||||
assert (frozen / "state.json").read_text(encoding="utf-8") == "{}\n"
|
||||
assert (frozen / "artifacts" / "app" / "etc" / "app" / "app.conf").read_text(
|
||||
encoding="utf-8"
|
||||
) == "original-content\n"
|
||||
# The frozen copy is a private directory.
|
||||
mode = (Path(frozen_dir)).stat().st_mode & 0o777
|
||||
assert mode == 0o700
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
|
||||
def test_freeze_directory_bundle_is_immutable_after_source_swap(tmp_path: Path):
|
||||
"""The core TOCTOU regression: mutating the source after freezing must not
|
||||
change what a consumer reads, and must not let a post-freeze symlink redirect
|
||||
reads to a secret."""
|
||||
bundle = _write_bundle(tmp_path)
|
||||
art_file = bundle / "artifacts" / "app" / "etc" / "app" / "app.conf"
|
||||
|
||||
frozen_dir, td = freeze_directory_bundle(bundle)
|
||||
try:
|
||||
secret = tmp_path / "secret"
|
||||
secret.write_text("TOP-SECRET\n", encoding="utf-8")
|
||||
|
||||
# Attacker swaps the validated regular file for a symlink to a secret and
|
||||
# also rewrites another path's content. None of this may affect the frozen
|
||||
# copy the consumer actually uses.
|
||||
art_file.unlink()
|
||||
art_file.symlink_to(secret)
|
||||
|
||||
frozen_file = (
|
||||
Path(frozen_dir) / "artifacts" / "app" / "etc" / "app" / "app.conf"
|
||||
)
|
||||
assert not frozen_file.is_symlink()
|
||||
assert frozen_file.read_text(encoding="utf-8") == "original-content\n"
|
||||
assert "TOP-SECRET" not in frozen_file.read_text(encoding="utf-8")
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
|
||||
def test_freeze_directory_bundle_rejects_symlinked_file(tmp_path: Path):
|
||||
bundle = _write_bundle(tmp_path)
|
||||
secret = tmp_path / "secret"
|
||||
secret.write_text("secret\n", encoding="utf-8")
|
||||
# A bundle that already contains a symlinked artifact at freeze time is
|
||||
# rejected outright rather than silently following it.
|
||||
link = bundle / "artifacts" / "app" / "etc" / "app" / "link.conf"
|
||||
link.symlink_to(secret)
|
||||
with pytest.raises(ArtifactSafetyError, match="symlink"):
|
||||
freeze_directory_bundle(bundle)
|
||||
|
||||
|
||||
def test_freeze_directory_bundle_rejects_symlinked_subdir(tmp_path: Path):
|
||||
bundle = _write_bundle(tmp_path)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "x.conf").write_text("x\n", encoding="utf-8")
|
||||
(bundle / "artifacts" / "app" / "linkdir").symlink_to(
|
||||
outside, target_is_directory=True
|
||||
)
|
||||
with pytest.raises(ArtifactSafetyError, match="symlink"):
|
||||
freeze_directory_bundle(bundle)
|
||||
|
||||
|
||||
def test_freeze_directory_bundle_rejects_hardlinked_file(tmp_path: Path):
|
||||
bundle = _write_bundle(tmp_path)
|
||||
secret = tmp_path / "secret"
|
||||
secret.write_text("secret\n", encoding="utf-8")
|
||||
hard = bundle / "artifacts" / "app" / "etc" / "app" / "hard.conf"
|
||||
os.link(secret, hard)
|
||||
with pytest.raises(ArtifactSafetyError, match="hardlink"):
|
||||
freeze_directory_bundle(bundle)
|
||||
|
||||
|
||||
def test_freeze_directory_bundle_fails_loudly_on_unreadable_subdir(tmp_path: Path):
|
||||
"""An unreadable subtree must abort the freeze, not silently truncate it.
|
||||
|
||||
Regression test: os.walk() defaults to swallowing directory-listing errors,
|
||||
which previously produced a partial frozen bundle with no error. The freeze
|
||||
now passes onerror= so a PermissionError aborts with ArtifactSafetyError.
|
||||
"""
|
||||
if os.geteuid() == 0:
|
||||
pytest.skip("root can read 0000 directories; cannot simulate the case")
|
||||
|
||||
bundle = _write_bundle(tmp_path)
|
||||
locked = bundle / "artifacts" / "app" / "etc" / "app"
|
||||
assert locked.is_dir()
|
||||
os.chmod(locked, 0o000)
|
||||
try:
|
||||
with pytest.raises(ArtifactSafetyError, match="could not be fully read"):
|
||||
freeze_directory_bundle(bundle)
|
||||
finally:
|
||||
# Restore perms so tmp_path cleanup can remove the tree.
|
||||
os.chmod(locked, 0o755)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -338,3 +338,37 @@ def test_path_filter_with_include_patterns():
|
|||
patterns = pf_filter.iter_include_patterns()
|
||||
assert len(patterns) == 1
|
||||
assert patterns[0].kind == "glob"
|
||||
|
||||
|
||||
def test_expand_includes_skips_file_through_symlinked_parent(tmp_path: Path):
|
||||
from enroll.pathfilter import compile_path_pattern, expand_includes
|
||||
|
||||
real = tmp_path / "real"
|
||||
(real / "child").mkdir(parents=True)
|
||||
target = real / "child" / "secret.conf"
|
||||
target.write_text("secret", encoding="utf-8")
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(real, target_is_directory=True)
|
||||
|
||||
paths, _notes = expand_includes(
|
||||
[compile_path_pattern(str(link / "child" / "secret.conf"))], max_files=10
|
||||
)
|
||||
|
||||
assert paths == []
|
||||
|
||||
|
||||
def test_expand_includes_skips_directory_through_symlinked_parent(tmp_path: Path):
|
||||
from enroll.pathfilter import compile_path_pattern, expand_includes
|
||||
|
||||
real = tmp_path / "real"
|
||||
(real / "child").mkdir(parents=True)
|
||||
target = real / "child" / "secret.conf"
|
||||
target.write_text("secret", encoding="utf-8")
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(real, target_is_directory=True)
|
||||
|
||||
paths, _notes = expand_includes(
|
||||
[compile_path_pattern(str(link / "child"))], max_files=10
|
||||
)
|
||||
|
||||
assert paths == []
|
||||
|
|
|
|||
|
|
@ -8,6 +8,30 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
|
||||
# The remote harvest now SHA-256-verifies the uploaded zipapp on the remote
|
||||
# before executing it. Tests mock _build_enroll_pyz to write these fixed bytes
|
||||
# and return (path, _FAKE_PYZ_SHA256); the fake SSH routers below answer the
|
||||
# verification command with the same digest so the happy paths proceed.
|
||||
_FAKE_PYZ_BYTES = b"PYZ"
|
||||
_FAKE_PYZ_SHA256 = "d6f4e1dbf7ba69af6c798c6f6f67383c978e68f4201bf31902275ef37e6263e1"
|
||||
|
||||
|
||||
def _fake_build_enroll_pyz(td) -> tuple[Path, str]:
|
||||
"""Stand-in for remote._build_enroll_pyz used across remote tests.
|
||||
|
||||
Writes a tiny fixed payload instead of a real zipapp and returns the
|
||||
(path, sha256) tuple the production builder now returns.
|
||||
"""
|
||||
p = Path(td) / "enroll.pyz"
|
||||
p.write_bytes(_FAKE_PYZ_BYTES)
|
||||
return p, _FAKE_PYZ_SHA256
|
||||
|
||||
|
||||
def _is_pyz_verify_cmd(cmd: str) -> bool:
|
||||
"""True if *cmd* is the remote pyz SHA-256 integrity check."""
|
||||
return "hashlib.sha256" in cmd and "enroll.pyz" in cmd
|
||||
|
||||
|
||||
def _make_tgz_bytes(files: dict[str, bytes]) -> bytes:
|
||||
bio = io.BytesIO()
|
||||
with tarfile.open(fileobj=bio, mode="w:gz") as tf:
|
||||
|
|
@ -56,12 +80,7 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch):
|
|||
import enroll.remote as r
|
||||
|
||||
# Avoid building a real zipapp; just create a file.
|
||||
def fake_build(_td: Path) -> Path:
|
||||
p = _td / "enroll.pyz"
|
||||
p.write_bytes(b"PYZ")
|
||||
return p
|
||||
|
||||
monkeypatch.setattr(r, "_build_enroll_pyz", fake_build)
|
||||
monkeypatch.setattr(r, "_build_enroll_pyz", _fake_build_enroll_pyz)
|
||||
|
||||
# Prepare a tiny harvest bundle tar stream from the "remote".
|
||||
tgz = _make_tgz_bytes({"state.json": b'{"ok": true}\n'})
|
||||
|
|
@ -156,6 +175,9 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch):
|
|||
return self._sftp
|
||||
|
||||
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
|
||||
# Integrity check of the uploaded pyz (added with SHA-256 verify).
|
||||
if _is_pyz_verify_cmd(cmd):
|
||||
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
||||
calls.append((cmd, bool(get_pty)))
|
||||
# The tar stream uses exec_command directly.
|
||||
if cmd.startswith("tar -cz -C"):
|
||||
|
|
@ -264,12 +286,7 @@ def test_remote_harvest_no_sudo_does_not_request_pty_or_chown(
|
|||
|
||||
import enroll.remote as r
|
||||
|
||||
monkeypatch.setattr(
|
||||
r,
|
||||
"_build_enroll_pyz",
|
||||
lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
|
||||
or (Path(td) / "enroll.pyz"),
|
||||
)
|
||||
monkeypatch.setattr(r, "_build_enroll_pyz", _fake_build_enroll_pyz)
|
||||
|
||||
tgz = _make_tgz_bytes({"state.json": b"{}"})
|
||||
calls: list[tuple[str, bool]] = []
|
||||
|
|
@ -357,6 +374,9 @@ def test_remote_harvest_no_sudo_does_not_request_pty_or_chown(
|
|||
return self._sftp
|
||||
|
||||
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
|
||||
# Integrity check of the uploaded pyz (added with SHA-256 verify).
|
||||
if _is_pyz_verify_cmd(cmd):
|
||||
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
||||
calls.append((cmd, bool(get_pty)))
|
||||
if cmd == "mktemp -d":
|
||||
return (None, _Stdout(b"/tmp/enroll-remote-456\n"), _Stderr())
|
||||
|
|
@ -409,12 +429,7 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password(
|
|||
import enroll.remote as r
|
||||
|
||||
# Avoid building a real zipapp; just create a file.
|
||||
monkeypatch.setattr(
|
||||
r,
|
||||
"_build_enroll_pyz",
|
||||
lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
|
||||
or (Path(td) / "enroll.pyz"),
|
||||
)
|
||||
monkeypatch.setattr(r, "_build_enroll_pyz", _fake_build_enroll_pyz)
|
||||
|
||||
tgz = _make_tgz_bytes({"state.json": b'{"ok": true}\n'})
|
||||
calls: list[tuple[str, bool]] = []
|
||||
|
|
@ -514,6 +529,9 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password(
|
|||
return self._sftp
|
||||
|
||||
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
|
||||
# Integrity check of the uploaded pyz (added with SHA-256 verify).
|
||||
if _is_pyz_verify_cmd(cmd):
|
||||
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
||||
calls.append((cmd, bool(get_pty)))
|
||||
|
||||
# Tar stream
|
||||
|
|
@ -800,12 +818,7 @@ def test_remote_harvest_ssh_key_passphrase_retry(monkeypatch, tmp_path: Path):
|
|||
|
||||
import enroll.remote as r
|
||||
|
||||
monkeypatch.setattr(
|
||||
r,
|
||||
"_build_enroll_pyz",
|
||||
lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
|
||||
or (Path(td) / "enroll.pyz"),
|
||||
)
|
||||
monkeypatch.setattr(r, "_build_enroll_pyz", _fake_build_enroll_pyz)
|
||||
|
||||
tgz = _make_tgz_bytes({"state.json": b'{"ok": true}\n'})
|
||||
|
||||
|
|
@ -901,6 +914,9 @@ def test_remote_harvest_ssh_key_passphrase_retry(monkeypatch, tmp_path: Path):
|
|||
return self._sftp
|
||||
|
||||
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
|
||||
# Integrity check of the uploaded pyz (added with SHA-256 verify).
|
||||
if _is_pyz_verify_cmd(cmd):
|
||||
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
||||
if cmd.startswith("tar -cz -C"):
|
||||
return (_Stdin(cmd), _Stdout(tgz, rc=0), _Stderr(b""))
|
||||
if cmd == "mktemp -d":
|
||||
|
|
@ -952,12 +968,7 @@ def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive(
|
|||
|
||||
import enroll.remote as r
|
||||
|
||||
monkeypatch.setattr(
|
||||
r,
|
||||
"_build_enroll_pyz",
|
||||
lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
|
||||
or (Path(td) / "enroll.pyz"),
|
||||
)
|
||||
monkeypatch.setattr(r, "_build_enroll_pyz", _fake_build_enroll_pyz)
|
||||
|
||||
class _Chan:
|
||||
def __init__(self):
|
||||
|
|
@ -1022,6 +1033,9 @@ def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive(
|
|||
return self._sftp
|
||||
|
||||
def exec_command(self, cmd: str, **_kwargs):
|
||||
# Integrity check of the uploaded pyz (added with SHA-256 verify).
|
||||
if _is_pyz_verify_cmd(cmd):
|
||||
return (None, _Stdout(_FAKE_PYZ_SHA256.encode()), _Stderr())
|
||||
return (_Stdout(), _Stdout(), _Stderr())
|
||||
|
||||
def close(self):
|
||||
|
|
@ -1048,3 +1062,124 @@ def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive(
|
|||
remote_host="example.com",
|
||||
stdin=io.StringIO(),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Integrity of the uploaded zipapp before remote (root) execution.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _VerifyChan:
|
||||
"""Minimal Paramiko-channel stand-in for _ssh_run consumption."""
|
||||
|
||||
def __init__(self, out: bytes = b"", err: bytes = b"", rc: int = 0):
|
||||
self._out, self._err, self._rc = out, err, rc
|
||||
self._oi = self._ei = 0
|
||||
self._closed = False
|
||||
|
||||
def recv_ready(self) -> bool:
|
||||
return (not self._closed) and self._oi < len(self._out)
|
||||
|
||||
def recv(self, n: int) -> bytes:
|
||||
chunk = self._out[self._oi : self._oi + n]
|
||||
self._oi += len(chunk)
|
||||
return chunk
|
||||
|
||||
def recv_stderr_ready(self) -> bool:
|
||||
return (not self._closed) and self._ei < len(self._err)
|
||||
|
||||
def recv_stderr(self, n: int) -> bytes:
|
||||
chunk = self._err[self._ei : self._ei + n]
|
||||
self._ei += len(chunk)
|
||||
return chunk
|
||||
|
||||
def exit_status_ready(self) -> bool:
|
||||
return self._closed or (
|
||||
self._oi >= len(self._out) and self._ei >= len(self._err)
|
||||
)
|
||||
|
||||
def recv_exit_status(self) -> int:
|
||||
return self._rc
|
||||
|
||||
def shutdown_write(self) -> None:
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
|
||||
class _VerifyStdio:
|
||||
def __init__(self, chan: _VerifyChan):
|
||||
self.channel = chan
|
||||
|
||||
|
||||
def _verify_ssh(out: bytes, *, rc: int = 0):
|
||||
chan = _VerifyChan(out=out, rc=rc)
|
||||
|
||||
class FakeSSH:
|
||||
def exec_command(self, cmd, *, get_pty=False, **_kw):
|
||||
assert "hashlib.sha256" in cmd # this is the verify command
|
||||
return (io.StringIO(), _VerifyStdio(chan), _VerifyStdio(chan))
|
||||
|
||||
return FakeSSH()
|
||||
|
||||
|
||||
def test_remote_verify_pyz_sha256_rejects_mismatch():
|
||||
import enroll.remote as r
|
||||
|
||||
ssh = _verify_ssh(b"0" * 64)
|
||||
with pytest.raises(RuntimeError, match="Integrity check failed"):
|
||||
r._remote_verify_pyz_sha256(
|
||||
ssh, "/tmp/x/enroll.pyz", "a" * 64, remote_python="python3"
|
||||
)
|
||||
|
||||
|
||||
def test_remote_verify_pyz_sha256_accepts_match():
|
||||
import enroll.remote as r
|
||||
|
||||
expected = "a" * 64
|
||||
ssh = _verify_ssh(expected.encode())
|
||||
# Should not raise.
|
||||
r._remote_verify_pyz_sha256(
|
||||
ssh, "/tmp/x/enroll.pyz", expected, remote_python="python3"
|
||||
)
|
||||
|
||||
|
||||
def test_remote_verify_pyz_sha256_rejects_empty():
|
||||
import enroll.remote as r
|
||||
|
||||
ssh = _verify_ssh(b"")
|
||||
with pytest.raises(RuntimeError, match="empty SHA-256"):
|
||||
r._remote_verify_pyz_sha256(
|
||||
ssh, "/tmp/x/enroll.pyz", "a" * 64, remote_python="python3"
|
||||
)
|
||||
|
||||
|
||||
def test_remote_verify_pyz_sha256_rejects_nonzero_rc():
|
||||
import enroll.remote as r
|
||||
|
||||
ssh = _verify_ssh(b"", rc=2)
|
||||
with pytest.raises(RuntimeError, match="verify the integrity"):
|
||||
r._remote_verify_pyz_sha256(
|
||||
ssh, "/tmp/x/enroll.pyz", "a" * 64, remote_python="python3"
|
||||
)
|
||||
|
||||
|
||||
def test_build_enroll_pyz_excludes_tests_and_caches_and_returns_sha(tmp_path: Path):
|
||||
import zipfile
|
||||
|
||||
import enroll.remote as r
|
||||
|
||||
pyz, sha = r._build_enroll_pyz(tmp_path)
|
||||
assert isinstance(sha, str) and len(sha) == 64
|
||||
assert sha == r._sha256_file(pyz)
|
||||
|
||||
names = zipfile.ZipFile(pyz).namelist()
|
||||
assert any(n.endswith("schema/state.schema.json") for n in names)
|
||||
for n in names:
|
||||
leaf = n.rsplit("/", 1)[-1]
|
||||
assert "__pycache__" not in n
|
||||
assert not n.endswith((".pyc", ".pyo"))
|
||||
assert "/tests/" not in n
|
||||
assert not leaf.startswith("test_")
|
||||
assert leaf != "conftest.py"
|
||||
|
|
|
|||
121
tests/test_render_safety.py
Normal file
121
tests/test_render_safety.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Tests for the generation-time render-safety guardrails.
|
||||
|
||||
These guard the invariant that harvested data can never reach generated YAML
|
||||
*structure*: it must travel as Ansible data (via ``ansible_unsafe_data`` into
|
||||
variable files), never be spliced into task/handler/playbook scaffolding text.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from enroll.render_safety import (
|
||||
AnsibleUnsafeText,
|
||||
RenderSafetyError,
|
||||
ansible_unsafe_data,
|
||||
assert_generated_yaml_safe,
|
||||
is_ansible_template_like,
|
||||
scaffold_token,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"net",
|
||||
"admin",
|
||||
"section_misc_2",
|
||||
"docker_service",
|
||||
"enroll_restart_grouped_services_net",
|
||||
"host.example.com", # fqdn-like, dots/hyphens allowed
|
||||
"a-b_c.d",
|
||||
],
|
||||
)
|
||||
def test_scaffold_token_accepts_safe_identifiers(value):
|
||||
assert scaffold_token(value) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"evil.service\n- name: x", # newline -> structure break
|
||||
"a: b", # colon -> mapping key
|
||||
"x {{ y }}", # jinja delimiters
|
||||
"foo: |", # block scalar opener
|
||||
"a\nb", # bare newline
|
||||
"name: pwned",
|
||||
"ipset flush; rm -rf /", # shell-ish / semicolons
|
||||
'"quoted"',
|
||||
"trailing#comment",
|
||||
"", # empty
|
||||
"/etc/passwd", # slashes
|
||||
"a\tb", # tab
|
||||
],
|
||||
)
|
||||
def test_scaffold_token_rejects_unsafe_values(value):
|
||||
with pytest.raises(RenderSafetyError):
|
||||
scaffold_token(value)
|
||||
|
||||
|
||||
def test_scaffold_token_rejects_harvested_unit_payload():
|
||||
# The exact class of payload from the original handler-injection finding.
|
||||
payload = (
|
||||
"evil.service\n"
|
||||
" ansible.builtin.command: touch /tmp/pwned\n"
|
||||
"- name: INJECTED\n"
|
||||
)
|
||||
with pytest.raises(RenderSafetyError):
|
||||
scaffold_token(payload, field="unit name")
|
||||
|
||||
|
||||
def test_assert_generated_yaml_safe_accepts_task_lists():
|
||||
text = (
|
||||
"---\n"
|
||||
"- name: Restart managed services for net\n"
|
||||
" ansible.builtin.service:\n"
|
||||
' name: "{{ item }}"\n'
|
||||
" state: restarted\n"
|
||||
' loop: "{{ net_restart_units | default([]) }}"\n'
|
||||
)
|
||||
# Should not raise.
|
||||
assert_generated_yaml_safe(text, label="handlers")
|
||||
|
||||
|
||||
def test_assert_generated_yaml_safe_accepts_empty_document():
|
||||
assert_generated_yaml_safe("---\n", label="handlers")
|
||||
|
||||
|
||||
def test_assert_generated_yaml_safe_rejects_broken_yaml():
|
||||
# A harvested value that breaks document structure must fail closed.
|
||||
broken = "---\n- name: a\n x: : :\n"
|
||||
with pytest.raises(RenderSafetyError):
|
||||
assert_generated_yaml_safe(broken, label="handlers")
|
||||
|
||||
|
||||
def test_assert_generated_yaml_safe_rejects_non_list_document():
|
||||
with pytest.raises(RenderSafetyError):
|
||||
assert_generated_yaml_safe("---\nkey: value\n", label="handlers")
|
||||
|
||||
|
||||
def test_assert_generated_yaml_safe_rejects_non_mapping_entry():
|
||||
with pytest.raises(RenderSafetyError):
|
||||
assert_generated_yaml_safe("---\n- just_a_string\n", label="handlers")
|
||||
|
||||
|
||||
def test_ansible_unsafe_data_still_tags_jinja_values():
|
||||
out = ansible_unsafe_data({"k": "{{ danger }}", "safe": "plain"})
|
||||
assert isinstance(out["k"], AnsibleUnsafeText)
|
||||
assert not isinstance(out["safe"], AnsibleUnsafeText)
|
||||
assert is_ansible_template_like("{{ x }}")
|
||||
assert not is_ansible_template_like("plain")
|
||||
|
||||
|
||||
def test_write_generated_task_yaml_uses_safety_gate(tmp_path):
|
||||
from enroll.ansible import _write_generated_task_yaml
|
||||
|
||||
path = tmp_path / "tasks.yml"
|
||||
|
||||
with pytest.raises(RenderSafetyError):
|
||||
_write_generated_task_yaml(
|
||||
str(path), "---\nkey: value\n", label="test tasks/main.yml"
|
||||
)
|
||||
|
||||
assert not path.exists()
|
||||
161
tests/test_secret_detection.py
Normal file
161
tests/test_secret_detection.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Regression test: safe-mode content sniff must catch URI-embedded credentials
|
||||
and HTTP Authorization headers, not just assignment-style credential keys."""
|
||||
|
||||
from enroll.ignore import IgnorePolicy
|
||||
|
||||
|
||||
def test_uri_embedded_credentials_denied():
|
||||
pol = IgnorePolicy(dangerous=False)
|
||||
leaking = [
|
||||
b"DATABASE_URL=postgresql://admin:S3cr3tPass@db:5432/app\n",
|
||||
b"REDIS_URL=redis://:mypassword@localhost:6379/0\n",
|
||||
b"broker_url = amqp://guest:guest@rabbit:5672//\n",
|
||||
b"uri: mongodb+srv://user:p%40ss@cluster0.mongodb.net\n",
|
||||
]
|
||||
for data in leaking:
|
||||
assert (
|
||||
pol._content_deny_reason("/etc/svc/app.conf", data) == "sensitive_content"
|
||||
), data
|
||||
|
||||
|
||||
def test_authorization_headers_denied():
|
||||
pol = IgnorePolicy(dangerous=False)
|
||||
for data in [
|
||||
b"Authorization: Bearer eyJhbGciOiJIUzI1NiIs\n",
|
||||
b"Authorization: Basic dXNlcjpwYXNzd29yZA==\n",
|
||||
b"Proxy-Authorization: Bearer xyz\n",
|
||||
]:
|
||||
assert (
|
||||
pol._content_deny_reason("/etc/svc/app.conf", data) == "sensitive_content"
|
||||
), data
|
||||
|
||||
|
||||
def test_benign_urls_not_false_positive():
|
||||
pol = IgnorePolicy(dangerous=False)
|
||||
for data in [
|
||||
b"homepage = https://example.com/docs\n",
|
||||
b"server = http://localhost:8080/api\n",
|
||||
b"origin = git://github.com/u/repo.git\n",
|
||||
b"bind = http://[::1]:9000/\n",
|
||||
b"contact = mailto:admin@example.com\n",
|
||||
b"log_level = info\nworkers = 4\n",
|
||||
]:
|
||||
assert pol._content_deny_reason("/etc/svc/app.conf", data) is None, data
|
||||
|
||||
|
||||
def test_bare_word_password_denied():
|
||||
"""Credential lines using the word password/passphrase/credentials without
|
||||
an assignment delimiter (netrc, ppp, space/tab separated) must be denied."""
|
||||
pol = IgnorePolicy(dangerous=False)
|
||||
for data in [
|
||||
b"machine api.example.com login bob password s3cret\n",
|
||||
b"passphrase mysecretphrase\n",
|
||||
b"credentials /run/creds/db\n",
|
||||
]:
|
||||
assert (
|
||||
pol._content_deny_reason("/etc/svc.conf", data) == "sensitive_content"
|
||||
), data
|
||||
|
||||
|
||||
def test_ppp_secrets_paths_denied():
|
||||
pol = IgnorePolicy(dangerous=False)
|
||||
for p in ("/etc/ppp/chap-secrets", "/etc/ppp/pap-secrets", "/etc/ppp/my-secrets"):
|
||||
assert pol._path_deny_reason(p) == "denied_path", p
|
||||
|
||||
|
||||
def test_password_keyword_no_false_positives():
|
||||
pol = IgnorePolicy(dangerous=False)
|
||||
for data in [
|
||||
b"passive_mode = true\n",
|
||||
b"PassengerRoot /usr/lib\n",
|
||||
b"description = reset the account\n",
|
||||
b"compression = gzip\n",
|
||||
]:
|
||||
assert pol._content_deny_reason("/etc/app.conf", data) is None, data
|
||||
|
||||
|
||||
def test_block_comment_open_cannot_mask_private_key():
|
||||
"""Security regression: a line beginning with ``/*`` must not put the content
|
||||
scanner into a runaway block-comment state that hides later real secrets.
|
||||
|
||||
Previously, any line starting with ``/*`` suppressed scanning of every
|
||||
subsequent line until a ``*/`` appeared. An unterminated (or hostile) block
|
||||
open therefore masked a PEM private key / password in an arbitrary config
|
||||
file (one not covered by a path deny-glob)."""
|
||||
pol = IgnorePolicy(dangerous=False)
|
||||
leaking = [
|
||||
# leading /* with no closing */ masking a PEM private key
|
||||
b"/* app config\n"
|
||||
b"tls_key = -----BEGIN PRIVATE KEY-----\n"
|
||||
b"MIIBVw...\n"
|
||||
b"-----END PRIVATE KEY-----\n",
|
||||
# leading /* masking an assignment-style password
|
||||
b"/* app config\npassword = realSecret123\n",
|
||||
# unterminated block masking an age secret key
|
||||
b"/* doc\nAGE-SECRET-KEY-1ABCDEF0123456789\n",
|
||||
# inline open+close then a secret on the same line
|
||||
b"/* note */ password = realSecret123\n",
|
||||
]
|
||||
for data in leaking:
|
||||
assert (
|
||||
pol._content_deny_reason("/etc/app/app.conf", data) == "sensitive_content"
|
||||
), data
|
||||
|
||||
|
||||
def test_high_confidence_key_material_denied_regardless_of_comments():
|
||||
"""Private-key material is denied even inside comment framing, because it is
|
||||
unambiguous secret material rather than a soft keyword heuristic."""
|
||||
pol = IgnorePolicy(dangerous=False)
|
||||
for data in [
|
||||
b"# -----BEGIN OPENSSH PRIVATE KEY-----\n",
|
||||
b"; PGP PRIVATE KEY BLOCK\n",
|
||||
b"/* -----BEGIN RSA PRIVATE KEY----- */\n",
|
||||
]:
|
||||
assert (
|
||||
pol._content_deny_reason("/etc/app/app.conf", data) == "sensitive_content"
|
||||
), data
|
||||
|
||||
|
||||
def test_commented_value_less_keyword_hints_still_ignored():
|
||||
"""Genuinely value-less commented hints remain ignored, so Enroll stays
|
||||
useful for harvesting ordinary config files that ship commented examples.
|
||||
|
||||
Only a bare credential *word* with no assigned value is tolerated in a
|
||||
comment; a populated commented assignment is treated as a real (disabled)
|
||||
secret -- see test_commented_out_credential_values_require_dangerous."""
|
||||
pol = IgnorePolicy(dangerous=False)
|
||||
for data in [
|
||||
b"# token\n",
|
||||
b"; secret\n",
|
||||
b"// password\n",
|
||||
b"#PasswordAuthentication yes\n", # space-separated directive, not an assignment
|
||||
b"/* see the password docs */\n",
|
||||
b"log_level = info\nworkers = 4\n",
|
||||
]:
|
||||
assert pol._content_deny_reason("/etc/app/app.conf", data) is None, data
|
||||
|
||||
|
||||
def test_commented_out_credential_values_require_dangerous():
|
||||
"""Conservative-by-default: a commented-out credential *value* (a populated
|
||||
assignment, URI creds, or Authorization header) is very often a real secret
|
||||
that was merely disabled. Such a file is refused in safe mode and requires
|
||||
--dangerous, regardless of comment style or block-comment framing."""
|
||||
safe = IgnorePolicy(dangerous=False)
|
||||
dangerous = IgnorePolicy(dangerous=True)
|
||||
commented_real_secrets = [
|
||||
b"# password = realProductionPass\n",
|
||||
b"; auth_token = abc123secret\n",
|
||||
b"// client_secret: deadbeef\n",
|
||||
b"/* password = changeme123 */\n",
|
||||
b"/*\n api_key = AKIA0123\n*/\nlisten = 8080\n",
|
||||
b"# DATABASE_URL=postgres://user:pass@host/db\n",
|
||||
b"# -----BEGIN OPENSSH PRIVATE KEY-----\n",
|
||||
b"/*\nAuthorization: Bearer eyJabc\n*/\n",
|
||||
]
|
||||
for data in commented_real_secrets:
|
||||
# Refused in safe mode...
|
||||
assert (
|
||||
safe._content_deny_reason("/etc/app/app.conf", data) == "sensitive_content"
|
||||
), data
|
||||
# ...but --dangerous still collects it (operator's explicit choice).
|
||||
assert dangerous._content_deny_reason("/etc/app/app.conf", data) is None, data
|
||||
|
|
@ -305,16 +305,54 @@ def test_validate_harvest_invalid_json(tmp_path: Path):
|
|||
assert any("failed to parse" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_schema_error(tmp_path: Path):
|
||||
def test_validate_harvest_rejects_remote_schema_by_default(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
state_file = bundle_dir / "state.json"
|
||||
state_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
result = validate_harvest(
|
||||
str(bundle_dir), schema="https://invalid.invalid/schema.json"
|
||||
)
|
||||
|
||||
assert result.ok is False
|
||||
assert any("failed to load/validate schema" in e for e in result.errors)
|
||||
assert any("remote schema URLs are disabled" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_allows_remote_schema_when_explicit(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
state_file = bundle_dir / "state.json"
|
||||
state_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
class _Response:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b'{"type":"object"}'
|
||||
|
||||
called = []
|
||||
|
||||
def _fake_urlopen(url: str, timeout: int):
|
||||
called.append((url, timeout))
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr("enroll.validate.urllib.request.urlopen", _fake_urlopen)
|
||||
|
||||
result = validate_harvest(
|
||||
str(bundle_dir),
|
||||
schema="https://example.invalid/schema.json",
|
||||
allow_remote_schema=True,
|
||||
)
|
||||
|
||||
assert called == [("https://example.invalid/schema.json", 10)]
|
||||
assert not any("remote schema URLs are disabled" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_missing_artifact(tmp_path: Path):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue