CoursesAnsible for secure automationHardening, testing & scale

Linting, molecule & policy gates

Catch it in CI, not on the fleet.

Advanced14 min · lesson 11 of 12

Getting a file mode wrong costs about ten seconds on your laptop, five minutes in a pull request, and a full credential rotation across four hundred servers once the playbook has already run. Same two characters. Three wildly different prices. Everything in this lesson is machinery for dragging the moment you find out toward the cheap end of that scale.

Locks make the three checks concrete. You can measure whether a key was cut to the right dimensions, you can check whether the locksmith followed the standard, or you can walk up to the door and turn the handle. Three different questions, and passing one tells you nothing about the other two. yamllint asks whether the file is even shaped like YAML (YAML Ain't Markup Language, the indentation-based text format your playbooks are written in). ansible-lint asks whether the tasks inside it are written safely. Molecule builds a throwaway machine, runs the role against it for real, then runs it a second time to find out whether the role was telling the truth.

Three gates, cheapest first
yamllint: shape
parse and layout
indentation, duplicate keys, long lines
milliseconds
knows nothing about Ansible
blind to
anything a task actually does
ansible-lint: habits
--profile production
six cumulative profiles; this is the bar
the safety rules
risky-file-permissions, risky-shell-pipe, package-latest
blind to
whether the role works at all
molecule: behavior
converge on a container
real modules, real files, disposable host
idempotence
run twice; any change on pass two fails
blind to
your real base image, SELinux, the fleet
Each layer catches a class of defect the one before it cannot see. Skipping a layer does not make the next one work harder. It leaves a hole in the middle of your gate.

Layer One: Does The File Even Parse

yamllint knows nothing about Ansible. It reads YAML as YAML and complains about shape: wrong indentation, trailing whitespace, over-long lines, bare words like yes that YAML quietly converts into a boolean, and duplicate keys. That last one earns its place in a security course. When the same key appears twice in one mapping, YAML keeps the last value and says nothing at all. Append ansible_become_user: root to a group_vars file that already set ansible_become_user: appuser forty lines further up, and you have quietly promoted every task that runs against that group. No parse error. No warning. Nothing in the diff that looks alarming to a reviewer skimming at five o'clock on a Friday.

.yamllint
---
extends: default
rules:
line-length:
max: 160
comments:
# ansible-lint's bundled copy of yamllint uses 1, so match it
min-spaces-from-content: 1
truthy:
# Default allowed-values is already ["true", "false"], which is what we want.
# check-keys must go, though: GitHub Actions workflows use `on:` as a key and
# yamllint reads that as the YAML 1.1 boolean true.
check-keys: false
terminal
yamllint --strict group_vars/ playbooks/
echo "exit=$?"
output
group_vars/webservers.yml
1:1 warning missing document start "---" (document-start)
41:1 error duplication of key "ansible_become_user" in mapping (key-duplicates)
14:161 error line too long (172 > 160 characters) (line-length)
playbooks/site.yml
22:7 error wrong indentation: expected 8 but found 6 (indentation)
exit=1

The exit code matters more than the text, because the exit code is the only part a pipeline reads. yamllint exits 1 the moment it finds an error, and 0 when the worst thing it found was a warning. Notice what that means for the missing document start above: on its own, it would have sailed straight through. Add --strict and warnings exit 2 instead. In continuous integration (CI, the robot that runs your checks automatically on every proposed change) you want that, because a rule quietly demoted to a warning is a rule everybody stops reading inside a month. ansible-lint carries its own copy of yamllint and reports the findings under its yaml rule (yaml[line-length], yaml[truthy], and so on), reading your .yamllint file if you have one, so the two tools agree instead of arguing about line length in your pull requests.

Layer Two: The Rules With Real Weight

ansible-lint is the reviewer who has read every Ansible incident report ever written and never gets tired at the end of a long day. It loads your playbooks and roles the way Ansible itself does, then runs a few hundred rules over them. Here is a role with six ordinary-looking tasks. Four of them are real bugs.

roles/webapp/tasks/main.yml
---
- name: Create the webapp service account
ansible.builtin.user:
name: webapp
system: true
shell: /usr/sbin/nologin
create_home: false
become: true
- name: Install nginx
ansible.builtin.package:
name: nginx
state: latest
become: true
- name: Create the config directory
ansible.builtin.file:
path: /etc/webapp
state: directory
owner: webapp
group: webapp
mode: "0750"
become: true
- name: Render the application environment file
ansible.builtin.template:
src: app.env.j2
dest: /etc/webapp/app.env
owner: webapp
group: webapp
become: true
- name: Count failed SSH logins
ansible.builtin.shell:
cmd: grep 'Failed password' /var/log/secure | wc -l
register: failed_logins
become: true
- name: Fetch the monitoring agent installer
ansible.builtin.command:
cmd: curl -sSL -o /tmp/agent.sh https://downloads.example.net/agent.sh
become: true
terminal
ansible-lint --profile production roles/webapp/tasks/main.yml
echo "exit=$?"
output
WARNING Listing 6 violation(s) that are fatal
package-latest: Package installs should not use latest.
roles/webapp/tasks/main.yml:10 Task/Handler: Install nginx
risky-file-permissions: File permissions unset or incorrect.
roles/webapp/tasks/main.yml:25 Task/Handler: Render the application environment file
no-changed-when: Commands should not change things if nothing needs doing.
roles/webapp/tasks/main.yml:33 Task/Handler: Count failed SSH logins
risky-shell-pipe: Shells that use pipes should set the pipefail option.
roles/webapp/tasks/main.yml:33 Task/Handler: Count failed SSH logins
command-instead-of-module: curl used in place of get_url or uri module
roles/webapp/tasks/main.yml:39 Task/Handler: Fetch the monitoring agent installer
no-changed-when: Commands should not change things if nothing needs doing.
roles/webapp/tasks/main.yml:39 Task/Handler: Fetch the monitoring agent installer
Read documentation for instructions on how to ignore specific rule violations.
Failed: 6 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Profile 'production' was required, but 'min' profile passed.
exit=2

risky-file-permissions is the one with teeth, and ansible-lint rates it VERY_HIGH for good reason. When ansible.builtin.template creates a file and you gave it no mode, Ansible falls back to the remote user's umask (the default permission mask that account happens to carry, subtracted from what would otherwise be granted). With the usual root umask of 0022 that lands on 0644: readable by everyone on the box. This particular file is an application environment file full of database credentials, so every local account on that host, including whichever one your web process runs as after somebody finds a bug in it, can now read your production password. Nothing failed. Nothing logged. The task went green. Note the directory task two steps above it, which sets mode explicitly and is therefore clean: the rule fires on missing modes, not on modes you chose.

risky-shell-pipe fires on the login-counting task because a shell pipeline reports the exit status of its last command only. If grep cannot open /var/log/secure, the pipeline still returns 0, wc still prints 0, and your "how many failed SSH (Secure Shell, the encrypted remote login protocol) attempts were there" check reports a comfortable zero forever. A control that silently reports all-clear is worse than no control, because you stop looking at it. The fix is set -o pipefail, and it needs executable: /bin/bash alongside it, because on Debian and Ubuntu /bin/sh is dash (a small, fast, stripped-down shell) and dash has no pipefail option at all. Half the internet's copy-pasted fix for this rule is broken for exactly that reason.

no-changed-when fires twice, on the shell task and the command task, because command, shell and raw have no idea whether they changed anything and so report changed on every single run. Two things break. Handlers wired to those tasks fire every time, turning a routine converge into an unplanned rolling restart of production. And the changed count in your run summary stops carrying information, which matters because that number is very often the only evidence you have about whether today's run altered a machine at all. Set changed_when: false for read-only commands, or give the task a creates: or removes: path so it can tell when its work is already done. The rule accepts any of the three.

command-instead-of-module spots curl and points you at ansible.builtin.get_url, which is not pedantry: get_url takes a checksum argument and curl does not, so switching modules is how you stop running whatever bytes a download server felt like handing you today. package-latest flags state: latest because the artifact you tested is then not the artifact you shipped, two hosts converged an hour apart end up on different versions, and a package repository compromised between runs gets pulled onto the fleet by your own automation. Its cousin, the rule named latest, catches the same idea in ansible.builtin.git tasks with no version pinned, where you check out whatever the branch tip says at this exact second and then execute it.

Six Profiles And The One You Are Held To

Rules are grouped into six named profiles that stack: min, basic, moderate, safety, shared, production. Each one contains everything in the profile before it and adds more, so they are rungs on a ladder rather than flavors to pick between. min catches only content Ansible cannot load at all. basic is where command-instead-of-module and the deprecation rules live. moderate adds the naming rules. safety brings in risky-file-permissions, risky-shell-pipe, risky-octal, package-latest and latest. no-changed-when sits one rung higher again, in shared. production adds fqcn (fully qualified collection names, the ansible.builtin.copy style of writing a module name), sanity, meta-no-dependencies and friends, and it is the bar for anything you deploy to a machine somebody depends on.

The --profile flag does not merely change the report. It unloads every rule outside the profile you chose, so --profile safety will genuinely never mention no-changed-when to you, however many command tasks you write. Run with no profile at all and ansible-lint grades you instead: the last line names the highest profile your code currently meets, which makes a decent backlog. The fqcn rule up in production has a security reason people miss. An unqualified copy: is resolved at run time through a search path, and that path checks a library/ directory sitting next to your playbook before it reaches the builtin module. Drop a file called library/copy.py into a repository and every unqualified copy: task in it now runs your code instead of Ansible's. Writing ansible.builtin.copy removes the ambiguity, and ambiguity is where supply-chain tricks make their home.

The Same Role, Fixed

roles/webapp/tasks/main.yml
---
- name: Create the webapp service account
ansible.builtin.user:
name: webapp
system: true
shell: /usr/sbin/nologin
create_home: false
become: true
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
become: true
- name: Create the config directory
ansible.builtin.file:
path: /etc/webapp
state: directory
owner: webapp
group: webapp
mode: "0750"
become: true
- name: Render the application environment file
ansible.builtin.template:
src: app.env.j2
dest: /etc/webapp/app.env
owner: webapp
group: webapp
mode: "0600"
become: true
- name: Count failed SSH logins
ansible.builtin.shell:
cmd: set -o pipefail && grep 'Failed password' /var/log/secure | wc -l
executable: /bin/bash
register: failed_logins
changed_when: false
# grep exits 1 when nothing matched, which is a normal answer, and 2 when it
# could not read the file, which is the failure pipefail now surfaces.
failed_when: failed_logins.rc not in [0, 1]
become: true
- name: Fetch the monitoring agent installer
ansible.builtin.get_url:
url: https://downloads.example.net/agent.sh
dest: /root/agent-install.sh
mode: "0700"
checksum: "sha256:{{ agent_sha256 }}"
become: true
terminal
ansible-lint --profile production roles/webapp/tasks/main.yml
echo "exit=$?"
output
Passed: 0 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Profile 'production' was required, and it passed.
exit=0

Two details in there are worth slowing down for. The first is the quotes around "0600". Write mode: 600 without them and YAML hands Ansible the decimal number 600, which Ansible applies as octal 1130: a sticky bit, an owner who can only execute, and a group that can write but not read. Not the permissions you asked for, and not permissions any human would pick on purpose. The risky-octal rule, also in the safety profile, exists entirely because of that one footgun. The second is the download destination, which moved off /tmp. A world-writable directory is a bad place to drop a file you are about to run as root, because any other local account can get there first and leave something waiting for you.

Suppressions That Survive A Review

Sometimes the linter is wrong about your situation and you need a way to say so that a reviewer can audit. Inline, a # noqa: rule-id comment on the task's name line skips exactly that rule on exactly that task. Put the reason directly above it, always, because a bare noqa is indistinguishable from somebody making a red light go away at six in the evening. For anything long-lived, prefer .ansible-lint-ignore, a plain text file of path and rule-id pairs separated by a space. Violations listed there stop failing the build but keep printing, under a heading announcing that they were deliberately ignored, so they stay in front of whoever runs the linter next month.

roles/legacy-mail/tasks/main.yml
# The vendor appliance image ships dash as /bin/sh and has no bash at all,
# so pipefail is genuinely unavailable here. Vendor case 41822.
# Revisit 2026-10-01. Do not copy this pattern into new roles.
- name: Rotate the vendor mail spool # noqa: risky-shell-pipe
ansible.builtin.shell:
cmd: /opt/vendor/bin/spool --dump | /opt/vendor/bin/rotate
changed_when: true
become: true
.ansible-lint
---
# The bar this repository is held to. Anything below production is a plan, not a gate.
profile: production
exclude_paths:
- .cache/
- .venv/
- collections/ # third-party content pulled by ansible-galaxy
# Opt-in rules: no profile switches these on for you.
enable_list:
- no-log-password # password arguments on tasks that lack no_log: true
# ansible-lint's default warn_list is ["experimental", "jinja[spacing]",
# "fqcn[deep]"], and no-log-password is tagged experimental, so by default it
# would only ever print a warning. Emptying warn_list makes it fail the build.
warn_list: []
# Deliberately empty. Use .ansible-lint-ignore instead, so suppressed
# findings keep printing rather than vanishing.
skip_list: []
Two ways to switch a rule off and only one you can audit
skip_list turns a rule off across the whole repository and prints absolutely nothing, which is how a repo ends up honestly reporting "production profile passed" while carrying every risky-file-permissions finding in it. It is the smoke alarm with the battery taken out: quiet, and quiet in a way nobody notices. Entries in .ansible-lint-ignore behave differently. They stop the build failing but the findings still print as warnings, with a count, so the debt stays visible. Give every entry a reason and a review date. There is a second trap here too. Adding no-log-password to enable_list is not enough on its own, because the rule also carries the experimental tag and "experimental" sits in ansible-lint's default warn_list, so the finding shows up as a warning and your build stays green. Empty warn_list if you want it to bite. That rule catches tasks handling passwords without no_log: true, which is exactly the mistake that writes a credential into your CI log in plain text, where it is retained for as long as your build history is.

Layer Three: Molecule Runs It Twice

A molecule scenario is a dress rehearsal on a stage you are allowed to burn down afterwards. It is a directory under molecule/ holding a config file and a few playbooks. The driver says what creates the disposable machine: podman or docker containers, which are programs that run a lightweight isolated copy of a Linux system on your laptop. Those drivers ship in the separate molecule-plugins package, not in molecule itself. platforms says which images. converge.yml applies the role under test, verify.yml checks the result. Running molecule test walks the sequence in order and any failure stops it dead.

roles/webapp/molecule/default/molecule.yml
---
dependency:
name: galaxy
driver:
name: podman # from molecule-plugins[podman]
platforms:
- name: webapp-rocky9
image: docker.io/geerlingguy/docker-rockylinux9-ansible:latest
pre_build_image: true
command: /usr/sbin/init # systemd, so service state is testable
privileged: true
tmpfs:
- /run
- /tmp
provisioner:
name: ansible
verifier:
name: ansible
scenario:
test_sequence:
- destroy
- create
- converge
- idempotence
- verify
- destroy
roles/webapp/molecule/default/verify.yml
---
- name: Verify
hosts: all
become: true
gather_facts: false
tasks:
- name: Stat the application environment file
ansible.builtin.stat:
path: /etc/webapp/app.env
register: app_env
- name: Assert the secret file is readable only by its owner
ansible.builtin.assert:
that:
- app_env.stat.exists
- app_env.stat.mode | default('') == "0600"
- app_env.stat.pw_name | default('') == "webapp"
fail_msg: >-
/etc/webapp/app.env is mode {{ app_env.stat.mode | default('absent') }}
owned by {{ app_env.stat.pw_name | default('nobody') }};
expected 0600 owned by webapp.

Point the scenario at the original version of the role, the one with curl in it, and watch what happens on the second pass.

terminal
molecule test
output
INFO [default > discovery] scenario test matrix: destroy, create, converge, idempotence, verify, destroy
INFO [default > converge] Executing
PLAY RECAP *********************************************************************
webapp-rocky9 : ok=7 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
INFO [default > idempotence] Executing
PLAY RECAP *********************************************************************
webapp-rocky9 : ok=7 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
CRITICAL Idempotence test failed because of the following tasks:
* [webapp-rocky9] => webapp : Count failed SSH logins
* [webapp-rocky9] => webapp : Fetch the monitoring agent installer

The idempotence step is the single most valuable automated test an Ansible role can have, and the mechanism behind it is refreshingly dumb. Molecule runs converge.yml a second time against the same container and searches the output for the text changed= followed by anything other than zero. First pass: seven tasks succeeded, six of them did work, which is what you expect on a fresh machine. Second pass: the account, the package, the directory and the template all report ok because they were already correct, so changed drops to two. Those two are the shell task and the command task, and molecule names them. Neither module can tell whether it needed to do anything, so both report changed forever, and forever includes run two. Swap curl for get_url with a checksum and the second run skips the download entirely. The no-changed-when violations from layer two and this idempotence failure are one defect seen from two angles, which is a good sign your gates are pointed at something real.

verify.yml is where you assert the security property instead of hoping for it. The default verifier is plain Ansible, so you write ordinary tasks: stat the environment file, assert the mode is exactly 0600 and the owner is webapp, and you now own a test that goes red the day somebody deletes that mode line. If you would rather write Python, molecule.yml also accepts verifier: name: testinfra and you write pytest functions against expressions like host.file("/etc/webapp/app.env").mode. The honest limit is the container. A role that passes inside a privileged podman image with a stand-in init system is not proven against a real host running SELinux (Security-Enhanced Linux, the kernel access-control layer) in enforcing mode on your fleet's own hardened base image. Molecule raises your floor. It does not certify your ceiling.

Making The Gate An Actual Gate

.github/workflows/ansible-ci.yml
---
name: ansible-ci
on:
pull_request:
push:
branches: [main]
jobs:
lint:
name: lint
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install "ansible-core>=2.16" ansible-lint yamllint
- run: ansible-galaxy install -r requirements.yml
- run: yamllint --strict .
- run: ansible-lint --profile production --offline
molecule:
name: molecule
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install "ansible-core>=2.16" molecule "molecule-plugins[podman]"
- name: molecule test
run: molecule test
working-directory: roles/webapp
.gitlab-ci.yml
---
stages:
- verify
lint:
stage: verify
image: python:3.12-slim
script:
- pip install --quiet "ansible-core>=2.16" ansible-lint yamllint
- ansible-galaxy install -r requirements.yml
- yamllint --strict .
- ansible-lint --profile production --offline
# allow_failure defaults to false, so a non-zero exit fails the pipeline

The chain is short and every link in it is an exit code. ansible-lint returns 2 when it finds violations, yamllint returns 1, molecule returns non-zero when idempotence or verify fails. A non-zero exit fails the step, a failed step fails the job, a failed job fails the check. The --offline flag earns the ansible-galaxy line beside it: without it, ansible-lint reaches out at lint time to refresh its JSON schemas, which quietly makes your merge gate depend on somebody else's web server being reachable. Under GitHub Actions both linters notice where they are running and switch to annotation output, so violations land inline on the changed lines of the pull request rather than three thousand log lines deep. Those action versions are worth pinning to a commit hash rather than a tag, since a tag is a name somebody else can move. The outputs in this lesson come from ansible-core 2.21, ansible-lint 26.6 and molecule 26.6; older majors report the same findings with slightly different wording.

None of that blocks a merge on its own. On GitHub you have to open the branch ruleset, switch on "Require status checks to pass", and name the lint and molecule checks specifically. On GitLab it is Settings, then Merge requests, then the "Pipelines must succeed" merge check. Until somebody does that, a red pipeline is a strongly worded opinion that anyone with the merge button can overrule.

The Gate That Quietly Is Not One
Four ways a green tick means nothing. continue-on-error: true on a GitHub step makes a failed job report success. allow_failure: true does the same to a GitLab job. A trailing || true in a script swallows the exit code somewhere nobody reads. And a workflow filtered by paths never reports at all when the paths do not match, which on GitHub leaves a required check sitting at "Expected" forever and blocks every unrelated pull request until someone "temporarily" unrequires it and forgets. Verify a merge gate the same way you verify a firewall rule: try to get through it. Push a branch with a deliberate mode: 0777 in it and confirm the merge button actually goes grey.
Quick check
01A template task creates /etc/webapp/app.env with no mode set. Why does ansible-lint rate risky-file-permissions as VERY_HIGH severity?
Incorrect — when the file already exists Ansible keeps its current mode, so idempotence is not what breaks here.
Incorrect — the task succeeds and reports green, which is precisely what makes the bug dangerous.
Correct — with a typical root umask of 0022 the credentials file lands on 0644, readable by every local account on the box.
Incorrect — it is not a fixed value at all, it is whatever the remote umask produces, and that unpredictability is the actual finding.
02Your pipeline runs ansible-lint --profile safety and reports zero violations. A reviewer then finds an ansible.builtin.command task with no changed_when. Why did the linter say nothing?
Correct — safety covers risky-file-permissions and risky-shell-pipe but stops short of no-changed-when, and the unloaded rule cannot report anything.
Incorrect — the rule covers command, shell and raw alike unless changed_when, creates or removes is present.
Incorrect — rules outside the profile are unloaded entirely, so there is nothing in the output to have missed.
Incorrect — the argument style is irrelevant; only changed_when, creates or removes suppresses the finding.
03molecule test converges cleanly, then the second pass reports changed=2 and prints: CRITICAL Idempotence test failed because of the following tasks, naming both an ansible.builtin.shell task and an ansible.builtin.command task that runs curl. What is actually happening?
Incorrect — molecule reuses the same instance and replays converge.yml against it.
Incorrect — a failed task would break the converge step outright, long before the idempotence pass runs.
Incorrect — molecule never inspects the filesystem; it reads the changed count out of the play recap text.
Correct — molecule text-matches the recap for changed= followed by anything other than zero, and those two modules always report changed.

Worth doing on your first day with an existing repository: run ansible-lint --profile production against it exactly as it stands and write the number down. It will be ugly, and that is fine. Run it again with --generate-ignore and ansible-lint writes what it just found into .ansible-lint-ignore for you, which freezes today's mess as a baseline and makes tomorrow's additions fail immediately. Then delete the risky-file-permissions and no-log-password lines out of that file first, because those two are the ones that leave a readable secret sitting on a disk somebody else can reach, and let the gate hold the line while you work through the rest.

Related