Skip to main content

Build a DevSecOps Pipeline with Security Gates

Build a four-gate GitHub Actions pipeline with Gitleaks, Trivy and Checkov that blocks secrets, vulnerable dependencies and insecure IaC.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project builds a GitHub Actions pipeline with four security gates: Gitleaks for secrets, Trivy for vulnerable dependencies and the container image, and Checkov for Terraform and Dockerfile misconfigurations. A gate is a job that fails the run when it finds a problem, so insecure code can never reach the image that gets published.

You will plant three problems on purpose (a vulnerable dependency, an insecure Terraform file, a root container), watch each gate catch them, fix them, and then prove the gates still block when a problem comes back. The result is a repository you can show in a portfolio and walk through in an interview.

flowchart TD A[Push or pull request] --> B[Gate 1: Secrets] A --> C[Gate 2: Dependencies] A --> D[Gate 3: IaC] B --> E[Gate 4: Build and scan image] C --> E D --> E E --> F{On main?} F -->|Yes| G[Publish scanned image] F -->|No| H[Stop, nothing published]

Why gates instead of reports

A scan that only writes a report is easy to ignore. A security gate is different: the pipeline stops, and the person who pushed the change sees the exact finding while the code is still fresh in their head. Three ideas make the gates trustworthy:

  • Scan before you publish. The image is built once, scanned as a file, and only that same image is pushed to the registry. Nothing is rebuilt after the scan.
  • Run independent gates in parallel. Secrets, dependencies and IaC do not depend on each other, so they run side by side and the developer sees every problem in one run.
  • Pin the scanners. Every tool is installed at an exact version and the downloads are checked against a SHA-256 checksum, so a compromised release or a surprise update cannot change what your gates do.

What you will build

Gate Tool What it blocks
1. Secrets Gitleaks API keys and tokens anywhere in git history
2. Dependencies Trivy (filesystem) HIGH and CRITICAL CVEs that have a fix
3. IaC Checkov Open SSH, public S3 buckets, root containers
4. Image Trivy (image) HIGH and CRITICAL CVEs in the built image

What you need first

  • A GitHub account and a new empty repository (private is fine)
  • Git, Docker and Python 3.12 on a Linux or macOS machine (or WSL)
  • Basic comfort with a terminal and with reading YAML
Tip

The version numbers in this project were current when it was written. Newer releases usually work the same way. If you upgrade, change the version and the checksum together.

Security

The "before" files in this project are deliberately insecure. Keep them in a throwaway repository and never run terraform apply on them. This project never applies Terraform; it only scans it.

Milestone 1: Create the App and Plant Three Problems

This milestone creates a tiny Flask service with one unit test, then adds three problems for the gates to find later: an old dependency, an insecure Terraform file and a Dockerfile that runs as root. A pipeline that has nothing to catch proves nothing, so you build the failures first.

Create the repository layout

Bash
mkdir gates-demo && cd gates-demo
git init -b main
mkdir -p app tests terraform scripts .github/workflows
touch app/__init__.py tests/__init__.py

Write the application and its test

Python
# app/main.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/health")
def health():
return jsonify(status="ok")
@app.get("/")
def index():
return jsonify(service="gates-demo", message="hello from a gated pipeline")
Python
# tests/test_app.py
import unittest
from app.main import app
class HealthTest(unittest.TestCase):
def test_health_returns_ok(self):
client = app.test_client()
response = client.get("/health")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json(), {"status": "ok"})
if __name__ == "__main__":
unittest.main()

Plant problem 1: an old dependency

The pinned urllib3 below is an old release with known, fixed vulnerabilities. Pinning an exact version matters: a scanner can only judge a version it can read.

Bash
# requirements.txt
Flask==3.1.3
urllib3==1.26.4

Plant problem 2: an insecure Terraform file

This file describes a bucket that allows public access and a security group that opens SSH to the whole internet. You will scan it, never apply it.

HCL
# terraform/main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "eu-west-1"
}
resource "aws_s3_bucket" "assets" {
bucket = "gates-demo-assets-example"
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
resource "aws_security_group" "app" {
name = "gates-demo-app"
description = "Application security group"
ingress {
description = "SSH from anywhere"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}

Plant problem 3: a container that runs as root

This Dockerfile never creates a user, so the process runs as root, and it has no health check.

Dockerfile
# Dockerfile
FROM python:3.12-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8080
CMD ["python", "-m", "flask", "--app", "app.main", "run", "--host", "0.0.0.0", "--port", "8080"]

Run the test and make the first commit

Bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python3 -m unittest discover -s tests -t .
printf '.venv/\n__pycache__/\n*.pyc\n' > .gitignore
git add . && git commit -m "Add demo app with three planted problems"
Command What it does
git init -b main Creates the repository with main as the first branch
unittest discover -s tests -t . Finds and runs every test under tests/
Common Mistake

Forgetting .gitignore before the first commit. If .venv/ gets committed, scanners later report hundreds of findings from files you do not own. Add the ignore file first, and if you already committed the folder, run git rm -r --cached .venv.

Milestone 2: Install Pinned Scanners and Run Them Locally

Running the scanners on your own machine first shows you what a finding looks like before it appears in a failed pipeline. You will install the tools at exact versions, and the install script verifies each download against a SHA-256 checksum, so you get the same tool every time.

Why pin versions and verify checksums

Security tools run with access to your source code and your CI credentials, so a tampered release is a serious risk. Installing "latest" means a change you did not review. Pinning a version and checking its hash means the only way the tool changes is a commit that you can see in review.

Write the install script

Bash
#!/usr/bin/env bash
# scripts/install-scanners.sh
# Usage: scripts/install-scanners.sh gitleaks trivy
set -euo pipefail
GITLEAKS_VERSION="8.30.1"
GITLEAKS_SHA256="551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"
TRIVY_VERSION="0.75.0"
TRIVY_SHA256="c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f"
BIN_DIR="${HOME}/.local/bin"
TMP_DIR="$(mktemp -d)"
mkdir -p "${BIN_DIR}"
install_tool() {
local name="$1" url="$2" sha="$3"
curl -fsSL -o "${TMP_DIR}/${name}.tar.gz" "${url}"
echo "${sha} ${TMP_DIR}/${name}.tar.gz" | sha256sum -c -
tar -xzf "${TMP_DIR}/${name}.tar.gz" -C "${TMP_DIR}" "${name}"
install -m 0755 "${TMP_DIR}/${name}" "${BIN_DIR}/${name}"
}
for tool in "$@"; do
case "${tool}" in
gitleaks)
install_tool gitleaks \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
"${GITLEAKS_SHA256}" ;;
trivy)
install_tool trivy \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
"${TRIVY_SHA256}" ;;
*) echo "unknown tool: ${tool}" >&2; exit 1 ;;
esac
done
if [ -n "${GITHUB_PATH:-}" ]; then
echo "${BIN_DIR}" >> "${GITHUB_PATH}"
fi
echo "installed: $*"
Bash
chmod +x scripts/install-scanners.sh
scripts/install-scanners.sh gitleaks trivy
export PATH="$HOME/.local/bin:$PATH"
gitleaks version && trivy --version
pip install checkov==3.3.26
Tip

The script targets Linux on x86-64, which matches GitHub's ubuntu-24.04 runners. On macOS, use brew install gitleaks trivy for local runs. The pipeline still uses the script, so CI stays pinned.

Gate 1: secrets with Gitleaks

Gitleaks reads your git history and flags text that looks like a credential. It scans commits, not just the files in your working folder.

Bash
gitleaks git --no-banner --redact .

Expected result for now: a line saying no leaks were found. Your first commit has no secret in it. You will break that on purpose in Milestone 5.

Gate 2: dependencies with Trivy

Trivy compares every package version it finds against a vulnerability database. Software composition analysis (checking the libraries you depend on) is the name for this kind of scan.

Bash
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .

Expected result: a table that lists urllib3 with the installed version 1.26.4, the CVE IDs and the version that fixes each one, and the command exits with code 1. The exact list changes as the database updates, so do not expect identical rows.

Gate 3: infrastructure as code with Checkov

Checkov reads Terraform and Dockerfiles and checks them against rules for known misconfigurations. You pick the rules to enforce with --check, which keeps the gate focused and predictable.

Bash
checkov -d . --framework terraform,dockerfile \
--check CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56,CKV_AWS_24,CKV_AWS_21,CKV2_AWS_6,CKV_DOCKER_2,CKV_DOCKER_3 \
--compact --quiet

Expected result: failures that include CKV_AWS_24 (SSH open to the world), CKV_AWS_53 to CKV_AWS_56 (public access block settings), CKV_AWS_21 (no versioning), CKV_DOCKER_3 (no container user) and CKV_DOCKER_2 (no health check).

Check ID What it enforces
CKV_AWS_53 to CKV_AWS_56 The four S3 public access block settings are on
CKV_AWS_24 No security group allows SSH from 0.0.0.0/0
CKV_AWS_21 S3 versioning is enabled
CKV2_AWS_6 The bucket has a public access block
CKV_DOCKER_2 The image defines a HEALTHCHECK
CKV_DOCKER_3 The image runs as a non-root user
Common Mistake

Running Checkov with no --check list and then wondering why it reports a dozen findings you never planted, such as logging, replication and lifecycle rules. Those are valid best practices but not the point of this project. Scope the gate to rules you can explain and fix.

Milestone 3: Fix the Findings and Get a Clean Run

This milestone fixes every problem from Milestone 1 so all three local scans pass. Fixing a finding teaches more than reading it, because you see which exact line the scanner cared about.

Fix the dependency

Upgrade urllib3 to a release that contains the fixes. For a real project, run your tests after any upgrade, because newer major versions can change behaviour.

Bash
# requirements.txt
Flask==3.1.3
urllib3==2.8.0
Bash
pip install -r requirements.txt
python3 -m unittest discover -s tests -t .
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .
echo "exit code: $?"

Expected result: the tests pass and Trivy exits with code 0.

Fix the Terraform

Turn on all four public access block settings, replace the world-open SSH rule with HTTPS from inside your network range, and enable versioning.

HCL
# terraform/main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "eu-west-1"
}
resource "aws_s3_bucket" "assets" {
bucket = "gates-demo-assets-example"
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_security_group" "app" {
name = "gates-demo-app"
description = "Application security group"
ingress {
description = "HTTPS from inside the VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
}
}

Fix the Dockerfile

Create a system user and switch to it before the container starts, and add a health check so the platform can tell when the service is unhealthy.

Dockerfile
# Dockerfile
FROM python:3.12-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& useradd --system --no-create-home appuser
COPY app ./app
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD ["python", "-c", "import urllib.request as u; u.urlopen('http://127.0.0.1:8080/health')"]
CMD ["python", "-m", "flask", "--app", "app.main", "run", "--host", "0.0.0.0", "--port", "8080"]
Tip

Flask's built-in server is fine for a demo. For a real service, put a production server such as Gunicorn in front of your app.

Re-run the IaC gate and the image scan

Bash
checkov -d . --framework terraform,dockerfile \
--check CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56,CKV_AWS_24,CKV_AWS_21,CKV2_AWS_6,CKV_DOCKER_2,CKV_DOCKER_3 \
--compact --quiet
echo "checkov exit code: $?"
docker build -t gates-demo:local .
docker save gates-demo:local -o image.tar
trivy image --input image.tar --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1
echo "image scan exit code: $?"
rm image.tar

Expected result: Checkov reports every check as passed and exits with 0. The image scan should exit with 0 as well. If it reports an operating system package, that is the image gate doing its job: rebuild with docker build --pull to get a newer base image.

Commit the clean state

Bash
git add . && git commit -m "Fix dependency, Terraform and Dockerfile findings"
Common Mistake

Silencing a finding with a skip comment or by dropping the check from the list so the pipeline goes green. A skip needs a written reason and an owner. If you cannot explain why the risk is acceptable, fix the code instead.

Milestone 4: Build the Four-Gate GitHub Actions Workflow

This milestone turns the local commands into a pipeline. Three gates (secrets, dependencies, IaC) run in parallel, the image gate waits for all of them, and a summary job reports the result. The same scanners and the same pinned versions run in CI as on your laptop.

How the jobs connect

The key line is needs. A job with needs: [secrets, dependencies, iac] only starts when all three succeed. That single line is what makes the earlier gates actually block the image build.

Create the workflow

YAML
# .github/workflows/pipeline.yml
name: Security gates
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: gates-${{ github.ref }}
cancel-in-progress: true
jobs:
secrets:
name: "Gate 1: Secrets"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: scripts/install-scanners.sh gitleaks
- name: Scan full git history
run: gitleaks git --no-banner --redact --exit-code 1 --report-format json --report-path gitleaks-report.json .
- uses: actions/upload-artifact@v4
if: always()
with:
name: gitleaks-report
path: gitleaks-report.json
if-no-files-found: ignore
dependencies:
name: "Gate 2: Dependencies"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies and run tests
run: |
pip install -r requirements.txt
python -m unittest discover -s tests -t .
- name: Install Trivy
run: scripts/install-scanners.sh trivy
- name: Scan dependencies
run: trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .
iac:
name: "Gate 3: IaC"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Checkov
run: pip install checkov==3.3.26
- name: Scan Terraform and Dockerfile
run: |
checkov -d . --framework terraform,dockerfile \
--check CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56,CKV_AWS_24,CKV_AWS_21,CKV2_AWS_6,CKV_DOCKER_2,CKV_DOCKER_3 \
--compact --quiet
image:
name: "Gate 4: Image"
runs-on: ubuntu-24.04
needs: [secrets, dependencies, iac]
steps:
- uses: actions/checkout@v4
- name: Build the image once
run: |
docker build -t gates-demo:${{ github.sha }} .
docker save gates-demo:${{ github.sha }} -o image.tar
- name: Install Trivy
run: scripts/install-scanners.sh trivy
- name: Scan the saved image
run: trivy image --input image.tar --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1
- uses: actions/upload-artifact@v4
with:
name: scanned-image
path: image.tar
retention-days: 1
summary:
name: Results summary
runs-on: ubuntu-24.04
needs: [secrets, dependencies, iac, image]
if: always()
steps:
- name: Write job summary
run: |
{
echo "| Gate | Result |"
echo "| --- | --- |"
echo "| Secrets | ${{ needs.secrets.result }} |"
echo "| Dependencies | ${{ needs.dependencies.result }} |"
echo "| IaC | ${{ needs.iac.result }} |"
echo "| Image | ${{ needs.image.result }} |"
} >> "$GITHUB_STEP_SUMMARY"

Push and watch the first run

Bash
git add .github scripts
git commit -m "Add four-gate security pipeline"
git remote add origin git@github.com:YOUR-USER/gates-demo.git
git push -u origin main

Open the Actions tab. You should see three jobs start together, then Gate 4: Image, then the summary table on the run page.

Setting Why it is there
permissions: contents: read The token can only read code, which limits damage if a step is compromised
fetch-depth: 0 Gitleaks needs the full history, not only the latest commit
--ignore-unfixed Blocks only what you can actually fix today
if: always() on the summary The table still appears when a gate fails
Tip

For stronger supply chain safety, pin each uses: line to a full commit SHA instead of a version tag, and keep the tag in a comment. Dependabot can update SHA pins for you.

Common Mistake

Using continue-on-error: true on a scan step "to see the results." The job then shows green even when it finds critical issues, and nobody notices. If you only want to look at results, run the scan in a separate workflow that is not a required check.

Security

The Trivy database downloads from a public registry that can rate-limit shared CI runners. If a run fails with a "too many requests" error rather than a finding, re-run the job. Do not respond by loosening the gate.

Milestone 5: Prove the Gates Block Real Problems

A gate you have never seen fail is a gate you cannot trust. In this milestone you reintroduce each problem on a throwaway branch and confirm the right job fails and the image job never starts.

Test 1: a leaked secret

Create a branch and commit a file that contains a token-like value. The value below is made up and unlocks nothing.

Bash
git switch -c test/leak
cat > config.py << 'EOF'
API_TOKEN = "xk9f3Qz81LmVt7RbPw20HdYc5NaUe4Sj6GoI"
EOF
git add config.py && git commit -m "test: add a fake token"
git push -u origin test/leak

Open a pull request into main. Expected result: Gate 1: Secrets fails with a finding of the generic-api-key rule, the line is redacted in the log, and Gate 4: Image is skipped because its needs were not met.

What to do about a real leak

If this had been a real credential, deleting the file would not be enough. The secret is still in git history, and Gitleaks keeps flagging that commit, which is the correct behaviour. The order of response is:

  • Revoke and rotate the credential first, because anyone who saw it can still use it.
  • Remove the commit from history (or delete the branch if it was never merged).
  • Only then, if needed, record the old finding's fingerprint in a .gitleaksignore file with a comment explaining why.
Bash
git switch main
git push origin --delete test/leak
git branch -D test/leak

Test 2: a vulnerable dependency

Bash
git switch -c test/old-dependency
sed -i 's/^urllib3==.*/urllib3==1.26.4/' requirements.txt
git commit -am "test: downgrade urllib3"
git push -u origin test/old-dependency

Expected result: Gate 2: Dependencies fails and the log shows urllib3 with its fixed versions. The other two parallel gates still pass, so the developer sees the whole picture in one run. Delete the branch afterwards.

Test 3: SSH open to the world

Bash
git switch main
git switch -c test/open-ssh
python3 - << 'EOF'
import re
p = "terraform/main.tf"
s = open(p).read()
s = s.replace('from_port = 443', 'from_port = 22').replace('to_port = 443', 'to_port = 22').replace('10.0.0.0/16', '0.0.0.0/0')
open(p, "w").write(s)
EOF
git commit -am "test: open SSH to the world"
git push -u origin test/open-ssh

Expected result: Gate 3: IaC fails on CKV_AWS_24. Delete the branch afterwards.

Bash
git switch main
git push origin --delete test/old-dependency test/open-ssh
git branch -D test/old-dependency test/open-ssh
Test Failing gate Evidence in the log
Fake token Gate 1: Secrets generic-api-key finding, redacted
Old urllib3 Gate 2: Dependencies CVE rows for urllib3
SSH to 0.0.0.0/0 Gate 3: IaC CKV_AWS_24 FAILED
Common Mistake

Testing only on main. A failing push to main blocks nothing that has already merged, and it can break the branch for everyone. Always break things on a branch and open a pull request, which is also where reviewers will see the result.

Milestone 6: Make Gates Mandatory and Publish the Scanned Image

Passing gates only matter if nobody can skip them. This milestone makes the four jobs required checks for merging, then adds a publish job that pushes the exact image you scanned, and only from main.

Require the gates before merging

In your repository, open Settings, then Rules, then Rulesets, and create a ruleset for the default branch with these options:

  • Require a pull request before merging
  • Require status checks to pass, and add Gate 1: Secrets, Gate 2: Dependencies, Gate 3: IaC and Gate 4: Image
  • Block force pushes
Common Mistake

A required check is matched by the job name. If you rename a job later, GitHub waits forever for a check with the old name and every pull request is stuck. Rename the job and update the ruleset in the same change.

Add the publish job

The job downloads the saved image from the artifact, loads it and pushes it. Nothing is rebuilt, so what you scanned is what you ship. Add this job to the end of the jobs: section.

YAML
publish:
name: Publish scanned image
runs-on: ubuntu-24.04
needs: [image]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@v4
with:
name: scanned-image
- name: Load, tag and push
env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
docker load -i image.tar
TARGET="ghcr.io/${GITHUB_REPOSITORY,,}:${GITHUB_SHA}"
docker tag "gates-demo:${GITHUB_SHA}" "${TARGET}"
echo "${REGISTRY_TOKEN}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
docker push "${TARGET}"
echo "Published ${TARGET}" >> "$GITHUB_STEP_SUMMARY"

Commit and push through a pull request, merge it, and watch the publish job run on main only.

Why the image is tagged with the commit SHA

A SHA tag is immutable in practice: it always points at one build of one commit. A floating tag such as latest can silently move to a different image, which makes it hard to say what was scanned.

Design choice What it protects against
Publish needs the image gate An unscanned image reaching the registry
Same tarball is loaded and pushed A rebuild that differs from the scanned image
packages: write only on this job Giving every job registry write access
main push only Pull request code publishing images
Security

Pull requests from forks run with a read-only token and no secrets by design. Never switch the workflow to pull_request_target to "fix" this, because it runs untrusted code with write access.

Tip

Container registries are private by default for new packages. If you want to pull the image from another machine, sign in with docker login ghcr.io using a token that has the read:packages scope.

Validation & Testing

Use this checklist to confirm the project works end to end and that you can explain it. Each item has a command or a place to look, plus the result you should see.

Local checks

Bash
# 1. Scanner versions match the pins
gitleaks version # 8.30.1
trivy --version # Version: 0.75.0
checkov --version # 3.3.26
# 2. Clean state passes all gates locally
gitleaks git --no-banner --redact . ; echo "gitleaks: $?"
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 . ; echo "trivy fs: $?"
checkov -d . --framework terraform,dockerfile \
--check CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56,CKV_AWS_24,CKV_AWS_21,CKV2_AWS_6,CKV_DOCKER_2,CKV_DOCKER_3 \
--compact --quiet ; echo "checkov: $?"

Expected result: every exit code is 0.

Pipeline checks

  • The first run on main shows Gates 1 to 3 in parallel, then Gate 4, then the publish job, and the summary table lists four success rows.
  • The package page for your repository shows an image tagged with the commit SHA.
  • Each branch from Milestone 5 failed in exactly one gate, and Gate 4: Image was skipped.
  • A pull request cannot be merged while a required gate is red.

Reflection questions

Answer these in your README. They are the questions an interviewer is likely to ask.

  • Why does the pipeline scan a saved tarball and push that same file instead of rebuilding?
  • Why can a deleted secret still fail the secrets gate, and what must you do first?
  • What does --ignore-unfixed trade away, and when would you remove it?
  • Why pin scanner versions and verify checksums instead of using latest?

Portfolio deliverable

Your README should contain the pipeline diagram, a screenshot of one failed gate with the finding visible, a screenshot of the passing run with the summary table, and a short list of what each gate does not catch. That honesty is what makes it credible.

Stretch goals

  • Pin every uses: line to a commit SHA and add Dependabot for GitHub Actions.
  • Add a nightly scheduled run so new CVEs in old code are found without a push.
  • Sign the published image and verify the signature before deploying.
Common Mistake

Treating a green pipeline as proof of security. These gates catch known patterns, known CVEs and listed misconfigurations. They do not find logic bugs or a missing authorization check, so keep code review and testing alongside them.

Tip

Re-run Test 1 to Test 3 from Milestone 5 whenever you change the workflow file. A gate that quietly stopped blocking is the most common way these pipelines fail.

Found a mistake, an outdated step or a broken link?