DevSecOps Pipeline: Security-in-CI/CD Reference¶
The Full 6-Job GitHub Actions Pipeline¶
The complete DevSecOps pipeline integrates SAST, SCA, secrets scanning, container scanning, IaC scanning, and a deploy job that only runs if all security gates pass.
# .github/workflows/devsecops.yml
name: DevSecOps Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
security-events: write
contents: read
id-token: write # Required for OIDC Azure auth in deploy job
jobs:
# ── JOB 1: SAST — Static Application Security Testing ──────────────────────
sast-semgrep:
name: SAST (Semgrep)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: returntocorp/semgrep-action@v1
with:
config: 'p/security-audit p/owasp-top-ten p/csharp p/typescript p/secrets'
generateSarif: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
if: always()
sast-codeql:
name: SAST (CodeQL)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: 'csharp, javascript' # CodeQL excels at C# and TypeScript
queries: security-extended
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3
with:
output: codeql-results.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: codeql-results.sarif
if: always()
# ── JOB 2: SCA — Software Composition Analysis (dependencies) ─────────────
sca-snyk:
name: SCA (Snyk)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Snyk for .NET
uses: snyk/actions/dotnet@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: '--severity-threshold=high --all-projects'
- name: Run Snyk for Node
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: '--severity-threshold=high'
# ── JOB 3: Secrets Scanning ─────────────────────────────────────────────────
secrets-scan:
name: Secrets Scan (Gitleaks)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history — scan all commits, not just latest
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} # Required for org scans
# ── JOB 4: Container Scanning ───────────────────────────────────────────────
container-scan:
name: Container Scan (Trivy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan filesystem (catches IaC and deps in repo)
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
exit-code: '1'
format: sarif
output: trivy-fs.sarif
- name: Scan container image
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:${{ github.sha }}'
severity: 'CRITICAL,HIGH'
exit-code: '1'
format: sarif
output: trivy-image.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-image.sarif
if: always()
# ── JOB 5: IaC Scanning ─────────────────────────────────────────────────────
iac-scan:
name: IaC Scan (Checkov)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkov for Terraform
uses: bridgecrewio/checkov-action@master
with:
directory: infra/
framework: terraform
soft_fail: false
output_format: sarif
output_file_path: checkov-tf.sarif
- name: Checkov for Bicep
uses: bridgecrewio/checkov-action@master
with:
directory: infra/
framework: bicep
soft_fail: false
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: checkov-tf.sarif
if: always()
# ── JOB 6: Deploy — Only if ALL security gates pass ─────────────────────────
deploy:
name: Deploy to Production
needs: [sast-semgrep, sast-codeql, sca-snyk, secrets-scan, container-scan, iac-scan]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # Requires environment protection rules
steps:
- uses: actions/checkout@v4
- name: Azure Login (OIDC — no stored secrets)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy
run: |
echo "All ${{ needs.sast-semgrep.result }}, ${{ needs.sca-snyk.result }} security gates passed — deploying"
# az deployment group create ...
Job-by-Job Breakdown¶
Job 1: SAST — Static Application Security Testing¶
Two tools for different strengths:
Semgrep — best for custom rules and rapid rule authorship. Rulesets to enable:
- p/security-audit — broad security patterns
- p/owasp-top-ten — OWASP Top 10 checks
- p/csharp — C#-specific patterns
- p/typescript — TypeScript/React patterns
- p/secrets — hardcoded credential detection
CodeQL — best for C# and TypeScript deep semantic analysis. Finds: - SQL injection (including EF Core string interpolation) - Cross-site scripting - Path traversal - Insecure deserialization - Missing authorization
Use languages: csharp, javascript and queries: security-extended for the most thorough analysis. autobuild handles .NET solution files automatically.
Both tools upload SARIF results to GitHub's Security tab. Results persist and can be reviewed even if the job fails — always use if: always() on SARIF upload steps.
Job 2: SCA — Software Composition Analysis¶
Snyk scans dependency trees for known vulnerabilities (CVEs) in NuGet and npm packages.
--severity-threshold=high fails the job on HIGH or CRITICAL findings. Use --all-projects for monorepo solutions with multiple .csproj files.
Complement with Dependabot for automated PR-based updates:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
- package-ecosystem: "nuget"
directory: "/backend"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
Also enable NuGet audit in .csproj to catch vulnerabilities at build time locally:
<PropertyGroup>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>low</NuGetAuditLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
Job 3: Secrets Scanning with Gitleaks¶
fetch-depth: 0 is critical — scans the entire git history, not just the latest commit. A secret committed 6 months ago and "deleted" in a subsequent commit is still in history.
Pre-commit hook to catch secrets before they reach CI:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
Install pre-commit: pip install pre-commit && pre-commit install
Custom Gitleaks rules for organization-specific patterns:
# .gitleaks.toml
[[rules]]
id = "azure-connection-string"
description = "Azure Storage Connection String"
regex = '''DefaultEndpointsProtocol=https;AccountName=[^;]+;AccountKey=[A-Za-z0-9+/=]{88}'''
tags = ["azure", "storage"]
Job 4: Container Scanning with Trivy¶
Two scan modes:
Filesystem scan (scan-type: fs) — scans the repository for vulnerable packages declared in package-lock.json, packages.lock.json, etc. Runs without building the image, so it catches issues early.
Image scan — scans the built container image including OS packages. This catches vulnerabilities in the base image that aren't visible in dependency files.
Container security best practices enforced by Trivy scanning:
# Use distroless (no shell, no package manager — minimal attack surface)
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
# Run as non-root user
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser
# Read-only filesystem (use volume mounts for writable paths)
# Enforced at pod level: readOnlyRootFilesystem: true
# No SUID/SGID binaries
RUN find / -perm /6000 -type f -exec chmod a-s {} \; 2>/dev/null || true
Trivy Operator for continuous in-cluster scanning (deploy separately):
helm install trivy-operator aquasecurity/trivy-operator \
--namespace trivy-system \
--create-namespace \
--set="trivy.ignoreUnfixed=true"
Job 5: IaC Scanning with Checkov¶
Checkov covers 1000+ built-in policies for CIS benchmarks, HIPAA, PCI-DSS, and SOC2 across: - Terraform (Azure provider) - Bicep / ARM templates - Kubernetes YAML manifests - Dockerfile - GitHub Actions workflows
soft_fail: false makes the job fail on policy violations. Use check / skip-check for exceptions:
- uses: bridgecrewio/checkov-action@master
with:
directory: infra/
framework: terraform
soft_fail: false
skip-check: >
CKV_AZURE_88,
CKV2_AZURE_21
Document every skipped check with justification in a comment or separate file. Unapproved exceptions require a security review.
Semgrep PostToolUse Hook for Real-Time Scanning¶
Wire Semgrep to scan files immediately after Claude Code edits them:
// .claude/settings.json
{
"hooks": {
"PostToolUse": {
"Edit": "semgrep scan --config p/secrets --config p/owasp-top-ten --quiet ${CLAUDE_FILE_PATH}",
"Write": "semgrep scan --config p/secrets --config p/owasp-top-ten --quiet ${CLAUDE_FILE_PATH}"
}
}
}
This catches AI-generated security anti-patterns immediately during development, before they reach CI:
- Hardcoded credentials (CWE-798)
- SQL injection via string interpolation (CWE-89)
- Missing authorization decorators (CWE-862)
- XSS in React via dangerouslySetInnerHTML (CWE-79)
Quality Gates and Exception Process¶
Gate thresholds: - SAST: fail on HIGH or CRITICAL severity findings - SCA: fail on HIGH or CRITICAL CVEs - Secrets: fail on any detected secret - Container: fail on CRITICAL or HIGH CVEs in base image or installed packages - IaC: fail on HIGH severity policy violations (MEDIUM as warning only)
Exceptions process: 1. Security engineer reviews the finding 2. Documents justification in code comment or exceptions file 3. Uses tool-specific suppression annotation:
// Semgrep suppression
var query = $"SELECT * FROM Users WHERE Id = {userId}"; // nosemgrep: sql-injection
// EXCEPTION: userId is validated as integer before this point — confirmed 2026-01-15
// Trivy suppression (in Trivy config file)
// trivy:ignore:CVE-2024-12345
# Checkov suppression inline
resource "azurerm_storage_account" "main" {
# checkov:skip=CKV_AZURE_33: Public access needed for CDN static assets — reviewed 2026-01-15
allow_nested_items_to_be_public = true
}
DAST: OWASP ZAP for Deployed Environments¶
Run DAST against a deployed staging environment (not in the main PR pipeline — deploy first):
dast-zap:
name: DAST (OWASP ZAP)
needs: [deploy-staging]
runs-on: ubuntu-latest
steps:
- name: ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.10.0
with:
target: 'https://staging.myapp.example.com'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a' # Include alpha passive scan rules
- name: ZAP Full Scan (weekly only)
if: github.event_name == 'schedule'
uses: zaproxy/action-full-scan@v0.10.0
with:
target: 'https://staging.myapp.example.com'
rules_file_name: '.zap/rules.tsv'
ZAP rules file to suppress known false positives:
# .zap/rules.tsv
10035 IGNORE (Strict-Transport-Security Header Not Set)
10038 IGNORE (Content Security Policy Header Not Set)
Secrets Management — No Hardcoded Credentials¶
In GitHub Actions workflows:
- Use ${{ secrets.* }} for sensitive values
- Use OIDC federation for Azure, AWS, GCP (no stored cloud credentials)
- Use ${{ vars.* }} for non-sensitive configuration
Repository secrets vs environment secrets:
- Repository secrets are available to all workflows — use for non-environment-specific values
- Environment secrets (under environment: production) are only injected when the job runs against that environment — use for production credentials
# OIDC pattern — only tenant/subscription IDs stored as secrets, no passwords
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }} # App registration client ID
tenant-id: ${{ secrets.AZURE_TENANT_ID }} # Tenant ID
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# No AZURE_CLIENT_SECRET — uses OIDC token exchange
Branch Protection Rules¶
Configure these on the main branch to enforce security gates before merge:
# Via GitHub repository settings or Terraform:
resource "github_branch_protection" "main" {
repository_id = github_repository.main.node_id
pattern = "main"
required_status_checks {
strict = true # Require branch to be up to date
contexts = [
"SAST (Semgrep)",
"SAST (CodeQL)",
"SCA (Snyk)",
"Secrets Scan (Gitleaks)",
"Container Scan (Trivy)",
"IaC Scan (Checkov)",
]
}
required_pull_request_reviews {
dismiss_stale_reviews = true
require_code_owner_reviews = true
required_approving_review_count = 1
}
enforce_admins = true # Admins cannot bypass checks
}
Reusable Security Workflow Pattern¶
Extract scanning jobs into reusable workflows for consistent security across all repositories:
# .github/workflows/security-scan.yml (in a central org repo)
on:
workflow_call:
inputs:
image-ref:
required: true
type: string
infra-directory:
required: false
type: string
default: 'infra/'
secrets:
SNYK_TOKEN:
required: true
jobs:
sast:
uses: ./.github/workflows/sast.yml
sca:
uses: ./.github/workflows/sca.yml
secrets: inherit
container-scan:
uses: ./.github/workflows/container-scan.yml
with:
image-ref: ${{ inputs.image-ref }}
Call from any repository:
security:
uses: myorg/shared-workflows/.github/workflows/security-scan.yml@main
with:
image-ref: myapp:${{ github.sha }}
infra-directory: infrastructure/
secrets: inherit
Security Policy as Code with OPA/Rego¶
Enforce security policies across the stack using OPA with Conftest:
# policy/kubernetes.rego
package kubernetes.security
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.securityContext.readOnlyRootFilesystem
msg := sprintf("Container '%s' must have readOnlyRootFilesystem: true", [container.name])
}
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.resources.limits.memory
msg := sprintf("Container '%s' must have memory limits set", [container.name])
}
deny[msg] {
input.kind == "Deployment"
input.spec.template.spec.containers[_].image
endswith(input.spec.template.spec.containers[_].image, ":latest")
msg := "Images must not use :latest tag — pin by digest or semver"
}
Run in CI:
- name: Policy check with Conftest
run: |
helm template ./charts/myapp | conftest test - \
--policy policy/ \
--namespace kubernetes.security
Dependency Management: Dependabot + Snyk + Renovate¶
Renovate bot (more configurable than Dependabot) for automated dependency updates:
// renovate.json
{
"extends": ["config:base", ":dependencyDashboard"],
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"automerge": true // Auto-merge patch updates
},
{
"matchPackagePatterns": ["^Azure\\.", "^Microsoft\\."],
"groupName": "Azure SDK packages"
}
],
"prConcurrentLimit": 5
}
Pipeline Security Anti-Patterns to Avoid¶
-
soft_fail: trueon security scans — defeats the purpose of gates. Only acceptable during initial rollout. -
Scanning only the PR diff — use
fetch-depth: 0for secrets scanning; scan the full image for container vulnerabilities, not just changed files. -
Skipping SARIF upload on failure — always
if: always()on SARIF uploads so findings appear in GitHub Security tab even when the job fails. -
Single SARIF for multiple scans — upload separate SARIF files from each tool so findings are attributed correctly.
-
Environment secrets as repository secrets — production credentials should be environment-scoped, not available to all workflows.
-
No exception process — blanket
soft_fail: trueor skipping all checks is worse than no scanning. Build a documented, time-bound exception process with a required security review. -
Building untrusted pull requests with access to secrets — use
pull_request_targetonly when necessary and keep secrets out of untrusted PR contexts.
Plugin: devsecops-cicd · View SKILL.md on GitHub