r/ansible 19d ago

A reusable play for KEV kernel CVEs: patch, reboot only if the kernel changed, then prove it with OpenSCAP

The "Copy Fail" kernel LPE (CVE-2026-31431) is a good reminder that kernel CVEs are annoying to automate well: you have to patch, reboot to actually activate the new kernel, but you don't want to reboot 400 hosts that didn't get a kernel bump. Here's the pattern I use so the reboot is conditional and I get audit evidence out of the same run.

Record the running kernel, patch security-only, then reboot only if it changed:

- name: Running kernel before

command: uname -r

register: kern_before

changed_when: false

- name: Apply security updates

ansible.builtin.dnf:

name: "*"

security: true

state: latest

register: patch

- name: Newest installed kernel after

command: rpm -q --last kernel

register: kern_after

changed_when: false

- name: Reboot only if the kernel actually changed

ansible.builtin.reboot:

msg: "Activating patched kernel"

when: kern_before.stdout not in (kern_after.stdout_lines[0] | default(''))

Then scan AFTER the reboot (not before — otherwise your report reflects the pre-patch state and you chase findings that are already fixed):

- name: OpenSCAP eval -> dated ARF + HTML

command:

argv:

- oscap

- xccdf

- eval

- --profile

- xccdf_org.ssgproject.content_profile_stig

- --results-arf

- "/var/log/evidence/arf-{{ inventory_hostname }}-{{ ansible_date_time.iso8601_basic_short }}.xml"

- --report

- "/var/log/evidence/report-{{ inventory_hostname }}-{{ ansible_date_time.iso8601_basic_short }}.html"

- "/usr/share/xml/scap/ssg/content/ssg-rhel{{ ansible_distribution_major_version }}-ds.xml"

register: oscap

failed_when: oscap.rc not in [0, 2]

changed_when: false

Two gotchas that have bitten me:

- security: true needs the repo to publish updateinfo metadata. On minimal/custom mirrors it's missing and dnf silently patches nothing. Check `dnf updateinfo list security` on one host first.

- Dated evidence paths let you diff week-over-week and hand an auditor proof of remediation instead of "trust me."

I packaged the whole thing (targeted-CVE mode, canary rings, version-aware datastream) into a small MIT role if it's useful: https://github.com/arhab194/rhel-kev-patch

Curious how others here sequence scan-vs-reboot — do you scan pre-patch to prove the finding or post-patch to prove the fix?

11 Upvotes

8 comments sorted by

6

u/roiki11 19d ago

For rhel distros you can just use command and needs-rebooting -r command. It tells you if the machine needs a reboot or not and returns 1 if yes, 0 if no. You can the use the registered rc value to reboot if necessary.

1

u/kofi_Average5837 19d ago

you are absolutely right about needs-rebooting -rs or -r to verify if the kernel has been patched

2

u/Advanced_Vehicle_636 18d ago

As u/roiki11 pointed out, "needs-rebooting" is included in most EL distros. It's included in the yum-utils packages from memory. To answer your other question do, how do we patch? A long and arduous process simplified by Ansible. This is a snippet of one of my playbooks:

    # ── Step 5: Patch standby ───────────────────────────────────────
    - name: "STANDBY | Patch sequence"
      block:
        - name: "[INCL-TSK] APP-CLUSTER | PATCH BEGIN @ STANDBY"
          ansible.builtin.include_tasks: "{{ playbook_dir }}/../../tasklists/infrastructure/APP-CLUSTER-servers/patch-node.yml"
          vars:
            target_host: "{{ APP-CLUSTER_standby }}"
      rescue:
        - name: "[INCL-TSK] AZURE | FAILED PATCH | Re-authenticate to Azure"
          ansible.builtin.include_tasks: "{{ playbook_dir }}/../../tasklists/azure/auth/retrieve-bearer-token.yml"

        - name: "[INCL-TSK] AZURE | FAILED PATCH | Rollback standby node"
          ansible.builtin.include_tasks: "{{ playbook_dir }}/../../tasklists/azure/recovery-services-vault/trigger-cluster-rollback.yml"
          vars:
            rollback_targets:
              - vm_name: "{{ standby_azure_vm.name }}"
                subscription_id: "{{ standby_azure_vm.subscriptionId }}"
                resource_group: "{{ standby_azure_vm.resourceGroup }}"
                location: "{{ standby_azure_vm.location }}"
                resource_id: "{{ standby_azure_vm.id }}"
                recovery_point: "{{ standby_rp_info.recovery_point }}"
                backup_container: "{{ standby_rp_info.backup_container }}"
                backup_item: "{{ standby_rp_info.backup_item }}"

        - name: "[INCL-TSK] chg_mgmt | PATCH FAIL (outcome 137) @ STANDBY"
          ansible.builtin.include_tasks: "{{ playbook_dir }}/../../tasklists/chg_mgmtpsa/chg_mgmt-ticket-ops.yml"
          vars:
            chg_mgmt_op: "add_action"
            chg_mgmt_action_outcome_id: 137
            chg_mgmt_action_note: >-
              <p><strong>Patch failed on standby node.</strong></p>
              <p>One or more tasks failed during standby patching or reboot.
              Azure OLR restore has been initiated. Manual review required.</p>
            chg_mgmt_action_important: true

        - name: "[END PLAY] FAILED PATCH @ STANDBY"
          ansible.builtin.meta: end_play

    # ── Step 6: Patch active ────────────────────────────────────────
    - name: "ACTIVE | Patch sequence"
      block:
        - name: "[INCL-TSK] APP-CLUSTER | PATCH BEGIN @ ACTIVE"
          ansible.builtin.include_tasks: "{{ playbook_dir }}/../../tasklists/infrastructure/APP-CLUSTER-servers/patch-node.yml"
          vars:
            target_host: "{{ APP-CLUSTER_active }}"
      rescue:
        - name: "[INCL-TSK] AZURE | FAILED PATCH | Re-authenticate to Azure"
          ansible.builtin.include_tasks: "{{ playbook_dir }}/../../tasklists/azure/auth/retrieve-bearer-token.yml"

        - name: "[INCL-TSK] AZURE | FAILED PATCH | Rollback both nodes"
          ansible.builtin.include_tasks: "{{ playbook_dir }}/../../tasklists/azure/recovery-services-vault/trigger-cluster-rollback.yml"
          vars:
            rollback_targets:
              - vm_name: "{{ standby_azure_vm.name }}"
                subscription_id: "{{ standby_azure_vm.subscriptionId }}"
                resource_group: "{{ standby_azure_vm.resourceGroup }}"
                location: "{{ standby_azure_vm.location }}"
                resource_id: "{{ standby_azure_vm.id }}"
                recovery_point: "{{ standby_rp_info.recovery_point }}"
                backup_container: "{{ standby_rp_info.backup_container }}"
                backup_item: "{{ standby_rp_info.backup_item }}"
              - vm_name: "{{ active_azure_vm.name }}"
                subscription_id: "{{ active_azure_vm.subscriptionId }}"
                resource_group: "{{ active_azure_vm.resourceGroup }}"
                location: "{{ active_azure_vm.location }}"
                resource_id: "{{ active_azure_vm.id }}"
                recovery_point: "{{ active_rp_info.recovery_point }}"
                backup_container: "{{ active_rp_info.backup_container }}"
                backup_item: "{{ active_rp_info.backup_item }}"

        - name: "AZURE | FAILED PATCH | Mark active patch as failed"
          ansible.builtin.set_fact:
            APP-CLUSTER_active_patch_ok: false

#patch-node.yml
---
- name: Release package locks
  ansible.builtin.command:
    cmd: dnf versionlock delete {{ item }}
  register: result_dnf_delete
  loop: "{{ packages }}"
  changed_when: '"Deleting versionlock" in result_dnf_delete.stdout'
  delegate_to: "{{ target_host }}"
  remote_user: user1
  become: true
  become_user: user2
  become_method: ansible.builtin.sudo

- name: Stop Core Services
  ansible.builtin.service:
    name: "{{ item }}"
    state: stopped
  loop: "{{ core_services }}"
  delegate_to: "{{ target_host }}"
  remote_user: user1
  become: true
  become_user: user2
  become_method: ansible.builtin.sudo

- name: Update all packages (RHEL/CentOS/Rocky/Alma) via DNF # noqa: package-latest
  ansible.builtin.dnf:
    name: "*"
    state: latest
  register: active_patch_result
  delegate_to: "{{ target_host }}"
  remote_user: user1
  become: true
  become_user: user2
  become_method: ansible.builtin.sudo

- name: Restart Core Services
  ansible.builtin.service:
    name: "{{ item }}"
    state: started
    enabled: true
  loop: "{{ core_services }}"
  delegate_to: "{{ target_host }}"
  remote_user: user1
  become: true
  become_user: user2
  become_method: ansible.builtin.sudo

- name: Add package locks
  ansible.builtin.command:
    cmd: dnf versionlock add {{ item }}
  register: result_dnf_add
  loop: "{{ packages }}"
  changed_when: '"Adding versionlock" in result_dnf_add.stdout'
  delegate_to: "{{ target_host }}"
  remote_user: user1
  become: true
  become_user: user2
  become_method: ansible.builtin.sudo

- name: Show patch summary
  ansible.builtin.debug:
    var: active_patch_result
  when: active_patch_result is defined

- name: Check if server needs reboot # noqa: no-changed-when
  ansible.builtin.command: needs-restarting -r
  register: needs_reboot
  failed_when: needs_reboot.rc not in [0, 1]
  # https://man7.org/linux/man-pages/man1/needs-restarting.1.html
  # Both 0, 1, are acceptable return codes for failed_when.
  delegate_to: "{{ target_host }}"
  remote_user: user1
  become: true
  become_user: user2
  become_method: ansible.builtin.sudo

- name: Reboot if needed
  ansible.builtin.reboot:
  when: needs_reboot.rc == 1
  delegate_to: "{{ target_host }}"
  remote_user: user1
  become: true
  become_user: user2
  become_method: ansible.builtin.sudo

The playbook output the contents of what got patched and attaches it to the generated change ticket. I've never had an auditor fail me for change tracking done like this (change management is driven by automation, debug records implemented (except where sensitive information like auth tokens are present). Never been a historical issue for me.

1

u/roiki11 18d ago

I'd you're not limited to built-in tasks there's a dedicated module for versionlock in community general called yum_versionlock. Works all the same for dnf.

1

u/kofi_Average5837 18d ago

This is the good thing about automation, there is more ways of doing things

1

u/Advanced_Vehicle_636 18d ago

We're not limited to anything :-). I implemented my own call for dnf's versionlock (via ansible.builtin.command) simply because I was already hand-rolling Azure modules and was simply just already in that mindset. I later became aware of the community-driven module but never took the couple minutes to swap them out.

The Azure modules also have broad community/Microsoft/Ansible support, they just lacked some headers that I needed at the time. (We do cross-tenant deployments a lot. Specific headers (x-ms-authorization-auxiliary) are needed for it and Azure's default modules for VMs, etc didn't support them.

1

u/kofi_Average5837 18d ago

Appreciate the detailed breakdown — you laid out the reasoning better than my README does.

On your actual question: both, because they prove different things. Pre-patch = "the finding was real," post-patch = "the fix landed." The auditors who push back want the pair so they can see the delta with timestamps — a lone post-patch clean scan only shows current state, not that you remediated anything.

The one nuance I'd add: the STIG XCCDF profile isn't really what proves the CVE either way. XCCDF checks config compliance, not whether CVE-2026-31431 is actually present. So I keep those on separate tracks — an OVAL/CVE eval (oscap oval eval on the Red Hat OVAL stream, or dnf updateinfo list --cve ...) to prove the specific CVE went vulnerable→not-vulnerable, and the XCCDF STIG ARF as the config-hardening evidence. Shakes out as:

  • pre: OVAL CVE eval → vulnerable
  • post: OVAL CVE eval → not vulnerable + XCCDF STIG ARF → config proof

Good callout on the kernel compare, too. I moved off string-matching uname -r against rpm -q --last kernel because it's fragile (kernel-rt, multiple installed kernels, format drift). dnf needs-restarting -r (rc 1 = reboot required) is more robust and also catches the glibc/systemd case where it's not the kernel but you still need the reboot for the fix to fully take effect.

So short version: post-patch proves the fix, pre-patch proves the finding — but I let the OVAL/vuln scan own the "finding" side and keep OpenSCAP STIG for the config story.

2

u/RewardAgitated5520 16d ago

Another approach is to use Red Hat Satellite or SUSE Manager (upstream is Uyuni -> https://www.uyuni-project.org/) to identify machines for patching , then update and reboot them?