CVE comparison report #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ============================================================================= | |
| # 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 |