Public Access
generated from Philipp/template-git
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4385ea7eea |
No files matched your search
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: k8s
|
runs-on: k8s
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run JS Unit Tests
|
- name: Run JS Unit Tests
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Test CVE Differential Action
|
- name: Test CVE Differential Action
|
||||||
uses: ./
|
uses: ./
|
||||||
|
|||||||
+21
-468
@@ -65,480 +65,33 @@ runs:
|
|||||||
|
|
||||||
- name: Install and Verify Trivy
|
- name: Install and Verify Trivy
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
env:
|
||||||
set -euo pipefail
|
TRIVY_VERSION: ${{ steps.vars.outputs.version }}
|
||||||
|
run: node "${{ github.action_path }}/lib/install-trivy.js"
|
||||||
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 "::error::Unsupported architecture: ${ARCH}"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
checksums_file="${{ github.action_path }}/checksums.txt"
|
|
||||||
tarball="trivy_${{ steps.vars.outputs.version }}_Linux-${T_ARCH}.tar.gz"
|
|
||||||
|
|
||||||
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${{ steps.vars.outputs.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
|
- name: Extract Multi-Platform SBOMs
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
env:
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
|
||||||
export TRIVY_SKIP_VERSION_CHECK="true"
|
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||||
mkdir -p sboms/head sboms/target
|
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
|
||||||
|
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
|
||||||
cleanup_file() {
|
INPUT_ARCHITECTURES: ${{ inputs.architectures }}
|
||||||
local f="$1"
|
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
|
||||||
if [ -f "$f" ]; then
|
run: node "${{ github.action_path }}/lib/extract-sboms.js"
|
||||||
if [ ! -s "$f" ] || [ "$(cat "$f" 2>/dev/null)" = "null" ]; then
|
|
||||||
rm -f "$f"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
head_sbom_err=""
|
|
||||||
target_sbom_err=""
|
|
||||||
use_inbuild="${{ inputs.use-inbuild-sbom }}"
|
|
||||||
|
|
||||||
# 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)}"
|
|
||||||
if [ "$use_inbuild" = "true" ]; then
|
|
||||||
echo "ℹ️ Strategy: Built-in SBOM attestation extraction enabled (Docker Scout / Syft). Skips container layer downloads for maximum speed."
|
|
||||||
echo "ℹ️ Note: Built-in SBOM vulnerability counts may differ from direct filesystem scans ('trivy image') due to binary vs. source package tracking."
|
|
||||||
else
|
|
||||||
echo "ℹ️ Strategy: Direct Trivy image scanning enabled (default). Inspects full container rootfs and package databases for maximum fidelity."
|
|
||||||
fi
|
|
||||||
|
|
||||||
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
|
|
||||||
echo "Attempting extraction of built-in SBOM for head image ($platform)..."
|
|
||||||
docker buildx imagetools inspect "${{ inputs.head-image }}" \
|
|
||||||
--format "{{ json (index .SBOM \"${platform}\").SPDX }}" > "sboms/head/sbom-${arch_tag}.spdx.json" 2>/dev/null || true
|
|
||||||
cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json"
|
|
||||||
[ -f "sboms/head/sbom-${arch_tag}.spdx.json" ] && cp "sboms/head/sbom-${arch_tag}.spdx.json" "sboms/head/inbuild-sbom-${arch_tag}.spdx.json" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "sboms/head/sbom-${arch_tag}.spdx.json" ]; then
|
|
||||||
echo "Generating SBOM for head image ($platform) with Trivy..."
|
|
||||||
if ! trivy image --skip-version-check --platform "$platform" --format spdx-json --output "sboms/head/sbom-${arch_tag}.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"
|
|
||||||
[ -f "sboms/target/sbom-${arch_tag}.spdx.json" ] && cp "sboms/target/sbom-${arch_tag}.spdx.json" "sboms/target/inbuild-sbom-${arch_tag}.spdx.json" 2>/dev/null || true
|
|
||||||
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
|
|
||||||
[ -f sboms/head/sbom-default.spdx.json ] && cp sboms/head/sbom-default.spdx.json sboms/head/inbuild-sbom-default.spdx.json 2>/dev/null || true
|
|
||||||
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"
|
|
||||||
fi
|
|
||||||
cleanup_file sboms/head/sbom-default.spdx.json
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
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
|
|
||||||
[ -f sboms/target/sbom-default.spdx.json ] && cp sboms/target/sbom-default.spdx.json sboms/target/inbuild-sbom-default.spdx.json 2>/dev/null || true
|
|
||||||
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"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ${#target_files[@]} -eq 0 ] && [ -z "$target_sbom_err" ]; then
|
|
||||||
target_sbom_err="No SBOM generated and target baseline image could not be inspected"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Diagnostic overview
|
|
||||||
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"
|
|
||||||
for f in "${head_files[@]}"; do
|
|
||||||
echo " - $(basename "$f") ($(wc -c < "$f") bytes)"
|
|
||||||
done
|
|
||||||
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"
|
|
||||||
for f in "${target_files[@]}"; do
|
|
||||||
echo " - $(basename "$f") ($(wc -c < "$f") bytes)"
|
|
||||||
done
|
|
||||||
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
|
|
||||||
for f in sboms/head/sbom-*.spdx.json; do
|
|
||||||
[ -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
|
||||||
run: |
|
env:
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
|
||||||
export TRIVY_SKIP_VERSION_CHECK="true"
|
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||||
mkdir -p cve-reports/head cve-reports/target
|
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
|
||||||
|
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
|
||||||
shopt -s nullglob
|
INPUT_TARGET_BRANCH: ${{ inputs.target-branch }}
|
||||||
head_sboms=(sboms/head/sbom-*.spdx.json)
|
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
|
||||||
target_sboms=(sboms/target/sbom-*.spdx.json)
|
INPUT_OUTPUT_DELTA: ${{ inputs.output-delta }}
|
||||||
|
INPUT_OUTPUT_SUMMARY: ${{ inputs.output-summary }}
|
||||||
head_ok=true
|
run: node "${{ github.action_path }}/lib/scan-sboms.js"
|
||||||
target_ok=true
|
|
||||||
head_report_count=0
|
|
||||||
target_report_count=0
|
|
||||||
|
|
||||||
head_summary=()
|
|
||||||
target_summary=()
|
|
||||||
use_inbuild="${{ inputs.use-inbuild-sbom }}"
|
|
||||||
|
|
||||||
detect_distro() {
|
|
||||||
node -e '
|
|
||||||
const fs = require("fs");
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
||||||
let name, ver;
|
|
||||||
for (const pkg of (data.packages || [])) {
|
|
||||||
for (const ref of (pkg.externalRefs || [])) {
|
|
||||||
if (ref.referenceType === "purl" && ref.referenceLocator) {
|
|
||||||
const mName = ref.referenceLocator.match(/[?&]os_name=([^&]+)/);
|
|
||||||
const mVer = ref.referenceLocator.match(/[?&]os_version=([^&]+)/);
|
|
||||||
if (mName && mVer) {
|
|
||||||
name = mName[1];
|
|
||||||
ver = mVer[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (name && ver) break;
|
|
||||||
}
|
|
||||||
if (name && ver) console.log(name + "/" + ver);
|
|
||||||
} catch (e) {}
|
|
||||||
' "$1" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
distro_arg=""
|
|
||||||
if [ "$use_inbuild" = "true" ]; then
|
|
||||||
detected_distro=$(detect_distro "$f")
|
|
||||||
if [ -n "$detected_distro" ]; then
|
|
||||||
echo "Detected distribution '${detected_distro}' from in-build SBOM PURLs. Scanning with --distro."
|
|
||||||
distro_arg="--distro ${detected_distro}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
scan_success=false
|
|
||||||
if trivy sbom $distro_arg --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then
|
|
||||||
scan_success=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
vuln_cnt=0
|
|
||||||
if [ -s "$out_json" ]; then
|
|
||||||
cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true)
|
|
||||||
cnt=$(echo "$cnt" | tr -d '[:space:]')
|
|
||||||
[ -n "$cnt" ] && vuln_cnt="$cnt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fallback if in-build SBOM scan produced 0 CVEs (e.g. incompatible third-party format) or scan failed
|
|
||||||
if [ "$use_inbuild" = "true" ] && { [ "$scan_success" != "true" ] || [ "$vuln_cnt" -eq 0 ]; }; then
|
|
||||||
echo "::warning::In-build SBOM for head image (${arch}) yielded ${vuln_cnt} CVE findings. Falling back to direct Trivy image scan..."
|
|
||||||
t_plat="linux/${arch}"
|
|
||||||
[ "$arch" = "default" ] && t_plat=""
|
|
||||||
plat_arg=""
|
|
||||||
[ -n "$t_plat" ] && plat_arg="--platform $t_plat"
|
|
||||||
if trivy image --skip-version-check $plat_arg --format spdx-json --output "$f" "${{ inputs.head-image }}"; then
|
|
||||||
echo "Re-scanning head SBOM generated directly by Trivy..."
|
|
||||||
if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then
|
|
||||||
scan_success=true
|
|
||||||
if [ -s "$out_json" ]; then
|
|
||||||
cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true)
|
|
||||||
cnt=$(echo "$cnt" | tr -d '[:space:]')
|
|
||||||
[ -n "$cnt" ] && vuln_cnt="$cnt"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$scan_success" = "true" ]; then
|
|
||||||
if [ -s "$out_json" ]; then
|
|
||||||
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"
|
|
||||||
distro_arg=""
|
|
||||||
if [ "$use_inbuild" = "true" ]; then
|
|
||||||
detected_distro=$(detect_distro "$f")
|
|
||||||
if [ -n "$detected_distro" ]; then
|
|
||||||
echo "Detected distribution '${detected_distro}' from in-build SBOM PURLs. Scanning with --distro."
|
|
||||||
distro_arg="--distro ${detected_distro}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
scan_success=false
|
|
||||||
if trivy sbom $distro_arg --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then
|
|
||||||
scan_success=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
vuln_cnt=0
|
|
||||||
if [ -s "$out_json" ]; then
|
|
||||||
cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true)
|
|
||||||
cnt=$(echo "$cnt" | tr -d '[:space:]')
|
|
||||||
[ -n "$cnt" ] && vuln_cnt="$cnt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fallback if in-build SBOM scan produced 0 CVEs (e.g. incompatible third-party format) or scan failed
|
|
||||||
if [ "$use_inbuild" = "true" ] && { [ "$scan_success" != "true" ] || [ "$vuln_cnt" -eq 0 ]; }; then
|
|
||||||
echo "::warning::In-build SBOM for target image (${arch}) yielded ${vuln_cnt} CVE findings. Falling back to direct Trivy image scan..."
|
|
||||||
t_plat="linux/${arch}"
|
|
||||||
[ "$arch" = "default" ] && t_plat=""
|
|
||||||
plat_arg=""
|
|
||||||
[ -n "$t_plat" ] && plat_arg="--platform $t_plat"
|
|
||||||
if trivy image --skip-version-check $plat_arg --format spdx-json --output "$f" "${{ inputs.target-image }}"; then
|
|
||||||
echo "Re-scanning target SBOM generated directly by Trivy..."
|
|
||||||
if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then
|
|
||||||
scan_success=true
|
|
||||||
if [ -s "$out_json" ]; then
|
|
||||||
cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true)
|
|
||||||
cnt=$(echo "$cnt" | tr -d '[:space:]')
|
|
||||||
[ -n "$cnt" ] && vuln_cnt="$cnt"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$scan_success" = "true" ]; then
|
|
||||||
if [ -s "$out_json" ]; then
|
|
||||||
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 "================================================================================"
|
|
||||||
if [ "$use_inbuild" = "true" ]; then
|
|
||||||
echo "Scan Strategy: Built-in OCI Attestation (Syft/Docker Scout) with distro detection"
|
|
||||||
else
|
|
||||||
echo "Scan Strategy: Direct Trivy Image Scan (Full rootfs & source package mapping)"
|
|
||||||
fi
|
|
||||||
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 and generate consolidated full summary report
|
|
||||||
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-summary }}" > "${{ inputs.output-delta }}"; then
|
|
||||||
echo "::error::Failed to generate CVE differential report"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Publish Reports to Step Summary
|
- name: Publish Reports to Step Summary
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Diagnostic formatting and reporting utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function formatSbomOverview({
|
||||||
|
headImage,
|
||||||
|
targetImage,
|
||||||
|
headFiles = [],
|
||||||
|
targetFiles = [],
|
||||||
|
headError = null,
|
||||||
|
targetError = null,
|
||||||
|
}) {
|
||||||
|
const lines = [
|
||||||
|
'',
|
||||||
|
'================================================================================',
|
||||||
|
' SBOM EXTRACTION DIAGNOSTIC OVERVIEW ',
|
||||||
|
'================================================================================',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (headError) {
|
||||||
|
lines.push(`HEAD Image (${headImage}): ❌ FAILED - ${headError}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`HEAD Image (${headImage}): ✅ SUCCESS`);
|
||||||
|
for (const f of headFiles) {
|
||||||
|
lines.push(` - ${f.name} (${f.size} bytes)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetError) {
|
||||||
|
lines.push(`TARGET Baseline Image (${targetImage}): ❌ FAILED - ${targetError}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`TARGET Baseline Image (${targetImage}): ✅ SUCCESS`);
|
||||||
|
for (const f of targetFiles) {
|
||||||
|
lines.push(` - ${f.name} (${f.size} bytes)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('================================================================================');
|
||||||
|
lines.push('');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScanOverview({
|
||||||
|
headImage,
|
||||||
|
targetImage,
|
||||||
|
targetBranch = 'main',
|
||||||
|
strategy = null,
|
||||||
|
headSummary = [],
|
||||||
|
targetSummary = [],
|
||||||
|
}) {
|
||||||
|
const lines = [
|
||||||
|
'',
|
||||||
|
'================================================================================',
|
||||||
|
' CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW ',
|
||||||
|
'================================================================================',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (strategy) {
|
||||||
|
lines.push(`Scan Strategy: ${strategy}`);
|
||||||
|
lines.push('--------------------------------------------------------------------------------');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`HEAD Image: ${headImage}`);
|
||||||
|
for (const line of headSummary) {
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`TARGET Baseline Image: ${targetImage} (branch: '${targetBranch}')`);
|
||||||
|
for (const line of targetSummary) {
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('================================================================================');
|
||||||
|
lines.push('');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function countVulnerabilities(report) {
|
||||||
|
if (!report) return 0;
|
||||||
|
let data = report;
|
||||||
|
if (typeof report === 'string' || Buffer.isBuffer(report)) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(report.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!data || typeof data !== 'object') return 0;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
if (Array.isArray(data.Results)) {
|
||||||
|
for (const res of data.Results) {
|
||||||
|
if (res && Array.isArray(res.Vulnerabilities)) {
|
||||||
|
count += res.Vulnerabilities.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
formatSbomOverview,
|
||||||
|
formatScanOverview,
|
||||||
|
countVulnerabilities,
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Distribution detection from SPDX SBOM package PURLs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function detectDistro(spdxInput) {
|
||||||
|
if (!spdxInput) return null;
|
||||||
|
|
||||||
|
let data = spdxInput;
|
||||||
|
if (typeof spdxInput === 'string' || Buffer.isBuffer(spdxInput)) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(spdxInput.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || typeof data !== 'object' || !Array.isArray(data.packages)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pkg of data.packages) {
|
||||||
|
if (!pkg || !Array.isArray(pkg.externalRefs)) continue;
|
||||||
|
for (const ref of pkg.externalRefs) {
|
||||||
|
if (ref && ref.referenceType === 'purl' && typeof ref.referenceLocator === 'string') {
|
||||||
|
const purl = ref.referenceLocator;
|
||||||
|
const nameMatch = purl.match(/[?&]os_name=([^&]+)/);
|
||||||
|
const verMatch = purl.match(/[?&]os_version=([^&]+)/);
|
||||||
|
if (nameMatch && verMatch) {
|
||||||
|
const osName = decodeURIComponent(nameMatch[1]);
|
||||||
|
const osVer = decodeURIComponent(verMatch[1]);
|
||||||
|
return `${osName}/${osVer}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
detectDistro,
|
||||||
|
};
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts or generates multi-platform SBOMs for head and target images.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const cp = require('node:child_process');
|
||||||
|
const { parsePlatforms, discoverPlatforms, toArchTag } = require('./platforms.js');
|
||||||
|
const { formatSbomOverview } = require('./diagnostics.js');
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
headImage: process.env.INPUT_HEAD_IMAGE || '',
|
||||||
|
targetImage: process.env.INPUT_TARGET_IMAGE || '',
|
||||||
|
architectures: process.env.INPUT_ARCHITECTURES || '',
|
||||||
|
useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 2; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === '--head-image' && i + 1 < argv.length) args.headImage = argv[++i];
|
||||||
|
else if (arg === '--target-image' && i + 1 < argv.length) args.targetImage = argv[++i];
|
||||||
|
else if (arg === '--architectures' && i + 1 < argv.length) args.architectures = argv[++i];
|
||||||
|
else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupInvalidFile(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return;
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (stat.size === 0) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8').trim();
|
||||||
|
if (content === 'null' || content === '') {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommandSafe(cmd, args) {
|
||||||
|
try {
|
||||||
|
return cp.execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOrGenerateSbom({ image, platform, archTag, outputDir, useInbuild }) {
|
||||||
|
const outputFile = path.join(outputDir, `sbom-${archTag}.spdx.json`);
|
||||||
|
const inbuildFile = path.join(outputDir, `inbuild-sbom-${archTag}.spdx.json`);
|
||||||
|
|
||||||
|
// 1. Attempt in-build SBOM extraction if requested
|
||||||
|
if (useInbuild) {
|
||||||
|
console.log(`Attempting extraction of built-in SBOM for image (${platform})...`);
|
||||||
|
const formatStr = `{{ json (index .SBOM "${platform}").SPDX }}`;
|
||||||
|
const rawSbom = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', image, '--format', formatStr]);
|
||||||
|
if (rawSbom) {
|
||||||
|
fs.writeFileSync(outputFile, rawSbom);
|
||||||
|
cleanupInvalidFile(outputFile);
|
||||||
|
if (fs.existsSync(outputFile)) {
|
||||||
|
fs.copyFileSync(outputFile, inbuildFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to direct Trivy image generation if SBOM not present
|
||||||
|
if (!fs.existsSync(outputFile)) {
|
||||||
|
console.log(`Generating SBOM for image (${platform}) with Trivy...`);
|
||||||
|
const platArgs = platform ? ['--platform', platform] : [];
|
||||||
|
try {
|
||||||
|
cp.execFileSync('trivy', [
|
||||||
|
'image',
|
||||||
|
'--skip-version-check',
|
||||||
|
...platArgs,
|
||||||
|
'--format', 'spdx-json',
|
||||||
|
'--output', outputFile,
|
||||||
|
image,
|
||||||
|
], { stdio: 'inherit' });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`::warning::Trivy image scan failed for image (${platform}): ${e.message}`);
|
||||||
|
}
|
||||||
|
cleanupInvalidFile(outputFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv);
|
||||||
|
const { headImage, targetImage, architectures, useInbuildSbom } = args;
|
||||||
|
const useInbuild = useInbuildSbom === 'true';
|
||||||
|
|
||||||
|
if (!headImage || !targetImage) {
|
||||||
|
console.error('::error::Both --head-image and --target-image must be provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headDir = path.join('sboms', 'head');
|
||||||
|
const targetDir = path.join('sboms', 'target');
|
||||||
|
fs.mkdirSync(headDir, { recursive: true });
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
// Determine platforms
|
||||||
|
let platforms = parsePlatforms(architectures);
|
||||||
|
if (platforms.length === 0) {
|
||||||
|
console.log('Auto-discovering architectures from images...');
|
||||||
|
const rawHead = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', '--raw', headImage]);
|
||||||
|
const rawTarget = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', '--raw', targetImage]);
|
||||||
|
platforms = discoverPlatforms(rawHead, rawTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Configured platforms: ${platforms.length > 0 ? platforms.join(', ') : '(single platform fallback)'}`);
|
||||||
|
if (useInbuild) {
|
||||||
|
console.log('ℹ️ Strategy: Built-in SBOM attestation extraction enabled (Docker Scout / Syft). Skips container layer downloads for maximum speed.');
|
||||||
|
console.log('ℹ️ Note: Built-in SBOM vulnerability counts may differ from direct filesystem scans (\'trivy image\') due to binary vs. source package tracking.');
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ Strategy: Direct Trivy image scanning enabled (default). Inspects full container rootfs and package databases for maximum fidelity.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPlatforms = platforms.length > 0 ? platforms : [''];
|
||||||
|
|
||||||
|
for (const platform of targetPlatforms) {
|
||||||
|
const archTag = toArchTag(platform);
|
||||||
|
// Head extraction
|
||||||
|
extractOrGenerateSbom({
|
||||||
|
image: headImage,
|
||||||
|
platform,
|
||||||
|
archTag,
|
||||||
|
outputDir: headDir,
|
||||||
|
useInbuild,
|
||||||
|
});
|
||||||
|
// Target extraction
|
||||||
|
extractOrGenerateSbom({
|
||||||
|
image: targetImage,
|
||||||
|
platform,
|
||||||
|
archTag,
|
||||||
|
outputDir: targetDir,
|
||||||
|
useInbuild,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect generated files
|
||||||
|
const getSbomFiles = (dir) => {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs.readdirSync(dir)
|
||||||
|
.filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json'))
|
||||||
|
.map((f) => {
|
||||||
|
const fullPath = path.join(dir, f);
|
||||||
|
return { name: f, size: fs.statSync(fullPath).size };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const headFiles = getSbomFiles(headDir);
|
||||||
|
const targetFiles = getSbomFiles(targetDir);
|
||||||
|
|
||||||
|
let headError = null;
|
||||||
|
let targetError = null;
|
||||||
|
if (headFiles.length === 0) headError = 'No SBOM generated and head image could not be inspected';
|
||||||
|
if (targetFiles.length === 0) targetError = 'No SBOM generated and target baseline image could not be inspected';
|
||||||
|
|
||||||
|
const overview = formatSbomOverview({
|
||||||
|
headImage,
|
||||||
|
targetImage,
|
||||||
|
headFiles,
|
||||||
|
targetFiles,
|
||||||
|
headError,
|
||||||
|
targetError,
|
||||||
|
});
|
||||||
|
console.log(overview);
|
||||||
|
|
||||||
|
if (headError || targetError) {
|
||||||
|
console.error('::error::Both head and target images must have valid SBOMs to proceed with differential scanning.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose extracted head SBOMs to workspace root for workflow artifact uploads
|
||||||
|
for (const file of headFiles) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(path.join(headDir, file.name), file.name);
|
||||||
|
} catch {
|
||||||
|
// Ignore copy error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseArgs,
|
||||||
|
cleanupInvalidFile,
|
||||||
|
extractOrGenerateSbom,
|
||||||
|
main,
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs and verifies Trivy binary against checksums lockfile.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const cp = require('node:child_process');
|
||||||
|
const { mapArch, parseChecksums, verifyFileSha256 } = require('./verifier.js');
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const version = process.env.TRIVY_VERSION || '0.74.0';
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
const binDir = path.join(homeDir, '.local', 'bin');
|
||||||
|
const cacheDir = path.join(homeDir, '.cache', 'trivy-bin');
|
||||||
|
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
|
||||||
|
// Expose bin directory to GITHUB_PATH if running in Actions runner
|
||||||
|
if (process.env.GITHUB_PATH) {
|
||||||
|
fs.appendFileSync(process.env.GITHUB_PATH, `${binDir}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unameArch = os.arch() === 'x64' ? 'x86_64' : (os.arch() === 'arm64' ? 'ARM64' : os.arch());
|
||||||
|
const tArch = mapArch(unameArch);
|
||||||
|
const tarball = `trivy_${version}_Linux-${tArch}.tar.gz`;
|
||||||
|
const tarballPath = path.join(cacheDir, tarball);
|
||||||
|
|
||||||
|
const checksumsFile = path.resolve(__dirname, '..', 'checksums.txt');
|
||||||
|
if (!fs.existsSync(checksumsFile)) {
|
||||||
|
console.error(`::error::Checksums lockfile not found at ${checksumsFile}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checksums = parseChecksums(fs.readFileSync(checksumsFile, 'utf8'));
|
||||||
|
const expectedHash = checksums.get(tarball);
|
||||||
|
if (!expectedHash) {
|
||||||
|
console.error(`::error::Tarball ${tarball} not found in checksums.txt`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(tarballPath) && verifyFileSha256(tarballPath, expectedHash)) {
|
||||||
|
console.log('Cached Trivy archive verified against lockfile.');
|
||||||
|
} else {
|
||||||
|
console.log('Archive missing or checksum mismatch; downloading...');
|
||||||
|
const downloadUrl = `https://github.com/aquasecurity/trivy/releases/download/v${version}/${tarball}`;
|
||||||
|
cp.execFileSync('curl', ['-sfLO', downloadUrl, '--output-dir', cacheDir], { stdio: 'inherit' });
|
||||||
|
|
||||||
|
if (!verifyFileSha256(tarballPath, expectedHash)) {
|
||||||
|
console.error(`::error::Downloaded archive ${tarball} failed SHA256 checksum verification!`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Downloaded Trivy archive verified successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract binary
|
||||||
|
cp.execFileSync('tar', ['-xzf', tarballPath, '-C', binDir, 'trivy'], { stdio: 'inherit' });
|
||||||
|
const trivyBin = path.join(binDir, 'trivy');
|
||||||
|
if (fs.existsSync(trivyBin)) {
|
||||||
|
fs.chmodSync(trivyBin, 0o755);
|
||||||
|
console.log(`Trivy ${version} installed and verified successfully at ${trivyBin}`);
|
||||||
|
} else {
|
||||||
|
console.error('::error::Failed to extract trivy binary from tarball.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { main };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Platform and architecture resolution utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parsePlatforms(input) {
|
||||||
|
if (!input || typeof input !== 'string') return [];
|
||||||
|
const parts = input.split(',');
|
||||||
|
const platforms = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const clean = part.trim();
|
||||||
|
if (!clean) continue;
|
||||||
|
if (!clean.includes('/')) {
|
||||||
|
platforms.push(`linux/${clean}`);
|
||||||
|
} else {
|
||||||
|
platforms.push(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return platforms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverPlatforms(headRawJson, targetRawJson) {
|
||||||
|
const set = new Set();
|
||||||
|
for (const raw of [headRawJson, targetRawJson]) {
|
||||||
|
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 {
|
||||||
|
// Ignore JSON parse errors from invalid manifests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArchTag(platform) {
|
||||||
|
if (!platform) return 'default';
|
||||||
|
// Strip optional leading os/ (e.g. linux/)
|
||||||
|
const withoutOs = platform.replace(/^[a-zA-Z0-9_-]+\//, '');
|
||||||
|
// Replace slashes with dashes (e.g. arm/v7 -> arm-v7)
|
||||||
|
return withoutOs.replace(/\//g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parsePlatforms,
|
||||||
|
discoverPlatforms,
|
||||||
|
toArchTag,
|
||||||
|
};
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans multi-platform SBOMs with Trivy, handles distro detection and fallbacks,
|
||||||
|
* prints diagnostic overview, and invokes diff engine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const cp = require('node:child_process');
|
||||||
|
const { detectDistro } = require('./distro.js');
|
||||||
|
const { formatScanOverview, countVulnerabilities } = require('./diagnostics.js');
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
headImage: process.env.INPUT_HEAD_IMAGE || '',
|
||||||
|
targetImage: process.env.INPUT_TARGET_IMAGE || '',
|
||||||
|
targetBranch: process.env.INPUT_TARGET_BRANCH || 'main',
|
||||||
|
useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false',
|
||||||
|
outputDelta: process.env.INPUT_OUTPUT_DELTA || 'cve-delta.md',
|
||||||
|
outputSummary: process.env.INPUT_OUTPUT_SUMMARY || 'cve-summary.md',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 2; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === '--head-image' && i + 1 < argv.length) args.headImage = argv[++i];
|
||||||
|
else if (arg === '--target-image' && i + 1 < argv.length) args.targetImage = argv[++i];
|
||||||
|
else if (arg === '--target-branch' && i + 1 < argv.length) args.targetBranch = argv[++i];
|
||||||
|
else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i];
|
||||||
|
else if (arg === '--output-delta' && i + 1 < argv.length) args.outputDelta = argv[++i];
|
||||||
|
else if (arg === '--output-summary' && i + 1 < argv.length) args.outputSummary = argv[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanSbomFile({ sbomPath, image, outJson, errLog, useInbuild }) {
|
||||||
|
const archMatch = path.basename(sbomPath, '.spdx.json').match(/^sbom-(.+)$/);
|
||||||
|
const arch = archMatch ? archMatch[1] : 'default';
|
||||||
|
|
||||||
|
console.log(`Scanning SBOM for architecture: ${arch}`);
|
||||||
|
let distroArg = [];
|
||||||
|
if (useInbuild) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(sbomPath, 'utf8');
|
||||||
|
const detected = detectDistro(content);
|
||||||
|
if (detected) {
|
||||||
|
console.log(`Detected distribution '${detected}' from in-build SBOM PURLs. Scanning with --distro.`);
|
||||||
|
distroArg = ['--distro', detected];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore read error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runTrivySbom = () => {
|
||||||
|
try {
|
||||||
|
const errFd = fs.openSync(errLog, 'w');
|
||||||
|
try {
|
||||||
|
cp.execFileSync('trivy', [
|
||||||
|
'sbom',
|
||||||
|
...distroArg,
|
||||||
|
'--skip-version-check',
|
||||||
|
'--format', 'json',
|
||||||
|
'--output', outJson,
|
||||||
|
sbomPath,
|
||||||
|
], { stdio: ['ignore', 'ignore', errFd] });
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(errFd);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let scanSuccess = runTrivySbom();
|
||||||
|
let vulnCount = 0;
|
||||||
|
if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||||
|
vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if in-build scan produced 0 findings or failed
|
||||||
|
if (useInbuild && (!scanSuccess || vulnCount === 0)) {
|
||||||
|
console.warn(`::warning::In-build SBOM for image (${arch}) yielded ${vulnCount} CVE findings. Falling back to direct Trivy image scan...`);
|
||||||
|
const platArgs = arch !== 'default' ? ['--platform', `linux/${arch}`] : [];
|
||||||
|
try {
|
||||||
|
cp.execFileSync('trivy', [
|
||||||
|
'image',
|
||||||
|
'--skip-version-check',
|
||||||
|
...platArgs,
|
||||||
|
'--format', 'spdx-json',
|
||||||
|
'--output', sbomPath,
|
||||||
|
image,
|
||||||
|
], { stdio: 'inherit' });
|
||||||
|
|
||||||
|
console.log('Re-scanning SBOM generated directly by Trivy...');
|
||||||
|
// Re-scan without external distroArg (Trivy's generated SBOM has OS declarations)
|
||||||
|
distroArg = [];
|
||||||
|
scanSuccess = runTrivySbom();
|
||||||
|
if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||||
|
vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`::warning::Direct Trivy scan fallback failed for ${arch}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { arch, scanSuccess, vulnCount, outJson, errLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv);
|
||||||
|
const {
|
||||||
|
headImage,
|
||||||
|
targetImage,
|
||||||
|
targetBranch,
|
||||||
|
useInbuildSbom,
|
||||||
|
outputDelta,
|
||||||
|
outputSummary,
|
||||||
|
} = args;
|
||||||
|
const useInbuild = useInbuildSbom === 'true';
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join('cve-reports', 'head'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join('cve-reports', 'target'), { recursive: true });
|
||||||
|
|
||||||
|
const getSboms = (dir) => {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs.readdirSync(dir)
|
||||||
|
.filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json'))
|
||||||
|
.sort()
|
||||||
|
.map((f) => path.join(dir, f));
|
||||||
|
};
|
||||||
|
|
||||||
|
const headSboms = getSboms(path.join('sboms', 'head'));
|
||||||
|
const targetSboms = getSboms(path.join('sboms', 'target'));
|
||||||
|
|
||||||
|
let headOk = true;
|
||||||
|
let targetOk = true;
|
||||||
|
let headReportCount = 0;
|
||||||
|
let targetReportCount = 0;
|
||||||
|
const headSummary = [];
|
||||||
|
const targetSummary = [];
|
||||||
|
|
||||||
|
// 1. Scan Head SBOMs
|
||||||
|
if (headSboms.length === 0) {
|
||||||
|
headOk = false;
|
||||||
|
headSummary.push(' - ❌ No SBOM files found in sboms/head/');
|
||||||
|
} else {
|
||||||
|
for (const f of headSboms) {
|
||||||
|
const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, '');
|
||||||
|
const outJson = path.join('cve-reports', 'head', `cve-${arch}.json`);
|
||||||
|
const errLog = path.join('cve-reports', 'head', `cve-${arch}.err`);
|
||||||
|
|
||||||
|
const res = scanSbomFile({
|
||||||
|
sbomPath: f,
|
||||||
|
image: headImage,
|
||||||
|
outJson,
|
||||||
|
errLog,
|
||||||
|
useInbuild,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||||
|
if (res.vulnCount === 0) {
|
||||||
|
console.warn(`::warning::Head image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`);
|
||||||
|
headSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`);
|
||||||
|
} else {
|
||||||
|
headSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`);
|
||||||
|
}
|
||||||
|
headReportCount++;
|
||||||
|
} else {
|
||||||
|
headOk = false;
|
||||||
|
let errMsg = 'Trivy scan exited with non-zero status';
|
||||||
|
if (fs.existsSync(errLog)) {
|
||||||
|
const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n');
|
||||||
|
errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg;
|
||||||
|
}
|
||||||
|
headSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scan Target SBOMs
|
||||||
|
if (targetSboms.length === 0) {
|
||||||
|
targetOk = false;
|
||||||
|
targetSummary.push(' - ❌ No SBOM files found in sboms/target/');
|
||||||
|
} else {
|
||||||
|
for (const f of targetSboms) {
|
||||||
|
const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, '');
|
||||||
|
const outJson = path.join('cve-reports', 'target', `cve-${arch}.json`);
|
||||||
|
const errLog = path.join('cve-reports', 'target', `cve-${arch}.err`);
|
||||||
|
|
||||||
|
const res = scanSbomFile({
|
||||||
|
sbomPath: f,
|
||||||
|
image: targetImage,
|
||||||
|
outJson,
|
||||||
|
errLog,
|
||||||
|
useInbuild,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||||
|
if (res.vulnCount === 0) {
|
||||||
|
console.warn(`::warning::Target baseline image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`);
|
||||||
|
targetSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`);
|
||||||
|
} else {
|
||||||
|
targetSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`);
|
||||||
|
}
|
||||||
|
targetReportCount++;
|
||||||
|
} else {
|
||||||
|
targetOk = false;
|
||||||
|
let errMsg = 'Trivy scan exited with non-zero status';
|
||||||
|
if (fs.existsSync(errLog)) {
|
||||||
|
const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n');
|
||||||
|
errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg;
|
||||||
|
}
|
||||||
|
targetSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Print Comprehensive Diagnostic Overview
|
||||||
|
const strategy = useInbuild
|
||||||
|
? 'Built-in OCI Attestation (Syft/Docker Scout) with distro detection'
|
||||||
|
: 'Direct Trivy Image Scan (Full rootfs & source package mapping)';
|
||||||
|
|
||||||
|
const overview = formatScanOverview({
|
||||||
|
headImage,
|
||||||
|
targetImage,
|
||||||
|
targetBranch,
|
||||||
|
strategy,
|
||||||
|
headSummary,
|
||||||
|
targetSummary,
|
||||||
|
});
|
||||||
|
console.log(overview);
|
||||||
|
|
||||||
|
// 4. Enforce Gating
|
||||||
|
let hasError = false;
|
||||||
|
if (!headOk || headReportCount === 0) {
|
||||||
|
console.error('::error::Head image CVE analysis failed or produced no reports.');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
if (!targetOk || targetReportCount === 0) {
|
||||||
|
console.error('::error::Target baseline CVE analysis failed or produced no reports.');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
console.error('::error::Aborting differential analysis: both head and target scans must succeed before diffing.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Run multi-arch set-based diff and generate reports
|
||||||
|
console.log('Both head and target scans succeeded. Computing set-based vulnerability delta...');
|
||||||
|
const cveDiffScript = path.resolve(__dirname, '..', 'cve-diff.js');
|
||||||
|
try {
|
||||||
|
const deltaOutput = cp.execFileSync(process.execPath, [
|
||||||
|
cveDiffScript,
|
||||||
|
path.join('cve-reports', 'target'),
|
||||||
|
path.join('cve-reports', 'head'),
|
||||||
|
targetBranch,
|
||||||
|
outputSummary,
|
||||||
|
], { encoding: 'utf8' });
|
||||||
|
|
||||||
|
fs.writeFileSync(outputDelta, deltaOutput, 'utf8');
|
||||||
|
console.log(`CVE delta written to ${outputDelta} and full summary written to ${outputSummary}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`::error::Failed to generate CVE differential report: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseArgs,
|
||||||
|
scanSbomFile,
|
||||||
|
main,
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Architecture mapping and cryptographic checksum verification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
|
function mapArch(unameArch) {
|
||||||
|
const normalized = (unameArch || '').trim().toLowerCase();
|
||||||
|
switch (normalized) {
|
||||||
|
case 'x86_64':
|
||||||
|
case 'amd64':
|
||||||
|
return '64bit';
|
||||||
|
case 'aarch64':
|
||||||
|
case 'arm64':
|
||||||
|
return 'ARM64';
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported architecture: ${unameArch}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChecksums(checksumsContent) {
|
||||||
|
const map = new Map();
|
||||||
|
if (!checksumsContent) return map;
|
||||||
|
|
||||||
|
const lines = checksumsContent.split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
// Format: <hash> <filename>
|
||||||
|
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const hash = match[1].toLowerCase();
|
||||||
|
const filename = match[2].trim();
|
||||||
|
map.set(filename, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyFileSha256(filePath, expectedHash) {
|
||||||
|
if (!fs.existsSync(filePath) || !expectedHash) return false;
|
||||||
|
try {
|
||||||
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
|
const actualHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
return actualHash.toLowerCase() === expectedHash.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapArch,
|
||||||
|
parseChecksums,
|
||||||
|
verifyFileSha256,
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
formatSbomOverview,
|
||||||
|
formatScanOverview,
|
||||||
|
countVulnerabilities,
|
||||||
|
} = require('../lib/diagnostics.js');
|
||||||
|
|
||||||
|
test('formatSbomOverview formats successful extraction banner', () => {
|
||||||
|
const overview = formatSbomOverview({
|
||||||
|
headImage: 'my-head:latest',
|
||||||
|
targetImage: 'my-target:latest',
|
||||||
|
headFiles: [{ name: 'sbom-amd64.spdx.json', size: 1024 }],
|
||||||
|
targetFiles: [{ name: 'sbom-amd64.spdx.json', size: 2048 }],
|
||||||
|
headError: null,
|
||||||
|
targetError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(overview.includes('SBOM EXTRACTION DIAGNOSTIC OVERVIEW'));
|
||||||
|
assert.ok(overview.includes('HEAD Image (my-head:latest): ✅ SUCCESS'));
|
||||||
|
assert.ok(overview.includes('TARGET Baseline Image (my-target:latest): ✅ SUCCESS'));
|
||||||
|
assert.ok(overview.includes('sbom-amd64.spdx.json (1024 bytes)'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSbomOverview formats failure banner when error present', () => {
|
||||||
|
const overview = formatSbomOverview({
|
||||||
|
headImage: 'my-head:latest',
|
||||||
|
targetImage: 'my-target:latest',
|
||||||
|
headFiles: [],
|
||||||
|
targetFiles: [],
|
||||||
|
headError: 'Image not found',
|
||||||
|
targetError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(overview.includes('HEAD Image (my-head:latest): ❌ FAILED - Image not found'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatScanOverview formats scan summary with strategy', () => {
|
||||||
|
const overview = formatScanOverview({
|
||||||
|
headImage: 'debian:bookworm-slim',
|
||||||
|
targetImage: 'debian:bullseye-slim',
|
||||||
|
targetBranch: 'main',
|
||||||
|
strategy: 'Direct Trivy Image Scan',
|
||||||
|
headSummary: [' - amd64: ✅ OK (104 vulnerability findings)'],
|
||||||
|
targetSummary: [' - amd64: ✅ OK (130 vulnerability findings)'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(overview.includes('CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW'));
|
||||||
|
assert.ok(overview.includes('Scan Strategy: Direct Trivy Image Scan'));
|
||||||
|
assert.ok(overview.includes('HEAD Image: debian:bookworm-slim'));
|
||||||
|
assert.ok(overview.includes('TARGET Baseline Image: debian:bullseye-slim (branch: \'main\')'));
|
||||||
|
assert.ok(overview.includes('104 vulnerability findings'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('countVulnerabilities counts all vulnerabilities in a Trivy JSON report', () => {
|
||||||
|
const report = {
|
||||||
|
Results: [
|
||||||
|
{
|
||||||
|
Vulnerabilities: [
|
||||||
|
{ VulnerabilityID: 'CVE-2023-0001' },
|
||||||
|
{ VulnerabilityID: 'CVE-2023-0002' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Vulnerabilities: [
|
||||||
|
{ VulnerabilityID: 'CVE-2023-0003' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(countVulnerabilities(report), 3);
|
||||||
|
assert.equal(countVulnerabilities(JSON.stringify(report)), 3);
|
||||||
|
assert.equal(countVulnerabilities({}), 0);
|
||||||
|
assert.equal(countVulnerabilities(null), 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { detectDistro } = require('../lib/distro.js');
|
||||||
|
|
||||||
|
test('detectDistro returns null for null, undefined, or empty input', () => {
|
||||||
|
assert.equal(detectDistro(null), null);
|
||||||
|
assert.equal(detectDistro(undefined), null);
|
||||||
|
assert.equal(detectDistro(''), null);
|
||||||
|
assert.equal(detectDistro('{}'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectDistro extracts distro from parsed SPDX object with package PURLs', () => {
|
||||||
|
const spdx = {
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
name: 'acl',
|
||||||
|
externalRefs: [
|
||||||
|
{
|
||||||
|
referenceType: 'purl',
|
||||||
|
referenceLocator: 'pkg:deb/debian/acl@2.3.1-3?os_distro=bookworm&os_name=debian&os_version=12',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(detectDistro(spdx), 'debian/12');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectDistro extracts distro from JSON string containing escaped unicode \\u0026', () => {
|
||||||
|
const jsonStr = JSON.stringify({
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
name: 'adduser',
|
||||||
|
externalRefs: [
|
||||||
|
{
|
||||||
|
referenceType: 'purl',
|
||||||
|
referenceLocator: 'pkg:deb/debian/adduser@3.134?os_distro=bullseye&os_name=debian&os_version=11',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).replace(/&/g, '\\u0026');
|
||||||
|
|
||||||
|
assert.ok(jsonStr.includes('\\u0026'));
|
||||||
|
assert.equal(detectDistro(jsonStr), 'debian/11');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectDistro handles non-debian distributions (e.g. ubuntu, alpine)', () => {
|
||||||
|
const spdx = {
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
externalRefs: [
|
||||||
|
{
|
||||||
|
referenceType: 'purl',
|
||||||
|
referenceLocator: 'pkg:apk/alpine/alpine-baselayout@3.4.3-r2?os_name=alpine&os_version=3.20',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(detectDistro(spdx), 'alpine/3.20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectDistro returns null when PURLs lack os_name or os_version', () => {
|
||||||
|
const spdx = {
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
externalRefs: [
|
||||||
|
{
|
||||||
|
referenceType: 'purl',
|
||||||
|
referenceLocator: 'pkg:generic/foo@1.0.0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(detectDistro(spdx), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const { parseArgs: parseExtractArgs, cleanupInvalidFile } = require('../lib/extract-sboms.js');
|
||||||
|
const { parseArgs: parseScanArgs } = require('../lib/scan-sboms.js');
|
||||||
|
|
||||||
|
test('extract-sboms parseArgs parses command line arguments and defaults', () => {
|
||||||
|
const argv = [
|
||||||
|
'node',
|
||||||
|
'extract-sboms.js',
|
||||||
|
'--head-image', 'my-repo/head:1.0',
|
||||||
|
'--target-image', 'my-repo/target:1.0',
|
||||||
|
'--architectures', 'amd64,arm64',
|
||||||
|
'--use-inbuild-sbom', 'true',
|
||||||
|
];
|
||||||
|
|
||||||
|
const args = parseExtractArgs(argv);
|
||||||
|
assert.equal(args.headImage, 'my-repo/head:1.0');
|
||||||
|
assert.equal(args.targetImage, 'my-repo/target:1.0');
|
||||||
|
assert.equal(args.architectures, 'amd64,arm64');
|
||||||
|
assert.equal(args.useInbuildSbom, 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extract-sboms cleanupInvalidFile removes empty files and files containing null', () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbom-clean-'));
|
||||||
|
const emptyFile = path.join(tmpDir, 'empty.json');
|
||||||
|
const nullFile = path.join(tmpDir, 'null.json');
|
||||||
|
const validFile = path.join(tmpDir, 'valid.json');
|
||||||
|
|
||||||
|
fs.writeFileSync(emptyFile, '');
|
||||||
|
fs.writeFileSync(nullFile, 'null\n');
|
||||||
|
fs.writeFileSync(validFile, '{"spdxVersion": "SPDX-2.3"}');
|
||||||
|
|
||||||
|
cleanupInvalidFile(emptyFile);
|
||||||
|
cleanupInvalidFile(nullFile);
|
||||||
|
cleanupInvalidFile(validFile);
|
||||||
|
|
||||||
|
assert.equal(fs.existsSync(emptyFile), false);
|
||||||
|
assert.equal(fs.existsSync(nullFile), false);
|
||||||
|
assert.equal(fs.existsSync(validFile), true);
|
||||||
|
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scan-sboms parseArgs parses scan arguments and defaults', () => {
|
||||||
|
const argv = [
|
||||||
|
'node',
|
||||||
|
'scan-sboms.js',
|
||||||
|
'--head-image', 'my-head:latest',
|
||||||
|
'--target-image', 'my-target:latest',
|
||||||
|
'--target-branch', 'feature',
|
||||||
|
'--output-delta', 'custom-delta.md',
|
||||||
|
'--output-summary', 'custom-summary.md',
|
||||||
|
];
|
||||||
|
|
||||||
|
const args = parseScanArgs(argv);
|
||||||
|
assert.equal(args.headImage, 'my-head:latest');
|
||||||
|
assert.equal(args.targetImage, 'my-target:latest');
|
||||||
|
assert.equal(args.targetBranch, 'feature');
|
||||||
|
assert.equal(args.outputDelta, 'custom-delta.md');
|
||||||
|
assert.equal(args.outputSummary, 'custom-summary.md');
|
||||||
|
assert.equal(args.useInbuildSbom, 'false');
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { parsePlatforms, discoverPlatforms, toArchTag } = require('../lib/platforms.js');
|
||||||
|
|
||||||
|
test('parsePlatforms handles empty and undefined input', () => {
|
||||||
|
assert.deepEqual(parsePlatforms(''), []);
|
||||||
|
assert.deepEqual(parsePlatforms(null), []);
|
||||||
|
assert.deepEqual(parsePlatforms(undefined), []);
|
||||||
|
assert.deepEqual(parsePlatforms(' '), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlatforms parses comma-separated architectures and adds linux/ prefix if omitted', () => {
|
||||||
|
const result = parsePlatforms('amd64, arm64, linux/arm/v7');
|
||||||
|
assert.deepEqual(result, ['linux/amd64', 'linux/arm64', 'linux/arm/v7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discoverPlatforms extracts unique architectures from OCI image manifests', () => {
|
||||||
|
const headManifest = JSON.stringify({
|
||||||
|
manifests: [
|
||||||
|
{ platform: { architecture: 'amd64', os: 'linux' } },
|
||||||
|
{ platform: { architecture: 'arm64', os: 'linux' } },
|
||||||
|
{ platform: { architecture: 'unknown', os: 'unknown' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetManifest = JSON.stringify({
|
||||||
|
manifests: [
|
||||||
|
{ platform: { architecture: 'amd64', os: 'linux' } },
|
||||||
|
{ platform: { architecture: 'arm', os: 'linux', variant: 'v7' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = discoverPlatforms(headManifest, targetManifest);
|
||||||
|
assert.deepEqual(result, ['linux/amd64', 'linux/arm/v7', 'linux/arm64']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discoverPlatforms safely handles invalid or missing manifest JSON', () => {
|
||||||
|
assert.deepEqual(discoverPlatforms('invalid json', null), []);
|
||||||
|
assert.deepEqual(discoverPlatforms('', '{}'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toArchTag converts platform strings to filesystem-safe arch tags', () => {
|
||||||
|
assert.equal(toArchTag('linux/amd64'), 'amd64');
|
||||||
|
assert.equal(toArchTag('linux/arm64'), 'arm64');
|
||||||
|
assert.equal(toArchTag('linux/arm/v7'), 'arm-v7');
|
||||||
|
assert.equal(toArchTag('amd64'), 'amd64');
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const { mapArch, parseChecksums, verifyFileSha256 } = require('../lib/verifier.js');
|
||||||
|
|
||||||
|
test('mapArch maps uname architecture to Trivy release arch', () => {
|
||||||
|
assert.equal(mapArch('x86_64'), '64bit');
|
||||||
|
assert.equal(mapArch('aarch64'), 'ARM64');
|
||||||
|
assert.equal(mapArch('arm64'), 'ARM64');
|
||||||
|
assert.throws(() => mapArch('mips'), /Unsupported architecture/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseChecksums parses sha256 checksum lockfile into filename map', () => {
|
||||||
|
const content = `
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 trivy_0.74.0_Linux-64bit.tar.gz
|
||||||
|
a1b2c3d4e5f60000000000000000000000000000000000000000000000000000 trivy_0.74.0_Linux-ARM64.tar.gz
|
||||||
|
`;
|
||||||
|
|
||||||
|
const map = parseChecksums(content);
|
||||||
|
assert.equal(map.get('trivy_0.74.0_Linux-64bit.tar.gz'), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||||
|
assert.equal(map.get('trivy_0.74.0_Linux-ARM64.tar.gz'), 'a1b2c3d4e5f60000000000000000000000000000000000000000000000000000');
|
||||||
|
assert.equal(map.has('nonexistent'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyFileSha256 verifies actual sha256 hash of a file', () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cve-test-'));
|
||||||
|
const testFile = path.join(tmpDir, 'test.txt');
|
||||||
|
const fileContent = 'hello world\n';
|
||||||
|
fs.writeFileSync(testFile, fileContent);
|
||||||
|
|
||||||
|
const expectedHash = crypto.createHash('sha256').update(fileContent).digest('hex');
|
||||||
|
assert.equal(verifyFileSha256(testFile, expectedHash), true);
|
||||||
|
assert.equal(verifyFileSha256(testFile, 'wronghash'), false);
|
||||||
|
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user