ity/misconfiguration scanning, OPA/Conftest for policy evaluation, and Cosign for image signing. The pipeline enforces decisions before images reach the registry.
Architecture Decisions and Rationale
- SBOM First: Generate a machine-readable SBOM before scanning. This creates a deterministic baseline for dependency tracking, license compliance, and post-incident forensics.
- Separation of Scanning and Policy: Trivy detects flaws; OPA evaluates them against business rules. This prevents hardcoding thresholds in CI scripts and enables centralized policy management.
- Immutable Pipeline Flow: Build β SBOM β Scan β Policy β Sign β Push. Each stage produces artifacts that feed the next, ensuring auditability and rollback capability.
- Runtime Context Integration: Policy rules must reference deployment manifests (ports, network policies, service accounts) to calculate blast radius. Static CVSS scores are insufficient.
Step-by-Step Implementation
Step 1: SBOM Generation
Use Syft to extract package metadata from the built image. Syft supports multiple ecosystems (npm, pip, go, apt, apk) and outputs SPDX or CycloneDX formats.
syft packages docker:myapp:latest -o cyclonedx-json > sbom.json
Step 2: Vulnerability Scanning
Trivy consumes the image and produces a structured JSON report containing CVEs, fix versions, severity, and package paths.
trivy image --format json --output trivy-report.json myapp:latest
Step 3: Policy Evaluation (TypeScript)
The following TypeScript module consumes Trivy output, applies CVSS thresholds, checks for exposed services, and returns a deterministic pass/fail with remediation guidance. This replaces brittle shell scripts with a typed, testable policy engine.
import { readFileSync } from 'fs';
import { join } from 'path';
interface TrivyResult {
Results: Array<{
Target: string;
Class: string;
Vulnerabilities?: Array<{
VulnerabilityID: string;
Severity: string;
CVSS: { nvd: { V2Score: number; V3Score: number } };
FixedVersion?: string;
Title: string;
}>;
}>;
}
interface PolicyConfig {
maxCriticalCVSS: number;
maxHighCVSS: number;
allowUnfixed: boolean;
exposedPorts: string[];
}
interface PolicyResult {
pass: boolean;
blockedCVEs: string[];
remediationSteps: string[];
riskScore: number;
}
export function evaluateContainerPolicy(
trivyPath: string,
config: PolicyConfig
): PolicyResult {
const raw = readFileSync(join(process.cwd(), trivyPath), 'utf-8');
const report: TrivyResult = JSON.parse(raw);
const blockedCVEs: string[] = [];
const remediationSteps: string[] = [];
let riskScore = 0;
for (const result of report.Results) {
if (!result.Vulnerabilities) continue;
for (const vuln of result.Vulnerabilities) {
const v3 = vuln.CVSS?.nvd?.V3Score ?? 0;
const severity = vuln.Severity.toLowerCase();
// Calculate risk weight
if (severity === 'critical') riskScore += v3 * 1.5;
else if (severity === 'high') riskScore += v3 * 1.0;
else if (severity === 'medium') riskScore += v3 * 0.3;
// Apply policy thresholds
if (severity === 'critical' && v3 >= config.maxCriticalCVSS) {
blockedCVEs.push(vuln.VulnerabilityID);
remediationSteps.push(
`Patch ${vuln.VulnerabilityID} (${vuln.Title}) to ${vuln.FixedVersion || 'latest'}`
);
} else if (severity === 'high' && v3 >= config.maxHighCVSS) {
blockedCVEs.push(vuln.VulnerabilityID);
remediationSteps.push(
`Patch ${vuln.VulnerabilityID} (${vuln.Title}) to ${vuln.FixedVersion || 'latest'}`
);
} else if (!vuln.FixedVersion && !config.allowUnfixed) {
blockedCVEs.push(vuln.VulnerabilityID);
remediationSteps.push(
`Block unpatched ${vuln.VulnerabilityID}. Monitor vendor advisory or isolate workload.`
);
}
}
}
return {
pass: blockedCVEs.length === 0,
blockedCVEs,
remediationSteps,
riskScore: Math.round(riskScore * 100) / 100,
};
}
// Usage in CI/CD pipeline
const policy = evaluateContainerPolicy('trivy-report.json', {
maxCriticalCVSS: 7.0,
maxHighCVSS: 8.5,
allowUnfixed: false,
exposedPorts: ['8080', '443'],
});
if (!policy.pass) {
console.error('Policy violation detected:');
policy.blockedCVEs.forEach(cve => console.error(` - ${cve}`));
console.error('Remediation steps:');
policy.remediationSteps.forEach(step => console.error(` * ${step}`));
process.exit(1);
}
Step 4: Image Signing and Registry Push
After policy approval, sign the image with Cosign to guarantee provenance. Push only signed images to the registry.
cosign sign --key env://COSIGN_PRIVATE_KEY myregistry.io/myapp:latest
docker push myregistry.io/myapp:latest
Step 5: Continuous Monitoring
Deploy a registry-side scanner (Trivy server, Clair, or AWS ECR native scanning) to re-evaluate images against updated vulnerability databases. Trigger alerts when new CVEs match deployed SBOMs.
Pitfall Guide
- Scanning Only Build Layers, Not Final Images: Multi-stage builds often copy artifacts into minimal runtime images. Scanning the builder stage yields false confidence. Always scan the final runtime image.
- Treating All CVSS Scores Equally: A CVSS 9.0 in a background worker with no network access is lower risk than a CVSS 6.5 on a public API. Policy engines must incorporate deployment topology and exposure mapping.
- Hardcoding Thresholds in CI Scripts: Embedding
if severity == "CRITICAL" then fail in shell scripts creates technical debt. Centralize rules in OPA/Rego or a typed policy module to enable version control, testing, and cross-team consistency.
- Ignoring Unpatchable Vulnerabilities: Some CVEs lack fix versions due to abandoned upstream packages. Blocking all unfixed flaws halts delivery. Implement a risk-weighted allowlist with expiration dates and monitoring SLAs.
- Skipping SBOM Generation: Without an SBOM, you cannot trace which dependency introduced a flaw, audit license compliance, or generate accurate impact reports. SBOMs are non-negotiable for supply chain security.
- Stale Vulnerability Databases: Trivy/Grype rely on external feeds. Running scanners without daily database updates produces outdated results. Automate
trivy image --update or use a managed scanning service with guaranteed feed freshness.
- Over-Reliance on Automated Remediation: Auto-patching can introduce breaking changes or dependency conflicts. Automated scans should trigger PRs with proposed fixes, not direct merges. Require human review for high-risk changes.
Production Best Practices:
- Shift-left scanning into pre-commit hooks for Dockerfile linting (hadolint)
- Enforce policy-as-code with OPA/Conftest for reproducible gates
- Maintain a vulnerability exception workflow with time-bound approvals
- Correlate scan results with runtime telemetry (e.g., Falco, eBPF) to validate exploitability
- Rotate scanning credentials and restrict registry access to service accounts with least privilege
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small team, single registry | Trivy CLI + GitHub Actions + Cosign | Low overhead, native CI integration, sufficient for <50 images/month | $0β$50/mo (CI minutes) |
| Multi-tenant SaaS | Trivy Server + OPA + SBOM pipeline | Centralized policy, cross-team consistency, audit-ready provenance | $200β$800/mo (managed scanning + infra) |
| High-compliance (FinTech/Health) | Full SBOM + OPA + Cosign + Continuous Registry Scanning + Audit Logging | Meets SOC2/ISO27001 requirements, deterministic supply chain, regulatory traceability | $1,500β$4,000/mo (tooling + compliance overhead) |
| Legacy monolith containers | Grype + Syft + Manual Policy Review + Exception Workflow | Legacy dependencies often lack fix versions; requires human triage and phased remediation | $100β$300/mo (scanning + engineering time) |
Configuration Template
# .github/workflows/container-security.yml
name: Container Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Generate SBOM
run: |
docker run --rm -v $PWD:/output anchore/syft \
packages docker:myapp:${{ github.sha }} -o cyclonedx-json > sbom.json
- name: Run Trivy Scan
run: |
docker run --rm -v $PWD:/output aquasec/trivy:latest \
image --format json --output /output/trivy-report.json myapp:${{ github.sha }}
- name: Evaluate Policy
run: |
node policy-evaluator.js
env:
TRIVY_REPORT: trivy-report.json
- name: Sign Image
if: success()
run: |
cosign sign --key env://COSIGN_PRIVATE_KEY myapp:${{ github.sha }}
# policy/container-security.rego
package container.security
import rego.v1
deny contains msg if {
some vuln in input.vulnerabilities
vuln.severity == "CRITICAL"
vuln.cvss.v3.score >= 7.0
msg := sprintf("Block critical CVE %s (CVSS %.1f): %s", [vuln.id, vuln.cvss.v3.score, vuln.title])
}
deny contains msg if {
some vuln in input.vulnerabilities
vuln.severity == "HIGH"
vuln.cvss.v3.score >= 8.5
msg := sprintf("Block high CVE %s (CVSS %.1f): %s", [vuln.id, vuln.cvss.v3.score, vuln.title])
}
allow if {
not deny
}
Quick Start Guide
- Install Trivy and Syft:
brew install aquasecurity/trivy/trivy syft (or use official install scripts)
- Build your image:
docker build -t myapp:test .
- Run scan and export JSON:
trivy image --format json --output report.json myapp:test
- Evaluate with policy: Execute the TypeScript policy module or OPA
opa eval -i report.json -d policy/container-security.rego "data.container.security.allow"
- Block on failure: Add a CI step that exits non-zero if policy returns
false, preventing registry push until remediation completes