Skip to main content

5 ways to harden a new system with Ansible

Use Ansible to harden newly-deployed servers for consistent configurations with security in mind.
Image
Hardening security

Photo by Burst from Pexels

This article discusses some common hardening tasks and how they can be accomplished in a repeatable way with Ansible. I provide a sample Ansible playbook at the end of the article that you can run against your systems on first boot to harden them. You can extend this playbook to include additional hardening tasks as you discover them.

Introduction

Every sysadmin has a checklist of security-related tasks that they execute against each new system. This checklist is a great habit, as it ensures that new servers in your environment meet a set of minimum security requirements. However, doing this work manually is also slow and error-prone. It's easy to encounter configuration inconsistencies due to the manual series of steps, and there's no way to address configuration drift without manually re-running your checklist.

Implementing your security workflow in Ansible is a great way to automate some "low hanging fruit" in your environment. This article discusses some of the basic steps that I take to harden a new system, and it shows you how to implement them using Ansible. The automations in this article aren't earth-shattering; they're probably little things that you already do to secure your systems. However, automating these tasks ensures that your infrastructure is configured in a consistent, repeatable way across your environment.

Before I get started, I'll quickly show you my environment so that you can follow along. I'm using a simple directory structure with a single Ansible playbook (main.yml) and one host in my inventory:

$ tree
.
├── files
│   └── etc
│       ├── issue
│       ├── motd
│       ├── ssh
│       │   └── sshd_config
│       └── sudoers.d
│           └── admin
├── inventory.ini
└── main.yml

4 directories, 6 files

$ cat inventory.ini
nyc1-webserver-1.example.com

Patch software

The first thing that I like to do on a newly-imaged system is ensure that its software is fully patched. An incredible amount of attack surface can be eliminated by merely staying vigilant about patching. Ansible makes this easy. The task below fully patches all of your packages, and can easily be run regularly using cron (or Ansible Tower, in a larger environment):

- name: Perform full patching
  package:
    name: '*'
    state: latest

Secure remote access

Once my host is patched, I quickly secure remote access via SSH. First, I create a local user with sudo permissions so that I can disable remote login by the root user. The tasks below are just an example, and you will probably want to customize them to meet your needs:

- name: Add admin group
  group:
    name: admin
    state: present

- name: Add local user
  user:
    name: admin
    group: admin
    shell: /bin/bash
    home: /home/admin
    create_home: yes
    state: present

- name: Add SSH public key for user
  authorized_key:
    user: admin
    key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
    state: present

- name: Add sudoer rule for local user
  copy:
    dest: /etc/sudoers.d/admin
    src: etc/sudoers.d/admin
    owner: root
    group: root
    mode: 0440
    validate: /usr/sbin/visudo -csf %s

These tasks add a local "admin" user and group, add an SSH public key for the user, and add a sudo rule for the admin user that permits passwordless sudo. The SSH key will be the same public key for the user that is locally executing the Ansible playbook, as shown by the file lookup call.

There are many ways to customize sshd to meet your unique security goals, but my fellow sudoer Nate Lager's post is a great start. Specifically, his post discusses configuration directives (such as disabling password authentication) in the sshd configuration file. Ansible can be used to create a known-good configuration for all of the servers in your environment. This goes a long way toward enforcing a consistent security posture for one of your most critical services, especially if you remain vigilant about regularly executing Ansible across your hosts.

Ansible's copy module is used to lay down this configuration file on remote systems:

- name: Add hardened SSH config
  copy:
    dest: /etc/ssh/sshd_config
    src: etc/ssh/sshd_config
    owner: root
    group: root
    mode: 0600
  notify: Reload SSH

The SSH configuration file that I use is below. It's mostly a default file with some additional tuning, such as disabling password authentication and barring root login. You will likely develop your own configuration file best practices to meet your organization's needs, such as only permitting a specific set of approved ciphers.

$ cat files/etc/ssh/sshd_config
HostKey /etc/ssh/ssh_host_rsa_key
HostKey /etc/ssh/ssh_host_ecdsa_key
HostKey /etc/ssh/ssh_host_ed25519_key
SyslogFacility AUTHPRIV
AuthorizedKeysFile	.ssh/authorized_keys
PasswordAuthentication no
ChallengeResponseAuthentication no
GSSAPIAuthentication yes
GSSAPICleanupCredentials no
UsePAM yes
X11Forwarding no
Banner /etc/issue.net
AcceptEnv LANG LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES
AcceptEnv LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT
AcceptEnv LC_IDENTIFICATION LC_ALL LANGUAGE
AcceptEnv XMODIFIERS
Subsystem	sftp	/usr/libexec/openssh/sftp-server
PermitRootLogin no

PermitRootLogin no

Finally, notice that I use a handler to trigger a refresh of the sshd service. That handler is found in the handlers section of the playbook. For more info about handlers and what they do, see the docs.

handlers:
  - name: Reload SSH
    service:
      name: sshd
      state: reloaded

Once you have SSH locked down from a configuration perspective, it's time to restrict SSH to only permitted IP addresses. If you're using the default firewalld, this is easily done by moving the SSH service to the internal zone and establishing a list of allowed networks. Ansible makes this simple with the firewalld module. Here is an example:

- name: Add SSH port to internal zone
  firewalld:
    zone: internal
    service: ssh
    state: enabled
    immediate: yes
    permanent: yes

- name: Add permitted networks to internal zone
  firewalld:
    zone: internal
    source: "{{ item }}"
    state: enabled
    immediate: yes
    permanent: yes
  with_items: "{{ allowed_ssh_networks }}"

- name: Drop ssh from the public zone
  firewalld:
    zone: public
    service: ssh
    state: disabled
    immediate: yes
    permanent: yes

This task uses a with_items loop with the allowed_ssh_networks variable. That variable is defined in the vars section of the playbook:

vars:
  allowed_ssh_networks:
    - 192.168.122.0/24
    - 10.10.10.0/24

In this case, the module restricts access to the internal zone to the 10.10.10.0/24 and 192.168.122.0/24 networks. The immediate and permanent parameters tell the module to apply the rules immediately and add them to firewalld's permanent rules to persist on reboot. You can confirm the configuration by looking at the generated rules. For more information about firewalld and its configuration, check out Enable Sysadmin's firewalld post.

[root@nyc1-webserver-1 ~]# firewall-cmd --list-all --zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources: 
  services: dhcpv6-client
  ports: 
  protocols: 
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules: 
	
[root@nyc1-webserver-1 ~]# firewall-cmd --list-all --zone=internal
internal (active)
  target: default
  icmp-block-inversion: no
  interfaces: 
  sources: 192.168.122.0/24 10.10.10.0/24
  services: dhcpv6-client mdns samba-client ssh
  ports: 
  protocols: 
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules: 

Disable unused software and services

With access to SSH locked down, I turn my attention to removing unused software and disabling unnecessary services. Ansible's package and service modules are up to the challenge, as seen below:

- name: Remove undesirable packages
  package:
    name: "{{ unnecessary_software }}"
    state: absent

- name: Stop and disable unnecessary services
  service:
    name: "{{ item }}"
    state: stopped
    enabled: no
  with_items: "{{ unnecessary_services }}"
  ignore_errors: yes

There are two things to notice about these tasks. First, I again use variables (and loops, for the service task) to keep my playbook short and reusable. These variables have been added to the vars section of the playbook:

vars:
  allowed_ssh_networks:
    - 192.168.122.0/24
    - 10.10.10.0/24
  unnecessary_services:
    - postfix
    - telnet
  unnecessary_software:
    - tcpdump
    - nmap-ncat
    - wpa_supplicant

Second, I set ignore_errors to yes for the service task. This prevents the playbook run from failing if a service doesn't exist on the target machine (which is acceptable). For example, many servers probably don't have the telnet service. But if they do, I want to make sure it's disabled. By ignoring errors, I can successfully run this playbook against a host even if it doesn't have the telnet service installed. If you're concerned with this approach, you could write more complex conditionals to only try disabling a service if it already exists on the remote system.

Security policy improvements

You've now performed several very concrete tasks that improve your newly-provisioned system's security posture. The last thing that I like to do on my systems isn't a technical security improvement—it's a process and legal-oriented control. I always ensure that my systems have a login banner and message of the day to alert users about my acceptable use policy. This ensures that there is no doubt that access to a system is restricted: it's printed right in the login banner and MOTD.

Installing these files is a perfect activity for Ansible's file module since they rarely change across all of my servers. For more discussion about when to use the file or copy module, check out my recent article.

- name: Set a message of the day
  copy:
    dest: /etc/motd
    src: etc/motd
    owner: root
    group: root
    mode: 0644

- name: Set a login banner
  copy:
    dest: "{{ item }}"
    src: etc/issue
    owner: root
    group: root
    mode: 0644
  with_items:
    - /etc/issue
    - /etc/issue.net

The contents of the actual files are a simple, terse explanation of authorized access and acceptable use. You will want to work with your company's legal team to ensure that the messaging is right for your organization.

$ cat files/etc/issue
Use of this system is restricted to authorized users only, and all use is subjected to an acceptable use policy.

IF YOU ARE NOT AUTHORIZED TO USE THIS SYSTEM, DISCONNECT NOW.

$ cat files/etc/motd
THIS SYSTEM IS FOR AUTHORIZED USE ONLY

By using this system, you agree to be bound by all policies found at https://wiki.example.com/acceptable_use_policy.html. Improper use of this system is subject to civil and legal penalties.

All activities are logged and monitored.

Wrap up

This article shows you how to bring together several server-hardening tasks into a single Ansible playbook to run against new systems (and continue running against existing systems) to improve your security posture. By leveraging some of the tips that you learned and combining them with your own security runbook, you can use Ansible to ensure quick, easy, and repeatable security configurations across your environment. This is a great way for the Ansible novice to begin automating a common workflow. It's also a perfect way for the experienced Ansible user to leverage their favorite tool to improve their organization's security posture.

The full playbook used in this article is included below:

---

- hosts: all
  vars:
    allowed_ssh_networks:
      - 192.168.122.0/24
      - 10.10.10.0/24
    unnecessary_services:
      - postfix
      - telnet
    unnecessary_software:
      - tcpdump
      - nmap-ncat
      - wpa_supplicant
  tasks:
    - name: Perform full patching
      package:
        name: '*'
        state: latest

    - name: Add admin group
      group:
        name: admin
        state: present

    - name: Add local user
      user:
        name: admin
        group: admin
        shell: /bin/bash
        home: /home/admin
        create_home: yes
        state: present

    - name: Add SSH public key for user
      authorized_key:
        user: admin
        key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
        state: present

    - name: Add sudoer rule for local user
      copy:
        dest: /etc/sudoers.d/admin
        src: etc/sudoers.d/admin
        owner: root
        group: root
        mode: 0440
        validate: /usr/sbin/visudo -csf %s

    - name: Add hardened SSH config
      copy:
        dest: /etc/ssh/sshd_config
        src: etc/ssh/sshd_config
        owner: root
        group: root
        mode: 0600
      notify: Reload SSH

    - name: Add SSH port to internal zone
      firewalld:
        zone: internal
        service: ssh
        state: enabled
        immediate: yes
        permanent: yes

    - name: Add permitted networks to internal zone
      firewalld:
        zone: internal
        source: "{{ item }}"
        state: enabled
        immediate: yes
        permanent: yes
      with_items: "{{ allowed_ssh_networks }}"

    - name: Drop ssh from the public zone
      firewalld:
        zone: public
        service: ssh
        state: disabled
        immediate: yes
        permanent: yes

    - name: Remove undesirable packages
      package:
        name: "{{ unnecessary_software }}"
        state: absent

    - name: Stop and disable unnecessary services
      service:
        name: "{{ item }}"
        state: stopped
        enabled: no
      with_items: "{{ unnecessary_services }}"
      ignore_errors: yes

    - name: Set a message of the day
      copy:
        dest: /etc/motd
        src: etc/motd
        owner: root
        group: root
        mode: 0644

    - name: Set a login banner
      copy:
        dest: "{{ item }}"
        src: etc/issue
        owner: root
        group: root
        mode: 0644
      with_items:
        - /etc/issue
        - /etc/issue.net

  handlers:
    - name: Reload SSH
      service:
        name: sshd
        state: reloaded

[ Need more on Ansible? Take a free technical overview course from Red Hat. Ansible Essentials: Simplicity in Automation Technical Overview. ]

Topics:   Ansible   Security  
Author’s photo

Anthony Critelli

Anthony Critelli is a Linux systems engineer with interests in automation, containerization, tracing, and performance. He started his professional career as a network engineer and eventually made the switch to the Linux systems side of IT. He holds a B.S. and an M.S. More about me

Try Red Hat Enterprise Linux

Download it at no charge from the Red Hat Developer program.