Reported symptoms
The 06:00 patch run fails on 61 of 200 hosts. Every failure is on a
task with become: true; every task without it succeeds on the same
hosts.
The message is the same everywhere:
fatal: [app017]: FAILED! => {"msg": "Missing sudo password"}
Nobody has ever configured a sudo password for this account, because it
has always had NOPASSWD. So the investigation started, reasonably, on
the controller:
- Is the vault password file readable? Yes.
- Did
become_passwordget set somewhere by accident? No. - Did the service account’s SSH key change? No, and
sshas that account works by hand. - Is the account still in the right group? Yes.
Then somebody ran sudo -l -U svc_ansible on a failing host and it
printed a NOPASSWD rule. At that point the team concluded the sudoers
policy was fine and went back to looking at the controller, where they
stayed for three hours.
Evidence provided
$ ansible app017 -i inventory -m ansible.builtin.pingapp017 | SUCCESS => {
"changed": false,
"ping": "pong"
}$ ansible app017 -i inventory -m ansible.builtin.ping -bapp017 | FAILED! => {
"msg": "Missing sudo password"
}$ ansible app017 -i inventory -m ansible.builtin.command -a 'sudo -l -U svc_ansible'User svc_ansible may run the following commands on app017:
(root) NOPASSWD: /usr/bin/dnf, /usr/bin/systemctl$ ansible app093 -i inventory -m ansible.builtin.command -a 'sudo -l -U svc_ansible'User svc_ansible may run the following commands on app093:
(root) NOPASSWD: ALL$ ansible-playbook -i inventory patch.yml --limit app017 -vvv 2>&1 | grep -m1 'sudo -H -S -n'sudo -H -S -n -u root /bin/sh -c 'echo BECOME-SUCCESS-xkjqwe ; /usr/bin/python3 /var/tmp/ansible-tmp-1786550412.7-1841-193/AnsiballZ_dnf.py'$ ansible app017 -i inventory -m ansible.builtin.command -a 'journalctl -t sudo --since 06:00 -n 3 --no-pager' -b --become-method suAug 11 06:04:11 app017 sudo[3312]: svc_ansible : command not allowed ; TTY=unknown ; PWD=/home/svc_ansible ; USER=root ; COMMAND=/bin/sh -c echo BECOME-SUCCESS-xkjqwe ; /usr/bin/python3 /var/tmp/ansible-tmp-.../AnsiballZ_dnf.pyWork the evidence before reading on
The controller is fine. The connection is fine. The account is fine. The sudoers rule is present, syntactically valid and grants NOPASSWD.
- Read the
-vvvline and thesudo -loutput side by side. Write down the command sudo is being asked to run, and then write down the list of commands it is allowed to run. Do they intersect? AnsiballZ_dnf.pyis a file in a temporary directory. Its name changes every run. Could any allow-list entry match it?- The failure began at 06:00 and spread alphabetically. What runs alphabetically across a fleet?
Before continuing: when a playbook uses ansible.builtin.dnf, what
binary does the target actually execute?
Root cause
1. Ansible never runs the tool the module is named after
This is the fact the whole incident turns on, and it is not obvious from the outside.
When a task uses ansible.builtin.dnf, Ansible does not construct a
dnf command line and run it. It assembles the module’s Python source
and its dependencies into a single self-contained payload, transfers it
to the target as AnsiballZ_dnf.py in a temporary directory, and
executes it with the target’s Python interpreter. The module code then
does whatever it does - which for dnf involves the package manager’s
Python bindings, not a shell invocation of /usr/bin/dnf at all.
So the command presented to sudo is always of the form:
/bin/sh -c 'echo BECOME-SUCCESS-... ; /usr/bin/python3 /var/tmp/ansible-tmp-<timestamp>-<pid>-<n>/AnsiballZ_<module>.py'
The module name appears only in a filename, inside a directory whose name is different on every run.
2. An allow-list of tools can never match
The hardening baseline replaced NOPASSWD: ALL with
NOPASSWD: /usr/bin/dnf, /usr/bin/systemctl. Those are the two tools
the playbook appears to use, and if a human were running the patch by
hand they would be exactly right.
They cannot match the interpreter invocation. Not with more entries, not
with wildcards on the temporary directory - and an allow-list that
permits /usr/bin/python3 with an arbitrary script argument grants
unrestricted root anyway, which is the opposite of what the baseline was
trying to achieve.
When no rule matches, sudo does not fail silently. It falls through to
its default behaviour, which is to ask for a password. Ansible has none,
so the connection plugin raises Missing sudo password.
3. The message describes the request, not the reason
Missing sudo password is literally true: sudo prompted, and no
password was supplied. It says nothing about why sudo prompted, and
the natural reading - a credential is missing - is wrong. Every hour
spent on the vault, the become password file and the service account was
spent chasing that reading.
The target knew. journalctl -t sudo on any failing host had been
logging command not allowed since 06:00.
Resolution
- Establish the split precisely. Run
ansible all -i inventory -m ansible.builtin.ping -bacross the fleet and record which hosts fail. That list is the blast radius of the baseline rollout and you will need it to confirm the repair. - Get root on one failing host by a path that does not depend on the broken rule - an existing session, the console, out-of-band management, or the configuration-management agent, which runs as root and does not consult sudoers.
- Confirm the diagnosis there with
sudo -l -U svc_ansible /usr/bin/python3, which must report that a password is required. This is the check that distinguishes this fault from a genuine credential problem. - Deploy the corrected rule through the configuration-management system that deployed the broken one. A hand-edit on 61 hosts is undone by the next converge and hides the fact that the source is still wrong.
- Restore escalation for the automation account. If the security requirement behind the baseline is real, change the shape of the control rather than its contents: unrestricted sudo for the account, combined with SSH access restricted by source address and key, and auditing from the run log and the target audit daemon.
- Canary to one host and verify with
ansible <host> -m ansible.builtin.ping -bbefore touching the other 60. - Roll out, then re-verify a random sample across roles and regions rather than assuming convergence implies correctness.
- Take the finding to the security team with the
-vvvline as evidence. The baseline is wrong about how Ansible works, and it will be applied to the next fleet unless the source is corrected.
Verification
- Escalation works on a repaired host.
ansible <host> -i inventory -m ansible.builtin.ping -breturns SUCCESS. This is the smallest command that exercises the whole become path. - The check can fail. Run the same command against a host that has not yet been repaired and confirm it still reports
Missing sudo password. A verification that passes on every host, including the broken ones, is not a verification. - The specific command is authorised.
sudo -l -U svc_ansible /usr/bin/python3on a repaired host reports the command as permitted rather than demanding a password. - The target agrees.
journalctl -t sudo --since -10minon a repaired host shows the python3 invocation being authorised, with nocommand not allowedentries. - A real run completes.
ansible-playbook -i inventory patch.yml --check --limit <host>finishes with no become failures; aping -bexercises escalation once, a playbook exercises it per task. - Convergence does not undo it. Re-run the configuration-management agent and repeat the first two checks. This is where a hand-fix that skipped the source is exposed.
- The full fleet is clean.
ansible all -i inventory -m ansible.builtin.ping -breports zero failures, and the count of hosts matches the count from the original survey.
Prevention
- Record in the repository that Ansible escalates the Python interpreter running a generated payload, never the tool a module is named after. This single sentence prevents the entire class of command-allow-list proposals.
- Test authorisation changes by exercising them.
sudo -lrenders a policy;ansible ... -m ping -bevaluates it. Only the second one can catch a rule that does not match. - Canary sudoers changes to one host, with an automated escalation check, before any fleet rollout. Authorisation changes deserve the same care as firewall changes because both can remove the means of repair.
- Keep the automation account’s escalation broad and its reachability narrow. Constrain who may become the account and from where; audit what it did from the controller run log and the target audit daemon, which record actions rather than permissions.
- Alert on become failures as their own category. A fleet-wide escalation failure is simultaneously an outage and a signal that authorisation changed without coordination.
- When a fault spreads alphabetically or by batch, look for a configuration-management rollout before looking anywhere else. That ordering is not a property of any fault; it is a property of a converge.