DevSecOps CI/CD Pipeline

Automate security scanning for secrets, code flaws, and CVEs in CI/CD.

Introduction

30 Second Summary

Every team shipping software has accidentally leaked an API key or deployed a container full of known vulnerabilities to production. The teams that catch these issues early use automated security pipelines that scan every code change the moment it's pushed.

In this project, you will build a DevSecOps pipeline using GitHub Actions that automatically scans your code for secrets, vulnerabilities, and container security issues every time you push or open a pull request.

What You'll Build

A complete security pipeline that runs three scanners in parallel on every push and pull request to your GitHub repository.

By the end of this project, you'll have:

  • A GitHub Actions workflow with three parallel security scanning jobs: secret detection with Gitleaks, SAST with Semgrep, and container scanning with Trivy.
  • A deliberately vulnerable Python Flask application packaged in Docker, designed to trigger real security findings from each scanner.
  • SARIF results uploaded to GitHub's Security tab for centralized visibility of all findings across every scan.
  • Secret Mission: Add Infrastructure-as-Code scanning with Checkov to catch Terraform misconfigurations before deployment.

Are there any prerequisites?

You should be comfortable with basic Git commands (clone, commit, push) and have a GitHub account ready. Familiarity with the command line is helpful, but every command is provided for you to copy.

Install Docker Desktop

Before you can build and scan container images, you need a container runtime on your machine. Docker Desktop packages everything you need to build, run, and manage containers on Windows.

In this step, you will enable the Windows Subsystem for Linux (WSL 2) backend that Docker Desktop requires, install Docker Desktop itself, and confirm that containers can run successfully on your machine.

In this step, get ready to:
  • Enable WSL 2 on your Windows machine.
  • Download and install Docker Desktop with the WSL 2 backend.
  • Verify Docker is working by running a test container.
Enable WSL 2 on Windows

Docker Desktop on Windows uses WSL 2 as its container backend. WSL 2 runs a real Linux kernel inside a lightweight virtual machine, which is how Docker containers execute on your Windows machine.

  • Press the Windows key to open your search bar.
  • Type PowerShell, right-click Windows PowerShell, and select Run as administrator.
  • A User Account Control prompt will appear asking if you want to allow this app to make changes. Click Yes.
  • Enable WSL 2 and the Virtual Machine Platform by running this command:
wsl --install

What does this command do?

The wsl --install command does several things in one step. It enables the Windows Subsystem for Linux feature, enables the Virtual Machine Platform, downloads the latest Linux kernel, sets WSL 2 as the default version, and installs the Ubuntu distribution.

  • When the command finishes, restart your computer to complete the WSL 2 setup.
  • After your computer restarts, Ubuntu may open a terminal window asking you to create a username and password. Enter any username and password you like. This is your Linux user account inside WSL.
  • Open PowerShell again (no need for Administrator this time) and verify WSL is working by running:
wsl --version

You should see version information for WSL, including a WSL version number of 2.1.5 or higher.

Not seeing a version number?

If you see an error or the command is not recognized, make sure you restarted your computer after running wsl --install. The restart is required to finish enabling the WSL features.

If you restarted and still see issues, make sure hardware virtualization is enabled in your BIOS settings. Look for Intel VT-x or AMD-V in your BIOS menu.

Help me troubleshoot my WSL 2 installation on Windows.

Download and install Docker Desktop

With WSL 2 ready, you can now install Docker Desktop. The per-user installation mode does not require administrator privileges for future updates.

  • Open your browser and navigate to the Docker Desktop download page:
  • https://docs.docker.com/desktop/setup/install/windows-install/
  • Click the Docker Desktop for Windows - x86_64 download button at the top of the page.
  • Once the download finishes, double-click Docker Desktop Installer.exe to run the installer.
  • The installer will ask which installation mode you prefer. Select the per-user option (recommended). This installs Docker Desktop without requiring administrator privileges.
  • On the Configuration page, ensure the Use WSL 2 instead of Hyper-V option is selected.
  • Follow the remaining prompts and click Close when the installation completes.

Why WSL 2 instead of Hyper-V?

WSL 2 is the recommended backend for Docker Desktop. It uses fewer system resources than a full Hyper-V virtual machine and starts containers faster. The per-user installation mode only supports WSL 2, which is exactly what you need.

  • Start Docker Desktop by pressing the Windows key, typing Docker Desktop, and pressing Enter.
  • Docker Desktop may take a minute to start. Wait until the whale icon in your system tray stops animating. You may also need to accept the Docker Subscription Service Agreement on first launch.

Docker Desktop won't start?

If Docker Desktop fails to start or shows a WSL-related error, open PowerShell and run wsl --update to make sure your WSL installation is up to date. Then try starting Docker Desktop again.

Help me get Docker Desktop running on Windows with WSL 2.

Verify Docker is working

The final check is to confirm Docker can pull images and run containers. You will run two quick commands to verify everything is connected.

  • Open a new terminal window (PowerShell or Command Prompt) and check the Docker version by running:
docker --version

You should see a version string like Docker version 28.x.x (the exact number will vary).

  • Now test that Docker can pull and run a container by running:
docker run hello-world

Docker will download a small test image and run it. You should see a message that starts with Hello from Docker! confirming that your installation is working correctly.

Seeing an error when running docker commands?

If you see a message about the Docker daemon not running, make sure Docker Desktop is fully started. Look for the whale icon in your system tray and wait until it stops animating.

If you see a permission error, close your terminal and open a fresh one. Docker Desktop needs a new terminal session to pick up the updated PATH.

Help me fix Docker command errors on Windows.

Docker is installed and running. Next up, you will create a deliberately vulnerable test application that your security pipeline will scan.

Create the Vulnerable Test Application

Your Docker environment is ready. Now you need an application to scan.

Security scanners are only useful when they have something to inspect. In this step, you will build a deliberately vulnerable Python web application, package it into a Docker container, and verify the whole thing runs locally. These intentional vulnerabilities will give each scanner in your pipeline something to detect in the next step.

In this step, get ready to:
  • Create a GitHub repository and clone it locally.
  • Write a Flask app with intentional security flaws.
  • Containerize the app and test it with Docker.
Create the repository and clone it locally

Every DevSecOps pipeline needs a code repository to watch. You will create a new GitHub repository and clone it to your machine so you can add files to it.

  • Open your browser and go to github.com/new.
  • Enter devsecops-pipeline in the **Repository name** field.
  • Leave the repository set to Public.
  • Leave all other options unchecked (no README, no .gitignore).
  • Click Create repository.

Why public?

GitHub's Security tab and SARIF upload features work on public repositories without requiring GitHub Advanced Security. Keeping the repo public means your pipeline results appear in the Security tab for free.

GitHub will show a quick setup page with your repository URL. Keep this page open.

  • Switch to your terminal and 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"]]
  • Move into the cloned folder by running:
cd devsecops-pipeline
  • Open the project in VS Code by running:
code .

What does code . do?

The code command launches VS Code from the terminal. The . tells it to open the current directory as a workspace. On Windows, the VS Code installer adds code to your PATH automatically.

VS Code opens with an empty devsecops-pipeline folder in the Explorer sidebar.

code command not found?

If you see an error when running code ., VS Code may not be on your PATH. Open VS Code manually, press Ctrl+Shift+P to open the Command Palette, type Shell Command: Install 'code' command in PATH, and select it. Then restart your terminal and try again.

Help me fix the code command not being recognized in my terminal.

Write the vulnerable Flask application

Your app needs three types of vulnerabilities for the three scanners to detect. Hardcoded secrets trigger Gitleaks. Unsafe code patterns trigger Semgrep. An outdated base image triggers Trivy.

  • In the VS Code Explorer sidebar, right-click and select New File.
  • Name the file app.py and press Enter.
  • Paste the following code into app.py:
from flask import Flask, request
import sqlite3
import os

app = Flask(__name__)

# INTENTIONAL VULNERABILITY: Hardcoded secret (Gitleaks will catch this)
DATABASE_PASSWORD = "super_secret_password_123"
API_KEY = "AKIAIOSFODNN7EXAMPLE"

def get_db():
    conn = sqlite3.connect("app.db")
    return conn

@app.route("/")
def home():
    return "Welcome to the DevSecOps Demo App"

What does this code do?

This is the foundation of your Flask web application. It imports the required libraries and creates the app instance.

The DATABASE_PASSWORD and API_KEY variables are intentionally hardcoded secrets. In production, these would be stored in environment variables or a secrets manager. Gitleaks will flag these when it scans the repository.

  • Below the home() function, add the user lookup route:
@app.route("/user")
def get_user():
    # INTENTIONAL VULNERABILITY: SQL Injection (Semgrep will catch this)
    username = request.args.get("name")
    conn = get_db()
    cursor = conn.cursor()
    query = f"SELECT * FROM users WHERE username = '{username}'"
    cursor.execute(query)
    result = cursor.fetchone()
    conn.close()
    return str(result)

Why is this vulnerable?

The get_user() function builds a SQL query by directly inserting user input into the string with an f-string. An attacker could pass ' OR 1=1 -- as the name parameter to bypass authentication or extract data. This is a classic SQL injection vulnerability that Semgrep's SAST rules will detect.

  • Below the get_user() function, add the debug route and the main entry point:
@app.route("/debug")
def debug_info():
    # INTENTIONAL VULNERABILITY: Information disclosure
    return {
        "database_password": DATABASE_PASSWORD,
        "environment": dict(os.environ),
    }

if __name__ == "__main__":
    # INTENTIONAL VULNERABILITY: Debug mode enabled in production
    app.run(host="0.0.0.0", port=5000, debug=True)

What does this code do?

The /debug route exposes the database password and all environment variables to anyone who visits it. This is an information disclosure vulnerability.

The debug=True flag at the bottom enables Flask's interactive debugger in production. An attacker could use this to execute arbitrary code on the server.

  • Save app.py by pressing Ctrl+S.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py and move on to the next file.

ⓧ I'd like to double check the full code

from flask import Flask, request
import sqlite3
import os

app = Flask(__name__)

# INTENTIONAL VULNERABILITY: Hardcoded secret (Gitleaks will catch this)
DATABASE_PASSWORD = "super_secret_password_123"
API_KEY = "AKIAIOSFODNN7EXAMPLE"

def get_db():
    conn = sqlite3.connect("app.db")
    return conn

@app.route("/")
def home():
    return "Welcome to the DevSecOps Demo App"

@app.route("/user")
def get_user():
    # INTENTIONAL VULNERABILITY: SQL Injection (Semgrep will catch this)
    username = request.args.get("name")
    conn = get_db()
    cursor = conn.cursor()
    query = f"SELECT * FROM users WHERE username = '{username}'"
    cursor.execute(query)
    result = cursor.fetchone()
    conn.close()
    return str(result)

@app.route("/debug")
def debug_info():
    # INTENTIONAL VULNERABILITY: Information disclosure
    return {
        "database_password": DATABASE_PASSWORD,
        "environment": dict(os.environ),
    }

if __name__ == "__main__":
    # INTENTIONAL VULNERABILITY: Debug mode enabled in production
    app.run(host="0.0.0.0", port=5000, debug=True)

Now create the dependencies file.

  • In the VS Code Explorer sidebar, right-click and select New File.
  • Name the file requirements.txt and press Enter.
  • Paste the following into requirements.txt:
flask==2.3.2

Why pin to an older version?

Pinning Flask to 2.3.2 means the dependency tree includes older packages with known vulnerabilities. Trivy will detect these CVEs when it scans the Docker image.

  • Save requirements.txt by pressing Ctrl+S.
Containerize and test the application

A Dockerfile tells Docker how to package your application into an image. You will use an older base image so Trivy has OS-level vulnerabilities to find during scanning.

  • In the VS Code Explorer sidebar, right-click and select New File.
  • Name the file Dockerfile (no extension) and press Enter.
  • Paste the following into 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"]

What does this Dockerfile do?

  • FROM python:3.9-slim starts from a Python 3.9 base image. This older image contains OS packages with known vulnerabilities for Trivy to find.
  • WORKDIR /app sets the working directory inside the container.
  • COPY and RUN pip install install the Python dependencies first, then copy the app code. This layering pattern lets Docker cache the dependency layer.
  • EXPOSE 5000 documents which port the app listens on.
  • CMD tells Docker what command to run when the container starts.
  • Save Dockerfile by pressing Ctrl+S.

Now build the Docker image to confirm everything is wired up correctly.

  • Open the VS Code integrated terminal by pressing Ctrl+` (backtick key).
  • Build the Docker image by running this command:
docker build -t devsecops-app .

What does this command do?

The -t devsecops-app flag names (tags) the image so you can reference it later. The . at the end tells Docker to look for the Dockerfile in the current directory.

Docker will download the Python base image and install Flask. This may take a minute or two on the first run.

  • Once the build finishes, run the container to verify the app starts:
docker run -p 5000:5000 devsecops-app

What does -p 5000:5000 do?

The -p flag maps port 5000 on your machine to port 5000 inside the container. This lets you access the Flask app from your browser at http://localhost:5000.

  • Open your browser and go to http://localhost:5000.

You should see the text "Welcome to the DevSecOps Demo App" in your browser.

  • Switch back to your terminal and press Ctrl+C to stop the container.

Don't see the welcome message?

Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray.

If you see a port conflict error, another process may already be using port 5000. Stop the other process or try docker run -p 5001:5000 devsecops-app and visit http://localhost:5001 instead.

Help me troubleshoot my Docker container not starting.

Your vulnerable test application is containerized and running. Next up, you will build the GitHub Actions pipeline that automatically scans this code every time you push.

Build the Security Scanning Pipeline

Your vulnerable app is containerized and ready to scan. Now you need an automated system that checks every code change for security issues before it reaches production.

In this step, you'll create a GitHub Actions workflow that runs three security scanners in parallel every time you push code or open a pull request. Each scanner targets a different attack surface: leaked secrets, code vulnerabilities, and container image weaknesses.

In this step, get ready to:
  • Create a GitHub Actions workflow file with triggers and permissions.
  • Add a secret detection job using Gitleaks.
  • Add a SAST scanning job using Semgrep.
  • Add a container scanning job using Trivy.
Create the workflow file with the Gitleaks secret scanning job

GitHub Actions workflows live in a special .github/workflows/ directory inside your repository. GitHub automatically detects any .yml file in this folder and runs it based on the triggers you define.

  • In your terminal, create the workflow directory by running these commands:
mkdir -p .github/workflows
  • Confirm the directory exists by running:
ls .github/workflows

You should see an empty directory listed with no errors.

  • Create a new file called security.yml inside the .github/workflows/ directory.
  • In VS Code, right-click the .github/workflows folder in the Explorer sidebar, then select New File.
  • Name the file security.yml.
  • Paste the following code into security.yml:
name: DevSecOps Security Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read
  security-events: write

jobs:
  secret-scanning:
    name: Secret Detection (Gitleaks)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v3
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

What does this code do?

  • on: push and on: pull_request tell GitHub to run this workflow whenever code is pushed to main or a PR is opened against it.
  • permissions grants the workflow read access to your code and write access to upload security findings.
  • fetch-depth: 0 tells the checkout action to pull your full Git history. Gitleaks needs this to scan all previous commits for leaked secrets, not just the latest one.
  • gitleaks/gitleaks-action@v3 runs the Gitleaks scanner, which pattern-matches against known secret formats (API keys, passwords, tokens) across your entire commit history.

Why does Gitleaks need full history?

A common mistake developers make is committing a secret, then deleting it in the next commit. The secret is still in Git history. By scanning all commits, Gitleaks catches secrets that were "removed" but still exist in your repository's past.

  • Save security.yml.

You won't see a change in your app yet. The workflow only runs once you push to GitHub, which you'll do in the next step.

Add the Semgrep SAST scanning job

SAST (Static Application Security Testing) analyses your source code without running it. Semgrep is an open-source SAST tool that matches code patterns against a database of known vulnerability signatures.

  • In security.yml, place your cursor at the end of the file (after the last line of the secret-scanning job).
  • Add the following job below the secret-scanning job, making sure it's indented at the same level (two spaces from the left margin, under jobs:):
  sast-scanning:
    name: SAST (Semgrep)
    runs-on: ubuntu-latest
    container:
      image: semgrep/semgrep
    steps:
      - uses: actions/checkout@v6
      - name: Run Semgrep SAST scan
        run: semgrep scan --config auto --sarif --output semgrep.sarif
      - name: Upload Semgrep SARIF
        uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: semgrep.sarif

What does this code do?

  • container: image: semgrep/semgrep runs this job inside Semgrep's official Docker container, which comes pre-installed with everything it needs. No account or login required.
  • semgrep scan --config auto tells Semgrep to automatically select the best scanning rules for your project's languages.
  • --sarif --output semgrep.sarif outputs results in SARIF format (Static Analysis Results Interchange Format), which is the standard format GitHub uses to display security findings.
  • upload-sarif@v4 pushes those results to GitHub's Security tab so you can see all findings in one place.
  • if: always() ensures the upload still happens even if Semgrep finds vulnerabilities and returns a non-zero exit code.
  • Save security.yml.

You won't see a change yet. The next chunk adds the final scanner.

Add the Trivy container scanning job

Your Docker image contains an OS, Python packages, and your application code. Trivy scans all of those layers for known CVEs (Common Vulnerabilities and Exposures) that attackers could exploit.

  • In security.yml, place your cursor at the end of the file (after the last line of the sast-scanning job).
  • Add the following job below the sast-scanning job at the same indentation level:
  container-scanning:
    name: Container Scan (Trivy)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - 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: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
      - name: Upload Trivy SARIF
        uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: trivy-results.sarif

What does this code do?

  • docker build -t devsecops-app:${{ github.sha }} builds your Docker image right inside the GitHub Actions runner, tagging it with the commit hash for traceability.
  • aquasecurity/trivy-action@v0.36.0 runs the Trivy scanner against the freshly built image. The version is pinned to v0.36.0 for security.
  • severity: CRITICAL,HIGH filters results to only report serious vulnerabilities, keeping your findings actionable.
  • The SARIF upload works the same way as Semgrep. All three scanners feed their results into one unified Security tab on GitHub.

Why pin the Trivy action version?

In March 2026, trivy-action experienced a supply chain attack where tags were force-pushed to malicious code. Pinning to v0.36.0 (a verified safe release) protects your pipeline from similar attacks in the future.

  • Save security.yml.

Your complete workflow file now has three parallel security scanning jobs.

YAML indentation errors?

YAML is very sensitive to spacing. Each job (secret-scanning, sast-scanning, container-scanning) must be indented exactly two spaces under jobs:.

If you're unsure about indentation, compare your file against the full code in the tab below. Use spaces (not tabs) for YAML indentation.

Help me fix my YAML indentation in security.yml

✔️ Awesome, I've got everything!

Great. Double check you've saved security.yml and you're ready to push to GitHub in the next step.

ⓧ I'd like to double check the full code

Here's the complete security.yml file. Make sure your file matches this exactly:

name: DevSecOps Security Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read
  security-events: write

jobs:
  secret-scanning:
    name: Secret Detection (Gitleaks)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v3
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  sast-scanning:
    name: SAST (Semgrep)
    runs-on: ubuntu-latest
    container:
      image: semgrep/semgrep
    steps:
      - uses: actions/checkout@v6
      - name: Run Semgrep SAST scan
        run: semgrep scan --config auto --sarif --output semgrep.sarif
      - name: Upload Semgrep SARIF
        uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: semgrep.sarif

  container-scanning:
    name: Container Scan (Trivy)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - 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: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
      - name: Upload Trivy SARIF
        uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: trivy-results.sarif

Your security pipeline is defined and ready to run. Next up, you'll push this code to GitHub and watch all three scanners fire in parallel, catching the vulnerabilities you planted in your app.

Trigger the Pipeline and Review Findings

Your security pipeline is built and ready to go. Now it's time to push your code to GitHub and watch the three scanners catch the intentional vulnerabilities you planted.

This is the moment that defines a DevSecOps workflow. Instead of waiting for a manual review or a production incident, your pipeline catches secrets, code flaws, and container vulnerabilities automatically on every push.

In this step, get ready to:
  • Commit and push all project files to trigger the pipeline.
  • Review security findings from all three scanners.
  • Fix one vulnerability from each scanner and verify the pipeline shows fewer findings.
Commit and push your code to GitHub

Your workflow triggers on pushes to the main branch. Committing and pushing your files will automatically kick off the security pipeline.

  • Stage all project files by running this command in your terminal:
git add .
  • Commit the files with a descriptive message by running:
git commit -m "Add vulnerable demo app and security scanning pipeline"
  • Push the commit to GitHub by running:
git push origin main

What happens when you push?

Your workflow file at .github/workflows/security.yml has on: push: branches: [main] configured. GitHub detects this workflow and immediately starts running its three jobs in parallel.

Each job runs on its own fresh Ubuntu runner. They don't wait for each other, so all three scanners execute simultaneously.

  • Open your repository on GitHub in your browser.
  • Click the Actions tab at the top of your repository.
  • Click on the workflow run named Add vulnerable demo app and security scanning pipeline to open the run summary.

You should see three jobs listed: Secret Detection (Gitleaks), SAST (Semgrep), and Container Scan (Trivy). Each shows a status icon indicating whether it's in progress, passed, or failed.

What do the status icons mean?

A green checkmark means the job passed with no findings. A red X means the job failed because it detected security issues. A yellow circle means the job is still running.

For this project, you should expect the Secret Detection (Gitleaks) job to fail (red X) because it found the hardcoded AWS key in app.py. The SAST (Semgrep) and Container Scan (Trivy) jobs upload SARIF results to the Security tab regardless of their exit status.

Don't see the workflow running?

Make sure your workflow file is at the exact path .github/workflows/security.yml. GitHub only picks up workflows in the .github/workflows/ directory.

Also confirm you pushed to the main branch. The workflow only triggers on pushes to main.

Why isn't my GitHub Actions workflow appearing in the Actions tab?

Review security findings in the GitHub Security tab

Your scanners have done their work. Now you need to understand what they found and how to interpret the results.

  • In the Actions tab, click on the Secret Detection (Gitleaks) job to expand its logs.
  • Look for the leaked secret findings in the job output. Gitleaks should have flagged the API_KEY value AKIAIOSFODNN7EXAMPLE in app.py because it matches the AWS access key ID pattern.

Why does Gitleaks fail the job?

Gitleaks is configured to exit with a non-zero code when it finds secrets. This fails the job, which is exactly what you want in a real pipeline. A failed job blocks the merge, preventing secrets from reaching production.

The other two scanners (Semgrep and Trivy) upload their results as SARIF files to GitHub's Security tab. You can view those findings in a centralized dashboard.

  • Navigate back to your repository's main page.
  • Click the Security tab. If you don't see it, click the dropdown menu (the three dots) and select Security.
  • In the left sidebar, click Code scanning.

You should see alerts from both Semgrep and Trivy listed here. Semgrep found the SQL injection vulnerability in the get_user function. Trivy identified CVEs in the python:3.9-slim base image and its system packages.

What is SARIF?

SARIF (Static Analysis Results Interchange Format) is a standard JSON format for reporting security scanner results. It allows different tools to upload findings to the same dashboard.

This is why both Semgrep and Trivy results appear together under Code scanning. Despite being completely different tools, they both output SARIF that GitHub understands.

  • Click on one of the Semgrep alerts to see the detailed view. You should see the exact line of code flagged, the severity level, and a description of the vulnerability.

Not seeing alerts in the Security tab?

SARIF uploads require the security-events: write permission in your workflow. Check that your security.yml has this permission set at the top level.

Also note that SARIF uploads can take a minute or two to process after the workflow completes. Refresh the Security tab after waiting.

Help me figure out why my SARIF results aren't appearing in the GitHub Security tab.

Fix vulnerabilities and re-run the pipeline

Finding vulnerabilities is only half the job. A DevSecOps engineer also remediates them. You'll fix one finding from each scanner and push the changes to verify the pipeline shows improvement.

  • In app.py, find this line:
API_KEY = "AKIAIOSFODNN7EXAMPLE"
  • Replace it with a safe environment variable lookup, so the line now looks like this:
API_KEY = os.environ.get("API_KEY", "not-set")

Why use environment variables for secrets?

Hardcoded secrets in source code get committed to Git history permanently. Even if you delete the line later, the secret remains in older commits. Environment variables keep secrets out of your codebase entirely.

  • Next, find the SQL injection vulnerability in the get_user function. Locate these lines:
    query = f"SELECT * FROM users WHERE username = '{username}'"
    cursor.execute(query)
  • Replace them with a parameterized query, so they now look like this:
    query = "SELECT * FROM users WHERE username = ?"
    cursor.execute(query, (username,))

Why do parameterized queries fix SQL injection?

With f-strings, user input is inserted directly into the SQL statement. An attacker could type ' OR 1=1 -- as a username to bypass authentication.

Parameterized queries treat user input as data, never as executable SQL. The database driver escapes any special characters automatically.

  • Finally, open Dockerfile and find the base image line:
FROM python:3.9-slim
  • Replace it with a newer base image that has fewer known CVEs:
FROM python:3.11-slim

Why does updating the base image reduce CVEs?

Container base images include OS-level packages (OpenSSL, glibc, etc.) that accumulate vulnerabilities over time. Newer image tags ship with patched versions of these packages, reducing the number of known CVEs that Trivy reports.

  • Save both app.py and Dockerfile.
  • Commit and push your fixes by running these commands:
git add .
git commit -m "Fix security findings: remove hardcoded key, fix SQL injection, update base image"
git push origin main
  • Return to the Actions tab on GitHub. A new workflow run should appear for your fix commit.
  • Wait for it to complete, then check the Security tab again. You should see fewer code scanning alerts than before.

What should change after the fix?

Gitleaks should no longer flag the AWS key pattern since you replaced it with an environment variable lookup. Semgrep should no longer report the SQL injection because the parameterized query is a safe pattern. Trivy should report fewer CVEs because the newer base image has more recent patches.

Some findings may remain. That's expected. The DATABASE_PASSWORD is still hardcoded, and older dependencies in the image may still have issues. The point is that you've demonstrated the fix-and-verify cycle.

Pipeline still showing the same findings?

Make sure you saved both files before committing. Run git status to confirm your changes are tracked.

Also note that GitHub may cache some alert states briefly. Give it a minute or two after the new workflow completes before rechecking the Security tab.

My security pipeline is still showing the same findings after I pushed fixes. Help me debug.

You've completed the full DevSecOps cycle: build a pipeline, detect vulnerabilities, remediate findings, and verify the fix. Next up, take on the Secret Mission to add Infrastructure-as-Code scanning to your pipeline.

Secret mission

Add Infrastructure-as-Code Scanning

Your pipeline catches secrets, code vulnerabilities, and container CVEs. But what about the cloud infrastructure those containers deploy to? In this secret mission, add a fourth pillar to your DevSecOps pipeline: Infrastructure-as-Code scanning with Checkov. You'll create a Terraform file with intentional misconfigurations and watch Checkov flag them automatically.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and on GitHub Actions (free for public repos), 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 (contains your workflow and vulnerable app).
  • Local Docker image devsecops-app built during testing.
  • Local project files (the cloned repository on your Desktop).
  • Docker Desktop (installed in Step 1).

Keep everything running

No action needed. Choose this if you're still actively building on this pipeline or plan to add more scanners.

  • Your GitHub repository stays active and the pipeline runs on every push.
  • Docker Desktop continues running in your system tray.
  • Your local project files remain on your Desktop.

Pause - I'll come back to this later

Shut down Docker Desktop to free up system memory. Your files and repository stay intact for when you return.

  • Right-click the Docker Desktop icon in your system tray and select Quit Docker Desktop.
  • Your GitHub repository and pipeline stay intact. The workflow will still run on any future pushes.
  • Your local project files remain on your Desktop.

Delete - I don't want to use this again

Remove all project resources completely. This is permanent and cannot be undone.

Delete the GitHub repository:

  • Go to your devsecops-pipeline repository on GitHub.
  • Click Settings in the top navigation.
  • Scroll to the bottom to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Remove local Docker images:

  • Remove the project image and clean up unused Docker resources by running these commands:
docker rmi devsecops-app
docker system prune -f

Delete local project files:

  • Remove the project folder from your Desktop by running:

macOS

cd ~/Desktop
rm -rf devsecops-pipeline

Windows

cd ~\Desktop
Remove-Item -Recurse -Force devsecops-pipeline

Uninstall Docker Desktop:

  • Open Settings on your Windows machine.
  • Select Apps, then Apps & features.
  • Find Docker Desktop in the list and select Uninstall.

Nice Work!

Nice Work!

You just built a production-style DevSecOps pipeline from scratch. Every push to your repository now triggers automated security scanning that catches secrets, code vulnerabilities, and container CVEs before they reach production.

You've learned how to:

  • Built a GitHub Actions workflow with parallel security scanning jobs that run Gitleaks, Semgrep, and Trivy on every push and pull request.
  • Containerized a Python application with Docker and scanned the resulting image for known CVEs using SARIF reporting to centralize findings in GitHub's Security tab.
  • Practiced the full shift-left security workflow by identifying findings, fixing vulnerabilities, and validating remediations through the pipeline.
  • Added Infrastructure-as-Code scanning with Checkov to catch cloud misconfigurations in Terraform before deployment.

Ready to quiz yourself?