Merge pull request 'feat: add dual-side diagnostic overview and fail-safe gating' (#10) from feat-diagnostic-overview-and-gating into main

Reviewed-on: #10
Reviewed-by: LLMReview <27+autoreview@noreplay.horstenkamp.eu>
This commit was merged in pull request #10.
This commit is contained in:
Philipp committed 2026-09-13 20:52:55 +02:00
commit abe45ffb02
3 files changed
+312 -82

No files matched your search

+26 -1
View File
@@ -27,6 +27,7 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
| `output-delta` | Output path for the CVE delta Markdown report | No | `cve-delta.md` |
| `output-summary` | Output path for the full vulnerability Markdown summary | No | `cve-summary.md` |
| `write-summary` | Whether to append the generated reports to the job summary (`$GITHUB_STEP_SUMMARY`) | No | `true` |
| `use-inbuild-sbom` | Whether to inspect and use built-in image SBOM attestations. If false, generates SBOMs directly with Trivy | No | `false` |
---
@@ -39,6 +40,31 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
---
## 🔍 Built-in SBOM Attestations vs. Trivy Direct Scanning (Syft vs. Trivy)
This action supports two modes for obtaining SBOMs:
### 1. Direct Image Scanning with Trivy (`use-inbuild-sbom: false` — Default & Recommended)
By default, the action uses `trivy image --format spdx-json` to generate the SBOM directly from the container images:
- **Full Filesystem Fidelity**: Inspects the entire flattened root filesystem, reading `/etc/os-release` and native package databases (e.g. `/var/lib/dpkg/status`, RPM databases, apk).
- **Accurate OS & Language Package Detection**: Successfully catalogs both OS packages (Debian, Alpine, RHEL, etc.) and application dependencies (Python, Node.js, Go, etc.).
- **Symmetric, Apples-to-Apples Comparisons**: Both `head` and `target` images are analyzed using the identical Trivy engine and vulnerability database, preventing skewed diffs.
### 2. Built-in Attestation Extraction (`use-inbuild-sbom: true`)
When enabled, the action extracts SBOM attestations attached to the container image using `docker buildx imagetools inspect`:
- **Speed**: Extracts the SBOM JSON metadata directly from the registry manifest index in seconds without pulling image layer blobs.
- **Underlying Generator (Syft)**: Docker Buildx (`sbom: true`) uses BuildKit's embedded Syft scanner (`moby/buildkit-syft-scanner`).
- **Known Limitations with Trivy**:
- **Testing/Rolling Distributions**: Embedded Syft versions may not recognize newer or unreleased distributions (such as Debian 13 "Trixie"), recording the OS as `family="none"` and skipping OS package vulnerability matching.
- **Multi-Stage Builds**: Analyzing layer diffs rather than the flattened container rootfs can cause package database paths to be omitted or detached from the OS manifest.
- **Schema/PURL Discrepancies**: Syft and Trivy express certain package qualifiers and namespaces differently in SPDX. When Trivy scans a Syft SPDX file that lacks explicit distro qualifiers, Trivy will silently skip OS vulnerability checks.
- **Differential Skew**: If a `head` image uses an incomplete Syft SBOM (detecting only language packages) while `target` is scanned directly with Trivy (detecting 400+ OS packages), the differential report will erroneously show hundreds of "resolved" CVEs.
> [!TIP]
> If your builds run on stable base images where BuildKit's Syft scanner accurately captures all OS packages, you can set `use-inbuild-sbom: 'true'` to speed up CI runs. Otherwise, leave it as `false` to ensure consistent and comprehensive scans.
---
## 🚀 Usage Example
```yaml
@@ -52,7 +78,6 @@ jobs:
with:
context: .
push: true
sbom: true
tags: registry.example.com/my-app:git-${{ github.sha }}
- name: Compare Vulnerabilities (Head vs Target)
+231 -58
View File
@@ -24,6 +24,10 @@ inputs:
description: Whether to append the generated reports to the job summary ($GITHUB_STEP_SUMMARY)
required: false
default: 'true'
use-inbuild-sbom:
description: Whether to inspect and use built-in image SBOM attestations. If false, generates SBOM directly from images with Trivy.
required: false
default: 'false'
outputs:
delta-report:
@@ -58,14 +62,15 @@ runs:
- name: Install and Verify Trivy
shell: bash
run: |
mkdir -p "$HOME/.cache/trivy-bin" "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
set -euo pipefail
ARCH=$(uname -m)
case "$ARCH" in
mkdir -p "$HOME/.local/bin" "$HOME/.cache/trivy-bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
ARCH="$(uname -m)"
case "${ARCH}" in
x86_64) T_ARCH="64bit" ;;
aarch64|arm64) T_ARCH="ARM64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
*) echo "::error::Unsupported architecture: ${ARCH}"; exit 1 ;;
esac
checksums_file="${{ github.action_path }}/checksums.txt"
@@ -86,60 +91,121 @@ runs:
shell: bash
run: |
export PATH="$HOME/.local/bin:$PATH"
export TRIVY_SKIP_VERSION_CHECK="true"
mkdir -p sboms/head sboms/target
cleanup_file() {
local f="$1"
if [ ! -s "$f" ] || [ "$(cat "$f" 2>/dev/null)" = "null" ]; then
rm -f "$f"
if [ -f "$f" ]; then
if [ ! -s "$f" ] || [ "$(cat "$f" 2>/dev/null)" = "null" ]; then
rm -f "$f"
fi
fi
}
# Extract platform SBOMs for head image
echo "Extracting platform SBOMs for head image: ${{ inputs.head-image }}..."
docker buildx imagetools inspect "${{ inputs.head-image }}" \
--format '{{ json (index .SBOM "linux/amd64").SPDX }}' > sboms/head/sbom-amd64.spdx.json 2>/dev/null || true
cleanup_file sboms/head/sbom-amd64.spdx.json
head_sbom_err=""
target_sbom_err=""
use_inbuild="${{ inputs.use-inbuild-sbom }}"
docker buildx imagetools inspect "${{ inputs.head-image }}" \
--format '{{ json (index .SBOM "linux/arm64").SPDX }}' > sboms/head/sbom-arm64.spdx.json 2>/dev/null || true
cleanup_file sboms/head/sbom-arm64.spdx.json
# If single-arch, extract root .SPDX
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then
# --- Head Image SBOM Extraction ---
if [ "$use_inbuild" = "true" ]; then
echo "Attempting extraction of built-in SBOM attestations for head image: ${{ inputs.head-image }}..."
docker buildx imagetools inspect "${{ inputs.head-image }}" \
--format '{{ json .SBOM.SPDX }}' > sboms/head/sbom-amd64.spdx.json 2>/dev/null || true
--format '{{ json (index .SBOM "linux/amd64").SPDX }}' > sboms/head/sbom-amd64.spdx.json 2>/dev/null || true
cleanup_file sboms/head/sbom-amd64.spdx.json
docker buildx imagetools inspect "${{ inputs.head-image }}" \
--format '{{ json (index .SBOM "linux/arm64").SPDX }}' > sboms/head/sbom-arm64.spdx.json 2>/dev/null || true
cleanup_file sboms/head/sbom-arm64.spdx.json
# If single-arch, extract root .SPDX
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then
docker buildx imagetools inspect "${{ inputs.head-image }}" \
--format '{{ json .SBOM.SPDX }}' > sboms/head/sbom-amd64.spdx.json 2>/dev/null || true
cleanup_file sboms/head/sbom-amd64.spdx.json
fi
fi
# Fallback to generating head SBOM with Trivy if attestation missing
# Generate head SBOM with Trivy if attestation not used or missing
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then
echo "Head image lacks SBOM attestation; generating with Trivy..."
trivy image --format spdx-json --output sboms/head/sbom-amd64.spdx.json "${{ inputs.head-image }}" || true
echo "Generating SBOM for head image directly with Trivy: ${{ inputs.head-image }}..."
if ! trivy image --skip-version-check --format spdx-json --output sboms/head/sbom-amd64.spdx.json "${{ inputs.head-image }}"; then
head_sbom_err="Failed to generate SBOM with Trivy for head image"
fi
cleanup_file sboms/head/sbom-amd64.spdx.json
fi
# Extract platform SBOMs for target image
echo "Extracting platform SBOMs for target image: ${{ inputs.target-image }}..."
docker buildx imagetools inspect "${{ inputs.target-image }}" \
--format '{{ json (index .SBOM "linux/amd64").SPDX }}' > sboms/target/sbom-amd64.spdx.json 2>/dev/null || true
cleanup_file sboms/target/sbom-amd64.spdx.json
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ] && [ -z "$head_sbom_err" ]; then
head_sbom_err="No SBOM generated and head image could not be inspected"
fi
docker buildx imagetools inspect "${{ inputs.target-image }}" \
--format '{{ json (index .SBOM "linux/arm64").SPDX }}' > sboms/target/sbom-arm64.spdx.json 2>/dev/null || true
cleanup_file sboms/target/sbom-arm64.spdx.json
if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ]; then
# --- Target Image SBOM Extraction ---
if [ "$use_inbuild" = "true" ]; then
echo "Attempting extraction of built-in SBOM attestations for target image: ${{ inputs.target-image }}..."
docker buildx imagetools inspect "${{ inputs.target-image }}" \
--format '{{ json .SBOM.SPDX }}' > sboms/target/sbom-amd64.spdx.json 2>/dev/null || true
--format '{{ json (index .SBOM "linux/amd64").SPDX }}' > sboms/target/sbom-amd64.spdx.json 2>/dev/null || true
cleanup_file sboms/target/sbom-amd64.spdx.json
docker buildx imagetools inspect "${{ inputs.target-image }}" \
--format '{{ json (index .SBOM "linux/arm64").SPDX }}' > sboms/target/sbom-arm64.spdx.json 2>/dev/null || true
cleanup_file sboms/target/sbom-arm64.spdx.json
if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ]; then
docker buildx imagetools inspect "${{ inputs.target-image }}" \
--format '{{ json .SBOM.SPDX }}' > sboms/target/sbom-amd64.spdx.json 2>/dev/null || true
cleanup_file sboms/target/sbom-amd64.spdx.json
fi
fi
# Generate target SBOM with Trivy if attestation not used or missing
if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ]; then
echo "Generating SBOM for target image directly with Trivy: ${{ inputs.target-image }}..."
if ! trivy image --skip-version-check --format spdx-json --output sboms/target/sbom-amd64.spdx.json "${{ inputs.target-image }}"; then
target_sbom_err="Failed to generate SBOM with Trivy for target image"
fi
cleanup_file sboms/target/sbom-amd64.spdx.json
fi
# Fallback to generating target SBOM with Trivy if attestation missing
if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ]; then
echo "Target image lacks SBOM attestation; attempting to generate with Trivy..."
trivy image --format spdx-json --output sboms/target/sbom-amd64.spdx.json "${{ inputs.target-image }}" || true
cleanup_file sboms/target/sbom-amd64.spdx.json
if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ] && [ -z "$target_sbom_err" ]; then
target_sbom_err="No SBOM generated and target baseline image could not be inspected"
fi
# Report on both sides
echo ""
echo "================================================================================"
echo " SBOM EXTRACTION DIAGNOSTIC OVERVIEW "
echo "================================================================================"
if [ -n "$head_sbom_err" ]; then
echo "HEAD Image (${{ inputs.head-image }}): ❌ FAILED - $head_sbom_err"
else
echo "HEAD Image (${{ inputs.head-image }}): ✅ SUCCESS"
[ -f sboms/head/sbom-amd64.spdx.json ] && echo " - sbom-amd64.spdx.json ($(wc -c < sboms/head/sbom-amd64.spdx.json) bytes)"
[ -f sboms/head/sbom-arm64.spdx.json ] && echo " - sbom-arm64.spdx.json ($(wc -c < sboms/head/sbom-arm64.spdx.json) bytes)"
fi
if [ -n "$target_sbom_err" ]; then
echo "TARGET Baseline Image (${{ inputs.target-image }}): ❌ FAILED - $target_sbom_err"
else
echo "TARGET Baseline Image (${{ inputs.target-image }}): ✅ SUCCESS"
[ -f sboms/target/sbom-amd64.spdx.json ] && echo " - sbom-amd64.spdx.json ($(wc -c < sboms/target/sbom-amd64.spdx.json) bytes)"
[ -f sboms/target/sbom-arm64.spdx.json ] && echo " - sbom-arm64.spdx.json ($(wc -c < sboms/target/sbom-arm64.spdx.json) bytes)"
fi
echo "================================================================================"
echo ""
has_sbom_err=false
if [ -n "$head_sbom_err" ]; then
echo "::error::Head SBOM extraction failed: $head_sbom_err"
has_sbom_err=true
fi
if [ -n "$target_sbom_err" ]; then
echo "::error::Target baseline SBOM extraction failed: $target_sbom_err"
has_sbom_err=true
fi
if [ "$has_sbom_err" = "true" ]; then
echo "::error::Both head and target images must have valid SBOMs to proceed with differential scanning."
exit 1
fi
# Expose extracted head SBOMs to workspace root for workflow artifact uploads
@@ -150,31 +216,134 @@ runs:
shell: bash
run: |
export PATH="$HOME/.local/bin:$PATH"
export TRIVY_SKIP_VERSION_CHECK="true"
mkdir -p cve-reports/head cve-reports/target
# Scan each available head SBOM
for f in sboms/head/sbom-*.spdx.json; do
[ -f "$f" ] || continue
arch=$(basename "$f" .spdx.json | sed 's/sbom-//')
echo "Scanning head SBOM for architecture: $arch"
trivy sbom --format json --output "cve-reports/head/cve-${arch}.json" "$f" || true
done
shopt -s nullglob
head_sboms=(sboms/head/sbom-*.spdx.json)
target_sboms=(sboms/target/sbom-*.spdx.json)
# Scan each available target SBOM
for f in sboms/target/sbom-*.spdx.json; do
[ -f "$f" ] || continue
arch=$(basename "$f" .spdx.json | sed 's/sbom-//')
echo "Scanning target SBOM for architecture: $arch"
trivy sbom --format json --output "cve-reports/target/cve-${arch}.json" "$f" || true
done
head_ok=true
target_ok=true
head_report_count=0
target_report_count=0
# Run multi-arch set-based diff
node "${{ github.action_path }}/cve-diff.js" \
head_summary=()
target_summary=()
# 1. Scan Head SBOMs
if [ ${#head_sboms[@]} -eq 0 ]; then
head_ok=false
head_summary+=(" - ❌ No SBOM files found in sboms/head/")
else
for f in "${head_sboms[@]}"; do
arch=$(basename "$f" .spdx.json | sed 's/sbom-//')
out_json="cve-reports/head/cve-${arch}.json"
err_log="cve-reports/head/cve-${arch}.err"
echo "Scanning head SBOM for architecture: $arch"
if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then
if [ -s "$out_json" ]; then
vuln_cnt=$(grep -o '"VulnerabilityID"' "$out_json" 2>/dev/null | wc -l || echo 0)
if [ "$vuln_cnt" -eq 0 ]; then
echo "::warning::Head image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized."
head_summary+=(" - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)")
else
head_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)")
fi
head_report_count=$((head_report_count + 1))
else
head_ok=false
head_summary+=(" - ${arch}: ❌ FAILED (Output report was empty / 0 bytes)")
fi
else
head_ok=false
err_msg=$(tail -n 2 "$err_log" | tr '\n' ' ' | sed 's/ */ /g')
[ -z "$err_msg" ] && err_msg="Trivy scan exited with non-zero status"
head_summary+=(" - ${arch}: ❌ FAILED (${err_msg})")
fi
done
fi
# 2. Scan Target SBOMs
if [ ${#target_sboms[@]} -eq 0 ]; then
target_ok=false
target_summary+=(" - ❌ No SBOM files found in sboms/target/")
else
for f in "${target_sboms[@]}"; do
arch=$(basename "$f" .spdx.json | sed 's/sbom-//')
out_json="cve-reports/target/cve-${arch}.json"
err_log="cve-reports/target/cve-${arch}.err"
echo "Scanning target SBOM for architecture: $arch"
if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then
if [ -s "$out_json" ]; then
vuln_cnt=$(grep -o '"VulnerabilityID"' "$out_json" 2>/dev/null | wc -l || echo 0)
if [ "$vuln_cnt" -eq 0 ]; then
echo "::warning::Target baseline image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized."
target_summary+=(" - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)")
else
target_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)")
fi
target_report_count=$((target_report_count + 1))
else
target_ok=false
target_summary+=(" - ${arch}: ❌ FAILED (Output report was empty / 0 bytes)")
fi
else
target_ok=false
err_msg=$(tail -n 2 "$err_log" | tr '\n' ' ' | sed 's/ */ /g')
[ -z "$err_msg" ] && err_msg="Trivy scan exited with non-zero status"
target_summary+=(" - ${arch}: ❌ FAILED (${err_msg})")
fi
done
fi
# 3. Print Comprehensive Diagnostic Overview
echo ""
echo "================================================================================"
echo " CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW "
echo "================================================================================"
echo "HEAD Image: ${{ inputs.head-image }}"
for line in "${head_summary[@]}"; do
echo "$line"
done
echo ""
echo "TARGET Baseline Image: ${{ inputs.target-image }} (branch: '${{ inputs.target-branch }}')"
for line in "${target_summary[@]}"; do
echo "$line"
done
echo "================================================================================"
echo ""
# 4. Enforce Gating: Both must have succeeded with valid reports
has_error=false
if [ "$head_ok" != "true" ] || [ "$head_report_count" -eq 0 ]; then
echo "::error::Head image CVE analysis failed or produced no reports."
has_error=true
fi
if [ "$target_ok" != "true" ] || [ "$target_report_count" -eq 0 ]; then
echo "::error::Target baseline CVE analysis failed or produced no reports."
has_error=true
fi
if [ "$has_error" = "true" ]; then
echo "::error::Aborting differential analysis: both head and target scans must succeed before diffing."
exit 1
fi
# 5. Run multi-arch set-based diff (both are guaranteed present and valid)
echo "Both head and target scans succeeded. Computing set-based vulnerability delta..."
if ! node "${{ github.action_path }}/cve-diff.js" \
"cve-reports/target" \
"cve-reports/head" \
"${{ inputs.target-branch }}" > "${{ inputs.output-delta }}" || true
"${{ inputs.target-branch }}" > "${{ inputs.output-delta }}"; then
echo "::error::Failed to generate CVE differential report"
exit 1
fi
# Generate full markdown summary report for primary platform
# 6. Generate full markdown summary report for primary platform
primary_sbom=""
if [ -f sboms/head/sbom-amd64.spdx.json ]; then
primary_sbom="sboms/head/sbom-amd64.spdx.json"
@@ -183,11 +352,15 @@ runs:
fi
if [ -n "$primary_sbom" ]; then
trivy sbom \
if ! trivy sbom \
--skip-version-check \
--format template \
--template "@${{ github.action_path }}/markdown.tpl" \
--output "${{ inputs.output-summary }}" \
"$primary_sbom" || true
"$primary_sbom"; then
echo "::error::Trivy summary generation failed for primary SBOM: $primary_sbom"
exit 1
fi
fi
- name: Publish Reports to Step Summary
+55 -23
View File
@@ -41,14 +41,24 @@ function loadReportsForArg(arg) {
}
}
if (filePaths.length === 0) {
console.error(`::error::No CVE JSON report files found for path: ${arg}`);
process.exit(1);
}
let loadedFiles = 0;
for (const filePath of filePaths) {
let stat;
try {
stat = fs.statSync(filePath);
} catch {
continue;
} catch (e) {
console.error(`::error::Failed to access report file ${filePath}: ${e.message}`);
process.exit(1);
}
if (stat.size === 0) {
console.error(`::error::CVE report file is empty (0 bytes): ${filePath}`);
process.exit(1);
}
if (stat.size === 0) continue;
const baseName = path.basename(filePath).toLowerCase();
let arch = 'all';
@@ -62,14 +72,18 @@ function loadReportsForArg(arg) {
try {
const content = fs.readFileSync(filePath, 'utf8');
data = JSON.parse(content);
} catch {
continue;
} catch (e) {
console.error(`::error::Invalid JSON in CVE report file ${filePath}: ${e.message}`);
process.exit(1);
}
if (!data || typeof data !== 'object' || !Array.isArray(data.Results)) {
continue;
console.error(`::error::Malformed Trivy report structure (missing Results array) in: ${filePath}`);
process.exit(1);
}
loadedFiles++;
for (const result of data.Results) {
const target = result.Target || result.Type || 'App';
const vulns = Array.isArray(result.Vulnerabilities) ? result.Vulnerabilities : [];
@@ -88,6 +102,11 @@ function loadReportsForArg(arg) {
}
}
if (loadedFiles === 0) {
console.error(`::error::No valid CVE reports could be loaded for path: ${arg}`);
process.exit(1);
}
return cves;
}
@@ -130,19 +149,17 @@ function formatVulnRow(v) {
* @returns {string}
*/
function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
if (baseCves.size === 0) {
return [
`### 🔄 Vulnerability Delta (vs \`${baseName}\`)`,
'',
'> [!NOTE]',
`> No baseline scan from \`${baseName}\` was available to compare against.`,
'',
].join('\n');
}
const baseKeys = new Set(baseCves.keys());
const branchKeys = new Set(branchCves.keys());
if (branchCves.size === 0) {
console.error('::warning::Head image contains 0 reported vulnerabilities. Verify that packages and dependencies were recognized.');
}
if (baseCves.size === 0) {
console.error(`::warning::Baseline target (${baseName}) contains 0 reported vulnerabilities.`);
}
const newKeys = [];
let unchangedCount = 0;
for (const key of branchKeys) {
@@ -177,15 +194,30 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
const lines = [
`### 🔄 Vulnerability Delta (vs \`${baseName}\`)`,
'',
'| Metric | Count |',
'| :--- | :--- |',
`| 🚨 **New Vulnerabilities** | ${newVulns.length > 0 ? `🚨 **${newVulns.length}**` : '0'} |`,
`| ✅ **Resolved Vulnerabilities** | ${resolvedVulns.length > 0 ? `✅ **${resolvedVulns.length}**` : '0'} |`,
`| ⚪ **Unchanged Vulnerabilities** | ${unchangedCount} |`,
'',
`#### 🚨 New Vulnerabilities Introduced (${newVulns.length})`,
];
if (branchCves.size === 0 && baseCves.size === 0) {
lines.push('> [!WARNING]');
lines.push('> Both the head image and baseline target have **0 reported vulnerabilities**.');
lines.push('');
} else if (branchCves.size === 0) {
lines.push('> [!WARNING]');
lines.push('> The head image has **0 reported vulnerabilities**. Verify that OS packages and language dependencies were properly indexed in the SBOM.');
lines.push('');
} else if (baseCves.size === 0) {
lines.push('> [!WARNING]');
lines.push(`> The baseline image (\`${baseName}\`) has **0 reported vulnerabilities**.`);
lines.push('');
}
lines.push('| Metric | Count |');
lines.push('| :--- | :--- |');
lines.push(`| 🚨 **New Vulnerabilities** | ${newVulns.length > 0 ? `🚨 **${newVulns.length}**` : '0'} |`);
lines.push(`| ✅ **Resolved Vulnerabilities** | ${resolvedVulns.length > 0 ? `✅ **${resolvedVulns.length}**` : '0'} |`);
lines.push(`| ⚪ **Unchanged Vulnerabilities** | ${unchangedCount} |`);
lines.push('');
lines.push(`#### 🚨 New Vulnerabilities Introduced (${newVulns.length})`);
if (newVulns.length > 0) {
lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |');
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');