Skip to content

CVE comparison report #3

CVE comparison report

CVE comparison report #3

# =============================================================================
# CVE Before/After Comparison Report
# =============================================================================
#
# Manual-only workflow that scans all images, rebuilds them locally, and
# produces a markdown report showing CVE changes (before vs after rebuild).
# Nothing is pushed — this is purely informational.
#
# =============================================================================
name: CVE comparison report
on:
workflow_dispatch:
inputs:
filter:
description: 'Image filter (regex, e.g. "redis|mariadb"). Leave empty for all.'
type: string
default: ''
severity:
description: 'Minimum severity to report'
type: choice
options:
- low
- medium
- high
- critical
default: medium
permissions:
contents: read
concurrency:
group: cve-comparison
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------------------
# Discover all images from TAGS files, optionally filtered
# ---------------------------------------------------------------------------
discover:
name: Discover images
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.discover.outputs.matrix }}
steps:
- uses: actions/checkout@v5
- name: Build image matrix
id: discover
env:
FILTER: ${{ inputs.filter }}
run: |
matrix="[]"
for tags_file in */*/TAGS; do
[ -f "$tags_file" ] || continue
image_name=$(echo "$tags_file" | cut -d'/' -f1)
version_dir=$(echo "$tags_file" | cut -d'/' -f2)
primary_tag=$(head -1 "$tags_file" | tr -d '[:space:]')
[ -z "$primary_tag" ] && continue
# Apply filter if set
if [ -n "$FILTER" ]; then
echo "${image_name}/${version_dir}" | grep -qE "$FILTER" || continue
fi
# Check Dockerfile exists
[ -f "${image_name}/${version_dir}/Dockerfile" ] || continue
matrix=$(echo "$matrix" | jq \
--arg img "$image_name" \
--arg dir "$version_dir" \
--arg tag "$primary_tag" \
'. + [{"image": $img, "dir": $dir, "tag": $tag}]')
done
echo "matrix=$(echo "$matrix" | jq -c '.')" >> "$GITHUB_OUTPUT"
count=$(echo "$matrix" | jq 'length')
echo "Discovered $count images"
echo "## Images to scan: $count" >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# Scan current + rebuild + scan new for each image
# ---------------------------------------------------------------------------
compare:
name: "${{ matrix.entry.image }}:${{ matrix.entry.tag }}"
needs: discover
if: ${{ fromJson(needs.discover.outputs.matrix)[0] != null }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 4
matrix:
entry: ${{ fromJson(needs.discover.outputs.matrix) }}
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
steps:
- uses: actions/checkout@v5
- name: Install Docker Scout
run: |
curl -fsSL https://raw.githubusercontent.com/docker/scout-cli/main/install.sh -o install-scout.sh
sh install-scout.sh
- name: Docker login
run: |
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USER" --password-stdin
- name: Login to DHI registry
run: |
if [ -n "$DOCKER_PASSWORD" ]; then
echo "$DOCKER_PASSWORD" | docker login dhi.io -u "$DOCKER_USER" --password-stdin 2>/dev/null || true
fi
- name: Scan current published image
id: before
env:
IMAGE: "wunderio/${{ matrix.entry.image }}:${{ matrix.entry.tag }}"
run: |
echo "Scanning $IMAGE ..."
mkdir -p /tmp/results
if docker scout cves "$IMAGE" --only-fixed 2>&1 | tee /tmp/results/before.txt; then
echo "scan_ok=true" >> "$GITHUB_OUTPUT"
else
if grep -q "MANIFEST_UNKNOWN\|not found" /tmp/results/before.txt; then
echo "scan_ok=false" >> "$GITHUB_OUTPUT"
else
echo "scan_ok=true" >> "$GITHUB_OUTPUT"
fi
fi
- name: Rebuild image locally
id: build
env:
IMAGE_NAME: ${{ matrix.entry.image }}
VERSION_DIR: ${{ matrix.entry.dir }}
TAG: ${{ matrix.entry.tag }}
run: |
build_dir="${IMAGE_NAME}/${VERSION_DIR}"
local_tag="local/${IMAGE_NAME}:${TAG}-rebuilt"
echo "Building ${build_dir} ..."
if docker build "$build_dir" --tag "$local_tag" --pull --no-cache --quiet; then
echo "build_ok=true" >> "$GITHUB_OUTPUT"
echo "local_tag=$local_tag" >> "$GITHUB_OUTPUT"
else
echo "::warning::Build failed for ${IMAGE_NAME}:${TAG}"
echo "build_ok=false" >> "$GITHUB_OUTPUT"
fi
- name: Scan rebuilt image
if: steps.build.outputs.build_ok == 'true'
env:
LOCAL_TAG: ${{ steps.build.outputs.local_tag }}
run: |
echo "Scanning rebuilt image ..."
docker scout cves "local://$LOCAL_TAG" --only-fixed 2>&1 | tee /tmp/results/after.txt || true
- name: Generate comparison data
env:
IMAGE_NAME: ${{ matrix.entry.image }}
TAG: ${{ matrix.entry.tag }}
BUILD_OK: ${{ steps.build.outputs.build_ok }}
SCAN_OK: ${{ steps.before.outputs.scan_ok }}
run: |
parse_counts() {
local file="$1"
if [ ! -f "$file" ]; then echo "0,0,0,0,0"; return; fi
critical=$(grep -oP '\d+(?=C)' "$file" 2>/dev/null | head -1 || echo "0")
high=$(grep -oP '\d+(?=H)' "$file" 2>/dev/null | head -1 || echo "0")
medium=$(grep -oP '\d+(?=M)' "$file" 2>/dev/null | head -1 || echo "0")
low=$(grep -oP '\d+(?=L)' "$file" 2>/dev/null | head -1 || echo "0")
[ -z "$critical" ] && critical=0
[ -z "$high" ] && high=0
[ -z "$medium" ] && medium=0
[ -z "$low" ] && low=0
total=$((critical + high + medium + low))
echo "${critical},${high},${medium},${low},${total}"
}
before_counts=$(parse_counts /tmp/results/before.txt)
if [ "$BUILD_OK" = "true" ]; then
after_counts=$(parse_counts /tmp/results/after.txt)
else
after_counts="N/A,N/A,N/A,N/A,N/A"
fi
mkdir -p /tmp/comparison
echo "${IMAGE_NAME},${TAG},${SCAN_OK},${BUILD_OK},${before_counts},${after_counts}" \
> "/tmp/comparison/${IMAGE_NAME}__${TAG}.csv"
- name: Upload comparison data
uses: actions/upload-artifact@v4
with:
name: "compare-${{ matrix.entry.image }}-${{ matrix.entry.dir }}"
path: /tmp/comparison/
retention-days: 30
# ---------------------------------------------------------------------------
# Assemble final markdown report
# ---------------------------------------------------------------------------
report:
name: Generate report
needs: [discover, compare]
if: always() && needs.discover.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Download all comparison data
uses: actions/download-artifact@v4
with:
path: /tmp/all-comparisons
pattern: compare-*
merge-multiple: true
- name: Build markdown report
env:
SEVERITY_FILTER: ${{ inputs.severity }}
IMAGE_FILTER: ${{ inputs.filter }}
run: |
report="/tmp/cve-comparison-report.md"
date=$(date -u +%Y-%m-%d)
cat > "$report" <<EOF
# CVE Before/After Comparison Report
**Date:** ${date}
**Severity filter:** ${SEVERITY_FILTER:-medium}+
**Image filter:** ${IMAGE_FILTER:-all}
## Results
| Image | Tag | Before (C/H/M/L) | After (C/H/M/L) | Before Total | After Total | Delta | Status |
|-------|-----|-------------------|------------------|:------------:|:-----------:|:-----:|:------:|
EOF
improved=0
unchanged=0
degraded=0
build_failed=0
not_published=0
for csv_file in /tmp/all-comparisons/*.csv; do
[ -f "$csv_file" ] || continue
IFS=',' read -r image tag scan_ok build_ok \
bc bh bm bl btotal \
ac ah am al atotal < "$csv_file"
before_sev="${bc}/${bh}/${bm}/${bl}"
if [ "$scan_ok" = "false" ]; then
echo "| \`${image}\` | \`${tag}\` | _not published_ | — | — | — | — | :new: |" >> "$report"
not_published=$((not_published + 1))
continue
fi
if [ "$build_ok" != "true" ]; then
echo "| \`${image}\` | \`${tag}\` | ${before_sev} | _build failed_ | ${btotal} | — | — | :x: |" >> "$report"
build_failed=$((build_failed + 1))
continue
fi
after_sev="${ac}/${ah}/${am}/${al}"
delta=$((atotal - btotal))
if [ "$delta" -lt 0 ]; then
abs_delta=${delta#-}
status=":white_check_mark: -${abs_delta}"
improved=$((improved + 1))
elif [ "$delta" -gt 0 ]; then
status=":warning: +${delta}"
degraded=$((degraded + 1))
else
status=":heavy_minus_sign: 0"
unchanged=$((unchanged + 1))
fi
echo "| \`${image}\` | \`${tag}\` | ${before_sev} | ${after_sev} | ${btotal} | ${atotal} | ${delta} | ${status} |" >> "$report"
done
total=$((improved + unchanged + degraded + build_failed + not_published))
cat >> "$report" <<EOF
## Summary
| Category | Count |
|----------|:-----:|
| Improved (fewer CVEs after rebuild) | ${improved} |
| Unchanged | ${unchanged} |
| Degraded (more CVEs — investigate) | ${degraded} |
| Build failed | ${build_failed} |
| Not yet published | ${not_published} |
| **Total images scanned** | **${total}** |
---
*Generated by cve-comparison.yml workflow*
EOF
# Show in workflow summary and console
cat "$report"
cat "$report" >> "$GITHUB_STEP_SUMMARY"
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: cve-comparison-report
path: /tmp/cve-comparison-report.md
retention-days: 90