Public Access
generated from Philipp/template-git
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45fc314eab | ||
|
|
826f853dff | ||
|
|
8c0c553e4f | ||
|
|
3f03635d45 | ||
|
|
9e607b1a24
|
||
|
|
7343f659bb |
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@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- 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@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Test CVE Differential Action
|
- name: Test CVE Differential Action
|
||||||
uses: ./
|
uses: ./
|
||||||
|
|||||||
+468
-21
@@ -65,33 +65,480 @@ runs:
|
|||||||
|
|
||||||
- name: Install and Verify Trivy
|
- name: Install and Verify Trivy
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
run: |
|
||||||
TRIVY_VERSION: ${{ steps.vars.outputs.version }}
|
set -euo pipefail
|
||||||
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
|
||||||
env:
|
run: |
|
||||||
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
export TRIVY_SKIP_VERSION_CHECK="true"
|
||||||
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
|
mkdir -p sboms/head sboms/target
|
||||||
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
|
|
||||||
INPUT_ARCHITECTURES: ${{ inputs.architectures }}
|
cleanup_file() {
|
||||||
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
|
local f="$1"
|
||||||
run: node "${{ github.action_path }}/lib/extract-sboms.js"
|
if [ -f "$f" ]; then
|
||||||
|
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
|
||||||
env:
|
run: |
|
||||||
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
export TRIVY_SKIP_VERSION_CHECK="true"
|
||||||
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
|
mkdir -p cve-reports/head cve-reports/target
|
||||||
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
|
|
||||||
INPUT_TARGET_BRANCH: ${{ inputs.target-branch }}
|
shopt -s nullglob
|
||||||
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
|
head_sboms=(sboms/head/sbom-*.spdx.json)
|
||||||
INPUT_OUTPUT_DELTA: ${{ inputs.output-delta }}
|
target_sboms=(sboms/target/sbom-*.spdx.json)
|
||||||
INPUT_OUTPUT_SUMMARY: ${{ inputs.output-summary }}
|
|
||||||
run: node "${{ github.action_path }}/lib/scan-sboms.js"
|
head_ok=true
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
#!/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,
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#!/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 };
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
#!/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,
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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