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