Public Access
generated from Philipp/template-git
feat: dynamic multi-architecture discovery, iteration, and conditional reporting #11
No files matched your search
@@ -6,9 +6,9 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Multi-Platform Support**: Automatically detects and extracts SPDX SBOM attestations for multi arch images.
|
- **Dynamic Multi-Platform Support**: Automatically discovers and scans all architectures present in container images, or iterates over an explicit list of target architectures.
|
||||||
- **Fast Attestation Extraction**: Uses `docker buildx imagetools inspect` to extract SBOMs directly from registry manifest indices in seconds without downloading layer blobs.
|
- **Fast Attestation Extraction**: Uses `docker buildx imagetools inspect` to extract platform-specific SBOMs directly from registry manifest indices in seconds without downloading layer blobs.
|
||||||
- **Graceful Fallbacks**: If SBOM attestations or baseline images are missing, automatically falls back to generating SBOMs directly with Trivy. Using images that do contain an SBOM is highly recomended though!
|
- **Graceful Fallbacks**: If SBOM attestations are missing for any platform, automatically falls back to generating SBOMs directly with Trivy per architecture.
|
||||||
- **Set-Based Differential Analysis**: Categorizes vulnerabilities into:
|
- **Set-Based Differential Analysis**: Categorizes vulnerabilities into:
|
||||||
- 🔴 **Introduced Vulnerabilities** (newly added in this branch / commit)
|
- 🔴 **Introduced Vulnerabilities** (newly added in this branch / commit)
|
||||||
- 🟢 **Fixed Vulnerabilities** (present in target baseline, resolved in this branch)
|
- 🟢 **Fixed Vulnerabilities** (present in target baseline, resolved in this branch)
|
||||||
@@ -20,10 +20,11 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
|
|||||||
## 📥 Inputs
|
## 📥 Inputs
|
||||||
|
|
||||||
| Input | Description | Required | Default |
|
| Input | Description | Required | Default |
|
||||||
| :--- | :--- | :---: | :--- |
|
| :--- | :--- | :--- | :--- |
|
||||||
| `head-image` | Head container image reference to scan (e.g. `registry.example.com/repo:git-sha`) | **Yes** | — |
|
| `head-image` | Head container image reference to scan (e.g. `registry.example.com/repo:git-sha`) | **Yes** | — |
|
||||||
| `target-image` | Target baseline container image reference to compare against (e.g. `registry.example.com/repo:latest`) | **Yes** | — |
|
| `target-image` | Target baseline container image reference to compare against (e.g. `registry.example.com/repo:latest`) | **Yes** | — |
|
||||||
| `target-branch` | Target branch name for report headings | No | `main` |
|
| `target-branch` | Target branch name for report headings | No | `main` |
|
||||||
|
| `architectures` | Comma-separated architectures/platforms to scan (e.g. `amd64,arm64`). If omitted, scans all architectures present in the image. | No | _(all)_ |
|
||||||
| `output-delta` | Output path for the CVE delta Markdown report | No | `cve-delta.md` |
|
| `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` |
|
| `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` |
|
| `write-summary` | Whether to append the generated reports to the job summary (`$GITHUB_STEP_SUMMARY`) | No | `true` |
|
||||||
|
|||||||
+133
-78
@@ -12,6 +12,10 @@ inputs:
|
|||||||
description: Target branch name for delta heading (e.g. main)
|
description: Target branch name for delta heading (e.g. main)
|
||||||
required: false
|
required: false
|
||||||
default: main
|
default: main
|
||||||
|
architectures:
|
||||||
|
description: Comma-separated list of architectures or platforms to scan (e.g. 'amd64,arm64' or 'linux/amd64,linux/arm64'). If empty, scans all architectures found in the container image.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
output-delta:
|
output-delta:
|
||||||
description: Output markdown file for CVE delta report
|
description: Output markdown file for CVE delta report
|
||||||
required: false
|
required: false
|
||||||
@@ -107,70 +111,136 @@ runs:
|
|||||||
target_sbom_err=""
|
target_sbom_err=""
|
||||||
use_inbuild="${{ inputs.use-inbuild-sbom }}"
|
use_inbuild="${{ inputs.use-inbuild-sbom }}"
|
||||||
|
|
||||||
# --- Head Image SBOM Extraction ---
|
# Determine target platforms / architectures
|
||||||
|
PLATFORMS=()
|
||||||
|
if [ -n "${{ inputs.architectures }}" ]; then
|
||||||
|
IFS=',' read -ra RAW_ARCHS <<< "${{ inputs.architectures }}"
|
||||||
|
for a in "${RAW_ARCHS[@]}"; do
|
||||||
|
clean_a=$(echo "$a" | xargs)
|
||||||
|
[ -z "$clean_a" ] && continue
|
||||||
|
if [[ "$clean_a" != *"/"* ]]; then
|
||||||
|
PLATFORMS+=("linux/${clean_a}")
|
||||||
|
else
|
||||||
|
PLATFORMS+=("$clean_a")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Auto-discovering architectures from images..."
|
||||||
|
raw_head=$(docker buildx imagetools inspect --raw "${{ inputs.head-image }}" 2>/dev/null || true)
|
||||||
|
raw_target=$(docker buildx imagetools inspect --raw "${{ inputs.target-image }}" 2>/dev/null || true)
|
||||||
|
|
||||||
|
discovered=$(node -e '
|
||||||
|
const raw1 = process.argv[1];
|
||||||
|
const raw2 = process.argv[2];
|
||||||
|
const set = new Set();
|
||||||
|
for (const raw of [raw1, raw2]) {
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
const manifests = data.manifests || [];
|
||||||
|
for (const m of manifests) {
|
||||||
|
if (m.platform && m.platform.architecture && m.platform.architecture !== "unknown") {
|
||||||
|
const os = m.platform.os || "linux";
|
||||||
|
const variant = m.platform.variant ? "/" + m.platform.variant : "";
|
||||||
|
set.add(`${os}/${m.platform.architecture}${variant}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
console.log(Array.from(set).sort().join("\n"));
|
||||||
|
' "$raw_head" "$raw_target")
|
||||||
|
|
||||||
|
if [ -n "$discovered" ]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] && PLATFORMS+=("$line")
|
||||||
|
done <<< "$discovered"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Configured platforms: ${PLATFORMS[*]:-(single platform fallback)}"
|
||||||
|
|
||||||
|
for platform in "${PLATFORMS[@]}"; do
|
||||||
|
arch_tag=$(echo "$platform" | sed -e 's#^[a-zA-Z0-9_-]*/##' -e 's#/#-#g')
|
||||||
|
|
||||||
|
# Head image extraction & fallback
|
||||||
if [ "$use_inbuild" = "true" ]; then
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
echo "Attempting extraction of built-in SBOM attestations for head image: ${{ inputs.head-image }}..."
|
echo "Attempting extraction of built-in SBOM for head image ($platform)..."
|
||||||
docker buildx imagetools inspect "${{ 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
|
--format "{{ json (index .SBOM \"${platform}\").SPDX }}" > "sboms/head/sbom-${arch_tag}.spdx.json" 2>/dev/null || true
|
||||||
cleanup_file sboms/head/sbom-amd64.spdx.json
|
cleanup_file "sboms/head/sbom-${arch_tag}.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
|
fi
|
||||||
|
|
||||||
# Generate head SBOM with Trivy if attestation not used or missing
|
if [ ! -f "sboms/head/sbom-${arch_tag}.spdx.json" ]; then
|
||||||
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then
|
echo "Generating SBOM for head image ($platform) with Trivy..."
|
||||||
echo "Generating SBOM for head image directly with Trivy: ${{ inputs.head-image }}..."
|
if ! trivy image --skip-version-check --platform "$platform" --format spdx-json --output "sboms/head/sbom-${arch_tag}.spdx.json" "${{ inputs.head-image }}"; then
|
||||||
if ! trivy image --skip-version-check --format spdx-json --output sboms/head/sbom-amd64.spdx.json "${{ inputs.head-image }}"; then
|
echo "::warning::Trivy image scan failed for head image ($platform)"
|
||||||
|
fi
|
||||||
|
cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Target image extraction & fallback
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
echo "Attempting extraction of built-in SBOM for target image ($platform)..."
|
||||||
|
docker buildx imagetools inspect "${{ inputs.target-image }}" \
|
||||||
|
--format "{{ json (index .SBOM \"${platform}\").SPDX }}" > "sboms/target/sbom-${arch_tag}.spdx.json" 2>/dev/null || true
|
||||||
|
cleanup_file "sboms/target/sbom-${arch_tag}.spdx.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "sboms/target/sbom-${arch_tag}.spdx.json" ]; then
|
||||||
|
echo "Generating SBOM for target image ($platform) with Trivy..."
|
||||||
|
if ! trivy image --skip-version-check --platform "$platform" --format spdx-json --output "sboms/target/sbom-${arch_tag}.spdx.json" "${{ inputs.target-image }}"; then
|
||||||
|
echo "::warning::Trivy image scan failed for target image ($platform)"
|
||||||
|
fi
|
||||||
|
cleanup_file "sboms/target/sbom-${arch_tag}.spdx.json"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Fallbacks if no platform-specific SBOMs were extracted (e.g. single-arch image without index)
|
||||||
|
if [ -z "$(find sboms/head -type f -name 'sbom-*.spdx.json' 2>/dev/null)" ]; then
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
docker buildx imagetools inspect "${{ inputs.head-image }}" \
|
||||||
|
--format '{{ json .SBOM.SPDX }}' > sboms/head/sbom-default.spdx.json 2>/dev/null || true
|
||||||
|
cleanup_file sboms/head/sbom-default.spdx.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f sboms/head/sbom-default.spdx.json ]; then
|
||||||
|
echo "Generating default head SBOM directly with Trivy..."
|
||||||
|
if ! trivy image --skip-version-check --format spdx-json --output sboms/head/sbom-default.spdx.json "${{ inputs.head-image }}"; then
|
||||||
head_sbom_err="Failed to generate SBOM with Trivy for head image"
|
head_sbom_err="Failed to generate SBOM with Trivy for head image"
|
||||||
fi
|
fi
|
||||||
cleanup_file sboms/head/sbom-amd64.spdx.json
|
cleanup_file sboms/head/sbom-default.spdx.json
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ] && [ -z "$head_sbom_err" ]; then
|
if [ -z "$(find sboms/target -type f -name 'sbom-*.spdx.json' 2>/dev/null)" ]; then
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
docker buildx imagetools inspect "${{ inputs.target-image }}" \
|
||||||
|
--format '{{ json .SBOM.SPDX }}' > sboms/target/sbom-default.spdx.json 2>/dev/null || true
|
||||||
|
cleanup_file sboms/target/sbom-default.spdx.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f sboms/target/sbom-default.spdx.json ]; then
|
||||||
|
echo "Generating default target SBOM directly with Trivy..."
|
||||||
|
if ! trivy image --skip-version-check --format spdx-json --output sboms/target/sbom-default.spdx.json "${{ inputs.target-image }}"; then
|
||||||
|
target_sbom_err="Failed to generate SBOM with Trivy for target image"
|
||||||
|
fi
|
||||||
|
cleanup_file sboms/target/sbom-default.spdx.json
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
head_files=(sboms/head/sbom-*.spdx.json)
|
||||||
|
target_files=(sboms/target/sbom-*.spdx.json)
|
||||||
|
|
||||||
|
if [ ${#head_files[@]} -eq 0 ] && [ -z "$head_sbom_err" ]; then
|
||||||
head_sbom_err="No SBOM generated and head image could not be inspected"
|
head_sbom_err="No SBOM generated and head image could not be inspected"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Target Image SBOM Extraction ---
|
if [ ${#target_files[@]} -eq 0 ] && [ -z "$target_sbom_err" ]; then
|
||||||
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 (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
|
|
||||||
|
|
||||||
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"
|
target_sbom_err="No SBOM generated and target baseline image could not be inspected"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Report on both sides
|
# Diagnostic overview
|
||||||
echo ""
|
echo ""
|
||||||
echo "================================================================================"
|
echo "================================================================================"
|
||||||
echo " SBOM EXTRACTION DIAGNOSTIC OVERVIEW "
|
echo " SBOM EXTRACTION DIAGNOSTIC OVERVIEW "
|
||||||
@@ -179,16 +249,18 @@ runs:
|
|||||||
echo "HEAD Image (${{ inputs.head-image }}): ❌ FAILED - $head_sbom_err"
|
echo "HEAD Image (${{ inputs.head-image }}): ❌ FAILED - $head_sbom_err"
|
||||||
else
|
else
|
||||||
echo "HEAD Image (${{ inputs.head-image }}): ✅ SUCCESS"
|
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)"
|
for f in "${head_files[@]}"; do
|
||||||
[ -f sboms/head/sbom-arm64.spdx.json ] && echo " - sbom-arm64.spdx.json ($(wc -c < sboms/head/sbom-arm64.spdx.json) bytes)"
|
echo " - $(basename "$f") ($(wc -c < "$f") bytes)"
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$target_sbom_err" ]; then
|
if [ -n "$target_sbom_err" ]; then
|
||||||
echo "TARGET Baseline Image (${{ inputs.target-image }}): ❌ FAILED - $target_sbom_err"
|
echo "TARGET Baseline Image (${{ inputs.target-image }}): ❌ FAILED - $target_sbom_err"
|
||||||
else
|
else
|
||||||
echo "TARGET Baseline Image (${{ inputs.target-image }}): ✅ SUCCESS"
|
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)"
|
for f in "${target_files[@]}"; do
|
||||||
[ -f sboms/target/sbom-arm64.spdx.json ] && echo " - sbom-arm64.spdx.json ($(wc -c < sboms/target/sbom-arm64.spdx.json) bytes)"
|
echo " - $(basename "$f") ($(wc -c < "$f") bytes)"
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
echo "================================================================================"
|
echo "================================================================================"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -209,8 +281,10 @@ runs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Expose extracted head SBOMs to workspace root for workflow artifact uploads
|
# Expose extracted head SBOMs to workspace root for workflow artifact uploads
|
||||||
[ -f sboms/head/sbom-amd64.spdx.json ] && cp sboms/head/sbom-amd64.spdx.json sbom-amd64.spdx.json || true
|
for f in sboms/head/sbom-*.spdx.json; do
|
||||||
[ -f sboms/head/sbom-arm64.spdx.json ] && cp sboms/head/sbom-arm64.spdx.json sbom-arm64.spdx.json || true
|
[ -f "$f" ] || continue
|
||||||
|
cp "$f" "$(basename "$f")" || true
|
||||||
|
done
|
||||||
|
|
||||||
- name: Scan Multi-Platform SBOMs and Generate Delta
|
- name: Scan Multi-Platform SBOMs and Generate Delta
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -333,36 +407,17 @@ runs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 5. Run multi-arch set-based diff (both are guaranteed present and valid)
|
# 5. Run multi-arch set-based diff and generate consolidated full summary report
|
||||||
echo "Both head and target scans succeeded. Computing set-based vulnerability delta..."
|
echo "Both head and target scans succeeded. Computing set-based vulnerability delta..."
|
||||||
if ! node "${{ github.action_path }}/cve-diff.js" \
|
if ! node "${{ github.action_path }}/cve-diff.js" \
|
||||||
"cve-reports/target" \
|
"cve-reports/target" \
|
||||||
"cve-reports/head" \
|
"cve-reports/head" \
|
||||||
"${{ inputs.target-branch }}" > "${{ inputs.output-delta }}"; then
|
"${{ inputs.target-branch }}" \
|
||||||
|
"${{ inputs.output-summary }}" > "${{ inputs.output-delta }}"; then
|
||||||
echo "::error::Failed to generate CVE differential report"
|
echo "::error::Failed to generate CVE differential report"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 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"
|
|
||||||
elif [ -f sboms/head/sbom-arm64.spdx.json ]; then
|
|
||||||
primary_sbom="sboms/head/sbom-arm64.spdx.json"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$primary_sbom" ]; then
|
|
||||||
if ! trivy sbom \
|
|
||||||
--skip-version-check \
|
|
||||||
--format template \
|
|
||||||
--template "@${{ github.action_path }}/markdown.tpl" \
|
|
||||||
--output "${{ inputs.output-summary }}" \
|
|
||||||
"$primary_sbom"; then
|
|
||||||
echo "::error::Trivy summary generation failed for primary SBOM: $primary_sbom"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Publish Reports to Step Summary
|
- name: Publish Reports to Step Summary
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+108
-14
@@ -24,6 +24,7 @@ const SEVERITY_BADGES = {
|
|||||||
*/
|
*/
|
||||||
function loadReportsForArg(arg) {
|
function loadReportsForArg(arg) {
|
||||||
const cves = new Map();
|
const cves = new Map();
|
||||||
|
const scannedArchs = new Set();
|
||||||
const filePaths = [];
|
const filePaths = [];
|
||||||
|
|
||||||
for (const part of arg.split(',').map((s) => s.trim())) {
|
for (const part of arg.split(',').map((s) => s.trim())) {
|
||||||
@@ -60,13 +61,19 @@ function loadReportsForArg(arg) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseName = path.basename(filePath).toLowerCase();
|
const baseName = path.basename(filePath, '.json').toLowerCase();
|
||||||
let arch = 'all';
|
let arch = 'all';
|
||||||
if (baseName.includes('amd64')) {
|
const cveMatch = baseName.match(/^cve-([a-z0-9_.-]+)$/i);
|
||||||
|
if (cveMatch) {
|
||||||
|
arch = cveMatch[1];
|
||||||
|
} else if (baseName.includes('amd64')) {
|
||||||
arch = 'amd64';
|
arch = 'amd64';
|
||||||
} else if (baseName.includes('arm64') || baseName.includes('aarch64')) {
|
} else if (baseName.includes('arm64') || baseName.includes('aarch64')) {
|
||||||
arch = 'arm64';
|
arch = 'arm64';
|
||||||
}
|
}
|
||||||
|
if (arch !== 'all') {
|
||||||
|
scannedArchs.add(arch);
|
||||||
|
}
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
@@ -107,15 +114,18 @@ function loadReportsForArg(arg) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cves.scannedArchs = scannedArchs;
|
||||||
return cves;
|
return cves;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a vulnerability record into a Markdown table row.
|
* Formats a vulnerability record into a Markdown table row.
|
||||||
* @param {object} v
|
* @param {object} v
|
||||||
|
* @param {Set<string>} [allScannedArchs]
|
||||||
|
* @param {boolean} [showArch]
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function formatVulnRow(v) {
|
function formatVulnRow(v, allScannedArchs = new Set(), showArch = true) {
|
||||||
const sev = v.Severity || 'UNKNOWN';
|
const sev = v.Severity || 'UNKNOWN';
|
||||||
const badge = SEVERITY_BADGES[sev] || `⚪ ${sev}`;
|
const badge = SEVERITY_BADGES[sev] || `⚪ ${sev}`;
|
||||||
const vid = v.VulnerabilityID || '';
|
const vid = v.VulnerabilityID || '';
|
||||||
@@ -123,14 +133,6 @@ function formatVulnRow(v) {
|
|||||||
const link = url ? `[${vid}](${url})` : vid;
|
const link = url ? `[${vid}](${url})` : vid;
|
||||||
const pkg = v.PkgName || '';
|
const pkg = v.PkgName || '';
|
||||||
|
|
||||||
const archs = v._archs || new Set(['all']);
|
|
||||||
let archBadge;
|
|
||||||
if ((archs.has('amd64') && archs.has('arm64')) || archs.has('all')) {
|
|
||||||
archBadge = '`all`';
|
|
||||||
} else {
|
|
||||||
archBadge = `\`${Array.from(archs).sort().join(', ')}\``;
|
|
||||||
}
|
|
||||||
|
|
||||||
const installed = v.InstalledVersion || '-';
|
const installed = v.InstalledVersion || '-';
|
||||||
const fixed = v.FixedVersion || '-';
|
const fixed = v.FixedVersion || '-';
|
||||||
let title = (v.Title || v.Description || '').replace(/\n/g, ' ').trim();
|
let title = (v.Title || v.Description || '').replace(/\n/g, ' ').trim();
|
||||||
@@ -138,7 +140,22 @@ function formatVulnRow(v) {
|
|||||||
title = title.slice(0, 77) + '...';
|
title = title.slice(0, 77) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showArch) {
|
||||||
|
const archs = v._archs || new Set(['all']);
|
||||||
|
let archBadge;
|
||||||
|
if (
|
||||||
|
archs.has('all') ||
|
||||||
|
(allScannedArchs && allScannedArchs.size > 1 && archs.size === allScannedArchs.size) ||
|
||||||
|
(archs.has('amd64') && archs.has('arm64'))
|
||||||
|
) {
|
||||||
|
archBadge = '`all`';
|
||||||
|
} else {
|
||||||
|
archBadge = `\`${Array.from(archs).sort().join(', ')}\``;
|
||||||
|
}
|
||||||
return `| ${badge} | ${link} | \`${pkg}\` | ${archBadge} | ${installed} | ${fixed} | ${title} |`;
|
return `| ${badge} | ${link} | \`${pkg}\` | ${archBadge} | ${installed} | ${fixed} | ${title} |`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `| ${badge} | ${link} | \`${pkg}\` | ${installed} | ${fixed} | ${title} |`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,6 +205,12 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
|
|||||||
return aPkg.localeCompare(bPkg);
|
return aPkg.localeCompare(bPkg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allScannedArchs = new Set([
|
||||||
|
...(baseCves.scannedArchs || []),
|
||||||
|
...(branchCves.scannedArchs || []),
|
||||||
|
]);
|
||||||
|
const showArch = allScannedArchs.size > 1;
|
||||||
|
|
||||||
const newVulns = newKeys.map((k) => branchCves.get(k)).sort(sortVulns);
|
const newVulns = newKeys.map((k) => branchCves.get(k)).sort(sortVulns);
|
||||||
const resolvedVulns = resolvedKeys.map((k) => baseCves.get(k)).sort(sortVulns);
|
const resolvedVulns = resolvedKeys.map((k) => baseCves.get(k)).sort(sortVulns);
|
||||||
|
|
||||||
@@ -219,10 +242,15 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
|
|||||||
lines.push(`#### 🚨 New Vulnerabilities Introduced (${newVulns.length})`);
|
lines.push(`#### 🚨 New Vulnerabilities Introduced (${newVulns.length})`);
|
||||||
|
|
||||||
if (newVulns.length > 0) {
|
if (newVulns.length > 0) {
|
||||||
|
if (showArch) {
|
||||||
lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |');
|
lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |');
|
||||||
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
} else {
|
||||||
|
lines.push('| Severity | CVE | Package | Installed | Fixed In | Title |');
|
||||||
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
}
|
||||||
for (const v of newVulns) {
|
for (const v of newVulns) {
|
||||||
lines.push(formatVulnRow(v));
|
lines.push(formatVulnRow(v, allScannedArchs, showArch));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lines.push('✅ No new vulnerabilities introduced in this build.');
|
lines.push('✅ No new vulnerabilities introduced in this build.');
|
||||||
@@ -231,10 +259,15 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
|
|||||||
|
|
||||||
lines.push(`#### ✅ Resolved / Removed Vulnerabilities (${resolvedVulns.length})`);
|
lines.push(`#### ✅ Resolved / Removed Vulnerabilities (${resolvedVulns.length})`);
|
||||||
if (resolvedVulns.length > 0) {
|
if (resolvedVulns.length > 0) {
|
||||||
|
if (showArch) {
|
||||||
lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |');
|
lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |');
|
||||||
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
} else {
|
||||||
|
lines.push('| Severity | CVE | Package | Previously Installed | Fixed In | Title |');
|
||||||
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
}
|
||||||
for (const v of resolvedVulns) {
|
for (const v of resolvedVulns) {
|
||||||
lines.push(formatVulnRow(v));
|
lines.push(formatVulnRow(v, allScannedArchs, showArch));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lines.push('⚪ No existing vulnerabilities were resolved.');
|
lines.push('⚪ No existing vulnerabilities were resolved.');
|
||||||
@@ -244,22 +277,82 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a full markdown summary report of all vulnerabilities in a scan.
|
||||||
|
* @param {Map<string, object>} cves
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function renderFullSummary(cves) {
|
||||||
|
const allScannedArchs = cves.scannedArchs || new Set();
|
||||||
|
const showArch = allScannedArchs.size > 1;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'### 🛡️ Security Scan Report',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cves.size === 0) {
|
||||||
|
lines.push('✅ No vulnerabilities found.');
|
||||||
|
lines.push('');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortVulns(a, b) {
|
||||||
|
const aSev = a.Severity || 'UNKNOWN';
|
||||||
|
const bSev = b.Severity || 'UNKNOWN';
|
||||||
|
const aIdx = SEVERITY_ORDER.indexOf(aSev) !== -1 ? SEVERITY_ORDER.indexOf(aSev) : 99;
|
||||||
|
const bIdx = SEVERITY_ORDER.indexOf(bSev) !== -1 ? SEVERITY_ORDER.indexOf(bSev) : 99;
|
||||||
|
if (aIdx !== bIdx) return aIdx - bIdx;
|
||||||
|
const aPkg = a.PkgName || '';
|
||||||
|
const bPkg = b.PkgName || '';
|
||||||
|
return aPkg.localeCompare(bPkg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedVulns = Array.from(cves.values()).sort(sortVulns);
|
||||||
|
|
||||||
|
lines.push(`| Metric | Count |`);
|
||||||
|
lines.push(`| :--- | :--- |`);
|
||||||
|
lines.push(`| Total Vulnerabilities | **${sortedVulns.length}** |`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
if (showArch) {
|
||||||
|
lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |');
|
||||||
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
} else {
|
||||||
|
lines.push('| Severity | CVE | Package | Installed | Fixed In | Title |');
|
||||||
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of sortedVulns) {
|
||||||
|
lines.push(formatVulnRow(v, allScannedArchs, showArch));
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
if (args.length < 2) {
|
if (args.length < 2) {
|
||||||
console.error('Usage: cve-diff.js <base_report(s)> <branch_report(s)> [base_name]');
|
console.error('Usage: cve-diff.js <base_report(s)> <branch_report(s)> [base_name] [summary_output_path]');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseArg = args[0];
|
const baseArg = args[0];
|
||||||
const branchArg = args[1];
|
const branchArg = args[1];
|
||||||
const baseName = args[2] || 'main';
|
const baseName = args[2] || 'main';
|
||||||
|
const summaryOutputPath = args[3];
|
||||||
|
|
||||||
const baseCves = loadReportsForArg(baseArg);
|
const baseCves = loadReportsForArg(baseArg);
|
||||||
const branchCves = loadReportsForArg(branchArg);
|
const branchCves = loadReportsForArg(branchArg);
|
||||||
|
|
||||||
const output = renderMarkdownDiff(baseCves, branchCves, baseName);
|
const output = renderMarkdownDiff(baseCves, branchCves, baseName);
|
||||||
console.log(output);
|
console.log(output);
|
||||||
|
|
||||||
|
if (summaryOutputPath) {
|
||||||
|
const summaryOutput = renderFullSummary(branchCves);
|
||||||
|
fs.writeFileSync(summaryOutputPath, summaryOutput, 'utf8');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
@@ -270,4 +363,5 @@ module.exports = {
|
|||||||
loadReportsForArg,
|
loadReportsForArg,
|
||||||
formatVulnRow,
|
formatVulnRow,
|
||||||
renderMarkdownDiff,
|
renderMarkdownDiff,
|
||||||
|
renderFullSummary,
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user