1 Commits
Author SHA1 Message Date
Philipp 599b74ef49 refactor(action): replace bash blocks with zero-dependency Node.js modules
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m53s
Extract ~450 lines of complex bash from action.yaml into modular,
unit-tested Node.js scripts in lib/. All logic is covered by 26 native
node:test unit tests (node --test, no external dependencies).

Modules added:
- lib/platforms.js   - platform parsing and OCI manifest auto-discovery
- lib/distro.js      - SPDX PURL distro detection (os_name/os_version)
- lib/verifier.js    - Trivy arch mapping and SHA256 verification
- lib/diagnostics.js - CI terminal diagnostic formatters
- lib/install-trivy.js  - Trivy install/verify entrypoint (replaces bash step)
- lib/extract-sboms.js  - SBOM extraction entrypoint (replaces bash step)
- lib/scan-sboms.js     - SBOM scan + diff entrypoint (replaces bash step)

Tests added (all pass, auto-discovered by
ode --test):
- test/platforms.test.js
- test/distro.test.js
- test/verifier.test.js
- test/diagnostics.test.js
- test/orchestration.test.js

action.yaml shrinks from 554 to 107 lines. Inputs are bridged to Node
scripts via INPUT_* env vars so modules are fully decoupled from the
Actions runner and testable in isolation.
2026-09-15 00:34:23 +02:00
24 changed files with 1185 additions and 865 deletions

No files matched your search

-111
View File
@@ -1,111 +0,0 @@
name: CI Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
unit-test:
name: JS Unit Tests
runs-on: k8s
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Run JS Unit Tests
shell: bash
run: node --test
test:
name: ${{ matrix.image_set.name }} (sbom=${{ matrix.use_inbuild_sbom }})
needs: unit-test
runs-on: k8s
strategy:
fail-fast: false
matrix:
image_set:
# Test 1: Slim images with built-in SBOMs (2 architectures, 15-40 CVEs each)
- name: with-inbuild-sbom
head_image: debian:bookworm-slim
target_image: debian:bullseye-slim
architectures: amd64,arm64
expect_arch_col: true
# Test 2: Slim images without built-in SBOMs (1 architecture, 70-120 CVEs each)
- name: without-inbuild-sbom
head_image: debian:buster-slim
target_image: debian:stretch-slim
architectures: amd64
expect_arch_col: false
# Test 3: Cross-check (Head has built-in SBOM, Target does not, 15-70 CVEs each)
- name: cross-check-mixed-sbom
head_image: debian:bookworm-slim
target_image: debian:buster-slim
architectures: amd64
expect_arch_col: false
use_inbuild_sbom:
- 'true'
- 'false'
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Test CVE Differential Action
uses: ./
with:
head-image: ${{ matrix.image_set.head_image }}
target-image: ${{ matrix.image_set.target_image }}
target-branch: main
architectures: ${{ matrix.image_set.architectures }}
use-inbuild-sbom: ${{ matrix.use_inbuild_sbom }}
output-delta: cve-delta.md
output-summary: cve-summary.md
- name: Verify Report Generation & Content
shell: bash
run: |
set -euo pipefail
test -s cve-delta.md || (echo "::error::cve-delta.md is empty or missing" && exit 1)
test -s cve-summary.md || (echo "::error::cve-summary.md is empty or missing" && exit 1)
# Verify that each scan produced at least 3 CVE findings
cve_count=$(grep -c "CVE-" cve-summary.md || echo 0)
echo "Total CVE entries found in summary report: $cve_count"
if [ "$cve_count" -lt 3 ]; then
echo "::error::Expected at least 3 CVEs in cve-summary.md, found only $cve_count"
exit 1
fi
echo "=== Delta Report Preview ==="
head -n 25 cve-delta.md
# Verify architectural column rendering behavior
if [ "${{ matrix.image_set.expect_arch_col }}" = "true" ]; then
grep -q "| Arch |" cve-delta.md || (echo "::error::Expected Arch column in multi-arch report" && exit 1)
else
if grep -q "| Arch |" cve-delta.md; then
echo "::error::Did not expect Arch column in single-arch report" && exit 1
fi
fi
echo "✅ All assertions passed (including >= 3 CVEs verification)."
- name: Archive SBOMs and CVE Reports
if: always()
uses: actions/upload-artifact@v7
with:
name: sboms-and-reports-${{ matrix.image_set.name }}-inbuild-${{ matrix.use_inbuild_sbom }}
path: |
sboms/
cve-reports/
cve-delta.md
cve-summary.md
if-no-files-found: ignore
-1
View File
@@ -13,4 +13,3 @@ cve-*.json
cve-delta.md
cve-summary.md
node_modules/
!test/fixtures/**
+13 -27
View File
@@ -7,12 +7,13 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
## ✨ Features
- **Dynamic Multi-Platform Support**: Automatically discovers and scans all architectures present in container images, or iterates over an explicit list of target architectures.
- **Fast Attestation Extraction with Automatic Fallback**: Uses `docker buildx imagetools inspect` to extract platform-specific SBOMs directly from registry manifest indices. If attestations are missing or produce 0 CVE findings (common when third-party generators omit OS qualifiers in SPDX), it automatically falls back to generating SBOMs directly with Trivy per architecture.
- **Fast Attestation Extraction**: Uses `docker buildx imagetools inspect` to extract platform-specific SBOMs directly from registry manifest indices in seconds without downloading layer blobs.
- **Graceful Fallbacks**: If SBOM attestations are missing for any platform, automatically falls back to generating SBOMs directly with Trivy per architecture.
- **Set-Based Differential Analysis**: Categorizes vulnerabilities into:
- 🔴 **Introduced Vulnerabilities** (newly added in this branch / commit)
- 🟢 **Fixed Vulnerabilities** (present in target baseline, resolved in this branch)
- ⚪ **Unchanged Vulnerabilities** (pre-existing in target baseline)
- **Secure & Unit Tested**: Verified Trivy binary downloads against static SHA256 checksum lockfiles, single-step unified caching, and comprehensive native unit tests (`node:test`).
- **Secure by Default**: Verified Trivy binary downloads against static SHA256 checksum lockfiles and single-step unified caching (binary + vulnerability database).
---
@@ -27,7 +28,7 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
| `output-delta` | Output path for the CVE delta Markdown report | No | `cve-delta.md` |
| `output-summary` | Output path for the full vulnerability Markdown summary | No | `cve-summary.md` |
| `write-summary` | Whether to append the generated reports to the job summary (`$GITHUB_STEP_SUMMARY`) | No | `true` |
| `use-inbuild-sbom` | Whether to inspect and use built-in image SBOM attestations. If false (recommended default), generates SBOMs directly with Trivy for maximum vulnerability fidelity. If true, extracts attestations without pulling layers (faster, but findings reflect binary-package SBOMs without rootfs source-mapping; falls back to Trivy image scan if 0 CVEs detected). | No | `false` |
| `use-inbuild-sbom` | Whether to inspect and use built-in image SBOM attestations. If false, generates SBOMs directly with Trivy | No | `false` |
---
@@ -47,36 +48,21 @@ This action supports two modes for obtaining SBOMs:
### 1. Direct Image Scanning with Trivy (`use-inbuild-sbom: false` — Default & Recommended)
By default, the action uses `trivy image --format spdx-json` to generate the SBOM directly from the container images:
- **Full Filesystem Fidelity**: Inspects the entire flattened root filesystem, reading `/etc/os-release` and native package databases (e.g. `/var/lib/dpkg/status`, RPM databases, apk).
- **Accurate OS & Language Package Detection**: Catalogs both OS packages (Debian, Alpine, RHEL, etc.) and application dependencies (Python, Node.js, Go, etc.).
- **Accurate OS & Language Package Detection**: Successfully catalogs both OS packages (Debian, Alpine, RHEL, etc.) and application dependencies (Python, Node.js, Go, etc.).
- **Symmetric, Apples-to-Apples Comparisons**: Both `head` and `target` images are analyzed using the identical Trivy engine and vulnerability database, preventing skewed diffs.
### 2. Built-in Attestation Extraction with Resilient Fallback (`use-inbuild-sbom: true`)
### 2. Built-in Attestation Extraction (`use-inbuild-sbom: true`)
When enabled, the action extracts SBOM attestations attached to the container image using `docker buildx imagetools inspect`:
- **Speed**: Extracts the SBOM JSON metadata directly from the registry manifest index in seconds without pulling image layer blobs.
- **Underlying Generator (Syft / Docker Scout)**: Docker Buildx (`sbom: true`) uses BuildKit's embedded Syft scanner (`moby/buildkit-syft-scanner`).
- **Distro Detection**: Automatically extracts OS distribution information from package PURLs (e.g. `debian/12`) and queries Trivy with `--distro` to accurately identify vulnerabilities on the extracted SBOM.
- **Resilient Fallback**: If an extracted in-build SBOM scan produces 0 CVE findings (a common issue when third-party generators omit OS distribution qualifiers from SPDX packages) or if scanning fails, the action **automatically falls back** to running `trivy image --platform "$platform" --format spdx-json` directly on the image. This guarantees that vulnerability scans never fail silently or report false zeros.
- **Audit Copies**: The action preserves copies of both the raw extracted in-build SBOM (`inbuild-sbom-*.spdx.json`) and the final scanned SBOM (`sbom-*.spdx.json`) for auditing and debugging.
### ⚖️ Trade-offs: Why do the results differ between strategies?
The vulnerability results between Strategy 1 (`sbom: false`) and Strategy 2 (`sbom: true`) are **not identical**:
| Feature / Metric | Direct Trivy Scan (`use-inbuild-sbom: false`) | Built-in Attestation (`use-inbuild-sbom: true`) |
| :--- | :--- | :--- |
| **Generator** | Trivy directly on container rootfs | Docker Scout / BuildKit (Syft) |
| **Execution Speed** | Slower (pulls image layer blobs) | **Very Fast** (~5x faster, 0 layer downloads) |
| **Debian Slim Findings Example** | **~225 CVEs** (`debian:bookworm-slim`) | **~104 CVEs** (`debian:bookworm-slim`) |
| **Package Resolution Model** | Source & binary packages (`/var/lib/dpkg/status`) | Binary packages cataloged in SPDX PURLs |
| **Filesystem Inspection** | Full filesystem (static binaries, libraries) | Manifest-only (packages cataloged at build time) |
| **Primary Use Case** | **High-fidelity security audits & release gates** | **High-speed PR checks & developer feedback loops** |
#### Why the vulnerability counts differ:
1. **Debian Source vs. Binary Packages**: Debian security advisories (DSAs / DLAs) are tracked and published per **source package** (e.g., the source package `glibc` produces multiple binary packages like `libc6`, `libc-bin`, `locales`). Trivy's filesystem scanner reads `/var/lib/dpkg/status` to map installed binary packages directly to Debian source package advisories. Syft's default SPDX output catalogs installed binary packages as PURLs without source-package relations, so Trivy's SBOM scan queries only the exact binary package entries that match Debian database schemas without the source-mapping context.
2. **Filesystem vs. Manifest Scope**: Direct image scanning unpacks and inspects the entire container image filesystem (including file hashes, crypto libraries, and static binaries). An in-build SBOM scan is strictly limited to the package inventory written by the build-time generator.
- **Underlying Generator (Syft)**: Docker Buildx (`sbom: true`) uses BuildKit's embedded Syft scanner (`moby/buildkit-syft-scanner`).
- **Known Limitations with Trivy**:
- **Testing/Rolling Distributions**: Embedded Syft versions may not recognize newer or unreleased distributions (such as Debian 13 "Trixie"), recording the OS as `family="none"` and skipping OS package vulnerability matching.
- **Multi-Stage Builds**: Analyzing layer diffs rather than the flattened container rootfs can cause package database paths to be omitted or detached from the OS manifest.
- **Schema/PURL Discrepancies**: Syft and Trivy express certain package qualifiers and namespaces differently in SPDX. When Trivy scans a Syft SPDX file that lacks explicit distro qualifiers, Trivy will silently skip OS vulnerability checks.
- **Differential Skew**: If a `head` image uses an incomplete Syft SBOM (detecting only language packages) while `target` is scanned directly with Trivy (detecting 400+ OS packages), the differential report will erroneously show hundreds of "resolved" CVEs.
> [!TIP]
> Setting `use-inbuild-sbom: 'false'` remains the recommended default for maximum accuracy and cross-platform consistency. If you choose to enable `use-inbuild-sbom: 'true'`, the built-in fallback ensures full coverage even when third-party attestations lack OS package metadata.
> If your builds run on stable base images where BuildKit's Syft scanner accurately captures all OS packages, you can set `use-inbuild-sbom: 'true'` to speed up CI runs. Otherwise, leave it as `false` to ensure consistent and comprehensive scans.
---
+23 -470
View File
@@ -48,13 +48,13 @@ runs:
id: vars
shell: bash
env:
TRIVY_VERSION: 0.75.0 # trivy-version
TRIVY_VERSION: 0.74.0 # trivy-version
run: |
echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
echo "version=${TRIVY_VERSION}" >> "$GITHUB_OUTPUT"
- name: Cache Trivy (Binary & Vulnerability DB)
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cache/trivy-bin
@@ -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
+22 -22
View File
@@ -1,22 +1,22 @@
89c88a41cc259cb93951a4fc923fbeee145302c369cc098b2af56298569cfa06 trivy_0.75.0_FreeBSD-64bit.tar.gz
21e1b4cfadfd4c975ee95c77b03c3ef3ffa8de732cbaf45b729e3bd5a59a4c67 trivy_0.75.0_Linux-32bit.deb
b617436b0b55c04f58f1f654ba80e98f1063d21ceaff2cc192f830bacb1d0aba trivy_0.75.0_Linux-32bit.rpm
5dca0e5b0579a1c5fff1bf7dc541185020a3df892fe7c3345a1bf22988cc0e9c trivy_0.75.0_Linux-32bit.tar.gz
b6d7b944b6cbf4e7de5b4bc6c3635900c50fed53b64b77b99a5fbc3136cd1d5d trivy_0.75.0_Linux-64bit.deb
fba2b73579645839892dfd7b94c5551983f0f094ff88efc94a69383de00a1f26 trivy_0.75.0_Linux-64bit.rpm
c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f trivy_0.75.0_Linux-64bit.tar.gz
6d35e18b2f09c3f389c5cf94f0f259c5320debf1ce16c37f010d9a8ca1bec88f trivy_0.75.0_Linux-ARM.deb
a39a931e5b88050892e9811f843e0c05e29c73c03bac4afffc7508a45b215dff trivy_0.75.0_Linux-ARM.rpm
8cbe2286600ba56267fab0f10cf5696c252993b7b7935c5cef518abab3c84af4 trivy_0.75.0_Linux-ARM.tar.gz
23018f1f7136b8391bb300e2579bcf4759e3c64012856f51c0fb6fffe1ee3841 trivy_0.75.0_Linux-ARM64.deb
a28a78b82c06049d3b1d8c4b17ddb4586357ccf8e4b0aa1b30d0867beb3b11a0 trivy_0.75.0_Linux-ARM64.rpm
a1ee9f6ffb7d112b64ff726a2a0717c21175c1114361391f4a132956751a13b3 trivy_0.75.0_Linux-ARM64.tar.gz
eed0d48c91904036a1c039c3c8c1c2b13eee8983b101c371dbe49c9ae24d8e01 trivy_0.75.0_Linux-PPC64LE.deb
cfefab190d7d972735ed372ec32e5adda6af30ff24d7e0669bfd0eac9a15a1b5 trivy_0.75.0_Linux-PPC64LE.rpm
dbd7403d4191b14efd5ee4a808c93bc97b56fa9f62dd98a691a61bf6e556642e trivy_0.75.0_Linux-PPC64LE.tar.gz
f440ffa772be9d517f5e03b93bfe99035bc28dd760d841877c5994259481c94a trivy_0.75.0_Linux-s390x.deb
c1269d4af420a69e81cb0b7cf234728db5bfe6ecc350c78f5f3bbfd0be1c228c trivy_0.75.0_Linux-s390x.rpm
6acd3a3af4688567f5d3cdf3dd45e871ae060a88c07cea01401ae1487c4f19a3 trivy_0.75.0_Linux-s390x.tar.gz
291edaa9778acbe4693d067b5ad60ee11570e5ac68296e85595417528ca641e4 trivy_0.75.0_macOS-64bit.tar.gz
4a77108cccf8e55c8d6823e1e759939a622277e66cd0daa3c1fc621ed69e4568 trivy_0.75.0_macOS-ARM64.tar.gz
4e43bd71a30f51aee39525f60f2b47043af77eb8df8fe082aae4372b69c6660f trivy_0.75.0_windows-64bit.zip
b8e8834a90b0f28c172498a96d1b91876eb2f09867f02aa314df9a862aa86227 trivy_0.74.0_FreeBSD-64bit.tar.gz
cea47f6624cab9123a6bebc2f6586f3f82b8cd3165fc0ecd21677ba8a4ca0f5c trivy_0.74.0_Linux-32bit.deb
bec3a7aed02d60b6bb2996bb63897f604464c2db0b2b29dfc3cc7ce9959c8aaf trivy_0.74.0_Linux-32bit.rpm
048af0c9597a8e275f60bb9e0543748dbc76e22bdd52456b84ea0fa0cd2a078a trivy_0.74.0_Linux-32bit.tar.gz
cf1e32ec8d4d8823e023096a28cadb14f5b5123ce03f201fb633c5b76aa712dd trivy_0.74.0_Linux-64bit.deb
2331b82ec0ce5529a09fad8ccbfe03987ec9277ff4fbd6e96c6cb0aff8103477 trivy_0.74.0_Linux-64bit.rpm
2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a trivy_0.74.0_Linux-64bit.tar.gz
5665eaa443bfb117c5c4c373254f0ba9e2888985e6233588ce98bb0960f558c3 trivy_0.74.0_Linux-ARM.deb
bdd73ea298ec538ffae3351614a3adefc8c48599fce7943e6376fcfa040c0e85 trivy_0.74.0_Linux-ARM.rpm
99adbe319fa685cb4bff04dd0077e31cb94828338cab5db7f78fe6dcecca8ec1 trivy_0.74.0_Linux-ARM.tar.gz
14fae2604eab589fd544224ef56b8b8a44266872c43b259bd36cfde81428acc8 trivy_0.74.0_Linux-ARM64.deb
bb753f2dacd22417ac7709fdd910a0b017f4bb43330eac8b51c2a90403ae2fd3 trivy_0.74.0_Linux-ARM64.rpm
b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5 trivy_0.74.0_Linux-ARM64.tar.gz
16a1e36b6d405951b36f1e4b31bb7655f8551f1581934fcb2e6e5bdf8c46f6c9 trivy_0.74.0_Linux-PPC64LE.deb
cc7183af95dbab1ae1400fca17b35ca8f9591676a1042144994698cad9cac5a6 trivy_0.74.0_Linux-PPC64LE.rpm
c5aabfe8789d0d43f578ef2d01174884c8ff0f2106c627a220a43f7bf9054959 trivy_0.74.0_Linux-PPC64LE.tar.gz
f8fe56cee9a502e82f05e45465943e3bc6e75de05530ffc7bddf93e65b0de5bb trivy_0.74.0_Linux-s390x.deb
4fb2d5bd1ab5e61917e73cd2936daeae1c5ab9af024321a222437d824bac5579 trivy_0.74.0_Linux-s390x.rpm
516cfe38eaede79c178088265657aad12be6c155271ac68ff728a141961ef197 trivy_0.74.0_Linux-s390x.tar.gz
472816f6888dda689d075c30254d4210b4d1035acf365aa72332f584c2f60485 trivy_0.74.0_macOS-64bit.tar.gz
1caada5e0e2091909357c7525d3aa76f4b660b13821bc143b190c7483e31cc11 trivy_0.74.0_macOS-ARM64.tar.gz
94c40e0696e4b907a74b7b2e1438d5d72ebaca83115817407f568a002d520842 trivy_0.74.0_windows-64bit.zip
+4 -5
View File
@@ -84,15 +84,14 @@ function loadReportsForArg(arg) {
process.exit(1);
}
if (!data || typeof data !== 'object') {
console.error(`::error::Malformed Trivy report structure in: ${filePath}`);
if (!data || typeof data !== 'object' || !Array.isArray(data.Results)) {
console.error(`::error::Malformed Trivy report structure (missing Results array) in: ${filePath}`);
process.exit(1);
}
const results = Array.isArray(data.Results) ? data.Results : [];
loadedFiles++;
for (const result of results) {
for (const result of data.Results) {
const target = result.Target || result.Type || 'App';
const vulns = Array.isArray(result.Vulnerabilities) ? result.Vulnerabilities : [];
for (const v of vulns) {
@@ -136,7 +135,7 @@ function formatVulnRow(v, allScannedArchs = new Set(), showArch = true) {
const installed = v.InstalledVersion || '-';
const fixed = v.FixedVersion || '-';
let title = (v.Title || v.Description || '').replace(/\n/g, ' ').replace(/\|/g, '\\|').trim();
let title = (v.Title || v.Description || '').replace(/\n/g, ' ').trim();
if (title.length > 80) {
title = title.slice(0, 77) + '...';
}
+106
View File
@@ -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,
};
+42
View File
@@ -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,
};
+202
View File
@@ -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,
};
+75
View File
@@ -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 };
+54
View File
@@ -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,
};
+280
View File
@@ -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,
};
+56
View File
@@ -0,0 +1,56 @@
/**
* Architecture mapping and cryptographic checksum verification.
*/
const fs = require('node:fs');
const crypto = require('node:crypto');
function mapArch(unameArch) {
const normalized = (unameArch || '').trim().toLowerCase();
switch (normalized) {
case 'x86_64':
case 'amd64':
return '64bit';
case 'aarch64':
case 'arm64':
return 'ARM64';
default:
throw new Error(`Unsupported architecture: ${unameArch}`);
}
}
function parseChecksums(checksumsContent) {
const map = new Map();
if (!checksumsContent) return map;
const lines = checksumsContent.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// Format: <hash> <filename>
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+(.+)$/);
if (match) {
const hash = match[1].toLowerCase();
const filename = match[2].trim();
map.set(filename, hash);
}
}
return map;
}
function verifyFileSha256(filePath, expectedHash) {
if (!fs.existsSync(filePath) || !expectedHash) return false;
try {
const fileBuffer = fs.readFileSync(filePath);
const actualHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
return actualHash.toLowerCase() === expectedHash.toLowerCase();
} catch {
return false;
}
}
module.exports = {
mapArch,
parseChecksums,
verifyFileSha256,
};
-111
View File
@@ -1,111 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const {
loadReportsForArg,
formatVulnRow,
renderMarkdownDiff,
renderFullSummary,
} = require('../cve-diff.js');
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
test('loadReportsForArg loads valid reports and extracts multi-arch metadata', () => {
const targetDir = path.join(FIXTURES_DIR, 'target');
const cves = loadReportsForArg(targetDir);
assert.equal(cves.size, 2);
assert.ok(cves.has('CVE-2023-0001:::glibc'));
assert.ok(cves.has('CVE-2023-0002:::openssl'));
const glibc = cves.get('CVE-2023-0001:::glibc');
assert.equal(glibc.Severity, 'CRITICAL');
assert.deepEqual(Array.from(glibc._archs).sort(), ['amd64', 'arm64']);
assert.deepEqual(Array.from(cves.scannedArchs).sort(), ['amd64', 'arm64']);
const openssl = cves.get('CVE-2023-0002:::openssl');
assert.equal(openssl.Severity, 'HIGH');
assert.deepEqual(Array.from(openssl._archs), ['amd64']);
});
test('loadReportsForArg handles reports with missing or null Results array without crashing', () => {
const emptyDir = path.join(FIXTURES_DIR, 'empty');
const cves = loadReportsForArg(emptyDir);
assert.equal(cves.size, 0);
});
test('renderMarkdownDiff produces correct introduced, resolved, and unchanged sets', () => {
const targetDir = path.join(FIXTURES_DIR, 'target');
const headDir = path.join(FIXTURES_DIR, 'head');
const baseCves = loadReportsForArg(targetDir);
const branchCves = loadReportsForArg(headDir);
const diff = renderMarkdownDiff(baseCves, branchCves, 'main');
// Introduced: CVE-2023-0003 (curl)
assert.ok(diff.includes('New Vulnerabilities Introduced (1)'));
assert.ok(diff.includes('CVE-2023-0003'));
assert.ok(diff.includes('`curl`'));
// Resolved: CVE-2023-0001 (glibc)
assert.ok(diff.includes('Resolved / Removed Vulnerabilities (1)'));
assert.ok(diff.includes('CVE-2023-0001'));
assert.ok(diff.includes('`glibc`'));
// Unchanged count
assert.ok(diff.includes('| ⚪ **Unchanged Vulnerabilities** | 1 |'));
// Multi-arch is present in fixtures (amd64, arm64), so Arch column should be shown
assert.ok(diff.includes('| Arch |'));
});
test('renderMarkdownDiff hides Arch column when single architecture is scanned', () => {
const targetSingle = path.join(FIXTURES_DIR, 'target', 'cve-amd64.json');
const headSingle = path.join(FIXTURES_DIR, 'head', 'cve-amd64.json');
const baseCves = loadReportsForArg(targetSingle);
const branchCves = loadReportsForArg(headSingle);
const diff = renderMarkdownDiff(baseCves, branchCves, 'main');
assert.ok(!diff.includes('| Arch |'));
assert.ok(diff.includes('New Vulnerabilities Introduced (1)'));
assert.ok(diff.includes('Resolved / Removed Vulnerabilities (1)'));
});
test('renderFullSummary outputs summary table and lists all vulnerabilities', () => {
const headDir = path.join(FIXTURES_DIR, 'head');
const headCves = loadReportsForArg(headDir);
const summary = renderFullSummary(headCves);
assert.ok(summary.includes('### 🛡️ Security Scan Report'));
assert.ok(summary.includes('| Total Vulnerabilities | **2** |'));
assert.ok(summary.includes('CVE-2023-0002'));
assert.ok(summary.includes('CVE-2023-0003'));
});
test('formatVulnRow properly formats badge, architecture tag, and escape pipes', () => {
const vuln = {
Severity: 'CRITICAL',
VulnerabilityID: 'CVE-2023-0001',
PrimaryURL: 'https://avd.aquasec.com/nvd/cve-2023-0001',
PkgName: 'glibc',
InstalledVersion: '2.31',
FixedVersion: '2.32',
Title: 'A vulnerability with | pipe symbol',
_archs: new Set(['amd64']),
};
const allArchs = new Set(['amd64', 'arm64']);
const rowWithArch = formatVulnRow(vuln, allArchs, true);
assert.ok(rowWithArch.includes('🔴 CRITICAL'));
assert.ok(rowWithArch.includes('`amd64`'));
assert.ok(rowWithArch.includes('\\|')); // Pipe escaped
const rowWithoutArch = formatVulnRow(vuln, allArchs, false);
assert.ok(!rowWithoutArch.includes('`amd64`'));
});
+76
View File
@@ -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);
});
+81
View File
@@ -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);
});
-8
View File
@@ -1,8 +0,0 @@
{
"SchemaVersion": 2,
"ArtifactName": "debian:bullseye-slim",
"ArtifactType": "spdx",
"Metadata": {
"OS": null
}
}
-30
View File
@@ -1,30 +0,0 @@
{
"SchemaVersion": 2,
"ArtifactName": "debian:bookworm-slim",
"ArtifactType": "container_image",
"Results": [
{
"Target": "debian:bookworm-slim (debian 12)",
"Class": "os-pkgs",
"Type": "debian",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2023-0002",
"PkgName": "openssl",
"InstalledVersion": "1.1.1n-0+deb11u4",
"FixedVersion": "1.1.1n-0+deb11u5",
"Severity": "HIGH",
"Title": "Timing attack in OpenSSL"
},
{
"VulnerabilityID": "CVE-2023-0003",
"PkgName": "curl",
"InstalledVersion": "7.88.1-10",
"FixedVersion": "7.88.1-10+deb12u1",
"Severity": "MEDIUM",
"Title": "Buffer overflow in curl"
}
]
}
]
}
-22
View File
@@ -1,22 +0,0 @@
{
"SchemaVersion": 2,
"ArtifactName": "debian:bookworm-slim",
"ArtifactType": "container_image",
"Results": [
{
"Target": "debian:bookworm-slim (debian 12)",
"Class": "os-pkgs",
"Type": "debian",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2023-0003",
"PkgName": "curl",
"InstalledVersion": "7.88.1-10",
"FixedVersion": "7.88.1-10+deb12u1",
"Severity": "MEDIUM",
"Title": "Buffer overflow in curl"
}
]
}
]
}
-36
View File
@@ -1,36 +0,0 @@
{
"SchemaVersion": 2,
"ArtifactName": "debian:bullseye-slim",
"ArtifactType": "container_image",
"Metadata": {
"OS": {
"Family": "debian",
"Name": "11"
}
},
"Results": [
{
"Target": "debian:bullseye-slim (debian 11)",
"Class": "os-pkgs",
"Type": "debian",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2023-0001",
"PkgName": "glibc",
"InstalledVersion": "2.31-13+deb11u5",
"FixedVersion": "2.31-13+deb11u6",
"Severity": "CRITICAL",
"Title": "Memory corruption in glibc"
},
{
"VulnerabilityID": "CVE-2023-0002",
"PkgName": "openssl",
"InstalledVersion": "1.1.1n-0+deb11u4",
"FixedVersion": "1.1.1n-0+deb11u5",
"Severity": "HIGH",
"Title": "Timing attack in OpenSSL"
}
]
}
]
}
-22
View File
@@ -1,22 +0,0 @@
{
"SchemaVersion": 2,
"ArtifactName": "debian:bullseye-slim",
"ArtifactType": "container_image",
"Results": [
{
"Target": "debian:bullseye-slim (debian 11)",
"Class": "os-pkgs",
"Type": "debian",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2023-0001",
"PkgName": "glibc",
"InstalledVersion": "2.31-13+deb11u5",
"FixedVersion": "2.31-13+deb11u6",
"Severity": "CRITICAL",
"Title": "Memory corruption in glibc"
}
]
}
]
}
+65
View File
@@ -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');
});
+47
View File
@@ -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');
});
+39
View File
@@ -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 });
});