Build a Complete DevSecOps Pipeline with SAST, DAST, and Container Scanning
Build a security-first CI/CD pipeline with Semgrep SAST, OWASP ZAP DAST, Trivy scanning, and Checkov IaC checks blocking on critical findings.
Domains & Technologies
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds a complete DevSecOps pipeline where security testing is automatic on every code change — not a manual process before quarterly releases. Every pull request triggers four layers of security scanning before any deployment can happen.
This is the pipeline that gets DevSecOps engineers hired at CRED, Razorpay, and Zerodha. When an interviewer asks "how do you shift security left?" — you describe this pipeline, running out of ap-south-1 (Mumbai).
TipThe gate sits before staging deployment — nothing with a CRITICAL finding ever reaches a running environment, let alone production.
The traditional approach — run security scans once a month, get a 200-page PDF report, hand it to developers who have already shipped three more features — does not work. By the time vulnerabilities are reported, the code is in production and the developer has no memory of writing it.
Shift-left security means finding vulnerabilities when the developer is still looking at the code. Semgrep catches SQL injection patterns in the PR before merge. Checkov catches an S3 bucket with a public-read ACL before the Terraform is applied. Trivy catches a container with a critical CVE before it is deployed. OWASP ZAP catches a missing authentication header before users can hit the endpoint.
SecurityThis project intentionally ships vulnerable code first (SQLi, root container, public S3 bucket) so you can watch each scanner catch it — then you fix it and watch the pipeline go green. Never deploy the "before" state anywhere reachable from the internet, even for a demo.
Milestone 1: Set Up the Repository with Intentional Vulnerabilities
Before wiring up any scanner, you need something for it to catch. This milestone creates a small Express + Postgres app, a Dockerfile, and Terraform — each with one planted vulnerability — so the rest of the project has real findings to work against instead of a clean repo that proves nothing.
mkdir devsecops-demo && cd devsecops-demomkdir -p src k8s terraform .github/workflowscat > src/app.js << 'EOF'const express = require('express');const { Pool } = require('pg'); const app = express();app.use(express.json()); const pool = new Pool({ connectionString: process.env.DATABASE_URL}); // VULNERABILITY: SQL injection — Semgrep will catch thisapp.get('/user', async (req, res) => { const { id } = req.query; const result = await pool.query(`SELECT * FROM users WHERE id = ${id}`); res.json(result.rows);}); // VULNERABILITY: Missing authentication — OWASP ZAP will flag thisapp.get('/admin/users', async (req, res) => { const result = await pool.query('SELECT * FROM users'); res.json(result.rows);}); app.get('/health', (req, res) => res.json({ status: 'healthy' })); app.listen(3000);EOFcat > Dockerfile << 'EOF'FROM node:16-alpine# ^ OLD version with known CVEs — Trivy will flag this WORKDIR /appCOPY package*.json ./RUN npm installCOPY src/ ./src/ # VULNERABILITY: no USER instruction — container runs as root EXPOSE 3000CMD ["node", "src/app.js"]EOFcat > terraform/main.tf << 'EOF'# VULNERABILITY: S3 bucket with public access — Checkov will catch thisresource "aws_s3_bucket" "app_assets" { bucket = "devsecops-demo-assets"} resource "aws_s3_bucket_acl" "app_assets" { bucket = aws_s3_bucket.app_assets.id acl = "public-read"} # VULNERABILITY: security group open to the worldresource "aws_security_group" "app" { name = "app-sg" ingress { from_port = 0 to_port = 65535 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }}EOFCommon MistakeEngineers deploy this "before" version to a real AWS account to "test the pipeline" and forget to tear it down. A public-read S3 bucket in
ap-south-1is indexed by scanners within hours — always run this milestone against a sandbox account with billing alerts, never a shared or production AWS account.
Milestone 2: Configure Semgrep for SAST
SAST (Static Application Security Testing) scans source code without running it — think of it as a linter that understands security patterns instead of just style. Semgrep runs custom rules against your JavaScript to catch the SQL injection and missing-auth issues from Milestone 1 before they ever reach a PR review.
cat > .semgrep.yml << 'EOF'rules: - id: sql-injection-string-concat patterns: - pattern: | $QUERY = "..." + $INPUT $DB.query($QUERY, ...) - pattern: | $DB.query(`...${$INPUT}...`, ...) message: > SQL Injection detected: user input $INPUT is concatenated directly into a SQL query. Use parameterised queries instead: pool.query('SELECT * FROM users WHERE id = $1', [id]) severity: ERROR languages: [javascript, typescript] metadata: cwe: CWE-89 owasp: A03:2021 - id: hardcoded-password patterns: - pattern: | const $VAR = "password..." - pattern: | password: "..." message: Hardcoded password detected. Use environment variables or a secrets manager. severity: ERROR languages: [javascript, typescript, yaml] - id: express-route-no-auth-middleware patterns: - pattern: | app.get('/admin/...', async ($REQ, $RES) => {...}) - pattern-not: | app.get('/admin/...', $AUTH, async ($REQ, $RES) => {...}) message: Admin route missing authentication middleware. severity: WARNING languages: [javascript]EOFnpm install -g semgrepsemgrep --config .semgrep.yml src/# Expected: finds the SQL injection and missing-auth findingsMilestone 3: Configure Checkov for IaC Scanning
Terraform describes infrastructure, so a misconfiguration in .tf files becomes a misconfiguration in real AWS — a public bucket in code becomes a public bucket in production the moment terraform apply runs. Checkov scans the Terraform itself, before apply, so the public S3 bucket and the wide-open security group from Milestone 1 get caught in CI instead of in an incident channel.
pip install checkov checkov -d terraform/ --framework terraform# Expected: flags the public S3 bucket and the 0.0.0.0/0 security groupcat > .checkov.yml << 'EOF'soft-fail-on: - CKV_AWS_18 # S3 access logging — accepted for this demo bucket skip-check: - CKV_AWS_144 # Cross-region replication — not needed for demoEOFMilestone 4: Build the Complete GitHub Actions Pipeline
This is where all four scanners get wired into one pipeline with a real gate: unit tests run first, then Semgrep/Checkov/Trivy run in parallel, and nothing reaches staging unless all three pass. OWASP ZAP only runs against the deployed staging app, since DAST needs a live target to attack — that's the key difference from SAST/IaC scanning, which read code without running it.
Create .github/workflows/devsecops.yml:
name: DevSecOps Pipeline on: push: branches: [main, develop] pull_request: branches: [main] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npm test sast-semgrep: runs-on: ubuntu-latest needs: unit-tests steps: - uses: actions/checkout@v4 - name: Run Semgrep SAST scan uses: returntocorp/semgrep-action@v1 with: config: > .semgrep.yml p/owasp-top-ten p/nodejs p/secrets generateSarif: true - name: Upload SARIF to GitHub Security tab uses: github/codeql-action/upload-sarif@v3 with: sarif_file: semgrep.sarif if: always() iac-checkov: runs-on: ubuntu-latest needs: unit-tests steps: - uses: actions/checkout@v4 - name: Run Checkov IaC scan uses: bridgecrewio/checkov-action@v12 with: directory: terraform/ framework: terraform output_format: sarif output_file_path: checkov.sarif soft_fail: false check: CKV_AWS_19,CKV_AWS_20,CKV_AWS_57 - name: Upload Checkov SARIF uses: github/codeql-action/upload-sarif@v3 with: sarif_file: checkov.sarif if: always() container-scan: runs-on: ubuntu-latest needs: unit-tests steps: - uses: actions/checkout@v4 - name: Build Docker image run: docker build -t devsecops-demo:${{ github.sha }} . - name: Run Trivy container vulnerability scan uses: aquasecurity/trivy-action@master with: image-ref: devsecops-demo:${{ github.sha }} format: 'sarif' output: 'trivy.sarif' severity: 'CRITICAL,HIGH' exit-code: '1' ignore-unfixed: true - name: Upload Trivy SARIF uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy.sarif if: always() deploy-staging: runs-on: ubuntu-latest needs: [sast-semgrep, iac-checkov, container-scan] environment: staging steps: - uses: actions/checkout@v4 - name: Deploy to staging run: echo "Deploying to staging after all security gates passed" dast-owasp-zap: runs-on: ubuntu-latest needs: deploy-staging steps: - uses: actions/checkout@v4 - name: Start application for DAST scanning run: | docker run -d -p 3000:3000 \ -e DATABASE_URL=postgresql://test:test@localhost/test \ --name app devsecops-demo:${{ github.sha }} sleep 10 - name: Run OWASP ZAP DAST scan uses: zaproxy/action-baseline@v0.10.0 with: target: 'http://localhost:3000' rules_file_name: '.zap/rules.tsv' cmd_options: '-a' - name: Upload ZAP report uses: actions/upload-artifact@v4 with: name: zap-report path: report_html.html if: always()Common MistakeSetting
needs: [sast-semgrep, iac-checkov, container-scan]ondeploy-stagingis what actually creates the gate — skip this and all three scan jobs become informational only, running in parallel with deployment instead of blocking it. Double-check this line before trusting the pipeline to block anything.
Milestone 5: Fix the Vulnerabilities and Watch the Pipeline Pass
Now close every finding from Milestone 1 and confirm the pipeline goes fully green — this is the "before → after" moment that makes the project click.
cat > src/app.js << 'EOF'// FIXED: parameterised query prevents SQL injectionapp.get('/user', async (req, res) => { const { id } = req.query; const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]); res.json(result.rows);}); // FIXED: authentication middleware on admin routesconst authenticate = (req, res, next) => { const token = req.headers.authorization; if (!token) return res.status(401).json({ error: 'Unauthorized' }); next();}; app.get('/admin/users', authenticate, async (req, res) => { const result = await pool.query('SELECT id, email FROM users'); res.json(result.rows);});EOFcat > Dockerfile << 'EOF'FROM node:20-alpine# Current LTS — fewer known CVEs than node:16 WORKDIR /appCOPY package*.json ./RUN npm ci --only=productionCOPY src/ ./src/ # FIXED: run as non-root userUSER node EXPOSE 3000CMD ["node", "src/app.js"]EOFcat > terraform/main.tf << 'EOF'resource "aws_s3_bucket" "app_assets" { bucket = "devsecops-demo-assets"} # FIXED: block all public accessresource "aws_s3_bucket_public_access_block" "app_assets" { bucket = aws_s3_bucket.app_assets.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true} # FIXED: minimal security group, internal VPC onlyresource "aws_security_group" "app" { name = "app-sg" ingress { from_port = 3000 to_port = 3000 protocol = "tcp" cidr_blocks = ["10.0.0.0/8"] }}EOFgit add -A && git commit -m "fix: resolve all security pipeline findings"git push origin main# Watch the pipeline — all stages should now passValidation & Testing
# 1. Introduce a vulnerability and verify the pipeline blocks itecho 'const password = "admin123";' >> src/app.jsgit add -A && git commit -m "test: introduce hardcoded password"git push# Expected: pipeline fails at sast-semgrep with a hardcoded-password finding # 2. Verify findings appear in GitHub Security tab# Repo -> Security -> Code scanning alerts# Expected: Semgrep, Checkov, and Trivy findings all visible # 3. Confirm CRITICAL findings block deploymentdocker build --build-arg NODE_VERSION=12 -t test-image .trivy image test-image --severity CRITICAL --exit-code 1# Expected: non-zero exit code — pipeline would block # 4. Verify fixed code passes all gatesgit revert HEADgit push# Expected: all stages pass, deployment proceeds # 5. Check the ZAP report# Download the zap-report artifact from GitHub Actions, open report_html.html# Expected: no FAIL-level findings, only informational warnings echo "Complete DevSecOps pipeline operational — security built into every deployment"SecurityA pipeline that always passes is worse than no pipeline — it gives false confidence. Step 1 above (deliberately reintroducing a finding) is not optional; run it every time you touch this pipeline's config to confirm the gate still actually blocks.
Videos & Guides
DevSecOps CI/CD Pipeline — Complete Security Integration Tutorial
Complete DevSecOps pipeline tutorial covering Semgrep SAST, OWASP ZAP DAST, Trivy container scanning, and Checkov IaC scanning integrated into GitHub Actions with deployment blocking.
Semgrep Rules and Custom Patterns Documentation
Official Semgrep documentation for writing custom security rules, using the rule registry, and integrating Semgrep into CI/CD pipelines with SARIF output for GitHub Security tab.
OWASP ZAP GitHub Actions Integration
Official OWASP ZAP documentation for GitHub Actions integration — baseline scans, full scans, API scans, and custom rules configuration for automated DAST in CI/CD pipelines.