Most "golden image" pipelines assume a cloud builder or a beefy Linux box. This one runs end-to-end on a Windows laptop, inside WSL2 with nested KVM, and produces a CentOS 9 Stream QCOW2 image that is:
CIS Level 1 hardened (via the officialansible-lockdownrole)
Pre-loaded with PingAccess 8.3.5 on a JRE 17 runtime
Shipped with a signed SBOM and VEX attestations so a Trivy scan tells you what's actually exploitable - not just what's theoretically vulnerable
Everything is driven by a single ./build.sh. Here's how it fits together, and the WSL2-specific gotchas I had to solve along the way.
The shape of the pipeline
The build is split into two Packer sources that run sequentially, because the second one boots the artifact the first one produced.
CentOS 9 Stream ISO
│ Build 1 - "harden"
▼
kickstart install ──► CIS Level 1 hardening (Ansible) ──► base SBOM
│
▼
output/base/centos9-base.qcow2
│ Build 2 - "pingaccess"
▼
JRE 17 + PingAccess ──► VEX/SBOM artifacts ──► app SBOM
│
▼
output/app/centos9-app.qcow2
│ "sbom" target
▼
Trivy scan (VEX-suppressed) + cosign-signed VEX + CVE report
build.sh exposes each stage as a target so you can iterate on just the part you're changing:
./build.sh harden # ISO → CIS-hardened base image
./build.sh pingaccess # base image → Java + PingAccess + security artifacts
./build.sh sbom # scan + sign an existing app image
./build.sh # all of the above, end to end
Build 1: a CIS-compliant base image from a kickstart
The foundation is an automated CentOS 9 Stream install. The interesting part is the partition layout - CIS expects several mount points to be isolated filesystems with restrictive flags, so the kickstart lays them out as separate LVM logical volumes:
part /boot xfs 512 MiB
volgroup centos_vg
lv_root / 6 GiB xfs
lv_tmp /tmp 512 MiB xfs nodev,nosuid,noexec
lv_var /var 2 GiB xfs
lv_varlog /var/log 512 MiB xfs
lv_audit /var/log/audit 512 MiB xfs
lv_home /home 512 MiB xfs nodev,nosuid
lv_swap swap 1 GiB
SELinux is left enforcing, the firewall is enabled with only SSH open, and unused network services are stripped out.
Hardening itself is delegated to the maintained ansible-lockdown.rhel9_cis role rather than a hand-rolled script. A few overrides keep it Packer-friendly:
rhel9cis_level_1: true
rhel9cis_level_2: false
os_check: false # role asserts != CentOS; Stream == RHEL 9 controls
skip_reboot: true # Packer powers off after all provisioners
rhel9cis_rule_5_2_2: false # "Defaults use_pty" hangs sudo under pipelining
rhel9cis_sudoers_exclude_nopasswd_list: [packer] # keep build sudo mid-play
That last group is the kind of thing you only learn by watching a build hang at 80%: CIS rule 5.2.2 adds Defaults use_pty to sudoers, which deadlocks sudo when Ansible pipelining is on and no PTY is allocated.
Build 2: PingAccess, the noexec-/tmp problem, and a lean image
Build 2 boots the hardened base over SSH (key-based now - password auth is gone) and layers on Java 17 and PingAccess.
The first surprise: /tmp is 512 MiB and mounted noexec thanks to the CIS layout. That breaks two assumptions at once - the PingAccess archive doesn't fit, and you can't execute staged binaries from there. The fix is to stage and extract under /opt (which lives on the 6 GiB root LV):
- name: "PA | Extract PingAccess archive"
ansible.builtin.unarchive:
src: /opt/pa_staging.zip
dest: /opt/pa_extract/
remote_src: true
To keep the image lean, the role deletes the PingAccess SDK samples after install (nobody runs the Maven sample projects in production), and the final Packer step does dnf clean all + fstrim -av. Combined with disk_discard = "unmap" and disk_compression = true, the TRIM is the single biggest lever on final image size.
The interesting part: shipping security context inside the image
A plain vulnerability scan of any real product image is mostly noise - hundreds of CVEs in transitive dependencies that aren't reachable, aren't loaded, or are already mitigated. The goal here was an image that carries its own answer to "is this actually exploitable?"
SBOMs generated inside the VM
Rather than scanning the QCOW2 from the host (which gives you the host's view, and made my base and app SBOMs come out identical at one point), SBOM generation runs inside each VM during its build via a small generate_sbom Ansible role. It downloads syft, scans /, writes the SBOM to /opt/security/sbom/, then fetches a copy back to the host and removes the binary:
- name: "SBOM | Generate SBOM"
ansible.builtin.shell:
cmd: >
syft / --output spdx-json
--file {{ sbom_output_dir }}/{{ sbom_filename }}
{% for e in sbom_excludes %}--exclude {{ e }} {% endfor %}
- name: "SBOM | Fetch SBOM to build host"
ansible.builtin.fetch:
src: "{{ sbom_output_dir }}/{{ sbom_filename }}"
dest: "{{ sbom_host_dest }}"
flat: true
Now you get a true base SBOM (OS only) and a true app SBOM (OS + JRE + PingAccess), and both are embedded in the image for later audit.
SOCIAL SHARE CARD GENERATOR