Container Hardening Compliance Pipeline
Build a dual-gate CI pipeline that proves container hardening with SCAP scans.
Introduction
30 Second Summary
When someone says "this system is secure," how do you know they are right? Most security claims live as opinions until they can be proven wrong. The moment a claim becomes falsifiable is the moment it becomes trustworthy.
In this project, you will build a pipeline that hardens a container image and gates every push with automated compliance scans. You will turn "are we hardened?" from an opinion into a falsifiable, machine-checked assertion that runs on a schedule without human intervention.
What You'll Build
When you push a change, your pipeline builds an Ubuntu 22.04 image, applies hardening via Ansible remediation, then runs an OpenSCAP configuration scan and a Trivy vulnerability scan as separate gates that refuse a bad image by naming the failing rule.
By the end of this project, you'll have:
- A numbered BEFORE and AFTER scan pair proving hardening improvement in concrete counts against a stated denominator, so you can point at the exact rules that moved from fail to pass.
- A dual-gate pipeline where OpenSCAP and Trivy report independently with distinct threshold logic, so a green configuration result is never misread as a clean image.
- A weekly scheduled rebuild that re-scans without human intervention. A build-date label on every image makes a silently stopped schedule distinguishable from one that fires and finds nothing.
- An exception register where accepted findings live with a named owner, a date, and the sentence: "which POA&M item does this become, and who signed the risk."
- Secret Mission: Run a regression drill witnessed by a second person working from your runbook alone, and render a delta report with the alignment caveat inline.
Are there any prerequisites?
This project assumes you have WSL2 with Ubuntu installed, Docker Engine (not Docker Desktop) running in WSL2, a GitHub account with Actions enabled, and a Linear workspace. You should be comfortable with Dockerfiles, YAML, and CI/CD concepts.
Before We Start
Before any code or configuration, lock in exactly what you are building and why it matters that the compliance claim can fail. This clarity shapes every decision from here.
Set Up Delivery Structure and Environment
A scheduled workflow only runs from the default branch. Its YAML file must exist on main before any feature branch is created, or the schedule never fires.
Without the delivery structure in place first, work arrives as a pile of files rather than a closed set of stories with a tagged release. This step creates the repo, tickets, tooling, and skeleton that everything else hangs off.
In this step, get ready to:
- Create a private GitHub repository and a Linear project with one epic and five stories.
- Install OpenSCAP, scap-security-guide, Ansible, and Trivy in WSL2.
- Commit the skeleton workflow file and directory structure directly to main.
Create the GitHub repository and Linear project
The repository holds everything: workflow YAML, Dockerfiles, ADRs, scan results, and documentation. The Linear project tracks each piece of work as a story closed by a pull request.
- In your browser, navigate to GitHub.
- Click the + icon in the top-right corner.
- Select New repository.
- Name the repository hardening-gate.
- Set the visibility to Private.
- Check Add a README file so the repo initialises with a default branch.
- Click Create repository.
Why private?
GitHub Actions gives private repos 2,000 free minutes per month. That is more than enough for this project. Branch protection rules require a paid plan on private repos, so this project treats them as an optional extra.
- Switch to your Linear workspace.
- Create a new project named Hardening Gate.
- Inside the project, create one epic named Hardening Gate.
- Create five stories under the epic with these titles:
You should now see the epic with all five stories listed in your Linear workspace.
Don't see the epic in your project?
Make sure you created stories under the epic, not as standalone issues. In Linear, epics group related stories. If the stories appear loose, drag them into the epic or set the epic field on each.
help me organise my Linear stories under an epic
Install and verify tools in WSL2
The pipeline depends on four tools running locally: OpenSCAP for configuration scanning, scap-security-guide for hardening content, Ansible for remediation, and Trivy for vulnerability scanning.
- Install OpenSCAP, the scap-security-guide content, and Ansible by running these commands in your WSL2 terminal:
sudo apt-get update
sudo apt-get install -y openscap-scanner openscap-utils ssg-debderived ansible
What does this install?
openscap-scanner provides the oscap binary. openscap-utils adds oscap-docker for scanning container images.
ssg-debderived installs the ComplianceAsCode data streams (including ssg-ubuntu2204-ds.xml) to /usr/share/xml/scap/ssg/content/. ansible is the automation engine that applies remediation playbooks generated from scan results.
- Confirm OpenSCAP installed successfully by running:
oscap --version
You should see a version string and a list of supported SCAP specifications.
Seeing 'package not found' for ssg-debderived?
This package lives in the universe repository. If your WSL2 Ubuntu does not have universe enabled, add it first:
sudo add-apt-repository universe
sudo apt-get update
Then re-run the install command. If ssg-debderived is still unavailable on older Ubuntu versions, download the latest release from the ComplianceAsCode GitHub and extract the data stream manually.
help me install scap-security-guide on my WSL2 Ubuntu version
Next, install Trivy. This is the vulnerability scanner that runs as a separate gate from OpenSCAP.
- Add the Trivy apt repository and install Trivy by running:
sudo apt-get install -y wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt-get update
sudo apt-get install -y trivy
Why not use the trivy-action GitHub Action?
In March 2026, 76 of 77 version tags in the aquasecurity/trivy-action repository were force-pushed to credential-stealing malware (CVE-2026-33634). This project installs the Trivy CLI binary directly rather than relying on the compromised action's tag-based dependency chain.
- Check the installed Trivy version by running:
trivy --version
What version do you expect to see? The project requires v0.71.0 or higher.
✔️ I see version 0.71.0 or higher
Your Trivy install is ready. The apt repo installed the latest release, which satisfies the project minimum.
ⓧ I see an older version
You may have a stale Trivy package from a previous source. Remove it and reinstall from the Aqua Security repo:
sudo apt-get remove -y trivy
sudo apt-get update
sudo apt-get install -y trivy
Run trivy --version again to confirm it now shows 0.71.0 or higher.
ⓧ Command not found
The apt repository was not added correctly. Verify the source list exists:
cat /etc/apt/sources.list.d/trivy.list
You should see a line containing aquasecurity.github.io/trivy-repo/deb generic main. If the file is empty or missing, re-run the repository setup commands above.
- Confirm Docker Engine is available in WSL2 by running:
docker info
You should see output including Server Version and an Operating System line referencing your WSL2 distribution.
Seeing 'Cannot connect to the Docker daemon'?
Docker Engine must be running inside WSL2, not Docker Desktop. Start the service:
sudo service docker start
If that fails, confirm Docker Engine (not Docker Desktop) is installed. Docker Desktop requires a paid subscription for government entities regardless of organisation size.
help me get Docker Engine running in my WSL2 environment
Commit the skeleton workflow and directory structure to main
GitHub Actions only runs scheduled workflows from the newest commit on the default branch. The workflow YAML must land on main before any story branches are created.
- Clone the repository to your WSL2 environment by running:
cd ~/Desktop
git clone https://github.com/[[GITHUB_USER="your-github-username"]]/hardening-gate.git
cd hardening-gate
- Create the workflow directory and skeleton workflow file by running:
mkdir -p .github/workflows
cat > .github/workflows/hardening-gate.yml << 'EOF'
on:
push:
schedule:
- cron: '0 3 * * 1'
jobs:
placeholder:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
EOF
What does this workflow do?
The on: push trigger fires on every commit to any branch. The on: schedule trigger fires every Monday at 03:00 UTC via the cron expression 0 3 * * 1.
The placeholder job just checks out the code. Later steps replace it with the real build and scan jobs. This file must exist on main first. If you create it on a feature branch, the schedule never fires until that branch merges.
- Create the project directory structure by running:
mkdir -p adr docs exceptions remediation reports
touch adr/.gitkeep docs/.gitkeep exceptions/.gitkeep remediation/.gitkeep reports/.gitkeep
- Verify the directories were created by running:
ls
You should see adr, docs, exceptions, remediation, reports, and README.md in the output.
- Replace the auto-generated README with the fidelity caveat by running:
cat > README.md << 'EOF'
# Hardening Gate
> **Fidelity caveat**: These profiles are aligned to the DISA STIG benchmark via ComplianceAsCode/content. They are not the issuing authority's own content. A green scan is evidence of drift control, not a compliance verdict.
A falsifiable, scheduled configuration compliance pipeline for a hardened Ubuntu 22.04 container image.
Images carry a `org.opencontainers.image.created` label. A silently stopped schedule is distinguishable from a clean run by checking this label age.
EOF
Why does the fidelity caveat come first?
The caveat is not a disclaimer buried at the bottom. It is the thing that makes the rest trustworthy. Anyone who reads this repo needs to understand immediately that a green scan is evidence of drift control, not a compliance verdict issued by the authority of record.
- Create the CODEOWNERS file and commit everything to main by running:
cat > CODEOWNERS << 'EOF'
docs/ @owner
EOF
git add -A
git commit -m "feat: skeleton workflow, directory structure, and README"
git push origin main
Push rejected or permission denied?
If git push fails with an authentication error, set up a personal access token or SSH key for GitHub. The HTTPS method prompts for a token, not your password.
If the push is rejected because the remote has commits your local does not (the README created during repo setup), pull first:
git pull origin main --rebase
git push origin main
help me authenticate git push to my private GitHub repository
Before you check GitHub, what do you expect to see on the repository page? Think about which files and folders should now appear on the main branch.
- Return to your hardening-gate repository in your browser.
- Confirm .github/workflows/hardening-gate.yml, the five directories, README.md, and CODEOWNERS all appear on the main branch.
You should see the fidelity caveat rendered as a blockquote at the top of the README, and the cron schedule line visible in the workflow file.
✔️ Awesome, I've got everything!
Great. Double check you see the schedule line cron: '0 3 * * 1' in the workflow YAML on GitHub. That line is what makes the weekly rebuild fire.
ⓧ I'd like to double check the full code
Here is the exact content of each file committed to main. Compare yours against these:
.github/workflows/hardening-gate.yml
on:
push:
schedule:
- cron: '0 3 * * 1'
jobs:
placeholder:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
README.md
# Hardening Gate
> **Fidelity caveat**: These profiles are aligned to the DISA STIG benchmark via ComplianceAsCode/content. They are not the issuing authority's own content. A green scan is evidence of drift control, not a compliance verdict.
A falsifiable, scheduled configuration compliance pipeline for a hardened Ubuntu 22.04 container image.
Images carry a `org.opencontainers.image.created` label. A silently stopped schedule is distinguishable from a clean run by checking this label age.
CODEOWNERS
docs/ @owner
Your delivery structure is locked in. You have a private repo with the schedule wired, a Linear epic tracking five stories, and all four tools verified locally. Next up, you will write the decision records that shape every design choice in the pipeline.
Write Decision Records and Topology
Your repository has a skeleton workflow and directory structure, but no stated positions about the design choices behind the pipeline.
The four decisions you write in this step determine the shape of everything that follows. Writing them first means the build implements a stated position rather than an accidental one. Each decision record travels with the work as the rationale someone else reads when they inherit the repo.
In this step, get ready to:
- Write four Architecture Decision Records (ADRs) covering content source, gate behaviour, schedule, and vulnerability scanning.
- Draw a topology diagram in Mermaid showing all pipeline edges.
- Merge the branch via PR and close the Linear story.
Create the story branch and write ADR-001
An Architecture Decision Record captures a design choice, the alternative it beat, and the trigger that would reverse it. ADR-001 is the most consequential: it states that this pipeline uses community-maintained ComplianceAsCode profiles, not the DISA-published content and SCC scanner used for the assessment of record.
- Create a feature branch for story 1 by running this command:
git checkout -b story/01-adrs-topology
- Create adr/ADR-001-hardening-content.md by running:
cat << 'EOF' > adr/ADR-001-hardening-content.md
# ADR-001: Hardening Content
## Status
Accepted
## Decision
Use ComplianceAsCode STIG profile aligned to the DISA STIG benchmark, not the DISA-published SCAP content and SCC scanner used for the assessment of record.
## Alternative Beaten
DISA's own content + SCC scanner.
## Tradeoff Accepted
Losing authoritative status, gaining automation and drift control.
## Consequence
A green scan here is evidence of intent, not a compliance verdict.
## Fidelity Limit
This pipeline uses community-maintained profiles. Results are not equivalent to an authoritative SCC assessment.
## Reversal Trigger
Program achieves SCC license or DISA publishes machine-readable content the pipeline can consume directly.
EOF
What does this ADR establish?
ADR-001 names the fidelity limit up front. The DISA STIG benchmark is the standard, but the tooling that issues authoritative verdicts (SCC) is licensed and proprietary.
By choosing ComplianceAsCode's community-maintained profiles, you gain automation and drift control. The tradeoff is that a green result here is evidence of intent, not a formal compliance verdict. The reversal trigger tells your successor exactly when this decision should be revisited.
- Confirm the file was created by running:
cat adr/ADR-001-hardening-content.md
You should see the full ADR content printed to your terminal, starting with # ADR-001: Hardening Content.
Seeing "No such file or directory"?
Make sure you are in the repository root directory (the folder containing adr/). Run ls adr/ to confirm the directory exists.
If heredocs fail on WSL2 with a path error, run unset TMPDIR and try again. A TMPDIR pointing to /mnt/c can break heredoc temp files.
Help me troubleshoot this
Write the remaining decision records
The next three ADRs follow the same structure. Each names one design choice that shapes the pipeline behaviour.
- Create adr/ADR-002-scan-as-gate.md by running:
cat << 'EOF' > adr/ADR-002-scan-as-gate.md
# ADR-002: Scan as Gate, Not Report
## Status
Accepted
## Decision
Fail the build on scan findings above threshold rather than publishing a report and continuing.
## Alternative Beaten
Publish scan results as artifacts only; never block the build.
## Tradeoff Accepted
Builds will fail on findings the team thinks are fine, forcing the exception register into existence.
## Consequence
Every accepted finding requires a named owner, a date, and an expiry.
## Reversal Trigger
Organization policy changes to require advisory-only scanning.
EOF
Why gate instead of report?
A report-only model lets findings pile up without forcing anyone to own them. By failing the build, ADR-002 forces the exception register into existence. Every accepted finding gets a named owner and an expiry date.
- Create adr/ADR-003-schedule-as-control.md by running:
cat << 'EOF' > adr/ADR-003-schedule-as-control.md
# ADR-003: Schedule as Control
## Status
Accepted
## Decision
Rebuild and re-scan on a weekly cron schedule without human intervention.
## Alternative Beaten
Scan only on push; rely on humans to trigger rebuilds.
## Tradeoff Accepted
A silently stopped schedule looks identical to a clean run without the build-date label check.
## Consequence
The image build-date label is the detection mechanism for schedule failure.
## Decay Mode Named
An image that sits untouched for two weeks while four advisories land.
## Reversal Trigger
Organization adopts a dedicated image monitoring service.
EOF
- Confirm both files exist by running:
ls adr/ADR-00*.md
You should see ADR-001-hardening-content.md, ADR-002-scan-as-gate.md, and ADR-003-schedule-as-control.md listed.
- Create adr/ADR-004-separate-vulnerability-scanner.md by running:
cat << 'EOF' > adr/ADR-004-separate-vulnerability-scanner.md
# ADR-004: Separate Vulnerability Scanner
## Status
Accepted
## Decision
Use Trivy as a separate gate for CVE/vulnerability scanning rather than OpenSCAP.
## Alternative Beaten
Use OpenSCAP for both configuration compliance and vulnerability scanning.
## Hard Fact
CVE/CVSS/CVRF modules were removed from OpenSCAP in 2024.
## Tradeoff Accepted
Two separate tools to maintain and two separate gate thresholds to tune.
## Consequence
A green configuration result is never misread as a clean image; each gate reports independently.
## Reversal Trigger
OpenSCAP re-integrates vulnerability scanning modules.
EOF
Why a separate vulnerability scanner?
OpenSCAP removed its CVE/CVSS/CVRF modules in 2024. The image-cve and container-cve subcommands in oscap-docker relied on OVAL data that is no longer maintained.
This makes Trivy architecturally required, not optional. ADR-004 cites this as a hard fact rather than a preference, so a future reader knows the decision was forced.
- Verify all four ADRs exist by running:
ls adr/
You should see four .md files alongside the original .gitkeep.
Draw the topology diagram and merge
The topology diagram shows every edge in the pipeline. It uses Mermaid syntax so it renders directly in GitHub.
- Create docs/topology.md by running:
cat << 'EOF' > docs/topology.md
# Pipeline Topology
```mermaid
graph LR
A[Dockerfile] --> B[Build Unhardened Image]
B --> C[Baseline OpenSCAP Scan]
C --> D[Generate Ansible Remediation]
D --> E[Apply Remediation - Dockerfile Stage]
E --> F[Build Hardened Image]
F --> G[OpenSCAP Gate Scan]
F --> H[Trivy Vulnerability Scan]
G --> I{Config Gate}
H --> J{Vuln Gate}
I --> K[Artifact Publish]
J --> K
L[Scheduled Trigger - weekly] --> B
M[Push Trigger] --> B
```
Edges marked with * are built in this project iteration.
EOF
What does this diagram show?
The graph shows the full pipeline flow: from a Dockerfile through an unhardened build, baseline scan, Ansible remediation, hardened rebuild, and then the dual-gate scan before artifact publish.
Two triggers feed into the build: push (every commit) and a weekly cron schedule. The gates are separate: one for configuration compliance (OpenSCAP), one for vulnerabilities (Trivy).
- Verify the topology file was created by previewing the first few lines:
head -5 docs/topology.md
You should see # Pipeline Topology followed by the opening Mermaid fence.
- Stage and commit all new files by running:
git add adr/ docs/topology.md
git commit -m "ADRs 001-004 and topology diagram"
- Push the branch to GitHub by running:
git push -u origin story/01-adrs-topology
Before you open the PR, what do you expect to see in the file list on GitHub?
- Switch to your browser where you are signed into GitHub.
- Click the banner that says story/01-adrs-topology had recent pushes, then click Compare & pull request.
- Set the PR title to ADRs and topology diagram.
- In the description, reference the Linear story so it auto-closes. Add Closes [story identifier] using your story's ID from Linear.
- Click Create pull request.
- Review the Files changed tab to confirm five new files appear (four ADRs and docs/topology.md).
- Click Merge pull request, then Confirm merge.
Why merge without branch protection?
Branch protection rules require a paid GitHub plan for private repositories. On the Free plan, you can still use PRs for review and traceability without enforced checks.
- In Linear, confirm that story 1 (ADRs and topology) has moved to Done. If it did not auto-close, drag it to Done manually.
- Back in your WSL2 terminal, switch to main and pull the merge by running:
git checkout main
git pull
You should see the merge commit and all five new files reflected in your local main branch.
PR not showing the expected files?
Run git status to make sure all files were committed. If any ADR is missing, recreate it with the heredoc command and commit again.
Help me debug my PR
Your design decisions are now committed and visible to anyone who inherits this repo. Next up, you will build the unhardened container image and run the baseline scan that establishes the numbered BEFORE.
Build Unhardened Image and Run Baseline Scan
Your decision records explain what you're building and why. Now you need a numbered starting point to measure against.
In this step, you'll build an unhardened container image and run an OpenSCAP configuration scan against it. The resulting counts become your BEFORE evidence. Every improvement you make in the next step is measured against this baseline.
In this step, get ready to:
- Write a Dockerfile that creates an unhardened Ubuntu 22.04 image with openssh-server installed.
- Run an OpenSCAP baseline scan and record the summary counts.
- Commit the BEFORE results and merge via PR that closes the Linear story.
Create the story branch and write the Dockerfile
The image needs something for the STIG profile rules to evaluate. Installing openssh-server gives the scanner real SSH configuration to check against.
- Switch to your WSL2 terminal and navigate to your hardening-gate repository directory.
- Create the story branch by running:
git checkout -b story/02-baseline
- Create a file called Dockerfile in the repository root by running:
touch Dockerfile
- Open Dockerfile in your editor and add the following content:
FROM ubuntu:22.04
# Accept build date as argument for the image label
ARG BUILD_DATE
LABEL org.opencontainers.image.created=${BUILD_DATE}
# Install openssh-server to give STIG rules something to evaluate
RUN apt-get update && apt-get install -y openssh-server && rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN useradd -m -s /bin/bash appuser
USER appuser
What does this Dockerfile do?
- FROM ubuntu:22.04 pins the base image so scans are reproducible across builds.
- ARG BUILD_DATE and the LABEL stamp each image with its build time. A silently stopped weekly schedule is detectable by checking this label's age.
- openssh-server installs real SSH configuration files that the STIG profile can evaluate. Without it, most SSH-related rules would return not-applicable.
- appuser demonstrates the non-root user pattern. The image does not run as root by default.
- Save Dockerfile.
- Build the image with the build-date label by running:
docker build --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t hardening-gate:baseline .
You should see Docker pull the Ubuntu 22.04 layer, install openssh-server, create the user, and finish with a success message.
- Verify the image exists and carries its label by running:
docker inspect --format '{{index .Config.Labels "org.opencontainers.image.created"}}' hardening-gate:baseline
You should see an ISO 8601 timestamp matching today's date.
Build failed or no timestamp shown?
- If Docker reports permission denied, confirm your user is in the docker group with groups $USER. If not, run sudo usermod -aG docker $USER and start a new shell.
- If the inspect command returns an empty string, check that the --build-arg flag is present in your build command.
Still stuck? Help me troubleshoot my Docker build for the baseline image.
Run the baseline OpenSCAP scan
This scan produces the numbered BEFORE evidence. Every count it reports becomes the denominator you'll measure improvement against after hardening.
Before you run the scan, predict: do you expect many rules to pass or fail on an unhardened image with default SSH configuration?
- Run the OpenSCAP scan against your baseline image by running:
sudo oscap-docker image hardening-gate:baseline xccdf eval \
--profile xccdf_org.ssgproject.content_profile_stig \
--results-arf reports/baseline-arf.xml \
--report reports/baseline-scan.html \
/usr/share/xml/scap/ssg/content/ssg-ubuntu2204-ds.xml
What does this command do?
- oscap-docker image mounts the container image filesystem so OpenSCAP can inspect its configuration files.
- --profile xccdf_org.ssgproject.content_profile_stig selects the STIG profile from the ComplianceAsCode project.
- --results-arf writes the machine-readable ARF XML that the remediation step will consume to generate an Ansible playbook.
- --report writes a human-readable HTML report you can open in a browser.
- The datastream file ssg-ubuntu2204-ds.xml contains all the rules, checks, and fixes for Ubuntu 22.04 from the scap-security-guide package.
The command requires sudo because oscap-docker mounts the image filesystem to inspect it, which needs root privileges.
The command exits with code 2. This is expected. Exit code 2 means at least one rule failed, which is exactly what you'd expect from an unhardened image.
Look at the summary output printed at the end of the scan. You'll see counts for rules that passed, failed, and returned not-applicable.
- Note down the four numbers from the summary: total rules evaluated, rules passed, rules failed, and rules not-applicable. You'll record these in the documentation file next.
Why are so many rules not-applicable?
Rules returning not-applicable require a running kernel, systemd, or full host context that does not exist inside a container image scan. These rules cannot pass or fail in this environment.
The effective denominator for measuring improvement is (Total Evaluated minus Not Applicable). Only the rules that can actually evaluate in container context count toward your improvement measurement.
🙋♀️ Scan failed before producing results?
- If you see a permission error, confirm you included sudo at the start of the command.
- If the datastream file is not found, verify the scap-security-guide package is installed with ls /usr/share/xml/scap/ssg/content/ssg-ubuntu2204-ds.xml.
- If all rules return not-applicable, confirm you are scanning an Ubuntu image from an Ubuntu WSL2 host, not a RHEL or CentOS image.
Need more help? Help me troubleshoot my oscap-docker baseline scan.
Document results and merge the PR
The scan results exist as files, but they need context. A summary document states what was measured, against what, and explains the not-applicable count so someone reading this later understands the denominator.
- Create the baseline results document by running:
touch docs/baseline-results.md
- Open docs/baseline-results.md and add the following content, replacing the TBD placeholders with the actual numbers from your scan output:
# Baseline Scan Results (BEFORE)
Profile: `xccdf_org.ssgproject.content_profile_stig`
Datastream: `ssg-ubuntu2204-ds.xml`
Image: `hardening-gate:baseline` (ubuntu:22.04 + openssh-server)
## Summary Counts
| Result | Count |
|--------|-------|
| Pass | TBD |
| Fail | TBD |
| Not Applicable | TBD |
| Total Evaluated | TBD |
## Not-Applicable Explanation
Rules returning not-applicable are those that require a running kernel, systemd, or full host context not present in a container image scan. The effective denominator for improvement measurement is (Total Evaluated - Not Applicable).
This document is the numbered BEFORE evidence pair. The AFTER results are in `reports/hardened-scan.html`.
Why record TBD as the placeholder here?
Your actual counts depend on the exact version of scap-security-guide installed and the datastream release. Replace each TBD with the numbers you noted from your scan output. The important thing is that these numbers are recorded so the AFTER scan can show a measurable reduction.
- Save docs/baseline-results.md with your actual numbers filled in.
- Confirm both report files exist by running:
ls reports/baseline-arf.xml reports/baseline-scan.html
You should see both filenames listed without errors.
- Stage and commit all new files by running:
git add Dockerfile reports/baseline-arf.xml reports/baseline-scan.html docs/baseline-results.md
git commit -m "feat: add unhardened baseline image and BEFORE scan results"
- Push the branch to GitHub by running:
git push -u origin story/02-baseline
- Open your hardening-gate repository in your browser.
- Click the Compare & pull request banner that appears for your newly pushed branch.
- In the PR description, write: Closes story 2: Baseline image and scan. BEFORE counts recorded.
- Click Create pull request.
- Click Merge pull request, then Confirm merge.
- Switch to your Linear workspace and move story 2 (baseline image and scan) to Done.
✔️ Awesome, I've got everything!
Great. Confirm you've saved both files and your PR is merged on GitHub.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM ubuntu:22.04
ARG BUILD_DATE
LABEL org.opencontainers.image.created=${BUILD_DATE}
RUN apt-get update && apt-get install -y openssh-server && rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash appuser
USER appuser
Here is the complete docs/baseline-results.md (with your actual numbers replacing TBD):
# Baseline Scan Results (BEFORE)
Profile: `xccdf_org.ssgproject.content_profile_stig`
Datastream: `ssg-ubuntu2204-ds.xml`
Image: `hardening-gate:baseline` (ubuntu:22.04 + openssh-server)
## Summary Counts
| Result | Count |
|--------|-------|
| Pass | TBD |
| Fail | TBD |
| Not Applicable | TBD |
| Total Evaluated | TBD |
## Not-Applicable Explanation
Rules returning not-applicable are those that require a running kernel, systemd, or full host context not present in a container image scan. The effective denominator for improvement measurement is (Total Evaluated - Not Applicable).
This document is the numbered BEFORE evidence pair. The AFTER results are in `reports/hardened-scan.html`.
Before you move on, confirm three things: the PR is merged to main, the Linear story is marked Done, and you can state the fail count from your scan as a number.
You now have a numbered starting point. The BEFORE counts are committed and the image carries its build date. Next up, you'll generate remediation from these failures, apply it, and prove the count drops.
Apply Remediation, Wire Dual Gate, and Ship Documentation
Your baseline scan produced numbered failures. You know what is broken and by how many rules. The question now is whether remediation can reduce that count against the same denominator.
This step generates Ansible remediation targeting only the failures from your baseline ARF, applies it inside the Dockerfile, re-scans to prove improvement, wires the dual-gate pipeline, and ships the documentation set. The AFTER count must be lower than the BEFORE count.
In this step, get ready to:
- Generate and apply Ansible remediation from baseline scan failures.
- Wire the dual-gate pipeline with exception register and threshold logic.
- Ship the documentation set and tag the release.
Generate remediation and prove improvement
You cannot claim improvement without a numbered AFTER to compare against the BEFORE. The remediation playbook must come from the scan results themselves so it targets only what actually failed.
- Create and switch to the story/03-remediation branch by running:
git checkout -b story/03-remediation
- Generate an Ansible playbook from your baseline ARF results by running:
oscap xccdf generate fix --fix-type ansible --result-id "" reports/baseline-arf.xml > remediation/playbook.yml
What does this command do?
The --fix-type ansible flag tells OpenSCAP to output Ansible tasks instead of bash scripts. The --result-id "" selects the default test result from the ARF file.
Because the command reads your actual scan results, it generates tasks ONLY for rules that failed. This scopes remediation to real findings, not the entire profile.
- Open remediation/playbook.yml and review it. Remove any tasks that require a running kernel or systemd, since those are inapplicable in a container image context.
- Confirm the file exists and contains Ansible tasks by running:
head -20 remediation/playbook.yml
You should see a YAML playbook header with - hosts: localhost and become: yes followed by tasks addressing your failed rules.
Empty playbook or error?
If the file is empty, verify reports/baseline-arf.xml exists and is a valid ARF file from your previous step. Re-run the baseline scan if needed.
help me debug why oscap xccdf generate fix produced an empty playbook
Next you need a tailoring file so both BEFORE and AFTER scans evaluate the same set of rules.
- Create remediation/tailoring.xml that deselects rules inapplicable in container context. You can generate it with autotailor from the openscap-utils package, or write it manually as XCCDF tailoring XML. The file must deselect rules that returned not-applicable in your baseline so both scans share the same denominator.
- Update the Dockerfile to add a multi-stage build that applies remediation. Replace your current Dockerfile content with this:
FROM ubuntu:22.04 AS base
ARG BUILD_DATE
LABEL org.opencontainers.image.created=${BUILD_DATE}
RUN apt-get update && apt-get install -y openssh-server ansible python3 && rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash appuser
# Hardening stage
COPY remediation/playbook.yml /tmp/playbook.yml
COPY remediation/tailoring.xml /tmp/tailoring.xml
RUN ansible-playbook /tmp/playbook.yml -c local
USER appuser
What changed in this Dockerfile?
- Added AS base to name the build stage. The CI workflow uses --target base to build the unhardened image for baseline comparison.
- Installed ansible and python3 so the remediation playbook can execute inside the build.
- The COPY and RUN ansible-playbook lines apply hardening during the image build.
- Build the hardened image by running:
docker build --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t hardening-gate:hardened .
- Re-scan the hardened image with the tailoring file to produce your AFTER results by running:
oscap-docker image hardening-gate:hardened xccdf eval --profile xccdf_org.ssgproject.content_profile_stig --tailoring-file remediation/tailoring.xml --results-arf reports/hardened-arf.xml --report reports/hardened-scan.html /usr/share/xml/scap/ssg/content/ssg-ubuntu2204-ds.xml || true
Before you look at the results, predict: do you expect the fail count to be lower, higher, or the same as your baseline?
Open reports/hardened-scan.html in a browser. The fail count should be measurably lower than your baseline. The pass count should be higher. The denominator (total evaluated minus not-applicable) should remain the same because the tailoring file ensures both scans evaluate the same rules.
Fail count not lower?
If counts are unchanged, verify the playbook tasks actually executed during the build. Check the Docker build output for Ansible task summaries. If tasks were skipped, they may reference systemd services that do not exist in the container.
help me figure out why my hardened scan shows the same fail count as baseline
- Commit all remediation artifacts, open a PR, merge, and close Linear story 3 by running:
git add remediation/ Dockerfile reports/hardened-arf.xml reports/hardened-scan.html
git commit -m "feat: apply Ansible remediation from baseline, re-scan proves improvement"
git push -u origin story/03-remediation
- Open a PR in GitHub from story/03-remediation to main. Merge it and close Linear story 3.
Wire the dual gate and exception register
The pipeline needs two independent gates: one for configuration compliance, one for vulnerabilities. Each has its own threshold logic. A green configuration result must never be misread as a clean image.
- Create and switch to the story/04-pipeline branch by running:
git checkout main && git pull
git checkout -b story/04-pipeline
- Create the exception register at exceptions/accepted-findings.yml with the following content:
# Exception Register
# Fields: rule_id, severity, owner, accepted_date, expiry_date, rationale
# Gate script suppresses only findings with expiry_date within 90 days of today.
exceptions: []
Why an exception register?
ADR-002 decided that builds fail on findings above threshold. That decision forces exceptions into named ownership. Every accepted finding requires a rule_id, severity, owner, accepted_date, expiry_date, and rationale. Expired exceptions are treated as active findings.
The cyber lead's question is always: which POA&M item does this become, and who signed the risk.
- Create the gate script at scripts/check-findings.sh with this structure:
#!/bin/bash
# Parses OpenSCAP ARF results and applies exception register logic.
# Fails on high-severity findings.
# Fails on medium-severity findings not covered by a non-expired exception.
# Exceptions must have expiry_date within 90 days of today to be valid.
set -e
What does this script need to do?
The script takes two arguments: the ARF results file and the exception register YAML. It must parse the ARF XML for failing rules with their severity, then cross-reference against the exceptions list. Any high-severity finding causes exit 1. Any medium-severity finding not covered by a non-expired exception also causes exit 1.
Implement the parsing logic using tools available in the CI runner (xmllint, grep, yq). Use AI assistance to draft the implementation. The design above is the contract. The implementation details are yours.
- Make the script executable by running:
chmod +x scripts/check-findings.sh
- Now replace the workflow file .github/workflows/hardening-gate.yml with the full dual-gate pipeline. Start with the triggers and baseline job:
on:
push:
schedule:
- cron: '0 3 * * 1'
jobs:
baseline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build unhardened image
run: docker build --target base --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t hardening-gate:baseline .
- name: Run OpenSCAP baseline scan
run: oscap-docker image hardening-gate:baseline xccdf eval --profile xccdf_org.ssgproject.content_profile_stig --tailoring-file remediation/tailoring.xml --results-arf reports/baseline-arf.xml --report reports/baseline-scan.html /usr/share/xml/scap/ssg/content/ssg-ubuntu2204-ds.xml || true
- uses: actions/upload-artifact@v4
with:
name: baseline-reports
path: reports/baseline-*
What does the baseline job do?
- Builds only the base stage (unhardened) using --target base.
- Runs the OpenSCAP scan with the tailoring file for a consistent denominator.
- The || true prevents the scan's non-zero exit code from failing the step. The baseline is informational, not a gate.
- Uploads scan reports as artifacts for later comparison.
- Add the hardened job below the baseline job in the same file:
hardened:
runs-on: ubuntu-latest
needs: baseline
steps:
- uses: actions/checkout@v4
- name: Build hardened image
run: docker build --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t hardening-gate:hardened .
- name: Run OpenSCAP gate scan
run: oscap-docker image hardening-gate:hardened xccdf eval --profile xccdf_org.ssgproject.content_profile_stig --tailoring-file remediation/tailoring.xml --results-arf reports/hardened-arf.xml --report reports/hardened-scan.html /usr/share/xml/scap/ssg/content/ssg-ubuntu2204-ds.xml || true
- name: Config gate check
run: bash scripts/check-findings.sh reports/hardened-arf.xml exceptions/accepted-findings.yml
- name: Trivy vulnerability scan
run: trivy image --exit-code 1 --ignore-unfixed hardening-gate:hardened
- uses: actions/upload-artifact@v4
with:
name: hardened-reports
path: reports/hardened-*
How do the two gates work independently?
- The config gate runs check-findings.sh which fails on high findings and on medium findings not covered by exceptions.
- The Trivy vulnerability gate uses --exit-code 1 to fail the step when fixable vulnerabilities exist. The --ignore-unfixed flag means it only fails on findings that have a fix available.
- Each gate reports independently. A green config result never implies a clean vulnerability scan.
💡 Why Trivy is invoked directly, not via trivy-action
On March 19, 2026, 76 of 77 version tags in aquasecurity/trivy-action were force-pushed to credential-stealing malware (CVE-2026-33634). Workflows that referenced tags silently executed attacker code. This project installs the Trivy CLI binary directly to avoid the action's dependency chain.
- Push the branch and verify the pipeline runs by running:
git add .github/workflows/hardening-gate.yml scripts/ exceptions/
git commit -m "feat: dual-gate pipeline with exception register"
git push -u origin story/04-pipeline
Before you check GitHub Actions, predict: do you expect the pipeline to pass or fail on this first push?
Open the repository on GitHub and navigate to the **Actions** tab. You should see the workflow trigger with both the baseline and hardened jobs. If your gate script and exception register are correctly implemented, the pipeline should pass green.
Pipeline failing on the config gate?
If the config gate fails, the scan found high or uncovered medium findings. Add the failing rule to exceptions/accepted-findings.yml with all required fields (rule_id, severity, owner, accepted_date, expiry_date, rationale) and push again. This is ADR-002 working as designed.
help me debug why my hardening gate pipeline is failing on the config check step
Ship documentation and tag the release
The documentation ships in the same PR as the pipeline work. Every page carries a reviewed date and every claim traces to a file, command, or named source.
- Create the documentation set by creating the following files in the docs/ directory. Use AI assistance to draft each one from the templates below.
- Create docs/architecture.md describing the pipeline topology at a glance with a reference to the Mermaid diagram.
- Create docs/quickstart.md with four numbered steps: clone, ensure Docker, push to main, download artifacts.
- Create docs/operations.md covering schedule health monitoring via the build-date label and the exception register contract.
- Create docs/runbook.md addressing three failure modes: gate fails on acceptable finding, schedule stops silently, scan returns all not-applicable.
- Create docs/decisions.md as an index linking to all four ADRs.
- Create docs/agents.md recording the AI agent architecture used and human review checkpoints.
- Create docs/configuration.md as a reference table for all pipeline inputs (BUILD_DATE, profile, datastream, exception register, cron schedule).
- Create docs/limits.md with the alignment caveat and note about not-applicable rules.
- Create docs/source-map.md mapping sections to files with reviewed dates and claim traceability.
- Verify all documentation files exist by running:
ls docs/
You should see: agents.md, architecture.md, baseline-results.md, configuration.md, decisions.md, limits.md, operations.md, quickstart.md, runbook.md, source-map.md, topology.md listed.
- Commit the documentation, push, open PR, and merge by running:
git add docs/
git commit -m "docs: ship documentation set with source map and claim traceability"
git push
- Merge the PR from story/04-pipeline to main on GitHub. Close Linear stories 4 and 5.
- Tag the release by running:
git checkout main && git pull
git tag v1.0.0
git push origin v1.0.0
- In Linear, close all remaining stories and mark the epic as **Completed**.
Verify the release: on GitHub, navigate to your repository's **Actions** tab. The push of the tag should trigger the workflow. Both the baseline and hardened jobs should complete with the pipeline green.
Tagged release not triggering the workflow?
The workflow triggers on push which includes tags. If you do not see a run, confirm the workflow file is on main and that on: push does not have a branches filter excluding tags.
help me figure out why my GitHub Actions workflow did not trigger on my tag push
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and that your release tag is pushed.
ⓧ I'd like to double check the full code
Scroll up and confirm each code block is present in your repository. Your key files should contain:
- Dockerfile with AS base stage, ansible install, COPY playbook, and RUN ansible-playbook.
- .github/workflows/hardening-gate.yml with baseline and hardened jobs, cron schedule, both gates, and artifact uploads.
- scripts/check-findings.sh with the gate logic header and your implementation.
- exceptions/accepted-findings.yml with the required fields structure.
- All 9 documentation files in docs/.
Your pipeline is live and the claim is now falsifiable. Anyone can push a weakened setting and watch the gate refuse the image. Next up, you will do exactly that in the Secret Mission.
Secret mission
Regression Drill and Handover Test
Your pipeline gates are live, but can you prove they catch a real regression? Deliberately weaken a single file permission, watch the gate refuse the image by rule ID, then hand the runbook to someone who did not build this and treat every question they ask as a document defect.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on local tools and GitHub's free tier. There are no ongoing cloud costs, but the scheduled workflow consumes GitHub Actions minutes from your private repo's 2,000 min/month allowance.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Private GitHub repository (hardening-gate workflow, Dockerfile, documentation set).
- Linear project (one epic, five stories, all marked Completed).
- Local project files (cloned repository in WSL2).
Keep everything running
No action needed. Choose this if you plan to continue iterating on the hardening pipeline or use it as a reference for future projects.
- The repository rebuilds and re-scans every Monday at 03:00 UTC via the cron schedule.
- Monitor your GitHub Actions minutes usage. Private repos on the free plan get 2,000 minutes per month. A silently stopped schedule (exhausted minutes) is indistinguishable from a clean run unless you check the image's org.opencontainers.image.created label age.
- OpenSCAP, scap-security-guide, Ansible, and Trivy remain installed in your WSL2 environment.
- The Linear project stays in Completed status and will auto-archive after the configured inactivity period.
Pause - I'll come back to this later
Disable the scheduled workflow to stop consuming Actions minutes while keeping all files intact for later.
- Open your repository on GitHub and click the Actions tab.
- In the left sidebar, click the hardening-gate workflow.
- Click the kebab menu (three dots) and select Disable workflow.
- To re-enable later, return to the same page and click Enable workflow.
What stays in place?
Your repository, branches, tags, scan reports, and all documentation remain untouched. Only the scheduled trigger stops firing. Push-triggered runs also stop while the workflow is disabled.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Delete the GitHub repository: open the repository on GitHub, click Settings, scroll to the Danger Zone section, and click Delete this repository. Confirm the deletion when prompted.
- Delete the Linear project: open the project in Linear, click the three-dot menu next to the project name, and select Delete. The project moves to Recently Deleted and is permanently removed after 30 days.
- Remove local project files by running this command in WSL2:
rm -rf ~/Desktop/hardening-gate
- Confirm the folder is gone:
ls ~/Desktop/hardening-gate
You should see a "No such file or directory" message confirming the deletion.
Nice Work!
Nice Work!
You just shipped a falsifiable configuration compliance pipeline from scratch. Your hardened Ubuntu 22.04 container image is now machine-verified on every push and every Monday at 03:00 UTC, with numbered evidence that proves the improvement.
You've learned how to:
- Ship a hardened container image with STIG-aligned configuration applied via Ansible remediation generated from scan results, delivered through a branch-per-story workflow to a tagged release with numbered BEFORE and AFTER evidence proving improvement against a stated denominator.
- Wire a dual-gate pipeline where OpenSCAP and Trivy report independently with distinct threshold logic and a shared exception register that forces every accepted finding into named ownership with a date and expiry.
- Commit a weekly scheduled rebuild with a build-date label that makes a silently stopped schedule distinguishable from a clean run, backed by four ADRs, a topology diagram, and a documentation set where the fidelity caveat travels with every report.
- Secret Mission: Run a regression drill witnessed by a second person working from the runbook alone, file their questions as document defects, and render the delta report as a GitHub Actions step summary with the alignment caveat inline.
Ready to quiz yourself?