Public Access
generated from Philipp/template-git
Compare commits
36
Commits
0.0.1
..
4385ea7eea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4385ea7eea | ||
|
|
17e5dab1b2 | ||
|
|
493d119b3c | ||
|
|
77357f0c3f
|
||
|
|
46c4fedc47 | ||
|
|
8070a3c155 | ||
|
|
0ccfd5f512 | ||
|
|
c98b31c23a | ||
|
|
def42c37a8
|
||
|
|
64747ec466
|
||
|
|
3b9043a746
|
||
|
|
790c1aa3de
|
||
|
|
f31be5eef6
|
||
|
|
d89fb25d7b
|
||
|
|
2ece0c93ac | ||
|
|
6643b7449e | ||
|
|
40209bbeed | ||
|
|
44c4b5a304 | ||
|
|
6dae2af2cd | ||
|
|
015e40634a | ||
|
|
e394966a96
|
||
|
|
e232a22547
|
||
|
|
abe45ffb02 | ||
|
|
e97c5b1fc9
|
||
|
|
86a00c54dc
|
||
|
|
77f3cf68e4
|
||
|
|
3b458f8749
|
||
|
|
d49634fba5 | ||
|
|
e4a0cac9ff
|
||
|
|
48faaf07d1 | ||
|
|
1ff782c6c9 | ||
|
|
1b4460f9bd | ||
|
|
c1dede3ce2 | ||
|
|
a62f996eb5 | ||
|
|
a513cc2c18 | ||
|
|
9638e61534 |
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@v4
|
||||||
|
|
||||||
|
- 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@v4
|
||||||
|
|
||||||
|
- 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/**
|
||||||
@@ -6,28 +6,28 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Multi-Platform Support**: Automatically detects and extracts SPDX SBOM attestations for multi arch images.
|
- **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 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 or baseline images are missing, automatically falls back to generating SBOMs directly with Trivy. Using images that do contain an SBOM is highly recomended though!
|
|
||||||
- **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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📥 Inputs
|
## 📥 Inputs
|
||||||
|
|
||||||
| Input | Description | Required | Default |
|
| Input | Description | Required | Default |
|
||||||
| :--- | :--- | :---: | :--- |
|
| :--- | :--- | :--- | :--- |
|
||||||
| `head-image` | Head container image reference to scan (e.g. `registry.example.com/repo:git-sha`) | **Yes** | — |
|
| `head-image` | Head container image reference to scan (e.g. `registry.example.com/repo:git-sha`) | **Yes** | — |
|
||||||
| `target-image` | Target baseline container image reference to compare against (e.g. `registry.example.com/repo:latest`) | **Yes** | — |
|
| `target-image` | Target baseline container image reference to compare against (e.g. `registry.example.com/repo:latest`) | **Yes** | — |
|
||||||
| `target-branch` | Target branch name for report headings | No | `main` |
|
| `target-branch` | Target branch name for report headings | No | `main` |
|
||||||
| `trivy-version` | Trivy version to install and verify | No | `0.74.0` |
|
| `architectures` | Comma-separated architectures/platforms to scan (e.g. `amd64,arm64`). If omitted, scans all architectures present in the image. | No | _(all)_ |
|
||||||
| `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 (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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,6 +40,46 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🔍 Built-in SBOM Attestations vs. Trivy Direct Scanning (Syft vs. Trivy)
|
||||||
|
|
||||||
|
This action supports two modes for obtaining SBOMs:
|
||||||
|
|
||||||
|
### 1. Direct Image Scanning with Trivy (`use-inbuild-sbom: false` — Default & Recommended)
|
||||||
|
By default, the action uses `trivy image --format spdx-json` to generate the SBOM directly from the container images:
|
||||||
|
- **Full Filesystem Fidelity**: Inspects the entire flattened root filesystem, reading `/etc/os-release` and native package databases (e.g. `/var/lib/dpkg/status`, RPM databases, apk).
|
||||||
|
- **Accurate OS & Language Package Detection**: Catalogs both OS packages (Debian, Alpine, RHEL, etc.) and application dependencies (Python, Node.js, Go, etc.).
|
||||||
|
- **Symmetric, Apples-to-Apples Comparisons**: Both `head` and `target` images are analyzed using the identical Trivy engine and vulnerability database, preventing skewed diffs.
|
||||||
|
|
||||||
|
### 2. Built-in Attestation Extraction with Resilient Fallback (`use-inbuild-sbom: true`)
|
||||||
|
When enabled, the action extracts SBOM attestations attached to the container image using `docker buildx imagetools inspect`:
|
||||||
|
- **Speed**: Extracts the SBOM JSON metadata directly from the registry manifest index in seconds without pulling image layer blobs.
|
||||||
|
- **Underlying Generator (Syft / Docker Scout)**: Docker Buildx (`sbom: true`) uses BuildKit's embedded Syft scanner (`moby/buildkit-syft-scanner`).
|
||||||
|
- **Distro Detection**: Automatically extracts OS distribution information from package PURLs (e.g. `debian/12`) and queries Trivy with `--distro` to accurately identify vulnerabilities on the extracted SBOM.
|
||||||
|
- **Resilient Fallback**: If an extracted in-build SBOM scan produces 0 CVE findings (a common issue when third-party generators omit OS distribution qualifiers from SPDX packages) or if scanning fails, the action **automatically falls back** to running `trivy image --platform "$platform" --format spdx-json` directly on the image. This guarantees that vulnerability scans never fail silently or report false zeros.
|
||||||
|
- **Audit Copies**: The action preserves copies of both the raw extracted in-build SBOM (`inbuild-sbom-*.spdx.json`) and the final scanned SBOM (`sbom-*.spdx.json`) for auditing and debugging.
|
||||||
|
|
||||||
|
### ⚖️ Trade-offs: Why do the results differ between strategies?
|
||||||
|
|
||||||
|
The vulnerability results between Strategy 1 (`sbom: false`) and Strategy 2 (`sbom: true`) are **not identical**:
|
||||||
|
|
||||||
|
| Feature / Metric | Direct Trivy Scan (`use-inbuild-sbom: false`) | Built-in Attestation (`use-inbuild-sbom: true`) |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Generator** | Trivy directly on container rootfs | Docker Scout / BuildKit (Syft) |
|
||||||
|
| **Execution Speed** | Slower (pulls image layer blobs) | **Very Fast** (~5x faster, 0 layer downloads) |
|
||||||
|
| **Debian Slim Findings Example** | **~225 CVEs** (`debian:bookworm-slim`) | **~104 CVEs** (`debian:bookworm-slim`) |
|
||||||
|
| **Package Resolution Model** | Source & binary packages (`/var/lib/dpkg/status`) | Binary packages cataloged in SPDX PURLs |
|
||||||
|
| **Filesystem Inspection** | Full filesystem (static binaries, libraries) | Manifest-only (packages cataloged at build time) |
|
||||||
|
| **Primary Use Case** | **High-fidelity security audits & release gates** | **High-speed PR checks & developer feedback loops** |
|
||||||
|
|
||||||
|
#### Why the vulnerability counts differ:
|
||||||
|
1. **Debian Source vs. Binary Packages**: Debian security advisories (DSAs / DLAs) are tracked and published per **source package** (e.g., the source package `glibc` produces multiple binary packages like `libc6`, `libc-bin`, `locales`). Trivy's filesystem scanner reads `/var/lib/dpkg/status` to map installed binary packages directly to Debian source package advisories. Syft's default SPDX output catalogs installed binary packages as PURLs without source-package relations, so Trivy's SBOM scan queries only the exact binary package entries that match Debian database schemas without the source-mapping context.
|
||||||
|
2. **Filesystem vs. Manifest Scope**: Direct image scanning unpacks and inspects the entire container image filesystem (including file hashes, crypto libraries, and static binaries). An in-build SBOM scan is strictly limited to the package inventory written by the build-time generator.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Setting `use-inbuild-sbom: 'false'` remains the recommended default for maximum accuracy and cross-platform consistency. If you choose to enable `use-inbuild-sbom: 'true'`, the built-in fallback ensures full coverage even when third-party attestations lack OS package metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚀 Usage Example
|
## 🚀 Usage Example
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -47,20 +87,16 @@ jobs:
|
|||||||
build-and-scan:
|
build-and-scan:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build and Push Head Container Image
|
- name: Build and Push Head Container Image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
sbom: true
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
tags: registry.example.com/my-app:git-${{ github.sha }}
|
tags: registry.example.com/my-app:git-${{ github.sha }}
|
||||||
|
|
||||||
- name: Compare Vulnerabilities (Head vs Target)
|
- name: Compare Vulnerabilities (Head vs Target)
|
||||||
uses: https://git.horstenkamp.eu/Philipp/cve-differential@main
|
uses: https://git.horstenkamp.eu/Philipp/cve-differential.git@main
|
||||||
with:
|
with:
|
||||||
head-image: registry.example.com/my-app:git-${{ github.sha }}
|
head-image: registry.example.com/my-app:git-${{ github.sha }}
|
||||||
target-image: registry.example.com/my-app:latest
|
target-image: registry.example.com/my-app:latest
|
||||||
|
|||||||
+37
-133
@@ -12,10 +12,10 @@ inputs:
|
|||||||
description: Target branch name for delta heading (e.g. main)
|
description: Target branch name for delta heading (e.g. main)
|
||||||
required: false
|
required: false
|
||||||
default: main
|
default: main
|
||||||
trivy-version:
|
architectures:
|
||||||
description: Trivy binary version to install and verify
|
description: Comma-separated list of architectures or platforms to scan (e.g. 'amd64,arm64' or 'linux/amd64,linux/arm64'). If empty, scans all architectures found in the container image.
|
||||||
required: false
|
required: false
|
||||||
default: 0.74.0 # trivy-version
|
default: ''
|
||||||
output-delta:
|
output-delta:
|
||||||
description: Output markdown file for CVE delta report
|
description: Output markdown file for CVE delta report
|
||||||
required: false
|
required: false
|
||||||
@@ -28,6 +28,10 @@ inputs:
|
|||||||
description: Whether to append the generated reports to the job summary ($GITHUB_STEP_SUMMARY)
|
description: Whether to append the generated reports to the job summary ($GITHUB_STEP_SUMMARY)
|
||||||
required: false
|
required: false
|
||||||
default: 'true'
|
default: 'true'
|
||||||
|
use-inbuild-sbom:
|
||||||
|
description: Whether to inspect and use built-in image SBOM attestations. If false (recommended default), generates SBOMs directly with Trivy for maximum vulnerability detection fidelity. If true, extracts attestations without pulling layers (faster, but findings may differ from direct scans due to binary vs source package tracking; falls back to Trivy image scan if 0 CVEs detected).
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
delta-report:
|
delta-report:
|
||||||
@@ -40,154 +44,54 @@ outputs:
|
|||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare Cache Keys
|
- name: Prepare Cache Keys and Environment
|
||||||
id: vars
|
id: vars
|
||||||
shell: bash
|
shell: bash
|
||||||
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
|
env:
|
||||||
|
TRIVY_VERSION: 0.74.0 # trivy-version
|
||||||
|
run: |
|
||||||
|
echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${TRIVY_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Cache Trivy (Binary & Vulnerability DB)
|
- 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
|
||||||
~/.cache/trivy
|
~/.cache/trivy
|
||||||
key: trivy-${{ inputs.trivy-version }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.vars.outputs.date }}
|
key: trivy-${{ steps.vars.outputs.version }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.vars.outputs.date }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
trivy-${{ inputs.trivy-version }}-${{ runner.os }}-${{ runner.arch }}-
|
trivy-${{ steps.vars.outputs.version }}-${{ runner.os }}-${{ runner.arch }}-
|
||||||
|
|
||||||
- name: Install and Verify Trivy
|
- name: Install and Verify Trivy
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
env:
|
||||||
mkdir -p "$HOME/.cache/trivy-bin" "$HOME/.local/bin"
|
TRIVY_VERSION: ${{ steps.vars.outputs.version }}
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
run: node "${{ github.action_path }}/lib/install-trivy.js"
|
||||||
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
case "$ARCH" in
|
|
||||||
x86_64) T_ARCH="64bit" ;;
|
|
||||||
aarch64|arm64) T_ARCH="ARM64" ;;
|
|
||||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
tarball="trivy_${{ inputs.trivy-version }}_Linux-${T_ARCH}.tar.gz"
|
|
||||||
checksums_file="${{ github.action_path }}/checksums.txt"
|
|
||||||
|
|
||||||
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${{ inputs.trivy-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
|
||||||
run: |
|
env:
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
|
||||||
mkdir -p sboms/head sboms/target
|
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||||
|
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
|
||||||
cleanup_file() {
|
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
|
||||||
local f="$1"
|
INPUT_ARCHITECTURES: ${{ inputs.architectures }}
|
||||||
if [ ! -s "$f" ] || [ "$(cat "$f" 2>/dev/null)" = "null" ]; then
|
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
|
||||||
rm -f "$f"
|
run: node "${{ github.action_path }}/lib/extract-sboms.js"
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract platform SBOMs for head image
|
|
||||||
echo "Extracting platform SBOMs for head image: ${{ inputs.head-image }}..."
|
|
||||||
docker buildx imagetools inspect "${{ inputs.head-image }}" \
|
|
||||||
--format '{{ json (index .SBOM "linux/amd64").SPDX }}' > sboms/head/sbom-amd64.spdx.json 2>/dev/null || true
|
|
||||||
cleanup_file sboms/head/sbom-amd64.spdx.json
|
|
||||||
|
|
||||||
docker buildx imagetools inspect "${{ inputs.head-image }}" \
|
|
||||||
--format '{{ json (index .SBOM "linux/arm64").SPDX }}' > sboms/head/sbom-arm64.spdx.json 2>/dev/null || true
|
|
||||||
cleanup_file sboms/head/sbom-arm64.spdx.json
|
|
||||||
|
|
||||||
# If single-arch, extract root .SPDX
|
|
||||||
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then
|
|
||||||
docker buildx imagetools inspect "${{ inputs.head-image }}" \
|
|
||||||
--format '{{ json .SBOM.SPDX }}' > sboms/head/sbom-amd64.spdx.json 2>/dev/null || true
|
|
||||||
cleanup_file sboms/head/sbom-amd64.spdx.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fallback to generating head SBOM with Trivy if attestation missing
|
|
||||||
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then
|
|
||||||
echo "Head image lacks SBOM attestation; generating with Trivy..."
|
|
||||||
trivy image --format spdx-json --output sboms/head/sbom-amd64.spdx.json "${{ inputs.head-image }}" || true
|
|
||||||
cleanup_file sboms/head/sbom-amd64.spdx.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract platform SBOMs for target image
|
|
||||||
echo "Extracting platform SBOMs for target image: ${{ inputs.target-image }}..."
|
|
||||||
docker buildx imagetools inspect "${{ inputs.target-image }}" \
|
|
||||||
--format '{{ json (index .SBOM "linux/amd64").SPDX }}' > sboms/target/sbom-amd64.spdx.json 2>/dev/null || true
|
|
||||||
cleanup_file sboms/target/sbom-amd64.spdx.json
|
|
||||||
|
|
||||||
docker buildx imagetools inspect "${{ inputs.target-image }}" \
|
|
||||||
--format '{{ json (index .SBOM "linux/arm64").SPDX }}' > sboms/target/sbom-arm64.spdx.json 2>/dev/null || true
|
|
||||||
cleanup_file sboms/target/sbom-arm64.spdx.json
|
|
||||||
|
|
||||||
if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ]; then
|
|
||||||
docker buildx imagetools inspect "${{ inputs.target-image }}" \
|
|
||||||
--format '{{ json .SBOM.SPDX }}' > sboms/target/sbom-amd64.spdx.json 2>/dev/null || true
|
|
||||||
cleanup_file sboms/target/sbom-amd64.spdx.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fallback to generating target SBOM with Trivy if attestation missing
|
|
||||||
if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ]; then
|
|
||||||
echo "Target image lacks SBOM attestation; attempting to generate with Trivy..."
|
|
||||||
trivy image --format spdx-json --output sboms/target/sbom-amd64.spdx.json "${{ inputs.target-image }}" || true
|
|
||||||
cleanup_file sboms/target/sbom-amd64.spdx.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Expose extracted head SBOMs to workspace root for workflow artifact uploads
|
|
||||||
[ -f sboms/head/sbom-amd64.spdx.json ] && cp sboms/head/sbom-amd64.spdx.json sbom-amd64.spdx.json || true
|
|
||||||
[ -f sboms/head/sbom-arm64.spdx.json ] && cp sboms/head/sbom-arm64.spdx.json sbom-arm64.spdx.json || true
|
|
||||||
|
|
||||||
- name: Scan Multi-Platform SBOMs and Generate Delta
|
- name: Scan Multi-Platform SBOMs and Generate Delta
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
env:
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
|
||||||
mkdir -p cve-reports/head cve-reports/target
|
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||||
|
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
|
||||||
# Scan each available head SBOM
|
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
|
||||||
for f in sboms/head/sbom-*.spdx.json; do
|
INPUT_TARGET_BRANCH: ${{ inputs.target-branch }}
|
||||||
[ -f "$f" ] || continue
|
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
|
||||||
arch=$(basename "$f" .spdx.json | sed 's/sbom-//')
|
INPUT_OUTPUT_DELTA: ${{ inputs.output-delta }}
|
||||||
echo "Scanning head SBOM for architecture: $arch"
|
INPUT_OUTPUT_SUMMARY: ${{ inputs.output-summary }}
|
||||||
trivy sbom --format json --output "cve-reports/head/cve-${arch}.json" "$f" || true
|
run: node "${{ github.action_path }}/lib/scan-sboms.js"
|
||||||
done
|
|
||||||
|
|
||||||
# Scan each available target SBOM
|
|
||||||
for f in sboms/target/sbom-*.spdx.json; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
arch=$(basename "$f" .spdx.json | sed 's/sbom-//')
|
|
||||||
echo "Scanning target SBOM for architecture: $arch"
|
|
||||||
trivy sbom --format json --output "cve-reports/target/cve-${arch}.json" "$f" || true
|
|
||||||
done
|
|
||||||
|
|
||||||
# Run multi-arch set-based diff
|
|
||||||
node "${{ github.action_path }}/cve-diff.js" \
|
|
||||||
"cve-reports/target" \
|
|
||||||
"cve-reports/head" \
|
|
||||||
"${{ inputs.target-branch }}" > "${{ inputs.output-delta }}" || true
|
|
||||||
|
|
||||||
# Generate full markdown summary report for primary platform
|
|
||||||
primary_sbom=""
|
|
||||||
if [ -f sboms/head/sbom-amd64.spdx.json ]; then
|
|
||||||
primary_sbom="sboms/head/sbom-amd64.spdx.json"
|
|
||||||
elif [ -f sboms/head/sbom-arm64.spdx.json ]; then
|
|
||||||
primary_sbom="sboms/head/sbom-arm64.spdx.json"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$primary_sbom" ]; then
|
|
||||||
trivy sbom \
|
|
||||||
--format template \
|
|
||||||
--template "@${{ github.action_path }}/markdown.tpl" \
|
|
||||||
--output "${{ inputs.output-summary }}" \
|
|
||||||
"$primary_sbom" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Publish Reports to Step Summary
|
- name: Publish Reports to Step Summary
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
+167
-40
@@ -24,6 +24,7 @@ const SEVERITY_BADGES = {
|
|||||||
*/
|
*/
|
||||||
function loadReportsForArg(arg) {
|
function loadReportsForArg(arg) {
|
||||||
const cves = new Map();
|
const cves = new Map();
|
||||||
|
const scannedArchs = new Set();
|
||||||
const filePaths = [];
|
const filePaths = [];
|
||||||
|
|
||||||
for (const part of arg.split(',').map((s) => s.trim())) {
|
for (const part of arg.split(',').map((s) => s.trim())) {
|
||||||
@@ -41,36 +42,57 @@ function loadReportsForArg(arg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filePaths.length === 0) {
|
||||||
|
console.error(`::error::No CVE JSON report files found for path: ${arg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadedFiles = 0;
|
||||||
for (const filePath of filePaths) {
|
for (const filePath of filePaths) {
|
||||||
let stat;
|
let stat;
|
||||||
try {
|
try {
|
||||||
stat = fs.statSync(filePath);
|
stat = fs.statSync(filePath);
|
||||||
} catch {
|
} catch (e) {
|
||||||
continue;
|
console.error(`::error::Failed to access report file ${filePath}: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (stat.size === 0) {
|
||||||
|
console.error(`::error::CVE report file is empty (0 bytes): ${filePath}`);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (stat.size === 0) continue;
|
|
||||||
|
|
||||||
const baseName = path.basename(filePath).toLowerCase();
|
const baseName = path.basename(filePath, '.json').toLowerCase();
|
||||||
let arch = 'all';
|
let arch = 'all';
|
||||||
if (baseName.includes('amd64')) {
|
const cveMatch = baseName.match(/^cve-([a-z0-9_.-]+)$/i);
|
||||||
|
if (cveMatch) {
|
||||||
|
arch = cveMatch[1];
|
||||||
|
} else if (baseName.includes('amd64')) {
|
||||||
arch = 'amd64';
|
arch = 'amd64';
|
||||||
} else if (baseName.includes('arm64') || baseName.includes('aarch64')) {
|
} else if (baseName.includes('arm64') || baseName.includes('aarch64')) {
|
||||||
arch = 'arm64';
|
arch = 'arm64';
|
||||||
}
|
}
|
||||||
|
if (arch !== 'all') {
|
||||||
|
scannedArchs.add(arch);
|
||||||
|
}
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
data = JSON.parse(content);
|
data = JSON.parse(content);
|
||||||
} catch {
|
} catch (e) {
|
||||||
continue;
|
console.error(`::error::Invalid JSON in CVE report file ${filePath}: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || typeof data !== 'object' || !Array.isArray(data.Results)) {
|
if (!data || typeof data !== 'object') {
|
||||||
continue;
|
console.error(`::error::Malformed Trivy report structure in: ${filePath}`);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const result of data.Results) {
|
const results = Array.isArray(data.Results) ? data.Results : [];
|
||||||
|
loadedFiles++;
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -88,15 +110,23 @@ function loadReportsForArg(arg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadedFiles === 0) {
|
||||||
|
console.error(`::error::No valid CVE reports could be loaded for path: ${arg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
cves.scannedArchs = scannedArchs;
|
||||||
return cves;
|
return cves;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a vulnerability record into a Markdown table row.
|
* Formats a vulnerability record into a Markdown table row.
|
||||||
* @param {object} v
|
* @param {object} v
|
||||||
|
* @param {Set<string>} [allScannedArchs]
|
||||||
|
* @param {boolean} [showArch]
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function formatVulnRow(v) {
|
function formatVulnRow(v, allScannedArchs = new Set(), showArch = true) {
|
||||||
const sev = v.Severity || 'UNKNOWN';
|
const sev = v.Severity || 'UNKNOWN';
|
||||||
const badge = SEVERITY_BADGES[sev] || `⚪ ${sev}`;
|
const badge = SEVERITY_BADGES[sev] || `⚪ ${sev}`;
|
||||||
const vid = v.VulnerabilityID || '';
|
const vid = v.VulnerabilityID || '';
|
||||||
@@ -104,24 +134,31 @@ function formatVulnRow(v) {
|
|||||||
const link = url ? `[${vid}](${url})` : vid;
|
const link = url ? `[${vid}](${url})` : vid;
|
||||||
const pkg = v.PkgName || '';
|
const pkg = v.PkgName || '';
|
||||||
|
|
||||||
const archs = v._archs || new Set(['all']);
|
|
||||||
let archBadge;
|
|
||||||
if ((archs.has('amd64') && archs.has('arm64')) || archs.has('all')) {
|
|
||||||
archBadge = '`all`';
|
|
||||||
} else {
|
|
||||||
archBadge = `\`${Array.from(archs).sort().join(', ')}\``;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showArch) {
|
||||||
|
const archs = v._archs || new Set(['all']);
|
||||||
|
let archBadge;
|
||||||
|
if (
|
||||||
|
archs.has('all') ||
|
||||||
|
(allScannedArchs && allScannedArchs.size > 1 && archs.size === allScannedArchs.size) ||
|
||||||
|
(archs.has('amd64') && archs.has('arm64'))
|
||||||
|
) {
|
||||||
|
archBadge = '`all`';
|
||||||
|
} else {
|
||||||
|
archBadge = `\`${Array.from(archs).sort().join(', ')}\``;
|
||||||
|
}
|
||||||
return `| ${badge} | ${link} | \`${pkg}\` | ${archBadge} | ${installed} | ${fixed} | ${title} |`;
|
return `| ${badge} | ${link} | \`${pkg}\` | ${archBadge} | ${installed} | ${fixed} | ${title} |`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return `| ${badge} | ${link} | \`${pkg}\` | ${installed} | ${fixed} | ${title} |`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compares base and branch vulnerabilities and formats a Markdown diff.
|
* Compares base and branch vulnerabilities and formats a Markdown diff.
|
||||||
* @param {Map<string, object>} baseCves
|
* @param {Map<string, object>} baseCves
|
||||||
@@ -130,19 +167,17 @@ function formatVulnRow(v) {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
|
function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
|
||||||
if (baseCves.size === 0) {
|
|
||||||
return [
|
|
||||||
`### 🔄 Vulnerability Delta (vs \`${baseName}\`)`,
|
|
||||||
'',
|
|
||||||
'> [!NOTE]',
|
|
||||||
`> No baseline scan from \`${baseName}\` was available to compare against.`,
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseKeys = new Set(baseCves.keys());
|
const baseKeys = new Set(baseCves.keys());
|
||||||
const branchKeys = new Set(branchCves.keys());
|
const branchKeys = new Set(branchCves.keys());
|
||||||
|
|
||||||
|
if (branchCves.size === 0) {
|
||||||
|
console.error('::warning::Head image contains 0 reported vulnerabilities. Verify that packages and dependencies were recognized.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseCves.size === 0) {
|
||||||
|
console.error(`::warning::Baseline target (${baseName}) contains 0 reported vulnerabilities.`);
|
||||||
|
}
|
||||||
|
|
||||||
const newKeys = [];
|
const newKeys = [];
|
||||||
let unchangedCount = 0;
|
let unchangedCount = 0;
|
||||||
for (const key of branchKeys) {
|
for (const key of branchKeys) {
|
||||||
@@ -171,26 +206,52 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
|
|||||||
return aPkg.localeCompare(bPkg);
|
return aPkg.localeCompare(bPkg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allScannedArchs = new Set([
|
||||||
|
...(baseCves.scannedArchs || []),
|
||||||
|
...(branchCves.scannedArchs || []),
|
||||||
|
]);
|
||||||
|
const showArch = allScannedArchs.size > 1;
|
||||||
|
|
||||||
const newVulns = newKeys.map((k) => branchCves.get(k)).sort(sortVulns);
|
const newVulns = newKeys.map((k) => branchCves.get(k)).sort(sortVulns);
|
||||||
const resolvedVulns = resolvedKeys.map((k) => baseCves.get(k)).sort(sortVulns);
|
const resolvedVulns = resolvedKeys.map((k) => baseCves.get(k)).sort(sortVulns);
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`### 🔄 Vulnerability Delta (vs \`${baseName}\`)`,
|
`### 🔄 Vulnerability Delta (vs \`${baseName}\`)`,
|
||||||
'',
|
'',
|
||||||
'| Metric | Count |',
|
|
||||||
'| :--- | :--- |',
|
|
||||||
`| 🚨 **New Vulnerabilities** | ${newVulns.length > 0 ? `🚨 **${newVulns.length}**` : '0'} |`,
|
|
||||||
`| ✅ **Resolved Vulnerabilities** | ${resolvedVulns.length > 0 ? `✅ **${resolvedVulns.length}**` : '0'} |`,
|
|
||||||
`| ⚪ **Unchanged Vulnerabilities** | ${unchangedCount} |`,
|
|
||||||
'',
|
|
||||||
`#### 🚨 New Vulnerabilities Introduced (${newVulns.length})`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (branchCves.size === 0 && baseCves.size === 0) {
|
||||||
|
lines.push('> [!WARNING]');
|
||||||
|
lines.push('> Both the head image and baseline target have **0 reported vulnerabilities**.');
|
||||||
|
lines.push('');
|
||||||
|
} else if (branchCves.size === 0) {
|
||||||
|
lines.push('> [!WARNING]');
|
||||||
|
lines.push('> The head image has **0 reported vulnerabilities**. Verify that OS packages and language dependencies were properly indexed in the SBOM.');
|
||||||
|
lines.push('');
|
||||||
|
} else if (baseCves.size === 0) {
|
||||||
|
lines.push('> [!WARNING]');
|
||||||
|
lines.push(`> The baseline image (\`${baseName}\`) has **0 reported vulnerabilities**.`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('| Metric | Count |');
|
||||||
|
lines.push('| :--- | :--- |');
|
||||||
|
lines.push(`| 🚨 **New Vulnerabilities** | ${newVulns.length > 0 ? `🚨 **${newVulns.length}**` : '0'} |`);
|
||||||
|
lines.push(`| ✅ **Resolved Vulnerabilities** | ${resolvedVulns.length > 0 ? `✅ **${resolvedVulns.length}**` : '0'} |`);
|
||||||
|
lines.push(`| ⚪ **Unchanged Vulnerabilities** | ${unchangedCount} |`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`#### 🚨 New Vulnerabilities Introduced (${newVulns.length})`);
|
||||||
|
|
||||||
if (newVulns.length > 0) {
|
if (newVulns.length > 0) {
|
||||||
|
if (showArch) {
|
||||||
lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |');
|
lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |');
|
||||||
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
} else {
|
||||||
|
lines.push('| Severity | CVE | Package | Installed | Fixed In | Title |');
|
||||||
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
}
|
||||||
for (const v of newVulns) {
|
for (const v of newVulns) {
|
||||||
lines.push(formatVulnRow(v));
|
lines.push(formatVulnRow(v, allScannedArchs, showArch));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lines.push('✅ No new vulnerabilities introduced in this build.');
|
lines.push('✅ No new vulnerabilities introduced in this build.');
|
||||||
@@ -199,10 +260,15 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
|
|||||||
|
|
||||||
lines.push(`#### ✅ Resolved / Removed Vulnerabilities (${resolvedVulns.length})`);
|
lines.push(`#### ✅ Resolved / Removed Vulnerabilities (${resolvedVulns.length})`);
|
||||||
if (resolvedVulns.length > 0) {
|
if (resolvedVulns.length > 0) {
|
||||||
|
if (showArch) {
|
||||||
lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |');
|
lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |');
|
||||||
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
} else {
|
||||||
|
lines.push('| Severity | CVE | Package | Previously Installed | Fixed In | Title |');
|
||||||
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
}
|
||||||
for (const v of resolvedVulns) {
|
for (const v of resolvedVulns) {
|
||||||
lines.push(formatVulnRow(v));
|
lines.push(formatVulnRow(v, allScannedArchs, showArch));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lines.push('⚪ No existing vulnerabilities were resolved.');
|
lines.push('⚪ No existing vulnerabilities were resolved.');
|
||||||
@@ -212,22 +278,82 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a full markdown summary report of all vulnerabilities in a scan.
|
||||||
|
* @param {Map<string, object>} cves
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function renderFullSummary(cves) {
|
||||||
|
const allScannedArchs = cves.scannedArchs || new Set();
|
||||||
|
const showArch = allScannedArchs.size > 1;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'### 🛡️ Security Scan Report',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cves.size === 0) {
|
||||||
|
lines.push('✅ No vulnerabilities found.');
|
||||||
|
lines.push('');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortVulns(a, b) {
|
||||||
|
const aSev = a.Severity || 'UNKNOWN';
|
||||||
|
const bSev = b.Severity || 'UNKNOWN';
|
||||||
|
const aIdx = SEVERITY_ORDER.indexOf(aSev) !== -1 ? SEVERITY_ORDER.indexOf(aSev) : 99;
|
||||||
|
const bIdx = SEVERITY_ORDER.indexOf(bSev) !== -1 ? SEVERITY_ORDER.indexOf(bSev) : 99;
|
||||||
|
if (aIdx !== bIdx) return aIdx - bIdx;
|
||||||
|
const aPkg = a.PkgName || '';
|
||||||
|
const bPkg = b.PkgName || '';
|
||||||
|
return aPkg.localeCompare(bPkg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedVulns = Array.from(cves.values()).sort(sortVulns);
|
||||||
|
|
||||||
|
lines.push(`| Metric | Count |`);
|
||||||
|
lines.push(`| :--- | :--- |`);
|
||||||
|
lines.push(`| Total Vulnerabilities | **${sortedVulns.length}** |`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
if (showArch) {
|
||||||
|
lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |');
|
||||||
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
} else {
|
||||||
|
lines.push('| Severity | CVE | Package | Installed | Fixed In | Title |');
|
||||||
|
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- |');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of sortedVulns) {
|
||||||
|
lines.push(formatVulnRow(v, allScannedArchs, showArch));
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
if (args.length < 2) {
|
if (args.length < 2) {
|
||||||
console.error('Usage: cve-diff.js <base_report(s)> <branch_report(s)> [base_name]');
|
console.error('Usage: cve-diff.js <base_report(s)> <branch_report(s)> [base_name] [summary_output_path]');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseArg = args[0];
|
const baseArg = args[0];
|
||||||
const branchArg = args[1];
|
const branchArg = args[1];
|
||||||
const baseName = args[2] || 'main';
|
const baseName = args[2] || 'main';
|
||||||
|
const summaryOutputPath = args[3];
|
||||||
|
|
||||||
const baseCves = loadReportsForArg(baseArg);
|
const baseCves = loadReportsForArg(baseArg);
|
||||||
const branchCves = loadReportsForArg(branchArg);
|
const branchCves = loadReportsForArg(branchArg);
|
||||||
|
|
||||||
const output = renderMarkdownDiff(baseCves, branchCves, baseName);
|
const output = renderMarkdownDiff(baseCves, branchCves, baseName);
|
||||||
console.log(output);
|
console.log(output);
|
||||||
|
|
||||||
|
if (summaryOutputPath) {
|
||||||
|
const summaryOutput = renderFullSummary(branchCves);
|
||||||
|
fs.writeFileSync(summaryOutputPath, summaryOutput, 'utf8');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
@@ -238,4 +364,5 @@ module.exports = {
|
|||||||
loadReportsForArg,
|
loadReportsForArg,
|
||||||
formatVulnRow,
|
formatVulnRow,
|
||||||
renderMarkdownDiff,
|
renderMarkdownDiff,
|
||||||
|
renderFullSummary,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Diagnostic formatting and reporting utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function formatSbomOverview({
|
||||||
|
headImage,
|
||||||
|
targetImage,
|
||||||
|
headFiles = [],
|
||||||
|
targetFiles = [],
|
||||||
|
headError = null,
|
||||||
|
targetError = null,
|
||||||
|
}) {
|
||||||
|
const lines = [
|
||||||
|
'',
|
||||||
|
'================================================================================',
|
||||||
|
' SBOM EXTRACTION DIAGNOSTIC OVERVIEW ',
|
||||||
|
'================================================================================',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (headError) {
|
||||||
|
lines.push(`HEAD Image (${headImage}): ❌ FAILED - ${headError}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`HEAD Image (${headImage}): ✅ SUCCESS`);
|
||||||
|
for (const f of headFiles) {
|
||||||
|
lines.push(` - ${f.name} (${f.size} bytes)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetError) {
|
||||||
|
lines.push(`TARGET Baseline Image (${targetImage}): ❌ FAILED - ${targetError}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`TARGET Baseline Image (${targetImage}): ✅ SUCCESS`);
|
||||||
|
for (const f of targetFiles) {
|
||||||
|
lines.push(` - ${f.name} (${f.size} bytes)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('================================================================================');
|
||||||
|
lines.push('');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScanOverview({
|
||||||
|
headImage,
|
||||||
|
targetImage,
|
||||||
|
targetBranch = 'main',
|
||||||
|
strategy = null,
|
||||||
|
headSummary = [],
|
||||||
|
targetSummary = [],
|
||||||
|
}) {
|
||||||
|
const lines = [
|
||||||
|
'',
|
||||||
|
'================================================================================',
|
||||||
|
' CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW ',
|
||||||
|
'================================================================================',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (strategy) {
|
||||||
|
lines.push(`Scan Strategy: ${strategy}`);
|
||||||
|
lines.push('--------------------------------------------------------------------------------');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`HEAD Image: ${headImage}`);
|
||||||
|
for (const line of headSummary) {
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`TARGET Baseline Image: ${targetImage} (branch: '${targetBranch}')`);
|
||||||
|
for (const line of targetSummary) {
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('================================================================================');
|
||||||
|
lines.push('');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function countVulnerabilities(report) {
|
||||||
|
if (!report) return 0;
|
||||||
|
let data = report;
|
||||||
|
if (typeof report === 'string' || Buffer.isBuffer(report)) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(report.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!data || typeof data !== 'object') return 0;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
if (Array.isArray(data.Results)) {
|
||||||
|
for (const res of data.Results) {
|
||||||
|
if (res && Array.isArray(res.Vulnerabilities)) {
|
||||||
|
count += res.Vulnerabilities.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
formatSbomOverview,
|
||||||
|
formatScanOverview,
|
||||||
|
countVulnerabilities,
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Distribution detection from SPDX SBOM package PURLs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function detectDistro(spdxInput) {
|
||||||
|
if (!spdxInput) return null;
|
||||||
|
|
||||||
|
let data = spdxInput;
|
||||||
|
if (typeof spdxInput === 'string' || Buffer.isBuffer(spdxInput)) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(spdxInput.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || typeof data !== 'object' || !Array.isArray(data.packages)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pkg of data.packages) {
|
||||||
|
if (!pkg || !Array.isArray(pkg.externalRefs)) continue;
|
||||||
|
for (const ref of pkg.externalRefs) {
|
||||||
|
if (ref && ref.referenceType === 'purl' && typeof ref.referenceLocator === 'string') {
|
||||||
|
const purl = ref.referenceLocator;
|
||||||
|
const nameMatch = purl.match(/[?&]os_name=([^&]+)/);
|
||||||
|
const verMatch = purl.match(/[?&]os_version=([^&]+)/);
|
||||||
|
if (nameMatch && verMatch) {
|
||||||
|
const osName = decodeURIComponent(nameMatch[1]);
|
||||||
|
const osVer = decodeURIComponent(verMatch[1]);
|
||||||
|
return `${osName}/${osVer}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
detectDistro,
|
||||||
|
};
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts or generates multi-platform SBOMs for head and target images.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const cp = require('node:child_process');
|
||||||
|
const { parsePlatforms, discoverPlatforms, toArchTag } = require('./platforms.js');
|
||||||
|
const { formatSbomOverview } = require('./diagnostics.js');
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
headImage: process.env.INPUT_HEAD_IMAGE || '',
|
||||||
|
targetImage: process.env.INPUT_TARGET_IMAGE || '',
|
||||||
|
architectures: process.env.INPUT_ARCHITECTURES || '',
|
||||||
|
useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 2; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === '--head-image' && i + 1 < argv.length) args.headImage = argv[++i];
|
||||||
|
else if (arg === '--target-image' && i + 1 < argv.length) args.targetImage = argv[++i];
|
||||||
|
else if (arg === '--architectures' && i + 1 < argv.length) args.architectures = argv[++i];
|
||||||
|
else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupInvalidFile(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return;
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (stat.size === 0) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8').trim();
|
||||||
|
if (content === 'null' || content === '') {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommandSafe(cmd, args) {
|
||||||
|
try {
|
||||||
|
return cp.execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOrGenerateSbom({ image, platform, archTag, outputDir, useInbuild }) {
|
||||||
|
const outputFile = path.join(outputDir, `sbom-${archTag}.spdx.json`);
|
||||||
|
const inbuildFile = path.join(outputDir, `inbuild-sbom-${archTag}.spdx.json`);
|
||||||
|
|
||||||
|
// 1. Attempt in-build SBOM extraction if requested
|
||||||
|
if (useInbuild) {
|
||||||
|
console.log(`Attempting extraction of built-in SBOM for image (${platform})...`);
|
||||||
|
const formatStr = `{{ json (index .SBOM "${platform}").SPDX }}`;
|
||||||
|
const rawSbom = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', image, '--format', formatStr]);
|
||||||
|
if (rawSbom) {
|
||||||
|
fs.writeFileSync(outputFile, rawSbom);
|
||||||
|
cleanupInvalidFile(outputFile);
|
||||||
|
if (fs.existsSync(outputFile)) {
|
||||||
|
fs.copyFileSync(outputFile, inbuildFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to direct Trivy image generation if SBOM not present
|
||||||
|
if (!fs.existsSync(outputFile)) {
|
||||||
|
console.log(`Generating SBOM for image (${platform}) with Trivy...`);
|
||||||
|
const platArgs = platform ? ['--platform', platform] : [];
|
||||||
|
try {
|
||||||
|
cp.execFileSync('trivy', [
|
||||||
|
'image',
|
||||||
|
'--skip-version-check',
|
||||||
|
...platArgs,
|
||||||
|
'--format', 'spdx-json',
|
||||||
|
'--output', outputFile,
|
||||||
|
image,
|
||||||
|
], { stdio: 'inherit' });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`::warning::Trivy image scan failed for image (${platform}): ${e.message}`);
|
||||||
|
}
|
||||||
|
cleanupInvalidFile(outputFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv);
|
||||||
|
const { headImage, targetImage, architectures, useInbuildSbom } = args;
|
||||||
|
const useInbuild = useInbuildSbom === 'true';
|
||||||
|
|
||||||
|
if (!headImage || !targetImage) {
|
||||||
|
console.error('::error::Both --head-image and --target-image must be provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headDir = path.join('sboms', 'head');
|
||||||
|
const targetDir = path.join('sboms', 'target');
|
||||||
|
fs.mkdirSync(headDir, { recursive: true });
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
// Determine platforms
|
||||||
|
let platforms = parsePlatforms(architectures);
|
||||||
|
if (platforms.length === 0) {
|
||||||
|
console.log('Auto-discovering architectures from images...');
|
||||||
|
const rawHead = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', '--raw', headImage]);
|
||||||
|
const rawTarget = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', '--raw', targetImage]);
|
||||||
|
platforms = discoverPlatforms(rawHead, rawTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Configured platforms: ${platforms.length > 0 ? platforms.join(', ') : '(single platform fallback)'}`);
|
||||||
|
if (useInbuild) {
|
||||||
|
console.log('ℹ️ Strategy: Built-in SBOM attestation extraction enabled (Docker Scout / Syft). Skips container layer downloads for maximum speed.');
|
||||||
|
console.log('ℹ️ Note: Built-in SBOM vulnerability counts may differ from direct filesystem scans (\'trivy image\') due to binary vs. source package tracking.');
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ Strategy: Direct Trivy image scanning enabled (default). Inspects full container rootfs and package databases for maximum fidelity.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPlatforms = platforms.length > 0 ? platforms : [''];
|
||||||
|
|
||||||
|
for (const platform of targetPlatforms) {
|
||||||
|
const archTag = toArchTag(platform);
|
||||||
|
// Head extraction
|
||||||
|
extractOrGenerateSbom({
|
||||||
|
image: headImage,
|
||||||
|
platform,
|
||||||
|
archTag,
|
||||||
|
outputDir: headDir,
|
||||||
|
useInbuild,
|
||||||
|
});
|
||||||
|
// Target extraction
|
||||||
|
extractOrGenerateSbom({
|
||||||
|
image: targetImage,
|
||||||
|
platform,
|
||||||
|
archTag,
|
||||||
|
outputDir: targetDir,
|
||||||
|
useInbuild,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect generated files
|
||||||
|
const getSbomFiles = (dir) => {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs.readdirSync(dir)
|
||||||
|
.filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json'))
|
||||||
|
.map((f) => {
|
||||||
|
const fullPath = path.join(dir, f);
|
||||||
|
return { name: f, size: fs.statSync(fullPath).size };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const headFiles = getSbomFiles(headDir);
|
||||||
|
const targetFiles = getSbomFiles(targetDir);
|
||||||
|
|
||||||
|
let headError = null;
|
||||||
|
let targetError = null;
|
||||||
|
if (headFiles.length === 0) headError = 'No SBOM generated and head image could not be inspected';
|
||||||
|
if (targetFiles.length === 0) targetError = 'No SBOM generated and target baseline image could not be inspected';
|
||||||
|
|
||||||
|
const overview = formatSbomOverview({
|
||||||
|
headImage,
|
||||||
|
targetImage,
|
||||||
|
headFiles,
|
||||||
|
targetFiles,
|
||||||
|
headError,
|
||||||
|
targetError,
|
||||||
|
});
|
||||||
|
console.log(overview);
|
||||||
|
|
||||||
|
if (headError || targetError) {
|
||||||
|
console.error('::error::Both head and target images must have valid SBOMs to proceed with differential scanning.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose extracted head SBOMs to workspace root for workflow artifact uploads
|
||||||
|
for (const file of headFiles) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(path.join(headDir, file.name), file.name);
|
||||||
|
} catch {
|
||||||
|
// Ignore copy error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseArgs,
|
||||||
|
cleanupInvalidFile,
|
||||||
|
extractOrGenerateSbom,
|
||||||
|
main,
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs and verifies Trivy binary against checksums lockfile.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const cp = require('node:child_process');
|
||||||
|
const { mapArch, parseChecksums, verifyFileSha256 } = require('./verifier.js');
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const version = process.env.TRIVY_VERSION || '0.74.0';
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
const binDir = path.join(homeDir, '.local', 'bin');
|
||||||
|
const cacheDir = path.join(homeDir, '.cache', 'trivy-bin');
|
||||||
|
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
|
||||||
|
// Expose bin directory to GITHUB_PATH if running in Actions runner
|
||||||
|
if (process.env.GITHUB_PATH) {
|
||||||
|
fs.appendFileSync(process.env.GITHUB_PATH, `${binDir}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unameArch = os.arch() === 'x64' ? 'x86_64' : (os.arch() === 'arm64' ? 'ARM64' : os.arch());
|
||||||
|
const tArch = mapArch(unameArch);
|
||||||
|
const tarball = `trivy_${version}_Linux-${tArch}.tar.gz`;
|
||||||
|
const tarballPath = path.join(cacheDir, tarball);
|
||||||
|
|
||||||
|
const checksumsFile = path.resolve(__dirname, '..', 'checksums.txt');
|
||||||
|
if (!fs.existsSync(checksumsFile)) {
|
||||||
|
console.error(`::error::Checksums lockfile not found at ${checksumsFile}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checksums = parseChecksums(fs.readFileSync(checksumsFile, 'utf8'));
|
||||||
|
const expectedHash = checksums.get(tarball);
|
||||||
|
if (!expectedHash) {
|
||||||
|
console.error(`::error::Tarball ${tarball} not found in checksums.txt`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(tarballPath) && verifyFileSha256(tarballPath, expectedHash)) {
|
||||||
|
console.log('Cached Trivy archive verified against lockfile.');
|
||||||
|
} else {
|
||||||
|
console.log('Archive missing or checksum mismatch; downloading...');
|
||||||
|
const downloadUrl = `https://github.com/aquasecurity/trivy/releases/download/v${version}/${tarball}`;
|
||||||
|
cp.execFileSync('curl', ['-sfLO', downloadUrl, '--output-dir', cacheDir], { stdio: 'inherit' });
|
||||||
|
|
||||||
|
if (!verifyFileSha256(tarballPath, expectedHash)) {
|
||||||
|
console.error(`::error::Downloaded archive ${tarball} failed SHA256 checksum verification!`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Downloaded Trivy archive verified successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract binary
|
||||||
|
cp.execFileSync('tar', ['-xzf', tarballPath, '-C', binDir, 'trivy'], { stdio: 'inherit' });
|
||||||
|
const trivyBin = path.join(binDir, 'trivy');
|
||||||
|
if (fs.existsSync(trivyBin)) {
|
||||||
|
fs.chmodSync(trivyBin, 0o755);
|
||||||
|
console.log(`Trivy ${version} installed and verified successfully at ${trivyBin}`);
|
||||||
|
} else {
|
||||||
|
console.error('::error::Failed to extract trivy binary from tarball.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { main };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Platform and architecture resolution utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parsePlatforms(input) {
|
||||||
|
if (!input || typeof input !== 'string') return [];
|
||||||
|
const parts = input.split(',');
|
||||||
|
const platforms = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const clean = part.trim();
|
||||||
|
if (!clean) continue;
|
||||||
|
if (!clean.includes('/')) {
|
||||||
|
platforms.push(`linux/${clean}`);
|
||||||
|
} else {
|
||||||
|
platforms.push(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return platforms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverPlatforms(headRawJson, targetRawJson) {
|
||||||
|
const set = new Set();
|
||||||
|
for (const raw of [headRawJson, targetRawJson]) {
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
const manifests = data.manifests || [];
|
||||||
|
for (const m of manifests) {
|
||||||
|
if (m.platform && m.platform.architecture && m.platform.architecture !== 'unknown') {
|
||||||
|
const os = m.platform.os || 'linux';
|
||||||
|
const variant = m.platform.variant ? `/${m.platform.variant}` : '';
|
||||||
|
set.add(`${os}/${m.platform.architecture}${variant}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parse errors from invalid manifests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArchTag(platform) {
|
||||||
|
if (!platform) return 'default';
|
||||||
|
// Strip optional leading os/ (e.g. linux/)
|
||||||
|
const withoutOs = platform.replace(/^[a-zA-Z0-9_-]+\//, '');
|
||||||
|
// Replace slashes with dashes (e.g. arm/v7 -> arm-v7)
|
||||||
|
return withoutOs.replace(/\//g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parsePlatforms,
|
||||||
|
discoverPlatforms,
|
||||||
|
toArchTag,
|
||||||
|
};
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans multi-platform SBOMs with Trivy, handles distro detection and fallbacks,
|
||||||
|
* prints diagnostic overview, and invokes diff engine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const cp = require('node:child_process');
|
||||||
|
const { detectDistro } = require('./distro.js');
|
||||||
|
const { formatScanOverview, countVulnerabilities } = require('./diagnostics.js');
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
headImage: process.env.INPUT_HEAD_IMAGE || '',
|
||||||
|
targetImage: process.env.INPUT_TARGET_IMAGE || '',
|
||||||
|
targetBranch: process.env.INPUT_TARGET_BRANCH || 'main',
|
||||||
|
useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false',
|
||||||
|
outputDelta: process.env.INPUT_OUTPUT_DELTA || 'cve-delta.md',
|
||||||
|
outputSummary: process.env.INPUT_OUTPUT_SUMMARY || 'cve-summary.md',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 2; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === '--head-image' && i + 1 < argv.length) args.headImage = argv[++i];
|
||||||
|
else if (arg === '--target-image' && i + 1 < argv.length) args.targetImage = argv[++i];
|
||||||
|
else if (arg === '--target-branch' && i + 1 < argv.length) args.targetBranch = argv[++i];
|
||||||
|
else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i];
|
||||||
|
else if (arg === '--output-delta' && i + 1 < argv.length) args.outputDelta = argv[++i];
|
||||||
|
else if (arg === '--output-summary' && i + 1 < argv.length) args.outputSummary = argv[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanSbomFile({ sbomPath, image, outJson, errLog, useInbuild }) {
|
||||||
|
const archMatch = path.basename(sbomPath, '.spdx.json').match(/^sbom-(.+)$/);
|
||||||
|
const arch = archMatch ? archMatch[1] : 'default';
|
||||||
|
|
||||||
|
console.log(`Scanning SBOM for architecture: ${arch}`);
|
||||||
|
let distroArg = [];
|
||||||
|
if (useInbuild) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(sbomPath, 'utf8');
|
||||||
|
const detected = detectDistro(content);
|
||||||
|
if (detected) {
|
||||||
|
console.log(`Detected distribution '${detected}' from in-build SBOM PURLs. Scanning with --distro.`);
|
||||||
|
distroArg = ['--distro', detected];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore read error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runTrivySbom = () => {
|
||||||
|
try {
|
||||||
|
const errFd = fs.openSync(errLog, 'w');
|
||||||
|
try {
|
||||||
|
cp.execFileSync('trivy', [
|
||||||
|
'sbom',
|
||||||
|
...distroArg,
|
||||||
|
'--skip-version-check',
|
||||||
|
'--format', 'json',
|
||||||
|
'--output', outJson,
|
||||||
|
sbomPath,
|
||||||
|
], { stdio: ['ignore', 'ignore', errFd] });
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(errFd);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let scanSuccess = runTrivySbom();
|
||||||
|
let vulnCount = 0;
|
||||||
|
if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||||
|
vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if in-build scan produced 0 findings or failed
|
||||||
|
if (useInbuild && (!scanSuccess || vulnCount === 0)) {
|
||||||
|
console.warn(`::warning::In-build SBOM for image (${arch}) yielded ${vulnCount} CVE findings. Falling back to direct Trivy image scan...`);
|
||||||
|
const platArgs = arch !== 'default' ? ['--platform', `linux/${arch}`] : [];
|
||||||
|
try {
|
||||||
|
cp.execFileSync('trivy', [
|
||||||
|
'image',
|
||||||
|
'--skip-version-check',
|
||||||
|
...platArgs,
|
||||||
|
'--format', 'spdx-json',
|
||||||
|
'--output', sbomPath,
|
||||||
|
image,
|
||||||
|
], { stdio: 'inherit' });
|
||||||
|
|
||||||
|
console.log('Re-scanning SBOM generated directly by Trivy...');
|
||||||
|
// Re-scan without external distroArg (Trivy's generated SBOM has OS declarations)
|
||||||
|
distroArg = [];
|
||||||
|
scanSuccess = runTrivySbom();
|
||||||
|
if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||||
|
vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`::warning::Direct Trivy scan fallback failed for ${arch}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { arch, scanSuccess, vulnCount, outJson, errLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv);
|
||||||
|
const {
|
||||||
|
headImage,
|
||||||
|
targetImage,
|
||||||
|
targetBranch,
|
||||||
|
useInbuildSbom,
|
||||||
|
outputDelta,
|
||||||
|
outputSummary,
|
||||||
|
} = args;
|
||||||
|
const useInbuild = useInbuildSbom === 'true';
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join('cve-reports', 'head'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join('cve-reports', 'target'), { recursive: true });
|
||||||
|
|
||||||
|
const getSboms = (dir) => {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs.readdirSync(dir)
|
||||||
|
.filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json'))
|
||||||
|
.sort()
|
||||||
|
.map((f) => path.join(dir, f));
|
||||||
|
};
|
||||||
|
|
||||||
|
const headSboms = getSboms(path.join('sboms', 'head'));
|
||||||
|
const targetSboms = getSboms(path.join('sboms', 'target'));
|
||||||
|
|
||||||
|
let headOk = true;
|
||||||
|
let targetOk = true;
|
||||||
|
let headReportCount = 0;
|
||||||
|
let targetReportCount = 0;
|
||||||
|
const headSummary = [];
|
||||||
|
const targetSummary = [];
|
||||||
|
|
||||||
|
// 1. Scan Head SBOMs
|
||||||
|
if (headSboms.length === 0) {
|
||||||
|
headOk = false;
|
||||||
|
headSummary.push(' - ❌ No SBOM files found in sboms/head/');
|
||||||
|
} else {
|
||||||
|
for (const f of headSboms) {
|
||||||
|
const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, '');
|
||||||
|
const outJson = path.join('cve-reports', 'head', `cve-${arch}.json`);
|
||||||
|
const errLog = path.join('cve-reports', 'head', `cve-${arch}.err`);
|
||||||
|
|
||||||
|
const res = scanSbomFile({
|
||||||
|
sbomPath: f,
|
||||||
|
image: headImage,
|
||||||
|
outJson,
|
||||||
|
errLog,
|
||||||
|
useInbuild,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||||
|
if (res.vulnCount === 0) {
|
||||||
|
console.warn(`::warning::Head image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`);
|
||||||
|
headSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`);
|
||||||
|
} else {
|
||||||
|
headSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`);
|
||||||
|
}
|
||||||
|
headReportCount++;
|
||||||
|
} else {
|
||||||
|
headOk = false;
|
||||||
|
let errMsg = 'Trivy scan exited with non-zero status';
|
||||||
|
if (fs.existsSync(errLog)) {
|
||||||
|
const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n');
|
||||||
|
errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg;
|
||||||
|
}
|
||||||
|
headSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scan Target SBOMs
|
||||||
|
if (targetSboms.length === 0) {
|
||||||
|
targetOk = false;
|
||||||
|
targetSummary.push(' - ❌ No SBOM files found in sboms/target/');
|
||||||
|
} else {
|
||||||
|
for (const f of targetSboms) {
|
||||||
|
const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, '');
|
||||||
|
const outJson = path.join('cve-reports', 'target', `cve-${arch}.json`);
|
||||||
|
const errLog = path.join('cve-reports', 'target', `cve-${arch}.err`);
|
||||||
|
|
||||||
|
const res = scanSbomFile({
|
||||||
|
sbomPath: f,
|
||||||
|
image: targetImage,
|
||||||
|
outJson,
|
||||||
|
errLog,
|
||||||
|
useInbuild,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||||
|
if (res.vulnCount === 0) {
|
||||||
|
console.warn(`::warning::Target baseline image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`);
|
||||||
|
targetSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`);
|
||||||
|
} else {
|
||||||
|
targetSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`);
|
||||||
|
}
|
||||||
|
targetReportCount++;
|
||||||
|
} else {
|
||||||
|
targetOk = false;
|
||||||
|
let errMsg = 'Trivy scan exited with non-zero status';
|
||||||
|
if (fs.existsSync(errLog)) {
|
||||||
|
const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n');
|
||||||
|
errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg;
|
||||||
|
}
|
||||||
|
targetSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Print Comprehensive Diagnostic Overview
|
||||||
|
const strategy = useInbuild
|
||||||
|
? 'Built-in OCI Attestation (Syft/Docker Scout) with distro detection'
|
||||||
|
: 'Direct Trivy Image Scan (Full rootfs & source package mapping)';
|
||||||
|
|
||||||
|
const overview = formatScanOverview({
|
||||||
|
headImage,
|
||||||
|
targetImage,
|
||||||
|
targetBranch,
|
||||||
|
strategy,
|
||||||
|
headSummary,
|
||||||
|
targetSummary,
|
||||||
|
});
|
||||||
|
console.log(overview);
|
||||||
|
|
||||||
|
// 4. Enforce Gating
|
||||||
|
let hasError = false;
|
||||||
|
if (!headOk || headReportCount === 0) {
|
||||||
|
console.error('::error::Head image CVE analysis failed or produced no reports.');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
if (!targetOk || targetReportCount === 0) {
|
||||||
|
console.error('::error::Target baseline CVE analysis failed or produced no reports.');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
console.error('::error::Aborting differential analysis: both head and target scans must succeed before diffing.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Run multi-arch set-based diff and generate reports
|
||||||
|
console.log('Both head and target scans succeeded. Computing set-based vulnerability delta...');
|
||||||
|
const cveDiffScript = path.resolve(__dirname, '..', 'cve-diff.js');
|
||||||
|
try {
|
||||||
|
const deltaOutput = cp.execFileSync(process.execPath, [
|
||||||
|
cveDiffScript,
|
||||||
|
path.join('cve-reports', 'target'),
|
||||||
|
path.join('cve-reports', 'head'),
|
||||||
|
targetBranch,
|
||||||
|
outputSummary,
|
||||||
|
], { encoding: 'utf8' });
|
||||||
|
|
||||||
|
fs.writeFileSync(outputDelta, deltaOutput, 'utf8');
|
||||||
|
console.log(`CVE delta written to ${outputDelta} and full summary written to ${outputSummary}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`::error::Failed to generate CVE differential report: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseArgs,
|
||||||
|
scanSbomFile,
|
||||||
|
main,
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Architecture mapping and cryptographic checksum verification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
|
function mapArch(unameArch) {
|
||||||
|
const normalized = (unameArch || '').trim().toLowerCase();
|
||||||
|
switch (normalized) {
|
||||||
|
case 'x86_64':
|
||||||
|
case 'amd64':
|
||||||
|
return '64bit';
|
||||||
|
case 'aarch64':
|
||||||
|
case 'arm64':
|
||||||
|
return 'ARM64';
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported architecture: ${unameArch}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChecksums(checksumsContent) {
|
||||||
|
const map = new Map();
|
||||||
|
if (!checksumsContent) return map;
|
||||||
|
|
||||||
|
const lines = checksumsContent.split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
// Format: <hash> <filename>
|
||||||
|
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const hash = match[1].toLowerCase();
|
||||||
|
const filename = match[2].trim();
|
||||||
|
map.set(filename, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyFileSha256(filePath, expectedHash) {
|
||||||
|
if (!fs.existsSync(filePath) || !expectedHash) return false;
|
||||||
|
try {
|
||||||
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
|
const actualHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
return actualHash.toLowerCase() === expectedHash.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapArch,
|
||||||
|
parseChecksums,
|
||||||
|
verifyFileSha256,
|
||||||
|
};
|
||||||
+3
-3
@@ -6,11 +6,11 @@
|
|||||||
"customManagers": [
|
"customManagers": [
|
||||||
{
|
{
|
||||||
"customType": "regex",
|
"customType": "regex",
|
||||||
"fileMatch": [
|
"managerFilePatterns": [
|
||||||
"^action\\.ya?ml$"
|
"/^action\\.ya?ml$/"
|
||||||
],
|
],
|
||||||
"matchStrings": [
|
"matchStrings": [
|
||||||
"default:\\s*(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)\\s*#\\s*trivy-version"
|
"TRIVY_VERSION:\\s*(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)\\s*#\\s*trivy-version"
|
||||||
],
|
],
|
||||||
"depNameTemplate": "aquasecurity/trivy",
|
"depNameTemplate": "aquasecurity/trivy",
|
||||||
"datasourceTemplate": "github-releases",
|
"datasourceTemplate": "github-releases",
|
||||||
|
|||||||
@@ -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`'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
formatSbomOverview,
|
||||||
|
formatScanOverview,
|
||||||
|
countVulnerabilities,
|
||||||
|
} = require('../lib/diagnostics.js');
|
||||||
|
|
||||||
|
test('formatSbomOverview formats successful extraction banner', () => {
|
||||||
|
const overview = formatSbomOverview({
|
||||||
|
headImage: 'my-head:latest',
|
||||||
|
targetImage: 'my-target:latest',
|
||||||
|
headFiles: [{ name: 'sbom-amd64.spdx.json', size: 1024 }],
|
||||||
|
targetFiles: [{ name: 'sbom-amd64.spdx.json', size: 2048 }],
|
||||||
|
headError: null,
|
||||||
|
targetError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(overview.includes('SBOM EXTRACTION DIAGNOSTIC OVERVIEW'));
|
||||||
|
assert.ok(overview.includes('HEAD Image (my-head:latest): ✅ SUCCESS'));
|
||||||
|
assert.ok(overview.includes('TARGET Baseline Image (my-target:latest): ✅ SUCCESS'));
|
||||||
|
assert.ok(overview.includes('sbom-amd64.spdx.json (1024 bytes)'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSbomOverview formats failure banner when error present', () => {
|
||||||
|
const overview = formatSbomOverview({
|
||||||
|
headImage: 'my-head:latest',
|
||||||
|
targetImage: 'my-target:latest',
|
||||||
|
headFiles: [],
|
||||||
|
targetFiles: [],
|
||||||
|
headError: 'Image not found',
|
||||||
|
targetError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(overview.includes('HEAD Image (my-head:latest): ❌ FAILED - Image not found'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatScanOverview formats scan summary with strategy', () => {
|
||||||
|
const overview = formatScanOverview({
|
||||||
|
headImage: 'debian:bookworm-slim',
|
||||||
|
targetImage: 'debian:bullseye-slim',
|
||||||
|
targetBranch: 'main',
|
||||||
|
strategy: 'Direct Trivy Image Scan',
|
||||||
|
headSummary: [' - amd64: ✅ OK (104 vulnerability findings)'],
|
||||||
|
targetSummary: [' - amd64: ✅ OK (130 vulnerability findings)'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(overview.includes('CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW'));
|
||||||
|
assert.ok(overview.includes('Scan Strategy: Direct Trivy Image Scan'));
|
||||||
|
assert.ok(overview.includes('HEAD Image: debian:bookworm-slim'));
|
||||||
|
assert.ok(overview.includes('TARGET Baseline Image: debian:bullseye-slim (branch: \'main\')'));
|
||||||
|
assert.ok(overview.includes('104 vulnerability findings'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('countVulnerabilities counts all vulnerabilities in a Trivy JSON report', () => {
|
||||||
|
const report = {
|
||||||
|
Results: [
|
||||||
|
{
|
||||||
|
Vulnerabilities: [
|
||||||
|
{ VulnerabilityID: 'CVE-2023-0001' },
|
||||||
|
{ VulnerabilityID: 'CVE-2023-0002' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Vulnerabilities: [
|
||||||
|
{ VulnerabilityID: 'CVE-2023-0003' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(countVulnerabilities(report), 3);
|
||||||
|
assert.equal(countVulnerabilities(JSON.stringify(report)), 3);
|
||||||
|
assert.equal(countVulnerabilities({}), 0);
|
||||||
|
assert.equal(countVulnerabilities(null), 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { detectDistro } = require('../lib/distro.js');
|
||||||
|
|
||||||
|
test('detectDistro returns null for null, undefined, or empty input', () => {
|
||||||
|
assert.equal(detectDistro(null), null);
|
||||||
|
assert.equal(detectDistro(undefined), null);
|
||||||
|
assert.equal(detectDistro(''), null);
|
||||||
|
assert.equal(detectDistro('{}'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectDistro extracts distro from parsed SPDX object with package PURLs', () => {
|
||||||
|
const spdx = {
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
name: 'acl',
|
||||||
|
externalRefs: [
|
||||||
|
{
|
||||||
|
referenceType: 'purl',
|
||||||
|
referenceLocator: 'pkg:deb/debian/acl@2.3.1-3?os_distro=bookworm&os_name=debian&os_version=12',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(detectDistro(spdx), 'debian/12');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectDistro extracts distro from JSON string containing escaped unicode \\u0026', () => {
|
||||||
|
const jsonStr = JSON.stringify({
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
name: 'adduser',
|
||||||
|
externalRefs: [
|
||||||
|
{
|
||||||
|
referenceType: 'purl',
|
||||||
|
referenceLocator: 'pkg:deb/debian/adduser@3.134?os_distro=bullseye&os_name=debian&os_version=11',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).replace(/&/g, '\\u0026');
|
||||||
|
|
||||||
|
assert.ok(jsonStr.includes('\\u0026'));
|
||||||
|
assert.equal(detectDistro(jsonStr), 'debian/11');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectDistro handles non-debian distributions (e.g. ubuntu, alpine)', () => {
|
||||||
|
const spdx = {
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
externalRefs: [
|
||||||
|
{
|
||||||
|
referenceType: 'purl',
|
||||||
|
referenceLocator: 'pkg:apk/alpine/alpine-baselayout@3.4.3-r2?os_name=alpine&os_version=3.20',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(detectDistro(spdx), 'alpine/3.20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectDistro returns null when PURLs lack os_name or os_version', () => {
|
||||||
|
const spdx = {
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
externalRefs: [
|
||||||
|
{
|
||||||
|
referenceType: 'purl',
|
||||||
|
referenceLocator: 'pkg:generic/foo@1.0.0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(detectDistro(spdx), null);
|
||||||
|
});
|
||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const { parseArgs: parseExtractArgs, cleanupInvalidFile } = require('../lib/extract-sboms.js');
|
||||||
|
const { parseArgs: parseScanArgs } = require('../lib/scan-sboms.js');
|
||||||
|
|
||||||
|
test('extract-sboms parseArgs parses command line arguments and defaults', () => {
|
||||||
|
const argv = [
|
||||||
|
'node',
|
||||||
|
'extract-sboms.js',
|
||||||
|
'--head-image', 'my-repo/head:1.0',
|
||||||
|
'--target-image', 'my-repo/target:1.0',
|
||||||
|
'--architectures', 'amd64,arm64',
|
||||||
|
'--use-inbuild-sbom', 'true',
|
||||||
|
];
|
||||||
|
|
||||||
|
const args = parseExtractArgs(argv);
|
||||||
|
assert.equal(args.headImage, 'my-repo/head:1.0');
|
||||||
|
assert.equal(args.targetImage, 'my-repo/target:1.0');
|
||||||
|
assert.equal(args.architectures, 'amd64,arm64');
|
||||||
|
assert.equal(args.useInbuildSbom, 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extract-sboms cleanupInvalidFile removes empty files and files containing null', () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbom-clean-'));
|
||||||
|
const emptyFile = path.join(tmpDir, 'empty.json');
|
||||||
|
const nullFile = path.join(tmpDir, 'null.json');
|
||||||
|
const validFile = path.join(tmpDir, 'valid.json');
|
||||||
|
|
||||||
|
fs.writeFileSync(emptyFile, '');
|
||||||
|
fs.writeFileSync(nullFile, 'null\n');
|
||||||
|
fs.writeFileSync(validFile, '{"spdxVersion": "SPDX-2.3"}');
|
||||||
|
|
||||||
|
cleanupInvalidFile(emptyFile);
|
||||||
|
cleanupInvalidFile(nullFile);
|
||||||
|
cleanupInvalidFile(validFile);
|
||||||
|
|
||||||
|
assert.equal(fs.existsSync(emptyFile), false);
|
||||||
|
assert.equal(fs.existsSync(nullFile), false);
|
||||||
|
assert.equal(fs.existsSync(validFile), true);
|
||||||
|
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scan-sboms parseArgs parses scan arguments and defaults', () => {
|
||||||
|
const argv = [
|
||||||
|
'node',
|
||||||
|
'scan-sboms.js',
|
||||||
|
'--head-image', 'my-head:latest',
|
||||||
|
'--target-image', 'my-target:latest',
|
||||||
|
'--target-branch', 'feature',
|
||||||
|
'--output-delta', 'custom-delta.md',
|
||||||
|
'--output-summary', 'custom-summary.md',
|
||||||
|
];
|
||||||
|
|
||||||
|
const args = parseScanArgs(argv);
|
||||||
|
assert.equal(args.headImage, 'my-head:latest');
|
||||||
|
assert.equal(args.targetImage, 'my-target:latest');
|
||||||
|
assert.equal(args.targetBranch, 'feature');
|
||||||
|
assert.equal(args.outputDelta, 'custom-delta.md');
|
||||||
|
assert.equal(args.outputSummary, 'custom-summary.md');
|
||||||
|
assert.equal(args.useInbuildSbom, 'false');
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { parsePlatforms, discoverPlatforms, toArchTag } = require('../lib/platforms.js');
|
||||||
|
|
||||||
|
test('parsePlatforms handles empty and undefined input', () => {
|
||||||
|
assert.deepEqual(parsePlatforms(''), []);
|
||||||
|
assert.deepEqual(parsePlatforms(null), []);
|
||||||
|
assert.deepEqual(parsePlatforms(undefined), []);
|
||||||
|
assert.deepEqual(parsePlatforms(' '), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlatforms parses comma-separated architectures and adds linux/ prefix if omitted', () => {
|
||||||
|
const result = parsePlatforms('amd64, arm64, linux/arm/v7');
|
||||||
|
assert.deepEqual(result, ['linux/amd64', 'linux/arm64', 'linux/arm/v7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discoverPlatforms extracts unique architectures from OCI image manifests', () => {
|
||||||
|
const headManifest = JSON.stringify({
|
||||||
|
manifests: [
|
||||||
|
{ platform: { architecture: 'amd64', os: 'linux' } },
|
||||||
|
{ platform: { architecture: 'arm64', os: 'linux' } },
|
||||||
|
{ platform: { architecture: 'unknown', os: 'unknown' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetManifest = JSON.stringify({
|
||||||
|
manifests: [
|
||||||
|
{ platform: { architecture: 'amd64', os: 'linux' } },
|
||||||
|
{ platform: { architecture: 'arm', os: 'linux', variant: 'v7' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = discoverPlatforms(headManifest, targetManifest);
|
||||||
|
assert.deepEqual(result, ['linux/amd64', 'linux/arm/v7', 'linux/arm64']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discoverPlatforms safely handles invalid or missing manifest JSON', () => {
|
||||||
|
assert.deepEqual(discoverPlatforms('invalid json', null), []);
|
||||||
|
assert.deepEqual(discoverPlatforms('', '{}'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toArchTag converts platform strings to filesystem-safe arch tags', () => {
|
||||||
|
assert.equal(toArchTag('linux/amd64'), 'amd64');
|
||||||
|
assert.equal(toArchTag('linux/arm64'), 'arm64');
|
||||||
|
assert.equal(toArchTag('linux/arm/v7'), 'arm-v7');
|
||||||
|
assert.equal(toArchTag('amd64'), 'amd64');
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const { mapArch, parseChecksums, verifyFileSha256 } = require('../lib/verifier.js');
|
||||||
|
|
||||||
|
test('mapArch maps uname architecture to Trivy release arch', () => {
|
||||||
|
assert.equal(mapArch('x86_64'), '64bit');
|
||||||
|
assert.equal(mapArch('aarch64'), 'ARM64');
|
||||||
|
assert.equal(mapArch('arm64'), 'ARM64');
|
||||||
|
assert.throws(() => mapArch('mips'), /Unsupported architecture/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseChecksums parses sha256 checksum lockfile into filename map', () => {
|
||||||
|
const content = `
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 trivy_0.74.0_Linux-64bit.tar.gz
|
||||||
|
a1b2c3d4e5f60000000000000000000000000000000000000000000000000000 trivy_0.74.0_Linux-ARM64.tar.gz
|
||||||
|
`;
|
||||||
|
|
||||||
|
const map = parseChecksums(content);
|
||||||
|
assert.equal(map.get('trivy_0.74.0_Linux-64bit.tar.gz'), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||||
|
assert.equal(map.get('trivy_0.74.0_Linux-ARM64.tar.gz'), 'a1b2c3d4e5f60000000000000000000000000000000000000000000000000000');
|
||||||
|
assert.equal(map.has('nonexistent'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyFileSha256 verifies actual sha256 hash of a file', () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cve-test-'));
|
||||||
|
const testFile = path.join(tmpDir, 'test.txt');
|
||||||
|
const fileContent = 'hello world\n';
|
||||||
|
fs.writeFileSync(testFile, fileContent);
|
||||||
|
|
||||||
|
const expectedHash = crypto.createHash('sha256').update(fileContent).digest('hex');
|
||||||
|
assert.equal(verifyFileSha256(testFile, expectedHash), true);
|
||||||
|
assert.equal(verifyFileSha256(testFile, 'wronghash'), false);
|
||||||
|
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user