Secure a CI/CD Pipeline with DevSecOps
Build a GitHub Actions pipeline that blocks deployments on CVEs and secrets.
Introduction
30 Second Summary
A single leaked API key can cost a company millions. Vulnerable container images get exploited within hours of deployment. These are preventable problems, but only if your pipeline catches them before code ships.
In this project, you will build a DevSecOps pipeline that automatically detects hardcoded secrets, vulnerable dependencies, and insecure container images on every push to GitHub.
What You'll Build
You will push code to GitHub and watch your pipeline automatically reject it when it finds a leaked secret or a critical vulnerability. Then you will fix each issue and watch the pipeline turn green.
By the end of this project, you'll have:
- A GitHub Actions pipeline that blocks deployments the moment it finds a hardcoded credential or a critical CVE, visible right in your Actions logs.
- Hands-on experience fixing three categories of security issues: leaked secrets, vulnerable dependencies, and insecure base images.
- A reusable DevSecOps workflow with configurable severity thresholds that you can drop into any future project and show to interviewers.
- Secret Mission: Add Dockerfile misconfiguration scanning to catch insecure container configurations before they reach production.
Are there any prerequisites?
This project assumes you are familiar with GitHub Actions, Docker, and basic CI/CD concepts (workflows, jobs, steps). You do not need any prior security experience.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters for your career.
Set Up the Vulnerable Project
Security scanning tools need something to catch. Without real vulnerabilities in your code, you would never see the tools do their job.
In this step, you will create a deliberately insecure Flask app with a hardcoded API key, outdated dependencies with known CVEs, and a vulnerable base image. Then you will set up a naive CI/CD pipeline that happily builds it without any security checks.
In this step, get ready to:
- Create a public GitHub repository and clone it to your machine.
- Build a deliberately insecure Flask app with known vulnerabilities.
- Set up a naive CI pipeline that builds without security scanning.
Create the GitHub repository and clone it
You need a GitHub repository to host your code and run the pipeline. A public repo means GitHub Actions runs for free.
- Open your browser and go to github.com.
- Click the + icon in the top-right corner, then select New repository.
- Enter devsecops-pipeline as the Repository name.
- Select Public for the visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why leave the repo empty?
Creating the repo without a README means it starts completely empty. This lets you push your local files cleanly without any merge conflicts on the first push.
GitHub now shows a Quick Setup page with the repository URL. Keep this page open.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your new repository by running this command:
git clone [[REPO_URL="https://github.com/YOUR-USERNAME/devsecops-pipeline.git"]]
Where do I find the URL?
Copy the HTTPS URL from the Quick Setup page on GitHub. It looks like https://github.com/your-username/devsecops-pipeline.git.
- Move into your new project folder by running:
cd devsecops-pipeline
- Open this folder in VS Code by running:
code .
VS Code didn't open?
If code . returns a "command not found" error, open VS Code manually. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Shell Command: Install 'code' command in PATH, and press Enter. Then try code . again.
help me fix the 'code' command not found error
Create the deliberately insecure application files
Your app is the thing your pipeline will scan. You are deliberately introducing three categories of vulnerability so the scanning tools have something real to catch later.
- In VS Code, click File in the top menu bar, then New File.
- Name the file app.py and save it inside your devsecops-pipeline folder.
- Paste the following code into app.py:
import os
from flask import Flask
app = Flask(__name__)
# Vulnerability 1: Hardcoded API key (should use environment variables)
SUPER_SECRET_API_KEY = "sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx234"
@app.route("/")
def hello():
return "Hello, World!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
What does this code do?
This is a minimal Flask web app with one route that returns "Hello, World!" on port 5000.
The critical flaw is line 7: SUPER_SECRET_API_KEY contains a hardcoded credential committed directly into source code. Anyone who can read your repository can steal this key.
- Save app.py with Cmd+S (macOS) or Ctrl+S (Windows).
- You should see app.py appear in VS Code's Explorer sidebar on the left.
- Create another new file named requirements.txt in the same folder.
- Paste this single line into requirements.txt:
Flask==2.0.1
Why Flask 2.0.1 specifically?
This version has confirmed security vulnerabilities: CVE-2023-30861 (session cookie disclosure) and CVE-2026-27205 (header bypass). Pinning to this old version guarantees the vulnerability scanner has real CVEs to find.
- Save requirements.txt.
- Create one more new file named Dockerfile (no extension) in the same folder.
- Paste the following into your Dockerfile:
# Vulnerability 2: Old base image with known OS-level CVEs
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]
What does this Dockerfile do?
It builds a Docker container image that installs Flask and runs your app.
The second deliberate flaw is the base image: python:3.9-slim is years old and contains dozens of known OS-level vulnerabilities. A scanner will flag these.
- Save Dockerfile.
- Confirm all your project files exist by running this in your terminal:
ls
You should see app.py, Dockerfile, and requirements.txt in the output.
Missing a file?
Make sure you saved each file inside the devsecops-pipeline folder, not in a subfolder or parent directory. Check your terminal is in the right directory with pwd (macOS) or cd (Windows). It should end with devsecops-pipeline.
help me find my missing project files
Create the naive CI pipeline and push to GitHub
Now you will create a GitHub Actions workflow that builds your Docker image on every push. This pipeline has zero security scanning.
- Create the workflow directory by running:
mkdir -p .github/workflows
- Create a new file at .github/workflows/ci.yml in VS Code.
- Paste the following workflow into ci.yml:
name: DevSecOps Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devsecops-app:${{ github.sha }} .
- name: Build successful
run: echo "Build successful"
What does this workflow do?
On every push to main (or any pull request), GitHub Actions spins up an Ubuntu runner, checks out your code, builds the Docker image, and prints "Build successful".
There is no security scanning at all. A hardcoded API key, vulnerable dependencies, and an insecure base image all pass through without a warning.
- Save ci.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before pushing.
ⓧ I'd like to double check the full code
Here is the complete ci.yml workflow:
name: DevSecOps Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devsecops-app:${{ github.sha }} .
- name: Build successful
run: echo "Build successful"
And here is app.py:
import os
from flask import Flask
app = Flask(__name__)
SUPER_SECRET_API_KEY = "sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx234"
@app.route("/")
def hello():
return "Hello, World!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
And Dockerfile:
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]
And requirements.txt:
Flask==2.0.1
Now push everything to GitHub so the pipeline triggers.
- Stage, commit, and push all your files by running these commands:
git add .
git commit -m "Add insecure Flask app and naive CI pipeline"
git push origin main
Push rejected or authentication failed?
If Git asks for credentials, GitHub no longer accepts passwords for HTTPS. Use a Personal Access Token or switch to SSH authentication.
If you see "error: src refspec main does not match any", your default branch might be named master instead. Run git branch -M main to rename it, then push again.
help me push my code to GitHub
Before you check GitHub Actions, think about this: do you expect this pipeline to pass or fail? Your code has a hardcoded API key and vulnerable dependencies.
- Go to your repository on GitHub in your browser.
- Click the Actions tab at the top of the page.
- Wait for the workflow run to finish (about 1-2 minutes).
You will see a green checkmark next to your commit message. The pipeline passed. It built the Docker image successfully and printed "Build successful" without catching a single vulnerability.
Why did the pipeline pass?
The pipeline only checks that the Docker image builds. It has no security scanning whatsoever. A hardcoded API key, a Flask version with known CVEs, and an insecure base image all sailed through without a warning.
This is the gap you will close in the next steps.
- Back in your terminal, confirm your commit by running:
git log --oneline
You should see your commit message (something like abc1234 Add insecure Flask app and naive CI pipeline) confirming the push.
Your insecure project is live and your naive pipeline sees nothing wrong. Next up, you will add secret detection to catch that hardcoded API key before it ever ships to production.
Add Secret Detection with Gitleaks
Your pipeline ran and passed with a green checkmark. There is a major problem hiding in plain sight.
The repository contains a hardcoded API key in app.py. Anyone who can read the repo can steal it. The pipeline did nothing to flag it. In this step, you will add Gitleaks to your workflow to scan every commit for secrets and block the build until you fix the leak.
In this step, get ready to:
- Add a Gitleaks secret-scan job to your GitHub Actions workflow.
- Fix the hardcoded API key and store it as a GitHub Actions secret.
- Understand why secret rotation matters even after removal.
Add the Gitleaks scanning job
Gitleaks is an open-source tool that scans Git repositories for hardcoded secrets. By running it as a job in your workflow, every push is automatically checked before the build proceeds.
- Switch back to .github/workflows/ci.yml in VS Code.
- Find the jobs: line. Add the following new job directly below it, above the existing build: job:
secret-scan:
name: Secret Detection
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
What does this code do?
This defines a new job called secret-scan that runs before the build.
- fetch-depth: 0 tells the checkout action to pull the full commit history. Gitleaks needs this to scan every previous commit for leaked secrets.
- gitleaks/gitleaks-action@v3 runs the Gitleaks scanner against the repository.
- GITHUB_TOKEN is automatically provisioned by GitHub for every workflow run. No manual setup required.
Now you need to make the build job wait for the secret scan to finish. If Gitleaks finds a secret, the build should never run.
- In the same file, find the build: job. Locate this line:
build:
name: Build
runs-on: ubuntu-latest
steps:
- Add needs: secret-scan between runs-on: ubuntu-latest and steps: so it looks like this:
build:
name: Build
runs-on: ubuntu-latest
needs: secret-scan
steps:
- Save ci.yml.
Before you push, think about what will happen. Your app.py still contains a hardcoded API key. Do you think Gitleaks will let the pipeline pass, or will it catch the secret and fail the run?
- Commit and push your changes by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add Gitleaks secret scanning"
git push
- Open your repository on GitHub.
- Click the Actions tab.
- Click the latest workflow run (it should show a red X).
- Click the Secret Detection job to see the Gitleaks output.
The pipeline fails. Gitleaks detected the hardcoded secret in app.py and blocked the entire run. The build job never started because of the needs: secret-scan dependency. This is exactly what you want.
Pipeline passed instead of failing?
- Make sure the secret-scan job is indented at the same level as build (two spaces under jobs:).
- Confirm fetch-depth: 0 is set on the checkout step inside secret-scan. Without it, Gitleaks only sees the latest commit and may miss the secret.
- Check that needs: secret-scan is on the build job so it waits for the scan.
help me debug why Gitleaks is not detecting my hardcoded secret
Fix the leaked secret
Gitleaks proved the pipeline catches hardcoded credentials. Now you need to remove the secret from the code and store it safely using GitHub Actions secrets.
- In app.py, find this line:
SUPER_SECRET_API_KEY = "sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx234"
- Replace it with this line that reads the key from an environment variable instead:
API_KEY = os.environ.get("API_KEY")
- Save app.py.
What changed?
Instead of storing the secret directly in code, the app reads it from the API_KEY environment variable at runtime. The actual value will live in GitHub Actions secrets, encrypted and never exposed in logs.
Now you need to store the API key value as a GitHub Actions secret so the workflow can access it during builds.
- In your browser, go to your devsecops-pipeline repository on GitHub.
- Click Settings.
- In the left sidebar under the Security section, select Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, type API_KEY.
- In the Secret field, enter any value (this is a demonstration key for learning purposes).
- Click Add secret.
You should see API_KEY listed in your repository secrets. The value is encrypted and hidden from view.
Finally, update the workflow to pass this secret to the Docker build step as an environment variable.
- Switch back to .github/workflows/ci.yml in VS Code.
- In the build job, find the Build Docker image step:
- name: Build Docker image
run: docker build -t devsecops-app:${{ github.sha }} .
- Add an env block below the run: line so the step looks like this:
- name: Build Docker image
run: docker build -t devsecops-app:${{ github.sha }} .
env:
API_KEY: ${{ secrets.API_KEY }}
- Save ci.yml.
What does this env block do?
The env block injects the API_KEY secret as an environment variable during the Docker build step. Your app can now read os.environ.get("API_KEY") at runtime without the value ever appearing in source code.
Before you push, predict: will Gitleaks still fail, or will it pass now that the secret has been removed from the code?
- Commit and push by running these commands:
git add -A
git commit -m "Fix secret leak - use environment variable"
git push
- Return to the Actions tab in your GitHub repository.
- Click the latest workflow run.
The pipeline passes green. Both the Secret Detection job and the Build job succeed. Gitleaks no longer finds a new secret introduction in this push.
Gitleaks still failing?
- Check that you fully removed the SUPER_SECRET_API_KEY line from app.py. If any trace of the string remains in the current commit diff, Gitleaks will flag it.
- Confirm you replaced it with API_KEY = os.environ.get("API_KEY") and not a different hardcoded value.
help me figure out why Gitleaks is still failing after I removed the hardcoded key
✔️ Awesome, I've got everything!
Great. Double check you have saved both app.py and ci.yml.
ⓧ I'd like to double check the full code
Confirm these pieces are in place across your two files:
- app.py uses API_KEY = os.environ.get("API_KEY") with no hardcoded secret anywhere in the file.
- ci.yml has the secret-scan job (with fetch-depth: 0 and gitleaks/gitleaks-action@v3) listed before the build job.
- The build job has needs: secret-scan so it waits for the scan.
- The Build Docker image step includes an env block with API_KEY: ${{ secrets.API_KEY }}.
Here is the full app.py for reference:
import os
from flask import Flask
app = Flask(__name__)
API_KEY = os.environ.get("API_KEY")
@app.route("/")
def hello():
return "Hello, World!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Here is the full ci.yml for reference:
name: DevSecOps Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
secret-scan:
name: Secret Detection
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build:
name: Build
runs-on: ubuntu-latest
needs: secret-scan
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devsecops-app:${{ github.sha }} .
env:
API_KEY: ${{ secrets.API_KEY }}
- name: Build successful
run: echo "Build successful"
Understand secret rotation and .gitleaksignore
Your pipeline passes, but there is an important nuance. The secret is gone from the current code, but it still lives in your Git history.
Why does the secret still exist in Git history?
Git is an append-only log. Every commit you have ever made is preserved. The commit from Step 1 that introduced the hardcoded key is still reachable by anyone who clones the repository.
This is why secret rotation is essential. Even after removing a secret from code, you must assume it has been compromised. Rotate the credential immediately by generating a new key and revoking the old one.
What is .gitleaksignore?
Sometimes Gitleaks flags findings in your Git history that you have already rotated and accepted the risk for. A .gitleaksignore file lets you suppress specific known findings by their fingerprint so they do not block future pipeline runs.
Use it sparingly. Every entry is a conscious decision to accept that a particular historical secret exposure is handled.
Your secrets are secured and the pipeline enforces detection on every push. Next up, you will tackle the other category of vulnerability hiding in your project: known CVEs in your Docker base image and Flask dependency.
Add Container Vulnerability Scanning with Trivy
Your secrets are dealt with. Gitleaks now catches any hardcoded credentials before they ship.
But the Docker image you are building is packed with known vulnerabilities. The python:3.9-slim base image contains outdated OS packages with published CVEs. Your pinned Flask==2.0.1 dependency has its own security advisories (CVE-2023-30861 and CVE-2026-27205). You need a scanner that catches these before deployment.
In this step, get ready to:
- Add Trivy container scanning to your GitHub Actions workflow.
- Fix vulnerable dependencies and harden your Dockerfile.
- Configure ignore rules for vulnerabilities that have no available patch.
Add Trivy container scanning to your workflow
Trivy is an open-source vulnerability scanner by Aqua Security. It checks your container images for known CVEs in both OS packages and application libraries.
- Open .github/workflows/ci.yml in VS Code.
- Find the build: job (everything from build: down to the end of the file).
- Delete the entire build: job and replace it with this build-and-scan: job:
build-and-scan:
name: Build and Vulnerability Scan
runs-on: ubuntu-latest
needs: secret-scan
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devsecops-app:${{ github.sha }} .
- name: Run Trivy container scan
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: devsecops-app:${{ github.sha }}
format: 'table'
exit-code: '1'
severity: 'CRITICAL,HIGH'
What does this code do?
This job builds your Docker image and then scans it with Trivy before anything else happens.
- needs: secret-scan ensures Gitleaks passes first. If secrets are found, this job never runs.
- image-ref tells Trivy which Docker image to scan.
- exit-code: '1' makes the step fail when vulnerabilities are found at or above the configured severity.
- severity: 'CRITICAL,HIGH' sets the threshold. Only CRITICAL and HIGH CVEs will trigger a failure.
Why remove the API_KEY env from the build step?
The old build step passed API_KEY as an environment variable during the Docker build. Your app reads the key at runtime with os.environ.get(), so it does not need the key at build time. Removing it keeps secrets out of the image layers entirely.
- Save ci.yml.
Before you push, think about what will happen. Your Dockerfile still uses python:3.9-slim and your requirements still pin Flask==2.0.1. Do you think Trivy will let the build pass?
- Commit and push your changes by running these commands:
git add .
git commit -m "Add Trivy container vulnerability scanning"
git push
- Open your repository on GitHub and click the Actions tab.
- Click the latest workflow run and watch both jobs.
The secret-scan job passes green. The build-and-scan job fails with a red X. Click into it to see Trivy's output table listing CRITICAL and HIGH CVEs from the python:3.9-slim OS packages and the outdated Flask dependency.
Why did the pipeline fail?
Trivy scanned every package inside your Docker image. The python:3.9-slim base image ships with Debian packages that have known CRITICAL/HIGH vulnerabilities. Flask 2.0.1 has CVE-2023-30861 (session cookie disclosure) and CVE-2026-27205 (Vary header bypass).
Because you set exit-code: '1', Trivy exits with code 1 when it finds issues at or above CRITICAL,HIGH severity. GitHub Actions treats any non-zero exit code as a failure, so the job is blocked.
Pipeline passed instead of failing?
If the build-and-scan job shows green, double check that you set exit-code: '1' (with quotes around the 1) in your Trivy step. Without it, the default is 0, meaning Trivy reports findings but does not block the pipeline.
Also confirm you removed the old build: job entirely and replaced it with build-and-scan:.
Help me debug why my Trivy scan is not failing on CRITICAL vulnerabilities.
Fix the vulnerabilities and harden your Dockerfile
The pipeline is doing its job. Now you need to fix the actual vulnerabilities so it can pass. You will upgrade both the Python base image and the Flask dependency, then harden the Dockerfile with security best practices.
- Open requirements.txt in VS Code.
- Find this line:
Flask==2.0.1
- Replace it with:
Flask==3.1.3
- Save requirements.txt.
- Now open app.py and replace its entire contents with this updated version:
import os
from flask import Flask
app = Flask(__name__)
API_KEY = os.environ.get("API_KEY")
@app.route("/")
def hello():
return "Hello, Secure World!"
@app.route("/health")
def health():
return "OK", 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
What changed in app.py?
- The greeting changed from "Hello, World!" to "Hello, Secure World!" to reflect your secured app.
- A new /health endpoint returns a 200 status. The Dockerfile's HEALTHCHECK instruction will use this to verify the container is running correctly.
- Save app.py.
✔️ Awesome, I've got everything!
Great. Make sure both requirements.txt and app.py are saved.
ⓧ I'd like to double check the full code
Here is the complete app.py:
import os
from flask import Flask
app = Flask(__name__)
API_KEY = os.environ.get("API_KEY")
@app.route("/")
def hello():
return "Hello, Secure World!"
@app.route("/health")
def health():
return "OK", 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
And requirements.txt should be a single line:
Flask==3.1.3
Now update the Dockerfile to use a modern, patched base image and add security hardening.
- Open Dockerfile and replace its entire contents with:
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd -r -s /bin/false appuser
USER appuser
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1
CMD ["python", "app.py"]
What does this Dockerfile do differently?
- python:3.14-slim replaces the vulnerable python:3.9-slim base. This eliminates hundreds of OS-level CVEs.
- RUN useradd and USER appuser run the app as a non-root user. If an attacker exploits the app, they cannot escalate to root inside the container.
- HEALTHCHECK instructs Docker to periodically verify the app is responding. Orchestrators like Kubernetes use this signal to restart unhealthy containers.
- Save Dockerfile.
- Verify the new image builds successfully by running this command in your terminal:
docker build -t devsecops-app:local .
You should see the build complete with a success message. The final line shows the image tagged as devsecops-app:local.
Docker build failing?
Make sure Docker Desktop is running. If you see a permissions error on the useradd line, confirm you have not accidentally indented it under USER. The RUN useradd must come before the USER appuser line.
Help me debug why my Docker build is failing after updating the Dockerfile.
✔️ Awesome, I've got everything!
Great. Confirm you have saved the Dockerfile and the local build completed successfully.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd -r -s /bin/false appuser
USER appuser
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1
CMD ["python", "app.py"]
- Push your fixes to GitHub by running:
git add .
git commit -m "Fix vulnerabilities: upgrade base image and Flask"
git push
- Return to the Actions tab in GitHub and watch the latest workflow run.
This time, both jobs should pass. Trivy scans the new python:3.14-slim image with Flask==3.1.3 and finds no CRITICAL or HIGH vulnerabilities above your threshold.
Trivy still failing after your fixes?
Some CRITICAL/HIGH CVEs in OS packages may have no available fix yet. If you see failures listing packages with "fixed version: none", that is expected. The next substep adds ignore-unfixed: true to handle this exact situation.
Help me understand why Trivy is still reporting CRITICAL vulnerabilities after I upgraded my base image.
Add the ignore-unfixed safety net
Even with the latest base image, some vulnerabilities have no patch available yet. Blocking your pipeline on issues you literally cannot fix is counterproductive.
- Open .github/workflows/ci.yml in VS Code.
- In the Run Trivy container scan step, find the with: block. It currently looks like this:
with:
image-ref: devsecops-app:${{ github.sha }}
format: 'table'
exit-code: '1'
severity: 'CRITICAL,HIGH'
- Add ignore-unfixed: true between exit-code: '1' and severity:, so the block now looks like this:
with:
image-ref: devsecops-app:${{ github.sha }}
format: 'table'
exit-code: '1'
ignore-unfixed: true
severity: 'CRITICAL,HIGH'
What does ignore-unfixed do?
When ignore-unfixed is set to true, Trivy skips any CVE that has no available patch in the package repository. This prevents your pipeline from being permanently blocked by vulnerabilities that you cannot resolve today. You still catch everything that has a known fix.
- Save ci.yml.
- Push the final configuration by running:
git add .
git commit -m "Add ignore-unfixed to Trivy scan"
git push
- Return to the Actions tab on GitHub and confirm the full pipeline (secret-scan and build-and-scan) passes with green checkmarks.
Both jobs pass green. Your pipeline now blocks deployments on fixable CRITICAL/HIGH vulnerabilities while ignoring ones that have no patch available yet.
Still seeing a red X?
Open the failed step's logs. If Trivy reports a CRITICAL vulnerability with a fixed version listed (not "none"), you have a fixable CVE that still exists in your image. Check that your Dockerfile uses python:3.14-slim (not python:3.9-slim) and requirements.txt contains Flask==3.1.3.
Help me figure out why my pipeline is still failing after adding ignore-unfixed.
✔️ Awesome, I've got everything!
Great. Confirm ci.yml is saved with the ignore-unfixed: true line in place.
ⓧ I'd like to double check the full code
Your ci.yml file has two jobs. Scroll through and confirm these sections are present in order:
- The secret-scan job with actions/checkout@v4 (fetch-depth 0) and gitleaks/gitleaks-action@v3.
- The build-and-scan job with needs: secret-scan, a Docker build step, and the Trivy step using aquasecurity/trivy-action@v0.36.0 with ignore-unfixed: true and severity: 'CRITICAL,HIGH'.
Your pipeline now has two layers of security. Gitleaks catches secrets, and Trivy catches vulnerable packages and OS components. Next up, you will extend this further in the Secret Mission by adding Dockerfile misconfiguration scanning.
Secret mission
Add Dockerfile Misconfiguration Scanning
Your container image passes vulnerability scans, but what about the Dockerfile itself? Trivy can also scan infrastructure-as-code files to catch misconfigurations like running as root or missing health checks. Add a third scanning gate to your pipeline.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and on GitHub Actions (free for public repositories), so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- GitHub repository devsecops-pipeline (public, free).
- Local project folder devsecops-pipeline on your Desktop.
- GitHub Actions secret (API_KEY) stored in the repository settings.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep the repository as a portfolio piece for interviews.
- Your repository stays public and accessible. GitHub Actions will continue to run on every push at no cost.
- Consider making it a template repository so you can reuse the DevSecOps pipeline in future projects. Go to your repository's Settings and check Template repository under the repository name.
- Your local folder remains on your Desktop for further development.
Pause - I'll come back to this later
Free up local disk space while keeping your remote repository intact for later use.
- Your GitHub repository and Actions workflows remain untouched. They cost nothing while idle.
- Delete the local folder to free disk space. You can re-clone anytime. Run this command on macOS/Linux:
cd ~/Desktop
rm -rf devsecops-pipeline
On Windows, run this in PowerShell:
cd ~\Desktop
Remove-Item -Recurse -Force devsecops-pipeline
- When you are ready to resume, clone the repository again with git clone.
Delete - I don't want to use this again
Remove all project resources completely. This is permanent.
- Delete the local project folder. On macOS/Linux:
cd ~/Desktop
rm -rf devsecops-pipeline
On Windows, run this in PowerShell:
cd ~\Desktop
Remove-Item -Recurse -Force devsecops-pipeline
- Delete the GitHub repository:
- Open your devsecops-pipeline repository on GitHub.
- Click Settings in the repository navigation bar.
- Scroll down to the Danger Zone section and click Delete this repository.
- Click I want to delete this repository, then click I have read and understand these effects.
- Type devsecops-pipeline to confirm, then click Delete this repository.
What about the GitHub Actions secret?
The API_KEY secret you stored in the repository settings is automatically deleted when you delete the repository. No separate cleanup is needed.
Nice Work!
Nice Work!
You just built a production-grade DevSecOps pipeline that automatically catches security vulnerabilities before code ships. Your pipeline now acts as a security gate that blocks deployments when it finds hardcoded secrets, vulnerable dependencies, or insecure container images.
You've learned how to:
- Build a GitHub Actions CI/CD pipeline with shift-left security gates that run Gitleaks secret detection and Trivy container vulnerability scanning on every push.
- Fix real security issues end-to-end: remove hardcoded credentials, upgrade vulnerable dependencies like Flask, and replace an insecure base image with a current one.
- Configure severity thresholds and ignore rules to balance security enforcement with practicality so your pipeline blocks on CRITICAL/HIGH CVEs without stalling on unfixable findings.
- Secret Mission: Added Dockerfile misconfiguration scanning to catch insecure container configurations like running as root or missing health checks.
Ready to quiz yourself?