Build a security-integrated CI/CD pipeline using GitHub Actions, Semgrep (SAST), OWASP Dependency-Check (SCA), and OWASP ZAP (DAST). Learn to shift security left — catching vulnerabilities in code before they reach production — and write custom Semgrep rules for your application stack.
Create a deliberately vulnerable Python Flask application to practice scanning against:
mkdir ~/devsecops-lab && cd ~/devsecops-lab
git init
mkdir src tests
cat > src/app.py << 'EOF'
# DELIBERATELY VULNERABLE APP — FOR LAB USE ONLY
from flask import Flask, request, render_template_string, redirect
import sqlite3, subprocess, os, pickle, hashlib
app = Flask(__name__)
app.secret_key = "hardcoded_secret_key_12345" # VULN: CWE-798
DB = '/tmp/lab.db'
def get_db():
conn = sqlite3.connect(DB)
return conn
@app.route('/login', methods=['POST'])
def login():
user = request.form['username']
pwd = request.form['password']
# VULN: SQL Injection (CWE-89)
conn = get_db()
query = f"SELECT * FROM users WHERE username='{user}' AND password='{pwd}'"
result = conn.execute(query).fetchone()
return "ok" if result else "fail"
@app.route('/render')
def render():
# VULN: Server-Side Template Injection (CWE-94)
template = request.args.get('template', '')
return render_template_string(template)
@app.route('/ping')
def ping():
# VULN: Command Injection (CWE-78)
host = request.args.get('host', 'localhost')
result = subprocess.run(f"ping -c 1 {host}", shell=True,
capture_output=True, text=True)
return result.stdout
@app.route('/load')
def load():
# VULN: Insecure Deserialization (CWE-502)
data = request.args.get('data', '')
obj = pickle.loads(bytes.fromhex(data))
return str(obj)
@app.route('/hash')
def weak_hash():
# VULN: Weak cryptography (CWE-327)
data = request.args.get('data', '')
return hashlib.md5(data.encode()).hexdigest()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
EOF
cat > requirements.txt << 'EOF'
flask==2.0.1
requests==2.26.0
cryptography==3.3.0
PyYAML==5.3.1
Pillow==8.2.0
EOF
echo "Vulnerable app created — DO NOT deploy outside lab environment"
pip3 install semgrep
cd ~/devsecops-lab
# Run Semgrep with auto-rules (OWASP Top 10 + security audit)
semgrep scan \
--config=p/owasp-top-ten \
--config=p/python \
--config=p/secrets \
--json \
--output=reports/semgrep_results.json \
src/
# Human-readable output
semgrep scan \
--config=p/owasp-top-ten \
--config=p/python \
--config=p/secrets \
src/ 2>&1 | tee reports/semgrep_readable.txt
echo "Semgrep findings:"
python3 -c "
import json
with open('reports/semgrep_results.json') as f:
results = json.load(f)
findings = results.get('results', [])
print(f'Total findings: {len(findings)}')
by_severity = {}
for f in findings:
sev = f.get('extra', {}).get('severity', 'INFO')
by_severity[sev] = by_severity.get(sev, 0) + 1
for sev, count in sorted(by_severity.items()):
print(f' {sev}: {count}')
"
mkdir -p ~/devsecops-lab/semgrep_rules
cat > ~/devsecops-lab/semgrep_rules/flask_security.yml << 'EOF'
rules:
- id: flask-debug-mode
patterns:
- pattern: |
app.run(..., debug=True, ...)
message: >
Flask debug mode is enabled. This exposes the Werkzeug debugger,
which allows arbitrary code execution. Never enable in production.
languages: [python]
severity: ERROR
metadata:
cwe: CWE-94
owasp: A05:2021 - Security Misconfiguration
mitre: T1190
- id: flask-hardcoded-secret
patterns:
- pattern: |
$APP.secret_key = "..."
- pattern: |
$APP.secret_key = '...'
message: >
Hardcoded Flask secret key detected. Use environment variables instead:
app.secret_key = os.environ.get('SECRET_KEY')
languages: [python]
severity: ERROR
metadata:
cwe: CWE-798
owasp: A02:2021 - Cryptographic Failures
- id: sqlite-string-format
patterns:
- pattern: |
$CONN.execute(f"...{$VAR}...")
- pattern: |
$CONN.execute("..." + $VAR + "...")
message: >
String formatting in SQL query — potential SQL injection.
Use parameterized queries: conn.execute("SELECT * WHERE x=?", (value,))
languages: [python]
severity: ERROR
metadata:
cwe: CWE-89
owasp: A03:2021 - Injection
mitre: T1190
- id: subprocess-shell-true
patterns:
- pattern: |
subprocess.run(..., shell=True, ...)
- pattern: |
subprocess.call(..., shell=True, ...)
- pattern: |
os.system($X)
message: >
Command executed with shell=True or os.system — potential command injection
if user input is included. Use shell=False and pass args as list.
languages: [python]
severity: ERROR
metadata:
cwe: CWE-78
owasp: A03:2021 - Injection
mitre: T1059.006
- id: pickle-deserialization
patterns:
- pattern: |
pickle.loads($DATA)
- pattern: |
pickle.load($FILE)
message: >
Deserialization of untrusted data with pickle. Pickle can execute
arbitrary code. Use JSON or msgpack instead.
languages: [python]
severity: ERROR
metadata:
cwe: CWE-502
owasp: A08:2021 - Software and Data Integrity Failures
- id: weak-hash-md5
patterns:
- pattern: hashlib.md5(...)
- pattern: hashlib.sha1(...)
message: >
MD5 and SHA1 are cryptographically weak. Use SHA-256 or better:
hashlib.sha256(data).hexdigest()
languages: [python]
severity: WARNING
metadata:
cwe: CWE-327
owasp: A02:2021 - Cryptographic Failures
EOF
# Test custom rules
semgrep --config ~/devsecops-lab/semgrep_rules/flask_security.yml src/
Scan your requirements.txt for known CVEs in dependencies:
# Install OWASP Dependency-Check (Java required)
sudo apt install -y default-jre
wget -O /tmp/dc.zip \
https://github.com/jeremylong/DependencyCheck/releases/download/v9.0.7/dependency-check-9.0.7-release.zip
unzip /tmp/dc.zip -d /opt/
export PATH=$PATH:/opt/dependency-check/bin
# Run SCA scan on Python dependencies
dependency-check.sh \
--project "DevSecOps Lab" \
--scan ~/devsecops-lab/requirements.txt \
--format HTML \
--format JSON \
--out ~/devsecops-lab/reports/ \
--enableExperimental
# Alternative: pip-audit (simpler, Python-native)
pip3 install pip-audit
pip-audit -r ~/devsecops-lab/requirements.txt \
--format json > ~/devsecops-lab/reports/pip_audit.json
python3 -c "
import json
with open('reports/pip_audit.json') as f:
data = json.load(f)
vulns = data.get('vulnerabilities', [])
print(f'Vulnerable packages: {len(set(v[\"package\"] for v in vulns))}')
for v in vulns:
print(f' {v[\"package\"]} {v[\"installed_version\"]}: {v[\"id\"]}')
"
# Create Dockerfile for the lab app cat > ~/devsecops-lab/Dockerfile << 'EOF' FROM python:3.9-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY src/ . EXPOSE 5000 CMD ["python", "app.py"] EOF # Build the image docker build -t devsecops-lab:latest ~/devsecops-lab/ # Install Trivy (container image scanner) wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add - echo "deb https://aquasecurity.github.io/trivy-repo/deb generic main" | \ sudo tee -a /etc/apt/sources.list.d/trivy.list sudo apt update && sudo apt install -y trivy # Scan the Docker image trivy image \ --format json \ --output ~/devsecops-lab/reports/trivy_image.json \ devsecops-lab:latest trivy image --severity HIGH,CRITICAL devsecops-lab:latest # Scan base OS packages too trivy image --scanners vuln,config,secret devsecops-lab:latest
# Start the vulnerable app cd ~/devsecops-lab && python3 src/app.py & APP_PID=$! sleep 3 # Wait for app to start # Run OWASP ZAP baseline scan (Docker — easiest method) docker run --rm \ --network="host" \ -v ~/devsecops-lab/reports:/zap/wrk:rw \ ghcr.io/zaproxy/zaproxy:stable \ zap-baseline.py \ -t http://localhost:5000 \ -r zap_baseline_report.html \ -J zap_baseline_report.json \ -l WARN # Full active scan (finds more, takes longer) docker run --rm \ --network="host" \ -v ~/devsecops-lab/reports:/zap/wrk:rw \ ghcr.io/zaproxy/zaproxy:stable \ zap-full-scan.py \ -t http://localhost:5000 \ -r zap_full_report.html \ -J zap_full_report.json kill $APP_PID 2>/dev/null echo "ZAP scan complete — see reports/zap_*.html"
python3 << 'EOF'
import json
try:
with open('reports/zap_full_report.json') as f:
data = json.load(f)
except FileNotFoundError:
print("ZAP report not found — run Step 6 first")
exit()
alerts = data.get('site', [{}])[0].get('alerts', [])
print(f"Total ZAP alerts: {len(alerts)}")
# Risk breakdown
risk_levels = {'High': [], 'Medium': [], 'Low': [], 'Informational': []}
for alert in alerts:
risk = alert.get('riskdesc', 'Informational').split()[0]
risk_levels.get(risk, risk_levels['Informational']).append(alert)
for risk, items in risk_levels.items():
print(f"\n[{risk.upper()}] ({len(items)} findings)")
for item in items[:3]:
print(f" - {item.get('alert', '')}")
print(f" CWE: {item.get('cweid', 'N/A')} | WASC: {item.get('wascid', 'N/A')}")
print(f" URL: {item.get('instances', [{}])[0].get('uri', 'N/A')}")
print(f" Solution: {item.get('solution','')[:100]}")
EOF
mkdir -p ~/devsecops-lab/.github/workflows
cat > ~/devsecops-lab/.github/workflows/devsecops.yml << 'EOF'
name: DevSecOps Security Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# SAST — Static Analysis
sast:
name: SAST - Semgrep
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Semgrep SAST
uses: semgrep/semgrep-action@v1
with:
config: >-
p/owasp-top-ten
p/python
p/secrets
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
# SCA — Dependency Scanning
sca:
name: SCA - Dependency Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: pip-audit
run: |
pip install pip-audit
pip-audit -r requirements.txt \
--format cyclonedx-json \
--output dependency-report.json || true
- name: Upload dependency report
uses: actions/upload-artifact@v4
with:
name: dependency-report
path: dependency-report.json
# Container Scanning
container:
name: Container - Trivy Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'app:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1' # Fail pipeline on CRITICAL/HIGH
- name: Upload Trivy SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
# DAST — Dynamic Testing
dast:
name: DAST - OWASP ZAP
runs-on: ubuntu-latest
needs: [sast, sca] # Only run DAST if SAST/SCA pass
steps:
- uses: actions/checkout@v4
- name: Start application
run: |
pip install -r requirements.txt
python src/app.py &
sleep 5 # Wait for startup
- name: ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.11.0
with:
target: 'http://localhost:5000'
fail_action: true
artifact_name: 'zap-results'
allow_issue_writing: false
# Security gate — aggregate and enforce thresholds
security-gate:
name: Security Gate
runs-on: ubuntu-latest
needs: [sast, sca, container, dast]
steps:
- name: Evaluate security posture
run: |
echo "All security scans passed!"
echo "SAST: Semgrep — passed"
echo "SCA: pip-audit — passed"
echo "Container: Trivy — passed"
echo "DAST: ZAP — passed"
echo "Deployment approved."
EOF
echo "GitHub Actions pipeline created at .github/workflows/devsecops.yml"
Fix the SQL injection and command injection vulnerabilities found by Semgrep:
cat > ~/devsecops-lab/src/app_fixed.py << 'EOF'
# FIXED version of the vulnerable app
from flask import Flask, request
import sqlite3, subprocess, os
app = Flask(__name__)
# FIX: Secret from environment variable
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(32))
def get_db():
return sqlite3.connect('/tmp/lab.db')
@app.route('/login', methods=['POST'])
def login():
user = request.form.get('username', '')
pwd = request.form.get('password', '')
# FIX: Parameterized query — no SQL injection
conn = get_db()
result = conn.execute(
"SELECT * FROM users WHERE username=? AND password=?",
(user, pwd)).fetchone()
return "ok" if result else "fail"
@app.route('/ping')
def ping():
host = request.args.get('host', '')
# FIX: Validate input, no shell=True, args as list
import re
if not re.match(r'^[a-zA-Z0-9.-]+$', host):
return "Invalid host", 400
result = subprocess.run(
['ping', '-c', '1', host],
capture_output=True, text=True, timeout=5)
return result.stdout
@app.route('/hash')
def strong_hash():
import hashlib
data = request.args.get('data', '')
# FIX: Use SHA-256 instead of MD5
return hashlib.sha256(data.encode()).hexdigest()
EOF
# Verify fixed code with Semgrep
semgrep --config p/owasp-top-ten \
--config ~/devsecops-lab/semgrep_rules/flask_security.yml \
~/devsecops-lab/src/app_fixed.py
echo "Fixed version should show fewer/no findings"
# Install gitleaks (detects secrets in git history and current code)
docker pull zricethezav/gitleaks:latest
# Scan the git repository for secrets
cd ~/devsecops-lab
docker run --rm \
-v $(pwd):/path \
zricethezav/gitleaks:latest detect \
--source /path \
--report-format json \
--report-path /path/reports/gitleaks_report.json \
--verbose
# Also install pre-commit hooks to catch secrets before they're committed
pip3 install pre-commit
cat > ~/devsecops-lab/.pre-commit-config.yaml << 'EOF'
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
- repo: https://github.com/pycqa/bandit
rev: 1.7.5
hooks:
- id: bandit
args: ["-ll"]
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
EOF
cd ~/devsecops-lab && pre-commit install
echo "Pre-commit hooks installed — will scan on every git commit"
| Vulnerability | Tool Detected | OWASP | CWE |
|---|---|---|---|
| SQL Injection | Semgrep | A03:2021 Injection | CWE-89 |
| Command Injection | Semgrep + ZAP | A03:2021 Injection | CWE-78 |
| Hardcoded Secret | Semgrep + Gitleaks | A02:2021 Crypto | CWE-798 |
| Weak Hash (MD5) | Semgrep | A02:2021 Crypto | CWE-327 |
| Insecure Deserialization | Semgrep | A08:2021 Integrity | CWE-502 |
| Vulnerable Dependencies | pip-audit/Trivy | A06:2021 Components | CVE-specific |
| Metric | Value |
|---|---|
| Semgrep findings (total) | |
| Vulnerable dependencies | |
| Container CVEs (CRITICAL) | |
| ZAP findings (HIGH) | |
| Secrets found in code | |
| Vulnerabilities after fix |