
Ansible will print a secret if you ask it to, and it will write that secret into a log if a task is too verbose.
Vault exists so a playbook can ship encrypted values. Those values still land in memory and sometimes in a callback. A CVE that logs vaulted content is a reminder: treat the controller as a secrets host.
The usual mistake is committing a vault password next to the vault, or running playbooks with debug on in CI.
This page is the Ansible settings that keep secrets off the log, and the privilege the control node actually has.
CVE-2024-8775 is CWE-532. NVD published it on 13 September 2024. Red Hat’s text is plain: vaulted values loaded with include_vars can print in playbook output when no_log is not set. ansible-core 2.18.0, GA 4 November 2024, lists that mask fix in its changelog. The project’s own maintenance table puts 2.18 End of Life in May 2026. If you are still on that line in August 2026, the vault file is not the whole job.
That is a man page. The control in 2026 is four things: encrypt before commit, keep secrets off the command line and off STDOUT, escalate as a person you can name in the log, and keep prod hosts out of the inventory a laptop can target with -l all.
Pair this runbook with the Ubuntu host guide for the sshd and the worker user the playbook should leave behind. Keep the secure coding checklist next to any template that writes app config. If a playbook concatenates a host var into a shell string, that is injection, not a vault miss.
ansible-core 2.18 in August 2026
I am dating the line from first-party pages. The ansible-core 2.18 roadmap lists the release as 4 November 2024. The 2.18 release and maintenance table lists GA 4 November 2024, security maintenance through 3 November 2025, and End of Life May 2026. Control-node Python on that line is 3.11 to 3.13. Target Python is 3.8 to 3.13.
The same maintenance table lists 2.20 GA as 3 November 2025. Community EOL trackers date 2.19 to 21 July 2025 and 2.21 to 31 May 2026, the month 2.18 went EOL. If ansible --version still prints 2.18, plan the jump. The vault, no_log, become, and inventory controls on this page still apply on a supported controller. They do not justify staying on an EOL line.
2.18.0’s changelog names two security fixes that belong on this page: result masking for action-sourced _ansible_no_log, and include_vars requesting that mask when it reads a vaulted file. Both cite CVE-2024-8775. The same release also added the vaulted_file test. Use it in CI to prove a path is ciphertext before you merge.
Vault encrypts the file, not the log
Ansible Vault is AES-256 on a file or a string. It is not a runtime secret manager. The controller decrypts at run time so a task can use the value. Anyone who can read the play output, the log_path file, or a CI artifact can read the value unless you mask it. Anyone who can read the vault password can decrypt the file on their laptop.
It is a hint. If dev and prod share a password because that was easier, Ansible will decrypt a prod file with the dev password unless you set DEFAULT_VAULT_ID_MATCH / vault_id_match. Set it. Split the passwords. Keep the prod password off developer laptops. A controller or a password client that pulls from a store you already trust is the source. A plaintext vault_password_file committed next to the playbook is the same as no vault.
# group_vars/prod/vault.yml (encrypt this file)
db_password: "{{ vault_db_password }}"
vault_db_password: redacted-in-git
# create or rekey, never commit the password file
ansible-vault encrypt group_vars/prod/vault.yml --vault-id prod@prompt
ansible-vault view group_vars/prod/vault.yml --vault-id prod@prompt
A pre-commit hook that rejects any file whose first line is not $ANSIBLE_VAULT;1.1;AES256 or the current vault header is how a forgotten plaintext stays off the default branch. Prefixes like VAULT_ are a convention. The header is the proof. ansible-vault encrypt_string is fine for a single value inside an otherwise public vars file. A whole vault.yml is easier to grep.
Vault guide for the header and the id-match note. The commands above are the ones you actually run.
extra-vars is a process-list leak
ansible-playbook -e db_password=s3cret puts the secret in the process list of every user on the controller who can run ps. It also lands in shell history, in CI “command” fields, and in -v output. A file passed as -e @extra.json is better only when that file holds non-secrets. If the file holds a password, you have moved the leak from ps to disk.
# BAD: visible in ps, history, and -v
# ansible-playbook site.yml -e db_password=s3cret
# FIX: extra.json has no secrets; vault.yml does
ansible-playbook -i inventories/prod/hosts.ini site.yml \
--vault-id prod@/run/ansible/prod-vault-pass \
-e @inventories/prod/extra.json
Identifiers stay db_password, vault_db_password, extra.json, and prod-vault-pass. The password file is mode 0600 on the controller, not in git. A CI job writes it from the job’s secret store at start and shreds it at end. ANSIBLE_VAULT_PASSWORD_FILE is the env name if you refuse a flag. The env value is a path, not the password string.
no_log and CVE-2024-8775
The logging appendix is blunt. If you save Ansible output to a log, you expose secret data in that output. Mark tasks that expose them with no_log: true. It does not hide -vvv debug. Do not debug a prod play on a shared runner.
CVE-2024-8775 was the case where include_vars read a vaulted file and the mask did not stick. 2.18.0 fixed the action-sourced _ansible_no_log so a later result processor could not overwrite it. 18 changelog for the fix. You still set no_log: true yourself. A patched controller still prints db_password if you put it in a debug: task.
- name: Load vaulted prod secrets
ansible.builtin.include_vars:
file: group_vars/prod/vault.yml
no_log: true
- name: Write app env from vaulted values
ansible.builtin.template:
src: app.env.j2
dest: /etc/webapp/app.env
owner: webapp
group: webapp
mode: "0600"
no_log: true
become: true
become_user: root
Grep the tree for debug: next to vault_ or password. Grep CI logs for the first eight characters of a known staging secret after a dry run. If they appear, the task is missing no_log or you passed the value on -e. display_args_to_stdout makes similar tasks easier to tell apart. It also prints more values. Leave it off on prod controllers.
become as a named person, not shared root
SSH as root with one key on every host is how the playbook works and how the log becomes useless. The control is a named account, key-only, with sudo for the tasks that need it. become: true is per task or per play. Do not store a root password in extra-vars to make become easier.
# ansible.cfg on the controller
[defaults]
inventory = inventories/dev/hosts.ini
host_key_checking = True
vault_id_match = True
nocows = True
[privilege_escalation]
become = False
become_method = sudo
become_ask_pass = False
# inventories/prod/hosts.ini
[web]
app-a.example.com ansible_user=deploy
[web:vars]
ansible_become=true
ansible_become_user=root
ansible_become_method=sudo
- name: Install nginx as sudo from deploy
ansible.builtin.apt:
name: nginx
state: present
become: true
become_user: root
Identifiers stay deploy, ansible_user, and become_user. Skip logins named admin or ansible. The Ubuntu host page is where PermitRootLogin no and PasswordAuthentication no live. This page is the playbook side: the user Ansible logs in as, and the user it becomes. become_user: webapp for tasks that only need to write /var/lib/webapp. Root is for package install and unit files, not for copying an env file you could chown.
CONTROLLER vault.yml (AES-256, vault-id prod) extra.json (region, image, no passwords) /run/ansible/prod-vault-pass (0600, not in git) | | ansible-playbook --vault-id prod@... -e @extra.json | become: deploy -> sudo root, then webapp v HOST /etc/webapp/app.env mode 0600 owner webapp sshd: deploy keys only, root never
Inventory isolation: keep prod off the laptop
A single hosts.ini with [dev] and [prod] is how a Friday -l all becomes a prod change. Split the trees. The default inventory in ansible.cfg points at dev. Prod is an explicit -i inventories/prod/hosts.ini on a dedicated controller, or a CI job with a protected environment.
inventories/
dev/hosts.ini
prod/hosts.ini
group_vars/
dev/vars.yml
prod/vault.yml
prod/vars.yml
ansible-inventory -i inventories/prod/hosts.ini --list is the proof of what a run can see. If a developer checkout can list prod addresses, the isolation is a folder name, not a control. Restrict the prod tree with CODEOWNERS and a protected branch. Pin host keys. host_key_checking = True stays on. A first run that cannot TOFU in CI should fail, not write a new key.
Dynamic inventory from a cloud account that can see prod and staging in one credential is the same bug with extra steps. Use a credential that can list only the account you mean. Limit with -l web:&prod only after the inventory itself cannot see the other side.
Prove a run before it hits prod
You are not attacking a fleet. You are proving your own playbook will not print a secret, will not SSH as root, and will not see prod hosts from a dev checkout.
ansible --versionmust not still be 2.18 if you can move. Note the line. Plan 2.19 or 2.20.head -n 1 group_vars/prod/vault.ymlmust start with$ANSIBLE_VAULT. A YAML key on line 1 is a failed hook.- Run the play with
--checkagainst a throwaway VM. Grep the output and the CI log forvault_db_passwordand for any known staging secret. Zero hits. ps aux | grep ansible-playbookduring the run must not show a password string. If it does, you still have-e key=value.ansible-inventory -i inventories/dev/hosts.ini --listmust not contain a prod hostname.- On the target,
sshd -T | grep -E 'permitrootlogin|passwordauthentication'must printnoafter the play. The Ubuntu page has the drop-in.
ansible-playbook -i inventories/dev/hosts.ini site.yml \
--vault-id dev@prompt --check --diff
ansible-inventory -i inventories/dev/hosts.ini --list | grep -i prod || true
# Expect: no prod hostnames
head -n 1 group_vars/prod/vault.yml
# Expect: $ANSIBLE_VAULT;1.1;AES256 or the current header
If --check still prints a secret, the task is missing no_log. If prod names appear in the dev list, split the inventory before you write another role. If the vault header is missing, encrypt the file and rotate whatever was in it. Treat a leaked vault password like a break-in, not a ticket.
Questions we keep getting
Is HashiCorp Vault a replacement for Ansible Vault?
For runtime app secrets, yes, prefer a store the host can pull. For the values the playbook itself must write on first boot, Ansible Vault is still the file encryption. mcx’s split still holds: Parameter Store or equivalent for the app, extra-vars only for non-secrets, playbook vault for the leftover bootstrap values.
Does no_log hide extra-vars?
No. extra-vars are on the command line before any task runs. no_log masks task results. It does not rewrite ps, shell history, or the CI “command” field. Keep passwords off -e.
Can I leave become on in ansible.cfg?
You can. I would not. Default become = False and opt in on the tasks that need root. A role that installs a user-level cron should not inherit a global root become from the cfg.



