Public Access
generated from Philipp/template-git
feat: initial commit of cve-differential action
This commit is contained in:
commit
bf996103f5
7 files changed
+651
No files matched your search
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# OS / Editor files
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Scanned artifacts / temp reports
|
||||||
|
sboms/
|
||||||
|
cve-reports/
|
||||||
|
*.spdx.json
|
||||||
|
cve-*.json
|
||||||
|
cve-delta.md
|
||||||
|
cve-summary.md
|
||||||
|
node_modules/
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# CVE Differential (`cve-differential`)
|
||||||
|
|
||||||
|
A self-contained Gitea and GitHub Actions composite action that extracts multi-platform SBOM attestations from container images, runs vulnerability scans with [Trivy](https://github.com/aquasecurity/trivy), and produces a set-based CVE delta report between a head image and a target baseline image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **Multi-Platform Support**: Automatically detects and extracts SPDX SBOM attestations for `linux/amd64` and `linux/arm64`.
|
||||||
|
- **Fast Attestation Extraction**: Uses `docker buildx imagetools inspect` to extract 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.
|
||||||
|
- **Zero-Dependency Diff Engine**: Built with native Node.js (`node:fs`, `node:path`) — no Python or external packages required on runner environments.
|
||||||
|
- **Set-Based Differential Analysis**: Categorizes vulnerabilities into:
|
||||||
|
- 🔴 **Introduced Vulnerabilities** (newly added in this branch / commit)
|
||||||
|
- 🟢 **Fixed Vulnerabilities** (present in target baseline, resolved in this branch)
|
||||||
|
- ⚪ **Unchanged Vulnerabilities** (pre-existing in target baseline)
|
||||||
|
- **Secure by Default**: Verified Trivy binary downloads against static SHA256 checksum lockfiles and single-step unified caching (binary + vulnerability database).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📥 Inputs
|
||||||
|
|
||||||
|
| Input | Description | Required | Default |
|
||||||
|
| :--- | :--- | :---: | :--- |
|
||||||
|
| `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-branch` | Target branch name for report headings | No | `main` |
|
||||||
|
| `trivy-version` | Trivy version to install and verify | No | `0.74.0` |
|
||||||
|
| `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` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📤 Outputs
|
||||||
|
|
||||||
|
| Output | Description |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `delta-report` | Path to the generated CVE delta Markdown report |
|
||||||
|
| `summary-report` | Path to the full vulnerability Markdown summary report |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Usage Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
build-and-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build and Push Head Container Image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
sbom: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: registry.example.com/my-app:git-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Compare Vulnerabilities (Head vs Target)
|
||||||
|
uses: Philipp/cve-differential@main
|
||||||
|
with:
|
||||||
|
head-image: registry.example.com/my-app:git-${{ github.sha }}
|
||||||
|
target-image: registry.example.com/my-app:latest
|
||||||
|
target-branch: ${{ github.base_ref || 'main' }}
|
||||||
|
|
||||||
|
- name: Publish CVE Delta to Job Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f cve-delta.md ]; then
|
||||||
|
cat cve-delta.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
name: Trivy Multi-Arch CVE Delta Comparison
|
||||||
|
description: Downloads and verifies Trivy, extracts multi-platform SBOMs, and computes a consolidated set-based CVE delta report
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
head-image:
|
||||||
|
description: Head container image reference (e.g. host/server/gitea:git-12345678)
|
||||||
|
required: true
|
||||||
|
target-image:
|
||||||
|
description: Target baseline container image reference (e.g. host/server/gitea:latest)
|
||||||
|
required: true
|
||||||
|
target-branch:
|
||||||
|
description: Target branch name for delta heading (e.g. main)
|
||||||
|
required: false
|
||||||
|
default: main
|
||||||
|
trivy-version:
|
||||||
|
description: Trivy binary version to install and verify
|
||||||
|
required: false
|
||||||
|
default: 0.74.0 # trivy-version
|
||||||
|
output-delta:
|
||||||
|
description: Output markdown file for CVE delta report
|
||||||
|
required: false
|
||||||
|
default: cve-delta.md
|
||||||
|
output-summary:
|
||||||
|
description: Output markdown file for full vulnerability summary
|
||||||
|
required: false
|
||||||
|
default: cve-summary.md
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
delta-report:
|
||||||
|
description: Path to CVE delta markdown report
|
||||||
|
value: ${{ inputs.output-delta }}
|
||||||
|
summary-report:
|
||||||
|
description: Path to full vulnerability markdown report
|
||||||
|
value: ${{ inputs.output-summary }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Prepare Cache Keys
|
||||||
|
id: vars
|
||||||
|
shell: bash
|
||||||
|
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache Trivy (Binary & Vulnerability DB)
|
||||||
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/trivy-bin
|
||||||
|
~/.cache/trivy
|
||||||
|
key: trivy-${{ inputs.trivy-version }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.vars.outputs.date }}
|
||||||
|
restore-keys: |
|
||||||
|
trivy-${{ inputs.trivy-version }}-${{ runner.os }}-${{ runner.arch }}-
|
||||||
|
|
||||||
|
- name: Install and Verify Trivy
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p "$HOME/.cache/trivy-bin" "$HOME/.local/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 ;;
|
||||||
|
esac
|
||||||
|
tarball="trivy_${{ inputs.trivy-version }}_Linux-${T_ARCH}.tar.gz"
|
||||||
|
checksums_file="${{ github.action_path }}/checksums.txt"
|
||||||
|
|
||||||
|
if [ -f "$HOME/.cache/trivy-bin/${tarball}" ] && (cd "$HOME/.cache/trivy-bin" && grep " ${tarball}$" "$checksums_file" | sha256sum -c -); then
|
||||||
|
echo "Cached Trivy archive verified against lockfile."
|
||||||
|
else
|
||||||
|
echo "Archive missing or checksum mismatch; downloading..."
|
||||||
|
curl -sfLO "https://github.com/aquasecurity/trivy/releases/download/v${{ inputs.trivy-version }}/${tarball}" --output-dir "$HOME/.cache/trivy-bin"
|
||||||
|
(cd "$HOME/.cache/trivy-bin" && grep " ${tarball}$" "$checksums_file" | sha256sum -c -)
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -xzf "$HOME/.cache/trivy-bin/${tarball}" -C "$HOME/.local/bin" trivy
|
||||||
|
chmod +x "$HOME/.local/bin/trivy"
|
||||||
|
|
||||||
|
- name: Extract Multi-Platform SBOMs
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
mkdir -p sboms/head sboms/target
|
||||||
|
|
||||||
|
cleanup_file() {
|
||||||
|
local f="$1"
|
||||||
|
if [ ! -s "$f" ] || [ "$(cat "$f" 2>/dev/null)" = "null" ]; then
|
||||||
|
rm -f "$f"
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Fallback to generating head SBOM with Trivy if attestation 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
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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
|
||||||
|
[ -f sboms/head/sbom-arm64.spdx.json ] && cp sboms/head/sbom-arm64.spdx.json sbom-arm64.spdx.json || true
|
||||||
|
|
||||||
|
- name: Scan Multi-Platform SBOMs and Generate Delta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Run multi-arch set-based diff
|
||||||
|
node "${{ github.action_path }}/cve-diff.js" \
|
||||||
|
"cve-reports/target" \
|
||||||
|
"cve-reports/head" \
|
||||||
|
"${{ inputs.target-branch }}" > "${{ inputs.output-delta }}" || true
|
||||||
|
|
||||||
|
# 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
|
||||||
|
trivy sbom \
|
||||||
|
--format template \
|
||||||
|
--template "@${{ github.action_path }}/markdown.tpl" \
|
||||||
|
--output "${{ inputs.output-summary }}" \
|
||||||
|
"$primary_sbom" || true
|
||||||
|
fi
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
b8e8834a90b0f28c172498a96d1b91876eb2f09867f02aa314df9a862aa86227 trivy_0.74.0_FreeBSD-64bit.tar.gz
|
||||||
|
cea47f6624cab9123a6bebc2f6586f3f82b8cd3165fc0ecd21677ba8a4ca0f5c trivy_0.74.0_Linux-32bit.deb
|
||||||
|
bec3a7aed02d60b6bb2996bb63897f604464c2db0b2b29dfc3cc7ce9959c8aaf trivy_0.74.0_Linux-32bit.rpm
|
||||||
|
048af0c9597a8e275f60bb9e0543748dbc76e22bdd52456b84ea0fa0cd2a078a trivy_0.74.0_Linux-32bit.tar.gz
|
||||||
|
cf1e32ec8d4d8823e023096a28cadb14f5b5123ce03f201fb633c5b76aa712dd trivy_0.74.0_Linux-64bit.deb
|
||||||
|
2331b82ec0ce5529a09fad8ccbfe03987ec9277ff4fbd6e96c6cb0aff8103477 trivy_0.74.0_Linux-64bit.rpm
|
||||||
|
2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a trivy_0.74.0_Linux-64bit.tar.gz
|
||||||
|
5665eaa443bfb117c5c4c373254f0ba9e2888985e6233588ce98bb0960f558c3 trivy_0.74.0_Linux-ARM.deb
|
||||||
|
bdd73ea298ec538ffae3351614a3adefc8c48599fce7943e6376fcfa040c0e85 trivy_0.74.0_Linux-ARM.rpm
|
||||||
|
99adbe319fa685cb4bff04dd0077e31cb94828338cab5db7f78fe6dcecca8ec1 trivy_0.74.0_Linux-ARM.tar.gz
|
||||||
|
14fae2604eab589fd544224ef56b8b8a44266872c43b259bd36cfde81428acc8 trivy_0.74.0_Linux-ARM64.deb
|
||||||
|
bb753f2dacd22417ac7709fdd910a0b017f4bb43330eac8b51c2a90403ae2fd3 trivy_0.74.0_Linux-ARM64.rpm
|
||||||
|
b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5 trivy_0.74.0_Linux-ARM64.tar.gz
|
||||||
|
16a1e36b6d405951b36f1e4b31bb7655f8551f1581934fcb2e6e5bdf8c46f6c9 trivy_0.74.0_Linux-PPC64LE.deb
|
||||||
|
cc7183af95dbab1ae1400fca17b35ca8f9591676a1042144994698cad9cac5a6 trivy_0.74.0_Linux-PPC64LE.rpm
|
||||||
|
c5aabfe8789d0d43f578ef2d01174884c8ff0f2106c627a220a43f7bf9054959 trivy_0.74.0_Linux-PPC64LE.tar.gz
|
||||||
|
f8fe56cee9a502e82f05e45465943e3bc6e75de05530ffc7bddf93e65b0de5bb trivy_0.74.0_Linux-s390x.deb
|
||||||
|
4fb2d5bd1ab5e61917e73cd2936daeae1c5ab9af024321a222437d824bac5579 trivy_0.74.0_Linux-s390x.rpm
|
||||||
|
516cfe38eaede79c178088265657aad12be6c155271ac68ff728a141961ef197 trivy_0.74.0_Linux-s390x.tar.gz
|
||||||
|
472816f6888dda689d075c30254d4210b4d1035acf365aa72332f584c2f60485 trivy_0.74.0_macOS-64bit.tar.gz
|
||||||
|
1caada5e0e2091909357c7525d3aa76f4b660b13821bc143b190c7483e31cc11 trivy_0.74.0_macOS-ARM64.tar.gz
|
||||||
|
94c40e0696e4b907a74b7b2e1438d5d72ebaca83115817407f568a002d520842 trivy_0.74.0_windows-64bit.zip
|
||||||
Executable
+241
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two Trivy vulnerability JSON reports (supporting multi-arch) and produce a set-based Markdown diff.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const SEVERITY_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'];
|
||||||
|
|
||||||
|
const SEVERITY_BADGES = {
|
||||||
|
CRITICAL: '🔴 CRITICAL',
|
||||||
|
HIGH: '🟠 HIGH',
|
||||||
|
MEDIUM: '🟡 MEDIUM',
|
||||||
|
LOW: '🔵 LOW',
|
||||||
|
UNKNOWN: '⚪ UNKNOWN',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads one or more JSON reports (path, comma-separated list, or directory) and merges findings with arch tracking.
|
||||||
|
* @param {string} arg
|
||||||
|
* @returns {Map<string, object>}
|
||||||
|
*/
|
||||||
|
function loadReportsForArg(arg) {
|
||||||
|
const cves = new Map();
|
||||||
|
const filePaths = [];
|
||||||
|
|
||||||
|
for (const part of arg.split(',').map((s) => s.trim())) {
|
||||||
|
if (!part || !fs.existsSync(part)) continue;
|
||||||
|
const stat = fs.statSync(part);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const entries = fs.readdirSync(part).sort();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.endsWith('.json')) {
|
||||||
|
filePaths.push(path.join(part, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
filePaths.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(filePath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stat.size === 0) continue;
|
||||||
|
|
||||||
|
const baseName = path.basename(filePath).toLowerCase();
|
||||||
|
let arch = 'all';
|
||||||
|
if (baseName.includes('amd64')) {
|
||||||
|
arch = 'amd64';
|
||||||
|
} else if (baseName.includes('arm64') || baseName.includes('aarch64')) {
|
||||||
|
arch = 'arm64';
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
data = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || typeof data !== 'object' || !Array.isArray(data.Results)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const result of data.Results) {
|
||||||
|
const target = result.Target || result.Type || 'App';
|
||||||
|
const vulns = Array.isArray(result.Vulnerabilities) ? result.Vulnerabilities : [];
|
||||||
|
for (const v of vulns) {
|
||||||
|
const vid = v.VulnerabilityID || '';
|
||||||
|
const pkg = v.PkgName || '';
|
||||||
|
if (!vid || !pkg) continue;
|
||||||
|
|
||||||
|
const key = `${vid}:::${pkg}`;
|
||||||
|
if (!cves.has(key)) {
|
||||||
|
cves.set(key, { ...v, _target: target, _archs: new Set([arch]) });
|
||||||
|
} else {
|
||||||
|
cves.get(key)._archs.add(arch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cves;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a vulnerability record into a Markdown table row.
|
||||||
|
* @param {object} v
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatVulnRow(v) {
|
||||||
|
const sev = v.Severity || 'UNKNOWN';
|
||||||
|
const badge = SEVERITY_BADGES[sev] || `⚪ ${sev}`;
|
||||||
|
const vid = v.VulnerabilityID || '';
|
||||||
|
const url = v.PrimaryURL || '';
|
||||||
|
const link = url ? `[${vid}](${url})` : vid;
|
||||||
|
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 fixed = v.FixedVersion || '-';
|
||||||
|
let title = (v.Title || v.Description || '').replace(/\n/g, ' ').trim();
|
||||||
|
if (title.length > 80) {
|
||||||
|
title = title.slice(0, 77) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `| ${badge} | ${link} | \`${pkg}\` | ${archBadge} | ${installed} | ${fixed} | ${title} |`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares base and branch vulnerabilities and formats a Markdown diff.
|
||||||
|
* @param {Map<string, object>} baseCves
|
||||||
|
* @param {Map<string, object>} branchCves
|
||||||
|
* @param {string} baseName
|
||||||
|
* @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());
|
||||||
|
|
||||||
|
const newKeys = [];
|
||||||
|
let unchangedCount = 0;
|
||||||
|
for (const key of branchKeys) {
|
||||||
|
if (!baseKeys.has(key)) {
|
||||||
|
newKeys.push(key);
|
||||||
|
} else {
|
||||||
|
unchangedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedKeys = [];
|
||||||
|
for (const key of baseKeys) {
|
||||||
|
if (!branchKeys.has(key)) {
|
||||||
|
resolvedKeys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 newVulns = newKeys.map((k) => branchCves.get(k)).sort(sortVulns);
|
||||||
|
const resolvedVulns = resolvedKeys.map((k) => baseCves.get(k)).sort(sortVulns);
|
||||||
|
|
||||||
|
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 (newVulns.length > 0) {
|
||||||
|
lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |');
|
||||||
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
for (const v of newVulns) {
|
||||||
|
lines.push(formatVulnRow(v));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('✅ No new vulnerabilities introduced in this build.');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
lines.push(`#### ✅ Resolved / Removed Vulnerabilities (${resolvedVulns.length})`);
|
||||||
|
if (resolvedVulns.length > 0) {
|
||||||
|
lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |');
|
||||||
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
for (const v of resolvedVulns) {
|
||||||
|
lines.push(formatVulnRow(v));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('⚪ No existing vulnerabilities were resolved.');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length < 2) {
|
||||||
|
console.error('Usage: cve-diff.js <base_report(s)> <branch_report(s)> [base_name]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseArg = args[0];
|
||||||
|
const branchArg = args[1];
|
||||||
|
const baseName = args[2] || 'main';
|
||||||
|
|
||||||
|
const baseCves = loadReportsForArg(baseArg);
|
||||||
|
const branchCves = loadReportsForArg(branchArg);
|
||||||
|
|
||||||
|
const output = renderMarkdownDiff(baseCves, branchCves, baseName);
|
||||||
|
console.log(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadReportsForArg,
|
||||||
|
formatVulnRow,
|
||||||
|
renderMarkdownDiff,
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
### 🛡️ Security Scan Report
|
||||||
|
|
||||||
|
{{- range . }}
|
||||||
|
{{- $targetName := .Target }}
|
||||||
|
{{- if eq $targetName "" }}
|
||||||
|
{{- if eq .Type "gobinary" }}
|
||||||
|
{{- $targetName = "Go binary" }}
|
||||||
|
{{- else if .Type }}
|
||||||
|
{{- $targetName = .Type }}
|
||||||
|
{{- else if .Class }}
|
||||||
|
{{- $targetName = .Class }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $targetName = "Application / Package" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- $hasFindings := false }}
|
||||||
|
|
||||||
|
{{- if .Vulnerabilities }}
|
||||||
|
{{- $hasFindings = true }}
|
||||||
|
#### Target: `{{ $targetName }}` (Vulnerabilities)
|
||||||
|
|
||||||
|
| Severity | CVE | Package | Installed | Fixed In | Title |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||||
|
{{- $vulns := .Vulnerabilities }}
|
||||||
|
{{- range $sev := list "CRITICAL" "HIGH" "MEDIUM" "LOW" "UNKNOWN" }}
|
||||||
|
{{- range $vuln := $vulns }}
|
||||||
|
{{- if eq $vuln.Severity $sev }}
|
||||||
|
| {{ if eq $vuln.Severity "CRITICAL" }}🔴 CRITICAL{{ else if eq $vuln.Severity "HIGH" }}🟠 HIGH{{ else if eq $vuln.Severity "MEDIUM" }}🟡 MEDIUM{{ else if eq $vuln.Severity "LOW" }}🔵 LOW{{ else }}⚪ {{ $vuln.Severity }}{{ end }} | [{{ $vuln.VulnerabilityID }}]({{ $vuln.PrimaryURL }}) | `{{ $vuln.PkgName }}` | {{ $vuln.InstalledVersion }} | {{ default "-" $vuln.FixedVersion }} | {{ $vuln.Title }} |
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if .Misconfigurations }}
|
||||||
|
{{- $hasFindings = true }}
|
||||||
|
#### Target: `{{ $targetName }}` (Misconfigurations)
|
||||||
|
|
||||||
|
| Severity | Rule ID | Title | Message | Resolution |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
{{- $misconfs := .Misconfigurations }}
|
||||||
|
{{- range $sev := list "CRITICAL" "HIGH" "MEDIUM" "LOW" "UNKNOWN" }}
|
||||||
|
{{- range $m := $misconfs }}
|
||||||
|
{{- if eq $m.Severity $sev }}
|
||||||
|
| {{ if eq $m.Severity "CRITICAL" }}🔴 CRITICAL{{ else if eq $m.Severity "HIGH" }}🟠 HIGH{{ else if eq $m.Severity "MEDIUM" }}🟡 MEDIUM{{ else if eq $m.Severity "LOW" }}🔵 LOW{{ else }}⚪ {{ $m.Severity }}{{ end }} | [{{ $m.ID }}]({{ $m.PrimaryURL }}) | {{ $m.Title }} | {{ $m.Message }} | {{ default "-" $m.Resolution }} |
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if .Secrets }}
|
||||||
|
{{- $hasFindings = true }}
|
||||||
|
#### Target: `{{ $targetName }}` (Secrets)
|
||||||
|
|
||||||
|
| Severity | Rule ID | Title | Lines |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
{{- range .Secrets }}
|
||||||
|
| {{ if eq .Severity "CRITICAL" }}🔴 CRITICAL{{ else if eq .Severity "HIGH" }}🟠 HIGH{{ else if eq .Severity "MEDIUM" }}🟡 MEDIUM{{ else if eq .Severity "LOW" }}🔵 LOW{{ else }}⚪ {{ .Severity }}{{ end }} | {{ .RuleID }} | {{ .Title }} | L{{ .StartLine }}-L{{ .EndLine }} |
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if not $hasFindings }}
|
||||||
|
#### Target: `{{ $targetName }}`
|
||||||
|
✅ No security issues found.
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
],
|
||||||
|
"customManagers": [
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": [
|
||||||
|
"^action\\.ya?ml$"
|
||||||
|
],
|
||||||
|
"matchStrings": [
|
||||||
|
"default:\\s*(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)\\s*#\\s*trivy-version"
|
||||||
|
],
|
||||||
|
"depNameTemplate": "aquasecurity/trivy",
|
||||||
|
"datasourceTemplate": "github-releases",
|
||||||
|
"versioningTemplate": "semver"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchDatasources": [
|
||||||
|
"github-releases"
|
||||||
|
],
|
||||||
|
"matchPackageNames": [
|
||||||
|
"aquasecurity/trivy"
|
||||||
|
],
|
||||||
|
"postUpgradeTasks": {
|
||||||
|
"commands": [
|
||||||
|
"curl -sfL https://github.com/aquasecurity/trivy/releases/download/v{{{newVersion}}}/trivy_{{{newVersion}}}_checksums.txt -o checksums.txt"
|
||||||
|
],
|
||||||
|
"fileFilters": [
|
||||||
|
"checksums.txt"
|
||||||
|
],
|
||||||
|
"executionMode": "update"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user