Build a DevSecOps Pipeline

Integrate SAST, SCA, and DAST scanning into a GitHub Actions CI/CD pipeline.

Introduction

30 Second Summary

Every production application is a potential target. Security teams catch vulnerabilities in code, outdated dependencies, and runtime behaviour, but manual reviews don't scale across dozens of daily deployments.

In this project, you will build a deliberately vulnerable Flask application and wire it into a GitHub Actions pipeline that automatically scans for security flaws at three different layers: source code, dependencies, and runtime.

What You'll Build

A complete DevSecOps pipeline that runs SonarQube for static analysis, Trivy for dependency scanning, and OWASP ZAP for dynamic runtime testing inside a single GitHub Actions workflow.

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

  • A deliberately vulnerable Flask application covering five CWE vulnerability classes including SQL injection, XSS, and command injection.
  • A GitHub Actions workflow that orchestrates three industry-standard security scanners in parallel and in sequence.
  • A working DevSecOps pipeline that detects vulnerabilities at three layers: source code (SAST), dependencies (SCA), and runtime (DAST).
  • Secret Mission: Add security gates that fail the pipeline on critical findings and generate a consolidated security report artifact.

Are there any prerequisites?

You should have Python, Git, and a GitHub account ready to go. Familiarity with GitHub Actions workflows and basic Flask applications is expected. All scanning tools run inside GitHub Actions, so there is nothing extra to install locally.

Before We Start

Before you can scan code for security flaws, you need a project to scan. In this step, you will create the repository and file structure that holds your deliberately vulnerable application and your scanning pipeline.

You will set up a public GitHub repository, clone it to your machine, and create the empty files that the next steps will fill with code.

In this step, get ready to:
  • Create a public GitHub repository and clone it locally.
  • Create the project file structure with empty placeholder files.
  • Verify Git is configured and push to the remote repository.
Create a new public GitHub repository

Your DevSecOps pipeline needs a public repository so that GitHub Actions can run for free. A public repo also makes the scan results easy to share.

  • Open github.com/new in your browser to create a new repository.
  • Enter devsecops-pipeline as the Repository name.
  • Select Public for the visibility setting.
  • Under Add .gitignore, select Python from the dropdown.
  • Click Create repository.

Why a Python .gitignore?

The Python .gitignore template prevents common clutter (like __pycache__/ and virtual environment folders) from being committed. It keeps your repository clean from the start.

GitHub redirects you to your new repository page. You will see the .gitignore file listed.

Clone the repository and create the file structure

Now you will clone the repository to your local machine and create the empty files the pipeline needs.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) and type Terminal, then press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your repository by running this command:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/devsecops-pipeline.git
  • Move into the project folder by running:
cd devsecops-pipeline

Seeing a 'repository not found' error?

Double-check that you made the repository public. Also confirm you replaced the username in the URL with your actual GitHub username.

If Git asks for authentication, you may need to set up a personal access token or SSH key for your GitHub account.

Help me troubleshoot my git clone command.

Now create the empty project files and the workflow directory. The commands differ slightly between operating systems.

macOS

  • Create the empty project files and workflow directory by running these commands:
touch app.py requirements.txt sonar-project.properties
mkdir -p .github/workflows

Windows

  • Create the empty project files and workflow directory by running these commands:
New-Item app.py, requirements.txt, sonar-project.properties -ItemType File
New-Item -Path .github/workflows -ItemType Directory -Force

What are these files for?

  • app.py will hold the deliberately vulnerable Flask application.
  • requirements.txt will list Python dependencies with known vulnerabilities for Trivy to detect.
  • sonar-project.properties will configure SonarQube to know how to scan the project.
  • .github/workflows/ is where GitHub Actions looks for workflow definition files.
  • Confirm the files exist by running:
ls

You should see app.py, requirements.txt, and sonar-project.properties listed in the output.

Commit and push to verify your setup

Pushing the empty files to GitHub confirms that your local Git setup can communicate with the remote repository.

  • Stage all new files by running:
git add .
  • Commit the files with a message by running:
git commit -m "Add initial project structure"
  • Push to GitHub by running:
git push

You should see output confirming the push to main. Open your repository on GitHub and confirm that app.py, requirements.txt, and sonar-project.properties appear in the file list.

Push failed or asking for credentials?

If Git prompts for a username and password, note that GitHub no longer accepts account passwords for HTTPS pushes. You need a personal access token (PAT) instead of your password.

Alternatively, switch to SSH by updating your remote URL. Visit your repository on GitHub, click the green Code button, and copy the SSH URL.

Help me push my code to GitHub.

Your repository is ready and your local environment can push to it. Next up, you will fill app.py with intentionally vulnerable code that gives the security scanners something to find.

Build the Vulnerable Flask Application

Your repository is cloned, your file structure is in place, and Git is ready to push. Now it's time to fill those empty files with code that your security scanners can sink their teeth into.

Security scanners need something to find. In this step, you'll write a deliberately vulnerable Flask application that contains five different classes of security flaws. You'll also pin outdated dependency versions with known CVEs and configure SonarQube's project settings.

In this step, get ready to:
  • Write a Flask application with five intentional vulnerability types.
  • Pin vulnerable dependency versions for SCA scanning.
  • Configure SonarQube project properties.
Write the vulnerable Flask application

Each endpoint in your app will demonstrate a different vulnerability class from the Common Weakness Enumeration (CWE) system. Security tools categorize findings by CWE ID, so your scanners will report exactly which weakness they found.

Why build a deliberately vulnerable app?

Security scanners are only useful if they can detect real problems. By planting known vulnerability patterns, you create a controlled test environment. This lets you verify that each scanner is working correctly before trusting it on production code.

  • Open app.py in your code editor and add the imports, app setup, hardcoded credentials, database helper, and index route by pasting the following code:
import os
import subprocess
import sqlite3
from flask import Flask, request, render_template_string

app = Flask(__name__)

# Hardcoded credentials (CWE-798)
DATABASE_USER = "admin"
DATABASE_PASSWORD = "SuperSecret123!"
API_KEY = "sk-1234567890abcdef"


def get_db():
    conn = sqlite3.connect(":memory:")
    cursor = conn.cursor()
    cursor.execute(
        "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)"
    )
    cursor.execute(
        "INSERT OR IGNORE INTO users (id, username, password) VALUES (1, 'admin', 'password123')"
    )
    conn.commit()
    return conn


@app.route("/")
def index():
    return render_template_string(
        "<h1>Vulnerable App</h1>"
        "<p>Endpoints: /login, /search, /ping, /file</p>"
    )

What does this code do?

  • DATABASE_USER, DATABASE_PASSWORD, and API_KEY are hardcoded credentials (CWE-798). Storing secrets directly in source code is a critical vulnerability that SonarQube will flag.
  • get_db() creates an in-memory SQLite database with a test user. This keeps the app self-contained without needing an external database server.
  • The index route serves a simple homepage listing the vulnerable endpoints.

You won't see a change yet. The next chunk adds the login endpoint.

  • Below the index route, add the login endpoint with SQL injection vulnerability by pasting this code:

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "")
        password = request.form.get("password", "")
        # SQL Injection (CWE-89)
        conn = get_db()
        cursor = conn.cursor()
        query = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
        cursor.execute(query)
        user = cursor.fetchone()
        conn.close()
        if user:
            return "Login successful"
        return "Login failed"
    return render_template_string(
        '<form method="POST">'
        '<input name="username" placeholder="Username">'
        '<input name="password" type="password" placeholder="Password">'
        '<button type="submit">Login</button>'
        "</form>"
    )

What makes this vulnerable?

The f-string on the query line embeds username and password directly into the SQL statement. An attacker could type ' OR '1'='1 as a username to bypass authentication entirely.

This is CWE-89: SQL Injection. The fix would be to use parameterized queries with ? placeholders, but we intentionally skip that here so the scanner catches it.

You won't see a change yet. The next chunk adds the remaining three vulnerable endpoints.

  • Below the login route, add the search, ping, and file endpoints plus the main block by pasting this code:

@app.route("/search")
def search():
    # Reflected XSS (CWE-79)
    query = request.args.get("q", "")
    return render_template_string(
        f"<h1>Search Results</h1><p>You searched for: {query}</p>"
    )


@app.route("/ping")
def ping():
    # OS Command Injection (CWE-78)
    host = request.args.get("host", "127.0.0.1")
    result = subprocess.run(
        f"ping -c 1 {host}",
        shell=True,
        capture_output=True,
        text=True,
    )
    return f"<pre>{result.stdout}</pre>"


@app.route("/file")
def read_file():
    # Path Traversal (CWE-22)
    filename = request.args.get("name", "")
    filepath = os.path.join("/tmp", filename)
    try:
        with open(filepath, "r") as f:
            content = f.read()
        return f"<pre>{content}</pre>"
    except FileNotFoundError:
        return "File not found", 404


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)

What does each endpoint do?

  • /search reflects user input directly into the HTML response without escaping. This is CWE-79 (Reflected XSS). An attacker could inject <script> tags via the query parameter.
  • /ping passes user input directly to a shell command with shell=True. This is CWE-78 (OS Command Injection). An attacker could append ; rm -rf / to the host parameter.
  • /file joins user input into a file path without validation. This is CWE-22 (Path Traversal). An attacker could use ../../etc/passwd to read sensitive system files.
  • The if __name__ == "__main__" block starts the Flask development server on port 5000 when run directly.
  • Save app.py.
  • Confirm the file has content by running:
wc -l app.py

You should see approximately 90 lines reported.

Seeing 0 lines or a file not found error?

  • Make sure you saved the file after pasting all three code chunks.
  • Confirm your terminal is inside the devsecops-pipeline folder. Run pwd to check.

Why isn't my app.py showing any lines?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and the line count looks right.

ⓧ I'd like to double check the full code

Here is the complete app.py file for comparison:

import os
import subprocess
import sqlite3
from flask import Flask, request, render_template_string

app = Flask(__name__)

# Hardcoded credentials (CWE-798)
DATABASE_USER = "admin"
DATABASE_PASSWORD = "SuperSecret123!"
API_KEY = "sk-1234567890abcdef"


def get_db():
    conn = sqlite3.connect(":memory:")
    cursor = conn.cursor()
    cursor.execute(
        "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)"
    )
    cursor.execute(
        "INSERT OR IGNORE INTO users (id, username, password) VALUES (1, 'admin', 'password123')"
    )
    conn.commit()
    return conn


@app.route("/")
def index():
    return render_template_string(
        "<h1>Vulnerable App</h1>"
        "<p>Endpoints: /login, /search, /ping, /file</p>"
    )


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "")
        password = request.form.get("password", "")
        # SQL Injection (CWE-89)
        conn = get_db()
        cursor = conn.cursor()
        query = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
        cursor.execute(query)
        user = cursor.fetchone()
        conn.close()
        if user:
            return "Login successful"
        return "Login failed"
    return render_template_string(
        '<form method="POST">'
        '<input name="username" placeholder="Username">'
        '<input name="password" type="password" placeholder="Password">'
        '<button type="submit">Login</button>'
        "</form>"
    )


@app.route("/search")
def search():
    # Reflected XSS (CWE-79)
    query = request.args.get("q", "")
    return render_template_string(
        f"<h1>Search Results</h1><p>You searched for: {query}</p>"
    )


@app.route("/ping")
def ping():
    # OS Command Injection (CWE-78)
    host = request.args.get("host", "127.0.0.1")
    result = subprocess.run(
        f"ping -c 1 {host}",
        shell=True,
        capture_output=True,
        text=True,
    )
    return f"<pre>{result.stdout}</pre>"


@app.route("/file")
def read_file():
    # Path Traversal (CWE-22)
    filename = request.args.get("name", "")
    filepath = os.path.join("/tmp", filename)
    try:
        with open(filepath, "r") as f:
            content = f.read()
        return f"<pre>{content}</pre>"
    except FileNotFoundError:
        return "File not found", 404


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)
Pin vulnerable dependency versions

Your security pipeline includes a Software Composition Analysis (SCA) scanner called Trivy. Trivy reads requirements.txt and checks each pinned version against a database of known CVEs.

  • Open requirements.txt and paste the following pinned dependency versions:
Flask==2.0.1
Jinja2==2.11.3
Werkzeug==2.0.0
requests==2.25.1
PyYAML==5.3.1
cryptography==3.3.2

Why these specific versions?

Each of these packages is pinned to a version with at least one known CVE:

  • Flask==2.0.1 has CVE-2023-30861 (cookie parsing vulnerability).
  • Jinja2==2.11.3 has CVE-2024-22195 (Server-Side Template Injection).
  • Werkzeug==2.0.0 has CVE-2023-25577 (High CPU consumption DoS).
  • requests==2.25.1 has CVE-2023-32681 (proxy credentials leakage).
  • PyYAML==5.3.1 has CVE-2020-14343 (arbitrary code execution via unsafe load).
  • cryptography==3.3.2 has CVE-2023-38325 (null pointer dereference).

Trivy will cross-reference these pinned versions against its vulnerability database and flag every one of them.

  • Save requirements.txt.
  • Confirm the file has content by running:
cat requirements.txt

You should see six package lines with == version pins.

File appears empty?

  • Make sure you saved requirements.txt after pasting the dependency list.
  • Verify you're in the devsecops-pipeline directory by running ls and confirming the file is listed.

Why is my requirements.txt still empty after saving?

Configure SonarQube project properties

SonarQube reads a sonar-project.properties file to know which project it's scanning, what language to expect, and which directories to include.

  • Open sonar-project.properties and paste the following configuration:
sonar.projectKey=devsecops-pipeline
sonar.projectName=DevSecOps Pipeline
sonar.sources=.
sonar.python.version=3.11
sonar.sourceEncoding=UTF-8
sonar.exclusions=.github/**

What do these properties mean?

  • sonar.projectKey uniquely identifies your project in SonarQube. This must match what the workflow creates via the API.
  • sonar.sources=. tells SonarQube to scan the current directory for source files.
  • sonar.python.version=3.11 ensures SonarQube uses the correct Python grammar for analysis.
  • sonar.exclusions=.github/** prevents SonarQube from scanning your workflow YAML files as application code.
  • Save sonar-project.properties.
  • Confirm all three files have content by running:
wc -l app.py requirements.txt sonar-project.properties

You should see line counts for each file (approximately 90, 6, and 6 respectively).

Seeing 0 lines for a file?

  • The file exists but has no content. Open it in your editor and confirm the paste worked.
  • Check for extra whitespace or hidden characters if the file appears populated but wc -l reports 0.

Help me figure out why my project files appear empty.

Your vulnerable application and its dependencies are ready. Next up, you'll create the GitHub Actions workflow that runs SonarQube and Trivy against this code.

Create the SAST and SCA Workflow Jobs

Your vulnerable application and dependencies are ready. Now you need an automated pipeline that scans them for security issues every time code is pushed.

In this step, you will create a GitHub Actions workflow with two jobs. The first job runs SonarQube as a service container for SAST (scanning your source code for vulnerabilities). The second job runs Trivy for SCA (scanning your dependencies for known CVEs).

In this step, get ready to:
  • Create a SAST job with SonarQube Community Build running as a service container.
  • Create an SCA job with Trivy scanning your pinned dependencies.
  • Configure both jobs to run in parallel on the same workflow trigger.
Create the SAST job with SonarQube

The SAST job needs a running SonarQube instance to analyze your code against. Instead of connecting to an external server, you will spin up SonarQube directly inside the workflow using a service container.

  • Create a new file called devsecops.yml inside your .github/workflows/ directory, then paste the following code:
name: DevSecOps Pipeline

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

jobs:
  sast:
    name: SAST - SonarQube Scan
    runs-on: ubuntu-latest
    services:
      sonarqube:
        image: sonarqube:community
        ports:
          - 9000:9000
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Wait for SonarQube to be ready
        run: |
          echo "Waiting for SonarQube to start..."
          for i in $(seq 1 60); do
            STATUS=$(curl -s http://localhost:9000/api/system/health | grep -o '"status":"[^"]*"' | head -1 || true)
            if echo "$STATUS" | grep -q "GREEN"; then
              echo "SonarQube is ready!"
              break
            fi
            echo "Attempt $i/60 - SonarQube not ready yet..."
            sleep 5
          done

      - name: Create SonarQube project and token
        id: sonar-setup
        run: |
          # Create project
          curl -s -u admin:admin -X POST \
            "http://localhost:9000/api/projects/create?name=devsecops-pipeline&project=devsecops-pipeline"

          # Generate token
          TOKEN=$(curl -s -u admin:admin -X POST \
            "http://localhost:9000/api/user_tokens/generate?name=gh-actions-token" \
            | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
          echo "token=$TOKEN" >> "$GITHUB_OUTPUT"

      - name: SonarQube Scan
        uses: SonarSource/sonarqube-scan-action@v7
        env:
          SONAR_TOKEN: ${{ steps.sonar-setup.outputs.token }}
          SONAR_HOST_URL: http://localhost:9000

What does this workflow do?

The services block launches a sonarqube:community Docker container alongside your job. GitHub Actions maps port 9000 from the container to the runner, so your steps can reach SonarQube at http://localhost:9000.

SonarQube takes 1-3 minutes to initialize. The polling loop checks /api/system/health every 5 seconds until it returns a GREEN status.

Once healthy, the workflow creates a project and generates an authentication token using SonarQube's default admin:admin credentials. The SonarSource/sonarqube-scan-action@v7 action then runs the scan using that token and your sonar-project.properties file.

Why a service container instead of SonarCloud?

Running SonarQube as a service container keeps your pipeline fully self-contained. There are no external accounts to configure, no API tokens to store as secrets, and no network dependencies. The container is ephemeral and destroyed when the job finishes.

This approach mirrors how enterprise teams test against disposable infrastructure in CI. It also means public repositories can use this for free with no SonarCloud subscription.

  • Save .github/workflows/devsecops.yml.

YAML indentation looks wrong?

YAML is whitespace-sensitive. Make sure you use spaces (not tabs) and that each level of nesting is indented by exactly 2 spaces. Most code editors show indentation guides to help.

help me fix YAML indentation issues in my GitHub Actions workflow

Add the SCA job with Trivy

The second job scans your requirements.txt for dependencies with known vulnerabilities. Trivy matches each pinned version against a CVE database and reports any matches.

  • In devsecops.yml, add the following sca job below the sast job (at the same indentation level as sast:):
  sca:
    name: SCA - Trivy Dependency Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          scan-type: "fs"
          scan-ref: "."
          scanners: "vuln"
          severity: "CRITICAL,HIGH,MEDIUM"
          format: "table"

What does the SCA job do?

The aquasecurity/trivy-action@v0.36.0 action runs Trivy in filesystem mode (scan-type: "fs"). It reads requirements.txt and checks each pinned version against Trivy's vulnerability database.

The severity filter limits output to CRITICAL, HIGH, and MEDIUM findings. The format: "table" setting prints results as a readable table in the workflow logs.

  • Save .github/workflows/devsecops.yml.

Seeing a YAML syntax error?

Make sure the sca: key starts at the same indentation level as sast: (two spaces in from jobs:). A common mistake is nesting it inside the sast job instead of beside it.

help me debug a YAML structure error in my GitHub Actions workflow

Understand the parallel execution

Both jobs run in parallel because neither job has a needs keyword referencing the other. GitHub Actions launches independent jobs simultaneously on separate runners.

Why run SAST and SCA in parallel?

SAST and SCA scan different things (source code vs. dependencies) and have no shared state. Running them in parallel cuts total pipeline time roughly in half compared to running them sequentially.

The DAST job you add in the next step will use the needs keyword to wait for both of these jobs to finish first.

  • Confirm that the fetch-depth: 0 line appears on the sast job's checkout step. SonarQube needs full Git history for accurate blame annotations.

✔️ Awesome, I've got everything!

Great. Double check you have saved .github/workflows/devsecops.yml with both jobs.

ⓧ I'd like to double check the full code

name: DevSecOps Pipeline

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

jobs:
  sast:
    name: SAST - SonarQube Scan
    runs-on: ubuntu-latest
    services:
      sonarqube:
        image: sonarqube:community
        ports:
          - 9000:9000
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Wait for SonarQube to be ready
        run: |
          echo "Waiting for SonarQube to start..."
          for i in $(seq 1 60); do
            STATUS=$(curl -s http://localhost:9000/api/system/health | grep -o '"status":"[^"]*"' | head -1 || true)
            if echo "$STATUS" | grep -q "GREEN"; then
              echo "SonarQube is ready!"
              break
            fi
            echo "Attempt $i/60 - SonarQube not ready yet..."
            sleep 5
          done

      - name: Create SonarQube project and token
        id: sonar-setup
        run: |
          # Create project
          curl -s -u admin:admin -X POST \
            "http://localhost:9000/api/projects/create?name=devsecops-pipeline&project=devsecops-pipeline"

          # Generate token
          TOKEN=$(curl -s -u admin:admin -X POST \
            "http://localhost:9000/api/user_tokens/generate?name=gh-actions-token" \
            | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
          echo "token=$TOKEN" >> "$GITHUB_OUTPUT"

      - name: SonarQube Scan
        uses: SonarSource/sonarqube-scan-action@v7
        env:
          SONAR_TOKEN: ${{ steps.sonar-setup.outputs.token }}
          SONAR_HOST_URL: http://localhost:9000

  sca:
    name: SCA - Trivy Dependency Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          scan-type: "fs"
          scan-ref: "."
          scanners: "vuln"
          severity: "CRITICAL,HIGH,MEDIUM"
          format: "table"

Your workflow now has two powerful scanners ready to go. Next up, you will add a DAST job that launches your Flask app and attacks it at runtime to find vulnerabilities that static analysis cannot detect.

Add the DAST Job and Run the Pipeline

Your SAST and SCA jobs are defined and ready to scan code and dependencies. Now it's time to add the final layer of defense.

DAST testing attacks your application while it's running, just like a real attacker would. In this step, you'll add the OWASP ZAP scanner to your pipeline, commit everything, and watch all three scanners work together.

In this step, get ready to:
  • Add an OWASP ZAP DAST job to the workflow.
  • Commit and push all files to trigger the pipeline.
  • Verify scan results in GitHub Actions.
Add the DAST job to the workflow

SAST and SCA scan your code and dependencies before the application runs. DAST complements them by probing the live application for vulnerabilities that only appear at runtime.

  • In your code editor, open .github/workflows/devsecops.yml.
  • Find the end of the sca job (after the last line of that job's with: block).
  • Add the following dast job below the sca job, at the same indentation level:
  dast:
    name: DAST - OWASP ZAP Scan
    runs-on: ubuntu-latest
    needs: [sast, sca]
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Start Flask application
        run: |
          python app.py &
          echo "Waiting for Flask to start..."
          for i in $(seq 1 30); do
            if curl -s http://localhost:5000 > /dev/null 2>&1; then
              echo "Flask is running!"
              break
            fi
            sleep 1
          done

      - name: OWASP ZAP Full Scan
        uses: zaproxy/action-full-scan@v0.13.0
        with:
          target: "http://localhost:5000"
          allow_issue_writing: false

What does this code do?

This job runs only after SAST and SCA complete successfully, enforced by the needs: [sast, sca] line.

  • It checks out the code, installs Python 3.11, and installs the Flask app's dependencies.
  • It starts the Flask app in the background with python app.py & and polls port 5000 until the server responds.
  • Once the app is live, ZAP runs its spider (to discover all endpoints) followed by a full active scan that sends real attack payloads to find runtime vulnerabilities.
  • Setting allow_issue_writing: false prevents ZAP from automatically creating GitHub issues for each finding.
  • Save devsecops.yml.

Why does DAST depend on the other jobs?

Running DAST after SAST and SCA ensures you only spend time on expensive active scanning if the cheaper static checks pass first. This mirrors how real DevSecOps pipelines gate deployments in stages.

✔️ Awesome, I've got everything!

Great. Double check you've 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]

jobs:
  sast:
    name: SAST - SonarQube Scan
    runs-on: ubuntu-latest
    services:
      sonarqube:
        image: sonarqube:community
        ports:
          - 9000:9000
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Wait for SonarQube to be ready
        run: |
          echo "Waiting for SonarQube to start..."
          for i in $(seq 1 60); do
            STATUS=$(curl -s http://localhost:9000/api/system/health | grep -o '"status":"[^"]*"' | head -1 || true)
            if echo "$STATUS" | grep -q "GREEN"; then
              echo "SonarQube is ready!"
              break
            fi
            echo "Attempt $i/60 - SonarQube not ready yet..."
            sleep 5
          done

      - name: Create SonarQube project and token
        id: sonar-setup
        run: |
          # Create project
          curl -s -u admin:admin -X POST \
            "http://localhost:9000/api/projects/create?name=devsecops-pipeline&project=devsecops-pipeline"

          # Generate token
          TOKEN=$(curl -s -u admin:admin -X POST \
            "http://localhost:9000/api/user_tokens/generate?name=gh-actions-token" \
            | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
          echo "token=$TOKEN" >> "$GITHUB_OUTPUT"

      - name: SonarQube Scan
        uses: SonarSource/sonarqube-scan-action@v7
        env:
          SONAR_TOKEN: ${{ steps.sonar-setup.outputs.token }}
          SONAR_HOST_URL: http://localhost:9000

  sca:
    name: SCA - Trivy Dependency Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          scan-type: "fs"
          scan-ref: "."
          scanners: "vuln"
          severity: "CRITICAL,HIGH,MEDIUM"
          format: "table"

  dast:
    name: DAST - OWASP ZAP Scan
    runs-on: ubuntu-latest
    needs: [sast, sca]
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Start Flask application
        run: |
          python app.py &
          echo "Waiting for Flask to start..."
          for i in $(seq 1 30); do
            if curl -s http://localhost:5000 > /dev/null 2>&1; then
              echo "Flask is running!"
              break
            fi
            sleep 1
          done

      - name: OWASP ZAP Full Scan
        uses: zaproxy/action-full-scan@v0.13.0
        with:
          target: "http://localhost:5000"
          allow_issue_writing: false
Commit and push to trigger the pipeline

All four project files are ready. Pushing them to main triggers the workflow automatically because of the on: push trigger you defined at the top of the workflow file.

  • Stage, commit, and push all files by running these commands in your terminal:
git add .
git commit -m "Add DevSecOps pipeline with SAST, SCA, and DAST"
git push origin main

What does this do?

The git add . stages all files (app.py, requirements.txt, sonar-project.properties, and the workflow file). The push to main matches the branch filter in your workflow, so GitHub Actions kicks off the pipeline immediately.

  • After pushing, you should see output confirming the push succeeded (e.g. a branch reference and commit hash).

Push rejected or authentication error?

  • Make sure you cloned the repo using HTTPS with a personal access token, or SSH with a configured key.
  • If you see a "remote rejected" error, confirm you're pushing to a repository where you have write access.
  • Help me fix my git push authentication issue
Observe the pipeline results in GitHub Actions

The real payoff is watching three scanners find different categories of vulnerabilities in your deliberately insecure application.

  • Open your repository on GitHub in your browser.
  • Click the Actions tab at the top of the repository page.
  • Click the workflow run titled Add DevSecOps pipeline with SAST, SCA, and DAST (your commit message).
  • You should see three jobs listed: SAST - SonarQube Scan, SCA - Trivy Dependency Scan, and DAST - OWASP ZAP Scan.

What should I see in each job?

  • SAST (SonarQube): Detects code-level issues like SQL injection in the /login endpoint and hardcoded credentials (DATABASE_PASSWORD, API_KEY).
  • SCA (Trivy): Flags known CVEs in your pinned dependencies. You'll see entries like CVE-2023-30861 for Flask and CVE-2020-14343 for PyYAML in the table output.
  • DAST (ZAP): Finds runtime issues by actively attacking endpoints. It discovers reflected XSS on /search and injection vulnerabilities on other endpoints.

The SAST and SCA jobs run in parallel. The DAST job starts only after both complete. The full pipeline typically takes 5-10 minutes because SonarQube needs time to boot inside its service container.

  • Click into each job to expand the step logs and review the findings.
  • For the SCA job, expand the Run Trivy vulnerability scanner step to see the CVE table.
  • For the DAST job, expand the OWASP ZAP Full Scan step to see the alert summary.

DAST job failed or ZAP shows no alerts?

  • If the DAST job never starts, check that the SAST and SCA jobs both completed (ZAP waits for them due to needs).
  • If ZAP reports no alerts, verify the Flask app started correctly by checking the "Start Flask application" step logs for the message "Flask is running!".
  • ZAP may show a non-zero exit code even on success. This is normal. ZAP exits with a warning code when it finds vulnerabilities. Check the logs for the alert summary rather than relying on the green/red status.
  • Help me debug why my DAST job isn't producing results

Secret mission

Add Security Gates and Consolidated Reporting

Your pipeline detects vulnerabilities, but it doesn't stop a deployment when critical issues are found. In a real DevSecOps workflow, security gates automatically fail the pipeline on HIGH or CRITICAL findings, and a consolidated report gives the team a single view of all scan results. Can you wire that up?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on GitHub Actions and your local machine, so there are no ongoing costs. Decide whether to keep your resources as-is, tidy them up, or delete them entirely.

Resources you used:

  • GitHub repository (devsecops-pipeline).
  • Local project folder cloned to your Desktop.

What about SonarQube?

The SonarQube service container only exists during a GitHub Actions workflow run. It is automatically destroyed when the job finishes. There is nothing to shut down or delete.

Keep everything running

No action needed. Choose this if you want to keep the repository public as a demonstration of your DevSecOps skills.

  • Your GitHub repository stays public and the workflow triggers on every push to main.
  • Your local project folder remains on your Desktop.
  • GitHub Actions is free for public repositories, so there are no charges.

Pause - I'll come back to this later

Make the repository private to hide it from public view while keeping all your work intact.

  • Go to your repository on GitHub.
  • Click Settings.
  • Scroll to the bottom of the page to the Danger Zone section.
  • Click Change visibility and select Private.
  • Your local files stay on your Desktop for when you return.

Delete - I don't want to use this again

Remove the GitHub repository and delete your local project files.

  • Go to your repository on GitHub.
  • Click Settings.
  • Scroll to the bottom of the page to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Delete the local project folder by running this command in your terminal:

macOS

cd ~/Desktop
rm -rf devsecops-pipeline

Windows

cd ~/Desktop
rmdir /s /q devsecops-pipeline
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see devsecops-pipeline in the output.

Nice Work!

Nice Work!

You just built a complete DevSecOps pipeline that scans a deliberately vulnerable application at three different layers. This is a core skill for security-focused engineering roles, and you now have a working example to prove it.

Here's what you accomplished:

  • Built a deliberately vulnerable Flask application covering 5 CWE vulnerability classes (SQL injection, XSS, command injection, path traversal, and hardcoded credentials).
  • Created a GitHub Actions workflow that orchestrates SonarQube Community Build as a service container for SAST, Trivy for SCA dependency scanning, and OWASP ZAP for DAST runtime testing.
  • Demonstrated a complete DevSecOps pipeline that detects vulnerabilities across source code, dependencies, and the running application in a single automated CI/CD workflow.
  • Secret Mission: Added security gates with pipeline-failing thresholds and a consolidated security report artifact posted as a GitHub Actions job summary.

Ready to quiz yourself?