OS Hardening as Code, Episode 5
Cloud AMI Security Risks · Linux Hardening as Code · Multi-Cloud OS Hardening · Automated OpenSCAP Compliance · CI/CD Compliance Gate**
Note: the tool in this series was released as Stratum and renamed to BakeX at
v0.6.0 — same project, same license, same team. Commands below use the currentbakex
CLI. If you arrived here looking forstratumorpip install stratumoss, you’re in the
right place: github.com/invicton/bakex.
TL;DR
- A CI/CD compliance gate turns an OS hardening grade from a report into a build constraint — unhardened images fail the pipeline before they can be deployed
POST /api/pipeline/scanscores an image against apass_thresholdand aseverity_threshold, and returns apassedboolean- The endpoint returns HTTP 200 even when the gate fails.
curl -sfwill not catch it — you must parse.passed. This is the single most important detail on this page - The gate is two-dimensional: a score floor and a severity ceiling, so one critical finding blocks a release that scores 94
- GitHub Actions, GitLab CI, Jenkins, and Tekton integrations are one curl plus one
jq - The structural guarantee: an image that doesn’t pass the gate doesn’t reach the deploy job
The Problem: A Grade No One Checks Is Decoration
Pipeline without compliance gate:
Build → Test → Security scan (results to dashboard) → Deploy
What actually happens:
Build → Test → Security scan → "C grade, but we need to ship" → Deploy anyway
│
└─ Dashboard shows C grade
Nobody is paged
Deployment succeeds
A CI/CD compliance gate means the pipeline can’t continue if the grade is below threshold.
EP04 showed that automated OpenSCAP compliance gives every image a verified, reproducible grade before deployment. What it assumed is that someone checks the grade before deploying. They don’t — not under deadline pressure, not when the image has been “working fine for months,” not at 2am.
The same problem that made hardening runbooks skippable applies to compliance grades: if checking the grade is a discretionary step, it will be skipped.
A new microservice was deployed from an unhardened base image. The team had built it quickly during a sprint, used a community AMI as the base, and planned to harden it “in the next sprint.”
Three weeks later, a penetration test found it. SSH password authentication enabled. Three unnecessary services running — one of them with a known CVE. The finding: the instance had full inbound access from the VPC and was reachable from a compromised adjacent instance.
The deployment had gone through the normal CI/CD pipeline. Unit tests passed. Integration tests passed. A vulnerability scan ran. The scan produced a report that went to a dashboard. Nobody had a gate set up to fail the build if the image was unhardened.
The hardening work from the “next sprint” plan would have taken four hours. The pentest remediation took a week, plus the time to investigate what had been exposed during the three weeks the instance was running.
The CI/CD pipeline had every check except the one that would have caught the base image problem before the first deployment.
The Pipeline API
The Pipeline API is a single HTTP endpoint that takes an image ID, scans it, and returns a verdict:
curl -s -X POST https://bakex.yourdomain.com/api/pipeline/scan \
-H "X-API-Key: ${BAKEX_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"image_id": "ami-0a7f3c9e82d1b4c05",
"provider": "aws",
"region": "us-east-1",
"pass_threshold": 75.0,
"severity_threshold": "high",
"wait": true
}'
Authentication takes either X-API-Key or Authorization: Bearer; keys are created at
/settings/api-keys. With wait: true the request blocks until the scan completes — which is what
you want in CI, where a job that returns before the answer exists is worse than a slow one. There’s
a timeout_seconds (default 900) for when it doesn’t.
The response is the same shape whether you passed or failed:
{
"job_id": "7f3c9e82-4d1b-4c05-a7f3-c9e82d1b4c05",
"status": "complete",
"passed": false,
"grade": "C",
"score_pct": 72.0,
"severity_counts": { "critical": 0, "high": 2, "medium": 5, "low": 11 },
"threshold_violations": ["high"],
"pass_threshold": 75.0,
"severity_threshold": "high",
"image_id": "ami-0c9d5e3f81a2b6e07",
"sarif_url": ".../api/auditor/scan-image/7f3c9e82.../report?fmt=sarif",
"html_report_url": ".../api/auditor/scan-image/7f3c9e82.../report"
}
The detail that will silently break your gate
A failed gate still returns HTTP 200. There is no 4xx on failure — the verdict is in the
passed field, not the status code.
That means the pattern everyone reaches for first is wrong:
# WRONG — this never fails. -f only reacts to HTTP >= 400,
# and a failed gate returns 200.
curl -sf -X POST .../api/pipeline/scan -d '...' || exit 1
You have to read the body:
# RIGHT
RESULT=$(curl -s -X POST "${BAKEX_URL}/api/pipeline/scan" \
-H "X-API-Key: ${BAKEX_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"image_id\": \"${AMI_ID}\", \"pass_threshold\": 75.0, \"severity_threshold\": \"high\"}")
echo "$RESULT" | jq -r '"grade=\(.grade) score=\(.score_pct) passed=\(.passed)"'
if [ "$(echo "$RESULT" | jq -r '.passed')" != "true" ]; then
echo "Compliance gate failed — violations: $(echo "$RESULT" | jq -c '.threshold_violations')"
echo "Report: $(echo "$RESULT" | jq -r '.html_report_url')"
exit 1
fi
A gate that reports failure and exits 0 is worse than no gate, because it produces a green
pipeline and the belief that something was checked.
Two thresholds, not one
passed is the AND of two independent conditions:
passed = (score_pct >= pass_threshold) AND (no findings at or above severity_threshold)
severity_threshold: "high" means any critical or high finding fails the build regardless of
score. An image can score 94 — a comfortable A — and still fail on a single critical finding. That
is the right default: scores average away the thing that gets you breached.
GitHub Actions Integration
# .github/workflows/deploy.yml
jobs:
build-image:
runs-on: ubuntu-latest
outputs:
ami_id: ${{ steps.build.outputs.ami_id }}
steps:
- name: Build hardened AMI
id: build
run: |
AMI_ID=$(bakex build blueprints/ubuntu/22.04/cis-l1-aws.yaml --json \
| jq -r '.artifact_id')
echo "ami_id=${AMI_ID}" >> $GITHUB_OUTPUT
compliance-gate:
runs-on: ubuntu-latest
needs: build-image
steps:
- name: BakeX compliance gate
run: |
RESULT=$(curl -s -X POST ${{ vars.BAKEX_URL }}/api/pipeline/scan \
-H "X-API-Key: ${{ secrets.BAKEX_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"image_id\": \"${{ needs.build-image.outputs.ami_id }}\",
\"pass_threshold\": 75.0, \"severity_threshold\": \"high\"}")
echo "$RESULT" | jq -r '"grade=\(.grade) score=\(.score_pct)"'
# Must check .passed — the endpoint returns 200 on failure
if [ "$(echo "$RESULT" | jq -r '.passed')" != "true" ]; then
echo "::error::Compliance gate failed: $(echo "$RESULT" | jq -c '.threshold_violations')"
exit 1
fi
- name: Upload SARIF to code scanning
if: always()
run: |
curl -s -o bakex.sarif "$(echo "$RESULT" | jq -r '.sarif_url')"
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: bakex.sarif
deploy:
runs-on: ubuntu-latest
needs: [build-image, compliance-gate]
steps:
- name: Deploy to staging
run: |
aws autoscaling update-auto-scaling-group \
--auto-scaling-group-name my-asg \
--launch-template "ImageId=${{ needs.build-image.outputs.ami_id }}"
The deploy job only runs if compliance-gate passes. The AMI doesn’t reach the autoscaling group if it doesn’t meet the grade threshold.
GitLab CI Integration
# .gitlab-ci.yml
stages:
- build
- compliance
- deploy
build-image:
stage: build
script:
- |
AMI_ID=$(bakex build blueprints/ubuntu/22.04/cis-l1-aws.yaml --json \
| jq -r '.artifact_id')
echo "AMI_ID=${AMI_ID}" >> build.env
artifacts:
reports:
dotenv: build.env
compliance-gate:
stage: compliance
needs: [build-image]
script:
- |
RESULT=$(curl -s -X POST ${BAKEX_URL}/api/pipeline/scan \
-H "X-API-Key: ${BAKEX_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"image_id\": \"${AMI_ID}\", \"pass_threshold\": 75.0,
\"severity_threshold\": \"high\"}")
echo "$RESULT" | jq -r '"grade=\(.grade) score=\(.score_pct) passed=\(.passed)"'
test "$(echo "$RESULT" | jq -r '.passed')" = "true"
deploy:
stage: deploy
needs: [build-image, compliance-gate]
script:
- ./deploy.sh ${AMI_ID}
What the Failed Gate Tells You
The value of the CI/CD compliance gate is not just that it blocks bad images — it’s that the failure output tells engineers what to fix.
The response carries three things an engineer can act on immediately:
$ echo "$RESULT" | jq '{grade, score_pct, threshold_violations, severity_counts}'
{
"grade": "C",
"score_pct": 72.0,
"threshold_violations": ["high"],
"severity_counts": { "critical": 0, "high": 2, "medium": 5, "low": 11 }
}
threshold_violations names the severities that broke the gate — here, two high findings, not the
score. That distinction matters: an engineer who reads “grade C” starts a broad hardening project,
while one who reads “two high findings” goes and fixes two things.
For the rule-level detail, follow sarif_url. Pushing that SARIF into GitHub code scanning (as in
the workflow above) puts each finding on the pull request diff, which is where someone will actually
read it — a link to a dashboard in a CI log is a link nobody clicks.
Thresholds by Environment
Not all environments need the same bar, and both dimensions are per-request — so the environment
distinction lives in your pipeline, not in BakeX config:
# Production — high score floor, nothing high or above
PASS=90.0 ; SEV=high
# Staging — lower floor, still no criticals
PASS=75.0 ; SEV=critical
# Development — score only, severity effectively off
PASS=60.0 ; SEV=low
curl -s -X POST "${BAKEX_URL}/api/pipeline/scan" \
-H "X-API-Key: ${BAKEX_TOKEN}" -H "Content-Type: application/json" \
-d "{\"image_id\": \"${AMI_ID}\", \"pass_threshold\": ${PASS}, \"severity_threshold\": \"${SEV}\"}"
Note that severity_threshold gets stricter as it goes down the list: low fails on any finding
at all, critical fails only on criticals. It reads backwards the first time. Development wanting a
permissive gate wants critical, not low.
Production Gotchas
The 200-on-failure behaviour is the whole ballgame. Repeating it because it is the one thing that
turns this page from useful to harmful if missed: check .passed. Never rely on curl -f, and never
rely on the HTTP status.
Scans take minutes, and wait: true blocks. The endpoint provisions an instance from the image
and scans it. With wait: true your CI job blocks for the duration; timeout_seconds defaults to
900. Set your CI step timeout above that, or use wait: false and poll GET /api/pipeline/scan/{job_id}.
Token rotation. The API key should rotate on the same schedule as other service credentials, and
environments should use different keys — a leaked staging key must not be able to satisfy a
production gate.
The gate needs a reachable BakeX server. This is an HTTP API, not a self-contained action: the
runner must reach the BakeX instance, and that instance needs cloud credentials for the provider
whose image it is scanning.
Key Takeaways
- A CI/CD compliance gate turns a compliance grade from a dashboard metric into a pipeline constraint — the image doesn’t deploy if it doesn’t pass
POST /api/pipeline/scanis a single HTTP call that any CI/CD system can make — no agent, no plugin, no SDK required- The endpoint returns 200 even when the gate fails. Parse
.passed;curl -sf || exit 1produces a green pipeline and a false sense of security - The verdict is two-dimensional — a score floor AND a severity ceiling — so a single critical finding blocks an image that scores 94
threshold_violationstells an engineer why it failed, which is the difference between “fix two high findings” and “start a hardening project”- Push the
sarif_urlinto GitHub code scanning so findings land on the pull request, not in a CI log
What’s Next
The CI/CD compliance gate closes the final gap: even if an unhardened image gets built, it can’t deploy. EP05 is the bookmark episode — this is the point where OS hardening becomes structurally enforced rather than procedurally expected.
EP06 is the series closer. For five episodes, you’ve been using BakeX as a user. What does it look like to run it yourself — extend it with a custom provider, deploy it in your own infrastructure, or contribute a blueprint back?
BakeX is Apache 2.0. EP06 is the architecture reveal, the deployment guide, and the extension points for everything the series taught.
Next: BakeX — open-source OS hardening platform for multi-cloud infrastructure
Get EP06 in your inbox when it publishes → linuxcent.com/subscribe