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.
Domains & 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:
┌─────────────────────────────────────────────────────────┐│ 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.
## Create the project directorymkdir razorpay-payment-service && cd razorpay-payment-service ## Set up Python virtual environmentpython3 -m venv venv && source venv/bin/activate ## Create the project structuremkdir -p src tests .github/workflows touch src/__init__.pytouch src/app.pytouch src/database.pytouch src/auth.pytouch requirements.txttouch Dockerfiletouch .gitignore echo "✅ Project structure created"## 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, jsonifyimport sqlite3import subprocessimport os app = Flask(__name__) ## ⚠️ This file intentionally contains vulnerabilities for learning.## The pipeline will catch them. You will fix them. 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}) 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}) def health(): return jsonify({"status": "ok", "service": "payment-service"}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080)## src/auth.py## Authentication module import hashlibimport os ## VULNERABILITY 3: Hardcoded secret key## Gitleaks will flag this immediatelyAWS_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## requirements.txt## Note: these versions are intentionally outdated## to trigger SCA findings in Trivy Flask==2.0.1requests==2.25.0cryptography==3.4.6PyYAML==5.3.1Pillow==8.1.0Common MistakeUsing
latestas 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## Uses an intentionally older base image to trigger image scan findings ## VULNERABILITY 4: Old base image with known CVEsFROM python:3.9-slim-buster WORKDIR /app ## Copy requirements first for Docker layer cachingCOPY requirements.txt . ## Install dependenciesRUN pip install --no-cache-dir -r requirements.txt ## Copy application codeCOPY 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
## Add a proper .gitignore before committing anythingcat > .gitignore << 'EOF'venv/__pycache__/*.pyc*.pyo.env*.db*.log.pytest_cache/EOF ## Initialize gitgit initgit 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 actiongit 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.
## Install Gitleaks (Linux/WSL)wget https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gztar xzf gitleaks_8.18.4_linux_x64.tar.gzsudo mv gitleaks /usr/local/bin/gitleaks version ## Install on macOSbrew install gitleaks## Run Gitleaks against the entire repositorygitleaks 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 1NoteThe
exit code 1is 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
## NEVER just delete the file containing the secret.## The commit history still has it. You need environment variables. ## Fix src/auth.py — remove hardcoded credentialscat > src/auth.py << 'EOF'import hashlibimport os ## ✅ Read from environment variables — never hardcode credentialsAWS_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) > 20EOF## Create a .gitleaks.toml config to tune false positivescat > .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## Verify Gitleaks passes after the fixgitleaks 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.
## Install Semgreppip install semgrep ## Run Semgrep with the Python security rulesetsemgrep --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 1Fixing the SAST findings
## src/app.py — fixed version with all vulnerabilities remediated from flask import Flask, request, jsonifyimport sqlite3import osimport 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$') 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}) 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}) def health(): return jsonify({"status": "ok", "service": "payment-service"}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080)## Re-run Semgrep to confirm zero findingssemgrep --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.
## Install Trivywget -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.listsudo apt update && sudo apt install trivy -y ## Scan the filesystem (checks requirements.txt)trivy fs --severity HIGH,CRITICAL --exit-code 1 .## 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 │## └─────────────────┴──────────────────┴──────────┴────────────┴──────────────┘## requirements.txt — FIXED with patched versionsFlask==3.0.0requests==2.31.0cryptography==41.0.5PyYAML==6.0.1Pillow==10.1.0## Re-run after updating requirements.txttrivy 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 — 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 CVEsFROM 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 accessRUN 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 entrypointUSER appuser EXPOSE 8080 ## ✅ Use exec form (array) not shell form (string)## Shell form spawns a shell process — exec form runs the process directlyCMD ["python", "src/app.py"]## Build the imagedocker build -t razorpay/payment-service:$(git rev-parse --short HEAD) . ## Scan the built imagetrivy 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).
## Install Syftcurl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \ | sh -s -- -b /usr/local/bin syft version## Generate an SBOM for the container image in CycloneDX format## CycloneDX is the preferred format for security toolingsyft 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 listedcat 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"## Inspect the SBOM — see every component and its versioncat sbom.cyclonedx.json \ | python3 -c "import json, syssbom = 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.
## Install Cosignwget https://github.com/sigstore/cosign/releases/download/v2.2.3/cosign-linux-amd64sudo mv cosign-linux-amd64 /usr/local/bin/cosignsudo chmod +x /usr/local/bin/cosign cosign version## 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 keyscosign 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## Sign the image after pushing to registry## The IMAGE_TAG should be the full registry pathIMAGE_TAG="ghcr.io/razorpay/payment-service:$(git rev-parse --short HEAD)" ## Push the image firstdocker 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:
## .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 jobsenv: 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
## ── Step 1: Run Gitleaks ─────────────────────────────────echo "=== Gate 1: Secrets Detection ==="gitleaks detect --source . --verboseecho "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_TAGecho "Gate 5 exit code: $?" ## ── Step 6: Generate SBOM ────────────────────────────────echo "=== Gate 6: SBOM Generation ==="syft razorpay/payment-service:$IMAGE_TAG \ --output cyclonedx-json > sbom.jsonecho "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_TAGcosign verify --key cosign.pub razorpay/payment-service:$IMAGE_TAGecho "Gate 7 exit code: $?" echo ""echo "✅ All 7 security gates passed. Ready to push."Production Checklist
## ─── 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 1echo "✅ 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.ymlecho "✅ 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.