Unsafe Permissions
Detects unsafe file permissions, ownership, and access control issues
49 rules in unsafe_permissions.yml
CRITICAL: 5 | HIGH: 25 | MEDIUM: 12 | LOW: 6
| Rule ID | Severity | Title | Description | Refs |
|---|---|---|---|---|
ansible_ | CRITICAL | ansible.cfg private_key_file in world-readable location | private_key_file = /tmp/…, /home/ | |
become_ | CRITICAL | Plaintext become_password in Playbook | ansible_become_password / ansible_become_pass / become_password is set inline to a literal string - the sudo password is stored in cleartext in version control. | |
inventory_ | CRITICAL | Inventory: ansible_become_pass / ansible_sudo_pass literal | ansible_become_pass / ansible_sudo_pass is set to a literal value in inventory - committing this gives anyone with repo read access the sudo password for every listed host. | |
inventory_ | CRITICAL | Inventory: ansible_ssh_pass / ansible_password literal | Inventory file (or inventory-style INI/YAML content) sets ansible_ssh_pass / ansible_password to a literal value - any checkout of the repo has every host’s SSH password in plaintext. | |
setuid_ | CRITICAL | SetUID Permission | mode: ‘<4-7>xxx’ or chmod u+s sets the setuid bit on a file. Any unverified setuid binary becomes a privilege-escalation primitive. | |
ansible_ | HIGH | ansible.cfg allow_world_readable_tmpfiles = True | Setting allow_world_readable_tmpfiles = True makes Ansible’s per-task temp files (which can briefly contain task parameters including secrets) world-readable. Any local user on the remote host can steal credentials mid-run. | |
ansible_ | HIGH | ansible.cfg host_key_checking = False | The defaults section of ansible.cfg sets host_key_checking = False, disabling host-key verification globally for every playbook run from this config. | |
ansible_ | HIGH | ansible.cfg log_path points to world-readable location | log_path set to /tmp, /var/tmp, /var/log with default umask - the ansible log captures task names, hostnames, and (without no_log) sometimes module arguments including secrets. A world-readable log is a credential leak waiting to happen. | |
ansible_ | HIGH | ansible.cfg roles_path / collections_path in world-writable dir | roles_path or collections_path pointing to /tmp, /var/tmp, or another world-writable directory lets any local user on the Ansible controller plant a malicious role/collection that the next playbook run will execute. | |
ansible_ | HIGH | ansible_host_key_checking = false | Setting ansible_host_key_checking to false (as a host_var, group_var, or extra-var) disables host-key verification for every SSH connection Ansible makes from that inventory scope. This turns off the entire trust layer for whole host groups at once. | |
become_ | HIGH | become_flags Injects NOPASSWD or sudoers Overrides | become_flags contains ‘-n’, ‘-k’, or ‘–preserve-env’ combined with ‘!authenticate’ / NOPASSWD - effectively disables sudo authentication for the remainder of the play. | |
copy_ | HIGH | copy/template with unsafe_writes: true | A copy: or template: task sets unsafe_writes: true, which disables Ansible’s atomic write-to-tempfile -> rename() strategy in favor of a direct open-and-truncate on the destination. Any local reader at that instant gets a half-written file; any local writer (even an unprivileged one, if the parent directory is world-writable) can race with the truncate and land arbitrary content in a root-owned file. | |
delegate_ | HIGH | delegate_to With Jinja-Interpolated Host | delegate_to value contains {{ … }} - if the variable comes from inventory or facts that an attacker can influence, the task runs against an attacker-chosen host with the controller’s credentials. | |
file_ | HIGH | file Module with follow: yes + become (Symlink TOCTOU) | An ansible.builtin.file (or legacy file:) task sets follow: yes / follow: true AND runs with become: privilege escalation. If the path: points into a world-writable directory (/tmp, /var/tmp, /dev/shm) or a user-owned directory, a local attacker can swap the target for a symlink to /etc/shadow, /root/.ssh/authorized_keys, or any sensitive file - file then changes mode/owner on the symlink target, as root. | |
inventory_ | HIGH | Inventory: ansible_connection=paramiko with host_key_auto_add | ansible_connection=paramiko combined with paramiko’s host_key_auto_add (or no known_hosts file) trusts whatever host key the target presents on first connection - MITM is trivial on the initial handshake. | |
inventory_ | HIGH | Inventory: ansible_become_flags contains -n (NOPASSWD sudo) | A host/group var sets ansible_become_flags to contain -n (non-interactive), meaning sudo will NEVER prompt for a password. That requires NOPASSWD in sudoers on the target - silently baked into inventory rather than explicitly reviewed. | |
inventory_ | HIGH | group_vars/all: variable name suggests secret, value is plaintext | A variable in group_vars/all (or host_vars) named *_password / *_token / *_secret / *_api_key / *_private_key is assigned a non-Jinja, non-vault, non-empty literal. That secret now applies to every host in the inventory. | |
inventory_ | HIGH | Inventory: ansible_winrm_server_cert_validation=ignore | Inventory sets ansible_winrm_server_cert_validation=ignore - the WinRM TLS handshake accepts any certificate, including an attacker-presented one. MITM on the Windows connection becomes trivial. | |
private_ | HIGH | Private Key Copied Or Linked Outside ~/.ssh | A shell task uses cp, mv, ln, or install on a path matching id_rsa, id_ed25519, id_ecdsa, *.pem, or *.key whose destination is NOT a .ssh/ directory. Common destinations are /home/root/.ssh/ (which is not root’s real home on standard Linux), /tmp, /var/tmp, /srv, /opt, or shared directories. Off-.ssh/ private keys typically inherit the surrounding directory’s umask (often 0755) and bypass tooling that audits ~/.ssh/ for mode 0600. | |
recursive_ | HIGH | Recursive Permission Change | chmod -R 777 or 666 walks an entire subtree. Recursive permits rarely target only what’s intended and frequently widen permissions on credentials buried in the tree. | |
setfacl_ | HIGH | setfacl Grants rwx to everyone / other on Sensitive Path | A task invokes setfacl (or the ansible.posix.acl module) to add an ACL entry of u::rwx,g::rwx,o::rwx, m::rwx, or user:anyone/group:wheel with rwx on paths like /etc, /root, /var/log, /home, /opt, or the recursive form -R on any system path. ACLs silently override POSIX mode bits - a 0600 /etc/shadow with a user:nobody:rwx ACL is world-readable in practice. This is a known AV/EDR-evasion trick used by recent Linux ransomware families. | |
setgid_ | HIGH | SetGID Permission | mode: ‘<2,3,6,7>xxx’ or chmod g+s sets the setgid bit on a file. Setgid on a binary inherits the file’s group on execution and is a classic group-escalation primitive. | |
ssh_ | HIGH | ansible_ssh_common_args / ssh_args Disables Host-Key Verification | ansible_ssh_common_args or ansible_ssh_extra_args contains -o StrictHostKeyChecking=no, -o UserKnownHostsFile=/dev/null, or both, disabling host-key checking for every host under this inventory scope. | |
ssh_ | HIGH | SSH StrictHostKeyChecking Disabled | Playbook sets StrictHostKeyChecking=no (via ssh args, ProxyCommand, or -o flag), which accepts any host key and is the primary MitM-enabler for SSH. An attacker on the network path can silently impersonate the target host. | |
ssh_ | HIGH | SSH UserKnownHostsFile=/dev/null Discards Host Keys | UserKnownHostsFile=/dev/null (often paired with StrictHostKeyChecking=no) causes ssh to forget host keys after each connection - every connection becomes trust-on-first-use with no record, defeating host-key pinning entirely. | |
ssh_ | HIGH | ansible_ssh_common_args / ssh_args Re-Enables Deprecated SSH Host-Key Algorithms (ssh-rsa / ssh-dss) | A task sets ansible_ssh_common_args, ansible_ssh_extra_args, a playbook environment: ANSIBLE_SSH_ARGS, or ssh_args in ansible.cfg to include -oHostKeyAlgorithms=+ssh-rsa, -oPubkeyAcceptedKeyTypes=+ssh-rsa, -oPubkeyAcceptedAlgorithms=+ssh-rsa, -oHostKeyAlgorithms=+ssh-dss, -oCASignatureAlgorithms=+ssh-rsa, or any equivalent form that re-enables the deprecated SSH SHA-1 host-key family. OpenSSH 8.8 (2021) disabled ssh-rsa (SHA-1) by default and 9.8 (2024) removed DSA entirely because SHA-1 collision attacks (SHAttered, 2017) make host-key forgery practical for a well-resourced on-path attacker. Re-enabling these algorithms to ‘work around’ target OSes stuck on OpenSSH 7.x silently reintroduces MITM risk across every managed node and is a specific CIS/CISA finding in 2024-2025 compliance audits. Distinct from ssh_args_disable_host_key (that rule catches StrictHostKeyChecking=no); this catches the more subtle ‘I kept verification on but trusted a broken algorithm’ failure mode. | |
template_ | HIGH | template Rendering Executable File to System Path | A template: task renders content to /etc/, /usr/bin/, /usr/local/bin/, /usr/sbin/, /opt/, or /root/ AND sets an executable mode (0755, 0775, 0777, 4xxx, or anything with the x-bit). That combination is the classic webshell / persistence-hook pattern: a templated script that runs during cron/systemd/PATH lookup, easy to forget in audits because it looks like ‘just a config file’. | |
tls_ | HIGH | Private Key Or TLS Secret Downloaded Into A World-Readable Directory | A shell task runs aws s3 cp, gcloud storage cp, az storage blob download, curl, or wget and writes a file whose name suggests a private key or TLS secret (*.key, *.pem, *privatekey*, *secret*) into /tmp, /var/tmp, /dev/shm, or /srv/<world-readable>. The destination directory is world-readable on every standard distribution, and the downloader does not enforce a restrictive mode: the way ansible.builtin.copy would; the secret is exposed for the entire interval before it is moved or removed. | |
winrm_ | HIGH | WinRM ansible_winrm_server_cert_validation = ignore | Setting ansible_winrm_server_cert_validation to ‘ignore’ disables TLS validation for all WinRM connections - the Windows equivalent of StrictHostKeyChecking=no. | |
world_ | HIGH | World Writable Files | mode: ‘0777’ is set on a file resource, or chmod 777 / chmod a+w / chmod o+w is invoked. Any local user can rewrite the path’s contents, defeating integrity assumptions of every later task. | |
ansible_ | MEDIUM | ansible.cfg callback_whitelist = | callback_whitelist = * (or a comma-separated list including unvetted plugins) loads every callback plugin on the plugin path. Unlike a specific whitelist, this loads attacker-planted callbacks (e.g. a tampered-with slack plugin) that can exfiltrate task output to external services. | |
ansible_ | MEDIUM | ansible.cfg retry_files_enabled = True | retry_files_enabled writes .retry files containing inventory hostnames after failed runs. On shared systems these files linger with 0644 permissions and can leak inventory structure to other users. | |
become_ | MEDIUM | Explicit become_user: root | Task sets become_user: root. When combined with broad play scopes, this grants every downstream module root - violates least-privilege when a dedicated service user would do. | |
connection_ | MEDIUM | connection: local on Privilege-Sensitive Task | Task sets connection: local while executing a module that changes state (shell, command, copy, file, user, service). Controller-side side-effects are rarely the intent and often indicate a misconfigured delegation. | |
delegate_ | MEDIUM | delegate_to: localhost With Privileged Remote Module | Task uses delegate_to: localhost (or 127.0.0.1) while running a module that otherwise modifies remote state - silently shifts credential context onto the controller, often unnoticed by reviewers. | |
git_ | MEDIUM | ansible.builtin.git With accept_hostkey: yes (SSH TOFU on Remote Git Clone) | An ansible.builtin.git task clones from an SSH remote (git@host:repo.git / ssh://git@host/repo.git) and sets accept_hostkey: yes. This flag tells Ansible to hand OpenSSH a temporary StrictHostKeyChecking=accept-new (or =no on older Ansible) argument for the single clone - effectively a TOFU (trust-on-first-use) bypass for the git remote’s host key. An on-path attacker (rogue GitHub enterprise mirror, DNS-hijacked self-hosted GitLab, BGP-hijacked proxy) gets handed the SSH deploy-key without any fingerprint check, then serves trojanized source code, build artifacts, or Ansible roles that subsequently run with the permissions of the Ansible run. The risk is amplified because most git: tasks run with become: yes to place the clone into /opt/, /srv/, /var/lib/, etc., so the malicious code inherits root privileges on the managed node. | |
lineinfile_ | MEDIUM | lineinfile/blockinfile Editing Sensitive File With backup: no | A lineinfile: or blockinfile: task edits /etc/sudoers, /etc/sudoers.d/, /etc/pam.d/, /etc/ssh/sshd_config, /etc/passwd, /etc/shadow, /etc/group, /etc/nsswitch.conf, /etc/hosts.allow, /etc/hosts.deny, /etc/crontab, /etc/cron.d/, or /etc/security/ AND explicitly sets backup: no (or backup: false). A typo in a sudoers line with no backup locks out every admin; a bad sshd_config edit with no backup loses SSH access. The file is version-controlled in the playbook, not on the target - recovery requires console access. | |
raw_ | MEDIUM | raw Module Used With become | The raw module bypasses Ansible’s module system (no argument sanitisation, no YAML-aware quoting). Combined with become: true, it elevates arbitrary shell commands to root with minimal guardrails. | |
ssh_ | MEDIUM | ssh-keyscan Appending Unverified Keys to known_hosts | Piping ssh-keyscan directly into known_hosts accepts whatever key the network returns, with no fingerprint comparison - the classic ‘TOFU at deploy time’ pattern that moves the MitM window earlier in the pipeline instead of eliminating it. | |
ssh_ | MEDIUM | SSH ProxyCommand With Trust-Bypass Flags | A nested ProxyCommand that re-invokes ssh with StrictHostKeyChecking=no turns the jump host into a silent MitM: the outer connection is verified, but the hop through the proxy is not. | |
sticky_ | MEDIUM | Sticky Bit Removal | Explicitly removing sticky bit from directories using chmod -t | |
world_ | MEDIUM | World Readable Sensitive Files | mode: ‘0744’ is applied to a file containing ‘key’, ‘secret’, ‘password’, or ‘credential’. World-readable secrets are a compliance failure mode under PCI-DSS, HIPAA, and SOC 2. | |
ansible_ | LOW | ansible.cfg any_errors_fatal = False for privileged playbooks | With any_errors_fatal = False, a task that fails on ONE host won’t stop the run on the other hosts - useful in dev, dangerous in hardening/patching playbooks where partial failure means a fleet in an inconsistent (possibly less-secure) state. | |
ansible_ | LOW | ansible.cfg display_skipped_hosts = False | display_skipped_hosts = False hides which hosts skipped a task - useful to reduce noise but also hides evidence that a security-relevant task (firewall, patching, hardening) didn’t run on a host. | |
ansible_ | LOW | ansible.cfg command_warnings = False (silences shell-over-module warnings) | command_warnings = False disables Ansible’s warning when you use the command/shell module for something a native module could do better. That warning is a security signal - silencing it hides shell: apt install ... style anti-patterns. | |
ansible_ | LOW | ansible.cfg pipelining = True (verify sudoers) | pipelining = True speeds up runs but REQUIRES Defaults !requiretty in /etc/sudoers on every managed host. If requiretty is enabled (default on RHEL/CentOS), pipelining silently breaks become and can mask security issues. | |
become_ | LOW | Windows Host Uses become: true Without become_method: runas | Plays targeting Windows hosts need become_method: runas. Missing become_method typically defaults to sudo, which will fail silently or do nothing on Windows, hiding escalation bugs during test runs. | |
inventory_ | LOW | Inventory: host pattern ‘all’ used in a privileged play | A play targets hosts: all AND uses become: true - a single typo, YAML anchor, or rogue PR can apply privileged changes to every host in your inventory at once. A narrower pattern (hosts: webservers, hosts: &prod:!canary) is safer. | |
legitimate_ | INFO | Standard Configuration File Permissions | Standard permissions for configuration files (informational) |