diff --git a/action.yaml b/action.yaml index 791e24a..16eaa59 100644 --- a/action.yaml +++ b/action.yaml @@ -65,33 +65,480 @@ runs: - name: Install and Verify Trivy shell: bash - env: - TRIVY_VERSION: ${{ steps.vars.outputs.version }} - run: node "${{ github.action_path }}/lib/install-trivy.js" + run: | + set -euo pipefail + + 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 shell: bash - env: - PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }} - TRIVY_SKIP_VERSION_CHECK: 'true' - INPUT_HEAD_IMAGE: ${{ inputs.head-image }} - INPUT_TARGET_IMAGE: ${{ inputs.target-image }} - INPUT_ARCHITECTURES: ${{ inputs.architectures }} - INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }} - run: node "${{ github.action_path }}/lib/extract-sboms.js" + run: | + export PATH="$HOME/.local/bin:$PATH" + export TRIVY_SKIP_VERSION_CHECK="true" + mkdir -p sboms/head sboms/target + + cleanup_file() { + local f="$1" + if [ -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 shell: bash - env: - PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }} - TRIVY_SKIP_VERSION_CHECK: 'true' - INPUT_HEAD_IMAGE: ${{ inputs.head-image }} - INPUT_TARGET_IMAGE: ${{ inputs.target-image }} - INPUT_TARGET_BRANCH: ${{ inputs.target-branch }} - INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }} - INPUT_OUTPUT_DELTA: ${{ inputs.output-delta }} - INPUT_OUTPUT_SUMMARY: ${{ inputs.output-summary }} - run: node "${{ github.action_path }}/lib/scan-sboms.js" + run: | + export PATH="$HOME/.local/bin:$PATH" + export TRIVY_SKIP_VERSION_CHECK="true" + mkdir -p cve-reports/head cve-reports/target + + shopt -s nullglob + head_sboms=(sboms/head/sbom-*.spdx.json) + target_sboms=(sboms/target/sbom-*.spdx.json) + + 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 shell: bash diff --git a/lib/diagnostics.js b/lib/diagnostics.js deleted file mode 100644 index 75b4de3..0000000 --- a/lib/diagnostics.js +++ /dev/null @@ -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, -}; diff --git a/lib/distro.js b/lib/distro.js deleted file mode 100644 index 6b902a1..0000000 --- a/lib/distro.js +++ /dev/null @@ -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, -}; diff --git a/lib/extract-sboms.js b/lib/extract-sboms.js deleted file mode 100644 index 7d0d5d1..0000000 --- a/lib/extract-sboms.js +++ /dev/null @@ -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, -}; diff --git a/lib/install-trivy.js b/lib/install-trivy.js deleted file mode 100644 index e3b06aa..0000000 --- a/lib/install-trivy.js +++ /dev/null @@ -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 }; diff --git a/lib/platforms.js b/lib/platforms.js deleted file mode 100644 index 2465df9..0000000 --- a/lib/platforms.js +++ /dev/null @@ -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, -}; diff --git a/lib/scan-sboms.js b/lib/scan-sboms.js deleted file mode 100644 index 2e4547c..0000000 --- a/lib/scan-sboms.js +++ /dev/null @@ -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, -}; diff --git a/lib/verifier.js b/lib/verifier.js deleted file mode 100644 index d3182d4..0000000 --- a/lib/verifier.js +++ /dev/null @@ -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: - 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, -}; diff --git a/test/diagnostics.test.js b/test/diagnostics.test.js deleted file mode 100644 index b8a97e4..0000000 --- a/test/diagnostics.test.js +++ /dev/null @@ -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); -}); diff --git a/test/distro.test.js b/test/distro.test.js deleted file mode 100644 index bd7952b..0000000 --- a/test/distro.test.js +++ /dev/null @@ -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); -}); diff --git a/test/orchestration.test.js b/test/orchestration.test.js deleted file mode 100644 index 10668b0..0000000 --- a/test/orchestration.test.js +++ /dev/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'); -}); diff --git a/test/platforms.test.js b/test/platforms.test.js deleted file mode 100644 index ead5bce..0000000 --- a/test/platforms.test.js +++ /dev/null @@ -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'); -}); diff --git a/test/verifier.test.js b/test/verifier.test.js deleted file mode 100644 index 1bccc1e..0000000 --- a/test/verifier.test.js +++ /dev/null @@ -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 }); -});