AnsibleXIX · Privilege EscalationPrivilege escalation
Least privilege for the automation account
What you'll learn
- State what a blanket automation sudo grant means for controller compromise
- Identify which least-privilege levers remain when command scoping is unavailable
- Split one automation identity into several with different grants
- Design a Runas restriction that keeps service work out of root
- Plan a sudo rule change so it cannot break every run at once
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Almost every Ansible estate starts with the same line, pushed to every host by the bootstrap play:
ansible ALL=(ALL) NOPASSWD: ALL
It works immediately, it never needs revisiting, and it makes a statement that nobody in the room intended to make:
whoever holds the controller’s SSH key is root on every host, without a password, without a second factor, and with the same audit trail as a legitimate run.
That is not a hypothetical. It is the arithmetic of what you deployed. The security question is not whether the automation is trustworthy; it is what a stolen key is worth, and with that line the answer is “the entire estate”.
The constraint that makes this harder than it should be
The become documentation is explicit, and lesson 1 of this part showed
the mechanism:
Become must be general; it cannot be limited to specific commands.
What sudo evaluates is a shell invocation running a Python interpreter
against a randomly-named temporary file. There is no module name in it.
So the sudoers Cmnd_Spec — the field that would normally carry least
privilege — cannot usefully enumerate what the automation does.
That constraint is real, and it is where most least-privilege efforts stop. They should not, because it removes exactly one of the five levers sudoers gives you.
sudoers field | Available to scope Ansible? |
|---|---|
| Who — the user or group the rule applies to | Yes, and this is the strongest lever |
Which hosts — the Host_List | Yes, when sudoers is centrally distributed |
| Runas — which target identities may be assumed | Yes, and it is routinely left as ALL |
Tags — NOPASSWD, LOG_OUTPUT | Yes |
Commands — the Cmnd_Spec | Mostly no, for module execution |
Four out of five. The design work is using them.
Lever one: more than one automation identity
The single biggest improvement is also the least technical. One account with one key and one grant means every playbook has the maximum authority any playbook needs. Splitting by what the automation is for turns one blast radius into several.
# Read-only inventory and compliance collection. No escalation at all -
# this account is not in any sudoers rule on this host, deliberately.
# ansible-audit (no rule)
# Service operations: may become the database service account, and
# nothing else. Cannot become root on this host.
ansible-dbops ALL=(postgres) NOPASSWD: ALL
# Platform operations: full root, on the hosts that need patching and
# package management, and only those.
ansible-platform ALL=(root) NOPASSWD: ALLA compromised ansible-dbops key is a serious incident. It is not root
on the database host, and it is nothing at all on the web tier where no
rule mentions it.
Lever two: Runas is not ALL
(ALL) in a sudoers rule means “may become any user on this system”.
For automation whose work is service-scoped, that is far more than
needed, and narrowing it is a single-word change.
# Before: any target user, including root.
ansible-appops ALL=(ALL) NOPASSWD: ALL
# After: the application account and its deploy helper, nothing else.
ansible-appops ALL=(appsvc, appdeploy) NOPASSWD: ALLThe playbook side is become_user: appsvc, which lesson 1 argued should
be written explicitly anyway. The two changes together mean a task that
tries to escalate to root on that host fails, loudly, in review or
in CI, rather than succeeding because the grant permitted more than the
design intended.
That is the property to aim for throughout: the grant is narrow enough that a mistake in a playbook produces a failed task rather than an unintended privileged operation.
Lever three: the host list is a real boundary
ALL= in the middle field means “on all hosts”, which matters only when
sudoers is distributed centrally — via LDAP, SSSD, or a
configuration-managed file that is identical everywhere.
If your estate distributes one sudoers file to every host, the host field can encode the same separation your inventory groups do — with the crucial difference that the managed node enforces it. An inventory group is a statement about what you intend to target. A sudoers host restriction is a statement the target itself checks.
Lever four: make the escalation auditable
NOPASSWD removes the authentication step. It does not have to remove
the record.
Defaults:ansible-platform log_output, logfile=/var/log/sudo-ansible.log
Defaults:ansible-platform !syslog
log_output records what the escalated session did. For automation,
where every session is a shell running a Python module, the value is not
in reading it routinely — it is in having it during an incident, when
the question is “what did this account do on this host between 02:00 and
02:40?”
Two costs to size deliberately: the log grows with every run, and it captures module output, which for a module handling secrets means the secret can land in the log. Scope the setting to the automation user rather than setting it globally, exclude accounts that handle credential material, and give the log a rotation policy before you enable it.
Part XLIII covers the automation-side audit trail — callbacks, run records, who triggered what. This is the managed-node side of the same question, and it is the one an attacker cannot edit from the controller.
Deploying the grant without locking yourself out
A sudoers change is a fleet-wide change to the mechanism your automation uses to make changes. Get it wrong and you cannot use Ansible to fix it.
- name: Deploy the automation sudo grant
ansible.builtin.template:
src: automation-sudoers.j2
dest: /etc/sudoers.d/50-automation
owner: root
group: root
mode: '0440'
validate: /usr/sbin/visudo -cf %s
become: truevalidate catches a malformed file. It does not catch a well-formed
file that grants less than your playbooks need, and that is the failure
that takes the fleet out.
ansible -i inventories/prod all --become --become-user root \
-m ansible.builtin.command -a "id -un" \
| grep -E 'UNREACHABLE|FAILED|root'The review that keeps it honest
A privilege review for an automation account asks four questions, and the answers should be findable without logging into anything:
- Which accounts can escalate, on which hosts? Enumerate the sudoers rules mentioning any automation identity, per environment.
- To which target users? Anything reading
(ALL)is a finding unless it is deliberately the platform account. - What holds the keys? One controller, or six laptops? Part III asked this; the answer determines what the grant is actually exposed to.
- How would you notice a change? If the security team tightened a rule tonight, would anything tell you before tomorrow’s run failed?
Knowledge check
Knowledge check · 4 questions
Q1. An estate deploys "ansible ALL=(ALL) NOPASSWD: ALL" to every host. What has that decided?
Q2. Ansible cannot scope escalation by command. Which sudoers levers remain genuinely useful? Select all that apply.
Q3. Running plays with --limit constrains what the automation account is permitted to change, so it is part of the least-privilege design.
Q4. The security team will tighten the fleet sudo rules this week. What most reduces the chance of a fleet-wide run failure?
Passing score: 75%. Answers are checked in this browser.