DevSecOps project - Build a Fully Gated Secure CI/CD Pipeline

Build a production-grade secure pipeline where every push triggers SAST, SCA, secrets detection, container scanning, SBOM generation, and image signing before any code reaches production.

Related Concepts & TermsgrepPipePipelineSASTContainerTrigger

Domains & Technologies

Domains
CAPSTONEDEVSECOPSSASTSCAGITHUB-ACTIONSTRIVYSEMGREPCOSIGN
Technologies

Blueprint Walkthrough

The Incident That Started Everything

It is a Thursday afternoon at Razorpay. A junior engineer pushes a payment processing microservice to GitHub. The code looks clean. The PR passes code review. The pipeline runs. Deployment succeeds.

Forty-eight hours later, AWS sends a security alert. An IAM key hardcoded in the repository has been used from an IP in Singapore to create hundreds of EC2 instances. The key has been live in the repo for six weeks, exposed in commit history even after someone noticed and deleted the file.

The damage: $140,000 in unauthorized cloud spend, a 12-hour incident response, and a compliance audit.

Here is what a proper DevSecOps pipeline would have done: caught the secret in under 4 seconds, blocked the push before it ever reached the remote, and sent a structured alert to Slack with the exact file and line number.

This capstone builds that pipeline from scratch. By the end, one git push from a developer triggers seven automated security gates. Every gate either passes and lets the code move forward, or fails with a structured report explaining exactly what is wrong and how to fix it.

Understanding the Security Gates

The seven gates and what each one catches

A security gate is a mandatory check that blocks deployment if it finds a violation. Unlike optional linting, a failing gate stops the pipeline completely. No gate failure means no deployment.

Here are the seven gates you are building:

Bash
┌─────────────────────────────────────────────────────────┐
git push
└──────────────────────────┬──────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Gate 1: Secrets Detection (Gitleaks) │
│ Catches: hardcoded API keys, passwords, tokens │
│ Time: ~4 seconds │
└──────────────────────────┬───────────────────────────────┘
│ pass
┌──────────────────────────────────────────────────────────┐
│ Gate 2: SAST — Static Analysis (Semgrep) │
│ Catches: SQL injection, path traversal, unsafe evals │
│ Time: ~45 seconds │
└──────────────────────────┬───────────────────────────────┘
│ pass
┌──────────────────────────────────────────────────────────┐
│ Gate 3: SCA — Dependency Scan (Trivy fs) │
│ Catches: CVEs in pip/npm/maven dependencies │
│ Time: ~30 seconds │
└──────────────────────────┬───────────────────────────────┘
│ pass
┌──────────────────────────────────────────────────────────┐
│ Gate 4: Container Image Build │
│ Builds the Docker image for scanning │
└──────────────────────────┬───────────────────────────────┘
│ pass
┌──────────────────────────────────────────────────────────┐
│ Gate 5: Container Image Scan (Trivy image) │
│ Catches: CVEs in the base OS, system packages │
│ Time: ~60 seconds │
└──────────────────────────┬───────────────────────────────┘
│ pass
┌──────────────────────────────────────────────────────────┐
│ Gate 6: SBOM Generation (Syft) │
│ Creates: full inventory of every package in the image │
│ Time: ~20 seconds │
└──────────────────────────┬───────────────────────────────┘
│ pass
┌──────────────────────────────────────────────────────────┐
│ Gate 7: Image Signing (Cosign) │
│ Signs: the image with a cryptographic key │
│ Time: ~10 seconds │
└──────────────────────────┬───────────────────────────────┘
│ pass
┌──────────────────────────────────────────────────────────┐
│ Deploy to production │
└──────────────────────────────────────────────────────────┘

Why each gate exists and what breaks without it

Gitleaks solves the most expensive class of security incident: credential exposure. Keys committed to git are permanent — even if you delete the file, the key lives in git history. Gitleaks scans every commit diff, not just the current state of files.

Semgrep (Static Application Security Testing or SAST) reads your source code without running it. It finds patterns that indicate vulnerabilities — exec(user_input) is dangerous regardless of what user_input contains. Semgrep uses community-maintained rule sets covering OWASP Top 10 for Python, Node.js, Java, and Go.

Trivy filesystem scan (Software Composition Analysis or SCA) reads your requirements.txt, package.json, go.mod, or pom.xml and checks every dependency version against the CVE database. A library you have been using for two years might have a critical vulnerability disclosed last week.

Trivy image scan goes further than the filesystem scan. After the Docker image is built, it scans the OS layer — the Ubuntu or Alpine base image packages, the system libraries. A Python app might be clean, but if it runs on an Ubuntu 20.04 base with an old glibc, that CVE is in your container.

Syft generates a Software Bill of Materials (SBOM) — a complete inventory of every package, library, and OS component in the image with exact versions. This is required by government compliance frameworks (NIST SSDF, Executive Order 14028) and increasingly required by enterprise customers before they will use your software.

Cosign signs the container image with a cryptographic key. When Kubernetes pulls the image, it can verify the signature before running it. This prevents supply chain attacks where someone replaces your image in the registry with a malicious version.

Setting Up the Project

The application you are securing

You are building a pipeline for a payment service — realistic enough to have actual vulnerabilities you will deliberately inject and then catch.

Bash
## Create the project directory
mkdir razorpay-payment-service && cd razorpay-payment-service
## Set up Python virtual environment
python3 -m venv venv && source venv/bin/activate
## Create the project structure
mkdir -p src tests .github/workflows
touch src/__init__.py
touch src/app.py
touch src/database.py
touch src/auth.py
touch requirements.txt
touch Dockerfile
touch .gitignore
echo "✅ Project structure created"
PYTHON
## src/app.py
## A realistic Flask payment service with some intentional
## vulnerabilities you will catch and fix during this capstone.
from flask import Flask, request, jsonify
import sqlite3
import subprocess
import os
app = Flask(__name__)
## ⚠️ This file intentionally contains vulnerabilities for learning.
## The pipeline will catch them. You will fix them.
@app.route("/payment/search")
def search_payments():
"""Search payments by merchant name."""
merchant = request.args.get("merchant", "")
## VULNERABILITY 1: SQL injection
## Semgrep will flag this as a SAST finding
conn = sqlite3.connect("payments.db")
cursor = conn.cursor()
query = f"SELECT * FROM payments WHERE merchant = '{merchant}'"
cursor.execute(query)
results = cursor.fetchall()
conn.close()
return jsonify({"payments": results})
@app.route("/payment/export")
def export_payments():
"""Export payments to CSV file."""
filename = request.args.get("filename", "export.csv")
## VULNERABILITY 2: Path traversal
## Semgrep will flag this as a SAST finding
with open(f"/exports/{filename}", "w") as f:
f.write("payment_id,amount,merchant\n")
return jsonify({"status": "exported", "file": filename})
@app.route("/health")
def health():
return jsonify({"status": "ok", "service": "payment-service"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
PYTHON
## src/auth.py
## Authentication module
import hashlib
import os
## VULNERABILITY 3: Hardcoded secret key
## Gitleaks will flag this immediately
AWS_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
DB_PASSWORD = "razorpay_prod_db_pass_2024"
def hash_password(password: str) -> str:
"""Hash a password using SHA-256."""
## SHA-256 is used here for demo — use bcrypt in production
return hashlib.sha256(password.encode()).hexdigest()
def verify_token(token: str) -> bool:
"""Verify a JWT token."""
## Simplified for the capstone — use PyJWT in production
return len(token) > 20
TEXT
## requirements.txt
## Note: these versions are intentionally outdated
## to trigger SCA findings in Trivy
Flask==2.0.1
requests==2.25.0
cryptography==3.4.6
PyYAML==5.3.1
Pillow==8.1.0
Common Mistake

Using latest as versions in requirements.txt makes Trivy unable to check for CVEs because it cannot identify the exact version. Always pin exact versions, even if old ones — then the scanner can tell you which ones are vulnerable.

Dockerfile
## Dockerfile
## Uses an intentionally older base image to trigger image scan findings
## VULNERABILITY 4: Old base image with known CVEs
FROM python:3.9-slim-buster
WORKDIR /app
## Copy requirements first for Docker layer caching
COPY requirements.txt .
## Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
## Copy application code
COPY src/ ./src/
## Run as root (bad practice — the image scan will warn about this)
EXPOSE 8080
CMD ["python", "src/app.py"]

Initializing git and the first commit

Bash
## Add a proper .gitignore before committing anything
cat > .gitignore << 'EOF'
venv/
__pycache__/
*.pyc
*.pyo
.env
*.db
*.log
.pytest_cache/
EOF
## Initialize git
git init
git add .
## This first commit will fail when you add Gitleaks
## because src/auth.py contains hardcoded secrets
## That is intentional — you will fix it after seeing the gate in action
git commit -m "Initial payment service setup"
Building Gate 1 — Secrets Detection with Gitleaks

Installing and running Gitleaks locally

Gitleaks is an open-source tool that scans git repositories for secrets using a pattern library of over 150 secret types. It checks file contents, commit history, and git blob diffs.

Bash
## Install Gitleaks (Linux/WSL)
wget https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gz
tar xzf gitleaks_8.18.4_linux_x64.tar.gz
sudo mv gitleaks /usr/local/bin/
gitleaks version
## Install on macOS
brew install gitleaks
Bash
## Run Gitleaks against the entire repository
gitleaks detect --source . --verbose
## Expected output when secrets exist:
## ○
## │╲
## │ ○
## ○ ░
## ░ gitleaks
##
## Finding: AWS Secret Access Key
## Secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
## RuleID: aws-secret-access-key
## Entropy: 4.55
## File: src/auth.py
## Line: 9
## Commit: a3f2b1c
## Author: dev@razorpay.com
## Date: 2024-01-15T10:23:11Z
## Fingerprint: a3f2b1c:src/auth.py:aws-secret-access-key:9
##
## 2 findings found.
## exit code 1
Note

The exit code 1 is intentional and critical. When Gitleaks returns exit code 1, the GitHub Actions step fails and the pipeline stops. No code moves forward until secrets are removed from the codebase and its history.

Fixing the secrets finding

Bash
## NEVER just delete the file containing the secret.
## The commit history still has it. You need environment variables.
## Fix src/auth.py — remove hardcoded credentials
cat > src/auth.py << 'EOF'
import hashlib
import os
## ✅ Read from environment variables — never hardcode credentials
AWS_SECRET_KEY = os.environ.get("AWS_SECRET_KEY")
DB_PASSWORD = os.environ.get("DB_PASSWORD")
if not AWS_SECRET_KEY:
raise EnvironmentError("AWS_SECRET_KEY environment variable not set")
def hash_password(password: str) -> str:
"""Hash a password using SHA-256."""
return hashlib.sha256(password.encode()).hexdigest()
def verify_token(token: str) -> bool:
"""Verify a JWT token."""
return len(token) > 20
EOF
Bash
## Create a .gitleaks.toml config to tune false positives
cat > .gitleaks.toml << 'EOF'
title = "Razorpay Payment Service Gitleaks Config"
[allowlist]
description = "Allowlisted patterns"
## Ignore example values in documentation
regexes = [
'''EXAMPLE''',
'''REPLACE_ME''',
]
## Ignore test files that contain fake credentials
paths = [
'''tests/fixtures/''',
]
EOF
Bash
## Verify Gitleaks passes after the fix
gitleaks detect --source . --verbose
## Expected: "No leaks found."
## exit code 0
echo "✅ Gate 1 passes"
Building Gate 2 — SAST with Semgrep

Understanding what SAST finds

SAST (Static Application Security Testing) analyzes source code for patterns that indicate vulnerabilities without running the code. It is pattern matching at a sophisticated level — not just looking for dangerous function names, but understanding the data flow from user input to dangerous operations.

Semgrep uses a rule language that lets security engineers write custom rules in YAML. The community maintains thousands of rules covering OWASP Top 10 for every major language.

Bash
## Install Semgrep
pip install semgrep
## Run Semgrep with the Python security ruleset
semgrep --config=p/python src/
## Expected output for our app.py:
## Scanning 3 files...
##
## src/app.py
## severity:error rule:python.lang.security.audit.formatted-sql-query
## 19| query = f"SELECT * FROM payments WHERE merchant = '{merchant}'"
## Detected SQL statement that is tainted by `request` object.
## Avoid string concatenation or formatting in SQL queries.
## [CWE-89: SQL Injection]
##
## src/app.py
## severity:warning rule:python.lang.security.audit.path-traversal.open
## 30| with open(f"/exports/{filename}", "w") as f:
## Found user-controlled `open()` call.
## [CWE-22: Path Traversal]
##
## Findings: 2 errors, 1 warning
## exit code 1

Fixing the SAST findings

PYTHON
## src/app.py — fixed version with all vulnerabilities remediated
from flask import Flask, request, jsonify
import sqlite3
import os
import re
app = Flask(__name__)
## ALLOWED_EXPORT_DIR constrains where files can be written
## Prevents path traversal by rejecting any filename with ../
ALLOWED_EXPORT_DIR = "/exports"
ALLOWED_FILENAME_PATTERN = re.compile(r'^[a-zA-Z0-9_\-]+\.csv$')
@app.route("/payment/search")
def search_payments():
"""Search payments by merchant name using parameterised query."""
merchant = request.args.get("merchant", "")
## ✅ FIXED: Parameterised query prevents SQL injection
## The ? placeholder means sqlite3 escapes the value automatically
conn = sqlite3.connect("payments.db")
cursor = conn.cursor()
cursor.execute("SELECT * FROM payments WHERE merchant = ?", (merchant,))
results = cursor.fetchall()
conn.close()
return jsonify({"payments": results})
@app.route("/payment/export")
def export_payments():
"""Export payments to CSV with safe filename validation."""
filename = request.args.get("filename", "export.csv")
## ✅ FIXED: Validate filename with allowlist pattern before use
if not ALLOWED_FILENAME_PATTERN.match(filename):
return jsonify({"error": "Invalid filename format"}), 400
safe_path = os.path.join(ALLOWED_EXPORT_DIR, filename)
## ✅ Double-check resolved path stays within allowed directory
if not os.path.realpath(safe_path).startswith(ALLOWED_EXPORT_DIR):
return jsonify({"error": "Access denied"}), 403
with open(safe_path, "w") as f:
f.write("payment_id,amount,merchant\n")
return jsonify({"status": "exported", "file": filename})
@app.route("/health")
def health():
return jsonify({"status": "ok", "service": "payment-service"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
Bash
## Re-run Semgrep to confirm zero findings
semgrep --config=p/python src/
## Expected: "No findings."
## exit code 0
echo "✅ Gate 2 passes"
Building Gate 3 — SCA with Trivy

Scanning dependencies for CVEs

SCA (Software Composition Analysis) checks your third-party dependencies against public vulnerability databases. CVE (Common Vulnerabilities and Exposures) is a list maintained by NIST — every known security flaw in public software gets a CVE ID, a severity score (CVSS), and a description of the impact.

Trivy is an open-source scanner from Aqua Security. It checks packages from pip, npm, go modules, maven, gradle, and more against multiple CVE databases including NVD, GitHub Security Advisories, and OS vendor advisories.

Bash
## Install Trivy
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 /etc/apt/sources.list.d/trivy.list
sudo apt update && sudo apt install trivy -y
## Scan the filesystem (checks requirements.txt)
trivy fs --severity HIGH,CRITICAL --exit-code 1 .
Bash
## Expected output — our old dependencies have CVEs:
## 2024-01-15T10:30:00.000Z INFO Detected OS: unknown
## 2024-01-15T10:30:01.000Z INFO Scanning files...
##
## requirements.txt (pip)
## Total: 4 (HIGH: 2, CRITICAL: 2)
##
## ┌─────────────────┬──────────────────┬──────────┬────────────┬──────────────┐
## │ Library │ Vulnerability │ Severity │ Installed │ Fixed In │
## ├─────────────────┼──────────────────┼──────────┼────────────┼──────────────┤
## │ cryptography │ CVE-2023-23931 │ CRITICAL │ 3.4.6 │ 39.0.1 │
## │ requests │ CVE-2023-32681 │ HIGH │ 2.25.0 │ 2.31.0 │
## │ PyYAML │ CVE-2022-1471 │ CRITICAL │ 5.3.1 │ 6.0 │
## │ Pillow │ CVE-2023-44271 │ HIGH │ 8.1.0 │ 10.0.0 │
## └─────────────────┴──────────────────┴──────────┴────────────┴──────────────┘
TEXT
## requirements.txt — FIXED with patched versions
Flask==3.0.0
requests==2.31.0
cryptography==41.0.5
PyYAML==6.0.1
Pillow==10.1.0
Bash
## Re-run after updating requirements.txt
trivy fs --severity HIGH,CRITICAL --exit-code 1 .
## Expected: "Total: 0 (HIGH: 0, CRITICAL: 0)"
echo "✅ Gate 3 passes"
Building Gates 4 and 5 — Container Image Build and Scan

Fixing the Dockerfile before scanning

Dockerfile
## Dockerfile — production-hardened version
## ✅ Use a specific recent tag, not latest or an old buster image
## python:3.12-slim-bookworm = Debian Bookworm base (2023), far fewer CVEs
FROM python:3.12-slim-bookworm
WORKDIR /app
## ✅ Create a non-root user to run the application
## Running as root means any container escape gives full root access
RUN groupadd -r appuser && useradd -r -g appuser appuser
## Copy and install requirements first (Docker layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
## Remove pip cache to reduce image size
&& pip cache purge
COPY src/ ./src/
## ✅ Switch to non-root user before defining the entrypoint
USER appuser
EXPOSE 8080
## ✅ Use exec form (array) not shell form (string)
## Shell form spawns a shell process — exec form runs the process directly
CMD ["python", "src/app.py"]
Bash
## Build the image
docker build -t razorpay/payment-service:$(git rev-parse --short HEAD) .
## Scan the built image
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
razorpay/payment-service:$(git rev-parse --short HEAD)
## Expected after using a modern base image:
## Total: 0 (HIGH: 0, CRITICAL: 0)
## ✅ Gate 5 passes
echo "✅ Gates 4 and 5 pass"
Building Gate 6 — SBOM Generation with Syft

What an SBOM is and why it matters

An SBOM (Software Bill of Materials) is an inventory document that lists every component inside your software: OS packages, language libraries, their exact versions, and their licenses. Think of it like a nutrition label on food — it tells you exactly what is inside.

SBOMs became legally significant after the 2021 US Executive Order on Improving Cybersecurity. Federal agencies now require SBOMs from software vendors. Enterprise customers are increasingly asking for them before procurement decisions.

Syft by Anchore generates SBOMs in multiple formats. The most widely used formats are SPDX (ISO standard) and CycloneDX (OWASP standard).

Bash
## Install Syft
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin
syft version
Bash
## Generate an SBOM for the container image in CycloneDX format
## CycloneDX is the preferred format for security tooling
syft razorpay/payment-service:$(git rev-parse --short HEAD) \
--output cyclonedx-json \
> sbom.cyclonedx.json
## Generate in SPDX format (required by some government contracts)
syft razorpay/payment-service:$(git rev-parse --short HEAD) \
--output spdx-json \
> sbom.spdx.json
## Check how many components are listed
cat sbom.cyclonedx.json | python3 -m json.tool | grep '"type": "library"' | wc -l
## A typical Python service shows 40-80 libraries including transitive deps
echo "✅ Gate 6 passes"
Bash
## Inspect the SBOM — see every component and its version
cat sbom.cyclonedx.json \
| python3 -c "
import json, sys
sbom = json.load(sys.stdin)
print(f'SBOM format: {sbom[\"bomFormat\"]}')
print(f'SBOM version: {sbom[\"specVersion\"]}')
print(f'Components: {len(sbom[\"components\"])}')
print()
print('Top 10 components:')
for c in sbom['components'][:10]:
print(f' {c[\"name\"]}=={c[\"version\"]} ({c.get(\"type\",\"unknown\")})')
"
Building Gate 7 — Image Signing with Cosign

Understanding supply chain attacks and how signing prevents them

A supply chain attack happens when an attacker compromises not your code, but something your code depends on. One class of supply chain attack targets container registries: an attacker who gains access to your registry can replace razorpay/payment-service:latest with a malicious image that looks identical but exfiltrates credentials.

Cosign solves this by creating a cryptographic signature tied to the image content hash. The signature is stored in the container registry alongside the image. When Kubernetes pulls the image, a policy admission controller (like Kyverno or Sigstore Policy Controller) verifies the signature before allowing the container to start.

Bash
## Install Cosign
wget https://github.com/sigstore/cosign/releases/download/v2.2.3/cosign-linux-amd64
sudo mv cosign-linux-amd64 /usr/local/bin/cosign
sudo chmod +x /usr/local/bin/cosign
cosign version
Bash
## Generate a signing key pair (do this once, store the private key securely)
## In production: use KMS (AWS KMS, GCP KMS) instead of file-based keys
cosign generate-key-pair
## This creates:
## cosign.key <- NEVER commit this to git, store in a secret manager
## cosign.pub <- Safe to commit, used for verification
echo "cosign.key" >> .gitignore
Bash
## Sign the image after pushing to registry
## The IMAGE_TAG should be the full registry path
IMAGE_TAG="ghcr.io/razorpay/payment-service:$(git rev-parse --short HEAD)"
## Push the image first
docker push $IMAGE_TAG
## Sign it (uses cosign.key, prompts for password)
cosign sign --key cosign.key $IMAGE_TAG
## Verify the signature (this is what Kubernetes admission would run)
cosign verify --key cosign.pub $IMAGE_TAG
## Expected verification output:
## Verification for ghcr.io/razorpay/payment-service:abc1234 --
## The following checks were performed on each of these signatures:
## - The cosign claims were validated
## - The signatures were verified against the specified public key
## {"critical":{"identity":{"docker-reference":"ghcr.io/razorpay/payment-service"},...}}
echo "✅ Gate 7 passes"
Writing the Full GitHub Actions Pipeline

The complete pipeline YAML

Create the file at .github/workflows/secure-pipeline.yml:

YAML
## .github/workflows/secure-pipeline.yml
## The complete DevSecOps pipeline.
## All 7 security gates run as separate jobs.
## Each job depends on the previous one — failure stops the chain.
name: DevSecOps Secure Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
## Define the image name once, use it across all jobs
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/payment-service
IMAGE_TAG: ${{ github.sha }}
jobs:
## ── Gate 1: Secrets Detection ─────────────────────────────────
secrets-detection:
name: "Gate 1 — Secrets Detection (Gitleaks)"
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
## fetch-depth: 0 = full git history
## Gitleaks scans ALL commits, not just the latest
fetch-depth: 0
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
## GITHUB_TOKEN lets Gitleaks post findings as PR comments
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
## Upload report even on failure so developers can see what was found
- name: Upload Gitleaks report
uses: actions/upload-artifact@v4
if: always()
with:
name: gitleaks-report
path: results.sarif
## ── Gate 2: SAST ──────────────────────────────────────────────
sast-scan:
name: "Gate 2 — SAST Scan (Semgrep)"
runs-on: ubuntu-latest
## Only run SAST after secrets gate passes
needs: secrets-detection
container:
## Use the official Semgrep Docker image
image: semgrep/semgrep
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Semgrep SAST scan
run: |
semgrep \
--config=p/python \
--config=p/owasp-top-ten \
--config=p/secrets \
--error \
--json \
--output semgrep-results.json \
src/
## --error = exit code 1 on any finding
## --json = structured output for artifact upload
- name: Upload Semgrep results
uses: actions/upload-artifact@v4
if: always()
with:
name: semgrep-report
path: semgrep-results.json
## ── Gate 3: SCA ───────────────────────────────────────────────
sca-scan:
name: "Gate 3 — Dependency Scan (Trivy SCA)"
runs-on: ubuntu-latest
needs: sast-scan
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Trivy filesystem scan
uses: aquasecurity/trivy-action@master
with:
## scan-type: fs = filesystem (reads requirements.txt, package.json, etc.)
scan-type: 'fs'
scan-ref: '.'
## Block on HIGH and CRITICAL CVEs only
severity: 'HIGH,CRITICAL'
## exit-code: 1 = fail the job if any findings match the severity
exit-code: '1'
format: 'sarif'
output: 'trivy-sca-results.sarif'
- name: Upload Trivy SCA results
uses: actions/upload-artifact@v4
if: always()
with:
name: trivy-sca-report
path: trivy-sca-results.sarif
## ── Gate 4: Build Image ───────────────────────────────────────
build-image:
name: "Gate 4 — Build Container Image"
runs-on: ubuntu-latest
needs: sca-scan
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
## GITHUB_TOKEN has write permission to ghcr.io automatically
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: true
## Tag with both the commit SHA (immutable) and latest (floating)
tags: |
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
${{ env.IMAGE_NAME }}:latest
## Cache layers in GitHub Actions cache for faster subsequent builds
cache-from: type=gha
cache-to: type=gha,mode=max
## ── Gate 5: Container Image Scan ─────────────────────────────
image-scan:
name: "Gate 5 — Container Image Scan (Trivy)"
runs-on: ubuntu-latest
needs: build-image
steps:
- name: Run Trivy image scan
uses: aquasecurity/trivy-action@master
with:
## scan-type: image = scans the built container (OS + app layers)
scan-type: 'image'
image-ref: '${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}'
severity: 'HIGH,CRITICAL'
exit-code: '1'
format: 'sarif'
output: 'trivy-image-results.sarif'
- name: Upload image scan results
uses: actions/upload-artifact@v4
if: always()
with:
name: trivy-image-report
path: trivy-image-results.sarif
## ── Gate 6: SBOM Generation ───────────────────────────────────
sbom-generation:
name: "Gate 6 — Generate SBOM (Syft)"
runs-on: ubuntu-latest
needs: image-scan
steps:
- name: Install Syft
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin
- name: Generate CycloneDX SBOM
run: |
syft ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} \
--output cyclonedx-json \
> sbom-${{ env.IMAGE_TAG }}.cyclonedx.json
## Print component count for visibility in CI logs
echo "SBOM components: $(cat sbom-${{ env.IMAGE_TAG }}.cyclonedx.json \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(len(d[\"components\"]))')"
- name: Upload SBOM as release artifact
uses: actions/upload-artifact@v4
with:
name: sbom-${{ env.IMAGE_TAG }}
path: sbom-${{ env.IMAGE_TAG }}.cyclonedx.json
## Keep SBOMs for 365 days for compliance purposes
retention-days: 365
## ── Gate 7: Image Signing ─────────────────────────────────────
sign-image:
name: "Gate 7 — Sign Image (Cosign)"
runs-on: ubuntu-latest
needs: sbom-generation
## This permission is required for keyless Cosign signing
permissions:
contents: read
id-token: write
packages: write
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Sign the container image
run: |
## Keyless signing uses Sigstore's ephemeral keys tied to the
## GitHub Actions OIDC token — no key management required
cosign sign \
--yes \
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
- name: Verify the signature
run: |
cosign verify \
--certificate-identity-regexp="https://github.com/${{ github.repository }}/*" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
echo "✅ Image signature verified"
## ── Deploy (only runs if all 7 gates pass) ────────────────────
deploy:
name: "Deploy to Production"
runs-on: ubuntu-latest
needs: sign-image
## Only deploy from main branch
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Deploy to Kubernetes
run: |
## In production: use kubectl, helm, or ArgoCD here
echo "All 7 security gates passed"
echo "Deploying ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} to production"
echo "Deployment complete ✅"
Running the Complete Pipeline Locally

Simulating the full pipeline before pushing

Bash
## ── Step 1: Run Gitleaks ─────────────────────────────────
echo "=== Gate 1: Secrets Detection ==="
gitleaks detect --source . --verbose
echo "Gate 1 exit code: $?"
## ── Step 2: Run Semgrep ──────────────────────────────────
echo "=== Gate 2: SAST Scan ==="
semgrep --config=p/python --config=p/owasp-top-ten --error src/
echo "Gate 2 exit code: $?"
## ── Step 3: Trivy filesystem scan ────────────────────────
echo "=== Gate 3: SCA Scan ==="
trivy fs --severity HIGH,CRITICAL --exit-code 1 .
echo "Gate 3 exit code: $?"
## ── Step 4: Build image ───────────────────────────────────
echo "=== Gate 4: Build Image ==="
IMAGE_TAG=$(git rev-parse --short HEAD)
docker build -t razorpay/payment-service:$IMAGE_TAG .
echo "Gate 4 exit code: $?"
## ── Step 5: Trivy image scan ─────────────────────────────
echo "=== Gate 5: Image Scan ==="
trivy image --severity HIGH,CRITICAL --exit-code 1 \
razorpay/payment-service:$IMAGE_TAG
echo "Gate 5 exit code: $?"
## ── Step 6: Generate SBOM ────────────────────────────────
echo "=== Gate 6: SBOM Generation ==="
syft razorpay/payment-service:$IMAGE_TAG \
--output cyclonedx-json > sbom.json
echo "SBOM components: $(cat sbom.json | python3 -c \
'import json,sys; d=json.load(sys.stdin); print(len(d["components"]))')"
echo "Gate 6 exit code: $?"
## ── Step 7: Sign image ───────────────────────────────────
echo "=== Gate 7: Sign Image ==="
cosign sign --key cosign.key razorpay/payment-service:$IMAGE_TAG
cosign verify --key cosign.pub razorpay/payment-service:$IMAGE_TAG
echo "Gate 7 exit code: $?"
echo ""
echo "✅ All 7 security gates passed. Ready to push."
Production Checklist
Bash
## ─── 1. All tools installed ──────────────────────────────────
gitleaks version && echo "✅ Gitleaks installed"
semgrep --version && echo "✅ Semgrep installed"
trivy --version && echo "✅ Trivy installed"
syft version && echo "✅ Syft installed"
cosign version && echo "✅ Cosign installed"
## ─── 2. No secrets in codebase ───────────────────────────────
gitleaks detect --source . --exit-code 1
echo "✅ No secrets found"
## ─── 3. No SAST findings ─────────────────────────────────────
semgrep --config=p/python --error --quiet src/
echo "✅ No SAST findings"
## ─── 4. No HIGH/CRITICAL CVEs in deps ────────────────────────
trivy fs --severity HIGH,CRITICAL --exit-code 1 --quiet .
echo "✅ No critical CVEs in dependencies"
## ─── 5. No HIGH/CRITICAL CVEs in image ───────────────────────
trivy image --severity HIGH,CRITICAL --exit-code 1 --quiet \
razorpay/payment-service:$(git rev-parse --short HEAD)
echo "✅ No critical CVEs in container image"
## ─── 6. SBOM exists and has components ───────────────────────
test -f sbom.json && \
python3 -c "import json; d=json.load(open('sbom.json')); \
assert len(d['components']) > 0, 'SBOM is empty'" && \
echo "✅ SBOM generated with components"
## ─── 7. GitHub Actions pipeline file exists ──────────────────
test -f .github/workflows/secure-pipeline.yml
echo "✅ Pipeline file present"
echo ""
echo "✅ All production checks passed. Ready for production."
Common Production Mistakes

Using --exit-code 0 on Trivy or Semgrep "just to see the findings" in a shared pipeline file and then forgetting to change it back. Once a gate runs with exit code 0, it will never block a deployment regardless of findings. Every security gate must run with --exit-code 1 in the main branch pipeline. Use a separate audit workflow without exit codes if you want informational scans that do not block deployment.

Scanning only the current file state with Gitleaks instead of full history. The default gitleaks detect with no flags only checks uncommitted working directory changes. In GitHub Actions you need fetch-depth: 0 to clone the full history and gitleaks detect (not protect) to scan it. A secret committed six months ago and deleted one month ago is still in git history and still a live credential risk.

Pinning the Trivy database version and never updating it. The CVE database updates daily. If you pin the Trivy container version without updating it, your scanner becomes blind to new vulnerabilities. Use trivy --download-db-only in a scheduled workflow that runs nightly to keep the database current.

Treating WARNING findings as acceptable in a production pipeline. Semgrep and Trivy both have CRITICAL, HIGH, MEDIUM, LOW, and UNKNOWN severity levels. Many teams start by blocking only CRITICAL findings, intending to address the rest "later." The HIGH and MEDIUM findings accumulate into dozens and then hundreds of deferred items. Set a policy on day one that HIGH findings also block deployment, with a documented exception process for accepted risks.

Not verifying the Cosign signature before deployment. Signing the image is pointless if nothing verifies the signature. Add a Kyverno ClusterPolicy or Sigstore Policy Controller to your Kubernetes cluster that rejects unsigned or improperly signed images. Without admission control enforcement, the signing step is documentation theater.

Generating the SBOM from the filesystem instead of the built image. Running syft . on the project directory misses OS-level components installed in the Docker base image. Always generate the SBOM from syft image:tag after building, not from the source directory. The full supply chain inventory includes the OS packages your application runs on.

Quick Reference
Tool Gate What It Scans Block Threshold
Gitleaks 1 - Secrets All committed files and history Any finding
Semgrep 2 - SAST Python/JS/Go source code ERROR severity
Trivy fs 3 - SCA requirements.txt, package.json HIGH, CRITICAL
Docker Build 4 - Build Builds the container image Build failure
Trivy image 5 - Image OS packages inside container HIGH, CRITICAL
Syft 6 - SBOM Full image component inventory Generation failure
Cosign 7 - Signing Signs image in registry Signing failure
Command What It Does
gitleaks detect --source . --verbose Scan repo for secrets
semgrep --config=p/python --error src/ Run SAST on Python source
trivy fs --severity HIGH,CRITICAL --exit-code 1 . SCA on dependencies
trivy image --severity HIGH,CRITICAL image:tag Scan built container
syft image:tag --output cyclonedx-json > sbom.json Generate SBOM
cosign sign --key cosign.key image:tag Sign container image
cosign verify --key cosign.pub image:tag Verify image signature

Videos & Guides

No external walkthroughs, video tutorials, or reference files attached to this blueprint yet.