← Blog

"AppSec Series #11: DevSecOps — Building Security Into Every Stage of CI/CD"

Security that lives outside the pipeline will always lag behind development. Build a complete DevSecOps pipeline with automated gates that catch vulnerabilities at commit, build, test, and deploy time.

reading now
views
comments

Series Navigation

Part 10: SAST, DAST, SCA and Secrets Management

Part 12: Threat Modeling — STRIDE, PASTA and Attack Trees


The DevSecOps Security Gates

DevSecOps Pipeline Security Gates:

Developer Machine (pre-commit)
  ├── detect-secrets       (prevent secret commits)
  ├── gitleaks             (detect leaked credentials)
  └── lint / format

Pull Request (CI)
  ├── SAST (Semgrep, Bandit, ESLint-security)
  ├── SCA  (npm audit, pip-audit, Snyk)
  ├── IaC scan (Checkov, tfsec)
  └── Secret scan (truffleHog)

Build Stage
  ├── Container scan (Trivy)
  ├── SBOM generation
  └── Image signing (Cosign)

Staging Environment
  ├── DAST (OWASP ZAP)
  └── Penetration test (automated)

Production Deploy
  ├── Policy check (OPA Gatekeeper)
  └── Runtime security (Falco)

Production Monitoring
  ├── SIEM alerts
  ├── GuardDuty findings
  └── Anomaly detection

Complete GitLab CI/CD Security Pipeline

# .gitlab-ci.yml
stages:
  - pre-check
  - sast
  - sca
  - build
  - dast
  - deploy

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  TRIVY_SEVERITY: HIGH,CRITICAL
  SEMGREP_RULES: p/owasp-top-ten,p/python

# ── Stage: Pre-check ─────────────────────────────────────────────────────────

secret-detection:
  stage: pre-check
  image: trufflesecurity/trufflehog:latest
  script:
    - trufflehog git file://. --only-verified --fail
  allow_failure: false  # BLOCK PR on secret detection

# ── Stage: SAST ──────────────────────────────────────────────────────────────

semgrep:
  stage: sast
  image: returntocorp/semgrep
  script:
    - semgrep ci
        --config "$SEMGREP_RULES"
        --error                          # exit 1 on findings
        --sarif-output semgrep.sarif
  artifacts:
    paths: [semgrep.sarif]
    reports:
      sast: semgrep.sarif
    when: always

bandit:
  stage: sast
  image: python:3.11
  script:
    - pip install bandit
    - bandit -r src/ -ll -f json -o bandit-report.json
    - python3 -c "
        import json, sys
        data = json.load(open('bandit-report.json'))
        highs = [i for i in data['results'] if i['issue_severity'] == 'HIGH']
        print(f'High severity: {len(highs)}')
        if highs: sys.exit(1)
      "
  artifacts:
    paths: [bandit-report.json]
    when: always

iac-scan:
  stage: sast
  image: bridgecrew/checkov:latest
  script:
    - checkov
        -d infrastructure/
        --framework terraform cloudformation
        --output json > checkov-results.json
        --soft-fail false
  artifacts:
    paths: [checkov-results.json]
    when: always

# ── Stage: SCA ───────────────────────────────────────────────────────────────

dependency-scan-python:
  stage: sca
  image: python:3.11
  script:
    - pip install pip-audit
    - pip-audit --format json > pip-audit-report.json
    - python3 -c "
        import json, sys
        data = json.load(open('pip-audit-report.json'))
        vulns = [v for d in data.get('dependencies',[]) for v in d.get('vulns',[])]
        criticals = [v for v in vulns if v.get('fix_versions')]
        print(f'Vulnerabilities: {len(vulns)}, fixable: {len(criticals)}')
        if criticals: sys.exit(1)
      "
  artifacts:
    paths: [pip-audit-report.json]

dependency-scan-node:
  stage: sca
  image: node:18
  script:
    - npm ci
    - npm audit --audit-level=high --json > npm-audit.json || true
    - |
      HIGH=$(cat npm-audit.json | python3 -c "
        import json,sys; d=json.load(sys.stdin)
        print(d.get('metadata',{}).get('vulnerabilities',{}).get('high',0))
      ")
      CRITICAL=$(cat npm-audit.json | python3 -c "
        import json,sys; d=json.load(sys.stdin)
        print(d.get('metadata',{}).get('vulnerabilities',{}).get('critical',0))
      ")
      echo "High: $HIGH, Critical: $CRITICAL"
      if [ "$CRITICAL" -gt "0" ]; then exit 1; fi

# ── Stage: Build ─────────────────────────────────────────────────────────────

build-image:
  stage: build
  image: docker:latest
  services: [docker:dind]
  script:
    - docker build -t $DOCKER_IMAGE .
    - docker push $DOCKER_IMAGE

trivy-scan:
  stage: build
  image: aquasec/trivy:latest
  needs: [build-image]
  script:
    - trivy image
        --exit-code 1
        --severity $TRIVY_SEVERITY
        --format sarif
        --output trivy-results.sarif
        $DOCKER_IMAGE
  artifacts:
    paths: [trivy-results.sarif]
    reports:
      container_scanning: trivy-results.sarif
    when: always

sign-image:
  stage: build
  needs: [trivy-scan]  # only sign if scan passes
  image: gcr.io/projectsigstore/cosign:latest
  script:
    # Sign image with keyless signing (OIDC + Sigstore Rekor transparency log)
    - cosign sign --yes $DOCKER_IMAGE

# ── Stage: DAST ──────────────────────────────────────────────────────────────

zap-baseline:
  stage: dast
  image: owasp/zap2docker-stable
  needs: []   # run in parallel with build stage
  script:
    - zap-baseline.py
        -t $STAGING_URL
        -r zap-report.html
        -J zap-report.json
        -I   # don't fail on warnings, only errors
  artifacts:
    paths: [zap-report.html, zap-report.json]
    when: always

# ── Stage: Deploy ─────────────────────────────────────────────────────────────

deploy-production:
  stage: deploy
  needs: [semgrep, bandit, trivy-scan, zap-baseline]  # all must pass
  environment: production
  script:
    - kubectl set image deployment/app app=$DOCKER_IMAGE
    - kubectl rollout status deployment/app --timeout=5m
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

GitHub Actions Security Pipeline

# .github/workflows/security.yml
name: Security Pipeline

on: [push, pull_request]

permissions:
  contents: read          # restrict default permissions
  security-events: write  # for SARIF uploads

jobs:
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Semgrep
        uses: returntocorp/semgrep-action@v1
        with:
          config: p/owasp-top-ten p/python

      - name: Run Bandit
        run: |
          pip install bandit
          bandit -r src/ -ll -f sarif -o bandit.sarif
        continue-on-error: true

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: bandit.sarif

  secrets:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0    # full history for secret scanning

      - name: TruffleHog Scan
        uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

  dependency-review:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - name: Dependency Review
        uses: actions/dependency-review-action@v3
        with:
          fail-on-severity: high

  container-security:
    runs-on: ubuntu-latest
    needs: []
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t ${{ github.repository }}:${{ github.sha }} .

      - name: Run Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ github.repository }}:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: '1'

      - name: Upload Trivy SARIF
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: trivy-results.sarif

Security as Code — Open Policy Agent (OPA)

# Kubernetes admission policy using OPA Gatekeeper
# Deny pods running as root

package kubernetes.admission

deny[msg] {
    input.request.kind.kind == "Pod"
    container := input.request.object.spec.containers[_]
    not container.securityContext.runAsNonRoot
    msg := sprintf("Container '%v' must set runAsNonRoot: true", [container.name])
}

deny[msg] {
    input.request.kind.kind == "Pod"
    container := input.request.object.spec.containers[_]
    container.securityContext.allowPrivilegeEscalation == true
    msg := sprintf("Container '%v' must not allow privilege escalation", [container.name])
}

deny[msg] {
    input.request.kind.kind == "Pod"
    not input.request.object.spec.securityContext.runAsNonRoot
    msg := "Pod must set runAsNonRoot at pod level"
}

# Block images without digest (must be immutable)
deny[msg] {
    input.request.kind.kind == "Pod"
    container := input.request.object.spec.containers[_]
    not contains(container.image, "@sha256:")
    not endswith(container.image, ":latest")  # also block :latest
    msg := sprintf("Container '%v' must use image digest", [container.name])
}

Security Metrics to Track

Key DevSecOps Metrics:
│
├── Mean Time to Remediate (MTTR)   — how fast are vulns fixed?
├── Vulnerability Density           — vulns per 1000 lines of code
├── Security Debt                   — backlog of known unfixed vulns
├── Critical Vuln Aging             — how old are critical findings?
├── Pipeline Security Gate Pass Rate— how many PRs blocked?
├── False Positive Rate             — how noisy are your tools?
└── Mean Time to Detect (MTTD)      — how fast do we find issues?

What's Next

In Part 12 we threat model like a professional — STRIDE methodology, attack trees, data flow diagrams, and how to run threat modeling sessions that actually prevent real attacks.

Discussion

Loading...

Leave a Comment

All comments are reviewed before appearing. No links please.

0 / 1000