DevSecOps project 3 — The Platform Security Challenge
The mega-capstone. Wire Terraform IaC scanning, secrets management with Vault, zero-trust mTLS, security chaos engineering, and a unified compliance dashboard into one production security platform.
Domains & Technologies
Blueprint Walkthrough
The Question That Exposes Gaps
A CISO at a fintech startup walked into the engineering team's standup and asked one question: "If I randomly terminate 20% of our containers right now, will any secrets get exposed?"
Nobody could answer. They had secrets in Kubernetes Secrets (base64-encoded, not encrypted). They had TLS at the ingress but plaintext traffic between internal services. They had security tools installed but no centralized view of their security posture. They had infrastructure as code but no scanning to ensure it stayed secure.
Each of those gaps is a separate problem. This capstone treats them as one problem: building a cohesive security platform where every piece reinforces the others.
You are building five components that integrate into a unified platform:
┌────────────────────────────────────────────────────────────┐│ Component 1: Terraform Security (tfsec + Checkov) ││ Scan IaC before provisioning — catch misconfigs at source │├────────────────────────────────────────────────────────────┤│ Component 2: HashiCorp Vault ││ Dynamic secrets — no static credentials anywhere │├────────────────────────────────────────────────────────────┤│ Component 3: Istio mTLS + Zero-Trust Networking ││ Every service-to-service call encrypted and authenticated │├────────────────────────────────────────────────────────────┤│ Component 4: Security Chaos Engineering (Chaos Monkey) ││ Deliberately break things to test security controls │├────────────────────────────────────────────────────────────┤│ Component 5: Unified Compliance Dashboard ││ Single pane of glass across all security tools │└────────────────────────────────────────────────────────────┘Before You Start
What this capstone builds on
This capstone is the integration layer. It assumes:
- Capstone 1: You have a Secure CI/CD pipeline (the gated pipeline from Capstone 1)
- Capstone 2: You have a hardened Kubernetes cluster (RBAC, NetworkPolicies, Falco from Capstone 2)
The new components in this capstone connect into both of those. Vault provides the secrets that the CI/CD pipeline and Kubernetes workloads use. Istio sits on top of the hardened cluster. The compliance dashboard aggregates findings from Trivy, Semgrep, Falco, kube-bench, and the new IaC scanners.
## Verify the prerequisites are runningkubectl cluster-infokubectl get pods -n falco | grep Runningkubectl get networkpolicies -n payment-service ## Install additional tools for this capstone## Terraformwget -O /tmp/terraform.zip \ https://releases.hashicorp.com/terraform/1.7.0/terraform_1.7.0_linux_amd64.zipunzip /tmp/terraform.zip -d /usr/local/bin/ ## tfsec — Terraform security scannerwget -q -O /usr/local/bin/tfsec \ https://github.com/aquasecurity/tfsec/releases/download/v1.28.4/tfsec-linux-amd64chmod +x /usr/local/bin/tfsec ## Checkov — multi-framework IaC scannerpip3 install checkov --break-system-packages ## Verify all toolsterraform version && tfsec --version && checkov --version echo "✅ Prerequisites verified"Component 1 — Infrastructure as Code Security
Why IaC misconfigurations are expensive to fix later
Infrastructure as Code is both a security strength and a security risk. The strength: every infrastructure change is in version control, reviewable, auditable. The risk: one misconfigured Terraform module can provision hundreds of insecure resources across all environments simultaneously.
An S3 bucket with acl = "public-read" in a Terraform module gets deployed to dev, staging, and production in the same terraform apply. If the misconfiguration is not caught in the CI pipeline, it sits there until someone notices a billing anomaly or — worse — a breach.
tfsec and Checkov scan Terraform code before it is applied. They catch misconfigurations that are expensive to fix after provisioning: S3 buckets without server-side encryption, security groups open to 0.0.0.0/0, EKS clusters without private API endpoints, RDS instances without deletion protection.
Writing the infrastructure code
## Set up the IaC project structuremkdir terraform-infrastructure && cd terraform-infrastructuremkdir -p modules/eks modules/rds modules/vpc modules/s3 environments/productiontouch main.tf variables.tf outputs.tftouch modules/eks/main.tf modules/rds/main.tf echo "✅ IaC project structure created"## modules/eks/main.tf## EKS cluster configuration## This file intentionally contains misconfigurations that the scanner catches terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } }} variable "cluster_name" { type = string description = "Name of the EKS cluster"} variable "cluster_version" { type = string description = "Kubernetes version for the cluster" default = "1.29"} ## MISCONFIGURATION 1: Public API endpoint enabled## tfsec will flag: aws-eks-no-public-cluster-access## The Kubernetes API server should not be publicly accessibleresource "aws_eks_cluster" "main" { name = var.cluster_name version = var.cluster_version role_arn = aws_iam_role.eks_cluster.arn vpc_config { subnet_ids = var.subnet_ids ## ❌ Public endpoint should be false in production endpoint_public_access = true endpoint_private_access = true ## ❌ API access is open to all IPs — should be restricted to VPN CIDR public_access_cidrs = ["0.0.0.0/0"] } ## MISCONFIGURATION 2: Secrets not encrypted with KMS ## tfsec will flag: aws-eks-enable-control-plane-logging ## encryption_config block is missing entirely} ## MISCONFIGURATION 3: CloudWatch logging not enabled## tfsec will flag: aws-eks-enable-control-plane-loggingresource "aws_eks_cluster" "logging_disabled" { name = "${var.cluster_name}-bad" enabled_cluster_log_types = [] ## ❌ should include ["api", "audit", "authenticator"]}## modules/rds/main.tf## RDS database configuration with deliberate misconfigurations variable "db_identifier" { type = string} ## MISCONFIGURATION 4: RDS instance publicly accessible## tfsec will flag: aws-rds-no-public-db-accessresource "aws_db_instance" "payment_db" { identifier = var.db_identifier engine = "postgres" engine_version = "15.4" ## ❌ Production databases should never be publicly accessible publicly_accessible = true ## ❌ Deletion protection prevents accidental deletion in production deletion_protection = false ## ❌ Automated backups provide point-in-time recovery backup_retention_period = 0 storage_encrypted = true ## This one is correct ✅}Running the IaC security scans
## ── tfsec: deep Terraform security analysis ──────────────────tfsec modules/ --format=json --out=tfsec-results.json ## View high-severity findings onlytfsec modules/ --minimum-severity HIGH ## Expected output:#### Result #1 HIGH## ──────────────────────────────────────────────────────────────## ID aws-eks-no-public-cluster-access## Impact EKS Kubernetes API server accessible to any IP## Resolution Restrict public access CIDRs to specific ranges## More Info https://aquasecurity.github.io/tfsec/...#### modules/eks/main.tf:22-34## 22 resource "aws_eks_cluster" "main" {## ...## 30 public_access_cidrs = ["0.0.0.0/0"]#### Passed: 3 Failed: 4 Ignored: 0## ── Checkov: multi-framework IaC scanning ─────────────────────## Checkov checks Terraform, Kubernetes YAML, Dockerfile, CloudFormationcheckov -d . \ --framework terraform \ --output json \ --output-file-path checkov-results.json \ --soft-fail ## run without blocking — generate report first ## Count failures by severitypython3 -c "import jsonwith open('checkov-results.json') as f: results = json.load(f) checks = results.get('results', {})failed = checks.get('failed_checks', [])passed = checks.get('passed_checks', []) severity_count = {}for check in failed: sev = check.get('severity', 'UNKNOWN') severity_count[sev] = severity_count.get(sev, 0) + 1 print(f'Total passed: {len(passed)}')print(f'Total failed: {len(failed)}')print()print('Failed by severity:')for sev, count in sorted(severity_count.items()): print(f' {sev}: {count}')"Fixing the IaC misconfigurations
## modules/eks/main.tf — SECURE VERSION resource "aws_eks_cluster" "main" { name = var.cluster_name version = var.cluster_version role_arn = aws_iam_role.eks_cluster.arn ## ✅ Enable all audit logging enabled_cluster_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"] vpc_config { subnet_ids = var.subnet_ids ## ✅ Private only — API server not exposed to internet endpoint_public_access = false endpoint_private_access = true } ## ✅ Encrypt Kubernetes secrets in etcd with KMS encryption_config { provider { key_arn = aws_kms_key.eks_secrets.arn } resources = ["secrets"] }} ## ✅ Dedicated KMS key for EKS secret encryptionresource "aws_kms_key" "eks_secrets" { description = "KMS key for EKS secrets encryption" deletion_window_in_days = 7 enable_key_rotation = true ## rotate the key annually tags = { Name = "${var.cluster_name}-eks-secrets" Environment = "production" ManagedBy = "terraform" }}## modules/rds/main.tf — SECURE VERSION resource "aws_db_instance" "payment_db" { identifier = var.db_identifier engine = "postgres" engine_version = "15.4" ## ✅ Never publicly accessible publicly_accessible = false ## ✅ Prevent accidental deletion — disable only for deliberate teardown deletion_protection = true ## ✅ 7 days of automated backups for point-in-time recovery backup_retention_period = 7 ## ✅ Encrypt data at rest storage_encrypted = true kms_key_id = aws_kms_key.rds.arn ## ✅ Enable enhanced monitoring and Performance Insights monitoring_interval = 60 performance_insights_enabled = true}## Add tfsec to the CI/CD pipeline from Capstone 1## Add this as the first job in secure-pipeline.yml## (after secrets-detection, before sast-scan)cat >> .github/workflows/secure-pipeline.yml << 'EOF' ## ── IaC Security Scan (add after secrets-detection) ────────── iac-scan: name: "Gate 0.5 — IaC Security Scan (tfsec + Checkov)" runs-on: ubuntu-latest needs: secrets-detection steps: - name: Checkout code uses: actions/checkout@v4 - name: Run tfsec uses: aquasecurity/tfsec-action@v1.0.3 with: working_directory: terraform-infrastructure/ severity: HIGH soft_fail: false - name: Run Checkov uses: bridgecrewio/checkov-action@master with: directory: terraform-infrastructure/ framework: terraform soft_fail: false output_format: sarif output_file_path: checkov-results.sarifEOF echo "✅ IaC scanning added to pipeline"Component 2 — HashiCorp Vault for Dynamic Secrets
Why static secrets are the root cause of most breaches
Every rotation schedule for static secrets has the same failure mode: the rotation does not happen on schedule because it breaks something, and "temporary" static secrets end up permanent for months or years.
HashiCorp Vault eliminates static secrets by making credentials dynamic. Instead of your application knowing the database password, Vault generates a unique database credential for each application instance, valid for 1 hour. When the lease expires, Vault automatically revokes it. A leaked credential is useless within an hour.
## Install Vault in development mode for this capstone## Production Vault uses HA configuration with cloud storage helm repo add hashicorp https://helm.releases.hashicorp.com ## Install Vault in dev mode (single node, in-memory storage)## Replace with production HA config for real deploymentshelm install vault hashicorp/vault \ --namespace vault \ --create-namespace \ --set "server.dev.enabled=true" \ --set "injector.enabled=true" ## Wait for Vault to be readykubectl rollout status deployment/vault -n vault --timeout=120s ## Port-forward for initial configurationkubectl port-forward svc/vault 8200:8200 -n vault &export VAULT_ADDR="http://localhost:8200"export VAULT_TOKEN="root" ## dev mode uses "root" as the token echo "✅ Vault installed"Configuring Vault for the payment service
## Enable the PostgreSQL database secrets enginevault secrets enable database ## Configure the PostgreSQL connection## Vault connects to the database with admin credentials## and creates/revokes credentials on behalf of applicationsvault write database/config/payment-db \ plugin_name=postgresql-database-plugin \ allowed_roles="payment-service" \ connection_url="postgresql://{{username}}:{{password}}@postgres.database.svc:5432/payments" \ username="vault_admin" \ password="$(kubectl get secret postgres-admin -n database -o jsonpath='{.data.password}' | base64 -d)" ## Create a role that defines what credentials Vault generatesvault write database/roles/payment-service \ db_name=payment-db \ creation_statements=" CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT, INSERT, UPDATE ON payments TO \"{{name}}\"; GRANT SELECT ON merchants TO \"{{name}}\"; " \ revocation_statements=" REVOKE ALL ON ALL TABLES IN SCHEMA public FROM \"{{name}}\"; DROP ROLE IF EXISTS \"{{name}}\"; " \ ## Credentials valid for 1 hour — Vault renews automatically until max_ttl default_ttl="1h" \ max_ttl="24h" ## Test: generate a credential manuallyvault read database/creds/payment-service## Output:## Key Value## --- -----## lease_id database/creds/payment-service/KX...## lease_duration 1h## username v-payment-KoiE8L8e## password A1B-random-generated-credential echo "✅ Database secrets engine configured"Kubernetes auth and the Vault Agent Injector
Vault Agent Injector uses a Kubernetes mutating webhook to inject a sidecar container into pods. The sidecar authenticates to Vault using the pod's ServiceAccount token and writes secrets to a shared volume. The application reads secrets from files — no code changes needed.
## Enable Kubernetes authentication in Vaultvault auth enable kubernetes ## Configure Kubernetes auth with the cluster's detailsvault write auth/kubernetes/config \ kubernetes_host="https://$(kubectl get svc kubernetes -o jsonpath='{.spec.clusterIP}'):443" \ kubernetes_ca_cert=@<(kubectl get secret \ $(kubectl get sa vault -n vault -o jsonpath='{.secrets[0].name}') \ -n vault -o jsonpath='{.data.ca\.crt}' | base64 -d) \ token_reviewer_jwt="$(kubectl create token vault -n vault)" ## Create a Vault policy for the payment service## This policy says: the payment service can read its own database credentialsvault policy write payment-service - << 'EOF'path "database/creds/payment-service" { capabilities = ["read"]} path "kv/data/payment-service/*" { capabilities = ["read"]}EOF ## Bind the Kubernetes ServiceAccount to the Vault policyvault write auth/kubernetes/role/payment-service \ bound_service_account_names=payment-service \ bound_service_account_namespaces=payment-service \ policies=payment-service \ ttl=1h echo "✅ Vault Kubernetes auth configured"## deployments/payment-service-with-vault.yaml## The Vault Agent Injector annotations tell the webhook to inject## a sidecar that fetches credentials from Vault apiVersion: apps/v1kind: Deploymentmetadata: name: payment-service namespace: payment-servicespec: replicas: 2 selector: matchLabels: app: payment-service template: metadata: labels: app: payment-service annotations: ## These annotations trigger the Vault Agent Injector webhook vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: "payment-service" ## Inject the database credentials as a file at /vault/secrets/db-creds vault.hashicorp.com/agent-inject-secret-db-creds: "database/creds/payment-service" ## Template the credentials into a format the app can source vault.hashicorp.com/agent-inject-template-db-creds: | {{- with secret "database/creds/payment-service" -}} export DB_USERNAME="{{ .Data.username }}" export DB_PASSWORD="{{ .Data.password }}" {{- end }} spec: serviceAccountName: payment-service containers: - name: payment-service image: ghcr.io/razorpay/payment-service:abc1234 command: ["/bin/sh", "-c"] args: ## Source the Vault-injected credentials before starting the app - | source /vault/secrets/db-creds python src/app.py env: - name: DB_HOST value: "postgres.database.svc.cluster.local" - name: DB_PORT value: "5432" ## DB_USERNAME and DB_PASSWORD come from Vault via /vault/secrets/db-creds securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 10001 capabilities: drop: ["ALL"] resources: requests: cpu: "100m" memory: "128Mi" limits: cpu: "500m" memory: "256Mi" volumeMounts: - name: tmp-dir mountPath: /tmp volumes: - name: tmp-dir emptyDir: {}## Verify Vault injection is workingkubectl apply -f deployments/payment-service-with-vault.yamlkubectl rollout status deployment/payment-service -n payment-service ## Check that credentials were injectedkubectl exec -n payment-service \ $(kubectl get pods -n payment-service -l app=payment-service -o name | head -1) \ -c payment-service \ -- cat /vault/secrets/db-creds## Expected output:## export DB_USERNAME="v-payment-KoiE8L8e"## export DB_PASSWORD="A1B-xYz-random-2024" echo "✅ Vault dynamic secrets working"Component 3 — Istio mTLS and Zero-Trust Networking
The problem with trusting internal traffic
Zero-trust security means "never trust, always verify" — including traffic from inside your own network. In a traditional Kubernetes setup without service mesh, if pod A can reach pod B on port 8080, there is no authentication happening at the transport layer. Pod B trusts the traffic because it comes from inside the cluster.
mTLS (mutual TLS) fixes this: both sides of every connection present certificates and verify each other's identity. Pod A proves it is allowed to talk to pod B. Pod B proves it is the real payment service, not an impersonating pod.
Istio is a service mesh that implements mTLS transparently using sidecar proxies (Envoy). Applications do not change. The Envoy sidecar handles all certificate management, rotation, and verification automatically.
## Install Istiocurl -L https://istio.io/downloadIstio | \ ISTIO_VERSION=1.20.1 TARGET_ARCH=x86_64 sh - export PATH="$PWD/istio-1.20.1/bin:$PATH" ## Install Istio in the cluster with default profile## Default = pilot (control plane) + ingress gatewayistioctl install --set profile=default -y ## Verify Istio is runningkubectl get pods -n istio-system## Expected: istiod and istio-ingressgateway in Running state echo "✅ Istio installed"## Enable Istio sidecar injection for the payment-service namespace## This label tells Istio to automatically inject Envoy sidecar into all new podskubectl label namespace payment-service istio-injection=enabled ## Restart pods to get sidecars injectedkubectl rollout restart deployment/payment-service -n payment-service ## Verify sidecar was injected — each pod should have 2 containers nowkubectl get pods -n payment-service## Expected: READY 2/2 (app container + envoy sidecar)## istio/peer-authentication.yaml## PeerAuthentication enforces mTLS for all traffic within the namespace## STRICT mode means plaintext connections are rejected — mTLS only apiVersion: security.istio.io/v1beta1kind: PeerAuthenticationmetadata: name: default namespace: payment-servicespec: ## mtls.mode: STRICT = reject all non-mTLS connections ## This means even other pods without Istio sidecars cannot connect mtls: mode: STRICT## istio/authorization-policy.yaml## AuthorizationPolicy controls which services can communicate## This is layer 7 (HTTP) access control on top of mTLS apiVersion: security.istio.io/v1beta1kind: AuthorizationPolicymetadata: name: payment-service-policy namespace: payment-servicespec: selector: matchLabels: app: payment-service action: ALLOW rules: ## Only allow traffic from the ingress gateway (external customers) - from: - source: ## The principal is the mTLS identity of the caller ## This format: cluster.local/ns/NAMESPACE/sa/SERVICEACCOUNT principals: - "cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" to: - operation: methods: ["GET", "POST"] paths: ["/payment/*", "/health"]## istio/deny-all-default.yaml## Start with deny-all and add explicit allows## Any traffic not matching an ALLOW policy is denied apiVersion: security.istio.io/v1beta1kind: AuthorizationPolicymetadata: name: deny-all namespace: payment-servicespec: ## No rules = deny all traffic {}## Apply all Istio policieskubectl apply -f istio/ ## Test that mTLS is enforced## A pod without a sidecar should be rejected kubectl run no-sidecar-test \ --image=curlimages/curl:8.4.0 \ --restart=Never \ --namespace=default \ -- curl -s --connect-timeout 3 \ http://payment-service.payment-service.svc.cluster.local:8080/health kubectl logs no-sidecar-test## Expected: connection refused or RBAC denied (mTLS strict mode rejects plaintext) ## Verify mTLS is active from within the meshistioctl x describe pod \ $(kubectl get pods -n payment-service -l app=payment-service -o name | head -1) \ -n payment-service | grep -i mtls## Expected: mTLS is STRICT echo "✅ Istio mTLS zero-trust networking active"Component 4 — Security Chaos Engineering
Testing your controls by breaking things
Security chaos engineering is the practice of deliberately injecting security failures into your systems to verify that controls work and alerts fire. Regular chaos experiments answer the question: "Does our security posture actually hold up under realistic conditions, or do we just think it does?"
The approach uses LitmusChaos — a Kubernetes-native chaos engineering platform — to inject failures, and then verifies that:
- Falco detected the suspicious activity
- The application recovered cleanly
- No secrets were exposed during the chaos
- mTLS prevented unauthorized connections during the disruption
## Install LitmusChaoskubectl apply -f https://litmuschaos.github.io/litmus/litmus-operator-v3.5.0.yaml ## Wait for LitmusChaos to be readykubectl rollout status deployment/chaos-operator-ce \ -n litmus --timeout=120s ## Install chaos experiments librarykubectl apply -f \ https://hub.litmuschaos.io/api/chaos/3.5.0?file=charts/generic/experiments.yaml \ -n payment-service echo "✅ LitmusChaos installed"## chaos/pod-kill-experiment.yaml## Chaos Experiment 1: Kill random pods in the payment namespace## Validates: pod recovery time, no secret exposure during restart,## Vault reinjects credentials to new pods successfully apiVersion: litmuschaos.io/v1alpha1kind: ChaosEnginemetadata: name: payment-pod-kill namespace: payment-servicespec: appinfo: appns: payment-service applabel: "app=payment-service" appkind: deployment chaosServiceAccount: litmus-admin experiments: - name: pod-delete spec: components: env: ## Kill 1 pod every 10 seconds for 60 seconds total - name: TOTAL_CHAOS_DURATION value: "60" - name: CHAOS_INTERVAL value: "10" ## Kill 50% of the matching pods at each interval - name: FORCE value: "false"## chaos/network-loss-experiment.yaml## Chaos Experiment 2: Introduce 50% packet loss to the payment service## Validates: service resilience, error rates stay below SLA,## Falco does not fire false positives on slow connections apiVersion: litmuschaos.io/v1alpha1kind: ChaosEnginemetadata: name: payment-network-loss namespace: payment-servicespec: appinfo: appns: payment-service applabel: "app=payment-service" appkind: deployment chaosServiceAccount: litmus-admin experiments: - name: pod-network-loss spec: components: env: - name: TOTAL_CHAOS_DURATION value: "60" ## 50% packet loss — service should still respond (slowly) - name: NETWORK_PACKET_LOSS_PERCENTAGE value: "50" ## Target only egress traffic to the database - name: DESTINATION_IPS value: "$(kubectl get svc postgresql -n database -o jsonpath='{.spec.clusterIP}')"## Run the security chaos experimentkubectl apply -f chaos/pod-kill-experiment.yaml ## Monitor the experiment statuskubectl get chaosresult payment-pod-kill-pod-delete \ -n payment-service -o jsonpath='{.status.verdict}' ## While chaos runs — verify these in parallel: ## 1. Check Vault is reinjecting credentials to new podskubectl logs -n vault -l app.kubernetes.io/name=vault-agent-injector \ --since=2m | grep "renewed\|injected" ## 2. Check no secrets are in environment variables (they should be in files)kubectl exec -n payment-service \ $(kubectl get pods -n payment-service -l app=payment-service -o name | head -1) \ -- env | grep -i "password\|secret\|token\|key"## Expected: NO matches — secrets are in /vault/secrets/, not env vars ## 3. Check Falco did not alert on expected behaviorkubectl logs -n falco daemonset/falco --since=2m | \ grep -v "INFO\|DEBUG" | tail -20 ## 4. Check the service is still responding during chaosfor i in $(seq 1 10); do STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ http://payment-service.payment-service.svc.cluster.local:8080/health) echo "$(date +%H:%M:%S) Health check: $STATUS" sleep 5done ## Collect the final chaos resultkubectl get chaosresult payment-pod-kill-pod-delete \ -n payment-service -o yaml | grep -A 10 "status:" echo "✅ Security chaos experiment complete"## chaos/verify-security-controls.py## After chaos experiments, verify that security controls held import subprocessimport jsonimport sys def run_kubectl(args): """Run a kubectl command and return stdout.""" result = subprocess.run( ["kubectl"] + args.split(), capture_output=True, text=True ) return result.stdout, result.returncode def check_no_exposed_secrets(): """Verify no secrets are in pod environment variables.""" stdout, _ = run_kubectl( "get pods -n payment-service -l app=payment-service -o name" ) pods = [p.strip() for p in stdout.strip().split("\n") if p] for pod in pods: pod_name = pod.replace("pod/", "") ## Check environment variables for suspicious patterns stdout, _ = run_kubectl( f"exec -n payment-service {pod_name} -- env" ) dangerous_vars = [ line for line in stdout.split("\n") if any(kw in line.lower() for kw in ["password", "secret", "api_key", "token", "credential"]) ] if dangerous_vars: print(f"❌ FAIL: Pod {pod_name} has sensitive env vars: {dangerous_vars}") return False print("✅ PASS: No secrets in pod environment variables") return True def check_vault_leases_active(): """Verify Vault has active leases for the payment service.""" stdout, _ = run_kubectl( "exec -n vault vault-0 -- vault list sys/leases/lookup/database/creds/payment-service" ) if "No value found" in stdout or not stdout.strip(): print("⚠️ WARNING: No active Vault leases found for payment-service") return False lease_count = len([l for l in stdout.strip().split("\n") if l and l != "Keys"]) print(f"✅ PASS: {lease_count} active Vault lease(s) for payment-service") return True def check_mtls_active(): """Verify mTLS is still enforced after chaos.""" stdout, _ = run_kubectl( "get peerauthentication -n payment-service -o jsonpath={.items[0].spec.mtls.mode}" ) if stdout.strip() == "STRICT": print("✅ PASS: mTLS STRICT mode active on payment-service namespace") return True else: print(f"❌ FAIL: mTLS mode is '{stdout.strip()}' (expected STRICT)") return False def check_falco_no_critical_alerts(): """Check Falco for any CRITICAL alerts during the chaos window.""" stdout, _ = run_kubectl("logs -n falco daemonset/falco --since=5m") critical_alerts = [ line for line in stdout.split("\n") if "Critical" in line and "Shell Spawned" not in line ## Exclude expected test alerts ] if critical_alerts: print(f"⚠️ WARNING: Unexpected Falco CRITICAL alerts during chaos:") for alert in critical_alerts[:5]: print(f" {alert}") else: print("✅ PASS: No unexpected Falco CRITICAL alerts during chaos") return len(critical_alerts) == 0 if __name__ == "__main__": print("=== Security Control Verification After Chaos ===") print() results = [ check_no_exposed_secrets(), check_vault_leases_active(), check_mtls_active(), check_falco_no_critical_alerts(), ] print() if all(results): print("✅ ALL CHECKS PASSED — Security controls held under chaos") sys.exit(0) else: failed = sum(1 for r in results if not r) print(f"❌ {failed} CHECK(S) FAILED — Review findings above") sys.exit(1)## Run the verification script after each chaos experimentpython3 chaos/verify-security-controls.pyComponent 5 — Unified Compliance Dashboard
One view of your entire security posture
You now have outputs from six different security tools: Gitleaks, Semgrep, Trivy, tfsec, Falco, kube-bench, and the Vault audit log. Each tool produces findings in a different format. A security engineer investigating a potential incident has to check six different places.
The unified dashboard collects findings from all tools, normalizes them to a common schema, and presents them in a single web interface. It also tracks compliance status over time — showing whether your security posture is improving or degrading.
## dashboard/collector.py## Collects security findings from all tools and normalizes them import jsonimport subprocessimport osimport requestsfrom datetime import datetimefrom typing import List, Dict def normalize_finding( source: str, severity: str, title: str, description: str, location: str = "", cve_id: str = "",) -> Dict: """Normalize a finding from any tool into a common schema.""" return { "id": f"{source}-{hash(title + location) % 100000}", "source": source, "severity": severity.upper(), ## Map tool-specific severities to our standard levels "normalized_severity": normalize_severity(source, severity), "title": title, "description": description, "location": location, "cve_id": cve_id, "timestamp": datetime.utcnow().isoformat() + "Z", "status": "open", } def normalize_severity(source: str, severity: str) -> str: """Map tool-specific severity terms to CRITICAL/HIGH/MEDIUM/LOW.""" severity_map = { "CRITICAL": "CRITICAL", "HIGH": "HIGH", "ERROR": "HIGH", "MEDIUM": "MEDIUM", "WARNING": "MEDIUM", "WARN": "MEDIUM", "LOW": "LOW", "INFO": "LOW", "INFORMATIONAL": "LOW", } return severity_map.get(severity.upper(), "MEDIUM") def collect_trivy_findings(results_file: str) -> List[Dict]: """Parse Trivy SARIF output and extract findings.""" if not os.path.exists(results_file): return [] findings = [] with open(results_file) as f: sarif = json.load(f) for run in sarif.get("runs", []): for result in run.get("results", []): rule_id = result.get("ruleId", "unknown") message = result.get("message", {}).get("text", "") severity = result.get("level", "warning") ## Extract location from the SARIF result location = "" locations = result.get("locations", []) if locations: uri = locations[0].get("physicalLocation", {}).get( "artifactLocation", {} ).get("uri", "") line = locations[0].get("physicalLocation", {}).get( "region", {} ).get("startLine", "") location = f"{uri}:{line}" if line else uri findings.append(normalize_finding( source="trivy", severity=severity, title=f"CVE: {rule_id}", description=message, location=location, cve_id=rule_id if rule_id.startswith("CVE-") else "", )) return findings def collect_semgrep_findings(results_file: str) -> List[Dict]: """Parse Semgrep JSON output and extract findings.""" if not os.path.exists(results_file): return [] findings = [] with open(results_file) as f: semgrep_output = json.load(f) for result in semgrep_output.get("results", []): findings.append(normalize_finding( source="semgrep", severity=result.get("extra", {}).get("severity", "WARNING"), title=result.get("check_id", "unknown-rule"), description=result.get("extra", {}).get("message", ""), location=f"{result.get('path', '')}:{result.get('start', {}).get('line', '')}", )) return findings def collect_tfsec_findings(results_file: str) -> List[Dict]: """Parse tfsec JSON output and extract findings.""" if not os.path.exists(results_file): return [] findings = [] with open(results_file) as f: tfsec_output = json.load(f) for result in tfsec_output.get("results", []): findings.append(normalize_finding( source="tfsec", severity=result.get("severity", "MEDIUM"), title=result.get("rule_id", "unknown"), description=result.get("description", ""), location=f"{result.get('location', {}).get('filename', '')}:" f"{result.get('location', {}).get('start_line', '')}", )) return findings def collect_falco_findings() -> List[Dict]: """Read recent Falco alerts from pod logs.""" findings = [] result = subprocess.run( ["kubectl", "logs", "-n", "falco", "daemonset/falco", "--since=24h"], capture_output=True, text=True, ) for line in result.stdout.split("\n"): if not line.strip(): continue ## Falco log format: TIMESTAMP SEVERITY MESSAGE (key=value ...) if "Critical" in line or "Warning" in line or "Error" in line: parts = line.split(" ", 2) if len(parts) >= 3: severity = "HIGH" if "Critical" in parts[1] else "MEDIUM" findings.append(normalize_finding( source="falco", severity=severity, title="Runtime security event", description=parts[2] if len(parts) > 2 else line, location="runtime", )) return findings def collect_all_findings() -> List[Dict]: """Aggregate findings from all security tools.""" all_findings = [] all_findings.extend(collect_trivy_findings("trivy-image-results.sarif")) all_findings.extend(collect_trivy_findings("trivy-sca-results.sarif")) all_findings.extend(collect_semgrep_findings("semgrep-results.json")) all_findings.extend(collect_tfsec_findings("tfsec-results.json")) all_findings.extend(collect_falco_findings()) return all_findings def calculate_risk_score(findings: List[Dict]) -> Dict: """Calculate an overall risk score from all findings.""" weights = {"CRITICAL": 10, "HIGH": 5, "MEDIUM": 2, "LOW": 1} severity_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} total_weight = 0 for finding in findings: sev = finding.get("normalized_severity", "LOW") severity_counts[sev] = severity_counts.get(sev, 0) + 1 total_weight += weights.get(sev, 1) ## Risk score: 0 (perfect) to 100 (critical) ## Penalise CRITICAL findings heavily raw_score = min(100, total_weight * 2) risk_score = 100 - raw_score grade = "A" if risk_score >= 90 else \ "B" if risk_score >= 75 else \ "C" if risk_score >= 60 else \ "D" if risk_score >= 40 else "F" return { "score": max(0, risk_score), "grade": grade, "severity_counts": severity_counts, "total_findings": len(findings), }## dashboard/app.py## Flask web application for the compliance dashboard from flask import Flask, render_template_string, jsonifyfrom dashboard.collector import collect_all_findings, calculate_risk_score app = Flask(__name__) DASHBOARD_HTML = """<!DOCTYPE html><html><head> <title>DevSecOps Compliance Dashboard — Razorpay</title> <style> body { font-family: monospace; background: #0d1117; color: #c9d1d9; margin: 2rem; } h1 { color: #58a6ff; border-bottom: 1px solid #30363d; padding-bottom: 1rem; } .score-card { background: #161b22; border: 1px solid #30363d; padding: 1.5rem; border-radius: 6px; display: inline-block; margin: 1rem 1rem 1rem 0; } .grade-A { color: #3fb950; } .grade-B { color: #56d364; } .grade-C { color: #e3b341; } .grade-D { color: #f85149; } .grade-F { color: #f85149; } .critical { color: #f85149; } .high { color: #e3b341; } .medium { color: #d29922; } .low { color: #8b949e; } table { border-collapse: collapse; width: 100%; margin-top: 1rem; } th, td { padding: 0.5rem 1rem; text-align: left; border-bottom: 1px solid #30363d; } th { background: #161b22; color: #58a6ff; } .source-badge { background: #21262d; padding: 2px 8px; border-radius: 12px; font-size: 0.85em; } </style></head><body> <h1>🔐 DevSecOps Compliance Dashboard</h1> <div class="score-card"> <div style="font-size: 3rem; font-weight: bold;" class="grade-{{ risk.grade }}">{{ risk.grade }}</div> <div>Security Score: {{ risk.score }}/100</div> <div>Total Findings: {{ risk.total_findings }}</div> </div> <div class="score-card"> <div><span class="critical">● CRITICAL: {{ risk.severity_counts.CRITICAL }}</span></div> <div><span class="high">● HIGH: {{ risk.severity_counts.HIGH }}</span></div> <div><span class="medium">● MEDIUM: {{ risk.severity_counts.MEDIUM }}</span></div> <div><span class="low">● LOW: {{ risk.severity_counts.LOW }}</span></div> </div> <h2>All Findings</h2> <table> <tr> <th>Severity</th><th>Source</th><th>Title</th> <th>Location</th><th>Timestamp</th> </tr> {% for finding in findings|sort(attribute='normalized_severity') %} <tr> <td class="{{ finding.normalized_severity|lower }}"> {{ finding.normalized_severity }} </td> <td><span class="source-badge">{{ finding.source }}</span></td> <td>{{ finding.title }}</td> <td style="font-size: 0.85em;">{{ finding.location }}</td> <td style="font-size: 0.85em;">{{ finding.timestamp[:19] }}</td> </tr> {% endfor %} </table></body></html>""" def dashboard(): findings = collect_all_findings() risk = calculate_risk_score(findings) return render_template_string(DASHBOARD_HTML, findings=findings, risk=risk) def api_findings(): findings = collect_all_findings() risk = calculate_risk_score(findings) return jsonify({"findings": findings, "risk_summary": risk}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)## Run the dashboardpip3 install flask --break-system-packagespython3 dashboard/app.py & ## Access at http://localhost:5000curl http://localhost:5000/api/findings | python3 -m json.tool | head -40Production Checklist
## ─── IaC Scanning ────────────────────────────────────────────tfsec modules/ --minimum-severity HIGH --exit-code 1 --quietecho "✅ No HIGH/CRITICAL IaC misconfigurations" checkov -d . --framework terraform --compact --quietecho "✅ Checkov IaC scan passed" ## ─── Vault ───────────────────────────────────────────────────## Verify Vault is unsealed and accessiblekubectl exec -n vault vault-0 -- vault status | grep "Sealed"## Expected: Sealed false ## Verify dynamic credentials are being issuedkubectl exec -n vault vault-0 -- \ vault read database/creds/payment-service## Expected: username and password fields with values ## Verify no static secrets in Kubernetes Secretskubectl get secrets -n payment-service \ -o jsonpath='{.items[*].metadata.name}'## Should show NO database password secrets ## ─── Istio mTLS ───────────────────────────────────────────────istioctl x describe pod \ $(kubectl get pods -n payment-service -l app=payment-service -o name | head -1) \ -n payment-service | grep -i "mtls\|strict"## Expected: mTLS is STRICT kubectl get peerauthentication -n payment-service \ -o jsonpath='{.items[0].spec.mtls.mode}'## Expected: STRICT ## ─── Chaos Engineering ───────────────────────────────────────python3 chaos/verify-security-controls.py## Expected: ALL CHECKS PASSED ## ─── Dashboard ───────────────────────────────────────────────curl -s http://localhost:5000/api/findings | \ python3 -c "import json, sysdata = json.load(sys.stdin)risk = data['risk_summary']print(f'Security Grade: {risk[\"grade\"]} ({risk[\"score\"]}/100)')print(f'CRITICAL findings: {risk[\"severity_counts\"][\"CRITICAL\"]}')assert risk['severity_counts']['CRITICAL'] == 0, 'CRITICAL findings must be zero'print('✅ Dashboard reporting zero CRITICAL findings')" echo ""echo "✅ All Platform Security components verified"Common Production Mistakes
Running tfsec and Checkov only on the main branch after merge. IaC misconfigurations caught after merge to main are misconfigurations that have already been reviewed and approved by your team. Run IaC scanning on every pull request as a blocking check, not just on the main branch pipeline. The earlier a misconfiguration is caught, the less expensive it is to fix — catching it before review means no developer has read and approved insecure infrastructure.
Using Vault in dev mode for anything beyond local development. Dev mode stores everything in memory — a pod restart loses all secrets, all leases, all configuration. Vault for production requires a persistent storage backend (integrated storage or Consul), an HA deployment with at least 3 nodes, a proper unseal strategy (cloud KMS auto-unseal), and a documented recovery procedure. Never let a dev mode Vault deployment persist beyond the day you set it up.
Not configuring Vault lease renewal. When Vault issues a dynamic database credential with a 1-hour TTL, the application must renew the lease before it expires or the credential becomes invalid and the application cannot connect to the database. The Vault Agent sidecar handles renewal automatically. If you are using the SDK directly, implement the lease renewal loop explicitly — a missed renewal causes a silent database connection failure at an unpredictable time.
Enabling Istio mTLS in STRICT mode on namespaces that still have non-mesh pods. If any pod in the namespace does not have an Envoy sidecar (because it was created before istio-injection=enabled was set), all traffic to that pod is rejected. Roll out mTLS in PERMISSIVE mode first, verify all pods have sidecars with kubectl get pods -n NAMESPACE -o jsonpath='{.items[*].spec.containers[*].name}', then switch to STRICT. Use istioctl analyze to catch configuration issues before they cause outages.
Running chaos experiments without a defined hypothesis. Chaos engineering without a hypothesis is just random breakage. Before running any experiment, write down: "I believe that when X happens, Y security control will hold and Z will be the observable evidence." Without a hypothesis, you cannot know whether the experiment revealed a gap or just confirmed existing behavior. The experiment from this capstone should have a hypothesis like: "When 50% of payment-service pods are killed, Vault will reinject fresh credentials to replacement pods within 90 seconds, and no credentials will appear in pod environment variables."
Treating the compliance dashboard as a compliance checkbox. A dashboard that shows "Grade A" is only meaningful if the underlying data is fresh and complete. Schedule a nightly CI job that re-collects all findings, updates the dashboard data, and alerts on any degradation from the previous day's score. A static dashboard that shows last week's results while this week's deployment introduced three critical CVEs is worse than no dashboard — it provides false assurance.
Quick Reference
| Component | Tool | What It Protects | Key Config File |
|---|---|---|---|
| IaC Scanning | tfsec + Checkov | Infrastructure misconfig | .tfsec.toml |
| Secrets | HashiCorp Vault | Dynamic database credentials | vault policy |
| Service Mesh | Istio | mTLS between services | PeerAuthentication |
| Chaos Testing | LitmusChaos | Validates controls under failure | ChaosEngine YAML |
| Dashboard | Flask + collectors | Unified security posture view | dashboard/app.py |
| Command | What It Does |
|---|---|
tfsec modules/ --minimum-severity HIGH |
Scan Terraform for HIGH+ misconfigs |
vault read database/creds/payment-service |
Generate a dynamic DB credential |
vault lease revoke -prefix database/creds/payment-service |
Revoke all active leases |
istioctl x describe pod POD_NAME -n NAMESPACE |
Check mTLS status of a pod |
kubectl apply -f chaos/pod-kill-experiment.yaml |
Run pod kill chaos experiment |
python3 chaos/verify-security-controls.py |
Verify controls after chaos |
curl http://localhost:5000/api/findings |
Get all findings as JSON |
Videos & Guides
No external walkthroughs, video tutorials, or reference files attached to this blueprint yet.