Compare commits

...

284 commits
0.0.2 ... main

Author SHA1 Message Date
32bf09887a
0.8.2
All checks were successful
CI / test (push) Successful in 47s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m14s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 16m23s
Lint / test (push) Successful in 44s
2026-08-03 17:17:39 +10:00
4559aa6c70
0.8.2
All checks were successful
CI / test (push) Successful in 46s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m8s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 15m30s
Lint / test (push) Successful in 42s
2026-08-03 15:59:43 +10:00
5932d22801
Make remote harvest zipapp stdlib-only
Lazy-load manifest, explain, and validation dependencies so the remote
harvest zipapp does not require jsonschema, PyYAML, Paramiko, or other
site-packages on the target host.

Run the remote zipapp with Python isolated mode and site-packages
disabled, while preserving existing CLI monkeypatch hooks.
2026-08-03 15:58:14 +10:00
70c90c62ae
Bump dependencies 2026-08-03 15:53:51 +10:00
5166894992
0.8.1
All checks were successful
CI / test (push) Successful in 46s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m24s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 15m55s
Lint / test (push) Successful in 43s
2026-08-03 15:30:20 +10:00
1e9806d2dc
Fix a TOCTOU in remote harvest zipapp
Some checks failed
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Waiting to run
CI / test (debian, docker.io/library/debian:13, python3) (push) Waiting to run
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
Promote uploaded zipapps into a private root-owned directory before
verification and execution. Copy and hash through the same pinned file
descriptor, reject unsafe file types and metadata, publish atomically,
and ensure sudo executes only the verified root-owned copy.
2026-08-03 15:29:17 +10:00
82db7a7d72
Remove Fediverse link
All checks were successful
CI / test (push) Successful in 48s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 10m25s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 14m49s
Lint / test (push) Successful in 41s
2026-07-24 10:57:46 +10:00
098b9c3ac6
Correct remote host
All checks were successful
CI / test (push) Successful in 42s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 9m0s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 13m26s
Lint / test (push) Successful in 38s
2026-07-13 10:54:24 +10:00
5bf247c485
* Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable.
All checks were successful
CI / test (push) Successful in 41s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 9m2s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 13m39s
Lint / test (push) Successful in 39s
* Security: enforce tar member limits while lazily parsing untrusted archives rather than after `TarFile.getmembers()` has already indexed the entire archive; count repeated `.` entries and cap remote compressed downloads as well.
 * Security: apply aggregate byte and total filesystem-entry limits when freezing directory harvest bundles, reject symlinked bundle roots, and abort when files or discovered directories change during the copy, so direct directory inputs remain bounded and fail closed under mutation.
2026-07-13 10:15:43 +10:00
da0d8851d3
Tweaks
All checks were successful
CI / test (push) Successful in 56s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m59s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 17m26s
Lint / test (push) Successful in 53s
2026-07-05 11:30:45 +10:00
bcaf660349
Update lock file 2026-07-05 11:30:15 +10:00
9d24ea99e9
Fix for bookworm build
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 52s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 2m15s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 3m11s
2026-07-05 11:21:05 +10:00
7d54696402
0.7.0
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 54s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m34s
CI / test (debian, docker.io/library/debian:13, python3) (push) Has been cancelled
2026-07-05 10:58:33 +10:00
1148b34bce
More hardening
All checks were successful
CI / test (push) Successful in 1m23s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 10m47s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 16m23s
Lint / test (push) Successful in 46s
2026-07-03 13:17:49 +10:00
d2a46394fe
More hardening
All checks were successful
CI / test (push) Successful in 51s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m8s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 16m23s
Lint / test (push) Successful in 43s
2026-07-03 12:24:08 +10:00
40bff49815
Forbid the ansible globals
All checks were successful
CI / test (push) Successful in 50s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m12s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 17m18s
Lint / test (push) Successful in 46s
2026-07-02 14:52:20 +10:00
65cbe18819
Don't reference built-in globals in Jinja2 into keys
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 52s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m12s
CI / test (debian, docker.io/library/debian:13, python3) (push) Has been cancelled
2026-07-02 14:21:23 +10:00
47c2c8accc
Metadata: Tighten to prefer stat taken from the no-follow descriptor that was inspected, to avoid tiny TOCTOU. Ensure schema forbids .. in src_rel (even though caught in validate anyway - defense in depth)
All checks were successful
CI / test (push) Successful in 50s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m11s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 17m36s
Lint / test (push) Successful in 51s
2026-07-01 15:52:04 +10:00
c6e171e0f0
Better detection of commented out Authorization strings (ignore policy)
All checks were successful
CI / test (push) Successful in 51s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m5s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 16m28s
Lint / test (push) Successful in 45s
2026-07-01 15:41:38 +10:00
c468bc221e
Sanitise the 'validate' output error messages too just in case
All checks were successful
CI / test (push) Successful in 53s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 10m32s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 16m31s
Lint / test (push) Successful in 46s
2026-07-01 11:33:26 +10:00
5ffad10665
Catch key/value sensitive strings in ignore, where there is no : or = delimiter
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 55s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 10m49s
CI / test (debian, docker.io/library/debian:13, python3) (push) Has been cancelled
2026-07-01 11:04:33 +10:00
17f771cadd
Sanitise the diff and explain markdown content like we do with README 2026-07-01 11:03:38 +10:00
a34813e788
Update README.md
All checks were successful
CI / test (push) Successful in 50s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m11s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 16m42s
Lint / test (push) Successful in 45s
2026-06-30 15:48:31 +10:00
85345083a3
Remove reference to --jinjaturtle auto/on/off (it's a boolean arg). Fix alma python
All checks were successful
CI / test (push) Successful in 53s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 10m44s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 16m3s
Lint / test (push) Successful in 46s
2026-06-30 12:34:26 +10:00
e72044b610
Ensure markdown README gets sanitised
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 50s
CI / test (almalinux, docker.io/library/almalinux:10, python3.11) (push) Failing after 16s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 16m21s
2026-06-30 12:11:48 +10:00
43fca6b3da
Resist 'pw' as well 2026-06-30 12:11:17 +10:00
3b9cfea404
Build on almalinux 10? 2026-06-30 12:01:25 +10:00
f3e8387a10
Fix in right place
Some checks failed
CI / test (push) Successful in 51s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 9m8s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 16m25s
Lint / test (push) Successful in 45s
2026-06-30 11:48:08 +10:00
a98d3372fb
Fix perms
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 49s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 2m40s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 3m12s
2026-06-30 11:43:07 +10:00
3781d602a1
Fix overenthusiastic match on pass
Some checks failed
CI / test (push) Successful in 50s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 2m42s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 3m12s
Lint / test (push) Successful in 1m13s
2026-06-30 11:34:37 +10:00
4146aa997b
Fix tests
Some checks failed
CI / test (push) Successful in 50s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 2m36s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 3m2s
Lint / test (push) Successful in 1m15s
2026-06-30 11:11:38 +10:00
737316d507
More phrases to catch as potentially sensitive 2026-06-30 11:10:57 +10:00
6d55ac6cc5
jinjaturtle tests can run on rpm via git
Some checks failed
CI / test (push) Successful in 1m21s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 2m40s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 3m38s
Lint / test (push) Successful in 47s
2026-06-30 09:26:34 +10:00
0fd722281e
Fix test of jinjaturtle without apt
All checks were successful
CI / test (push) Successful in 48s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 8m38s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 17m4s
Lint / test (push) Successful in 45s
2026-06-29 20:45:21 +10:00
bb17e57f93
Revert "Try and install jinjaturtle via poetry / appimage"
This reverts commit 76aafcc08d.
2026-06-29 15:05:26 +10:00
b546bb9bf2
Fix import of stat_triplet
Some checks failed
CI / test (push) Successful in 49s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 9m3s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 12m13s
Lint / test (push) Successful in 46s
2026-06-29 14:51:32 +10:00
3c1e08bdde
More TOCTOU, update to tests for jinjaturtle
Some checks failed
CI / test (push) Successful in 48s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 2m32s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 3m0s
Lint / test (push) Successful in 44s
2026-06-29 14:30:07 +10:00
56ae883948
Don't skip jinjaturtle on RPM 2026-06-29 14:16:28 +10:00
76aafcc08d
Try and install jinjaturtle via poetry / appimage
Some checks failed
CI / test (push) Successful in 50s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 8m40s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 11m53s
Lint / test (push) Successful in 47s
2026-06-29 14:05:52 +10:00
78b52eae71
Fix tests
Some checks failed
CI / test (push) Successful in 49s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 8m18s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 11m13s
Lint / test (push) Successful in 44s
2026-06-29 13:19:04 +10:00
1c20dddf52
Update docs 2026-06-29 13:18:25 +10:00
7519adc705
Update docs
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 58s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 3m16s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 3m46s
2026-06-29 13:13:33 +10:00
b3d4adf7d4
Show a warning when manifesting from a harvest as reminder it should be trusted first 2026-06-29 12:25:35 +10:00
ddb18403c8
fail loudly on an empty/truncated frozen harvest bundle. Tighten as much as we can the remote harvesting 2026-06-29 08:56:06 +10:00
a2dc054882
Close TOCTOU gap by freezing directory harvest bundles into a private 0700 copy before the two consumers that actually read artifacts (manifest and diff) touch them. 2026-06-28 21:07:49 +10:00
1bfbfd90f6
Escape verbatim JSON and defend against jinja in it. Add option to salt role prefix to guard against malicious injection of var names 2026-06-28 20:34:06 +10:00
44d1a22db4
hardlinked source files are refused, remote schema URLs require an explicit flag, generated harvest artifacts use the hardened bundle writer, and task/handler/playbook YAML writes go through one safety gate. 2026-06-28 18:17:45 +10:00
f56f076765
Update CHANGELOG 2026-06-28 17:43:28 +10:00
f5b85d29d3
Better protection against symlink traversal in flatpak. Other hardening 2026-06-28 17:03:24 +10:00
903125976d
Remove 'enroll diff --enforce' option. Tighten yaml data re: handlers - use listen: instead of notify: 2026-06-28 16:01:11 +10:00
e9d7d74445
Remove puppet and salt 2026-06-25 16:54:23 +10:00
88e9dba39a
Revert "Byebye Enroll"
This reverts commit 958f8e3aa7.
2026-06-25 16:14:19 +10:00
680d286a20
Revert "So long, etc"
This reverts commit d99ba66951.
2026-06-25 16:14:08 +10:00
8a9fe82461
Revert "Fix typos"
This reverts commit 1d42b2bfb9.
2026-06-25 16:13:50 +10:00
1d42b2bfb9
Fix typos
Some checks failed
CI / test (push) Successful in 52s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 3m47s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 4m16s
Lint / test (push) Successful in 53s
2026-06-22 20:51:52 +10:00
d99ba66951
So long, etc
Some checks failed
CI / test (push) Successful in 53s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 3m52s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 4m23s
Lint / test (push) Has been cancelled
2026-06-22 20:40:51 +10:00
958f8e3aa7
Byebye Enroll
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 54s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 3m46s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 4m25s
2026-06-22 20:29:47 +10:00
d96ad3dc02
Some more hardening to not process raw jinja inside salt/ansible cmd. But, I think this is the end of the road
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 57s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Has been cancelled
CI / test (debian, docker.io/library/debian:13, python3) (push) Has been cancelled
2026-06-22 20:26:06 +10:00
c3c3608049
Validate state.json is a normal file 2026-06-22 17:47:36 +10:00
5757bf4275
Update DEVELOPMENT.md
All checks were successful
CI / test (push) Successful in 51s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 12m41s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 22m25s
Lint / test (push) Successful in 1m19s
2026-06-22 17:23:31 +10:00
992b8060a5
validation of artifact dir 2026-06-22 17:23:25 +10:00
efb6d7cc15
Be strict about XDG_CACHE_DIR ownership etc 2026-06-22 17:22:27 +10:00
4277e029d0
fix changelog
All checks were successful
CI / test (push) Successful in 52s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m50s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 21m39s
Lint / test (push) Successful in 47s
2026-06-22 15:39:22 +10:00
5930758398
Fix pyproject to make debian build happy 2026-06-22 15:39:17 +10:00
952687e15d
Ensure that --include-path records (but does not traverse) symlinks
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Failing after 44s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Has been cancelled
CI / test (debian, docker.io/library/debian:13, python3) (push) Has been cancelled
2026-06-22 15:34:44 +10:00
07b07e60c5
Ensure paths are not followed through parent links 2026-06-22 15:32:40 +10:00
e10a3f62b0
Belts and braces: normalise paths before globbing 2026-06-22 15:06:46 +10:00
c4448226c0
Ensure tests run through the poetry env's pytest 2026-06-22 15:05:48 +10:00
00f960d01e
Upgrade to Poetry 2 2026-06-22 15:03:32 +10:00
70525e52d8
Doc updates
All checks were successful
CI / test (push) Successful in 49s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m47s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m32s
Lint / test (push) Successful in 47s
2026-06-22 14:49:56 +10:00
ad019f6b09
normalise control characters in generated manifest scalars 2026-06-22 14:45:12 +10:00
cec6023a40
Ensure that diff also runs through validate()
All checks were successful
CI / test (push) Successful in 48s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m15s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m51s
Lint / test (push) Successful in 46s
2026-06-22 14:14:51 +10:00
1312b7eac2
Add SECURITY.md 2026-06-22 13:33:30 +10:00
a1d7a9e4e6
Add warning about --dangerous mode if sops is not in use
All checks were successful
CI / test (push) Successful in 50s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 12m37s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m21s
Lint / test (push) Successful in 45s
2026-06-22 12:56:21 +10:00
bf1c72c542
CHANGELOG updates 2026-06-22 12:47:39 +10:00
d93de8a8a2
Fix for remote harvest tmp dir 2026-06-22 12:46:45 +10:00
21a3ef3447
More safety about writing output harvests/manifests to safe locations, including SOPS and diff. 2026-06-22 12:21:33 +10:00
3feba9a9f2
More information about use of --dangerous mode 2026-06-22 12:03:48 +10:00
d1e99db2df
Update the cli help info about enroll.ini location 2026-06-22 12:00:48 +10:00
def1c2bbc7
Add note about README.md 2026-06-22 11:59:38 +10:00
e78f61c5ed
Avoid TOCTOU issues, stronger perms on manifest dir, don't allow harvesting to existing dir by default, scan whole file for potential secrets
All checks were successful
CI / test (push) Successful in 48s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m19s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m40s
Lint / test (push) Successful in 48s
2026-06-22 11:41:11 +10:00
c7a6bfe979
Update tests
All checks were successful
CI / test (push) Successful in 51s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m30s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 19m55s
Lint / test (push) Successful in 44s
2026-06-22 11:06:24 +10:00
a0914e1369
Strict validation of PATH when running as root in case it could contain potentially unsafe binaries 2026-06-22 11:06:01 +10:00
205c419a7a
Sanity check on FQDN name to avoid accidental path traversal and similar woes 2026-06-22 10:59:17 +10:00
3e8ad600e2
Use shlex.quote on remote commands 2026-06-22 10:58:20 +10:00
0a0f067111
Add other common strings that could represent sensitive values to ignore unless in --dangerous mode 2026-06-22 10:57:54 +10:00
e2b61bcdf1
Ensure jinjifying an artifact passes through safe_artifact_file just in case 2026-06-22 10:57:08 +10:00
03dc467e32
Updates to DEVELOPMENT.md re: manifest and validate 2026-06-22 10:09:31 +10:00
1e61ae2ff9
Fix tests for deb build
All checks were successful
CI / test (push) Successful in 49s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m32s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m1s
Lint / test (push) Successful in 44s
2026-06-22 10:05:17 +10:00
67b92731f6
Update tests
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Failing after 49s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Has been cancelled
CI / test (debian, docker.io/library/debian:13, python3) (push) Has been cancelled
2026-06-22 09:58:54 +10:00
0384f8817b
Fail closed on SMTP STARTTLS credential failure before sending creds. Ensure diff's manifest dir works now that we don't remove the target location if it exists (temp dir) 2026-06-22 09:57:56 +10:00
5ffd4ee755
Perform harvest validation before trying to manifest from it 2026-06-22 09:56:55 +10:00
706604df74
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) 2026-06-22 09:55:38 +10:00
a85e8265f4
Don't allow .enroll.ini in CWD, rely on env var or XDG path 2026-06-22 09:52:33 +10:00
6ee8c60e64
Fix the almalinux tests - skip jinjaturtle and systemd in CI
All checks were successful
CI / test (push) Successful in 46s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m26s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m24s
Lint / test (push) Successful in 45s
2026-06-21 17:49:51 +10:00
ce2652a3b3
Handle gracefully debian stuff when testing on rhel-like
Some checks failed
CI / test (push) Has been cancelled
Lint / test (push) Has been cancelled
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 5m10s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 10m10s
2026-06-21 16:15:33 +10:00
b704a6c80b
Add node before checkout
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 46s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 3m14s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 11m33s
2026-06-21 16:07:03 +10:00
b3a9cd3fb9
Fix curl on almalinux
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 46s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 2m9s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 2m28s
2026-06-21 16:00:35 +10:00
429da3f4c1
Attempt to run tests on Alma Linux
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 46s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 30s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 2m47s
2026-06-21 15:57:41 +10:00
f21bac7d1c
Updates to CHANGELOG and release script
All checks were successful
CI / test (push) Successful in 26m56s
Lint / test (push) Successful in 42s
2026-06-21 13:40:07 +10:00
fc120f02a5
More test coverage 2026-06-21 13:37:37 +10:00
528176ad82
Enforce the galaxy requirements in tests
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-21 13:15:10 +10:00
90e863df40
Add DEVELOPMENT.md 2026-06-21 13:03:26 +10:00
a0ac28f213
Support '--enforce' mode in 'enroll diff' with '--target' to use a specific config manager to run to enforce
All checks were successful
CI / test (push) Successful in 27m26s
Lint / test (push) Successful in 45s
2026-06-21 12:38:10 +10:00
5b0e945c99
Fix jinjaturtle tests
All checks were successful
CI / test (push) Successful in 27m22s
Lint / test (push) Successful in 41s
2026-06-21 09:42:19 +10:00
d81c32ab7f
Require version 1.20.0 or higher of podman container collection, for the platform arg 2026-06-21 09:41:56 +10:00
c7c8b93e09
make tests.sh executable again, whoops
Some checks failed
CI / test (push) Failing after 3m49s
Lint / test (push) Successful in 44s
2026-06-21 09:30:15 +10:00
5bb22afefd
Run jinjaturtle unit tests across the three renderers
Some checks failed
CI / test (push) Failing after 3m9s
Lint / test (push) Has been cancelled
2026-06-21 09:17:29 +10:00
582679a523
0.7.0b7
Some checks failed
CI / test (push) Successful in 20m0s
Lint / test (push) Has been cancelled
2026-06-21 09:03:52 +10:00
97b64522c6
Merge branch 'erb' 2026-06-21 09:03:33 +10:00
eeb37be567
0.7.0b6
All checks were successful
CI / test (push) Successful in 19m38s
Lint / test (push) Successful in 44s
2026-06-20 18:39:28 +10:00
f335077e59
Fix salt rendering of yaml/json 2026-06-20 18:38:49 +10:00
8cbde1423a
erb support, and fix notify services in puppet/salt in fqdn mode 2026-06-20 18:22:08 +10:00
4fd0facaf8
0.7.0b5
All checks were successful
CI / test (push) Successful in 19m16s
Lint / test (push) Successful in 44s
2026-06-20 15:33:47 +10:00
5845ff58e4
Update pyproject comment 2026-06-20 15:33:24 +10:00
097022f782
Fix notification of individual services when related config changes, even when roles are grouped
All checks were successful
CI / test (push) Successful in 19m18s
Lint / test (push) Successful in 42s
2026-06-20 15:31:42 +10:00
08066595f1
README updates 2026-06-20 14:36:59 +10:00
eb286b1db0
0.7.0b4
All checks were successful
CI / test (push) Successful in 19m23s
Lint / test (push) Successful in 43s
2026-06-20 12:30:39 +10:00
ceb86c513c
Improve test coverage of salt and puppet
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-20 12:30:02 +10:00
899724097e
Standardise more into CMModule parent class for the 3 child renderers
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-20 12:19:04 +10:00
7379587a28
Don't enforce /etc/enroll if no firewall rules to set in subdir
All checks were successful
CI / test (push) Successful in 19m38s
Lint / test (push) Successful in 43s
2026-06-19 20:29:12 +10:00
d6371ccccd
Fixes for ensuring /etc/enroll exists if /etc/enroll/firewall is to be created 2026-06-19 20:18:19 +10:00
5644062040
0.7.0b2 2026-06-19 19:12:26 +10:00
de42e16510
loooots of fixes.
Some checks failed
CI / test (push) Failing after 20m26s
Lint / test (push) Successful in 44s
2026-06-19 18:55:30 +10:00
b8926f9a5f
Simplify the over-engineered ansible rendering. Simplify docker image mgmt on Puppet so it doesn't use that awful puppetlabs-docker module
All checks were successful
CI / test (push) Successful in 20m26s
Lint / test (push) Successful in 47s
2026-06-19 16:32:25 +10:00
05b2875c17
Oh, Salt now works with JinjaTurtle :)
All checks were successful
CI / test (push) Successful in 19m36s
Lint / test (push) Successful in 45s
2026-06-18 20:38:50 +10:00
adfeb21d4b
reintroduce Salt
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-18 20:35:38 +10:00
0d111caf62
Revert "Remove salt"
This reverts commit b149b2e5d7.
2026-06-18 20:12:56 +10:00
02feff014f
Version 0.7.0b1
All checks were successful
CI / test (push) Successful in 18m28s
Lint / test (push) Successful in 46s
2026-06-18 09:13:03 +10:00
37523514b0
Make clear that flatpak/snap config manifesting is Ansible only for now
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-18 09:12:24 +10:00
79e73584e9
Set version to beta for pypi
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-18 09:09:47 +10:00
bf0228a76a
Dependency updates 2026-06-18 09:09:40 +10:00
22723678bd
Note in CHANGELOG that 0.7.0 is not yet released 2026-06-18 09:09:32 +10:00
a4b0ef0544
Extra clarity on modulepath with Puppet for docker support 2026-06-18 09:09:16 +10:00
b149b2e5d7
Remove salt
All checks were successful
CI / test (push) Successful in 18m7s
Lint / test (push) Successful in 41s
2026-06-17 18:13:06 +10:00
ebc27e1111
Support for detecting Docker images
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-17 18:05:02 +10:00
e2be9a6239
Separate up the ansible renderer. Simplify the package management bits by using ansible.builtin.package
All checks were successful
CI / test (push) Successful in 22m12s
Lint / test (push) Successful in 44s
2026-06-17 16:40:36 +10:00
e448994470
No sudo needed in the CI test
All checks were successful
CI / test (push) Successful in 25m53s
Lint / test (push) Successful in 44s
2026-06-17 14:29:16 +10:00
845f8d9ad1
Refactor tests.sh
Some checks failed
CI / test (push) Failing after 8s
Lint / test (push) Successful in 44s
2026-06-17 14:25:41 +10:00
c7e3b94355
Add separate pytests.sh script for local use 2026-06-17 14:22:57 +10:00
ee08bf43ba
Support manifesting Salt 2026-06-17 14:19:25 +10:00
ceca3df83c
Fix hiera/fqdn support for Puppet
All checks were successful
CI / test (push) Successful in 16m41s
Lint / test (push) Successful in 48s
2026-06-17 11:47:47 +10:00
20cc48e1ce
More refactoring, support hiera and multi site mode for Puppet
All checks were successful
CI / test (push) Successful in 15m30s
Lint / test (push) Successful in 44s
2026-06-17 10:54:46 +10:00
ed9ec6893a
Try to resolve circular imports
All checks were successful
CI / test (push) Successful in 15m37s
Lint / test (push) Successful in 44s
2026-06-17 09:51:47 +10:00
de7531424d
Huge refactor to support extending a generic Config Manager class for different types (Ansible, Puppet... Salt soon?)
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-17 09:37:32 +10:00
5e6c8e6455
remove sudo call
All checks were successful
CI / test (push) Successful in 14m27s
Lint / test (push) Successful in 42s
2026-06-16 16:53:28 +10:00
3c84b3c070
Test puppet run
Some checks failed
CI / test (push) Failing after 3m29s
Lint / test (push) Successful in 42s
2026-06-16 16:47:47 +10:00
380a0b8ca2
Update the puppet reserved names
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-16 16:44:10 +10:00
33b9d44c55
Output the AppImage into the dir it's going into anyway
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-16 16:39:28 +10:00
f9e93cd6fd
Support manifesting Puppet :o 2026-06-16 16:39:18 +10:00
e682aae41e
Filter out more sysctl params that throw Invalid argument when executed on the fly 2026-06-16 16:30:33 +10:00
9546e1b8ed
Add sysctl detection 2026-06-16 14:23:44 +10:00
3c19ae54b2
Only capture user-specific .bashrc style files when using mode, in case they contain sensitive env vars.
All checks were successful
CI / test (push) Successful in 14m0s
Lint / test (push) Successful in 42s
2026-06-16 13:35:33 +10:00
8774d019d3
Fix tests
All checks were successful
CI / test (push) Successful in 14m26s
Lint / test (push) Successful in 43s
2026-06-14 19:21:32 +10:00
1e996f4a43
Group all package roles into Debian/RPM 'sections'
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
This includes 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.

This is a breaking change.
2026-06-14 19:19:59 +10:00
e2339616fb
remove flatpak tests which don't work great in CI 2026-06-14 18:49:26 +10:00
00329cdd33
install flatpak in test
Some checks failed
CI / test (push) Failing after 5m15s
Lint / test (push) Successful in 42s
2026-06-14 18:42:28 +10:00
9dfbd411de
Add some flatpak tests
Some checks failed
CI / test (push) Failing after 3m5s
Lint / test (push) Has been cancelled
2026-06-14 18:37:48 +10:00
8f425b595b
Remove newlines
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-14 18:34:22 +10:00
eb1d096c90
Add support for detecting flatpaks and snaps
Some checks failed
CI / test (push) Failing after 5m51s
Lint / test (push) Successful in 43s
2026-06-14 18:25:26 +10:00
11351cce87
Fix test 2026-06-14 16:23:06 +10:00
bbfc338734
Fix regression that enforced merge_simple_packages
Some checks failed
CI / test (push) Failing after 10m40s
Lint / test (push) Successful in 38s
2026-06-14 16:03:52 +10:00
76df10ee92
Add --merge-simple-packages to reduce the number of roles, for packages that have no config files or services to maintain.
Some checks failed
CI / test (push) Failing after 5m32s
Lint / test (push) Successful in 40s
2026-06-14 15:52:07 +10:00
a0fbed5ca5
Fix curl option
All checks were successful
CI / test (push) Successful in 11m55s
Lint / test (push) Successful in 41s
2026-06-07 14:44:36 +10:00
6c58beddfe
Attempt to install SOPS in tests
Some checks failed
CI / test (push) Failing after 2m11s
Lint / test (push) Successful in 41s
2026-06-07 14:40:08 +10:00
fbb06f1177
More coverage
Some checks failed
Lint / test (push) Successful in 45s
CI / test (push) Failing after 2m35s
2026-05-31 17:55:22 +10:00
62b2f2ffe6
More coverage
Some checks failed
CI / test (push) Failing after 1s
Lint / test (push) Failing after 1s
2026-05-31 17:21:45 +10:00
bf735c8328
More coverage
Some checks failed
CI / test (push) Failing after 1s
Lint / test (push) Failing after 1s
2026-05-31 17:15:22 +10:00
1544dc0295
more test coverage 2026-05-31 16:50:57 +10:00
b25dd1e314
* Add support for capturing ipset and iptables configuration files
All checks were successful
CI / test (push) Successful in 8m23s
Lint / test (push) Successful in 33s
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present (`firewall_runtime` role)
 * Dependency updates
2026-05-14 15:16:36 +10:00
3fcfefe644
0.5.0
All checks were successful
CI / test (push) Successful in 8m28s
Lint / test (push) Successful in 1m5s
2026-05-12 12:24:00 +10:00
618dd20e7c
Update deps 2026-05-12 12:23:52 +10:00
5695f4258e
Add support for ssh configs as templates, via JinjaTurtle 2026-05-12 12:23:41 +10:00
5c686d27cc
Remove trivy..
All checks were successful
CI / test (push) Successful in 8m16s
Lint / test (push) Successful in 33s
2026-03-23 11:20:56 +11:00
4ea7267b92
Update my GPG key
All checks were successful
CI / test (push) Successful in 8m26s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 25s
2026-03-11 12:02:39 +11:00
d403dcb918
0.4.4
All checks were successful
CI / test (push) Successful in 8m14s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s
2026-02-17 10:58:38 +11:00
778237740a
Add ability to gracefully handle an encrypted private key for SSH (can be forced or automated with an env var too)
All checks were successful
CI / test (push) Successful in 8m22s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s
2026-02-17 10:35:51 +11:00
87ddf52e81
Update cryptography dependency
All checks were successful
CI / test (push) Successful in 8m22s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 26s
2026-02-17 10:00:39 +11:00
5f6b0f49d9
Update dependencies
All checks were successful
CI / test (push) Successful in 8m22s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 26s
2026-01-16 10:59:22 +11:00
1856e3a79d
Add support for AddressFamily and ConnectTimeout in the .ssh/config when using --remote-ssh-config. 2026-01-16 10:58:39 +11:00
478b0e1b9d
Add README example for --remote-ssh-config
All checks were successful
CI / test (push) Successful in 8m19s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 25s
2026-01-13 22:03:58 +11:00
f5eaac9f75
Support --remote-ssh-config [path-to-ssh-config] as an argument in case extra params are required beyond --remote-port or --remote-user.
All checks were successful
CI / test (push) Successful in 8m18s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 25s
Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
2026-01-13 21:56:28 +11:00
5754ef1aad
Add interactive output when 'enroll diff --enforce' is invoking Ansible.
All checks were successful
CI / test (push) Successful in 8m18s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s
2026-01-11 10:01:16 +11:00
d172d848c4
Relax python3-jsonschema version for Fedora support
All checks were successful
CI / test (push) Successful in 8m16s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 24s
2026-01-10 11:44:51 +11:00
f84d795c49
Rename test file
All checks were successful
CI / test (push) Successful in 8m15s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s
2026-01-10 11:24:01 +11:00
95b784c1a0
Fix and add tests
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-10 11:16:28 +11:00
ebd30247d1
Add --enforce mode to enroll diff and add --ignore-package-versions
Some checks failed
CI / test (push) Failing after 1m48s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 22s
If there is diff detected between the two harvests, and it can
enforce restoring the state from the older harvest, it will
manifest the state and apply it with ansible. Only the specific
roles that had diffed will be applied (via the new tags capability).

`--ignore-package-versions` will skip reporting when packages are
upgraded/downgraded in the diff.
2026-01-10 10:51:41 +11:00
9a249cc973
Initial pass at an --enforce mode for enroll diff, to manifest and restore state of old harvest if ansible is on the PATH
All checks were successful
CI / test (push) Successful in 8m13s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 23s
2026-01-10 09:50:28 +11:00
9749190cd8
Fix test
All checks were successful
CI / test (push) Successful in 8m14s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 21s
2026-01-10 09:15:29 +11:00
ca3d958a96
Add --exclude-path to enroll diff command
Some checks failed
CI / test (push) Failing after 1m45s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
So that you can ignore certain churn from the diff

(stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
2026-01-10 08:56:35 +11:00
8be821c494
Update pynacl dependency to resolve CVE-2025-69277
All checks were successful
CI / test (push) Successful in 8m1s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-08 17:16:58 +11:00
8daed96b7c
Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
All checks were successful
CI / test (push) Successful in 8m13s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-06 12:47:12 +11:00
e0ef5ede98
Run validate in CLI tests
All checks were successful
CI / test (push) Successful in 8m28s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 26s
2026-01-05 21:30:14 +11:00
025f00f924
Fix tests
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-05 21:25:46 +11:00
66d032d981
Introduce 'enroll validate' to check a harvest meets the schema spec and isn't lacking artifacts or contains orphaned ones
Some checks failed
CI / test (push) Failing after 1m47s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-05 21:17:50 +11:00
45e0d9bb16
0.3.0
All checks were successful
CI / test (push) Successful in 8m25s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-05 17:13:43 +11:00
9f30c56e8a
Don't remove apache2 (it breaks the manifest run)
Some checks failed
CI / test (push) Successful in 8m22s
Lint / test (push) Successful in 31s
Trivy / test (push) Has been cancelled
2026-01-05 17:04:06 +11:00
7a9a0abcd1
Add tests for symlinks management
Some checks failed
CI / test (push) Failing after 7m32s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-05 16:54:39 +11:00
aea58c8684
Install Apache2 to test symlinks management in the ansible manifests
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-05 16:48:08 +11:00
ca4cf00e84
Changelog entry for symlinks
All checks were successful
CI / test (push) Successful in 7m36s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-05 16:30:14 +11:00
d3fdfc9ef7
Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-05 16:29:21 +11:00
bcf3dd7422
Fix tests
All checks were successful
CI / test (push) Successful in 7m18s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 22s
2026-01-05 15:52:25 +11:00
91ec1b8791
Ignore files ending in - in the /etc/ dir e.g /etc/shadow-
Some checks failed
CI / test (push) Failing after 1m43s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 23s
2026-01-05 15:48:17 +11:00
b5e32770a3
Ignore files that end with a tilde (probably backup files generated by editors) 2026-01-05 15:23:45 +11:00
e04b158c39
Fix non-interactive test
All checks were successful
CI / test (push) Successful in 7m19s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-05 15:06:20 +11:00
a1433d645f
Capture other files in the user's home directory
Some checks failed
CI / test (push) Failing after 1m57s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 27s
Such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
2026-01-05 15:02:22 +11:00
e68ec0bffc
More test coverage 2026-01-05 14:27:56 +11:00
24cedc8c8d
Centralise the cron and logrotate stuff into their respective roles.
All checks were successful
CI / test (push) Successful in 7m52s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
We had a bit of duplication between roles based on harvest discovery.

Arguably some crons/logrotate scripts are specific to other packages,
but it helps to go to one place to find them all. We'll apply these
roles last in the playbook, to give an opportunity for all other
packages / non-system users to have been installed already.
2026-01-05 12:01:25 +11:00
c9003d589d
Fix test. Update README
All checks were successful
CI / test (push) Successful in 8m1s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-05 10:23:15 +11:00
59674d4660
Introduce enroll explain
Some checks failed
CI / test (push) Failing after 1m45s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
A tool to analyze and explain what's in (or not in) a harvest and why.
2026-01-05 10:16:44 +11:00
56d0148614
Update README
All checks were successful
CI / test (push) Successful in 6m53s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 22s
2026-01-04 21:27:23 +11:00
04234e296f
0.2.3
All checks were successful
CI / test (push) Successful in 6m55s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-04 21:05:49 +11:00
a2be708a31
Support for remote hosts that require password for sudo.
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible.

It will also fall back to this prompt if a password is required but the arg wasn't passed in.

With thanks to slhck from HN for the initial patch, advice and feedback.
2026-01-04 20:49:10 +11:00
9df4dc862d
Add CONTRIBUTORS.md 2026-01-04 15:53:33 +11:00
fd55bcde9b
fix fedora release
All checks were successful
CI / test (push) Successful in 7m2s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-03 12:56:59 +11:00
1d3ce6191e
remove 'fc' from release root
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-03 12:49:14 +11:00
626d76c755
Update README for RPM repo URL
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-03 12:46:32 +11:00
f82fd894ca
More test coverage (71%)
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-03 12:34:39 +11:00
9a2516d858
Fix release date
All checks were successful
CI / test (push) Successful in 7m0s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-03 12:17:43 +11:00
6c3275b44a
Fix tests
All checks were successful
CI / test (push) Successful in 7m4s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 22s
2026-01-03 11:46:40 +11:00
824010b2ab
Several bug fixes and prep for 0.2.2
Some checks failed
CI / test (push) Failing after 1m40s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 24s
- Fix stat() of parent directory so that we set directory perms correct on --include paths.
 - Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
2026-01-03 11:39:57 +11:00
29b52d451d
0.2.1
Some checks failed
CI / test (push) Failing after 2m37s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-02 21:29:16 +11:00
c88405ef01
Ensure directories in the tree of anything included with --include are defined in the state and manifest so we make dirs before we try to create files 2026-01-02 21:10:32 +11:00
781efef467
Don't accidentally add extra_paths role to usr_local_custom list, resulting in extra_paths appearing twice in manifested playbook 2026-01-02 20:19:47 +11:00
09438246ae
Build for Fedora 43
All checks were successful
CI / test (push) Successful in 6m42s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-01 15:24:21 +11:00
e4887b7add
Update README.md
All checks were successful
CI / test (push) Successful in 6m39s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-01 11:02:30 +11:00
e44e4aaf3a
0.2.0
All checks were successful
CI / test (push) Successful in 4m52s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 17s
2025-12-29 17:39:39 +11:00
f01603dac4
Better attribution of config files to parent service/role (not systemd helpers)
All checks were successful
CI / test (push) Successful in 4m51s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 15s
2025-12-29 17:19:59 +11:00
081739fd19
Fix tests
All checks were successful
CI / test (push) Successful in 5m7s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 18s
2025-12-29 16:35:21 +11:00
043802e800
Refactor state structure and capture versions of packages 2025-12-29 16:10:27 +11:00
984b0fa81b
Add ability to enroll RH-style systems (DNF5/DNF/RPM)
All checks were successful
CI / test (push) Successful in 5m9s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 17s
2025-12-29 14:59:34 +11:00
ad2abed612
Add version CLI arg 2025-12-29 14:29:11 +11:00
8c19473e18
Fix an attribution bug for certain files ending up in the wrong package/role.
All checks were successful
CI / test (push) Successful in 5m2s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 21s
2025-12-28 18:37:14 +11:00
921801caa6
0.1.6
All checks were successful
CI / test (push) Successful in 5m24s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 16s
2025-12-28 15:32:40 +11:00
3fc5aec5fc
0.1.5
All checks were successful
CI / test (push) Successful in 5m4s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 17s
2025-12-28 09:56:52 +11:00
8c6b51be3e
Manage apt stuff in its own role, not in etc_custom
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-28 09:39:14 +11:00
303c1b0dd8
Consolidate logrotate and cron files into their main service/package roles if they exist. Standardise on MAX_FILES_CAP in one place 2025-12-28 09:30:21 +11:00
cae6246177
Add Fedora install steps to README
All checks were successful
CI / test (push) Successful in 5m1s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 18s
2025-12-27 19:14:01 +11:00
40aad9e798
0.1.4
All checks were successful
CI / test (push) Successful in 5m0s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 18s
2025-12-27 19:04:00 +11:00
054a6192d1
Capture more singletons in /etc and avoid apt duplication
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-27 19:02:22 +11:00
4d2250f974
Add fedora rpm building
All checks were successful
CI / test (push) Successful in 4m45s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 17s
2025-12-27 16:56:30 +11:00
8c478249d9
Add build-deb action workflow
All checks were successful
CI / test (push) Successful in 4m48s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 17s
2025-12-23 17:22:50 +11:00
51196a0a2b
Fix trivy exit code
All checks were successful
CI / test (push) Successful in 4m48s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 17s
2025-12-22 17:28:10 +11:00
59239eb2d2
Fix formatting in README
All checks were successful
CI / test (push) Successful in 5m33s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 17s
2025-12-20 18:38:05 +11:00
cf819f755a
0.1.3
All checks were successful
CI / test (push) Successful in 5m35s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 20s
2025-12-20 18:26:04 +11:00
9641637d4d
Add support for an enroll.ini config file to store arguments per subcommand, to avoid having to remember them all for repetitive executions.
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-20 18:24:46 +11:00
240e79706f
Allow the user to add extra paths to harvest, or
All checks were successful
CI / test (push) Successful in 5m31s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 19s
paths to ignore, using `--exclude-path` and
`--include-path` arguments.
2025-12-20 17:47:00 +11:00
25add369dc
README.md update
All checks were successful
CI / test (push) Successful in 5m3s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 18s
2025-12-18 17:24:45 +11:00
4660a0703e
Include files from /usr/local/bin and /usr/local/etc in harvest (assuming they aren't binaries or symlinks) and store in usr_local_custom role, similar to etc_custom.
All checks were successful
CI / test (push) Successful in 5m43s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 19s
2025-12-18 17:11:04 +11:00
b5d2b99174
Add diff mode
All checks were successful
CI / test (push) Successful in 5m14s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2025-12-18 14:59:51 +11:00
55e50ebf59
Fix end of file/whitespace per pre-commit
All checks were successful
CI / test (push) Successful in 5m11s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 17s
2025-12-18 13:50:00 +11:00
e94bd86c75
Add files param to bandit pre-commit
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-18 13:45:59 +11:00
bfa2f4a724
Add bandit to pre-commit 2025-12-18 13:44:26 +11:00
591ecaa235
Add pre-commit config
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-18 13:41:22 +11:00
a235028f3b
black
All checks were successful
CI / test (push) Successful in 5m38s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 21s
2025-12-18 13:34:37 +11:00
62ec8e8b1b
Silence bandit paranoia on certain lines
Some checks failed
CI / test (push) Successful in 5m24s
Lint / test (push) Failing after 29s
Trivy / test (push) Successful in 20s
2025-12-17 19:05:07 +11:00
9ebd8ff990
remove --out from harvest examples with remote mode, in README 2025-12-17 19:03:31 +11:00
33b1176800
Add --sops mode to encrypt harvest and manifest data at rest (especially useful if using --dangerous)
Some checks failed
CI / test (push) Successful in 5m35s
Lint / test (push) Failing after 29s
Trivy / test (push) Successful in 18s
2025-12-17 18:51:40 +11:00
6a36a9d2d5
Remote mode and dangerous flag, other tweaks
* Add remote mode for harvesting a remote machine via a local workstation (no need to install enroll remotely)
   Optionally use `--no-sudo` if you don't want the remote user to have passwordless sudo when conducting the
   harvest, albeit you'll end up with less useful data (same as if running `enroll harvest` on a machine without
   sudo)
 * Add `--dangerous` flag to capture even sensitive data (use at your own risk!)
 * Do a better job at capturing other config files in `/etc/<package>/` even if that package doesn't normally
   ship or manage those files.
2025-12-17 17:02:16 +11:00
026416d158
Fix tests
All checks were successful
CI / test (push) Successful in 5m36s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 21s
2025-12-16 20:48:08 +11:00
f40b9d834d
black and pyflakes3 2025-12-16 20:15:21 +11:00
f255ba566c
biiiiig refactor to support jinjaturtle and multi site mode 2025-12-16 20:14:20 +11:00
576649a49c
README.md adjustment
All checks were successful
CI / test (push) Successful in 5m1s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 18s
2025-12-15 17:13:06 +11:00
e4be7f5975
Rename secrets to ignore as it does more than secrets
All checks were successful
CI / test (push) Successful in 5m35s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 17s
2025-12-15 17:03:28 +11:00
4882ddff49
Add custom_etc and users last
Some checks failed
CI / test (push) Successful in 5m5s
Lint / test (push) Failing after 28s
Trivy / test (push) Successful in 18s
2025-12-15 16:46:39 +11:00
651549b949
Change message about whether it is a meta package or not 2025-12-15 16:28:10 +11:00
b7ffc4e7c2
0.0.3
All checks were successful
CI / test (push) Successful in 5m18s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 20s
2025-12-15 15:10:13 +11:00
883e9f963f
use no-cache
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-15 15:05:09 +11:00
9edbfb676a
Remove obsolete comment in release.sh 2025-12-15 15:04:43 +11:00
25550c21bf
poetry lock
All checks were successful
CI / test (push) Successful in 4m56s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 18s
2025-12-15 13:37:49 +11:00
18ca83c255
remove ansible from dev dependencies 2025-12-15 13:37:41 +11:00
2f5a65b737
Bump version
Some checks failed
CI / test (push) Failing after 1m18s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 18s
2025-12-15 13:34:20 +11:00
82dc870213
Debian packaging 2025-12-15 13:33:56 +11:00
d8fb33f0d0
hmm
All checks were successful
CI / test (push) Successful in 4m58s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 18s
2025-12-15 13:23:11 +11:00
019f6bf6f3
Attempt fix for unit name
Some checks failed
CI / test (push) Failing after 4m44s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 21s
2025-12-15 12:49:50 +11:00
c6f174dd55
fix...
Some checks failed
CI / test (push) Failing after 4m43s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 21s
2025-12-15 12:34:26 +11:00
ac0c884c39
Another fix for systemd unit file
Some checks failed
CI / test (push) Failing after 1m37s
Lint / test (push) Failing after 28s
Trivy / test (push) Successful in 19s
2025-12-15 12:28:21 +11:00
283f3286f2
Fix test
Some checks failed
CI / test (push) Failing after 1m34s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 19s
2025-12-15 12:22:59 +11:00
2eecb73a49
Ensure we only try to enable service if the unit file existed
Some checks failed
CI / test (push) Failing after 2m0s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 18s
2025-12-15 12:18:26 +11:00
2c5e901450
needs python3-apt for tests
Some checks failed
CI / test (push) Failing after 5m11s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 19s
2025-12-15 12:01:01 +11:00
809f21dd35
CHANGELOG is markdown
Some checks failed
CI / test (push) Failing after 1m55s
Lint / test (push) Successful in 30s
Trivy / test (push) Has been cancelled
2025-12-15 11:58:23 +11:00
4710231c91
Remove sudo in tests
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-15 11:57:33 +11:00
d50f1505bb
Update README.md 2025-12-15 11:56:38 +11:00
68e797a054
Needs systemctl
Some checks failed
Trivy / test (push) Waiting to run
CI / test (push) Failing after 1m34s
Lint / test (push) Has been cancelled
2025-12-15 11:53:26 +11:00
119 changed files with 39361 additions and 1816 deletions

View file

@ -0,0 +1,66 @@
name: CI
on:
push:
jobs:
test:
runs-on: docker
steps:
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
devscripts \
debhelper \
dh-python \
pybuild-plugin-pyproject \
python3-all \
python3-poetry-core \
python3-yaml \
python3-paramiko \
python3-jsonschema \
rsync \
ca-certificates
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Build deb
run: |
mkdir /out
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'dist' \
--exclude 'build' \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude '.mypy_cache' \
./ /out/
cd /out/
export DEBEMAIL="mig@mig5.net"
export DEBFULLNAME="Miguel Jacq"
dch --distribution "trixie" --local "~trixie" "CI build for trixie"
dpkg-buildpackage -us -uc -b
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

View file

@ -7,27 +7,96 @@ jobs:
test:
runs-on: docker
strategy:
fail-fast: false
matrix:
include:
- distro: debian
image: docker.io/library/debian:13
python: python3
- distro: almalinux
image: docker.io/library/almalinux:9
python: python3.11
container:
image: ${{ matrix.image }}
steps:
- name: Install system dependencies
env:
DISTRO: ${{ matrix.distro }}
PYTHON_BIN: ${{ matrix.python }}
run: |
set -eux
case "${DISTRO}" in
debian)
mkdir -m 755 -p /etc/apt/keyrings
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl gnupg git tar gzip findutils bash nodejs procps \
ansible ansible-lint python3 python3-venv python3-pip pipx systemctl python3-apt jq python3-jsonschema
;;
almalinux)
dnf -y upgrade --refresh
dnf -y install \
ca-certificates curl-minimal gnupg2 git tar gzip findutils bash which jq nodejs procps-ng \
dnf-plugins-core epel-release
dnf -y config-manager --set-enabled crb || true
dnf -y makecache
dnf -y install \
python3.11 python3.11-devel python3.11-pip gcc make \
ansible-core ansible-lint systemd rpm httpd
;;
*)
echo "Unsupported CI distro: ${DISTRO}" >&2
exit 1
;;
esac
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ansible ansible-lint python3-venv pipx
- name: Install Poetry
env:
PYTHON_BIN: ${{ matrix.python }}
POETRY_VERSION: "2.4.1"
run: |
pipx install poetry==1.8.3
/root/.local/bin/poetry --version
set -eux
if ! command -v pipx >/dev/null 2>&1; then
"${PYTHON_BIN}" -m pip install --user pipx
fi
PIPX_BIN="$(command -v pipx || true)"
if [ -z "${PIPX_BIN}" ]; then
PIPX_BIN="${HOME}/.local/bin/pipx"
fi
"${PIPX_BIN}" install --python "${PYTHON_BIN}" "poetry==${POETRY_VERSION}"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
poetry --version
poetry --version | grep -E "Poetry \(version 2\."
- name: Install project deps (including test extras)
env:
PYTHON_BIN: ${{ matrix.python }}
run: |
poetry install --with test
poetry env use "${PYTHON_BIN}"
poetry install --with dev
- name: Install sops
run: |
set -eux
case "$(uname -m)" in
x86_64) sops_arch=amd64 ;;
aarch64|arm64) sops_arch=arm64 ;;
*) echo "Unsupported architecture for sops: $(uname -m)" >&2; exit 1 ;;
esac
curl -L -o /usr/local/bin/sops "https://github.com/getsops/sops/releases/download/v3.13.1/sops-v3.13.1.linux.${sops_arch}"
chmod +x /usr/local/bin/sops
- name: Run test script
env:
PYTHON_BIN: ${{ matrix.python }}
run: |
./tests.sh

View file

@ -1,40 +0,0 @@
name: Trivy
on:
schedule:
- cron: '0 1 * * *'
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy
- name: Run trivy
run: |
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ dist
*.pdf
*.csv
*.html
coverage.xml
*.orig
*.rej

25
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,25 @@
repos:
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
args: ["--select=F"]
types: [python]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.11.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/PyCQA/bandit
rev: 1.9.2
hooks:
- id: bandit
files: ^enroll/

185
CHANGELOG.md Normal file
View file

@ -0,0 +1,185 @@
# 0.8.2
* Bump dependencies
* Make remote harvest zipapp stdlib-only
# 0.8.1
* Security: fix a TOCTOU in remote harvest.
# 0.8.0
* Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable.
* Security: enforce tar member limits while lazily parsing untrusted archives rather than after `TarFile.getmembers()` has already indexed the entire archive; count repeated `.` entries and cap remote compressed downloads as well.
* Security: apply aggregate byte and total filesystem-entry limits when freezing directory harvest bundles, reject symlinked bundle roots, and abort when files or discovered directories change during the copy, so direct directory inputs remain bounded and fail closed under mutation.
# 0.7.0
* BREAKING CHANGE: Remove the `enroll diff --enforce` option. Enroll no longer applies the old harvest state locally to repair drift; this avoids the risk of enforcing a potentially malicious or tampered harvest. To restore baseline state, regenerate a manifest from the trusted harvest and apply it yourself, or compare two `enroll diff` runs and act on the result.
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
* BREAKING CHANGE: Don't allow reading `.enroll.ini` in the CWD. Use only the ENROLL_CONFIG env var, an explicit `--config` path or else the XDG default location (or `~/.config/enroll/enroll.ini` if `XDG_CONFIG_HOME` is not set).
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
* Support for detecting Docker and Podman images and enforcing their presence (by SHA256 hash).
* Add support for detecting Flatpaks and Snaps.
* Stricter validation of harvests to ensure that they meet the schema and don't contain unsafe artifacts (e.g symlinks pointing outside the artifact tree)
* Perform harvest validation before trying to manifest from it.
* Stricter validation on FQDN name in multisite mode.
* Strict check of `$PATH` when running harvest as root, in case it could lead to execution of unsafe binaries during harvest. Override with `--assume-safe-path` for non-interactive or CI purposes.
* Stricter validation of the destination dirs that harvest or manifest write to, to prevent writing to a different user-controlled area. Stricter permissions on the output dirs too.
# 0.6.0
* Add support for capturing ipset and iptables configuration files
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present (`firewall_runtime` role)
* Dependency updates
# 0.5.0
* Add support for templating `sshd_config`, if a compatible version of JinjaTurtle is also present.
* Dependency updates
# 0.4.4
* Update cryptography dependency
* Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
# 0.4.3
* Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
* Update dependencies
# 0.4.2
* Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
# 0.4.1
* Add interactive output when 'enroll diff --enforce' is invoking Ansible.
# 0.4.0
* Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
* Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
* Update pynacl dependency to resolve CVE-2025-69277
* Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
* Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
* Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
* Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible. Only the specific roles that had diffed will be applied (via the new tags capability)
# 0.3.0
* Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.
* Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery.
* Capture other files in the user's home directory such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
* Ignore files that end with a tilde or - (probably backup files generated by editors or shadow file changes)
* Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
# 0.2.3
* Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible. It will also fall back to this prompt if a password is required but the arg wasn't passed in.
# 0.2.2
* Fix stat() of parent directory so that we set directory perms correct on --include paths.
* Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
# 0.2.1
* Don't accidentally add `extra_paths` role to `usr_local_custom` list, resulting in `extra_paths` appearing twice in manifested playbook
* Ensure directories in the tree of anything included with --include are defined in the state and manifest so we make dirs before we try to create files
# 0.2.0
* Add version CLI arg
* Add ability to enroll RH-style systems (DNF5/DNF/RPM)
* Refactor harvest state to track package versions
# 0.1.7
* Fix an attribution bug for certain files ending up in the wrong package/role.
# 0.1.6
* DRY up some code logic
* More test coverage
# 0.1.5
* Consolidate logrotate and cron files into their main service/package roles if they exist.
* Standardise on `MAX_FILES_CAP` in one place
* Manage apt stuff in its own role, not in `etc_custom`
# 0.1.4
* Attempt to capture more stuff from /etc that might not be attributable to a specific package. This includes common singletons and systemd timers
* Avoid duplicate apt data in package-specific roles.
# 0.1.3
* Allow the user to add extra paths to harvest, or paths to ignore, using `--exclude-path` and `--include-path`
arguments.
* Add support for an enroll.ini config file to store arguments per subcommand, to avoid having to remember
them all for repetitive executions.
# 0.1.2
* Include files from `/usr/local/bin` and `/usr/local/etc` in harvest (assuming they aren't binaries or
symlinks) and store in `usr_local_custom` role, similar to `etc_custom`.
# 0.1.1
* Add `diff` subcommand which can compare two harvests and send email or webhook notifications in different
formats.
# 0.1.0
* Add remote mode for harvesting a remote machine via a local workstation (no need to install enroll remotely)
Optionally use `--no-sudo` if you don't want the remote user to have passwordless sudo when conducting the
harvest, albeit you'll end up with less useful data (same as if running `enroll harvest` on a machine without
sudo)
* Add `--dangerous` flag to capture even sensitive data (use at your own risk!)
* Add `--sops` flag which makes the harvest and the manifest 'out' data encrypted as a single SOPS data file.
This would make `--dangerous` a little bit safer, if your intention is just to store the Ansible manifest
in git or somewhere similar for disaster-recovery purposes (e.g encrypted at rest for safe-keeping).
* Do a better job at capturing other config files in `/etc/<package>/` even if that package doesn't normally
ship or manage those files.
* Don't collect files ending in `.log`
# 0.0.5
* Use JinjaTurtle to generate dynamic template/inventory if it's on the PATH
* Support --fqdn flag for site-specific inventory and an inventory hosts file.
This radically re-architects the roles to loop through abstract inventory
because otherwise different servers can collide with each other through use
of the same role. Use 'single site' mode (no `--fqdn`) if you want more readable,
self-contained roles (in which case, store each manifested output in its own
repo per server)
* Generate an ansible.cfg if not present, to support `host_vars` plugin and other params,
when using `--fqdn` mode
* Be more permissive with files that we previously thought contained secrets (ignore commented lines)
# 0.0.4
* Fix dash package detection issue
* Reorder which roles install first
# 0.0.3
* various bug fixes
* Add debian packaging
# 0.0.2
* Merge pkg_ and roles created based on file/service detection
* Avoid idempotency issue with users (`password_lock`)
* Rename subcommands/args ('export' is now 'enroll', '--bundle' is now '--harvest')
* Don't try and start systemd services that were Inactive at harvest time
* Capture miscellaneous files in /etc under their own `etc_custom` role, but not backup files
* Add tests
* Various other bug fixes
# 0.0.1
* Initial commit

View file

@ -1,13 +0,0 @@
# 0.0.2
* Merge pkg_ and roles created based on file/service detection
* Avoid idempotency issue with users (password_lock)
* Rename subcommands/args ('export' is now 'enroll', '--bundle' is now '--harvest')
* Don't try and start systemd services that were Inactive at harvest time
* Capture miscellaneous files in /etc under their own etc_custom role, but not backup files
* Add tests
* Various other bug fixes
# 0.0.1
* Initial commit

5
CONTRIBUTORS.md Normal file
View file

@ -0,0 +1,5 @@
## Contributors
mig5 would like to thank the following people for their contributions to Enroll.
* [slhck](https://slhck.info/)

1317
DEVELOPMENT.md Normal file

File diff suppressed because it is too large Load diff

81
Dockerfile.debbuild Normal file
View file

@ -0,0 +1,81 @@
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=debian:bookworm
FROM ${BASE_IMAGE}
ENV DEBIAN_FRONTEND=noninteractive
# If Ubuntu, ensure Universe is enabled.
RUN set -eux; \
. /etc/os-release; \
if [ "${ID:-}" = "ubuntu" ]; then \
apt-get update; \
apt-get install -y --no-install-recommends software-properties-common ca-certificates; \
add-apt-repository -y universe; \
fi
# Build deps
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
build-essential \
devscripts \
libdistro-info-perl \
debhelper \
dh-python \
pybuild-plugin-pyproject \
python3-all \
python3-poetry-core \
python3-yaml \
python3-paramiko \
python3-jsonschema \
rsync \
ca-certificates \
; \
rm -rf /var/lib/apt/lists/*
# Build runner script
RUN set -eux; \
cat > /usr/local/bin/build-deb <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
SRC="${SRC:-/src}"
WORKROOT="${WORKROOT:-/work}"
WORK="${WORKROOT}/src"
OUT="${OUT:-/out}"
mkdir -p "$WORK" "$OUT"
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'dist' \
--exclude 'build' \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude '.mypy_cache' \
"${SRC}/" "${WORK}/"
cd "${WORK}"
if [ -n "${SUITE:-}" ]; then
export DEBEMAIL="mig@mig5.net"
export DEBFULLNAME="Miguel Jacq"
dch --distribution "$SUITE" --local "~${SUITE}" "CI build for $SUITE"
fi
dpkg-buildpackage -us -uc -b
shopt -s nullglob
cp -v "${WORKROOT}"/*.deb \
"${WORKROOT}"/*.changes \
"${WORKROOT}"/*.buildinfo \
"${WORKROOT}"/*.dsc \
"${WORKROOT}"/*.tar.* \
"${OUT}/" || true
echo "Artifacts copied to ${OUT}"
EOF
RUN chmod +x /usr/local/bin/build-deb
WORKDIR /work
ENTRYPOINT ["/usr/local/bin/build-deb"]

88
Dockerfile.rpmbuild Normal file
View file

@ -0,0 +1,88 @@
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=fedora:42
FROM ${BASE_IMAGE}
RUN set -eux; \
dnf -y update; \
dnf -y install \
rpm-build \
rpmdevtools \
redhat-rpm-config \
gcc \
make \
findutils \
tar \
gzip \
rsync \
python3 \
python3-devel \
python3-setuptools \
python3-wheel \
pyproject-rpm-macros \
python3-rpm-macros \
python3-yaml \
python3-paramiko \
python3-jsonschema \
openssl-devel \
python3-poetry-core ; \
dnf -y clean all
# Build runner script (copies repo, tars, runs rpmbuild)
RUN set -eux; cat > /usr/local/bin/build-rpm <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
SRC="${SRC:-/src}"
WORKROOT="${WORKROOT:-/work}"
OUT="${OUT:-/out}"
VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)"
echo "Version ID is ${VERSION_ID}"
mkdir -p "${WORKROOT}" "${OUT}"
WORK="${WORKROOT}/src"
rm -rf "${WORK}"
mkdir -p "${WORK}"
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'dist' \
--exclude 'build' \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude '.mypy_cache' \
"${SRC}/" "${WORK}/"
cd "${WORK}"
# Determine version from pyproject.toml unless provided
if [ -n "${VERSION:-}" ]; then
ver="${VERSION}"
else
ver="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)".*/\1/')"
fi
TOPDIR="${WORKROOT}/rpmbuild"
mkdir -p "${TOPDIR}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
tarball="${TOPDIR}/SOURCES/enroll-${ver}.tar.gz"
tar -czf "${tarball}" --transform "s#^#enroll/#" .
spec_src="rpm/enroll.spec"
cp -v "${spec_src}" "${TOPDIR}/SPECS/enroll.spec"
rpmbuild -ba "${TOPDIR}/SPECS/enroll.spec" \
--define "_topdir ${TOPDIR}" \
--define "upstream_version ${ver}"
shopt -s nullglob
cp -v "${TOPDIR}"/RPMS/*/*.rpm "${OUT}/" || true
cp -v "${TOPDIR}"/SRPMS/*.src.rpm "${OUT}/" || true
echo "Artifacts copied to ${OUT}"
EOF
RUN chmod +x /usr/local/bin/build-rpm
WORKDIR /work
ENTRYPOINT ["/usr/local/bin/build-rpm"]

625
README.md
View file

@ -1,91 +1,638 @@
# Enroll
# Enroll
<div align="center">
<img src="https://git.mig5.net/mig5/enroll/raw/branch/main/enroll.svg" alt="Enroll logo" width="240" />
</div>
**enroll** inspects a Linux machine (currently Debian-only) and generates Ansible roles for things it finds running on the machine.
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates Ansible configuration-management code from it.
It aims to be **optimistic and noninteractive**:
- Detects packages that have been installed
- Detects Debian package ownership of `/etc` files using dpkgs local database.
- Captures config that has **changed from packaged defaults** (dpkg conffile hashes + package md5sums when available).
- 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 that exist on the system, and their SSH public keys
- Captures miscellaneous `/etc` files that it can't attribute to a package, and installs it in an `etc_custom` role
- Avoids trying to start systemd services that were detected as being Inactive during harvest
- 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.
## Install
---
### AppImage
## Mental model
Download the AppImage file from the Releases page (verify with GPG if you wish, my fingerprint is [here](https://mig5.net/static/mig5.asc),
then make it executable and run it:
`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
```
**IMPORTANT**: Always make sure that you take adequate precautions to prevent a malicious actor from tampering with your harvest. Enroll tries to set the permissions of it to something your running user has access to, but environments and situations can vary. A malicious actor could change your harvest contents in a way that doesn't violate the schema but results in sensitive exposure or dangerous execution once you apply the 'manifested' configuration management version of it.
Whenever in doubt, add `--sops` (with SOPS installed on your PATH) and encrypt the harvest so that only you can decrypt it.
---
### `enroll single-shot`
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` (and abbreviations like `passwd`, `pwd`, and `pw`, e.g. `db_pw`), `client_secret`, `secret_key`, `auth_token`, `api_key`, `aws_access_key_id`, `aws_secret_access_key`, `azure_client_secret`, `GOOGLE_APPLICATION_CREDENTIALS`, and service-account key names.
**IMPORTANT**: Enroll tolerates value-less credential keyword mentions in comments, such as `# token`, so ordinary stock configuration files do not become unusable. However, commented-out credential values are still treated as sensitive. A populated credential assignment, credential-bearing URI, `Authorization` header, or private-key material is refused in default safe mode even when it appears inside a comment. Use `--dangerous` only when you intentionally want to collect such material, and prefer `--sops` or another appropriate form of at-rest encryption whenever in doubt.
Automatic harvesting of per-user shell dotfiles is also disabled by default, even when those files differ from `/etc/skel`, because `.bashrc`, `.profile`, `.bash_aliases`, and similar files commonly contain exported tokens, credentials, or aliases/functions with embedded secrets. Use `--dangerous` for automatic shell-dotfile capture, or use targeted `--include-path` patterns for narrower safe-mode review.
If you wish to opt in to collecting everything, use `--dangerous` mode, but be aware of what it means:
### `--dangerous`
**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
## Pip/PipX
```bash
pip install enroll
```
### Poetry
Clone this repository with git, then:
## Poetry (dev)
```bash
poetry install
poetry run enroll --help
```
## Usage
---
On the host (root recommended):
## Found a bug / have a suggestion?
### 1. Harvest state/information about the host
My Forgejo doesn't currently support federation, so I haven't opened registration/login for issues.
Instead, email me (see `pyproject.toml`).
---
# Examples
## Harvest
### Local harvest
```bash
sudo poetry run enroll harvest --out /tmp/enroll-harvest
enroll harvest --out /tmp/enroll-harvest
```
### 2. Generate Ansible manifests (roles/playbook) from that harvest
### Remote harvest over SSH
```bash
sudo poetry run enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
enroll harvest --remote-host myhost.example.com --remote-user myuser --out /tmp/enroll-harvest
```
### Alternatively, do both steps in one shot:
### 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
sudo poetry run enroll enroll --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
enroll harvest --remote-host myhostalias --remote-ssh-config ~/.ssh/config --out /tmp/enroll-harvest
```
Then run:
### 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
```
## Notes / Safety
### 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
```
- enroll **skips** common sensitive locations like `/etc/ssl/private/*`, `/etc/ssh/ssh_host_*`, and files that look like private keys/tokens.
- It also skips symlinks, binary-ish files, and large files by default.
- Review each generated roles README before committing it anywhere.
- It only stores the raw config files. If you want to turn these into Jinja2 templates with dynamic inventory, see my other tool https://git.mig5.net/mig5/jinjaturtle .
## Configuration file
As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands.
## Troubleshooting
Sometimes, it can be easier to store them in a config file so you don't have to remember them!
- Run as root for the most complete harvest (`sudo ...`).
Enroll supports reading an ini-style file of all the arguments for each subcommand.
## Found a bug, have a suggestion?
### Location of the config file
You can e-mail me (see the pyproject.toml for details) or contact me on the Fediverse:
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`.
https://goto.mig5.net/@mig5
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/.*$
```

97
SECURITY.md Normal file
View file

@ -0,0 +1,97 @@
# Enroll Threat Model and Security Scope
Enroll is a command-line systems administration tool. It is designed to be executed intentionally by a system administrator, often with elevated privileges, in order to inspect a host, harvest selected system state, and optionally generate or apply configuration-management output.
Because of that design, Enrolls security model is different from that of a network service, web application, daemon, or setuid program. Enroll does not attempt to defend against arbitrary local compromise of the account executing it. If an attacker can control the command line, environment, configuration file, working directory, `PATH`, harvested input bundle, or configuration-management tools used by the administrator, they may be able to influence what Enroll does. That situation is considered a local trust-boundary failure outside Enrolls intended security model.
## Core assumptions
Enroll assumes that the person running the tool understands what they are asking it to do.
In particular:
* If Enroll is run as root, the root user is assumed to control and understand the command line, environment, configuration file, and output location being used.
* If an `enroll.ini` configuration file is loaded, its location and contents are assumed to be owned, selected, and understood by the operator.
* The operator is expected to understand the implications of options such as `--dangerous`, `--assume-safe-path`, `--sops`, `--remote-host`, and `--remote-ssh-config`.
* Harvest bundles used for `manifest` or `diff` are assumed to come from a trusted source unless the operator is deliberately inspecting untrusted input without applying it.
* Configuration-management tools invoked by Enroll, such as Ansible, SOPS, SSH, `sudo`, Docker, Podman, Flatpak, Snap, package managers, and system utilities, are assumed to be the trusted tools the operator intended to use.
## What is in scope
Enroll tries to protect careful administrators from common and serious mistakes that can occur when a privileged CLI tool reads and writes host state.
In-scope security concerns include:
* Avoiding accidental capture of obvious secrets in default safe mode.
* Refusing known sensitive paths such as shadow files, SSH host keys, private key material, and common certificate/private-key locations unless the operator explicitly opts into dangerous collection.
* Warning when `--dangerous` is used, especially without encrypted output.
* Supporting encrypted harvest bundles via `--sops`.
* Avoiding symlink traversal and time-of-check/time-of-use mistakes when copying harvested files.
* Refusing unsafe artifact paths, symlinks, hardlinks, device nodes, and tar path traversal in harvest bundles.
* Treating a harvest bundle as point-in-time validated: before `manifest` or `diff` re-open artifacts to render or hash them, a plain *directory* bundle is copied into a private, attacker-immutable temp tree (regular files only, no symlinks or hardlinks, opened without following links) so the bundle that is consumed cannot be raced and swapped after validation. Tar and SOPS inputs get the equivalent treatment by being extracted into a private temp directory.
* Keeping harvested values in Ansible *data* rather than playbook *structure*, so a harvested path, owner, group, username, or link target cannot inject YAML structure or be re-evaluated as a Jinja/template expression at apply time.
* Writing plaintext harvest outputs into private directories by default.
* Hardening root-run output path handling so Enroll does not accidentally write through attacker-prepared symlinks or unsafe parent directories.
* Refusing to continue non-interactively when run as root with an unsafe `PATH`, unless the operator explicitly confirms with `--assume-safe-path`.
* Avoiding injection in generated manifests where harvested values are embedded into Ansible output — not only shell injection, but YAML-structure injection and runtime Jinja/template re-evaluation — by serializing harvested data through a safe YAML dumper, tagging template-looking values as Ansible `!unsafe`, and allowlisting the only identifiers ever spliced into raw task YAML.
* Rejecting unknown SSH host keys by default during remote harvests.
These measures are defense-in-depth. They are intended to reduce the chance of accidental exposure, unsafe filesystem writes, path traversal, command injection, or dangerous behavior when Enroll is used normally by an administrator.
## What is out of scope
The following are generally out of scope and should not be reported as Enroll vulnerabilities unless they also bypass one of Enrolls explicit hardening mechanisms:
* A malicious local user who can already control the root users command line, shell environment, config file, `PATH`, `XDG_CONFIG_HOME`, SSH config, working directory, or invoked binaries.
* A root user loading an `enroll.ini` file whose contents intentionally request dangerous behavior.
* A root user passing `--dangerous` and then observing that Enroll may collect sensitive information.
* A root user passing `--assume-safe-path` and then observing that Enroll does not prompt about `PATH` safety.
* A user applying generated Ansible manifests from an untrusted harvest.
* A user configuring a webhook, email target, SSH proxy command, SOPS binary, package manager, or configuration-management tool that they do not trust.
* A compromised system where an attacker already controls root-owned files, roots shell, roots configuration, or the privileged tools Enroll invokes.
* Reports that amount to “if root runs this tool with malicious options, root can make the system do dangerous things.”
* Enroll harvesting a file that merely *mentions* a credential-related word in a comment with no assigned value (for example a commented-out `# token` hint in a stock config). Enroll tolerates value-less keyword mentions in comments so it is not useless for harvesting ordinary configuration files. However, a commented-out credential *value* — a populated `key = value` assignment, a URI with embedded credentials, an `Authorization` header, or private-key material — is treated as sensitive even inside a comment, because a "commented out" secret is very often a real secret that was merely disabled. Such a file is refused in default safe mode and requires `--dangerous` (ideally with `--sops`) to collect. It remains the responsibility of the user to use `--sops` or appropriate at-rest encryption if in the slightest doubt about what might get harvested.
Enroll is a tool for administrators, not a sandbox for hostile local users. It cannot make unsafe local trust decisions safe if the operators own execution environment is already attacker-controlled.
## Trusted harvests
Harvest bundles should be treated as sensitive and trusted administrative artifacts.
A harvest may contain hostnames, usernames, package lists, service state, filesystem metadata, configuration files, firewall snapshots, container image references, Flatpak/Snap state, and other operational details. In `--dangerous` mode it may contain substantially more sensitive material.
Before running `manifest` or `diff`, or applying a generated manifest, the operator should be confident that the harvest bundle came from a trusted source and has not been tampered with.
Enroll validates harvest structure and artifact safety. Validation can detect many unsafe filesystem constructs, such as path traversal, missing artifacts, symlinks, hardlinks, and schema mismatches. Validation does not and cannot prove that the desired state represented by a harvest is safe to apply.
## Local compromise
Enroll includes hardening against some local filesystem attack patterns because it is often run with high privileges. For example, it tries to avoid symlink races, unsafe output directories, path traversal, and accidental secret capture.
However, local compromise cannot be ruled out completely for a privileged CLI tool. If an attacker can influence the administrators shell, environment, config file, binaries, SSH configuration, SOPS binary, configuration-management tools, or harvest inputs, they may be able to influence Enrolls behavior.
Such scenarios are treated as local compromise or operator trust failures, not as vulnerabilities in Enroll by themselves.
## Security report guidance
Useful vulnerability reports include issues where Enroll behaves unsafely despite the documented trust model. Examples include:
* Enroll captures a clearly sensitive default-denied file without `--dangerous`.
* Enroll follows a symlink or hardlink in a way that causes privileged file disclosure or overwrite.
* Enroll extracts a tar member outside the intended harvest directory.
* Enroll accepts a malicious harvest artifact that escapes the artifact root.
* Enroll generates an Ansible manifest where ordinary harvested data can cause command injection.
* Enroll writes root-run output into an unsafe attacker-controlled path despite its safety checks.
* Enroll silently ignores a failed safety check and proceeds anyway.
* Enroll accepts an unknown SSH host key unexpectedly.
* Enroll exposes secrets in logs, errors, reports, or generated output when not explicitly requested by the operator.
Less useful reports, and normally out of scope, include:
* “Root can configure Enroll to collect sensitive files.”
* “Root can pass `--dangerous` and collect dangerous data.”
* “Root can pass `--assume-safe-path` and bypass the root `PATH` warning.”
* “Root can point Enroll at a malicious config file.”
* “A malicious local user can compromise Enroll after already controlling roots 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.

212
debian/changelog vendored Normal file
View file

@ -0,0 +1,212 @@
enroll (0.8.2) unstable; urgency=medium
* Make remote harvest zipapp stdlib-only
-- Miguel Jacq <mig@mig5.net> Mon, 03 Aug 2026 16:00:00 +1000
enroll (0.8.1) unstable; urgency=medium
* Security: fix a TOCTOU in remote harvest.
-- Miguel Jacq <mig@mig5.net> Mon, 03 Aug 2026 15:20:00 +1000
enroll (0.8.0) unstable; urgency=medium
* Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable.
* Security: enforce tar member limits while lazily parsing untrusted archives rather than after `TarFile.getmembers()` has already indexed the entire archive; count repeated `.` entries and cap remote compressed downloads as well.
* Security: apply aggregate byte and total filesystem-entry limits when freezing directory harvest bundles, reject symlinked bundle roots, and abort when files or discovered directories change during the copy, so direct directory inputs remain bounded and fail closed under mutation.
-- Miguel Jacq <mig@mig5.net> Mon, 13 Jul 2026 10:00:00 +1000
enroll (0.7.0) unstable; urgency=medium
* BREAKING CHANGE: Remove the `enroll diff --enforce` option. Enroll no longer applies the old harvest state locally to repair drift; this avoids the risk of enforcing a potentially malicious or tampered harvest. To restore baseline state, regenerate a manifest from the trusted harvest and apply it yourself, or compare two `enroll diff` runs and act on the result.
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
* BREAKING CHANGE: Don't allow reading `.enroll.ini` in the CWD. Use only the ENROLL_CONFIG env var, an explicit `--config` path or else the XDG default location (or `~/.config/enroll/enroll.ini` if `XDG_CONFIG_HOME` is not set).
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
* Support for detecting Docker and Podman images and enforcing their presence (by SHA256 hash).
* Add support for detecting Flatpaks and Snaps.
* Stricter validation of harvests to ensure that they meet the schema and don't contain unsafe artifacts (e.g symlinks pointing outside the artifact tree)
* Perform harvest validation before trying to manifest from it.
* Stricter validation on FQDN name in multisite mode.
* Strict check of `$PATH` when running harvest as root, in case it could lead to execution of unsafe binaries during harvest. Override with `--assume-safe-path` for non-interactive or CI purposes.
* Stricter validation of the destination dirs that harvest or manifest write to, to prevent writing to a different user-controlled area. Stricter permissions on the output dirs too.
* Lots of hardening across the whole codebase and integration with Jinjaturtle.
-- Miguel Jacq <mig@mig5.net> Sun, 5 Jul 2026 11:00:00 +1000
enroll (0.6.0) unstable; urgency=medium
* Add support for capturing ipset and iptables configuration files
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present ('firewall_runtime' role)
-- Miguel Jacq <mig@mig5.net> Thu, 14 May 2026 15:00:00 +1000
enroll (0.5.0) unstable; urgency=medium
* Add ssh config support where JinjaTurtle is used
-- Miguel Jacq <mig@mig5.net> Tue, 12 May 2026 12:00 +1000
enroll (0.4.4) unstable; urgency=medium
* Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
-- Miguel Jacq <mig@mig5.net> Tue, 17 Feb 2026 11:00 +1100
enroll (0.4.3) unstable; urgency=medium
* Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
-- Miguel Jacq <mig@mig5.net> Fri, 16 Jan 2026 11:00 +1100
enroll (0.4.2) unstable; urgency=medium
* Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
-- Miguel Jacq <mig@mig5.net> Tue, 13 Jan 2026 21:55:00 +1100
enroll (0.4.1) unstable; urgency=medium
* Add interactive output when 'enroll diff --enforce' is invoking Ansible.
-- Miguel Jacq <mig@mig5.net> Sun, 11 Jan 2026 10:00:00 +1100
enroll (0.4.0) unstable; urgency=medium
* Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
* Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
* Update pynacl dependency to resolve CVE-2025-69277
* Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
* Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
* Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
* Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible.
Only the specific roles that had diffed will be applied (via the new tags capability)
-- Miguel Jacq <mig@mig5.net> Sat, 10 Jan 2026 10:30:00 +1100
enroll (0.3.0) unstable; urgency=medium
* Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.
* Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery.
* Capture other files in the user's home directory such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
* Ignore files that end with a tilde or - (probably backup files generated by editors or shadow file changes)
* Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
-- Miguel Jacq <mig@mig5.net> Mon, 05 Jan 2026 17:00:00 +1100
enroll (0.2.3) unstable; urgency=medium
* Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible. It will also fall back to this prompt if a password is required but the arg wasn't passed in.
-- Miguel Jacq <mig@mig5.net> Sun, 04 Jan 2026 20:38:00 +1100
enroll (0.2.2) unstable; urgency=medium
* Fix stat() of parent directory so that we set directory perms correct on --include paths.
* Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
-- Miguel Jacq <mig@mig5.net> Sat, 03 Jan 2026 09:56:00 +1100
enroll (0.2.1) unstable; urgency=medium
* Don't accidentally add extra_paths role to usr_local_custom list, resulting in extra_paths appearing twice in manifested playbook
* Ensure directories in the tree of anything included with --include are defined in the state and manifest so we make dirs before we try to create files
-- Miguel Jacq <mig@mig5.net> Fri, 02 Jan 2026 21:30:00 +1100
enroll (0.2.0) unstable; urgency=medium
* Add version CLI arg
* Add ability to enroll RH-style systems (DNF5/DNF/RPM)
* Refactor harvest state to track package versions
-- Miguel Jacq <mig@mig5.net> Mon, 29 Dec 2025 17:30:00 +1100
enroll (0.1.7) unstable; urgency=medium
* Fix an attribution bug for certain files ending up in the wrong package/role.
-- Miguel Jacq <mig@mig5.net> Sun, 28 Dec 2025 18:30:00 +1100
enroll (0.1.6) unstable; urgency=medium
* DRY up some code logic
* More test coverage
-- Miguel Jacq <mig@mig5.net> Sun, 28 Dec 2025 15:30:00 +1100
enroll (0.1.5) unstable; urgency=medium
* Consolidate logrotate and cron files into their main service/package roles if they exist.
* Standardise on MAX_FILES_CAP in one place
* Manage apt stuff in its own role, not in etc_custom
-- Miguel Jacq <mig@mig5.net> Sun, 28 Dec 2025 10:00:00 +1100
enroll (0.1.4) unstable; urgency=medium
* Attempt to capture more stuff from /etc that might not be attributable to a specific package. This includes common singletons and systemd timers
* Avoid duplicate apt data in package-specific roles.
-- Miguel Jacq <mig@mig5.net> Sat, 27 Dec 2025 19:00:00 +1100
enroll (0.1.3) unstable; urgency=medium
* Allow the user to add extra paths to harvest, or paths to ignore, using `--exclude-path` and `--include-path`
arguments.
* Add support for an enroll.ini config file to store arguments per subcommand, to avoid having to remember
them all for repetitive executions.
-- Miguel Jacq <mig@mig5.net> Sat, 20 Dec 2025 18:24:00 +1100
enroll (0.1.2) unstable; urgency=medium
* Include files from `/usr/local/bin` and `/usr/local/etc` in harvest (assuming they aren't binaries or
symlinks) and store in `usr_local_custom` role, similar to `etc_custom`.
-- Miguel Jacq <mig@mig5.net> Thu, 18 Dec 2025 17:07:00 +1100
enroll (0.1.1) unstable; urgency=medium
* Add `diff` subcommand which can compare two harvests and send email or webhook notifications in different
formats.
-- Miguel Jacq <mig@mig5.net> Thu, 18 Dec 2025 15:00:00 +1100
enroll (0.1.0) unstable; urgency=medium
* Add remote mode for harvesting a remote machine via a local workstation (no need to install enroll remotely)
Optionally use `--no-sudo` if you don't want the remote user to have passwordless sudo when conducting the
harvest, albeit you'll end up with less useful data (same as if running `enroll harvest` on a machine without
sudo)
* Add `--dangerous` flag to capture even sensitive data (use at your own risk!)
* Add `--sops` flag which makes the harvest and the manifest 'out' data encrypted as a single SOPS data file.
This would make `--dangerous` a little bit safer, if your intention is just to store the Ansible manifest
in git or somewhere similar for disaster-recovery purposes (e.g encrypted at rest for safe-keeping).
* Do a better job at capturing other config files in `/etc/<package>/` even if that package doesn't normally
ship or manage those files.
* Don't collect files ending in `.log`
-- Miguel Jacq <mig@mig5.net> Wed, 17 Dec 2025 18:00:00 +1100
enroll (0.0.5) unstable; urgency=medium
* Use JinjaTurtle to generate dynamic template/inventory if it's on the PATH
* Support --fqdn flag for site-specific inventory and an inventory hosts file
* Generate an ansible.cfg if not present, to support host_vars plugin and other params
* Be more permissive with files that we previously thought contained secrets (ignore commented lines)
-- Miguel Jacq <mig@mig5.net> Tue, 16 Dec 2025 12:00:00 +1100
enroll (0.0.4) unstable; urgency=medium
* Fix dash package detection issue
* Reorder which roles install first
-- Miguel Jacq <mig@mig5.net> Mon, 15 Dec 2025 17:00:00 +1100
enroll (0.0.3) unstable; urgency=medium
* Initial package
-- Miguel Jacq <mig@mig5.net> Mon, 15 Dec 2025 12:00:00 +1100

22
debian/control vendored Normal file
View file

@ -0,0 +1,22 @@
Source: enroll
Section: admin
Priority: optional
Maintainer: Miguel Jacq <mig@mig5.net>
Rules-Requires-Root: no
Build-Depends:
debhelper-compat (= 13),
dh-python,
pybuild-plugin-pyproject,
python3-all,
python3-yaml,
python3-poetry-core,
python3-paramiko,
python3-jsonschema
Standards-Version: 4.6.2
Homepage: https://git.mig5.net/mig5/enroll
Package: enroll
Architecture: all
Depends: ${misc:Depends}, ${python3:Depends}, python3-yaml, python3-paramiko, python3-jsonschema
Description: Harvest a host into Ansible roles
A tool that inspects a system and emits Ansible roles/playbooks to reproduce it.

6
debian/rules vendored Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/make -f
export PYBUILD_NAME=enroll
export PYBUILD_SYSTEM=pyproject
%:
dh $@ --with python3 --buildsystem=pybuild

1
debian/source/format vendored Normal file
View file

@ -0,0 +1 @@
3.0 (quilt)

6
debian/source/options vendored Normal file
View file

@ -0,0 +1,6 @@
tar-ignore = ".git"
tar-ignore = ".venv"
tar-ignore = "__pycache__"
tar-ignore = ".pytest_cache"
tar-ignore = "dist"
tar-ignore = "build"

View file

@ -109,4 +109,3 @@
<tspan class="text-dark">en</tspan><tspan class="text-light">roll</tspan>
</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before After
Before After

View file

@ -1,8 +1,55 @@
from __future__ import annotations
import configparser
import os
from dataclasses import dataclass
from typing import Dict, List, Set, Tuple
import re
import stat
import shutil
import subprocess # nosec
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
from .fsutil import (
is_dir_no_symlink_components,
open_no_follow_path,
path_has_symlink_component,
)
@dataclass
class FlatpakInstall:
name: str
method: str
remote: Optional[str] = None
branch: Optional[str] = None
arch: Optional[str] = None
kind: Optional[str] = None
ref: Optional[str] = None
user: Optional[str] = None
home: Optional[str] = None
source: str = "filesystem"
@dataclass
class FlatpakRemote:
name: str
method: str
url: str
user: Optional[str] = None
home: Optional[str] = None
source: str = "filesystem"
@dataclass
class SnapInstall:
name: str
channel: Optional[str] = None
revision: Optional[int] = None
classic: bool = False
devmode: bool = False
dangerous: bool = False
notes: List[str] = field(default_factory=list)
source: str = "snap-list"
@dataclass
@ -16,6 +63,7 @@ class UserRecord:
primary_group: str
supplementary_groups: List[str]
ssh_files: List[str]
flatpaks: List[FlatpakInstall] = field(default_factory=list)
def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]:
@ -105,7 +153,12 @@ def is_human_user(uid: int, shell: str, uid_min: int) -> bool:
def find_user_ssh_files(home: str) -> List[str]:
sshdir = os.path.join(home, ".ssh")
out: List[str] = []
if not os.path.isdir(sshdir):
# ``os.path.isdir`` follows symlinks, so a user who replaces ``~/.ssh``
# with a link to a sensitive directory (e.g. /etc/ssl/private) could
# otherwise have a regular file inside it harvested through the symlinked
# parent. Refuse a symlinked .ssh outright; capture_file() applies the
# same parent-symlink protection at copy time as defense in depth.
if os.path.islink(sshdir) or not os.path.isdir(sshdir):
return out
ak = os.path.join(sshdir, "authorized_keys")
@ -115,6 +168,642 @@ def find_user_ssh_files(home: str) -> List[str]:
return sorted(set(out))
def _read_first_existing_text(
paths: List[str], *, max_bytes: int = 8192
) -> Optional[str]:
"""Read the first small regular text file without following symlinks.
Per-user Flatpak metadata lives under user-controlled home directories.
When Enroll is run as root, plain ``open()`` would let a user replace
``active/origin`` or ``repo/config`` with a symlink to a privileged file and
have its contents copied into state.json. Use the same no-symlink component
invariant as the normal harvester, require a regular file, and cap reads to
avoid device/large-file DoS.
"""
for path in paths:
fd: Optional[int] = None
try:
fd = open_no_follow_path(path)
st = os.fstat(fd)
if (
not stat.S_ISREG(st.st_mode)
or st.st_nlink > 1
or st.st_size > max_bytes
):
continue
data = os.read(fd, max_bytes + 1)
if len(data) > max_bytes:
continue
value = data.decode("utf-8", errors="replace").strip()
if value:
return value
except OSError:
continue
finally:
if fd is not None:
try:
os.close(fd)
except OSError:
pass
return None
def _parse_flatpak_ref(
ref: str,
) -> Tuple[Optional[str], str, Optional[str], Optional[str]]:
"""Return (kind, name, arch, branch) for a Flatpak ref.
refs look like app/org.example.App/x86_64/stable or
runtime/org.example.Platform/x86_64/23.08. If the value is already just an
application/runtime ID, keep it as the name and leave the other fields empty.
"""
parts = [p for p in (ref or "").strip().split("/") if p]
if len(parts) >= 4 and parts[0] in {"app", "runtime"}:
return parts[0], parts[1], parts[2], parts[3]
return None, (ref or "").strip(), None, None
def _parse_plain_flatpak_list_output(
output: str,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakInstall]:
"""Parse default `flatpak list` table output.
Example:
Name Application ID Version Branch Installation
OnionShare org.onionshare.OnionShare 2.6.4 stable system
"""
out: List[FlatpakInstall] = []
seen: Set[
Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]
] = set()
id_re = re.compile(r"\b(?:[A-Za-z0-9_-]+\.)+[A-Za-z0-9_-]+\b")
for line in output.splitlines():
line = line.rstrip()
if not line.strip():
continue
if "Application ID" in line and "Installation" in line:
continue
match = id_re.search(line)
if not match:
continue
name = match.group(0)
tail = line[match.end() :].split()
installation = tail[-1] if tail else ""
if installation in {"system", "user"} and installation != method:
continue
branch = None
if len(tail) >= 2 and tail[-1] in {"system", "user"}:
branch = tail[-2]
elif tail:
branch = tail[-1]
key = (name, None, branch, None, None)
if key in seen:
continue
seen.add(key)
out.append(
FlatpakInstall(
name=name,
method=method,
remote=None,
branch=branch,
arch=None,
kind=None,
ref=None,
user=user,
home=home,
source="flatpak-list",
)
)
return sorted(out, key=lambda f: (f.name, f.branch or ""))
def _parse_flatpak_list_output(
output: str,
*,
method: str,
columns: Optional[Tuple[str, ...]] = None,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakInstall]:
"""Parse Flatpak list output.
If columns is None, parse the default table. Otherwise columns names must
match the order passed to `flatpak list --columns=...`.
"""
if columns is None:
return _parse_plain_flatpak_list_output(
output, method=method, user=user, home=home
)
out: List[FlatpakInstall] = []
seen: Set[
Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]
] = set()
for line in output.splitlines():
line = line.strip()
if not line:
continue
lower = line.lower()
if lower.startswith("ref") or lower.startswith("application id"):
continue
parts = line.split("\t")
if len(parts) < len(columns):
parts = line.split()
if not parts:
continue
fields = {
name: parts[idx].strip()
for idx, name in enumerate(columns)
if idx < len(parts)
}
ref = fields.get("ref") or fields.get("application") or ""
kind, name, ref_arch, ref_branch = _parse_flatpak_ref(ref)
if not name:
continue
remote = fields.get("origin") or None
branch = fields.get("branch") or ref_branch
arch = fields.get("arch") or ref_arch
if remote in {"", "-"}:
remote = None
if branch in {"", "-"}:
branch = None
if arch in {"", "-"}:
arch = None
key = (name, remote, branch, arch, kind)
if key in seen:
continue
seen.add(key)
out.append(
FlatpakInstall(
name=name,
method=method,
remote=remote,
branch=branch,
arch=arch,
kind=kind,
ref=ref if "/" in ref else None,
user=user,
home=home,
source="flatpak-list",
)
)
return sorted(
out,
key=lambda f: (
f.kind or "",
f.name,
f.remote or "",
f.branch or "",
f.arch or "",
),
)
_KNOWN_FLATPAK_LIST_COLUMNS = {
"name",
"description",
"application",
"version",
"branch",
"arch",
"origin",
"installation",
"ref",
"active",
"latest",
"size",
"options",
}
def _parse_flatpak_columns_help(output: str) -> Set[str]:
"""Parse `flatpak list --columns=help` output into supported fields."""
supported: Set[str] = set()
for line in output.splitlines():
# Help output varies a bit between Flatpak versions. Treat any known
# token as a supported field, whether it appears alone or in a
# description table.
for token in re.findall(r"[A-Za-z_][A-Za-z0-9_-]*", line.lower()):
if token in _KNOWN_FLATPAK_LIST_COLUMNS:
supported.add(token)
return supported
def _run_flatpak_columns_help() -> Optional[Set[str]]:
if shutil.which("flatpak") is None:
return None
try:
proc = subprocess.run( # nosec
["flatpak", "list", "--columns=help"],
shell=False,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except Exception:
return None
if proc.returncode != 0:
return None
supported = _parse_flatpak_columns_help(proc.stdout or "")
return supported or None
def _flatpak_list_attempts(
scope: str, supported: Optional[Set[str]]
) -> List[Tuple[List[str], Optional[Tuple[str, ...]]]]:
def supported_columns(*wanted: str) -> Optional[Tuple[str, ...]]:
if supported is not None and not set(wanted).issubset(supported):
return None
return tuple(wanted)
column_sets: List[Tuple[str, ...]] = []
for wanted in (
("application", "origin", "branch", "arch"),
("application", "branch", "arch"),
("application", "branch"),
("application",),
("ref", "origin", "branch", "arch"),
("ref", "branch", "arch"),
("ref", "branch"),
("ref",),
):
cols = supported_columns(*wanted)
if cols is not None and cols not in column_sets:
column_sets.append(cols)
attempts: List[Tuple[List[str], Optional[Tuple[str, ...]]]] = [
(
["flatpak", "list", scope, "--columns=" + ",".join(cols)],
cols,
)
for cols in column_sets
]
attempts.append((["flatpak", "list", scope], None))
return attempts
def _run_flatpak_list(method: str) -> Optional[Tuple[str, Optional[Tuple[str, ...]]]]:
if shutil.which("flatpak") is None:
return None
scope = "--system" if method == "system" else "--user"
supported = _run_flatpak_columns_help()
for args, columns in _flatpak_list_attempts(scope, supported):
try:
proc = subprocess.run( # nosec
args,
shell=False,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except Exception: # nosec B112
continue
if proc.returncode == 0:
return proc.stdout or "", columns
return None
def _flatpak_remote_from_ref(
flatpak_root: str, app_id: str, arch: str, branch: str, remote_names: List[str]
) -> Optional[str]:
for remote_name in remote_names:
ref = os.path.join(
flatpak_root,
"repo",
"refs",
"remotes",
remote_name,
"app",
app_id,
arch,
branch,
)
if not path_has_symlink_component(ref) and os.path.exists(ref):
return remote_name
return None
def _parse_flatpak_deploy_origin(branch_dir: str) -> Optional[str]:
active_dir = os.path.join(branch_dir, "active")
candidates = [
os.path.join(active_dir, "origin"),
os.path.join(active_dir, "metadata"),
]
origin = _read_first_existing_text([candidates[0]])
if origin:
return origin
metadata = _read_first_existing_text([candidates[1]])
if metadata:
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read_string(metadata)
except Exception:
return None
for section in ("Application", "Runtime"):
if parser.has_option(section, "origin"):
value = parser.get(section, "origin", fallback="").strip()
if value:
return value
return None
def _find_flatpaks_in_root(
flatpak_root: str,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakInstall]:
apps_dir = os.path.join(flatpak_root, "app")
if not is_dir_no_symlink_components(apps_dir):
return []
remote_names = [
r.name
for r in find_flatpak_remotes(flatpak_root, method=method, user=user, home=home)
]
out: List[FlatpakInstall] = []
try:
app_ids = sorted(os.listdir(apps_dir))
except OSError:
return []
seen: Set[Tuple[str, Optional[str], Optional[str], Optional[str]]] = set()
for app_id in app_ids:
app_path = os.path.join(apps_dir, app_id)
if not is_dir_no_symlink_components(app_path):
continue
try:
arches = sorted(os.listdir(app_path))
except OSError:
continue
for arch in arches:
arch_path = os.path.join(app_path, arch)
if not is_dir_no_symlink_components(arch_path):
continue
try:
branches = sorted(os.listdir(arch_path))
except OSError:
continue
for branch in branches:
branch_path = os.path.join(arch_path, branch)
if not is_dir_no_symlink_components(branch_path):
continue
active_dir = os.path.join(branch_path, "active")
if not is_dir_no_symlink_components(active_dir):
continue
remote = _parse_flatpak_deploy_origin(branch_path)
if not remote:
remote = _flatpak_remote_from_ref(
flatpak_root, app_id, arch, branch, remote_names
)
key = (app_id, remote, branch, arch)
if key in seen:
continue
seen.add(key)
out.append(
FlatpakInstall(
name=app_id,
method=method,
remote=remote,
branch=branch or None,
arch=arch or None,
kind="app",
ref=f"app/{app_id}/{arch}/{branch}",
user=user,
home=home,
)
)
return sorted(
out, key=lambda f: (f.name, f.remote or "", f.branch or "", f.arch or "")
)
def find_flatpak_remotes(
flatpak_root: str,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakRemote]:
"""Return configured Flatpak remotes for a Flatpak installation root.
Flatpak stores remotes in the OSTree repo config. This gives us the remote
names and repository URLs. It does not reliably preserve the original
.flatpakref/.flatpakrepo URL that was used during installation.
"""
config_path = os.path.join(flatpak_root, "repo", "config")
config_text = _read_first_existing_text([config_path])
if not config_text:
return []
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
parser.read_string(config_text)
except Exception:
return []
out: List[FlatpakRemote] = []
for section in parser.sections():
match = re.fullmatch(r'remote\s+"(.+)"', section)
if not match:
continue
name = match.group(1).strip()
url = parser.get(section, "url", fallback="").strip()
if not name or not url:
continue
out.append(
FlatpakRemote(
name=name,
method=method,
url=url,
user=user,
home=home,
)
)
return sorted(out, key=lambda r: (r.method, r.user or "", r.name))
def find_user_flatpaks(home: str, user: Optional[str] = None) -> List[FlatpakInstall]:
"""Return per-user Flatpak applications installed under a home directory."""
flatpak_root = os.path.join(home, ".local", "share", "flatpak")
return _find_flatpaks_in_root(flatpak_root, method="user", user=user, home=home)
def find_user_flatpak_remotes(
home: str, user: Optional[str] = None
) -> List[FlatpakRemote]:
flatpak_root = os.path.join(home, ".local", "share", "flatpak")
return find_flatpak_remotes(flatpak_root, method="user", user=user, home=home)
def find_system_flatpaks() -> List[FlatpakInstall]:
"""Return Flatpak refs installed system-wide.
Prefer `flatpak list --system` because it is Flatpak's own view of
installed refs and includes layouts the filesystem scanner might miss.
Fall back to the on-disk app deployment tree when the command is
unavailable or produces unparsable output.
"""
listing = _run_flatpak_list("system")
if listing is not None:
output, columns = listing
parsed = _parse_flatpak_list_output(output, method="system", columns=columns)
if parsed or not output.strip():
return parsed
return _find_flatpaks_in_root("/var/lib/flatpak", method="system")
def find_system_flatpak_remotes() -> List[FlatpakRemote]:
return find_flatpak_remotes("/var/lib/flatpak", method="system")
def _parse_snap_notes(notes: str) -> List[str]:
if not notes or notes == "-":
return []
cleaned = notes.replace(",", " ").replace(";", " ")
return sorted(
{n.strip().lower() for n in cleaned.split() if n.strip() and n.strip() != "-"}
)
def _parse_snap_list_output(output: str) -> List[SnapInstall]:
out: List[SnapInstall] = []
for idx, line in enumerate(output.splitlines()):
line = line.strip()
if not line:
continue
if idx == 0 and line.lower().startswith("name"):
continue
parts = line.split(maxsplit=5)
if len(parts) < 5:
continue
name = parts[0]
revision: Optional[int]
try:
revision = int(parts[2])
except ValueError:
revision = None
tracking = parts[3]
channel = None if tracking in {"-", ""} else tracking
notes = _parse_snap_notes(parts[5] if len(parts) > 5 else "")
out.append(
SnapInstall(
name=name,
channel=channel,
revision=revision,
classic="classic" in notes,
devmode="devmode" in notes,
dangerous="dangerous" in notes,
notes=notes,
source="snap-list",
)
)
return sorted(out, key=lambda s: s.name)
def _run_snap_list() -> Optional[str]:
if shutil.which("snap") is None:
return None
try:
proc = subprocess.run( # nosec
["snap", "list"],
shell=False,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except Exception:
return None
if proc.returncode != 0:
return None
return proc.stdout or ""
def _find_system_snaps_from_filesystem() -> List[SnapInstall]:
snapd_snaps = "/var/lib/snapd/snaps"
if not os.path.isdir(snapd_snaps):
return []
current_revisions: Dict[str, int] = {}
snap_mounts = "/snap"
if os.path.isdir(snap_mounts):
try:
mount_names = os.listdir(snap_mounts)
except OSError:
mount_names = []
for name in mount_names:
current = os.path.join(snap_mounts, name, "current")
try:
target = os.readlink(current)
except OSError:
continue
try:
current_revisions[name] = int(os.path.basename(target.rstrip("/")))
except ValueError:
continue
candidates: Dict[str, List[int]] = {}
try:
entries = os.listdir(snapd_snaps)
except OSError:
return []
for entry in entries:
if not entry.endswith(".snap") or "_" not in entry:
continue
name, rev_text = entry[:-5].rsplit("_", 1)
try:
revision = int(rev_text)
except ValueError:
continue
candidates.setdefault(name, []).append(revision)
out: List[SnapInstall] = []
for name, revisions in candidates.items():
revision = current_revisions.get(name)
if revision is None:
revision = max(revisions)
out.append(SnapInstall(name=name, revision=revision, source="filesystem"))
return sorted(out, key=lambda s: s.name)
def find_system_snaps() -> List[SnapInstall]:
"""Return system-wide snap packages.
Prefer `snap list` because it exposes channel tracking and confinement notes.
Fall back to snapd's on-disk snap filenames when the command is unavailable.
"""
output = _run_snap_list()
if output is not None:
parsed = _parse_snap_list_output(output)
if parsed:
return parsed
return _find_system_snaps_from_filesystem()
def collect_non_system_users() -> List[UserRecord]:
defs = parse_login_defs()
uid_min = defs.get("UID_MIN", 1000)
@ -139,6 +828,10 @@ def collect_non_system_users() -> List[UserRecord]:
ssh_files = find_user_ssh_files(home) if home and home.startswith("/") else []
flatpaks: List[FlatpakInstall] = []
if home and home.startswith("/"):
flatpaks = find_user_flatpaks(home, user=name)
users.append(
UserRecord(
name=name,
@ -150,6 +843,7 @@ def collect_non_system_users() -> List[UserRecord]:
primary_group=primary_group,
supplementary_groups=supp,
ssh_files=ssh_files,
flatpaks=flatpaks,
)
)

2511
enroll/ansible.py Normal file

File diff suppressed because it is too large Load diff

82
enroll/cache.py Normal file
View file

@ -0,0 +1,82 @@
from __future__ import annotations
import os
import re
import tempfile
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
from .harvest_safety import OutputSafetyError, ensure_private_dir
def _safe_component(s: str) -> str:
s = s.strip()
if not s:
return "unknown"
s = re.sub(r"[^A-Za-z0-9_.-]+", "_", s)
s = re.sub(r"_+", "_", s)
return s[:64]
def enroll_cache_dir() -> Path:
"""Return the base cache directory for enroll.
We default to ~/.local/cache to match common Linux conventions in personal
homedirs, but honour XDG_CACHE_HOME if set.
"""
base = os.environ.get("XDG_CACHE_HOME")
if base:
root = Path(base).expanduser()
else:
root = Path.home() / ".local" / "cache"
return root / "enroll"
@dataclass(frozen=True)
class HarvestCache:
"""A locally-persistent directory that holds a harvested bundle."""
dir: Path
@property
def state_json(self) -> Path:
return self.dir / "state.json"
def _ensure_dir_secure(path: Path) -> None:
"""Create a private cache directory with output-path safety checks.
Cache roots are persistent, so existing directories are allowed, but they
still need the same symlink-component and root-parent trust checks as
plaintext harvest/manifest output paths.
"""
try:
ensure_private_dir(path, label="cache directory")
except OutputSafetyError as e:
raise RuntimeError(str(e)) from e
def new_harvest_cache_dir(*, hint: Optional[str] = None) -> HarvestCache:
"""Create a new, unpredictable harvest directory under the user's cache.
This mitigates pre-guessing attacks (e.g. an attacker creating a directory
in advance in a shared temp location) by creating the bundle directory under
the user's home and using mkdtemp() randomness.
"""
base = enroll_cache_dir() / "harvest"
_ensure_dir_secure(base)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
safe = _safe_component(hint or "harvest")
prefix = f"{ts}-{safe}-"
# mkdtemp creates a new directory with a random suffix.
d = Path(tempfile.mkdtemp(prefix=prefix, dir=str(base)))
try:
os.chmod(d, 0o700)
except OSError:
pass
return HarvestCache(dir=d)

357
enroll/capture.py Normal file
View file

@ -0,0 +1,357 @@
from __future__ import annotations
import os
import errno
import stat
from typing import List, Optional, Set
from .fsutil import open_no_follow_path, stat_triplet, stat_triplet_from_stat
from .harvest_types import ExcludedFile, ManagedFile, ManagedLink
from .ignore import IgnorePolicy
from .pathfilter import PathFilter
def files_differ(a: str, b: str, *, max_bytes: int = 2_000_000) -> bool:
"""Return True if file ``a`` differs from file ``b``.
Best-effort and conservative: unreadable/missing baselines, non-regular
files, and unexpectedly large files are treated as different so callers err
on the side of preserving user state.
"""
try:
st_a = os.stat(a, follow_symlinks=True)
except OSError:
return True
if not stat.S_ISREG(st_a.st_mode):
return True
try:
st_b = os.stat(b, follow_symlinks=True)
except OSError:
return True
if not stat.S_ISREG(st_b.st_mode):
return True
if st_a.st_size != st_b.st_size:
return True
if st_a.st_size > max_bytes:
return True
try:
with open(a, "rb") as fa, open(b, "rb") as fb:
while True:
ca = fa.read(1024 * 64)
cb = fb.read(1024 * 64)
if ca != cb:
return True
if not ca:
return False
except OSError:
return True
def _open_no_follow_write(path: str, mode: int = 0o600) -> int:
return open_no_follow_path(path, write=True, mode=mode)
def write_bytes_into_bundle(
bundle_dir: str, role_name: str, src_rel: str, data: bytes
) -> None:
dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
fd = -1
try:
fd = _open_no_follow_write(dst, 0o600)
with os.fdopen(fd, "wb") as f:
fd = -1
f.write(data)
try:
os.chmod(dst, 0o600)
except OSError:
pass
finally:
if fd >= 0:
os.close(fd)
def copy_into_bundle(
bundle_dir: str, role_name: str, abs_path: str, src_rel: str
) -> None:
"""Legacy safe copy helper used by tests and non-IgnorePolicy callers.
Real harvests using IgnorePolicy copy the exact bytes read from the safely
opened source file in capture_file(). This helper still refuses source
symlinks at copy time and refuses destination symlink overwrites.
"""
fd = -1
try:
try:
fd = open_no_follow_path(abs_path)
except OSError as e:
if e.errno in {errno.ELOOP, errno.ENOTDIR}:
raise OSError("refusing to copy symlink source") from e
raise
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
raise OSError("refusing to copy non-regular source")
if st.st_nlink > 1:
raise OSError("refusing to copy hardlinked source")
chunks: list[bytes] = []
while True:
chunk = os.read(fd, 1024 * 1024)
if not chunk:
break
chunks.append(chunk)
write_bytes_into_bundle(bundle_dir, role_name, src_rel, b"".join(chunks))
finally:
if fd >= 0:
os.close(fd)
def capture_file(
*,
bundle_dir: str,
role_name: str,
abs_path: str,
reason: str,
policy: IgnorePolicy,
path_filter: PathFilter,
managed_out: List[ManagedFile],
excluded_out: List[ExcludedFile],
seen_role: Optional[Set[str]] = None,
seen_global: Optional[Set[str]] = None,
metadata: Optional[tuple[str, str, str]] = None,
) -> bool:
"""Try to capture a single file into the bundle.
Returns True if the file was copied and appended to ``managed_out``.
``seen_role`` de-duplicates within a role; ``seen_global`` de-duplicates
across harvest stages so multiple generated roles do not manage one path.
"""
if seen_global is not None and abs_path in seen_global:
return False
if seen_role is not None and abs_path in seen_role:
return False
def _mark_seen() -> None:
if seen_role is not None:
seen_role.add(abs_path)
if seen_global is not None:
seen_global.add(abs_path)
if path_filter.is_excluded(abs_path):
excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
_mark_seen()
return False
inspection = None
inspect_file = getattr(policy, "inspect_file", None)
if callable(inspect_file):
inspected = inspect_file(abs_path)
if isinstance(inspected, tuple) and len(inspected) == 2:
deny, inspection = inspected
else:
# Some tests and third-party callers use MagicMock/spec policies that
# expose inspect_file but have not configured it. Fall back to the
# legacy deny_reason/copy path for those non-real policies.
deny = policy.deny_reason(abs_path)
else:
deny = policy.deny_reason(abs_path)
if deny:
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
_mark_seen()
return False
try:
if inspection is not None:
# Prefer the stat taken from the no-follow descriptor that was
# actually inspected and whose bytes are about to be written. A
# caller-supplied ``metadata`` triplet (see below) may have been
# derived from a separate, symlink-following stat with its own
# time-of-check/time-of-use window, so it must not override the
# authoritative descriptor stat when we have one.
owner, group, mode = stat_triplet_from_stat(inspection.stat_result)
elif metadata is not None:
# Fallback for callers that pre-computed metadata and are not using a
# real IgnorePolicy that returns a FileInspection (e.g. tests, or the
# /usr/local scanner when inspection is unavailable). This value is a
# convenience/perf optimisation only; it never widens what gets
# captured, since inspection (secret scan, no-follow, size/hardlink
# checks) has already run above.
owner, group, mode = metadata
else:
owner, group, mode = stat_triplet(abs_path)
except OSError:
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
_mark_seen()
return False
src_rel = abs_path.lstrip("/")
try:
if inspection is not None:
write_bytes_into_bundle(bundle_dir, role_name, src_rel, inspection.data)
else:
copy_into_bundle(bundle_dir, role_name, abs_path, src_rel)
except OSError:
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
_mark_seen()
return False
managed_out.append(
ManagedFile(
path=abs_path,
src_rel=src_rel,
owner=owner,
group=group,
mode=mode,
reason=reason,
)
)
_mark_seen()
return True
USER_SHELL_DOTFILES_WITH_SKEL_BASELINE = [
(".bashrc", "user_shell_rc"),
(".profile", "user_profile"),
(".bash_logout", "user_shell_logout"),
]
USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE = [
(".bash_aliases", "user_shell_aliases"),
]
def capture_user_shell_dotfiles(
*,
bundle_dir: str,
role_name: str,
home: str,
skel_dir: str,
enabled: bool,
policy: IgnorePolicy,
path_filter: PathFilter,
managed_out: List[ManagedFile],
excluded_out: List[ExcludedFile],
seen_role: Optional[Set[str]],
seen_global: Optional[Set[str]],
) -> int:
"""Capture selected per-user shell dotfiles when explicitly enabled."""
if not enabled:
return 0
home = (home or "").rstrip("/")
if not home or not home.startswith("/"):
return 0
captured = 0
max_compare_bytes = int(getattr(policy, "max_file_bytes", 256_000))
for rel, reason in USER_SHELL_DOTFILES_WITH_SKEL_BASELINE:
upath = os.path.join(home, rel)
if not os.path.isfile(upath) or os.path.islink(upath):
continue
skel_path = os.path.join(skel_dir, rel)
if not files_differ(upath, skel_path, max_bytes=max_compare_bytes):
continue
if capture_file(
bundle_dir=bundle_dir,
role_name=role_name,
abs_path=upath,
reason=reason,
policy=policy,
path_filter=path_filter,
managed_out=managed_out,
excluded_out=excluded_out,
seen_role=seen_role,
seen_global=seen_global,
):
captured += 1
for rel, reason in USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE:
upath = os.path.join(home, rel)
if not os.path.isfile(upath) or os.path.islink(upath):
continue
if capture_file(
bundle_dir=bundle_dir,
role_name=role_name,
abs_path=upath,
reason=reason,
policy=policy,
path_filter=path_filter,
managed_out=managed_out,
excluded_out=excluded_out,
seen_role=seen_role,
seen_global=seen_global,
):
captured += 1
return captured
def capture_link(
*,
role_name: str,
abs_path: str,
reason: str,
policy: IgnorePolicy,
path_filter: PathFilter,
managed_out: List[ManagedLink],
excluded_out: List[ExcludedFile],
seen_role: Optional[Set[str]] = None,
seen_global: Optional[Set[str]] = None,
) -> bool:
"""Record a symlink for later materialisation by the manifest renderer."""
if seen_global is not None and abs_path in seen_global:
return False
if seen_role is not None and abs_path in seen_role:
return False
def _mark_seen() -> None:
if seen_role is not None:
seen_role.add(abs_path)
if seen_global is not None:
seen_global.add(abs_path)
if path_filter.is_excluded(abs_path):
excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
_mark_seen()
return False
deny_link = getattr(policy, "deny_reason_link", None)
if callable(deny_link):
deny = deny_link(abs_path)
else:
deny = policy.deny_reason(abs_path)
if deny in ("not_regular_file", "not_file", "not_regular"):
deny = None
if deny:
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
_mark_seen()
return False
if not os.path.islink(abs_path):
excluded_out.append(ExcludedFile(path=abs_path, reason="not_symlink"))
_mark_seen()
return False
try:
target = os.readlink(abs_path)
except OSError:
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
_mark_seen()
return False
managed_out.append(ManagedLink(path=abs_path, target=target, reason=reason))
_mark_seen()
return True

File diff suppressed because it is too large Load diff

933
enroll/cm.py Normal file
View file

@ -0,0 +1,933 @@
from __future__ import annotations
import re
import shlex
from dataclasses import dataclass, field
from pathlib import Path
from typing import (
Any,
Callable,
ClassVar,
Dict,
Iterable,
Iterator,
List,
Mapping,
Set,
)
from .state import load_state, state_path, write_state
@dataclass
class CMModule:
"""Renderer-neutral configuration-management resource group.
A CMModule is intentionally small: it captures the resources that the
renderer turns into Ansible tasks. The renderer may still decide how to
name/include/order the group.
"""
role_name: str
module_name: str
packages: Set[str] = field(default_factory=set)
groups: Set[str] = field(default_factory=set)
users: Dict[str, Dict[str, Any]] = field(default_factory=dict)
dirs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
files: Dict[str, Dict[str, Any]] = field(default_factory=dict)
links: Dict[str, Dict[str, Any]] = field(default_factory=dict)
services: Dict[str, Dict[str, Any]] = field(default_factory=dict)
firewall_runtime: Dict[str, Any] = field(default_factory=dict)
notes: List[str] = field(default_factory=list)
managed_owner_attr: ClassVar[str] = "owner"
firewall_runtime_dir: ClassVar[str] = "/etc/enroll/firewall"
firewall_runtime_artifacts: ClassVar[tuple[tuple[str, str, str], ...]] = (
("ipset_save", "ipset.save", "0600"),
("iptables_v4_save", "iptables.v4", "0600"),
("iptables_v6_save", "iptables.v6", "0600"),
)
def has_core_resources(self) -> bool:
return bool(
self.packages
or self.groups
or self.users
or self.dirs
or self.files
or self.links
or self.services
or self.firewall_runtime
or self.notes
)
def has_resources(self) -> bool:
return self.has_core_resources()
def has_resources_or_attrs(self, *attrs: str) -> bool:
"""Return true if core resources or named renderer extras are present."""
return self.has_core_resources() or any(
bool(getattr(self, attr, None)) for attr in attrs
)
@staticmethod
def state_path(bundle_dir: str | Path) -> Path:
"""Return the canonical state.json path for a harvest bundle."""
return state_path(bundle_dir)
@classmethod
def load_state(cls, bundle_dir: str | Path) -> Dict[str, Any]:
"""Load state.json for a renderer using the shared bundle state loader."""
return load_state(bundle_dir)
@classmethod
def _load_state(cls, bundle_dir: str | Path) -> Dict[str, Any]:
"""Backward-compatible alias for renderer subclasses."""
return cls.load_state(bundle_dir)
@classmethod
def write_state(
cls,
bundle_dir: str | Path,
state: Mapping[str, Any],
*,
indent: int = 2,
sort_keys: bool = True,
) -> Path:
"""Write state.json using the shared bundle state writer."""
return write_state(bundle_dir, state, indent=indent, sort_keys=sort_keys)
@staticmethod
def _snapshot_items(snap: Dict[str, Any], key: str) -> Iterator[Dict[str, Any]]:
values = snap.get(key) or []
if not isinstance(values, list):
return
for item in values:
if isinstance(item, dict):
yield item
@classmethod
def managed_dirs_from_snapshot(
cls, snap: Dict[str, Any]
) -> Iterator[Dict[str, Any]]:
return cls._snapshot_items(snap, "managed_dirs")
@classmethod
def managed_files_from_snapshot(
cls, snap: Dict[str, Any]
) -> Iterator[Dict[str, Any]]:
return cls._snapshot_items(snap, "managed_files")
@classmethod
def managed_links_from_snapshot(
cls, snap: Dict[str, Any]
) -> Iterator[Dict[str, Any]]:
return cls._snapshot_items(snap, "managed_links")
def add_managed_dir(
self,
path: str,
*,
owner: Any = "root",
group: Any = "root",
mode: Any = "0755",
**attrs: Any,
) -> None:
if not path:
return
data: Dict[str, Any] = {
"owner": owner or "root",
"group": group or "root",
"mode": mode or "0755",
}
data.update(attrs)
self.dirs.setdefault(path, data)
def add_managed_file(
self,
path: str,
*,
owner: Any = "root",
group: Any = "root",
mode: Any = "0644",
**attrs: Any,
) -> None:
if not path:
return
data: Dict[str, Any] = {
"owner": owner or "root",
"group": group or "root",
"mode": mode or "0644",
}
data.update(attrs)
self.files.setdefault(path, data)
def add_managed_link(self, path: str, **attrs: Any) -> None:
if path:
self.links.setdefault(path, attrs)
def add_snapshot_notes(self, snap: Dict[str, Any]) -> None:
self.notes.extend(str(n) for n in (snap.get("notes", []) or []))
@staticmethod
def package_name_from_snapshot(snap: Dict[str, Any]) -> str:
return str(snap.get("package") or "").strip()
@staticmethod
def package_names_from_snapshot(snap: Dict[str, Any]) -> Iterator[str]:
for pkg in snap.get("packages", []) or []:
pkg_s = str(pkg or "").strip()
if pkg_s:
yield pkg_s
def add_package_snapshot(self, snap: Dict[str, Any]) -> None:
pkg = self.package_name_from_snapshot(snap)
if pkg:
self.packages.add(pkg)
def add_service_packages_from_snapshot(self, snap: Dict[str, Any]) -> None:
self.packages.update(self.package_names_from_snapshot(snap))
def service_unit_from_snapshot(self, snap: Dict[str, Any]) -> str:
return str(snap.get("unit") or "").strip()
def service_enabled_from_snapshot(self, snap: Dict[str, Any]) -> bool:
unit_file_state = str(snap.get("unit_file_state") or "")
return unit_file_state in ("enabled", "enabled-runtime")
def service_state_from_snapshot(
self,
snap: Dict[str, Any],
*,
running: str,
stopped: str,
) -> str:
return running if snap.get("active_state") == "active" else stopped
def add_service_snapshot_state(
self,
snap: Dict[str, Any],
*,
state_key: str,
running: str,
stopped: str,
include_manage: bool = False,
) -> None:
"""Add the common systemd service parts, parameterised per renderer."""
self.add_service_packages_from_snapshot(snap)
unit = self.service_unit_from_snapshot(snap)
if not unit:
return
data: Dict[str, Any] = {
"name": unit,
state_key: self.service_state_from_snapshot(
snap, running=running, stopped=stopped
),
"enable": self.service_enabled_from_snapshot(snap),
}
if include_manage:
data["manage"] = True
self.services[unit] = data
@staticmethod
def normalise_flatpak_item(
item: Any,
*,
method: str,
user: str | None = None,
home: str | None = None,
) -> Dict[str, Any]:
if isinstance(item, dict):
out = dict(item)
elif isinstance(item, str):
out = {"name": item}
else:
out = {"name": str(item)}
out["method"] = str(out.get("method") or method or "system").strip() or "system"
if user and not out.get("user"):
out["user"] = user
if home and not out.get("home"):
out["home"] = home
ref = str(out.get("ref") or "").strip()
if ref and not out.get("name"):
out["name"] = ref.rsplit("/", 1)[-1]
name = str(out.get("name") or out.get("app_id") or "").strip()
if name:
out["name"] = name
remote = str(out.get("remote") or "").strip()
if remote:
out["remote"] = remote
branch = str(out.get("branch") or out.get("origin") or "").strip()
if branch:
out["branch"] = branch
if ref:
out["ref"] = ref
return out
@staticmethod
def normalise_flatpak_remote(item: Any) -> Dict[str, Any]:
if isinstance(item, dict):
out = dict(item)
else:
out = {"name": str(item)}
name = str(out.get("name") or out.get("remote") or "").strip()
url = str(out.get("url") or out.get("from_url") or "").strip()
method = (
str(out.get("method") or out.get("scope") or "system").strip() or "system"
)
if name:
out["name"] = name
if url:
out["url"] = url
out["method"] = "user" if method == "user" else "system"
return out
@staticmethod
def normalise_snap_item(item: Any) -> Dict[str, Any]:
if isinstance(item, dict):
out = dict(item)
elif isinstance(item, str):
out = {"name": item}
else:
out = {"name": str(item)}
name = str(out.get("name") or "").strip()
if name:
out["name"] = name
channel = str(out.get("tracking") or out.get("channel") or "").strip()
if channel:
out["channel"] = channel
raw_notes = out.get("notes") or []
if isinstance(raw_notes, str):
raw_notes = [raw_notes]
notes = [str(note).lower() for note in raw_notes]
confinement = str(out.get("confinement") or "").strip().lower()
out["classic"] = bool(
out.get("classic")
or confinement == "classic"
or any("classic" in note for note in notes)
)
out["devmode"] = bool(
out.get("devmode")
or any("devmode" in note or "dev mode" in note for note in notes)
)
out["dangerous"] = bool(
out.get("dangerous") or any("dangerous" in note for note in notes)
)
revision = str(out.get("revision") or "").strip()
if revision and not channel:
out["revision"] = revision
return out
def prepare_flatpak_remote(self, item: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
def prepare_flatpak_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
def prepare_snap_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
@staticmethod
def user_records_from_snapshot(snap: Dict[str, Any]) -> List[Dict[str, Any]]:
records: List[Dict[str, Any]] = []
for raw in snap.get("users", []) or []:
if not isinstance(raw, dict):
continue
name = str(raw.get("name") or "").strip()
if not name:
continue
primary_group = str(raw.get("primary_group") or name).strip()
supplementary = sorted(
{
str(group).strip()
for group in (raw.get("supplementary_groups") or [])
if str(group).strip()
}
)
records.append(
{
"name": name,
"uid": raw.get("uid"),
"gid": raw.get("gid"),
"primary_group": primary_group,
"home": raw.get("home") or f"/home/{name}",
"shell": raw.get("shell"),
"gecos": raw.get("gecos"),
"supplementary_groups": supplementary,
}
)
return records
@staticmethod
def user_group_names_from_records(records: Iterable[Mapping[str, Any]]) -> Set[str]:
groups: Set[str] = set()
for record in records:
primary_group = str(record.get("primary_group") or "").strip()
if primary_group:
groups.add(primary_group)
groups.update(
str(group).strip()
for group in (record.get("supplementary_groups") or [])
if str(group).strip()
)
return groups
@staticmethod
def package_service_entries(
roles: Mapping[str, Any],
inventory_packages: Mapping[str, Any],
*,
use_common_roles: bool,
) -> Iterator[Dict[str, Any]]:
for svc in roles.get("services", []) or []:
if not isinstance(svc, dict):
continue
own_label = str(svc.get("role_name") or svc.get("unit") or "service")
role_label = (
section_label_for_packages(
svc.get("packages", []) or [], inventory_packages
)
if use_common_roles
else own_label
)
yield {"kind": "service", "snapshot": svc, "role_label": role_label}
for pkg in roles.get("packages", []) or []:
if not isinstance(pkg, dict):
continue
own_label = str(pkg.get("role_name") or pkg.get("package") or "package")
role_label = (
package_section_label(pkg, inventory_packages)
if use_common_roles
else own_label
)
yield {"kind": "package", "snapshot": pkg, "role_label": role_label}
@staticmethod
def active_service_units_by_package(
entries: Iterable[Mapping[str, Any]],
) -> Dict[str, List[Dict[str, str]]]:
"""Return active service units keyed by the packages that produced them.
Renderers use this when a package-owned managed file should refresh the
service that package provides. The helper is deliberately conservative:
stopped/inactive services are not included, and ambiguous package->many
service mappings are left to the renderer/caller to resolve.
"""
by_package: Dict[str, List[Dict[str, str]]] = {}
for entry in entries:
if str(entry.get("kind") or "package") != "service":
continue
snap = entry.get("snapshot") or {}
if not isinstance(snap, Mapping):
continue
unit = str(snap.get("unit") or "").strip()
if not unit or str(snap.get("active_state") or "") != "active":
continue
role_name = str(snap.get("role_name") or unit).strip()
for pkg in snap.get("packages", []) or []:
package = str(pkg or "").strip()
if package:
by_package.setdefault(package, []).append(
{"unit": unit, "role_name": role_name}
)
for package, services in list(by_package.items()):
seen: Set[str] = set()
unique: List[Dict[str, str]] = []
for svc in services:
unit = svc.get("unit") or ""
if unit and unit not in seen:
seen.add(unit)
unique.append(svc)
by_package[package] = sorted(unique, key=lambda svc: svc.get("unit", ""))
return by_package
@staticmethod
def active_service_units_for_package_snapshot(
package_snapshot: Mapping[str, Any],
service_units_by_package: Mapping[str, List[Dict[str, str]]],
) -> List[str]:
"""Return active service units that a package snapshot can safely refresh.
If one active service is associated with the package, return it. If
several are associated, only return a role-name match; otherwise avoid
guessing and return no services. This prevents package-level config from
recreating the old broad-restart problem.
"""
package = str(package_snapshot.get("package") or "").strip()
if not package:
return []
services = list(service_units_by_package.get(package) or [])
if len(services) == 1:
unit = services[0].get("unit") or ""
return [unit] if unit else []
role_name = str(package_snapshot.get("role_name") or "").strip()
if role_name:
matched = [
svc.get("unit") or ""
for svc in services
if svc.get("role_name") == role_name and svc.get("unit")
]
if matched:
return sorted(set(matched))
return []
def add_user_flatpaks_snapshot(self, snap: Dict[str, Any]) -> None:
home_by_user = {
str(u.get("name")): str(u.get("home") or "")
for u in (snap.get("users", []) or [])
if isinstance(u, dict) and u.get("name")
}
for remote in snap.get("user_flatpak_remotes", []) or []:
item = self.normalise_flatpak_remote(remote)
user = str(item.get("user") or "").strip()
if user and not item.get("home"):
item["home"] = home_by_user.get(user) or f"/home/{user}"
if item.get("method") == "user" and item.get("name") and item.get("url"):
self.flatpak_remotes.append( # type: ignore[attr-defined]
self.prepare_flatpak_remote(item)
)
for uname, flatpaks in (snap.get("user_flatpaks", {}) or {}).items():
user = str(uname)
for fp in flatpaks or []:
item = self.normalise_flatpak_item(
fp, method="user", user=user, home=home_by_user.get(user) or None
)
if item.get("name"):
self.flatpaks.append( # type: ignore[attr-defined]
self.prepare_flatpak_item(item)
)
def add_flatpak_snapshot(self, snap: Dict[str, Any]) -> None:
for remote in snap.get("remotes", []) or []:
item = self.normalise_flatpak_remote(remote)
if item.get("name") and item.get("url"):
self.flatpak_remotes.append( # type: ignore[attr-defined]
self.prepare_flatpak_remote(item)
)
for fp in snap.get("system_flatpaks", []) or []:
item = self.normalise_flatpak_item(fp, method="system")
if item.get("name"):
self.flatpaks.append( # type: ignore[attr-defined]
self.prepare_flatpak_item(item)
)
self.add_snapshot_notes(snap)
def add_snap_snapshot(self, snap: Dict[str, Any]) -> None:
for raw in snap.get("system_snaps", []) or []:
item = self.normalise_snap_item(raw)
if item.get("name"):
self.snaps.append( # type: ignore[attr-defined]
self.prepare_snap_item(item)
)
self.add_snapshot_notes(snap)
def firewall_runtime_snapshot_has_artifacts(self, snap: Mapping[str, Any]) -> bool:
return any(
str(snap.get(key) or "").strip()
for key, _dest, _mode in self.firewall_runtime_artifacts
)
def firewall_runtime_source_refs(self, snap: Mapping[str, Any]) -> Dict[str, str]:
return {
key: str(snap.get(key) or "").strip()
for key, _dest, _mode in self.firewall_runtime_artifacts
if str(snap.get(key) or "").strip()
}
def firewall_runtime_dest_path(self, dest_name: str) -> str:
return f"{self.firewall_runtime_dir}/{dest_name}"
def firewall_runtime_ipset_sets(self, snap: Mapping[str, Any]) -> List[str]:
return [
str(x).strip() for x in (snap.get("ipset_sets") or []) if str(x).strip()
]
@staticmethod
def shell_quote(value: Any) -> str:
return shlex.quote(str(value or ""))
def firewall_ipset_restore_cmd(self, path: str, sets: List[str]) -> str:
flush_parts = [f"ipset flush {self.shell_quote(name)} || true" for name in sets]
flush = "; ".join(flush_parts)
restore = f"ipset restore -exist < {self.shell_quote(path)}"
if flush:
return f"/bin/sh -c {self.shell_quote(flush + '; ' + restore)}"
return f"/bin/sh -c {self.shell_quote(restore)}"
def firewall_runtime_commands(self, runtime: Mapping[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
ipset_path = str(runtime.get("ipset_save") or "")
if ipset_path:
sets = [str(x) for x in (runtime.get("ipset_sets") or []) if str(x)]
out["ipset_restore_cmd"] = self.firewall_ipset_restore_cmd(ipset_path, sets)
ipt4_path = str(runtime.get("iptables_v4_save") or "")
if ipt4_path:
out["iptables_v4_restore_cmd"] = (
f"iptables-restore {self.shell_quote(ipt4_path)}"
)
ipt6_path = str(runtime.get("iptables_v6_save") or "")
if ipt6_path:
out["iptables_v6_restore_cmd"] = (
f"ip6tables-restore {self.shell_quote(ipt6_path)}"
)
return out
def _managed_owner_attrs(self, owner: Any) -> Dict[str, Any]:
return {self.managed_owner_attr: owner or "root"}
def add_firewall_runtime_snapshot(
self,
snap: Dict[str, Any],
*,
bundle_dir: str,
artifact_role: str,
files_dir: Path,
copy_artifact: Callable[..., str | None],
source_uri: Callable[[str, str], str],
file_prefix: str | None = None,
dir_attrs: Mapping[str, Any] | None = None,
file_attrs: Mapping[str, Any] | None = None,
) -> None:
"""Add captured live firewall state using renderer-supplied file hooks."""
self.add_service_packages_from_snapshot(snap)
attrs: Dict[str, Any] = {
**self._managed_owner_attrs("root"),
"group": "root",
"mode": "0750",
"reason": "firewall_runtime",
}
if dir_attrs:
attrs.update(dir_attrs)
self.add_managed_dir(self.firewall_runtime_dir, **attrs)
runtime: Dict[str, Any] = {}
for key, dest_name, mode in self.firewall_runtime_artifacts:
src_rel = str(snap.get(key) or "").strip()
if not src_rel:
continue
role_rel = copy_artifact(
bundle_dir,
artifact_role,
src_rel,
files_dir,
dst_prefix=file_prefix,
)
if not role_rel:
self.notes.append(
f"Firewall runtime artifact {src_rel!r} was referenced but not found."
)
continue
file_data: Dict[str, Any] = {
**self._managed_owner_attrs("root"),
"group": "root",
"mode": mode,
"source": source_uri(self.module_name, role_rel),
"reason": "firewall_runtime",
}
if file_attrs:
file_data.update(file_attrs)
dest = self.firewall_runtime_dest_path(dest_name)
self.add_managed_file(dest, **file_data)
runtime[key] = dest
ipset_sets = self.firewall_runtime_ipset_sets(snap)
if ipset_sets:
runtime["ipset_sets"] = ipset_sets
if runtime:
runtime.update(self.firewall_runtime_commands(runtime))
self.firewall_runtime.update(runtime)
self.add_snapshot_notes(snap)
def remove_directory_resource_conflicts(self) -> None:
for path in set(self.files) | set(self.links):
self.dirs.pop(path, None)
def package_section_label(
package_role: Dict[str, Any], inventory_packages: Dict[str, Any]
) -> str:
"""Return the Debian Section/RPM Group label for a package role."""
pkg = str(package_role.get("package") or "").strip()
inv = inventory_packages.get(pkg) or {}
candidates: List[str] = []
for value in (package_role.get("section"), inv.get("section"), inv.get("group")):
if isinstance(value, str) and value.strip():
candidates.append(value.strip())
for inst in inv.get("installations", []) or []:
if not isinstance(inst, dict):
continue
for key in ("section", "group"):
value = inst.get(key)
if isinstance(value, str) and value.strip():
candidates.append(value.strip())
for value in candidates:
if value.lower() not in {"(none)", "none", "unspecified"}:
return value
return "misc"
def section_label_for_packages(
packages: List[str], inventory_packages: Dict[str, Any]
) -> str:
"""Return a stable section/group label for a set of packages."""
for pkg in packages or []:
label = package_section_label({"package": pkg}, inventory_packages)
if label and label.lower() != "misc":
return label
return "misc"
def role_order_key(role: str) -> tuple[int, str]:
# Keep broadly similar ordering to generated Ansible playbooks: package/config
# scaffolding first, then services/users, then host-specific runtime state.
priority = {
"apt_config": 10,
"dnf_config": 11,
"etc_custom": 80,
"usr_local_custom": 81,
"extra_paths": 82,
"container_images": 88,
"users": 90,
"enroll_runtime": 94,
"sysctl": 95,
"firewall_runtime": 99,
}
return (priority.get(role, 50), role)
# Control characters (excluding ordinary tab) that must never reach generated
# documentation. A raw newline/carriage return in a harvested value would let it
# break out of a Markdown list item or code span and inject new document
# structure (a fake heading, a misleading link/command block); other C0/C1
# control bytes can smuggle terminal escape sequences when the README is printed.
_MARKDOWN_CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
def sanitize_markdown_text(value: Any) -> str:
"""Neutralise harvested text before it is spliced into generated Markdown.
Generated docs (the Ansible ``README.md``) embed harvested, attacker-
influenceable values such as the host name and captured file paths. These
are not executed by Ansible, but a value containing a newline, carriage
return, backtick, or control byte could otherwise break out of its
surrounding list item / code span and inject misleading Markdown structure
(a forged heading, a deceptive ``[link](...)``/command block) or a terminal
escape sequence when the file is viewed. This collapses any whitespace run
(including newlines and tabs) to a single space, drops other control bytes,
and replaces backticks with a similar-looking single quote so a value can
never escape an inline code span. It is deliberately lossy: the README is a
human-readable summary, and faithful representation of hostile bytes there
is not a goal.
"""
text = str(value)
# Collapse any run of whitespace (newlines, CR, tabs, spaces) to one space so
# a harvested value stays on a single Markdown line / inside one code span.
text = re.sub(r"\s+", " ", text)
# Drop remaining control characters that survived the whitespace collapse.
text = _MARKDOWN_CONTROL_RE.sub("", text)
# A backtick would close an inline code span and let following characters be
# interpreted as Markdown; swap it for a visually-similar acute accent.
text = text.replace("`", "\u00b4")
return text.strip()
def sanitize_report_text(value: Any) -> str:
"""Neutralise harvested text before it is spliced into a plaintext report.
The ``enroll diff`` text/markdown reports embed harvested, attacker-
influenceable values (file paths, owners, groups, link targets, host names,
metadata old/new values). Even in the non-Markdown text report a raw
newline or carriage return in such a value would let it forge additional
report lines (e.g. a fake "No differences detected." line or a spoofed
package/file entry), and other C0/C1 control bytes could smuggle terminal
escape sequences when the report is printed or piped to a notification
channel.
This collapses any whitespace run (including newlines and tabs) to a single
space and drops other control bytes. Unlike :func:`sanitize_markdown_text`
it does not rewrite backticks, since the plaintext report does not use
Markdown code spans. It is deliberately lossy: the report is a
human-readable summary, not a faithful byte-for-byte rendering of hostile
input.
"""
text = str(value)
text = re.sub(r"\s+", " ", text)
text = _MARKDOWN_CONTROL_RE.sub("", text)
return text.strip()
def markdown_list(items: Iterable[Any], *, empty: str = "None.") -> str:
"""Render already-composed Markdown list lines.
Callers that embed harvested values (``snapshot_note_lines``,
``snapshot_excluded_lines``, ``path_reason_lines``) sanitise those values
with :func:`sanitize_markdown_text` before composing each line, so this
helper only joins lines it is given. It still drops empty entries.
"""
values = [str(item) for item in items if str(item).strip()]
return "\n".join(f"- {item}" for item in values) or f"- {empty}"
def path_reason_lines(
items: Iterable[Mapping[str, Any]], *, source_key: str = "path"
) -> List[str]:
lines: List[str] = []
for item in items or []:
path = sanitize_markdown_text(item.get(source_key) or "")
if not path:
continue
reason = sanitize_markdown_text(item.get("reason") or "")
lines.append(f"{path} ({reason})" if reason else path)
return lines
def iter_role_snapshots(roles: Mapping[str, Any]) -> Iterator[Mapping[str, Any]]:
for value in roles.values():
if isinstance(value, list):
for item in value:
if isinstance(item, Mapping):
yield item
elif isinstance(value, Mapping):
yield value
def snapshot_note_lines(roles: Mapping[str, Any]) -> List[str]:
notes: List[str] = []
for snap in iter_role_snapshots(roles):
source = sanitize_markdown_text(
snap.get("role_name") or snap.get("unit") or snap.get("package") or "role"
)
notes.extend(
f"`{source}`: {sanitize_markdown_text(note)}"
for note in snap.get("notes", []) or []
)
return notes
def snapshot_excluded_lines(roles: Mapping[str, Any]) -> List[str]:
excluded: List[str] = []
for snap in iter_role_snapshots(roles):
source = sanitize_markdown_text(
snap.get("role_name") or snap.get("unit") or snap.get("package") or "role"
)
for line in path_reason_lines(snap.get("excluded", []) or []):
excluded.append(f"`{source}`: {line}")
return excluded
def _drop_duplicate_set_items(
module: CMModule,
values: Set[str],
seen: Set[str],
resource_type: str,
) -> Set[str]:
kept: Set[str] = set()
for value in sorted(values):
if value in seen:
module.notes.append(
f"Skipped duplicate {resource_type}[{value}] already emitted earlier in this catalog."
)
continue
kept.add(value)
seen.add(value)
return kept
def _drop_duplicate_mapping_items(
module: CMModule,
values: Dict[str, Dict[str, Any]],
seen: Set[str],
resource_type: str,
*,
excluded_titles: Set[str] | None = None,
excluded_reason: str = "conflicts with another resource",
) -> Dict[str, Dict[str, Any]]:
kept: Dict[str, Dict[str, Any]] = {}
excluded_titles = excluded_titles or set()
for title, attrs in values.items():
if title in excluded_titles:
module.notes.append(f"Skipped {resource_type}[{title}]: {excluded_reason}.")
continue
if title in seen:
module.notes.append(
f"Skipped duplicate {resource_type}[{title}] already emitted earlier in this catalog."
)
continue
kept[title] = attrs
seen.add(title)
return kept
def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None:
"""Resolve global catalog conflicts in the shared model.
Deduplicates the same package, service, or parent directory appearing in
more than one role. The Ansible renderer tolerates such duplicates, but this
helper remains available for any catalog-style consumer of the shared model.
"""
ordered = list(modules)
concrete_file_paths: Set[str] = set()
for module in ordered:
concrete_file_paths.update(module.files)
concrete_file_paths.update(module.links)
seen_packages: Set[str] = set()
seen_groups: Set[str] = set()
seen_users: Set[str] = set()
seen_dirs: Set[str] = set()
seen_files: Set[str] = set()
seen_links: Set[str] = set()
seen_services: Set[str] = set()
for module in ordered:
module.packages = _drop_duplicate_set_items(
module, module.packages, seen_packages, "Package"
)
module.groups = _drop_duplicate_set_items(
module, module.groups, seen_groups, "Group"
)
module.users = _drop_duplicate_mapping_items(
module, module.users, seen_users, "User"
)
module.dirs = _drop_duplicate_mapping_items(
module,
module.dirs,
seen_dirs,
"File",
excluded_titles=concrete_file_paths,
excluded_reason="a file or link with the same path is emitted in this catalog",
)
module.files = _drop_duplicate_mapping_items(
module, module.files, seen_files | seen_links, "File"
)
seen_files.update(module.files)
module.links = _drop_duplicate_mapping_items(
module, module.links, seen_links | seen_files, "File"
)
seen_links.update(module.links)
module.services = _drop_duplicate_mapping_items(
module, module.services, seen_services, "Service"
)

View file

@ -1,11 +1,12 @@
from __future__ import annotations
import glob
import hashlib
import os
import subprocess # nosec
from typing import Dict, List, Optional, Set, Tuple
_DIVERSION_PREFIX = "diversion by "
def _run(cmd: list[str]) -> str:
p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec
@ -18,9 +19,32 @@ def dpkg_owner(path: str) -> Optional[str]:
p = subprocess.run(["dpkg", "-S", path], text=True, capture_output=True) # nosec
if p.returncode != 0:
return None
left = p.stdout.split(":", 1)[0].strip()
pkg = left.split(":", 1)[0].strip()
return pkg or None
for raw in (p.stdout or "").splitlines():
line = raw.strip()
if not line:
continue
# dpkg diversion chatter; not an ownership line
if line.startswith(_DIVERSION_PREFIX):
continue
# Expected: "<pkg>[, <pkg2>...][:<arch>]: <path>"
if ":" not in line:
continue
left, _ = line.split(":", 1)
# If multiple pkgs listed, pick the first (common case is just one)
left = left.split(",", 1)[0].strip()
# Strip any ":arch" suffix from left side
pkg = left.split(":", 1)[0].strip()
if pkg and not pkg.startswith(_DIVERSION_PREFIX):
return pkg
return None
def list_manual_packages() -> List[str]:
@ -39,6 +63,53 @@ def list_manual_packages() -> List[str]:
return sorted(set(pkgs))
def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
"""Return mapping of installed package name -> installed instances.
Uses dpkg-query and is expected to work on Debian/Ubuntu-like systems.
Output format:
{"pkg": [{"version": "...", "arch": "...", "section": "..."}, ...], ...}
"""
try:
p = subprocess.run(
[
"dpkg-query",
"-W",
"-f=${Package}\t${Version}\t${Architecture}\t${Section}\n",
],
text=True,
capture_output=True,
check=False,
) # nosec
except Exception:
return {}
out: Dict[str, List[Dict[str, str]]] = {}
for raw in (p.stdout or "").splitlines():
line = raw.strip("\n")
if not line:
continue
parts = line.split("\t")
if len(parts) < 3:
continue
name, ver, arch = parts[0].strip(), parts[1].strip(), parts[2].strip()
if not name:
continue
instance = {"version": ver, "arch": arch}
if len(parts) >= 4 and parts[3].strip():
instance["section"] = parts[3].strip()
out.setdefault(name, []).append(instance)
# Stable ordering for deterministic JSON dumps.
for k in list(out.keys()):
out[k] = sorted(
out[k], key=lambda x: (x.get("arch") or "", x.get("version") or "")
)
return out
def build_dpkg_etc_index(
info_dir: str = "/var/lib/dpkg/info",
) -> Tuple[Set[str], Dict[str, str], Dict[str, Set[str]], Dict[str, List[str]]]:
@ -115,7 +186,12 @@ def parse_status_conffiles(
if m:
out[pkg] = m
with open(status_path, "r", encoding="utf-8", errors="replace") as f:
try:
f = open(status_path, "r", encoding="utf-8", errors="replace")
except OSError:
return out
with f:
for line in f:
if line.strip() == "":
if cur:
@ -129,7 +205,9 @@ def parse_status_conffiles(
if ":" in line:
k, v = line.split(":", 1)
key = k
cur[key] = v.lstrip()
# Preserve leading spaces in continuation lines, but strip
# the trailing newline from the initial key line value.
cur[key] = v.lstrip().rstrip("\n")
if cur:
flush()
@ -150,31 +228,10 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]:
line = line.strip()
if not line:
continue
md5, rel = line.split(None, 1)
parts = line.split(None, 1)
if len(parts) != 2:
# Skip malformed/truncated lines instead of aborting the harvest.
continue
md5, rel = parts
m[rel.strip()] = md5.strip()
return m
def file_md5(path: str) -> str:
h = hashlib.md5() # nosec
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def stat_triplet(path: str) -> Tuple[str, str, str]:
st = os.stat(path, follow_symlinks=True)
mode = oct(st.st_mode & 0o777)[2:].zfill(4)
import pwd, grp
try:
owner = pwd.getpwuid(st.st_uid).pw_name
except KeyError:
owner = str(st.st_uid)
try:
group = grp.getgrgid(st.st_gid).gr_name
except KeyError:
group = str(st.st_gid)
return owner, group, mode

1050
enroll/diff.py Normal file

File diff suppressed because it is too large Load diff

618
enroll/explain.py Normal file
View file

@ -0,0 +1,618 @@
from __future__ import annotations
import json
from collections import Counter, defaultdict
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Tuple
from .diff import _bundle_from_input # reuse existing bundle handling
from .state import load_state
from .cm import sanitize_report_text
@dataclass(frozen=True)
class ReasonInfo:
title: str
why: str
_MANAGED_FILE_REASONS: Dict[str, ReasonInfo] = {
# Package manager / repo config
"apt_config": ReasonInfo(
"APT configuration",
"APT configuration affecting package installation and repository behavior.",
),
"apt_source": ReasonInfo(
"APT repository source",
"APT source list entries (e.g. sources.list or sources.list.d).",
),
"apt_keyring": ReasonInfo(
"APT keyring",
"Repository signing key material used by APT.",
),
"apt_signed_by_keyring": ReasonInfo(
"APT Signed-By keyring",
"Keyring referenced via a Signed-By directive in an APT source.",
),
"yum_conf": ReasonInfo(
"YUM/DNF main config",
"Primary YUM configuration (often /etc/yum.conf).",
),
"yum_config": ReasonInfo(
"YUM/DNF config",
"YUM/DNF configuration files (including conf.d).",
),
"yum_repo": ReasonInfo(
"YUM/DNF repository",
"YUM/DNF repository definitions (e.g. yum.repos.d).",
),
"dnf_config": ReasonInfo(
"DNF configuration",
"DNF configuration affecting package installation and repositories.",
),
"rpm_gpg_key": ReasonInfo(
"RPM GPG key",
"Repository signing keys used by RPM/YUM/DNF.",
),
# SSH
"authorized_keys": ReasonInfo(
"SSH authorized keys",
"User authorized_keys files (controls who can log in with SSH keys).",
),
"ssh_public_key": ReasonInfo(
"SSH public key",
"SSH host/user public keys relevant to authentication.",
),
# System config / security
"system_security": ReasonInfo(
"Security configuration",
"Security-sensitive configuration (SSH, sudoers, PAM, auth, etc.).",
),
"system_network": ReasonInfo(
"Network configuration",
"Network configuration (interfaces, resolv.conf, network managers, etc.).",
),
"system_firewall": ReasonInfo(
"Firewall configuration",
"Firewall rules/configuration (ufw, nftables, iptables, ipset, etc.).",
),
"system_sysctl": ReasonInfo(
"sysctl configuration",
"Kernel sysctl tuning (sysctl.conf / sysctl.d).",
),
"system_modprobe": ReasonInfo(
"modprobe configuration",
"Kernel module configuration (modprobe.d).",
),
"system_mounts": ReasonInfo(
"Mount configuration",
"Mount configuration (e.g. /etc/fstab and related).",
),
"system_rc": ReasonInfo(
"Startup/rc configuration",
"Startup scripts / rc configuration that can affect boot behavior.",
),
# systemd + timers
"systemd_dropin": ReasonInfo(
"systemd drop-in",
"systemd override/drop-in files that modify a unit's behavior.",
),
"systemd_envfile": ReasonInfo(
"systemd EnvironmentFile",
"Files referenced by systemd units via EnvironmentFile.",
),
"related_timer": ReasonInfo(
"Related systemd timer",
"A systemd timer captured because it is related to a unit/service.",
),
# cron / logrotate
"system_cron": ReasonInfo(
"System cron",
"System cron configuration (crontab, cron.d, etc.).",
),
"cron_snippet": ReasonInfo(
"Cron snippet",
"Cron snippets referenced/used by harvested services or configs.",
),
"system_logrotate": ReasonInfo(
"System logrotate",
"System logrotate configuration.",
),
"logrotate_snippet": ReasonInfo(
"logrotate snippet",
"logrotate snippets/configs referenced in system configuration.",
),
# Custom paths / drift signals
"modified_conffile": ReasonInfo(
"Modified package conffile",
"A package-managed conffile differs from the packaged/default version.",
),
"modified_packaged_file": ReasonInfo(
"Modified packaged file",
"A file owned by a package differs from the packaged version.",
),
"custom_unowned": ReasonInfo(
"Unowned custom file",
"A file not owned by any package (often custom/operator-managed).",
),
"custom_specific_path": ReasonInfo(
"Custom specific path",
"A specific path included by a custom rule or snapshot.",
),
"usr_local_bin_script": ReasonInfo(
"/usr/local/bin script",
"Executable scripts under /usr/local/bin (often operator-installed).",
),
"usr_local_etc_custom": ReasonInfo(
"/usr/local/etc custom",
"Custom configuration under /usr/local/etc.",
),
# User includes
"user_include": ReasonInfo(
"User-included path",
"Included because you specified it via --include-path / include patterns.",
),
}
_MANAGED_DIR_REASONS: Dict[str, ReasonInfo] = {
"parent_of_managed_file": ReasonInfo(
"Parent directory",
"Included so permissions/ownership can be recreated for managed files.",
),
"user_include_dir": ReasonInfo(
"User-included directory",
"Included because you specified it via --include-path / include patterns.",
),
}
_EXCLUDED_REASONS: Dict[str, ReasonInfo] = {
"user_excluded": ReasonInfo(
"User excluded",
"Excluded because you explicitly excluded it (e.g. --exclude-path / patterns).",
),
"unreadable": ReasonInfo(
"Unreadable",
"Enroll could not read this path with the permissions it had.",
),
"log_file": ReasonInfo(
"Log file",
"Excluded because it appears to be a log file (usually noisy/large).",
),
"denied_path": ReasonInfo(
"Denied path",
"Excluded because the path is in a denylist for safety.",
),
"too_large": ReasonInfo(
"Too large",
"Excluded because it exceeded the size limit for harvested files.",
),
"not_regular_file": ReasonInfo(
"Not a regular file",
"Excluded because it was not a regular file (device, socket, etc.).",
),
"symlink_component": ReasonInfo(
"Unsafe symlinked path",
"Excluded because a directory in the path was a symlink, which could "
"redirect capture into a sensitive location; Enroll refuses to follow "
"symlinked parents when harvesting files.",
),
"binary_like": ReasonInfo(
"Binary-like",
"Excluded because it looked like binary content (not useful for config management).",
),
"sensitive_content": ReasonInfo(
"Sensitive content",
"Excluded because it likely contains secrets (e.g. shadow, private keys).",
),
}
_OBSERVED_VIA: Dict[str, ReasonInfo] = {
"user_installed": ReasonInfo(
"User-installed",
"Package appears explicitly installed (as opposed to only pulled in as a dependency).",
),
"systemd_unit": ReasonInfo(
"Referenced by systemd unit",
"Package is associated with a systemd unit that was harvested.",
),
"package_role": ReasonInfo(
"Referenced by package role",
"Package was referenced by an enroll packages snapshot/role.",
),
"firewall_runtime": ReasonInfo(
"Referenced by firewall runtime role",
"Package was referenced by captured live ipset/iptables runtime state.",
),
}
def _ri(mapping: Dict[str, ReasonInfo], key: str) -> ReasonInfo:
return mapping.get(key) or ReasonInfo(key, f"Captured with reason '{key}'")
def _role_common_counts(role_obj: Dict[str, Any]) -> Tuple[int, int, int, int]:
"""Return (managed_files, managed_dirs, excluded, notes) counts for a RoleCommon object."""
mf = len(role_obj.get("managed_files") or [])
md = len(role_obj.get("managed_dirs") or [])
ex = len(role_obj.get("excluded") or [])
nt = len(role_obj.get("notes") or [])
return mf, md, ex, nt
def _summarize_reasons(
items: Iterable[Dict[str, Any]],
reason_key: str,
*,
mapping: Dict[str, ReasonInfo],
max_examples: int,
) -> List[Dict[str, Any]]:
by_reason: Dict[str, List[str]] = defaultdict(list)
counts: Counter[str] = Counter()
for it in items:
if not isinstance(it, dict):
continue
r = it.get(reason_key)
if not r:
continue
r = str(r)
counts[r] += 1
p = it.get("path")
if (
max_examples > 0
and isinstance(p, str)
and p
and len(by_reason[r]) < max_examples
):
by_reason[r].append(p)
out: List[Dict[str, Any]] = []
for reason, count in counts.most_common():
info = _ri(mapping, reason)
out.append(
{
"reason": reason,
"count": count,
"title": info.title,
"why": info.why,
"examples": by_reason.get(reason, []),
}
)
return out
def explain_state(
harvest: str,
*,
sops_mode: bool = False,
fmt: str = "text",
max_examples: int = 3,
) -> str:
"""Explain a harvest bundle's state.json.
`harvest` may be:
- a bundle directory
- a path to state.json
- a tarball (.tar.gz/.tgz)
- a SOPS-encrypted bundle (.sops)
"""
bundle = _bundle_from_input(harvest, sops_mode=sops_mode)
state = load_state(bundle.dir)
host = state.get("host") or {}
enroll = state.get("enroll") or {}
roles = state.get("roles") or {}
inv = state.get("inventory") or {}
inv_pkgs = (inv.get("packages") or {}) if isinstance(inv, dict) else {}
role_summaries: List[Dict[str, Any]] = []
# Users
users_obj = roles.get("users") or {}
user_entries = users_obj.get("users") or []
mf, md, ex, _nt = (
_role_common_counts(users_obj) if isinstance(users_obj, dict) else (0, 0, 0, 0)
)
role_summaries.append(
{
"role": "users",
"summary": f"{len(user_entries)} user(s), {mf} file(s), {ex} excluded",
"notes": users_obj.get("notes") or [],
}
)
# Services
services_list = roles.get("services") or []
if isinstance(services_list, list):
total_mf = sum(
len((s.get("managed_files") or []))
for s in services_list
if isinstance(s, dict)
)
total_ex = sum(
len((s.get("excluded") or [])) for s in services_list if isinstance(s, dict)
)
role_summaries.append(
{
"role": "services",
"summary": f"{len(services_list)} unit(s), {total_mf} file(s), {total_ex} excluded",
"units": [
{
"unit": s.get("unit"),
"active_state": s.get("active_state"),
"sub_state": s.get("sub_state"),
"unit_file_state": s.get("unit_file_state"),
"condition_result": s.get("condition_result"),
}
for s in services_list
if isinstance(s, dict)
],
}
)
# Package snapshots
pkgs_list = roles.get("packages") or []
if isinstance(pkgs_list, list):
total_mf = sum(
len((p.get("managed_files") or []))
for p in pkgs_list
if isinstance(p, dict)
)
total_ex = sum(
len((p.get("excluded") or [])) for p in pkgs_list if isinstance(p, dict)
)
role_summaries.append(
{
"role": "packages",
"summary": f"{len(pkgs_list)} package snapshot(s), {total_mf} file(s), {total_ex} excluded",
"packages": [
p.get("package") for p in pkgs_list if isinstance(p, dict)
],
}
)
# Runtime firewall snapshot
firewall_obj = roles.get("firewall_runtime") or {}
if isinstance(firewall_obj, dict) and firewall_obj:
captures = [
key
for key in ("ipset_save", "iptables_v4_save", "iptables_v6_save")
if firewall_obj.get(key)
]
role_summaries.append(
{
"role": "firewall_runtime",
"summary": f"{len(captures)} snapshot(s), {len(firewall_obj.get('ipset_sets') or [])} ipset(s)",
"notes": firewall_obj.get("notes") or [],
}
)
# Single snapshots
for rname in [
"apt_config",
"dnf_config",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
]:
robj = roles.get(rname) or {}
if not isinstance(robj, dict):
continue
mf, md, ex, _nt = _role_common_counts(robj)
extra: Dict[str, Any] = {}
if rname == "extra_paths":
extra = {
"include_patterns": robj.get("include_patterns") or [],
"exclude_patterns": robj.get("exclude_patterns") or [],
}
role_summaries.append(
{
"role": rname,
"summary": f"{mf} file(s), {md} dir(s), {ex} excluded",
"notes": robj.get("notes") or [],
**extra,
}
)
# Flatten managed/excluded across roles
all_managed_files: List[Dict[str, Any]] = []
all_managed_dirs: List[Dict[str, Any]] = []
all_excluded: List[Dict[str, Any]] = []
def _consume_role(role_obj: Dict[str, Any]) -> None:
for f in role_obj.get("managed_files") or []:
if isinstance(f, dict):
all_managed_files.append(f)
for d in role_obj.get("managed_dirs") or []:
if isinstance(d, dict):
all_managed_dirs.append(d)
for e in role_obj.get("excluded") or []:
if isinstance(e, dict):
all_excluded.append(e)
if isinstance(users_obj, dict):
_consume_role(users_obj)
if isinstance(services_list, list):
for s in services_list:
if isinstance(s, dict):
_consume_role(s)
if isinstance(pkgs_list, list):
for p in pkgs_list:
if isinstance(p, dict):
_consume_role(p)
for rname in [
"apt_config",
"dnf_config",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
]:
robj = roles.get(rname)
if isinstance(robj, dict):
_consume_role(robj)
managed_file_reasons = _summarize_reasons(
all_managed_files,
"reason",
mapping=_MANAGED_FILE_REASONS,
max_examples=max_examples,
)
managed_dir_reasons = _summarize_reasons(
all_managed_dirs,
"reason",
mapping=_MANAGED_DIR_REASONS,
max_examples=max_examples,
)
excluded_reasons = _summarize_reasons(
all_excluded,
"reason",
mapping=_EXCLUDED_REASONS,
max_examples=max_examples,
)
# Inventory observed_via breakdown (count packages that contain at least one entry for that kind)
observed_kinds: Counter[str] = Counter()
observed_refs: Dict[str, Counter[str]] = defaultdict(Counter)
for _pkg, entry in inv_pkgs.items():
if not isinstance(entry, dict):
continue
seen_kinds = set()
for ov in entry.get("observed_via") or []:
if not isinstance(ov, dict):
continue
kind = ov.get("kind")
if not kind:
continue
kind = str(kind)
seen_kinds.add(kind)
ref = ov.get("ref")
if isinstance(ref, str) and ref:
observed_refs[kind][ref] += 1
for k in seen_kinds:
observed_kinds[k] += 1
observed_via_summary: List[Dict[str, Any]] = []
for kind, cnt in observed_kinds.most_common():
info = _ri(_OBSERVED_VIA, kind)
top_refs = [
r for r, _ in observed_refs.get(kind, Counter()).most_common(max_examples)
]
observed_via_summary.append(
{
"kind": kind,
"count": cnt,
"title": info.title,
"why": info.why,
"top_refs": top_refs,
}
)
report: Dict[str, Any] = {
"bundle_dir": str(bundle.dir),
"host": host,
"enroll": enroll,
"inventory": {
"package_count": len(inv_pkgs),
"observed_via": observed_via_summary,
},
"roles": role_summaries,
"reasons": {
"managed_files": managed_file_reasons,
"managed_dirs": managed_dir_reasons,
"excluded": excluded_reasons,
},
}
if fmt == "json":
return json.dumps(report, indent=2, sort_keys=True)
# Text rendering.
#
# Harvested, attacker-influenceable values (host name, file paths used as
# examples, include/exclude patterns, snapshot notes) are interpolated into
# this human-readable text. Route each through ``sanitize_report_text`` (the
# same helper the diff text report uses) so a value containing a raw newline
# cannot forge an additional output line and a control byte cannot smuggle a
# terminal escape sequence when the explanation is printed or written with
# --out. The JSON branch above does not need this: json.dumps already escapes
# control characters and cannot have its structure altered by a string value.
s = sanitize_report_text
out: List[str] = []
out.append(f"Enroll explained: {s(harvest)}")
hn = host.get("hostname") or "(unknown host)"
os_family = host.get("os") or "unknown"
pkg_backend = host.get("pkg_backend") or "?"
ver = enroll.get("version") or "?"
out.append(f"Host: {s(hn)} (os: {s(os_family)}, pkg: {s(pkg_backend)})")
out.append(f"Enroll: {s(ver)}")
out.append("")
out.append("Inventory")
out.append(f"- Packages: {len(inv_pkgs)}")
if observed_via_summary:
out.append("- Why packages were included (observed_via):")
for ov in observed_via_summary:
extra = ""
if ov.get("top_refs"):
extra = f" (e.g. {', '.join(s(x) for x in ov['top_refs'])})"
out.append(f" - {s(ov['kind'])}: {ov['count']} {s(ov['why'])}{extra}")
out.append("")
out.append("Roles collected")
for rs in role_summaries:
out.append(f"- {s(rs['role'])}: {s(rs['summary'])}")
if rs["role"] == "extra_paths":
inc = rs.get("include_patterns") or []
exc = rs.get("exclude_patterns") or []
if inc:
suffix = "" if len(inc) > max_examples else ""
out.append(
f" include_patterns: {', '.join(s(x) for x in inc[:max_examples])}{suffix}"
)
if exc:
suffix = "" if len(exc) > max_examples else ""
out.append(
f" exclude_patterns: {', '.join(s(x) for x in exc[:max_examples])}{suffix}"
)
notes = rs.get("notes") or []
if notes:
for n in notes[:max_examples]:
out.append(f" note: {s(n)}")
if len(notes) > max_examples:
out.append(
f" note: (+{len(notes) - max_examples} more. Use --format json to see them all)"
)
out.append("")
out.append("Why files were included (managed_files.reason)")
if managed_file_reasons:
for r in managed_file_reasons[:15]:
exs = r.get("examples") or []
ex_txt = f" Examples: {', '.join(s(x) for x in exs)}" if exs else ""
out.append(f"- {s(r['reason'])} ({r['count']}): {s(r['why'])}.{ex_txt}")
if len(managed_file_reasons) > 15:
out.append(
f"- (+{len(managed_file_reasons) - 15} more reasons. Use --format json to see them all)"
)
else:
out.append("- (no managed files)")
if managed_dir_reasons:
out.append("")
out.append("Why directories were included (managed_dirs.reason)")
for r in managed_dir_reasons:
out.append(f"- {s(r['reason'])} ({r['count']}): {s(r['why'])}")
out.append("")
out.append("Why paths were excluded")
if excluded_reasons:
for r in excluded_reasons:
exs = r.get("examples") or []
ex_txt = f" Examples: {', '.join(s(x) for x in exs)}" if exs else ""
out.append(f"- {s(r['reason'])} ({r['count']}): {s(r['why'])}.{ex_txt}")
else:
out.append("- (no excluded paths)")
return "\n".join(out) + "\n"

268
enroll/fsutil.py Normal file
View file

@ -0,0 +1,268 @@
from __future__ import annotations
import errno
import hashlib
import os
import stat
from typing import Tuple
def open_no_follow_path(
path: str,
*,
write: bool = False,
mode: int = 0o600,
directory: bool = False,
) -> int:
"""Open ``path`` without following a symlink in *any* path component.
``O_NOFOLLOW`` only protects the final component of a path. A regular
file reached through a symlinked *parent* directory (for example a user
replacing ``~/.ssh`` with a link to a sensitive directory) would still be
opened by a plain ``os.open(path, O_NOFOLLOW)``.
This helper resolves the path one component at a time with ``openat``
semantics:
- each intermediate component is opened relative to its parent's
descriptor without following symlinks;
- the final component is opened with ``O_NOFOLLOW`` (read, or
``O_WRONLY | O_CREAT | O_EXCL`` when ``write`` is True).
The important detail is that intermediate components are opened with
``O_PATH | O_NOFOLLOW`` when ``O_PATH`` is available, and then verified
with ``fstat()``. On Linux, ``O_RDONLY | O_DIRECTORY | O_NOFOLLOW`` is not
sufficient for this job: a symlink whose target is a directory can still be
opened as the target directory on some kernels. Opening with ``O_PATH`` and
checking the resulting descriptor reliably exposes such a component as a
symlink instead.
A symlink (or a ``..`` component) anywhere in the path raises
``OSError(ELOOP)``. On platforms without ``openat``/``O_DIRECTORY``
support, this falls back to a single ``O_NOFOLLOW`` open of the whole path,
which is no worse than the historical behaviour.
"""
cloexec = getattr(os, "O_CLOEXEC", 0)
nofollow = getattr(os, "O_NOFOLLOW", 0)
o_directory = getattr(os, "O_DIRECTORY", 0)
o_path = getattr(os, "O_PATH", 0)
if write and directory:
raise ValueError("directory=True cannot be combined with write=True")
if write:
final_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | cloexec | nofollow
elif directory and o_path:
# O_PATH|O_NOFOLLOW opens a final symlink as the symlink object itself
# on Linux, allowing inspect_dir_no_follow() to reject it via fstat().
# O_RDONLY|O_DIRECTORY|O_NOFOLLOW can still follow a symlink-to-dir on
# some kernels/filesystems.
final_flags = o_path | cloexec | nofollow
else:
final_flags = os.O_RDONLY | cloexec | nofollow
if directory:
final_flags |= o_directory
supports_openat = bool(
o_directory and nofollow and os.open in getattr(os, "supports_dir_fd", set())
)
if not supports_openat:
return os.open(path, final_flags, mode)
absolute = path.startswith("/")
parts = [p for p in path.split("/") if p not in ("", ".")]
if not parts:
return os.open(path, final_flags, mode)
*parent_parts, leaf = parts
# Use O_PATH for directory descriptors when available. O_PATH descriptors
# can be used as dir_fd anchors for later openat-style calls, and with
# O_NOFOLLOW they let us fstat() a symlink component instead of silently
# following it. If O_PATH is unavailable, use O_RDONLY and an lstat()
# pre-check for intermediate components as a best-effort fallback.
dir_base_flags = (o_path if o_path else os.O_RDONLY) | cloexec | o_directory
component_flags = (
(o_path if o_path else os.O_RDONLY) | cloexec | o_directory | nofollow
)
dir_fd = os.open("/" if absolute else ".", dir_base_flags)
try:
for component in parent_parts:
if component == "..":
raise OSError(errno.ELOOP, "unsafe '..' path component", path)
if not o_path:
# Best-effort fallback for platforms without O_PATH. This is not
# as race-resistant as the descriptor-only path, but it avoids
# known symlink parents where we cannot open the component itself
# as a non-followed O_PATH descriptor.
try:
st = os.lstat(component, dir_fd=dir_fd)
except OSError:
raise
if stat.S_ISLNK(st.st_mode):
raise OSError(errno.ELOOP, "symlinked path component", path)
if not stat.S_ISDIR(st.st_mode):
raise OSError(errno.ENOTDIR, "non-directory path component", path)
try:
next_fd = os.open(component, component_flags, dir_fd=dir_fd)
except OSError as e:
if e.errno in {errno.ELOOP, errno.ENOTDIR}:
try:
st = os.lstat(component, dir_fd=dir_fd)
except OSError:
raise
if stat.S_ISLNK(st.st_mode):
raise OSError(
errno.ELOOP,
"symlinked path component",
path,
) from e
raise
try:
st = os.fstat(next_fd)
if stat.S_ISLNK(st.st_mode):
raise OSError(errno.ELOOP, "symlinked path component", path)
if not stat.S_ISDIR(st.st_mode):
raise OSError(errno.ENOTDIR, "non-directory path component", path)
except Exception:
os.close(next_fd)
raise
os.close(dir_fd)
dir_fd = next_fd
if leaf == "..":
raise OSError(errno.ELOOP, "unsafe '..' path component", path)
return os.open(leaf, final_flags, mode, dir_fd=dir_fd)
finally:
os.close(dir_fd)
def inspect_dir_no_follow(path: str) -> os.stat_result:
"""Return fstat() metadata for a directory opened without following symlinks.
Directory metadata capture must have the same TOCTOU properties as file
capture: inspect the exact object reached through a no-follow descriptor,
and reject symlink components anywhere in the path. Path-based
``os.stat()`` / ``os.path.isdir()`` checks can be swapped between check and
use when an include root is attacker-writable; this helper keeps the check
bound to the opened descriptor.
"""
fd = open_no_follow_path(path, directory=True)
try:
st = os.fstat(fd)
if stat.S_ISLNK(st.st_mode):
raise OSError(errno.ELOOP, "symlinked directory path", path)
if not stat.S_ISDIR(st.st_mode):
raise OSError(errno.ENOTDIR, "not a directory", path)
return st
finally:
os.close(fd)
def stat_dir_triplet(path: str) -> Tuple[str, str, str]:
"""Return (owner, group, mode) for a safely-opened directory path.
Unlike :func:`stat_triplet`, this refuses final symlinks and symlinked
parent components, and derives metadata from the directory descriptor that
passed those checks.
"""
return stat_triplet_from_stat(inspect_dir_no_follow(path))
def path_has_symlink_component(path: str) -> bool:
"""Return True if any existing component of *path* is a symlink.
This is a lightweight discovery-time companion to ``open_no_follow_path``.
It is intended for directory-walking code paths that must decide whether a
candidate root is safe to enumerate before opening individual files. Missing
trailing components are treated as non-symlinks; ``..`` is treated as unsafe
and therefore reported as a symlink-like component.
"""
norm = os.path.normpath(path)
if norm in ("", "."):
return False
if os.path.isabs(norm):
cur = os.sep
parts = [p for p in norm.split(os.sep) if p]
else:
cur = os.getcwd()
parts = [p for p in norm.split(os.sep) if p]
for part in parts:
if part in ("", "."):
continue
if part == "..":
return True
cur = os.path.join(cur, part)
try:
st = os.lstat(cur)
except FileNotFoundError:
return False
except OSError:
# Fail closed for unreadable/racy paths used as discovery roots.
return True
if stat.S_ISLNK(st.st_mode):
return True
return False
def is_dir_no_symlink_components(path: str) -> bool:
"""Return True only for directories reached without symlink components."""
if path_has_symlink_component(path):
return False
try:
st = os.stat(path, follow_symlinks=False)
except OSError:
return False
return stat.S_ISDIR(st.st_mode)
def stat_triplet_from_stat(st: os.stat_result) -> Tuple[str, str, str]:
"""Return (owner, group, mode) for an existing stat result."""
mode = oct(st.st_mode & 0o7777)[2:].zfill(4)
import grp
import pwd
try:
owner = pwd.getpwuid(st.st_uid).pw_name
except KeyError:
owner = str(st.st_uid)
try:
group = grp.getgrgid(st.st_gid).gr_name
except KeyError:
group = str(st.st_gid)
return owner, group, mode
def file_md5(path: str) -> str:
"""Return hex MD5 of a file.
Used for Debian dpkg baseline comparisons.
"""
h = hashlib.md5() # nosec
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def stat_triplet(path: str) -> Tuple[str, str, str]:
"""Return (owner, group, mode) for a path.
owner/group are usernames/group names when resolvable, otherwise numeric ids.
mode is a zero-padded octal string (e.g. "0644").
"""
return stat_triplet_from_stat(os.stat(path, follow_symlinks=True))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
"""Harvest collector package exports"""
from __future__ import annotations
from importlib import import_module
from .context import HarvestCollector, HarvestContext
_COLLECTOR_EXPORTS = {
"CronLogrotateCollection": ".cron_logrotate",
"CronLogrotateCollector": ".cron_logrotate",
"ExtraPathsCollector": ".paths",
"PackageManagerConfigCollection": ".package_manager",
"PackageManagerConfigCollector": ".package_manager",
"RuntimeStateCollection": ".runtime",
"RuntimeStateCollector": ".runtime",
"ServicePackageCollection": ".services",
"ServicePackageCollector": ".services",
"UsersCollection": ".users",
"UsersCollector": ".users",
"UsrLocalCustomCollector": ".paths",
}
__all__ = [
"HarvestCollector",
"HarvestContext",
*_COLLECTOR_EXPORTS,
]
def __getattr__(name: str):
module_name = _COLLECTOR_EXPORTS.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module = import_module(module_name, __name__)
value = getattr(module, name)
globals()[name] = value
return value

View file

@ -0,0 +1,251 @@
from __future__ import annotations
import json
import re
import shutil
import subprocess # nosec B404
from collections.abc import (
Iterable,
) # nosec - executes fixed docker/podman command arguments only
from typing import Any, Dict, List, Optional, Sequence, Tuple
from ..harvest_types import ContainerImagesSnapshot
from .context import HarvestCollector
_DIGEST_RE = re.compile(r"@sha256:[0-9A-Fa-f]{32,}")
_SHA_ID_RE = re.compile(r"^(?:sha256:)?[0-9A-Fa-f]{64}$")
def _normalise_image_id(value: Any) -> Optional[str]:
s = str(value or "").strip()
if not s:
return None
if s.startswith("sha256:"):
return s
if _SHA_ID_RE.match(s):
return "sha256:" + s
return s
def _as_string_list(value: Any) -> List[str]:
if not value:
return []
if isinstance(value, str):
values = [value]
elif isinstance(value, Iterable):
values = list(value)
else:
values = [value]
out: List[str] = []
for item in values:
s = str(item or "").strip()
if not s or s in {"<none>", "<none>:<none>"}:
continue
if s not in out:
out.append(s)
return out
def _pullable_digests(value: Any) -> List[str]:
return [s for s in _as_string_list(value) if _DIGEST_RE.search(s)]
def _split_tag_ref(ref: str) -> Optional[Dict[str, str]]:
"""Split an image tag into repository/tag, preserving registry ports."""
s = str(ref or "").strip()
if not s or "@" in s or s == "<none>:<none>":
return None
last_slash = s.rfind("/")
last_colon = s.rfind(":")
if last_colon > last_slash:
repository = s[:last_colon]
tag = s[last_colon + 1 :]
else:
repository = s
tag = "latest"
if not repository or not tag:
return None
return {"ref": s, "repository": repository, "tag": tag}
def _tag_aliases(value: Any) -> List[Dict[str, str]]:
out: List[Dict[str, str]] = []
seen = set()
for ref in _as_string_list(value):
item = _split_tag_ref(ref)
if not item:
continue
key = (item["repository"], item["tag"])
if key in seen:
continue
seen.add(key)
out.append(item)
return out
def _platform_from_inspect(
item: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
os_name = item.get("Os") or item.get("OS")
arch = item.get("Architecture") or item.get("Arch")
variant = item.get("Variant")
os_s = str(os_name).strip() if os_name not in (None, "") else None
arch_s = str(arch).strip() if arch not in (None, "") else None
variant_s = str(variant).strip() if variant not in (None, "") else None
platform = None
if os_s and arch_s:
platform = f"{os_s}/{arch_s}"
if variant_s:
platform = f"{platform}/{variant_s}"
return os_s, arch_s, variant_s, platform
def _run_command(
argv: Sequence[str], *, timeout: int = 20
) -> subprocess.CompletedProcess[str]:
return subprocess.run( # nosec - argv is constructed from fixed binary names and image ids
list(argv),
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
)
def _chunks(items: Sequence[str], size: int) -> Iterable[List[str]]:
for i in range(0, len(items), size):
yield list(items[i : i + size])
class ContainerImagesCollector(HarvestCollector):
"""Collect local Docker and Podman image metadata.
The harvest records pullable registry digests where present. Local image IDs
are kept as evidence but are not treated as pull references.
"""
def collect(self) -> ContainerImagesSnapshot:
images: List[Dict[str, Any]] = []
notes: List[str] = []
images.extend(self._collect_engine("docker", notes=notes))
images.extend(self._collect_engine("podman", notes=notes))
if images:
digest_count = len([img for img in images if img.get("pull_ref")])
notes.append(
f"Detected {len(images)} container image(s); {digest_count} have registry digests usable for exact pulls."
)
return ContainerImagesSnapshot(
role_name="container_images",
images=images,
notes=notes,
)
def _collect_engine(self, engine: str, *, notes: List[str]) -> List[Dict[str, Any]]:
exe = shutil.which(engine)
if not exe:
return []
try:
listed = _run_command([exe, "image", "ls", "-q", "--no-trunc"])
except Exception as exc:
notes.append(f"Failed to list {engine} images: {exc!r}")
return []
if listed.returncode != 0:
detail = (listed.stderr or listed.stdout or "").strip()
if detail:
notes.append(f"Failed to list {engine} images: {detail}")
else:
notes.append(
f"Failed to list {engine} images: exit {listed.returncode}"
)
return []
image_ids = []
seen_ids = set()
for line in listed.stdout.splitlines():
image_id = _normalise_image_id(line)
if not image_id or image_id in seen_ids:
continue
seen_ids.add(image_id)
image_ids.append(image_id)
if not image_ids:
return []
out: List[Dict[str, Any]] = []
for chunk in _chunks(image_ids, 40):
try:
inspected = _run_command([exe, "image", "inspect", *chunk])
except Exception as exc:
notes.append(f"Failed to inspect {engine} images: {exc!r}")
continue
if inspected.returncode != 0:
detail = (inspected.stderr or inspected.stdout or "").strip()
notes.append(
f"Failed to inspect {engine} images {', '.join(chunk[:3])}: {detail or inspected.returncode}"
)
continue
try:
data = json.loads(inspected.stdout or "[]")
except json.JSONDecodeError as exc:
notes.append(f"Failed to parse {engine} image inspect JSON: {exc}")
continue
if not isinstance(data, list):
notes.append(f"Unexpected {engine} image inspect JSON shape")
continue
for item in data:
if isinstance(item, dict):
normalised = self._normalise_inspect(engine, item)
if normalised is not None:
out.append(normalised)
return out
def _normalise_inspect(
self, engine: str, item: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
image_id = _normalise_image_id(item.get("Id") or item.get("ID"))
repo_tags = _as_string_list(item.get("RepoTags"))
repo_digests = _pullable_digests(item.get("RepoDigests"))
pull_ref = sorted(repo_digests)[0] if repo_digests else None
os_name, arch, variant, platform = _platform_from_inspect(item)
if not image_id and not repo_tags and not repo_digests:
return None
notes: List[str] = []
if not pull_ref:
if repo_tags:
notes.append(
"Image has tag(s) but no RepoDigest; exact digest-pinned pull cannot be rendered."
)
else:
notes.append(
"Image has no tag or RepoDigest; local-only/dangling images cannot be pulled from a registry."
)
out: Dict[str, Any] = {
"engine": engine,
"scope": "system",
"user": None,
"home": None,
"image_id": image_id,
"repo_tags": repo_tags,
"repo_digests": repo_digests,
"pull_ref": pull_ref,
"tag_aliases": _tag_aliases(repo_tags),
"os": os_name,
"architecture": arch,
"variant": variant,
"platform": platform,
"size": item.get("Size"),
"created": item.get("Created"),
"source": f"{engine} image inspect",
"notes": notes,
}
return out

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Set
from ..ignore import IgnorePolicy
from ..pathfilter import PathFilter
@dataclass
class HarvestContext:
"""Shared context passed to feature collectors."""
bundle_dir: str
policy: IgnorePolicy
path_filter: PathFilter
platform: Dict[str, Any]
backend: Any
installed_pkgs: Dict[str, Any]
installed_names: Set[str]
owned_etc: Set[str]
etc_owner_map: Dict[str, str]
topdir_to_pkgs: Dict[str, Set[str]]
pkg_to_etc_paths: Dict[str, List[str]]
captured_global: Set[str]
class HarvestCollector:
"""Base class for harvest feature collectors."""
def __init__(self, context: HarvestContext) -> None:
self.context = context

View file

@ -0,0 +1,161 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import List, Optional, Set
from ..capture import capture_file
from ..harvest_types import ExcludedFile, ManagedFile, PackageSnapshot
from ..package_hints import package_section_from_installations
from ..system_paths import iter_matching_files
from .context import HarvestCollector
def _pick_installed(installed_names: Set[str], candidates: List[str]) -> Optional[str]:
for candidate in candidates:
if candidate in installed_names:
return candidate
return None
def _is_cron_path(path: str) -> bool:
return (
path == "/etc/crontab"
or path == "/etc/anacrontab"
or path in ("/etc/cron.allow", "/etc/cron.deny")
or path.startswith("/etc/cron.")
or path.startswith("/etc/cron.d/")
or path.startswith("/etc/anacron/")
or path.startswith("/var/spool/cron/")
or path.startswith("/var/spool/crontabs/")
or path.startswith("/var/spool/anacron/")
)
def _is_logrotate_path(path: str) -> bool:
return path == "/etc/logrotate.conf" or path.startswith("/etc/logrotate.d/")
_CRON_CAPTURE_GLOBS = [
"/etc/crontab",
"/etc/cron.d/*",
"/etc/cron.hourly/*",
"/etc/cron.daily/*",
"/etc/cron.weekly/*",
"/etc/cron.monthly/*",
"/etc/cron.allow",
"/etc/cron.deny",
"/etc/anacrontab",
"/etc/anacron/*",
# user crontabs / spool state
"/var/spool/cron/*",
"/var/spool/cron/crontabs/*",
"/var/spool/crontabs/*",
"/var/spool/anacron/*",
]
_LOGROTATE_CAPTURE_GLOBS = [
"/etc/logrotate.conf",
"/etc/logrotate.d/*",
]
@dataclass
class CronLogrotateCollection:
cron_pkg: Optional[str]
logrotate_pkg: Optional[str]
cron_snapshot: Optional[PackageSnapshot]
logrotate_snapshot: Optional[PackageSnapshot]
class CronLogrotateCollector(HarvestCollector):
"""Collect dedicated cron/logrotate package roles before general packages."""
cron_role_name = "cron"
logrotate_role_name = "logrotate"
def collect(self) -> CronLogrotateCollection:
cron_pkg = _pick_installed(
self.context.installed_names,
["cron", "cronie", "cronie-anacron", "vixie-cron", "fcron"],
)
logrotate_pkg = _pick_installed(self.context.installed_names, ["logrotate"])
cron_snapshot = self._collect_cron_snapshot(cron_pkg) if cron_pkg else None
logrotate_snapshot = (
self._collect_logrotate_snapshot(logrotate_pkg) if logrotate_pkg else None
)
return CronLogrotateCollection(
cron_pkg=cron_pkg,
logrotate_pkg=logrotate_pkg,
cron_snapshot=cron_snapshot,
logrotate_snapshot=logrotate_snapshot,
)
def _collect_cron_snapshot(self, cron_pkg: str) -> PackageSnapshot:
managed: List[ManagedFile] = []
excluded: List[ExcludedFile] = []
notes: List[str] = []
seen: Set[str] = set()
for spec in _CRON_CAPTURE_GLOBS:
for path in iter_matching_files(spec):
if not os.path.isfile(path) or os.path.islink(path):
continue
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=self.cron_role_name,
abs_path=path,
reason="system_cron",
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=managed,
excluded_out=excluded,
seen_role=seen,
seen_global=self.context.captured_global,
)
return PackageSnapshot(
package=cron_pkg,
role_name=self.cron_role_name,
section=package_section_from_installations(
self.context.installed_pkgs.get(cron_pkg, [])
),
managed_files=managed,