Automated Security Scanning in Your Pipeline
Implement SAST, DAST, dependency scanning, and container security checks in your CI/CD pipeline to catch vulnerabilities before production.
Security isn’t something you bolt on after development—it’s something you bake into every commit. This guide covers how to implement comprehensive security scanning in your CI/CD pipeline without slowing down deployments.
The Security Scanning Landscape
Modern application security requires multiple layers:
| Type | What It Scans | When | Catches |
|---|---|---|---|
| SAST | Source code | Build time | SQL injection, XSS, hardcoded secrets |
| SCA | Dependencies | Build time | Vulnerable libraries |
| Container | Docker images | Build time | OS vulnerabilities, misconfigurations |
| DAST | Running app | Post-deploy | Runtime vulnerabilities, auth issues |
| Secrets | Code/config | Pre-commit | API keys, passwords in code |
Secret Detection (Pre-Commit)
Catch secrets before they enter the repository:
Gitleaks
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.1
hooks:
- id: gitleaks
# .gitleaks.toml
[extend]
useDefault = true
[[rules]]
id = "custom-api-key"
description = "Custom API Key Pattern"
regex = '''(?i)mycompany[_-]?api[_-]?key\s*[:=]\s*['"]?([a-zA-Z0-9]{32,})['"]?'''
secretGroup = 1
[allowlist]
paths = [
'''\.test\.js$''',
'''fixtures/''',
'''__mocks__/'''
]
TruffleHog
# GitHub Actions
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for scanning
- name: TruffleHog Scan
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
extra_args: --only-verified
SAST (Static Application Security Testing)
Analyze source code for vulnerabilities:
Semgrep
Semgrep is fast, customizable, and has excellent language support:
# .github/workflows/sast.yml
name: SAST
on: [push, pull_request]
jobs:
semgrep:
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- name: Run Semgrep
run: semgrep ci
env:
SEMGREP_RULES: >-
p/default
p/owasp-top-ten
p/r2c-security-audit
p/javascript
p/typescript
p/python
# .semgrep.yml - Custom rules
rules:
- id: no-exec-user-input
pattern: exec($USER_INPUT)
message: "Potential command injection - user input passed to exec()"
languages: [javascript, typescript]
severity: ERROR
metadata:
cwe: "CWE-78"
owasp: "A03:2021 - Injection"
- id: no-innerhtml-user-input
pattern: $EL.innerHTML = $USER_INPUT
message: "Potential XSS - user input assigned to innerHTML"
languages: [javascript, typescript]
severity: ERROR
- id: sql-injection-python
patterns:
- pattern: |
cursor.execute($QUERY % ...)
- pattern: |
cursor.execute($QUERY.format(...))
- pattern: |
cursor.execute(f"...")
message: "SQL injection risk - use parameterized queries"
languages: [python]
severity: ERROR
CodeQL (GitHub Native)
# .github/workflows/codeql.yml
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Weekly deep scan
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write
strategy:
matrix:
language: [javascript, python]
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
SonarQube
For enterprise environments:
# GitLab CI
sonarqube:
stage: security
image: sonarsource/sonar-scanner-cli:latest
variables:
SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
GIT_DEPTH: 0
cache:
key: "${CI_JOB_NAME}"
paths:
- .sonar/cache
script:
- sonar-scanner
-Dsonar.projectKey=${CI_PROJECT_NAME}
-Dsonar.sources=src
-Dsonar.host.url=${SONAR_HOST_URL}
-Dsonar.token=${SONAR_TOKEN}
-Dsonar.qualitygate.wait=true
# sonar-project.properties
sonar.projectKey=my-project
sonar.sources=src
sonar.tests=tests
sonar.exclusions=**/node_modules/**,**/dist/**
sonar.test.inclusions=**/*.test.js,**/*.spec.js
sonar.javascript.lcov.reportPaths=coverage/lcov.info
Dependency Scanning (SCA)
Snyk
# GitHub Actions
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high --fail-on=upgradable
- name: Upload results to GitHub
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: snyk.sarif
OWASP Dependency-Check
# GitLab CI
dependency-check:
stage: security
image: owasp/dependency-check:latest
script:
- /usr/share/dependency-check/bin/dependency-check.sh
--project "$CI_PROJECT_NAME"
--scan .
--format HTML
--format JSON
--out reports/
--failOnCVSS 7
artifacts:
paths:
- reports/
when: always
npm/yarn Audit
npm-audit:
stage: security
script:
- npm audit --audit-level=high
- npm audit --json > npm-audit.json || true
artifacts:
paths:
- npm-audit.json
allow_failure: true
Trivy for Filesystems
trivy-fs:
stage: security
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy fs --exit-code 1 --severity HIGH,CRITICAL .
Container Security Scanning
Trivy
# GitHub Actions
container-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
Grype
grype-scan:
runs-on: ubuntu-latest
steps:
- name: Scan image
uses: anchore/scan-action@v3
with:
image: myapp:latest
fail-build: true
severity-cutoff: high
output-format: sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
Docker Scout
# Native Docker scanning
docker-scout:
stage: security
image: docker:latest
services:
- docker:dind
script:
- docker login -u $DOCKER_USER -p $DOCKER_TOKEN
- docker build -t myapp:$CI_COMMIT_SHA .
- docker scout cves myapp:$CI_COMMIT_SHA --exit-code --only-severity critical,high
DAST (Dynamic Application Security Testing)
OWASP ZAP
# GitHub Actions
dast:
runs-on: ubuntu-latest
needs: [deploy-staging]
steps:
- name: ZAP Baseline Scan
uses: zaproxy/[email protected]
with:
target: 'https://staging.example.com'
rules_file_name: '.zap/rules.tsv'
- name: Upload Report
uses: actions/upload-artifact@v4
with:
name: zap-report
path: report_html.html
# Full ZAP scan for scheduled runs
dast-full:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ZAP Full Scan
uses: zaproxy/[email protected]
with:
target: 'https://staging.example.com'
docker_name: 'ghcr.io/zaproxy/zaproxy:stable'
cmd_options: '-a -j -l WARN -z "-config api.disablekey=true"'
# .zap/rules.tsv - Customize alert thresholds
10015 IGNORE (Incomplete or No Cache-control and Pragma HTTP Header Set)
10020 WARN (X-Frame-Options Header Not Set)
10021 FAIL (X-Content-Type-Options Header Missing)
10038 FAIL (Content Security Policy Header Not Set)
40012 FAIL (Cross Site Scripting (Reflected))
40014 FAIL (Cross Site Scripting (Persistent))
Nuclei
nuclei-scan:
runs-on: ubuntu-latest
steps:
- name: Nuclei Scan
uses: projectdiscovery/nuclei-action@main
with:
target: https://staging.example.com
templates: cves,vulnerabilities,misconfiguration
output: nuclei-results.txt
sarif-export: nuclei-results.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: nuclei-results.sarif
Complete Pipeline Example
# .github/workflows/security.yml
name: Security Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Weekly comprehensive scan
env:
IMAGE_NAME: myapp
jobs:
# Pre-commit style checks
secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Static analysis
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Semgrep
uses: semgrep/semgrep-action@v1
with:
config: >-
p/default
p/security-audit
p/owasp-top-ten
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
# Dependency vulnerabilities
dependencies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
# Container scanning
container:
runs-on: ubuntu-latest
needs: [sast, dependencies]
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t $IMAGE_NAME:${{ github.sha }} .
- name: Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:${{ github.sha }}
format: 'sarif'
output: 'trivy.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy.sarif'
- name: Push image
if: github.ref == 'refs/heads/main'
run: |
docker tag $IMAGE_NAME:${{ github.sha }} $REGISTRY/$IMAGE_NAME:${{ github.sha }}
docker push $REGISTRY/$IMAGE_NAME:${{ github.sha }}
# Deploy to staging
deploy-staging:
runs-on: ubuntu-latest
needs: [container]
if: github.ref == 'refs/heads/main'
environment: staging
steps:
- name: Deploy
run: |
kubectl set image deployment/myapp myapp=$REGISTRY/$IMAGE_NAME:${{ github.sha }}
kubectl rollout status deployment/myapp
# Dynamic testing
dast:
runs-on: ubuntu-latest
needs: [deploy-staging]
steps:
- uses: actions/checkout@v4
- name: ZAP Scan
uses: zaproxy/[email protected]
with:
target: 'https://staging.example.com'
- name: Upload Report
uses: actions/upload-artifact@v4
with:
name: zap-report
path: report_html.html
# Gate for production
security-gate:
runs-on: ubuntu-latest
needs: [secrets, sast, dependencies, container, dast]
steps:
- name: Security Gate Passed
run: echo "All security checks passed!"
Handling False Positives
Semgrep Ignores
// nosemgrep: javascript.express.security.injection.tainted-sql-string
const query = `SELECT * FROM users WHERE id = ${userId}`;
// Or inline
const result = exec(userInput); // nosemgrep
Trivy Ignore File
# .trivyignore.yaml
vulnerabilities:
- id: CVE-2023-12345
statement: "Not exploitable in our context - we don't use affected feature"
expires: 2024-06-01
- id: CVE-2023-67890
statement: "Waiting for upstream fix"
expires: 2024-03-15
Snyk Ignore
# Ignore for 30 days with reason
snyk ignore --id=SNYK-JS-LODASH-1234567 \
--expiry=2024-03-01 \
--reason="No upgrade path available, not using vulnerable function"
Metrics and Reporting
Security Dashboard
# Generate consolidated report
security-report:
runs-on: ubuntu-latest
needs: [sast, dependencies, container]
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Generate report
run: |
python scripts/consolidate-security-report.py \
--semgrep semgrep-results.json \
--snyk snyk-results.json \
--trivy trivy-results.json \
--output security-report.html
- name: Post to Slack
uses: slack/action@v1
with:
payload-file: security-summary.json
Track Trends
// scripts/track-metrics.js
const metrics = {
timestamp: new Date().toISOString(),
commit: process.env.GITHUB_SHA,
vulnerabilities: {
critical: parsedResults.filter(v => v.severity === 'CRITICAL').length,
high: parsedResults.filter(v => v.severity === 'HIGH').length,
medium: parsedResults.filter(v => v.severity === 'MEDIUM').length,
},
newVulnerabilities: calculateDelta(previousRun, parsedResults),
meanTimeToRemediate: calculateMTTR(resolvedVulnerabilities),
};
// Send to your metrics backend
await fetch(process.env.METRICS_ENDPOINT, {
method: 'POST',
body: JSON.stringify(metrics),
});
When NOT to Block
Not every finding should block deployment:
# Allow informational/low severity
sast:
allow_failure: false
script:
- semgrep ci --severity=ERROR # Only fail on ERROR
# Don't block on DAST (too many false positives)
dast:
allow_failure: true
# Block on criticals, warn on high
container-scan:
script:
- trivy image --exit-code 1 --severity CRITICAL myapp:latest
- trivy image --exit-code 0 --severity HIGH myapp:latest
Key Takeaways
- Layer your scanning—no single tool catches everything
- Shift left—catch secrets before commit, SAST before merge
- Automate everything—manual security reviews don’t scale
- Manage false positives—document suppressions with expiration dates
- Don’t block on everything—informational findings shouldn’t stop deploys
- Track metrics—measure vulnerability trends over time
- Schedule deep scans—daily quick scans, weekly comprehensive ones
Security scanning isn’t about achieving zero vulnerabilities—it’s about systematically reducing risk while maintaining development velocity.