Container Vulnerability Scanning in CI/CD Pipelines: Trivy, Grype, and Policy Enforcement

Container Vulnerability Scanning in CI/CD Pipelines: Trivy, Grype, and Policy Enforcement

Problem

Most teams add container scanning at the registry level — a post-push scan that runs after the image is already stored, ready to be pulled, and potentially being deployed. Registry scanning is better than nothing, but it has a fundamental ordering problem: the vulnerable image reaches the registry before any policy is applied. By the time a registry scanner fires an alert, a deployment may already be in flight.

Build-time scanning changes the order of operations. The scan runs inside the CI pipeline, after the image is built but before it is pushed. A CRITICAL CVE fails the build. The image never reaches the registry. No deployment occurs. The developer sees the failure immediately, in the same workflow where they are already focused on the change.

Common failures without build-time scanning:

  • Base image CVEs slip through. A FROM python:3.11 pulls in dozens of OS packages. A new CVE in libssl is not noticed until a registry scanner fires a week later, by which point the image is running in production.
  • Application dependency CVEs are invisible. pip install and npm ci bring in transitive dependencies. Without scanning the final image layer, these CVEs are never surfaced in the pipeline.
  • No blocking policy. Scanning tools exist in the pipeline but are configured with exit-code: 0 — they report findings without failing the build. Developers see warnings they learn to ignore.
  • False positives block releases. Overcorrection in the other direction: every CVE blocks the build, including CVEs with no fix available, CVEs in packages that are not reachable at runtime, and CVEs in the build stage that do not appear in the final image.
  • IaC is not scanned. Kubernetes manifests and Terraform configurations contain misconfigurations (privileged pods, unrestricted allowPrivilegeEscalation) that are distinct from CVEs but equally deployable via the same pipeline.

Target systems: Trivy 0.50+; Grype 0.74+; Syft 1.0+; GitHub Actions; GitLab CI; Dependency Track 4.x; Anchore Enterprise (optional).

Threat Model

  • Adversary 1 — Known CVE in base image OS packages: An attacker exploits a published CVE (e.g., a remote code execution in glibc) in a production container. The base image was pulled six weeks ago; the CVE was published four weeks ago. No pipeline check detected it.
  • Adversary 2 — Compromised transitive dependency: A supply chain attack injects malicious code into a transitive npm/pip/Maven package. The application image is built with the compromised package. Without layer-level scanning, the compromise is not detected before deployment.
  • Adversary 3 — Misconfigured Kubernetes manifest: A developer deploys a pod with securityContext.privileged: true and hostNetwork: true. The manifest passes YAML linting but is never checked for security misconfigurations.
  • Adversary 4 — Stale base image in long-lived branch: A feature branch is opened, a base image is selected, and the branch sits unmerged for three weeks. The main branch updates its base image digest; the feature branch does not. The merged image contains a regressed CVE profile.
  • Access level: Adversary 1 and 2 exploit the running application; no pipeline access required. Adversary 3 has developer access and can merge manifests. Adversary 4 is an accidental configuration drift.
  • Objective: Execute code in the container; escape to the node; exfiltrate data.
  • Blast radius: A single unscanned image in production can expose all workloads sharing the same node if the CVE enables container escape.

Configuration

Step 1: Trivy in GitHub Actions

Trivy is the most widely deployed open-source container scanner. It scans OS packages, language ecosystem files (go.sum, package-lock.json, requirements.txt, pom.xml), and Kubernetes manifests in a single binary.

# .github/workflows/build-and-scan.yml
name: Build, Scan, Push

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build-scan-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write   # Required for uploading SARIF to GitHub Security tab.
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build image (local, not pushed yet)
        uses: docker/build-push-action@v5
        with:
          context: .
          load: true            # Load into local daemon; do not push.
          tags: myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      # Scan the base image separately to distinguish base CVEs from app CVEs.
      - name: Scan base image layer
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: python:3.12-slim   # Match your FROM line.
          format: table
          severity: CRITICAL,HIGH
          exit-code: 0                  # Warn only; base image may have no-fix CVEs.
          ignore-unfixed: true

      # Scan the full application image (base + app layers).
      - name: Scan application image
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: 1                  # FAIL the build on CRITICAL or HIGH.
          ignore-unfixed: true          # Skip CVEs with no upstream fix.
          trivyignores: .trivyignore    # Project-level suppression file.

      - name: Upload SARIF to GitHub Security tab
        if: always()                    # Upload even on scan failure.
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

      - name: Push image (only if scan passed)
        if: github.ref == 'refs/heads/main' && success()
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha

Scanning the base image separately before scanning the full application image gives operators visibility into which CVEs originate in the upstream base image versus which were introduced by the application’s own dependencies. This separation matters for prioritisation: a CVE in the base image OS may have no fix yet, while a CVE in an application package often does.

Step 2: Managing False Positives with .trivyignore

Not every CVE warrants a blocked build. .trivyignore suppresses specific CVE IDs with a brief comment explaining the justification. Without a comment, suppressed CVEs become invisible technical debt.

# .trivyignore
# Format: CVE-ID [optional comment]

# CVE-2023-44487 (HTTP/2 Rapid Reset) — mitigated by NGINX ingress rate limiting.
# Tracked in: https://linear.app/myorg/issue/SEC-142
CVE-2023-44487

# CVE-2022-1664 — dpkg path traversal; only exploitable if arbitrary packages
# are installed at runtime. Our images are immutable and run read-only.
# No fix available in debian:bookworm as of 2026-04-01.
CVE-2022-1664

# CVE-2024-3094 (xz-utils backdoor) — not present in our build; confirmed by
# checking liblzma version pinned to 5.4.1 pre-backdoor.
CVE-2024-3094

Review .trivyignore quarterly. CVE suppressions that have aged beyond 90 days without a tracking issue or fix should be re-evaluated.

Step 3: Trivy in GitLab CI

# .gitlab-ci.yml — container scanning integrated into the pipeline.

stages:
  - build
  - scan
  - push

variables:
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

build:
  stage: build
  image: docker:26
  services:
    - docker:26-dind
  script:
    - docker build -t $IMAGE_TAG .
    - docker save $IMAGE_TAG -o image.tar
  artifacts:
    paths:
      - image.tar
    expire_in: 1 hour

scan:trivy:
  stage: scan
  image:
    name: aquasec/trivy:0.50.0
    entrypoint: [""]
  script:
    # Scan the saved image tarball — no need to push first.
    - trivy image
        --input image.tar
        --exit-code 1
        --severity CRITICAL,HIGH
        --ignore-unfixed
        --ignorefile .trivyignore
        --format json
        --output trivy-report.json
    # Also produce a human-readable table for the job log.
    - trivy image
        --input image.tar
        --exit-code 0
        --severity CRITICAL,HIGH,MEDIUM
        --ignore-unfixed
        --format table
  artifacts:
    when: always
    paths:
      - trivy-report.json
    reports:
      # GitLab Security Dashboard integration (GitLab Ultimate).
      container_scanning: trivy-report.json
    expire_in: 7 days
  allow_failure: false

# Scan Kubernetes manifests and Terraform in the same pipeline.
scan:iac:
  stage: scan
  image:
    name: aquasec/trivy:0.50.0
    entrypoint: [""]
  script:
    - trivy config
        --exit-code 1
        --severity HIGH,CRITICAL
        ./deploy/k8s/
    - trivy config
        --exit-code 1
        --severity HIGH,CRITICAL
        ./terraform/
  allow_failure: false

push:
  stage: push
  image: docker:26
  services:
    - docker:26-dind
  script:
    - docker load -i image.tar
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker push $IMAGE_TAG
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Step 4: Grype as an Alternative — Syft SBOM + Grype Scan

Grype (by Anchore) separates SBOM generation from vulnerability matching, which enables two distinct use cases: scanning during the build, and re-scanning an existing SBOM against a new vulnerability database without rebuilding.

# Install Syft (SBOM generator) and Grype (vulnerability matcher).
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
# .github/workflows/grype-scan.yml
      # Generate an SBOM using Syft (CycloneDX or SPDX format).
      - name: Generate SBOM with Syft
        run: |
          syft myapp:${{ github.sha }} \
            --output cyclonedx-json=sbom.cyclonedx.json \
            --output spdx-json=sbom.spdx.json

      - name: Upload SBOM as artifact
        uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: |
            sbom.cyclonedx.json
            sbom.spdx.json

      # Scan the SBOM with Grype — or scan the image directly.
      - name: Scan with Grype
        run: |
          grype sbom:./sbom.cyclonedx.json \
            --fail-on high \
            --output sarif \
            > grype-results.sarif

      - name: Upload Grype SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: grype-results.sarif

The key advantage of the SBOM-first workflow: the sbom.cyclonedx.json artifact can be stored alongside the image in the registry (as an OCI attestation via cosign attach sbom) and re-scanned at any time. When a new CVE is published that matches a package in an already-deployed image, the stored SBOM can be re-matched against the updated Grype vulnerability database without rebuilding or re-pulling the image.

# Grype suppress file — equivalent to .trivyignore.
# ~/.grype.yaml or .grype.yaml at project root.
ignore:
  - vulnerability: CVE-2023-44487
    reason: "Mitigated at the ingress layer; not reachable in this container."
    fix-state: not-fixed
  - package:
      name: xz-utils
      version: "5.4.1"
    vulnerability: CVE-2024-3094
    reason: "Confirmed: 5.4.1 predates the backdoor introduction."

Step 5: Blocking Policy — CRITICAL/HIGH vs Warn-Only

The policy question is not technical; it is organisational. Blocking builds on CRITICAL and HIGH CVEs produces safe deployments but requires operational discipline to handle the case where no fix exists.

Recommended tiered policy:

Severity Default action Override mechanism Review cadence
CRITICAL Block build .trivyignore entry with tracking issue Monthly
HIGH Block build .trivyignore entry with tracking issue Quarterly
MEDIUM Warn; create issue None required Quarterly
LOW / UNKNOWN Warn; optional None required Ad hoc

For ignore-unfixed: true vs blocking everything: CVEs with no available fix cannot be remediated by the application team — only by the upstream package maintainer. Blocking on these creates build failures that developers cannot resolve, causing alert fatigue and teaching teams to disable the scanner. Set ignore-unfixed: true and track unfixed CVEs in a separate dashboard (Dependency Track is the right tool here).

# GitHub Actions: two-threshold pattern.
# First pass: fail on CRITICAL immediately.
- name: Scan — CRITICAL (blocking)
  uses: aquasecurity/trivy-action@0.24.0
  with:
    image-ref: myapp:${{ github.sha }}
    severity: CRITICAL
    exit-code: 1
    ignore-unfixed: false   # Block even if no fix — CRITICAL is urgent.

# Second pass: fail on HIGH only when a fix is available.
- name: Scan — HIGH (blocking with fix available)
  uses: aquasecurity/trivy-action@0.24.0
  with:
    image-ref: myapp:${{ github.sha }}
    severity: HIGH
    exit-code: 1
    ignore-unfixed: true    # Only block if a patched version exists.

# Third pass: warn on MEDIUM/LOW; results go to Security tab.
- name: Scan — MEDIUM/LOW (warn only)
  uses: aquasecurity/trivy-action@0.24.0
  with:
    image-ref: myapp:${{ github.sha }}
    severity: MEDIUM,LOW
    exit-code: 0
    format: sarif
    output: trivy-warn.sarif

Step 6: Distroless and Minimal Base Images

The fastest way to reduce CVE surface is to ship fewer packages. Distroless images contain only the application runtime — no shell, no package manager, no OS utilities. Fewer installed packages means fewer CVEs to scan, triage, and patch.

# Before: debian-based image with ~200 OS packages.
FROM python:3.12
COPY . /app
RUN pip install -r /app/requirements.txt
CMD ["python", "/app/main.py"]

# After: distroless Python runtime.
FROM python:3.12-slim AS builder
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

FROM gcr.io/distroless/python3-debian12:nonroot
COPY --from=builder /root/.local /root/.local
COPY --chown=nonroot:nonroot app/ /app/
USER nonroot
ENTRYPOINT ["python3", "/app/main.py"]

Trivy scan results with distroless typically show 90%+ reduction in OS-level CVE count compared to the full base image. The remaining CVEs are primarily in the language runtime itself and are shared across all distroless users, which increases upstream pressure to fix them.

Minimal base image options by runtime:

Runtime Minimal base CVE surface vs full base
Go (static binary) scratch Zero OS packages
Go / C gcr.io/distroless/static-debian12 ~10 packages
Python gcr.io/distroless/python3-debian12 ~30 packages
Java gcr.io/distroless/java21-debian12 ~30 packages
Node.js gcr.io/distroless/nodejs20-debian12 ~30 packages
General Linux alpine:3.19 ~20 packages (musl libc; not glibc)

Step 7: Scanning IaC — Kubernetes Manifests and Terraform

Trivy’s config subcommand scans Kubernetes manifests for security misconfigurations using the same binary used for image scanning:

# Scan a directory of Kubernetes manifests.
trivy config \
  --severity HIGH,CRITICAL \
  --exit-code 1 \
  ./deploy/

# Example findings Trivy checks for:
# KSV001: Process can elevate its own privileges (allowPrivilegeEscalation: true)
# KSV003: Default capabilities not dropped (capabilities.drop not set)
# KSV012: Runs as root user (runAsNonRoot: false)
# KSV016: Memory not limited (resources.limits.memory missing)
# KSV030: Runtime/Default Seccomp profile not applied

# Scan Terraform.
trivy config \
  --severity HIGH,CRITICAL \
  --exit-code 1 \
  ./terraform/
# Kubernetes manifest scan in GitLab CI.
scan:k8s-manifests:
  stage: scan
  image:
    name: aquasec/trivy:0.50.0
    entrypoint: [""]
  script:
    - trivy config
        --format json
        --output k8s-findings.json
        --severity HIGH,CRITICAL
        --exit-code 1
        ./deploy/k8s/
  artifacts:
    when: always
    paths:
      - k8s-findings.json

IaC scanning in the pipeline closes a gap that image scanning cannot address: a perfectly clean container image can still be deployed insecurely if the Kubernetes manifest grants it hostPID: true or mounts sensitive host paths.

Step 8: VEX — Vulnerability Exploitability eXchange

VEX is a structured format for documenting that a specific CVE, while present in a package, is not exploitable in a given product due to deployment context or mitigating controls. It gives .trivyignore a standardised, machine-readable equivalent that can be attached to the SBOM and understood by multiple tools.

// vex.json — OpenVEX format (openvex.dev).
{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://example.com/vex/myapp-v2.1.0",
  "author": "security@example.com",
  "timestamp": "2026-05-07T00:00:00Z",
  "version": 1,
  "statements": [
    {
      "vulnerability": {
        "@id": "https://www.cve.org/CVERecord?id=CVE-2023-44487"
      },
      "products": [
        {
          "@id": "pkg:oci/myapp@sha256:abc123..."
        }
      ],
      "status": "not_affected",
      "justification": "protected_by_mitigating_control",
      "impact_statement": "HTTP/2 Rapid Reset is mitigated by NGINX ingress rate limiting configured at the cluster level. The application container does not directly terminate TLS or accept HTTP/2 from untrusted clients."
    },
    {
      "vulnerability": {
        "@id": "https://www.cve.org/CVERecord?id=CVE-2022-1664"
      },
      "products": [
        {
          "@id": "pkg:oci/myapp@sha256:abc123..."
        }
      ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "dpkg path traversal requires ability to install packages at runtime. Image is immutable; no package manager is invoked post-build."
    }
  ]
}
# Use the VEX document with Trivy.
trivy image \
  --vex vex.json \
  --severity CRITICAL,HIGH \
  --exit-code 1 \
  myapp:latest

# Use with Grype.
grype myapp:latest \
  --vex vex.json \
  --fail-on high

VEX produces an auditable justification trail for every suppressed CVE. Unlike .trivyignore entries (which are opaque to external parties), a VEX document can be attached to the image attestation in the registry and verified by downstream consumers.

Step 9: Integrating with Dependency Track

Dependency Track (OWASP) is a centralised vulnerability management platform that ingests CycloneDX SBOMs, continuously matches them against the NVD/OSV/GitHub Advisory databases, and tracks remediation over time.

# .github/workflows/dependency-track.yml
      - name: Generate SBOM
        run: |
          syft myapp:${{ github.sha }} \
            --output cyclonedx-json=sbom.cyclonedx.json

      - name: Upload SBOM to Dependency Track
        run: |
          # Encode SBOM as base64 for the API call.
          SBOM_BASE64=$(base64 -w 0 sbom.cyclonedx.json)
          
          curl -s -X PUT \
            -H "X-Api-Key: ${{ secrets.DEPENDENCY_TRACK_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d "{
              \"projectName\": \"myapp\",
              \"projectVersion\": \"${{ github.sha }}\",
              \"autoCreate\": true,
              \"bom\": \"$SBOM_BASE64\"
            }" \
            "https://dtrack.internal/api/v1/bom"

Dependency Track continuously re-evaluates all uploaded SBOMs against its vulnerability feeds. When a new CVE is published that affects a package in any stored SBOM, Dependency Track raises a finding without requiring a new build. This fills the gap between pipeline scans: an image built three months ago can be re-evaluated against today’s CVE feed.

Policy violations configured in Dependency Track can also drive pipeline gates. The API endpoint /api/v1/metrics/project/{uuid}/current returns the current violation count, which a pipeline step can query to gate deployment:

# Query Dependency Track for current policy violations before deployment.
VIOLATIONS=$(curl -s \
  -H "X-Api-Key: $DT_API_KEY" \
  "https://dtrack.internal/api/v1/metrics/project/$DT_PROJECT_UUID/current" | \
  jq '.policyViolationsTotal')

if [ "$VIOLATIONS" -gt 0 ]; then
  echo "ERROR: $VIOLATIONS policy violations in Dependency Track. Aborting deployment."
  exit 1
fi

Step 10: Scanning Frequency — Every Build vs Scheduled

Scan trigger What it catches When to use
Every build (PR + merge) CVEs introduced by this change; regressions from base image updates Always; non-negotiable
Scheduled (nightly/weekly) New CVEs published against already-built images in registry For images that are not rebuilt on every commit
Pre-deployment gate CVEs introduced since last build; policy drift Mature pipelines; high-risk environments
Post-deploy (continuous) Runtime behavioural anomalies; zero-days Requires separate runtime scanner (Falco, etc.)

Scheduled scanning matters for long-lived images. A production image built on Monday may have a new CRITICAL CVE published on Wednesday. Without scheduled scanning or Dependency Track continuous monitoring, this CVE is invisible until the next rebuild.

# .github/workflows/scheduled-scan.yml
name: Scheduled vulnerability scan

on:
  schedule:
    - cron: '0 2 * * *'   # Nightly at 02:00 UTC.

jobs:
  scan-production-images:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        image:
          - ghcr.io/myorg/myapp:latest
          - ghcr.io/myorg/worker:latest
          - ghcr.io/myorg/api:latest
    steps:
      - name: Scan ${{ matrix.image }}
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: ${{ matrix.image }}
          format: sarif
          output: trivy-${{ strategy.job-index }}.sarif
          severity: CRITICAL,HIGH
          exit-code: 1
          ignore-unfixed: true
          trivyignores: .trivyignore

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-${{ strategy.job-index }}.sarif

Telemetry

container_scan_duration_seconds{scanner, image, stage}              histogram
container_scan_cve_count{severity, scanner, image, fixable}         gauge
container_scan_build_blocked_total{reason, severity}                counter
container_scan_false_positive_suppressed_total{cve_id, justification_type} counter
iac_scan_finding_count{severity, check_id, manifest_path}           gauge
dependency_track_policy_violations_total{project, policy_name}      gauge
dependency_track_cve_new_since_build_total{project, severity}       gauge

Alert on:

  • container_scan_cve_count{severity="CRITICAL", fixable="true"} > 0 on a production image — a deployed image has a patchable critical CVE; trigger emergency rebuild.
  • dependency_track_cve_new_since_build_total{severity="CRITICAL"} > 0 — new critical CVE published against a deployed image’s packages; initiate scheduled rebuild.
  • container_scan_false_positive_suppressed_total growing faster than one per week — suppression file is accumulating entries without remediation; review and clean up.
  • iac_scan_finding_count{severity="CRITICAL"} > 0 — a Kubernetes manifest or Terraform configuration has a critical misconfiguration that reached the scan stage; indicates a gap in earlier review.

Expected Behaviour

Signal Without build-time scanning With build-time scanning
CRITICAL CVE in base image Deployed; detected by registry scanner post-push Build fails; image never pushed; developer alerted
High CVE in app dependency with fix Deployed; detected if registry scanner is configured Build fails; fix indicated in scan output
High CVE in app dependency, no fix available Deployed Build passes with ignore-unfixed: true; tracked in Dependency Track
False positive CVE N/A Suppressed via .trivyignore or VEX document with justification
Privileged Kubernetes manifest Deployed IaC scan fails the build
New CVE published against deployed image Undetected until next build Dependency Track alerts within hours

Trade-offs

Aspect Benefit Cost Mitigation
Blocking on CRITICAL/HIGH Prevents known-vulnerable deploys Breaks builds developers cannot fix (no-fix CVEs) ignore-unfixed: true for HIGH; track in Dependency Track
Distroless base images Eliminates 90%+ of base OS CVEs No shell for debugging; harder kubectl exec Use kubectl debug with ephemeral containers for investigation
VEX suppression Machine-readable, auditable justification Additional document to maintain Attach VEX to image attestation; review with SBOM quarterly
Dependency Track integration Continuous monitoring between builds Infrastructure to host and maintain Deploy on Kubernetes; resource requirements are modest
Scheduled scans Catches CVEs between builds Extra pipeline runs; noise if threshold is not tuned Apply same ignore-unfixed and .trivyignore policy as build scans

Failure Modes

Failure Symptom Detection Recovery
.trivyignore entry without tracking issue Suppressed CVE with no record of justification trivyignore entries growing; no linked issues Require PR approval for all .trivyignore changes; add issue URL as comment
Scanner rate-limited on vulnerability DB pull Scan passes with stale DB; new CVEs missed trivy image --download-db-only fails in CI Cache Trivy DB as a CI artifact; refresh daily
Grype/Syft version mismatch between SBOM generation and scan False negatives; packages not matched SBOM has packages Grype does not match Pin Syft and Grype to the same major version; update together
IaC scan check too noisy Developers disable --exit-code 1 Scan step always passes despite findings Start with HIGH/CRITICAL only; tune down to MEDIUM after baseline is clean
Dependency Track API key rotation SBOM upload fails; no new findings after rotation Upload step returns 401 Rotate key in CI secrets and DT simultaneously; alert on upload failures
Base image scan separate from app scan confusion Team doesn’t know which layer owns a CVE CVEs attributed to wrong team Tag findings by layer in scan output; add --layer flag to Trivy