Overview
This is the methodology I used to audit a containerized web application running on legacy PHP. The goal was not just to run a one-off scan but to build a repeatable workflow that surfaces vulnerabilities continuously and assigns them to humans for triage. Trivy plus GitHub Actions plus the Issues API.
The target was a vulnerable-by-design container (DVWA family) running on an old PHP 5.x base. Real CVE counts in the hundreds, which is exactly what you want when learning to triage at scale.
Toolchain
| Tool | Role |
|---|---|
| Trivy | Vulnerability scanner (CVE database, OS packages, language deps) |
| GitHub Actions | CI runner, scan trigger on push and on schedule |
| GitHub Issues API | Auto-create one issue per finding above a severity threshold |
| Docker Hub | Image source, baseline comparison |
Trivy was the choice because it is fast, free, and pulls from the same NVD plus distro-specific advisories that downstream commercial scanners use. Grype and Snyk are alternatives that produce similar outputs. The methodology below applies regardless of scanner.
Step 1: Baseline Scan
Before integrating into CI, I ran the scan manually against the image to understand the volume.
trivy image vulnerables/web-dvwa
Output: dozens of Critical, hundreds of High and Medium findings. A typical first-time scan against an old PHP container.
Two things I noted immediately:
- The Critical findings clustered around specific CVE IDs that appeared multiple times (different packages, same underlying library)
- The split between base-image CVEs (Ubuntu/Debian/Alpine deps) and application-level CVEs (PHP extensions) is roughly 70/30 for legacy images
Step 2: Severity Triage
Raw output is overwhelming. Filtered down to make triage tractable:
trivy image --severity CRITICAL,HIGH --ignore-unfixed vulnerables/web-dvwa
The --ignore-unfixed flag is the single most useful triage filter. It hides CVEs where no upstream patch exists yet. Those are still real risks but cannot be fixed by remediation, only by mitigation (network controls, runtime protections, version upgrade).
After filtering: the list is roughly one third the size and contains only CVEs an engineer can act on today.
Step 3: Sample Findings
A few examples from the output that illustrate the categories:
| CVE | Component | Type | Why It Matters |
|---|---|---|---|
| CVE-2022-37434 | zlib | Heap buffer over-read | Affects any process linked against the vulnerable libz. Wide blast radius. |
| CVE-2022-31625 | PHP | Use-after-free in pg_query_params | Exploitable in PHP processes that interact with PostgreSQL. |
| CVE-2017-8923 | PHP | Stack buffer overflow in mbstring | Pre-auth in many PHP web stacks. Old but still found in unpatched images. |
The pattern: most Critical findings live in low-level libraries (zlib, openssl, libxml2) that get linked into many higher-level packages. One vulnerable library can show up across ten Trivy findings because ten packages depend on it.
Step 4: GitHub Actions Integration
Manual scans are good for learning. Automated scans are what actually keep an image clean over time. I added a workflow that runs Trivy on push and on a daily cron schedule.
name: container-scan
on:
push:
paths:
- 'Dockerfile'
- '.github/workflows/container-scan.yml'
schedule:
- cron: '0 6 * * *'
jobs:
trivy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: 'docker.io/your-org/your-image:latest'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
format: 'sarif'
output: 'trivy.sarif'
- name: Upload to GitHub Security
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy.sarif'
SARIF output goes into the GitHub Security tab. Each finding becomes a tracked issue that can be assigned, commented on, and closed when remediated. This is the difference between "we ran a scan once" and "we have an active vulnerability program."
Step 5: Issue Auto-Creation
For findings that need explicit ownership (not just visibility in the Security tab), I configured a secondary workflow that opens GitHub Issues with the CVE ID, severity, and remediation hint.
- name: Create issue per Critical CVE
if: failure()
uses: peter-evans/create-issue-from-file@v5
with:
title: '${{ steps.scan.outputs.cve }} Critical in ${{ steps.scan.outputs.image }}'
content-filepath: trivy-report.md
labels: security, critical
Result: every new Critical finding produces a GitHub Issue. The issue shows up in the project board, gets assigned, and follows the same lifecycle as any other engineering work. No dedicated security tracker, no separate process. Vulns become tickets.
Step 6: Base Image Hardening
After triage, the question is what to actually fix. Patterns I applied:
Upgrade the base image. The single biggest reduction in CVE count came from moving the dev container from an Ubuntu 18.04 base to a slim Debian 12 base. CVE count dropped by roughly 60% because the base image itself shipped fewer outdated packages.
Pin specific package versions. When the base image cannot move (legacy app dependency), pin individual packages to patched versions:
RUN apt-get update && apt-get install -y \
zlib1g=1:1.2.13.dfsg-1+b1 \
libxml2=2.9.14+dfsg-1.3 \
&& rm -rf /var/lib/apt/lists/*
Remove what is not used. Containers carry packages by inheritance. Stripping development packages (build-essential, debug symbols) at the end of the Dockerfile reduces both image size and CVE surface.
Multi-stage builds for compiled apps. Build in a fat image with tooling, copy artifacts to a minimal runtime image (distroless or alpine). Final image has no compilers, no shells, no package manager. Dramatically smaller attack surface.
Step 7: Verifying the Fix
After remediation, the same scan needs to confirm the CVE is gone:
trivy image --severity CRITICAL,HIGH --ignore-unfixed your-image:remediated
Before and after counts give a concrete metric for security work that is otherwise hard to quantify. Going from 47 Critical to 6 Critical in one Dockerfile change is a defensible number to report.
Key Takeaways
- Most Critical CVEs in container images come from the base layer, not from the application. Base image choice is the highest-impact security decision in containerization
--ignore-unfixedis the single best triage filter for getting an actionable list- Trivy plus GitHub Actions plus Issues API gives you a working vulnerability management workflow without buying enterprise tooling
- One vulnerable library (libxml2, zlib, openssl) typically explains 5-10 Trivy findings due to transitive dependencies
- Multi-stage builds and minimal base images reduce both attack surface and ongoing CVE noise
- The goal is not zero Criticals on day one. The goal is a sustained downward trend with explicit ownership of what remains
Methodology, Not Theater
A scan that produces 400 findings and no follow-up is security theater. The point of integrating Trivy with Issues is that findings have to be looked at by humans, decided on, and either fixed or accepted with rationale. That distinction is the difference between AppSec work that scales and AppSec work that produces dashboards nobody reads.