From snowflake harvester to hardened Ansible generator
Enroll started as a way to snapshot a snowflake server’s /etc into Ansible roles. After
months of development and several security audits, it’s now a hardened harvesting tool with remote
mode, SOPS encryption, drift detection, and a focus on fail-closed safety.
The problem
Every sysadmin has met the snowflake: a server that was hand-configured over years, never under config management, and now nobody wants to touch it. Rebuilding it from scratch means weeks of archaeology. Enroll was born to short-circuit that - inspect a running Debian or RedHat-like host, harvest the state that matters, and generate Ansible roles and playbooks that could reproduce it.
The beginning
I started Enroll in early December 2025 as a way to move quickly on exactly that problem. From day one, I wanted to avoid the reverse problem: capturing excessive state that is a waste of time - for example, stuff one gets from the OS no matter what.
The first iterations could inspect packages, services, and /etc files on a running host, that
were likely to be ’extra’ stuff installed on the machine beyond the base OS image. The goal then
was to emit one Ansible role per detected package.
It became clear early on that Enroll could do more than copy files. A short time earlier in 2025 I had produced JinjaTurtle , a tool to generate Jinja2 templates from basic configuration files in various formats, and produce the key/value data that can be plugged into Ansible inventory.
If JinjaTurtle was on the PATH, Enroll can generate Jinja2 templates and Ansible variables
from native config files rather than copying them verbatim.
After this, I realised that running Enroll across a fleet of unmanaged servers (the horror!) effectively created a lot of monolothic repos per host, which is a bit of an anti-pattern for Ansible, which is good at DRYing up configuration by moving host-specific data into inventory.
So introduced multi-site mode via the --fqdn option. And so the scope continued to creep: remote
mode introduced harvesting over SSH without installing anything on the target. SOPS encryption
was then added to secure harvests at rest, and diff mode to compare two harvests in order to
detect drift and notify about that by email or webhook.
Growing up
RedHat support came next (partly because I became enthusiastic about running Enroll against
QubesOS AppVMs and templates) - DNF5, DNF, and RPM-based systems could be harvested alongside
Debian and APT. Enroll learned to explain its own harvests (enroll explain), validate them
against an official schema (which was partly just because I was interested in learning about
the JSON Schema standards), and even attempt to repair diff-detected drift automatically
(because I also miss the Puppet days where an agent would revert on-the-fly changes).
From there, I became interested in more esoteric stuff, such as: what exists on a server that might not even be written to disk in a clear form such as a config file?
So ‘firewall state’ joined the harvest: ipset and iptables rules captured both from on-disk
config and from live runtime, emitted as an Ansible role. Flatpaks, Snaps, and Docker/Podman
images are also now detected. Active sysctl parameters are captured into
/etc/sysctl.d/99-enroll.conf.
In parallel, and in response to nice feedback from strangers, JinjaTurtle-powered templating
of sshd_config has since rounded out the config-file coverage.
Further feedback from an old friend pointed out that the number of roles in the manifested Ansible was quite overwhelming - effectively a role per package. I have since modified Enroll to detect the common ‘category’ of programs (the same sort of categories that tend to dictate how your applications are grouped in an app menu in a desktop Linux OS, for example), and to build Ansible roles based on those category names instead. (The old behaviour is still enforceable).
The hardening turn
By mid 2026, I started to get a bit nervous. And, I’ll be honest, throwing some LLMs at the codebase started to uncover risks I hadn’t really thought about before.
The more Enroll could do, the more obvious the attack surface became. The tool reads untrusted filesystem state and generates code that runs on production hosts. Let’s consider a ‘shared hosting’ scenario: data pre-prepared by an unprivileged user could then be swept up into what is now a malicious harvest. Malicious, how? I’m talking about the ability to inject malicious Jinja2 into generated playbooks, or use symlinks to exfiltrate files outside the harvest tree, just to name a few examples.
In the end, I opted to remove some unsafe features and added a lot - and I mean a lot of defence in depth. Examples:
- Auto-repair of drift was removed - automatically applying a harvest to repair drift meant a tampered harvest could write arbitrary state. The safer path was to regenerate a manifest from a trusted harvest and apply it yourself.
- Harvests were validated before manifesting: symlinks pointing outside the artifact tree were rejected, schema validation was enforced, and hardlinked source files were refused.
PATHchecking was tightened when running as root.- Output directories were validated.
- TOCTOU mitigations froze directory harvest bundles into a private
0700copy before consumers read them. - Jinja2 injection defences escaped verbatim JSON, forbade Ansible built-in globals in generated variable names
- salted role prefixes against malicious var name injection.
There was even a period where I had added Puppet and Salt renderers, but I then removed them in the same cycle - the complexity wasn’t worth it, and Ansible-only kept the attack surface smaller.
Closing the remote tampering window
Remote harvesting also had its own subtle TOCTOU risk. Sudo-created remote bundles were chowned to the SSH user before packaging - a tiny window where the user could tamper with the bundle. The fix keeps bundles root-owned while root packages and hashed them, exposes only the archive to the authenticated SSH uid, and verifies the root-computed digest after download. Tar member limits are now enforced lazily during parsing, and aggregate byte and entry limits are applied to directory harvest bundles with fail-closed behaviour under mutation, to avoid deliberate resource exhaustion issues.
Lessons learned
You can sometimes really get carried away without a strict scope.
I care a lot about security, and there was even a point where I felt too scared to keep Enroll published. I worried a lot about the trust people might place in the tool given all the things I (well, AI to a large extent) kept uncovering. For a while I pulled the app down altogether from my servers/package repositories.
People close to me have since been at pains to point out that this sort of worry is what makes a good maintainer. The worse problem is indifference.
There may still be security issues in Enroll. Nothing is perfect. As my friend Marco says, “water that is too pure, carries no fish”.
When I put the app back up, some people even thanked me.
Where it stands
Enroll today is a tool I’d run against a client’s snowflake quite happily. It harvests packages, services, config files, firewall state, sysctl, containers, and Flatpaks/Snaps. It generates clean Ansible roles grouped by package or section. It diffs harvests for drift detection. And it does all of this with a security model that assumes the harvested host might be hostile. The threat model is probably the most well defined I’ve ever written for a piece of software.
The code and full changelog are at git.mig5.net/mig5/enroll , with documentation at enroll.sh .
- Started: December 2025
- Language: Python
- Targets: Debian, Ubuntu, Fedora, RHEL-like
- Output: Ansible roles & playbooks