Build a DevSecOps Pipeline
Build a 4-stage security pipeline that scans code on every push to GitHub.
Introduction
30 Second Summary
Every week, another company makes headlines for a data breach that started with a hardcoded API key or an unpatched dependency. These are preventable problems. The fix is catching them automatically before code ever reaches production.
In this project, you will build a DevSecOps pipeline using GitHub Actions that scans a deliberately vulnerable Python app for secrets, code vulnerabilities, risky dependencies, and container misconfigurations.
What You'll Build
A complete CI/CD security pipeline with four parallel scanning stages that automatically catches vulnerabilities on every push to your repository.
By the end of this project, you'll have:
- A working GitHub Actions workflow with 4 parallel security gates: secrets detection, SAST, dependency scanning, and container scanning.
- Hands-on experience remediating real security findings including hardcoded credentials, SQL injection, vulnerable packages, and container misconfigurations.
- A reusable DevSecOps pipeline template you can drop into any future project to enforce security automatically.
- Secret Mission: Write a custom Semgrep rule that catches a project-specific anti-pattern and integrates it into your pipeline.
Are there any prerequisites?
You should have a basic understanding of CI/CD pipelines, Git, and Docker. Familiarity with Python and Flask is helpful but not required.
Create the Vulnerable Flask Application
To build a DevSecOps pipeline, you need something to scan. The best way to learn how security tools work is to give them real vulnerabilities to find.
In this step, you'll create a deliberately vulnerable Flask application. This app contains hardcoded secrets, a SQL injection flaw, outdated dependencies, and container misconfigurations. Each vulnerability maps to one of the four security scanning stages you'll build in the next step.
In this step, get ready to:
- Create a Flask app with intentional security flaws.
- Define dependencies with known-vulnerable versions.
- Write a Dockerfile with security misconfigurations.
Create the Flask application with intentional vulnerabilities
Your app needs two categories of flaws for the security scanners to detect: hardcoded secrets and a SQL injection vulnerability. You'll place these intentionally so you can see exactly what each scanner catches.
- Create a new file called app.py in the root of your cloned repository.
- Add the following code to set up the app configuration and database helpers:
import os
import sqlite3
from flask import Flask, request, jsonify
app = Flask(__name__)
# Hardcoded secrets (intentional vulnerability)
SECRET_KEY = "super-secret-key-12345"
DB_PASSWORD = "admin123"
API_KEY = "sk-prod-abc123xyz789"
DATABASE = "users.db"
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.execute(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, email TEXT)"
)
conn.execute(
"INSERT OR IGNORE INTO users (id, username, email) VALUES (1, 'admin', 'admin@example.com')"
)
conn.commit()
conn.close()
What does this code do?
The import block brings in Flask for the web framework and sqlite3 for a lightweight local database.
Lines 8-10 hardcode three secrets directly in the source code. This is the exact anti-pattern that Gitleaks will detect in your pipeline. In production, these would be environment variables or a secrets manager.
The get_db() and init_db() functions handle database connections and create a simple users table.
You won't see a visible change yet. The next chunk completes this file.
- Add the following code below the init_db() function in app.py:
@app.route("/")
def index():
return jsonify({"status": "healthy", "app": "devsecops-demo"})
@app.route("/users", methods=["GET"])
def get_users():
conn = get_db()
users = conn.execute("SELECT * FROM users").fetchall()
conn.close()
return jsonify([dict(row) for row in users])
@app.route("/users/search", methods=["GET"])
def search_users():
username = request.args.get("username", "")
conn = get_db()
# SQL injection vulnerability (intentional)
query = f"SELECT * FROM users WHERE username = '{username}'"
users = conn.execute(query).fetchall()
conn.close()
return jsonify([dict(row) for row in users])
if __name__ == "__main__":
init_db()
port = int(os.environ.get("PORT", 5000))
app.run(host="0.0.0.0", port=port)
What does this code do?
The /users/search endpoint uses an f-string to build a SQL query directly from user input. This is a classic SQL injection vulnerability that Semgrep will flag during static analysis.
The other two routes (/ and /users) are safe endpoints for health checks and listing users. The main block initializes the database and starts the Flask development server on port 5000.
- Save app.py.
- Confirm the file exists by running:
ls
You should see app.py listed in the output.
Don't see app.py?
Make sure you saved the file in the root of your cloned repository folder, not in a subfolder. Your terminal's working directory and the file location should match.
Help me find where my app.py file was saved.
✔️ Awesome, I've got everything!
Great. Double check you've saved app.py and move on to the next file.
ⓧ I'd like to double check the full code
import os
import sqlite3
from flask import Flask, request, jsonify
app = Flask(__name__)
# Hardcoded secrets (intentional vulnerability)
SECRET_KEY = "super-secret-key-12345"
DB_PASSWORD = "admin123"
API_KEY = "sk-prod-abc123xyz789"
DATABASE = "users.db"
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.execute(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, email TEXT)"
)
conn.execute(
"INSERT OR IGNORE INTO users (id, username, email) VALUES (1, 'admin', 'admin@example.com')"
)
conn.commit()
conn.close()
@app.route("/")
def index():
return jsonify({"status": "healthy", "app": "devsecops-demo"})
@app.route("/users", methods=["GET"])
def get_users():
conn = get_db()
users = conn.execute("SELECT * FROM users").fetchall()
conn.close()
return jsonify([dict(row) for row in users])
@app.route("/users/search", methods=["GET"])
def search_users():
username = request.args.get("username", "")
conn = get_db()
# SQL injection vulnerability (intentional)
query = f"SELECT * FROM users WHERE username = '{username}'"
users = conn.execute(query).fetchall()
conn.close()
return jsonify([dict(row) for row in users])
if __name__ == "__main__":
init_db()
port = int(os.environ.get("PORT", 5000))
app.run(host="0.0.0.0", port=port)
- Create a new file called requirements.txt in the same folder, then add the following:
flask==2.0.1
gunicorn==20.0.4
werkzeug==2.0.1
Why these specific versions?
These are intentionally outdated versions with known security vulnerabilities. Trivy's filesystem scan compares your pinned versions against vulnerability databases and will flag these.
For example, flask==2.0.1 is affected by CVE-2023-30861 (session cookie leakage) and werkzeug==2.0.1 has multiple known CVEs including denial-of-service and debugger PIN bypass vulnerabilities.
- Save requirements.txt.
- Run ls to confirm both app.py and requirements.txt appear.
Missing requirements.txt in the listing?
Check for typos in the filename. The file must be named exactly requirements.txt (all lowercase, no spaces).
Help me troubleshoot my requirements.txt file.
Create the Dockerfile with security misconfigurations
A Docker container image is only as secure as its configuration. This Dockerfile intentionally uses patterns that Trivy's container scanner will flag.
- Create a new file called Dockerfile (no file extension) in the same folder, then add the following:
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
What's insecure about this Dockerfile?
FROM python:3.12 uses the full Python image (around 1GB) which includes hundreds of packages your app doesn't need. Each extra package is a potential attack vector. A python:3.12-slim image is much smaller and more secure.
There is no USER directive, so the container runs everything as root. If an attacker exploits the app, they get root access inside the container. Trivy's container scan will flag both issues.
- Save Dockerfile.
- Confirm all three files exist by running:
ls
You should see app.py, requirements.txt, and Dockerfile listed in the output.
Don't see Dockerfile?
Make sure the file has no extension. Some editors add .txt automatically. The file should be named exactly Dockerfile with a capital D and no dot after it.
Help me fix my Dockerfile naming issue.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved and move on.
ⓧ I'd like to double check the full code
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
Your vulnerable app is ready to scan. In the next step, you'll build the GitHub Actions pipeline that catches every one of these flaws automatically.
Build the Security Pipeline
You now have a deliberately vulnerable Flask application sitting in your local repo. The app has hardcoded secrets, a SQL injection vulnerability, outdated dependencies, and a misconfigured Dockerfile.
In this step, you will build the automated security pipeline that catches all of these issues. You will create a GitHub Actions workflow with 4 parallel security scanning jobs that run every time code is pushed.
In this step, get ready to:
- Create a GitHub Actions workflow file with 4 security scanning jobs.
- Push your code to GitHub and trigger the pipeline.
- Review the scan results to understand what each tool detected.
Create the GitHub Actions workflow file
GitHub Actions looks for workflow files inside a .github/workflows directory in your repository. Each YAML file in that directory defines a separate automated pipeline.
- Create the required directory structure by running this command:
mkdir -p .github/workflows
- Confirm the directory was created by running:
ls .github
You should see workflows listed in the output.
- Create a new file called devsecops.yml inside the .github/workflows directory and paste the following workflow configuration:
name: DevSecOps Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
secrets-scan:
name: Secrets Detection
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sast-scan:
name: SAST - Semgrep
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep scan --config auto --error .
dependency-scan:
name: Dependency Scan - Trivy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy filesystem scan
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
exit-code: '1'
severity: 'HIGH,CRITICAL'
container-scan:
name: Container Scan - Trivy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devsecops-demo:${{ github.sha }} .
- name: Run Trivy image scan
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: 'devsecops-demo:${{ github.sha }}'
exit-code: '1'
severity: 'HIGH,CRITICAL'
ignore-unfixed: true
What does this workflow do?
This pipeline defines 4 independent security jobs that all run in parallel on every push to main:
- secrets-scan uses Gitleaks to scan the full Git history for hardcoded secrets like API keys and passwords. The fetch-depth: 0 setting ensures it checks every commit, not just the latest.
- sast-scan uses Semgrep to perform Static Application Security Testing. The --config auto flag loads curated community rules including OWASP Top Ten patterns.
- dependency-scan uses Trivy in filesystem mode to check your requirements.txt for packages with known vulnerabilities.
- container-scan builds your Docker image and then uses Trivy to scan it for OS-level vulnerabilities in the base image and installed packages.
Each job uses exit-code: '1' which means the pipeline fails if any findings are detected. This is how security gates work in practice.
Why parallel jobs instead of sequential?
All 4 jobs run independently at the same time because they don't depend on each other's output. This keeps pipeline duration short. If one scan takes 3 minutes, the whole pipeline finishes in about 3 minutes total instead of 12.
- Save .github/workflows/devsecops.yml.
Seeing YAML errors?
YAML is sensitive to indentation. Make sure you are using spaces (not tabs) and that the indentation matches exactly. Each jobs key should be indented 2 spaces from the root, and each steps key should be indented 4 spaces from the job name.
help me fix YAML indentation errors in my GitHub Actions workflow file
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
name: DevSecOps Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
secrets-scan:
name: Secrets Detection
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sast-scan:
name: SAST - Semgrep
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep scan --config auto --error .
dependency-scan:
name: Dependency Scan - Trivy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy filesystem scan
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
exit-code: '1'
severity: 'HIGH,CRITICAL'
container-scan:
name: Container Scan - Trivy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devsecops-demo:${{ github.sha }} .
- name: Run Trivy image scan
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: 'devsecops-demo:${{ github.sha }}'
exit-code: '1'
severity: 'HIGH,CRITICAL'
ignore-unfixed: true
Push the code to GitHub
Your workflow file is in place, along with the vulnerable app files from the previous step. Pushing everything to GitHub will automatically trigger the pipeline because the workflow is configured to run on every push to main.
- Stage all your files by running:
git add .
- Commit your changes by running:
git commit -m "Add vulnerable app and security pipeline"
- Push the commit to GitHub by running:
git push origin main
You should see Git upload your files to the remote repository and display a success message.
Push rejected or authentication error?
If Git asks for authentication, make sure you have configured a personal access token or SSH key with your GitHub account. If the push is rejected because the remote has changes you do not have locally, run git pull --rebase origin main first, then push again.
help me fix Git push authentication or rejection errors
- Open your browser and navigate to your GitHub repository.
- Click the Actions tab at the top of the repository page.
You should see a workflow run titled Add vulnerable app and security pipeline in progress or already completed. The pipeline has 4 jobs, and all 4 will show red failure icons.
Why are all 4 jobs failing?
This is exactly what you want. The deliberately vulnerable app contains issues that each scanner is designed to catch. A failing pipeline means your security gates are working correctly. In the next step, you will fix these vulnerabilities and watch the pipeline turn green.
Review the scan results in GitHub Actions logs
Each failed job's log tells you exactly what the scanner found. Understanding these findings is the first step toward remediation.
- In the Actions tab, click on the workflow run titled Add vulnerable app and security pipeline.
- Click the Secrets Detection job to expand its logs.
You should see Gitleaks reporting findings for the hardcoded values in app.py like SECRET_KEY, DB_PASSWORD, and API_KEY.
- Click the back arrow to return to the workflow run summary.
- Click the SAST - Semgrep job to view its findings.
Semgrep reports the SQL injection vulnerability in the search_users function where user input is concatenated directly into a SQL query string.
- Navigate back and click the Dependency Scan - Trivy job.
Trivy reports known vulnerabilities (CVEs) in the outdated packages listed in requirements.txt.
- Navigate back and click the Container Scan - Trivy job.
The container scan reports vulnerabilities in the full python:3.12 base image, which includes many OS-level packages your app does not need.
What did each scanner catch?
- Gitleaks found the hardcoded secrets (SECRET_KEY, DB_PASSWORD, API_KEY) that should never be committed to source control.
- Semgrep found the SQL injection pattern where an f-string builds a query from unsanitized user input.
- Trivy filesystem scan found known CVEs in the outdated flask==2.0.1 and werkzeug==2.0.1 packages.
- Trivy container scan found OS-level vulnerabilities in the full python:3.12 image and flagged that the container runs as root.
Not seeing all 4 jobs?
If you only see some jobs, check that your devsecops.yml file is saved in the correct directory (.github/workflows/) and that the YAML indentation is correct. Each job definition must be at the same indentation level under jobs:.
help me figure out why some GitHub Actions jobs are not showing up in my workflow
Your security pipeline is working exactly as designed. It caught 4 categories of vulnerabilities across your entire application stack. Next up, you will fix each of these findings and turn the pipeline green.
Remediate the Vulnerabilities
Your pipeline did its job. It caught four categories of security issues before any of them reached production. Now you get to experience the second half of the DevSecOps lifecycle: remediation.
In this step, you will fix each vulnerability class one by one. You will remove hardcoded secrets, replace the SQL injection with a parameterized query, upgrade outdated dependencies, and harden the Docker container.
In this step, get ready to:
- Fix the application code in app.py to eliminate secrets and SQL injection.
- Upgrade vulnerable dependencies in requirements.txt.
- Harden the Dockerfile with a slim base image and a non-root user.
Fix the application security flaws
The Gitleaks scan found hardcoded credentials and the Semgrep scan found a SQL injection vulnerability. Both live in app.py.
- In app.py, find the hardcoded secrets block near the top of the file:
# Hardcoded secrets (intentional vulnerability)
SECRET_KEY = "super-secret-key-12345"
DB_PASSWORD = "admin123"
API_KEY = "sk-prod-abc123xyz789"
- Delete these four lines entirely (the comment and the three variable assignments).
Why delete instead of moving to environment variables?
The remediated app does not use these values at all. In a real project, you would move secrets to environment variables or a secrets manager. Here, the variables were only present to trigger the Gitleaks finding.
Now fix the SQL injection. The search_users function uses an f-string to build a SQL query, which lets attackers inject malicious SQL through the username parameter.
- In app.py, scroll down to the search_users function and find these lines:
# SQL injection vulnerability (intentional)
query = f"SELECT * FROM users WHERE username = '{username}'"
users = conn.execute(query).fetchall()
- Replace those three lines with this parameterized query:
query = "SELECT * FROM users WHERE username = ?"
users = conn.execute(query, (username,)).fetchall()
What does the parameterized query do?
The ? placeholder tells Python's sqlite3 module to treat username as data, not as SQL syntax. The database binds the value after parsing the query structure. This makes SQL injection structurally impossible.
- Save app.py.
Getting a syntax error after editing?
Make sure you kept the indentation consistent. The query and users lines should be indented with four spaces inside the function body.
Also confirm you removed the entire f-string line including the comment above it.
help me fix a syntax error in my app.py after removing hardcoded secrets and fixing SQL injection
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py before moving on.
ⓧ I'd like to double check the full code
Here is what your app.py should look like at this point:
import os
import sqlite3
from flask import Flask, request, jsonify
app = Flask(__name__)
DATABASE = "users.db"
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.execute(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, email TEXT)"
)
conn.execute(
"INSERT OR IGNORE INTO users (id, username, email) VALUES (1, 'admin', 'admin@example.com')"
)
conn.commit()
conn.close()
@app.route("/")
def index():
return jsonify({"status": "healthy", "app": "devsecops-demo"})
@app.route("/users", methods=["GET"])
def get_users():
conn = get_db()
users = conn.execute("SELECT * FROM users").fetchall()
conn.close()
return jsonify([dict(row) for row in users])
@app.route("/users/search", methods=["GET"])
def search_users():
username = request.args.get("username", "")
conn = get_db()
query = "SELECT * FROM users WHERE username = ?"
users = conn.execute(query, (username,)).fetchall()
conn.close()
return jsonify([dict(row) for row in users])
if __name__ == "__main__":
init_db()
port = int(os.environ.get("PORT", 5000))
app.run(host="0.0.0.0", port=port)
Upgrade the vulnerable dependencies
The Trivy filesystem scan flagged outdated packages with known CVEs. The fix is straightforward: upgrade to patched versions.
- Replace the entire contents of requirements.txt with the following:
flask==3.1.1
gunicorn==23.0.0
What changed in requirements.txt?
You upgraded flask from 2.0.1 to 3.1.1 and gunicorn from 20.0.4 to 23.0.0. You also removed the explicit werkzeug pin because Flask 3.1.1 manages its own compatible Werkzeug version automatically.
- Save requirements.txt.
Not sure which versions to use?
The versions above are the latest stable releases at the time of this project. If Trivy still flags a version in the future, check PyPI for the latest patched release of each package.
help me figure out which package versions to use to pass the Trivy dependency scan
Harden the Docker container
The Trivy container scan found two problems: the full python:3.12 base image ships hundreds of unnecessary packages with known vulnerabilities, and the container runs as root by default. Both are common misconfigurations in production containers.
- Replace the entire contents of your Dockerfile with the following hardened version:
FROM python:3.12-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
What does this Dockerfile do differently?
- python:3.12-slim uses a minimal Debian base with far fewer pre-installed packages. This drastically reduces the number of known CVEs Trivy finds.
- groupadd -r appuser && useradd -r -g appuser appuser creates a dedicated non-root user and group.
- chown -R appuser:appuser /app gives that user ownership of the app directory.
- USER appuser switches to the non-root user. Everything after this line, including the final CMD, runs as appuser instead of root.
- Save the Dockerfile.
- Verify the Docker image builds successfully by running:
docker build -t devsecops-demo:local .
- You should see a successful build ending with a tagged image name.
Build failing with a pip error?
Make sure you saved requirements.txt with the updated versions before building. The pip install step reads from the file on disk.
Also check that your Dockerfile starts with FROM python:3.12-slim (not FROM python:3.12).
help me fix a Docker build error for my DevSecOps project
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full Dockerfile
Here is what your Dockerfile should look like:
FROM python:3.12-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
- Commit all your remediated changes by running:
git add .
git commit -m "Fix security vulnerabilities across all 4 scan categories"
Why commit but not push yet?
Committing locally saves a clean checkpoint. In the next step, you will push to GitHub and watch all four security gates turn green.
All four vulnerability categories are now remediated. Next up, you will push to GitHub and watch the pipeline go green for the first time.
Verify the Hardened Pipeline
In the previous step, you remediated all four categories of security findings: hardcoded secrets, SQL injection, vulnerable dependencies, and container misconfigurations. Your code is now hardened against the threats the pipeline detected.
Now it's time to prove that your fixes actually work. You'll push the remediated code to GitHub, watch all four security gates turn green, and review the threshold configuration that makes this pipeline production-ready.
In this step, get ready to:
- Push the hardened code to GitHub and confirm all 4 security gates pass.
- Review the security gate threshold configuration.
- Understand the final pipeline as a production-ready DevSecOps template.
Push the hardened code to GitHub
Your remediated code is committed locally but hasn't reached GitHub yet. Once you push, the pipeline triggers automatically and runs all four security scans against your hardened code.
- Push your committed changes to GitHub by running this command:
git push
What happens when you push?
Your workflow file has on: push: branches: [main] configured. This means every push to main automatically triggers the pipeline. All four security jobs start in parallel immediately.
- Open your repository on GitHub in your browser.
- Click the Actions tab at the top of the repository page.
- Click on the latest workflow run (it should say "DevSecOps Pipeline" with a yellow circle indicating it's in progress, or a green checkmark if it already completed).
Wait for the pipeline to finish. You should see all 4 jobs with green checkmarks: Secrets Detection, SAST - Semgrep, Dependency Scan - Trivy, and Container Scan - Trivy.
Still seeing a red X on one of the jobs?
Click on the failing job to view its logs. The log output tells you exactly which finding triggered the failure.
Common causes: a leftover hardcoded secret that wasn't removed, requirements.txt still listing an outdated version, or the Dockerfile still using the python:3.12 full image instead of python:3.12-slim.
Why is my DevSecOps pipeline still failing after remediation?
Review the security gate threshold configuration
Your pipeline already has threshold configuration built in. The key settings that control when the pipeline fails or passes live in the Trivy job definitions.
- In the Actions tab, click on the Dependency Scan - Trivy job to expand its logs.
- Look for the severity filter in the log output. You'll see that only HIGH and CRITICAL vulnerabilities were checked.
This behaviour comes from two lines in your workflow file:
exit-code: '1'
severity: 'HIGH,CRITICAL'
How do these threshold settings work?
exit-code: '1' tells Trivy to return a non-zero exit code when it finds vulnerabilities matching the filter. A non-zero exit code causes the GitHub Actions job to fail.
severity: 'HIGH,CRITICAL' limits the scan to only HIGH and CRITICAL findings. MEDIUM and LOW vulnerabilities are not reported by the scan, so they won't block your pipeline.
This is a common production pattern. Teams start by blocking only CRITICAL findings, then expand to include HIGH once they've resolved the initial backlog. This prevents alert fatigue while still catching dangerous issues.
The container scan job also uses ignore-unfixed: true. This means vulnerabilities with no available patch are skipped, so your pipeline only blocks on issues you can actually fix.
Review the final pipeline architecture
Take a step back and look at what you've built. Your pipeline runs four independent security scans every time code is pushed to main or a pull request is opened.
- Return to the Actions tab and look at the workflow visualization graph. Notice that all four jobs run in parallel with no dependencies between them.
- Click on any job name to expand its steps and review the log output one more time.
Why do all 4 jobs run in parallel?
None of the four jobs have a needs: field, so GitHub Actions starts them all simultaneously. This minimizes total pipeline duration because the slowest job determines how long you wait, not the sum of all jobs.
In production, you might add a final "deploy" job that uses needs: [secrets-scan, sast-scan, dependency-scan, container-scan] so it only runs after all four gates pass.
What makes this a production-ready template?
- Secrets Detection (Gitleaks) catches credentials before they reach your repo history.
- SAST (Semgrep) finds code-level vulnerabilities like SQL injection and command injection.
- Dependency Scanning (Trivy filesystem mode) flags known-vulnerable packages in your requirements.txt.
- Container Scanning (Trivy image mode) checks the built Docker image for OS-level vulnerabilities and misconfigurations.
You can drop this workflow file into any project repository and immediately get four layers of automated security coverage.
✔️ Awesome, I've got everything!
Great. Make sure you've saved a screenshot of those four green checkmarks for your validation.
ⓧ I'd like to double check the full code
Here is the complete .github/workflows/devsecops.yml file for reference:
name: DevSecOps Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
secrets-scan:
name: Secrets Detection
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sast-scan:
name: SAST - Semgrep
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep scan --config auto --error .
dependency-scan:
name: Dependency Scan - Trivy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy filesystem scan
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
exit-code: '1'
severity: 'HIGH,CRITICAL'
container-scan:
name: Container Scan - Trivy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devsecops-demo:${{ github.sha }} .
- name: Run Trivy image scan
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: 'devsecops-demo:${{ github.sha }}'
exit-code: '1'
severity: 'HIGH,CRITICAL'
ignore-unfixed: true
And the complete app.py:
import os
import sqlite3
from flask import Flask, request, jsonify
app = Flask(__name__)
DATABASE = "users.db"
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.execute(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, email TEXT)"
)
conn.execute(
"INSERT OR IGNORE INTO users (id, username, email) VALUES (1, 'admin', 'admin@example.com')"
)
conn.commit()
conn.close()
@app.route("/")
def index():
return jsonify({"status": "healthy", "app": "devsecops-demo"})
@app.route("/users", methods=["GET"])
def get_users():
conn = get_db()
users = conn.execute("SELECT * FROM users").fetchall()
conn.close()
return jsonify([dict(row) for row in users])
@app.route("/users/search", methods=["GET"])
def search_users():
username = request.args.get("username", "")
conn = get_db()
query = "SELECT * FROM users WHERE username = ?"
users = conn.execute(query, (username,)).fetchall()
conn.close()
return jsonify([dict(row) for row in users])
if __name__ == "__main__":
init_db()
port = int(os.environ.get("PORT", 5000))
app.run(host="0.0.0.0", port=port)
The requirements.txt:
flask==3.1.1
gunicorn==23.0.0
And the Dockerfile:
FROM python:3.12-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
Secret mission
Write a Custom Semgrep Rule
Your pipeline uses Semgrep's built-in rulesets, but every codebase has its own anti-patterns. What if you could teach Semgrep to catch project-specific mistakes that no public ruleset covers? In this mission, you'll write a custom Semgrep rule from scratch and wire it into your pipeline.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on GitHub Actions with a public repository, so there are no ongoing costs. Decide whether to keep your resources available, pause them, or delete them entirely.
Resources you used:
- GitHub repository (your DevSecOps pipeline repo).
- Local project files (the cloned repository folder on your Desktop).
Keep everything running
No action needed. Choose this if you plan to keep iterating on your pipeline, adding more security tools, or using this as a template for other projects.
- Your public GitHub repository will continue running the DevSecOps pipeline on every push at no cost.
- Your local project files remain on your Desktop for future editing.
Pause - I'll come back to this later
Free up local disk space while keeping your remote repository intact on GitHub.
- Your GitHub repository stays available. You can clone it again anytime.
- Delete the local project folder to free up space. You can re-clone the repository when you return.
Remove the local folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf devsecops-demo
On Windows, run:
cd ~\Desktop
rmdir /s /q devsecops-demo
Delete - I don't want to use this again
Remove all project resources completely. This cannot be undone.
- Delete your GitHub repository by navigating to your repository page on GitHub.
- Click Settings in the repository navigation bar.
- Scroll down to the Danger Zone section on the General settings page.
- Click Delete this repository.
- Click I want to delete this repository.
- Read the warnings and click I have read and understand these effects.
- Type the repository name to confirm, then click Delete this repository.
Delete the local project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf devsecops-demo
On Windows, run:
cd ~\Desktop
rmdir /s /q devsecops-demo
Nice Work!
Nice Work!
You just built a production-grade DevSecOps pipeline from scratch! Starting with a deliberately vulnerable Flask application, you layered in four automated security gates that catch real vulnerabilities on every single push.
You've learned how to:
- Build a 4-stage security pipeline that runs secrets detection, SAST, dependency scanning, and container scanning in parallel on every push.
- Remediate real security findings across the full DevSecOps lifecycle, from hardcoded secrets to SQL injection to vulnerable containers.
- Create a reusable GitHub Actions pipeline template using industry-standard tools: Gitleaks, Semgrep, and Trivy.
- Write a custom Semgrep rule to enforce project-specific security patterns in your pipeline.
Ready to quiz yourself?