Compare commits

...

177 commits
0.4.0 ... main

Author SHA1 Message Date
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
98 changed files with 25591 additions and 5743 deletions

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 systemctl python3-apt jq python3-jsonschema
- name: Install Poetry
env:
PYTHON_BIN: ${{ matrix.python }}
POETRY_VERSION: "2.4.1"
run: |
pipx install poetry==1.8.3
/root/.local/bin/poetry --version
set -eux
if ! command -v pipx >/dev/null 2>&1; then
"${PYTHON_BIN}" -m pip install --user pipx
fi
PIPX_BIN="$(command -v pipx || true)"
if [ -z "${PIPX_BIN}" ]; then
PIPX_BIN="${HOME}/.local/bin/pipx"
fi
"${PIPX_BIN}" install --python "${PYTHON_BIN}" "poetry==${POETRY_VERSION}"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
poetry --version
poetry --version | grep -E "Poetry \(version 2\."
- name: Install project deps (including test extras)
env:
PYTHON_BIN: ${{ matrix.python }}
run: |
poetry env use "${PYTHON_BIN}"
poetry install --with dev
- name: Install sops
run: |
set -eux
case "$(uname -m)" in
x86_64) sops_arch=amd64 ;;
aarch64|arm64) sops_arch=arm64 ;;
*) echo "Unsupported architecture for sops: $(uname -m)" >&2; exit 1 ;;
esac
curl -L -o /usr/local/bin/sops "https://github.com/getsops/sops/releases/download/v3.13.1/sops-v3.13.1.linux.${sops_arch}"
chmod +x /usr/local/bin/sops
- name: Run test script
env:
PYTHON_BIN: ${{ matrix.python }}
run: |
./tests.sh

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 --skip-version-check --exit-code 1 .
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

3
.gitignore vendored
View file

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

View file

@ -1,3 +1,54 @@
# 0.8.0
* Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable.
* Security: enforce tar member limits while lazily parsing untrusted archives rather than after `TarFile.getmembers()` has already indexed the entire archive; count repeated `.` entries and cap remote compressed downloads as well.
* Security: apply aggregate byte and total filesystem-entry limits when freezing directory harvest bundles, reject symlinked bundle roots, and abort when files or discovered directories change during the copy, so direct directory inputs remain bounded and fail closed under mutation.
# 0.7.0
* BREAKING CHANGE: Remove the `enroll diff --enforce` option. Enroll no longer applies the old harvest state locally to repair drift; this avoids the risk of enforcing a potentially malicious or tampered harvest. To restore baseline state, regenerate a manifest from the trusted harvest and apply it yourself, or compare two `enroll diff` runs and act on the result.
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
* BREAKING CHANGE: Don't allow reading `.enroll.ini` in the CWD. Use only the ENROLL_CONFIG env var, an explicit `--config` path or else the XDG default location (or `~/.config/enroll/enroll.ini` if `XDG_CONFIG_HOME` is not set).
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
* Support for detecting Docker and Podman images and enforcing their presence (by SHA256 hash).
* Add support for detecting Flatpaks and Snaps.
* Stricter validation of harvests to ensure that they meet the schema and don't contain unsafe artifacts (e.g symlinks pointing outside the artifact tree)
* Perform harvest validation before trying to manifest from it.
* Stricter validation on FQDN name in multisite mode.
* Strict check of `$PATH` when running harvest as root, in case it could lead to execution of unsafe binaries during harvest. Override with `--assume-safe-path` for non-interactive or CI purposes.
* Stricter validation of the destination dirs that harvest or manifest write to, to prevent writing to a different user-controlled area. Stricter permissions on the output dirs too.
# 0.6.0
* Add support for capturing ipset and iptables configuration files
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present (`firewall_runtime` role)
* Dependency updates
# 0.5.0
* Add support for templating `sshd_config`, if a compatible version of JinjaTurtle is also present.
* Dependency updates
# 0.4.4
* Update cryptography dependency
* Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
# 0.4.3
* Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
* Update dependencies
# 0.4.2
* Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
# 0.4.1
* Add interactive output when 'enroll diff --enforce' is invoking Ansible.
# 0.4.0
* Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.

1317
DEVELOPMENT.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@ RUN set -eux; \
apt-get install -y --no-install-recommends \
build-essential \
devscripts \
libdistro-info-perl \
debhelper \
dh-python \
pybuild-plugin-pyproject \

119
README.md
View file

@ -4,17 +4,20 @@
<img src="https://git.mig5.net/mig5/enroll/raw/branch/main/enroll.svg" alt="Enroll logo" width="240" />
</div>
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates Ansible roles/playbooks (and optionally inventory) for what it finds.
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates Ansible configuration-management code from it.
- Detects packages that have been installed.
- Detects package ownership of `/etc` files where possible
- Captures config that has **changed from packaged defaults** where possible (e.g dpkg conffile hashes + package md5sums when available).
- Also captures **service-relevant custom/unowned files** under `/etc/<service>/...` (e.g. drop-in config includes).
- Defensively excludes likely secrets (path denylist + content sniff + size caps).
- Captures non-system users and their SSH public keys and any .bashrc or .bash_aliases or .profile files that deviate from the skel defaults.
- Captures non-system users and their SSH public keys. In `--dangerous` mode, it also auto-harvests common shell dotfiles such as `.bashrc`, `.profile`, `.bash_logout`, and `.bash_aliases` when appropriate.
- Captures miscellaneous `/etc` files it can't attribute to a package and installs them in an `etc_custom` role.
- When running as root/sudo, captures live writable sysctl state into a `sysctl` role that manages `/etc/sysctl.d/99-enroll.conf`.
- Captures live ipset and iptables runtime state, when active ipsets/iptables rules are present *and* no corresponding persistent ipset/iptables *files* were found.
- Captures symlinks in common applications that rely on them, e.g apache2/nginx 'sites-enabled'
- Ditto for /usr/local/bin (for non-binary files) and /usr/local/etc
- Tries to capture Flatpak, Snap, Docker image presence
- Captures snowflake-y things found in /usr/local/bin (for non-binary files) and /usr/local/etc
- Avoids trying to start systemd services that were detected as inactive during harvest.
---
@ -24,7 +27,7 @@
`enroll` works in two phases:
1) **Harvest**: collect host facts + relevant files into a harvest bundle (`state.json` + harvested artifacts)
2) **Manifest**: turn that harvest into Ansible roles/playbooks (and optionally inventory)
2) **Manifest**: turn that harvest into Ansible configuration-management code.
Additionally, some other functionalities exist:
@ -35,8 +38,6 @@ Additionally, some other functionalities exist:
## Output modes: single-site vs multi-site (`--fqdn`)
`enroll manifest` (and `enroll single-shot`) support two distinct output styles.
### Single-site mode (default: *no* `--fqdn`)
Use when enrolling **one server** (or generating a “golden” role set you intend to reuse).
@ -69,12 +70,16 @@ Harvest state about a host and write a harvest bundle.
- “Manual” packages
- Changed-from-default config (plus related custom/unowned files under service dirs)
- Non-system users + SSH public keys
- In `--dangerous` mode: common per-user shell dotfiles that are likely to represent deliberate account customisation
- Misc `/etc` that can't be attributed to a package (`etc_custom` role)
- Static firewall config files such as nftables, UFW, firewalld, `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, and `/etc/ipset*`
- Live writable sysctl state via `sysctl -a`, emitted as `/etc/sysctl.d/99-enroll.conf` at manifest time when running as root/sudo (`sysctl` role)
- Live kernel ipset/iptables state via `ipset save`, `iptables-save`, and `ip6tables-save` as a fallback, but only when the corresponding persistent config was not found (`firewall_runtime` role at manifest time)
- Optional user-specified extra files/dirs via `--include-path` (emitted as an `extra_paths` role at manifest time)
**Common flags**
- Remote harvesting:
- `--remote-host`, `--remote-user`, `--remote-port`
- `--remote-host`, `--remote-user`, `--remote-port`, `--remote-ssh-config`
- `--no-sudo` (if you don't want/need sudo)
- Sensitive-data behaviour:
- default: tries to avoid likely secrets
@ -89,8 +94,30 @@ Harvest state about a host and write a harvest bundle.
- glob (default): supports `*` and `**` (prefix with `glob:` to force)
- regex: prefix with `re:` or `regex:`
- Precedence: excludes win over includes.
* Using remote mode and sudo requires password?
- `--ask-become-pass` (or `-K`) will prompt for the password. If you forget, and remote requires password for sudo, it'll still fall back to prompting for a password, but will be a bit slower to do so.
* Using remote mode and auth requires secrets?
* sudo password:
* `--ask-become-pass` (or `-K`) prompts for the sudo password.
* If you forget, and remote sudo requires a password, Enroll will still fall back to prompting in interactive mode (slightly slower due to retry).
* SSH private-key passphrase:
* `--ask-key-passphrase` prompts for the SSH key passphrase.
* `--ssh-key-passphrase-env ENV_VAR` reads the SSH key passphrase from an environment variable (useful for CI/non-interactive runs).
* If neither is provided, and Enroll detects an encrypted key in an interactive session, it will still fall back to prompting on-demand.
* In non-interactive sessions, pass `--ask-key-passphrase` or `--ssh-key-passphrase-env ENV_VAR` when using encrypted private keys.
* Note: `--ask-key-passphrase` and `--ssh-key-passphrase-env` are mutually exclusive.
- Root PATH safety:
- when run as root, Enroll warns and asks for confirmation if `PATH` contains `.`, an empty/relative entry, or a group/world-writable directory.
- use `--assume-safe-path` for trusted non-interactive automation where that `PATH` is intentional.
Examples (encrypted SSH key)
```bash
# Interactive
enroll harvest --remote-host myhost.example.com --remote-user myuser --ask-key-passphrase --out /tmp/enroll-harvest
# Non-interactive / CI
export ENROLL_SSH_KEY_PASSPHRASE='correct horse battery staple'
enroll single-shot --remote-host myhost.example.com --remote-user myuser --ssh-key-passphrase-env ENROLL_SSH_KEY_PASSPHRASE --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn myhost.example.com
```
---
@ -102,11 +129,12 @@ Generate Ansible output from an existing harvest bundle.
or `--harvest /path/to/harvest.tar.gz.sops` (if using `--sops`)
**Output**
- In plaintext mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
- In plaintext Ansible mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.
**Common flags**
- `--fqdn <host>`: enables **multi-site** output style
- `--fqdn <host>`: enables **multi-site** output style for Ansible (host-specific state lives in inventory `host_vars`).
- `--no-common-roles`: disables the default grouping of package and systemd-unit roles into Debian Section/RPM Group roles, preserving one generated role per package/unit. `--fqdn` implies this behaviour.
**Role tags**
Generated playbooks tag each role so you can target just the parts you need:
@ -119,6 +147,10 @@ Example:
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_services,role_users
```
**IMPORTANT**: Always make sure that you take adequate precautions to prevent a malicious actor from tampering with your harvest. Enroll tries to set the permissions of it to something your running user has access to, but environments and situations can vary. A malicious actor could change your harvest contents in a way that doesn't violate the schema but results in sensitive exposure or dangerous execution once you apply the 'manifested' configuration management version of it.
Whenever in doubt, add `--sops` (with SOPS installed on your PATH) and encrypt the harvest so that only you can decrypt it.
---
### `enroll single-shot`
@ -126,7 +158,7 @@ Convenience wrapper that runs **harvest → manifest** in one command.
Use this when you want “get me something workable ASAP”.
Supports the same general flags as harvest/manifest, including `--fqdn`, remote harvest flags, and `--sops`.
Supports the same general flags as harvest/manifest, including `--fqdn`, `--no-common-roles`, remote harvest flags, and `--sops`.
---
@ -144,24 +176,11 @@ Compare two harvest bundles and report what changed.
- `--sops` when comparing SOPS-encrypted harvest bundles
- `--exclude-path <PATTERN>` (repeatable) to ignore file/dir drift under matching paths (same pattern syntax as harvest)
- `--ignore-package-versions` to ignore package version-only drift (upgrades/downgrades)
- `--enforce` to apply the **old** harvest state locally (requires `ansible-playbook` on `PATH`)
**Noise suppression**
- `--exclude-path` is useful for things that change often but you still want in the harvest baseline (e.g. `/var/anacron`).
- `--ignore-package-versions` keeps routine upgrades from alerting; package add/remove drift is still reported.
**Enforcement (`--enforce`)**
If a diff exists and `ansible-playbook` is available, Enroll will:
1) generate a manifest from the **old** harvest into a temporary directory
2) run `ansible-playbook -i localhost, -c local <tmp>/playbook.yml` (often with `--tags role_<...>` to limit runtime)
3) record in the diff report that the old harvest was enforced
Enforcement is intentionally “safe”:
- reinstalls packages that were removed (`state: present`), but does **not** attempt downgrades/pinning
- restores users, files (contents + permissions/ownership), and service enable/start state
If `ansible-playbook` is not on `PATH`, Enroll returns an error and does not enforce.
**Output formats**
- `--format json` (default for webhooks)
@ -247,12 +266,21 @@ enroll validate ./harvest --fail-on-warnings
By default, `enroll` does **not** assume how you handle secrets in Ansible. It will attempt to avoid harvesting likely sensitive data (private keys, passwords, tokens, etc.). This can mean it skips some config files you may ultimately want to manage.
If you opt in to collecting everything:
Safe-mode content scanning is intentionally conservative. It treats common assignment-style credential keys as sensitive, including names such as `password` (and abbreviations like `passwd`, `pwd`, and `pw`, e.g. `db_pw`), `client_secret`, `secret_key`, `auth_token`, `api_key`, `aws_access_key_id`, `aws_secret_access_key`, `azure_client_secret`, `GOOGLE_APPLICATION_CREDENTIALS`, and service-account key names.
**IMPORTANT**: Enroll tolerates value-less credential keyword mentions in comments, such as `# token`, so ordinary stock configuration files do not become unusable. However, commented-out credential values are still treated as sensitive. A populated credential assignment, credential-bearing URI, `Authorization` header, or private-key material is refused in default safe mode even when it appears inside a comment. Use `--dangerous` only when you intentionally want to collect such material, and prefer `--sops` or another appropriate form of at-rest encryption whenever in doubt.
Automatic harvesting of per-user shell dotfiles is also disabled by default, even when those files differ from `/etc/skel`, because `.bashrc`, `.profile`, `.bash_aliases`, and similar files commonly contain exported tokens, credentials, or aliases/functions with embedded secrets. Use `--dangerous` for automatic shell-dotfile capture, or use targeted `--include-path` patterns for narrower safe-mode review.
If you wish to opt in to collecting everything, use `--dangerous` mode, but be aware of what it means:
### `--dangerous`
**WARNING:** disables “likely secret” safety checks. This can copy private keys, TLS key material, API tokens, database passwords, and other credentials into the harvest output **in plaintext**.
If you intend to keep harvests/manifests long-term (especially in git), strongly consider encrypting them at rest.
**IMPORTANT:** 'dangerous' mode is exactly that: it disables “likely secret” safety checks when harvesting system data.
This means it can copy private keys, TLS key material, API tokens, database passwords, and other credentials into the harvest output **in plaintext**, including paths that would normally be considered very secret.
If you intend to keep harvests/manifests long-term on disk away from the host or its usual protected paths, strongly consider encrypting them at rest!
### Encrypt bundles at rest with `--sops`
`--sops` encrypts the harvest and/or manifest outputs into a single `.tar.gz.sops` file (GPG). This is for **storage-at-rest**, not for direct “Ansible SOPS inventory” workflows.
@ -261,16 +289,17 @@ If you intend to keep harvests/manifests long-term (especially in git), strongly
---
## JinjaTurtle integration (both modes)
## JinjaTurtle integration
If [JinjaTurtle](https://git.mig5.net/mig5/jinjaturtle) is installed, `enroll` can generate Jinja2 templates for ini/json/xml/toml-style config.
If [JinjaTurtle](https://git.mig5.net/mig5/jinjaturtle) is installed, `enroll` can generate templates for ini/json/xml/toml-style config in renderers.
For Ansible:
- Templates live in `roles/<role>/templates/...`
- Variables live in:
- single-site: `roles/<role>/defaults/main.yml`
- multi-site: `inventory/host_vars/<fqdn>/<role>.yml`
You can force it on with `--jinjaturtle` or disable with `--no-jinjaturtle`.
You can force template generation on with `--jinjaturtle` or disable it with `--no-jinjaturtle`.
---
@ -355,6 +384,14 @@ enroll harvest --out /tmp/enroll-harvest
enroll harvest --remote-host myhost.example.com --remote-user myuser --out /tmp/enroll-harvest
```
### Remote harvest over SSH, where the SSH configuration is in ~/.ssh/config (e.g a different SSH key)
Note: you must still pass `--remote-host`, but in this case, its value can be the 'Host' alias of an entry in your `~/.ssh/config`.
```bash
enroll harvest --remote-host myhostalias --remote-ssh-config ~/.ssh/config --out /tmp/enroll-harvest
```
### Include paths (`--include-path`)
```bash
# Add a few dotfiles from /home (still secret-safe unless --dangerous)
@ -402,6 +439,13 @@ enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
```
### Container image caches
If Docker or Podman is available during harvest, Enroll records local image-cache metadata from `image ls` and `image inspect`. Images that expose registry `RepoDigest` values are reproducible by digest, for example `registry.example.net/app@sha256:...`; those are the references rendered into manifests. Local image IDs and tag-only images are preserved as evidence and notes, but are not treated as exact registry pull references.
For Ansible, digest-pinned Docker images are pulled with `community.docker.docker_image_pull` and digest-pinned Podman images are pulled with `containers.podman.podman_image`; harvested tag aliases are re-applied where possible. The generated `requirements.yml` includes `community.docker` and `containers.podman` alongside any other required collections. In `--fqdn` mode the image list is host-specific inventory data.
### Manifest with `--sops`
```bash
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)
@ -452,11 +496,6 @@ enroll diff --old /path/to/harvestA --new /path/to/harvestB --exclude-path /var/
enroll diff --old /path/to/harvestA --new /path/to/harvestB --ignore-package-versions
```
### Enforce the old harvest state when drift is detected (requires Ansible)
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --enforce --ignore-package-versions --exclude-path /var/anacron
```
---
## Explain
@ -504,6 +543,7 @@ Roles collected
- packages: 232 package snapshot(s), 41 file(s), 0 excluded
- apt_config: 26 file(s), 7 dir(s), 10 excluded
- dnf_config: 0 file(s), 0 dir(s), 0 excluded
- firewall_runtime: 2 snapshot(s), 1 ipset(s)
- etc_custom: 70 file(s), 20 dir(s), 0 excluded
- usr_local_custom: 35 file(s), 1 dir(s), 0 excluded
- extra_paths: 0 file(s), 0 dir(s), 0 excluded
@ -549,8 +589,8 @@ Enroll supports reading an ini-style file of all the arguments for each subcomma
### Location of the config file
The path the config file can be specified with `-c` or `--config` on the command-line. Otherwise,
Enroll will look for `./enroll.ini`, `./.enroll.ini` (in the current working directory),
`~/.config/enroll/enroll.ini` (or `$XDG_CONFIG_HOME/enroll/enroll.ini`).
Enroll will look for the `ENROLL_CONFIG` environment variable, `$XDG_CONFIG_HOME/enroll/enroll.ini`,
or `~/.config/enroll/enroll.ini`.
You may also pass `--no-config` if you deliberately want to ignore the config file even if it existed.
@ -585,13 +625,12 @@ exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
[manifest]
# you can set defaults here too, e.g.
no_jinjaturtle = true
sops = 00AE817C24A10C2540461A9C1D7CDE0234DB458D
sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
[diff]
# ignore noisy drift
exclude_path = /var/anacron
ignore_package_versions = true
# enforce = true # requires ansible-playbook on PATH
[single-shot]
# if you use single-shot, put its defaults here.

97
SECURITY.md Normal file
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.

64
debian/changelog vendored
View file

@ -1,5 +1,67 @@
enroll (0.4.0) unstable; urgency=medium
enroll (0.8.0) unstable; urgency=medium
* Security: keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes the post-harvest tampering window created by recursively chowning the bundle before packaging without making the plaintext archive world-readable.
* Security: enforce tar member limits while lazily parsing untrusted archives rather than after `TarFile.getmembers()` has already indexed the entire archive; count repeated `.` entries and cap remote compressed downloads as well.
* Security: apply aggregate byte and total filesystem-entry limits when freezing directory harvest bundles, reject symlinked bundle roots, and abort when files or discovered directories change during the copy, so direct directory inputs remain bounded and fail closed under mutation.
-- Miguel Jacq <mig@mig5.net> Mon, 13 Jul 2026 10:00:00 +1000
enroll (0.7.0) unstable; urgency=medium
* BREAKING CHANGE: Remove the `enroll diff --enforce` option. Enroll no longer applies the old harvest state locally to repair drift; this avoids the risk of enforcing a potentially malicious or tampered harvest. To restore baseline state, regenerate a manifest from the trusted harvest and apply it yourself, or compare two `enroll diff` runs and act on the result.
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
* BREAKING CHANGE: Don't allow reading `.enroll.ini` in the CWD. Use only the ENROLL_CONFIG env var, an explicit `--config` path or else the XDG default location (or `~/.config/enroll/enroll.ini` if `XDG_CONFIG_HOME` is not set).
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
* Support for detecting Docker and Podman images and enforcing their presence (by SHA256 hash).
* Add support for detecting Flatpaks and Snaps.
* Stricter validation of harvests to ensure that they meet the schema and don't contain unsafe artifacts (e.g symlinks pointing outside the artifact tree)
* Perform harvest validation before trying to manifest from it.
* Stricter validation on FQDN name in multisite mode.
* Strict check of `$PATH` when running harvest as root, in case it could lead to execution of unsafe binaries during harvest. Override with `--assume-safe-path` for non-interactive or CI purposes.
* Stricter validation of the destination dirs that harvest or manifest write to, to prevent writing to a different user-controlled area. Stricter permissions on the output dirs too.
* Lots of hardening across the whole codebase and integration with Jinjaturtle.
-- Miguel Jacq <mig@mig5.net> Sun, 5 Jul 2026 11:00:00 +1000
enroll (0.6.0) unstable; urgency=medium
* Add support for capturing ipset and iptables configuration files
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present ('firewall_runtime' role)
-- Miguel Jacq <mig@mig5.net> Thu, 14 May 2026 15:00:00 +1000
enroll (0.5.0) unstable; urgency=medium
* Add ssh config support where JinjaTurtle is used
-- Miguel Jacq <mig@mig5.net> Tue, 12 May 2026 12:00 +1000
enroll (0.4.4) unstable; urgency=medium
* Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
-- Miguel Jacq <mig@mig5.net> Tue, 17 Feb 2026 11:00 +1100
enroll (0.4.3) unstable; urgency=medium
* Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
-- Miguel Jacq <mig@mig5.net> Fri, 16 Jan 2026 11:00 +1100
enroll (0.4.2) unstable; urgency=medium
* Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
-- Miguel Jacq <mig@mig5.net> Tue, 13 Jan 2026 21:55:00 +1100
enroll (0.4.1) unstable; urgency=medium
* Add interactive output when 'enroll diff --enforce' is invoking Ansible.
-- Miguel Jacq <mig@mig5.net> Sun, 11 Jan 2026 10:00:00 +1100
enroll (0.4.0) unstable; urgency=medium
* Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
* Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
* Update pynacl dependency to resolve CVE-2025-69277

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

View file

@ -8,6 +8,8 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from .harvest_safety import OutputSafetyError, ensure_private_dir
def _safe_component(s: str) -> str:
s = s.strip()
@ -44,16 +46,17 @@ class HarvestCache:
def _ensure_dir_secure(path: Path) -> None:
"""Create a directory with restrictive permissions; refuse symlinks."""
# Refuse a symlink at the leaf.
if path.exists() and path.is_symlink():
raise RuntimeError(f"Refusing to use symlink path: {path}")
path.mkdir(parents=True, exist_ok=True, mode=0o700)
"""Create a private cache directory with output-path safety checks.
Cache roots are persistent, so existing directories are allowed, but they
still need the same symlink-component and root-parent trust checks as
plaintext harvest/manifest output paths.
"""
try:
os.chmod(path, 0o700)
except OSError:
# Best-effort; on some FS types chmod may fail.
pass
ensure_private_dir(path, label="cache directory")
except OutputSafetyError as e:
raise RuntimeError(str(e)) from e
def new_harvest_cache_dir(*, hint: Optional[str] = None) -> HarvestCache:

357
enroll/capture.py Normal file
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

View file

@ -4,6 +4,7 @@ import argparse
import configparser
import json
import os
import stat
import sys
import tarfile
import tempfile
@ -13,16 +14,19 @@ from typing import Optional
from .cache import new_harvest_cache_dir
from .diff import (
compare_harvests,
enforce_old_harvest,
format_report,
has_enforceable_drift,
post_webhook,
send_email,
)
from .explain import explain_state
from .harvest import harvest
from .harvest_safety import ensure_safe_output_parent, write_text_output_file
from .manifest import manifest
from .remote import remote_harvest, RemoteSudoPasswordRequired
from .remote import (
remote_harvest,
RemoteSudoPasswordRequired,
RemoteSSHKeyPassphraseRequired,
)
from .sopsutil import SopsError, encrypt_file_binary
from .validate import validate_harvest
from .version import get_enroll_version
@ -35,8 +39,10 @@ def _discover_config_path(argv: list[str]) -> Optional[Path]:
1) --no-config disables loading.
2) --config PATH (or -c PATH)
3) $ENROLL_CONFIG
4) ./enroll.ini, ./.enroll.ini
5) $XDG_CONFIG_HOME/enroll/enroll.ini (or ~/.config/enroll/enroll.ini)
4) $XDG_CONFIG_HOME/enroll/enroll.ini (or ~/.config/enroll/enroll.ini)
Current-directory config files are deliberately not auto-loaded; use
--config ./enroll.ini if that behaviour is desired.
The config file is optional; if no file is found, returns None.
"""
@ -62,12 +68,6 @@ def _discover_config_path(argv: list[str]) -> Optional[Path]:
if envp:
return Path(envp).expanduser()
cwd = Path.cwd()
for name in ("enroll.ini", ".enroll.ini"):
cp = cwd / name
if cp.exists() and cp.is_file():
return cp
xdg = os.environ.get("XDG_CONFIG_HOME")
if xdg:
base = Path(xdg).expanduser()
@ -111,6 +111,15 @@ def _action_lookup(p: argparse.ArgumentParser) -> dict[str, argparse.Action]:
return m
def _warn_dangerous_harvest(*, sops_enabled: bool) -> None:
if not sops_enabled:
print(
"warning: --dangerous is enabled. The harvest may contain sensitive "
"files, credentials, private keys, tokens, or application secrets. "
"Consider using --sops to encrypt the harvest at rest."
)
def _choose_flag(a: argparse.Action) -> Optional[str]:
# Prefer a long flag if available (e.g. --dangerous over -d)
for s in getattr(a, "option_strings", []) or []:
@ -134,6 +143,149 @@ def _split_list_value(v: str) -> list[str]:
return [raw] if raw else []
def _root_trust_reason(path: Path, *, final: bool) -> Optional[str]:
"""Return why a PATH directory/ancestor is unsafe for root execution."""
running_as_root = _is_effective_root()
if not final and not running_as_root:
return None
try:
st = os.stat(path)
except OSError:
return None
if not stat.S_ISDIR(st.st_mode):
return None
subject = "directory" if final else "parent directory"
if running_as_root and st.st_uid != 0:
return f"{subject} is not owned by root"
writable_by_group = bool(st.st_mode & stat.S_IWGRP)
writable_by_other = bool(st.st_mode & stat.S_IWOTH)
sticky = bool(st.st_mode & stat.S_ISVTX)
# A sticky shared ancestor such as /tmp may contain a root-owned PATH
# directory safely enough for this check, but the PATH entry itself must
# never be writable by group/other because that permits command planting.
if final or not sticky:
if writable_by_other:
return f"{subject} is world-writable"
if writable_by_group:
return f"{subject} is group-writable"
return None
def _root_parent_trust_reason(path: Path) -> Optional[str]:
"""Check original and resolved PATH ancestors for root trust."""
if not _is_effective_root():
return None
candidates: list[Path] = []
candidates.extend(reversed(path.parents))
try:
resolved = path.resolve(strict=True)
except OSError:
resolved = None
if resolved is not None and resolved != path:
candidates.extend(reversed(resolved.parents))
seen: set[str] = set()
for parent in candidates:
key = str(parent)
if key in seen:
continue
seen.add(key)
reason = _root_trust_reason(parent, final=False)
if reason:
return f"{reason}: {parent}"
return None
def _path_entry_is_unsafe(entry: str) -> Optional[str]:
"""Return a human-readable reason if a PATH entry is unsafe for root.
Empty PATH entries and relative entries resolve via the current working
directory, which is equivalent to trusting whatever directory the operator
happens to be in. Existing group/world-writable directories are also risky
when Enroll is run as root because Enroll deliberately invokes host tools
from PATH while harvesting and enforcing state. When running as root, an
existing PATH directory must also be root-owned; a non-root-owned 0755
directory is still attacker-controlled by its owner.
"""
if entry == "":
return "empty PATH entry resolves to the current directory"
if entry == ".":
return "'.' resolves to the current directory"
if not os.path.isabs(entry):
return "relative PATH entry resolves from the current directory"
p = Path(entry)
parent_reason = _root_parent_trust_reason(p)
if parent_reason:
return parent_reason
try:
st = os.stat(entry)
except OSError:
return None
if not stat.S_ISDIR(st.st_mode):
return None
final_reason = _root_trust_reason(p, final=True)
if final_reason:
return final_reason
return None
def _unsafe_root_path_reasons(path_value: Optional[str] = None) -> list[str]:
"""Return unsafe PATH entries that should make root execution interactive."""
raw = os.environ.get("PATH", "") if path_value is None else str(path_value)
out: list[str] = []
for entry in raw.split(os.pathsep):
reason = _path_entry_is_unsafe(entry)
if reason:
label = entry if entry else "<empty>"
out.append(f"{label}: {reason}")
return out
def _is_effective_root() -> bool:
geteuid = getattr(os, "geteuid", None)
return bool(geteuid is not None and geteuid() == 0)
def _confirm_root_path_safety(*, force: bool = False) -> None:
"""Prompt before running as root with a PATH that trusts writable entries."""
if force or not _is_effective_root():
return
reasons = _unsafe_root_path_reasons()
if not reasons:
return
details = "\n".join(f" - {r}" for r in reasons)
msg = (
"warning: enroll is running as root and PATH contains entries that "
"could allow an untrusted binary to be executed:\n"
f"{details}\n"
)
if not sys.stdin.isatty():
raise SystemExit(
msg + "error: refusing to continue non-interactively. Re-run with "
"--assume-safe-path if you intentionally trust this PATH."
)
print(msg, file=sys.stderr, end="")
answer = input("Are you sure you want to continue? [y/N] ")
if answer.strip().lower() not in {"y", "yes"}:
raise SystemExit("aborted: unsafe root PATH was not confirmed")
def _section_to_argv(
p: argparse.ArgumentParser, cfg: configparser.ConfigParser, section: str
) -> list[str]:
@ -275,7 +427,7 @@ def _resolve_sops_out_file(out: Optional[str], *, hint: str) -> Path:
def _tar_dir_to(path_dir: Path, tar_path: Path) -> None:
tar_path.parent.mkdir(parents=True, exist_ok=True)
ensure_safe_output_parent(tar_path, label="harvest tar output")
with tarfile.open(tar_path, mode="w:gz") as tf:
# Keep a stable on-disk layout when extracted: state.json + artifacts/
tf.add(str(path_dir), arcname=".")
@ -285,7 +437,7 @@ def _encrypt_harvest_dir_to_sops(
bundle_dir: Path, out_file: Path, fps: list[str]
) -> Path:
out_file = Path(out_file)
out_file.parent.mkdir(parents=True, exist_ok=True)
ensure_safe_output_parent(out_file, label="encrypted harvest output")
# Create the tarball alongside the output file (keeps filesystem permissions/locality sane).
fd, tmp_tgz = tempfile.mkstemp(
@ -306,7 +458,15 @@ def _encrypt_harvest_dir_to_sops(
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--fqdn",
help="Host FQDN/name for site-mode output (creates inventory/, inventory/host_vars/, playbooks/).",
help="Host FQDN/name for site-mode output (creates Ansible host_vars for that host).",
)
p.add_argument(
"--no-common-roles",
action="store_true",
help=(
"Do not group package and systemd-unit roles into common section/group roles. "
"This preserves one generated role per package/unit. --fqdn implies this."
),
)
g = p.add_mutually_exclusive_group()
g.add_argument(
@ -321,12 +481,12 @@ def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
)
def _jt_mode(args: argparse.Namespace) -> str:
def _jt_mode(args: argparse.Namespace) -> Optional[bool]:
if getattr(args, "jinjaturtle", False):
return "on"
return True
if getattr(args, "no_jinjaturtle", False):
return "off"
return "auto"
return False
return None
def _add_config_args(p: argparse.ArgumentParser) -> None:
@ -334,8 +494,8 @@ def _add_config_args(p: argparse.ArgumentParser) -> None:
"-c",
"--config",
help=(
"Path to an INI config file for default options. If omitted, enroll will look for "
"./enroll.ini, ./.enroll.ini, or ~/.config/enroll/enroll.ini (or $XDG_CONFIG_HOME/enroll/enroll.ini)."
"Path to an INI config file for default options. If omitted, enroll will look for a path defined by the "
"ENROLL_CONFIG environment variable , ~/.config/enroll/enroll.ini (or $XDG_CONFIG_HOME/enroll/enroll.ini)."
),
)
p.add_argument(
@ -345,21 +505,53 @@ def _add_config_args(p: argparse.ArgumentParser) -> None:
)
def _add_path_safety_args(
p: argparse.ArgumentParser, *, default: object = False
) -> None:
p.add_argument(
"--assume-safe-path",
action="store_true",
default=default,
help=(
"When running as root, continue without confirmation even if PATH "
"contains '.', an empty/relative entry, or a group/world-writable "
"directory. Intended for trusted non-interactive automation."
),
)
def _add_remote_args(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--remote-host",
help="SSH host to run harvesting on (if set, harvest runs remotely and is pulled locally).",
)
p.add_argument(
"--remote-ssh-config",
nargs="?",
const=str(Path.home() / ".ssh" / "config"),
default=None,
help=(
"Use OpenSSH-style ssh_config settings for --remote-host. "
"If provided without a value, defaults to ~/.ssh/config. "
"(Applies HostName/User/Port/IdentityFile/ProxyCommand/HostKeyAlias when supported.)"
),
)
p.add_argument(
"--remote-port",
type=int,
default=22,
help="SSH port for --remote-host (default: 22).",
default=None,
help=(
"SSH port for --remote-host. If omitted, defaults to 22, or a value from ssh_config when "
"--remote-ssh-config is set."
),
)
p.add_argument(
"--remote-user",
default=os.environ.get("USER") or None,
help="SSH username for --remote-host (default: local $USER).",
default=None,
help=(
"SSH username for --remote-host. If omitted, defaults to local $USER, or a value from ssh_config when "
"--remote-ssh-config is set."
),
)
# Align terminology with Ansible: "become" == sudo.
@ -373,6 +565,24 @@ def _add_remote_args(p: argparse.ArgumentParser) -> None:
),
)
keyp = p.add_mutually_exclusive_group()
keyp.add_argument(
"--ask-key-passphrase",
action="store_true",
help=(
"Prompt for the SSH private key passphrase when using --remote-host. "
"If not set, enroll will still prompt on-demand if it detects an encrypted key in an interactive session."
),
)
keyp.add_argument(
"--ssh-key-passphrase-env",
metavar="ENV_VAR",
help=(
"Read the SSH private key passphrase from environment variable ENV_VAR "
"(useful for non-interactive runs/CI)."
),
)
def main() -> None:
ap = argparse.ArgumentParser(prog="enroll")
@ -383,10 +593,12 @@ def main() -> None:
version=f"{get_enroll_version()}",
)
_add_config_args(ap)
_add_path_safety_args(ap)
sub = ap.add_subparsers(dest="cmd", required=True)
h = sub.add_parser("harvest", help="Harvest service/package/config state")
_add_config_args(h)
_add_path_safety_args(h, default=argparse.SUPPRESS)
_add_remote_args(h)
h.add_argument(
"--out",
@ -420,7 +632,6 @@ def main() -> None:
"Excludes apply to all harvesting, including defaults."
),
)
h.add_argument(
"--sops",
nargs="+",
@ -436,8 +647,11 @@ def main() -> None:
help="Don't use sudo on the remote host (when using --remote options). This may result in a limited harvest due to permission restrictions.",
)
m = sub.add_parser("manifest", help="Render Ansible roles from a harvest")
m = sub.add_parser(
"manifest", help="Render configuration-management code from a harvest"
)
_add_config_args(m)
_add_path_safety_args(m, default=argparse.SUPPRESS)
m.add_argument(
"--harvest",
required=True,
@ -468,9 +682,11 @@ def main() -> None:
_add_common_manifest_args(m)
s = sub.add_parser(
"single-shot", help="Harvest state, then manifest Ansible code, in one shot"
"single-shot",
help="Harvest state, then manifest configuration-management code, in one shot",
)
_add_config_args(s)
_add_path_safety_args(s, default=argparse.SUPPRESS)
_add_remote_args(s)
s.add_argument(
"--harvest",
@ -504,7 +720,6 @@ def main() -> None:
"Excludes apply to all harvesting, including defaults."
),
)
s.add_argument(
"--sops",
nargs="+",
@ -532,6 +747,7 @@ def main() -> None:
d = sub.add_parser("diff", help="Compare two harvests and report differences")
_add_config_args(d)
_add_path_safety_args(d, default=argparse.SUPPRESS)
d.add_argument(
"--old",
required=True,
@ -575,15 +791,6 @@ def main() -> None:
"Package additions/removals are still reported. Useful when routine upgrades would otherwise create noisy drift."
),
)
d.add_argument(
"--enforce",
action="store_true",
help=(
"If differences are detected, attempt to enforce the old harvest state locally by generating a manifest and "
"running ansible-playbook. Requires ansible-playbook on PATH. "
"Enroll does not attempt to downgrade packages; if the only drift is package version upgrades (or newly installed packages), enforcement is skipped."
),
)
d.add_argument(
"--out",
help="Write the report to this file instead of stdout.",
@ -644,6 +851,7 @@ def main() -> None:
e = sub.add_parser("explain", help="Explain a harvest state.json")
_add_config_args(e)
_add_path_safety_args(e, default=argparse.SUPPRESS)
e.add_argument(
"harvest",
help=(
@ -672,6 +880,7 @@ def main() -> None:
"validate", help="Validate a harvest bundle (state.json + artifacts)"
)
_add_config_args(v)
_add_path_safety_args(v, default=argparse.SUPPRESS)
v.add_argument(
"harvest",
help=(
@ -686,8 +895,17 @@ def main() -> None:
v.add_argument(
"--schema",
help=(
"Optional JSON schema source (file path or https:// URL). "
"If omitted, uses the schema vendored in the enroll codebase."
"Optional JSON schema source. File paths are loaded by default; "
"http(s) URLs require --allow-remote-schema. If omitted, uses "
"the schema vendored in the enroll codebase."
),
)
v.add_argument(
"--allow-remote-schema",
action="store_true",
help=(
"Allow --schema to fetch an http(s) URL. Disabled by default so "
"validation never makes network requests unless explicitly requested."
),
)
v.add_argument(
@ -728,6 +946,24 @@ def main() -> None:
)
args = ap.parse_args(argv)
if args.cmd in {"harvest", "single-shot"} and bool(
getattr(args, "dangerous", False)
):
_warn_dangerous_harvest(sops_enabled=bool(getattr(args, "sops", None)))
_confirm_root_path_safety(force=bool(getattr(args, "assume_safe_path", False)))
# Preserve historical defaults for remote harvesting unless ssh_config lookup is enabled.
# This lets ssh_config values take effect when the user did not explicitly set
# --remote-user / --remote-port.
if hasattr(args, "remote_host"):
rsc = getattr(args, "remote_ssh_config", None)
if not rsc:
if getattr(args, "remote_port", None) is None:
setattr(args, "remote_port", 22)
if getattr(args, "remote_user", None) is None:
setattr(args, "remote_user", os.environ.get("USER") or None)
try:
if args.cmd == "harvest":
sops_fps = getattr(args, "sops", None)
@ -743,14 +979,20 @@ def main() -> None:
pass
remote_harvest(
ask_become_pass=args.ask_become_pass,
ask_key_passphrase=bool(args.ask_key_passphrase),
ssh_key_passphrase_env=getattr(
args, "ssh_key_passphrase_env", None
),
local_out_dir=tmp_bundle,
remote_host=args.remote_host,
remote_port=int(args.remote_port),
remote_port=args.remote_port,
remote_user=args.remote_user,
remote_ssh_config=args.remote_ssh_config,
dangerous=bool(args.dangerous),
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=True,
)
_encrypt_harvest_dir_to_sops(
tmp_bundle, out_file, list(sops_fps)
@ -764,14 +1006,20 @@ def main() -> None:
)
state = remote_harvest(
ask_become_pass=args.ask_become_pass,
ask_key_passphrase=bool(args.ask_key_passphrase),
ssh_key_passphrase_env=getattr(
args, "ssh_key_passphrase_env", None
),
local_out_dir=out_dir,
remote_host=args.remote_host,
remote_port=int(args.remote_port),
remote_port=args.remote_port,
remote_user=args.remote_user,
remote_ssh_config=args.remote_ssh_config,
dangerous=bool(args.dangerous),
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=not bool(args.out),
)
print(str(state))
else:
@ -789,6 +1037,7 @@ def main() -> None:
dangerous=bool(args.dangerous),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=True,
)
_encrypt_harvest_dir_to_sops(
tmp_bundle, out_file, list(sops_fps)
@ -808,6 +1057,7 @@ def main() -> None:
dangerous=bool(args.dangerous),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=not bool(args.out),
)
print(path)
elif args.cmd == "explain":
@ -825,6 +1075,7 @@ def main() -> None:
sops_mode=bool(getattr(args, "sops", False)),
schema=getattr(args, "schema", None),
no_schema=bool(getattr(args, "no_schema", False)),
allow_remote_schema=bool(getattr(args, "allow_remote_schema", False)),
)
fmt = str(getattr(args, "format", "text"))
@ -835,9 +1086,7 @@ def main() -> None:
out_path = getattr(args, "out", None)
if out_path:
p = Path(out_path).expanduser()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(txt, encoding="utf-8")
write_text_output_file(out_path, txt, label="validation report")
else:
sys.stdout.write(txt)
@ -853,6 +1102,7 @@ def main() -> None:
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
sops_fingerprints=getattr(args, "sops", None),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
)
if getattr(args, "sops", None) and out_enc:
print(str(out_enc))
@ -867,47 +1117,10 @@ def main() -> None:
),
)
# Optional enforcement: if drift is detected, attempt to restore the
# system to the *old* (baseline) state using ansible-playbook.
if bool(getattr(args, "enforce", False)):
if has_changes:
if not has_enforceable_drift(report):
report["enforcement"] = {
"requested": True,
"status": "skipped",
"reason": (
"no enforceable drift detected (only additions and/or package version changes); "
"enroll does not attempt to downgrade packages"
),
}
else:
try:
info = enforce_old_harvest(
args.old,
sops_mode=bool(getattr(args, "sops", False)),
report=report,
)
except Exception as e:
raise SystemExit(
f"error: could not enforce old harvest state: {e}"
) from e
report["enforcement"] = {
"requested": True,
**(info or {}),
}
else:
report["enforcement"] = {
"requested": True,
"status": "skipped",
"reason": "no differences detected",
}
txt = format_report(report, fmt=str(getattr(args, "format", "text")))
out_path = getattr(args, "out", None)
if out_path:
p = Path(out_path).expanduser()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(txt, encoding="utf-8")
write_text_output_file(out_path, txt, label="diff report")
else:
print(txt, end="" if txt.endswith("\n") else "\n")
@ -966,14 +1179,20 @@ def main() -> None:
pass
remote_harvest(
ask_become_pass=args.ask_become_pass,
ask_key_passphrase=bool(args.ask_key_passphrase),
ssh_key_passphrase_env=getattr(
args, "ssh_key_passphrase_env", None
),
local_out_dir=tmp_bundle,
remote_host=args.remote_host,
remote_port=int(args.remote_port),
remote_port=args.remote_port,
remote_user=args.remote_user,
remote_ssh_config=args.remote_ssh_config,
dangerous=bool(args.dangerous),
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=True,
)
_encrypt_harvest_dir_to_sops(
tmp_bundle, out_file, list(sops_fps)
@ -985,6 +1204,7 @@ def main() -> None:
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
sops_fingerprints=list(sops_fps),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
)
if not args.harvest:
print(str(out_file))
@ -996,20 +1216,27 @@ def main() -> None:
)
remote_harvest(
ask_become_pass=args.ask_become_pass,
ask_key_passphrase=bool(args.ask_key_passphrase),
ssh_key_passphrase_env=getattr(
args, "ssh_key_passphrase_env", None
),
local_out_dir=harvest_dir,
remote_host=args.remote_host,
remote_port=int(args.remote_port),
remote_port=args.remote_port,
remote_user=args.remote_user,
remote_ssh_config=args.remote_ssh_config,
dangerous=bool(args.dangerous),
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=not bool(args.harvest),
)
manifest(
str(harvest_dir),
args.out,
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
)
# For usability (when --harvest wasn't provided), print the harvest path.
if not args.harvest:
@ -1029,6 +1256,7 @@ def main() -> None:
dangerous=bool(args.dangerous),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=True,
)
_encrypt_harvest_dir_to_sops(
tmp_bundle, out_file, list(sops_fps)
@ -1040,6 +1268,7 @@ def main() -> None:
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
sops_fingerprints=list(sops_fps),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
)
if not args.harvest:
print(str(out_file))
@ -1059,11 +1288,18 @@ def main() -> None:
args.out,
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
)
except RemoteSudoPasswordRequired:
raise SystemExit(
"error: remote sudo requires a password. Re-run with --ask-become-pass."
) from None
except RemoteSSHKeyPassphraseRequired as e:
msg = str(e).strip() or (
"SSH private key passphrase is required. "
"Re-run with --ask-key-passphrase or --ssh-key-passphrase-env VAR."
)
raise SystemExit(f"error: {msg}") from None
except RuntimeError as e:
raise SystemExit(f"error: {e}") from None
except SopsError as e:

933
enroll/cm.py Normal file
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

@ -69,7 +69,7 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
Uses dpkg-query and is expected to work on Debian/Ubuntu-like systems.
Output format:
{"pkg": [{"version": "...", "arch": "..."}, ...], ...}
{"pkg": [{"version": "...", "arch": "...", "section": "..."}, ...], ...}
"""
try:
@ -77,7 +77,7 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
[
"dpkg-query",
"-W",
"-f=${Package}\t${Version}\t${Architecture}\n",
"-f=${Package}\t${Version}\t${Architecture}\t${Section}\n",
],
text=True,
capture_output=True,
@ -97,7 +97,10 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
name, ver, arch = parts[0].strip(), parts[1].strip(), parts[2].strip()
if not name:
continue
out.setdefault(name, []).append({"version": ver, "arch": arch})
instance = {"version": ver, "arch": arch}
if len(parts) >= 4 and parts[3].strip():
instance["section"] = parts[3].strip()
out.setdefault(name, []).append(instance)
# Stable ordering for deterministic JSON dumps.
for k in list(out.keys()):
@ -183,7 +186,12 @@ def parse_status_conffiles(
if m:
out[pkg] = m
with open(status_path, "r", encoding="utf-8", errors="replace") as f:
try:
f = open(status_path, "r", encoding="utf-8", errors="replace")
except OSError:
return out
with f:
for line in f:
if line.strip() == "":
if cur:
@ -220,6 +228,10 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]:
line = line.strip()
if not line:
continue
md5, rel = line.split(None, 1)
parts = line.split(None, 1)
if len(parts) != 2:
# Skip malformed/truncated lines instead of aborting the harvest.
continue
md5, rel = parts
m[rel.strip()] = md5.strip()
return m

View file

@ -3,11 +3,14 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess # nosec
import tarfile
import tempfile
import sys
import threading
import time
import itertools
import urllib.request
from contextlib import ExitStack
from dataclasses import dataclass
@ -17,8 +20,100 @@ from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
from .remote import _safe_extract_tar
from .state import (
inventory_packages_from_state as _packages_inventory,
load_state as _load_state,
roles_from_state as _roles,
state_path,
)
from .pathfilter import PathFilter
from .sopsutil import decrypt_file_binary_to, require_sops_cmd
from .manifest_safety import freeze_directory_bundle
from .cm import sanitize_markdown_text, sanitize_report_text
def _validate_diff_bundle(label: str, bundle_dir: Path) -> None:
"""Validate a resolved harvest bundle before diff reads artifacts.
`diff` intentionally compares older harvests, so keep schema validation out
of this internal safety pass. The important security property here is that
the bundle's artifact tree has the same path/symlink/hardlink/special-file
checks that `manifest` relies on before copying artifacts.
"""
# Import lazily to avoid a module-level cycle: enroll.validate imports
# BundleRef/_bundle_from_input from this module.
from .validate import validate_harvest
validation = validate_harvest(str(bundle_dir), no_schema=True)
if not validation.ok:
raise RuntimeError(
f"{label} harvest failed validation; refusing to diff unsafe bundle.\n"
+ validation.to_text().strip()
)
def _progress_enabled() -> bool:
"""Return True if we should display interactive progress UI on the CLI.
We only emit progress when stderr is a TTY, so it won't pollute JSON/text reports
captured by systemd, CI, webhooks, etc. Users can also disable this explicitly via
ENROLL_NO_PROGRESS=1.
"""
if os.environ.get("ENROLL_NO_PROGRESS", "").strip() in {"1", "true", "yes"}:
return False
try:
return sys.stderr.isatty()
except Exception:
return False
class _Spinner:
"""A tiny terminal spinner with an elapsed-time counter (stderr-only)."""
def __init__(self, message: str, *, interval: float = 0.12) -> None:
self.message = message.rstrip()
self.interval = interval
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self._last_len = 0
self._start = 0.0
def start(self) -> None:
if self._thread is not None:
return
self._start = time.monotonic()
self._thread = threading.Thread(
target=self._run, name="enroll-spinner", daemon=True
)
self._thread.start()
def stop(self, final_line: Optional[str] = None) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=1.0)
# Clear spinner line.
try:
sys.stderr.write("\r" + (" " * max(self._last_len, 0)) + "\r")
if final_line:
sys.stderr.write(final_line.rstrip() + "\n")
sys.stderr.flush()
except Exception:
pass # nosec
def _run(self) -> None:
frames = itertools.cycle("|/-\\")
while not self._stop.is_set():
elapsed = time.monotonic() - self._start
line = f"{self.message} {next(frames)} {elapsed:0.1f}s"
try:
sys.stderr.write("\r" + line)
sys.stderr.flush()
self._last_len = max(self._last_len, len(line))
except Exception:
return
self._stop.wait(self.interval)
def _utc_now_iso() -> str:
@ -49,10 +144,12 @@ class BundleRef:
@property
def state_path(self) -> Path:
return self.dir / "state.json"
return state_path(self.dir)
def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
def _bundle_from_input(
path: str, *, sops_mode: bool, freeze: bool = False
) -> BundleRef:
"""Resolve a user-supplied path to a harvest bundle directory.
Accepts:
@ -60,6 +157,14 @@ def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
- a path to state.json inside a bundle directory
- (sops mode or .sops) a SOPS-encrypted tar.gz bundle
- a plain tar.gz/tgz bundle
When ``freeze`` is True, a plain *directory* input is copied into a private
0700 temp directory (no-follow, regular-files-only) before being returned, so
a later consumer cannot be raced by an unprivileged owner mutating the source
directory after validation. Tar/SOPS inputs are always extracted into a
private temp directory and so are inherently frozen. ``freeze`` is left False
for purely diagnostic callers (e.g. ``validate``) that should report on the
exact directory the operator named rather than on a copy of it.
"""
p = Path(path).expanduser()
@ -69,6 +174,9 @@ def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
p = p.parent
if p.is_dir():
if freeze:
frozen_dir, td_frozen = freeze_directory_bundle(p, label="harvest bundle")
return BundleRef(dir=Path(frozen_dir), tempdir=td_frozen)
return BundleRef(dir=p)
if not p.exists():
@ -122,24 +230,10 @@ def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
)
def _load_state(bundle_dir: Path) -> Dict[str, Any]:
sp = bundle_dir / "state.json"
with open(sp, "r", encoding="utf-8") as f:
return json.load(f)
def _packages_inventory(state: Dict[str, Any]) -> Dict[str, Any]:
return (state.get("inventory") or {}).get("packages") or {}
def _all_packages(state: Dict[str, Any]) -> List[str]:
return sorted(_packages_inventory(state).keys())
def _roles(state: Dict[str, Any]) -> Dict[str, Any]:
return state.get("roles") or {}
def _pkg_version_key(entry: Dict[str, Any]) -> Optional[str]:
"""Return a stable string used for version comparison."""
installs = entry.get("installations") or []
@ -236,6 +330,12 @@ def _iter_managed_files(state: Dict[str, Any]) -> Iterable[Tuple[str, Dict[str,
for mf in ac.get("managed_files", []) or []:
yield str(ac_role), mf
# sysctl
sc = _roles(state).get("sysctl") or {}
sc_role = sc.get("role_name") or "sysctl"
for mf in sc.get("managed_files", []) or []:
yield str(sc_role), mf
# etc_custom
ec = _roles(state).get("etc_custom") or {}
ec_role = ec.get("role_name") or "etc_custom"
@ -299,13 +399,16 @@ def compare_harvests(
Returns (report, has_changes).
"""
with ExitStack() as stack:
old_b = _bundle_from_input(old_path, sops_mode=sops_mode)
new_b = _bundle_from_input(new_path, sops_mode=sops_mode)
old_b = _bundle_from_input(old_path, sops_mode=sops_mode, freeze=True)
new_b = _bundle_from_input(new_path, sops_mode=sops_mode, freeze=True)
if old_b.tempdir:
stack.callback(old_b.tempdir.cleanup)
if new_b.tempdir:
stack.callback(new_b.tempdir.cleanup)
_validate_diff_bundle("old", old_b.dir)
_validate_diff_bundle("new", new_b.dir)
old_state = _load_state(old_b.dir)
new_state = _load_state(new_b.dir)
@ -539,276 +642,6 @@ def compare_harvests(
return report, has_changes
def has_enforceable_drift(report: Dict[str, Any]) -> bool:
"""Return True if the diff report contains drift that is safe/meaningful to enforce.
Enforce mode is intended to restore *state* (files/users/services) and to
reinstall packages that were removed.
It is deliberately conservative about package drift:
- Package *version* changes alone are not enforced (no downgrades).
- Newly installed packages are not removed.
This helper lets the CLI decide whether `--enforce` should actually run.
"""
pk = report.get("packages", {}) or {}
if pk.get("removed"):
return True
sv = report.get("services", {}) or {}
# We do not try to disable newly-enabled services; we only restore units
# that were enabled in the baseline but are now missing.
if sv.get("enabled_removed") or []:
return True
for ch in sv.get("changed", []) or []:
changes = ch.get("changes") or {}
# Ignore package set drift for enforceability decisions; package
# enforcement is handled via reinstalling removed packages, and we
# avoid trying to "undo" upgrades/renames.
for k in changes.keys():
if k != "packages":
return True
us = report.get("users", {}) or {}
# We restore baseline users (missing/changed). We do not remove newly-added users.
if (us.get("removed") or []) or (us.get("changed") or []):
return True
fl = report.get("files", {}) or {}
# We restore baseline files (missing/changed). We do not delete newly-managed files.
if (fl.get("removed") or []) or (fl.get("changed") or []):
return True
return False
def _role_tag(role: str) -> str:
"""Return the Ansible tag name for a role (must match manifest generation)."""
r = str(role or "").strip()
safe = re.sub(r"[^A-Za-z0-9_-]+", "_", r).strip("_")
if not safe:
safe = "other"
return f"role_{safe}"
def _enforcement_plan(
report: Dict[str, Any],
old_state: Dict[str, Any],
old_bundle_dir: Path,
) -> Dict[str, Any]:
"""Return a best-effort enforcement plan (roles/tags) for this diff report.
We only plan for drift that the baseline manifest can safely restore:
- packages that were removed (reinstall, no downgrades)
- baseline users that were removed/changed
- baseline files that were removed/changed
- baseline systemd units that were disabled/changed
We do NOT plan to remove newly-added packages/users/files/services.
"""
roles: set[str] = set()
# --- Packages (only removals)
pk = report.get("packages", {}) or {}
removed_pkgs = set(pk.get("removed") or [])
if removed_pkgs:
pkg_to_roles: Dict[str, set[str]] = {}
for svc in _roles(old_state).get("services") or []:
r = str(svc.get("role_name") or "").strip()
for p in svc.get("packages", []) or []:
if p:
pkg_to_roles.setdefault(str(p), set()).add(r)
for pr in _roles(old_state).get("packages") or []:
r = str(pr.get("role_name") or "").strip()
p = pr.get("package")
if p:
pkg_to_roles.setdefault(str(p), set()).add(r)
for p in removed_pkgs:
for r in pkg_to_roles.get(str(p), set()):
if r:
roles.add(r)
# --- Users (removed/changed)
us = report.get("users", {}) or {}
if (us.get("removed") or []) or (us.get("changed") or []):
u = _roles(old_state).get("users") or {}
u_role = str(u.get("role_name") or "users")
if u_role:
roles.add(u_role)
# --- Files (removed/changed)
fl = report.get("files", {}) or {}
file_paths: List[str] = []
for e in fl.get("removed", []) or []:
if isinstance(e, dict):
p = e.get("path")
else:
p = e
if p:
file_paths.append(str(p))
for e in fl.get("changed", []) or []:
if isinstance(e, dict):
p = e.get("path")
else:
p = e
if p:
file_paths.append(str(p))
if file_paths:
idx = _file_index(old_bundle_dir, old_state)
for p in file_paths:
rec = idx.get(p)
if rec and rec.role:
roles.add(str(rec.role))
# --- Services (enabled_removed + meaningful changes)
sv = report.get("services", {}) or {}
units: List[str] = []
for u in sv.get("enabled_removed", []) or []:
if u:
units.append(str(u))
for ch in sv.get("changed", []) or []:
if not isinstance(ch, dict):
continue
unit = ch.get("unit")
changes = ch.get("changes") or {}
if unit and any(k != "packages" for k in changes.keys()):
units.append(str(unit))
if units:
old_units = _service_units(old_state)
for u in units:
snap = old_units.get(u)
if snap and snap.get("role_name"):
roles.add(str(snap.get("role_name")))
# Drop empty/unknown roles.
roles = {r for r in roles if r and str(r).strip() and str(r).strip() != "unknown"}
tags = sorted({_role_tag(r) for r in roles})
return {
"roles": sorted(roles),
"tags": tags,
}
def enforce_old_harvest(
old_path: str,
*,
sops_mode: bool = False,
report: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Enforce the *old* (baseline) harvest state on the current machine.
When Ansible is available, this:
1) renders a temporary manifest from the old harvest, and
2) runs ansible-playbook locally to apply it.
Returns a dict suitable for attaching to the diff report under
report['enforcement'].
"""
ansible_playbook = shutil.which("ansible-playbook")
if not ansible_playbook:
raise RuntimeError(
"ansible-playbook not found on PATH (cannot enforce; install Ansible)"
)
# Import lazily to avoid heavy import cost and potential CLI cycles.
from .manifest import manifest
started_at = _utc_now_iso()
with ExitStack() as stack:
old_b = _bundle_from_input(old_path, sops_mode=sops_mode)
if old_b.tempdir:
stack.callback(old_b.tempdir.cleanup)
old_state = _load_state(old_b.dir)
plan: Optional[Dict[str, Any]] = None
tags: Optional[List[str]] = None
roles: List[str] = []
if report is not None:
plan = _enforcement_plan(report, old_state, old_b.dir)
roles = list(plan.get("roles") or [])
t = list(plan.get("tags") or [])
tags = t if t else None
with tempfile.TemporaryDirectory(prefix="enroll-enforce-") as td:
td_path = Path(td)
try:
os.chmod(td_path, 0o700)
except OSError:
pass
# 1) Generate a manifest in a temp directory.
manifest(str(old_b.dir), str(td_path))
playbook = td_path / "playbook.yml"
if not playbook.exists():
raise RuntimeError(
f"manifest did not produce expected playbook.yml at {playbook}"
)
# 2) Apply it locally.
env = dict(os.environ)
cfg = td_path / "ansible.cfg"
if cfg.exists():
env["ANSIBLE_CONFIG"] = str(cfg)
cmd = [
ansible_playbook,
"-i",
"localhost,",
"-c",
"local",
str(playbook),
]
if tags:
cmd.extend(["--tags", ",".join(tags)])
p = subprocess.run(
cmd,
cwd=str(td_path),
env=env,
capture_output=True,
text=True,
check=False,
) # nosec
finished_at = _utc_now_iso()
info: Dict[str, Any] = {
"status": "applied" if p.returncode == 0 else "failed",
"started_at": started_at,
"finished_at": finished_at,
"ansible_playbook": ansible_playbook,
"command": cmd,
"returncode": int(p.returncode),
}
# Record tag selection (if we could attribute drift to specific roles).
info["roles"] = roles
info["tags"] = list(tags or [])
if not tags:
info["scope"] = "full_playbook"
if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip()
raise RuntimeError(
"ansible-playbook failed"
+ (f" (rc={p.returncode})" if p.returncode is not None else "")
+ (f": {err}" if err else "")
)
return info
def format_report(report: Dict[str, Any], *, fmt: str = "text") -> str:
fmt = (fmt or "text").lower()
if fmt == "json":
@ -819,19 +652,26 @@ def format_report(report: Dict[str, Any], *, fmt: str = "text") -> str:
def _report_text(report: Dict[str, Any]) -> str:
# Harvested, attacker-influenceable values (host names, file paths, owners,
# groups, link targets, metadata old/new values) must be neutralised before
# being spliced into the report. A raw newline/CR could otherwise forge
# additional report lines, and other control bytes could smuggle terminal
# escape sequences into a printed or piped report. ``s`` is the text-report
# sanitiser (collapses whitespace, drops control bytes).
s = sanitize_report_text
lines: List[str] = []
old = report.get("old", {})
new = report.get("new", {})
lines.append(
f"enroll diff report (generated {report.get('generated_at')})\n"
f"old: {old.get('input')} (host={old.get('host')}, state_mtime={old.get('state_mtime')})\n"
f"new: {new.get('input')} (host={new.get('host')}, state_mtime={new.get('state_mtime')})"
f"enroll diff report (generated {s(report.get('generated_at'))})\n"
f"old: {s(old.get('input'))} (host={s(old.get('host'))}, state_mtime={s(old.get('state_mtime'))})\n"
f"new: {s(new.get('input'))} (host={s(new.get('host'))}, state_mtime={s(new.get('state_mtime'))})"
)
filt = report.get("filters", {}) or {}
ex_paths = filt.get("exclude_paths", []) or []
if ex_paths:
lines.append(f"file exclude patterns: {', '.join(str(p) for p in ex_paths)}")
lines.append(f"file exclude patterns: {', '.join(s(p) for p in ex_paths)}")
if filt.get("ignore_package_versions"):
ignored = int(
@ -842,38 +682,6 @@ def _report_text(report: Dict[str, Any]) -> str:
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
lines.append(msg)
enf = report.get("enforcement") or {}
if enf:
lines.append("\nEnforcement")
status = str(enf.get("status") or "").strip().lower()
if status == "applied":
extra = ""
tags = enf.get("tags") or []
scope = enf.get("scope")
if tags:
extra = f" (tags={','.join(str(t) for t in tags)})"
elif scope:
extra = f" ({scope})"
lines.append(
f" applied old harvest via ansible-playbook (rc={enf.get('returncode')})"
+ extra
+ (
f" (finished {enf.get('finished_at')})"
if enf.get("finished_at")
else ""
)
)
elif status == "failed":
lines.append(
f" attempted enforcement but ansible-playbook failed (rc={enf.get('returncode')})"
)
elif status == "skipped":
r = enf.get("reason")
lines.append(" skipped" + (f": {r}" if r else ""))
else:
# Best-effort formatting for future fields.
lines.append(" " + json.dumps(enf, sort_keys=True))
pk = report.get("packages", {})
lines.append("\nPackages")
lines.append(f" added: {len(pk.get('added', []) or [])}")
@ -883,73 +691,77 @@ def _report_text(report: Dict[str, Any]) -> str:
suffix = f" (ignored {ignored_v})" if ignored_v else ""
lines.append(f" version_changed: {vc}{suffix}")
for p in pk.get("added", []) or []:
lines.append(f" + {p}")
lines.append(f" + {s(p)}")
for p in pk.get("removed", []) or []:
lines.append(f" - {p}")
lines.append(f" - {s(p)}")
for ch in pk.get("version_changed", []) or []:
lines.append(f" ~ {ch.get('package')}: {ch.get('old')} -> {ch.get('new')}")
lines.append(
f" ~ {s(ch.get('package'))}: {s(ch.get('old'))} -> {s(ch.get('new'))}"
)
sv = report.get("services", {})
lines.append("\nServices (enabled systemd units)")
for u in sv.get("enabled_added", []) or []:
lines.append(f" + {u}")
lines.append(f" + {s(u)}")
for u in sv.get("enabled_removed", []) or []:
lines.append(f" - {u}")
lines.append(f" - {s(u)}")
for ch in sv.get("changed", []) or []:
unit = ch.get("unit")
lines.append(f" * {unit} changed")
lines.append(f" * {s(unit)} changed")
for k, v in (ch.get("changes") or {}).items():
if k == "packages":
a = (v or {}).get("added", [])
r = (v or {}).get("removed", [])
if a:
lines.append(f" packages +: {', '.join(a)}")
lines.append(f" packages +: {', '.join(s(x) for x in a)}")
if r:
lines.append(f" packages -: {', '.join(r)}")
lines.append(f" packages -: {', '.join(s(x) for x in r)}")
else:
lines.append(f" {k}: {v.get('old')} -> {v.get('new')}")
lines.append(f" {s(k)}: {s(v.get('old'))} -> {s(v.get('new'))}")
us = report.get("users", {})
lines.append("\nUsers")
for u in us.get("added", []) or []:
lines.append(f" + {u}")
lines.append(f" + {s(u)}")
for u in us.get("removed", []) or []:
lines.append(f" - {u}")
lines.append(f" - {s(u)}")
for ch in us.get("changed", []) or []:
name = ch.get("name")
lines.append(f" * {name} changed")
lines.append(f" * {s(name)} changed")
for k, v in (ch.get("changes") or {}).items():
if k == "supplementary_groups":
a = (v or {}).get("added", [])
r = (v or {}).get("removed", [])
if a:
lines.append(f" groups +: {', '.join(a)}")
lines.append(f" groups +: {', '.join(s(x) for x in a)}")
if r:
lines.append(f" groups -: {', '.join(r)}")
lines.append(f" groups -: {', '.join(s(x) for x in r)}")
else:
lines.append(f" {k}: {v.get('old')} -> {v.get('new')}")
lines.append(f" {s(k)}: {s(v.get('old'))} -> {s(v.get('new'))}")
fl = report.get("files", {})
lines.append("\nFiles")
for e in fl.get("added", []) or []:
lines.append(
f" + {e.get('path')} (role={e.get('role')}, reason={e.get('reason')})"
f" + {s(e.get('path'))} (role={s(e.get('role'))}, reason={s(e.get('reason'))})"
)
for e in fl.get("removed", []) or []:
lines.append(
f" - {e.get('path')} (role={e.get('role')}, reason={e.get('reason')})"
f" - {s(e.get('path'))} (role={s(e.get('role'))}, reason={s(e.get('reason'))})"
)
for ch in fl.get("changed", []) or []:
p = ch.get("path")
lines.append(f" * {p} changed")
lines.append(f" * {s(p)} changed")
for k, v in (ch.get("changes") or {}).items():
if k == "content":
if "old_sha256" in (v or {}):
lines.append(" content: sha256 changed")
else:
lines.append(f" content: {v.get('old')} -> {v.get('new')}")
lines.append(
f" content: {s(v.get('old'))} -> {s(v.get('new'))}"
)
else:
lines.append(f" {k}: {v.get('old')} -> {v.get('new')}")
lines.append(f" {s(k)}: {s(v.get('old'))} -> {s(v.get('new'))}")
if not any(
[
@ -973,14 +785,23 @@ def _report_text(report: Dict[str, Any]) -> str:
def _report_markdown(report: Dict[str, Any]) -> str:
# Harvested, attacker-influenceable values (host names, file paths, owners,
# groups, link targets, metadata old/new values) are embedded in Markdown
# code spans below. Without neutralisation a value containing a backtick,
# newline, or control byte could break out of its code span / list item and
# inject misleading Markdown structure (a forged heading, a deceptive
# ``[link](...)``), exactly as the generated README guards against. ``m`` is
# the Markdown sanitiser used for the README; reuse it here so both
# documentation surfaces share one policy.
m = sanitize_markdown_text
old = report.get("old", {})
new = report.get("new", {})
out: List[str] = []
out.append("# enroll diff report\n")
out.append(f"Generated: `{report.get('generated_at')}`\n")
out.append(f"Generated: `{m(report.get('generated_at'))}`\n")
out.append(
f"- **Old**: `{old.get('input')}` (host={old.get('host')}, state_mtime={old.get('state_mtime')})\n"
f"- **New**: `{new.get('input')}` (host={new.get('host')}, state_mtime={new.get('state_mtime')})\n"
f"- **Old**: `{m(old.get('input'))}` (host={m(old.get('host'))}, state_mtime={m(old.get('state_mtime'))})\n"
f"- **New**: `{m(new.get('input'))}` (host={m(new.get('host'))}, state_mtime={m(new.get('state_mtime'))})\n"
)
filt = report.get("filters", {}) or {}
@ -988,7 +809,7 @@ def _report_markdown(report: Dict[str, Any]) -> str:
if ex_paths:
out.append(
"- **File exclude patterns**: "
+ ", ".join(f"`{p}`" for p in ex_paths)
+ ", ".join(f"`{m(p)}`" for p in ex_paths)
+ "\n"
)
@ -1001,57 +822,14 @@ def _report_markdown(report: Dict[str, Any]) -> str:
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
out.append(msg + "\n")
enf = report.get("enforcement") or {}
if enf:
out.append("\n## Enforcement\n")
status = str(enf.get("status") or "").strip().lower()
if status == "applied":
extra = ""
tags = enf.get("tags") or []
scope = enf.get("scope")
if tags:
extra = " (tags=" + ",".join(str(t) for t in tags) + ")"
elif scope:
extra = f" ({scope})"
out.append(
"- ✅ Applied old harvest via ansible-playbook"
+ extra
+ (
f" (rc={enf.get('returncode')})"
if enf.get("returncode") is not None
else ""
)
+ (
f" (finished `{enf.get('finished_at')}`)"
if enf.get("finished_at")
else ""
)
+ "\n"
)
elif status == "failed":
out.append(
"- ⚠️ Attempted enforcement but ansible-playbook failed"
+ (
f" (rc={enf.get('returncode')})"
if enf.get("returncode") is not None
else ""
)
+ "\n"
)
elif status == "skipped":
r = enf.get("reason")
out.append("- Skipped" + (f": {r}" if r else "") + "\n")
else:
out.append(f"- {json.dumps(enf, sort_keys=True)}\n")
pk = report.get("packages", {})
out.append("## Packages\n")
out.append(f"- Added: {len(pk.get('added', []) or [])}\n")
for p in pk.get("added", []) or []:
out.append(f" - `+ {p}`\n")
out.append(f" - `+ {m(p)}`\n")
out.append(f"- Removed: {len(pk.get('removed', []) or [])}\n")
for p in pk.get("removed", []) or []:
out.append(f" - `- {p}`\n")
out.append(f" - `- {m(p)}`\n")
ignored_v = int(pk.get("version_changed_ignored_count") or 0)
vc = len(pk.get("version_changed", []) or [])
@ -1059,7 +837,7 @@ def _report_markdown(report: Dict[str, Any]) -> str:
out.append(f"- Version changed: {vc}{suffix}\n")
for ch in pk.get("version_changed", []) or []:
out.append(
f" - `~ {ch.get('package')}`: `{ch.get('old')}` → `{ch.get('new')}`\n"
f" - `~ {m(ch.get('package'))}`: `{m(ch.get('old'))}` → `{m(ch.get('new'))}`\n"
)
sv = report.get("services", {})
@ -1067,60 +845,64 @@ def _report_markdown(report: Dict[str, Any]) -> str:
if sv.get("enabled_added"):
out.append("- Enabled added\n")
for u in sv.get("enabled_added", []) or []:
out.append(f" - `+ {u}`\n")
out.append(f" - `+ {m(u)}`\n")
if sv.get("enabled_removed"):
out.append("- Enabled removed\n")
for u in sv.get("enabled_removed", []) or []:
out.append(f" - `- {u}`\n")
out.append(f" - `- {m(u)}`\n")
if sv.get("changed"):
out.append("- Changed\n")
for ch in sv.get("changed", []) or []:
unit = ch.get("unit")
out.append(f" - `{unit}`\n")
out.append(f" - `{m(unit)}`\n")
for k, v in (ch.get("changes") or {}).items():
if k == "packages":
a = (v or {}).get("added", [])
r = (v or {}).get("removed", [])
if a:
out.append(
f" - packages added: {', '.join('`'+x+'`' for x in a)}\n"
f" - packages added: {', '.join('`'+m(x)+'`' for x in a)}\n"
)
if r:
out.append(
f" - packages removed: {', '.join('`'+x+'`' for x in r)}\n"
f" - packages removed: {', '.join('`'+m(x)+'`' for x in r)}\n"
)
else:
out.append(f" - {k}: `{v.get('old')}` → `{v.get('new')}`\n")
out.append(
f" - {m(k)}: `{m(v.get('old'))}` → `{m(v.get('new'))}`\n"
)
us = report.get("users", {})
out.append("## Users\n")
if us.get("added"):
out.append("- Added\n")
for u in us.get("added", []) or []:
out.append(f" - `+ {u}`\n")
out.append(f" - `+ {m(u)}`\n")
if us.get("removed"):
out.append("- Removed\n")
for u in us.get("removed", []) or []:
out.append(f" - `- {u}`\n")
out.append(f" - `- {m(u)}`\n")
if us.get("changed"):
out.append("- Changed\n")
for ch in us.get("changed", []) or []:
name = ch.get("name")
out.append(f" - `{name}`\n")
out.append(f" - `{m(name)}`\n")
for k, v in (ch.get("changes") or {}).items():
if k == "supplementary_groups":
a = (v or {}).get("added", [])
r = (v or {}).get("removed", [])
if a:
out.append(
f" - groups added: {', '.join('`'+x+'`' for x in a)}\n"
f" - groups added: {', '.join('`'+m(x)+'`' for x in a)}\n"
)
if r:
out.append(
f" - groups removed: {', '.join('`'+x+'`' for x in r)}\n"
f" - groups removed: {', '.join('`'+m(x)+'`' for x in r)}\n"
)
else:
out.append(f" - {k}: `{v.get('old')}` → `{v.get('new')}`\n")
out.append(
f" - {m(k)}: `{m(v.get('old'))}` → `{m(v.get('new'))}`\n"
)
fl = report.get("files", {})
out.append("## Files\n")
@ -1128,29 +910,31 @@ def _report_markdown(report: Dict[str, Any]) -> str:
out.append("- Added\n")
for e in fl.get("added", []) or []:
out.append(
f" - `+ {e.get('path')}` (role={e.get('role')}, reason={e.get('reason')})\n"
f" - `+ {m(e.get('path'))}` (role={m(e.get('role'))}, reason={m(e.get('reason'))})\n"
)
if fl.get("removed"):
out.append("- Removed\n")
for e in fl.get("removed", []) or []:
out.append(
f" - `- {e.get('path')}` (role={e.get('role')}, reason={e.get('reason')})\n"
f" - `- {m(e.get('path'))}` (role={m(e.get('role'))}, reason={m(e.get('reason'))})\n"
)
if fl.get("changed"):
out.append("- Changed\n")
for ch in fl.get("changed", []) or []:
p = ch.get("path")
out.append(f" - `{p}`\n")
out.append(f" - `{m(p)}`\n")
for k, v in (ch.get("changes") or {}).items():
if k == "content":
if "old_sha256" in (v or {}):
out.append(" - content: sha256 changed\n")
else:
out.append(
f" - content: `{v.get('old')}` → `{v.get('new')}`\n"
f" - content: `{m(v.get('old'))}` → `{m(v.get('new'))}`\n"
)
else:
out.append(f" - {k}: `{v.get('old')}` → `{v.get('new')}`\n")
out.append(
f" - {m(k)}: `{m(v.get('old'))}` → `{m(v.get('new'))}`\n"
)
if not any(
[
@ -1252,8 +1036,14 @@ def send_email(
try:
s.starttls()
s.ehlo()
except Exception:
# STARTTLS is optional; ignore if unsupported.
except Exception as e:
if smtp_user or smtp_password:
raise RuntimeError(
"email: SMTP STARTTLS failed; refusing to send credentials "
"without TLS"
) from e
# Without credentials, keep STARTTLS opportunistic so localhost or
# unauthenticated relay setups continue to work.
pass # nosec
if smtp_user:
s.login(smtp_user, smtp_password or "")

View file

@ -5,7 +5,9 @@ from collections import Counter, defaultdict
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Tuple
from .diff import _bundle_from_input, _load_state # reuse existing bundle handling
from .diff import _bundle_from_input # reuse existing bundle handling
from .state import load_state
from .cm import sanitize_report_text
@dataclass(frozen=True)
@ -72,7 +74,7 @@ _MANAGED_FILE_REASONS: Dict[str, ReasonInfo] = {
),
"system_firewall": ReasonInfo(
"Firewall configuration",
"Firewall rules/configuration (ufw, nftables, iptables, etc.).",
"Firewall rules/configuration (ufw, nftables, iptables, ipset, etc.).",
),
"system_sysctl": ReasonInfo(
"sysctl configuration",
@ -188,6 +190,12 @@ _EXCLUDED_REASONS: Dict[str, ReasonInfo] = {
"Not a regular file",
"Excluded because it was not a regular file (device, socket, etc.).",
),
"symlink_component": ReasonInfo(
"Unsafe symlinked path",
"Excluded because a directory in the path was a symlink, which could "
"redirect capture into a sensitive location; Enroll refuses to follow "
"symlinked parents when harvesting files.",
),
"binary_like": ReasonInfo(
"Binary-like",
"Excluded because it looked like binary content (not useful for config management).",
@ -211,6 +219,10 @@ _OBSERVED_VIA: Dict[str, ReasonInfo] = {
"Referenced by package role",
"Package was referenced by an enroll packages snapshot/role.",
),
"firewall_runtime": ReasonInfo(
"Referenced by firewall runtime role",
"Package was referenced by captured live ipset/iptables runtime state.",
),
}
@ -285,7 +297,7 @@ def explain_state(
- a SOPS-encrypted bundle (.sops)
"""
bundle = _bundle_from_input(harvest, sops_mode=sops_mode)
state = _load_state(bundle.dir)
state = load_state(bundle.dir)
host = state.get("host") or {}
enroll = state.get("enroll") or {}
@ -359,10 +371,27 @@ def explain_state(
}
)
# Runtime firewall snapshot
firewall_obj = roles.get("firewall_runtime") or {}
if isinstance(firewall_obj, dict) and firewall_obj:
captures = [
key
for key in ("ipset_save", "iptables_v4_save", "iptables_v6_save")
if firewall_obj.get(key)
]
role_summaries.append(
{
"role": "firewall_runtime",
"summary": f"{len(captures)} snapshot(s), {len(firewall_obj.get('ipset_sets') or [])} ipset(s)",
"notes": firewall_obj.get("notes") or [],
}
)
# Single snapshots
for rname in [
"apt_config",
"dnf_config",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
@ -415,6 +444,7 @@ def explain_state(
for rname in [
"apt_config",
"dnf_config",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
@ -498,15 +528,25 @@ def explain_state(
if fmt == "json":
return json.dumps(report, indent=2, sort_keys=True)
# Text rendering
# Text rendering.
#
# Harvested, attacker-influenceable values (host name, file paths used as
# examples, include/exclude patterns, snapshot notes) are interpolated into
# this human-readable text. Route each through ``sanitize_report_text`` (the
# same helper the diff text report uses) so a value containing a raw newline
# cannot forge an additional output line and a control byte cannot smuggle a
# terminal escape sequence when the explanation is printed or written with
# --out. The JSON branch above does not need this: json.dumps already escapes
# control characters and cannot have its structure altered by a string value.
s = sanitize_report_text
out: List[str] = []
out.append(f"Enroll explained: {harvest}")
out.append(f"Enroll explained: {s(harvest)}")
hn = host.get("hostname") or "(unknown host)"
os_family = host.get("os") or "unknown"
pkg_backend = host.get("pkg_backend") or "?"
ver = enroll.get("version") or "?"
out.append(f"Host: {hn} (os: {os_family}, pkg: {pkg_backend})")
out.append(f"Enroll: {ver}")
out.append(f"Host: {s(hn)} (os: {s(os_family)}, pkg: {s(pkg_backend)})")
out.append(f"Enroll: {s(ver)}")
out.append("")
out.append("Inventory")
@ -516,30 +556,30 @@ def explain_state(
for ov in observed_via_summary:
extra = ""
if ov.get("top_refs"):
extra = f" (e.g. {', '.join(ov['top_refs'])})"
out.append(f" - {ov['kind']}: {ov['count']} {ov['why']}{extra}")
extra = f" (e.g. {', '.join(s(x) for x in ov['top_refs'])})"
out.append(f" - {s(ov['kind'])}: {ov['count']} {s(ov['why'])}{extra}")
out.append("")
out.append("Roles collected")
for rs in role_summaries:
out.append(f"- {rs['role']}: {rs['summary']}")
out.append(f"- {s(rs['role'])}: {s(rs['summary'])}")
if rs["role"] == "extra_paths":
inc = rs.get("include_patterns") or []
exc = rs.get("exclude_patterns") or []
if inc:
suffix = "" if len(inc) > max_examples else ""
out.append(
f" include_patterns: {', '.join(map(str, inc[:max_examples]))}{suffix}"
f" include_patterns: {', '.join(s(x) for x in inc[:max_examples])}{suffix}"
)
if exc:
suffix = "" if len(exc) > max_examples else ""
out.append(
f" exclude_patterns: {', '.join(map(str, exc[:max_examples]))}{suffix}"
f" exclude_patterns: {', '.join(s(x) for x in exc[:max_examples])}{suffix}"
)
notes = rs.get("notes") or []
if notes:
for n in notes[:max_examples]:
out.append(f" note: {n}")
out.append(f" note: {s(n)}")
if len(notes) > max_examples:
out.append(
f" note: (+{len(notes) - max_examples} more. Use --format json to see them all)"
@ -550,8 +590,8 @@ def explain_state(
if managed_file_reasons:
for r in managed_file_reasons[:15]:
exs = r.get("examples") or []
ex_txt = f" Examples: {', '.join(exs)}" if exs else ""
out.append(f"- {r['reason']} ({r['count']}): {r['why']}.{ex_txt}")
ex_txt = f" Examples: {', '.join(s(x) for x in exs)}" if exs else ""
out.append(f"- {s(r['reason'])} ({r['count']}): {s(r['why'])}.{ex_txt}")
if len(managed_file_reasons) > 15:
out.append(
f"- (+{len(managed_file_reasons) - 15} more reasons. Use --format json to see them all)"
@ -563,15 +603,15 @@ def explain_state(
out.append("")
out.append("Why directories were included (managed_dirs.reason)")
for r in managed_dir_reasons:
out.append(f"- {r['reason']} ({r['count']}): {r['why']}")
out.append(f"- {s(r['reason'])} ({r['count']}): {s(r['why'])}")
out.append("")
out.append("Why paths were excluded")
if excluded_reasons:
for r in excluded_reasons:
exs = r.get("examples") or []
ex_txt = f" Examples: {', '.join(exs)}" if exs else ""
out.append(f"- {r['reason']} ({r['count']}): {r['why']}.{ex_txt}")
ex_txt = f" Examples: {', '.join(s(x) for x in exs)}" if exs else ""
out.append(f"- {s(r['reason'])} ({r['count']}): {s(r['why'])}.{ex_txt}")
else:
out.append("- (no excluded paths)")

View file

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

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,
excluded=excluded,
notes=notes,
)
def _collect_logrotate_snapshot(self, logrotate_pkg: str) -> PackageSnapshot:
managed: List[ManagedFile] = []
excluded: List[ExcludedFile] = []
notes: List[str] = []
seen: Set[str] = set()
for spec in _LOGROTATE_CAPTURE_GLOBS:
for path in iter_matching_files(spec):
if not os.path.isfile(path) or os.path.islink(path):
continue
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=self.logrotate_role_name,
abs_path=path,
reason="system_logrotate",
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=managed,
excluded_out=excluded,
seen_role=seen,
seen_global=self.context.captured_global,
)
return PackageSnapshot(
package=logrotate_pkg,
role_name=self.logrotate_role_name,
section=package_section_from_installations(
self.context.installed_pkgs.get(logrotate_pkg, [])
),
managed_files=managed,
excluded=excluded,
notes=notes,
)

View file

@ -0,0 +1,87 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Set
from ..capture import capture_file
from ..harvest_types import (
AptConfigSnapshot,
DnfConfigSnapshot,
ExcludedFile,
ManagedFile,
)
from ..system_paths import iter_apt_capture_paths, iter_dnf_capture_paths
from .context import HarvestCollector, HarvestContext
@dataclass
class PackageManagerConfigCollection:
apt_config_snapshot: AptConfigSnapshot
dnf_config_snapshot: DnfConfigSnapshot
class PackageManagerConfigCollector(HarvestCollector):
"""Collect package-manager configuration into existing role snapshots."""
def __init__(
self, context: HarvestContext, seen_by_role: Dict[str, Set[str]]
) -> None:
super().__init__(context)
self.seen_by_role = seen_by_role
def collect(self) -> PackageManagerConfigCollection:
apt_notes: List[str] = []
apt_excluded: List[ExcludedFile] = []
apt_managed: List[ManagedFile] = []
dnf_notes: List[str] = []
dnf_excluded: List[ExcludedFile] = []
dnf_managed: List[ManagedFile] = []
apt_role_name = "apt_config"
dnf_role_name = "dnf_config"
if self.context.backend.name == "dpkg":
apt_role_seen = self.seen_by_role.setdefault(apt_role_name, set())
for path, reason in iter_apt_capture_paths():
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=apt_role_name,
abs_path=path,
reason=reason,
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=apt_managed,
excluded_out=apt_excluded,
seen_role=apt_role_seen,
seen_global=self.context.captured_global,
)
elif self.context.backend.name == "rpm":
dnf_role_seen = self.seen_by_role.setdefault(dnf_role_name, set())
for path, reason in iter_dnf_capture_paths():
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=dnf_role_name,
abs_path=path,
reason=reason,
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=dnf_managed,
excluded_out=dnf_excluded,
seen_role=dnf_role_seen,
seen_global=self.context.captured_global,
)
return PackageManagerConfigCollection(
apt_config_snapshot=AptConfigSnapshot(
role_name=apt_role_name,
managed_files=apt_managed,
excluded=apt_excluded,
notes=apt_notes,
),
dnf_config_snapshot=DnfConfigSnapshot(
role_name=dnf_role_name,
managed_files=dnf_managed,
excluded=dnf_excluded,
notes=dnf_notes,
),
)

View file

@ -0,0 +1,293 @@
from __future__ import annotations
import glob
import os
from typing import Dict, List, Optional, Set
from ..capture import capture_file, capture_link
from ..harvest_types import (
ExcludedFile,
ExtraPathsSnapshot,
ManagedDir,
ManagedFile,
ManagedLink,
UsrLocalCustomSnapshot,
)
from ..system_paths import MAX_FILES_CAP
from ..pathfilter import expand_includes
from ..fsutil import (
is_dir_no_symlink_components,
path_has_symlink_component,
stat_dir_triplet,
stat_triplet,
)
from .context import HarvestCollector, HarvestContext
class UsrLocalCustomCollector(HarvestCollector):
"""Collect selected /usr/local state into the usr_local_custom role."""
role_name = "usr_local_custom"
def __init__(
self,
context: HarvestContext,
seen_by_role: Dict[str, Set[str]],
already_all: Set[str],
) -> None:
super().__init__(context)
self.seen_by_role = seen_by_role
self.already_all = already_all
self.notes: List[str] = []
self.excluded: List[ExcludedFile] = []
self.managed: List[ManagedFile] = []
def collect(self) -> UsrLocalCustomSnapshot:
self._scan_tree(
"/usr/local/etc",
require_executable=False,
cap=MAX_FILES_CAP,
reason="usr_local_etc_custom",
)
self._scan_tree(
"/usr/local/bin",
require_executable=True,
cap=MAX_FILES_CAP,
reason="usr_local_bin_script",
)
return UsrLocalCustomSnapshot(
role_name=self.role_name,
managed_files=self.managed,
excluded=self.excluded,
notes=self.notes,
)
def _scan_tree(
self,
root: str,
*,
require_executable: bool,
cap: int,
reason: str,
) -> None:
scanned = 0
if not os.path.isdir(root):
return
role_seen = self.seen_by_role.setdefault(self.role_name, set())
for dirpath, _, filenames in os.walk(root):
for filename in filenames:
path = os.path.join(dirpath, filename)
if path in self.already_all:
continue
if not os.path.isfile(path) or os.path.islink(path):
continue
try:
owner, group, mode = stat_triplet(path)
except OSError:
self.excluded.append(ExcludedFile(path=path, reason="unreadable"))
continue
if require_executable:
try:
if (int(mode, 8) & 0o111) == 0:
continue
except ValueError:
continue
if capture_file(
bundle_dir=self.context.bundle_dir,
role_name=self.role_name,
abs_path=path,
reason=reason,
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=self.managed,
excluded_out=self.excluded,
seen_role=role_seen,
seen_global=self.context.captured_global,
metadata=(owner, group, mode),
):
self.already_all.add(path)
scanned += 1
if scanned >= cap:
self.notes.append(
f"Reached file cap ({cap}) while scanning {root}."
)
return
class ExtraPathsCollector(HarvestCollector):
"""Collect user-requested include/exclude paths into extra_paths."""
role_name = "extra_paths"
def __init__(
self,
context: HarvestContext,
seen_by_role: Dict[str, Set[str]],
already_all: Set[str],
*,
include_paths: Optional[List[str]] = None,
exclude_paths: Optional[List[str]] = None,
) -> None:
super().__init__(context)
self.seen_by_role = seen_by_role
self.already_all = already_all
self.include_specs = list(include_paths or [])
self.exclude_specs = list(exclude_paths or [])
self.notes: List[str] = []
self.excluded: List[ExcludedFile] = []
self.managed: List[ManagedFile] = []
self.managed_links: List[ManagedLink] = []
self.managed_dirs: List[ManagedDir] = []
self.dir_seen: Set[str] = set()
def collect(self) -> ExtraPathsSnapshot:
self._collect_included_dirs()
if self.include_specs:
self.notes.append("User include patterns:")
self.notes.extend([f"- {p}" for p in self.include_specs])
if self.exclude_specs:
self.notes.append("User exclude patterns:")
self.notes.extend([f"- {p}" for p in self.exclude_specs])
included_files: List[str] = []
if self.include_specs:
files, inc_notes = expand_includes(
self.context.path_filter.iter_include_patterns(),
exclude=self.context.path_filter,
max_files=MAX_FILES_CAP,
)
included_files = files
self.notes.extend(inc_notes)
role_seen = self.seen_by_role.setdefault(self.role_name, set())
for path in included_files:
if path in self.already_all:
continue
if capture_file(
bundle_dir=self.context.bundle_dir,
role_name=self.role_name,
abs_path=path,
reason="user_include",
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=self.managed,
excluded_out=self.excluded,
seen_role=role_seen,
seen_global=self.context.captured_global,
):
self.already_all.add(path)
return ExtraPathsSnapshot(
role_name=self.role_name,
include_patterns=self.include_specs,
exclude_patterns=self.exclude_specs,
managed_dirs=self.managed_dirs,
managed_files=self.managed,
managed_links=self.managed_links,
excluded=self.excluded,
notes=self.notes,
)
def _collect_included_dirs(self) -> None:
role_seen = self.seen_by_role.setdefault(self.role_name, set())
for pat in self.context.path_filter.iter_include_patterns():
if pat.kind == "prefix":
path = pat.value
if os.path.islink(path):
self._capture_included_link(path, role_seen)
elif is_dir_no_symlink_components(path):
self._walk_and_capture_dirs(path, role_seen)
elif pat.kind == "glob":
for hit in glob.glob(pat.value, recursive=True):
if os.path.islink(hit):
self._capture_included_link(hit, role_seen)
elif is_dir_no_symlink_components(hit):
self._walk_and_capture_dirs(hit, role_seen)
def _capture_included_link(self, path: str, role_seen: Set[str]) -> None:
path = os.path.normpath(path)
if not path.startswith("/"):
path = "/" + path
if path in self.already_all:
return
if capture_link(
role_name=self.role_name,