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

Domains
CI-CDSECURITY
Technologies
DOCKERAWS

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).

flowchart TD A[Developer pushes code] --> B[GitHub Actions triggers] B --> C[Unit Tests] C --> D[Semgrep SAST] C --> E[Checkov IaC scan] C --> F[Trivy image scan] D --> G{Security Gate} E --> G F --> G G -->|CRITICAL found| H[Block pipeline] G -->|Passed| I[Deploy to staging] I --> J[OWASP ZAP DAST] J -->|Passed| K[Deploy to production] J -->|FAIL findings| H
Tip

The 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.

Security

This 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.

Bash
mkdir devsecops-demo && cd devsecops-demo
mkdir -p src k8s terraform .github/workflows
Bash
cat > 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 this
app.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 this
app.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);
EOF
Dockerfile
cat > Dockerfile << 'EOF'
FROM node:16-alpine
# ^ OLD version with known CVEs — Trivy will flag this
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src/ ./src/
# VULNERABILITY: no USER instruction — container runs as root
EXPOSE 3000
CMD ["node", "src/app.js"]
EOF
HCL
cat > terraform/main.tf << 'EOF'
# VULNERABILITY: S3 bucket with public access — Checkov will catch this
resource "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 world
resource "aws_security_group" "app" {
name = "app-sg"
ingress {
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
EOF
Common Mistake

Engineers 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-1 is 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.

YAML
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]
EOF
Bash
npm install -g semgrep
semgrep --config .semgrep.yml src/
# Expected: finds the SQL injection and missing-auth findings
Milestone 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.

Bash
pip install checkov
checkov -d terraform/ --framework terraform
# Expected: flags the public S3 bucket and the 0.0.0.0/0 security group
YAML
cat > .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 demo
EOF
Milestone 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:

YAML
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 Mistake

Setting needs: [sast-semgrep, iac-checkov, container-scan] on deploy-staging is 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.

Bash
cat > src/app.js << 'EOF'
// FIXED: parameterised query prevents SQL injection
app.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 routes
const 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);
});
EOF
Dockerfile
cat > Dockerfile << 'EOF'
FROM node:20-alpine
# Current LTS — fewer known CVEs than node:16
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src/ ./src/
# FIXED: run as non-root user
USER node
EXPOSE 3000
CMD ["node", "src/app.js"]
EOF
HCL
cat > terraform/main.tf << 'EOF'
resource "aws_s3_bucket" "app_assets" {
bucket = "devsecops-demo-assets"
}
# FIXED: block all public access
resource "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 only
resource "aws_security_group" "app" {
name = "app-sg"
ingress {
from_port = 3000
to_port = 3000
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
}
}
EOF
Bash
git add -A && git commit -m "fix: resolve all security pipeline findings"
git push origin main
# Watch the pipeline — all stages should now pass
Validation & Testing
Bash
# 1. Introduce a vulnerability and verify the pipeline blocks it
echo 'const password = "admin123";' >> src/app.js
git 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 deployment
docker 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 gates
git revert HEAD
git 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"
Security

A 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.