DevSecOps Security Gate with GitHub Actions
Build a CI/CD pipeline that scans code for vulnerabilities on every push.
Introduction
30 Second Summary
Every time a developer pushes code, there's a risk that vulnerabilities, leaked secrets, or outdated dependencies slip into production unnoticed. Companies solve this by adding automated security checks directly into their code pipelines, catching problems before they ever reach users.
In this project, you will build a DevSecOps security gate using GitHub Actions. You'll create a deliberately vulnerable Python app, then set up three automated scanners that catch security issues on every push and block unsafe code from merging.
What You'll Build
A GitHub Actions workflow that runs three security scanners (SAST, SCA, and secrets detection) on every push and pull request to your repository.
By the end of this project, you'll have:
- A GitHub Actions workflow that automatically scans code for vulnerabilities, insecure dependencies, and leaked secrets.
- SARIF reports that surface findings directly in GitHub's Security tab.
- A branch protection rule that blocks pull requests from merging when critical security issues are found.
- Secret Mission: Write a custom Semgrep rule that detects a project-specific anti-pattern in your codebase.
Are there any prerequisites?
You should be comfortable with Git, GitHub, basic YAML syntax, and introductory Python. All security scanning runs in the cloud via GitHub Actions, so no local tool installations are required beyond Git.
Before We Start
Before We Start...
Create the Repository and Vulnerable App
Before you build the security pipeline, you need something to scan. In this step, you'll create a deliberately vulnerable Python application that contains real security flaws.
The app will have three types of vulnerabilities: code-level issues like SQL injection, outdated packages with known CVEs, and hardcoded secrets. Each one maps to a different scanner you'll wire up later.
In this step, get ready to:
- Create a public GitHub repository with a Python .gitignore.
- Write three files that each trigger a different category of security finding.
- Push the vulnerable code to the main branch.
Create the GitHub repository
Your security pipeline needs a GitHub repository to live in. The repository must be public so that GitHub Actions can upload scan results to the Security tab for free.
- Open your browser and go to github.com/new.
- In the Repository name field, type devsecops-pipeline.
- Select Public for the repository visibility.
- Check the Add a README file checkbox.
- Click the Add .gitignore dropdown and select Python.
- Click Create repository.
Why a public repository?
GitHub's Code Scanning feature (which displays SARIF results in the Security tab) is free for public repositories on all plans, including free and student accounts. Private repos require GitHub Advanced Security, which is a paid add-on.
Now clone the repository to your local machine so you can add files to it.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your new repository by running this command:
git clone https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/devsecops-pipeline.git
- Move into the cloned repository by running:
cd devsecops-pipeline
- Confirm you're inside the repository by running:
ls
You should see README.md and .gitignore listed in the output.
Clone command failed?
- Make sure you replaced the username in the URL with your actual GitHub username.
- If you see a "repository not found" error, double check you created the repo as public and that the name is exactly devsecops-pipeline.
- If prompted for authentication, follow GitHub's instructions to set up HTTPS credentials or an SSH key.
Help me troubleshoot cloning my GitHub repository.
Create the vulnerable application files
You'll create three files, each designed to trigger a different type of security scanner. This gives your pipeline real findings to detect across all three categories.
- Create a file called app.py in your devsecops-pipeline folder with the following content:
import os
import sqlite3
from flask import Flask, request
app = Flask(__name__)
def get_db():
conn = sqlite3.connect("app.db")
return conn
@app.route("/search")
def search():
# VULNERABILITY: SQL Injection - user input directly in query
query = request.args.get("q", "")
db = get_db()
cursor = db.execute(f"SELECT * FROM users WHERE name = '{query}'")
results = cursor.fetchall()
return str(results)
@app.route("/ping")
def ping():
# VULNERABILITY: Command Injection - user input passed to os.system
host = request.args.get("host", "localhost")
os.system(f"ping -c 1 {host}")
return f"Pinged {host}"
@app.route("/")
def index():
return "DevSecOps Demo App"
if __name__ == "__main__":
app.run(debug=True)
What does this code do?
This is a small Flask web application with two intentional security vulnerabilities:
- The /search route has a SQL injection vulnerability. User input from the URL is inserted directly into a SQL query string without sanitization.
- The /ping route has a command injection vulnerability. User input is passed directly to os.system(), which lets an attacker run arbitrary shell commands.
These are the exact patterns that Semgrep's SAST rules will flag.
- Create a file called requirements.txt in your devsecops-pipeline folder with the following content:
flask==2.0.1
requests==2.25.0
jinja2==3.0.1
werkzeug==2.0.1
Why are these versions so old?
These package versions are deliberately outdated and contain known CVEs (publicly disclosed vulnerabilities). Trivy's SCA scanner cross-references your dependency versions against vulnerability databases. It will flag these packages as having HIGH or CRITICAL severity issues.
- Create a file called config.py in your devsecops-pipeline folder with the following content:
# VULNERABILITY: Hardcoded secret - Gitleaks will detect this
API_KEY = "AKIAIOSFODNN7EXAMPLE"
DATABASE_URL = "postgresql://admin:password123@localhost:5432/mydb"
SECRET_TOKEN = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef01"
What will Gitleaks detect here?
Each value matches a pattern that Gitleaks recognizes as a leaked secret:
- AKIAIOSFODNN7EXAMPLE matches the AWS access key format.
- The postgresql:// URL contains a plaintext password.
- ghp_ is the prefix for GitHub personal access tokens.
These are fake values for demonstration purposes. Never commit real credentials to a repository.
- Save all three files.
✔️ Awesome, I've got everything!
Great. Double check that you have app.py, requirements.txt, and config.py saved in your devsecops-pipeline folder.
ⓧ I'd like to double check the full code
Here is the complete content for each file:
import os
import sqlite3
from flask import Flask, request
app = Flask(__name__)
def get_db():
conn = sqlite3.connect("app.db")
return conn
@app.route("/search")
def search():
# VULNERABILITY: SQL Injection - user input directly in query
query = request.args.get("q", "")
db = get_db()
cursor = db.execute(f"SELECT * FROM users WHERE name = '{query}'")
results = cursor.fetchall()
return str(results)
@app.route("/ping")
def ping():
# VULNERABILITY: Command Injection - user input passed to os.system
host = request.args.get("host", "localhost")
os.system(f"ping -c 1 {host}")
return f"Pinged {host}"
@app.route("/")
def index():
return "DevSecOps Demo App"
if __name__ == "__main__":
app.run(debug=True)
flask==2.0.1
requests==2.25.0
jinja2==3.0.1
werkzeug==2.0.1
# VULNERABILITY: Hardcoded secret - Gitleaks will detect this
API_KEY = "AKIAIOSFODNN7EXAMPLE"
DATABASE_URL = "postgresql://admin:password123@localhost:5432/mydb"
SECRET_TOKEN = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef01"
Commit and push to GitHub
With all three files created, you need to commit them to your repository and push to GitHub so the security pipeline can scan them.
- Stage all new files by running:
git add .
- Commit the files with a descriptive message by running:
git commit -m "Add vulnerable Flask app for security scanning"
- Push the commit to GitHub by running:
git push
- Verify the push succeeded by visiting your repository at github.com/your GitHub username/devsecops-pipeline in your browser.
You should see app.py, requirements.txt, and config.py listed alongside the README.md and .gitignore that GitHub created for you.
Push was rejected?
- If you see "Updates were rejected because the remote contains work that you do not have locally," run git pull --rebase first, then git push again.
- If you're prompted for credentials, you may need to set up a personal access token or SSH key for authentication.
Help me push my code to GitHub.
Your vulnerable target application is ready. Next up, you'll build the first scanner job that detects those code-level vulnerabilities using Semgrep.
Build the Semgrep SAST Scanner Job
Your vulnerable app is committed and ready to be scanned. Now it's time to build the automated pipeline that catches those vulnerabilities every time code is pushed.
The first scanner you'll add is Semgrep, a Static Application Security Testing (SAST) tool. SAST tools analyze your source code without running it, looking for patterns that match known vulnerability types like SQL injection and command injection.
In this step, get ready to:
- Create a GitHub Actions workflow file with the Semgrep scanner job.
- Push the workflow to GitHub and verify Semgrep detects vulnerabilities in your code.
Create the GitHub Actions workflow file
GitHub Actions workflows live in a special directory called .github/workflows/ inside your repository. Any YAML file placed there automatically becomes a workflow that GitHub can run.
- Create the workflow directory by running this command:
mkdir -p .github/workflows
- Create a new file called security.yml inside .github/workflows/ and paste in the following code:
name: Security Scans
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
semgrep:
name: SAST - Semgrep
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- name: Run Semgrep scan
run: semgrep scan --config p/security-audit --config p/secrets --sarif --output semgrep-results.sarif .
- name: Upload Semgrep SARIF
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: semgrep-results.sarif
category: semgrep
What does this workflow do?
- on: push/pull_request triggers the workflow every time code is pushed to main or a PR is opened against it.
- permissions: security-events: write grants the workflow permission to upload scan results to the Security tab.
- container: image: semgrep/semgrep runs the job inside Semgrep's official Docker container, so you don't need to install anything.
- The scan step runs Semgrep with two rulesets: p/security-audit (code vulnerabilities) and p/secrets (leaked credentials). Results are written in SARIF format.
- The upload step uses github/codeql-action/upload-sarif@v4 to send the SARIF file to GitHub's Security tab. The if: always() ensures results are uploaded even if the scan finds issues and exits with a non-zero code.
- Save .github/workflows/security.yml.
- Confirm the file exists by running:
ls .github/workflows/
You should see security.yml in the output.
Don't see the file?
- Make sure you created the directory with mkdir -p .github/workflows before creating the file. The -p flag creates parent directories as needed.
- Check that you saved the file inside .github/workflows/ and not in the repository root.
Still stuck? Help me find my security.yml file.
✔️ 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: Security Scans
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
semgrep:
name: SAST - Semgrep
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- name: Run Semgrep scan
run: semgrep scan --config p/security-audit --config p/secrets --sarif --output semgrep-results.sarif .
- name: Upload Semgrep SARIF
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: semgrep-results.sarif
category: semgrep
Push and verify the scan results
With the workflow file saved, you need to commit and push it to GitHub so that Actions picks it up and runs it automatically.
- Stage, commit, and push the workflow file by running these commands:
git add .github/workflows/security.yml
git commit -m "Add Semgrep SAST scanner workflow"
git push
What happens after you push?
Because the workflow triggers on push to main, GitHub Actions immediately picks up the new file and starts running the Semgrep scan. This takes about 30-60 seconds to complete.
- Open your repository on GitHub in your browser.
- Click the **Actions** tab at the top of the repository page. You should see a workflow run called Security Scans either in progress or completed.
- Click on the workflow run to see the job details. Look for the SAST - Semgrep job and click it to view its logs.
- Expand the Run Semgrep scan step in the logs. You should see Semgrep listing findings for the SQL injection and command injection vulnerabilities in app.py.
Now check the Security tab to see findings displayed natively in GitHub's interface.
- Click the **Security** tab in your repository. If you cannot see it, click the dropdown menu (the ... icon) and select Security.
- In the left sidebar, click **Code scanning**.
- You should see alerts from Semgrep listing the vulnerabilities it detected in your code.
Why use SARIF format?
SARIF (Static Analysis Results Interchange Format) is the standard format that GitHub Code Scanning consumes. By outputting SARIF and uploading it, your scan results appear directly in GitHub's Security tab rather than only in the Actions log. This gives your team a single dashboard for all security findings.
Workflow failed or no findings in Security tab?
- If the workflow failed on the upload step, make sure the permissions: block in your YAML includes security-events: write. Without this, the SARIF upload is denied.
- If you don't see findings in the Security tab, confirm the repository is public. Code scanning alerts via SARIF upload are available on public repositories for all GitHub plans.
- If the Semgrep step shows no findings, check that app.py was pushed to main before the workflow file. The scan needs your vulnerable code to be present in the repository.
Need help? My Semgrep workflow failed or I don't see findings in the Security tab.
Your first scanner is live and catching real vulnerabilities. Next, you'll add Trivy to detect known-vulnerable packages hiding in your Python dependencies.
Add the Trivy SCA Scanner Job
Your Semgrep job is now catching code-level vulnerabilities like SQL injection and command injection. But vulnerable code is only one attack surface.
Your project also depends on third-party packages listed in requirements.txt. If any of those packages have known security flaws, attackers can exploit them without ever touching your code. In this step, you'll add Trivy to scan your dependencies for known vulnerabilities.
In this step, get ready to:
- Add the Trivy SCA scanner job to your workflow.
- Verify Trivy detects vulnerable dependencies in your requirements.txt.
Add the Trivy scanner job to your workflow
What is Software Composition Analysis?
Software Composition Analysis (SCA) scans your project's dependencies for known vulnerabilities (CVEs). Unlike SAST which looks at code you wrote, SCA looks at code other people wrote that you depend on.
Trivy is an open-source scanner that reads dependency files like requirements.txt and checks each package version against a database of known vulnerabilities.
- In your code editor, open .github/workflows/security.yml.
- Add the following trivy job below the semgrep job (at the same indentation level as semgrep:):
trivy:
name: SCA - Trivy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'HIGH,CRITICAL'
exit-code: '1'
What do these settings do?
- scan-type: 'fs' tells Trivy to scan the filesystem (your repository files) rather than a container image.
- scan-ref: '.' points the scan at the current directory, which is the repo root after checkout.
- severity: 'HIGH,CRITICAL' filters results to only HIGH and CRITICAL findings.
- exit-code: '1' makes the job fail when vulnerabilities matching the severity filter are found.
- format: 'sarif' outputs results in SARIF format so GitHub can display them in the Security tab.
You won't see changes yet. The next chunk adds the upload step and pushes everything.
Add the SARIF upload step and verify findings
The Trivy job will fail when it finds vulnerabilities (because of exit-code: '1'). Without if: always() on the upload step, the SARIF file would never reach GitHub's Security tab.
- Add the following upload step below the Trivy scanner step (still inside the trivy job's steps: list):
- name: Upload Trivy SARIF
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy-results.sarif
category: trivy
Why use if: always()?
When Trivy finds vulnerabilities matching the severity filter, it exits with code 1. GitHub Actions treats a non-zero exit as a step failure and skips all subsequent steps by default.
The if: always() condition tells GitHub to run the upload step regardless of whether the previous step passed or failed. This ensures findings always appear in the Security tab even when the scanner flags issues.
- Save .github/workflows/security.yml.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before pushing.
ⓧ I'd like to double check the full code
Here is the complete security.yml file after this step:
name: Security Scans
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
semgrep:
name: SAST - Semgrep
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- name: Run Semgrep scan
run: semgrep scan --config p/security-audit --config p/secrets --sarif --output semgrep-results.sarif .
- name: Upload Semgrep SARIF
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: semgrep-results.sarif
category: semgrep
trivy:
name: SCA - Trivy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'HIGH,CRITICAL'
exit-code: '1'
- name: Upload Trivy SARIF
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy-results.sarif
category: trivy
- Commit and push the updated workflow by running these commands:
git add .github/workflows/security.yml
git commit -m "Add Trivy SCA scanner job"
git push
- In GitHub, click the Actions tab.
- Click the latest workflow run (it will be named Add Trivy SCA scanner job or similar).
- You should see two jobs: SAST - Semgrep and SCA - Trivy.
- The Trivy job will show a red X (failure) because it detected HIGH or CRITICAL vulnerabilities in your requirements.txt packages.
- Click the Security tab, then Code scanning.
- You should see findings under both the semgrep and trivy categories.
What should I see in the Security tab?
Trivy's findings will list the vulnerable packages from your requirements.txt along with their CVE identifiers and severity ratings. The category: trivy setting keeps these findings separate from Semgrep's results, so you can filter by tool.
Trivy job not appearing or not failing?
- Check your YAML indentation. The trivy: key must be at the same level as semgrep: under jobs:.
- Make sure the with: block is indented under the uses: line for the Trivy action step.
- If the upload step is skipped, confirm you added if: always() to the upload step.
Why isn't my Trivy job running or uploading SARIF results?
Your pipeline now covers two out of three security layers: code vulnerabilities and vulnerable dependencies. Next up, you'll add the final scanner to catch hardcoded secrets before they reach production.
Add the Gitleaks Secrets Scanner Job
Your pipeline already catches code vulnerabilities with Semgrep and outdated dependencies with Trivy. But there's a third category that's just as dangerous: secrets accidentally committed to your repository.
Hardcoded API keys, database passwords, and access tokens in your git history are one of the most common causes of security breaches. Gitleaks is an open-source tool that scans your entire git history for these patterns. In this step, you'll add it as the third and final scanner in your security pipeline.
In this step, get ready to:
- Add a Gitleaks secrets scanner job to your workflow.
- Push and verify Gitleaks detects the hardcoded secrets in config.py.
Add the Gitleaks job to the workflow
Unlike Semgrep and Trivy, Gitleaks needs access to your full git history to scan for secrets that might have been committed at any point. This is why you'll configure the checkout step with fetch-depth: 0 instead of the default shallow clone.
- Open .github/workflows/security.yml in your editor.
- Find the end of the trivy job (after the category: trivy line).
- Add the following Gitleaks job below it:
gitleaks:
name: Secrets - Gitleaks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
What does this code do?
This job has two steps that work together to scan your repository for leaked secrets.
- fetch-depth: 0 tells actions/checkout to clone the full git history, not just the latest commit. Gitleaks needs this to find secrets buried in older commits.
- gitleaks/gitleaks-action@v2 runs the official Gitleaks action. It uses built-in regex patterns to detect common secret formats like AWS keys, GitHub tokens, and database connection strings.
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} provides the action with a token to interact with the GitHub API. This token is automatically available in every workflow run.
Why no license key?
The Gitleaks action requires a license key for organization accounts, but personal accounts (including GitHub Student accounts) can use it for free. No GITLEAKS_LICENSE secret is needed.
- Save .github/workflows/security.yml.
Indentation errors in YAML?
YAML is whitespace-sensitive. The gitleaks: key must be indented at the same level as semgrep: and trivy: (two spaces under jobs:). If you see a workflow parse error in the Actions tab, double-check your indentation matches the structure of the other jobs.
Help me fix YAML indentation errors in my GitHub Actions workflow
✔️ 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
Here's the complete security.yml with all three jobs:
name: Security Scans
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
semgrep:
name: SAST - Semgrep
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- name: Run Semgrep scan
run: semgrep scan --config p/security-audit --config p/secrets --sarif --output semgrep-results.sarif .
- name: Upload Semgrep SARIF
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: semgrep-results.sarif
category: semgrep
trivy:
name: SCA - Trivy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'HIGH,CRITICAL'
exit-code: '1'
- name: Upload Trivy SARIF
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy-results.sarif
category: trivy
gitleaks:
name: Secrets - Gitleaks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Push and verify secrets detection
Your config.py file contains three hardcoded secrets that match patterns Gitleaks is built to detect: an AWS-style key, a PostgreSQL connection string with credentials, and a GitHub personal access token.
- Commit and push the updated workflow by running these commands:
git add .github/workflows/security.yml
git commit -m "Add Gitleaks secrets scanner job"
git push
- Return to your repository on GitHub.
- Click the **Actions** tab.
- Click on the latest workflow run (titled "Add Gitleaks secrets scanner job").
- Wait for the jobs to complete. You should see the Secrets - Gitleaks job with a red ✗ icon, indicating it failed because it found secrets.
The Gitleaks job fails because it detected the hardcoded credentials in config.py. This is exactly what you want. A failing job means the security gate is working.
- Click on the Secrets - Gitleaks job to see the scan output.
- In the job logs, look for findings that reference config.py and the detected secret patterns (AWS key, generic credentials, GitHub token).
What should I see?
The Gitleaks job logs show each detected secret with the file path, line number, and the rule that matched. You should see findings for API_KEY, DATABASE_URL, and SECRET_TOKEN in config.py.
All three scanner jobs (Semgrep, Trivy, and Gitleaks) should now appear in your workflow run. Your security pipeline covers code vulnerabilities, vulnerable dependencies, and leaked secrets.
Gitleaks job passed when it should have failed?
Make sure config.py is committed and pushed to the repository. Gitleaks scans the git history, so the secrets must actually be in a commit.
Also verify that fetch-depth: 0 is set on the checkout step. Without it, the action only sees a shallow clone and may miss secrets in earlier commits.
Help me figure out why Gitleaks isn't detecting the secrets in my config.py file
Your security pipeline is now complete with all three scanners running on every push and pull request. Next up, you'll enforce a branch protection rule that blocks merges when any of these scanners find critical issues.
Enforce the Security Gate with Branch Protection
Your three scanners are running and surfacing findings, but nothing stops someone from merging vulnerable code into main.
In this step, you will configure a branch protection rule that requires all three security jobs to pass before a pull request can be merged. Then you will test the gate end-to-end by introducing a new vulnerability on a branch and watching the pipeline block it.
In this step, get ready to:
- Add a branch protection rule on main requiring all three security checks to pass.
- Test the security gate by opening a PR with a new vulnerability and verifying it gets blocked.
- Fix the vulnerability and confirm the checks pass.
Add a branch protection rule for main
Right now, anyone with write access can push directly to main or merge a PR regardless of whether the security scans passed. A branch protection rule closes that gap by making specific checks mandatory.
- Open your devsecops-pipeline repository on GitHub.
- Click Settings in the top navigation bar of the repository.
- In the left sidebar under Code and automation, click Branches.
- Next to Branch protection rules, click Add rule.
- In the Branch name pattern field, type main.
- Check the box for Require status checks to pass before merging.
- Check the box for Require branches to be up to date before merging.
- In the search field that appears, search for and select these three checks: SAST - Semgrep, SCA - Trivy, and Secrets - Gitleaks.
- Scroll to the bottom and click Create.
Why do the check names match the job names in the workflow?
GitHub uses the name field of each job (SAST - Semgrep, SCA - Trivy, Secrets - Gitleaks) as the status check identifier. That is why you search for those exact names when configuring the required checks.
If you do not see the checks in the search results, your workflow has not run yet. Status checks only appear after they have been reported at least once.
What is a branch protection rule?
A branch protection rule prevents direct pushes and unreviewed merges into a critical branch. You define conditions (like passing CI checks) that must be met before any PR can be merged.
This shifts security left. Vulnerabilities are caught before they reach production, not after.
Don't see the status checks in the search?
GitHub only shows status checks that have run at least once. If the checks are missing, go to the Actions tab, confirm the workflow has completed at least one run on main, then return to the branch protection settings and search again.
Help me find my status checks in branch protection settings.
Test the security gate with a failing PR
The best way to prove the gate works is to open a pull request that introduces a new vulnerability and watch the pipeline block it.
- Return to your terminal in the devsecops-pipeline directory.
- Create and switch to a new branch by running this command:
git checkout -b test-security-gate
- Open config.py and add a new hardcoded secret at the bottom of the file. Copy the following line and paste it below the existing SECRET_TOKEN line:
# Another leaked credential for testing
AWS_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
What does this test?
This fake AWS secret key matches Gitleaks' built-in regex patterns. When the pipeline runs on your PR, Gitleaks will detect it and fail the check. This simulates exactly what would happen if a developer accidentally committed real credentials.
- Commit and push the change by running these commands:
git add config.py
git commit -m "test: add secret to trigger security gate"
git push origin test-security-gate
- Go back to your repository on GitHub. You should see a banner offering to open a pull request for your new branch.
- Click Compare & pull request.
- Leave the default title and click Create pull request.
- Wait for the security checks to run. After a minute or two, you should see red X marks next to the failed checks and a message that merging is blocked.
What should I see?
On the PR page, GitHub displays the status of each required check. You should see the Gitleaks check fail (red X) because it detected the new secret. The merge button will be greyed out with a message indicating that required checks have not passed.
Checks not appearing on the PR?
Make sure your workflow triggers on pull_request events targeting main. Check the Actions tab to see if a workflow run was triggered by your PR. If no run appears, verify the on: block in security.yml includes pull_request: branches: [main].
Why aren't my GitHub Actions checks showing on my pull request?
Fix the vulnerability and confirm the gate passes
Now that you have seen the gate block a bad PR, remove the vulnerability so the checks pass and the PR becomes mergeable.
- In your terminal, open config.py and remove the two lines you just added (the comment and the AWS_SECRET_KEY line).
- Commit and push the fix by running these commands:
git add config.py
git commit -m "fix: remove test secret"
git push origin test-security-gate
- Return to the pull request page on GitHub.
- Wait for the checks to run again. This time, the Gitleaks check should pass (green checkmark) and the merge button should become available.
Why might some checks still fail?
The Semgrep and Trivy checks may still fail because the original vulnerabilities in app.py and requirements.txt remain. That is expected. Those vulnerabilities are intentional for this project. The key test here is that the gate blocked the PR when a new secret was introduced, and unblocked it once you removed the secret.
In a real production setup, you would fix all findings before enabling branch protection. For this learning exercise, you have proven the mechanism works.
Keep or close the test PR
You can close this PR without merging since it was only a test. Click Close pull request at the bottom of the PR page. Your branch protection rule remains active regardless.
✔️ Awesome, I've got everything!
Great. Your DevSecOps security gate is fully operational. Every future PR against main will be scanned automatically.
ⓧ I'd like to double check the full code
Here is the full security.yml workflow file. The branch protection rule is configured in the GitHub UI, not in code.
name: Security Scans
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
semgrep:
name: SAST - Semgrep
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- name: Run Semgrep scan
run: semgrep scan --config p/security-audit --config p/secrets --sarif --output semgrep-results.sarif .
- name: Upload Semgrep SARIF
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: semgrep-results.sarif
category: semgrep
trivy:
name: SCA - Trivy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'HIGH,CRITICAL'
exit-code: '1'
- name: Upload Trivy SARIF
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy-results.sarif
category: trivy
gitleaks:
name: Secrets - Gitleaks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Secret mission
Write a Custom Semgrep Rule
Your pipeline uses community rulesets to catch common vulnerabilities. But what about patterns specific to your project? In this mission, you'll write a custom Semgrep rule that detects the use of os.system() and enforces subprocess.run() as the safer alternative.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on GitHub's free tier for public repositories. There are no ongoing costs for the repository, Actions minutes, or security scanning.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- devsecops-pipeline GitHub repository (includes workflow, branch protection rule, and Security tab findings).
- Local devsecops-pipeline folder on your Desktop.
Keep everything running
No action needed. Choose this if you plan to keep iterating on the pipeline or want to reference it later.
- Your GitHub Actions workflow continues to run on every push and PR at no cost (public repositories get unlimited Actions minutes).
- Security findings remain visible in the repository's Security tab.
- The branch protection rule stays active, blocking merges that fail security checks.
Pause - I'll come back to this later
Disable the workflow so it stops running on every push. Your code, findings, and branch protection rule stay intact for when you return.
- Open your devsecops-pipeline repository on GitHub.
- Click the Actions tab.
- In the left sidebar, click Security Scans (your workflow name).
- Click the dropdown menu and select Disable workflow.
- When you return, follow the same path and click Enable workflow to resume scanning.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your repository, workflow history, and security findings.
- Delete the GitHub repository:
- Open your devsecops-pipeline repository on GitHub.
- Click Settings.
- Scroll down to the Danger Zone section and click Delete this repository.
- Click I want to delete this repository.
- Click I have read and understand these effects.
- Type devsecops-pipeline to confirm, then click Delete this repository.
Delete the local folder by running this command in your terminal:
cd ~/Desktop
rm -rf devsecops-pipeline
On Windows, use this command instead:
cd ~\Desktop
Remove-Item -Recurse -Force devsecops-pipeline
Nice Work!
Nice Work!
You just built a real DevSecOps security gate from scratch. Your GitHub repository now has an automated pipeline that scans for vulnerabilities, flags dangerous dependencies, and catches leaked secrets on every single push and pull request.
Here's what you learned:
- Built a multi-job GitHub Actions workflow with three security scanners (Semgrep for SAST, Trivy for SCA, Gitleaks for secrets detection) that produces SARIF reports visible in GitHub's Security tab.
- Created a deliberately vulnerable Python app to trigger real findings across all three scanner categories, including SQL injection, command injection, outdated dependencies with known CVEs, and hardcoded secrets.
- Enforced a security gate using branch protection rules that block PRs from merging when critical issues are found.
- Wrote a custom Semgrep rule tailored to your project's specific security requirements, detecting os.system() usage and enforcing safer alternatives.
Ready to quiz yourself?