Building a Security Scanning Pipeline: From Code to Container
Security scanning is not a single tool or a single stage. A comprehensive approach covers four phases: static analysis of your source code (SAST), scanning of your dependencies for known vulnerabilities, scanning of your built container images, and runtime monitoring of your deployed containers. Each phase catches different categories of vulnerabilities, and relying on any single phase leaves significant blind spots.
This guide walks through building a practical, automated security scanning pipeline that covers all four phases, with concrete CI/CD integration examples and policy enforcement patterns.
The Four Phases of Security Scanning
| Phase | What It Scans | What It Catches | Tools |
|---|---|---|---|
| SAST | Your source code | Code-level vulnerabilities (SQL injection, XSS, hardcoded secrets) | Semgrep, CodeQL, gosec |
| Dependency scanning | Libraries and packages | Known CVEs in dependencies | Trivy, Grype, npm audit |
| Image scanning | Container images | OS package CVEs, misconfigurations, secrets in layers | Trivy, Grype, Snyk |
| Runtime scanning | Running containers | Anomalous behavior, exploit attempts, policy violations | Falco, Tracee, Sysdig |
Phase 1: Static Application Security Testing (SAST)
Semgrep
Semgrep is an open-source static analysis tool that supports 30+ languages. It uses pattern-matching rules that are easy to write and understand, unlike regex-based tools.
# Install Semgrep
pip install semgrep
# Run with default rules for your language
semgrep --config auto .
# Run with specific rule sets
semgrep --config p/security-audit .
semgrep --config p/owasp-top-ten .
semgrep --config p/docker .
# Output in JSON for pipeline processing
semgrep --config auto --json --output results.json .
# Run only on changed files (faster in CI)
git diff --name-only HEAD~1 | xargs semgrep --config auto
Semgrep rules are YAML files with a pattern-matching syntax. You can write custom rules for your codebase:
# .semgrep/custom-rules.yml
rules:
- id: hardcoded-password
patterns:
- pattern: |
password = "..."
- pattern-not: |
password = ""
- pattern-not: |
password = "changeme"
message: "Hardcoded password detected"
severity: ERROR
languages: [python, javascript, go]
- id: docker-socket-mount
pattern: |
volumes:
- ...
- /var/run/docker.sock:/var/run/docker.sock
- ...
message: "Docker socket mounted - ensure this is necessary and secured"
severity: WARNING
languages: [yaml]
- id: no-resource-limits
pattern: |
services:
...:
image: ...
pattern-not: |
services:
...:
deploy:
resources:
limits: ...
message: "Container has no resource limits defined"
severity: WARNING
languages: [yaml]
Language-Specific SAST
# Go: gosec
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec ./...
# Python: bandit
pip install bandit
bandit -r . -f json -o bandit-results.json
# JavaScript/TypeScript: eslint-plugin-security
npm install eslint-plugin-security --save-dev
# Add to .eslintrc: plugins: ["security"]
Phase 2: Dependency Scanning
Trivy for Dependencies
# Install Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Scan filesystem for dependency vulnerabilities
trivy fs --scanners vuln .
# Scan specific lock files
trivy fs --scanners vuln ./go.sum
trivy fs --scanners vuln ./package-lock.json
trivy fs --scanners vuln ./requirements.txt
# Output in table format (human-readable)
trivy fs --scanners vuln --severity HIGH,CRITICAL .
# Output in JSON (for pipeline processing)
trivy fs --scanners vuln --format json --output trivy-deps.json .
# Fail the build on critical vulnerabilities
trivy fs --scanners vuln --severity CRITICAL --exit-code 1 .
Grype for Dependencies
# Install Grype
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
# Scan a directory
grype dir:.
# Scan specific lock files
grype sbom:./sbom.spdx.json
# Generate an SBOM first (with Syft), then scan
syft . -o spdx-json > sbom.spdx.json
grype sbom:sbom.spdx.json
# Fail on high severity
grype dir:. --fail-on high
Phase 3: Container Image Scanning
Trivy Image Scanning
# Scan a Docker image
trivy image nginx:alpine
# Scan a locally built image
docker build -t my-app:latest .
trivy image my-app:latest
# Scan for vulnerabilities AND misconfigurations
trivy image --scanners vuln,misconfig my-app:latest
# Scan for secrets embedded in image layers
trivy image --scanners secret my-app:latest
# Comprehensive scan with all scanners
trivy image --scanners vuln,misconfig,secret --severity HIGH,CRITICAL my-app:latest
# Output as SARIF (for GitHub Security tab)
trivy image --format sarif --output trivy-results.sarif my-app:latest
# Scan with a policy file
trivy image --severity CRITICAL --exit-code 1 my-app:latest
Grype Image Scanning
# Scan a Docker image
grype nginx:alpine
# Scan a local image
grype docker:my-app:latest
# Scan an image from a registry (without pulling)
grype registry:ghcr.io/myorg/my-app:latest
# Scan and fail on high severity
grype docker:my-app:latest --fail-on high
# Output in different formats
grype docker:my-app:latest -o json > grype-results.json
grype docker:my-app:latest -o table
grype docker:my-app:latest -o cyclonedx-json > sbom.json
Comparing Trivy and Grype
| Feature | Trivy | Grype |
|---|---|---|
| Vulnerability database | NVD, vendor advisories, GitHub | NVD, vendor advisories, GitHub |
| Misconfiguration scanning | Yes (Dockerfile, K8s, Terraform) | No (use separate tool) |
| Secret detection | Yes | No |
| SBOM generation | Yes | No (use Syft) |
| Scan speed | Fast | Very fast |
| CI/CD integration | GitHub Actions, GitLab CI, Jenkins | GitHub Actions, GitLab CI |
Recommendation: Use Trivy as your primary scanner for its breadth (vulnerabilities, misconfigurations, secrets in one tool). Use Grype as a secondary scanner for its speed and different vulnerability database. Running both catches more issues than either alone.
Phase 4: Runtime Scanning with Falco
Falco monitors running containers for anomalous behavior by intercepting system calls at the kernel level. It detects activities like unexpected process execution, file system modifications, network connections, and privilege escalation attempts.
# Install Falco
curl -fsSL https://falco.org/repo/falcosecurity-packages.asc | sudo gpg --dearmor -o /usr/share/keyrings/falco-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/falco-archive-keyring.gpg] https://download.falco.org/packages/deb stable main" | sudo tee /etc/apt/sources.list.d/falcosecurity.list
sudo apt update && sudo apt install -y falco
# Or run Falco in a Docker container
docker run -d \
--name falco \
--privileged \
-v /var/run/docker.sock:/host/var/run/docker.sock \
-v /proc:/host/proc:ro \
-v /boot:/host/boot:ro \
-v /lib/modules:/host/lib/modules:ro \
-v /etc:/host/etc:ro \
falcosecurity/falco:latest
Falco Rules
# /etc/falco/falco_rules.local.yaml
# Detect shell spawned in a container
- rule: Shell Spawned in Container
desc: A shell was spawned in a container
condition: >
spawned_process and container and
proc.name in (bash, sh, zsh, dash, ksh)
output: >
Shell spawned in container
(user=%user.name container=%container.name
image=%container.image.repository
shell=%proc.name parent=%proc.pname
cmdline=%proc.cmdline)
priority: WARNING
tags: [container, shell]
# Detect sensitive file read in container
- rule: Read Sensitive File in Container
desc: Sensitive file was read inside a container
condition: >
open_read and container and
fd.name in (/etc/shadow, /etc/passwd, /etc/sudoers)
output: >
Sensitive file read in container
(file=%fd.name container=%container.name
image=%container.image.repository user=%user.name)
priority: ERROR
tags: [container, filesystem]
# Detect outbound connection from database container
- rule: Unexpected Outbound Connection from Database
desc: Database container made an outbound network connection
condition: >
outbound and container and
container.image.repository contains "postgres"
output: >
Database container made outbound connection
(container=%container.name ip=%fd.sip port=%fd.sport)
priority: CRITICAL
tags: [container, network]
Falco Alerting
# /etc/falco/falco.yaml
# Send alerts to multiple outputs
# Stdout (for container log collection)
stdout_output:
enabled: true
# File output
file_output:
enabled: true
filename: /var/log/falco/events.json
keep_alive: false
# HTTP webhook
http_output:
enabled: true
url: https://hooks.slack.com/services/YOUR/WEBHOOK/URL
# gRPC output (for Falco Sidekick)
grpc:
enabled: true
bind_address: "0.0.0.0:5060"
CI/CD Integration: GitHub Actions
# .github/workflows/security-scan.yml
name: Security Scanning Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
sast:
name: Static Analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Semgrep
uses: semgrep/semgrep-action@v1
with:
config: >-
p/security-audit
p/owasp-top-ten
generateSarif: true
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
dependency-scan:
name: Dependency Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy (filesystem)
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'HIGH,CRITICAL'
format: 'sarif'
output: 'trivy-fs.sarif'
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-fs.sarif
image-scan:
name: Container Image Scan
runs-on: ubuntu-latest
needs: [sast, dependency-scan]
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t my-app:${{ github.sha }} .
- name: Run Trivy (image)
uses: aquasecurity/trivy-action@master
with:
image-ref: 'my-app:${{ github.sha }}'
severity: 'HIGH,CRITICAL'
format: 'sarif'
output: 'trivy-image.sarif'
exit-code: '1'
- name: Run Grype (image)
uses: anchore/scan-action@v4
with:
image: 'my-app:${{ github.sha }}'
fail-build: true
severity-cutoff: high
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-image.sarif
generate-sbom:
name: Generate SBOM
runs-on: ubuntu-latest
needs: [image-scan]
steps:
- name: Generate SBOM with Syft
uses: anchore/sbom-action@v0
with:
image: 'my-app:${{ github.sha }}'
format: spdx-json
artifact-name: sbom.spdx.json
Policy Enforcement
Scanning without enforcement is informational at best. Define clear policies for what blocks a deployment:
# .trivyignore - Accepted vulnerabilities with justification
# CVE-2024-XXXXX: False positive, not exploitable in our configuration
CVE-2024-XXXXX
# CVE-2024-YYYYY: Vendor patch pending, mitigated by WAF rule
CVE-2024-YYYYY
# trivy.yaml - Policy configuration
severity:
- CRITICAL
- HIGH
# Block deployments with:
# - Any CRITICAL vulnerability
# - HIGH vulnerabilities older than 30 days
# - Secrets in image layers
# - Running as root without justification
Vulnerability Management Workflow
- Scan: Automated scanning in CI/CD and on a schedule for deployed images
- Triage: Classify vulnerabilities by severity, exploitability, and relevance to your deployment
- Fix: Update dependencies, rebuild base images, or apply mitigations
- Accept: Document accepted risks with justification and review dates
- Monitor: Re-scan regularly as new CVEs are published
A complete security scanning pipeline catches vulnerabilities at every stage of the software lifecycle. SAST catches bugs before they are committed. Dependency scanning catches known CVEs in libraries. Image scanning catches issues in your production artifacts. Runtime scanning catches exploitation attempts in production. No single tool or phase is sufficient alone, but together they provide comprehensive coverage that dramatically reduces your security risk.