Your pipeline builds code, runs tests, and deploys to production in 8 minutes. But does it check for vulnerabilities before deploying?
If security scanning isn't in your CI/CD pipeline, vulnerabilities ship to production on every merge. Here's how to fix that — with real pipeline configs you can copy today.
What to Scan in CI/CD
A comprehensive security pipeline covers five areas:
| Scan Type | What It Catches | When to Run |
|-----------|----------------|-------------|
| Dependency scanning | Known CVEs in packages | Every PR |
| Container image scanning | OS + app vulns in images | After docker build |
| SAST (Static Analysis) | Code-level vulnerabilities | Every PR |
| Secrets detection | API keys, passwords in code | Every commit |
| IaC scanning | Cloud misconfigurations | Every PR |
GitHub Actions: Complete Pipeline
Here's a production-ready security pipeline for GitHub Actions:
Step 1: Dependency Scanning
name: Security Scan
on: [pull_request]
jobs:
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan dependencies with Trivy
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
severity: CRITICAL,HIGH
exit-code: 1
This scans package-lock.json, go.sum, requirements.txt, Gemfile.lock, and any other lockfile for known CVEs.
Step 2: Container Image Scanning
container-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan container image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
format: table
Step 3: Secrets Detection
secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for secret scanning
- name: Scan for secrets
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Step 4: SAST (Static Analysis)
sast-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Semgrep SAST
uses: returntocorp/semgrep-action@v1
with:
config: auto
Full Pipeline (Combined)
name: Security Pipeline
on: [pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Dependency scan
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
severity: CRITICAL,HIGH
exit-code: 1
- name: Secrets scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: SAST scan
uses: returntocorp/semgrep-action@v1
with:
config: auto
- name: Build and scan container
run: |
docker build -t myapp:${{ github.sha }} .
- name: Container scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
GitLab CI Configuration
stages:
- test
- security
- build
- deploy
dependency-scan:
stage: security
image: aquasec/trivy:latest
script:
- trivy fs --severity CRITICAL,HIGH --exit-code 1 .
allow_failure: false
container-scan:
stage: security
needs: [build]
image: aquasec/trivy:latest
script:
- trivy image --severity CRITICAL,HIGH --exit-code 1 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
secrets-scan:
stage: security
image: zricethezav/gitleaks:latest
script:
- gitleaks detect --source . --verbose
allow_failure: false
sast:
stage: security
image: returntocorp/semgrep
script:
- semgrep --config auto .
Setting Up Policy Gates
Not every vulnerability should block deployment. Configure policies based on real risk:
Gate 1: Block on Critical + Exploitable
# Only block on critical findings with EPSS > 10%
name: Policy gate
run: |
CRITICAL_COUNT=$(trivy image --format json $IMAGE | jq '[.Results[].Vulnerabilities[] | select(.Severity=="CRITICAL")] | length')
if [ "$CRITICAL_COUNT" -gt 0 ]; then
echo "::error::$CRITICAL_COUNT critical vulnerabilities found"
exit 1
fi
Gate 2: Warn on High, Block on Critical
Allow high-severity findings to pass but flag them for review:
- name: Scan (warn on high)
uses: aquasecurity/trivy-action@master
with:
severity: CRITICAL
exit-code: 1 # Only block on critical
name: Report high findings
uses: aquasecurity/trivy-action@master
with:
severity: HIGH
exit-code: 0 # Don't block, just report
Gate 3: Baseline Comparison
Only fail on NEW vulnerabilities (not pre-existing ones):
# Generate baseline on main branch
trivy image --format json main-image:latest > baseline.json
# Compare PR image against baseline
trivy image --format json pr-image:$SHA > current.json
# Diff — only new findings trigger failure
diff <(jq -r '.Results[].Vulnerabilities[].VulnerabilityID' baseline.json | sort) \
<(jq -r '.Results[].Vulnerabilities[].VulnerabilityID' current.json | sort) \
| grep "^>" && exit 1 || echo "No new vulnerabilities"
Handling False Positives
Security scan noise kills adoption. Handle false positives properly:
Trivy Ignore File
# .trivyignore
CVE-2024-12345 # False positive: not reachable in our code path
CVE-2024-67890 # Accepted risk: mitigated by WAF
Semgrep Inline Suppression
# nosemgrep: python.lang.security.deserialization.avoid-pickle
data = pickle.loads(trusted_internal_data)
Document Everything
Every suppressed finding should have:
Performance Tips
Security scanning shouldn't double your pipeline time:
trivy --skip-db-update with a cached DB saves 30+ seconds# Cache Trivy DB
name: Cache Trivy DB
uses: actions/cache@v4
with:
path: ~/.cache/trivy
key: trivy-db-${{ hashFiles('.trivyignore') }}
Centralizing Results with Vulnios
Running individual scanners in CI/CD works, but results live in pipeline logs — scattered, ephemeral, and hard to track over time.
Vulnios centralizes everything:
---
Add multi-engine scanning to your pipeline at vulnios.com. 48 engines, EPSS prioritization, compliance reports — in one API call.
Ready to secure your organization?
Start scanning with 32 security engines — free tier available.
Get Started Free