2026-05-20

Container Image Security Audit: Trivy in CI with Automated Issue Tracking

Vulnerability scanning workflow for a containerized PHP application using Trivy. Integration with GitHub Actions and auto-generated issues, CVE triage by severity and exploitability, and the base-image vs application-dependency split that drives remediation priorities.

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

ToolRole
TrivyVulnerability scanner (CVE database, OS packages, language deps)
GitHub ActionsCI runner, scan trigger on push and on schedule
GitHub Issues APIAuto-create one issue per finding above a severity threshold
Docker HubImage 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:

CVEComponentTypeWhy It Matters
CVE-2022-37434zlibHeap buffer over-readAffects any process linked against the vulnerable libz. Wide blast radius.
CVE-2022-31625PHPUse-after-free in pg_query_paramsExploitable in PHP processes that interact with PostgreSQL.
CVE-2017-8923PHPStack buffer overflow in mbstringPre-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-unfixed is 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.