Step 1: Repository Structure and Branch Strategy
Before writing a single line of pipeline code, establish the repository structure. Every security decision downstream depends on this foundation.
At Lockheed, we used a mono-repo structure for each program. At Cigna, we used polyrepos with a shared pipeline template repository. Either works, but the branch protection rules are non-negotiable.
Required branch protection rules on `main`:
- Require pull request reviews (minimum 2 approvals for changes to pipeline files)
- Require status checks to pass before merging (every CI job must be a required check)
- Require signed commits (especially important for FedRAMP and CMMC environments)
- Disallow force pushes to `main`
- Require conversation resolution before merging
For secrets management, never store secrets in the repository. In GitHub Actions, use environment secrets with environment-level approval gates. For production deployments, use OIDC (OpenID Connect) to authenticate to cloud providers — this eliminates long-lived credentials entirely.
# .github/workflows/setup-oidc.yml — example OIDC trust for AWS
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
This eliminates `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` from your secrets store entirely. Every authentication event is logged as an assumed-role event in CloudTrail, with the GitHub Actions job URL embedded in the session context. At Cigna, this was the single change that most dramatically improved our audit trail quality.
Step 2: Static Analysis with SonarQube
SonarQube is the SAST tool I reach for first. It handles code quality and security simultaneously, which matters in enterprise environments where you are selling both properties to engineering leadership.
The critical configuration decision is the quality gate. The default SonarQube quality gate is too permissive for production workloads. I always create a custom gate with at minimum:
- Zero new Critical or Blocker security issues
- Zero new vulnerabilities with CVSS score ≥ 7.0
- Code coverage on new code ≥ 80%
- Duplicated lines on new code < 3%
Here is the GitHub Actions job that runs SonarQube analysis and fails the pipeline if the quality gate fails:
sonarqube-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for SonarQube branch analysis
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v3
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
with:
args: >
-Dsonar.projectKey=my-app
-Dsonar.qualitygate.wait=true
-Dsonar.qualitygate.timeout=300
The `sonar.qualitygate.wait=true` flag is what people miss. Without it, the action submits the scan and immediately returns success. With it, the action polls the SonarQube server until the quality gate computation completes, then fails the build if the gate fails. This is a blocking check, which is what you want.
At NantHealth, we extended this with custom SonarQube rules for PHI (Protected Health Information) — specifically, custom rules that flagged any string concatenation involving variables named `patient_id`, `ssn`, `dob`, or matching common PHI field naming patterns. This caught logging statements that would have written PHI to CloudWatch in plain text.
Step 3: Container Scanning with Trivy
Trivy is the fastest production-grade container scanner I have used. It scans container images for OS package vulnerabilities (CVEs), application dependency vulnerabilities, secrets accidentally baked into images, and misconfigurations. A full scan of a production Node.js image completes in under 30 seconds.
The key configuration decision: what severity level fails the build? I use `CRITICAL` and `HIGH` for most workloads. For CMMC Level 2 and FedRAMP Moderate environments at Lockheed, we blocked on `MEDIUM` as well, which requires a more disciplined base image update cadence.
container-scan:
runs-on: ubuntu-latest
needs: build
steps:
- name: Build Docker image
run: docker build -t my-app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1
ignore-unfixed: true
vuln-type: os,library
scanners: vuln,secret,misconfig
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
The `ignore-unfixed: true` flag deserves explanation. When a CVE has been identified but no patch is yet available, failing the build on an unfixable vulnerability accomplishes nothing except blocking deployment. Set this to `true` and focus enforcement energy on vulnerabilities that have available fixes.
Uploading SARIF results to the GitHub Security tab creates a persistent vulnerability tracking record linked to specific commits. This is exactly the kind of audit trail that FedRAMP assessors and HIPAA auditors look for.
One practice I established at Cigna: pin base images to specific digest hashes, not just tags. `FROM python:3.12.3-slim-bookworm@sha256:abc123...` is immutable. `FROM python:3.12-slim` is not — the tag can be updated to point to a different, potentially vulnerable image without your knowledge.
Step 4: Infrastructure Scanning with Checkov
Terraform is the IaC tool of choice across all three organizations I mentioned. Checkov is the tool that validates Terraform configurations against security best practices before any infrastructure is provisioned.
At Lockheed, a misconfigured S3 bucket (public read access on a bucket containing program artifacts) would have been a career-ending event. Checkov catches that in the pipeline before `terraform apply` ever runs.
Here is the Checkov job configuration:
iac-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Checkov IaC scanner
uses: bridgecrewio/checkov-action@v12
with:
directory: ./terraform
framework: terraform
output_format: sarif
output_file_path: checkov-results.sarif
soft_fail: false
skip_check: CKV_AWS_144 # Example: skip cross-region replication for dev
check: CKV_AWS_*,CKV2_AWS_*
- name: Upload Checkov results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: checkov-results.sarif
A common Checkov finding in Terraform configurations is unencrypted storage. Here is what a compliant Terraform S3 bucket with the checks that Checkov enforces looks like:
resource "aws_s3_bucket" "artifacts" {
bucket = "my-app-artifacts-${var.environment}"
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.artifacts.arn
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
This configuration passes the critical Checkov checks: `CKV_AWS_19` (encryption enabled), `CKV_AWS_21` (versioning enabled), `CKV_AWS_53`, `CKV_AWS_54`, `CKV_AWS_55`, `CKV_AWS_56` (public access blocked). At Lockheed, we had Checkov enforcement prevent over 200 potential misconfigurations across 18 months of active Terraform development. Most were in development environments — but development environments have a way of becoming production environments when timelines compress.
For more Terraform security patterns and hardened IaC templates, the Cybersecurity Frameworks collection at Citadel Cloud includes Terraform modules pre-validated against CMMC Level 2 and NIST 800-53 controls.
Step 5: Dynamic Analysis with OWASP ZAP
Static and IaC scanning catches issues before the application runs. DAST catches issues in the running application — authentication bypasses, injection vulnerabilities, misconfigurations that only manifest at runtime. OWASP ZAP is the tool for this layer.
The practical challenge with DAST in CI/CD is that you need a running instance of the application to scan. This means running ZAP in a separate job after the application is deployed to a staging environment. The workflow looks like this:
dast-scan:
runs-on: ubuntu-latest
needs: deploy-staging
steps:
- uses: actions/checkout@v4
- name: OWASP ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.12.0
with:
target: ${{ secrets.STAGING_URL }}
rules_file_name: .zap/rules.tsv
cmd_options: >
-a
-j
-T 60
-z "-config api.addrs.addr.name=.*
-config api.addrs.addr.regex=true"
fail_action: true
artifact_name: zap-report
- name: Upload ZAP report
uses: actions/upload-artifact@v4
if: always()
with:
name: zap-security-report
path: report_html.html
The `.zap/rules.tsv` file lets you configure which findings block the pipeline versus which are accepted risks. At Cigna, we maintained a controlled suppression list — findings that had been reviewed and accepted as low risk for our specific deployment context — with each suppression requiring a documented security exception approved by the HIPAA Security Officer.
A baseline scan is the starting point. For more thorough coverage, run ZAP's full active scan (`zaproxy/action-full-scan`) on weekly scheduled runs rather than every PR — active scanning takes 10-40 minutes depending on application surface area, which is acceptable for a nightly or weekly schedule but too slow for per-commit feedback.
Step 6: Composing the Full Pipeline
The full pipeline composes these stages with proper job dependencies and parallelization:
name: DevSecOps Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Stage 1: Code quality and security (runs in parallel)
sast:
name: Static Analysis (SonarQube)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v3
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
with:
args: -Dsonar.qualitygate.wait=true
iac-scan:
name: IaC Security (Checkov)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bridgecrewio/checkov-action@v12
with:
directory: ./terraform
framework: terraform
soft_fail: false
# Stage 2: Build and scan container image
build-and-scan:
name: Build + Container Scan (Trivy)
runs-on: ubuntu-latest
needs: [sast, iac-scan]
permissions:
contents: read
packages: write
security-events: write
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Trivy image scan
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1
ignore-unfixed: true
- name: Upload Trivy SARIF
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
# Stage 3: Deploy to staging and run DAST
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build-and-scan
environment: staging
if: github.ref == 'refs/heads/develop' || github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Deploy via Terraform
run: |
cd terraform/environments/staging
terraform init
terraform apply -auto-approve \
-var="image_tag=${{ github.sha }}"
dast:
name: Dynamic Analysis (OWASP ZAP)
runs-on: ubuntu-latest
needs: deploy-staging
steps:
- uses: actions/checkout@v4
- uses: zaproxy/action-baseline@v0.12.0
with:
target: ${{ secrets.STAGING_URL }}
rules_file_name: .zap/rules.tsv
fail_action: true
# Stage 4: Production deployment (requires passing all security gates)
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [build-and-scan, dast]
environment: production
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PROD_ROLE_ARN }}
aws-region: us-east-1
- name: Deploy via Terraform
run: |
cd terraform/environments/production
terraform init
terraform apply -auto-approve \
-var="image_tag=${{ github.sha }}"
This structure enforces a clear security gate chain: SAST and IaC scanning must pass before a container is built; the container must pass Trivy scanning before staging deployment; staging DAST must pass before production deployment. No shortcutting the chain.
Step 7: Compliance-Specific Configurations
HIPAA Workloads (Cigna Pattern)
For HIPAA, the audit trail requirements are the primary driver of additional pipeline configuration. Every pipeline run must produce artifacts that demonstrate:
- Which code version was scanned
- Which tool versions performed the scan
- What the scan results were
- Who approved the deployment
- What time the deployment occurred
GitHub Actions' environment protection rules with required reviewers satisfy the human approval requirement. The SARIF uploads to the Security tab satisfy the scan result retention requirement. Add artifact retention policies to ensure scan results are kept for at least 6 years (HIPAA retention requirement for security records):
- name: Upload security artifacts
uses: actions/upload-artifact@v4
with:
name: security-scan-results-${{ github.sha }}
path: |
trivy-results.sarif
checkov-results.sarif
zap-report.html
retention-days: 2190 # 6 years
CMMC Level 2 / FedRAMP Moderate (Lockheed Pattern)
CMMC and FedRAMP add requirements around the pipeline infrastructure itself — not just what the pipeline scans, but how the pipeline runs. Key additional controls:
- Use self-hosted GitHub Actions runners on government-controlled infrastructure (not GitHub-hosted runners) to satisfy data sovereignty requirements
- Require signed commits with GPG keys tied to PIV/CAC cards
- Run Semgrep with the OWASP rule set in addition to SonarQube, to satisfy the requirement for multiple independent SAST tools
- Archive all pipeline logs to a FIPS 140-2 validated storage endpoint
compliance-scan:
runs-on: self-hosted # Government-compliant runner
steps:
- uses: actions/checkout@v4
- name: Semgrep OWASP scan
uses: semgrep/semgrep-action@v1
with:
config: >-
p/owasp-top-ten
p/cwe-top-25
p/secrets
auditOn: push
Building the Skills to Own This Pipeline
The tooling in this guide reflects patterns I developed across a decade of building pipelines in regulated industries. The combination of SonarQube, Trivy, Checkov, and OWASP ZAP covers the four critical security layers that compliance frameworks — FedRAMP, CMMC, HIPAA, SOC 2 — consistently require.
For engineers building these skills from scratch, the DevOps Pipelines collection at Citadel Cloud includes pre-built GitHub Actions workflow templates, hardened Terraform modules, and Checkov policy sets for common compliance frameworks. The free courses at Citadel Cloud include a full DevSecOps module covering each tool in this stack with hands-on labs.
FAQ
Do I need all four tools (SonarQube, Trivy, Checkov, OWASP ZAP) or can I start with just one?
Start with Trivy and Checkov if you are building from zero — they cover the highest-impact risk categories with the least configuration overhead. Trivy will immediately surface known CVEs in your container images, and Checkov will catch the most common Terraform misconfigurations that lead to breaches. SonarQube requires more setup (a hosted SonarQube instance or SonarCloud account) and OWASP ZAP requires a running staging environment, so they are natural second-phase additions. The goal is a complete pipeline, not a perfect pipeline on day one.
How do I handle the false positive problem in SAST and DAST tools?
False positives are real and manageable. In SonarQube, use the "Won't Fix" marking with a documented rationale — this creates an audit trail showing the finding was reviewed and accepted, not ignored. In OWASP ZAP, maintain a `.zap/rules.tsv` file where suppressed findings are listed with alert IDs. In Checkov, use the `skip_check` parameter with a comment explaining the business justification. The key discipline is: every suppression must be documented with a reason and reviewed periodically. At Cigna, we reviewed all suppressions quarterly during our HIPAA risk assessment cycle.
How long does a full DevSecOps pipeline run take in practice?
The parallel jobs in the pipeline above (SAST and IaC scanning running simultaneously while the previous stage completes) typically complete in 8-15 minutes for a medium-sized application. Container build and Trivy scan adds another 3-5 minutes. Staging deployment varies by infrastructure complexity — 5-15 minutes for a containerized ECS service. OWASP ZAP baseline scan on a staging application takes 3-8 minutes. Total wall-clock time for the full pipeline: 20-40 minutes. That is acceptable for a staging-to-production promotion. For PR validation (which excludes staging deployment and DAST), the wall-clock time is 12-20 minutes.
What is the difference between a DevSecOps pipeline and just adding a security scan to my CI/CD?
A single security scan bolted onto an existing CI/CD pipeline is better than nothing, but it is not DevSecOps. The key differences are: (1) multiple complementary scanning layers covering code, container, IaC, and runtime rather than a single tool; (2) security gates that actually block deployment on failure rather than generating reports nobody reads; (3) security findings tracked in the same tooling as code quality findings, visible to the entire engineering team rather than siloed in a security team dashboard; (4) compliance-ready artifact retention so security evidence is available for audits without manual collection efforts. The pipeline in this guide implements all four properties.
Can this pipeline work with cloud providers other than AWS?
Yes. The OIDC authentication pattern works with GCP (Workload Identity Federation), Azure (Federated Identity Credentials), and AWS — the specific `aws-actions/configure-aws-credentials` action swaps for the GCP or Azure equivalent. Terraform and Checkov are cloud-agnostic; Checkov supports GCP, Azure, Kubernetes, Dockerfile, and CloudFormation in addition to AWS Terraform. Trivy is container-registry-agnostic. SonarQube and OWASP ZAP operate at the code and HTTP layer and have no cloud dependency. For multi-cloud environments — which is the default in most enterprise deployments today — the pipeline architecture requires minimal modification beyond the deployment steps.
*Kenny Ogunlowo is a Senior Multi-Cloud DevSecOps Architect and AI Engineer. He has built production DevSecOps pipelines at Lockheed Martin (defense, Secret Clearance), Cigna Healthcare (HIPAA), and NantHealth. He holds certifications in AWS, Azure, GCP, FedRAMP, CMMC, and HIPAA security engineering.*