Public Access
generated from Philipp/template-git
Compare commits
26
Commits
599b74ef49
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e464443894 | ||
|
|
d92089a5f4
|
||
|
|
6b70a25cf6 | ||
|
|
2d58558b5a | ||
|
|
45fc314eab | ||
|
|
826f853dff | ||
|
|
8c0c553e4f | ||
|
|
3f03635d45 | ||
|
|
9e607b1a24
|
||
|
|
7343f659bb | ||
|
|
17e5dab1b2 | ||
|
|
493d119b3c | ||
|
|
77357f0c3f
|
||
|
|
46c4fedc47 | ||
|
|
8070a3c155 | ||
|
|
0ccfd5f512 | ||
|
|
c98b31c23a | ||
|
|
def42c37a8
|
||
|
|
64747ec466
|
||
|
|
3b9043a746
|
||
|
|
790c1aa3de
|
||
|
|
f31be5eef6
|
||
|
|
d89fb25d7b
|
||
|
|
2ece0c93ac | ||
|
|
40209bbeed | ||
|
|
44c4b5a304 |
No files matched your search
@@ -0,0 +1,111 @@
|
|||||||
|
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
|
||||||
@@ -13,3 +13,4 @@ cve-*.json
|
|||||||
cve-delta.md
|
cve-delta.md
|
||||||
cve-summary.md
|
cve-summary.md
|
||||||
node_modules/
|
node_modules/
|
||||||
|
!test/fixtures/**
|
||||||
@@ -7,13 +7,12 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
|
|||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Dynamic Multi-Platform Support**: Automatically discovers and scans all architectures present in container images, or iterates over an explicit list of target architectures.
|
- **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**: Uses `docker buildx imagetools inspect` to extract platform-specific SBOMs directly from registry manifest indices in seconds without downloading layer blobs.
|
- **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.
|
||||||
- **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:
|
- **Set-Based Differential Analysis**: Categorizes vulnerabilities into:
|
||||||
- 🔴 **Introduced Vulnerabilities** (newly added in this branch / commit)
|
- 🔴 **Introduced Vulnerabilities** (newly added in this branch / commit)
|
||||||
- 🟢 **Fixed Vulnerabilities** (present in target baseline, resolved in this branch)
|
- 🟢 **Fixed Vulnerabilities** (present in target baseline, resolved in this branch)
|
||||||
- ⚪ **Unchanged Vulnerabilities** (pre-existing in target baseline)
|
- ⚪ **Unchanged Vulnerabilities** (pre-existing in target baseline)
|
||||||
- **Secure by Default**: Verified Trivy binary downloads against static SHA256 checksum lockfiles and single-step unified caching (binary + vulnerability database).
|
- **Secure & Unit Tested**: Verified Trivy binary downloads against static SHA256 checksum lockfiles, single-step unified caching, and comprehensive native unit tests (`node:test`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,7 +27,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-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` |
|
| `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` |
|
| `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, generates SBOMs directly with Trivy | No | `false` |
|
| `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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,21 +47,36 @@ This action supports two modes for obtaining SBOMs:
|
|||||||
### 1. Direct Image Scanning with Trivy (`use-inbuild-sbom: false` — Default & Recommended)
|
### 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:
|
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).
|
- **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**: Successfully catalogs both OS packages (Debian, Alpine, RHEL, etc.) and application dependencies (Python, Node.js, Go, etc.).
|
- **Accurate OS & Language Package Detection**: 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.
|
- **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 (`use-inbuild-sbom: true`)
|
### 2. Built-in Attestation Extraction with Resilient Fallback (`use-inbuild-sbom: true`)
|
||||||
When enabled, the action extracts SBOM attestations attached to the container image using `docker buildx imagetools inspect`:
|
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.
|
- **Speed**: Extracts the SBOM JSON metadata directly from the registry manifest index in seconds without pulling image layer blobs.
|
||||||
- **Underlying Generator (Syft)**: Docker Buildx (`sbom: true`) uses BuildKit's embedded Syft scanner (`moby/buildkit-syft-scanner`).
|
- **Underlying Generator (Syft / Docker Scout)**: Docker Buildx (`sbom: true`) uses BuildKit's embedded Syft scanner (`moby/buildkit-syft-scanner`).
|
||||||
- **Known Limitations with Trivy**:
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
### ⚖️ 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.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 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.
|
> 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+470
-23
@@ -48,13 +48,13 @@ runs:
|
|||||||
id: vars
|
id: vars
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
TRIVY_VERSION: 0.74.0 # trivy-version
|
TRIVY_VERSION: 0.75.0 # trivy-version
|
||||||
run: |
|
run: |
|
||||||
echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
|
echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=${TRIVY_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "version=${TRIVY_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Cache Trivy (Binary & Vulnerability DB)
|
- name: Cache Trivy (Binary & Vulnerability DB)
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cache/trivy-bin
|
~/.cache/trivy-bin
|
||||||
@@ -65,33 +65,480 @@ runs:
|
|||||||
|
|
||||||
- name: Install and Verify Trivy
|
- name: Install and Verify Trivy
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
run: |
|
||||||
TRIVY_VERSION: ${{ steps.vars.outputs.version }}
|
set -euo pipefail
|
||||||
run: node "${{ github.action_path }}/lib/install-trivy.js"
|
|
||||||
|
mkdir -p "$HOME/.local/bin" "$HOME/.cache/trivy-bin"
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "${ARCH}" in
|
||||||
|
x86_64) T_ARCH="64bit" ;;
|
||||||
|
aarch64|arm64) T_ARCH="ARM64" ;;
|
||||||
|
*) echo "::error::Unsupported architecture: ${ARCH}"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
checksums_file="${{ github.action_path }}/checksums.txt"
|
||||||
|
tarball="trivy_${{ steps.vars.outputs.version }}_Linux-${T_ARCH}.tar.gz"
|
||||||
|
|
||||||
|
if [ -f "$HOME/.cache/trivy-bin/${tarball}" ] && (cd "$HOME/.cache/trivy-bin" && grep " ${tarball}$" "$checksums_file" | sha256sum -c -); then
|
||||||
|
echo "Cached Trivy archive verified against lockfile."
|
||||||
|
else
|
||||||
|
echo "Archive missing or checksum mismatch; downloading..."
|
||||||
|
curl -sfLO "https://github.com/aquasecurity/trivy/releases/download/v${{ steps.vars.outputs.version }}/${tarball}" --output-dir "$HOME/.cache/trivy-bin"
|
||||||
|
(cd "$HOME/.cache/trivy-bin" && grep " ${tarball}$" "$checksums_file" | sha256sum -c -)
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -xzf "$HOME/.cache/trivy-bin/${tarball}" -C "$HOME/.local/bin" trivy
|
||||||
|
chmod +x "$HOME/.local/bin/trivy"
|
||||||
|
|
||||||
- name: Extract Multi-Platform SBOMs
|
- name: Extract Multi-Platform SBOMs
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
run: |
|
||||||
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
export TRIVY_SKIP_VERSION_CHECK="true"
|
||||||
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
|
mkdir -p sboms/head sboms/target
|
||||||
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
|
|
||||||
INPUT_ARCHITECTURES: ${{ inputs.architectures }}
|
cleanup_file() {
|
||||||
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
|
local f="$1"
|
||||||
run: node "${{ github.action_path }}/lib/extract-sboms.js"
|
if [ -f "$f" ]; then
|
||||||
|
if [ ! -s "$f" ] || [ "$(cat "$f" 2>/dev/null)" = "null" ]; then
|
||||||
|
rm -f "$f"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
head_sbom_err=""
|
||||||
|
target_sbom_err=""
|
||||||
|
use_inbuild="${{ inputs.use-inbuild-sbom }}"
|
||||||
|
|
||||||
|
# Determine target platforms / architectures
|
||||||
|
PLATFORMS=()
|
||||||
|
if [ -n "${{ inputs.architectures }}" ]; then
|
||||||
|
IFS=',' read -ra RAW_ARCHS <<< "${{ inputs.architectures }}"
|
||||||
|
for a in "${RAW_ARCHS[@]}"; do
|
||||||
|
clean_a=$(echo "$a" | xargs)
|
||||||
|
[ -z "$clean_a" ] && continue
|
||||||
|
if [[ "$clean_a" != *"/"* ]]; then
|
||||||
|
PLATFORMS+=("linux/${clean_a}")
|
||||||
|
else
|
||||||
|
PLATFORMS+=("$clean_a")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Auto-discovering architectures from images..."
|
||||||
|
raw_head=$(docker buildx imagetools inspect --raw "${{ inputs.head-image }}" 2>/dev/null || true)
|
||||||
|
raw_target=$(docker buildx imagetools inspect --raw "${{ inputs.target-image }}" 2>/dev/null || true)
|
||||||
|
|
||||||
|
discovered=$(node -e '
|
||||||
|
const raw1 = process.argv[1];
|
||||||
|
const raw2 = process.argv[2];
|
||||||
|
const set = new Set();
|
||||||
|
for (const raw of [raw1, raw2]) {
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
const manifests = data.manifests || [];
|
||||||
|
for (const m of manifests) {
|
||||||
|
if (m.platform && m.platform.architecture && m.platform.architecture !== "unknown") {
|
||||||
|
const os = m.platform.os || "linux";
|
||||||
|
const variant = m.platform.variant ? "/" + m.platform.variant : "";
|
||||||
|
set.add(`${os}/${m.platform.architecture}${variant}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
console.log(Array.from(set).sort().join("\n"));
|
||||||
|
' "$raw_head" "$raw_target")
|
||||||
|
|
||||||
|
if [ -n "$discovered" ]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] && PLATFORMS+=("$line")
|
||||||
|
done <<< "$discovered"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Configured platforms: ${PLATFORMS[*]:-(single platform fallback)}"
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
echo "ℹ️ Strategy: Built-in SBOM attestation extraction enabled (Docker Scout / Syft). Skips container layer downloads for maximum speed."
|
||||||
|
echo "ℹ️ Note: Built-in SBOM vulnerability counts may differ from direct filesystem scans ('trivy image') due to binary vs. source package tracking."
|
||||||
|
else
|
||||||
|
echo "ℹ️ Strategy: Direct Trivy image scanning enabled (default). Inspects full container rootfs and package databases for maximum fidelity."
|
||||||
|
fi
|
||||||
|
|
||||||
|
for platform in "${PLATFORMS[@]}"; do
|
||||||
|
arch_tag=$(echo "$platform" | sed -e 's#^[a-zA-Z0-9_-]*/##' -e 's#/#-#g')
|
||||||
|
|
||||||
|
# Head image extraction & fallback
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
echo "Attempting extraction of built-in SBOM for head image ($platform)..."
|
||||||
|
docker buildx imagetools inspect "${{ inputs.head-image }}" \
|
||||||
|
--format "{{ json (index .SBOM \"${platform}\").SPDX }}" > "sboms/head/sbom-${arch_tag}.spdx.json" 2>/dev/null || true
|
||||||
|
cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json"
|
||||||
|
[ -f "sboms/head/sbom-${arch_tag}.spdx.json" ] && cp "sboms/head/sbom-${arch_tag}.spdx.json" "sboms/head/inbuild-sbom-${arch_tag}.spdx.json" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "sboms/head/sbom-${arch_tag}.spdx.json" ]; then
|
||||||
|
echo "Generating SBOM for head image ($platform) with Trivy..."
|
||||||
|
if ! trivy image --skip-version-check --platform "$platform" --format spdx-json --output "sboms/head/sbom-${arch_tag}.spdx.json" "${{ inputs.head-image }}"; then
|
||||||
|
echo "::warning::Trivy image scan failed for head image ($platform)"
|
||||||
|
fi
|
||||||
|
cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Target image extraction & fallback
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
echo "Attempting extraction of built-in SBOM for target image ($platform)..."
|
||||||
|
docker buildx imagetools inspect "${{ inputs.target-image }}" \
|
||||||
|
--format "{{ json (index .SBOM \"${platform}\").SPDX }}" > "sboms/target/sbom-${arch_tag}.spdx.json" 2>/dev/null || true
|
||||||
|
cleanup_file "sboms/target/sbom-${arch_tag}.spdx.json"
|
||||||
|
[ -f "sboms/target/sbom-${arch_tag}.spdx.json" ] && cp "sboms/target/sbom-${arch_tag}.spdx.json" "sboms/target/inbuild-sbom-${arch_tag}.spdx.json" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "sboms/target/sbom-${arch_tag}.spdx.json" ]; then
|
||||||
|
echo "Generating SBOM for target image ($platform) with Trivy..."
|
||||||
|
if ! trivy image --skip-version-check --platform "$platform" --format spdx-json --output "sboms/target/sbom-${arch_tag}.spdx.json" "${{ inputs.target-image }}"; then
|
||||||
|
echo "::warning::Trivy image scan failed for target image ($platform)"
|
||||||
|
fi
|
||||||
|
cleanup_file "sboms/target/sbom-${arch_tag}.spdx.json"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Fallbacks if no platform-specific SBOMs were extracted (e.g. single-arch image without index)
|
||||||
|
if [ -z "$(find sboms/head -type f -name 'sbom-*.spdx.json' 2>/dev/null)" ]; then
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
docker buildx imagetools inspect "${{ inputs.head-image }}" \
|
||||||
|
--format '{{ json .SBOM.SPDX }}' > sboms/head/sbom-default.spdx.json 2>/dev/null || true
|
||||||
|
cleanup_file sboms/head/sbom-default.spdx.json
|
||||||
|
[ -f sboms/head/sbom-default.spdx.json ] && cp sboms/head/sbom-default.spdx.json sboms/head/inbuild-sbom-default.spdx.json 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f sboms/head/sbom-default.spdx.json ]; then
|
||||||
|
echo "Generating default head SBOM directly with Trivy..."
|
||||||
|
if ! trivy image --skip-version-check --format spdx-json --output sboms/head/sbom-default.spdx.json "${{ inputs.head-image }}"; then
|
||||||
|
head_sbom_err="Failed to generate SBOM with Trivy for head image"
|
||||||
|
fi
|
||||||
|
cleanup_file sboms/head/sbom-default.spdx.json
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(find sboms/target -type f -name 'sbom-*.spdx.json' 2>/dev/null)" ]; then
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
docker buildx imagetools inspect "${{ inputs.target-image }}" \
|
||||||
|
--format '{{ json .SBOM.SPDX }}' > sboms/target/sbom-default.spdx.json 2>/dev/null || true
|
||||||
|
cleanup_file sboms/target/sbom-default.spdx.json
|
||||||
|
[ -f sboms/target/sbom-default.spdx.json ] && cp sboms/target/sbom-default.spdx.json sboms/target/inbuild-sbom-default.spdx.json 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f sboms/target/sbom-default.spdx.json ]; then
|
||||||
|
echo "Generating default target SBOM directly with Trivy..."
|
||||||
|
if ! trivy image --skip-version-check --format spdx-json --output sboms/target/sbom-default.spdx.json "${{ inputs.target-image }}"; then
|
||||||
|
target_sbom_err="Failed to generate SBOM with Trivy for target image"
|
||||||
|
fi
|
||||||
|
cleanup_file sboms/target/sbom-default.spdx.json
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
head_files=(sboms/head/sbom-*.spdx.json)
|
||||||
|
target_files=(sboms/target/sbom-*.spdx.json)
|
||||||
|
|
||||||
|
if [ ${#head_files[@]} -eq 0 ] && [ -z "$head_sbom_err" ]; then
|
||||||
|
head_sbom_err="No SBOM generated and head image could not be inspected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#target_files[@]} -eq 0 ] && [ -z "$target_sbom_err" ]; then
|
||||||
|
target_sbom_err="No SBOM generated and target baseline image could not be inspected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Diagnostic overview
|
||||||
|
echo ""
|
||||||
|
echo "================================================================================"
|
||||||
|
echo " SBOM EXTRACTION DIAGNOSTIC OVERVIEW "
|
||||||
|
echo "================================================================================"
|
||||||
|
if [ -n "$head_sbom_err" ]; then
|
||||||
|
echo "HEAD Image (${{ inputs.head-image }}): ❌ FAILED - $head_sbom_err"
|
||||||
|
else
|
||||||
|
echo "HEAD Image (${{ inputs.head-image }}): ✅ SUCCESS"
|
||||||
|
for f in "${head_files[@]}"; do
|
||||||
|
echo " - $(basename "$f") ($(wc -c < "$f") bytes)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$target_sbom_err" ]; then
|
||||||
|
echo "TARGET Baseline Image (${{ inputs.target-image }}): ❌ FAILED - $target_sbom_err"
|
||||||
|
else
|
||||||
|
echo "TARGET Baseline Image (${{ inputs.target-image }}): ✅ SUCCESS"
|
||||||
|
for f in "${target_files[@]}"; do
|
||||||
|
echo " - $(basename "$f") ($(wc -c < "$f") bytes)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo "================================================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
has_sbom_err=false
|
||||||
|
if [ -n "$head_sbom_err" ]; then
|
||||||
|
echo "::error::Head SBOM extraction failed: $head_sbom_err"
|
||||||
|
has_sbom_err=true
|
||||||
|
fi
|
||||||
|
if [ -n "$target_sbom_err" ]; then
|
||||||
|
echo "::error::Target baseline SBOM extraction failed: $target_sbom_err"
|
||||||
|
has_sbom_err=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$has_sbom_err" = "true" ]; then
|
||||||
|
echo "::error::Both head and target images must have valid SBOMs to proceed with differential scanning."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Expose extracted head SBOMs to workspace root for workflow artifact uploads
|
||||||
|
for f in sboms/head/sbom-*.spdx.json; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
cp "$f" "$(basename "$f")" || true
|
||||||
|
done
|
||||||
|
|
||||||
- name: Scan Multi-Platform SBOMs and Generate Delta
|
- name: Scan Multi-Platform SBOMs and Generate Delta
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
run: |
|
||||||
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
export TRIVY_SKIP_VERSION_CHECK="true"
|
||||||
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
|
mkdir -p cve-reports/head cve-reports/target
|
||||||
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
|
|
||||||
INPUT_TARGET_BRANCH: ${{ inputs.target-branch }}
|
shopt -s nullglob
|
||||||
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
|
head_sboms=(sboms/head/sbom-*.spdx.json)
|
||||||
INPUT_OUTPUT_DELTA: ${{ inputs.output-delta }}
|
target_sboms=(sboms/target/sbom-*.spdx.json)
|
||||||
INPUT_OUTPUT_SUMMARY: ${{ inputs.output-summary }}
|
|
||||||
run: node "${{ github.action_path }}/lib/scan-sboms.js"
|
head_ok=true
|
||||||
|
target_ok=true
|
||||||
|
head_report_count=0
|
||||||
|
target_report_count=0
|
||||||
|
|
||||||
|
head_summary=()
|
||||||
|
target_summary=()
|
||||||
|
use_inbuild="${{ inputs.use-inbuild-sbom }}"
|
||||||
|
|
||||||
|
detect_distro() {
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||||
|
let name, ver;
|
||||||
|
for (const pkg of (data.packages || [])) {
|
||||||
|
for (const ref of (pkg.externalRefs || [])) {
|
||||||
|
if (ref.referenceType === "purl" && ref.referenceLocator) {
|
||||||
|
const mName = ref.referenceLocator.match(/[?&]os_name=([^&]+)/);
|
||||||
|
const mVer = ref.referenceLocator.match(/[?&]os_version=([^&]+)/);
|
||||||
|
if (mName && mVer) {
|
||||||
|
name = mName[1];
|
||||||
|
ver = mVer[1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name && ver) break;
|
||||||
|
}
|
||||||
|
if (name && ver) console.log(name + "/" + ver);
|
||||||
|
} catch (e) {}
|
||||||
|
' "$1" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Scan Head SBOMs
|
||||||
|
if [ ${#head_sboms[@]} -eq 0 ]; then
|
||||||
|
head_ok=false
|
||||||
|
head_summary+=(" - ❌ No SBOM files found in sboms/head/")
|
||||||
|
else
|
||||||
|
for f in "${head_sboms[@]}"; do
|
||||||
|
arch=$(basename "$f" .spdx.json | sed 's/sbom-//')
|
||||||
|
out_json="cve-reports/head/cve-${arch}.json"
|
||||||
|
err_log="cve-reports/head/cve-${arch}.err"
|
||||||
|
|
||||||
|
echo "Scanning head SBOM for architecture: $arch"
|
||||||
|
distro_arg=""
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
detected_distro=$(detect_distro "$f")
|
||||||
|
if [ -n "$detected_distro" ]; then
|
||||||
|
echo "Detected distribution '${detected_distro}' from in-build SBOM PURLs. Scanning with --distro."
|
||||||
|
distro_arg="--distro ${detected_distro}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
scan_success=false
|
||||||
|
if trivy sbom $distro_arg --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then
|
||||||
|
scan_success=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
vuln_cnt=0
|
||||||
|
if [ -s "$out_json" ]; then
|
||||||
|
cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true)
|
||||||
|
cnt=$(echo "$cnt" | tr -d '[:space:]')
|
||||||
|
[ -n "$cnt" ] && vuln_cnt="$cnt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback if in-build SBOM scan produced 0 CVEs (e.g. incompatible third-party format) or scan failed
|
||||||
|
if [ "$use_inbuild" = "true" ] && { [ "$scan_success" != "true" ] || [ "$vuln_cnt" -eq 0 ]; }; then
|
||||||
|
echo "::warning::In-build SBOM for head image (${arch}) yielded ${vuln_cnt} CVE findings. Falling back to direct Trivy image scan..."
|
||||||
|
t_plat="linux/${arch}"
|
||||||
|
[ "$arch" = "default" ] && t_plat=""
|
||||||
|
plat_arg=""
|
||||||
|
[ -n "$t_plat" ] && plat_arg="--platform $t_plat"
|
||||||
|
if trivy image --skip-version-check $plat_arg --format spdx-json --output "$f" "${{ inputs.head-image }}"; then
|
||||||
|
echo "Re-scanning head SBOM generated directly by Trivy..."
|
||||||
|
if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then
|
||||||
|
scan_success=true
|
||||||
|
if [ -s "$out_json" ]; then
|
||||||
|
cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true)
|
||||||
|
cnt=$(echo "$cnt" | tr -d '[:space:]')
|
||||||
|
[ -n "$cnt" ] && vuln_cnt="$cnt"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$scan_success" = "true" ]; then
|
||||||
|
if [ -s "$out_json" ]; then
|
||||||
|
if [ "$vuln_cnt" -eq 0 ]; then
|
||||||
|
echo "::warning::Head image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized."
|
||||||
|
head_summary+=(" - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)")
|
||||||
|
else
|
||||||
|
head_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)")
|
||||||
|
fi
|
||||||
|
head_report_count=$((head_report_count + 1))
|
||||||
|
else
|
||||||
|
head_ok=false
|
||||||
|
head_summary+=(" - ${arch}: ❌ FAILED (Output report was empty / 0 bytes)")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
head_ok=false
|
||||||
|
err_msg=$(tail -n 2 "$err_log" | tr '\n' ' ' | sed 's/ */ /g')
|
||||||
|
[ -z "$err_msg" ] && err_msg="Trivy scan exited with non-zero status"
|
||||||
|
head_summary+=(" - ${arch}: ❌ FAILED (${err_msg})")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Scan Target SBOMs
|
||||||
|
if [ ${#target_sboms[@]} -eq 0 ]; then
|
||||||
|
target_ok=false
|
||||||
|
target_summary+=(" - ❌ No SBOM files found in sboms/target/")
|
||||||
|
else
|
||||||
|
for f in "${target_sboms[@]}"; do
|
||||||
|
arch=$(basename "$f" .spdx.json | sed 's/sbom-//')
|
||||||
|
out_json="cve-reports/target/cve-${arch}.json"
|
||||||
|
err_log="cve-reports/target/cve-${arch}.err"
|
||||||
|
|
||||||
|
echo "Scanning target SBOM for architecture: $arch"
|
||||||
|
distro_arg=""
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
detected_distro=$(detect_distro "$f")
|
||||||
|
if [ -n "$detected_distro" ]; then
|
||||||
|
echo "Detected distribution '${detected_distro}' from in-build SBOM PURLs. Scanning with --distro."
|
||||||
|
distro_arg="--distro ${detected_distro}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
scan_success=false
|
||||||
|
if trivy sbom $distro_arg --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then
|
||||||
|
scan_success=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
vuln_cnt=0
|
||||||
|
if [ -s "$out_json" ]; then
|
||||||
|
cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true)
|
||||||
|
cnt=$(echo "$cnt" | tr -d '[:space:]')
|
||||||
|
[ -n "$cnt" ] && vuln_cnt="$cnt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback if in-build SBOM scan produced 0 CVEs (e.g. incompatible third-party format) or scan failed
|
||||||
|
if [ "$use_inbuild" = "true" ] && { [ "$scan_success" != "true" ] || [ "$vuln_cnt" -eq 0 ]; }; then
|
||||||
|
echo "::warning::In-build SBOM for target image (${arch}) yielded ${vuln_cnt} CVE findings. Falling back to direct Trivy image scan..."
|
||||||
|
t_plat="linux/${arch}"
|
||||||
|
[ "$arch" = "default" ] && t_plat=""
|
||||||
|
plat_arg=""
|
||||||
|
[ -n "$t_plat" ] && plat_arg="--platform $t_plat"
|
||||||
|
if trivy image --skip-version-check $plat_arg --format spdx-json --output "$f" "${{ inputs.target-image }}"; then
|
||||||
|
echo "Re-scanning target SBOM generated directly by Trivy..."
|
||||||
|
if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then
|
||||||
|
scan_success=true
|
||||||
|
if [ -s "$out_json" ]; then
|
||||||
|
cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true)
|
||||||
|
cnt=$(echo "$cnt" | tr -d '[:space:]')
|
||||||
|
[ -n "$cnt" ] && vuln_cnt="$cnt"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$scan_success" = "true" ]; then
|
||||||
|
if [ -s "$out_json" ]; then
|
||||||
|
if [ "$vuln_cnt" -eq 0 ]; then
|
||||||
|
echo "::warning::Target baseline image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized."
|
||||||
|
target_summary+=(" - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)")
|
||||||
|
else
|
||||||
|
target_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)")
|
||||||
|
fi
|
||||||
|
target_report_count=$((target_report_count + 1))
|
||||||
|
else
|
||||||
|
target_ok=false
|
||||||
|
target_summary+=(" - ${arch}: ❌ FAILED (Output report was empty / 0 bytes)")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
target_ok=false
|
||||||
|
err_msg=$(tail -n 2 "$err_log" | tr '\n' ' ' | sed 's/ */ /g')
|
||||||
|
[ -z "$err_msg" ] && err_msg="Trivy scan exited with non-zero status"
|
||||||
|
target_summary+=(" - ${arch}: ❌ FAILED (${err_msg})")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Print Comprehensive Diagnostic Overview
|
||||||
|
echo ""
|
||||||
|
echo "================================================================================"
|
||||||
|
echo " CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW "
|
||||||
|
echo "================================================================================"
|
||||||
|
if [ "$use_inbuild" = "true" ]; then
|
||||||
|
echo "Scan Strategy: Built-in OCI Attestation (Syft/Docker Scout) with distro detection"
|
||||||
|
else
|
||||||
|
echo "Scan Strategy: Direct Trivy Image Scan (Full rootfs & source package mapping)"
|
||||||
|
fi
|
||||||
|
echo "--------------------------------------------------------------------------------"
|
||||||
|
echo "HEAD Image: ${{ inputs.head-image }}"
|
||||||
|
for line in "${head_summary[@]}"; do
|
||||||
|
echo "$line"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "TARGET Baseline Image: ${{ inputs.target-image }} (branch: '${{ inputs.target-branch }}')"
|
||||||
|
for line in "${target_summary[@]}"; do
|
||||||
|
echo "$line"
|
||||||
|
done
|
||||||
|
echo "================================================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4. Enforce Gating: Both must have succeeded with valid reports
|
||||||
|
has_error=false
|
||||||
|
if [ "$head_ok" != "true" ] || [ "$head_report_count" -eq 0 ]; then
|
||||||
|
echo "::error::Head image CVE analysis failed or produced no reports."
|
||||||
|
has_error=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$target_ok" != "true" ] || [ "$target_report_count" -eq 0 ]; then
|
||||||
|
echo "::error::Target baseline CVE analysis failed or produced no reports."
|
||||||
|
has_error=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$has_error" = "true" ]; then
|
||||||
|
echo "::error::Aborting differential analysis: both head and target scans must succeed before diffing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Run multi-arch set-based diff and generate consolidated full summary report
|
||||||
|
echo "Both head and target scans succeeded. Computing set-based vulnerability delta..."
|
||||||
|
if ! node "${{ github.action_path }}/cve-diff.js" \
|
||||||
|
"cve-reports/target" \
|
||||||
|
"cve-reports/head" \
|
||||||
|
"${{ inputs.target-branch }}" \
|
||||||
|
"${{ inputs.output-summary }}" > "${{ inputs.output-delta }}"; then
|
||||||
|
echo "::error::Failed to generate CVE differential report"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Publish Reports to Step Summary
|
- name: Publish Reports to Step Summary
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
+22
-22
@@ -1,22 +1,22 @@
|
|||||||
b8e8834a90b0f28c172498a96d1b91876eb2f09867f02aa314df9a862aa86227 trivy_0.74.0_FreeBSD-64bit.tar.gz
|
89c88a41cc259cb93951a4fc923fbeee145302c369cc098b2af56298569cfa06 trivy_0.75.0_FreeBSD-64bit.tar.gz
|
||||||
cea47f6624cab9123a6bebc2f6586f3f82b8cd3165fc0ecd21677ba8a4ca0f5c trivy_0.74.0_Linux-32bit.deb
|
21e1b4cfadfd4c975ee95c77b03c3ef3ffa8de732cbaf45b729e3bd5a59a4c67 trivy_0.75.0_Linux-32bit.deb
|
||||||
bec3a7aed02d60b6bb2996bb63897f604464c2db0b2b29dfc3cc7ce9959c8aaf trivy_0.74.0_Linux-32bit.rpm
|
b617436b0b55c04f58f1f654ba80e98f1063d21ceaff2cc192f830bacb1d0aba trivy_0.75.0_Linux-32bit.rpm
|
||||||
048af0c9597a8e275f60bb9e0543748dbc76e22bdd52456b84ea0fa0cd2a078a trivy_0.74.0_Linux-32bit.tar.gz
|
5dca0e5b0579a1c5fff1bf7dc541185020a3df892fe7c3345a1bf22988cc0e9c trivy_0.75.0_Linux-32bit.tar.gz
|
||||||
cf1e32ec8d4d8823e023096a28cadb14f5b5123ce03f201fb633c5b76aa712dd trivy_0.74.0_Linux-64bit.deb
|
b6d7b944b6cbf4e7de5b4bc6c3635900c50fed53b64b77b99a5fbc3136cd1d5d trivy_0.75.0_Linux-64bit.deb
|
||||||
2331b82ec0ce5529a09fad8ccbfe03987ec9277ff4fbd6e96c6cb0aff8103477 trivy_0.74.0_Linux-64bit.rpm
|
fba2b73579645839892dfd7b94c5551983f0f094ff88efc94a69383de00a1f26 trivy_0.75.0_Linux-64bit.rpm
|
||||||
2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a trivy_0.74.0_Linux-64bit.tar.gz
|
c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f trivy_0.75.0_Linux-64bit.tar.gz
|
||||||
5665eaa443bfb117c5c4c373254f0ba9e2888985e6233588ce98bb0960f558c3 trivy_0.74.0_Linux-ARM.deb
|
6d35e18b2f09c3f389c5cf94f0f259c5320debf1ce16c37f010d9a8ca1bec88f trivy_0.75.0_Linux-ARM.deb
|
||||||
bdd73ea298ec538ffae3351614a3adefc8c48599fce7943e6376fcfa040c0e85 trivy_0.74.0_Linux-ARM.rpm
|
a39a931e5b88050892e9811f843e0c05e29c73c03bac4afffc7508a45b215dff trivy_0.75.0_Linux-ARM.rpm
|
||||||
99adbe319fa685cb4bff04dd0077e31cb94828338cab5db7f78fe6dcecca8ec1 trivy_0.74.0_Linux-ARM.tar.gz
|
8cbe2286600ba56267fab0f10cf5696c252993b7b7935c5cef518abab3c84af4 trivy_0.75.0_Linux-ARM.tar.gz
|
||||||
14fae2604eab589fd544224ef56b8b8a44266872c43b259bd36cfde81428acc8 trivy_0.74.0_Linux-ARM64.deb
|
23018f1f7136b8391bb300e2579bcf4759e3c64012856f51c0fb6fffe1ee3841 trivy_0.75.0_Linux-ARM64.deb
|
||||||
bb753f2dacd22417ac7709fdd910a0b017f4bb43330eac8b51c2a90403ae2fd3 trivy_0.74.0_Linux-ARM64.rpm
|
a28a78b82c06049d3b1d8c4b17ddb4586357ccf8e4b0aa1b30d0867beb3b11a0 trivy_0.75.0_Linux-ARM64.rpm
|
||||||
b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5 trivy_0.74.0_Linux-ARM64.tar.gz
|
a1ee9f6ffb7d112b64ff726a2a0717c21175c1114361391f4a132956751a13b3 trivy_0.75.0_Linux-ARM64.tar.gz
|
||||||
16a1e36b6d405951b36f1e4b31bb7655f8551f1581934fcb2e6e5bdf8c46f6c9 trivy_0.74.0_Linux-PPC64LE.deb
|
eed0d48c91904036a1c039c3c8c1c2b13eee8983b101c371dbe49c9ae24d8e01 trivy_0.75.0_Linux-PPC64LE.deb
|
||||||
cc7183af95dbab1ae1400fca17b35ca8f9591676a1042144994698cad9cac5a6 trivy_0.74.0_Linux-PPC64LE.rpm
|
cfefab190d7d972735ed372ec32e5adda6af30ff24d7e0669bfd0eac9a15a1b5 trivy_0.75.0_Linux-PPC64LE.rpm
|
||||||
c5aabfe8789d0d43f578ef2d01174884c8ff0f2106c627a220a43f7bf9054959 trivy_0.74.0_Linux-PPC64LE.tar.gz
|
dbd7403d4191b14efd5ee4a808c93bc97b56fa9f62dd98a691a61bf6e556642e trivy_0.75.0_Linux-PPC64LE.tar.gz
|
||||||
f8fe56cee9a502e82f05e45465943e3bc6e75de05530ffc7bddf93e65b0de5bb trivy_0.74.0_Linux-s390x.deb
|
f440ffa772be9d517f5e03b93bfe99035bc28dd760d841877c5994259481c94a trivy_0.75.0_Linux-s390x.deb
|
||||||
4fb2d5bd1ab5e61917e73cd2936daeae1c5ab9af024321a222437d824bac5579 trivy_0.74.0_Linux-s390x.rpm
|
c1269d4af420a69e81cb0b7cf234728db5bfe6ecc350c78f5f3bbfd0be1c228c trivy_0.75.0_Linux-s390x.rpm
|
||||||
516cfe38eaede79c178088265657aad12be6c155271ac68ff728a141961ef197 trivy_0.74.0_Linux-s390x.tar.gz
|
6acd3a3af4688567f5d3cdf3dd45e871ae060a88c07cea01401ae1487c4f19a3 trivy_0.75.0_Linux-s390x.tar.gz
|
||||||
472816f6888dda689d075c30254d4210b4d1035acf365aa72332f584c2f60485 trivy_0.74.0_macOS-64bit.tar.gz
|
291edaa9778acbe4693d067b5ad60ee11570e5ac68296e85595417528ca641e4 trivy_0.75.0_macOS-64bit.tar.gz
|
||||||
1caada5e0e2091909357c7525d3aa76f4b660b13821bc143b190c7483e31cc11 trivy_0.74.0_macOS-ARM64.tar.gz
|
4a77108cccf8e55c8d6823e1e759939a622277e66cd0daa3c1fc621ed69e4568 trivy_0.75.0_macOS-ARM64.tar.gz
|
||||||
94c40e0696e4b907a74b7b2e1438d5d72ebaca83115817407f568a002d520842 trivy_0.74.0_windows-64bit.zip
|
4e43bd71a30f51aee39525f60f2b47043af77eb8df8fe082aae4372b69c6660f trivy_0.75.0_windows-64bit.zip
|
||||||
+5
-4
@@ -84,14 +84,15 @@ function loadReportsForArg(arg) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || typeof data !== 'object' || !Array.isArray(data.Results)) {
|
if (!data || typeof data !== 'object') {
|
||||||
console.error(`::error::Malformed Trivy report structure (missing Results array) in: ${filePath}`);
|
console.error(`::error::Malformed Trivy report structure in: ${filePath}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const results = Array.isArray(data.Results) ? data.Results : [];
|
||||||
loadedFiles++;
|
loadedFiles++;
|
||||||
|
|
||||||
for (const result of data.Results) {
|
for (const result of results) {
|
||||||
const target = result.Target || result.Type || 'App';
|
const target = result.Target || result.Type || 'App';
|
||||||
const vulns = Array.isArray(result.Vulnerabilities) ? result.Vulnerabilities : [];
|
const vulns = Array.isArray(result.Vulnerabilities) ? result.Vulnerabilities : [];
|
||||||
for (const v of vulns) {
|
for (const v of vulns) {
|
||||||
@@ -135,7 +136,7 @@ function formatVulnRow(v, allScannedArchs = new Set(), showArch = true) {
|
|||||||
|
|
||||||
const installed = v.InstalledVersion || '-';
|
const installed = v.InstalledVersion || '-';
|
||||||
const fixed = v.FixedVersion || '-';
|
const fixed = v.FixedVersion || '-';
|
||||||
let title = (v.Title || v.Description || '').replace(/\n/g, ' ').trim();
|
let title = (v.Title || v.Description || '').replace(/\n/g, ' ').replace(/\|/g, '\\|').trim();
|
||||||
if (title.length > 80) {
|
if (title.length > 80) {
|
||||||
title = title.slice(0, 77) + '...';
|
title = title.slice(0, 77) + '...';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* Diagnostic formatting and reporting utilities.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function formatSbomOverview({
|
|
||||||
headImage,
|
|
||||||
targetImage,
|
|
||||||
headFiles = [],
|
|
||||||
targetFiles = [],
|
|
||||||
headError = null,
|
|
||||||
targetError = null,
|
|
||||||
}) {
|
|
||||||
const lines = [
|
|
||||||
'',
|
|
||||||
'================================================================================',
|
|
||||||
' SBOM EXTRACTION DIAGNOSTIC OVERVIEW ',
|
|
||||||
'================================================================================',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (headError) {
|
|
||||||
lines.push(`HEAD Image (${headImage}): ❌ FAILED - ${headError}`);
|
|
||||||
} else {
|
|
||||||
lines.push(`HEAD Image (${headImage}): ✅ SUCCESS`);
|
|
||||||
for (const f of headFiles) {
|
|
||||||
lines.push(` - ${f.name} (${f.size} bytes)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetError) {
|
|
||||||
lines.push(`TARGET Baseline Image (${targetImage}): ❌ FAILED - ${targetError}`);
|
|
||||||
} else {
|
|
||||||
lines.push(`TARGET Baseline Image (${targetImage}): ✅ SUCCESS`);
|
|
||||||
for (const f of targetFiles) {
|
|
||||||
lines.push(` - ${f.name} (${f.size} bytes)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('================================================================================');
|
|
||||||
lines.push('');
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatScanOverview({
|
|
||||||
headImage,
|
|
||||||
targetImage,
|
|
||||||
targetBranch = 'main',
|
|
||||||
strategy = null,
|
|
||||||
headSummary = [],
|
|
||||||
targetSummary = [],
|
|
||||||
}) {
|
|
||||||
const lines = [
|
|
||||||
'',
|
|
||||||
'================================================================================',
|
|
||||||
' CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW ',
|
|
||||||
'================================================================================',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (strategy) {
|
|
||||||
lines.push(`Scan Strategy: ${strategy}`);
|
|
||||||
lines.push('--------------------------------------------------------------------------------');
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(`HEAD Image: ${headImage}`);
|
|
||||||
for (const line of headSummary) {
|
|
||||||
lines.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('');
|
|
||||||
lines.push(`TARGET Baseline Image: ${targetImage} (branch: '${targetBranch}')`);
|
|
||||||
for (const line of targetSummary) {
|
|
||||||
lines.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('================================================================================');
|
|
||||||
lines.push('');
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function countVulnerabilities(report) {
|
|
||||||
if (!report) return 0;
|
|
||||||
let data = report;
|
|
||||||
if (typeof report === 'string' || Buffer.isBuffer(report)) {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(report.toString('utf8'));
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!data || typeof data !== 'object') return 0;
|
|
||||||
|
|
||||||
let count = 0;
|
|
||||||
if (Array.isArray(data.Results)) {
|
|
||||||
for (const res of data.Results) {
|
|
||||||
if (res && Array.isArray(res.Vulnerabilities)) {
|
|
||||||
count += res.Vulnerabilities.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
formatSbomOverview,
|
|
||||||
formatScanOverview,
|
|
||||||
countVulnerabilities,
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* Distribution detection from SPDX SBOM package PURLs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function detectDistro(spdxInput) {
|
|
||||||
if (!spdxInput) return null;
|
|
||||||
|
|
||||||
let data = spdxInput;
|
|
||||||
if (typeof spdxInput === 'string' || Buffer.isBuffer(spdxInput)) {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(spdxInput.toString('utf8'));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data || typeof data !== 'object' || !Array.isArray(data.packages)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pkg of data.packages) {
|
|
||||||
if (!pkg || !Array.isArray(pkg.externalRefs)) continue;
|
|
||||||
for (const ref of pkg.externalRefs) {
|
|
||||||
if (ref && ref.referenceType === 'purl' && typeof ref.referenceLocator === 'string') {
|
|
||||||
const purl = ref.referenceLocator;
|
|
||||||
const nameMatch = purl.match(/[?&]os_name=([^&]+)/);
|
|
||||||
const verMatch = purl.match(/[?&]os_version=([^&]+)/);
|
|
||||||
if (nameMatch && verMatch) {
|
|
||||||
const osName = decodeURIComponent(nameMatch[1]);
|
|
||||||
const osVer = decodeURIComponent(verMatch[1]);
|
|
||||||
return `${osName}/${osVer}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
detectDistro,
|
|
||||||
};
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts or generates multi-platform SBOMs for head and target images.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const cp = require('node:child_process');
|
|
||||||
const { parsePlatforms, discoverPlatforms, toArchTag } = require('./platforms.js');
|
|
||||||
const { formatSbomOverview } = require('./diagnostics.js');
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
|
||||||
const args = {
|
|
||||||
headImage: process.env.INPUT_HEAD_IMAGE || '',
|
|
||||||
targetImage: process.env.INPUT_TARGET_IMAGE || '',
|
|
||||||
architectures: process.env.INPUT_ARCHITECTURES || '',
|
|
||||||
useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false',
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 2; i < argv.length; i++) {
|
|
||||||
const arg = argv[i];
|
|
||||||
if (arg === '--head-image' && i + 1 < argv.length) args.headImage = argv[++i];
|
|
||||||
else if (arg === '--target-image' && i + 1 < argv.length) args.targetImage = argv[++i];
|
|
||||||
else if (arg === '--architectures' && i + 1 < argv.length) args.architectures = argv[++i];
|
|
||||||
else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i];
|
|
||||||
}
|
|
||||||
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupInvalidFile(filePath) {
|
|
||||||
if (!fs.existsSync(filePath)) return;
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
if (stat.size === 0) {
|
|
||||||
fs.unlinkSync(filePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
||||||
if (content === 'null' || content === '') {
|
|
||||||
fs.unlinkSync(filePath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCommandSafe(cmd, args) {
|
|
||||||
try {
|
|
||||||
return cp.execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractOrGenerateSbom({ image, platform, archTag, outputDir, useInbuild }) {
|
|
||||||
const outputFile = path.join(outputDir, `sbom-${archTag}.spdx.json`);
|
|
||||||
const inbuildFile = path.join(outputDir, `inbuild-sbom-${archTag}.spdx.json`);
|
|
||||||
|
|
||||||
// 1. Attempt in-build SBOM extraction if requested
|
|
||||||
if (useInbuild) {
|
|
||||||
console.log(`Attempting extraction of built-in SBOM for image (${platform})...`);
|
|
||||||
const formatStr = `{{ json (index .SBOM "${platform}").SPDX }}`;
|
|
||||||
const rawSbom = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', image, '--format', formatStr]);
|
|
||||||
if (rawSbom) {
|
|
||||||
fs.writeFileSync(outputFile, rawSbom);
|
|
||||||
cleanupInvalidFile(outputFile);
|
|
||||||
if (fs.existsSync(outputFile)) {
|
|
||||||
fs.copyFileSync(outputFile, inbuildFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fallback to direct Trivy image generation if SBOM not present
|
|
||||||
if (!fs.existsSync(outputFile)) {
|
|
||||||
console.log(`Generating SBOM for image (${platform}) with Trivy...`);
|
|
||||||
const platArgs = platform ? ['--platform', platform] : [];
|
|
||||||
try {
|
|
||||||
cp.execFileSync('trivy', [
|
|
||||||
'image',
|
|
||||||
'--skip-version-check',
|
|
||||||
...platArgs,
|
|
||||||
'--format', 'spdx-json',
|
|
||||||
'--output', outputFile,
|
|
||||||
image,
|
|
||||||
], { stdio: 'inherit' });
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`::warning::Trivy image scan failed for image (${platform}): ${e.message}`);
|
|
||||||
}
|
|
||||||
cleanupInvalidFile(outputFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const args = parseArgs(process.argv);
|
|
||||||
const { headImage, targetImage, architectures, useInbuildSbom } = args;
|
|
||||||
const useInbuild = useInbuildSbom === 'true';
|
|
||||||
|
|
||||||
if (!headImage || !targetImage) {
|
|
||||||
console.error('::error::Both --head-image and --target-image must be provided.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headDir = path.join('sboms', 'head');
|
|
||||||
const targetDir = path.join('sboms', 'target');
|
|
||||||
fs.mkdirSync(headDir, { recursive: true });
|
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
|
||||||
|
|
||||||
// Determine platforms
|
|
||||||
let platforms = parsePlatforms(architectures);
|
|
||||||
if (platforms.length === 0) {
|
|
||||||
console.log('Auto-discovering architectures from images...');
|
|
||||||
const rawHead = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', '--raw', headImage]);
|
|
||||||
const rawTarget = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', '--raw', targetImage]);
|
|
||||||
platforms = discoverPlatforms(rawHead, rawTarget);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Configured platforms: ${platforms.length > 0 ? platforms.join(', ') : '(single platform fallback)'}`);
|
|
||||||
if (useInbuild) {
|
|
||||||
console.log('ℹ️ Strategy: Built-in SBOM attestation extraction enabled (Docker Scout / Syft). Skips container layer downloads for maximum speed.');
|
|
||||||
console.log('ℹ️ Note: Built-in SBOM vulnerability counts may differ from direct filesystem scans (\'trivy image\') due to binary vs. source package tracking.');
|
|
||||||
} else {
|
|
||||||
console.log('ℹ️ Strategy: Direct Trivy image scanning enabled (default). Inspects full container rootfs and package databases for maximum fidelity.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPlatforms = platforms.length > 0 ? platforms : [''];
|
|
||||||
|
|
||||||
for (const platform of targetPlatforms) {
|
|
||||||
const archTag = toArchTag(platform);
|
|
||||||
// Head extraction
|
|
||||||
extractOrGenerateSbom({
|
|
||||||
image: headImage,
|
|
||||||
platform,
|
|
||||||
archTag,
|
|
||||||
outputDir: headDir,
|
|
||||||
useInbuild,
|
|
||||||
});
|
|
||||||
// Target extraction
|
|
||||||
extractOrGenerateSbom({
|
|
||||||
image: targetImage,
|
|
||||||
platform,
|
|
||||||
archTag,
|
|
||||||
outputDir: targetDir,
|
|
||||||
useInbuild,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect generated files
|
|
||||||
const getSbomFiles = (dir) => {
|
|
||||||
if (!fs.existsSync(dir)) return [];
|
|
||||||
return fs.readdirSync(dir)
|
|
||||||
.filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json'))
|
|
||||||
.map((f) => {
|
|
||||||
const fullPath = path.join(dir, f);
|
|
||||||
return { name: f, size: fs.statSync(fullPath).size };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const headFiles = getSbomFiles(headDir);
|
|
||||||
const targetFiles = getSbomFiles(targetDir);
|
|
||||||
|
|
||||||
let headError = null;
|
|
||||||
let targetError = null;
|
|
||||||
if (headFiles.length === 0) headError = 'No SBOM generated and head image could not be inspected';
|
|
||||||
if (targetFiles.length === 0) targetError = 'No SBOM generated and target baseline image could not be inspected';
|
|
||||||
|
|
||||||
const overview = formatSbomOverview({
|
|
||||||
headImage,
|
|
||||||
targetImage,
|
|
||||||
headFiles,
|
|
||||||
targetFiles,
|
|
||||||
headError,
|
|
||||||
targetError,
|
|
||||||
});
|
|
||||||
console.log(overview);
|
|
||||||
|
|
||||||
if (headError || targetError) {
|
|
||||||
console.error('::error::Both head and target images must have valid SBOMs to proceed with differential scanning.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose extracted head SBOMs to workspace root for workflow artifact uploads
|
|
||||||
for (const file of headFiles) {
|
|
||||||
try {
|
|
||||||
fs.copyFileSync(path.join(headDir, file.name), file.name);
|
|
||||||
} catch {
|
|
||||||
// Ignore copy error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
main();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
parseArgs,
|
|
||||||
cleanupInvalidFile,
|
|
||||||
extractOrGenerateSbom,
|
|
||||||
main,
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Installs and verifies Trivy binary against checksums lockfile.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const cp = require('node:child_process');
|
|
||||||
const { mapArch, parseChecksums, verifyFileSha256 } = require('./verifier.js');
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const version = process.env.TRIVY_VERSION || '0.74.0';
|
|
||||||
const homeDir = os.homedir();
|
|
||||||
const binDir = path.join(homeDir, '.local', 'bin');
|
|
||||||
const cacheDir = path.join(homeDir, '.cache', 'trivy-bin');
|
|
||||||
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
|
||||||
fs.mkdirSync(cacheDir, { recursive: true });
|
|
||||||
|
|
||||||
// Expose bin directory to GITHUB_PATH if running in Actions runner
|
|
||||||
if (process.env.GITHUB_PATH) {
|
|
||||||
fs.appendFileSync(process.env.GITHUB_PATH, `${binDir}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unameArch = os.arch() === 'x64' ? 'x86_64' : (os.arch() === 'arm64' ? 'ARM64' : os.arch());
|
|
||||||
const tArch = mapArch(unameArch);
|
|
||||||
const tarball = `trivy_${version}_Linux-${tArch}.tar.gz`;
|
|
||||||
const tarballPath = path.join(cacheDir, tarball);
|
|
||||||
|
|
||||||
const checksumsFile = path.resolve(__dirname, '..', 'checksums.txt');
|
|
||||||
if (!fs.existsSync(checksumsFile)) {
|
|
||||||
console.error(`::error::Checksums lockfile not found at ${checksumsFile}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const checksums = parseChecksums(fs.readFileSync(checksumsFile, 'utf8'));
|
|
||||||
const expectedHash = checksums.get(tarball);
|
|
||||||
if (!expectedHash) {
|
|
||||||
console.error(`::error::Tarball ${tarball} not found in checksums.txt`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(tarballPath) && verifyFileSha256(tarballPath, expectedHash)) {
|
|
||||||
console.log('Cached Trivy archive verified against lockfile.');
|
|
||||||
} else {
|
|
||||||
console.log('Archive missing or checksum mismatch; downloading...');
|
|
||||||
const downloadUrl = `https://github.com/aquasecurity/trivy/releases/download/v${version}/${tarball}`;
|
|
||||||
cp.execFileSync('curl', ['-sfLO', downloadUrl, '--output-dir', cacheDir], { stdio: 'inherit' });
|
|
||||||
|
|
||||||
if (!verifyFileSha256(tarballPath, expectedHash)) {
|
|
||||||
console.error(`::error::Downloaded archive ${tarball} failed SHA256 checksum verification!`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('Downloaded Trivy archive verified successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract binary
|
|
||||||
cp.execFileSync('tar', ['-xzf', tarballPath, '-C', binDir, 'trivy'], { stdio: 'inherit' });
|
|
||||||
const trivyBin = path.join(binDir, 'trivy');
|
|
||||||
if (fs.existsSync(trivyBin)) {
|
|
||||||
fs.chmodSync(trivyBin, 0o755);
|
|
||||||
console.log(`Trivy ${version} installed and verified successfully at ${trivyBin}`);
|
|
||||||
} else {
|
|
||||||
console.error('::error::Failed to extract trivy binary from tarball.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
main();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { main };
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Platform and architecture resolution utilities.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function parsePlatforms(input) {
|
|
||||||
if (!input || typeof input !== 'string') return [];
|
|
||||||
const parts = input.split(',');
|
|
||||||
const platforms = [];
|
|
||||||
for (const part of parts) {
|
|
||||||
const clean = part.trim();
|
|
||||||
if (!clean) continue;
|
|
||||||
if (!clean.includes('/')) {
|
|
||||||
platforms.push(`linux/${clean}`);
|
|
||||||
} else {
|
|
||||||
platforms.push(clean);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return platforms;
|
|
||||||
}
|
|
||||||
|
|
||||||
function discoverPlatforms(headRawJson, targetRawJson) {
|
|
||||||
const set = new Set();
|
|
||||||
for (const raw of [headRawJson, targetRawJson]) {
|
|
||||||
if (!raw) continue;
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(raw);
|
|
||||||
const manifests = data.manifests || [];
|
|
||||||
for (const m of manifests) {
|
|
||||||
if (m.platform && m.platform.architecture && m.platform.architecture !== 'unknown') {
|
|
||||||
const os = m.platform.os || 'linux';
|
|
||||||
const variant = m.platform.variant ? `/${m.platform.variant}` : '';
|
|
||||||
set.add(`${os}/${m.platform.architecture}${variant}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore JSON parse errors from invalid manifests
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(set).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toArchTag(platform) {
|
|
||||||
if (!platform) return 'default';
|
|
||||||
// Strip optional leading os/ (e.g. linux/)
|
|
||||||
const withoutOs = platform.replace(/^[a-zA-Z0-9_-]+\//, '');
|
|
||||||
// Replace slashes with dashes (e.g. arm/v7 -> arm-v7)
|
|
||||||
return withoutOs.replace(/\//g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
parsePlatforms,
|
|
||||||
discoverPlatforms,
|
|
||||||
toArchTag,
|
|
||||||
};
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scans multi-platform SBOMs with Trivy, handles distro detection and fallbacks,
|
|
||||||
* prints diagnostic overview, and invokes diff engine.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const cp = require('node:child_process');
|
|
||||||
const { detectDistro } = require('./distro.js');
|
|
||||||
const { formatScanOverview, countVulnerabilities } = require('./diagnostics.js');
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
|
||||||
const args = {
|
|
||||||
headImage: process.env.INPUT_HEAD_IMAGE || '',
|
|
||||||
targetImage: process.env.INPUT_TARGET_IMAGE || '',
|
|
||||||
targetBranch: process.env.INPUT_TARGET_BRANCH || 'main',
|
|
||||||
useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false',
|
|
||||||
outputDelta: process.env.INPUT_OUTPUT_DELTA || 'cve-delta.md',
|
|
||||||
outputSummary: process.env.INPUT_OUTPUT_SUMMARY || 'cve-summary.md',
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 2; i < argv.length; i++) {
|
|
||||||
const arg = argv[i];
|
|
||||||
if (arg === '--head-image' && i + 1 < argv.length) args.headImage = argv[++i];
|
|
||||||
else if (arg === '--target-image' && i + 1 < argv.length) args.targetImage = argv[++i];
|
|
||||||
else if (arg === '--target-branch' && i + 1 < argv.length) args.targetBranch = argv[++i];
|
|
||||||
else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i];
|
|
||||||
else if (arg === '--output-delta' && i + 1 < argv.length) args.outputDelta = argv[++i];
|
|
||||||
else if (arg === '--output-summary' && i + 1 < argv.length) args.outputSummary = argv[++i];
|
|
||||||
}
|
|
||||||
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanSbomFile({ sbomPath, image, outJson, errLog, useInbuild }) {
|
|
||||||
const archMatch = path.basename(sbomPath, '.spdx.json').match(/^sbom-(.+)$/);
|
|
||||||
const arch = archMatch ? archMatch[1] : 'default';
|
|
||||||
|
|
||||||
console.log(`Scanning SBOM for architecture: ${arch}`);
|
|
||||||
let distroArg = [];
|
|
||||||
if (useInbuild) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(sbomPath, 'utf8');
|
|
||||||
const detected = detectDistro(content);
|
|
||||||
if (detected) {
|
|
||||||
console.log(`Detected distribution '${detected}' from in-build SBOM PURLs. Scanning with --distro.`);
|
|
||||||
distroArg = ['--distro', detected];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore read error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runTrivySbom = () => {
|
|
||||||
try {
|
|
||||||
const errFd = fs.openSync(errLog, 'w');
|
|
||||||
try {
|
|
||||||
cp.execFileSync('trivy', [
|
|
||||||
'sbom',
|
|
||||||
...distroArg,
|
|
||||||
'--skip-version-check',
|
|
||||||
'--format', 'json',
|
|
||||||
'--output', outJson,
|
|
||||||
sbomPath,
|
|
||||||
], { stdio: ['ignore', 'ignore', errFd] });
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
fs.closeSync(errFd);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let scanSuccess = runTrivySbom();
|
|
||||||
let vulnCount = 0;
|
|
||||||
if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
|
||||||
vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback if in-build scan produced 0 findings or failed
|
|
||||||
if (useInbuild && (!scanSuccess || vulnCount === 0)) {
|
|
||||||
console.warn(`::warning::In-build SBOM for image (${arch}) yielded ${vulnCount} CVE findings. Falling back to direct Trivy image scan...`);
|
|
||||||
const platArgs = arch !== 'default' ? ['--platform', `linux/${arch}`] : [];
|
|
||||||
try {
|
|
||||||
cp.execFileSync('trivy', [
|
|
||||||
'image',
|
|
||||||
'--skip-version-check',
|
|
||||||
...platArgs,
|
|
||||||
'--format', 'spdx-json',
|
|
||||||
'--output', sbomPath,
|
|
||||||
image,
|
|
||||||
], { stdio: 'inherit' });
|
|
||||||
|
|
||||||
console.log('Re-scanning SBOM generated directly by Trivy...');
|
|
||||||
// Re-scan without external distroArg (Trivy's generated SBOM has OS declarations)
|
|
||||||
distroArg = [];
|
|
||||||
scanSuccess = runTrivySbom();
|
|
||||||
if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
|
||||||
vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8'));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`::warning::Direct Trivy scan fallback failed for ${arch}: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { arch, scanSuccess, vulnCount, outJson, errLog };
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const args = parseArgs(process.argv);
|
|
||||||
const {
|
|
||||||
headImage,
|
|
||||||
targetImage,
|
|
||||||
targetBranch,
|
|
||||||
useInbuildSbom,
|
|
||||||
outputDelta,
|
|
||||||
outputSummary,
|
|
||||||
} = args;
|
|
||||||
const useInbuild = useInbuildSbom === 'true';
|
|
||||||
|
|
||||||
fs.mkdirSync(path.join('cve-reports', 'head'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join('cve-reports', 'target'), { recursive: true });
|
|
||||||
|
|
||||||
const getSboms = (dir) => {
|
|
||||||
if (!fs.existsSync(dir)) return [];
|
|
||||||
return fs.readdirSync(dir)
|
|
||||||
.filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json'))
|
|
||||||
.sort()
|
|
||||||
.map((f) => path.join(dir, f));
|
|
||||||
};
|
|
||||||
|
|
||||||
const headSboms = getSboms(path.join('sboms', 'head'));
|
|
||||||
const targetSboms = getSboms(path.join('sboms', 'target'));
|
|
||||||
|
|
||||||
let headOk = true;
|
|
||||||
let targetOk = true;
|
|
||||||
let headReportCount = 0;
|
|
||||||
let targetReportCount = 0;
|
|
||||||
const headSummary = [];
|
|
||||||
const targetSummary = [];
|
|
||||||
|
|
||||||
// 1. Scan Head SBOMs
|
|
||||||
if (headSboms.length === 0) {
|
|
||||||
headOk = false;
|
|
||||||
headSummary.push(' - ❌ No SBOM files found in sboms/head/');
|
|
||||||
} else {
|
|
||||||
for (const f of headSboms) {
|
|
||||||
const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, '');
|
|
||||||
const outJson = path.join('cve-reports', 'head', `cve-${arch}.json`);
|
|
||||||
const errLog = path.join('cve-reports', 'head', `cve-${arch}.err`);
|
|
||||||
|
|
||||||
const res = scanSbomFile({
|
|
||||||
sbomPath: f,
|
|
||||||
image: headImage,
|
|
||||||
outJson,
|
|
||||||
errLog,
|
|
||||||
useInbuild,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
|
||||||
if (res.vulnCount === 0) {
|
|
||||||
console.warn(`::warning::Head image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`);
|
|
||||||
headSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`);
|
|
||||||
} else {
|
|
||||||
headSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`);
|
|
||||||
}
|
|
||||||
headReportCount++;
|
|
||||||
} else {
|
|
||||||
headOk = false;
|
|
||||||
let errMsg = 'Trivy scan exited with non-zero status';
|
|
||||||
if (fs.existsSync(errLog)) {
|
|
||||||
const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n');
|
|
||||||
errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg;
|
|
||||||
}
|
|
||||||
headSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Scan Target SBOMs
|
|
||||||
if (targetSboms.length === 0) {
|
|
||||||
targetOk = false;
|
|
||||||
targetSummary.push(' - ❌ No SBOM files found in sboms/target/');
|
|
||||||
} else {
|
|
||||||
for (const f of targetSboms) {
|
|
||||||
const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, '');
|
|
||||||
const outJson = path.join('cve-reports', 'target', `cve-${arch}.json`);
|
|
||||||
const errLog = path.join('cve-reports', 'target', `cve-${arch}.err`);
|
|
||||||
|
|
||||||
const res = scanSbomFile({
|
|
||||||
sbomPath: f,
|
|
||||||
image: targetImage,
|
|
||||||
outJson,
|
|
||||||
errLog,
|
|
||||||
useInbuild,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
|
||||||
if (res.vulnCount === 0) {
|
|
||||||
console.warn(`::warning::Target baseline image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`);
|
|
||||||
targetSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`);
|
|
||||||
} else {
|
|
||||||
targetSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`);
|
|
||||||
}
|
|
||||||
targetReportCount++;
|
|
||||||
} else {
|
|
||||||
targetOk = false;
|
|
||||||
let errMsg = 'Trivy scan exited with non-zero status';
|
|
||||||
if (fs.existsSync(errLog)) {
|
|
||||||
const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n');
|
|
||||||
errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg;
|
|
||||||
}
|
|
||||||
targetSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Print Comprehensive Diagnostic Overview
|
|
||||||
const strategy = useInbuild
|
|
||||||
? 'Built-in OCI Attestation (Syft/Docker Scout) with distro detection'
|
|
||||||
: 'Direct Trivy Image Scan (Full rootfs & source package mapping)';
|
|
||||||
|
|
||||||
const overview = formatScanOverview({
|
|
||||||
headImage,
|
|
||||||
targetImage,
|
|
||||||
targetBranch,
|
|
||||||
strategy,
|
|
||||||
headSummary,
|
|
||||||
targetSummary,
|
|
||||||
});
|
|
||||||
console.log(overview);
|
|
||||||
|
|
||||||
// 4. Enforce Gating
|
|
||||||
let hasError = false;
|
|
||||||
if (!headOk || headReportCount === 0) {
|
|
||||||
console.error('::error::Head image CVE analysis failed or produced no reports.');
|
|
||||||
hasError = true;
|
|
||||||
}
|
|
||||||
if (!targetOk || targetReportCount === 0) {
|
|
||||||
console.error('::error::Target baseline CVE analysis failed or produced no reports.');
|
|
||||||
hasError = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
console.error('::error::Aborting differential analysis: both head and target scans must succeed before diffing.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Run multi-arch set-based diff and generate reports
|
|
||||||
console.log('Both head and target scans succeeded. Computing set-based vulnerability delta...');
|
|
||||||
const cveDiffScript = path.resolve(__dirname, '..', 'cve-diff.js');
|
|
||||||
try {
|
|
||||||
const deltaOutput = cp.execFileSync(process.execPath, [
|
|
||||||
cveDiffScript,
|
|
||||||
path.join('cve-reports', 'target'),
|
|
||||||
path.join('cve-reports', 'head'),
|
|
||||||
targetBranch,
|
|
||||||
outputSummary,
|
|
||||||
], { encoding: 'utf8' });
|
|
||||||
|
|
||||||
fs.writeFileSync(outputDelta, deltaOutput, 'utf8');
|
|
||||||
console.log(`CVE delta written to ${outputDelta} and full summary written to ${outputSummary}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`::error::Failed to generate CVE differential report: ${e.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
main();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
parseArgs,
|
|
||||||
scanSbomFile,
|
|
||||||
main,
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* Architecture mapping and cryptographic checksum verification.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
|
|
||||||
function mapArch(unameArch) {
|
|
||||||
const normalized = (unameArch || '').trim().toLowerCase();
|
|
||||||
switch (normalized) {
|
|
||||||
case 'x86_64':
|
|
||||||
case 'amd64':
|
|
||||||
return '64bit';
|
|
||||||
case 'aarch64':
|
|
||||||
case 'arm64':
|
|
||||||
return 'ARM64';
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported architecture: ${unameArch}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseChecksums(checksumsContent) {
|
|
||||||
const map = new Map();
|
|
||||||
if (!checksumsContent) return map;
|
|
||||||
|
|
||||||
const lines = checksumsContent.split(/\r?\n/);
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
||||||
// Format: <hash> <filename>
|
|
||||||
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+(.+)$/);
|
|
||||||
if (match) {
|
|
||||||
const hash = match[1].toLowerCase();
|
|
||||||
const filename = match[2].trim();
|
|
||||||
map.set(filename, hash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyFileSha256(filePath, expectedHash) {
|
|
||||||
if (!fs.existsSync(filePath) || !expectedHash) return false;
|
|
||||||
try {
|
|
||||||
const fileBuffer = fs.readFileSync(filePath);
|
|
||||||
const actualHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
|
||||||
return actualHash.toLowerCase() === expectedHash.toLowerCase();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
mapArch,
|
|
||||||
parseChecksums,
|
|
||||||
verifyFileSha256,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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`'));
|
||||||
|
});
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const {
|
|
||||||
formatSbomOverview,
|
|
||||||
formatScanOverview,
|
|
||||||
countVulnerabilities,
|
|
||||||
} = require('../lib/diagnostics.js');
|
|
||||||
|
|
||||||
test('formatSbomOverview formats successful extraction banner', () => {
|
|
||||||
const overview = formatSbomOverview({
|
|
||||||
headImage: 'my-head:latest',
|
|
||||||
targetImage: 'my-target:latest',
|
|
||||||
headFiles: [{ name: 'sbom-amd64.spdx.json', size: 1024 }],
|
|
||||||
targetFiles: [{ name: 'sbom-amd64.spdx.json', size: 2048 }],
|
|
||||||
headError: null,
|
|
||||||
targetError: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(overview.includes('SBOM EXTRACTION DIAGNOSTIC OVERVIEW'));
|
|
||||||
assert.ok(overview.includes('HEAD Image (my-head:latest): ✅ SUCCESS'));
|
|
||||||
assert.ok(overview.includes('TARGET Baseline Image (my-target:latest): ✅ SUCCESS'));
|
|
||||||
assert.ok(overview.includes('sbom-amd64.spdx.json (1024 bytes)'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatSbomOverview formats failure banner when error present', () => {
|
|
||||||
const overview = formatSbomOverview({
|
|
||||||
headImage: 'my-head:latest',
|
|
||||||
targetImage: 'my-target:latest',
|
|
||||||
headFiles: [],
|
|
||||||
targetFiles: [],
|
|
||||||
headError: 'Image not found',
|
|
||||||
targetError: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(overview.includes('HEAD Image (my-head:latest): ❌ FAILED - Image not found'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatScanOverview formats scan summary with strategy', () => {
|
|
||||||
const overview = formatScanOverview({
|
|
||||||
headImage: 'debian:bookworm-slim',
|
|
||||||
targetImage: 'debian:bullseye-slim',
|
|
||||||
targetBranch: 'main',
|
|
||||||
strategy: 'Direct Trivy Image Scan',
|
|
||||||
headSummary: [' - amd64: ✅ OK (104 vulnerability findings)'],
|
|
||||||
targetSummary: [' - amd64: ✅ OK (130 vulnerability findings)'],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(overview.includes('CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW'));
|
|
||||||
assert.ok(overview.includes('Scan Strategy: Direct Trivy Image Scan'));
|
|
||||||
assert.ok(overview.includes('HEAD Image: debian:bookworm-slim'));
|
|
||||||
assert.ok(overview.includes('TARGET Baseline Image: debian:bullseye-slim (branch: \'main\')'));
|
|
||||||
assert.ok(overview.includes('104 vulnerability findings'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('countVulnerabilities counts all vulnerabilities in a Trivy JSON report', () => {
|
|
||||||
const report = {
|
|
||||||
Results: [
|
|
||||||
{
|
|
||||||
Vulnerabilities: [
|
|
||||||
{ VulnerabilityID: 'CVE-2023-0001' },
|
|
||||||
{ VulnerabilityID: 'CVE-2023-0002' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Vulnerabilities: [
|
|
||||||
{ VulnerabilityID: 'CVE-2023-0003' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(countVulnerabilities(report), 3);
|
|
||||||
assert.equal(countVulnerabilities(JSON.stringify(report)), 3);
|
|
||||||
assert.equal(countVulnerabilities({}), 0);
|
|
||||||
assert.equal(countVulnerabilities(null), 0);
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { detectDistro } = require('../lib/distro.js');
|
|
||||||
|
|
||||||
test('detectDistro returns null for null, undefined, or empty input', () => {
|
|
||||||
assert.equal(detectDistro(null), null);
|
|
||||||
assert.equal(detectDistro(undefined), null);
|
|
||||||
assert.equal(detectDistro(''), null);
|
|
||||||
assert.equal(detectDistro('{}'), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detectDistro extracts distro from parsed SPDX object with package PURLs', () => {
|
|
||||||
const spdx = {
|
|
||||||
packages: [
|
|
||||||
{
|
|
||||||
name: 'acl',
|
|
||||||
externalRefs: [
|
|
||||||
{
|
|
||||||
referenceType: 'purl',
|
|
||||||
referenceLocator: 'pkg:deb/debian/acl@2.3.1-3?os_distro=bookworm&os_name=debian&os_version=12',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(detectDistro(spdx), 'debian/12');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detectDistro extracts distro from JSON string containing escaped unicode \\u0026', () => {
|
|
||||||
const jsonStr = JSON.stringify({
|
|
||||||
packages: [
|
|
||||||
{
|
|
||||||
name: 'adduser',
|
|
||||||
externalRefs: [
|
|
||||||
{
|
|
||||||
referenceType: 'purl',
|
|
||||||
referenceLocator: 'pkg:deb/debian/adduser@3.134?os_distro=bullseye&os_name=debian&os_version=11',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).replace(/&/g, '\\u0026');
|
|
||||||
|
|
||||||
assert.ok(jsonStr.includes('\\u0026'));
|
|
||||||
assert.equal(detectDistro(jsonStr), 'debian/11');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detectDistro handles non-debian distributions (e.g. ubuntu, alpine)', () => {
|
|
||||||
const spdx = {
|
|
||||||
packages: [
|
|
||||||
{
|
|
||||||
externalRefs: [
|
|
||||||
{
|
|
||||||
referenceType: 'purl',
|
|
||||||
referenceLocator: 'pkg:apk/alpine/alpine-baselayout@3.4.3-r2?os_name=alpine&os_version=3.20',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(detectDistro(spdx), 'alpine/3.20');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detectDistro returns null when PURLs lack os_name or os_version', () => {
|
|
||||||
const spdx = {
|
|
||||||
packages: [
|
|
||||||
{
|
|
||||||
externalRefs: [
|
|
||||||
{
|
|
||||||
referenceType: 'purl',
|
|
||||||
referenceLocator: 'pkg:generic/foo@1.0.0',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(detectDistro(spdx), null);
|
|
||||||
});
|
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"SchemaVersion": 2,
|
||||||
|
"ArtifactName": "debian:bullseye-slim",
|
||||||
|
"ArtifactType": "spdx",
|
||||||
|
"Metadata": {
|
||||||
|
"OS": null
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+30
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+36
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const { parseArgs: parseExtractArgs, cleanupInvalidFile } = require('../lib/extract-sboms.js');
|
|
||||||
const { parseArgs: parseScanArgs } = require('../lib/scan-sboms.js');
|
|
||||||
|
|
||||||
test('extract-sboms parseArgs parses command line arguments and defaults', () => {
|
|
||||||
const argv = [
|
|
||||||
'node',
|
|
||||||
'extract-sboms.js',
|
|
||||||
'--head-image', 'my-repo/head:1.0',
|
|
||||||
'--target-image', 'my-repo/target:1.0',
|
|
||||||
'--architectures', 'amd64,arm64',
|
|
||||||
'--use-inbuild-sbom', 'true',
|
|
||||||
];
|
|
||||||
|
|
||||||
const args = parseExtractArgs(argv);
|
|
||||||
assert.equal(args.headImage, 'my-repo/head:1.0');
|
|
||||||
assert.equal(args.targetImage, 'my-repo/target:1.0');
|
|
||||||
assert.equal(args.architectures, 'amd64,arm64');
|
|
||||||
assert.equal(args.useInbuildSbom, 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('extract-sboms cleanupInvalidFile removes empty files and files containing null', () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbom-clean-'));
|
|
||||||
const emptyFile = path.join(tmpDir, 'empty.json');
|
|
||||||
const nullFile = path.join(tmpDir, 'null.json');
|
|
||||||
const validFile = path.join(tmpDir, 'valid.json');
|
|
||||||
|
|
||||||
fs.writeFileSync(emptyFile, '');
|
|
||||||
fs.writeFileSync(nullFile, 'null\n');
|
|
||||||
fs.writeFileSync(validFile, '{"spdxVersion": "SPDX-2.3"}');
|
|
||||||
|
|
||||||
cleanupInvalidFile(emptyFile);
|
|
||||||
cleanupInvalidFile(nullFile);
|
|
||||||
cleanupInvalidFile(validFile);
|
|
||||||
|
|
||||||
assert.equal(fs.existsSync(emptyFile), false);
|
|
||||||
assert.equal(fs.existsSync(nullFile), false);
|
|
||||||
assert.equal(fs.existsSync(validFile), true);
|
|
||||||
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scan-sboms parseArgs parses scan arguments and defaults', () => {
|
|
||||||
const argv = [
|
|
||||||
'node',
|
|
||||||
'scan-sboms.js',
|
|
||||||
'--head-image', 'my-head:latest',
|
|
||||||
'--target-image', 'my-target:latest',
|
|
||||||
'--target-branch', 'feature',
|
|
||||||
'--output-delta', 'custom-delta.md',
|
|
||||||
'--output-summary', 'custom-summary.md',
|
|
||||||
];
|
|
||||||
|
|
||||||
const args = parseScanArgs(argv);
|
|
||||||
assert.equal(args.headImage, 'my-head:latest');
|
|
||||||
assert.equal(args.targetImage, 'my-target:latest');
|
|
||||||
assert.equal(args.targetBranch, 'feature');
|
|
||||||
assert.equal(args.outputDelta, 'custom-delta.md');
|
|
||||||
assert.equal(args.outputSummary, 'custom-summary.md');
|
|
||||||
assert.equal(args.useInbuildSbom, 'false');
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { parsePlatforms, discoverPlatforms, toArchTag } = require('../lib/platforms.js');
|
|
||||||
|
|
||||||
test('parsePlatforms handles empty and undefined input', () => {
|
|
||||||
assert.deepEqual(parsePlatforms(''), []);
|
|
||||||
assert.deepEqual(parsePlatforms(null), []);
|
|
||||||
assert.deepEqual(parsePlatforms(undefined), []);
|
|
||||||
assert.deepEqual(parsePlatforms(' '), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parsePlatforms parses comma-separated architectures and adds linux/ prefix if omitted', () => {
|
|
||||||
const result = parsePlatforms('amd64, arm64, linux/arm/v7');
|
|
||||||
assert.deepEqual(result, ['linux/amd64', 'linux/arm64', 'linux/arm/v7']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('discoverPlatforms extracts unique architectures from OCI image manifests', () => {
|
|
||||||
const headManifest = JSON.stringify({
|
|
||||||
manifests: [
|
|
||||||
{ platform: { architecture: 'amd64', os: 'linux' } },
|
|
||||||
{ platform: { architecture: 'arm64', os: 'linux' } },
|
|
||||||
{ platform: { architecture: 'unknown', os: 'unknown' } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetManifest = JSON.stringify({
|
|
||||||
manifests: [
|
|
||||||
{ platform: { architecture: 'amd64', os: 'linux' } },
|
|
||||||
{ platform: { architecture: 'arm', os: 'linux', variant: 'v7' } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = discoverPlatforms(headManifest, targetManifest);
|
|
||||||
assert.deepEqual(result, ['linux/amd64', 'linux/arm/v7', 'linux/arm64']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('discoverPlatforms safely handles invalid or missing manifest JSON', () => {
|
|
||||||
assert.deepEqual(discoverPlatforms('invalid json', null), []);
|
|
||||||
assert.deepEqual(discoverPlatforms('', '{}'), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toArchTag converts platform strings to filesystem-safe arch tags', () => {
|
|
||||||
assert.equal(toArchTag('linux/amd64'), 'amd64');
|
|
||||||
assert.equal(toArchTag('linux/arm64'), 'arm64');
|
|
||||||
assert.equal(toArchTag('linux/arm/v7'), 'arm-v7');
|
|
||||||
assert.equal(toArchTag('amd64'), 'amd64');
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
const { mapArch, parseChecksums, verifyFileSha256 } = require('../lib/verifier.js');
|
|
||||||
|
|
||||||
test('mapArch maps uname architecture to Trivy release arch', () => {
|
|
||||||
assert.equal(mapArch('x86_64'), '64bit');
|
|
||||||
assert.equal(mapArch('aarch64'), 'ARM64');
|
|
||||||
assert.equal(mapArch('arm64'), 'ARM64');
|
|
||||||
assert.throws(() => mapArch('mips'), /Unsupported architecture/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseChecksums parses sha256 checksum lockfile into filename map', () => {
|
|
||||||
const content = `
|
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 trivy_0.74.0_Linux-64bit.tar.gz
|
|
||||||
a1b2c3d4e5f60000000000000000000000000000000000000000000000000000 trivy_0.74.0_Linux-ARM64.tar.gz
|
|
||||||
`;
|
|
||||||
|
|
||||||
const map = parseChecksums(content);
|
|
||||||
assert.equal(map.get('trivy_0.74.0_Linux-64bit.tar.gz'), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
|
||||||
assert.equal(map.get('trivy_0.74.0_Linux-ARM64.tar.gz'), 'a1b2c3d4e5f60000000000000000000000000000000000000000000000000000');
|
|
||||||
assert.equal(map.has('nonexistent'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifyFileSha256 verifies actual sha256 hash of a file', () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cve-test-'));
|
|
||||||
const testFile = path.join(tmpDir, 'test.txt');
|
|
||||||
const fileContent = 'hello world\n';
|
|
||||||
fs.writeFileSync(testFile, fileContent);
|
|
||||||
|
|
||||||
const expectedHash = crypto.createHash('sha256').update(fileContent).digest('hex');
|
|
||||||
assert.equal(verifyFileSha256(testFile, expectedHash), true);
|
|
||||||
assert.equal(verifyFileSha256(testFile, 'wronghash'), false);
|
|
||||||
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
Reference in new issue
Block a user